跳到主要内容

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

· 阅读需 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 之后。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates