我大半夜盯着终端里那条排在最上面的报错,脑子里只有一个念头:完了,这代码被 AI 改乱了。项目原本 200 行出头,三轮修改之后变成了 500 行,报错倒是没变多——它换着花样变。从NoneType的属性错误变成字符串的属性错误,再变成时区序列化的类型错误。每一步看起来都有道理,每一步都没修到根上。
后来我做了件事:把报错缩成一个十几行的小例子,再扔回给 AI。结果它一眼就找到问题。这篇文章想跟你聊的就是这件事——为什么 AI 改代码会越改越乱,以及“把报错缩成最小可复现示例”这个动作如何让局面回到正轨。适合正在用 AI 辅助写代码、但经常被它改到失控的人。
1. AI 越改越乱,问题出在上下文,不在“模型笨”
很多人把“AI 改代码越改越乱”归咎于模型智力不够,我用了挺久之后发现不是这么回事。问题更像出在信息组织方式上:我们给 AI 看的上下文,本身就是一堆互相干扰的线索。
1.1 你以为它在诊断,其实它在预测下一个 token
首先要接受一个事实:AI 修 bug 的过程,本质上不是“医生看病人”式的诊断,而是“根据已有上下文预测最可能补丁”的生成过程。它没有运行过你的程序,不知道你定义的那些变量在真实运行时是什么值,它只是从海量代码里学到了“这种报错后面大概跟着这种修复”。
所以在一次对话里,你贴的报错全文、贴的完整模块、贴的关键函数,对它来说都是输入特征。特征越多,模型就越可能被那些“看起来相关、实际上无关”的代码带偏。比如它看到一个datetime的序列化报错,就真的去改所有与日期有关的地方,哪怕根因根本不在那里。
这也是为什么老练的开发者用 AI 时总爱“喂小样”:不是模型笨,是它容易在复杂上下文里做错误的归因。
1.2 “新报错盖住旧报错”的上下文污染
“越改越乱”还有个特别经典的路径:第一轮 AI 改完,原来的报错确实消失了,但新的报错冒出来。你继续把新报错发过去,AI 继续改。几轮下来,上下文的最后几条全是“昨晚的新报错”,而最初的触发条件、最初的代码结构反而被冲出了窗口之外。
我管这叫“报错平移”。它最坑的地方在于:每轮 AI 都是在“上一轮的修改结果”上打补丁,而不是在“原始 bug”上动手术。补丁叠补丁,代码自然膨胀。
你可以回忆一下自己踩坑的经历,是不是经常出现这种情况:
| 轮次 | 你给 AI 的信息 | AI 的典型行为 | 结果 |
|---|---|---|---|
| 第一轮 | 完整报错 + 完整文件 | 定位到某一处,给出补丁 | 原报错消失 |
| 第二轮 | 新报错 + 上次的对话记录 | 基于补丁继续改“关联位置” | 新报错冒出来 |
| 第三轮 | 更新后的报错 + 更长上下文 | 开始修改看起来相关的无关代码 | 代码膨胀,错误继续平移 |
| 第四轮 | 你已经有点慌,直接说“帮我看看哪里有问题” | 到处给建议,涉及多个文件 | 改完不知道能不能跑 |
这个表不是讽刺 AI,而是提醒我们:上下文轮数越多、信息越杂,AI 的注意力就越分散。
1.3 对话轮数越多,AI 越容易固化前几轮的错假设
还有一层隐蔽的机制:AI 在长对话里会把前面几轮的“错误判断”当成既成事实吸收进去。如果你第一轮同意了它一个错误的方向,后面几轮它大概率会顺着这个方向继续修,甚至会为了自圆其说,主动改动那些本来没病的地方。
简单说,它也会“被自己带偏”。所以正确姿势是:不要在一个对话里无限续杯。一旦发现改了两次还没好,果断把场景切回原始问题,把上下文清空,重新构造问题。
这正是“缩成小例子”的用武之地:当你把报错缩到只剩核心逻辑时,对话记录里那些无效补丁就全被切掉了,AI 的注意力被迫回到真正出错的表达式上。
2. 把报错缩成小例子:先承认自己需要“信噪比”
“先把报错缩成一个小例子”这句话听起来像常识,但我见过太多人做不到。不是能力问题,是心态问题——总觉得自己能把完整上下文讲清楚,觉得 AI 应该能理解整个项目结构。结果往往是被乱改一通之后,才回头去搭最小例子。
2.1 缩小例子的核心是提高信噪比
假设你的一段业务代码有 800 行,真正出错的逻辑可能只有 20 行。完整代码喂给 AI,20 行对 780 行噪声,信噪比大约 1:39;缩成 30 行的最小例子后,信噪比变成 2:3。AI 给出的建议质量完全不在一个量级。
这不是玄学。模型在长上下文中定位问题的能力,远没有我们想象中那么强。它更擅长在“紧凑、确定、可验证”的小代码片段里做推理。类似让一个远程专家看一整个仓库的日志,还是看一个十几行的复现脚本——正常人都会选后者。
2.2 缩小过程也是一次带定位的二分查找
更大的收获在于:缩小报错的过程,本身就是一次人脑辅助的二分定位。
你注释掉前半段代码,发现报错还在;注释掉后半段,报错消失。这就把问题范围砍掉一半。你再删掉某个依赖调用,报错从NoneType变成了IndexError,这又告诉你错误发生在更具体的下一层。每一轮删除都是一次“自变量控制”,最终你会惊讶地发现,自己已经知道问题大概在哪一行了。
这也是为什么我总跟人强调:让 AI 修代码之前,请先手动把一个复杂场景缩到可控范围。不是为了迁就 AI,是为了让你自己在喂 Prompt 之前就完成 80% 的排查。
2.3 什么时候不需要“完全最小”
当然,不是所有场景都得缩到极致。如果 AI 只是帮你写一段新函数、做代码风格调整,或者解释一段陌生代码,直接给完整文件完全没问题。需要“最小化”的是特定情况:存在一个可复现的报错,而你希望 AI 定位根因并给出可靠修复。
还有一种情况是:报错依赖真实的分布式环境或大量外部数据,完全最小化会导致错误无法复现。这时不需要追求“彻底最小”,只需要把触发条件固定下来,比如保留能稳定触发的测试数据,禁止再改其他模块。后面的章节我会说怎么处理这类边界。
3. 四步实操法:从完整报错到最小可复现示例
这部分我直接给一套我反复用、确认有效的最小化步骤。不需要特殊工具,一个编辑器、一个能跑脚本的终端就够了。
3.1 第一步:固定错误现场
先别急着删代码。第一步是把“错误现场”完整记录下来:准确的报错类型、报错信息、回溯栈里第一个属于自己的函数、触发这个报错的输入是什么。如果你在修数据相关的 bug,最好把导致失败的那条数据单独拎出来存好。
这一步的目的,是防止你在删代码的过程中“修好了报错却无法确认根因”。有现场才能做对照实验:改一步,跑一次,看报错有没有变化。
提示:记录报错时连“成功路径”也要记一笔。比如同样一份数据,哪个分支能跑通、哪个分支必崩。这样你缩小出来的例子自带对照组,AI 判断起来会容易得多。
3.2 第二步:二分法删代码,而不是猜代码
最小化的核心操作是二分删除。把程序从入口处拆成两个部分:先注释掉后半部分,让函数入口返回固定数据,看报错还在不在。还在,说明问题在前半段;消失了,说明问题在后半段。
重复这个步骤,每次删掉大约一半的执行路径。大概四到五次之后,你会得到一个“只跑关键路径”的脚本,里面没有数据库、没有外部接口、没有定时任务,只有触发报错的那几行运算。
这里有个很多人会犯的错:他们倾向于“按功能删”,比如删掉静态文件处理、删掉日志模块、删掉监控上报。但功能之间可能有隐藏的耦合,你删错了就会把报错也删没。二分法按执行路径删,不按业务模块删,能最大程度避免误删。
3.3 第三步:用假数据骗过真实依赖
等到脚本里只剩下跟报错相关的逻辑时,把外部依赖替换成假数据。数据库查询结果用一个写死的列表代替;网络请求返回值用一个字典代替;当前时间用固定时间戳代替;随机数用一个常量代替。
替换原则只有一条:让程序的输入变得确定。这样你每次运行都会得到一样的结果,报错要么 100% 复现,要么 100% 消失,不会再出现“有时候崩、有时候不崩”的随机状态。
这一步也很考验耐心。你会发现有些“假数据”并不那么好造,因为真实依赖的参数结构很复杂。但请相信我,造假数据花掉的十分钟,一定比你被 AI 乱改两小时省时间。
3.4 第四步:留一个确定性骨架
最后留下的代码,我建议满足三个特征:可运行、可崩溃、可验证。
- 可运行:一个命令就能跑起来,不需要先启动一堆服务。
- 可崩溃:只要复现原报错,程序就稳定地崩在你面前。
- 可验证:你能立刻判断“这次改动是否让报错消失”。
我最常用的形式是一个 20 行左右的 Python 文件,顶部定义假数据,中间是疑似有问题的逻辑,底部加几个print把关键变量打出来。这样做的好处是:当你把这个文件发给 AI 时,它不需要理解你整个项目,只看这几行代码就能分析;你本地也能随时跑,立刻验证它的修复是否有效。
对比一下三种方案的取舍:
| 方案 | 代价 | 收益 | 适用场景 |
|---|---|---|---|
| 完整项目直接问 AI | 零成本 | 低,AI 常被噪声带跑 | 代码量小、结构清晰 |
| 复制关键函数 + 报错 | 少量 | 中,能解决局部问题 | 大多数日常 bug |
| 最小可复现示例 | 较高 | 高,AI 能准确定位根因 | 反复修改仍未解决的顽固问题 |
4. 实战复盘:一个从 200 行改到 500 行的翻车现场
理论说多了,上一个真实的例子。这不是网上抄来的案例,是我帮某位同事排查的一段内部数据导出脚本,典型得几乎可以作为反面教材。
4.1 事发现场:三轮修改,错误从 A → B → C
某个内部工具里有段导出逻辑,原本 200 行左右,功能是把一批订单数据做格式化后输出。第一轮让 AI 改的时候,报错是'NoneType' object has no attribute 'strftime',某条数据的deadline字段是空值。AI 给的第一版修复是“如果 deadline 为空,就给它一个默认时间”。
这听起来很合理对吧?问题在于它顺手把读取数据那里也改了,改了日期格式的解析逻辑,于是第二轮的报错变成时区对象无法序列化成 JSON。AI 又加了一层isoformat(),结果某个变量在它前一轮的修改中已经被写成了字符串,第三轮报错变成'str' object has no attribute 'isoformat'。
三轮下来,代码从 200 行膨胀到 500 行,功能没修好,反而多了一堆补丁函数。那位同事的原话是:“我都不敢回滚,因为我不知道回滚到哪一版才是对的。”
4.2 我如何一步步把它缩成 10 行
我的第一步是停掉所有“继续改”的动作,把报错现场固定住:最后一次报错、触发那条数据的样例、当前代码里deadline变量在失败时到底存了什么。
第二步做二分删除。我把导出流程拆成三段:读取数据、格式化字段、写文件。注释掉写文件部分,报错还在,说明问题在读取和格式化;再注释掉读取部分,直接用一个写死的字典列表代替真实数据,报错直接消失。于是范围缩小到“真实数据 → 格式化字段”这一段。
第三步是造假数据。我本来想复制那条真实数据,但发现它太长了,就手工构造了两条:一条deadline是正常字符串,一条deadline是None。然后发现只有第二条会崩,并且崩在deadline.strftime(...)这一行。到这里,整个问题已经很明显了,但我还是决定把最终的小例子发给 AI,看看它的修复建议是否和我的判断一致。
最终留下来的脚本大约 20 行,结构类似:
# 模拟一条触发问题的数据 order = {"id": 1, "deadline": None} # 疑似有问题的格式化逻辑 def format_order(order): deadline = order["deadline"].strftime("%Y-%m-%d") # 报错行 return {"id": order["id"], "deadline": deadline} print(format_order(order))4.3 小例子让 AI 给出了正确的修复
把这个代码发给 AI,它的反应和之前完全不一样。几分钟内它给出的结论是:deadline在真实输入里是None,所以strftime必崩,根因在上游数据解析时不处理空值,而不是在格式化函数本身。
它给的最小修复是在数据读取层加一个空值守卫:
if not value: deadline = None同时它没有再建议我去改任何其他函数。因为小例子把它的注意力锁定在“数据是空”这一个事实上,它不会再发散到时区、序列化、默认时间这些干扰项上。
我后来把这个修复合并回完整项目,一轮通过。
4.4 复盘:小例子为什么一击命中
回头看我同事原来喂给 AI 的信息,问题也很清楚:他每次都贴一长段日志和整个文件,日志里夹杂了权限警告、缓存过期、字体缺失这类无关信息。AI 确实优秀,但在一堆噪声里选错了重点。
小例子的作用就是把“什么是核心”强制提前定下来。你删代码的过程,相当于手动给 AI 做了一次特征筛选,最后留下的每一行几乎都是 bug 相关特征。当输入全是高价值特征时,模型自然容易收敛到正确结论。
5. 让 AI“不乱改”的 Prompt 心法
缩成小例子只是第一步,怎么把例子喂给 AI 同样有讲究。同样一段代码,措辞不同、约束不同,AI 的行为差别很大。下面几条是我反复踩坑总结出来的。
5.1 控制信息量:只给最小例子,别贴完整日志
很多人喂 Prompt 时手太重,生怕 AI 看不到足够信息,于是把日志、配置、相关文件全甩过去。结果 AI 就像同时看了十份病历的实习医生,每条线索都觉得可疑,每条都可能去碰。
我现在喂报错相关的问题,结构基本固定:什么代码、什么输入、什么报错、期望什么结果。四行以内说完。比如:
这段代码在 deadline 是 None 时崩溃,但 deadline 可能是空。 帮我定位根因并给出最小修复。 代码:...反例则是:
这是我们的导出模块,完整代码如下。它最近一直报错, 日志贴在后面。另外这几个配置文件也给你参考。 你可以顺便优化一下代码结构。后者简直是给 AI 乱改下发许可证。它不但要修 bug,还被你授权“顺手优化”,不越改越乱才怪。
5.2 先要“诊断报告”,再要补丁
我试过很多次直接说“帮我改一下”,AI 往往直接甩一段修改后的完整文件,你根本看不出它的判断依据。现在我让它先输出诊断报告,再给补丁:
- 请先说最可能的一个根因是什么,用证据支撑;
- 如果无法 100% 确定,请列出两个候选原因,并说明如何区分;
- 确定后再给出最少代码变更,禁止跨文件修改。
这样做还有一层好处:AI 先写诊断时,它会重新检查自己的推理,很多“看似合理实则瞎猜”的答案会在这个过程里被它自己筛掉。对我而言,读诊断报告也比读 diff 更高效,因为我能快速验证它有没有跑偏。
5.3 用一条测试命令守住修复
最小例子的另一个好处是:修复后你能很快验证。我会在 Prompt 结尾加上一句“请确保下面的命令能直接运行通过”,或者干脆在代码块里自带一份断言:
# 断言:None 应该被处理,不再崩溃 assert format_order({"id": 1, "deadline": None}) is not None print("ok")这样 AI 在生成补丁时,会主动往“让断言通过”的方向收敛,而不是写一些概念上正确但实际跑不起来的建议。人在这个环节也不要偷懒:修复后务必跑一次最小例子,再决定是否合并回真实项目。
注意:如果 AI 给出的修复让你必须同时改三个地方才能跑通,你就要警惕这是补丁式修法,而不是根因式修法。根因修复通常是小而集中的,补丁式修复才会四处开花。
5.4 我现在的处理顺序和实用清单
把上面的经验串起来,我现在遇到“AI 改来改去还是崩”的情况,处理顺序是固定的:
- 停手,不再续对话。新开一个窗口,避免上下文污染。
- 固定现场:记录报错、输入、崩溃行。
- 二分删代码,把报错缩到一个 20 行以内的小例子。
- 用假数据确保确定性,运行确认它稳定复现。
- 把“代码 + 输入 + 报错 + 期望”发给 AI,先要诊断,再要最小补丁。
- 本地跑断言验证,通过后再合并回项目。
- 合并后跑一遍完整流程,确认没有报错平移。
这个过程听起来繁琐,但真正遇到疑难杂症时,比我盲目让 AI 改第四轮省时间得多。尤其是“最少代码变更、禁止跨文件修改”这条约束,几乎能杜绝 AI 把 200 行改到 500 行的情况。
如果你手头也有一个越改越乱的烂摊子,别急着责备模型,也别急着回滚,花二十分钟把报错缩成一个小例子。你会看到 AI 的判断力在一个干净的输入上完全不一样。