读 OpenViking 源码:把 Agent 上下文从「检索问题」改写成「数据库问题」

克隆火山引擎开源的 OpenViking(Self-evolving Context Database),对照文档精读核心源码。它的全部设计可以压成三次「换表示」:把记忆/知识/技能统一成一个 viking:// 文件系统、把内容压成 L0/L1/L2 三层分辨率、把经验目录当成可优化的策略集并用「语义梯度」更新。这篇按写入、检索、进化三条路径逐一对照代码,最后用官方 benchmark 数字划清「已验证」和「未复现」的边界。

当大多数记忆框架还在给向量库加功能时,OpenViking 问了一个更根本的问题:Agent 的上下文为什么非得是「检索出来」的? 它的答案是把记忆、知识、技能全部装进一个 viking:// 虚拟文件系统,让 Agent 像开发者浏览代码库一样 lstreefind 自己的上下文——向量搜索只负责「定位到哪个目录」,之后的每一步都是确定性的寻址,且留下可回放的浏览轨迹。这是本站继 MemOS 源码审计之后的第二篇记忆系统源码深读,这次的审计结论比上次干净:它承诺的三件事,代码里都是真的。

本文基于 2026-08-19 的浅克隆(commit dc39985),openviking Python 包 619 个 .py 文件、crates 下 156 个 .rs 文件(亲手 find 统计)。项目由北京火山引擎技术有限公司开源,AGPL-3.0 协议,自述是 “Self-evolving Context Database for AI Agents. Unify Agent Memory, Knowledge RAG and Skills.”

名词速查

术语一句话解释
viking:// URIOpenViking 给每条上下文的统一地址,形如 viking://resources/my_project/docs/,和文件路径一个用法
sidecar目录里的隐藏伴生文件(.abstract.md / .overview.md),存这个目录的摘要,普通 ls 不显示
L0 / L1 / L2同一份内容的三档分辨率:一句话摘要(默认 256 字符)/ 导航概览(默认 4000 字符)/ 原始全文
AGFS / RAGFS项目自己的内容存储层(Aggregated File System),已用 Rust 重写(RAGFS),插件式挂载多种后端
rerank向量召回后用专门模型对候选重新打分排序的精排步骤,本站 RAG 主线一篇有展开
rollout让 Agent 在一个任务上完整跑一遍所产生的执行记录,强化学习术语
语义梯度用自然语言表达的「这份经验应该怎么改」信号,此处具体是一对 before/after 文件加修改理由
LoCoMo超长多轮对话记忆基准(arXiv:2402.17753),平均每场对话约 300 轮、9K token
tau2-bench多轮工具调用 Agent 任务基准,含 retail / airline 等客服场景

它反对什么:黑盒向量库的两笔账

先交代问题,再看设计。把向量库直接当 Agent 记忆用,有两笔账始终算不平:

第一笔是调试账。 向量检索是「query 进、top-k 出」,中间没有可检查的状态。当检索结果错了,你无法回答「为什么是它」——只能怪 embedding 不够好。这和《别把向量数据库当知识库》里的结论一致:向量库是定位组件,不是知识组织方式。

第二笔是 token 账。 命中的 chunk 要么太碎(丢上下文),要么太大(塞爆窗口)。《遗忘是一种能力》里讲过 context rot:塞得越多,答得越差。检索粒度和加载粒度绑死在一起,是 chunk 范式的结构性缺陷。

OpenViking 对这两笔账的回答,是三次「换表示」。用本站 AI 顶级原理望远镜的话说,这个项目几乎全部押注在「表示决定成败」这一条上。

第一次换表示:一切上下文皆文件

OpenViking 把三类上下文——Memory(用户偏好、事件、经验)、Resource(文档、代码库、网页)、Skill(可执行技能)——统一挂在一棵目录树下:

viking://
├── resources/                  # 共享知识库
│   └── my_project/
│       ├── docs/
│       └── src/
└── user/{user_id}/
    ├── memories/               # 长期记忆(profile、preferences、events、experiences…)
    ├── resources/              # 私有资源
    ├── skills/                 # 技能
    └── peers/                  # 交互对象的画像

这个表示换掉的是「记忆、RAG、技能各用一套 API」的现状。三类上下文都是目录和文件,Agent 操作它们用的是同一组动词:lstreereadgrepfindmv。官方 CLI 的体验完全是文件系统的肌肉记忆:

ov add-resource https://github.com/volcengine/OpenViking
ov tree viking://resources/volcengine -L 2
ov find "what is openviking"

「一切皆文件」对 Agent 的意义和它对 Unix 的意义相同:动词集合封闭了,名词空间才能无限扩张。 新增一种上下文类型不需要新增 API,只需要一个新目录。

第二次换表示:内容的三层分辨率

每个目录带两个隐藏 sidecar 文件,构成三档分辨率:

viking://resources/docs/auth/
├── .abstract.md          # L0:一句话摘要,默认上限 256 字符
├── .overview.md          # L1:结构概览 + 子项导航,默认上限 4000 字符
├── oauth.md              # L2:原始全文
└── jwt.md                # L2

关键设计:L0/L1 是目录级的,不是文件级的。 文件摘要只作为输入聚合进所在目录的 L1。这意味着 Agent 判断「这个目录值不值得进」只需读 256 字符,判断「进来之后读哪个文件」只需读 4000 字符——把「检索粒度」和「加载粒度」解耦了。这正是对上面第二笔 token 账的回答。

写入流水线:解析与语义分离

三层分辨率不是免费的,代价在写入侧。OpenViking 的写入流水线把「无 LLM 的解析」和「有 LLM 的语义生成」严格分开:

flowchart LR
    A[输入文档] --> B[Parser<br>格式转换+智能分割<br>无 LLM]
    B --> C[TreeBuilder<br>移入 AGFS]
    C --> D[SemanticQueue<br>异步生成 L0/L1<br>LLM 自底向上]
    D --> E[EmbeddingQueue<br>向量化]
    E --> F[(向量库<br>只存索引)]
    C --> G[(AGFS<br>存全部内容)]

Parser 的分割规则是确定性的:文档不超过 1024 token 存单文件;超过则按标题切,小于 512 token 的小节合并,大于 1024 token 的大节升格为子目录。代码文件走 tree-sitter 骨架提取(优先各语言维护的 tags.scm,缺失时回退到 language-pack 通用解析)——和本站读 tree-sitter 那篇讲的 aider 用法同源。

语义生成是异步的、自底向上的:文件摘要 → 叶子目录 L1 → 从 L1 提取 L0 → 逐级向父目录冒泡,直到 namespace 根。每个 sidecar 的 frontmatter 里记录 freshness 元数据(直接子项总数、本轮采样数、已知未反映的变更数);子项超过 32 个时用确定性稳定采样,保证同一棵树重复刷新不产生无意义的 diff。

这里有个值得注意的诚实细节:文档里用 TODO 明确承认当前每次语义任务成功都会向父级冒泡刷新,热点目录存在重复刷新和写放大,节流策略还没做。承诺和现状分得很清,这是好文档的标志。

检索:带地图的下钻,而不是一次近邻查询

读路径分两档。find() 是快路径:单查询直接进层级检索。search() 是慢路径:先由 IntentAnalyzer 用 LLM 把「当前查询 + 会话摘要 + 最近 5 条消息」改写成 0 到 5 个带类型的查询(TypedQuery)——技能查询动词开头(「提取 PDF 表格」)、资源查询名词短语(「API 使用指南」)、记忆查询固定「用户XX」句式。闲聊会产出 0 个查询,直接跳过检索。

真正的核心是 HierarchicalRetriever(openviking/retrieve/hierarchical_retriever.py,644 行)。它不做全库平铺检索,而是先全局向量搜索定位起始目录,再用优先队列逐层下钻

# 伪代码,对应 hierarchical_retriever.py L452-556 主循环,省略:并行批处理/遥测/level 过滤
dir_queue = [(-score, uri) for uri, score in starting_points]   # 最大堆

while dir_queue:
    current_uri, parent_score = heappop_best(dir_queue)
    children = vector_search(parent_uri=current_uri, query_vector)  # 只搜直接子节点

    if thinking_mode and rerank_available:
        scores = rerank(query, [c.abstract for c in children])      # 精排,失败回退向量分

    for child, score in zip(children, scores):
        final = alpha * score + (1 - alpha) * parent_score          # 分数传播,默认 alpha=1.0
        if final < threshold:
            continue
        collected[child.uri] = max(collected.get(child.uri), final)
        if child.is_directory:                                      # L2 文件是终点,目录继续下钻
            heappush(dir_queue, (-final, child.uri))

    if topk_unchanged_for_3_rounds or pool_stagnant_for_3_rounds:   # 收敛检测
        break

几个从源码核实的参数:收敛判定 MAX_CONVERGENCE_ROUNDS = 3(L55),每轮最多并行展开 4 个目录(MAX_PARALLEL_CHILD_SEARCHES = 4,L58,注释写明是为了限制对远端向量库的扇出),分数传播系数 score_propagation_alpha 默认 1.0——即默认只信子节点自己的分数,父分数传播是留着的钩子而非当前行为。

这个算法回答了第一笔调试账:检索过程就是一条目录浏览轨迹(进了哪个目录、每层看到什么分数、在哪停下),错了可以逐跳回放。代价也明确:多轮向量查询 + 可选 rerank,延迟高于一次平铺检索。官方自己在 find/search 对比表里承认 search() 延迟「较高」——这是拿延迟换可解释性和结构完整性的灰度选择,不是免费午餐。

第三次换表示:经验目录是策略集,更新是语义梯度

前两次换表示解决存和取,第三次解决「自进化」——这是它区别于一般记忆框架的部分。

会话如何变成记忆

session.commit() 分两阶段:同步阶段只做归档(消息写入 history/archive_NNN/messages.jsonl,立即返回 task_id);异步阶段由 LLM 生成会话摘要、按 schema 提取候选记忆,然后走一条去重流水线:向量预过滤找相似旧记忆 → LLM 做去重决策(跳过 / 新建 / 合并进旧记忆 / 删除冲突旧记忆)→ 写入

每次提交在归档目录留一份 memory_diff.json,用 before/after 记录本次所有记忆增删改。这个设计值得单独点赞:记忆系统的写操作有了审计日志,这是「数据库范式」对「黑盒范式」的又一次胜利——多 Agent 内存那篇里论文呼吁的「结构化访问控制」,在这里至少有了可回溯的一半。

Agent Evolution:把 RL 的骨架搬到自然语言上

内置记忆类型里有三个特殊成员:cases(任务案例)、trajectories(执行轨迹)、experiences(经验)。启用 experiences 会激活完整的进化链路。docs/design/traj-exp-experience-learning-redesign.md 把设计意图说得毫不掩饰:experiences 目录被视为一个可优化的 Experience Policy Set(经验策略集),训练框架的抽象链路是:

flowchart LR
    A[CaseLoader<br>加载任务] --> B[RolloutExecutor<br>跑一遍任务]
    B --> C[RolloutAnalyzer<br>抽取轨迹]
    C --> D[GradientEstimator<br>估计语义梯度]
    D --> E[PolicyOptimizer<br>生成更新计划]
    E --> F[PolicyUpdater<br>写回经验文件]
    F -.经验参与下次执行.-> B

「梯度」在这里是什么?openviking/session/train/gradients.py 全文只有 46 行,核心是:

@dataclass(slots=True)
class PatchSemanticGradient:
    before_file: MemoryFile | None   # None 表示建议新建这条经验
    after_file: MemoryFile           # 建议的目标状态
    base_version: int | None
    rationale: str                   # 为什么这么改
    links: list[StoredLink]          # 经验 -> 来源轨迹的溯源链接
    confidence: float

一个「语义梯度」就是一对 before/after 经验文件,加一段修改理由和置信度。这和 TextGrad(arXiv:2406.07496)的思路同源——把「梯度」重新定义为自然语言的修改信号——但 OpenViking 把它落到了更工程的地方:梯度不是自由文本,而是结构化的文件 diff;应用梯度前有 ExperienceSet.lock() 串行化(rollout 和梯度估计可并行,但 reload → plan → apply 必须持锁串行),还有 base-content guard 防止覆盖已经被别人改过的经验。这是把数据库的并发控制纪律,用在了「学习」这件事上。

设计文档还规定了溯源协议:每条经验通过 derived_from 链接指回产生它的轨迹。经验错了可以顺着链接找到「它是从哪次失败里学来的」。

我的判断是:这一层是整个项目最有野心也最未经检验的部分。类 RL 的骨架(case → rollout → gradient → policy update)搬到自然语言上之后,收敛性没有任何理论保证,全靠 LLM 做 GradientEstimator 的质量兜底。官方给出的 tau2-bench 提升(见下节)说明这条链路在客服场景能跑通,但「经验策略集会不会随迭代漂移、退化」这类问题,目前没有公开数据回答。

工程底座:Rust 文件系统 + 只存索引的向量库

存储是双层的,职责切得很干净:

存什么不存什么
AGFS(内容层)L0/L1/L2 全部内容、多媒体、关联关系
向量库(索引层)URI、向量、元数据文件内容

单一数据源原则:所有内容从 AGFS 读,向量库只存引用——索引坏了可以从内容重建,反之不行。AGFS 已用 Rust 重写为 RAGFS(crates/ragfs),是一个插件式虚拟文件系统:MemFS、KVFS、QueueFS 等实现挂载在不同路径下,另有 cache(Redis / Mooncake / 元戎等多种后端)、crypto(静态加密)、git(版本控制)模块。连语义处理队列本身都是文件系统的一个插件(QueueFS)——「一切皆文件」贯彻到了自己的内部实现。

Embedding 输入有白名单纪律:sidecar frontmatter 里只有 directory 字段进向量,sourcegenerated_byfreshness 都不进——保证重建索引不会改变检索语义。这种「哪些字节参与索引」的显式契约,又是数据库工程的味道。

官方数字与它们的边界

以下数字全部来自官方 README 与 benchmark 报告(版本 0.3.22),我未亲手复现,复现脚本在仓库 benchmark/ 目录:

基准无 OpenViking有 OpenViking变化
LoCoMo(OpenClaw 原生记忆)24.20%82.08%+57.88pp
LoCoMo(Hermes 原生记忆)33.38%82.86%+49.48pp
LoCoMo(Claude Code 原生记忆)57.21%80.32%+23.11pp
tau2-bench retail(无记忆)70.94%77.81%+6.87pp
tau2-bench airline(无记忆)54.38%66.25%+11.87pp

官方同时报告 LoCoMo 场景下输入 token 下降 34.3%–91.0%、查询延迟下降 58.45%–66.10%。

两点解读边界:其一,LoCoMo 的对照组是各 Agent 的「原生记忆」(把历史直接塞上下文或简单摘要),赢原生记忆是记忆框架的及格线,Mem0、MemOS 等在各自报告里也都大幅赢原生基线,跨框架横比要看第三方评测。其二,tau2-bench 那两行是「经验记忆」的增量收益,+6.87pp 和 +11.87pp 是这个「自进化」故事目前最硬的证据,但只覆盖客服型任务。

三次换表示,一张总表

换表示换来什么代价
上下文 → 文件系统记忆/RAG/技能三套 API一棵 viking:// 目录树 + 统一动词确定性寻址、可浏览、可审计所有内容都要过一遍导入流水线
内容 → 三层分辨率扁平 chunk目录级 L0/L1 + 原文 L2检索粒度与加载粒度解耦,省 token写入侧 LLM 成本 + 父级冒泡写放大(官方 TODO 承认)
经验 → 策略集一次性写入的记忆带锁、带版本、带溯源的可优化经验文件任务成功率可随使用提升(官方 tau2 数据)收敛无保证,依赖 LLM 梯度质量

12 透镜收束:OpenViking = 表示决定成败(文件系统 + 三层分辨率,占八成)+ 搜索与优化(优先队列下钻)+ 反馈闭环(rollout → 语义梯度 → 经验更新)。它赌的是:上下文工程的下一步不是更好的 embedding,而是更好的数据组织——五十年数据库工程的纪律(寻址、事务、审计、并发控制、索引与内容分离)值得在 Agent 上下文上重演一遍。

诚实的提醒

  • 本文所有 benchmark 数字来自官方报告,未亲手复现;官方评测用的 rerank 后端是自家的 doubao-seed-rerank,换后端后的检索质量未知。
  • 「层级检索优于平铺检索」在本文中是设计论证 + 官方数据支撑的判断,不是我验证过的事实;层级检索的多轮查询延迟代价在深目录树上有多大,同样未测。
  • Agent Evolution 链路我只读了设计文档和 domain/gradient 层源码(session/train/),2171 行的 compressor_v3.py 提取主流程没有逐行读完,「案例触发轨迹与经验训练」的描述以文档为准。
  • 成本最低的亲手验证实验:pip install openviking --upgrade && openviking-server init(向导支持本地 Ollama,可零 API 成本),然后 ov add-resource 导入任意一个 GitHub 仓库,用 ov tree -L 2 看 L0/L1 sidecar 是否如期生成,再 ov find "某个只在深层文档里出现的概念" 对照返回的目录轨迹——半小时内能验证本文前两次「换表示」的全部核心声明。

参考来源

工程实践

arXiv 论文

本站相关