21 · 实战:LLM 是怎么总结一个代码仓库的
一个中等仓库有几百万 token,窗口只有几十万——“总结仓库”本质是采样与预算分配问题,不是“读完再总结”。本篇把这条流水线的六个阶段逐个拆开:git 边界裁剪、确定性骨架采集、tree-sitter 符号 map、重要性排序、分治总结与归并、验证与失效管理,并给出七个常见失败模式。
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)解析出每个文件的顶层符号:
// 原文件 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 在网络抖动下不中断的关键”。
┌──────────────────────────────────┐
│ [全局头] 项目名片(阶段 1 产出) │ ← N 次调用里逐字节相同
│ [全局头] 目录树骨架 │ 放最前面,整体命中缓存
├──────────────────────────────────┤
│ [本块] src/retry/ 的完整源码 │ ← 只有这部分在变
├──────────────────────────────────┤
│ [指令] 按固定 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 打转,说项目是做序列化的 |
| 3 | README 迷信 | 把营销文案或过期快照当架构事实 |
| 4 | 无依据幻觉 | 补全出一个“看起来很对”的架构,八九不离十 |
| 5 | 深层归并 | 深处的关键模块在最终摘要里消失 |
| 6 | 无失效机制 | 三个月前的摘要,自信地误导 |
| 7 | 切在语义边界外 | 按 token 数硬切,一个模块被劈成两半 |
这七条里,1、2、6 占了实际问题的绝大多数——而它们全都不是模型能力问题,全是流水线的工程问题。
六、小结
- 仓库总结的本质是采样与预算分配,不是“读完再总结”。想清楚这一点,后面所有决策都有了依据。
- 流水线的组织原则:越便宜的手段越早用——git 裁剪(0 token) → 确定性骨架(0 token) → tree-sitter 签名(0 token) → 只在最后那 5% 的内容上花模型调用。
- 重要性排序是成败手。
git改动频率与引用图 PageRank 是两个最有效、也最容易拿到的信号。 - map 阶段:沿语义边界切、带全局头(顺便吃满缓存)、结构化输出、要求 evidence、并行且用子 Agent 隔离。
- reduce 阶段要克制——“薄概览 + 不再压缩的模块索引”这个两层结构,通常优于深层归并。
- 产物必须带 commit 戳并支持增量更新,否则它迟早从资产变成负债。
回到序章那个比方:如果说前二十篇是在把一块芯片焊成一台机器,那这一篇就是第一次让这台机器去干一件真正复杂的活。而你会发现,整件事里最难的部分——排除生成物、按 git 热度排序、按语义边界切块、把不变的放在 prompt 前面——没有一处发生在模型内部。
模型只负责最后那几次调用里的理解。而理解的质量,几乎完全由你在此之前替它做的那些取舍决定。
本篇对应代码:本篇是综合运用篇,用到的部件(工具抽象、并发执行、子 Agent、缓存)在前面各篇都已落地,配套仓库无新增 tag——从 git checkout article-20 出发,照着上面六个阶段就能把这条流水线搭起来。