深度分析:OpenViking 中关于 LLM wiki 的理念的工程级实践

深度分析: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 范式的基础上做了四件事:

  1. 虚拟文件系统 :所有知识通过 viking:// URI 寻址,有目录、有权限、有 ACL
  2. L0/L1/L2 三级上下文:每个目录自动生成 100 token 摘要和 2k token 概览,Agent 按需逐层展开
  3. 目录递归检索:从目录级定位到文件级精确定位,兼顾全局和局部
  4. 自我进化:会话结束后自动提取记忆、更新知识库、重新生成摘要

下面逐一深入。


一、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 的工作流变成:

  1. 搜索 L0 → 快速判断哪些目录相关(~100 token/目录)
  2. 读 L1 → 了解目录内部结构和文件关系(~2k token/目录)
  3. 读 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 的 findsearch 工具在内部调用这些接口,实现"先看摘要,再决定是否深入"的渐进式信息获取。


二、目录递归检索:从"一袋子 chunk"到"一棵树"

2.1 RAG 检索的根本问题

传统 RAG 的检索是扁平的:所有 chunk 平等地躺在向量库里,查询时按相似度 top-k 返回。这带来两个问题:

  1. 丢失层级信息:一个文件的 chunk 和它所在目录的关系被切断了
  2. 全局与局部的矛盾:全局搜索可能命中不相关目录的零散 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 有几个可以直接借鉴的设计:

  1. L0/L1 思路可以轻量化落地 :不一定需要向量数据库,给每个 wiki 目录生成一个 SUMMARY.md(L1)和一句话描述(L0),就能实现按需加载
  2. 增量 DAG:文件变更时只重新生成受影响的摘要,不全量重建
  3. 热度衰减:在 wiki 检索中引入时间衰减因子,最近更新的页面优先展示
  4. 会话后自动提取:每次有价值的对话结束后,自动更新相关 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 分析撰写,以及排版发布。