让 Coding Agent 重构时,边界要比目标更清楚

0阅读9分钟

重构任务最容易被 AI 扩大范围

在所有交给 Coding Agent 的任务里,重构最容易失控。

原因很直接:AI 擅长发现代码中的「改进机会」——重复代码、不一致的命名、过长的函数、可以提取的公共逻辑。这些能力在写新功能时是优点,在重构时却变成风险。你让它「优化一下这个函数」,它很可能顺便整理了相邻的函数、重命名了参数、改了样式、甚至动了不在任务范围内的文件。

我见过太多开发者踩过这个坑。AI 没有「只改这里」的隐含意识——对它来说,上下文窗口里的每一行代码都是可修改的。

Martin Fowler 对重构的经典定义包含两个硬约束:行为保持不变结构得到改善。两者缺一不可。但 AI 在执行重构时几乎总是只关注「让代码更好」,忽略了「让行为不变」——因为「不变」不会体现在 diff 里,「改善」会。

我想聊的核心观点是:给 AI 做重构时,边界比目标更重要。 目标决定 Agent 往哪走,边界决定它不会走多远。没有边界的重构,就是让 AI 自由发挥。

重构到底是什么

前面提到了「行为保持不变」和「结构得到改善」两个约束。日常开发中重构经常被滥用为「改代码」的同义词,先把定义展开。

Martin Fowler 的经典定义

Martin Fowler 在《Refactoring: Improving the Design of Existing Code》中给出了重构的标准定义:

重构(名词):对软件的内部结构所做的一种调整,使其更易于理解和修改,同时不改变其可观察行为

——Martin Fowler, Definition of Refactoring

这个定义有三个核心支柱:

支柱含义反例
内部结构调整重命名、提取函数、移动职责、拆分大类不改结构只改格式
行为保持不变外部可观察的行为必须完全一致优化时顺便修了个 bug
设计得到改善代码更容易理解、更容易修改改了结构但变得更复杂

Fowler 还特别强调,重构和添加新功能应该在两个独立的阶段进行——永远不要同时做两件事,这样你才能确信行为没有改变。

Opdyke 的行为保持变换

更早的理论基础来自 William Opdyke 在 1992 年的博士论文《Refactoring Object-Oriented Frameworks》,他提出了「行为保持变换」(behavior-preserving transformation)的概念:每个重构步骤都是一组满足前置条件的变换,只要前置条件成立,行为就自动得到保持。

人工开发时代,这些是「软约束」——靠开发者自觉。但重构主体变成 AI Agent 后,约束必须变成「硬约束」——AI 没有「自觉」这个概念。

AI 为什么容易扩大重构范围

AI 特别容易突破重构边界,根子在它的工作机制。

原因一:目标函数倾向于「尽可能多的改善」

AI Agent 收到「优化这个组件」这样的指令时,优化目标会在上下文中被最大化。能发现 5 处可以改进的地方,它就会倾向于同时改进 5 处,而不是只改你要求的那 1 处。

人类开发者在重构时有隐含的所有权意识——「这个文件是别人负责的,我不该改」。AI 没有这种意识,它把整个仓库都视为可修改范围。

原因二:上下文窗口的「全知错觉」

当 AI 读取了多个文件后,它会在上下文窗口中形成一个全局视图。这个全局视图会产生一个副作用:AI 会认为它看到的「可以改进的地方」都是它应该改进的。

一个工程师说得很直接:「Simple changes often touched 20+ files」(简单的改动经常触及 20 多个文件)。AI 把整个仓库当作可修改范围,缺乏对「哪些文件不归我管」的判断。

原因三:缺乏「风险-收益」的权衡能力

人类开发者在重构时会做一个隐性判断:这个改动的收益是否值得引入的风险?改一个稳定的核心函数,即使代码写得不好,风险也可能大于收益。AI 很难做这种权衡,它更多是从「代码质量指标」出发,而不是从「工程风险」出发。

原因四:没有「沉没成本」概念

人类开发者重构到一半发现影响范围太大,会主动收手。AI 没有这种直觉,它会一视同仁地执行所有发现的重构机会,直到任务完成。

案例一:组件拆分改坏了公开 API

场景

一个 ArticleList 组件有 320 行,负责渲染文章列表、处理分页和筛选。团队让 Agent「把组件拆小,提高可读性」。

Agent 做了什么

Agent 将 ArticleList 拆成 5 个子组件(Header、Body、Footer、Card、FilterPanel),结构本身合理。但它同时改了 Props 接口、事件回调签名(onSelect(article)onSelect(articleId))、CSS 类名(.article-list.article-list-root)、导出方式(default → named)、内部状态管理(useState → 自定义 hook)。结果:12 处 import 报错,3 处调用方编译失败,E2E 选择器失效,初始化时序微妙变化。

好的做法

提前定义边界:Props 接口不变、export default 不变、CSS 类名不变、onSelect 签名不变、子组件不对外暴露。Agent 的拆分就被限制在组件内部,不影响外部调用方。

坏 diff 547 行,审查者分不清哪些是必要拆分、哪些是顺手整理。好 diff 85 行,一眼看出:结构拆了,公开接口和样式完全不变。

案例二:函数重构让行为悄悄变了

场景

一个 150 行的数据转换函数 transformApiResponse,负责把后端 API 原始响应转成前端类型。团队让 Agent「重构,提高可读性」。

Agent 做了什么

Agent 拆成 4 个辅助函数,代码更好读了,但边界行为悄悄变了:null 字段从返回默认值 {} 变成返回 null;空数组从返回 [] 变成返回 undefined;嵌套对象从递归转换所有层级变成只转第一层;日期字符串从保持原样变成转成 Date 对象。

每个变化单独看都有道理,合在一起就改变了整体行为。由于没有测试保护,code review 没发现。上线后 Dashboard 图表全部显示异常,团队才定位到问题。

正确做法

重构前先为现有行为建立快照测试,覆盖所有边界情况。每次重构后跑测试,失败就知道行为变了。

教训:没有测试的重构不是重构,是赌博。 对人类如此,对 AI 更是如此。

案例三:大规模重命名让 diff 无法审查

场景

一个 2000 行的模块用了匈牙利命名法(strNameintCount),团队让 Agent「重命名变量,符合 TypeScript 规范」。

Agent 做了什么

Agent 重命名了 437 个标识符,涉及 23 个文件,产生 1200 行 diff。单个改动看都合理:

- function calculateTotalPrice(intQuantity: number, fltPrice: number): number {
-   const fltTax = fltPrice * 0.1
-   return intQuantity * (fltPrice + fltTax)
- }
+ function calculateTotalPrice(quantity: number, price: number): number {
+   const tax = price * 0.1
+   return quantity * (price + tax)
+ }

但 437 个改动混在一起,code review 就变成了不可能完成的任务:无法判断每个重命名是否正确、无法确认是否有漏改、无法发现「顺手」改的逻辑、无法验证跨模块影响。

正确做法

分批次进行,每批控制在可审查范围内:公开 API 约 15 个标识符 → 内部辅助函数约 20 个 → 局部变量按文件逐个进行。每批次独立 PR,diff 不超过 100 行。

重构边界确认流程

让 Agent 执行重构前,先走边界确认流程。目标是:在 Agent 写第一行代码之前,把「不可改变项」锁定。

流程图画布 · 115%
Mermaid 流程图加载中...

关键节点:明确重构目标(具体到「降低圈复杂度」,别说「优化一下」);列出不可改变项(API、路由、文案、样式、依赖、导出方式);审查 diff 形状(一句话说不清收益就暂停拆分)。

行为保护策略

重构的核心约束是「行为保持不变」,但「不变」需要证据。以下分级的行为保护策略:

保护级别策略适用场景成本可靠性
L1单元测试纯函数、工具函数、数据转换
L2快照测试组件输出、API 响应结构中高
L3页面截图对比UI 组件、样式相关的重构
L4类型检查接口重构、类型定义调整中(只覆盖类型层面)
L5E2E 测试关键路径、跨组件交互
L6手动验收视觉效果、交互细节取决于审查者

使用建议

有测试:先跑一遍确认基线,重构后再跑一遍。测试失败时先假设重构引入了回归。无测试:用快照测试(toMatchSnapshot)快速锁定行为。核心逻辑:先补小范围单测再重构。UI 组件:用页面截图作基线,Playwright toHaveScreenshot 或 Chromatic 对比。类型变更:跑 pnpm typecheck 确认类型层面没破坏,运行时行为仍需测试保护。

行为保护的「不可改变项」清单

重构指令中,以下项目应显式列为「不可改变」,除非重构目标明确要求修改:

不可改变项(Invariants):
1. 组件的 Props 接口
2. 函数的参数和返回值类型
3. 导出的名称和方式(default vs named)
4. 路由路径和 URL 结构
5. 数据库 schema 和 API 契约
6. 文案和用户可见的字符串
7. 视觉样式(CSS 类名、样式文件)
8. 事件回调的签名和触发时机
9. 错误处理行为和错误消息
10. 第三方依赖的调用方式
11. 环境变量的读取方式
12. 日志输出的格式和级别
13. 并发和时序相关行为
14. 缓存策略和缓存 key

好的 diff vs 坏的 diff

审查 Agent 的 diff 时,几个快速判断标准:

好的 diff坏的 diff
可一句话解释混合多种改动
方向一致,所有改动指向同一目标大面积重命名
删除 > 新增引入新依赖
无副作用,不动不相关文件改了不相关文件
可分步验证改变公开 API
删除了测试

一句话解释不了收益,就该暂停拆分。

重构边界清单

下次让 Agent 重构前,按这个清单过一遍:

重构前

  • 明确重构目标:降低哪种复杂度(圈复杂度、代码行数、认知负担、耦合度)
  • 列出不可改变项:API、路由、文案、样式、依赖、导出方式
  • 确认行为保护证据:有测试?可以跑?没有测试?先补快照或截图
  • 评估影响范围:这个改动会影响哪些文件和模块?有没有跨模块依赖?
  • 拆分重构步骤:一次重构只做一件事。大范围的重构拆成多个独立 PR
  • 编写明确的重构指令:告诉 Agent 目标、边界、不可改变项

重构中

  • 监控 diff 大小:如果 diff 超过 200 行,考虑是否需要拆分
  • 检查 diff 方向:所有改动是否都指向同一个目标?有没有「顺手整理」?
  • 检查公开 API:Props 接口、导出方式、事件回调签名是否被修改?
  • 检查不相关文件:是否修改了目标文件之外的文件?

重构后

  • 运行验证命令:typecheck、测试、lint
  • 审查 diff 形状:能否用一句话解释收益?
  • 对比行为证据:测试通过?快照一致?截图一致?
  • 检查遗漏:是否有引用目标组件/函数的其他文件没有被更新?
  • 记录重构决策:在 PR 描述中写明目标、不可改变项、验证方式

重构指令模板

重构任务:[具体目标,如「降低 ArticleList 组件的圈复杂度」]
目标文件:[精确到文件和函数]

不可改变项:
- Props 接口不变
- 导出方式不变
- CSS 类名不变
- 事件回调签名不变
- 不引入新依赖
- 不修改其他文件

行为保护:
- 重构前运行 [命令] 确认测试通过
- 重构后再次运行确认测试通过

验证标准:
- typecheck 通过
- 测试通过

期望 diff:
- [一句话描述,如「将 300 行组件拆为 3 个子组件,公开接口不变」]

关键是把「不可改变项」放最前面。Agent 开始工作前就知道哪些不能碰,比事后检查 diff 有效得多。

结论

AI 做重构最容易超范围——目标函数倾向于最大化改善、上下文窗口产生全知错觉、缺乏风险权衡、没有沉没成本概念。应对策略:开始工作前把边界锁死。

  1. 先写不可改变项:API、路由、文案、样式、依赖
  2. 用行为保护重构:测试、快照、截图多层验证
  3. 审查 diff 形状:一句话说不清收益就拆分
  4. 拆分重构步骤:大范围拆成多个独立 PR

下次让 Agent 重构,先回答三个问题:

  • 要降低哪种复杂度?(目标)
  • 哪些东西不能改?(边界)
  • 怎么证明行为没变?(验证)

这三个问题能把失控的 diff 变成精准的重构。边界不是束缚,是让重构可控的前提。

参考资料

评论 0

0 / 1000