诊断 Bug 的技艺
一套用于处理棘手 Bug 的规范。除非有明确理由,否则不要跳过任何阶段。
在探索代码库时,请先阅读 CONTEXT.md(如果存在),以建立相关模块的清晰心智模型,并检查你正在触及的区域的 ADR。
脱敏
此技能要求你展示命令、输出和捕获的产物。首先对每个秘密进行脱敏处理:用 <REDACTED> 替换。针对环境变量构建循环,以便凭据保留在环境中,而不是显示在输出中。捕获的产物可能包含认证头:只引用包含信号的行。
如果脱敏后的输出不足以诊断 Bug,请说明情况并询问用户。
阶段 1:构建反馈循环
这是技能的核心。 其他所有内容都是机械性的。如果你有一个紧密的针对该 Bug 的通过/失败信号(一个能针对这个 Bug 变红的信号),你就能找到原因;二分法、假设检验和代码插桩都只是消费这个信号。如果你没有这样一个信号,再多的代码审查也救不了你。
在此处花费不成比例的精力。要激进。要有创意。拒绝放弃。
构建反馈循环的方法,大致按此顺序
- 失败的测试,在能触及 Bug 的任何层:单元测试、集成测试、端到端测试。
- Curl / HTTP 脚本,针对正在运行的开发服务器。
- CLI 调用,使用固定的输入,并将 stdout 与已知正确的快照进行对比。
- 无头浏览器脚本 (Playwright / Puppeteer),驱动 UI 并在 DOM/控制台/网络上进行断言。
- 重放捕获的追踪。 将真实的网络请求/负载/事件日志保存到磁盘;通过隔离的代码路径重放它。
- 临时测试工具。 启动系统的一个最小子集(一个服务,模拟依赖),用单个函数调用就能执行 Bug 代码路径。
- 属性 / 模糊测试循环。 如果 Bug 是“有时输出错误结果”,运行 1000 个随机输入并寻找失败模式。
- 二分法测试工具。 如果 Bug 出现在两个已知状态(提交、数据集、版本)之间,自动化“在状态 X 启动,检查,重复”的过程,以便你可以用
git bisect run执行它。 - 差异化循环。 对旧版本 vs 新版本(或两种配置)运行相同的输入,并比较输出。
- 人机交互 shell 脚本。 最后的手段。如果需要人类手动点击,用
scripts/hitl-loop.template.sh脚本来驱动他们,使循环仍然结构清晰。捕获的输出会反馈给你。
构建了正确的反馈循环,Bug 就完成了 90% 的修复。
收紧循环
将循环视为一个产品。一旦你有了一个循环,就收紧它:
- 我能让它更快吗?(缓存设置,跳过无关的初始化,缩小测试范围。)
- 我能让信号更清晰吗?(针对具体症状进行断言,而不是“没有崩溃”。)
- 我能让它更确定吗?(固定时间,种子化 RNG,隔离文件系统,冻结网络。)
一个 30 秒的不稳定循环几乎不比没有循环好;一个 2 秒的确定性循环是紧密的,是一个调试超能力。
非确定性 Bug
目标不是一个干净的复现,而是更高的复现率。循环触发 100 次,并行化,增加压力,缩小时间窗口,注入休眠。一个 50% 概率出现的 Bug 是可调试的;1% 则不是,所以持续提高复现率直到它可以被调试。
当你确实无法构建循环时
停下来并明确说明。列出你尝试过的方法。向用户请求:(a) 访问能复现问题的环境,(b) 一个经过脱敏处理的捕获产物(HAR 文件、日志转储、核心转储、带时间戳的屏幕录制),或 (c) 添加临时生产环境插桩的权限。在没构建出循环之前,不要继续提出假设。
完成标准:一个能变红的紧密循环
当循环紧密且具备变红能力时,阶段 1 完成:你可以命名一个命令(脚本路径、测试调用、curl 命令),你已经至少运行过它一次(展示调用和其输出,已脱敏),并且它满足:
- 具备变红能力:它驱动实际的 Bug 代码路径,并就用户描述的确切症状进行断言,因此它可以针对此 Bug 变红,并在修复后变绿。不仅仅是“运行没有报错”;它必须能够捕获这个特定的 Bug。
- 确定性:每次运行结果相同(对于不稳定 Bug:固定的、高复现率,如上所述)。
- 快速:秒级,而不是分钟级。
- 代理可运行:你可以无人值守地运行它;人机交互环节仅通过
scripts/hitl-loop.template.sh存在。
如果你发现自己在这个命令存在之前就通过阅读代码来构建理论,请停下来:直接跳到假设正是此技能旨在防止的失败模式。 没有能变红的命令,就没有阶段 2。
阶段 2:复现 + 最小化
运行循环。观察它变红,Bug 出现。
确认:
- 循环产生了用户描述的失败模式,而不是另一个恰好附近的失败。错误的 Bug = 错误的修复。
- 失败可以在多次运行中复现(或者,对于非确定性 Bug,可以以足够高的速率复现以便调试)。
- 你已经捕获了确切症状(错误消息、错误输出、缓慢时间),以便后续阶段可以验证修复是否真正解决了它。
最小化
一旦它变红,将复现缩小到仍然能使它变红的最简场景。一次一个地削减输入、调用者、配置、数据和步骤,每次削减后重新运行循环,只保留对失败起关键作用的部分。
为什么要费心:最小化复现缩小了阶段 3 中的假设空间(剩余的可疑部件更少),并成为阶段 5 中干净的回归测试。
当每个剩余元素都是关键性的时完成:移除其中任何一个都会使循环变绿。
在完成复现和最小化之前,不要继续。
阶段 3:假设
在测试任何一个假设之前,生成 3–5 个排好序的假设。单个假设生成会锚定在第一个看似合理的想法上。
每个假设必须是可证伪的:陈述它所做出的预测。
格式:“如果 是原因,那么 <改变 Y> 将使 Bug 消失 / <改变 Z> 将使 Bug 更严重。”
如果你无法陈述预测,那么这个假设只是一种感觉:放弃它或强化它。
在测试之前向用户展示排序后的列表。 他们通常拥有可以立即重新排序的领域知识(“我们刚刚部署了一个对 #3 的更改”),或者知道他们已经排除过的假设。这是一个廉价的检查点,能节省大量时间。如果用户不在,不要因此阻塞;继续使用你的排序进行工作。
阶段 4:插桩
每次探测必须对应阶段 3 中的一个具体预测。一次只改变一个变量。
工具偏好:
- 调试器 / REPL 检查,如果环境支持的话。一个断点胜过十个日志。
- 目标明确的日志,放在能区分假设的边界上。
- 绝不“记录所有内容然后 grep”。
给每个调试日志打上唯一前缀标签,例如 [DEBUG-a4f2]。最后的清理工作可以变成一个简单的 grep。没有标签的日志会保留;有标签的日志会被删除。
性能分支。 对于性能回退,日志通常是错误的。相反:建立一个基线测量(时间测试工具、performance.now()、分析器、查询计划),然后进行二分查找。先测量,再修复。
阶段 5:修复 + 回归测试
在修复之前编写回归测试,但仅当存在一个正确的切入点时。
正确的切入点是指测试能像在调用点发生的那样,锻炼真实的 Bug 模式。如果唯一可用的切入点太浅(单调用者测试,但 Bug 需要多个调用者;无法复制触发 Bug 的调用链的单元测试),那么那里的回归测试会提供虚假的信心。
如果不存在正确的切入点,这本身就是一个发现。 记录下来。代码库架构阻止了 Bug 被锁定。为此需要在下一阶段标记出来。
如果存在正确的切入点:
- 将最小化后的复现转化为在该切入点上的一个失败测试。
- 观察它失败。
- 应用修复。
- 观察它通过。
- 针对原始(未最小化)场景重新运行阶段 1 的反馈循环。
阶段 6:清理
在声明完成之前需要完成的工作:
- 原始复现不再出现(重新运行阶段 1 的循环)
- 回归测试通过(或记录不存在正确的切入点)
- 所有
[DEBUG-...]插桩已移除(使用前缀进行grep确认) - 临时原型已删除(或移至明确标记的调试位置)
- 被证明正确的假设已记录在提交/PR 消息中,以便未来的调试者学习