“AI改代码越改越乱”,这事儿我太熟了。前阵子帮一个学员排查报错,他让AI修一个接口超时问题,AI先给他改了数据库连接池配置,然后改了ORM查询,接着把整个函数的错误处理逻辑重写了一遍,最后还把无关的一个工具函数顺手“优化”了。改完以后,原来的报错倒是没了,但三个原本正常的页面直接崩了两个。他说:“我现在看到它改代码,心里就慌。”
这种场景不是个例。我自己的经验是,AI改代码越改越乱,九成以上是因为我们对它“太慷慨”了——把整个项目的上下文都甩给它,让它在一个巨大的、充满无关信息的代码海里找那根针。AI不是不行,是它的注意力被稀释了。所以我现在帮人解决这类问题,第一句话永远是:先把报错缩成一个小例子,缩到不能再缩为止。这不是什么高深技巧,就是把给AI的问题“降维”,让它只能看到病灶,看不到整个病人。
这篇内容我会把这套方法掰开揉碎:为什么AI越改越乱、最小复现例子的原理是什么、怎么把一个几百行的业务模块压缩成一个十几行的demo、压缩完该怎么交给AI、以及压缩过程中常见的坑。全程会用我排查过的实际案例来讲,尽量给你能照着走的路数。
1. 为什么AI改代码会越改越乱:不是它笨,是它“看太多”
1.1 上下文窗口的注意力稀释问题
先打一个比方。你让一个医生看病,如果只说“我肚子疼”,医生会按常规思路排查;如果你把过去十年的体检报告、所有化验单、不同医院的病历全堆在他面前,他反而不知道该信哪个,可能开出一堆无关检查。AI改代码也是一样——它的注意力是有限的,夹在几百行业务代码、配置文件、路由声明、数据库模型之间,它很难准确判断哪个信息跟这个报错真正相关。
现在的大模型普遍有很长的上下文窗口,动辄几十万token,听起来很厉害,但“能容纳”不代表“会在意”。有研究表明,模型对上下文中间部分的信息关注度会明显下降,这就是所谓“lost in the middle”。你把它需要的报错信息埋在1300行代码中间,它大概率扫一遍之后注意力全在那些结构规整、逻辑完整的模块上,而真正出错那几行反而成了背景噪音。所以你看到的结果就是:改一个配置、调一个参数、重写一个函数,看着都在忙,实际上跟病灶没关系。
更有意思的是,AI还有一种“讨好倾向”。当你给出一个庞大的代码库让它“找出并修复报错”,它倾向于展示自己“做了很多工作”,因为它被训练成“越是完整详尽的回答越被认可”。它会把无关的函数顺手“优化”,会把过时的注释清理掉,会顺手把你的工具函数改成更“现代”的写法。这些动作在单个审查时看不出毛病,但叠加在一起,就是灾难。
1.2 上下文污染是怎么让模型“带偏”的
另一个核心问题是上下文污染。这里说的污染不是指脏数据,而是指“跟当前报错无关的代码,会反过来干扰模型对报错成因的判断”。
举个例子。我有个模拟项目,里面有个线上日志分析系统,报错是“KeyError: 'request_id'”。我当初图省事,直接把整个处理流程的代码(大概400行)扔给AI,还附带了一堆日志配置和测试脚本。AI给出的修法五花八门:有人说要检查日志配置的字段映射,有人说要用get方法带默认值,有人把异常捕获范围扩大到了整个函数,还有人说测试脚本里构造的日志格式不对。其实真正的锅只有一个——在某个数据处理分支里,上一层的返回结果没有按约定字段命名,导致这个key根本没被写入。那个field mapping的代码块被淹没在400行里,模型看到了所有相近的、跟key相关的代码,于是产生了大量“相关但错误”的判断。
这就是上下文污染的本质:AI会从它看到的所有内容里挖掘模式,哪怕这些模式和报错只是“看起来相关”,它也会当成线索。你给的代码越多,伪线索就越多,模型被带偏的概率就越大。
1.3 为什么“最小复现”能打破这个死局
把报错缩成一小段独立示例,本质上是替AI做了“特征选择”。你剪掉了所有无关分支,保留从输入到报错的最短路径,这样模型面对的是一张干净的X光片,而不是全身CT薄层扫描。在这个小例子上,AI的“推理能力”才能真正发挥作用,因为它不需要花大半的注意力去分辨“哪些代码跟这个报错相关”,它可以聚焦于“为什么这一行会出这个错”。
我自己实测下来,给AI喂一个20行左右的最小复现,它的修复准确率远远高于给2000行的完整项目。这个差别不是一点半点,是质变。用最小复现调试,AI给出来的方案通常也能让你一眼就看明白,因为它的推理链路短,解释的每一条都能对应到具体代码行——这对验证它改得对不对也特别方便。
2. 把报错缩成小例子的实操路径:四步剪枝法
2.1 第一步:从“报错栈”反推最短触发路径
拿到一个报错,别急着打开编辑器,先把报错信息完整读一遍。我见过太多人只复制最后一行错误描述(比如“KeyError: 'request_id'”),把前面的Traceback整个忽略了。实际上Traceback才是定位最短路径的关键——它清晰地列出了从程序入口到错误发生点的每一层调用。
我的做法是,新建一个文本文件,把Traceback完整贴进去,然后从最底层的调用开始,逐层往上标注“这一层是干什么的”。走到一半你会发现,很多调用层其实跟这个错误无关——它们只是把参数传下去,或者做了一些通用处理,这些就是可以第一批剪掉的枝干。
举个例子,如果报错在某个数据转换的辅助函数里抛出来的,而Traceback显示调用链是:路由处理 → 鉴权 → 读取配置 → 数据转换,那么前两层基本可以确定不参与问题。你可以只保留数据转换那一层,再往上补一个最简单的入口来触发它。这个过程有点像一个漏斗,初始是1:40的结构,最后筛到1:5。
2.2 第二步:剥离“基础设施依赖”,用假数据顶上
很多代码没法直接跑,是因为它依赖了数据库连接、外部接口、配置文件,甚至是消息队列。最小复现的标准是“能跑起来就行”,所以这些外部依赖全部可以去除,用硬编码的假数据顶替。
打个比方。如果一个函数报错是因为“期望输入的是一个列表,但实际得到一个None”,那你根本不需要数据库,直接在代码里写死:
data = None # 模拟上游返回了None result = process_data(data) # 这里报错了这就够了。数据库连接、ORM映射、真实调用链,统统不需要。
需要说明的是,这个剥离过程不是洪水猛兽,反而是帮你理清思路。要伪造上游数据,你必须知道上游返回值的“真实结构”,你得去看接口定义、去翻日志、去找字段约定。这个过程本身就是排查问题。很多次我在伪造数据的时候,看着看着字段结构就自己发现bug在哪了——根本没到喂给AI那一步,问题已经解决了。所以,即使你最后不找AI,缩例子这个动作本身就是在排查。
2.3 第三步:逐步删除“非必要代码”,守住最小复现的三条标准
当你把依赖都换成假数据以后,第一版还原出来可能还是七八十行。不要慌,继续剪。剪枝的原则很简单:每一行都问自己,“这行删掉,报错还会出现吗?”如果会,留着;如果不会,说明它不是触发条件,删掉。
剪到什么时候为止?我一般用三句话来验收:
- 代码能独立运行(不依赖外部服务、不依赖项目里其他模块);
- 报错能稳定复现(同一个输入必然触发同一个错误);
- 删除任意一行,报错要么消失、要么变成另一个不同的错误(换言之,每一行都有它存在的必要性)。
第三条经常被忽略,但特别重要。它保证了“最小”——你没法再缩小了,这个体量就是触发报错的最小单位。一个学员问过我:删一行报错就变了,那不是说明可能删早了?不一定。报错“变了”意味着你剪掉了报错链上的一环,可能是中间处理步骤,也可能是异常包装层。这个现象说明当前版本已经是“局部最小”,你可以接受它作为复现代码,也可以在它基础上继续探索其他触发路径。
2.4 第四步:把“真实运行环境”也调成可控状态
最小复现不只要求代码小,还要求“运行环境可预期”。比如报错是跟时间相关的,那你的测试代码里最好把“现在”固定下来;跟随机数相关的,最好把种子设死;跟并发相关的,最好把并发数写死。
我记得有一次模拟一个支付回调排序的问题,真实业务里是异步消息触发的,报错时有时无。这个例子很难缩小,因为并发触发条件本身就依赖运行时序。后来我把复现步骤简化成了:用固定顺序调用两次回调函数,中间不插入任何异步操作,问题一下就变成稳定复现了——虽然它不再是“全真模拟”,但触发了同一个bug,就是合格的最小复现。
这也给你提了个醒:最小复现的核心目标是“复现bug”,不是“复现业务”。不要追求场景逼真,要追求触发可靠。在bug面前,真实业务是P0,复现至上;等修完以后再去验证真实场景。
3. 把最小复现交给AI的正确姿势:不只是贴代码
3.1 一份合格的“AI调试工单”要包含四件套
很多人喂给AI一个最小复现,结果还是不满意,问题往往出在“只给代码、不给说明”。AI不是读心术,它看到一段代码和一个报错,能猜出你要它修,但猜不出你还有没有别的约束。我总结了一个“四件套”格式,每次照这个写,AI的理解质量直线上升:
- 目标行为描述:这段代码本应该做什么?一行话就行,比如“这个函数应该从字典里取出request_id并打印”。
- 当前实际行为:运行后发生了什么?把完整报错贴进来,注意是“完整traceback”加“最后一行错误”。
- 最小复现代码:上面剪枝得到的那一小段,必须是能直接运行的最小代码块。
- 隐藏约束/边界条件:这个函数在真实项目里还有其他调用方吗?有没有约定必须返回某种格式?有没有兼容旧数据的特殊逻辑?这些AI从你给的一小段代码里看不出来,必须由你来补充。
比如上面那个KeyError的例子,我在真实项目里补充的隐藏约束是“request_id必须保持原始字符串,不能做strip处理,因为下游要精确匹配”。AI之前建议的方案里有“用get然后加.strip()”,我在约束里明确禁止了,它就不会越界。
3.2 如何用“差分对比”给AI设边界
我发现一个特别好用的技巧:当你有一个最小复现代码,并且在人工排查下已经大概猜到了问题区域,你可以构造一个“差分对”——一段是“复现报错的代码”,一段是“修改一行之后行为正常/报错变化的代码”,让AI对比两者来定位差异。
比如某次模拟项目里,同样的数据处理函数,我给了AI两份输入:一份是字典键为request_id,一份是键为requestId,前者报KeyError,后者正常。AI很快就看出了代码里写死的是requestId,而上游用的却是request_id。如果我只给一份,AI在各种可能性里打转;给完差分,它立刻锁定字段名不匹配。
这个方法的原理是:AI擅长“找不同”,远胜过“从零推理”。你给它两个高度相似的结果,它不需要生成一个新方案,只需要比较现有差异,判断哪种改动方向符合预期。哪怕你不确定差分对是哪里,也可以按“这个报错,我觉得可能是参数类型问题,这是我试过的两种写法”来构造,AI会根据报错差异给出非常有价值的判断方向。
3.3 怎么说“不要乱改”:给AI一个明确的改动边界
“越改越乱”还有一个原因:AI默认你在让它“帮我把这段代码改好”,于是它把防御性编程、代码规范、性能优化全加进去了。为了制止它“发挥”,我会在工单最后明确写一句“只修改xx行,不要动其他代码”,并且提供“改动前后对比”的输出格式要求。
要求AI输出格式为:先说明它认为根因是什么,再给出精确到行的diff,然后解释改动为什么能解决报错。如果AI给出一段“大而全”的重写,我会直接回上一句:“请只保留最小改动。你现在做的不符合要求。”这句话简单直接,居然能让AI收敛很多。刚用的时候我有点意外,用多了就明白了,因为AI确实有“自由发挥”倾向,你不给它明确的评价标准,它就按“看起来更优”来办。
一句实用的指令型模板分享给各位:
请分析下面这个最小复现的报错原因。给出定位结论后,只做最小代码改动来消除报错。禁止重构、禁止优化、禁止改动与该报错无关的代码。输出格式:1)根因分析;2)改动后完整代码;3)用自然语言解释改了什么、为什么。
这套格式我用到现在,基本不会有“顺手优化”的情况出现。
3.4 一轮改不对怎么办:不要继续在原上下文里“缠斗”
即使你把小例子给得很到位,AI也还是可能第一轮就猜错方向。这时候最忌讳的是在同一个对话里无限追加:“不对”“还是错”“你再试试”。因为前几轮里AI会积累“不安定的猜测”,它越猜越虚,最后给你一个四不像的混合方案。
我的做法是:一旦发现AI连续两轮都没摸到根因,我会停止对话,回到代码里再剪一枝——把最小复现再缩小一步,或者在代码里加上打印语句,把“错误现场”先看看清楚。有时候是在稍加一行print(type(x), x)之后,你自己就能定位了;有时候是把它拆成两个子场景分别让AI判断,很快就有结论。总之,不要在同一个模糊的prompt上死磕,要回来减小粒度、增大信息质量。
4. 常见问题与排查技巧实录:那些踩过的坑
4.1 从“最小复现”到“真实场景”之间的鸿沟
最小复现能帮AI快速确定修复方向,但它不等于真实环境。修复完小例子只是第一关,你还要把小修复搬回真实项目里验证。最典型的坑是:小例子里通过,搬回大项目里报新的错。原因往往是你在剪枝的时候,把一些“关键的非业务条件”也剪掉了——比如某个全局变量在主程序里被提前改了,比如多线程下的竞态条件,比如外部依赖的副作用。
- 解决思路:在搬回真实项目前,先列出你在剪枝时移除的“依赖清单”,每一条都要检查“是否可能影响这个修复”。如果有条件,把修复放到真实代码里跑一遍回归,尤其是跟报错相邻的模块。
- 我自己的习惯:小例子验证通过后,会把修复逻辑在真实项目里复制出来,单独跑一个“最小回放测试”——用之前记录的真实输入数据直接调用真实函数,比对修复前后的输出差异,这一步能挡住大半“场景化回归”问题。
4.2 AI改到“伪根因”时,如何用二分法迫近
AI经常会定位到一个“真实存在的、但不是当前报错根因”的点。比如报错是“数组越界”,AI却把问题归因到“上游数据格式不规范”,然后给你加了一堆防御判断。这在某种意义上是好听的“答非所问”。
这时候怎么追?用二分法。把最小复现代码运行的过程,在前半段和后半段各插入一行打印,看问题出在进入函数前、中间处理、还是后续输出。一旦定位到阶段,就把那一阶段单独拆成新的最小复现,再让AI分析。一次二分不够就两次,剪子不能停。
举个例子,一个模拟的库存扣减系统报“NegativeStockError”,AI一直在改扣减逻辑,但我用打印检查发现,负值根本不是扣减那段代码产生的——是上游在组装请求时把库存数字提前减过一次,等到了扣减函数已经是负数。如果不是二分法打印,我可能会纵容AI在扣减逻辑里反复补防御,最后代码越来越绕。
4.3 上下文被“带偏”之后,如何干净地重置
在同一个对话里反复让AI改,你的历史信息会不断累积,包括它自己说过的错误猜测。到后期,AI会倾向于“延续自己之前的结论”而不是“重新审查问题”,这就是上下文锚定效应。
一旦发现AI在重复自己说过的错误,或者在一个方向上反复打转,直接开新对话,不要试图在旧对话里“纠偏”。新对话里只贴三样东西:朴素的最小复现、报错、简短目标说明。你会发现AI的判断清醒很多,因为没了那些错误猜测的锚定,它能重新以旁观者的眼光看这份干净的代码。
这个操作一开始很反直觉,毕竟你前面已经聊了五六轮,丢掉总觉得可惜,但实测下来重开对话的准确率远高于继续纠缠。用我学员的话说:“原来最大的上下文污染,是AI自己刚才说的废话。”
4.4 一个问题速查表
| 现象 | 大概率原因 | 处理建议 |
|---|---|---|
| AI改了很多处,报错没消失 | 上下文太长,注意力被无关代码稀释 | 缩成最小复现,明确只改报错相关行 |
| AI总在防御性编程,加重代码复杂度 | 工单缺少“禁止重构/优化”约束 | prompt里加“只做最小改动” |
| 小例子修好了,大项目仍报错 | 剪枝时剪掉了关键非业务条件 | 逐一复查依赖清单,做真实输入回放 |
| AI反复给同一个错误方案 | 上下文锚定,被自己的历史猜测带偏 | 开新对话,重新提交干净的最小复现和报错 |
| 报错时有时无,不好复现 | 并发/时间/随机等因素未被固定 | 把运行时序固定,用固定种子/并发数触发 |
4.5 一个被严重低估的动作:自己先“读一遍”最小复现
最后补一个非常重要的习惯。把最小复现代码写完以后,不要立刻发给AI,先自己盯着它读一遍,想象这个程序跑起来的过程。你往往能发现,在剪枝完成的那一刻,问题就已经浮出水面了——因为当代码缩小到20行,你的工作记忆能容纳下整个程序的执行流,很多在400行里藏得很深的逻辑颠倒和字段错位,在这个体量下根本藏不住。
我有一半的bug,最后不是AI帮我找到的,是缩例子的过程中自己看到的。所以我要强调,最小复现的真正价值不是“给AI省钱”,而是“逼你自己把问题看懂”。AI只是你确认判断的工具。
5. 从“修一次”到“修一类”:把最小复现变成调试习惯
5.1 给每次修复补一个“复现测试”
修完以后,别急着把最小复现删了。给它起个名字放进测试目录,比如test_regression_xxx.py。哪怕你项目里没有完整的测试框架,单独放一个脚本也好。这样下次再有人动这块代码,一旦踩到同一个坑,测试会直接拦住他。
这在工程上的价值非常大。因为“修一次”只管当前这一刻,“修一类”依赖的是机制。最小复现其实天生就是一个好的回归测试用例,因为它足够小、足够稳定、触发路径极短。把它转成一个测试函数,通常只需要改两三行:把硬编码变量变成函数参数,再断言“运行后不应该抛异常”或“应该返回某种结果”。
5.2 给AI的“调试剧本”复用化
当你吃过几次亏以后,你会发现自己喂给AI的那套“四件套”其实可以做成一个模板,复用无数次。模板字段固定:目标行为、当前行为、最小复现代码、约束说明、输出格式要求。以后遇到任何AI改代码的需求,直接套模板,不用每回都重新想怎么描述。
我自己的库里存了大几十个这样的“调试剧本”,每个都附了当时的最小复现代码和最终修复diff。回过头看,这就是一本个人定制的“疑难杂症手册”,价值远超网上能找到的通用教程。
5.3 最后一点个人体会
帮人排查了这么多AI越改越乱的情况,我越来越觉得,问题的核心根本不在AI,而在我们自己给它投喂的信息结构。它像一面镜子,你给它混乱、模糊、庞大的一团毛线,它回给你的就是理不清的线头;你给它干净、具体、明确的一个病灶切片,它回给你的就是可靠的手术方案。“先把报错缩成一个小例子”这句话,不只是debug方法,更是一种思维方式——从一团乱麻里抽出那条最关键的主线。一旦养成这个习惯,你会发现不光是AI调试,连你自己读代码、找bug的效率都会上一个台阶。