21 · 实战:LLM 是怎么总结一个代码仓库的

一个中等仓库有几百万 token,窗口只有几十万——“总结仓库”本质是采样与预算分配问题,不是“读完再总结”。本篇把这条流水线的六个阶段逐个拆开:git 边界裁剪、确定性骨架采集、tree-sitter 符号 map、重要性排序、分治总结与归并、验证与失效管理,并给出七个常见失败模式。

作者 David更新于

21 · 实战:LLM 是怎么总结一个代码仓库的

正文二十篇已经收官。这一篇是实战番外:不再引入新部件,而是把前面焊上的零件——工具、并发、子 Agent、缓存、压缩——组装起来,解决一个具体而普遍的问题:

让 Agent 读懂一个它从没见过的代码仓库。

这是每个 Coding Agent 开工前的第一件事,也是 /init 生成一份项目说明文件、新人入职文档自动化、代码库问答这些功能背后共同的那条流水线。

一、背景:仓库根本塞不进上下文

先算一笔账。一个中等规模的 TypeScript 项目:

node_modules 之外的源文件      ~1500 个
平均每个文件                    ~180 行
合计                            ~27 万行代码
按代码约 12 token/行估算        ≈ 320 万 token

而当前主流模型的上下文窗口在 20 万 token 量级(部分支持到百万)。差 15 倍以上。 换成一个几万文件的大型单体仓库,差距是两到三个数量级。

更关键的是——就算塞得下,也不该塞:

  • 成本:几百万 token 的输入,而且因为 LLM 无状态,每一轮都要整个重发。
  • 注意力稀释:把关键信息埋进几百万 token 的样板代码里,模型的检索准确率会显著下降。信噪比比绝对信息量更重要。
  • 缓存全废:一个巨大的前缀,任何一处改动都会让缓存整体失效。

所以,问题必须重新表述:

不是“怎么把仓库读完再总结”,而是“在注定读不完的前提下,怎么让模型建立一张尽可能准确的地图”。

这句话是整篇的题眼。它把“总结仓库”从一个自然语言处理问题,变成了一个采样与预算分配问题——你有 15 万 token 的预算,仓库有 320 万 token 的内容,你要决定的是哪 5% 值得花,以及怎么让这 5% 尽可能有代表性。

二、三条根本不同的路子

在进入流程之前,先把三种策略摆清楚,因为真实系统是把它们混着用的。

策略做法强在哪弱在哪
检索式不通读,按问题去搜(grep / 向量检索)便宜、精准、永远最新只能答“具体问题”,答不了“整体是什么”
分治归并切块 → 逐块总结 → 归并成全局视图唯一能产出全局理解的路子一次性成本高、会过期、归并有损
代理式探索给 Agent 工具,让它像新人一样顺藤摸瓜深,能追出跨文件的因果链慢、贵、覆盖不均匀

关于向量检索这条路,值得多说一句:它在文档上很有效,但在代码上表现常常不如朴素的 grep。原因是代码的检索需求多半是精确的符号名(“ToolRegistry 在哪定义的”),而不是模糊的语义相似(“讲工具注册的那段”)。而且嵌入索引需要构建、存储、随代码变更维护——这些成本在一个每天几十次提交的仓库里并不低。能用符号搜索解决的,别上向量库。

这三者不是三选一,而是一条流水线上的三段。 生产级做法几乎总是同一个顺序:先用不花 token 的确定性手段把范围压到 1%,再用便宜的分治总结覆盖全局,最后才用昂贵的代理式探索补关键深度。

反过来做——一上来就让 Agent 拿着工具满仓库乱逛——是最贵、最慢、也最不可靠的做法:它会把大量预算花在“发现 dist/ 目录不用看”这种本该由一行 .gitignore 解决的事情上。

三、完整流程:六个阶段

5000 个文件 / 320 万 token
   │
   ├─ 阶段 0  边界裁剪       确定性,0 token      → 800 个文件
   ├─ 阶段 1  骨架采集       确定性,0 token      → 3k token 的“项目名片”
   ├─ 阶段 2  符号级 map     tree-sitter,0 token → 40k token 的“全仓 .d.ts”
   ├─ 阶段 3  重要性排序     图算法 + git 信号    → Top-30 全文 / 其余仅签名
   ├─ 阶段 4  分治总结(map)  N 次并行 LLM 调用    → N 份结构化模块摘要
   ├─ 阶段 5  归并(reduce)   1~2 次 LLM 调用      → 全局架构视图
   └─ 阶段 6  验证与落盘     工具核对 + 记 commit → 可增量更新的产物

注意前三个阶段一个 token 都不花。这不是巧合,而是这条流水线的组织原则:越便宜的手段越早用,把模型留到最后。

阶段 0:边界裁剪(不花一个 token)

第一条,也是最容易被做错的一条:用 git,别用文件系统遍历。

git ls-files

这一行天然尊重 .gitignore,一步排除 node_modules、dist、.venv、target 这些占了 95% 体积却几乎零信息量的东西。用 find 或 readdir 递归,你得自己重新实现一遍 gitignore 的匹配规则,而且一定会漏。

在此之上再叠一层显式排除:

类别例子为什么排
锁文件pnpm-lock.yaml、Cargo.lock几万行,零架构信息
生成物*.pb.go、*_generated.ts、OpenAPI 客户端机器写的,高度重复
vendored 依赖third_party/、vendor/不是这个项目的代码
数据与快照__snapshots__/、fixtures/、*.csv体量大,无结构信息
压缩产物*.min.js、source map不可读
超大单文件超过 100 KB 的多半是生成的或数据

生成物污染是最常见、也最隐蔽的失败原因。 一个 Go 微服务仓库里,60% 的代码行可能来自 protobuf 生成的 .pb.go——它们语法完整、体量巨大、命名规范、且高度重复。不排掉的话,后面每一个阶段都会被它们带偏:重要性排序会把它们排到前面(因为被 import 得多),分治总结会认真地总结它们,最后你得到一份“这是一个关于消息序列化的项目”的摘要。

判断技巧:文件头部通常有 // Code generated by ... DO NOT EDIT. 这类标记,扫一遍首几行就能识别一大半。

经过这一步,5000 个文件通常会降到 800 左右——一次 6 倍压缩,成本是零。

阶段 1:骨架采集(仍然 0 token)

这一步依旧不调用模型,只用确定性工具抽取信息密度最高的东西。四类:

① 元数据文件——一眼看清技术栈

package.json / go.mod / Cargo.toml / pyproject.toml / pom.xml 里直接写着:语言、框架、依赖清单、脚本入口、二进制入口。

其中 scripts 字段格外有价值——它告诉你怎么构建、怎么跑测试、怎么启动。这三件事是阶段 6 验证环节要用的,也是 Agent 后续真正干活时最常需要的知识。

② 目录树(必须限深限宽)

直接输出的完整目录树,本身就可能几万 token。要限制:深度限 3~4 层,同层同类条目超过阈值就折叠。

src/
├── core/            (12 个文件)
│   ├── loop.ts
│   ├── registry.ts
│   └── ...
├── tools/           (28 个文件)
├── ui/
│   ├── components/  (140 个文件,已折叠)
│   └── hooks/       (22 个文件)
└── index.ts

折叠时保留计数很重要——“这里有 140 个组件”本身就是一条结构信息,它告诉模型这是个 UI 密集型项目。

③ git 信号——被严重低估的重要性来源

git log --since=6.months --name-only --format= | sort | uniq -c | sort -rn | head -50

这一行给你最近半年改动最频繁的 50 个文件。

改动频率 ≈ 重要性 ∩ 活跃度。 一个项目的核心逻辑,几乎总是被反复修改的那部分;而从建仓起就没动过的文件,多半是样板、配置,或已经稳定的边缘模块。

这个信号的三个优点很难被超越:完全免费、语言无关、且反映真实的工程重心——它衡量的不是“静态分析认为哪里复杂”,而是“人类维护者实际在哪里花时间”。在我见过的启发式里,它的性价比排第一。

同源的另外两个信号也顺手可得:git log --format='%s' -50 的提交标题透露“最近在做什么”;git shortlog -sn 的作者分布透露“谁维护哪块”。

④ 文档与入口

README、docs/、CONTRIBUTING.md、Dockerfile 的 CMD、CI 配置里的构建与测试命令。

不要迷信 README。 README 常常是营销文案,或者是半年前的快照,与代码实际结构脱节是常态而非例外。

正确的用法是把它当作假设来源而不是事实来源:它说“采用微服务架构”,你要去目录结构里找证据;找不到,就以代码为准。README 与代码矛盾时,永远信代码。

阶段 1 的产出通常只有 2~5k token,但它往往已经回答了“这是个什么项目”这个问题的一多半——而且一个 token 的模型调用都没花。

阶段 2:符号级骨架——压缩比最高的一步

现在要处理那 800 个源文件。读全文不可能,但有一个近乎免费的巨大压缩:只保留签名,丢掉函数体。

用 tree-sitter(或 ctags、语言自带的 LSP)解析出每个文件的顶层符号:

从 480 行的实现,抽出 9 行的骨架
// 原文件 src/tools/registry.ts —— 480 行,约 6000 token
// 抽取后 —— 9 行,约 120 token:

export interface Tool { name: string; schema: ZodSchema; run(...): Promise<Result> }
export class ToolRegistry {
  register(tool: Tool): void
  get(name: string): Tool | undefined
  list(): Tool[]
  private resolveConflict(a: Tool, b: Tool): Tool
}
export function defineTool(spec: ToolSpec): Tool

这一步的压缩比通常在 10:1 到 50:1 之间,而且丢掉的恰好是最不该进摘要的东西。

函数体讲的是“怎么做”,签名讲的是“做什么”。建立仓库地图需要的是后者——你要知道 ToolRegistry 提供 register/get/list 这三个能力,而不需要知道 resolveConflict 内部用了什么排序。把 320 万 token 的正文换成 20~50 万 token 的“全仓 .d.ts”,是整条流水线上性价比最高的一次转换。

还能再进一步:按引用关系排序。 把 import 与调用关系建成一张图,跑一遍 PageRank——被引用得越多、且引用它的文件本身越重要的符号,排名越高。然后只保留 Top-N 个符号。

这正是 aider 的 “repo map” 采用的做法(tree-sitter 抽符号 + PageRank 排序)。它的价值在于:当你只有固定的 token 预算时,装进去的是最值得装的那些符号,而不是碰巧排在字母表前面的那些。

阶段 3:重要性排序与预算分配

现在手上有:项目名片(3k) + 全仓签名骨架(40k)。还差最后一层决策——哪些文件值得读正文。

把前面的信号合成一个分数:

信号含义怎么用
git 改动频率工程重心在哪直接加权,权重给高
入度(被 import 次数)基础设施性PageRank
出度(import 别人多)编排层 / 入口中等加权
路径语义src/core/ > src/utils/ > test/规则加权
文件大小太小可能是 barrel,太大疑似生成物两端降权

然后按预算分层分配:

总预算 150k token
├── 项目名片          3k    全文
├── Top-30 文件      60k    读全文
├── 次要 200 个      50k    仅签名骨架
└── 长尾 570 个      10k    仅路径 + 一行职责
                    ────
                    123k    留 27k 给模型输出与后续对话

这是整条流水线里最“工程”、也最容易被跳过的一步。 很多失败的仓库总结不是模型不行,而是喂给它的是随机的 5%,而不是最重要的 5%。

最典型的错误做法是“每个文件读前 50 行”——那你会得到 800 份 import 语句和版权头,信息量接近于零。均匀采样是仓库总结的头号反模式:代码的重要性分布是极度长尾的,均匀对待等于把绝大部分预算花在长尾上。

阶段 4:分治总结(map)

终于开始调模型了。四个要点。

① 切块要沿语义边界,不要沿 token 边界。

按模块 / 目录切,不要按“每 50k token 切一刀”。一个模块的文件放在一起,模型才能看出内聚关系;切在半个模块上,两边都总结不好,而且两边都不知道自己被切了。

② 每块都要带全局上下文头。

这是最容易漏、影响却最大的一点。孤立地丢给模型一个 src/retry/ 目录,它只能说“这是一个实现重试逻辑的模块”——正确,但毫无用处。带上“本项目是一个 Coding Agent CLI,本模块位于工具执行层与 API 客户端之间”,它才说得出:“它把 API 的 429/5xx 转换成带抖动的指数退避,是 Agent Loop 在网络抖动下不中断的关键”。

每次 map 调用的 prompt 结构
┌──────────────────────────────────┐
│ [全局头] 项目名片(阶段 1 产出)      │ ← N 次调用里逐字节相同
│ [全局头] 目录树骨架                │   放最前面,整体命中缓存
├──────────────────────────────────┤
│ [本块]   src/retry/ 的完整源码     │ ← 只有这部分在变
├──────────────────────────────────┤
│ [指令]   按固定 schema 输出摘要    │
└──────────────────────────────────┘

这个结构对 prompt 缓存极其友好。 全局头在所有 N 次调用里逐字节相同,放在最前面就能整体命中——N 个模块并行跑,这块前缀只真正计算一次。

把不变的放前面、把变化的放后面,是所有 map 阶段的通用优化,也是第 12 篇那条“字节稳定性”红线在这里的直接应用。

③ 强制结构化输出。

让每块摘要按同一个 schema 产出,归并阶段才好处理:

模块摘要的 schema
{
  module: 'src/retry',
  responsibility: string,      // 一句话职责
  publicApi: string[],         // 对外暴露什么
  dependsOn: string[],         // 依赖哪些内部模块
  keyDecisions: string[],      // 值得记住的设计决策
  entryPoints: string[],       // 从哪个文件读起
  evidence: string[],          // 每条结论的文件路径依据  ← 关键
}

evidence 这个字段值得单独说:要求模型为每条结论给出文件路径依据,既能显著压制幻觉(说不出出处的结论,模型往往就不说了),又让阶段 6 的验证有的放矢。

④ 并行,并且隔离。

N 个模块之间没有依赖,天然可以并发(第 06 篇)。更进一步,用子 Agent 跑每一块——这样某个模块需要额外读五个文件才看得懂时,那五份原文留在子上下文里,主线程只收到一份结构化摘要。

仓库总结是子 Agent 最典型的应用场景——第 18 篇给的三条判据(探索量大、结论自足、可并行)在这里全中,几乎是为它量身定做的。

阶段 5:归并(reduce)

N 份模块摘要 → 一张全局图。

关键认识:归并要做的不是“拼接”,而是“发现跨模块的关系”。模块摘要各自都是局部真相,归并阶段的全部价值在于回答局部视角看不到的问题:

  • 请求 / 数据从哪进来、流经哪些模块、从哪出去?
  • 分层怎么分的?哪些是基础设施,哪些是业务?
  • 哪两个模块耦合得不正常?

N 很大时自然会想到分层归并(树形 reduce):目录级 → 子系统级 → 全局。但这里有个陷阱。

归并层数越多,损失越大,而且损失是不均匀的。 一份摘要每被归并一次就被压缩一次;被归并两次的模块,细节存活率大致是单次的平方。

结果是深层目录里的模块在最终摘要里几乎消失,而顶层的几个模块占据了全部篇幅——这不反映真实的重要性,只反映目录深度。一个埋在 src/core/engine/exec/ 里的关键模块,会输给一个躺在 src/ 根下的工具函数文件。

所以真实系统往往刻意不做深层归并,而是保留一个两层结构:

最终产物
├── 全局概览     1~2 页:架构、数据流、怎么跑起来、从哪读起
└── 模块索引     每模块 2~3 行,保留阶段 4 的原始摘要,不再压缩

这样既有全局视图,又不丢局部细节,而且总量仍然很小(几千 token),小到可以常驻上下文。这是关键——一份需要检索才能用的摘要,价值远低于一份一直在窗口里的摘要。

阶段 6:验证与失效管理

摘要生成完了,还有两件事必须做,否则前面全白干。

① 验证:让结论可证伪。

基于摘要提几个能用工具核对的问题,然后真的去核对:

摘要里的结论怎么验
“入口是 src/cli.ts”文件存在吗?里面真有入口逻辑吗?
“测试用 vitest 跑”package.json 里真有这个脚本吗?跑一下?
“鉴权在 src/auth/”grep 相关符号,对得上吗?

模型会为它没读到的部分“补全”一个合理的架构。 这是仓库总结最危险的失败模式——产物读起来极其顺畅、结构完整、术语专业,但其中某几条是基于常见项目模式推断出来的,而不是从这个仓库里读出来的。

越是符合业界惯例的项目,这种幻觉越难被发现,因为它猜得往往八九不离十——而那“不离十”的一两条,恰恰会在 Agent 真正干活时把它带进沟里。阶段 4 那个 evidence 字段就是为此准备的:每条没有文件路径支撑的结论,都应该被当作假设,而不是事实。

② 失效管理:摘要一定会过期。

仓库每天都在变。一份三个月前的摘要比没有摘要更糟——它会用一副权威的口吻,自信地误导。

所以产物里必须记下生成时的 commit:

<!-- generated-at: 8f3a2c1 · 2026-08-02 -->

之后判断失效范围就变成一次 diff:

git diff --name-only 8f3a2c1..HEAD

改动落在哪些模块,就只重跑那几个模块的 map,再重跑一次 reduce。

增量更新的成本是全量的几十分之一,这让“每次开会话都保持摘要新鲜”从不可能变成了日常。没有这一步,仓库摘要会从资产变成负债——而且是一笔你不知道自己欠着的负债。

四、两条路线,其实都要

一次性总结(本篇的流水线)按需探索(Agent 自己去查)
何时发生预处理,离线提问时,在线
成本一次性大,之后近乎免费每次提问都花
新鲜度会过期,需增量维护永远是最新代码
覆盖全仓,均匀但浅局部,不均匀但深
擅长回答“这项目是干嘛的”“从哪读起”“鉴权具体怎么实现的”

真实的 Coding Agent 两个都用,分工明确: 一份薄的全局摘要常驻上下文(几千 token,回答“我在哪”),具体问题靠工具现场去查(回答“这个怎么实现的”)。

摘要的作用不是替代探索,而是让探索有方向。 没有地图时,Agent 只能靠 grep 关键词碰运气,一个词没搜中就得换个词再来;有地图时,它知道该去哪个模块找。这个差别在大仓库上是数量级的。

五、七个常见失败模式

#失败模式症状
1均匀采样每个文件读前 N 行,收获 800 份 import 语句
2生成物污染摘要围着 .pb.go 打转,说项目是做序列化的
3README 迷信把营销文案或过期快照当架构事实
4无依据幻觉补全出一个“看起来很对”的架构,八九不离十
5深层归并深处的关键模块在最终摘要里消失
6无失效机制三个月前的摘要,自信地误导
7切在语义边界外按 token 数硬切,一个模块被劈成两半

这七条里,1、2、6 占了实际问题的绝大多数——而它们全都不是模型能力问题,全是流水线的工程问题。

六、小结

  1. 仓库总结的本质是采样与预算分配,不是“读完再总结”。想清楚这一点,后面所有决策都有了依据。
  2. 流水线的组织原则:越便宜的手段越早用——git 裁剪(0 token) → 确定性骨架(0 token) → tree-sitter 签名(0 token) → 只在最后那 5% 的内容上花模型调用。
  3. 重要性排序是成败手。git 改动频率与引用图 PageRank 是两个最有效、也最容易拿到的信号。
  4. map 阶段:沿语义边界切、带全局头(顺便吃满缓存)、结构化输出、要求 evidence、并行且用子 Agent 隔离。
  5. reduce 阶段要克制——“薄概览 + 不再压缩的模块索引”这个两层结构,通常优于深层归并。
  6. 产物必须带 commit 戳并支持增量更新,否则它迟早从资产变成负债。

回到序章那个比方:如果说前二十篇是在把一块芯片焊成一台机器,那这一篇就是第一次让这台机器去干一件真正复杂的活。而你会发现,整件事里最难的部分——排除生成物、按 git 热度排序、按语义边界切块、把不变的放在 prompt 前面——没有一处发生在模型内部。

模型只负责最后那几次调用里的理解。而理解的质量,几乎完全由你在此之前替它做的那些取舍决定。


本篇对应代码:本篇是综合运用篇,用到的部件(工具抽象、并发执行、子 Agent、缓存)在前面各篇都已落地,配套仓库无新增 tag——从 git checkout article-20 出发,照着上面六个阶段就能把这条流水线搭起来。

本页目录