一、先把问题说准:让 Agent 改仓库,难的不是写代码
现在几乎所有前沿代码模型都能在几十秒内写出一个能跑的单文件函数,也能读懂一段贴进对话框的代码。真正在工程里卡住的是另一件事:给它一个真实仓库和一个跨文件的需求,让它改完之后仓库还能构建、测试还能通过、同事 review 时看不出它在偷懒。这两件事的难度差着量级。瓶颈不在生成能力,而在信息可得性与改动的爆炸半径。
把真实仓库上的失败日志归类,大致能收敛成六类,而这六类里只有一类真的是「模型不会写代码」。
第一类是看不见。模型不知道某个函数在哪里被调用、不知道一个接口有几个实现、不知道某个配置键被谁读走。它基于提示里出现过的片段做局部推理,把没看到的东西当成不存在。这类失败占比最高,而且在几千到几万行的中等规模仓库上就已经开始出现。
第二类是看不见自己。模型不知道自己读到的代码是不是完整的、有没有被截断、有没有读的是构建产物而不是源文件。它在残缺的上下文上做出「很自信」的判断。这类失败最阴险,因为它不会立刻报错,往往在三小时后的某个测试里才炸。
第三类是改太多。需求是「加一个字段」,模型顺手重排 import、改了日志措辞、调整了函数顺序、在十几个文件里留下风格变化。review 的人看不出逻辑对错,只看到 diff 有四百行,于是选择重写。这不是能力问题,是目标函数问题——系统没有把「最小」编码进约束或奖励里。
第四类是改漏。加了一个参数,忘了更新类型定义、序列化层、文档字符串和另一个模块的调用点。强类型语言里表现为编译错误,动态语言里更隐蔽,通常到运行时才炸。
第五类是验证缺失。模型改完了没跑测试就宣布完成;或者跑了全量测试看到几个红灯,判断成「本来就有问题」。前者让错误流向主干,后者让 Agent 学会忽略信号——这是最坏的一种学习。
第六类是破坏性操作。批量删除看起来是死代码的文件、顺手升级了一个依赖版本、改了一个公开函数的签名。这些动作在 Agent 眼里都是局部改动,在维护者眼里是不可逆事件。
这六类里,第一、二类属于信息层,第三、四类属于目标层,第五类属于反馈层,第六类属于权限层。后面的整条链路——索引、预算、改动集、补丁、验证、闸门、自检——本质上就是把这四层问题逐个接住。这也解释了为什么「换一个更强的模型」能提升但不能根治:模型再强,也无法凭空知道它没有被允许看到的东西。
这里值得补一句现实观察:模型能力的提升主要改善了第一类问题里最浅的那一层——写出正确函数体。而第二类问题(不知道自己没看全)和第六类问题(不知道自己动了什么)属于元认知缺口,它不由参数量或训练数据决定,而由运行时的约束决定。同一个模型,在一个给它完整符号信息、明确改动白名单、可执行验证和不可逆操作闸门的环境里,表现远好于一个只给它一个文件编辑框的环境——差的那部分不是模型能力差,而是它没有被告知自己不知道什么。
1.1 为什么纯文本 grep 不够:名字不等于结构
在动手做 AST 索引之前,值得先讲清楚纯文本检索的失效点,因为这决定了索引层必须额外提供哪些能力。最朴素的方案是全仓库搜索符号名。它在很多场景下确实有效,也应该保留为索引之上的第一层召回——它便宜、鲁棒、对注释和文档友好。但它有六类系统性缺陷:
- 同名合并。
get、run、Handler这类名字在一个仓库里出现几百次,全文搜索返回的排序由匹配质量决定,而语义相关性和名字相似度毫无关系。你得到的是噪声排序,不是相关排序。 - 重载与遮蔽。同一文件里三个
send方法,调用点self.send(x)到底指向哪个,取决于静态类型与参数个数。文本搜索给你三个候选,模型必须自己猜。 - 别名与再导出。
from .handlers import send as dispatch之后,dispatch这个名字在全仓库的 grep 里一次都不会出现,但它是真实调用点。反过来,公开入口send被重导出到包级api/__init__.py,你在业务代码里 grepapi.send只看到一行转发。 - 动态构造。注册表模式、插件加载、依赖注入容器、字符串形式的模板名、装饰器改写行为——这些调用点语法上完全合法,文本搜索和朴素 AST 抽取都可能失手。
- 跨语言与生成物。IDL 定义生成客户端、前端 schema 生成类型、protobuf 生成桩代码、模板生成配置。这些「派生代码」和源文件的映射关系不在任何语言服务器里,只能靠构建规则反推。
- 定义与使用的距离。改一个被 200 处引用的常量,文本搜索能告诉你有 200 个匹配,但只有调用图能告诉你哪些路径真的可达、哪些是死代码、哪些只在测试里出现。
结论不是「用 AST 替代 grep」,而是grep 负责召回,AST 负责过滤和归一:先用便宜的方式把候选拉进来,再用结构把「同名不同物」折叠掉,把别名还原成规范名,把不可达的边降权。两者是级联关系,不是替代关系。这个判断在实践中反复被验证:任何试图跳过全文检索、纯靠索引定位的方案,在真实的、有历史包袱的仓库里召回率都会明显偏低,因为总有索引没覆盖到的边(字符串拼接、动态导入、模板)。
二、仓库索引:符号表、调用图与依赖关系
仓库索引要回答三类问题,而且三类问题的查询模式和更新频率都不一样,把它们塞进一套索引里通常会两头不讨好。
| 索引层 | 回答的问题 | 典型查询 | 更新成本 |
|---|---|---|---|
| 词法层(全文检索) | 哪些地方提到过这个名字 / 这段文本 | 标识符、字面量、注释、错误码 | 低,增量友好 |
| 符号层(AST) | 它是什么定义在哪、有哪些成员 | 定义查询、成员列举、类型签名 | 中,按文件解析 |
| 关系层(调用图) | 谁依赖它、改它会影响谁 | 反向引用、影响面、可达性 | 高,跨文件消歧 |
把三层放在一起看,还有一个经常被忽略的第四类信息:构建与测试的结构。哪个测试文件覆盖哪个源文件、哪条命令会触发重新编译、这个包在 CI 里的 job 名称。它不属于静态分析,却直接决定第六节里「最小验证集」能不能构造出来。索引如果不含这部分,验证阶段就得退化成全量跑测试。
2.1 用 tree-sitter 拿到语法级的确定性
2026 年的主流做法是:语言解析层统一走 tree-sitter,而不是为每种语言维护一套正则或手写解析器。理由很实际——tree-sitter 生成的语法树是增量的、容错的、无需完整类型检查就能出结果,而且同一套遍历代码可以在所有语言上复用。对智能体系统来说,「容错」这个属性比什么都重要:仓库里总有正在编辑中的、有语法错误的、或者由生成器写出但尚未格式化的文件,解析器绝不能因为一个文件崩掉而让整次索引失败。
下面这段是符号与调用边抽取的一个最小骨架,展示从「遍历」到「归一化边」的关键几步。真实系统会在此基础上叠加跨文件消歧,但这个结构是通用的。
import tree_sitter
from dataclasses import dataclass
@dataclass
class Symbol:
name: str
kind: str # func / class / method / var / type
file: str
start_byte: int
end_byte: int
parent: str | None # 所属类或模块路径
@dataclass
class Edge:
src_file: str
src_line: int
callee_raw: str # 源码里写出来的名字
dynamic: bool # 是否经过 getattr / 反射 / 模板
LANGUAGE_BY_EXT = {".py": "python", ".ts": "typescript", ".go": "go"}
def extract(path: str, src: bytes) -> tuple[list[Symbol], list[Edge]]:
parser = tree_sitter.Parser(LANGUAGE_BY_EXT.get(path[path.rfind("."):], "python"))
tree = parser.parse(src)
symbols, edges = [], []
def walk(node, scope: str | None):
# 1) 定义:把每个绑定点登记为规范符号
if node.type in ("function_definition", "class_definition",
"method_definition", "type_alias_declaration"):
name_node = node.child_by_field_name("name")
if name_node is not None:
symbols.append(Symbol(
name=src[name_node.start_byte:name_node.end_byte].decode(),
kind=node.type, file=path,
start_byte=node.start_byte, end_byte=node.end_byte,
parent=scope,
))
scope = f"{scope}.{name_node.text.decode()}" if scope else name_node.text.decode()
# 2) 引用:调用、导入、类型标注
if node.type in ("call", "call_expression", "import_from_statement"):
raw = _callee_text(node, src)
if raw:
edges.append(Edge(path, node.start_point[0] + 1, raw,
dynamic=raw.startswith("getattr")))
for child in node.children:
walk(child, scope)
walk(tree.root_node, None)
return symbols, edges
这段代码里有三个值得强调的设计点。第一,dynamic 字段是故意留的:调用边必须区分「静态可解析的边」和「运行时才确定的边」,前者可以放心地用于影响面分析,后者只能作为提示。绝大多数只做正向传播的失败,根因就是这里被忽略了。第二,符号带 parent 作用域链,才能区分同名方法。第三,遍历容错——单文件解析失败只影响该文件的边,不影响整次索引。
2.2 跨文件消歧:把「名字」变成「唯一的身份」
抽出局部符号之后,真正的工程量在消歧。核心手段是规范名(canonical name):把 a.b.c.method、obj.method、self.method、Other().method 都还原成带包路径和接收者类型的稳定标识。消歧要按优先级依次用四种信息:
- 接收者静态类型。语言服务器能直接给,这是最强信号。类型未知时退化为名字匹配。
- 词法作用域。当前文件内的局部绑定优先级最高,其次是类成员、模块全局,最后才是导入名。
- 导入表。从
*.import或from x import y建立的别名映射,反向把y还原成x.y。 - 唯一性兜底。以上都判不了,但全仓库只有一个同名定义时,可以安全地认领;出现多个候选时,标记为歧义边并降权,而不是随便挑一个。
歧义边必须显式建模。一个常见反模式是「取第一个候选」,这会系统性地把改动集算小——因为大量真实的调用点被归到了错误的目标上,于是影响面分析漏掉了它们。更保守但正确的做法是:歧义边既不计入「确定受影响」,也不丢弃,而是进入一个独立的人工确认队列,Agent 在改动涉及它们时主动说明。
2.3 增量化与失效传播
索引最贵的成本是「每次任务重新全量扫一遍仓库」。全量扫描在 1 万行的小项目上勉强可接受,在几十万行上就不可行了,所以增量化是硬需求。失效传播的粒度决定了方案上限:
- 文件级:文件修改时间戳变化 → 重解析该文件 → 重算它的出边 → 与入边表比对,标记受影响符号。实现最简单,但一个被 import 200 次的核心模块改动会引发大范围重算。
- 符号级:对每个符号存一个内容指纹(规范化后的 token 序列哈希,忽略空白和注释)。只有指纹变化的符号才重新向外传播。多花一点索引构建时间,换来传播精度的大幅提升,在改动频繁的场景里更划算。
- 边级:增量维护邻接表,删除旧边、加入新边。这是精度最高的方案,但对跨文件消歧的处理最复杂,因为一个导入变更可能同时影响很多条边的解析结果。
实践中常见的选择是符号级指纹 + 文件级重解析的混合方案:解析是文件级的天然粒度,指纹是符号级的精度,两者的成本都在可控范围。还要注意一个非技术约束:索引必须能在沙箱里增量重建。如果索引依赖 IDE 状态或者正在运行的编译守护进程,它就无法在无头容器里工作,整个自动化链路就断了。
2.4 索引之上:查询接口该长什么样
很多索引项目失败在「建得很漂亮但没有清晰的查询接口」。索引不是给搜索引擎用的,它是给一个受限决策器用的。接口应该围绕智能体真正要问的几个问题来设计:definitions(symbol)(定义在哪,可能多个)、references(symbol, limit)(谁引用了它,区分静态/歧义)、impact(symbol, depth)(改动它,一跳到两跳的影响面)、members(type)(这个类型有哪些实现,是否有未实现的抽象方法)、test_mapping(source_file)(哪些测试覆盖它)。
其中 impact 是最被低估的一个。它回答的是「如果我改这里,会碰到什么」,而且要能区分同层影响(调用方需要适配)和跨层影响(依赖这个契约的上层模块需要重新验证)。有了它,第四节的「最小改动集」和第六节的「最小验证集」才有依据;没有它,整条链路只能靠模型自由发挥。
二点五、索引的度量:怎么知道索引够不够好
索引是整套链路的基础设施,而基础设施如果没有可观测指标,就会在两个糟糕的方向上漂移:一种是没人知道它已经半坏了,于是所有上层决策都在残缺信息上做;另一种是没人知道它有多大用,于是投入成本失控。索引必须有自己的健康指标,而且这些指标要能落到具体文件、具体符号,而不是只给一个全仓库的总数。
2.5.1 覆盖率比绝对数量重要
最有价值的两个指标是定义覆盖率和边解析率。定义覆盖率回答「这个仓库里有多少绑定点被登记成了符号」,边解析率回答「抽出的引用里有多少被成功消歧成了唯一的规范目标」。这两个数字必须能下钻:如果边解析率是 0.72,剩下的 0.28 在哪里?如果集中在某一个目录,通常意味着那个目录用了本仓库没覆盖的语言特性(模板、宏、条件编译),需要补解析规则;如果分散在各处,则更可能是动态语言的固有歧义,需要靠显式标注而不是继续投入解析。
还有一个容易忽略的度量:生成物占比。很多仓库里生成代码占三分之一以上,如果索引把它们当作普通源文件,调用图会被大量派生边污染,符号重载会让规范名失去唯一性。正确做法是给符号打来源标签(手写、生成、测试、第三方依赖),然后在影响面分析时对不同来源赋予不同的信任度——手写代码的调用方必须改,生成代码的调用方应该改它的生成源。
2.5.2 高风险符号集会暴露真实盲区
与其追求全仓库的平均解析率,不如维护一个高风险符号集:被引用次数最多的前若干个符号、被多个模块导入的公共类型、位于接口层和适配层的抽象。这些符号的索引如果出错,代价是系统性的。更实用的做法是反过来——主动注入扰动:对一批已知正确的调用点,人为在源文件里插入空行和注释,如果索引结果不变才认为可靠;插入一次真实重命名,看有多少条边跟着更新。这是一种廉价的自检,能抓到那些平时不会暴露的缓存失效 bug。
| 指标 | 含义 | 健康信号 | 越界时的动作 |
|---|---|---|---|
| 定义覆盖率 | 已登记绑定点 / 语法分析发现的绑定点 | 主要语言下接近饱和 | 补解析规则,或排除生成目录 |
| 边解析率 | 成功消歧的引用 / 抽出的引用 | 静态语言应很高,动态语言需下钻 | 定位歧义集中的目录分类处理 |
| 陈旧度 | 索引快照与工作区状态的差异文件数 | 任务开始时应为零 | 任务开始前强制增量更新 |
| 增量延迟 | 单文件改动到索引可见的时间 | 远小于单轮测试耗时 | 降级为符号级指纹粒度 |
| 生成物污染度 | 生成目录符号占全仓比例 | 已知且被标注 | 影响面分析时按来源加权 |
2.5.3 索引的边际收益是分级的
并非所有仓库都值得建完整索引。一个务实的分级是:只有符号表(几乎零成本,所有语言都能做,解决「这个函数在哪」);符号表加调用边(解决「谁调用了它」,需要消歧,收益最大);再加类型推断(解决「重载选哪个」,成本陡增);再加构建与测试映射(解决「该跑哪些测试」,需要解析构建系统)。
关键判断是:调用边这一层的投入产出比几乎总是最高的,因为它同时喂给上下文收集、改动集推导、影响面分析和验证集选择四件事,是唯一一个被复用的索引。而类型推断的收益高度依赖语言,在动态语言仓库上经常得不偿失——更划算的做法是把消歧结果显式暴露给模型,让它在两三个候选里读一下决定,而不是为了消歧去跑一个重型类型检查器。
最后一个判断标准是可重建性。如果索引在依赖变更、语言升级、构建系统调整之后无法可靠重建,那它就是负债而不是资产。一个健康的索引系统应该有一条明确命令,能在干净容器里从零建出与线上完全一致的索引——这条命令本身就是最好的验证手段,因为它必须在每次依赖变更时都被真正执行一次。
二点六、多语言与 monorepo:索引最难翻车的地方
单语言、扁平结构的仓库里,索引的正确率相当容易做到可接受。真正让索引翻车的三类结构值得单独讨论,因为它们的共同点是符号身份不唯一,而前面整套消歧逻辑都建立在「一个符号名对应一个定义」的假设上。
2.6.1 路径别名与模块解析
TypeScript/JavaScript 生态里,模块解析规则本身就是一门独立的语言:配置文件里的路径别名、基础路径、包导出字段、条件导出,工作区包与发布包的双重身份,类型声明与实际实现的分离。如果索引不读这些配置,它会把同一个文件算成两个不同模块,会漏掉跨包引用,还会在类型声明存在而实现被重写时给出完全错误的规范名。这类问题在单体仓库里体现为「同一个工具函数在索引里出现了十几个副本」,进而让影响面分析彻底失真。
Python 的情况稍好但有它自己的坑:命名空间包、多层相对导入在重构期留下的绝对导入与相对导入混用、通过字符串动态加入的搜索路径、以及同名的顶层包被误认为标准库。这里的关键判断是:导入语句本身不是边,是待解析的查询。真正的边必须经过一次模块解析才能确定目标文件,直接把导入名当目标会生成大量虚假边。
2.6.2 monorepo 的包边界是语义边界
在单体仓库里,包之间的依赖方向约束往往是真实的架构约束(分层、禁止循环、领域隔离)。如果索引能读出这些约束,影响面分析就能升级为架构分析:改动一个底层包但只被同层包依赖时,可以直接放行;改动被上层包依赖的公共接口时,应当标记为高风险。
更重要的是,包边界决定了验证集。绝大多数构建工具都提供了按包构建或按包测试的能力,把改动限制在包内就能获得巨大的速度收益。前面的「受影响测试」选择之所以能做得又快又准,本质上就是复用这套已有的依赖图——重新造一套调用图来做这件事,既费劲又不如构建系统权威。
所以这里有一个明确的优先级建议:先榨干构建系统已经提供的信息,再考虑自建索引。依赖图、测试映射、类型检查范围、编译产物,这些构建工具早就算准了,而且它们和 CI 保持一致。自己算一套却和构建系统不一致,是很常见也很隐蔽的故障源。
2.6.3 跨语言边界与代码生成
真实仓库里存在大量非语言边界:接口定义语言到多语言客户端的生成、数据库模式到模型类的生成、协议缓冲区到桩代码的生成、前后端共享的模式文件。处理它们的通用原则有三条:区分源头与派生,改派生文件永远不如改源头再重新生成;把生成命令纳入工作流,改完生成源自动跑生成,而不是指望模型记住;验证要验生成结果,重新生成之后如果产物与预期不符,必须在这一步就拦住。
最常见的失败是模型改了生成产物里的一处逻辑,测试也过了,但下一位开发者重新生成时改动全部消失。这类问题在短期完全看不出来,所以把生成命令接进验证回路不是优化项,而是必需项。
三、上下文收集与窗口预算分配
有了索引,下一个问题是:仓库里有几万行相关代码,窗口只有几十万 token,该放什么。这是整条链路上性价比最高的一环——它不需要模型变强,只需要一个好的收集器,就能显著减少漏改和幻觉引用。
3.1 从需求到候选集:种子扩展而不是全仓倾倒
上下文收集的正确起点是种子(seed),而不是「与需求最相关的若干文件」。后者的排序质量受限于检索模型对代码的语义理解能力,而前者可以完全依赖确定性结构。典型流程是:
- 从需求里抽取锚点词:标识符、文件路径、配置键、错误码、路由名。这些通常来自需求描述本身,或者来自用户给的 issue 链接。
- 用锚点在索引里做精确匹配(不是模糊检索),得到一组种子符号。
- 沿调用图做受限扩散:每个种子向外扩一到两跳,边扩展边记分,扩散到的节点越多分越低。
- 把扩散结果按类型分层:定义、实现体、调用点、测试、配置、文档。这六层的价值密度完全不同,后面预算分配直接依赖这个分层。
这里有个关键的设计取舍:扩散的跳数应该是自适应的,而不是固定两跳。固定两跳在解析层(工具、路由、模型)很合适,在叶子节点上会引入大量无关内容。判断依据是节点类型——往上游走的是契约(接口、类型、协议),应该多走;往下游走的是实现细节(叶子函数、工具函数),走一跳就够。
3.2 预算分配:按层分配,而不是按相关性排序截断
把候选集塞进窗口时,常见做法是按检索分数排序然后截断。这个做法的缺陷在于分数分布是长尾的:最前面的几十个片段可能都是同一个函数的定义和它的注释,而真正决定改动正确性的调用点在第 200 位被砍掉。
更稳的做法是分层配额。下面这个清单结构是实际可用的形态,它让收集器、模型和人类 review 看到的是同一份事实:
budget:
total_tokens: 90000
policy: layered_quota # 不用 top-k 截断,按层配额
layers:
signatures: { quota: 0.12, rule: "所有受影响符号的签名与文档串,含未展开的调用者列表" }
definitions: { quota: 0.28, rule: "将要修改的函数/类型的完整实现" }
call_sites: { quota: 0.22, rule: "每个调用点保留 ±3 行窗口 + 接收者静态类型" }
contracts: { quota: 0.15, rule: "上游接口、类型定义、序列化 schema" }
tests: { quota: 0.13, rule: "映射到受影响源文件的现有测试与断言" }
config_history: { quota: 0.10, rule: "相关配置键、依赖声明、最近 5 次改动摘要" }
seeds:
- symbol: PaymentGateway.charge
why: "需求点名要支持新的支付渠道"
confidence: high
ambiguities:
- symbol: notify
reason: "同名定义 7 处,无法静态确定接收者类型"
action: ask_before_edit
几层之间有可调的取舍关系,通常的规律是:当改动涉及跨文件一致性时,contracts 配额要抬(模型必须看到所有需要同步的地方);当改动是纯新增时,tests 配额要抬(现有测试是最可靠的行为规格);当改动是重构时,definitions 配额要抬(行为等价性只能靠读全实现来保证)。
3.3 结构化裁剪:比按 token 砍行更好的做法
窗口不够时,按行截断是最差的策略——它会切在函数中间,切掉的往往是错误处理或者边界判断。真正有效的裁剪有四层,从不丢语义到逐步降级:
- 摘要层:函数体只保留签名 + 文档串 + 前若干行 + return 语句,其余替换成一行
...(省略 N 行,行为未变)...。关键是要显式标注省略了多少行,让模型知道自己没看全——这直接对应第一节说的「看不见自己」。 - 骨架层:用 AST 提取函数签名、类成员列表、分支结构标签(有哪些 if/except 分支),形成一份「结构提纲」。提纲对判断改动影响面极其有用,因为它几乎不占 token 却覆盖了全部控制流。
- 断言层:测试文件只保留测试函数名、fixture、断言表达式,删掉实现细节。断言表达式本身就是最精确的行为规格。
- 片段层:实在要砍时,按「同一文件内的邻近窗口」保留,而不是全局按分数取。代码的语义单元是连续块,跨块切割的伤害大于丢弃。
还有两个容易被忽略的技巧。一是去重:调用图扩散经常把同一个函数通过多条路径重复塞进来,按内容哈希去重能立刻省出可观预算。二是反重复放大:如果一个公共工具函数出现在 30 个候选上下文里,只保留一份完整实现加 30 条「调用者: 文件:行」的引用列表——信息密度高得多。
3.4 上下文排列顺序也影响结果
内容选对之后,放的顺序依然有影响。经验上有几条稳定规律:任务指令放最前,验收标准放最后;定义靠近它要改的位置,不要把所有定义堆在开头、中间夹一堆调用点;相关的片段相邻,把同一符号的片段合并成连续块,让注意力层不需要跨长距离做配对;测试紧跟在它所验证的实现之后,这样模型在读实现时就已经在脑子里建立了行为预期。
更激进的做法是两阶段上下文:第一阶段只给结构提纲 + 签名,让模型做「我需要看哪些实现的完整代码」的判断;第二阶段再按它的请求补充。这本质上把上下文选择变成了一个可观测的推理步骤,比一次性塞满更容易发现遗漏,也更省预算。
三点五、长任务里的上下文衰减与状态固化
单轮任务里上下文是一次性消耗品,但真实工程任务往往持续几十轮甚至跨会话。这时候一个隐蔽的问题出现了:早期的信息会在上下文压缩中逐渐失去具体性。最开始的符号定义可能被摘要成一句「涉及支付网关的代码」,而模型到了第十轮还在基于这句话推理,并做出「我当时看到的实现里没有这个分支」这类错误判断。
3.5.1 压缩会让不确定性静默消失
上下文压缩通常是「保留要点、丢弃细节」,这对叙事性任务是合理的,对代码任务是危险的:细节里恰恰藏着条件判断、边界检查和异常分支。更糟的是,压缩后的摘要在语义上仍然通顺,模型不会意识到自己丢了信息,它只是变得更有信心地基于残缺前提行动。
解决办法是结构化状态与叙事上下文分离:把所有需要长期保真的东西——符号列表、接口签名、已排除方案、未决疑点、验证结果、失败记录——写进结构化存储,按需检索;对话上下文只用来承载当下的推理。这种分工和第五节里「测试只允许新增不允许删除」是同一种思路:凡是必须精确的东西,就不该依赖一个不可靠的通道。
3.5.2 失败记录比成功记录更重要
很多系统在状态固化时只记录「做了什么」,这会导致一个很糟糕的模式:模型在第三轮尝试了方案 A,失败了,失败原因被丢掉;第十轮它又想试方案 A,因为忘记了。这是长任务里最典型也最愚蠢的重复劳动。
失败记录应该包含三部分:尝试了什么、在哪一步失败、排除的原因是什么。第三项最有价值——它把一次具体失败升级成一条可复用的约束。而且失败记录需要压缩成判断而不是日志,「锚点失配,重读后修正即可」比完整的堆栈有用得多。
3.5.3 跨会话的续接
如果任务跨会话,那么恢复现场需要三样东西:任务目标与当前进度(到哪个能力点了)、工作区状态(改了哪些文件、当前是否干净)、未决问题(哪些歧义还没确认)。缺任何一样,续接都会退化成「重新读一遍代码从头判断」,而重新判断很可能得出和之前不同的结论,这比停下来更糟糕。
这里有一个反模式要避免:把恢复过程做成「让新会话自己探索仓库以了解现状」。看起来很聪明,实际上是让模型重新犯一遍之前已经犯过的错误。正确的恢复是读取已有结构化记录并核对与工作区的一致性,只对不一致的部分做重新探索。
3.4.1 跨仓库与跨服务的上下文边界
当改动涉及多个仓库或多个服务时,前面那套「一次建索引、一次收集」的假设就不成立了,因为这些仓库有各自的生命周期、构建系统和发布节奏。处理这类任务的核心不是把索引合并,而是先把依赖关系的主从方向确定下来。
绝大多数跨仓库改动是有方向的:接口定义在上游,实现或调用方在下游。方向一旦搞反,整个任务就会做成「改下游适配上游的新接口」,而正确做法是「先在上游加兼容字段,下游不动,等上游发布后再推进下一阶段」。这和第 5.5 节说的迁移顺序是同一个道理,只是尺度从单个服务扩大到了多个服务。
第二个要点是索引的新鲜度在跨仓库时差异巨大。本地仓库可以即时重建,而上游仓库可能几天没同步。此时正确做法是显式标注「这一段的依据是 N 天前的上游快照」,并且只把上游的契约部分(接口定义、协议、字段声明)放进上下文,不要把它的实现细节也拉进来——那不仅浪费预算,而且会诱导模型去修改它无权修改的部分。
第三个要点是跨仓库的验证很难闭环。下游的测试不可能真正跑到上游去,只能靠契约测试和模拟上游。如果两者都没有,那么这类改动的正确性本质上无法被自动证明,此时系统应当直说「这部分无法自动验证」,并把验证责任显式交回给人——这正是第六节结尾那条原则的延伸。
四、从需求到「最小改动集」
前两节解决「看清楚」,这一节解决「只改该改的」。这里的判断标准很硬:改动集是否可以被一个不了解上下文的人在十分钟内读完并判断对错。读不完,就说明改动集过大。
4.1 先把需求翻译成能力,而不是翻译成代码
需求描述通常以用户语言给出(「下单时如果库存不足要能自动拆单」),直接翻译成代码会跳掉中间一大截。中间这一步是把需求拆成可验证的能力点,每个能力点对应一句可测试的陈述和一组符号:
能力点:库存不足时下单自动拆分为多个子单,每个子单独立扣减库存;任一子单扣减失败则整体回滚。
每个能力点标注它触及的层次:领域逻辑、持久化、接口契约、配置、测试。这一步的价值在于它把「模糊需求」变成了「有限的、可枚举的改动点清单」。清单里没有的东西,就是不该动的东西——这是「最小」得以被执行的依据。
4.2 用影响面反推改动集,而不是让模型自由发挥
拿到能力点和种子符号之后,改动集应当被算法推导出来,而不是由模型直接编。推导规则大致是四类:
- 定义改动:能力点直接落到的函数或类。
- 契约同步:定义改动的签名如果变了,则所有引用该符号且需适配的调用点属于改动集——注意是「需适配的」,经过索引确认存在硬编码旧形态的调用点才纳入,纯参数顺序不变的不用动。
- 行为镜像:同一个逻辑在两处实现(前端与后端、生成代码与手写代码、缓存层与源层),必须同步。这是跨文件一致性里最容易漏的一类。
- 测试补齐:能力点没有对应测试的,新增测试文件或测试函数。已有测试会因此失效的,一并纳入。
推导出来的改动集通常带一个置信度分级:索引直接支持的(高)、靠模式匹配推断的(中)、纯语义猜测的(低)。高置信度的直接改,低置信度的先向用户澄清或先做一步探测读取。这个分级不是为了保守,是为了把模型的注意力花在不确定的地方——注意力预算是有限的,平均用力是最差策略。
4.3 探测式读取:把「读」放在「写」之前
一个反直觉但被反复验证的做法是:改动前必须已经读过要改的那几行,且读到的内容与待写补丁的行上下文一致。这听起来是废话,实际上是大量失败的根因。典型场景是:上下文收集阶段拿到的是 20 分钟前的文件快照(或者被摘要裁剪过的版本),补丁生成阶段按摘要里的印象写,于是锚点根本对不上,补丁应用失败或者错位插入。
工程上的落地方式是行锚点断言:每个补丁块必须携带它所基于的原文片段,工具在应用前做一次精确比对,不一致就拒绝并要求重新读取。这条约束几乎零成本,但把一整类「幻觉修改」变成了硬失败。
4.4 允许 Agent 说「不」
一个成熟的工程链路必须允许三种拒绝:需求本身有歧义(两种合理解释会导致不同实现)、存在明显更小的做法(把新增逻辑做成配置而不是改代码)、需求超出当前仓库能验证的范围(要改的东西没有任何测试覆盖,改了也证明不了对)。第三种最容易被忽略,但它恰恰是防止 Agent 在盲区里大改的关键:如果没有测试、没有调用点、没有契约,改动的正确性就完全无法被评估,此时正确做法是提示风险并要求补测试,而不是先改了再说。
四点五、中间产物:一份可读的改动计划
索引和上下文收集都是机器内部的产物,对人类不可读。但系统和人之间必须有一个共同的交接物,否则每一次需要人工介入的时候,人都要重新读一遍模型脑内的推理,成本极高。这个交接物就是改动计划——它既是给人看的确认清单,也是给后续执行步骤的契约。
4.5.1 计划里应该有什么
一份改动计划至少包含六类信息,每一类都有明确的消费方:
- 能力点及其验收句。从需求拆出来的可测试陈述。它回答「做什么」,是最终覆盖性检查的依据。
- 动作与目标符号。具体到「新增什么函数、修改哪个签名字段、调整哪段分支」,而不是「修改支付模块」。它回答「动哪里」。
- 触发理由。这个动作为什么被列进来——是能力点直接要求,还是契约同步,还是行为镜像。这一栏是可审核性的核心,缺了它,review 就退化成逐行读代码。
- 置信度与来源。由索引推导、由模式推断,还是由语义猜测。决定它是自动执行还是先问一句。
- 验证方式。哪个测试覆盖它、需要新增什么断言、是否有类型检查兜底。它把第六节的验证集提前到改动之前,让验证不再是事后补救。
- 明确不做的事。被识别为候选但被排除的改动,以及排除理由。这一栏最容易被省略,也最有价值。
| 字段 | 回答的问题 | 谁消费 | 缺失的后果 |
|---|---|---|---|
| 能力点 | 需求要实现什么 | 模型、验收方 | 做出来的功能与需求对不上 |
| 目标符号 | 具体改哪里 | 补丁生成 | 改动散落、无法核对 |
| 触发理由 | 为什么非改不可 | 人类 review | review 只能逐行读,效率极低 |
| 置信度 | 索引支持到什么程度 | 调度逻辑 | 平均用力,低置信度处犯错 |
| 验证方式 | 怎么证明它对 | 验证阶段 | 测试全绿但功能没实现 |
| 不做的事 | 哪些被刻意跳过 | 人类介入决策 | 关键决策被静默吞掉 |
4.5.2 三个提问时机
计划真正的价值在于它定义了几个可以低成本打断模型的动作点。我认为只有三处值得打断,其余情况应该让模型自己走完:
其一,需求歧义。同一句需求有两种合理实现且会导向不同的接口设计时(比如「超时后重试」是重试三次还是重试到超时上限)。这种地方猜错的代价是整段工作作废,而且模型不会自知。
其二,存在明显更小的做法。计划里要新增一个抽象层,但仓库里其实有个配置项或钩子能直接解决问题。这时打断的价值极高:省下的不只是工作量,还有新增抽象带来的长期维护成本。
其三,零覆盖改动。要改的符号没有任何测试、没有任何调用点能反查到影响面,说明这部分代码处在系统的盲区。此时正确做法是先说明风险并建议补测试,而不是默默改完交付一份无法验证的补丁。
除这三处之外打断模型都是在制造噪音。特别要警惕的是为了凑进度而提问:把模型自己能判断的细节包装成问题抛给用户,会让系统显得谨慎,实际上是把决策成本转嫁出去。
4.5.3 计划作为多步任务的契约
当任务需要多步(比如先改数据结构、再改序列化、再改调用方、再补测试)时,计划还承担另一个职责:每一步的完成定义和下一步的输入。这一点在长任务里至关重要,因为上下文中途会被压缩,一旦某一步的产出没有落到明确的结构化字段里,它就会在压缩中消失。
实践上的做法是每完成一步就把该步的结论固化:新的符号列表、新的测试名、已排除的方案、未解决的疑点。这些固化下来的状态是后续步骤唯一可靠的记忆来源,比让模型「记住前面做过什么」要可靠得多。同样的道理,如果用多个智能体接力完成一个任务,这份计划就是它们之间唯一的接口——下一个代理不需要读前一个代理的对话,只需要读计划。
五、补丁生成:从「写代码」到「提交改动」
5.1 diff 格式的选择:三种方案的边界
补丁不是越多越好表达,而是越容易被校验和应用越好。三种主流格式的适用边界很清晰:
| 格式 | 优点 | 风险 | 适用 |
|---|---|---|---|
| 全文重写 | 模型最擅长,生成质量最高 | 极易产生无关变动;大文件超限时不可用 | 新建文件、短函数、小文件 |
| Unified Diff | 只表达差异;天然可审阅;可统计改动量 | 行号漂移敏感;跨文件一致性需额外保证 | 通用主力格式 |
| 结构化编辑指令 | 锚点 + 精确行区间,与语义对齐;抗重排 | 实现复杂度高;需工具侧配合 | 有成熟编辑工具的代码库 |
实用的结论是混合策略:新建文件和小文件用全文重写;已有文件优先用 unified diff;但只要编辑工具支持结构化锚点,就优先用结构化编辑,因为它的失败模式是「明确报错」而不是「静默错位」。
5.2 跨文件一致性:多文件补丁必须是一个事务
跨文件改动最大的坑不是生成质量,而是部分应用:补丁 A 改了函数签名,补丁 B 改了调用点,如果 B 因为锚点漂移失败而系统只应用了 A,仓库就进入了一个编译不过的中间态。对 Agent 系统来说,中间态和最终态一样危险,因为后续的验证步骤跑在了一个不一致的仓库上。
解决办法有三条,通常三条一起用:
- 先校验后应用:所有补丁先做锚点比对和语法解析,全部通过才进入应用阶段。任何一块失败则整批回滚。
- 声明跨文件契约:在补丁集合里显式写出「新签名 = X(…, idempotency_key: str)」,并把所有引用它的调用点列为强耦合块。强耦合块要么全部应用成功,要么全部不应用。
- 应用后立即做符号一致性检查:不跑完整测试,只做一次轻量的「定义-调用签名匹配」校验(用索引的符号表对比),几秒内就能发现中间态。
还有一个容易被忽略的一致性问题:同一个改动的多个文件之间,顺序有时会影响内容。典型情况是迁移脚本与 schema、代码生成器与生成产物、常量定义与它的多个使用点。如果改动包含生成类文件,正确做法不是手改生成产物,而是改生成源并重跑生成命令——这一点在 Agent 里必须显式判定,否则会出现「改了 .py 却没跑 protoc/orm 重新生成」的死锁。
5.3 抑制格式化噪声
判断一个 Agent 的补丁质量,最快的指标不是逻辑对错,而是噪声比:改动行里有多少行与需求无关。噪声的来源几乎总是格式:缩进风格变化、行尾空白、import 排序、单双引号、末尾换行、文件编码。每一个都足以让 diff 从 20 行变成 300 行。
工程上的抑制手段按强度排序:
- 用仓库自己的格式化工具回写。最省事的方案是补丁应用后跑一遍
black/gofmt/prettier,但要注意它必须只在被改动的文件上跑,且要检查格式化是否反向改动了未涉及的行(多见于格式化器版本与仓库不一致时)。 - 上下文保持:提示词里明确要求保留现有缩进风格、行宽、引号风格,并给出目标文件的风格样例。效果显著但依赖模型遵守。
- 噪声过滤与还原:应用补丁后,把与补丁无关的行级差异自动还原。这需要一个「原始内容 - 补丁结果」的差异分析,把纯空白差异识别出来并还原。这条最稳,因为它不依赖任何一方自觉。
- diff 体积闸门:设定硬阈值(例如单文件改动超过 200 行或总改动行数超过需求复杂度的合理倍数),触发一次「重新以最小改动集重写」的回路。这是兜底,也是最有效的自检。
unified diff 的一个具体例子,注意锚点上下文必须来自实际文件:
--- a/payments/gateway.py
+++ b/payments/gateway.py
@@ -41,9 +41,11 @@ class PaymentGateway:
def charge(self, order: Order, amount: Decimal,
idempotency_key: str | None = None) -> ChargeResult:
- if amount <= 0:
- raise InvalidAmount(amount)
+ if amount <= 0:
+ raise InvalidAmount(amount)
+ key = idempotency_key or order.id
+ if cached := self._replay_cache.get(key):
+ return cached
with self._unit_of_work() as uow:
receipt = self._provider.authorize(order, amount)
+ self._replay_cache.put(key, receipt)
uow.add(receipt)
return receipt
--- a/payments/api.py
+++ b/payments/api.py
@@ -18,7 +18,7 @@
-def charge(order_id: str, amount: float) -> dict:
+def charge(order_id: str, amount: float, idempotency_key: str | None = None) -> dict:
order = _load(order_id)
- return _gw.charge(order, Decimal(str(amount))).as_dict()
+ return _gw.charge(order, Decimal(str(amount)), idempotency_key).as_dict()
注意两个文件的改动是强耦合的:gateway.py 新增了 idempotency_key 参数,api.py 的调用点必须同步。这两行必须在同一事务里应用,中间态会直接触发类型检查失败。
如果编辑工具支持结构化指令,同一处改动可以表达得更抗漂移:锚点是符号而不是行号,因此文件在上方插入内容也不会错位:
{"ops": [
{"op": "replace_body",
"file": "payments/gateway.py",
"anchor": {"symbol": "PaymentGateway.charge", "kind": "method"},
"expect_contains": "with self._unit_of_work() as uow:",
"body": "..."},
{"op": "insert_line",
"file": "payments/api.py",
"anchor": {"symbol": "charge", "kind": "function", "line_contains": "return _gw.charge"},
"expect_contains": "_gw.charge(order, Decimal(str(amount)))",
"text": "return _gw.charge(order, Decimal(str(amount)), idempotency_key).as_dict()"}
]}
expect_contains 是这里的锚点断言,对应 4.3 节说的行锚点校验。它把「我以为文件长这样」变成一个可验证的假设,不匹配就拒绝写入。
五点五、迁移类改动:补丁生成的另一套规则
前面讨论的补丁生成默认了一个前提:改动落在单个部署单元内,改完部署即生效。但有一类改动不满足这个前提——需要新旧两个版本共存一段时间的改动,包括数据模式迁移、接口协议演进、配置格式升级、存储格式变更。这类改动是代码智能体最容易造成生产事故的地方,因为「本地测试全绿」在这里几乎没有信息量。
5.5.1 向后兼容是默认要求
迁移改动的第一原则是部署顺序不能影响正确性。具体到写法上,通常意味着:新代码必须能读旧数据格式和新数据格式两种;旧代码必须能处理新代码写出的数据(在滚动发布的窗口期里必然会有旧实例读到新写入);接口新增字段必须有默认值;删除字段必须分两次,先停止写入再清理读取。
这意味着一个「删除字段」的需求,实际产生的补丁是三次独立的、跨越多次部署的改动,而不是一个补丁里的一个删除行。模型天然倾向于把需求字面化——用户说「删掉这个字段」,它就删一行。正确做法是把这类需求识别为迁移任务,套用固定的迁移模式:先引入新形态并双写、再回填、再切读、再停止写、再清理旧字段。每一步单独验证、单独部署。
5.5.2 数据回填是独立任务
模式变更之后的历史数据需要回填,这是一个和代码改动完全不同性质的任务:它跑在生产环境上,影响行数量巨大,可回滚性差,而且失败往往不是立刻显现的。合理的处理是把它从代码补丁里彻底剥离出来,作为独立的数据操作任务,有自己的检查清单——影响行数上限、批处理大小、幂等性设计、失败断点续跑、影子运行与比对。
让智能体顺手生成并执行回填脚本是危险的。它可以把脚本写对(这部分和普通代码改动一样),但「什么时候执行、执行多少行、失败了怎么办」这些判断需要结合业务对数据的理解,不该由一个只看到了 schema 的模型来决定。
5.5.3 迁移补丁的验证方式不同
迁移改动没法靠单元测试证明正确,因为它验证的是「两个版本共存时行为都对」。可行的验证手段有四类,按可信度排序:双版本兼容性测试(同一份测试数据分别用旧代码和新代码跑,结果应当一致);契约测试(新旧版本对同一请求的响应差异应当在预期范围内);影子比对(新逻辑只算不写,与旧逻辑结果对比);以及属性测试(对不变量做随机输入验证,比如往返转换后数据应当等价)。
这四类的共同点是它们都在两个版本之间做对比,而不是在单个版本里做断言。这也是为什么普通的最小验证集策略在这里几乎完全失效——它按调用图选测试,而迁移的真正风险在于时间维度上的共存。一个忠实的验证层必须能表达「拿同一份输入,分别过一遍改前和改后」这种双轨比较,而这在很多测试框架里需要专门搭架子。
5.3.1 什么情况下该让模型重写整个文件
前面反复强调最小改动,但仍有一种情况是全文重写更合适:文件本身就要大改,且改动覆盖了它的大部分内容。这时候逐块打补丁产生的 diff 反而不如一次重写干净——因为无论怎么切分,重写式改动在语义上就是连贯的一整块。
判断标准可以量化:如果预期改动行数与文件总行数的比值超过某个阈值(经验上三分之一左右),就直接全文重写。低于阈值时用补丁,好处是 diff 只显示真正的变化,review 的人一眼能看到改了什么;高于阈值时两者差别不大,但全文重写能避免模型在多个小块之间丢失全局一致性——那种丢失在 diff 上表现为「几处看起来无关的调整」,实际上是因为模型在第五块时已经忘了第一块的意图。
全文重写有两个必须配套的检查。第一是行尾与缩进一致性:模型重写长文件时容易在某些段落切换风格,而这类噪声在几百行的 diff 里会非常刺眼。第二是未改动段落必须逐字相同:重写完成后应当机械地比对——如果某些行与原文的差异既不在补丁预期内,也不是格式化工具造成的,那就是模型漂移,必须回退重来。这一条用工具检查只需几毫秒,但它拦住的是最难在 review 里被发现的一类错误。
六、最小验证集与快速反馈回路
验证是这条链路上最容易做得又慢又没信息量的环节。全量跑测试一次几分钟,Agent 试错五次就超时了;但只跑一个单测又覆盖不到跨文件改动。解法是把验证分层并给每层一个明确的时间预算。
6.1 四层验证,从秒级到分钟级
| 层级 | 检查什么 | 典型耗时 | 失败时的动作 |
|---|---|---|---|
| L0 语法与结构 | 补丁能否解析、import 是否闭合、格式是否一致 | 亚秒级 | 立即回到补丁生成,重来 |
| L1 符号一致性 | 签名变更是否在所有引用点同步;是否有未解析的引用 | 秒级 | 补齐遗漏的调用点,不进入测试 |
| L2 局部测试 | 映射到被改文件的相关测试(不是全量) | 十秒到一分钟 | 归因:是补丁错还是原有问题 |
| L3 影响面回归 | 由调用图反查出的上游契约测试 | 分钟级 | 判断是否为真回归,必要时回滚 |
L0 和 L1 是最被低估的两层。它们几乎不花时间,却能在进入测试之前挡掉绝大多数「明显的错」。尤其是 L1:改签名漏改调用点这种错误,如果等到编译或测试才发现,Agent 已经浪费了一整轮生成;而符号一致性检查只需要拿索引里的符号表做一次比对,几秒就能定位漏掉的调用点。
6.2 变更选择器:只跑可能受影响的测试
L2 的关键是测试到源代码的反向映射。有三条来源,质量依次递减:
- 覆盖率反查:用最近的覆盖率数据,把被改文件映射到覆盖它的测试集合。这是最准的,但依赖覆盖率数据新鲜。
- 构建依赖图:测试如果依赖被改模块,则整个相关包的测试都要跑。包级粒度,比文件级粗但永远安全。
- 调用图反查:被改符号的调用者对应的测试。这一层最贴合语义,代价是调用图必须包含测试调用边。
实践中最稳妥的做法是取并集但分批跑:先跑覆盖率反查出的最小集合,几十秒内拿到第一轮信号;如果这一层全绿,再跑包级集合。把「一次十分钟的测试」拆成「二十秒的快测 + 十分钟的慢测」,迭代速度的差距是数量级的。
# L1:符号一致性闸门(秒级,不依赖编译器)
from collections import defaultdict
def check_signature_consistency(index, patch_plan) -> list[str]:
problems = []
for change in patch_plan.changed_signatures:
for ref in index.references(change.symbol, limit=200):
if ref.dynamic: # 歧义边:不猜,升级为人工确认
problems.append(("ambiguous", change.symbol, ref))
continue
if ref.compatible_with(change.new_signature):
continue
if ref.file not in patch_plan.touched_files:
problems.append(("missing_sync", change.symbol, ref))
return problems
# L2:最小测试集选择
def select_tests(changed_files, coverage_map, build_deps, budget_s=25):
fast = set()
for f in changed_files:
fast |= coverage_map.get(f, set()) # 覆盖这些文件的测试
if fast and estimate_runtime(fast) <= budget_s:
return fast, "coverage"
return build_deps.affected_tests(changed_files), "package"
6.3 失败归因:把「本来就有问题」和「我改坏了」分开
这是验证环节最容易出错、后果也最严重的一步。Agent 看到测试红灯有两条路:认定自己错了,或者认定是环境问题。前者会导致无意义的反复重写,后者会让真实 bug 蒙混过关。正确的做法是建立基线:在应用任何补丁之前,先在干净树上跑同一个最小测试集,记录哪些用例本来就是红的。这个基线的成本是 L2 的一半,但它把归因问题从主观判断变成了确定性查询:
- 补丁前红、补丁后红 → 与本次改动无关,记录并跳过。
- 补丁前绿、补丁后红 → 真回归,必须修。
- 补丁前绿、补丁后绿,但新增能力没有对应断言 → 验证不足,补测试。
最后一条常被跳过,但它才是真正的问题:「测试全绿」不等于「需求被满足」。加了 idempotency_key 但没加一个重复调用返回相同结果的断言,功能上什么都没保证。每个能力点必须映射到至少一个断言,否则这个能力点没被验证——这是 L2 之前应该做的一次覆盖性检查。
6.4.1 反馈回路的迭代纪律
一个完整的回路是:读 → 定位 → 改 → 验 → 归因 → 再改。工程上要给这个回路设硬约束,否则 Agent 会陷入「改一版跑一次」的无限试探:
- 单任务最大迭代次数:比如三次。三次之后仍失败,应该切换策略(换假设、缩小改动集)而不是继续微调。
- 每轮只改一个变量:同时改逻辑和改测试会让归因不可能。失败时应该先回滚测试改动确认失败是真实的。
- 失败信息回灌要压缩:把十几条堆栈压成「失败用例 + 首个相关断言 + 期望 vs 实际」,而不是原样塞回去。噪声淹没信号时,Agent 会开始猜。
- 记录死路:尝试过但失败的方案要显式标注为不可行,避免下一轮重复探索。这在多轮会话里尤其重要。
6.4.2 把验证预算花在刀刃上
验证本身也要分配预算,因为完整跑一遍不一定付得起。合理的分配原则是按「失败概率 × 失败代价 × 检出难度」排序,而不是按检查项的自然顺序。
最值得投入的通常是三类检查。第一类是检出难度极高但失败代价极高的:契约同步遗漏、破坏性删除、危险操作——这些一旦漏过,后面所有检查都白费了,所以闸门必须在这里。第二类是检出极便宜的:语法、格式噪声、遗留调试代码、diff 体积——它们几乎不花钱,应该每次都做。第三类是成本高但覆盖有限的:完整回归——必要但不必频繁,放在循环的末尾而不是每次改动之后。
还有一条容易被忽略的判断:当迭代次数变多时,应当收窄改动集而不是加大验证范围。第三次还失败时再大的回归测试也解决不了问题,因为问题多半在假设层面而不是实现层面。正确的动作是退回去重读代码、重新确认那个最初的判断——这听起来像倒退,实际上是唯一能跳出局部收敛的路径。
最后是延迟预算这件事的一个反直觉结论:反馈延迟对有效迭代次数的影响是乘性的。一轮十秒的任务在同样的墙钟时间里能试十五次,一轮三分钟的任务只能试两次。前者的十五次里模型会自然地探索多种假设并从中学习,后者基本只有一次机会——它甚至来不及把失败信息消化完就超时了。所以「把快检查做到亚秒级」的价值,远远超过「把完整测试做得更全面」。
六点五、哪些验证信号不能信
前面把验证分成了四层,但层的划分解决的是「跑什么」,不解决「结果意味着什么」。有几个信号看起来很强、实际上几乎不带信息量,需要显式地从验证体系里排除出去,否则它们会持续制造虚假的信心。
6.5.1 让模型评价模型
最需要警惕的是「用语言模型审查语言模型的产出」这一类做法。它的吸引力在于便宜、可扩展、听起来比字符串匹配聪明。但它的判定标准是文本表面的合理性,而代码缺陷往往完全隐藏在文本表面之下:一个签名改了、所有调用点文本上都自洽、读起来非常合理,但它漏掉了一个通过反射调用的入口。
这类判断的正确用法是作为生成假设的来源,而不是作为通过的依据:让它产出「可能的缺陷假设」清单,再把每个假设转成可执行的具体检查——去找那个反射入口、看某处断言是否存在、查某个配置项。判断本身不构成信号,转成确定性检查之后才是。同理,它也不该被用来否决改动,否决必须是确定性的证据。
6.5.2 测试全绿的三个常见假象
第一种是覆盖假象:改动涉及的分支没有任何断言经过。第二种是环境假象:因为没有网络、没有数据库、缺少某个可选依赖,测试走了降级分支,验证的是一个和线上不同的行为。第三种是弱断言假象:断言只检查了返回类型不是空,没检查具体值。
识别这三种假象的办法不是更聪明的检查,而是在验证前先确认验证本身是有效的:对关键能力点,故意把补丁改坏,看对应的验证是否会失败。这叫变异测试的思路,成本不高但极其有效——一个永远不会被触发的测试,会在第一次变异检查时就暴露出来。很多团队从没做过这件事,而这恰恰是让「测试全绿」这个信号变得可信的唯一办法。
6.5.3 类型检查的特殊地位
类型检查在验证体系里位置特殊:它几乎不产生误报,速度快,覆盖面是全仓库而不是选中的测试。它应该被放在比 L2 更早的位置,甚至作为每次补丁应用的即时闸门。它的局限也必须说清楚——类型检查只能发现签名层面的不一致,对逻辑错误、并发问题、边界条件无能为力,而且对于动态语言基本失效。
还有一个实践中的陷阱:为了「让类型检查通过」而加类型忽略、加断言、加宽松转换,这是用验证手段的漏洞来掩盖改动的问题。它和删除测试属于同一类行为,应该进同一个禁止清单。判定方法很简单:忽略注解和转换的加入,必须在任何为了通过检查而做的改动里被显式标注并单独列出,否则就视为绕过。
6.5.4 不可验证的部分要显式声明
最后一条也是最诚实的一条:承认有一部分东西是这套验证体系覆盖不到的——多端交互的表现差异、长时间运行后的资源泄漏、竞态条件、性能特征、用户可见的文案与视觉、以及所有依赖真实外部系统的行为。系统不应该假装这些不存在,而应该在交付说明里明确列出「这些没有被验证」以及原因。
这种声明的实际价值在于它会改变人的行为:看到「并发路径未覆盖」,review 的人会主动去检查那段代码;看到一份声称「全部验证通过」的交付,他会更倾向于相信系统而不做检查。把不确定性如实暴露出来,本质上是在帮接收方正确分配注意力——这比任何一项自动化检查都更能让整体质量上一个台阶。
七、破坏性改动的防范
前面六节的目标是「把正确的改动做小」。这一节的目标相反:把危险的改动变大之前的犹豫变长。区分它们需要一个明确的危险操作清单和对应的闸门策略。
7.1 六类危险操作与它们的特征
| 危险操作 | 识别特征 | 默认策略 |
|---|---|---|
| 批量删除文件/目录 | 一次改动删除超过 N 个文件,或删除未被 import 的文件 | 默认拒绝,需显式确认,附引用证据 |
| 依赖版本变更 | 修改 requirements、package.json、Cargo.toml、go.mod | 单独成任务,不与逻辑改动混提 |
| 公开接口签名变更 | 被修改符号的引用数超过阈值,或属于导出符号 | 要求全量调用点覆盖 + 迁移说明 |
| 批量重命名/移动 | 改动跨越大量文件的标识符 | 优先用 IDE/LSP 重构而非文本替换 |
| 权限、密钥、CI 配置 | 路径含 .env、secret、workflow、Dockerfile | 直接禁写 |
| 测试删除或跳过 | 删除测试、加入 skip、注释掉断言 | 直接禁写,测试只可新增不可删减 |
最后两行值得单独强调。「让现有测试变绿」的诱惑是所有 Agent 系统最大的内生风险。一个删掉失败断言的补丁和一个修好 bug 的补丁,在「测试通过」这个信号下无法区分。因此正确的规则是:测试文件只允许新增和放宽到显式标注(说明理由),不允许删除、注释、跳过。这条规则必须在工具层强制,靠提示词约束是靠不住的。
7.2 闸门的三种强度
危险操作的防护不应该只有「允许/禁止」两档,按强度分成三级更实用:
- 自动放行:影响面小、可逆、有测试覆盖的局部改动。
- 需附证据:不阻断流程,但要求补丁附带论证材料——例如删除文件时必须列出「全仓库无引用」的两条独立证据(import 图无入边、全文检索仅命中自身与文档),Agent 无法提供就自动降级为拒绝。
- 硬阻断:密钥、CI、权限、以及任何不可逆且无回滚路径的操作。这些不进入对话流程,直接在工具层拒绝。
依赖升级是第三种强度里最典型的特例,因为它「看起来只是改一行」:
# 危险操作闸门:在补丁应用前逐条检查
DANGEROUS_PATTERNS = [
(r"^-\s*(import|from)\s+[\w.]+", "删除 import:可能是死代码判断错误"),
(r"^\+\s*(import|from)\s+[\w.]+", "新增依赖:需要单独评估传递依赖"),
(r"^[+-].*\"[a-z_\-]+\"\s*:\s*\"[\^~]?[<>=]", "manifest 版本变更:锁文件必须同步"),
(r"^\+.*(password|secret|token|api[_-]?key)", "凭据字面量:直接拒绝"),
(r"^\-.*(pytest\.mark\.skip|@Ignore|xit|xtest)", "跳过测试:直接拒绝"),
]
def gate(diff_text: str, index, policy) -> list[tuple[str, str]]:
findings = []
for line in diff_text.splitlines():
if not line.startswith(("+", "-")) or line.startswith(("+++", "---")):
continue
for pattern, reason in DANGEROUS_PATTERNS:
if re.match(pattern, line, re.IGNORECASE):
findings.append(("block", reason))
deleted = re.findall(r"^--- a/(.+)$", diff_text, re.MULTILINE)
if len(deleted) >= policy.max_delete_files:
refs = {f: index.references_by_path(f) for f in deleted}
unproven = [f for f, r in refs.items() if r]
findings.append(("need_evidence",
f"批量删除 {len(deleted)} 个文件,其中 {len(unproven)} 个仍有引用"))
return findings
依赖升级需要单独说明四个常被忽略的点:锁文件必须同步提交,否则本地能过 CI 不能过;传递依赖变化不可见,直接依赖升一位可能带来几十个传递依赖变化;可选依赖与 peer 依赖常常造成最难查的破坏;以及废弃版本与安全公告——有时升版本是被迫的,此时必须区分「升到最新」和「升到最小可用」。这四点意味着依赖变更应该是一个独立任务、独立补丁、独立验证,而不是和业务逻辑混在一起。
7.3 隔离执行:验证必须在沙箱里
验证阶段会真的执行代码,这件事本身就带风险。最低限度的隔离要求:文件系统只读挂载源码区、独立可写的工作区(让 Agent 无法直接改源)、默认断网(测试需要网络时显式开白名单)、资源限额(CPU 时间、内存、进程数,防止跑飞的测试占满机器)、以及无生产凭据注入。这几条里,断网这条经常被忽略,但它同时是安全和正确性的保障——很多测试在没有网络时会走 mock 分支,此时「测试通过」的含义完全不同。
还有一个容易被忽略的隔离维度:工作区的可丢弃性。整个验证应该在一次性容器或临时 worktree 里进行,验证完销毁。这样 Agent 的任何越界改动都不会污染真实仓库,也不需要「怎么撤销 Agent 的改动」这个难题。反过来说,如果系统不能提供一次性工作区,那么就必须有等价的补丁级回滚能力。
7.3.1 回滚能力:改动之后怎么退回去
前面所有讨论都围绕「不要做出无法承受的改动」,但只要有自动写入,就必须回答一个更朴素的问题:当它真的做错了,怎么退回去。这个能力经常被放到最后才想起来补,而它的形态其实决定了很多前置设计。
回滚方案按强度分三档。补丁级回滚要求所有改动都以可逆的补丁形式提交,撤销就是反向应用,改动过程完全可追溯。前提是工作区在任务开始时是干净的——如果本来就有未提交的改动,补丁级回滚就无法安全执行,这时必须先要求清理或转存,这也是很多系统实际卡住的地方。快照级回滚是在任务开始前打一个完整快照,出问题就整体还原,实现简单但粒度粗,一次任务里如果混入了人手的改动就会一起被抹掉。分支级回滚是每个任务在自己的分支或工作树上工作,任务完成后以合并方式进入主线,出问题只需丢弃分支——它在语义上最干净,因为任务的中间态从头到尾就没有污染过主线。
三种方案对前置设计的要求完全不同,这正是它们不能临时决定的原因。分支级回滚要求系统从一开始就支持工作树隔离;补丁级回滚要求改动从第一次写入起就以结构化补丁留存,而不是随手覆写文件;快照级回滚几乎没有要求,但会在恢复时连带损失人的改动。选择哪一种,取决于任务开始时工作区是否干净、是否有人工介入、以及仓库大小是否允许频繁打快照,而不是取决于实现难度。
还要区分一种看起来像回滚、实际不是的情况:让模型「把改动改回去」。这依赖模型记得自己改了什么,而这份记忆恰恰是它最不可靠的部分,而且改回去的过程本身还可能引入新的不一致。回滚必须是确定性的操作,不能是一条自然语言指令。
最后,不可回滚的操作要单独隔离,而不是靠回滚兜底。已经发出去的请求、已经推送到远端的提交、已经执行过的迁移、已经删除的文件——这些一旦发生就没有回滚路径,只能靠第八节那些闸门在事前拦住。系统的诚实做法是把操作分成两类并明确标注:可回滚的随便做,不可回滚的走人工确认通道。把它们混在一起,用同一套流程对待,是自动化系统里最容易出事的地方。
八、改动完成后的自检清单
流水线跑完不等于改动可以交付。最后一道关口是一份结构化的自检,它的作用是把「模型的自我感觉良好」替换成「可核对的客观事实」。下面这张表是我认为实际可用的最小集合,按检查成本从低到高排列,越靠前越应该自动化执行而不是交给模型自觉。
| 检查项 | 判定信号 | 不通过时的动作 |
|---|---|---|
| diff 体积 | 总改动行数 / 改动文件数 vs 需求复杂度的上限 | 以最小改动集重写 |
| 格式噪声 | 纯空白/引号/import 顺序差异的行数占比 | 自动还原噪声行 |
| 改动文件白名单 | 每个文件是否都能映射到某个能力点或契约同步 | 无映射则回退该文件 |
| 契约完整性 | 签名变更的全部调用点是否都改了 | 补齐或改为可选参数 |
| 测试覆盖映射 | 每个能力点是否至少对应一个断言 | 补测试 |
| 危险操作扫描 | 是否触碰密钥/CI/依赖/删除测试 | 阻断并说明 |
| 遗留物 | 是否有调试 print、断点、临时文件、被注释掉的代码 | 清理后重跑 L2 |
| 生成物一致性 | 改动是否需要重跑代码生成/迁移 | 执行生成命令或改生成源 |
| 基线对比 | 新增失败用例数必须为零 | 回到补丁生成 |
| 能力点覆盖 | 需求拆出的每个能力点是否有对应改动与验证 | 回到改动集推导 |
这张表里有三项是我认为最容易被系统忽略、但收益最高的:改动文件白名单(它把「最小」从期望变成了可核对的约束)、遗留物扫描(调试残留是 Agent 最常见的交付缺陷,而且会让 review 的人对整份补丁失去信任)、能力点覆盖(它是唯一能回答「需求真的被满足了吗」的检查,其余检查都只在问「改动是否自洽」)。
验证机制本身也值得单独说一句:它必须可被质疑、可被复现。如果一个人无法独立重跑出同样的结论,那么这套验证就只是在为交付背书,而不是在为正确性把关。最基本的可质疑性要求是:测试命令、运行范围、被跳过的用例、以及跳过的理由都要写在交付里,让人可以在三十秒内判断这次验证的分量到底有多重。
还想强调一句关于检查顺序的判断:这张表从上到下大致是按成本递增排列的,但执行时不要机械照抄。纯逻辑错误永远不能靠格式检查发现,纯格式问题也不值得跑完整测试。真正合理的做法是让每一层只回答它能回答的问题,一旦某层的检查失败就直接回退,不要指望下一层补救。这个「失败即回退」的纪律,比清单上多列十项检查有用得多。
8.1 交付物应当包含什么
最后,交付给人类的应该是一份解释加上一个补丁,而不是一个补丁加一句「已完成」。一份合格的说明至少包含:改了什么(对应到需求原文)、为什么这么改(特别是非显然的设计选择)、改了什么没改(显式列出被跳过的候选改动及原因)、验证了什么(跑了哪些、结果如何、哪些没跑及原因)、以及残余风险(索引没覆盖到的边、歧义调用点、未验证的平台差异)。
「改了什么没改」这一项价值很高。它把模型的判断过程外化,让人类能在几个关键决策点上快速介入——比如模型认为某个调用点不用改,但人知道那是外部系统的固定契约,这一秒钟的人工介入就能避免一次线上事故。反过来说,如果这一栏是空的,接收方就只能自己去逐行读 diff 来重建模型的判断过程,成本高得多,而且往往读不出来。
这份说明还有一个容易被忽略的用途:它是下一次修改这个仓库时最有价值的上下文。三个月后有人接手这块代码,最想知道的不是当时的实现细节,而是「哪些地方看起来是冗余但其实不能删」「哪个边界条件是有意为之」。如果这些判断被写进了交付说明里,下一个改动就会比这一个容易得多;如果没有,这些知识就随着会话结束一起消失了,下一次只能重新踩一遍坑。从这个角度看,一份好的交付说明本质上是把一次性的修复变成一次性的知识沉淀,这是自动化改动相对于一次性人工改动的真正优势所在——前提是你把知识写下来了。
八点五、多智能体协作引入的额外约束
前面八节描述的是单个智能体在单条线程上的链路。一旦引入并行代理,难度不是线性上升,而是跳了一档——因为新增了三类完全不同的失败:共享状态的竞争、任务切分造成的接口缝隙、以及风格不一致导致的合并噩梦。
8.5.1 共享索引的并发与任务隔离
多个代理同时在一个工作区上改代码,最直接的问题是索引会看到彼此的中间态:代理 A 刚改了签名还没改调用点,代理 B 在这一刻读取索引,看到的是一个编译不过的仓库,于是它的影响面分析整个偏掉。解决办法有三个,成本递增:
- 快照读取。每个任务开始时固定一份索引快照,任务期间不感知其他代理的改动。代价是任务结束时的签名一致性会跨任务不一致,必须在最后做一次全局的合并后校验。
- 写集隔离。每个代理只允许改自己认领的符号,同一符号只能被一个代理认领。冲突从运行期前移到调度期,用规划换稳定性。
- 分支隔离。每个代理在自己的分支或 worktree 上工作,最后由一个合并阶段处理冲突。这是最彻底的做法,也是最接近人类团队工作方式的。
值得强调的是,写集隔离往往比想象中容易实现,因为前面的改动集推导已经把「谁要改哪些符号」算出来了。真正困难的不是隔离本身,而是当两个能力点确实需要改同一个符号时的处理——这时正确的做法是让它们在计划阶段就被发现并合并成同一个任务,而不是让它们在补丁阶段撞车。
8.5.2 切分的单位是能力点,不是文件
按文件切分任务是最常见也最糟糕的做法,因为能力点天然跨文件。一个「支持新的支付渠道」的能力点必然同时落在网关层、接口层和测试层。按文件切的结果是每个代理都只拿到三分之一的信息,然后各自猜测另外两部分长什么样,最后合并出一堆能过编译但语义错误的代码。
正确的切分原则有三条:按能力点切,保证每个任务的产出自洽;按依赖关系串行化,被依赖的部分必须先完成并被验证;共享类型定义单独成任务且最先完成,因为它是所有其他任务的输入契约。判断能否并行的标准很简单——如果任务 B 的正确实现依赖于任务 A 的产出内容,那它们就必须合并或串行,而不能并行猜测。
8.5.3 独立的校验者比更多执行者更有价值
让第二个代理用同样的上下文、同样的模型去「检查」第一个代理的补丁,收益远低于看起来的价值——同样的盲区会复现同样的错误。有效的交叉验证需要上下文和角色的双重隔离:
一是从需求而非从补丁出发。校验者看到的是原始需求和最终 diff,而不是执行者的推理过程。这样它才能问出「这个改动满足需求吗」,而不是「这个补丁自洽吗」。
二是反向验证。对每个能力点,让校验者回答「如果这条需求没有被实现,最可能的现象是什么」,然后检查测试里有没有对应这个现象的断言。这是一种便宜的、能有效抓出「测试全绿但功能缺失」的手段。
三是破坏性假设检验。让校验者扮演相反的角色:「假设这个补丁是有 bug 的,最可能错在哪」。这种对抗式提问比中性的「请检查」能挖出更多问题,前提是校验者拿到的是足够的符号级上下文而不是只有 diff。
8.5.4 合并前的补丁归一
多个代理产出的补丁会在风格上产生系统性差异:命名偏好不同、日志措辞不同、错误处理习惯不同、格式化程度不同。如果这些差异混在同一个补丁集里交给人类 review,结果是 review 直接放弃——不是补丁质量差,而是 diff 噪声让它无法阅读。
解决办法是在合并阶段做一次风格归一:统一走仓库自己的格式化器、统一按最近的代码风格调整、对重复出现的模式(比如错误日志模板)做对齐。归一必须在合并之后、交付之前完成,并且要重新跑一次验证——因为格式化器和风格对齐本身也是会改变代码的改动,做完之后不验证等于没验证。
八点六、把这套能力做成工具契约
前面所有能力最终都要以工具形式暴露给模型。这意味着接口设计本身就是这套工程的一部分,而不是一个包装问题。一个粗糙的接口会让模型频繁误用,一个精心设计的接口会让模型的正确率明显上升,哪怕底层能力完全相同。
8.6.1 粒度选择:粗工具与细原语的配比
粒度上没有唯一正解,但有一条经验规律:把「判断」留在原语层,把「编排」留在粗工具层。原语层提供精确、可验证、无副作用的能力——按符号查定义、按符号查引用、按路径读文件、应用带锚点断言的补丁。粗工具层提供编排——「实现这个能力点」这类高层动作,内部自行决定要调哪些原语、调几次、怎么分配预算。
全做成粗工具的问题是可观测性差,出错时说不清是哪一步错了;全做成细原语的问题是模型负担重,需要自己规划调用序列,在真实任务上浪费大量轮次还容易漏步骤。合理的配比是:原语层丰富且稳定,粗工具层少而可组合,并且粗工具的每一步都应该能被单独观测和单独重试。
8.6.2 错误信息是写给模型看的
这是最容易被忽略、收益却极大的一点。工具返回的错误信息不是给人看的日志,而是模型的下一步输入。把「错误」两个字原样返回给模型,等于让它自己猜该怎么恢复。正确的做法是把错误分类,并且每一类都明确告诉模型「现在应该怎么办」。
| 错误类别 | 典型触发 | 模型应采取的动作 | 不该出现的信息 |
|---|---|---|---|
| 锚点失配 | 补丁基于的原文片段已改变 | 重新读取该区域,再基于新内容生成补丁 | 只有一句「apply failed」 |
| 索引不可用 | 索引陈旧或该符号未收录 | 降级到全文检索,并标注本次结果置信度低 | 假装符号不存在 |
| 预算超限 | 需要的上下文超过剩余额度 | 优先降级裁剪粒度,其次请求扩大预算 | 静默截断 |
| 歧义未决 | 存在多个候选目标无法静态确定 | 停止猜测,向用户列出候选并请求确认 | 随便挑一个继续 |
| 危险操作 | 触碰密钥、删除测试等 | 停止,附上触发原因与替代方案 | 仅拒绝但不解释 |
| 部分失败 | 多文件事务中某块未应用 | 已回滚,需基于当前状态重新生成整批 | 只报成功的那几块 |
这里面最反直觉的一条是「索引不可用时不要假装符号不存在」。一个诚实的降级加上明确的置信度标注,能让模型主动采取更保守的策略;而一个伪装成成功的空结果,会让模型以很高的置信度做出错误的全局判断——它会以为这个符号真的没被引用,于是跳过了本来必要的同步改动。
8.6.3 幂等、可重放与事务边界
三个性质直接决定了系统能否容错:幂等保证重试不会造成二次破坏(尤其是删除和重命名这类操作,重试必须是安全的空操作);可重放保证任何一轮的输入输出都能事后审计(索引快照、上下文清单、补丁、验证结果必须一起落盘,缺一样就复现不了);事务边界清晰保证失败时能确定性地回到某个一致状态。
这三者有一个共同的前提:所有决策都要落成结构化数据,而不是留在对话历史里。对话历史是可以被压缩、会被摘要、会被截断的,它不是可靠的存储。这一点在长任务里是生死线——一个跑到中段的任务如果只保留了对话记录而不保留结构化决策日志,那么它既不能继续,也不能复盘。
8.6.4 可观测性:把上下文决策变成可审计轨迹
最后,把整条链路的每一步记录成一条轨迹:这一步为什么召回这些文件、每层预算实际用了多少、补丁里的每个块对应哪个能力点、验证跑了哪些用例、失败如何归因。这类轨迹的价值在开发期是最高的——它能让你发现「模型漏改是因为上下文收集阶段就把调用点裁掉了」这类根本原因,而不是停留在「模型不够聪明」的结论上。
而一旦这套系统要长期服务多个仓库、多个人,轨迹的价值会转向审计:谁在什么时候通过什么依据改了这段代码、当时的索引状态是什么样子的、当时的测试覆盖到了哪里。它其实是一份比代码本身更完整的变更记录,因为代码只记录了结果,轨迹记录了推理过程。在多智能体系统里,这份记录也是唯一能让人理解「这一堆改动是怎么来的」的东西。
九、串起来看:这是一次预算分配,不是六个独立步骤
把整条链路压平来看,它其实是在一个固定的总预算(模型上下文 + 墙钟时间 + 人工介入额度)下做一次分配:
- 信息预算分给索引与上下文收集——这是最值得投资的部分,因为错误的上下文无法被更强的推理补偿。
- 推理预算分给改动集推导与补丁生成,但必须靠「最小」约束压制它无节制地扩散。
- 反馈预算分给验证,越早越便宜的检查越优先,L0/L1 的投入产出比远高于 L3。
- 权限预算留给危险操作,核心是把「不可逆」的部分从对话里彻底移走。
这四个预算彼此竞争,这才是真正的工程难点。一个常见的失衡是:把大量预算花在上下文收集上(塞得很满),却只做一次全量测试;或者反过来,只用 grep 召回、不做索引,就把更多 token 塞进上下文。两种都会失败——前者慢到无法迭代,后者贵到无法规模化。
9.1 适用边界:什么规模、什么语言、什么场景
需要说明这套做法在哪里不划算。单文件或极小项目(几千行以内)不需要完整索引,直接全文塞进上下文反而更简单可靠,构建索引的时间和出错风险收不回来。首次接触的项目,索引的价值也打折——因为缺少历史(谁改过、为什么改、哪块代码稳定),只有当前结构,而结构之外的语义信息才是 Agent 最容易判断错的部分。
语言层面,静态类型语言收益最高:接收者类型已知,消歧几乎免费,符号一致性检查可以直接用类型信息。动态语言能做,但歧义边会明显增多,需要把更多不确定性显式暴露给用户。生成代码占比高的仓库要格外小心索引的正确性——派生代码的边会把调用图污染得很严重。
团队成熟度也是一个真实的约束。如果一个仓库没有测试、构建经常是坏的、代码风格不统一,那么前面这套链路里依赖「测试映射」和「格式化归一」的环节都会落空,此时投入产出比最好的顺序是反过来的:先补测试、补构建、补风格,再上智能体链路。很多团队试图跳过这一步直接上,结果是链路建好了但从来没能跑到 L2 和 L3 那一层,只剩下一个搜索加一个文件写入,用起来还不如直接改代码。
组织层面的现实约束同样值得说清。这类系统的维护成本相当可观:索引规则要跟着语言版本和构建系统调整,闸门策略要按事故复盘不断增补,验证集选择要适配每个项目的测试框架。它不是一个装好就能用的工具,而是一项需要有人持续投入的基础设施。如果团队没有这个人力,与其做一个半成品的版本,不如把范围收到最小——只做符号索引加只读检索,不开自动写入——至少前两节的收益能立刻拿到,而且不会引入写操作带来的风险。
最后是场景边界。这套链路解决的是局部到中等爆炸半径的改动:新增功能、修 bug、适配参数、重构局部实现。对于跨系统的架构级变更(大范围接口版本化、存储引擎替换、多语言运行时统一),索引和验证仍然必要,但主导因素会变成迁移策略和人工设计决策,Agent 在这里更适合扮演「影响面分析器」和「方案草案生成器」,而不是「改动执行者」。认清这个边界,比把这套东西硬套到所有任务上更有价值。
九点五、端到端走一遍:给订单加「部分退款」能力
抽象的方法论容易显得轻,把一条真实需求从头走一遍更能看出各节之间怎么咬合。下面这个任务足够典型:有跨文件改动、有契约变更、有数据影响、有测试缺口,而且每一步都可能失败。
需求原文:「客服在后台对已支付订单发起部分退款,退款金额不能超过订单实付,已发货订单不允许部分退款,操作要留审计日志。」这句话里其实藏着五个能力点,缺一个都会在后面某个环节暴露出来。
9.5.1 索引与种子扩展
从锚点词出发——「订单」「退款」「支付」——在符号表里精确匹配,会命中几组候选:订单领域模型、支付网关的退款方法(已有全额退款)、后台接口层的相关端点、以及审计日志模块。这里最关键的一步是分清「已有但未暴露」和「完全不存在」:全额退款已经存在,部分退款应该扩展它而不是另起炉灶。如果索引没有把这条路径的调用链拉出来(控制器、服务、网关实现、持久化、审计钩子),模型很可能会新写一套退款逻辑,从而绕过已有的权限检查和幂等处理。
调用图在这里还会暴露一个容易漏的边:金额类型的转换。实付金额在系统里同时存在整数分和十进制浮点两种表示,退款金额如果用错类型,会出现精度丢失。这一条是靠「契约」层的上下文被发现的——补一个 refund_amount 字段时,所有涉及金额的类型定义都进了配额。
9.5.2 预算分配与一次真实的裁剪
候选集算出来有 180 个符号,肯定塞不下。按分层配额分配之后,definitions 层拿到了退款服务、订单模型和网关接口的完整实现;call_sites 层拿到了全部 6 个调用点及其接收者类型;contracts 层拿到了订单状态枚举、金额类型定义、以及审计日志的写入接口签名。这里触发了一次裁剪:审计日志模块里有 40 个写入方法,全部展开要占掉两万多 token,所以只展开了和订单相关的三个,其余折叠成一行方法名列表。
裁剪的结果是合理的——因为需求只要求「留审计日志」,而不是「调用任意日志方法」。如果这里贪心地全展开,不仅浪费预算,还会把模型注意力稀释到无关的日志分类上。裁剪的正确性依赖于能力点划分的清晰度,这也是为什么第四节要把需求先翻译成能力点:能力点既是改动的边界,也是上下文裁剪的合法性依据。
9.5.3 改动集与那一次提问
改动集推导出来是 5 个文件:退款服务(加部分退款方法)、网关接口(加参数)、两个调用点(传参)、订单状态枚举(加中间态)、审计日志接入。系统在这里触发了三次提问中的第二次——因为发现已有的退款方法已经处理了幂等,而需求里出现「操作」一词,隐含着「重复点击不应重复退款」的要求,但需求原文并没有明说。这是典型的「存在明显更小的做法」:不需要新增逻辑,只需要保证幂等键覆盖部分退款场景。于是向用户确认,得到确认后这一条被并入改动集,而不是作为额外能力点。
第三种提问(零覆盖改动)也在这次任务里出现了:新加的中间态在状态枚举里,但没有任何测试覆盖状态机的转移合法性。这个信号被显式上报,附带说明「如果不补测试,非法状态组合无法被验证」,最终补丁里多了一个状态机转移的测试文件——这是一次由验证缺口反向驱动的新增改动。
9.5.4 补丁与一次事务回滚
5 个文件被组织成 3 个事务块:接口与实现必须同生共死(强耦合),两个调用点可以独立但都属于同一能力点(弱耦合),状态枚举与测试单独。应用过程中,某个调用点因为另一处重构导致锚点失配——这一处正是第 2.3 节说的陈旧索引问题:任务开始时的索引快照里,那个调用点还在原位,而任务中间某次探测读取触发了格式化,行号与内容都变了。
事务块整体回滚,工具返回的是「锚点失配,请重新读取该区域」,而不是「apply failed」。模型读到这个错误知道该干什么:重新读取、重新生成该调用点的补丁、重新走锚点校验。整批补丁最终应用成功,改动共 61 行,其中 4 行是格式化噪声被自动还原。
9.5.5 验证与它的不完整
L0 通过;L1 符号一致性检查发现两处漏改——两个通过反射注册的处理器里也有调用,索引把它们标记成了动态边,模型按规则先重读确认再同步改掉,这是本任务里 L1 真正救回一次的场景。L2 选出 12 个相关测试,其中 2 个在基线时就是红的(仓库里本来就有个未修复的问题),被正确归因为既有问题;新增能力点补了 3 个断言,分别覆盖金额上限、已发货拦截、幂等重复调用。L3 跑了退款与订单两个包的回归,一致性测试通过。
交付说明里明确写了未验证的部分:并发场景下两人同时操作同一订单的部分退款没有验证,因为现有测试框架里没有这一类的并发夹具;审计日志的落库内容格式也需要人工确认。这是一个诚实且有用的交付——它把「没有覆盖」变成了明确的行动项,而不是藏在一句「测试全部通过」后面。
把这次走查压缩成一句话:索引决定了它能否找到正确的扩展点,预算决定了它能否看清全貌,改动集推导决定了它能否把边界划对,事务化补丁和锚点断言决定了它能否在失败时不留下半成品,而验证体系决定了它是否知道自己到底证明了什么。五个环节里任何一环缺失,其余四环的成果都会打折——这也正是这套链路必须整体设计、而不是挑一两个技巧用的原因。
十、几个容易被做反的决定
最后收束一下这份清单里最常见的几个反向决策,它们在实践中造成的损失不比技术缺陷小。
用向量检索替代符号检索。语义检索适合找「哪里处理支付失败重试」,但不适合回答「charge 在哪定义」。把精确查询交给符号表、把模糊查询交给检索,是分工不是替代;反过来做,代码库越大越离谱。
只做一次性全量索引。任务开始时建一次索引看起来很干净,但会话中仓库状态会变(Agent 自己改了文件、用户又提了新需求)。索引与工作区不一致时,Agent 的影响面分析会系统性出错,而这种错误极难察觉。索引必须绑定到某个工作区状态,并在每次改动后增量更新。
把验证交给模型的自我报告。「我认为改动是正确的」没有任何信息量。可用的验证信号只有三种:可执行检查的退出码、结构化的一致性检查结果、以及代码与基线的机器可判定差异。模型的自然语言结论不是信号。
把「最小」只写进提示词。提示词里的「请保持改动最小」被遵守的概率随文件数增加而快速下降。可靠的做法是把它变成结构约束:改动文件白名单、diff 行数上限、噪声行自动还原。约束在工具层,愿望在提示词层。
过早优化智能程度。在索引和验证闭环还不健全的系统上,把预算全花在更好的提示和更长的思考上,收益远低于先把「改完能自动验证」这件事做扎实。地基不牢时,上面盖多高都是浪费。
把幂等、重试、事务这些能力交给模型自觉。它们必须是工具和运行时的属性。让模型「记得加幂等键」和让它「每次都加幂等键」之间差距极大,因为模型在改第三个文件时,前两个文件的约束早就被稀释了。同理,回滚能力、事务边界、锚点校验,都是运行时提供的能力,不是提示词能替代的。
把测试基线当作可选步骤。在应用补丁前跑一遍同样的测试,代价是验证时间的一半,但它决定了后续每一次失败到底是回归还是既有问题。省掉基线的系统要么会因为既有问题反复重写补丁,要么会逐渐学会忽略红灯,而后者是不可逆的——一旦信号被忽略,再严格的规则也拦不住。
过早并行化。并行能缩短墙钟时间,但会让失败归因变难、让风格差异变多、让索引竞争变复杂。一个稳妥的顺序是先把单线程链路跑稳——能让一个代理连续完成十次复杂改动且验证闭环工作——再考虑拆分。反过来的路径代价很高:并行会掩盖单线程下本可被发现的接口缝隙。
用平均值做验收。这套链路的质量分布是长尾的:大多数任务顺利,少数任务在完全错误的方向上跑得很远。平均值看起来不错,但那些跑偏的任务正在污染生产代码、消耗信任。所以指标应该看最差的一档——比如「有多少比例的改动在没有触发任何提问的情况下被交付」「有多少比例的补丁需要人工重写」。后者尤其关键,它是判断改动集推导是否准确的最好指标。
把这几条反过来看,这套工程链路的优先级其实很清楚:先让系统看得见(索引)、再让它只动必要的(改动集约束)、让它动得可撤销(事务化补丁)、让结果可证(分层验证)、让危险的进不来(闸门与沙箱)。这五件事做完,模型能力的提升会自动转化成更好的结果;反过来,先升模型再补链路,收益会大量泄漏在看不见、改太多、无法验证这三个黑洞里。