让 Agent 改代码前,先让它学会读仓库:符号索引、调用图和补丁验证
让模型写一段函数不难,让它在一个真实仓库里找到该改哪里、改完不破坏别的地方,才是代码 Agent 的难点。我最早做代码 Agent 时,犯的错误很直接:把文件搜索工具交给模型,然后期待它像熟悉项目的维护者一样行动。
结果很不稳定。它有时能精准找到文件,有时会在相似名字之间乱跳;有时只改实现不改测试;有时读了 README 就开始下结论;更糟的是,它能给出一段看起来合理的解释,但 diff 根本没碰到真正的调用路径。
后来我把目标改小:先不追求“自动完成复杂需求”,只做一个能修小 bug、补小测试、解释证据的 Code Agent。为此我先给它做仓库地图。没有地图,Agent 不是在读仓库,而是在迷宫里碰运气。
把整个仓库塞进上下文不是理解仓库
小项目可以把几个文件全放进 prompt。项目稍微大一点,这个办法立刻失效。上下文窗口不够,费用高,噪声多,模型还容易被无关文件带偏。
代码仓库和普通文档不一样。文档主要按语义阅读,代码还要看结构:
文件和目录
导出符号
函数定义
调用关系
类型定义
测试覆盖
配置入口
构建脚本一个 bug 可能表现在 UI 组件,根因在数据转换函数,测试在另一个目录。只做全文搜索,能搜到词,不一定能搜到关系。
所以我给 Code Agent 的第一层不是聊天上下文,而是 repo map。它像一张索引表,告诉模型这个仓库有哪些模块、重要入口在哪里、符号之间大致怎么连。
仓库地图先粗后细,避免一开始读爆上下文
仓库地图不用包含每一行代码。第一版只需要回答几个问题:
- 项目使用什么语言和框架
- 主要目录各自负责什么
- 入口文件和路由在哪里
- 测试目录和测试命令是什么
- 常见配置文件在哪里
- 哪些文件最近改动或与任务关键词相关
我会让工具先生成一个概要:
src/pages/ Astro routes
src/components/ UI components
src/lib/ shared domain logic
src/utils/ formatting, date and slug helpers
tests/ Vitest tests
scripts/ maintenance scripts这类信息不需要模型逐个猜。程序可以通过文件名、扩展名、package.json、配置文件和目录结构生成初稿。模型再根据任务选择要深入的区域。
粗地图的价值是控制注意力。用户说“归档页标签排序有问题”,Agent 不应该先读主题 CSS 或图片上传 API。它应该从路由、归档 view model、标签工具函数和相关测试开始。
符号索引比文件名搜索更接近代码理解
很多代码问题不是文件名能搜到的。用户说“slug 校验有问题”,相关函数可能叫 ESSAY_PUBLIC_SLUG_RE、normalizeSlug 或 parseEssayDateInput。如果只搜自然语言,很容易漏。
符号索引至少保存:
{
"name": "parseEssayDateInput",
"kind": "function",
"file": "src/utils/date-only.ts",
"exported": true,
"imports": [],
"references": [
"src/content.config.ts",
"tests/date-only.test.ts"
]
}实现可以从简单开始。TypeScript 项目可以用 tsserver、AST 解析,或者先用正则加 rg 做近似索引。近似索引不完美,但比纯文件搜索强。
有了符号索引,Agent 可以先问:“这个任务涉及哪些符号?”再读相关定义和引用。它不需要一开始读完整文件,先读函数签名、导出关系和测试引用,通常就能缩小范围。
这里有一个取舍。索引越精确,构建成本越高;索引越简单,误报和漏报越多。我倾向于先做可解释的简单索引,再用评测样例判断是否值得引入更复杂的语言服务器。
调用图帮助 Agent 区分入口、实现和影响范围
代码修改最怕只看局部。一个函数改起来很简单,但它可能被多个页面、脚本和测试调用。Agent 如果不知道影响范围,就容易修一个场景坏另一个场景。
调用图不一定要完整。对小任务,近似关系也有用:
src/pages/archive/[...page].astro
-> getArchivePageData()
-> listEssays()
-> groupByTag()
-> normalizeTag()当用户报告“归档页标签链接不对”,Agent 看到这条链,就知道应该读页面、数据组装函数和标签规范化函数,而不是只改页面模板。
调用图还能帮助选择测试。改了 normalizeTag,只跑页面截图可能不够,应该跑标签和归档相关单元测试。改了一个底层日期函数,影响面更大,测试范围也要扩大。
我不会假装调用图能解决所有问题。动态导入、框架约定、字符串路由和运行时注册都会让静态分析漏掉关系。但它能提供一个比“模型凭经验猜”更好的起点。
检索代码要混合三种信号
我给 Code Agent 的检索不是单一路径,而是混合三种信号:
文本信号:
关键词、错误信息、函数名、文件路径
结构信号:
符号定义、引用、导入、调用链
历史信号:
最近修改、相关测试、失败日志、评测样例错误日志里的精确字符串应该优先走文本搜索。自然语言需求可以先做语义检索和目录分类。函数名、类型名和导出名应该走符号索引。测试失败栈则直接指向文件和行号。
我以前让模型自己决定搜什么,结果它常常用很宽泛的关键词,比如 tag、date、config,返回一堆无关结果。后来我让检索工具返回分组证据:
{
"exact_matches": [...],
"symbol_matches": [...],
"semantic_matches": [...],
"recent_related_files": [...]
}模型不再只看到一串平铺结果,而是知道结果来自哪类信号。精确错误匹配通常比语义相似更可信,最近修改可以作为怀疑点,但不能单独当证据。
读文件要按窗口读,不要一次吞完整文件
很多源码文件几百行甚至上千行。Agent 一次读取全文,既贵又容易丢重点。我给读文件工具加了窗口参数:
{
"path": "src/utils/date-only.ts",
"start_line": 40,
"end_line": 120
}符号索引可以告诉它函数大概在哪几行,测试失败栈也能告诉它附近位置。先读相关窗口,再根据上下文向上或向下扩展。
窗口读取还有一个好处:最终解释可以引用更准确的位置。Agent 说“我修改了 date-only.ts 里的解析逻辑”,不如说“我修改了 parseEssayDateInput,它被 content schema 和 date-only 测试覆盖”。
但窗口读取也有风险。函数外的类型定义、常量和调用方约束可能被漏掉。所以我给工具加了“周边摘要”:读取窗口时同时返回 imports、导出符号和同文件函数列表。模型知道当前片段不是全部文件,就不容易过早下结论。
生成补丁前,先写修改假设
我不让 Code Agent 直接从需求跳到 diff。它需要先写一个修改假设:
问题:
归档页 tag slug 没有使用统一规则,导致中文标签路径不一致。
证据:
1. archive page 使用原始 tag 构造链接。
2. slug-rules.ts 已有公开 slug 规则。
3. tests/tag-filter.test.ts 没覆盖中文 tag。
计划:
1. 在 view model 层统一 normalize。
2. 增加中文 tag regression test。
3. 跑相关测试和 build check。这个假设不是给用户看的长篇解释,而是给运行时和评测用的中间产物。它能被检查:证据是否来自 trace,计划是否涉及允许文件,是否需要新增测试。
如果假设写不出来,说明 Agent 还没读够证据。此时直接生成补丁,大概率是在猜。
我也会把假设和最终 diff 对比。最后改动如果偏离假设,比如原计划改 view model,实际却改了路由生成,系统应该要求解释计划修订。这样可以减少“写着写着换方向”的问题。
AST 级别的编辑比整段重写更安全
模型很喜欢重写整段代码。它觉得这样更完整,但对仓库来说风险更大。一个函数本来只需要改一行条件,模型可能顺手改变量名、调整格式、重排 import,最后 diff 很大,review 成本也大。
我开始把编辑工具分成两类。
第一类是文本补丁,适合 Markdown、配置、小范围替换。
第二类是结构化编辑,适合代码:
替换某个函数体
给某个对象 schema 增加字段
给某个测试文件追加 case
修改某个 import结构化编辑需要 AST 或至少需要符号定位。它比直接让模型输出完整文件麻烦,但能减少无关改动。
例如新增一个测试,不应该让模型重写整个测试文件。工具可以提供 append_test_case(file, describe_block, code),先定位目标 describe,再插入 case。模型负责生成测试内容,工具负责插入位置。
这也方便验证。工具知道它改了哪个符号、哪几行,最终报告可以精确说明。整文件重写则很难区分哪些改动是必要的,哪些只是模型风格。
当然,AST 编辑不适合所有语言和场景。小项目里先用 unified diff 也可以。但我会在评测中关注 diff 大小和无关改动。只要发现模型经常重写太多代码,就说明需要更窄的编辑工具。
补丁应用要可回滚,不能直接改坏工作区
代码 Agent 一定会生成错误补丁。关键是错误补丁不能污染工作区。
我给它的补丁流程是:
生成 unified diff
│
▼
检查路径是否允许
│
▼
应用到临时工作树或 patch sandbox
│
▼
运行格式、类型和测试
│
├── 通过 -> 生成待确认 diff
└── 失败 -> 回滚并把错误作为观察返回如果直接在真实工作区上边试边改,几轮失败后很难知道哪些改动属于当前候选,哪些是用户原有修改。尤其在已有 dirty worktree 时,Agent 必须保护用户改动。
临时工作树可以用 git worktree、复制目录,或者更轻量的 patch 应用层。实现方式取决于项目大小。原则是一样的:候选补丁先验证,再合并到真实工作区。
补丁还要检查路径。一个修博客的 Agent 不应该改 .env、package-lock.json 或无关配置。写权限来自工具沙箱,不来自模型声称“这个文件需要改”。
依赖和环境问题不能让 Agent 自己硬猜
代码 Agent 经常会遇到环境问题:依赖没安装、Node 版本不对、测试需要环境变量、某个服务没启动。模型很容易把这些问题误判成代码 bug。
我给环境检查单独做了节点:
读取 package.json 和 lockfile
确认 Node 或 Python 版本
确认测试命令
运行最小 smoke command
记录缺失依赖或环境变量如果环境不可用,Agent 应该停止或请求授权,而不是继续改代码。比如 npm test 因为依赖没装失败,不能说明源码有问题。它需要报告:
当前无法验证补丁,因为 node_modules 不存在。
需要运行 npm ci 才能继续。
该操作会写入依赖目录并访问 registry,需要确认。这又回到工具沙箱。安装依赖是写操作和网络操作,不能伪装成普通测试。
环境状态也要进最终报告。一个补丁如果没跑测试,不应该写“已验证”。应该明确“只做了静态检查,没有运行测试”。对代码 Agent 来说,诚实说明未验证,比假装完成更重要。
测试选择要根据影响范围,而不是永远跑全量
全量测试最安全,但慢。小项目可以每次跑 npm run verify,项目大了就不现实。
我让 Agent 先选择相关测试:
改了 src/utils/date-only.ts
-> 跑 tests/date-only.test.ts
-> 跑依赖 content schema 的 smoke check
改了 src/components/TagFilterHeader.astro
-> 跑相关 view model 测试
-> 跑 astro check选择依据来自符号引用、文件路径约定和测试命名。运行相关测试通过后,再根据风险决定是否跑更大的检查。比如改底层工具函数或 schema,就应该跑 npm run check 或完整 verify。
这里的难点是测试选择也会错。Agent 可能漏掉某个间接影响。所以评测里要记录:改动文件、选择测试、未运行测试。人工 review 时能看到它为什么认为这些测试足够。
我不会让 Agent 用“相关测试通过”冒充“全项目安全”。最终报告要明确:
已运行:
tests/date-only.test.ts
npm run check
未运行:
npm run build
原因:
本次只修改解析函数和 schema 检查,未改页面渲染。这个说明让风险透明。需要更高信心时,再跑完整构建。
代码解释必须绑定 diff,而不是复述需求
很多模型生成的“修改说明”其实没有说明修改,只是在复述用户需求:
本次修改修复了标签 slug 不一致的问题,并提升了稳定性。这句话没有 review 价值。好的说明应该绑定 diff:
本次改动把归档页 tag 链接从原始 tag 改为 normalizeTagSlug 的结果。
原因是 content schema 中已有公开 slug 规则,直接拼原始中文 tag 会导致链接与 tag 页面生成规则不一致。
新增测试覆盖中文 tag,旧实现会生成未规范化路径,新实现通过。我会让 Agent 最终报告包含四块:
改了什么文件
为什么改这些文件
怎样验证
还没验证什么每一块都必须引用 diff、trace 或测试输出。不能写“提升稳定性”“优化逻辑”这种没有证据的词。
解释绑定 diff 还有助于发现幻觉。Agent 如果说“新增了测试”,但 diff 里没有测试文件,报告检查就能发现。它如果说“运行了 build”,但 trace 里没有构建命令,也应该判失败。
代码 Agent 的评测不看解释,先看补丁
模型解释很容易写得像对的。代码 Agent 的核心评测应该看补丁是否真的修复问题。
我给 Code Agent 准备的 eval case 包括:
输入:
一段 bug 描述或失败测试
仓库 fixture:
包含已知 bug 的小项目
预期:
补丁应用后目标测试通过
禁止修改无关文件
新增或更新回归测试
最终解释引用实际 diff 和测试结果评分分几层:
compile_pass:
项目是否还能构建
test_pass:
目标测试是否通过
regression_added:
是否补了能失败于旧代码、通过于新代码的测试
diff_minimal:
是否只改相关文件
explanation_grounded:
解释是否基于实际 diff 和测试输出解释放在最后。没有通过测试的漂亮解释,不值钱。通过测试但解释不准确,也需要修,因为用户会根据解释 review 代码。
评测 fixture 要保留真实项目的脏边角
如果 eval 仓库太干净,Code Agent 会显得比实际强很多。真实项目里会有历史命名、重复工具、旧测试、边界配置和不一致风格。
我会故意在 fixture 里保留一些干扰:
相似但无关的函数名
旧实现和新实现同时存在
测试文件命名不完全统一
配置里有默认值覆盖
README 里有过期说明这些不是为了刁难模型,而是为了模拟真实仓库。Agent 需要学会区分证据可信度。README 说某功能在 A 文件,实际调用链在 B 文件,应该相信代码和测试,而不是旧文档。
评测 fixture 还要保存失败前后的测试。最好的 case 是旧代码目标测试失败,新代码目标测试通过。如果没有这样的验证,Agent 很容易给出看似合理但不可证明的补丁。
我也会记录“允许的最小 diff”。不是要求 Agent 输出完全相同补丁,而是检查它是否改了无关文件,是否绕过测试,是否引入大范围重构。真实项目里,一个小 bug 修成大重构,风险通常不划算。
我踩过的坑:Agent 喜欢修症状,不喜欢找根因
有一次测试失败是因为日期解析函数对时区处理不一致。Agent 的第一版补丁是在测试里放宽断言。测试过了,问题没修。
这类“修测试不修代码”的行为很常见。模型看到失败断言,最容易修改断言。对它来说,这也是让红变绿的路径。
我后来加了几条约束:
- 如果修改测试,必须说明旧测试为什么错误或为什么需要新增覆盖
- 对 bug fix,优先修改实现,测试改动应体现回归覆盖
- 不能删除断言来通过测试
- 新增测试应在旧实现上失败,在新实现上通过
评测里也要检查 diff。只看测试结果,很容易让 Agent 学会钻空子。代码 Agent 的目标不是让 CI 绿,而是以合理补丁让 CI 绿。
人类 review 入口要保留在 diff 之前,而不是失败之后
自动改代码最容易让人不放心。我的处理方式不是让 Agent 少做,而是让它在关键点展示证据。
在生成最终补丁前,它应该能展示:
相关文件列表
调用链摘要
修改假设
计划修改范围
预计运行测试
风险点用户如果发现方向错了,可以在写文件前纠正。比如 Agent 认为问题在前端组件,但用户知道最近改的是后端 API,就可以让它调整检索方向。
这比最后拿到一个大 diff 再 review 更有效。最后 review 当然也需要,但那时 Agent 已经花了大量步骤,错误方向的成本更高。
我把这种交互看成 Code Agent 的一部分,而不是产品 UI 附属。好的 Code Agent 不是闷头写完,而是把自己的证据链暴露出来,让人能在低成本阶段介入。
代码仓库 Agent 最重要的能力是承认范围不足
不是每个代码任务都适合自动完成。需求太大、测试缺失、项目无法安装依赖、用户工作区已有大量修改,这些都应该让 Agent 降级。
一个可靠的回答可以是:
我定位到可能相关的三个文件,但没有找到覆盖该路径的测试。
当前环境无法安装依赖,所以不能验证补丁。
我可以给出候选修改和建议测试,但不建议自动应用。这比硬改一版安全得多。
Code Agent 的价值不只是自动写代码。它可以读仓库、缩小范围、解释调用链、生成候选补丁、选择测试、记录风险。自动应用只是最后一步,而且应该建立在证据足够、测试可运行、工作区可回滚的前提上。
从会写代码到会改项目,中间差了一套工程系统
这篇文章往上走了一层。前面的运行时、沙箱、Planner 和评测台都在这里用上了。
仓库地图和符号索引让 Agent 知道该读哪里。Planner 把任务拆成定位、假设、补丁、验证。工具沙箱限制读写范围。补丁沙箱保护工作区。评测台用真实 fixture 检查它是否修对问题。
这套东西不如“模型一键修 bug”听起来酷,但更接近真实可用的 Code Agent。
我的结论是:模型会写代码,只是起点。要让它改项目,必须给它仓库结构、影响分析、验证路径和回滚机制。否则它只是一个很会说话的补丁生成器,偶尔命中,偶尔把项目改得更乱。
下一步如果继续往上做,我会关注多个 Agent 或多个角色怎样协作:一个负责读仓库,一个负责写补丁,一个负责评审和测试。但在那之前,单个 Code Agent 必须先学会在一个仓库里站稳。