用 AI 重构总是越改越乱:先固定行为再动结构,每一步都能退回去
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
重构翻车的锅,多数人扣在”模型不懂我的架构”上,于是把设计意图写得越来越长、换更强的模型、喂更多的文件。真正的病根通常在另一处:你在还没有任何手段能证明”行为没变”的时候,就允许它同时动了结构。重构的定义就是外部行为不变、内部结构改变——一旦”行为不变”这半句没有可观测的证据,剩下的就不叫重构,叫无保护的改写。 模型强弱只影响改写的质量,不影响你能不能发现它改坏了。这两件事必须分开看,也决定了这篇文章的排查顺序:先确认你在做的是哪种事,再看行为怎么钉住,最后才谈结构怎么动、怎么退。
站内已经有两篇相邻的文章:一句需求换来大范围改动怎么收手 讲的是任务边界失控这个通用问题,几千行单文件改不动怎么切片 讲的是单个大文件的交付形态。本篇不重复那两件事,只按”重构”这一个工程环节往下切:这个环节里 AI 能替你做到哪一步、哪一步必须你自己拍板,以及每一步对应的验收动作和回退点。
一、先分清:你手上的到底是重构,还是改写
这一步不做,后面全是白忙。判别标准只有一句话:改完之后,同样的输入是否应该产生同样的输出。
- 重构:应该。提取函数、拆类、换命名、消除重复、把嵌套 if 换成早返回、把散在各处的配置读取收进一个入口——这些都不该改变任何外部可见的结果。
- 改写:不应该,或者你也不知道。修 bug、调整边界条件、换算法带来精度差异、顺手把某个默认值改”合理”了——外部行为本来就要变。
现实里最坑的是第三种:你想做的是重构,但 AI 顺手把它认为”不合理”的地方修了。它给你返回一段更干净的代码,同时悄悄把某个空值分支的处理从”返回空列表”改成”抛异常”。这类改动在代码评审里极难看出来,因为它长得比原代码还讲道理。
判断方法很直接:让它先输出改动清单而不是代码,清单里每一条标注属于”结构调整”还是”行为调整”。凡是它自己标成行为调整的,全部退回去,另开一次任务处理。凡是它标成结构调整但你一眼看出会改变返回值的,说明它对这段代码的理解有偏差,这时候继续往下推没有意义,先补上下文或者缩小范围。
这一步的成本很低,收益是把后面所有的排查从”代码为什么错了”降级成”哪一步改坏的”。
二、行为基线:AI 能帮到哪一步,哪一步必须你定
“先固定行为”具体要固定什么?不是把测试覆盖率刷到某个数字,而是拿到一组在重构前后都必须一致的可观测输出。这组输出就是基线。
AI 在这个环节能干的活,比在重构本身能干的多得多,但它有明确的天花板。
AI 能做的:
- 读一段没有测试的函数,枚举出所有分支路径和边界输入(空值、空集合、越界、类型异常路径)。这活它做得又快又全,比人肉扫分支可靠。
- 把这些路径写成特征测试(characterization test)的骨架——不是断言”应该等于什么”,而是断言”等于当前实际输出的什么”。这是给遗留代码上保险的标准做法:先把现状固化成测试,不管现状是否合理。
- 写一个跑批脚本,把一批真实输入喂进旧实现,把输出序列化落盘作为快照。
- 重构之后,逐条对比新旧输出的差异,并解释每处差异可能来自哪个改动。
必须你定的:
- 哪些行为是”契约”,哪些只是”当前恰好这样”。 一个函数返回列表的顺序,是调用方依赖的语义,还是实现细节偶然导致的?AI 没有这个信息,它只能看到代码。你把当前顺序固化成断言,重构时就被绑死;不固化,重构后顺序变了你也发现不了。这个取舍只能人做。
- 基线的输入取样。 拿哪批真实数据、覆盖哪些租户/时段/异常场景,取决于你对业务的了解。让 AI 造的输入通常是教科书式的均匀分布,真正会炸的往往是那几条脏数据。
- 允许的差异范围。 浮点计算、时间戳、随机排序、并发产生的日志顺序,重构前后不可能逐字节一致。哪些差异算无害、哪些必须归零,是你的判断。
- 基线本身的可信度。 特征测试有个天然的坑:它把 bug 也固化成”正确行为”。这是刻意的取舍——重构阶段不修 bug,先保证不引入新问题。但你要清楚哪几条断言其实是在保护一个已知缺陷,别在重构做完后忘了它还在那儿。
有一类失败特别容易被基线漏掉:测试跑绿了但根本没断言到关键路径。这在补测试的时候比想象中常见,站内 测试假通过是怎么发生的 那篇拆得比较细,重构前值得先照着核一遍你的基线是不是空转。
一个通用的快照对比做法,跟具体框架无关:
import json, pathlib
def snapshot(name, records):
"""把一批输出落盘,键是输入标识,值是规范化后的输出"""
out = {k: json.dumps(v, sort_keys=True, ensure_ascii=False)
for k, v in records.items()}
path = pathlib.Path("baseline") / f"{name}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(out, sort_keys=True, ensure_ascii=False, indent=2),
encoding="utf-8")
def diff(name, records):
old = json.loads(pathlib.Path(f"baseline/{name}.json").read_text(encoding="utf-8"))
new = {k: json.dumps(v, sort_keys=True, ensure_ascii=False)
for k, v in records.items()}
changed = [k for k in old if k in new and old[k] != new[k]]
return {"changed": changed,
"missing": [k for k in old if k not in new],
"added": [k for k in new if k not in old]}
sort_keys=True 这一步是关键:不做规范化,字典序抖动会让你看到一堆假差异,几轮之后你就开始忽略 diff 输出了。
三、判别表:出问题时先定位是哪一环松了
重构过程中出问题的现象就那么几种,成因却不同。对照下表定位,比重新读一遍代码快。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 改完编译/启动直接失败 | 引用未同步:改了签名或路径,调用方漏改 | 全量构建看报错位置;git diff --stat 看改动文件数是否远小于预期 | 不用回滚,让它按报错逐个补齐;补完重新跑构建 |
| 构建通过、测试全绿,上线后行为不对 | 基线没覆盖到那条路径,或断言空转 | 找一条线上失败输入,手工跑旧版本与新版本各一次对比 | 先把这条输入补进基线,再判断是哪次提交引入 |
| 新旧输出有大量细微差异 | 规范化没做(顺序、浮点、时间戳) | 挑两条差异手工看是否语义等价 | 补规范化再重跑,别急着改代码 |
| 只有个别输入结果不同 | 真的改变了行为,通常是”顺手修了个 bug”或边界分支被合并 | 对这条输入做二分定位到具体提交 | 回退那一步,把行为改动单独拆成一次任务 |
| 改动范围远超预期,牵出一堆无关文件 | 任务粒度太大,或它把格式化、命名统一顺手带上了 | git diff --stat 按目录看分布 | 丢弃本次改动重来,把任务拆成单一动作 |
| 每次让它继续改,之前改好的又变回去 | 会话里旧版本代码仍在上下文中,它按旧版本推理 | 让它复述当前这个函数的签名,看是否与磁盘一致 | 提交并开新会话,只带当前版本文件 |
| 结构改了但性能明显下降 | 抽象层次加深、循环内重复计算、原本的缓存被拆散 | 用同一批输入做前后耗时对比 | 单独评估是否值得,必要时回退这一步 |
| 回退之后状态还是不对 | 生成的文件、迁移、缓存不在版本控制里 | git status 看未跟踪文件;检查构建产物与数据库状态 | 按下一节的回退清单逐项清理 |
最后一行是老坑:git revert 只管代码,不管你在过程中跑过的数据库迁移和写进缓存的东西。站内 回滚之后状态不一致怎么处理 那篇专讲这个,重构前把清理路径想清楚,比事后补救省事。
四、动结构:一次一种改动,每步都留退路
行为钉住之后,才轮到结构。这里的核心纪律只有一条:一次提交里只包含一种机械变换。
所谓一种机械变换,指的是提取函数、内联变量、重命名、移动文件、改变参数顺序、拆分类这类有明确名字的单一动作。混着做的问题不在于难看,而在于二分定位会失效——当一次提交同时改了名字、挪了位置又抽了函数,你定位到这次提交也说不清是哪一处坏的。
可执行的节奏:
git switch -c refactor/order-service
# 每完成一种变换:
git add -p # 逐块看一遍,别整目录 add
git commit -m "refactor: 提取 calcDiscount,无行为变更"
python -m pytest tests/baseline # 或你自己的基线跑批
提交信息里明确写”无行为变更”,不是形式主义。等你半个月后回来做二分,这一句能省掉重读 diff 的时间。
几个具体做法:
先改名字,再动位置。 重命名在版本控制里是最容易看懂的 diff,先做完、单独提交,后面的移动才能被识别成移动而不是”删了一堆加了一堆”。反过来做,diff 会糊成一团,评审直接放弃。
先加新的,再切调用,最后删旧的。 三次提交而不是一次。中间态代码里新旧并存看着别扭,但每一步都能独立验证,也能独立回退。等所有调用方切完、基线跑绿,再单独提交一次删除。
让它一次只看到该看的。 重构任务的上下文供给和写新功能不一样:给它当前文件、直接调用方、基线测试就够了。给多了它就会开始”顺手优化”,这是范围失控的常见起点,机制部分前面那篇讲过,这里不展开。
每步之后跑基线,不要攒着。 攒三步再跑,出问题你要在三种变换里找,二分的价值就没了。
用工作区隔离并行尝试。 如果你想同时试两种拆法:
# 分支还不存在时必须带 -b,否则会报 invalid reference
git worktree add -b refactor/plan-a ../proj-planA
git worktree add -b refactor/plan-b ../proj-planB
两个目录、两条分支、互不干扰,比在同一个工作区来回 stash 可靠得多。分支已经存在时去掉 -b、把分支名写在路径后面即可。做完对比再决定留哪个,另一个用 git worktree remove ../proj-planB 拆掉;如果你已经手工把目录删了,再补一次 git worktree prune 清掉登记信息。
回退分三种,别混用。
- 还没提交:
git restore <path>丢弃工作区改动;想留着以后看就git stash push -m "planA" -- <path>。 - 已提交没推:
git reset --hard <commit>回到某个点,但要清楚这会丢弃之后的提交。 - 已推送或多人协作:用
git revert <commit>,生成一次反向提交,历史可追溯。多人分支上强推是另一类事故的开头。
定位坏在哪一步用二分。 前提是每次提交都能独立跑基线:
git bisect start
git bisect bad HEAD
git bisect good <重构开始前的提交>
# 每次它 checkout 一个提交,你跑一遍基线,然后二选一告诉它结果:
git bisect good # 基线绿
git bisect bad # 基线红
# 反复到它报出第一个坏提交,最后一定要收尾,否则工作区一直停在 bisect 状态:
git bisect reset
如果你的提交是”一次一种变换”,这个过程几分钟就能定位。如果是三天攒一次大提交,这个工具对你没用。
五、什么时候别再折腾了
重构是有沉没成本陷阱的活:已经改了两天,总觉得再改一晚就好了。给几条硬的判断线,触发任意一条就停。
第一条:同一处,AI 连续三轮没让基线的失败条数下降。 不是”没修好”,是失败条数没减少甚至增加。这说明它对这段代码的语义理解有偏差,继续对话只会在错误理解上叠更多改动。停下来,回退到最近一个绿点,人工读一遍那段代码再决定。这类”越修越乱”的循环有它自己的成因,AI 修不好时的思维循环 那篇专门拆过怎么打断。
第二条:你已经说不清当前工作区相对基线改了什么。 git diff --stat 输出超过你能一眼扫完的量,就是失控信号。丢弃重来的成本,几乎总是低于在混乱状态上继续推进的成本。
第三条:需要改测试才能让测试通过。 重构阶段修改基线断言,等于在改考卷。除非你能明确说出”这条断言固化的是一个 bug / 一个实现细节,我现在有意解除它”,否则一律不动断言。说不出来就是坏了。
第四条:为了结构好看,开始动数据结构或存储格式。 这已经越过重构的边界了,它带的是数据迁移风险,评估口径完全不同。拆成独立的任务,走独立的评审和灰度。
第五条:性能出现不可接受的回退,且原因不明。 抽象引入的开销有时很反直觉,查起来耗时长。如果这次重构的收益只是”更好读”,那么性能回退就是直接的否决项,回退比查因划算。
换条路的信号: 如果你发现真正的障碍是这段代码根本没人说得清它该做什么——没有文档、原作者已离职、调用方也不确定依赖了哪些行为,那么继续重构的前置条件不成立。这时候该做的是先补一层特征测试跑一段时间,观察线上真实调用分布,把契约摸清楚,而不是硬着头皮改结构。
六、避坑清单
坑一:让 AI 一次性交付”重构后的完整文件”。 为什么会踩:这么问最省事,返回的结果也最像成品。但整文件交付会把漏改、静默的行为变化都藏在一份看起来完整的代码里,你失去了所有局部信号。 怎么避:要求它输出”变换名称 + 影响的符号 + 具体片段”,你自己往文件里落。大文件的切法前面链的那篇有细节。
坑二:把格式化和重构混在一次提交里。 为什么会踩:AI 顺手统一了引号、缩进、导入顺序,看着舒服就一起提交了。结果 diff 里两千行噪音把真正的十行逻辑改动埋了,评审和二分都失效。 怎么避:格式化单独一次提交,且在重构开始之前做完。项目里配好统一的格式化配置,别靠模型自觉。
坑三:基线测试是让 AI 照着新代码写的。
为什么会踩:顺序反了却很难察觉——改完结构再补测试,你会不自觉地拿新实现当参照。这样的测试永远是绿的,因为它描述的就是新行为。
怎么避:基线必须在动结构之前生成、提交,并且在重构分支上禁止修改。可以在评审规则里明确:同一个提交同时改 src/ 和基线断言的,直接打回。
坑四:以为覆盖率高就等于行为固定了。 为什么会踩:覆盖率只说明代码被执行过,不说明输出被检查过。一堆只调用不断言的测试能把覆盖率刷得很好看。 怎么避:随机挑三条断言,手工把被测函数的返回值改错,看测试是否变红。不红的就是空转。
坑五:跨会话继续重构,模型按旧版本推理。 为什么会踩:长对话里前几轮贴过的旧代码还在上下文中,模型分不清哪份是当前磁盘状态,于是把已经改好的地方又改回去。 怎么避:每完成一种变换就提交,然后开新会话,只提供当前文件。判断方法很简单:让它先复述当前函数签名,对不上就说明上下文脏了。
坑六:注释和文档没跟着结构走。 为什么会踩:AI 挪代码时通常会带上注释,但注释里描述的调用关系、参数含义已经过时了。这种不一致比没注释更危险,后来人会照着注释理解。 怎么避:重构提交前扫一遍被动过的注释和文档字符串,把描述结构的部分(“由 XXX 调用""见下方的 YYY 分支”)当成代码一样核对,对不上就直接删掉,空着比错着强。
坑七:把重构做成了一个超长分支。 为什么会踩:想着”一次做干净”,分支开了两周不合。等主干往前跑了几十个提交,合并时的冲突量足以让你放弃整个分支。 怎么避:按变换切成能当天合入的小批。合不了主干的重构,等于没做。
收尾:动手前过一遍这个清单
重构这件事上,AI 的位置很清楚:它是一台高效的变换执行器和分支枚举器,不是决定”什么行为算契约”的人。你把契约定清楚、把证据准备好、把每一步切到可回退的粒度,它能帮你把两周的活压到两天;你跳过这些直接让它”优化一下这段代码”,它给你的就是一份读起来很舒服、没人敢合的 diff。
开工前逐条确认:
- 这次是重构还是改写,说得清吗?带行为变化的部分是否已经拆出去单独排期?
- 基线在哪、包含哪些输入、跑一次多久?现在跑是绿的吗?
- 基线里哪几条断言其实在保护已知缺陷,记下来了吗?
- 允许的差异(时间戳、浮点、顺序)是否已经在对比脚本里规范化掉?
- 分支开好了吗?工作区是干净的吗?
- 本次要做的变换列出来了吗?每一种是否能单独提交、单独验证?
- 回退路径想过了吗——除了代码,还有没有迁移、缓存、生成文件需要一起退?
- 止损线定了吗:连续几轮不收敛就停、改动量超过多少就重来?
这八条里但凡有一条答不上来,先别开始让它改。多花的十分钟,换的是出事时你知道该退到哪儿。