深度分析:OpenViking 中关于 LLM wiki 的理念的工程级实践
火山引擎开源的 OpenViking 项目,把 Karpathy 的 LLM Wiki 理念做成了一套有文件系统、有分层索引、有递归检索、有自我进化的完整工程系统。我从源码层面拆解了它的两个核心创新:L0/L1/L2 上下文分层和目录递归检索。
从理念到工程的鸿沟
llm wiki 理念的核心洞察是:
RAG 每次查询从零推导,什么都不积累。LLM Wiki 预编译、持续维护,知识随时间复利。
理念很清晰,但从"用 LLM 维护一个 wiki"到"让 Agent 真正用上这个 wiki",中间有一道工程鸿沟:
- 规模问题 :wiki 膨胀到数百页后,
index.md本身就撑爆 context window - 精度问题:grep + 正则只认字面命中,语义检索能力不足
- 效率问题:Agent 每次都要读全量内容,Token 消耗失控
- 进化问题:知识不会自动更新、不会自动发现关联
个人用的 wiki-vault 可以用 index.md + wikilink 解决前 100 页的问题。但当你有几千份文档、几十个 Agent 并发查询时,需要一套完全不同的基础设施。
OpenViking 就是这套基础设施的一个工程回答。
OpenViking 是什么
OpenViking 是火山引擎(字节跳动)开源的 Agent 知识操作系统。它的核心命题用一句话概括:
用文件系统的范式替代扁平的向量存储,让 Agent 的上下文像操作文件一样被分层加载、按需检索、持续迭代。
技术栈是 Python(服务端 + AI 管线)+ Rust(底层文件系统 AGFS)+ C++(向量索引引擎),提供 FastAPI HTTP 服务。
它在 LLM Wiki 范式的基础上做了四件事:
- 虚拟文件系统 :所有知识通过
viking://URI 寻址,有目录、有权限、有 ACL - L0/L1/L2 三级上下文:每个目录自动生成 100 token 摘要和 2k token 概览,Agent 按需逐层展开
- 目录递归检索:从目录级定位到文件级精确定位,兼顾全局和局部
- 自我进化:会话结束后自动提取记忆、更新知识库、重新生成摘要
下面逐一深入。
一、L0/L1/L2:知识的三级分层
1.1 为什么需要分层
LLM Wiki 的一个隐性假设是:Agent 每次查询时读 wiki 页面获取信息。但当 wiki 有 500 个页面时,Agent 不可能全部读完。它需要一个机制------像 CPU 缓存一样,先看 L1 Cache(小而快),miss 了再看 L2 Cache(中而准),最后才访问 Main Memory(大而全)。
OpenViking 的 L0/L1/L2 正是这个思路:
| 层级 | 载体 | 大小 | 作用 | 类比 |
|---|---|---|---|---|
| L0 Abstract | .abstract.md |
~100 tokens | 一句话说明这个目录/文件"是什么" | CPU L1 Cache |
| L1 Overview | .overview.md |
~2k tokens | 核心信息 + 导航树 + 文件关系 | CPU L2 Cache |
| L2 Detail | 原始文件 | 不限 | 完整内容 | Main Memory |
每个目录自动维护三层内容:
viking://resources/my_project/
├── .abstract.md # L0 --- "这是XX项目,包含API文档和设计文档"
├── .overview.md # L1 --- 详细的功能模块、核心文件、导航指引
├── docs/
│ ├── .abstract.md # L0 --- "API 文档目录"
│ ├── .overview.md # L1 --- API 端点列表与认证机制概述
│ ├── auth.md # L2 --- 完整认证文档
│ └── endpoints.md # L2 --- 完整端点文档
Agent 的工作流变成:
- 搜索 L0 → 快速判断哪些目录相关(~100 token/目录)
- 读 L1 → 了解目录内部结构和文件关系(~2k token/目录)
- 读 L2 → 只在确认需要时才读完整内容
Token 消耗从全量降到按需
1.2 分层内容是怎么生成的------自底向上的 DAG
L0/L1 不是手写的,而是 LLM 自动生成的。关键在于生成顺序:自底向上,先处理叶子文件,再逐层汇总到根目录。
OpenViking 用了一个 SemanticDagExecutor(DAG 调度器)来编排整个过程:
┌─────────┐
│ root/ │ ← 最后生成 overview
└────┬────┘
┌────┴────┐
┌───┴──┐ ┌───┴──┐
│docs/ │ │src/ │ ← 等子节点完成后生成 overview
└───┬──┘ └───┬──┘
┌─────┼───┐ ┌───┼─────┐
│ │ │ │ │ │
a.md b.md c.md x.py y.py ← 最先生成 summary
Step 1:文件级摘要
每个文件根据其类型使用不同的 prompt 模板:
| 文件类型 | Prompt 模板 | 摘要长度 | 特殊处理 |
|---|---|---|---|
| 普通文件 | semantic.file_summary |
50-150 词 | 直接读内容 |
| 文档文件 | semantic.document_summary |
60-180 词 | 关注章节结构 |
| 代码文件 | semantic.code_summary |
80-200 词 | 关注类/函数/依赖 |
| 大代码文件 | semantic.code_ast_summary |
80-200 词 | 先 AST 提取骨架再总结 |
代码文件有一个精巧的优化:对于超过 100 行的代码,先用 AST 提取结构骨架(import、class、function 签名),再用 LLM 总结骨架而非全文。这大幅降低了 Token 消耗,同时保留了代码的结构信息。
Step 2:目录级概览
当一个目录的所有文件和子目录都处理完后,DAG 调度器触发目录的 overview 生成。prompt 接收两类输入:
- 目录内所有文件的摘要(编号引用:
[1] auth.md: ...) - 子目录的 abstract(一句话摘要)
输出是一份结构化的 Markdown,包含:
markdown
# 目录名
(一段 50-150 词的描述性文字------这就是 L0 abstract)
## Quick Navigation
- 想了解认证机制?→ [1] auth.md
- 想查看所有 API 端点?→ [2] endpoints.md
## Detailed Description
### [1] auth.md
(文件摘要内容)
### [2] endpoints.md
(文件摘要内容)
L0 是从 L1 输出中自动提取的 ------取标题之后、第一个 ## 之前的那段文字。不是两次 LLM 调用,而是一次调用产出两层结果。
Step 3:写入 + 向量化
生成的 .abstract.md 和 .overview.md 作为 sidecar 文件写入目录,然后送入 EmbeddingQueue 做向量化。每个目录产出两条向量记录:
python
# L0 向量化
Context(uri=dir_uri, level=ContextLevel.ABSTRACT, abstract=abstract_text)
# L1 向量化
Context(uri=dir_uri, level=ContextLevel.OVERVIEW, abstract=abstract_text, vectorize=overview_text)
1.3 增量更新:不是每次都全量重建
DAG 调度器内置了增量检测:
- 文件级:检查内容是否变化,未变则复用已有摘要
- 目录级:检查子文件/子目录是否有变更,无变更则复用已有 overview
- 变更传播:只有内容真正变化的路径才会触发重新向量化
这意味着日常增量摄入的成本很低------只有变化的部分需要 LLM 重新处理。
1.4 分层服务接口
三层内容通过 HTTP API 暴露:
| 端点 | 层级 | 使用场景 |
|---|---|---|
GET /api/v1/content/abstract?uri=... |
L0 | 搜索时快速判断相关性 |
GET /api/v1/content/overview?uri=... |
L1 | 了解目录内部结构 |
GET /api/v1/content/read?uri=... |
L2 | 读取完整内容 |
Agent 的 find 和 search 工具在内部调用这些接口,实现"先看摘要,再决定是否深入"的渐进式信息获取。
二、目录递归检索:从"一袋子 chunk"到"一棵树"
2.1 RAG 检索的根本问题
传统 RAG 的检索是扁平的:所有 chunk 平等地躺在向量库里,查询时按相似度 top-k 返回。这带来两个问题:
- 丢失层级信息:一个文件的 chunk 和它所在目录的关系被切断了
- 全局与局部的矛盾:全局搜索可能命中不相关目录的零散 chunk,局部搜索又可能错过其他目录的相关内容
OpenViking 的解法是目录递归检索------利用 L0/L1 建立的目录层级结构,先定位到相关目录,再在目录内精确搜索。
2.2 算法全貌:五步法
HierarchicalRetriever 的完整流程:
Step 1: 意图分析(可选)
↓
Step 2: 全局定位 --- 在 L0/L1 向量中搜索 top-10 目录
↓
Step 3: 选择递归入口 --- 高分目录 + 预设根目录
↓
Step 4: 递归搜索 --- 优先队列驱动,并行展开子目录
↓
Step 5: 结果聚合 --- 去重、分数融合、热度加权、排序
2.3 核心算法:优先队列驱动的并行递归
这是整个系统最精巧的部分。_recursive_search 用一个最大堆(用负分实现)来驱动目录的展开顺序:
python
# 初始化:全局搜索的高分目录入队
dir_queue = [] # (-score, uri)
for uri, score in starting_points:
heapq.heappush(dir_queue, (-score, uri))
while dir_queue:
# 每轮取最多 4 个最高分的目录并行展开
batch = []
while dir_queue and len(batch) < 4:
score, uri = heapq.heappop(dir_queue)
if uri not in visited:
batch.append((uri, -score))
# 并行搜索这些目录的子节点
batch_results = await asyncio.gather(
*(search_children(uri) for uri, _ in batch)
)
for (uri, parent_score), results in zip(batch, batch_results):
for child, score in zip(results, query_scores):
# 分数传播公式
final_score = α × child_score + (1-α) × parent_score
# L2 文件是终端命中,不再递归
# L0/L1 目录继续入队等待展开
if child.level != 2:
heapq.heappush(dir_queue, (-final_score, child.uri))
为什么用优先队列而不是 BFS/DFS?
- BFS 平等对待所有目录,浪费算力在低分目录上
- DFS 可能在一个分支上钻太深,错过全局更优解
- 优先队列保证每一轮都展开当前全局最高分的目录------这是 best-first search 的变体
2.4 分数传播:父目录的信誉为子节点背书
python
final_score = α × child_score + (1-α) × parent_score
这个公式的含义是:一个子节点的最终得分,不仅取决于它自己和查询的相似度,还受到它所在目录相关性的加权。
直觉上:如果一个目录整体和查询高度相关(比如"认证模块"目录 vs "认证"查询),那么即使目录下某个文件的向量分数不是最高,它也可能比一个无关目录下的高分文件更值得返回。
α 的值通过 retrieval.score_propagation_alpha 配置,控制"自身相似度"和"父目录信誉"的权重平衡。
2.5 收敛检测:避免无限递归
递归搜索不能无限进行。OpenViking 用三重机制控制停止:
python
# 每轮展开后检查
current_topk = sorted(collected, key=score, reverse=True)[:limit]
current_topk_uris = {c.uri for c in current_topk}
# 机制一:top-k 收敛
if current_topk_uris == prev_topk_uris and len(current_topk_uris) >= limit:
convergence_rounds += 1
if convergence_rounds >= 3:
break # 连续 3 轮 top-k 不变,停止
# 机制二:池大小停滞
elif current_pool_size == prev_pool_size:
stagnant_rounds += 1
if stagnant_rounds >= 3:
break # 连续 3 轮无新结果,停止
# 机制三:已访问去重
# visited set 保证每个目录只展开一次
实测中,大多数查询在 2-4 轮内收敛。
2.6 热度加权:新知识优先
最终的排序不仅看语义相似度,还融合了"热度分":
python
final_score = (1 - hotness_alpha) × semantic_score + hotness_alpha × hotness_score
热度分的计算借鉴了 Hacker News 的排序算法:
python
def hotness_score(active_count, updated_at, half_life_days=7.0):
freq = 1.0 / (1.0 + exp(-log1p(active_count))) # 频率(sigmoid 压缩)
age_days = max((now - updated_at).total_seconds() / 86400, 0)
recency = exp(-log(2) / half_life_days * age_days) # 指数衰减(7 天半衰期)
return freq * recency
含义:被频繁访问且最近更新的知识排在前面。7 天半衰期意味着一周前的知识热度减半------这对 Agent 的记忆系统尤其重要,最近的经验应该优先被召回。
2.7 Rerank:二次精排
在 THINKING 模式下,每一轮递归展开的结果都会经过 rerank 模型精排:
python
if self._rerank_client and mode == RetrieverMode.THINKING:
documents = [r.abstract for r in results]
query_scores = await self._rerank_scores(query, documents, query_scores)
Rerank 失败时自动 fallback 到原始向量分数,保证系统不因为精排组件故障而整体不可用。
三、自迭代:知识库自己更新自己
OpenViking 的第三个创新是会话结束后的自动知识提取。
3.1 Session Memory 提取流程
每次会话提交时,系统自动执行一条完整的知识沉淀管线:
会话消息
↓
归档摘要生成(compression summary)
↓
ExtractLoop(ReAct 循环)
├── LLM 看到:对话历史 + 现有记忆概览 + 记忆 schema
├── LLM 决策:用 read 工具查看更多细节?直接输出操作?
├── 最多 3 轮迭代
└── 输出:upsert/delete 操作列表
↓
MemoryUpdater 执行操作
├── 写入/更新/删除 viking:// 中的记忆文件
├── 重新向量化
└── 重新生成目录的 L0/L1 摘要
3.2 ExtractLoop:ReAct 式记忆更新
ExtractLoop 不是一个简单的"LLM 提取 → 写入"管线,而是一个有工具调用能力的 ReAct 循环:
Round 1: LLM 看到对话历史 + 记忆目录概览
→ "我需要先读取用户已有的 profile.md 看看现有内容"
→ 调用 read("viking://user/alice/memories/profile.md")
Round 2: LLM 看到文件内容
→ "用户这次提到了新的技术偏好,我应该更新 profile"
→ 输出操作:{op: "upsert", uri: "profile.md", merge: "patch", content: "..."}
LLM 有 read 工具可以在循环中主动查阅现有知识,避免重复或矛盾的记忆写入。支持的操作类型包括:
| 操作 | 合并策略 | 说明 |
|---|---|---|
| upsert + patch | 增量合并 | 保留旧内容,追加/修改新信息 |
| upsert + replace | 整体替换 | 新内容完全覆盖旧内容 |
| upsert + immutable | 不覆盖 | 只在文件不存在时创建 |
| delete | 删除 | 清理过时记忆 |
| link | 关联 | 建立记忆间的双向链接 |
3.3 闭环:记忆更新触发摘要重建
MemoryUpdater.apply_operations() 在写完记忆文件后,会触发目录的 L0/L1 重新生成。这意味着:
用户和 Agent 对话
→ Agent 完成任务
→ 会话提交
→ 系统自动提取新的记忆
→ 记忆写入 viking:// 文件系统
→ 目录的 .abstract.md 和 .overview.md 自动更新
→ 向量索引自动更新
→ 下次查询时,新知识已经"编译"好了
这就是 LLM Wiki 理念的工程闭环:每次交互都在丰富知识库,知识随时间复利。
四、回到 LLM Wiki 五范式
把 OpenViking 放回我们 \[开发/概念/知识库五范式\|已有的知识范式框架] 中看:
| 维度 | LLM Wiki 原版 | OpenViking |
|---|---|---|
| 知识表示 | Markdown + wikilink | viking:// URI + L0/L1/L2 三层 |
| 检索方式 | index.md + grep |
目录递归检索 + rerank |
| 规模承载 | ~100 页 | 数千页(向量索引 + 分层过滤) |
| 知识更新 | 手动 ingest | 会话后自动提取 + 增量 DAG |
| 权限控制 | 无 | 多租户 + ACL + 路径级权限 |
| 可观测性 | 无 | OpenTelemetry + Prometheus + 事件总线 |
| 自进化 | 无 | ReAct 记忆提取 + RL 训练管线 |
OpenViking 本质上是 LLM Wiki(范式二)+ GraphRAG(范式四的检索能力)+ 企业级基础设施 的工程融合,但核心哲学仍然是 Karpathy 的:
知识要被编译、沉淀下来,而不是每次从零检索。
L0/L1 就是"编译后的知识"------它们不是原始文档的 chunk,而是 LLM 理解了整棵目录树后产出的结构化摘要。
对 wiki-vault 的启示
回到我们自己的 wiki-vault 实践,OpenViking 有几个可以直接借鉴的设计:
- L0/L1 思路可以轻量化落地 :不一定需要向量数据库,给每个 wiki 目录生成一个
SUMMARY.md(L1)和一句话描述(L0),就能实现按需加载 - 增量 DAG:文件变更时只重新生成受影响的摘要,不全量重建
- 热度衰减:在 wiki 检索中引入时间衰减因子,最近更新的页面优先展示
- 会话后自动提取:每次有价值的对话结束后,自动更新相关 wiki 页面
五、局限与思考
OpenViking 不是银弹,它有自己的工程代价:
| 代价 | 具体表现 |
|---|---|
| LLM 调用成本高 | 每个文件都要 LLM 生成摘要,每个目录都要生成 overview,大规模摄入时 Token 消耗巨大 |
| 延迟 | 递归检索需要多轮向量搜索 + 可能的 rerank,比单次向量检索慢 |
| 系统复杂度 | Python + Rust + C++ 三语言栈,AGFS 文件系统 + 向量数据库 + 消息队列,部署运维门槛高 |
| 摘要质量依赖 LLM | L0/L1 的质量完全取决于 LLM 的总结能力,弱模型会导致摘要失真 |
这些代价在企业场景下是可接受的(有运维团队、有预算),但对个人用户来说,\[开发/概念/LLM-Wiki模式\|LLM Wiki 原版]的轻量方案仍然是更务实的选择。
核心判断 :OpenViking 证明了 LLM Wiki 理念可以在工程上做到大规模、分层、自进化。但它的价值更多是方向性的验证而非直接可用的产品------它告诉你"上下文分层供给"这条路是走得通的,具体的实现可以根据你自己的规模选择合适的复杂度。
关于我们
主页 https://github.com/zhuzhaoyun
我们当前开源了一款 llm wiki 理念的实践工具 Molio ,知识管理与创作工具。支持类 Obsidian 的知识管理、Claude Code/Codex 等 AI runtime 集成、以及 doocs/md 排版和多平台发布。
本文由 Molio 分析撰写,以及排版发布。