--- name: diagnosing-bugs description: 用于诊断(debug)疑难 bug、测试失败、性能退化和偶发问题;用户附报错或堆栈请求修复时也触发。 --- # 诊断 bug 处理疑难 bug 的方法。按阶段推进;跳过某个阶段时,明确说明理由。 探索代码库时,阅读 `CONTEXT.md`(如果存在),准确理解相关模块,并查看相关区域的 ADR。 ## 脱敏 本 skill 需要你展示命令、输出和抓取的数据。**先对所有密钥脱敏**:在原位置写 `<已脱敏>`。 - 反馈回路通过环境变量读取凭据,让凭据留在环境中,不出现在你展示的内容里。 - 抓取的数据常带有认证头:只引用包含诊断信息的那几行。 脱敏后的输出不足以诊断时,说明这一点并请用户协助。 ## 阶段 1:建立反馈回路 **这是本 skill 的核心。** 其余阶段都比较机械。只要拥有一个针对这个 bug 的**快速、确定**的通过或失败信号(一个会在*这个* bug 上**变红**的信号),就一定能找到原因;二分、假设检验和插桩都建立在这个信号之上。没有这个信号,读再久的代码也无法定位问题。 在这里投入最多精力。积极尝试不同方法,直到建立起可用的反馈回路。 ### 先从这里开始 构建反馈回路所需的信息往往已经存在: - **完整阅读错误信息**:把堆栈读到底,记下行号、文件路径和错误码。 - **查看最近的变更**:`git log`、`git diff`、新增依赖、配置改动、环境差异。 ### 构建反馈回路的方式(大致按优先顺序) 1. **失败的测试**:放在任何能触发 bug 的接缝上,包括单元、集成或端到端层级。 2. **curl 或 HTTP 脚本**:请求正在运行的开发服务器。 3. **CLI 调用**:提供固定输入,把 stdout 与已知正确的快照做 diff。 4. **无头浏览器脚本**(Playwright 或 Puppeteer):驱动 UI,并断言 DOM、控制台或网络请求。 5. **回放抓取的数据**:把真实的网络请求、请求体、事件日志保存到磁盘,单独让代码路径回放一遍。 6. **一次性测试环境**:启动系统的最小子集(一个服务,依赖用 mock 替代),一次函数调用就能执行到 bug 所在的代码路径。 7. **属性测试或模糊测试**:bug 表现为“有时输出错误”时,运行 1000 个随机输入,寻找失败规律。 8. **二分脚本**:bug 出现在两个已知状态(提交、数据集、版本)之间时,把“启动到状态 X、检查、重复”自动化,以便使用 `git bisect run`。 9. **差分对比**:用同一输入分别运行旧版本和新版本(或两种配置),对比输出。 10. **人工参与的 bash 脚本**:最后的手段。必须由人点击操作时,用 [scripts/hitl-loop.template.sh](scripts/hitl-loop.template.sh) 引导*人*完成步骤,让反馈回路仍然保持结构化。抓取到的输出会回传给你。Windows 上在 Git Bash 或 WSL 中运行它。 反馈回路建好了,问题基本就解决了九成。 ### 改进反馈回路 像打磨产品一样改进反馈回路。有了*一个*可用的回路后,继续改进: - 能更快吗?(缓存准备工作、跳过无关的初始化、缩小测试范围。) - 信号能更明确吗?(断言具体症状,而不是“没有崩溃”。) - 能更确定吗?(固定时间、固定随机种子、隔离文件系统、屏蔽网络。) 一个耗时 30 秒且时灵时不灵的反馈回路,比没有好不了多少;一个耗时 2 秒且结果稳定的反馈回路,才能真正支撑调试。 ### 非确定性 bug 目标不是得到干净的复现,而是**提高复现率**:把触发条件循环执行 100 次、并行执行、增加负载、缩小时间窗口、注入 sleep。复现率 50% 的 bug 可以调试,1% 的不行,所以要持续提高复现率,直到可以调试。与时序相关的不稳定测试,阅读 [CONDITION-BASED-WAITING.md](CONDITION-BASED-WAITING.md)。 测试单独运行通过、和其他测试一起运行才失败,或者测试跑完后多出不该有的文件时,问题出在另一个测试上:阅读 [TEST-POLLUTION.md](TEST-POLLUTION.md),用其中的脚本找出是哪个测试文件。 ### 确实无法建立反馈回路时 停下来明确说明,并列出已经尝试过的方法。向用户请求以下任一支持: - 能复现问题的环境的访问权限; - 脱敏后的抓取数据(HAR 文件、日志转储、core dump、带时间戳的录屏); - 添加临时生产环境插桩的许可。 没有反馈回路,就不进入假设阶段。 ### 完成条件:一个会变红的快速反馈回路 阶段 1 完成的标志是:你能给出**一条命令**(脚本路径、测试调用或一条 curl),并且**已经至少运行过一次**(展示调用和输出,已脱敏)。这条命令需要满足: - [ ] **能变红**:它执行真实的 bug 代码路径,断言**用户描述的确切症状**,因此会在这个 bug 上失败、修复后通过。“运行不报错”不够,它必须能*发现这个具体的 bug*。 - [ ] **确定**:每次运行结论一致(不稳定的 bug:复现率稳定在较高水平,见上文)。 - [ ] **快速**:秒级,而不是分钟级。 - [ ] **agent 可运行**:你能在无人值守时运行它;需要人参与时,只通过 `scripts/hitl-loop.template.sh` 进行。 如果发现自己在这条命令出现之前就开始读代码、构建理论,**立即停下:跳过反馈回路直接猜测原因,正是本 skill 要防止的失败。** 没有会变红的命令,就不进入阶段 2。 ## 阶段 2:复现并最小化 运行反馈回路,确认它随 bug 出现而变红。 确认: - [ ] 反馈回路产生的是**用户**描述的失败,而不是附近另一个碰巧出现的失败。找错了 bug,修复也会错。 - [ ] 失败可以多次复现(非确定性 bug:复现率足够高,可以用来调试)。 - [ ] 已经记录确切症状(错误信息、错误输出、耗时),后续阶段可以用它验证修复确实解决了问题。 ### 最小化 变红之后,把复现缩小到**仍然会失败的最小场景**。对输入、调用方、配置、数据和步骤**每次只删除一项**,每删除一项就重新运行反馈回路,只保留导致失败所必需的部分。 这样做的价值:最小复现能缩小阶段 3 的假设范围(可疑因素更少),也能在阶段 5 直接变成清晰的回归测试。 完成条件:**剩下的每个元素都是必需的**,去掉任何一个,反馈回路都会通过。 没有复现并最小化,就不继续往下。 ## 阶段 3:提出假设 检验任何假设之前,先提出 **3 到 5 个排好序的假设**。只提出一个假设,容易被第一个看似合理的想法带偏。 假设的来源: - **对照正常工作的代码**:在代码库中找一段相似但正常工作的代码,逐条列出它与出问题代码之间的差异,再小的差异也列出来。 - **回溯根源**:错误出现在调用栈深处时,沿调用链往回追,找到错误值最初从哪里产生。方法见 [ROOT-CAUSE-TRACING.md](ROOT-CAUSE-TRACING.md)。 - **完整阅读参考实现**:你在套用某个模式时,逐行读完参考实现。 每个假设都必须**可证伪**:写出它作出的预测。 > 格式:“如果 是原因,那么 <改变 Y> 会让 bug 消失,或 <改变 Z> 会让它更严重。” 说不出预测的假设只是直觉:丢弃它,或者把它改写得更具体。 **检验之前,把排好序的假设清单给用户看。** 用户常有能立即调整排序的领域知识(例如“我们刚部署了一个改动,和第 3 个假设有关”),或者知道哪些假设已经排除过。这个检查点成本很低,却能节省大量时间。无需等待回复;用户不在时,按你的排序继续。 ## 阶段 4:插桩 每个探测点都要对应阶段 3 的某个具体预测。**每次只改变一个变量。** 工具优先级: 1. 环境支持时,使用**调试器或 REPL** 检查。一个断点通常胜过十条日志。 2. 在能够区分不同假设的边界上添加**定向日志**。在多组件系统中(例如 CI、构建、签名,或 API、服务、数据库),在每个组件边界记录流入的数据、流出的数据,以及环境和配置是否正确传递。运行一次,根据证据判断问题出在哪一层,再深入这一层。 3. 日志要针对假设。“把所有东西打日志再 grep”会淹没真正有用的信号。 **每条调试日志都加唯一前缀**,例如 `[DEBUG-a4f2]`。这样收尾清理只需要一次 grep。加了前缀的日志会被清理,没加的会被遗留下来。 **性能问题的处理。** 性能退化时,日志通常不是合适的工具。先建立基线测量(计时脚本、`performance.now()`、profiler、查询计划),再做二分。先测量,后修复。 ## 阶段 5:修复与回归测试 **修复之前**编写回归测试,前提是存在**合适的接缝**。 合适的接缝是指:测试能按照 bug 在调用点的实际发生方式,复现**真实的 bug 模式**。如果唯一可用的接缝太浅(例如 bug 需要多个调用方参与,而测试只有一个调用方;或单元测试无法复现触发 bug 的调用链),在那里写回归测试只会带来虚假的安全感。 **没有合适的接缝,这本身就是一项发现。** 记录下来:代码库的架构使这个 bug 无法被测试覆盖。留到阶段 6 处理。 存在合适的接缝时: 1. 把最小复现变成该接缝上的失败测试。 2. 确认它失败。 3. 实施修复:**在根源处修复**,每次只做一个改动,只修改与这个根因相关的代码。 4. 确认它通过。 5. 对原始(未最小化的)场景重新运行阶段 1 的反馈回路。 根源修复完成后,是否还要在数据流经的其他层加校验,阅读 [DEFENSE-IN-DEPTH.md](DEFENSE-IN-DEPTH.md)。 ### 修复三次仍未解决 修复没有生效时,回到阶段 1,结合新信息重新分析。统计已经尝试的修复次数: - **少于 3 次**:回到阶段 3,根据新证据重新排序假设。 - **3 次及以上**:停下来检查架构。以下迹象说明问题不是某个假设错了,而是结构有问题:每次修复都在其他地方暴露新的共享状态或耦合、修复需要“大规模重构”才能落地、每次修复都在别处引发新症状。把这个判断和证据交给用户讨论,再决定是否继续修复。 ## 阶段 6:收尾 声明完成之前必须满足: - [ ] 原始问题不再复现(重新运行阶段 1 的反馈回路并阅读输出) - [ ] 回归测试通过,并完成了红绿验证(撤销修复后测试会失败);或者已经记录缺少合适接缝 - [ ] 所有 `[DEBUG-...]` 插桩已删除(用 grep 搜索前缀确认) - [ ] 一次性原型已删除(或移到明确标注的调试位置) - [ ] 最终得到证实的假设已写入提交信息或 PR 描述,方便下一个调试的人参考 完成声明遵循 `verifying-completion` skill。 然后思考:**怎样才能从一开始就避免这个 bug?** 如果答案涉及架构改动(缺少合适的接缝、调用方之间耦合过紧、存在隐藏的共享状态),带着具体细节告诉用户可以运行 `/improve-codebase-architecture`。在修复*之后*提出,因为这时你对问题的了解最充分。 ## 确实找不到根因时 完整走完各阶段,证据表明问题来自环境、时序或外部系统(例如第三方服务偶发超时)时: 1. 写下调查过程:建立了哪些反馈回路、检验并排除了哪些假设、证据指向哪里。 2. 加上与原因相称的处理:重试、超时、清楚的错误信息。 3. 加上日志或监控,让下次出现时能留下诊断所需的信息。 声称“找不到根因”之前,先核对阶段 1 的完成条件是否都满足、阶段 3 的每个假设是否都检验过。多数“找不到根因”其实是调查还没做完。 ## 常见借口与对应事实 | 借口 | 事实 | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | | “问题很简单,不用走流程” | 简单的问题也有根因,对简单问题走流程很快。 | | “情况紧急,没时间走流程” | 系统化诊断比反复猜测、反复试错更快。 | | “先试个快速修复,再慢慢调查” | 第一个修复会定下之后的做法。先建立反馈回路,再修复。 | | “一眼就看出问题了” | 看到症状不等于理解根因。让反馈回路变红,再用它证明修复有效。 | | “修好之后再补测试” | 没有测试的修复留不住。先写会变红的回归测试,存在合适接缝时见阶段 5。 | | “一次改几处,省时间” | 同时改几处就分不清是哪一处起了作用,还容易引入新问题。每次只改一个变量。 | | “参考实现太长,照着思路改改就行” | 只理解一部分,照搬必然出错。逐行读完参考实现。 | | “再试一次修复”(已经失败两次以上) | 连续失败说明方向错了。回到阶段 1 重新分析;失败三次及以上时,按“修复三次仍未解决”检查架构。 | | “加个 sleep 或调大超时,先稳定下来” | 等待时间只会让失败变少,不会消除原因。用条件等待,见 [CONDITION-BASED-WAITING.md](CONDITION-BASED-WAITING.md)。 | ## 多个互不相关的失败 同时出现多个失败,且它们属于互不相关的领域(不同的测试文件、不同的子系统、没有共享状态)时,为每个领域派一个子代理并行诊断。每个子代理获得: - 明确的范围; - 失败的测试名称和错误信息; - “不修改范围外代码”的约束; - 返回根因和改动摘要的要求。 子代理返回后,检查改动之间是否冲突,并运行完整测试套件。如果失败之间可能相关(修好一个可能顺带修好另一个),先放在一起诊断。