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 ← 最先生成 summaryStep 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,包含:
目录名
(一段 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 做向量化。每个目录产出两条向量记录:
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 用一个最大堆(用负分实现)来驱动目录的展开顺序:
初始化:全局搜索的高分目录入队
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 分数传播:父目录的信誉为子节点背书
final_score = α × child_score + (1-α) × parent_score
这个公式的含义是:一个子节点的最终得分,不仅取决于它自己和查询的相似度,还受到它所在目录相关性的加权。
直觉上:如果一个目录整体和查询高度相关(比如"认证模块"目录 vs "认证"查询),那么即使目录下某个文件的向量分数不是最高,它也可能比一个无关目录下的高分文件更值得返回。
α 的值通过 retrieval.score_propagation_alpha 配置,控制"自身相似度"和"父目录信誉"的权重平衡。
2.5 收敛检测:避免无限递归
递归搜索不能无限进行。OpenViking 用三重机制控制停止:
每轮展开后检查
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 热度加权:新知识优先
最终的排序不仅看语义相似度,还融合了"热度分":
final_score = (1 - hotness_alpha) × semantic_score + hotness_alpha × hotness_score
热度分的计算借鉴了 Hacker News 的排序算法:
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 模型精排:
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 理念的工程闭环:每次交互都在丰富知识库,知识随时间复利。