学习目标:掌握用 Claude Code 作为调试伙伴的系统化工作流——从错误观察、假设生成、证据收集、根因确认到回归测试,建立可复现、可审计的调试闭环。适合已具备 Claude Code 基础操作能力、希望将调试效率提升 3 倍以上的开发者。
适用人群:有一定 Claude Code 使用经验的后端/全栈开发者,遇到过程序员日常最痛苦的场景——"线上 bug 排查两小时,修复三行代码"。
前置条件:Claude Code 2.0+ 已安装并登录、Node.js 18+ / Python 3.10+ 环境、Git 版本控制、Vitest 或 Pytest 测试框架。
绝大多数开发者第一次尝试用 Claude Code 调试时,会这样做:
❌ 直接粘贴错误信息,说"帮我修一下"
这种方式会得到一个 看起来合理的补丁,但很可能: - 修复了表面症状而非根因 - 引入新的边界条件漏洞 - 无法通过不同输入路径复现
来自 dev.to 社区的生产环境调试统计表明:直接要求修复的平均耗时反而比"先诊断后修复"长 40%,因为补丁经常需要返工。
核心思维转换:
旧模式:错误 → 补丁 → 测试 → 返工 → 再次补丁 → ...
新模式:观察 → 假设 → 证据 → 确认 → 最小修复 → 回归测试
这不是纯粹的理论——每个阶段都有对应的 Claude Code 技巧和命令,下面逐一展开。
flowchart TD
A["阶段1: 观察<br/>收集错误上下文"] --> B["阶段2: 假设生成<br/>列出3个可能根因"]
B --> C["阶段3: 证据收集<br/>逐个验证/排除假设"]
C --> D{"根因已确认?"}
D -- 否 --> B
D -- 是 --> E["阶段4: 最小修复<br/>精确改动确认的根因"]
E --> F["阶段5: 回归测试<br/>确保bug不会重现"]
F --> G{"测试通过?"}
G -- 否 --> E
G -- 是 --> H["调试完成 ✅"]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#f3e5f5
style E fill:#e8f5e9
style F fill:#fce4ec
style H fill:#c8e6c9
不要一股脑粘贴整个项目代码。给 Claude Code 精确的三个输入:
输入1:错误信息 + 调用栈
# 先复现错误,捕获完整输出
npm run test 2>&1 | tee /tmp/error-output.txt
# 或 Python 项目
pytest tests/test_checkout.py -v --tb=long 2>&1 | tee /tmp/error-output.txt
然后将结果提供给 Claude Code:
我在运行 checkout 流程测试时遇到了以下错误:
TypeError: Cannot read properties of undefined (reading 'map')
at normalizeUsers (src/checkout/user-handler.js:15:32)
at processOrder (src/checkout/order-processor.js:42:18)
...
这个错误出现在用户有店铺积分且同时使用促销折扣时。
输入2:相关代码路径
# 让 Claude Code 只读取相关的执行路径文件
# 而不是整个代码库
在 Claude Code 交互中:
请阅读以下文件,它们构成了出错的执行路径:
/read src/checkout/discount-calculator.js
/read src/checkout/credit-balance.js
/read src/models/order.js
输入3:最近的变更历史
# 查看出错模块最近的提交——这是最容易被遗忘的输入
git log --oneline -20 src/checkout/
将输出粘贴到对话中。很多生产 bug 的根因就藏在最近两周的提交里。
| 遗漏项 | 后果 | 正确做法 |
|---|---|---|
| 只给错误码不给调用栈 | Claude 猜测出错位置 | 提供完整 stack trace |
| 没说触发条件 | 无法复现 | 描述"什么操作在什么情况下出错" |
| 忘记 git log | 忽略最近的引入变更 | 始终提供最近 20 条相关提交 |
| 给了整个代码库 | 注意力被稀释 | 只给执行路径上的文件 |
| 没说期望行为 | 只修复崩溃不修复逻辑 | 明确"应该怎样"vs"实际怎样" |
有时候 bug 没有清晰的错误栈——比如"周二下午下的订单有时显示错误的运费估算"。这时用 Claude Code 的搜索工具做依赖图谱:
我需要找出所有可能影响运费计算的代码路径。
请用 grep 搜索 "shipping_estimate"、"shippingEstimate"、"calculateShipping",
帮我构建一个调用关系图:什么函数调用了什么函数。
不要问"哪里出错了",而要问"可能是哪些原因":
基于以上错误和代码,请列出三个最可能的根因,按概率排序。
暂时不要修改任何代码——我想先理解所有可能的失败模式,再开始收集证据。
对每个假设,说明:
1. 为什么认为这个原因概率高
2. 需要什么证据来确认/排除
3. 如果是这个原因,预期的修复方向是什么
假设1(概率:高)— 积分余额检查的竞态条件
原因:余额在折扣计算之前读取,导致看到的是过时状态
确认证据:在 creditBalanceCheck 和 applyDiscounts 之间加日志
假设2(概率:中)— 整数/浮点数精度问题
原因:积分以分为单位存储,折扣以美元计算
确认证据:检查 currency-helpers.js 中的类型转换
假设3(概率:低)— 空积分余额缺少 null 检查
原因:stack trace 显示错误发生在 null 检查之前
确认证据:测试无活跃余额用户下单场景
一旦有了假设列表,你就有了 调试计划——每个假设告诉你该收集什么证据。
不要在第一个看起来合理的假设上就停下来。Claude 会倾向于给出"最可能"的答案,但"最可能"不等于"正确"。强迫自己走完三个假设的评估流程。
这是 Claude Code 真正发挥威力的阶段。你不需要在终端和编辑器之间切换——所有诊断工具都在 Claude Code 内完成。
请帮我写一个诊断脚本:
1. 模拟特定条件(店铺积分 + 促销折扣同时应用)
2. 在每个步骤添加日志,显示 balance 对象的精确状态
3. 运行 50 次场景,看失败是否间歇性的
4. 输出成功次数、失败次数和失败时的状态快照
Claude Code 会生成类似这样的脚本:
// diagnosis/race-condition-test.js
import { checkout } from '../src/checkout/index.js';
const results = { success: 0, failure: 0, snapshots: [] };
for (let i = 0; i < 50; i++) {
const order = {
userId: 'test-user',
storeCredit: 5000, // 50 美元积分(以分为单位)
promoDiscount: 0.2, // 20% 折扣
items: [{ price: 10000, qty: 1 }] // 100 美元商品
};
try {
const result = await checkout(order);
console.log(`Run ${i+1}: SUCCESS - balance after = ${result.remainingCredit}`);
results.success++;
} catch (err) {
console.log(`Run ${i+1}: FAILURE - ${err.message}`);
results.failure++;
results.snapshots.push({ run: i+1, error: err.message });
}
}
console.log('\n=== 诊断结果 ===');
console.log(`成功: ${results.success}/50`);
console.log(`失败: ${results.failure}/50`);
if (results.failure > 0) {
console.log('失败快照:', JSON.stringify(results.snapshots, null, 2));
}
请读取 src/utils/currency-helpers.js,
检查所有的除法和乘法操作——整个流程中是否一致地使用分为单位?
找出每一个类型转换点。
Claude Code 会用 Read 和 Grep 工具定位所有相关代码:
# Claude Code 内部执行的搜索
grep -rn "parseFloat\|parseInt\|Number\|Math.round\|Math.floor\|Math.ceil" src/utils/
grep -rn "\* 100\|/ 100\|\* 0.01\|/ 100" src/checkout/
/run 保持上下文连贯不要退出 Claude Code 去终端运行命令——用内置能力:
/run node diagnosis/race-condition-test.js
Claude Code 执行后自动将结果纳入对话上下文,无需手动复制粘贴。这种 迭代式反馈循环 是高效调试的核心:
flowchart LR
A["假设"] --> B["生成诊断脚本"]
B --> C["/run 执行"]
C --> D["结果自动回传"]
D --> E{"假设被确认?"}
E -- 否 --> F["下一个假设"]
F --> A
E -- 是 --> G["进入阶段4"]
style D fill:#e1f5fe
style G fill:#c8e6c9
在写任何修复代码之前,要求 Claude Code 用一句话陈述根因:
基于我们收集的证据,请:
1. 用一句清晰的中文陈述根因
2. 陈述正确的行为应该是什么
3. 陈述产生正确行为的最小改动是什么
不要开始写代码——等我确认后再动手。
| ❌ 差的陈述 | ✅ 好的陈述 |
|---|---|
| "代码里有个 bug" | "积分余额在促销折扣计算之前从数据库读取,导致积分检查看到的是折扣前的总额" |
| "类型不匹配" | "积分以分为单位存储(整数),但折扣计算在美元空间进行(浮点数),两者之间缺少显式转换" |
| "加个 try-catch 就行了" | "当用户没有活跃余额时,creditBalance 字段为 null,但下游代码假设它至少是空数组" |
如果 Claude Code 无法清晰地陈述根因,说明你 还没理解到足够深入的层次,不应该开始写修复代码。
来自社区实战的 最佳实践:先写捕获 bug 的测试,再写修复代码。
基于确认的根因,请按以下顺序操作:
1. 先写一个回归测试,它在当前代码下应该失败(因为 bug 还在)
2. 运行测试,确认它确实失败
3. 然后写最小修复
4. 再次运行测试,确认它通过
5. 运行完整测试套件,确保没有引入新问题
运行命令:npm test && npm run typecheck
假设根因是 normalizeUsers 函数没有处理 undefined 输入:
// tests/normalize-users.test.ts
import { describe, expect, it } from "vitest";
import { normalizeUsers } from "../src/normalize-users.js";
describe("normalizeUsers", () => {
// 回归测试:确保 undefined/null 输入不会导致崩溃
it("当 API 返回空数据时,应返回空数组", () => {
expect(normalizeUsers(undefined)).toEqual([]);
expect(normalizeUsers(null)).toEqual([]);
});
// 回归测试:确保空名字被过滤
it("应只保留有效的显示名", () => {
expect(
normalizeUsers([
{ id: "u1", name: " 张三 " },
{ id: "u2", name: "" },
{ id: "u3", name: null },
])
).toEqual(["张三"]);
});
// 正向测试:正常情况
it("应正确 trim 和过滤名字", () => {
expect(
normalizeUsers([
{ id: "u1", name: "Alice" },
{ id: "u2", name: " Bob " },
])
).toEqual(["Alice", "Bob"]);
});
});
修复代码(最小改动):
// src/normalize-users.ts
export type User = { id: string; name?: string | null };
export function normalizeUsers(users: User[] | null | undefined): string[] {
// 核心修复:容忍缺失的 API 数据
if (!Array.isArray(users)) return [];
return users
.filter((user): user is User & { name: string } =>
typeof user.name === "string"
)
.map((user) => user.name.trim())
.filter((name) => name.length > 0);
}
users ?? []最常见的"浅层修复"是给一个默认值然后收工:
// ❌ 这只修复了崩溃,但保留了 null 名字、空名字等问题
const list = users ?? [];
return list.map(u => u.name); // 如果 name 是 null,这里会出问题
关键原则:给 Claude Code 你想要的业务规则,而不只是你看到的异常。
normalizeUsers 必须容忍缺失的 API 数据,并移除空白显示名。
先添加一个失败的 Vitest 测试用例,然后做最小修复。
最后运行 npm test 和 npm run typecheck。
这是一个真实场景——某服务偶尔失败,重试机制应该兜底但似乎没有正确工作。
我们的支付服务偶尔超时,重试逻辑应该最多重试3次。
但日志显示:有时重试后成功,有时静默失败没有报错。
请先阅读 src/utils/retry.ts,了解当前的重试实现。
基于代码,列出三个可能导致"静默失败"的根因:
1. 最终错误被吞掉了(catch 块没有重新抛出)
2. 重试次数计数有 off-by-one 错误
3. delay 逻辑导致超时累积,触发外层的全局超时
不要修改代码,只分析。
请帮我写一个测试:
- 使用 mock 函数模拟"前2次失败、第3次成功"的场景
- 验证重试次数、等待间隔、最终成功
- 同时测试"3次都失败"的场景,验证最终错误是否被正确抛出
- 使用 vi.useFakeTimers() 控制时间
// tests/retry.test.ts
import { describe, expect, it, vi, beforeEach } from "vitest";
import { retry } from "../src/retry.js";
describe("retry", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("前2次失败后第3次成功,应返回结果", async () => {
const task = vi.fn()
.mockRejectedValueOnce(new Error("timeout"))
.mockRejectedValueOnce(new Error("timeout"))
.mockResolvedValueOnce("success");
const resultPromise = retry(task, { times: 3, delayMs: 1000 });
// 推进时间让 delay 完成
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
expect(result).toBe("success");
expect(task).toHaveBeenCalledTimes(3);
});
it("全部失败时,应抛出最后的错误", async () => {
const task = vi.fn()
.mockRejectedValue(new Error("service unavailable"));
const resultPromise = retry(task, { times: 3, delayMs: 1000 });
await vi.advanceTimersByTimeAsync(5000);
await expect(resultPromise).rejects.toThrow("service unavailable");
expect(task).toHaveBeenCalledTimes(3);
});
});
基于测试结果,用一句话陈述根因:
期望输出:
"retry 函数在最后一次尝试失败后,catch 块没有重新抛出错误,导致错误被静默吞掉。正确行为是:最后一次失败后应 throw lastError 而不是隐式返回 undefined。"
// src/retry.ts — 修复后的版本
export async function retry<T>(
task: () => Promise<T>,
{ times, delayMs, sleep = defaultSleep }: RetryOptions
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= times; attempt += 1) {
try {
return await task();
} catch (error) {
lastError = error;
if (attempt < times) await sleep(delayMs);
}
}
// 关键修复:确保总是抛出最后的错误
throw lastError instanceof Error
? lastError
: new Error("Retry failed");
}
将以下模板保存到项目的 CLAUDE.md 或 .claude/prompts/debug.md 中:
## 调试提示词模板
### 通用调试模板
目标:找到根因,添加回归测试,用最小的有效 diff 修复 bug。
**观察:**
- 完整错误信息:
- 复现步骤:
- 期望行为:
- 实际行为:
- 最近变更的文件:
**约束:**
- 列出三个假设后再编辑代码
- 避免大范围重构
- 不要用 any 隐藏类型错误
- 完成前移除临时日志
- 最后运行 npm test 和 npm run typecheck
**报告:**
- 根因
- 变更的文件
- 添加的回归测试
- 残余风险
### 间歇性 Bug 模板
这个 bug 不是每次都出现。请帮我:
1. 写一个诊断脚本,运行 50 次来确认失败率
2. 分析并发/时序相关的代码路径
3. 使用 mock 隔离外部依赖
4. 不要做全局重构
### 行为异常模板(无错误信息)
现象:[描述不正确的行为]
没有报错信息。请帮我:
1. 搜索所有相关的代码路径
2. 构建调用关系图
3. 找出可能影响该行为的所有条件分支
4. 添加日志来追踪执行路径
来自 Ralphable 博客的实战总结——将复杂调试拆分为 带通过/失败标准的原子任务,让 Claude Code 可以自主运行而不需要反复确认:
flowchart TD
subgraph 原子任务模式
T1["模式1: 日志分析<br/>输入: 日志文件<br/>输出: 异常行 + 时间线"]
T2["模式2: 最小复现<br/>输入: 错误描述<br/>输出: 复现脚本"]
T3["模式3: 二分定位<br/>输入: 首次出现的版本范围<br/>输出: 引入bug的具体commit"]
T4["模式4: 类型审计<br/>输入: 出错的类型链<br/>输出: 类型不匹配的位置"]
T5["模式5: 性能剖析<br/>输入: 慢查询/慢函数<br/>输出: 瓶颈函数 + 耗时"]
T6["模式6: 依赖检查<br/>输入: 报错的依赖<br/>输出: 版本冲突/缺失声明"]
T7["模式7: 回归验证<br/>输入: 修复diff<br/>输出: 测试通过 + 无新失败"]
end
T1 --> T2
T2 --> T3
T3 --> T4
T4 --> T5
T5 --> T6
T6 --> T7
每个模式的关键配置:
# 模式1:日志分析
name: "analyze-logs"
prompt: |
分析以下日志文件,找出所有异常和错误。
输出格式:时间戳 | 级别 | 消息 | 可能的关联事件
pass_criteria: "输出包含至少3条异常记录和关联分析"
fail_criteria: "输出为空或只有INFO级别日志"
# 模式2:最小复现
name: "create-reproduction"
prompt: |
基于以下错误描述,创建最小复现脚本。
脚本应该是自包含的,不依赖外部服务。
pass_criteria: "脚本运行后能复现原始错误"
fail_criteria: "脚本运行成功或超时"
# 模式3:二分定位
name: "git-bisect-bug"
prompt: |
使用 git bisect 在 v2.3.0(已知无bug)和 v2.5.0(已知有bug)之间
找出引入 bug 的具体 commit。
运行测试套件来判断每个 commit 是否有 bug。
pass_criteria: "找到单个引入 bug 的 commit"
fail_criteria: "无法确定或范围超过 5 个 commit"
flowchart LR
subgraph 反模式
A1["🚫 粘贴海量上下文<br/>希望Claude自己找"]
A2["🚫 不生成假设<br/>直接要修复"]
A3["🚫 接受第一个建议<br/>不做验证"]
A4["🚫 不反馈结果<br/>一次性对话"]
end
subgraph 正确做法
B1["✅ 只给执行路径上的代码"]
B2["✅ 先列3个假设再动手"]
B3["✅ 用证据验证每个假设"]
B4["✅ 迭代反馈,每轮缩小范围"]
end
A1 -.->|纠正| B1
A2 -.->|纠正| B2
A3 -.->|纠正| B3
A4 -.->|纠正| B4
调试时的上下文量存在最佳区间:
| 上下文量 | 问题 | 解决方案 |
|---|---|---|
| 太少(只给错误码) | Claude 猜测,命中率低 | 补充调用栈 + 触发条件 |
| 适中(执行路径 + 最近变更) | 最佳区间 ✅ | 维持 |
| 太多(整个代码库) | 注意力被稀释 | 只提供相关文件 |
/compact 保持上下文新鲜在长时间调试会话中,对话历史会膨胀。适时压缩:
/compact
这会将之前的对话压缩为摘要,释放上下文窗口空间。建议在每完成一个假设的验证后执行一次 /compact,然后简要补充"已确认/已排除假设X"的结论。
将以下清单保存为 Claude Code 可读取的上下文:
## 调试检查清单
### 启动调试前
- [ ] 错误信息 + 完整调用栈已收集
- [ ] 复现步骤已确认
- [ ] 期望行为 vs 实际行为已明确
- [ ] 受影响文件的最近 20 条 git log 已获取
- [ ] 执行路径上的相关文件已用 /read 加载
### 假设阶段
- [ ] 至少列出了 3 个假设
- [ ] 每个假设有概率评估和验证方法
- [ ] 没有在第一个看起来合理的假设上就停下
### 证据阶段
- [ ] 对每个假设生成了诊断脚本或搜索命令
- [ ] 使用 /run 在 Claude Code 内执行诊断
- [ ] 结果已反馈到对话中
### 确认阶段
- [ ] 根因已用一句话清晰陈述
- [ ] 正确行为已定义
- [ ] 最小改动已明确
### 修复阶段
- [ ] 先写了回归测试,确认当前代码下测试失败
- [ ] 然后写了最小修复
- [ ] 修复后回归测试通过
- [ ] 完整测试套件通过
- [ ] 临时日志已移除
来自社区的最佳实践:
如果一个 bug 已经排查了 30 分钟,但你还没有确认的根因——从头开始。
这不是开玩笑。30 分钟没有根因意味着你的假设方向错了。重启阶段1,重新收集上下文,重新生成假设。重新开始感觉慢,但比在错误方向上继续要快得多。
对于可重复的调试模式,可以创建 Claude Code 子代理配置:
// .claude/subagents/bug-analyzer.json
{
"name": "bug-analyzer",
"description": "分析错误日志并生成假设",
"tools": ["Bash", "Read", "Grep", "Glob"],
"prompt": "你是一个专业的 bug 分析师。分析给定的错误信息和代码上下文,列出三个最可能的根因,按概率排序。不要修改任何代码。对每个假设说明需要什么证据来确认或排除。"
}
// .claude/subagents/test-first-fixer.json
{
"name": "test-first-fixer",
"description": "先写回归测试再修复 bug",
"tools": ["Bash", "Read", "Edit", "Write", "Grep"],
"prompt": "你是一个严格的 TDD 修复工程师。给定确认的根因,你将:1) 先写一个在当前代码下会失败的回归测试;2) 运行测试确认失败;3) 写最小修复;4) 运行测试确认通过;5) 运行完整测试套件确保没有回归。不要跳过任何步骤。"
}
在调试会话中使用:
请调用 bug-analyzer 子代理分析以下错误:
[paste error]
然后根据分析结果,调用 test-first-fixer 子代理进行修复。
| 框架 | 测试命令 | 类型检查 | 特殊考虑 |
|---|---|---|---|
| TypeScript + Vitest | npm test |
npx tsc --noEmit |
注意 any 类型隐藏的错误 |
| Python + Pytest | pytest -v |
mypy src/ |
注意动态类型的隐式转换 |
| Go | go test ./... |
编译器内置 | 注意 goroutine 竞态条件 |
| Rust | cargo test |
编译器内置 | 注意生命周期和所有权问题 |
| Java + JUnit | mvn test |
编译器内置 | 注意空指针和并发问题 |
对于每种框架,在提示词中明确告诉 Claude Code 使用对应的测试命令和类型检查命令。
graph TB
subgraph 旧方式["旧方式:直觉驱动"]
O1["看到错误"] --> O2["猜测原因"]
O2 --> O3["尝试修复"]
O3 --> O4["测试失败"]
O4 --> O2
end
subgraph 新方式["新方式:证据驱动"]
N1["收集上下文"] --> N2["生成3个假设"]
N2 --> N3["收集证据逐个验证"]
N3 --> N4["一句话确认根因"]
N4 --> N5["先写测试再修复"]
N5 --> N6["回归测试通过 ✅"]
end
style 旧方式 fill:#ffebee
style 新方式 fill:#e8f5e9
核心要点回顾:
这套工作流经过 6 周以上的生产环境验证,平均诊断时间从 2 小时降至 40 分钟。关键不是 Claude Code 有多聪明,而是 你如何引导它思考。
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
