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

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

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-08-02
- 原文：https://daiw.org/manual/llm-to-agent/repo-summarization
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

正文二十篇已经收官。这一篇是**实战番外**：不再引入新部件，而是把前面焊上的零件——[工具](https://daiw.org/manual/llm-to-agent/tool-abstraction)、[并发](https://daiw.org/manual/llm-to-agent/streaming-tool-execution)、[子 Agent](https://daiw.org/manual/llm-to-agent/subagents)、[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)、[压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)——组装起来，解决一个具体而普遍的问题：

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

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

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

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

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

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

更关键的是——**就算塞得下，也不该塞**：

- **成本**：几百万 token 的输入，而且因为 LLM [无状态](https://daiw.org/manual/llm-to-agent/raw-llm-api)，每一轮都要**整个重发**。
- **注意力稀释**：把关键信息埋进几百万 token 的样板代码里，模型的检索准确率会显著下降。信噪比比绝对信息量更重要。
- **缓存全废**：一个巨大的前缀，任何一处改动都会让[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)整体失效。

所以，问题必须重新表述：

<Callout type="info">
  **不是“怎么把仓库读完再总结”，而是“在注定读不完的前提下，怎么让模型建立一张尽可能准确的地图”。**

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

## 二、三条根本不同的路子

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

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

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

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

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

## 三、完整流程：六个阶段

```text
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，别用文件系统遍历。**

```bash
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 的 | 多半是生成的或数据 |

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**④ 文档与入口**

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

<Callout type="warn">
  **不要迷信 README。** README 常常是**营销文案**，或者是**半年前的快照**，与代码实际结构脱节是常态而非例外。

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

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

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

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

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

```ts title="从 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
```

<Callout type="info">
  **这一步的压缩比通常在 10:1 到 50:1 之间，而且丢掉的恰好是最不该进摘要的东西。**

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

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

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

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

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

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

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

然后**按预算分层分配**：

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

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

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

### 阶段 4：分治总结(map)

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

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

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

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

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

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

<Callout type="info">
  **这个结构对 [prompt 缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)极其友好。** 全局头在所有 N 次调用里逐字节相同，放在最前面就能整体命中——N 个模块并行跑，这块前缀只真正计算一次。

  **把不变的放前面、把变化的放后面**，是所有 map 阶段的通用优化，也是[第 12 篇](https://daiw.org/manual/llm-to-agent/prompt-caching)那条“字节稳定性”红线在这里的直接应用。
</Callout>

**③ 强制结构化输出。**

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

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

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

**④ 并行，并且隔离。**

N 个模块之间没有依赖，天然可以并发([第 06 篇](https://daiw.org/manual/llm-to-agent/streaming-tool-execution))。更进一步，用[子 Agent](https://daiw.org/manual/llm-to-agent/subagents) 跑每一块——这样某个模块需要额外读五个文件才看得懂时，那五份原文留在**子上下文**里，主线程只收到一份结构化摘要。

<Callout type="info">
  **仓库总结是子 Agent 最典型的应用场景**——[第 18 篇](https://daiw.org/manual/llm-to-agent/subagents)给的三条判据(探索量大、结论自足、可并行)在这里全中，几乎是为它量身定做的。
</Callout>

### 阶段 5：归并(reduce)

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

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

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

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

<Callout type="warn">
  **归并层数越多，损失越大，而且损失是不均匀的。** 一份摘要每被归并一次就被压缩一次；被归并两次的模块，细节存活率大致是单次的平方。

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

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

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

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

### 阶段 6：验证与失效管理

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

**① 验证：让结论可证伪。**

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

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

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

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

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

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

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

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

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

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

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

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

## 四、两条路线，其实都要

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

<Callout type="info">
  **真实的 Coding Agent 两个都用，分工明确：** 一份薄的全局摘要**常驻上下文**(几千 token，回答“我在哪”)，具体问题靠[工具](https://daiw.org/manual/llm-to-agent/tool-abstraction)现场去查(回答“这个怎么实现的”)。

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

## 五、七个常见失败模式

| # | 失败模式 | 症状 |
| --- | --- | --- |
| 1 | **均匀采样** | 每个文件读前 N 行，收获 800 份 import 语句 |
| 2 | **生成物污染** | 摘要围着 `.pb.go` 打转，说项目是做序列化的 |
| 3 | **README 迷信** | 把营销文案或过期快照当架构事实 |
| 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 戳并支持增量更新**，否则它迟早从资产变成负债。

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

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

---

**本篇对应代码**：本篇是**综合运用篇**，用到的部件([工具抽象](https://daiw.org/manual/llm-to-agent/tool-abstraction)、[并发执行](https://daiw.org/manual/llm-to-agent/streaming-tool-execution)、[子 Agent](https://daiw.org/manual/llm-to-agent/subagents)、[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching))在前面各篇都已落地，配套仓库无新增 tag——从 `git checkout article-20` 出发，照着上面六个阶段就能把这条流水线搭起来。
