前一篇翻完一个把 agent 摆进办公室的项目,顺手又去看了火山引擎开源的这个,两个都在解决 agent 记不住事,路子完全不一样。
OpenViking 的做法是别让 agent 去查一个黑盒向量库,让它像开发者一样用 lstreefind 翻自己的上下文。记忆、资源、技能统一挂在 viking:// 这个虚拟文件系统下,每个目录带一份摘要和一份概览,检索先定位到目录再往下钻。
这篇记一下它的结构、检索流程,以及一致性那块的取舍。

项目概况

不是薄封装,是三种语言各管一层。到今天的 HEAD(afa5aae9),从 1 月 29 日起 2,033 次提交。Python 那层是服务和业务逻辑,openviking/ 下 619 个文件、148,477 行;Rust 那层是文件系统 RAGFS,crates/ragfs/ 78 个文件、40,673 行;C++ 那层是向量与标量索引引擎,src/ 65 个文件、12,260 行,自带 croaring 位图、leveldb、rapidjson、spdlog 这些依赖,是真的在写索引引擎不是调库。
协议是 AGPLv3,crates/ov_cliexamples/ 单独给 Apache 2.0。背后有论文,VikingMem,arXiv:2605.29640,VLDB 2026 收录,README 说开源的是其中一个子集。跑起来是个单独的服务,openviking-server,默认 1933 端口,客户端 ov 走 HTTP。

viking:// 的目录结构

上下文按认知习惯切成三类,各有不同的生命周期和发起方。Resource 是外部知识与规则,长期、相对静态,用户主动加,比如文档、手册、代码仓;Memory 是 agent 的认知,长期、持续更新,agent 自己记;Skill 是可声明的能力配置,长期、静态,用户或系统加。三类挂在同一棵树上:

viking://
├── resources/                  # 用户加的知识
└── user/{user_id}/
    ├── memories/               # agent 记的
    ├── resources/              # 私有资源
    ├── skills/
    └── peers/{peer_id}/        # 对话里出现的稳定第三方

记忆类型

内置九种,各有默认落点:profile.md 基本信息、preferences/ 按主题分的偏好、entities/ 人和项目和组织、events/ 决定与里程碑、identity.md 助手的名字和人格、soul.md 原则边界风格,以及给 agent 进化用的 cases/trajectories/experiences/。应用可以自定义类型。

peers

如果对话里出现了一个稳定的第三方,关于他的记忆写进他自己的空间而不是糊在当前用户的记忆里。路由规则定得很死,只有在归档批次里真实出现过、通过安全校验的 peer_id 才允许写入,不在白名单里的直接跳过;某些 schema 比如 cases 标了 peer_enabled: false,就完全忽略 peer 目标只写自己;执行派生的那三类永远不写 peer 记忆。

L0/L1/L2 三层

这一处 README 容易让人误会而文档说得很准。摘要和概览是目录级的挂件,不是每个文件配一份。L0 是目录里的 .abstract.md,默认正文上限 256 字符,管向量检索和快速筛;L1 是 .overview.md,上限 4000 字符,管 rerank 和内容导航;L2 就是原始文件和子目录,没有统一上限,按需加载。
普通文件不会各自生成挂件,文件摘要只作为输入汇进所属目录的 L1。而 L0 又是从 L1 正文里截出来的,具体是 H1 标题之后、第一个 ## 之前那段简述。生成方向自底向上,文件摘要到叶子目录 L1,到叶子 L0,再到父目录,一直冒到命名空间边界。

OpenViking 的写入主链

OKF 挂件格式

新版挂件用 YAML frontmatter 加可见正文。frontmatter 里比较有意思的是 freshness 那组,统计的是直接子项而不是整个递归子树:

yaml
freshness:
  total_entries: 3        # 直接文件加直接子目录
  sampled_entries: 3      # 这次摘要真的看了几个
  unsampled_entries: 0
  pending_child_changes: 0  # 已知变了但还没反映进正文的

直接子项超过 semantic.sidecar_sample_size(默认 32)就走确定性、保序的稳定采样,同一棵没变的树反复刷新会选中同一批样本,避免正文被无意义地重写、避免 git diff 里全是噪声。pending_child_changes 大于零的含义也写清楚了,正文仍然可读但已知落后于底层变化。
文档里挂着一个 TODO,说当前实现在每一个成功的语义任务后都尝试向上冒泡,即使新生成的子摘要没有变化,这不是最终的调度策略,未来应该用 freshness 来合并、设阈值或加时间窗,目标是减少热目录上的重复刷新和向上的写放大。这个问题是真的,一个深目录里改一个文件会一路把每层父目录的摘要都重刷一遍,承认它比藏起来好。

读取面与嵌入白名单

abstract()overview()、find 与 rerank 的预览、ls output=agent 都只给正文,只有直接 read(".../.abstract.md") 才拿到 frontmatter,普通 ls 把这两个挂件藏起来。
做嵌入时元数据有白名单,初始只放 directory 一个字段,sourcegenerated_byfreshness 全排除,并且明确写了正常向量化和管理员 vectors_only 重建索引用同一套策略,所以重建索引不会改变检索输入。这条对可复现挺关键。

写保护

公开 writebatch_write 能改挂件的正文,但挂件必须已存在,公开接口不能凭空造一个 .abstract.md;只给正文的请求会继承已存的元数据;给完整 OKF 的请求必须保留所有已知元数据,改一个受保护字段就失败;append 只追加正文永不碰 frontmatter。而且正文更新之后只重建当前存在的那些层、不重新生成语义,不然刚手写的正文会被后台摘要盖掉。

检索流程

两条入口,差别是要不要过模型。find() 不需要会话上下文、没有意图分析、单条查询、延迟低;search() 要会话上下文、过 LLM 做意图分析、产出零到五条带类型的查询、延迟高一些。

意图分析

吃的是会话压缩摘要加最近五条消息加当前查询。零条也是合法输出,闲聊和寒暄不需要检索。三类查询各有文体约定,skill 用动词开头比如「创建 RFC 文档」,resource 用名词短语比如「RFC 文档模板」,memory 用「用户的 XX」比如「用户的代码风格偏好」。看起来土,但这让 embedding 的输入分布和被检索的内容对齐了。

分层下钻

用优先队列递归,每轮并行展开最多 4 个目录,收敛判定是 top-k 连续 3 轮不变、或者候选池连续 3 轮不长就停。
其他几个常量调参时有用:GLOBAL_SEARCH_TOPK = 10 是全局定位起点的候选数,MAX_CONVERGENCE_ROUNDS = 3MAX_PARALLEL_CHILD_SEARCHES = 4MAX_RELATIONS = 5DIRECTORY_DOMINANCE_RATIO = 1.2。rerank 只在配了 AK/SK 且处于 THINKING 模式时启用,返回非法结果或者 API 失败就退回向量分数,这个降级是对的,rerank 是精度优化不是正确性依赖。

分数传播的默认值

文档给的打分公式是 final_score = alpha * embedding_score + (1 - alpha) * parent_score,而 score_propagation_alpha 的默认值是 1.0,代入一下父节点分数的权重就是零。去代码里核对过,hierarchical_retriever.py:506 写的就是 alpha * score + (1 - alpha) * current_score,alpha 取 1 时 current_score 被完全乘掉。旁边的 hotness_alpha,把访问热度掺进分数那个,默认是 0.0,同样关着。
所以默认行为是树负责导航和上下文完整性,最终排序纯粹是每个节点自己的语义分数,配了 rerank 就再加一层。这不是 bug,配置文档把「1.0 忽略父节点分数」写得明明白白,但和「目录递归检索」这个说法给人的印象不太一样,你可能以为父目录的相关性会抬高子文件的排名,默认并不会,想要那个行为得自己把 alpha 调到 0.5 之类。

路径锁与一致性

09-transaction.md 开头就把立场摆出来了,说 OpenViking 是一个上下文数据库,FS 是真相源,VectorDB 是派生索引,丢掉的索引可以从源数据重建,丢掉的源数据无法恢复,因此宁可漏掉一条搜索结果也不要返回一条错的。

操作顺序

每个操作的步骤顺序都是上面那句的推论。rm 先删索引再删文件,反过来的话文件没了索引还在,搜索会返回一个不存在的文件,按这个顺序中间崩了最坏是文件还在但搜不到,重试能补完。mv 先拷贝到新位置,源还完整所以安全,再改向量库里的 URI,最后删源,改索引失败就把拷贝清掉,源和旧索引都完好。add_resource 先在最终路径上拿子树锁,语义处理跑在临时目录上,完成后同步进最终位置,这期间任何 rm 想拿同一路径的子树锁都会拿到 ResourceBusyError

锁的类型与过期

只有两种类型,EXACT 锁一个具体路径,TREE 逻辑上覆盖整个子树但只在根上写一个锁文件。锁文件的内容是栅栏令牌 {handle_id}:{time_ns}:{lock_type}
写完锁文件之后还要再扫一遍祖先和子孙,发现冲突就比较时间戳和 handle_id,较晚的那个撤掉自己的锁等一会再试。这是防活锁,双方都固执重试就都进不去,定一个全序让后来者退就总有一个能走。
运行期等待超时被固定成 0.0 并且不再接受外部配置,抢不到锁直接 LockAcquisitionError。一开始以为是没做完,读下去发现是刻意的,不排队意味着不会有调用方被莫名堵住,冲突立刻暴露给上层去决定重试还是报错。配套是三层过期清理:锁文件里的令牌时间戳超过 lock_expire(默认 30 秒)就在下次获取时被当残锁删掉;LockManager 每 60 秒扫一遍还持有锁但已不活跃的 handle 强制释放;进程崩溃留下的孤儿锁靠同一套残锁检测收掉。长任务不靠调大超时,而是开一个刷新循环每 lock_expire/2 刷一次令牌。

两段式提交

模型调用一律在锁外,理由写得很直接,LLM 调用延迟不可预测,五秒到六十秒以上,不能待在持锁操作里。所以 session.commit() 拆成两段,第一段不持锁,生成归档摘要、写归档、清空 messages.jsonl、清内存;第二段走持久队列,落归档元数据并入队、抽取记忆、写当前状态、写关系、入语义队列。
第一段不加锁的依据是不完整的归档没有副作用;第二段不再用独立 redo log 而是靠一个持久化的 session_commit 队列,QueueFS 用 SQLite 落盘,重启后 QueueManager 接着跑剩下的活。这条成立的前提被单独点出来了,记忆抽取是幂等的,从同一份归档重复抽取产出相同结果,没有这条重放就是危险的。

并发刷摘要

用的是 coalesce_version,同一个 dirty key 上可能起多个刷新任务但只有最新版本允许写回,过期任务在写回前把自己的结果丢掉,最后落盘时对两个挂件短暂拿一下 EXACT 锁。这样并发改 docs/a.mddocs/b.mddocs/c.md 各持自己的锁互不阻塞,不需要为了刷一次目录摘要去锁整棵子树。

会话归档与记忆抽取

抽取流程是消息过 LLM 抽成候选记忆,向量预筛找相似,再过一次 LLM 判重,然后写 AGFS 并向量化。

判重决策

粒度比常见的「相似度超过阈值就合并」细得多,分两级。对候选是 skip 重复什么都不做、create 创建并可以先删掉冲突的旧记忆、none 不创建但用条目级决策去处理已有记忆;对每条已有记忆是 merge 把候选内容并进去、delete 删掉这条冲突的。也就是说一次抽取可以同时表达「这条新的不要,但把旧的那两条合并成一条、再删掉第三条」。代价是判重要过一次 LLM,而且它有权删已有记忆。

变更审计

每次 commit() 都往归档目录写一份 memory_diff.json,逐条记这次的 adds、updates、deletes,updates 带 beforeafter,deletes 带被删内容全文,即使一条都没动也会写一个全零的空 diff。有了这个,agent 的记忆什么时候被谁改成了什么是可查可回滚的。归档目录里还有个 .done 标记表示第二段真的跑完了。

策略开关

memory_policy 控制,可以只开 self 不开 peer、可以关掉每次归档的工作记忆摘要、可以用 memory_types 白名单限定只抽哪几类。有条依赖关系要注意,experiences 会自动激活 casestrajectories,也就是整条 agent 进化流水线;反过来如果没有 experiences,显式写的 casestrajectories 会被静默忽略而不报错(memory_policy.py:80 那几行)。这个静默不太好,配置写错了没人会告诉你。

技能中的敏感值处理

add_skill 的时候会用 LLM 从技能正文里抽取敏感值,api_keytokenbase_url 之类,把明文替换成占位符 {{ov_privacy:skill:{skill_name}:{field_name}}},真实值存进版本化的隐私配置,读 SKILL.md 的时候自动还原。配置按 category + target_key 组织,current.json 是当前生效快照,history/version_N.json 是全量历史快照,值不变就不产生新版本,可以激活任一历史版本回滚。还原时如果占位符找不到对应值会保留占位符并追加一段 [OpenViking Privacy Notice],说明哪些没替换、哪些配了却没被引用。
好处是真的,明文不常驻在内容文件里,密钥轮换和回滚有版本可依,对调用方透明。但边界得看清,哪些算敏感值是模型判断的。模型漏掉一个 token,那个 token 就以明文存进了 SKILL.md,而且没有任何提示,注意力都在「配了却没用上」这种无害情况上,漏抽这种有害情况反而是静默的。另外读取时的 URI 匹配是后缀匹配 /skills/{name}/SKILL.md,宽松了些。个人会把它当成一层降低暴露面的便利设施,不当成密钥管理的边界,真正的密钥该在进入正文之前就不在那里。

基准测试

README 的图很扎眼。LoCoMo 上 OpenClaw 从 24.20% 提到 82.08%,Hermes 从 33.38% 到 82.86%,Claude Code 从 57.21% 到 80.32%,同时输入 token 降 34.3% 到 91.0%,查询延迟降 58.45% 到 66.10%。tau2-bench 上任务成功率零售加 6.87 个点、航空加 11.87 个点。
加分的地方是评测脚本真的开源出来了,在 benchmark/ 下按被测对象分目录,locomo、longmemeval、tau2、skillsbench、retrieval、RAG、vectordb_perf,而且给竞品也写了脚本,mem0 和 supermemory 都有,一键跑的 shell、导入、评估、LLM 裁判打分、统计各是独立文件,还提交了一份 locomo_bad_case_questions.csv 把自己答错的题记着。这个态度比只贴一张图强得多。
该打的折扣有两条。一是打分靠 LLM 裁判(judge.py),绝对数值取决于裁判模型和判分 prompt,跨报告横比意义有限。二是「原生记忆对比接上 OpenViking」比的是集成后的端到端效果,不是记忆算法的同条件对照,基线里 agent 用的是自己那套简易记忆,提升里既有检索质量的贡献也有本来几乎没有长期记忆的贡献。24% 到 82% 这种跨度主要说明基线薄,反而 Claude Code 那条,57.21% 到 80.32%、基线本身不弱,更有参考价值。

文档与代码的偏差

老规矩查一处。crates/ragfs/ORIGIN.md 说 RAGFS 是 AGFS 的 Rust 重写,源在本仓库的 third_party/agfs/,是个 Go 实现,还给了个开关:

export RAGFS_IMPL=auto (default to rust, with fallback to go)

全仓库搜了一遍。third_party/ 下只有 croaring、krl、leveldb-1.23、rapidjson、spdlog,没有 agfs;RAGFS_IMPL 这个字符串在整个仓库里只出现在 ORIGIN.md 自己那三行里,没有任何代码读它。也就是说回退到 Go 实现那个开关是纯文档,Go 实现已经不在仓库里了。不影响使用,Rust 实现就是唯一实现,但照着 ORIGIN.md 去设那个环境变量会得到一个什么都不做的环境变量。
相比之下 docs/en/concepts/ 那一批准确度不错。抽查的 score_propagation_alpha = 1.0hotness_alpha = 0.0MAX_CONVERGENCE_ROUNDS = 3GLOBAL_SEARCH_TOPK = 10、挂件上限 256 和 4000 字符、采样阈值 32、锁过期 30 秒,都和代码对得上,而且好几处主动标注了「当前实现如此,未来会改」。docs/design/ 下还有十九份 RFC,memory-link-design.md 有 96 KB,比 README 有用得多。

它赌的是结构化导航比扁平检索好,不是加了向量库这件事,向量库谁都有。收益挺实在,结果自带周围上下文,每次查询留下轨迹可以回看是哪条路径给出的,按需只读到需要的那一层;代价也实在,写入路径上要为每一层生成并维护摘要,还带来向上的写放大,它自己在 TODO 里承认这块调度还没做完。
用之前想清楚两件事。一是 AGPLv3,服务化对外提供会触发传染条款,README 里那句开源版没有阉割、不需要激活码是可信的,但许可证本身就是最大的约束。二是它现在的形态是一个要单独跑起来的服务,加上 embedding、rerank、判重、意图分析、摘要好几处模型调用,不是一个进程内的小库,是一套要运维的基础设施。想要轻的,同类里有把整个记忆层做成一个 Rust 单二进制的做法,之前写过一篇,那个项目也把真相在磁盘上、数据库只是派生索引列为最大的架构决策,理由几乎一字不差。
和前一篇那个办公室对着看也挺有意思,两个项目在同一件事上想法一致,先问判错的两个方向哪个更贵,再把不确定性推到便宜那一侧。那边是读屏只能清除草稿不能发明草稿,这边是 rm 反着删、锁不等待直接失败。