一、引子:永远修不完的 bug

前几天,以完全实现 GEAR 协议为目标的 Aristotle 项目[1],终于成功验证了所有核心技术线路,代码也完成了第三次重构,实现了基本功能,并完善了测试。在准备把开发分支合并到 main 上线前,我做了一轮手工测试,发现 SKILL.md 的指令没有被模型正确执行——拿到了 action 却不调用 task() 启动后台 subagent,反而去加载了 LEARN.md。从排查这个问题开始,更多的 bug 被陆续发现:

修了 #1(SKILL.md 注入上下文),发现 #3 更严重——Shell 语法 $(date +%s) 直接注入主 session,用户界面上平白多出一行时间戳命令。修了 #3,发现 #5 是架构级缺陷:Reflector session 消失后,生成的 DRAFT 报告全丢了,我输入 /Aristotle review 命令直接报错。修了 #5 回头跑测试,#1 又回来了。AI 给的修复方案把刚修好的 bug 又引进来了。

每修一个 bug,进度条不是前进一格,是前进半格后退一格。你以为修了 5 个,测了一下发现还剩 8 个——其中 3 个是旧的回归,2 个是修的时候新引入的。上线看起来遥遥无期。

我发现我掉入了一个死循环。

整个过程里,所有的代码编写、测试脚本、修复方案——都是 AI 写的。我做的是质量把控:review AI 的产出是否达到预期,根据它的回答给予肯定或否定,指出它可能疏忽或理解错误的信息点。但即使 AI 把诊断和编码速度大幅提升,循环依然逃不出去。修得快了,回归也快了。

这不是倒计时赶工的焦虑,是踩在流沙上的绝望:你永远不知道下一脚踩下去,是硬地还是又一个坑。修 bug 不再是线性递减的过程,是螺旋。修一个冒三个,修完三个回头发现第一个又坏了。

我需要解决两个问题:

  1. 怎么准确定位根因?不是表面解释,是真正能把 bug 钉死的根因。
  2. 怎么防止修新 bug 引入旧回归?这个循环怎么打破?

这篇文章就是这次攻坚的完整复盘。它不是"我用 AI 修了一个 bug"的轶事,也不是"AI 调试 prompt 技巧"的列表。它是一个 15+ bug 战役的真实记录,包含四轮归因(三次失败一次成功)、AI 特有的回归陷阱、以及 TDD 如何被痛苦逼出来的全过程。之前写过"让 AI 学会反思"系列的第一篇[1],讲 AI 自我反思的架构设计,这篇是它的实战延续。


二、四轮归因:从一个 SKILL.md 指令失效说起

这个故事的起点是一个奇怪的现象:模型拿到 fire_o action 后,没有执行 task() 启动后台 subagent,反而自主加载了 LEARN.md。

这个问题的排查过程就是全文要讲的核心:四轮归因的每一步推导,AI 都是主力。

老实说,AI 给出的技术结论经常因为过于细节,导致我无法分辨事实对错,因此我采用这样的方法让 AI 自己挖掘问题的根因,推动问题的解决:让它完整展示推导出结论所需的全部信息和推理过程,然后我只评估两件事——依赖的信息是否全面、准确,推理的过程是否符合逻辑。信息不全或推理有跳跃,就让 AI 补充和修正。用这套方法引导它不断往正确的方向逼近,而不是替它做具体判断——我没这个时间和精力。

第一轮:正确但片面

我把现象喂给 AI,它很快就给出了结论:“opencode run 不支持异步通知”。

这是个事实。Layer 4 测试用的 opencode run 是单次命令模式,异步通知确实投递不了。AI 甚至贴出了 opencode 源码里的相关实现,证据确凿。我差点就接受了"整个方案在 opencode 上不可行"的结论,让 AI 准备重写架构。

但我多问了一句:“那为什么交互式 session 中也失败了?”

AI 卡壳了。这个解释只覆盖了 Layer 4 测试场景,完全没解释交互式 session 里复现的失败。事实是正确的,但基于这个事实推导出来的结论是错的。我当时用的检查方法就是后来总结的这条规则:做结论前列出全部异常行为,确认结论覆盖每一条。

顿悟 1:事实正确 ≠ 结论正确。 AI 给了一个结论,只解释了一部分异常,剩下的被忽略了。我的做法是:把所有已知异常摆出来,让它继续找能同时解释全部异常的原因。科学哲学里这叫 Inference to the Best Explanation——竞争假设中,解释力最强、不与已知证据矛盾的,更可能正确。[2]

第二轮:看似合理实则错误

AI 调整了方向,很快给出第二个结论:“模型不遵循 SKILL.md 指令”。

听起来太有道理了。模型确实没执行 ACTIONS 里的 task() 调用,自主加载了 LEARN.md,这不就是不遵循指令吗?我甚至已经让 AI 开始写优化 prompt 增强指令遵循能力的方案了。

但我又问了一句:“那为什么 ROUTE 部分的指令被遵循了?”

模型准确调用了 MCP orchestrate_start,说明它完全能读懂 SKILL.md 的内容。如果它"不遵循指令",为什么一部分遵循一部分不遵循?我让 AI 同时测试 reflect 流程,发现它也被部分遵循、部分忽略。这说明声称"learn 不遵循而 reflect 遵循"本身就是错的——两个流程都有问题,根因在更深的层面。

顿悟 2:声称 X 行为而 Y 不行为时,必须实测 Y。 反向验证发现两个流程都有问题,根因在更深的层面。这对应实验方法论里的基本原则:声称两个场合存在差异,两边都必须实测,不能假设其中一边的结果。密尔在《逻辑体系》里把这个规范化为"求异法"——差异判断成立的前提是两边都有实际观察。[3]

第三轮:表面原因

AI 做了文件可见性分析,给出第三个结论:“LEARN.md 的存在导致模型绕过 dispatcher”。

删除 LEARN.md 测试,确实能回避问题。AI 已经开始写"删除 LEARN.md,把内容合并到 SKILL.md"的修复方案了。我让它暂停——等等,“删文件"是方案还是分析?

我追问:为什么 LEARN.md 会被加载?你理解了为什么再回答怎么做。

我突然反应过来。“删除文件"是一个方案,不是一个分析。我只知道删了文件问题就消失了,但完全不知道问题为什么发生。就像家里灯闪烁,你拔掉旁边一个电器灯就不闪了——但不知道为什么,下次换个电器可能又闪。这次是 LEARN.md,下次说不定是 REFLECT.md、REVIEW.md,问题永远解决不完。

顿悟 3:先理解问题再给方案。 “删文件"是一个方案,不是一个因果链。方案提出前,先完成因果链分析。亚里士多德在《后分析篇》里区分过两种认知:知道现象是什么(hoti,知其然),和知道它为什么发生(dioti,知其所以然)。“删了文件问题消失"是 hoti,“为什么文件存在会导致问题"才是 dioti——没有后者,修一个冒一个。[4]

第四轮:真正的根因

我让 AI 把 ROUTE 和 ACTIONS 两部分的内容放在一起对比,差异立刻就出来了:

  • ROUTE 用的是具体动词:“call MCP orchestrate_start",模型理解为可执行动作
  • ACTIONS 用的是 bullet-list 格式:fire_o → task(...),模型理解为说明信息

ACTIONS 部分的指令格式是文档风格而非执行风格。模型不是"不遵循指令”,是"ACTIONS 没有提供足够清晰的指令,模型选择了语义上最合理的替代路径”。

我当时特意跟 AI 确认:你确定这是根因?有没有可能还有其他原因?AI 回复说它对比了 GitHub 上公开的 opencode skill 实现,所有执行成功的指令用的都是编号步骤(STEP N)或条件分支(### If action is X:),没有用 bullet-list 做动作映射的。把 ACTIONS 改成条件分支格式,大概率能解决问题。

AI 修改后测试,模型准确执行了 task() 启动 subagent,没有再加载 LEARN.md。

根因找到了,修复也一次通过。但验证还没完——回调链走通了,不等于异步通知真的生效了。两种机制可能产生相同的输出:一种是真正的异步通知(<system-reminder> 投递到 session),一种是同步 fallback(task tool 在 run mode 下降级为同步执行)。我让 AI 做了区分性实验才最终确认:

  1. 检查响应中是否有 <system-reminder> 标记:存在
  2. 对比执行时间线:task 执行耗时远超总响应时间,说明确实是后台执行
  3. 追踪 opencode 源码路径:确认 task tool 在 run mode 下的行为定义

顿悟 4:行为观察 ≠ 机制确认。 看到回调链走通不等于异步通知生效——两种机制产生相同输出,本质完全不同。同一个现象可以有不同的解释——这在科学哲学里有个名字叫"不充分决定”。怎么确认是哪种?设计一个测试,让两种解释给出不同的结果。Popper 称之为"判决性实验”。[5]

AI 的角色:推理加速器,不是诊断工具

这里必须诚实:四轮归因的前三轮错误,都是 AI 主导推导的。第四轮正确的归因,也是 AI 主导推导的。

这不是矛盾。这是 AI 推理风格的结构性特征。AI 擅长从给定的事实 A 快速跳到结论 B,但不会自动追问"这个 B 是否覆盖了全部现象”。它的每一次归因都基于正确的事实,但每个正确的事实只覆盖了部分现象。前三轮的结论"看起来合理”,因为它确实有证据支撑,只是证据不全。

我做了什么?我不判断每个技术结论对不对——很多时候判断不了。我做的是检查 AI 推理过程的可靠性:它依赖的信息全不全?推理链有没有跳跃?有没有已知异常没被解释?答案错了可以重来,推理方向错了会一直在错误的路上跑。

这是 AI 辅助调试的关键边界:AI 能把推理和编码速度大幅提升,但它不会主动考虑全局上下文。人要做的,就是在它跳到局部最优解的时候,把它拉回全局视角。我的全部工作就是 review 产出、给予反馈、指出疏漏——不写一行代码,但每一行代码背后的推理都经过我的审视。

后来我发现这和管理一个复杂组织是一样的。组织大到一定程度,管理者不可能理解每个下属做的事情的技术细节。ta 能评估的只有几件事:结果是否达成目标,下属汇报时展示的问题考虑是否全面,行为和目标是否相关,逻辑是否自洽。管理者判断的不是每个技术决策对不对,是得出决策的这条路走得对不对。我评估 AI 的推理过程,做的也是同样的事。


三、战场:15 个 bug 以及为什么它们杀不死

SKILL.md 的问题解决了,更大的战场才刚刚开始。我跑功能测试暴露了 8 个问题,E2E 测试又发现了多个新问题,加起来 15 个 bug 堆在面前。

04-21 我开始测试,一小时内发现了 4 个 bug:

  • Bug #3:主 session 直接出现 $(date +%s) 命令,根因是 rule_id 不该由 Reflector 生成,应该交给 MCP 处理。翻代码才发现 AI 写的生成逻辑直接把 Shell 命令写到了 prompt 里。
  • Bug #2:模型输出"根据协议,执行 REFLECT 操作",根因是 SKILL.md 没加"不输出协议推理"的约束。
  • Bug #5:我测试时点 review 报 “Reflector session no longer exists”,根因是 DRAFT 只存在易失性 session 中,session 关了就全丢了。这个问题让 AI 花了两天时间重构持久化逻辑,把 DRAFT 写到磁盘 ~/.config/opencode/aristotle-drafts/ 目录。
  • Bug #8:我测试全新安装后的使用场景,第一次调用 write_rule 直接报 repo 未初始化,根因是 install.sh 忘了调用 init_repo_tool()。查 commit 记录,发现是 AI 写 install 脚本的时候漏了这一步——安装脚本根本没有被测试流程覆盖。

修到 04-25 号,AI 把 8 个功能测试问题全修完了。我松了口气,让它跑 E2E 测试准备上线,结果又冒出多个新问题:

Bug #14 是个典型的"现象清晰、根因隐蔽"的问题:review 流程偶发空指针报错。表象是代码里某个对象为空,但为什么为空?追下去发现是模型输出长度不够,DRAFT 报告没写完就被截断了。第一反应是模型本身的限制,继续查才发现是 API 服务商的配置文件示例里给所有模型都设了 4K 的最大输出量——改成模型实际支持的最大输出值,问题解决。从报错追到截断,从截断追到配置,三层原因一层比一层深,正是用了上面第二部分提到的诊断方法逐步逼近,才算比较顺利地定位到根因,解决了问题。

Bug #13 是并发问题:多实例并发时 reconcile 卡在 stale session 上阻塞启动,审查还发现 saveToDisk 有覆盖其他实例数据的风险。AI 加了实例 ID 隔离和超时机制,针对 #13 测了三次都过了。当时没有回归测试套件——测 #13 就是只测 #13,没有跑一遍确认其他已修的 bug 还在不在。于是我满心希望按计划继续测试。

结果通知机制又出问题了。让 AI 分析原因,它开始离题万里地解释各种可能,没有一条对得上。我突然意识到——这可能是之前修过的老问题。于是让 AI 把之前已解决的 bug 全写成回归测试,跑下来发现,果然有老 bug 回归了。查 commit 记录,AI 在修 #13 时改动了 session 隔离逻辑,顺带动了通知相关的代码,但这个改动跟 #13 的修复目标无关。

更糟糕的是,这次不是第一次。之前已经发生过一两次小回归,靠人工发现了,当时觉得是偶发。到了第三次,我意识到这不是运气问题——每次发现异常都要先花时间区分"新 bug 还是回归",既耗精力又浪费 token。事不过三,必须把方法流程固化下来。

这就是回归陷阱:修 bug 的时候引入了已修 bug 的回归,而现有的测试和 review 流程完全没有机制能捕获它。

回归套件与 TDD 的诞生

我被逼得没办法,让 AI 写了 regression_b1_checks.sh 脚本,把 39 个已经修复的 bug 全变成了回归检查点[6]。修新 bug 前先跑一遍回归确认基线,修完再跑一遍确认没有回归。

TDD 就是这么被逼出来的。一开始我只是"修完跑一下测试",发现回归后变成"先让 AI 写回归测试确认基线,再修",后来演化成完整的流程:写测试(跑失败)→ 写修复(跑通过)→ 跑全量回归(确认没影响其他功能)。TDD 不是我从教科书里搬来的,是回归的痛苦逼出来的。

这套流程建立后,修 bug 的速度立刻就上来了。不用再花时间确认"是不是又回归了",修复 → 回归全绿 → 下一个,形成了稳定的节奏。后面的 bug 修复一路顺畅:

  • Bug #14b:notify parent session 逻辑修复,加了 4 个新测试,回归全绿
  • logger default level 配置错误,修复后回归全绿
  • config module 被 Oracle 审查出 2 个高危 + 4 个中危 + 2 个次要问题,全部修复后回归全绿
  • 最终全量回归:39 个回归检查点全绿 + E2E 全绿

四、胜利与教训

测试全绿

v1.1.0 合并到 main 分支那天,所有测试全绿:134 pytest + 162 vitest + 358 static = 654 个测试[7]。每个曾经的 bug 都有对应的测试用例守护,再也不用担心修新 bug 把旧的带出来。

三个盲区

这次攻坚最大的教训:人不是替 AI 判断结论对错,是审核 AI 推理过程的可靠性。AI 有三个天然的盲区,需要人来补位:

AI 做不到的事后果人的角色
不追问"假设是否覆盖全部现象"基于正确事实得出错误结论检查推理依赖的信息是否全面
不追问行为观察与机制确认的区别相同输出掩盖不同机制检查推理链是否有未验证的跳跃
不会自动检查修复是否引入回归修复引入回归建立规则让 AI 自动维护回归测试

循环之外

项目最终发布了,不只是因为 15 个已知 bug 全修完、业务流程跑通了—— bug 是永远修不完的。能发布是因为建立了系统化的诊断和回归机制,从而在当前约定的需求边界内,bug 不再是无限循环。

绝望的阶段不是 bug 多,是修一个冒三个、修完回头发现第一个又坏了。打破循环的不是更努力地修 bug,是把每个已修的 bug 钉死在测试里。后来这套做法我整理成了独立的 tdd-pipeline 工具[8],但它的根就在这次痛苦的攻坚里。

AI 不是银弹。它是效率放大器,你正确的决策会被放大十倍,错误的决策也会被放大十倍。用好 AI 的关键,从来不是写更精妙的 prompt,是理解它的推理盲区,知道在哪里检查它的推理过程。


参考

  1. “让 AI 学会反思"系列第一篇:Aristotle:让 AI 学会从错误中反思
  2. Harman, G. “The Inference to the Best Explanation.” Philosophical Review, 1965. Whewell 的 consilience of inductions(归纳的汇聚,1858)是这一思路的历史源头,核心论点:一个假设若能解释比它最初设计覆盖的更多类型的异常现象,它就更可能正确。概述见 https://plato.stanford.edu/entries/whewell/
  3. Mill, J.S. A System of Logic, 1843, Book III, Ch. 8. “求异法”(Method of Difference):要判断两个场合的差异是否构成因果关系,两边都必须有实际观察。现代实验设计的阴性对照组(negative control)是这一原则的直接应用。
  4. Aristotle, Posterior Analytics I.13 (78a22). 亚里士多德区分 hoti(知其然)与 dioti(知其所以然):知道某个事实成立,不等于知道它为什么成立。真正的科学知识必须通过原因来建立推导。概述见 https://plato.stanford.edu/entries/aristotle-causality/
  5. Duhem, P. The Aim and Structure of Physical Theory, 1906. 不充分决定论题(Underdetermination):同一组经验证据可以支持多个互不相容的理论。Duhem 本人对"判决性实验"持怀疑态度,认为总有辅助假设可以保护被反驳的理论。Popper 则认为可以通过设计高风险测试来区分竞争假设——他称之为"判决性实验”。概述见 https://plato.stanford.edu/entries/scientific-underdetermination/
  6. 回归测试脚本:https://github.com/alexwwang/aristotle/blob/main/test/regression_b1_checks.sh(39 个检查点)
  7. 最终测试数:325 pytest + 162 vitest + 其他 = 654,test-coverage 合并 commit:bf777fe,后续数字更新见 commit cd0a364
  8. tdd-pipeline 项目:https://github.com/alexwwang/tdd-pipeline