调试与修 bug
用四步法把调试拆成可执行动作。
修 bug 是最能体现 Claude Code 价值的场景之一,也是最容易翻车的场景之一。翻车的原因几乎都一样:上来就让它“修一下”,它凭直觉改几行,看起来跑通了,其实根因还在。
这一节讲一套四步法,能把调试变成可稳定输出的流程,Claude Code 走起来不会跑偏。
四步法:复现 → 定位 → 最小改动 → 回归测试
好的调试永远是这四步。跳过任何一步都是给自己埋雷。
第一步:复现
没能稳定复现的 bug 不能修。 这句话贴在墙上。改完了怎么知道修好了?只能靠稳定复现的用例。
让 Claude Code 帮你复现,通常有几种做法:
- 已经有报错栈的:粘给它,让它写一个能触发同样栈的最小脚本或测试。
- 只有现象描述的:让它先读相关代码,猜测触发路径,写脚本验证。
- 只在生产上出现的:让它整理线上日志、请求参数,尽量还原本地能跑的用例。
你: 生产上有用户反馈登录接口偶尔返回 500,日志里看到
"TypeError: Cannot read properties of undefined (reading 'toLowerCase')",
在 src/services/auth.ts 附近。帮我先稳定复现。Claude Code 会读 auth.ts,找到 toLowerCase 的调用点,反推什么样的输入会让上游变成 undefined,然后写一个能触发同样报错的测试用例。
第二步:定位
复现之后再定位。不要跳过复现直接定位,因为你不知道自己有没有找对地方。
定位阶段最好用 /plan 模式。它会先分析、给出根因假设,暂时不动代码。
你: /plan
稳定复现了,现在帮我定位根因。别改代码,先分析给我看。
Claude Code (plan 模式):
[Grep] [Read src/services/auth.ts:120-160]
根因假设:
1. login 函数假设 req.body.email 一定存在(第 132 行 email.toLowerCase())
2. 前端在某些浏览器上(比如清空表单后按 Enter)不发 email 字段
3. Express 的 body-parser 在这种情况下把 body 解析成 {},
访问 req.body.email 得到 undefined
4. undefined.toLowerCase() 抛错,被 Express 500
修复方案有两条路:
A. auth service 内部校验 email 存在,缺失时返回 400
B. 前端保证一定发 email 字段
推荐 A,因为 auth service 不能信任任何调用方。
需要我进入 accept-edits 开始改吗?/plan 模式让你有机会在动手前打断。这一步能救回很多本来会跑偏的会话。
第三步:最小改动
定位之后,要求 Claude Code 只做最小改动。这里的最小有明确定义:
- 只改和根因直接相关的代码
- 不顺手重构、不顺手改风格、不顺手升级依赖
- 不改和 bug 无关的测试
你: 方案 A 走起,最小改动。不要顺手重构别的。保持这个纪律的好处是:改动小,review 快,出问题也容易 revert。
别让 Claude Code 顺手做别的
Claude Code 常常会热情地建议“我顺便把这几个函数也整理了吧”。修 bug 阶段拒绝所有这类顺便。想清理是好事,但另开一个 refactor PR,不要污染 bugfix PR。
第四步:加回归测试
修完之后必须补一条能防止这个 bug 再回来的测试。这一步很多人偷懒,也是同一个 bug 反复出现的根本原因。
你: 修完了。补一条能覆盖这个 case 的测试,用例名带上 bug 编号。
Claude Code:
[写入 src/services/auth.test.ts]
it("login 拒绝缺失 email 的请求 (#BUG-1042)", async () => {
const res = await request(app).post("/login").send({ password: "x" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("email is required");
});
[npm test] ✓ 43 passed这条测试的作用不只是防回归,也是给未来的自己一个说明:这里为什么要这么写。测试用例名带上 bug 编号,日后 blame 追溯更快。
用 log 定位复杂问题
有些 bug 光看代码看不出来,得跑起来打 log。让 Claude Code 加 log 也要有节奏。
你: 我不确定这个死循环发生在哪一层。帮我在 processQueue 和它调的
三个下游函数里各加一条带前缀 [DEBUG-Q] 的 log,跑复现脚本,
定位到之后把 log 全删掉。关键在于最后一句“定位到之后把 log 全删掉”。Claude Code 会:
- 加 log
- 跑脚本,收集输出
- 分析日志,给出结论
- 用同样的 diff 把 log 删掉
- 再跑一次测试,确认复现脚本还是通过
这套流程结束后,代码库不会残留任何调试代码。
用 grep 兜底
让 Claude Code 加 log 时统一用一个特殊前缀(比如 [DEBUG-XXX]),最后可以 grep -r "DEBUG-XXX" 兜底检查有没有漏删的。
完整案例:IE11 上的登录报错
假设你收到一个反馈:“新版本上线后,IE11 用户登录时页面白屏,控制台看到 SyntaxError: Unexpected token。”
第一步:复现。
你: 帮我在项目里搜一下最近改过的 auth 相关代码,我怀疑是新语法在
IE11 上不兼容导致的。找出来后写个能在 IE11 上复现的最小 HTML。Claude Code 读 git log,找到最近三天改动过的 src/auth/login.ts,看到里面用了 ?? 空值合并操作符和 async/await。它猜这可能就是根因,写了一个引用编译后 bundle 的最小 HTML 让你在 IE11 上打开验证。
第二步:定位。
你: /plan
复现了。帮我确认根因,方案给我看看。
Claude Code:
[Read tsconfig.json] [Read package.json] [Read src/auth/login.ts]
根因:tsconfig.json 的 target 是 ES2020,`??` 编译出来还是原生
语法,IE11 引擎不认。当前项目声称支持 IE11(package.json 的
browserslist 里写了 ie 11),但构建配置没对齐。
方案:
A. target 降到 ES5,全项目一起降级
B. 保留 target,但用 babel-loader 单独把 auth bundle 降级到 ES5
C. 明确放弃 IE11,从 browserslist 移除
推荐 A,因为项目还有其他新语法,改一处治不了本。第三步:最小改动。
你: A 走。改 tsconfig.json 的 target 到 ES5,别的先别动。
Claude Code:
[Edit tsconfig.json]
[npm run build] ✓
[npm test] ✓ 全绿第四步:回归测试。
你: 加一条测试,防止以后有人手滑把 target 又改回去。
Claude Code:
[写入 build.test.ts]
it("编译输出不含 ES6+ 语法(保 IE11)", () => {
const bundle = fs.readFileSync("dist/main.js", "utf8");
expect(bundle).not.toMatch(/\?\?/);
expect(bundle).not.toMatch(/\?\./);
});一个从现象到修复到防回归的完整 bugfix 流程,全程五六条对话。
什么时候不该走四步法
有些场景不需要这么严谨,比如你 100% 知道根因、改动是一行 typo 修正、或者环境问题(改一下配置就好)。硬套四步法会浪费时间。
判断标准很简单:这个改动如果错了,你能不能 5 分钟内发现并回滚? 能就快改;不能就走四步法。