跳到主要内容

无法合并的智能体重构:为什么多文件差异会在衔接处崩溃

· 阅读需 10 分钟
Tian Pan
Software Engineer

一个来自 AI 编程智能体的 40 个文件的重构任务摆到了你的桌面。你打开 PR,滚动查看差异(diff),每一个代码块(hunk)看起来都没问题。命名重构很一致,导入很整洁,测试在隔离状态下也能编译。你合并了代码。40 分钟后,主分支的 CI 变红了,因为同级包(sibling package)中的两个调用点仍然向一个现在需要四个参数的函数传递三个参数,而原本能捕获这一错误的类型检查器从未包含在智能体的内环(inner loop)中。

这是当今智能体编写的重构中最常见的失败模式,而且它与单个修改的质量几乎无关。每一个文件单独审查时,看起来都像是一个细心的人类写的。Bug 存在于“接缝”处——即来自不同文件的修改必须保持一致的边界。文件级的审查隐藏了接缝级的正确性,而大多数审查工作流都是围绕文件设计的。

智能体看到的是代码块,而不是图

人类使用的重构工具——IntelliJ 的 Rename、rust-analyzer 的 change-signature、TypeScript 的项目级引用——都运行在解析后的符号图上。当你将 getUser 重命名为 fetchUser 时,工具不会搜索字符串;它会遍历 AST(抽象语法树),找到绑定到该特定符号的每一个引用,并重写它们。如果任何位置无法通过机械方式更新,工具会拒绝执行并向你显示一个列表。

默认情况下,编程智能体不会做这些。它们根据提示词和文件窗口生成的一系列文本编辑进行操作。符号图存在于编译器内部;而智能体看到的是源代码的扁平化、分块后的视图。关于 AST 引导生成的专门研究阐明了这一差距:LLM 优化的是局部似然性,而不是全局语义,因此无法保证分层正确性。每个代码块在语法上的合理性是模型所擅长的。而跨文件的一致性则是模型无法看到的。

其后果是一类特定且可重复的 Bug。定义文件中的签名被修改了。同一文件中的两个调用点因为在上下文内而得到了更新。兄弟测试文件中的第三个调用点被遗漏了,因为相关文件没有被加载。第四个调用点位于一个独立打包的消费者中,智能体甚至不知道要去搜索。每一个单独的编辑在局部都是合理的。只有合并在一起时才是错误的。

为什么审查在这里也会失效

自然的本能是将问题推给代码审查。审查者捕获人类制造的 Bug;那就让他们也捕获智能体制造的 Bug。这行不通,原因在于人类阅读 diff 的方式。

代码审查是一项基于代码块(hunk)级别的认知任务。审查者查看一组修改,询问“考虑到代码块中显示的周围上下文,这个编辑是否有意义?”,然后予以批准或发表评论。关于 AI 辅助审查的研究已经聚焦于代码块级的反馈,正是因为这是人类能够实际进行推理的粒度。问题在于,接缝 Bug 在该粒度下是不可见的。“每个代码块看起来都没问题”并不是注意力的缺失;它是一个正确的观察,只有当你组合这些代码块时,它才会变得错误。

一个包含 40 个文件的 PR 加剧了这个问题。审查者现在必须在跨越每个代码块的工作记忆中保持重命名的完整语义,将定义与调用点、测试以及导入进行交叉引用。一个经验丰富的审查者可以处理 10 个文件的 diff。但没有人能处理好 40 个。审查者最终只能进行抽样——仔细阅读几个代码块,略读其余部分,并寄希望于如果编译器要报错的话早该报错了。当编译器不在反馈环中时,这种信任就错位了。

编译器是最廉价的审查者

摆脱这类 Bug 最快的方法是将编译器作为智能体循环的一部分,而不是在智能体写完 PR 后的事后门槛。这种区别至关重要:“编译优先”的循环会拒绝无效的中间状态,并强制智能体在生成下一个编辑之前修复它们。 “编译作为门槛”的工作流稍后也能捕获同样的错误,但那是在智能体已经生成了它认为正确的完整 diff 之后。

在实践中,“编译优先”的原则是这样的。在任何多文件编辑之后,智能体都会运行类型检查器(或该语言等效的静态分析器)。如果失败,失败输出将被反馈到下一轮对话中。智能体不能生成一个无法通过类型检查的 PR。对于 TypeScript,这意味着在每一轮编辑中运行 tsc --noEmit。对于 Rust,运行 cargo check。对于使用了类型系统的 Python 代码库,运行 mypypyright。最近关于智能体操作的 CI 流水线的文章描述的正是这种分层——严格的类型检查作为内入门槛(inner gate)运行,而不是稍后的环节。

这对于无类型语言是不够的,对于有类型语言中的语义 Bug 也是不够的。一个被重命名的常量,如果旧名称和新名称都已定义,它仍然可以编译通过,但会在运行时出错。但是,“编译优先”可以捕获绝大多数接缝 Bug——如签名更改后遗漏的调用点、过时的导入、在测试中仍被引用的已删除符号——并在智能体可以低成本纠正它们的时刻捕获它们。

使用重构工具,而不是文本编辑器

第二个原则在文化上更难执行,但在实践中更为重要:停止将重构视为文本生成。重命名不是一个“提示词加差异对比”(prompt-and-diff)的任务。它是一种机械化的转换,Language Server(语言服务器)自 2005 年以来就一直在正确地执行这类操作。现代 IDE 工具链暴露了这些转换接口——例如 VSCode 的“修改签名”API、ast-grep 基于模式的重写、Language Server 的重命名符号——而调用它们的智能体(agent)可以免费获得正确性保证。

反面模式(anti-pattern)是一个智能体读取文件,注意到一个符号,然后要求模型“更新所有用法”。这种路径每次都会产生“接缝漏洞”(seam bug)。行之有效的模式是,智能体能够识别出“这是一个重命名”或“这是一个签名更改”,并将其分发给运行在符号图(symbol graph)上的确定性工具。Kiro 关于程序分析辅助重构的文章明确阐述了这一点:LLM 用于意图识别,程序分析用于执行转换。分工明确才是关键。

对这种方法的抵触通常源于这样一种观察:Language Server 的重构无法组合——你很难通过 IDE 脚本化地执行一系列“重命名+移动+签名更改”的操作。的确如此,这也是为什么智能体框架开始将这些操作作为具有稳定 API 的工具暴露出来。发展方向很明确:智能体选择重构方案,工具执行重构,模型永远不会看到文本层面的差异。

接缝感知审查:向我展示所有调用点

在某些文本生成不可避免的情况下——例如代码库所用语言缺乏优秀的工具支持、重构在结构上非常复杂以至于没有现成的转换模板可用、或者修改内容是新代码而非简单的重命名——审查原则必须从“代码块级”转向“接缝级”。

接缝感知审查意味着审查者在未看到差异对比中每一个调用点的情况下,绝不会批准任何签名更改。对于重命名的符号,审查者会要求查看整个仓库(不仅仅是 PR 范围)的 grep 输出,并确认每一个匹配项要么已被更新,要么被明确标注为不在处理范围内。对于更改后的返回类型,审查者会检查每一个调用方。这种工作人类很难做好,而工具却能完美胜任。合适的 CI 层应自动运行这些一致性检查,并将其作为差异注释呈现出来:“此签名已更改;PR 中有三个调用点,PR 之外有两个未被改动的调用点。”

越来越多的审查智能体正在执行此类操作——Anthropic 最近部署的代码审查智能体以及各种多智能体 PR 审查工具包,都包含某种版本的“一致性扫描”,用于标记整个代码库中符号定义与其引用之间的不匹配。这才是工具应有的形态:人类审查意图,工具验证不变性,从而在无需任何人死记硬背涉及 40 个文件的重命名细节的情况下,捕捉到接缝漏洞。

令人不安的迁移

呈现出的景象是从一种协作模式向另一种模式的迁移。在旧模式中,智能体编写代码,人类审查差异。在新模式中,智能体编写代码,确定性工具验证全局不变性,人类审查意图和判断决策。在一个包含 40 个文件的 PR 中,对于跨越 8 个文件的一致性漏洞,人类是最糟糕的检查者。类型检查器、Language Server 和交叉引用 Linter 几乎是同类漏洞的完美检查者,运行成本极低,而且在检查第 37 个文件时永远不会感到疲倦。

完成这一转变的团队报告了一个具体且可预见的收益:他们的智能体化重构成功合并了。过去由于人类需要缓慢检查每个文件而停滞三天的 PR,现在一小时内就能落地,因为内部循环在 PR 生成之前就捕捉到了接缝漏洞。审查者将时间花在他们真正能回答的问题上——这是否是正确的重构?是否符合架构方向?是否存在工具无法发现的更微妙漏洞?——而不是纠结于每个调用点是否都已更新。

尚未完成这一转变的团队正在产生一种特定的病态:大型 PR 在局部看起来合理,但在全局上是不一致的,审查者无法安全地批准,智能体也无法在不推倒重来的情况下安全地修改。解决方法不是更好的提示词或更大的上下文窗口。解决方法是承认重构是一个程序分析问题,将程序分析工具引入智能体的循环中,让模型去做它唯一真正擅长的事情:从一开始就决定重构的目标。

References:Let's stay in touch and Follow me for more thoughts and updates