本文基于开源项目 LLM Wiki(v0.3.1)的源码,对比 Andrej Karpathy 的原始方法论文档,分析该项目在摄入管线、增量缓存、队列调度和检索管线四个方面的工程实现与设计取舍。

一、Karpathy 的原始方法论

2026 年初,Andrej Karpathy 发布了一份名为 llm-wiki.md 的设计模式文档,提出了一种用 LLM 构建个人知识库的思路。其核心观点是:与传统 RAG 在每次查询时从头检索不同,LLM 应该增量构建并维护一个持久化的 Wiki,知识只编译一次并持续更新。

该文档定义了三层架构和三个核心操作:

三层架构

  • Raw sources(原始资料):不可变的源文档集合
  • Wiki(知识库):LLM 生成和维护的 Markdown 文件集合
  • Schema(规则):告诉 LLM 如何组织 Wiki 的配置文档

三个操作

  • Ingest(摄入):导入新文档,LLM 阅读后更新 Wiki
  • Query(查询):基于 Wiki 内容回答问题
  • Lint(检查):定期健康检查,发现矛盾、孤立页面、知识空白

但 Karpathy 在文档末尾明确指出:“This document is intentionally abstract. It describes the idea, not a specific implementation.”——这是一个设计模式,不是工程方案。

LLM Wiki 项目将这个抽象模式实现为一个完整的跨平台桌面应用(Tauri v2 + React 19),在此过程中做了大量工程决策。本文聚焦其中最核心的部分:摄入管线。

二、两步思维链摄入

2.1 原始方法论的描述

Karpathy 对摄入的描述是一个概括性的流程:“the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log.”——LLM 阅读、讨论、写入,一气呵成。

2.2 工程实现:拆分为分析与生成

LLM Wiki 将这个单步流程拆分为两次独立的 LLM 调用,核心代码在 ingest.tsautoIngest() 函数中:

Step 1(分析):LLM 阅读原始文档 → 输出结构化分析报告
Step 2(生成):LLM 基于分析报告 + 原始文档 → 生成 Wiki 文件

第一步:分析

系统向 LLM 发送原始文档内容,要求输出一份结构化分析,涵盖六个维度:关键实体、关键概念、主要论点与发现、与现有 Wiki 的关联、矛盾与张力、建议。

分析阶段的 system prompt 中包含两个关键上下文:Wiki 的 purpose.md(目标定义)和 index.md(现有内容目录)。这使得 LLM 在分析时能够判断哪些内容是新增的、哪些与现有知识重叠或矛盾。

const [sourceContent, schema, purpose, index, overview] = await Promise.all([
  tryReadFile(sp),
  tryReadFile(`${pp}/schema.md`),
  tryReadFile(`${pp}/purpose.md`),
  tryReadFile(`${pp}/wiki/index.md`),
  tryReadFile(`${pp}/wiki/overview.md`),
])

五个文件并行读取,减少 I/O 等待。

第二步:生成

LLM 接收第一步的分析报告和原始文档内容,按照严格的格式规范生成 Wiki 文件。输出格式使用自定义的文件块标记:

---FILE: wiki/sources/filename.md---
(完整文件内容,含 YAML frontmatter)
---END FILE---

生成阶段的 prompt 对输出格式有详细约束:每个页面必须包含 YAML frontmatter(type、title、created、updated、tags、related、sources),必须使用 [[wikilink]] 语法进行交叉引用,文件名使用 kebab-case。

2.3 拆分的工程价值

两步拆分带来三个具体收益:

质量提升:分析步骤迫使 LLM 先"思考"再"动手"。在单步模式下,LLM 容易在阅读文档的同时就开始生成页面,导致遗漏后文中的重要信息或忽略与现有 Wiki 的矛盾。两步模式下,第一步的分析报告作为第二步的输入,确保生成阶段拥有完整的分析上下文。

可观测性:分析报告是一个可读的中间产物。如果生成结果不理想,开发者可以检查分析报告来定位问题——是分析阶段遗漏了关键信息,还是生成阶段没有正确执行分析建议。

容错性:两步之间是独立的 LLM 调用。如果生成步骤失败(如 API 超时),分析结果不会丢失,可以基于已有分析重试生成。

2.4 审核项的生成

生成阶段还会输出审核项(Review Items),使用另一种标记格式:

---REVIEW: type | Title---
描述内容
OPTIONS: Create Page | Skip
PAGES: wiki/page1.md, wiki/page2.md
SEARCH: search query 1 | search query 2
---END REVIEW---

审核类型被限制为四种:contradiction(矛盾)、duplicate(重复)、missing-page(缺页)、suggestion(建议)。操作选项也被限制为 Create PageSkip 两种。这种约束设计避免了 LLM 生成任意操作带来的不可控性。

每个审核项还包含预生成的搜索查询(SEARCH 字段),用于后续的深度研究功能。这些查询在摄入时由 LLM 生成,针对搜索引擎优化,而非通用的自然语言描述。

2.5 兜底机制

代码中包含一个兜底逻辑:如果 LLM 的生成输出中没有包含资料摘要页面(wiki/sources/ 目录下的文件),系统会自动创建一个,内容取自第一步的分析报告:

if (!hasSourceSummary) {
  const fallbackContent = [
    "---",
    `type: source`,
    `title: "Source: ${fileName}"`,
    // ... frontmatter
    "---",
    "",
    analysis ? analysis.slice(0, 3000) : "(Analysis not available)",
  ].join("\n")
  await writeFile(sourceSummaryFullPath, fallbackContent)
}

这确保了每份原始文档至少有一个对应的 Wiki 摘要页面,即使 LLM 在生成阶段遗漏了它。

三、SHA256 增量缓存

3.1 原始方法论中的空白

Karpathy 的文档没有讨论重复摄入的问题。在实际使用中,用户可能重新导入已有文档(如应用重启后恢复队列),如果每次都执行完整的两步 LLM 调用,会浪费 token 和时间。

3.2 工程实现

ingest-cache.ts 实现了基于 SHA256 的增量缓存,核心逻辑简洁:

export async function checkIngestCache(
  projectPath: string,
  sourceFileName: string,
  sourceContent: string,
): Promise<string[] | null> {
  const cache = await loadCache(projectPath)
  const entry = cache.entries[sourceFileName]
  if (!entry) return null

  const currentHash = await sha256(sourceContent)
  if (entry.hash === currentHash) {
    return entry.filesWritten  // 命中缓存,返回上次生成的文件列表
  }
  return null  // 内容已变更,需要重新摄入
}

缓存数据结构:

interface CacheEntry {
  hash: string          // 源文件内容的 SHA256 哈希
  timestamp: number     // 摄入时间戳
  filesWritten: string[] // 上次摄入生成的文件路径列表
}

缓存以 JSON 文件形式持久化在 .llm-wiki/ingest-cache.json 中。哈希计算使用 Web Crypto API(crypto.subtle.digest),在 Tauri 的 WebView 环境中原生可用。

缓存的粒度是源文件级别,以文件名为键。当源文件被删除时,对应的缓存条目也会被清除(removeFromIngestCache)。

3.3 设计取舍

该缓存方案选择了简单性而非精确性:它只检查源文件内容是否变更,不检查 Wiki 的当前状态。如果用户手动编辑了 Wiki 页面(如修正了一个实体页面的描述),然后重新导入同一份源文件,缓存会命中并跳过摄入,用户的手动修改不会被覆盖。这在大多数场景下是合理的行为,但在某些边缘情况下(如 Wiki schema 发生了重大变更,需要基于新规则重新生成所有页面)可能需要手动清除缓存。

四、持久化摄入队列

4.1 原始方法论的描述

Karpathy 提到可以"batch-ingest many sources at once with less supervision",但没有讨论并发控制、崩溃恢复等工程问题。

4.2 串行处理与崩溃恢复

ingest-queue.ts 实现了一个持久化的任务队列,核心设计决策是严格串行处理

async function processNext(projectPath: string): Promise<void> {
  if (processing) return  // 同一时刻只有一个任务在执行

  const next = queue.find((t) => t.status === "pending")
  if (!next) return

  processing = true
  next.status = "processing"
  await saveQueue(projectPath)
  // ... 执行摄入 ...
  processing = false
  processNext(projectPath)  // 递归处理下一个
}

串行处理的原因是:每次摄入都会读取和更新 index.mdoverview.md 等共享文件,并发执行会导致写冲突。

队列状态持久化到 .llm-wiki/ingest-queue.json。应用启动时,restoreQueue() 函数加载磁盘上的队列,将所有 processing 状态的任务重置为 pending(因为它们是被应用关闭中断的),然后恢复处理:

export async function restoreQueue(projectPath: string): Promise<void> {
  const saved = await loadQueue(pp)
  // 将中断的任务重置为待处理
  for (const task of saved) {
    if (task.status === "processing") {
      task.status = "pending"
      restored++
    }
  }
  queue = saved
  await saveQueue(pp)
  processNext(pp)  // 恢复处理
}

4.3 失败重试与取消

失败的任务会自动重试,最多 3 次:

const MAX_RETRIES = 3

// 在 catch 块中:
next.retryCount++
if (next.retryCount >= MAX_RETRIES) {
  next.status = "failed"
} else {
  next.status = "pending"  // 重新排队
}

取消正在执行的任务时,系统通过 AbortController 中断 LLM 调用,并清理已写入的文件:

export async function cancelTask(projectPath: string, taskId: string): Promise<void> {
  if (task.status === "processing") {
    if (currentAbortController) {
      currentAbortController.abort()
    }
    // 清理已写入的文件
    for (const filePath of lastWrittenFiles) {
      await deleteFile(fullPath)
    }
  }
  queue = queue.filter((t) => t.id !== taskId)
}

lastWrittenFiles 数组跟踪当前摄入任务已写入的文件路径。取消时逐一删除,避免留下不完整的 Wiki 页面。

4.4 文件夹上下文

批量导入时,文件的目录路径作为 folderContext 传递给 LLM:

export interface IngestTask {
  sourcePath: string      // "raw/sources/papers/energy/solar-panel-review.pdf"
  folderContext: string   // "papers > energy"
  // ...
}

分析阶段的 prompt 中包含指引:“If a folder context is provided, use it as a hint for categorization — the folder structure often reflects the user’s organizational intent.”

这是一个低成本但有效的设计:用户的文件夹组织方式本身就包含分类信息,将其传递给 LLM 可以提高实体和概念分类的准确性,而不需要额外的分类模型。

五、多阶段检索管线

5.1 原始方法论的描述

Karpathy 描述的查询流程是:"The LLM searches for relevant pages, reads them, and synthesizes an answer with citations."他建议在小规模时使用 index.md 作为导航入口,规模增长后引入搜索引擎。

5.2 工程实现:四阶段管线

LLM Wiki 实现了一个多阶段检索管线,代码分布在 search.tsgraph-relevance.ts 中:

阶段 1:分词搜索

对查询文本进行分词,支持中英文混合:

export function tokenizeQuery(query: string): string[] {
  // 英文:按空格和标点分词 + 停用词过滤
  // 中文:CJK 字符检测 → 二元组分词
  // "默会知识" → ["默会", "会知", "知识", "默", "会", "知", "识"]
}

中文分词采用了二元组(bigram)策略而非分词库,这是一个务实的选择——避免了引入 jieba 等分词库的依赖,在大多数场景下二元组的召回率足够。标题匹配额外加 10 分。

搜索范围覆盖 wiki/raw/sources/ 两个目录。

阶段 2:向量语义搜索(可选)

如果用户在设置中启用了向量搜索,系统会通过 LanceDB(嵌入式向量数据库,Rust 实现)进行语义检索:

if (embCfg.enabled && embCfg.model) {
  const vectorResults = await searchByEmbedding(pp, query, embCfg, 10)
  // 合并策略:已有结果加分,新结果追加
  for (const vr of vectorResults) {
    const existing = results.find(/* 匹配已有结果 */)
    if (existing) {
      existing.score += vr.score * 5  // 增强已有匹配
    } else {
      results.push(/* 添加新发现 */)  // 补充新页面
    }
  }
}

向量搜索的结果与分词搜索的结果通过分数叠加进行融合,而非替换。这确保了即使向量搜索返回了误匹配,分词搜索的精确匹配仍然排在前面。

阶段 3:图谱扩展

在查询阶段,系统使用四信号关联度模型(详见本系列第一篇文章)对搜索命中的页面进行图谱扩展,发现语义相关但未被关键词或向量搜索命中的页面。

阶段 4:上下文预算控制

用户可配置上下文窗口大小(4K 到 1M tokens),系统按固定比例分配预算:

  • 60% 给 Wiki 页面内容
  • 20% 给聊天历史
  • 5% 给 index.md
  • 15% 给系统提示

页面按搜索分数 + 图谱关联度的综合分数排序,在预算内尽可能多地纳入高相关度页面。

5.3 与 Karpathy 方案的对比

维度 Karpathy 方法论 LLM Wiki 实现
小规模检索 index.md 导航 分词搜索 + index.md
大规模检索 建议引入搜索引擎 内置分词搜索 + 可选向量搜索
关联发现 wikilink 手动浏览 四信号图谱自动扩展
上下文控制 未讨论 可配置预算 + 比例分配
中文支持 未讨论 CJK 二元组分词

六、Purpose.md:方法论中缺失的一环

Karpathy 的方法论定义了 Schema(Wiki 如何运作),但没有定义 为什么 这个 Wiki 存在。LLM Wiki 新增了 purpose.md,包含目标、关键问题、研究范围等信息。

这个文件在摄入和查询的每次 LLM 调用中都作为上下文注入。其工程价值在于:它为 LLM 提供了一个稳定的"方向锚点",使得不同时间点的摄入结果保持一致的关注方向,而不是每次都由 LLM 自行判断什么是重要的。

七、方法论到工程的差距总结

方法论描述 工程问题 LLM Wiki 的解决方案
“LLM reads and writes” 单步生成质量不稳定 两步思维链(分析→生成)
“batch-ingest many sources” 并发写冲突 串行队列 + 持久化
未讨论 重复摄入浪费 token SHA256 增量缓存
未讨论 应用崩溃后队列丢失 磁盘持久化 + 启动恢复
未讨论 摄入中断留下残留文件 AbortController + 文件清理
未讨论 LLM 遗漏必要页面 资料摘要兜底机制
“index.md is enough at moderate scale” 中文分词、语义检索 多阶段检索管线
未讨论 Wiki 的目标方向漂移 purpose.md 方向锚点
“humans in the loop reviewing” 阻塞式审核影响效率 异步审核队列 + 预定义操作

Karpathy 的方法论提供了正确的抽象——三层架构、三个操作、持久化 Wiki 优于即时 RAG。LLM Wiki 的贡献在于将这些抽象落地为可运行的工程系统,过程中解决了大量方法论未涉及的实际问题。这种从设计模式到工程实现的转化过程,对于任何基于 LLM 构建知识管理系统的团队都有参考价值。


本文分析基于 LLM Wiki v0.3.1 源码,项目地址:https://github.com/nashsu/llm_wiki ,采用 GPL-3.0 许可证。Karpathy 原始方法论文档:https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐