返回文章

systematic-debugging:报错消失,不等于 bug 修好

这是逐份拆 skill 系列的一篇,拆的是 systematic-debugging——实现阶段里管调试的一份,由开发者角色在任务中途遇到失败时使用。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》讲了整套系统的形状;这个系列逐份拆开,每篇只讲一份:它为什么存在,具体怎么干。我用的系统是开源的 frontend-harness-cli ,文中规则都能在仓库的 templates/skills/systematic-debugging/SKILL.md 里找到原文。

二十分钟,三个“修复”

先看一个具体的场景。

实现做到一半,任务检查红了:一条端到端用例失败——用户提交订单之后,订单列表里迟迟看不到这条新订单,用例等了两秒,判定失败。

AI 开始"修"。第一个动作:把用例里的等待从两秒改成五秒。绿了。过了一会儿,另一条用例红了:订单详情页的金额偶尔差一分钱。它在金额计算外面包了一层四舍五入。红消失了。再后来联调时发现,页面偶尔会闪一下报错,它给那段请求加了 try-catch,错误吞掉,页面安静了。

二十分钟,三个修复,每一个都让眼前的红消失了。但有一个最基本的问题,从头到尾没人回答过:新订单为什么没有立刻出现在列表里?

真实原因可能在数据层:提交成功之后,没有人让列表的查询缓存失效,列表读的一直是提交之前的旧数据。等待改成五秒只是运气好时等到了缓存过期;四舍五入把一个上游的精度问题盖住了;try-catch 则把将来所有的同类错误一起吞掉了。症状搬了三次家,bug 本人一次都没露面——而且代码里多了三处谁也解释不了的改动。

这份 skill 的第一句话,就是冲着这个场景写的:“No guess-and-patch. A fix you cannot explain is a second bug waiting.”(不猜、不乱补。解释不了的修复,是在等着发作的第二个 bug。)

这份 skill 干什么:把调试从猜测改成实验

它的触发条件写在描述里:任务检查、测试或修复项失败,且原因不明显时。 原因一目了然的失败不需要它——导入路径写错、类型拼错,编译器已经把原因写在脸上了,照着改就是。它管的是那种"红了,但为什么红不知道"的失败。

它在流程里的位置也和别的 skill 不同:它不是某个阶段的固定环节,而是实现阶段内部的一份纪律——干活的开发者角色一旦陷进"改一下试试"的循环,就切换到这份 skill 的走法。

它的产出有两样。一是修在根因上的改动;二是一行记录,写进任务报告:root cause: …。第二样和第一样同等重要:调试的结论如果只活在当前上下文里,下一个接手的上下文还得把这条路重走一遍。

顺带一提,harness 任务之外的普通 bug 用的是同一套方法的通用版 bug-root-cause;这份是任务内的版本,多了和任务报告、任务检查的衔接。

七步:从稳定复现到根因落盘

skill 给的流程是七步,顺序不许换:

  1. 稳定复现。 先找到一条命令,每次运行都能让失败出现。复现不了,就没有调试对象,只有一段传闻。如果失败本身不稳定,先查不稳定:是时序、执行顺序还是数据在变——把不稳定因素钉住,失败才会稳定。
  2. 收集证据。 完整的报错、堆栈、日志、输入,外加一样最容易被跳过的东西:上次正常以来改了什么(git log -p、git diff)。“以前是好的"意味着原因大概率就藏在这段差异里。
  3. 一次一个假设。 把假设写下来,设计一个能证伪它的观察,执行,留一行日志:假设 → 验证 → 结果。一次只验一个,是因为同时改三处,就算好了你也不知道是哪一处起的作用,等于什么都没学到;要设计证伪而不是证实的观察,是因为支持一个猜测的现象到处都是,能杀死它的现象才有信息量。
  4. 确认根因。 确认的标准是可操作的一句话:你能有意让失败出现,也能有意让它消失。 做不到这一点,你手里只有相关性,没有原因。
  5. 最小修复,修在原因上。 根因在哪,就在哪动手,改动越小越好——不是在症状冒头的地方糊一层。
  6. 重新验证。 顺序是钉死的:先复现用例,它现在必须通过;再跑本任务的检查;最后跑这次改动可能波及的更大范围的测试。
  7. 记录。 在任务报告里写一行 root cause: …。一行就够,但必须有。

设计一:模型为什么默认打补丁

这份 skill 之所以要存在,是因为模型的默认调试行为恰好和七步相反:它朝症状扑。

原因在它面对的信号结构里。调试时最显眼的信号是报错信息,而"让报错消失"是一个立刻可见、立刻能验收的成果;“找到根因"慢,中途没有可见进展。于是成本最低的路径永远是:在报错的那一行附近动手,让这一行安静下来。

但症状和原因通常不在一个地方。列表显示旧数据,原因在提交之后的缓存失效;金额差一分,原因在上游某个字段的类型。报错信息是烟,火在别处;在烟最浓的地方扇风,烟会散,火还在。

更麻烦的是账算在后面:每一个说不清的补丁,本身都是一次没被验证过的改动。放宽的超时会让整套测试从此慢下去;包住一切的 try-catch 会把将来所有同类错误一起吞掉,让下一个 bug 连出声的机会都没有。这就是"解释不了的修复是第二个 bug"的具体所指——不是修辞,是字面意思:你往代码里又埋了一个。

设计二:让报警器闭嘴的动作,一律不算修复

七步之外,skill 还有三条硬规则,第一条直接点名了禁区:

绝不掩盖症状还称之为修复——原文举的三个例子是:捕获后忽略(catch-and-ignore)、跳过测试、放宽超时。

这三件事的共同点是:它们都不改变被测对象的行为,只改变你能看到多少。catch 后忽略,错误还在发生,只是不再有人知道;跳过或删掉失败的测试,等于把报警器拆了,屋里继续烧;放宽超时,是把"慢"重新定义成"正常”。在语言层里给报错行贴 @ts-ignore,是同一类动作的另一个版本——都是让检查闭嘴,而不是让错误消失。这类改动在评测环节还有更硬的下场:靠抑制手段让检查通过,会触发 rubric 里 pitfall 条目的一票否决。

第二条:调试提交里不混入无关改动。 修复必须能被单独看懂、单独回滚;顺手重构、顺手改格式混进来,将来出问题时,连哪一行是"修复"都分不出来。

第三条是关于假设日志和复现用例的去向:它们不是一次性脚手架。复现命令在修复后是第一个被重跑的东西,它从"找 bug 的工具"变成"bug 已死"的证据;而且任务检查会一再重跑,同一个根因如果复活,会被同一批检查当场抓住。根因那一行记录也是同理——它让这次调试成为可以交接的结论,而不是随上下文一起消失的过程。

设计三:三个假设都错了,就往上报告

七步里藏着一条最容易被忽略的规则:三个假设都失败之后,停下来。

停下来干什么?重读契约和技术设计——因为 bug 可能不在代码里,在计划里。代码忠实地实现了设计,而设计本身错了,这种失败你提多少个代码层面的假设都不会中。

这时的做法是向主 Agent(orchestrator)报告,而不是自己动手改设计。边界划在这里是有意的:开发者角色的权限是修实现,不是改契约。它要是自己把设计改了,等于把错误从代码层搬进计划层,还没人知道发生过——下游的评测拿着旧契约打分,账就再也对不上了。升级报告,是让错误在它所在的层被处理。

它管不了什么

边界说清,免得误用。

第一,它对"失败不可复现"无能为力。如果失败只在某个特定环境出现、本地构造不出来,七步的第一步就无从下手——这时候缺的不是调试纪律,是可观测性:先得让失败能以可复现的形式被看见。下一篇讲的 e2e-testing 正是管这一头的:把功能的真实行为变成可反复运行的检查,让失败在出现的第一时间就带着复现方法到场。

第二,它保证的是找到原因,不保证原因好修。根因可能在第三方库里、在后端接口里、在一份你无权改的契约里——skill 能把"不知道为什么"变成"知道为什么、以及为什么现在修不了”,但变不出权限。

第三,它有成本。七步走完比打三个补丁慢得多。这正是它把触发条件限定在"原因不明显"上的原因:看得见的原因直接修,看不见的才值得把调试当成实验来做。