ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI修bug总修不干净?定位与修复必须拆成两步

AI修bug总修不干净?定位与修复必须拆成两步

1. 为什么“让AI顺手把bug修了”往往修不干净

很多人第一次用大模型改代码,都是同一个姿势:把报错日志、相关文件、需求描述一股脑塞进对话框,然后说一句“帮我修一下”。结果要么改错地方,要么改对了地方却引入新问题,要么干脆给你一段看起来对、跑起来崩的代码。于是有人得出结论:AI修bug不靠谱。

但真正在一线用AI改代码的人会发现,问题往往不在模型能力,而在任务结构。“定位”和“修复”是两种性质完全不同的认知活动,把它们压进一次对话,等于让一个人同时当侦探和外科医生——侦探还没锁定病灶,外科医生已经动刀了。

我拿一个真实场景举例。某次线上服务偶发空指针,日志只抛了一行NullPointerException,堆栈指向一个工具类的第87行。如果直接让AI“修这个bug”,它大概率会做一件事:在第87行加一个if (obj != null)判空。代码能跑,测试能过,但根因可能是上游某个接口在特定条件下返回了null,而那个条件才是真正要处理的。判空只是把症状盖住了。

这就是定位与修复混在一起时最典型的失败模式:AI会优先选择“局部最小改动”来让报错消失,而不是找到真正的因果链。因为在一个混合任务里,模型同时承受着“解释问题”和“产出可运行代码”两种压力,而后者往往在训练信号里权重更高——它更倾向于给你一个能交差的补丁。

所以“分成两份任务”不是流程洁癖,而是对模型工作方式的尊重。定位任务的目标是收敛假设空间:从一堆可能的原因里,逐步排除、验证,最终锁定一个或几个高置信度的根因。修复任务的目标是在已知根因的前提下,生成最小且正确的变更。两者的输入、输出、成功标准都不一样。

维度定位任务修复任务
输入报错信息、日志、代码上下文、复现步骤已确认的根因描述、相关代码片段、约束条件
输出根因假设列表 + 验证方法具体代码变更 + 影响范围说明
成功标准假设能被验证或排除变更后测试通过且不引入回归
主要风险过早收敛到错误假设过度修改、破坏原有逻辑
模型行为倾向需要被引导去“怀疑”需要被约束去“最小化”

这张表是我自己在多个项目里反复验证后总结的。你会发现,定位任务里模型最需要的是发散和怀疑,而修复任务里最需要的是收敛和克制。这两种倾向在同一个提示里会互相干扰。

还有一个更隐蔽的问题:当定位和修复混在一起时,你很难判断AI到底哪一步错了。它给的补丁没生效,是因为根因找错了,还是因为修复方式不对?你没法归因,也就没法迭代。拆成两步之后,每一步都有独立的验证点,整个调试过程从“黑盒抽奖”变成了“可观测流水线”。

2. 定位任务到底在做什么:从报错到根因的收敛过程

2.1 定位不是“猜”,而是构造可验证的假设

很多人以为定位就是让AI“看看哪里有问题”。这是最大的误解。定位的本质是在有限信息下构造一组可验证的假设,然后设计最小成本的验证手段逐个排除。

我通常会把定位任务拆成三个子步骤交给AI,但注意,这三个子步骤是在同一个定位任务里完成的,不要和修复混在一起:

  1. 信息整理:把报错、日志、相关代码、最近变更、复现条件结构化。这一步AI很擅长,它能从一堆杂乱信息里提取出关键字段。
  2. 假设生成:基于整理后的信息,列出所有可能的原因,按可能性排序。这里要明确要求AI“不要给修复方案”,只给原因和验证方法。
  3. 验证设计:对每个高可能性假设,给出一个具体的验证动作,比如“在X处加日志”“用Y输入复现”“检查Z配置”。

举个例子。之前有个Python服务在并发下偶发KeyError,日志只显示某个字典取值失败。如果直接修,AI会加.get()默认值。但我让它先做定位,它给出的假设列表是这样的:

  • 假设A:该字典在某个初始化路径下未被填充,概率高,验证方法是检查初始化顺序。
  • 假设B:多线程同时读写导致中间状态被读到,概率中,验证方法是加锁后压测。
  • 假设C:上游传入的key本身拼写不一致,概率低,验证方法是打印所有key。

然后我按假设A去查,发现确实是一个懒加载的配置字典在并发首次访问时被两个线程同时初始化,其中一个线程读到了空字典。根因是初始化竞态,不是取值方式。如果我一开始就让AI“修”,它加.get()只会让问题从崩溃变成静默错误,更难排查。

注意:定位任务里一定要明确告诉AI“先不要改代码”。否则它会在假设列表后面顺手附上补丁,而那个补丁会锚定你的思路,让你不自觉地跳过验证。

2.2 定位任务的输出应该长什么样

一个合格的定位输出,不是一段散文式的分析,而是一张假设-证据-验证表。我一般要求AI按这个格式给:

假设编号根因描述支持证据反对证据验证方法置信度
H1配置懒加载竞态偶发于首次并发请求单线程无法复现加日志观察初始化时序高
H2上游key拼写不一致无日志中key格式统一打印所有传入key低

这张表的价值在于:它把“我觉得”变成了“可检验”。你可以拿着验证方法去实际操作,然后根据结果更新置信度。整个过程是可追溯的,而不是“AI说啥就是啥”。

还有一个关键点:定位任务要允许AI说“信息不足”。很多人在提示里写“你必须找出原因”,这会逼着模型编一个看起来合理的根因。正确的做法是明确告诉它:“如果现有信息不足以区分假设,请指出还需要哪些信息。”这样它才会诚实地告诉你“需要看XX模块的代码”或“需要复现步骤”。

2.3 为什么定位阶段最怕“顺手修一下”

我踩过最深的坑,就是在定位阶段让AI“顺便给个修复建议”。那次是一个前端状态更新不触发渲染的问题。AI在分析原因时提到“可能是依赖数组缺少某个值”,然后顺手把依赖补上了。我看代码确实合理,就直接用了。结果渲染是触发了,但陷入了无限循环——因为那个值每次渲染都会变。

问题出在哪?修复建议一旦出现,它就成了一个“锚”。你的大脑会不自觉地围绕这个补丁去构建解释,而不是独立地验证根因。AI也一样,如果它在同一次输出里既定位又修复,它的定位部分会不自觉地偏向那个修复方案,形成自我一致性偏差。

所以我的硬性规则是:定位任务的提示词里必须包含“只输出根因假设和验证方法,不要输出任何代码变更”。这不是形式主义,是在保护你自己的判断力。

3. 修复任务的独立价值:为什么它需要另一套提示词

3.1 修复的约束条件比定位多得多

定位阶段你可以天马行空,假设越多越好。但修复阶段,约束是硬性的:不能破坏现有接口、不能引入新依赖、要兼容旧数据、要符合代码风格、要考虑性能影响。这些约束如果和定位混在一起,模型根本顾不过来。

我通常会在修复任务的提示里明确列出这些约束,比如:

  • 只允许修改service/order.py中的calculate_total方法
  • 不允许新增第三方库
  • 必须保持函数签名不变
  • 变更后需通过现有单元测试
  • 如果根因涉及上游,只做防御性处理并标注TODO

有了这些约束,AI的输出会收敛很多。它不会再给你“重构整个模块”这种大动作,而是老老实实在指定范围内做最小变更。

3.2 修复任务需要“变更说明”而不是“解释原因”

定位任务的输出是“为什么”,修复任务的输出应该是“改了什么、影响什么、怎么验证”。我一般要求修复输出包含三部分:

  1. 变更内容:具体改了哪几行,改成什么。
  2. 变更理由:一句话说明这个改动如何消除根因,注意是“消除根因”不是“绕过症状”。
  3. 影响评估:这个改动可能影响哪些调用方、哪些边界条件、是否需要额外测试。

这三部分里,影响评估是最容易被忽略但最重要的。因为修复的本质是引入变更,而任何变更都有副作用。AI在独立修复任务里,因为没有定位阶段的“发散惯性”,反而更容易冷静地评估影响。

3.3 一个反直觉的发现:修复任务里AI更容易“过度修改”

你可能觉得,定位和修复分开后,修复应该更精准。但实测下来有个反直觉的现象:在独立修复任务里,如果根因描述不够具体,AI反而更容易过度修改。

原因很简单:定位阶段它已经“理解”了问题,修复时它想“既然都改了,不如把相关的地方也优化一下”。比如你告诉它“根因是空值未处理”,它可能不仅加判空,还会顺手把整个方法的错误处理重构一遍。

所以修复任务的提示词里,根因描述必须具体到可操作。不要写“空值问题”,要写“当user.profile为 null 时,第42行的profile.age取值会抛异常”。根因越具体,修复越收敛。

根因描述方式AI典型行为风险
“空值问题”加判空 + 重构错误处理 + 加日志变更范围失控
“第42行 profile 为 null 时取值异常”在第42行前加判空或默认值变更可控
“上游可能返回 null,需防御”在调用处加多层防御可能掩盖上游问题

这张表是我在code review里反复观察到的模式。你会发现,根因描述的粒度直接决定了修复的粒度。而根因描述的质量,又取决于定位任务做得好不好。这就是为什么两步必须分开:定位的输出质量,直接决定修复的输入质量。

4. 两步之间怎么衔接:定位输出到修复输入的转换

4.1 不是所有定位结果都值得修

定位任务可能会给出多个假设,但修复任务只能针对已确认的根因。所以两步之间有一个关键动作:验证与筛选。这个动作目前还得人来做,或者至少人要在环里。

我的做法是:定位输出假设表后,我按置信度和验证成本排序,先验证高置信度、低成本的假设。验证通过后,把那个假设的根因描述重新写一遍,写成修复任务能直接用的输入。注意,是重新写,不是复制粘贴。因为定位阶段的描述是面向“理解”的,修复阶段需要面向“操作”。

比如定位输出是:“H1:配置懒加载竞态,两个线程同时初始化导致读到空字典。”修复输入应该改写成:“在ConfigLoader.get()方法中,_instance为 null 时存在竞态。需保证初始化只执行一次,且其他线程等待初始化完成。只修改该方法,不改变外部调用方式。”

你看,修复输入里包含了位置、条件、目标、约束,而定位输出里只有原因和证据。这个转换过程本身就是一种质量把关。

4.2 修复任务要带上定位阶段的“排除记录”

还有一个细节:修复任务的提示里,最好附上定位阶段排除了哪些假设。比如“已排除上游key拼写问题,日志确认key格式统一”。这样AI不会在修复时又绕回去处理那些已经被排除的可能性。

这就像医生做手术前,护士会告诉他“过敏史已确认无、术前禁食已完成”。这些信息不直接参与手术,但能防止医生做出错误决策。修复任务里的“排除记录”起的就是这个作用。

4.3 如果定位错了,修复再漂亮也没用

这是最残酷的现实。如果定位阶段收敛到了错误根因,修复任务会非常“成功”地修一个不存在的问题,而真正的问题还在。更糟的是,它可能引入新问题,让排查变得更复杂。

所以我的经验是:定位阶段的验证成本,永远比修复阶段的返工成本低。宁可多花时间在定位上做验证,也不要急着进入修复。一个被验证过的根因,哪怕修复方案笨一点,也是安全的;一个未验证的根因,哪怕修复方案再优雅,也是危险的。

我一般会给自己定一个规则:定位阶段至少排除两个假设,才能进入修复。如果只找到一个假设就直接修,大概率会翻车。

5. 实战拆解:一个并发bug的两步处理全过程

5.1 问题背景与初始信息

某订单服务在压测时偶发ConcurrentModificationException,堆栈指向一个遍历订单列表的方法。单线程测试无法复现,QPS超过200时概率出现。最近一次变更是在该方法里加了一个“过滤无效订单”的逻辑。

如果直接让AI修,它大概率会把for循环改成Iterator或者用CopyOnWriteArrayList。但这些都是症状级修复,因为真正的问题可能是:过滤逻辑在遍历时修改了原列表。

5.2 定位任务的实际提示与输出

我给AI的定位提示是这样的:

你是一个代码调试助手。现在只做定位,不要给任何修复代码。 背景:订单服务压测时偶发 ConcurrentModificationException,堆栈在 OrderService.filterValidOrders 的遍历处。 单线程不复现,QPS>200 时出现。最近变更是该方法内新增了过滤逻辑。 请输出: 1. 所有可能的根因假设,按可能性排序 2. 每个假设的支持证据和反对证据 3. 每个假设的最小验证方法 4. 如果信息不足,指出还需要什么

AI的输出里,高置信度假设是:“过滤逻辑在遍历过程中直接调用了list.remove(),导致迭代器失效。”验证方法是:“检查过滤逻辑是否在遍历同一列表时做了删除操作。”我一看代码,果然如此。根因确认。

5.3 修复任务的提示与输出

确认根因后,我重新写修复提示:

根因已确认:filterValidOrders 方法在 for-each 遍历 orderList 时,调用了 orderList.remove()。 已排除:并发写入问题(该方法内无其他线程写操作)。 要求:只修改 filterValidOrders 方法,保持方法签名和返回类型不变。 不允许新增依赖。变更后需保证遍历安全且过滤逻辑不变。 请输出:变更后的代码 + 变更说明 + 影响评估。

AI给出的修复是用Iterator.remove()替代list.remove(),并附上说明:“在迭代器遍历时使用迭代器自身的remove方法,避免并发修改异常。”影响评估指出:“该方法为私有方法,仅被processOrders调用,无外部影响。”

这个修复干净、最小、可验证。如果我一开始就让AI“修这个并发异常”,它很可能给我换成CopyOnWriteArrayList,那会改变整个列表的语义,影响其他读取方。

5.4 这个案例里两步分开带来的实际收益

对比项混合任务(假设)两步任务(实际)
修复方案换线程安全列表或加锁改用 Iterator.remove
变更范围可能影响列表所有使用方仅一个私有方法
性能影响写时复制有内存开销无额外开销
回归风险中高低
根因是否消除可能只是绕过是

这个对比不是理论推演,是我实际对比过的。同一个bug,混合提示下AI给了三个方案,两个都有副作用;两步提示下AI给了一个方案,直接命中。

6. 把两步任务固化成可复用的提示词模板

6.1 定位任务模板

你是一个代码调试助手,当前只做问题定位,禁止输出任何修复代码或代码变更建议。 【背景信息】 - 报错/异常: - 复现条件: - 相关代码位置: - 最近变更: - 已尝试的排查: 【任务要求】 1. 列出所有可能的根因假设,按可能性从高到低排序 2. 每个假设给出支持证据和反对证据 3. 每个假设给出最小验证方法(具体到操作) 4. 如果现有信息不足以区分假设,明确指出还需要哪些信息 5. 输出格式为表格:假设编号 | 根因描述 | 支持证据 | 反对证据 | 验证方法 | 置信度 再次强调:不要输出任何修复方案。

这个模板的关键在于最后那句重复强调。我试过不写,AI有30%左右的概率会附上修复建议。写了之后,基本能压住。

6.2 修复任务模板

你是一个代码修复助手。根因已确认,当前只做修复,不做额外重构或优化。 【已确认根因】 (具体到位置、条件、触发路径) 【已排除假设】 (列出定位阶段排除的可能性,防止绕回) 【修复约束】 - 允许修改的文件/方法: - 不允许修改的内容: - 不允许新增的依赖: - 必须保持的接口/签名: - 必须通过的测试: 【任务要求】 1. 输出变更后的代码 2. 输出变更说明:改了什么,为什么这样改能消除根因 3. 输出影响评估:可能影响哪些调用方、边界条件、需要补充的测试

这个模板里,“已排除假设”是我后来加上的,效果很明显。不加的时候,AI有概率在修复时又去处理那些已经被排除的问题,导致变更范围扩大。

6.3 两步之间的检查清单

在从定位切换到修复之前,我会过一遍这个清单:

  • [ ] 根因是否被至少一个验证方法确认过?
  • [ ] 是否至少排除了两个其他假设?
  • [ ] 根因描述是否具体到位置和条件?
  • [ ] 修复约束是否明确列出?
  • [ ] 是否已把定位输出重新改写成修复输入?

这个清单看起来简单,但每次跳过其中一项,后面都会付出代价。尤其是第三项,根因描述不够具体时,修复几乎必然过度。

7. 常见误区与踩坑记录

7.1 误区一:让AI“先定位再直接修”

这是最常见的。很多人觉得分两步麻烦,就在一个提示里写“先分析原因,然后修复”。结果AI的分析部分会不自觉地服务于它想好的修复方案,形成“先射箭再画靶”。你以为它在定位,其实它在为修复找理由。

我实测过同一个bug,用“先定位再修复”的混合提示,AI给出的根因是“缺少空值检查”,修复是“加判空”。而用两步提示,定位给出的根因是“上游接口在超时分支返回了不完整对象”,修复是“在超时分支补全对象或抛明确异常”。后者才是真正的根因。

7.2 误区二:定位阶段信息给太多

有人觉得信息越多越好,把整个项目代码都塞进去。结果AI的假设列表里全是低置信度的噪音,反而淹没了真正的高概率原因。定位阶段的信息要精准,不是全面。报错堆栈、相关方法、最近变更、复现条件,这四样够了。其他代码按需追加。

7.3 误区三:修复阶段不写约束

不写约束的修复任务,等于让AI自由发挥。它可能给你重构、可能给你换库、可能给你加一堆防御性代码。这些在它看来都是“合理”的,因为你的提示里没有说“不”。所以约束必须显式写出来,哪怕你觉得是常识。

7.4 误区四:定位输出直接当修复输入

定位输出是给人看的,修复输入是给AI操作的。两者格式和粒度都不一样。直接复制粘贴定位输出到修复提示里,AI会缺少操作所需的精确位置和约束。中间那个“重写”步骤不能省。

7.5 一个我至今还在用的土办法

如果时间紧,来不及走完整的两步流程,我会做一个简化版:先让AI只输出“根因假设列表”,我扫一眼,挑一个最可能的,然后新开一个对话,把那个假设作为已知条件,让AI修复。新开对话很重要,因为同一个对话里AI会记住之前的发散过程,修复时容易带惯性。新对话等于给它一个干净的上下文。

这个土办法在紧急线上问题处理时救过我几次。虽然不如完整两步严谨,但比混合提示强得多。

8. 这套方法在哪些场景下收益最大

不是所有bug都值得走两步流程。改个拼写错误、调个样式,直接修就行。但以下几类场景,两步分开的收益非常明显:

  • 并发/时序问题:根因往往不在报错位置,混合提示容易修错地方。
  • 偶发/难复现问题:需要假设-验证循环,混合提示会过早收敛。
  • 涉及多模块调用链的问题:根因可能在上下游,混合提示容易在本地打补丁。
  • 性能问题:根因和症状距离远,需要独立定位。
  • 数据一致性问题:修复方案影响面大,需要独立评估。

反过来,以下场景可以不走两步:

  • 编译错误、语法错误:根因明确,直接修。
  • 单行逻辑错误:位置和原因都清楚。
  • 样式/文案调整:不涉及因果链。

我自己的判断标准是:如果报错位置和根因位置可能不是同一个地方,就走两步。这个标准简单但有效。

9. 关于AI辅助调试的一点个人体会

用了这么久AI改bug,我最大的体会是:AI在定位阶段的价值被严重低估,在修复阶段的价值被高估。很多人把AI当“自动修理工”,指望它直接给补丁。但实际上,AI最擅长的是从杂乱信息里提取模式、生成假设、设计验证方法——这些恰恰是定位阶段的核心工作。而修复阶段,因为约束多、影响面大,反而更需要人的判断。

把定位和修复分成两份任务,本质上是在把AI放在它最擅长的位置上。定位时让它发散、怀疑、构造假设;修复时让它收敛、克制、最小变更。两个任务各自有独立的提示词、独立的输出格式、独立的验证标准。这样你得到的不是一个“黑盒补丁”,而是一条可追溯、可验证、可复现的调试流水线。

最后分享一个小技巧:每次修完bug,把定位阶段的假设表和修复阶段的变更说明一起存档。下次遇到类似报错,先翻存档,往往能直接命中根因,连AI都不用问。这个习惯我坚持了半年,现在处理同类问题的速度比之前快了一倍不止。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进