AI 把能跑的代码改坏了:先止损再回滚的排查顺序
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
这类事故里真正吃掉时间的不是改代码,是归错因:你以为是模型这次变笨了,实际是你的改动边界失控了。 一次会话动了十几个文件,测试从绿变红,你没有任何一个中间状态可以退回去,只能对着一堆混在一起的改动猜哪一处是元凶——这不是模型能力问题,是过程控制问题。模型确实会写错代码,但让错代码变成事故的,是你没留下可回退的坐标。
所以顺序必须是:先固定现场,再按现象分因,然后分离改动定位破坏点,最后才谈回滚粒度。这四步反过来做就会越修越乱:很多人第一反应是让 AI 继续修,于是坏状态被新改动覆盖,连”到底哪一版是坏的”都查不清了。
站内另外两篇讲的是相邻但不同的事:AI 代码能上生产吗谈的是上线前的准入标准,用 worktree 跑并行会话谈的是怎么在开工前就把改动隔离开;本篇只管一件事——事故已经发生了,你手上是一堆混着好坏的改动,怎么按顺序把损失止住。
一、第一步:固定现场,别让坏状态消失
现场包含两样东西:一个能复现故障的坏状态,一个确定可用的好状态。两个都要拿在手里,缺一个都没法做对比。
先停手。不要再让 AI 继续改,也不要手动”顺手修一下”。当前工作区就是唯一的坏状态样本,覆盖掉就再也拿不回来。
把它存下来:
git status
git stash push -u -m "broken-state-$(date +%Y%m%d-%H%M)"
git stash list
-u 会把未跟踪的新文件一起收进去。AI 生成的新文件常常还没 git add,只 stash 已跟踪文件等于把一半改动扔了。存完之后 git stash apply 可以随时把坏状态放回来,反复对比而不怕丢。
再确认好状态在哪:
git log --oneline -20
git reflog -30
reflog 比 log 有用得多。如果这轮改动中间有过 commit 又被 reset 掉,或者分支被切换过,reflog 里还留着那些提交的哈希。找到最后一次”你确信测试是绿的”那个提交,把哈希抄到一个记事本里,这是你后面所有对比的原点。
然后固定复现路径。写下最短的复现步骤:跑哪条命令、点哪个页面、传什么输入、期望什么、实际什么。这一步很多人跳过,代价是后面每次验证都用不同的方式试,结果时好时坏,误判成”随机故障”。
最后固定环境。改动期间如果装过依赖、动过环境变量、改过本地配置,这些都不在 git 里,但完全可能是真正的破坏源:
git diff HEAD -- package-lock.json pnpm-lock.yaml requirements.txt
env | sort > /tmp/env-now.txt
现场固定住之后,你才有资格开始判断成因。
二、第二步:分因,现象对应的是哪一类破坏
不要一上来就读 diff。先看现象属于哪一类,不同类别的定位成本差好几倍。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 语法错误、导入失败、构建直接崩 | 生成的代码不完整,或者合并时留下了冲突标记 | 全仓搜 <<<<<<<、>>>>>>>;跑一次构建看首个报错文件 | 单文件修正,不用回滚整批 |
| 测试红了,但报错信息指向一个你没让它改的模块 | AI 顺手重构了公共函数或类型定义 | git diff --stat 看改动文件清单,找出超出任务范围的那些 | 只回滚越界文件,保留范围内改动 |
| 本地跑得通,切到别的机器或 CI 就挂 | 依赖版本或环境变量被动过,锁文件与实际安装不一致 | 对比锁文件 diff;用干净目录重装依赖复跑 | 回滚锁文件与配置,代码改动可保留 |
| 请求返回 401 或 403 | 密钥、鉴权头或代理配置被改写 | 用 curl 带同一套凭证绕开应用层直接打:curl 通而应用不通,就是代码里读密钥或拼鉴权头的逻辑被改了;curl 也不通,与本轮改动无关 | 回滚配置与密钥读取逻辑 |
| 请求返回 429 | 触发了服务端的速率或额度限制,各家规则不同且会调整,以官方最新说明为准 | 停手等一会儿,再手动、低频地打一次同样的请求:能通就是节奏问题不是代码问题 | 不是代码故障,加退避重试而非回滚 |
| 返回 500,或出现 ETIMEDOUT、ECONNRESET | 服务端异常或网络链路问题,与本轮改动可能无关 | 用上一个好状态的代码打同一个接口 | 先排除外部因素,再看代码 |
| 自签证书环境下报证书链校验失败 | 改动引入了新的 HTTP 客户端,没继承原有的证书信任配置 | 对比新旧代码里创建客户端的位置 | 回滚客户端替换,或补信任配置 |
| 行为变了但不报错,结果数值对不上 | 业务逻辑被”优化”过,边界条件或默认值被改写 | 对同一组输入,用好状态与坏状态各跑一遍,逐字段对比输出 | 需要精确定位,见下一节 |
前七行都能在十几分钟内定位,最后一行才是真正花时间的。区分清楚这一点很重要:如果现象属于前七类,你根本不需要动回滚这么重的手段。
判别时有个容易被忽略的动作:确认故障真的是这轮改动引起的。把好状态检出到一个临时目录复跑一遍:
git worktree add /tmp/known-good <好状态哈希>
如果好状态也复现同样的故障,那问题在别处——可能是上游服务、可能是数据、可能是环境。这一步花两分钟,能省掉半天误判。
三、第三步:分离改动,把一堆改动切成可判定的块
到这一步你已知故障确实由本轮改动引起,且属于”行为变了但不报错”这类。接下来的目标不是找出正确写法,而是找出哪一块改动导致了行为变化。
最直接的办法是二分。如果改动过程中有若干个提交,用 git bisect:
git bisect start
git bisect bad
git bisect good <好状态哈希>
# 每次跑复现步骤,然后 git bisect good 或 git bisect bad
git bisect reset
复现步骤能做成一条退出码正确的命令时,可以直接自动化:
git bisect run ./scripts/repro.sh
问题在于,很多人整轮改动只有一个提交,或者干脆没提交。那就按文件二分。注意前提:第一步把坏状态 stash 走之后工作区是干净的,按文件二分要先 git stash apply 把坏状态放回工作区,再从里面往回退文件。把改动文件列出来,一半退回好状态,跑一遍复现;故障消失说明元凶在这一半里,继续切。
git stash apply
git diff --stat <好状态哈希>
git checkout <好状态哈希> -- 路径A 路径B
按文件切不动了就按 hunk 切,逐块决定这一块用好状态还是坏状态。两条命令等价,注意都要显式指定来源,否则默认只对比暂存区、已经 git add 过的改动不会被覆盖:
git checkout -p <好状态哈希> -- 路径
git restore -p --source=<好状态哈希> 路径
逐块看的时候你会发现一件事:AI 的越界改动通常有明显特征——重命名了你没提的变量、给函数加了你没要求的参数默认值、把某个显式判断改成了”更简洁”的写法。这些地方优先怀疑。
这里有个前提条件决定了这一步是十分钟还是两小时:改动的粒度。如果你让 AI 一口气改了十五个文件才第一次跑测试,二分的搜索空间就是十五个文件;如果每改两三个文件就跑一次测试并提交,搜索空间只有两三个文件。这不是回滚技巧,是开工时就该定的规矩——具体怎么在会话里控制单次改动范围,可以配合计划模式先把改哪些文件说定,再让它动手。
四、第四步:定回滚粒度,四档里选一档
定位到破坏点之后,别急着让 AI 改。先决定回滚粒度,四档从轻到重:
第一档,只回滚配置与依赖。 适用于依赖版本、环境变量、锁文件被动过的情况。代码改动全部保留,只把这些文件退回好状态。成本最低,优先尝试。
第二档,挑拣保留。 适用于改动大部分有价值、只有少数越界的情况。用 git checkout <哈希> -- 路径 把越界文件逐个退回,其余保留。做完必须完整跑一遍测试,因为部分回滚很容易留下调用方与被调方版本不一致的半截状态。
第三档,整批弃掉重来。 适用于改动交织太深、切不开的情况。
git reset --hard HEAD
git clean -fd
用 reset --hard 而不是 git checkout -- .:后者只丢弃工作区相对暂存区的改动,AI 工具或你自己顺手 git add 过的部分会原样留下,弃得不干净。如果坏改动已经落成本地提交,把 HEAD 换成好状态哈希。git clean -fd 会删掉未跟踪文件,执行前务必确认第一步的 stash 已经存好,否则删掉就真没了。整批弃掉不等于白干——你已经知道了故障现象和触发条件,重来时把这些作为约束写进任务说明,第二次通常比第一次快。
第四档,回滚已经推出去的提交。 如果坏改动已经推到共享分支,用 git revert 而不是 git reset:
git revert <坏提交哈希>
reset 会改写历史,别人拉过之后你再改写就会给整个团队制造麻烦。revert 生成一个反向提交,历史线性可查,代价只是多一个提交记录。
选哪一档有个粗略判断依据:如果挑拣保留的预估工作量超过重写的工作量,就直接选第三档。人对”已经写好的代码”有沉没成本偏好,会高估挑拣的价值。
五、什么情况下别再折腾
止损点比技巧重要。下面几种情形出现时,继续在原路上修的期望收益是负的。
同一处代码让 AI 改了三次还没好,停。 三次都没解决,说明它对这段代码的理解和你的期望有系统性偏差,第四次只是换个写法继续错。这时候要么你自己读代码定位,要么换一种拆解方式重新描述任务,而不是重复要求。
每次修复都让另一处坏掉,停。 这是典型的上下文不足或者改动范围失控。继续修会进入按下一个冒起一个的循环。正确动作是退回好状态,把任务切小,一次只动一个模块。会话里携带的信息太多太杂也会导致这种漂移,怎么控制可以参考上下文管理。
故障复现不稳定,停。 时好时坏说明变量没控住——可能是缓存、可能是并发、可能是外部服务波动。在复现不稳定的状态下做二分,结论一定是错的。先把复现变稳定,或者接受”这不是代码问题”的可能。
你已经花掉的时间超过重写这段功能的时间,停。 直接回到好状态重写。
报错指向服务端而非代码,停。 429、500 这类响应码说明问题在服务侧或调用节奏上,改代码逻辑没用。这类情况要做的是加退避重试、检查调用频率,或者切到备用通道。
换条路的判断依据。 如果你怀疑是模型本身不适合这类任务,可以换模型或换工具再试一次同样的任务——但要注意,部分海外 AI 编程工具官方对中国大陆的服务范围有区域限制,可用性与合规口径以官方最新说明为准,本文不讨论任何绕过手段。更现实的做法是先把任务拆得更小,多数所谓”模型不行”的场景,换成三个小任务就通了。
六、避坑清单
用 AI 修 AI 改坏的代码,却没告诉它坏在哪。 为什么会踩:出问题时人的第一反应是把报错贴过去让它修,觉得信息够了。实际上它看不到你的复现步骤、看不到改动前的行为,只能基于报错猜。怎么避:贴报错的同时给出三样——最短复现步骤、改动前的正确行为、你已经排除的可能性。
在没存 stash 的情况下执行 git clean -fd。 为什么会踩:清理命令用惯了会形成肌肉记忆,而 AI 生成的新文件默认是未跟踪状态。怎么避:把”先 stash 再 clean”固定成两条连续命令,stash push -u 一定带 -u。
部分回滚后只跑了出问题的那个测试。 为什么会踩:挑拣保留时注意力都在破坏点上,觉得那处好了就完了。实际上部分回滚会造成接口版本错配,坏在别的地方。怎么避:任何部分回滚之后跑全量测试,而不是只跑刚才那一个用例。
把锁文件的改动当噪音忽略。 为什么会踩:锁文件 diff 动辄几百行,看不出信息量,习惯性跳过。实际上依赖版本变化是”本地能跑、别处挂掉”的头号原因。怎么避:只看锁文件里包名和版本号那几行的变化,工具链有专门的锁文件 diff 展示就用它。
用 git reset 处理已推送的坏提交。 为什么会踩:本地用 reset 顺手,忘了这个提交已经在别人机器上了。怎么避:判断依据只有一条——推出去了用 revert,没推出去才可以 reset。
把密钥和配置的改动混在同一批里提交。 为什么会踩:AI 改动过程中常会顺手动配置文件,而配置和代码在同一次提交里,回滚时就没法分开处理。怎么避:配置类文件单独提交,回滚时可以只退这一档。相关的密钥管理规矩见API 密钥安全管理。
故障定位完就直接上线,没补测试。 为什么会踩:定位过程耗尽了耐心,修好那一刻只想赶紧收工。结果同类问题下次还会发生,而且下次同样没有测试能挡住。怎么避:把这次的复现步骤直接转成一个测试用例,代价通常十分钟以内。上线前的检查项可以配合代码评审工具一起做。
收尾
这套顺序的核心不是 git 命令,而是”任何判断之前先保住两个状态”。坏状态是你的证据,好状态是你的原点,两者都在手上,剩下的都是机械操作;两者丢一个,你就只能靠猜。
事故当场可以过一遍这份自检清单:
- 停手了吗?坏状态 stash 了吗?带
-u了吗? - 好状态的提交哈希记下来了吗?在临时 worktree 里验过它确实是好的吗?
- 最短复现步骤写下来了吗?复现稳定吗?
- 现象归到判别表哪一行了?是不是根本不需要回滚?
- 锁文件、环境变量、配置文件的 diff 看过了吗?
- 决定回滚粒度前,比过挑拣与重写的工作量了吗?
- 坏提交推出去了吗?推了就用 revert。
- 修完跑的是全量测试吗?这次的复现步骤转成测试用例了吗?
下一轮开工前顺手做一件事:约定每改动两三个文件就跑一次测试并提交。搜索空间从十几个文件降到两三个文件,同类事故的定位时间就跟着降下来,比任何回滚技巧都划算。