诊断 bug
一套对付难调 bug 的纪律。只有在明确说出理由时才能跳过阶段。
探索代码库时,先读 CONTEXT.md(若存在)建立相关模块的心智模型,并查你将要改动区域的 ADR。
脱敏
本 skill 会让你展示命令、输出和抓取到的产物。先给每个密钥脱敏——在它原位写 <REDACTED>。回路对着环境变量搭,让凭证留在环境里,而不是留在你展示的东西里。抓取的产物带 auth header:只引用承载信号的那几行。
如果脱敏后的输出不足以诊断 bug,说明情况,问用户。
阶段 1 — 建立反馈回路
这一步就是本 skill 的全部。 其余都是机械操作。如果你有一个紧的通过/失败信号——一个在这个 bug 上会变红的信号——你一定能找到根因;二分、验证假设、加打点,全都只是在用它。没有这样一个信号,盯着代码看到天亮也没用。
在这里投入不成比例的精力。积极点。有创意点。别放弃。
怎么搭一个回路——大致按这个顺序试
- 失败的测试,落在能触达 bug 的任意接口——单元、集成、端到端。
- curl / HTTP 脚本,打本地 dev server。
- CLI 调用,喂一个 fixture 输入,把 stdout 和一个已知正确的快照做 diff。
- 无头浏览器脚本(Playwright / Puppeteer)——驱动 UI,对 DOM / console / network 做断言。
- 回放一条抓到的 trace。 把一个真实的网络请求 / payload / 事件日志存到磁盘,单独把它喂回代码路径。
- 一次性脚手架。 起一个系统的最小子集(一个服务、依赖打桩),用一次函数调用走通出 bug 的代码路径。
- 性质测试 / fuzz 回路。 如果 bug 是"偶尔输出错",跑 1000 个随机输入找失败模式。
- 二分脚手架。 如果 bug 出现在两个已知状态之间(commit、数据集、版本),把"启动到状态 X、检查、再来"自动化,好让
git bisect run接管。 - 差分回路。 同一输入跑老版本 vs 新版本(或两套配置),diff 输出。
- HITL bash 脚本。 最后手段。如果非得人手点,用
scripts/hitl-loop.template.sh驱动他们,让回路仍然有结构。抓到的输出再喂回给你。
搭对反馈回路,bug 就修好了九成。
收紧回路
把回路当成一个产品来对待。一旦有了一个回路,就收紧它:
- 能不能更快?(缓存 setup、跳过无关初始化、收窄测试范围。)
- 信号能不能更锋利?(断言具体的症状,而不是"没崩"。)
- 能不能更确定?(钉住时间、固定 RNG 种子、隔离文件系统、冻结网络。)
一个 30 秒还偶发挂的回路,比没回路好不了多少;一个 2 秒、每次结果都确定的回路,才是紧的——调试的超能力。
非确定性 bug
目标不是一次干净的复现,而是更高的复现率。把触发条件跑 100 遍、并行、加压力、收窄时序窗口、注入 sleep。50% 偶发的 bug 能调;1% 的不能——持续把复现率拉高,直到能调为止。
真搭不出回路时
停下来,明说。列出你已经试过的。问用户要:(a) 能复现它的那个环境的访问权限,(b) 一份脱敏的抓取产物(HAR 文件、日志转储、core dump、带时间戳的录屏),或 (c) 加临时生产打点的许可。没有回路时,不要进入假设阶段。
完成条件——一个会变红的紧回路
阶段 1 算完,当回路是紧的且能变红:你能说出一条命令——一个脚本路径、一次测试调用、一条 curl——而且你已经至少跑过一次(贴出调用和它的输出,脱敏的),它满足:
- 能变红——走的是 bug 真正的代码路径,断言的是用户原话描述的症状,所以它能在这个 bug 上变红、修好后变绿。不是"跑起来不报错"——它必须能逮住这个具体的 bug。
- 确定——每次跑结果一致(偶发 bug:按上面说的,固定一个高复现率)。
- 快——秒级,不是分钟级。
- agent 能跑——能无人值守地跑;人介入只通过
scripts/hitl-loop.template.sh。
如果你发现这条命令还不存在、自己就开始读代码构建理论,停下——直接跳到假设,正是本 skill 要防止的那个失败。 没有能变红的命令,就没有阶段 2。
阶段 2 — 复现 + 缩小
跑回路。看它变红——bug 出现了。
确认:
- 回路产出的是用户描述的那个失败模式——不是恰好挨着的另一个失败。搞错 bug = 修错地方。
- 多次跑都能复现(或:偶发 bug 以足够高的复现率出现,能拿来调)。
- 已经抓下了精确的症状(错误信息、错误输出、慢的耗时),后面阶段才能验证修复确实对上了。
缩小
一旦变红,把复现缩到仍然会变红的最小场景。一次砍掉一项——输入、调用方、配置、数据、步骤,每砍一项就重跑一次回路——只留下对失败真正承重的。
为什么要费这个劲:最小复现缩小了阶段 3 的假设空间(剩下的可疑动件更少),并在阶段 5 成为干净的回归测试。
完成于剩下的每一项都承重——去掉任何一项,回路都会变绿。
没有复现且没有缩小之前,别往下走。
阶段 3 — 假设
测试之前,先给出 3–5 条排好序的假设。只生成一条假设,会把你锚在第一个听起来靠谱的念头上。
每条假设必须可证伪:说出它做出的预测。
格式:"如果 <X> 是根因,那么 <改 Y> 会让 bug 消失 / <改 Z> 会让它更严重。"
如果说不清预测,这条假设只是个感觉——丢掉,或磨利它。
测试之前,把排好序的清单给用户看一眼。 他们常有领域知识能立刻重排("我们刚把 #3 那块部署上去了"),或知道哪些假设已经被排除。便宜的检查点,省大把时间。用户不在就别等——按你的排序继续。
阶段 4 — 打点
每一处探针对应阶段 3 的一条具体预测。一次只改一个变量。
工具优先级:
- 调试器 / REPL 检查,环境支持的话。一个断点抵十条 log。
- 针对性的 log,打在能区分各假设的边界上。
- 永不"什么都 log 再 grep 筛选"。
给每条 debug log 打一个唯一前缀,比如 [DEBUG-a4f2]。收尾时一次 grep 就能清干净。没打标签的 log 会活下来;打了标签的会死掉。
性能分支。 对性能回归,log 多半是错的。改成:建立基线测量(计时脚手架、performance.now()、profiler、query plan),然后二分。先量,再修。
阶段 5 — 修复 + 回归测试
先于修复写回归测试——但前提是存在一个正确的接口。
正确的接口,是测试能在调用点真正演习 bug 发生模式的接口。如果手头唯一的测试面太浅(bug 需要多个调用方、却只有单调用方测试;bug 需要整条调用链、单元测试却复现不了),在那里加回归测试只会给虚假信心。
如果没有正确的接口,这本身就是一条发现。 记下来。代码库的架构正在挡住这个 bug 被锁住。标给下一阶段。
存在正确的接口时:
- 把缩小后的复现变成那个接口上的失败测试。
- 看它挂。
- 上修复。
- 看它过。
- 用阶段 1 的回路,对着原始(未缩小的)场景再跑一遍。
阶段 6 — 收尾 + 复盘
宣布完成之前必做:
- 原始复现不再复现(重跑阶段 1 的回路)
- 回归测试通过(或:接口缺失已被记录)
- 所有
[DEBUG-...]打点已移除(grep前缀) - 一次性原型已删(或挪到一个明确标注的 debug 位置)
- 最终被验证为对的那条假设,写进了 commit / PR message——让下一个调它的人有得学
然后问一句:什么本可以预防这个 bug? 如果答案涉及架构改动(没有好的测试接口、调用方纠缠、隐藏的耦合),把这个建议留在修复落地之后再给,不是之前——你现在的信息比开工时多。