诊断缺陷
一套针对难缠缺陷的纪律。只有明确给出理由时,才可以跳过阶段。
在探索代码库时,读 GLOSSARY.md(如果存在),以便对相关模块建立清晰的心智模型,并查看你正在触及的那片区域里的 ADR。
脱敏
本技能会要求你展示命令、输出和捕获到的材料。先把每一处机密信息脱敏:在原处写 <REDACTED>。把回路搭在环境变量之上,让凭据留在环境里,而不是留在你展示的东西里。捕获到的材料里带着认证头:只引用承载信号的那几行。
如果脱敏后的输出不足以诊断这个缺陷,就明说,并向用户提问。
阶段 1:搭一个反馈回路
这就是本技能的核心。 其余都是机械操作。如果你对这个缺陷有一个紧密的通过/失败信号(一个会在这个缺陷上变红的信号),你就能找到原因;二分、假设检验和插桩都只是在消耗它。如果你没有,盯着代码看多久都救不了你。
在这里投入不成比例的精力。要下狠劲。要敢想。拒绝放弃。
构造回路的方式,大致按这个顺序
- 失败的测试,用在任何能触及这个缺陷的接缝上:单元、集成、端到端。
- 对着运行中的开发服务器跑 curl / HTTP 脚本。
- 命令行调用,配一份固定的样例输入,把 stdout 与已知正确的快照做 diff。
- 无头浏览器脚本(Playwright / Puppeteer),驱动界面并对 DOM/控制台/网络做断言。
- 回放捕获到的轨迹。 把一个真实的网络请求/载荷/事件日志存到磁盘;在隔离状态下让它重走那条代码路径。
- 用完即弃的测试台。 起一个系统的最小子集(一个服务,依赖都模拟掉),一次函数调用就能走一遍出错的代码路径。
- 属性/模糊测试回路。 如果缺陷是「输出有时是错的」,就跑 1000 个随机输入,去找那个失败模式。
- 二分测试台。 如果缺陷出现在两个已知状态之间(提交、数据集、版本),把「在状态 X 启动、检查、重复」自动化,这样你就能用
git bisect run跑它。 - 差分回路。 把同样的输入分别跑过旧版本和新版本(或者两份配置),对输出做 diff。
- HITL bash 脚本。 最后的手段。如果必须有人来点,就用
scripts/hitl-loop.template.sh驱动那个人,让回路仍然是结构化的。捕获到的输出会回喂给你。
搭对了反馈回路,这个缺陷就修好了 90%。
收紧回路
把回路当成一个产品来对待。一旦你有了一条回路,就收紧它:
- 我能让它更快吗?(缓存初始化,跳过无关的启动,缩小测试范围。)
- 我能让信号更锐利吗?(对具体的症状做断言,而不是「没崩」。)
- 我能让它更确定吗?(固定时间、给随机数生成器设种子、隔离文件系统、冻结网络。)
一条 30 秒的偶发失败回路比没有回路好不了多少;一条 2 秒的确定性回路才叫紧密,那是调试的超能力。
非确定性缺陷
目标不是一份干净的重现,而是更高的复现率。把触发动作循环 100 次、并行化、加压力、收窄时间窗口、注入 sleep。复现率 50% 的偶发缺陷是可调试的,1% 的不是,所以不断提高这个比率,直到它可调试为止。
当你确实搭不出回路时
停下来,并明确说出来。列出你试过的东西。向用户要:(a)能重现它的那个环境的访问权限;(b)一份脱敏后的捕获材料(HAR 文件、日志转储、崩溃转储、带时间戳的录屏);或者(c)添加临时生产环境插桩的许可。在没有回路的情况下不要继续去做假设。
完成判据:一条会变红的紧密回路
阶段 1 在回路紧密且能变红时结束:你能点出一条命令(一个脚本路径、一次测试调用、一次 curl),你至少已经运行过它一次(展示这次调用及其输出,已脱敏),并且它满足:
- 能变红:它驱动真实的出错代码路径,并对用户的确切症状做断言,所以它能对这个缺陷变红、在修好之后变绿。不是「跑起来不报错」;它必须能抓住这个具体的缺陷。
- 确定性:每次运行的结论都一样(偶发失败的缺陷:按上面的说法,固定一个高复现率)。
- 快:以秒计,不是以分钟计。
- 代理可运行:你能无人值守地跑它;需要人类介入时,只通过
scripts/hitl-loop.template.sh。
如果你发现自己在这条命令存在之前就开始读代码、搭理论,停下:直接跳到假设上去,正是本技能要防的那种失败。 没有能变红的命令,就没有阶段 2。
阶段 2:复现 + 最小化
跑这条回路。看着它在缺陷出现时变红。
确认:
- 这条回路产生的失败模式是用户描述的那个,而不是恰好在旁边的另一个失败。错的缺陷 = 错的修复。
- 这个失败在多次运行中都能复现(或者,对于非确定性缺陷,以足够高的比率复现,足以支撑调试)。
- 你已经捕获了确切的症状(错误信息、错误的输出、慢的耗时),这样后面的阶段才能验证修复确实解决了它。
最小化
一旦它变红,就把复现缩小到仍然会变红的最小场景。一次一个地砍掉输入、调用方、配置、数据和步骤,每砍一刀就重跑一次回路,只留下对这次失败真正起支撑作用的部分。
为什么要费这个劲:最小复现会缩小阶段 3 的假设空间(剩下可怀疑的活动部件更少),并成为阶段 5 里干净的回归测试。
当剩下的每一个元素都在起支撑作用时才算完成:拿掉其中任何一个,回路都会变绿。
在你既复现又最小化之前,不要继续。
阶段 3:提出假设
在检验任何一个之前,先产出 3–5 个排好序的假设。只生成一个假设,会让你锚定在第一个看起来合理的想法上。
每个假设都必须是可证伪的:说出它做出的预测。
格式:「如果 是原因,那么 <改变 Y> 会让缺陷消失/<改变 Z> 会让它更严重。」
如果你说不出这个预测,那这个假设只是一种感觉:丢掉它,或者把它磨锋利。
在检验之前,把排好序的清单给用户看。 他们往往有领域知识,能立刻重排(「我们刚给第 3 条假设部署了一次改动」),或者知道哪些假设他们早就排除过了。便宜的检查点,省下大把时间。不要卡在这里等;如果用户不在(AFK),就按你的排序继续。
阶段 4:插桩
每一个探针都必须对应阶段 3 里某个具体的预测。一次只改一个变量。
工具偏好:
- 调试器/REPL 检查,如果环境支持。一个断点胜过十条日志。
- 有针对性的日志,打在能区分各假设的边界上。
- 永远不要「把所有东西都打日志再 grep」。
给每一条调试日志打上标签,用一个唯一的前缀,例如 [DEBUG-a4f2]。最后的清理就变成一次 grep。没标签的日志会活下来;有标签的日志会死掉。
性能分支。 对性能回归来说,日志通常不是合适的办法。改为:先建立基线测量(计时工具、performance.now()、性能分析器、查询计划),然后二分。先测量,后修复。
阶段 5:修复 + 回归测试
在修复之前写回归测试,但前提是存在一个正确的接缝。
正确的接缝,是测试能演练真实的缺陷模式(如同它在调用点发生的那样)的那个接缝。如果唯一可用的接缝太浅(缺陷需要多个调用方,却只有单调用方的测试;无法复现触发缺陷的那条链路的单元测试),在那里写回归测试只会给出虚假的信心。
如果不存在正确的接缝,这件事本身就是发现。 记下它。代码库的架构正在妨碍这个缺陷被锁死。把这一点标出来留给下一个阶段。
如果存在正确的接缝:
- 把最小化的复现变成那条接缝上的一个失败测试。
- 看着它失败。
- 应用修复。
- 看着它通过。
- 对着最初(未最小化)的场景重跑阶段 1 的反馈回路。
阶段 6:清理
宣布完成之前必须做到:
- 最初的重现不再重现(重跑阶段 1 的回路)
- 回归测试通过(或者,缺少接缝这件事已经记录在案)
- 所有
[DEBUG-...]插桩都已移除(用grep搜那个前缀) - 用完即弃的原型已删除(或移到一处明确标记为调试的位置)
- 最终被证明正确的那个假设已写在提交/PR 信息里,好让下一个调试的人学到东西


