bug-root-cause:在崩溃点设防,是最常见的假修复
这是逐份拆 skill 系列的一篇,拆的是 bug-root-cause——通用技能组里管修 bug 的一份,不绑任何阶段,任何上下文里遇到 bug 都能用。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记
》讲了整套系统的形状;这个系列逐份拆开,每篇只讲一份:它为什么存在,具体怎么干。我用的系统是开源的 frontend-harness-cli
,文中规则都能在仓库的 templates/skills/bug-root-cause/SKILL.md 里找到原文。
它和前面拆过的 systematic-debugging
是同一套方法的两个版本:那份绑在实现阶段里,产出要写进任务报告,假设全错了还要向主 Agent 升级;这份是独立版——没有任务、没有报告格式、没有编排者,只剩方法本身。
崩溃点上加了一个空值检查
先看一个具体的场景。
用户主页偶尔白屏,报错很明确:读取 profile.name 时,profile 是 null。修法看起来也很明确:在读取处加一个空值判断,profile 为空时显示占位。“修复"上线,白屏消失。
两周后,设置页开始出问题:同一批用户的昵称是空的。再后来,订单确认页拿不到收货人姓名,流程卡住。三个毛病,查到最后是同一个源头:有一条注册路径漏了创建 profile 的步骤,这批用户的 profile 从出生起就不存在。而按业务约定,登录用户必然有 profile——它就不该是 null。
回头看第一处修复:它没有让任何东西变对。它只是让第一个爆炸点安静下来,于是坏值继续往下游走,在第二、第三个屏幕上以更不像 bug 的样子发作——昵称为空看起来像数据问题,流程卡住看起来像另一个功能的问题。如果当初有人多问一句"这个值是从哪儿变成 null 的”,后面三件事一件都不会发生。
但没人问。因为崩溃点就在眼前,补丁也就在手边。
这份 skill 开篇就把这件事说死了:“The fastest fix is usually a patch over the symptom: a null check where the value shouldn’t be null, a retry where the call shouldn’t fail, a special case where the rule is wrong. The bug then returns in a different costume.”(最快的修法通常是给症状打补丁:在不该为空的地方加空值检查、在不该失败的调用上加重试、在规则错了的地方加特例。然后 bug 换身衣服又回来。)
这份 skill 干什么:把坏掉的值往上游追
它的自我定义只有一句:在根因处修 bug——复现、缩小、建立证据链,然后改那个原因,永远不给症状打补丁。
适用范围写在 skill 里,有四类:根因尚未被证实的任何 bug;之前"修过"又回来的 bug;间歇性故障、竞态和"在我机器上是好的"这类报告;以及你正打算在一份还没完全读懂的代码里动手的时候。反过来说,原因已经被证实的 bug 不需要它——编译器把行号都写出来了,照着改就是。它管的是"现象明确、原因不明"的那一类,而这类恰恰是补丁的重灾区:越不知道为什么,越想先在看得见的地方按一下。
它和 systematic-debugging 的分工也在这里:方法同源,但那份是流程内的纪律,嵌在任务检查和任务报告里;这份是流程外的工具,给任何一次普通修 bug 用。流程能提供的强制力它都没有——没有内核拦着你,没有报告等着你填。它能靠的只有流程本身排得够清楚,让跳步的人自己觉得别扭。
八步:从复现到把根因写下来
skill 给的流程是八步,每一步都写明了产出:
- 复现它。 拿到确定性或接近确定性的复现:确切的步骤、输入、环境,以及实际表现和预期表现的对照。产出是一份别人能跑的复现。关键的一条在后面:复现不了,就把"拿到复现"本身当作当前任务——不要开修。没有复现的修复无法验收,你甚至没法区分"修好了"和"这次没碰上"。
- 缩小范围。 对输入、代码路径和历史做二分:关掉一半功能、用
git bisect找引入问题的提交、把失败用例剥到最小。产出是一个最小失败样例。范围越小,后面每一步的成本越低;一个能在十行代码里复现的 bug,不需要你通读整个仓库。 - 建立证据链。 提出假设,逐个用观察检验——日志、调试器、一个专门写的失败测试。留下的必须是经得住证据的那个。产出是从症状到根因的一条链,每一环都是观察到的,不是"大概是"。这一步专门防一种最常见的自欺:停在第一个貌似合理的解释上,在没有任何观察能区分它和第二个解释之前就动手写修复。
- 用一句话说出根因。 格式是钉死的:“X 之所以发生,是因为 Y,在条件 Z 下。“而且这句话必须解释每一个观察到的症状,包括那些间歇性的——只能解释主症状、解释不了偶发症状的根因,多半只是根因的一个下游。
- 在根因处修根因。 在错误的值、状态或假设产生的地方,做最小改动。skill 给这一改动的检验标准很形象:一个放到别处就会显得不对的 diff。如果你的改动放在崩溃点也行、放在调用方也行、放在工具函数里也行,那它大概率还没落在根因上。
- 查爆炸半径。 搜其他调用方、同类兄弟和同一模式的副本,逐个说明:此处已修、同样受影响、不受影响(以及你是怎么查的)。只修报告的那一处、同样的缺陷在三个兄弟组件里躺着连查都不查,是 skill 明文列出的反模式——根因类 bug 很少只犯一次,产生坏值的那个地方通常喂着不止一个下游。
- 用测试锁住。 加上或找出那个测试:没有这个修复时它失败,有修复时它通过。测试加在根因所在的层级——根因在数据生成处,测试就该打在那里,而不是在最终崩溃的页面上断言"不白屏”;后者只能证明症状被盖住了。实在没法自动化,就明确写下为什么。
- 写下来。 把根因、证据、修复和爆炸半径记进提交信息或 issue。产出是:以后的读者不用重做这场调查,就能审计这段推理。
设计一:在错误产生的地方改,别在它爆炸的地方改
整份 skill 的骨架就是一条总规则:从第一性原理出发,在原因处修,别在症状上糊。
为什么反复强调位置?因为崩溃点是整个系统里信息量最少的地方。值传到这里时已经坏了,你在这里能做的只有防御:判空、兜底、捕获。而防御改变的是"坏值接下来造成什么”,不是"坏值为什么存在"。skill 把"在崩溃点设防"列为头号反模式,理由写得很直接:你加了 ?. 或空值检查之后,真正的腐坏在上游继续静默发生——而且比修复前更安静,因为连最后一个会喊疼的地方都被你堵上了。
反过来,根因处的特点是:改一处,下游全好。profile 在注册时被正确创建,主页、设置页、订单页的问题同时消失,不需要在三个屏幕上各设一道防线。修复位置离根因越远,需要打的补丁就越多;补丁的数量,本身就是你离根因距离的度量。
设计二:症状补丁会复利
症状补丁最大的代价不在当下,在以后。
每一个补丁都是一条特例:这个值在这里可能为空,那个调用在那里可能失败,那条规则在某个条件下不算数。特例写进代码之后就成了地形的一部分:下一个 bug 的调查必须先绕开这些特例——它到底是新问题,还是某个旧补丁的副作用?下一次有人改到附近,先得搞懂每个特例在防什么,而当初为什么加,往没有任何记录(查爆炸半径和"写下来"两步,正是用来对抗这一点的)。
更隐蔽的是复利的方向:补丁让坏值活得更久、走得更远,坏值流经的地方越多,后来者越倾向于在自己那一处再加一个补丁——因为源头已经被层层包裹,动源头的成本看起来比再加一层更高。于是代码慢慢变成一套防御工事,每道工事都真实地防住过一次,但没人说得清最初的那件坏事到底是什么。根因修复看起来慢,贵的是起步那几步;补丁看起来快,贵的是从此每一次。
设计三:止血可以,但止血不算修好
有一条边界要说清,免得把这份 skill 读成教条:线上有用户正在受影响时,先止血是对的。加一个判空让页面别白屏、加一个降级让流程能走完——作为止血带,这是合理的处置。
但止血带和修复是两笔账,skill 的逻辑在这里没有让步:
- 止血必须是有标记、临时的——它买到的是时间,不是对 bug 的宣判。bug 单还开着,根因调查还欠着,止血的改动要在记录里写明"这是缓解,不是修复"。
- 止血绝不能反过来冒充修复:因为一旦那处判空被当成"修好了",调查就永远不会发生,坏值就永久地在上游生产、在下游被逐个拦截——这正是开头那个场景的成因。
区分二者的标准很简单:止血之后,你能不能用一句话说出根因?说不出,这个 bug 就还没有被修,只是被按住了。
它管不了什么
边界同样说清。
第一,它治不了"复现不了又造不出复现"的 bug。第一步拿不到复现,后面七步全部悬空;这时候缺的是可观测性——日志、监控、能重放的输入——那是另一份工作,这份 skill 帮不上。
第二,它保证你找到并改掉的是原因,不保证代码从此没 bug。爆炸半径查得再全,也只覆盖"同一模式"的副本;根因修对了,也不妨碍隔壁藏着另一个独立的根因。它给的是这一次的终局,不是整片代码的免疫。
第三,也是最实在的:它慢。复现、二分、证据链、一句话根因——对一个赶着上线的补丁来说,这套起步明显更贵。它的适用范围因此写得很克制:根因已被证实的 bug 不要走它,真正简单的错误照着报错改就是。它只要求一件事:当你打算在没读懂的代码里、或者对一个回来过两次的 bug 动手时,别再用最快的那种修法。
修完之后还有一步不在这份 skill 里:怎么证明"修好了"这个结论本身可信——复现不再失败是证据,但够不够、要不要更硬的标准,是下一篇 verification-before-completion 管的事。