# Debugging

> 遇到 bug、报错、崩溃或行为异常，需定位根因时。

- Skill: `lion-1209/debugging` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add lion-1209/debugging`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lion-1209/debugging/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lion-1209 (https://skillmd.com/u/lion-1209)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lion-1209/debugging

---


# Debugging

## 概述

用科学方法定位 bug 的根因，而不是凭直觉乱改碰运气。核心：调试是**观察 → 假设 → 设计实验验证 → 缩小范围 → 定位根因**的循环，每一步都有依据。改到"不报错"不算修好——那可能只是把症状盖住了，根因还在。

## 何时使用

- 遇到报错/崩溃/行为异常，要定位原因
- bug 不稳定复现，不知触发条件
- 调试卡住、改了好几处都不行
- 看了代码"没发现问题"，但程序确实有问题

**不该用**：行为符合预期的"不是 bug"（先确认是不是 bug，别把功能当 bug 调）；环境/部署问题（先排查是不是代码外原因）。

**与相邻 skill 的边界**：`debugging` 管**定位根因**（调查：复现、假设、二分、读堆栈），`verify-and-fix` 管**修复与验证**（改对：修病因不症状、防回归）。两者接力——debugging 找出"问题是什么、在哪"，verify-and-fix 接手"怎么改对、怎么确认修好"。debugging 的终点（定位的根因）就是 verify-and-fix 的起点。

**当 bug 报告太模糊时**（"页面打不开""功能不工作"却没给报错/复现步骤），先回到 `clarifying-questions` 的思路——要可观察的现象（报错信息、触发操作、环境、是所有情况还是特定情况）再分析。没有现象的调试就是盲猜。

## 核心内容

### 第一原则：先看报错，别跳过它直接猜

报错信息（异常类型、消息、堆栈、行号）往往**直接包含根因线索**，是调试最便宜的情报。最常见、最浪费的错法是**不看报错就凭直觉改**——明明堆栈第 3 行写着 `Cannot read 'id' of undefined at line 42`，却跳过它去猜"是不是网络问题""是不是缓存"。

读报错的顺序：

1. **异常类型 + 消息**：发生了什么（TypeError？NullPointer？超时？）
2. **第一个你自己代码的堆栈帧**：在哪发生的（行号 + 函数）——注意是"你的代码"，不是框架/库内部的帧
3. **触发上下文**：什么操作/数据触发的

**读堆栈的技巧**：堆栈常被框架/异步包装得很难读，几个技巧帮你找到真正的根因帧：

- **跳过框架帧**：堆栈顶部往往是一堆框架内部代码（React 调度、Express 中间件、ORM 反射），真正的根因在**第一个属于你项目源码的帧**——往下翻找到你认识的文件名/行号。
- **异步代码的堆栈可能不连续**：`async/await`、Promise、回调、事件循环的堆栈经常断开（一个错误在 setTimeout 里抛，堆栈却看不到触发它的代码）。现代运行时有 `--async-stack-traces` 或类似的异步堆栈支持，开启它；否则要在触发处手动打日志补全调用链。
- **错误被转发后原始堆栈会丢**：如果错误被 catch 又重新抛（尤其改了消息或包了新异常），原始堆栈可能藏在 `error.cause` 或 `originalError` 里——别只看最外层，挖嵌套的 cause 链。

养成习惯：**遇到 bug，第一件事是完整读一遍报错**，而不是打开编辑器开始改。读不懂报错时，先查懂它（搜异常类型、读文档），别跳过。

### 先复现，再调试

不能稳定复现的 bug 几乎无法调试——你改了不知道有没有效，因为"不报错"可能是修好了，也可能是这次没触发。**调试前先建立可复现**：

- 找到触发 bug 的**最小条件**：什么输入、什么操作顺序、什么状态组合下必现？
- 最小化：剥离无关因素，直到只剩"做 X 就必崩"。最小复现让你能反复试验、验证修复。

不稳定复现的 bug（偶发）尤其要先攻克复现——它通常意味着有**隐含条件**没找到（并发时序、特定数据、资源竞争、时间相关）。找这个条件本身就是定位根因的关键。

> 反例：bug 偶发，你直接多加几个 try/catch 把可能出错的地方包起来"这样就不崩了"——错误被吞了看不见，但触发条件和根因一行没动，换个场景又炸，而且现在连报错都没了，更难查。

### 科学方法：假设 → 实验 → 验证

定位根因靠**假设驱动**，不是碰运气：

1. **观察**：报错是什么、何时发生、复现条件。
2. **假设**："我猜根因是 X"——基于观察和代码理解提出**具体、可证伪**的假设（不是"大概是哪里有问题"）。
3. **设计实验**：如果是 X 导致的，那应该观察到 Y（可验证的预测）。
4. **验证**：跑实验，看 Y 是否成立。成立 → 假设支持，继续深入；不成立 → 排除这个假设，换下一个。
5. **缩小范围**：每次实验排除一部分可能性，把根因锁定在更小的范围。

关键纪律：**一次只验证一个假设**。同时改多个变量，有效了也不知道是哪个起作用——这是 shotgun debugging（散弹枪式乱改）的典型。

**什么样的假设是好假设**：调试假设要**具体且可证伪**——能说"如果是 X 导致的，那应该观察到 Y"，并且能设计实验验证 Y。

- 差的假设："大概是缓存的问题""可能是哪里有 bug"——模糊、无法验证，等于没假设，只会让你乱试。
- 好的假设："我猜是用户列表为空时 find 返回 undefined 导致后续 .name 抛错——如果是，那传一个空数组应该必现，传非空不报。" 具体（空数组触发）、可证伪（传非空应该不报）、能设计实验（构造两种输入对比）。

提不出具体假设，说明你对问题观察得不够——回到读报错、看运行时真实数据、建立复现，先把现象搞清楚。

### 二分法：范围太大时快速缩小

当代码量大、不知问题在哪时，用**二分法**（git bisect 是经典实现）：

- 注释/禁用一半代码，看 bug 还在不在
- 还在 → bug 在剩下的一半里；消失了 → bug 在被禁用的那一半里
- 对剩下一半重复，每轮砍一半

二分法把"在 10000 行里找 bug"变成"log₂(10000) ≈ 13 次实验"。它不依赖理解代码，纯靠排除——当你读代码读不出问题时，二分是最有力的工具。

适用场景：回归 bug（之前好的现在坏了，用 git bisect 定位是哪次提交引入的）、不知问题模块的大型代码库、读代码陷入僵局。

### 回归 bug：用好"曾经正常"这个参照点

"之前好的，现在坏了"是最容易调的一类 bug——你有一个**已知正常的版本**作参照，省掉了"重建预期行为"的功夫。策略：

- **git bisect 是首选**：在"好的"和"坏的"提交之间二分，每轮 git 自动 checkout 中间提交让你测，log₂(N) 次就能锁定引入 bug 的那次提交。这比读 diff 猜快得多。
- **锁定提交后看 diff**：那次提交改了什么，根因大概率就在 diff 里——范围从"整个代码库"缩到"一次提交的几处改动"。
- **想清楚"为什么之前没事"**：很多回归 bug 的根因是"旧代码依赖了一个隐含假设，新改动破坏了它"。问"这个改动为什么会引发这个症状"往往直接指向根因，比泛泛调试快。

### 区分"代码问题"和"运行时问题"

"看了代码没发现问题"不等于"代码没问题"——也可能是**运行时问题**，代码逻辑没错，但运行时的数据/环境/状态让行为出错：

- **代码问题**：逻辑写错（看代码能发现）。
- **运行时问题**：数据不符合预期（null、空、格式错）、环境差异（依赖版本、配置、权限）、状态问题（时序、并发、缓存残留）、外部依赖故障（网络、第三方服务）。

调试时两者都要查。如果代码读起来"没问题"，转向查运行时：**实际数据是什么样？打印/日志看一下真实值**，而不是盯着代码猜。很多 bug 是"代码假设数据是 A，实际数据是 B"——光看代码发现不了，看真实数据立刻暴露。

## 常见错误

| 问题 | 修法 |
|------|------|
| 不看报错就凭直觉猜 | 第一件事完整读报错（类型/消息/堆栈/行号） |
| 用 try/catch 把错误盖住当"修好" | 那是掩盖症状，先定位根因（见 verify-and-fix） |
| 乱改试错（同时改多处） | 一次只改一个变量、验证一个假设 |
| 不能复现就硬调 | 先建立最小复现，否则改了也不知道有没有效 |
| 改到"不报错"就停 | 不报错≠修好，可能只是症状被盖住，查清根因 |
| 只读代码不看运行时数据 | 代码没问题可能是运行时问题，打印/日志看真实值 |
| 陷入循环（A↔B），继续硬改 | 停，回到稳定状态，换方法（二分/重新假设） |
| 范围太大读代码读不出 | 用二分法快速缩小，不依赖理解代码 |
| 回归 bug 逐行读 diff 猜 | 用 git bisect 在好/坏提交间二分，锁定引入提交再看那一次的 diff |

