---
title: "用 CLI Agent 做系统化调试"
wiki: cli
category: "使用模式"
slug: systematic-debugging
url: https://learnagent.wiki/cli/cards/systematic-debugging
tags: ["debug", "调试", "工作流", "Claude Code", "root cause"]
last_updated: 2026-04-22
reading_time: 16
---

> 调试这件事，老程序员都知道有两种打开方式。一种是**凭直觉**：看一眼报错，"哦应该是这里加个 null 判断"，改完跑一下不报错就提交了；另一种是**系统化**：先稳定复现，再二分隔离，再问"为什么会这样"直到挖到根因，最后写一个回归测试把这个 bug 钉死。前者快但 bug 容易复发，后者慢但每修一次就少一种 bug 类。

# 用 CLI Agent 做系统化调试

## 基础概念

调试这件事，老程序员都知道有两种打开方式。一种是**凭直觉**：看一眼报错，"哦应该是这里加个 null 判断"，改完跑一下不报错就提交了；另一种是**系统化**：先稳定复现，再二分隔离，再问"为什么会这样"直到挖到根因，最后写一个回归测试把这个 bug 钉死。前者快但 bug 容易复发，后者慢但每修一次就少一种 bug 类。

把这件事放到 CLI Agent 时代，会出现一个非常微妙的失衡。Claude Code、Codex CLI 这类工具天然偏向"看到 bug 立即修"——它们读完报错、扫一眼栈追踪、直接 Edit 一个文件，五秒内就能给你一个看起来"很对"的修复。**这种"快速凑一个 fix"的倾向是默认行为，不是 bug**。原因很直白：模型被训练成"对话即解决问题"，而对话天然鼓励即时回应；同时，CLI Agent 在每个 token 上都有时延和成本压力，"少做一步"几乎是结构性偏好。

但调试这件事，**做少一步通常意味着把症状当 root cause**。比如某个 API 偶尔返回 500，Agent 读了日志看到 `NullPointerException`，于是包了个 `try/catch` 静默吞掉异常——上线以后报错确实没了，但底层那个本该被发现的"配置加载顺序错了"问题被永久埋葬。下次同一个根因换一个表征出现，又得重新挖一遍。

系统化调试就是给 Agent 套一套"做事的顺序约束"，让它必须**先稳定复现，再隔离最小复现集，再追根因，再写 fix，再补回归测试**。这五步少一步都不算结束。

```mermaid
flowchart LR
    A[1. 稳定复现<br/>Reproduce] --> B[2. 隔离最小集<br/>Isolate]
    B --> C[3. 定位 root cause<br/>5 Whys]
    C --> D[4. 写修复<br/>Fix]
    D --> E[5. 回归测试<br/>Regression]
    E -.防止复发.-> A
    style A fill:#e1f5ff
    style C fill:#fff4e1
    style E fill:#e8f5e9
```

### 核心要素

| 要素 | 在调试场景里的含义 |
|------|------------------|
| **稳定复现** | 必须能在本地命令行/测试框架里 100% 触发，不依赖"刚才在生产看到"这种二手信息 |
| **最小复现集** | 把无关上下文（配置、依赖、数据）逐项剥离，直到剩下能触发 bug 的最小代码 |
| **Root Cause** | 不是"哪一行抛了异常"，而是"为什么这一行会被以这个状态调用"，常用 Five Whys 追问 |
| **回归测试** | 写一个测试，在 fix 之前先让它失败（红），fix 之后变绿。没有这一步，Agent 不能宣称"修完了" |
| **二分隔离** | 在不知道 bug 是什么时候引入的，用 `git bisect` 让 Agent 自动二分到引入提交 |

### 为什么 Agent 容易跳过这五步

| 倾向 | 背后的原因 |
|------|----------|
| 看到报错立即 Edit | 训练目标是"即时回应"，越快给出 patch 越像"好助手" |
| 跳过复现直接读代码猜 | 复现要跑命令、要看输出，比只读文本贵 |
| 把 stack trace 顶端当 root cause | stack trace 顶端是症状所在的位置，不是原因发生的位置 |
| 改完不写测试 | 写测试比写 fix 多一倍 token 和工具调用 |
| 上下文长了之后开始"总结结论" | 长 context 会触发模型的"复述偏好"，开始凭印象而非实际验证 |

记住这一点对写调试 prompt 很关键：**默认 Agent 会偷懒，你得在 prompt 里把每一步都写死**。

## 基础用法

### 一个典型 bug 的完整调试对话

假设你接到一个 bug 报告："订单详情页偶尔会 500，触发条件不明"。下面是把 CLI Agent 拉进来做系统化调试的 prompt 模板，可以直接复制使用：

```text
你是我的 debug 搭子。我们要排查一个 bug，请严格按这五步走，每一步都不要跳：

【bug 描述】
- 现象：/orders/:id 详情页偶尔返回 500
- 频率：约 5% 请求
- 已知线索：日志里看到 NullPointerException

【你必须按顺序做的事】
1. **复现**：写一个 pytest 测试或 curl 命令，在本地稳定触发 500。
   - 如果 5 次跑都没触发，停下来跟我讨论可能漏了什么前置条件，不要跳到第 2 步。
   - 复现脚本要能让我直接 `python repro.py` 跑出来看到 500。

2. **隔离**：找到能触发 bug 的最小输入。
   - 砍掉所有跟 bug 无关的请求字段、数据库行、配置项。
   - 输出一个"最小复现集"：什么样的 order_id + 什么样的用户态 → 必然 500。

3. **root cause**：用 5 Whys 追问，至少问到第 3 层。
   - 不准把 stack trace 顶端那一行直接当 root cause。
   - 输出格式：
     Why 1: 为什么会 NPE？→ 因为 order.user 是 null
     Why 2: 为什么 order.user 是 null？→ 因为 join 查询少了一个外键
     Why 3: 为什么 join 少了？→ 因为上周改 ORM 时漏迁移了关联

4. **fix**：在 root cause 那一层修，不在症状层修。
   - 修之前先把"为什么这样修"写出来给我看。
   - 不允许加 try/catch 把异常吞掉当作 fix。

5. **回归**：把第 1 步那个复现测试加进项目测试套件里。
   - fix 之前这个测试必须红，fix 之后必须绿。
   - 给我看一次 `pytest path/to/test_xxx.py` 的红 + 绿两次输出。

如果你想跳步，请先停下来告诉我"我想跳第 X 步因为……"，由我决定。
```

这个模板的关键不在"问什么"，而在 **"禁止什么"**：禁止跳步、禁止把 stack trace 当根因、禁止 try/catch 当 fix、禁止不写回归测试。Agent 看到这种"必须按顺序、每一步要给我看产物"的指令，会比开放式问"帮我修这个 bug"老实很多。

### 复现测试模板

第 1 步要求 Agent 写一个能 100% 触发 bug 的脚本。下面是个 pytest 风格的最小模板，用 marker 把它标成"红色测试"，跑测试套件时不会被忽略：

```python
# tests/regression/test_order_500.py
import pytest
from app import create_app

@pytest.mark.regression
@pytest.mark.bug("ISSUE-1234")
def test_order_500_repro():
    """
    复现 ISSUE-1234：订单详情页对 user=null 的 order 返回 500。
    fix 之前：这个测试预期失败（红）
    fix 之后：必须绿
    """
    app = create_app(env="test")
    client = app.test_client()

    # 最小复现集：构造一个 user_id 已被删除但 order 还在的场景
    order_id = seed_orphan_order(user_deleted=True)

    resp = client.get(f"/orders/{order_id}")

    # 期望应该是 404（订单的归属用户不存在），而不是 500
    assert resp.status_code == 404, (
        f"应当返回 404，实际拿到 {resp.status_code}，"
        f"说明 NPE 还没修干净"
    )
```

让 Agent 写这种"先红后绿"的测试有两个好处：

1. **强制它真的复现了 bug**，而不是凭报错文本推测一通就开始改代码；
2. **fix 完成的判定标准是测试变绿，不是 Agent 自己说"修好了"**。

### `git bisect` + Agent 联动

如果你知道某个版本是好的、当前是坏的，但不知道哪一次提交引入了 bug，可以让 Agent 直接驱动 `git bisect run`：

```bash
# 1. 让 Agent 写一个最小复现脚本，约定退出码：0=好，非 0=坏
#    脚本路径：scripts/repro.sh
cat > scripts/repro.sh <<'EOF'
#!/usr/bin/env bash
set -e
pytest tests/regression/test_order_500.py -q
# pytest 失败会以非 0 退出，刚好满足 git bisect 对"坏"的定义
EOF
chmod +x scripts/repro.sh

# 2. 启动 bisect，给一个已知 good 和已知 bad
git bisect start
git bisect bad HEAD
git bisect good v1.4.0

# 3. 让 git 自己二分跑脚本，直到锁定引入 bug 的提交
git bisect run scripts/repro.sh

# 4. 拿到那个 commit hash 之后，把它和 diff 喂给 Agent：
#    "下面是引入 bug 的提交 diff，请按 5 Whys 找 root cause"
git bisect reset
```

这一招的精髓在于：**Agent 不擅长在几百个 commit 里"凭语感"找哪一次引入了 bug，但 `git bisect run` + 一个能稳定复现的测试，就把"找引入点"这件事变成了一个纯机械的二分**，速度极快、不依赖模型记忆。Linus 把 bug 报告到 fix 的时间从 142 小时压到 16 小时，主要靠的就是这个机制。

### 用 hook 强制"先写复现测试再改代码"

Claude Code 的 `PreToolUse` hook 可以拦截 `Edit` / `Write` 工具调用。下面这段 settings.json 片段做了一件事：**只要 Agent 想 Edit `src/` 下的文件，就先检查 `tests/regression/` 下有没有今天新加的测试，没有就直接拒绝**。

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c '\nfile=\"$(jq -r .tool_input.file_path)\"\n# 只对 src/ 下的源码文件生效\nif [[ \"$file\" != src/* ]]; then exit 0; fi\n# 检查今天有没有新增/修改的回归测试\nif ! git status --porcelain tests/regression/ | grep -q .; then\n  echo \"REJECTED: 编辑 src/ 之前请先在 tests/regression/ 下写一个能复现 bug 的测试\" >&2\n  exit 2\nfi\nexit 0\n'"
          }
        ]
      }
    ]
  }
}
```

`exit 2` 在 PreToolUse hook 里是"硬拒绝"——Edit 调用会被取消，stderr 里的提示会反馈给模型。Agent 收到这个反馈后，会自己回到"先写复现测试"那一步。这就是"用确定性的 hook 替代不确定的 prompt 提醒"的思路：与其在系统提示里写"请记得先写测试"（Agent 经常忘），不如让 hook 强制它做。

## 同类工具对比

| 维度 | CLI Agent 调试 | IDE 断点调试 | printf / log 调试 | 测试驱动调试（TDD） |
|------|---------------|------------|------------------|------------------|
| 入门门槛 | 低（自然语言提问） | 中（需要熟悉 IDE 调试器配置） | 极低（写两行 print） | 中（要会写测试框架） |
| 复现成本 | Agent 可写复现脚本 | 需要手工触发后断点 | 需要手工触发 | 复现即测试，零额外成本 |
| 适合的 bug 类型 | 业务逻辑、配置、跨文件追因 | 单进程、可断点的同步逻辑 | 异步、生产环境、性能问题 | 任何能写测试的 bug |
| 状态保留 | 上下文随会话保留 | 调试会话结束即丢失 | 日志文件可归档 | 测试是永久回归资产 |
| 多语言/多栈 | 跨语言天然支持 | 不同语言要换 IDE 配置 | 任意语言通吃 | 看测试框架 |
| 回归保护 | **要主动让 Agent 补测试** | 默认无 | 默认无 | **天然就是回归测试** |
| 团队协作 | 调试结论可粘贴成 PR 描述 | 调试过程难复现给同事 | 日志可共享 | 测试入库即共享 |
| 致命短板 | Agent 容易"凑 fix"跳过 root cause | 不能调远程/生产 | 上线前要清理 | 写测试本身需要时间 |

**核心区别**：CLI Agent 调试的真正价值不是"替代以上三种"，而是**把它们编排成一条流水线**——让 Agent 同时驱动写复现测试（TDD 那块）、调用 `git bisect run`（自动化二分）、读 log 输出（printf 调试），并把每一步的产物沉淀回工程。它的天花板取决于你给它套了多严的"必须按步骤做"的约束；约束越松，它越像第三种"凑一个 fix"。

## 常见误区

| 误区 | 准确理解 |
|------|---------|
| 看到 bug 立刻让 Agent "修一下" | 一定要让它先稳定复现。没有可重跑的复现脚本，所谓的"修复"只是改了一段看起来相关的代码，无从验证 |
| 把 stack trace 顶端那一行当 root cause | stack trace 顶端是**症状抛出的位置**，root cause 通常在**调用方/上游配置/初始化顺序**里。要逼 Agent 用 5 Whys 至少追问 3 层 |
| 用 try/catch 静默吞掉异常当作 fix | 这是 Agent 最爱的"快修"，因为它确实能让报错消失。规则上要禁止：异常要么向上抛、要么显式处理并返回有意义的错误码，绝不静默吞 |
| 改完不写回归测试就 commit | "fix 完成"的判定标准必须是"复现测试从红变绿"，不是 Agent 自己宣称修好。没有回归测试的 fix，下次会以另一种症状复发 |
| 让 Agent 在长 context 里凭记忆做调试结论 | context 越长，模型越倾向于"总结回复"而非"实际验证"。跑过 30 轮以上的调试会话要主动 `/clear` 或开新会话，把当前进度浓缩成"已知事实 + 待验证假设"再继续 |
| 让 Agent 同时怀疑 5 个地方并发改 | 调试是**一次只验证一个假设**的活儿。同时改多个地方就算修好了，你也不知道是哪一改起作用，root cause 反而更模糊 |
| 跳过 `git bisect`，让 Agent 凭直觉读 commit log 找引入点 | Agent 不擅长几百个 commit 里凭语感找。能复现的 bug 一定走 `git bisect run`，把找引入点变成纯机械二分，比让模型猜快一个数量级 |

## 优劣势分析

| 优势 | 劣势 |
|------|------|
| **跨文件、跨语言追因极快**：Agent 可以同时读 backend/frontend/配置文件，人脑很难同时持有这么多上下文 | **天然偏向"凑 fix"**：必须用 prompt + hook 强制约束做事顺序，否则会跳过复现和回归测试 |
| **复现脚本和回归测试可由 Agent 一起产出**：每修一个 bug 顺手沉淀一份测试资产，长期收益巨大 | **长 context 后期判断力下降**：超过 30 轮的调试会话需要主动收敛、清空上下文，不然模型会开始"凭印象总结" |
| **可以编排 git bisect、log 抓取、grep、测试运行等机械动作**：把人不愿意做的重复工序自动化掉 | **对生产环境调试支持弱**：拿不到生产数据库/链路追踪时，Agent 只能读日志猜，不如人 + APM 工具组合好用 |
| **调试过程天然留痕**：每一步的 prompt 和产物可以直接粘进 PR 描述或 incident 文档，省了"事后写复盘"的时间 | **5 Whys 追问会被 Agent 偷工**：默认它只追到第 1 层就开始改代码，需要在 prompt 里硬性要求"至少 3 层" |
| **降低了"系统化调试"的执行门槛**：以前要老程序员才坚持得了的复现-隔离-根因-回归流程，现在新人有 Agent 配合也能做到 | **依赖测试基础设施**：项目没有像样的测试框架时，"先写复现测试"这一步会卡很久，Agent 要先帮你搭测试脚手架 |

## 思考题

<details>
<summary>初级：为什么必须让 Agent 在 fix 之前先写复现测试？只在 fix 之后补一个测试不行吗？</summary>

**参考答案：**

技术上当然可以，但工程上很危险，原因有三点。

第一，**只有先红后绿才能证明你真的修对了**。如果你先 fix 再写测试，那个测试很可能是"已经绿的状态下补的"——它跑过 1 次，但你不知道它在 bug 还活着的那一刻能不能真的失败。这是工程上典型的"幸存者偏差"：测试存在 ≠ 测试有效。

第二，**先写复现测试逼你先理解 bug**。写一个能稳定触发 500 的测试，意味着你必须搞清楚"什么样的输入 + 什么样的状态"会触发，这个过程本身就是在做"最小复现集"那一步。跳过这一步，Agent 的 fix 通常是"看起来相关的改动"，而不是"针对最小复现集的精准修补"。

第三，**先红后绿提供了一个无歧义的完成判据**。Agent 如果可以自己宣称"修好了"，它就会在 stack trace 不再出现时停手。但 stack trace 不出现可能只是因为代码路径变了，bug 仍然在；只有"这个特定的复现测试在 fix 之前红、之后绿"这个事实，才能让"修完了"这件事不掺水分。

</details>

<details>
<summary>中级：Five Whys 追问到第几层就该停？追太深会不会变成过度设计？</summary>

**参考答案：**

经验上的判据有三条，满足任一就可以停：

1. **追到一个"不该被这样调用"的契约违反**。比如追到"为什么 order.user 会是 null？因为 ORM 关联迁移漏了"——这一层已经触达了"代码契约本来约定 user 必须存在"的事实，再往下追就到组织流程层（"为什么 PR review 没拦下"）了，那是工程治理问题，不是这次 bug 的修复范围。

2. **追到一个"修了它就不会再以任何形态复发"的层次**。如果在第 2 层修能让所有变体复发都消失，第 3 层才修就是过度。判断方法：在拟定 fix 之后问自己"还有什么场景能触发同一个 root cause"，如果想不出，那就是修对了层次。

3. **追到一个不属于本次代码改动责任范围的边界**。比如追到"为什么 Postgres 在网络抖动时返回 stale 数据"——再往下就是数据库内核问题了，这次 bug 应该在应用层做防御性处理（比如重试或一致性读），而不是去改 Postgres。

**过度设计的信号**：Why 链超过 5 层、每一层的回答都需要新假设、修复方案开始动到完全无关的模块。出现这些就是追深了，应该退回去看上一层那个"修了能止血"的位置。

实践里 3 层是个不错的默认值：第 1 层告诉你 bug 长什么样，第 2 层告诉你为什么这样发生，第 3 层告诉你为什么没被防住。修第 3 层通常是性价比最高的位置。

</details>

<details>
<summary>进阶：你的项目代码量超过 50 万行，bug 分布在很多个微服务里。如何设计一套 hook + 提示词组合，让团队所有 CLI Agent 都强制走系统化调试流程？</summary>

**参考答案：**

这种规模下不能靠每个开发者写 prompt 时记着五步走，必须把规则机械化。设计思路分三层。

**第一层：项目级 settings.json + hooks（机器强制）**

- `PreToolUse` 拦截 `Edit|Write`：检测被改文件路径在 `src/` 下时，要求 `tests/regression/` 下必须有当天新增或修改的测试文件，否则 `exit 2` 拒绝。
- `PreToolUse` 拦截 `Bash`：禁止 `git commit` 包含 `src/` 改动但不包含 `tests/` 改动的提交，强制配套测试。
- `PostToolUse` 监听 `Edit`：每次 src/ 下文件被改后，自动跑该模块的测试套件，把红的测试名注入回 Agent 上下文，让它必须解释每一处变化。
- `Stop` hook：会话结束时，如果检测到 `git diff` 里改了 src/ 但没有对应 `tests/regression/` 新增，发送告警到团队群。

**第二层：标准化 prompt 模板入仓库（共享约束）**

在仓库根目录维护 `.claude/prompts/debug.md`，把"五步走 + 禁止项 + 输出格式"写死。开发者起调试会话只需 `/debug ISSUE-1234`，Agent 自动加载这个模板。模板里明确要求：

- 复现脚本必须放在 `scripts/repro/<ISSUE-ID>.sh`
- 5 Whys 链必须以注释形式写在回归测试文件头
- root cause 必须填到 PR 模板的指定字段，否则 PR 模板检查脚本拒绝合并

**第三层：CI 校验（最后一道闸）**

在 CI 上加一个 `regression-guard` job：每次 PR 提交时，检查 diff 里是否包含至少一个 `tests/regression/` 下的新增/修改测试，且该测试在 base commit 上能跑红、在 head commit 上能跑绿。这一步是"先红后绿"原则的机械化校验，绕不过去。

**关键点**：不要相信"开发者会按规范做"，也不要相信"Agent 会按 prompt 做"。把规则写进 hook 和 CI 才能在 50 万行规模下稳住。Prompt 模板提供共识，hook 提供拦截，CI 提供兜底——三层叠起来才能让"系统化调试"在团队里成为默认行为而不是个人习惯。

互链阅读：把 hooks 拦截规则写细可以参考 [/cli/cards/lifecycle-hooks](/cli/cards/lifecycle-hooks)，权限边界看 [/cli/cards/permission-model](/cli/cards/permission-model)。

</details>

## 参考资料

1. Claude Code 官方最佳实践（含 debug 与 hooks 章节）：<https://code.claude.com/docs/en/best-practices>（查询日期 2026-04-22）
2. Claude Code Hooks 参考文档（PreToolUse / PostToolUse 等）：<https://code.claude.com/docs/en/hooks>（查询日期 2026-04-22）
3. 2026 年 Claude Code 工作流综述（Plan Mode + Hooks + 多 Agent debug）：<https://medium.com/@sean.j.moran/effective-claude-code-workflows-in-2026-what-changed-and-what-works-now-c93ebc6f8f50>
4. MIT 6.031 systematic debugging 教学讲义：<https://web.mit.edu/6.031/www/sp17/classes/11-debugging/>
5. 5 Whys 框架介绍（Miro）：<https://miro.com/root-cause-analysis/what-is-5-whys-framework/>
6. Binary search debugging（Code with Jason）：<https://www.codewithjason.com/binary-search-debugging/>
7. `git bisect` 官方文档：<https://git-scm.com/docs/git-bisect>
8. Fully automated bisecting with `git bisect run`（LWN.net 经典文）：<https://lwn.net/Articles/317154/>
9. AI Root Cause Analysis 行业综述（Algomox）：<https://www.algomox.com/resources/blog/agentic_ai_rca_root_cause/>
10. Nicole Tietz 关于系统化调试的实战笔记：<https://ntietz.com/blog/how-i-debug-2023/>

---
*Source: https://learnagent.wiki/cli/cards/systematic-debugging*
*Markdown mirror of https://learnagent.wiki, served as text/markdown for LLM ingestion.*