用 CLI Agent 跑 TDD 工作流
用 CLI Agent 跑"红 - 绿 - 重构"循环:先写测试 → 让 Agent 写实现 → 自动跑测试 → 验证通过 → 重构的完整工作流
用 CLI Agent 系统化定位 bug 的五步走 —— 复现 → 隔离 → root cause → fix → 回归,附 prompt 模板与避坑
内容摘要
调试这件事,老程序员都知道有两种打开方式。一种是**凭直觉**:看一眼报错,"哦应该是这里加个 null 判断",改完跑一下不报错就提交了;另一种是**系统化**:先稳定复现,再二分隔离,再问"为什么会这样"直到挖到根因,最后写一个回归测试把这个 bug 钉死。前者快但 bug 容易复发,后者慢但每修一次就少一种 bug 类。
调试这件事,老程序员都知道有两种打开方式。一种是凭直觉:看一眼报错,"哦应该是这里加个 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,再补回归测试。这五步少一步都不算结束。
| 要素 | 在调试场景里的含义 |
|---|---|
| 稳定复现 | 必须能在本地命令行/测试框架里 100% 触发,不依赖"刚才在生产看到"这种二手信息 |
| 最小复现集 | 把无关上下文(配置、依赖、数据)逐项剥离,直到剩下能触发 bug 的最小代码 |
| Root Cause | 不是"哪一行抛了异常",而是"为什么这一行会被以这个状态调用",常用 Five Whys 追问 |
| 回归测试 | 写一个测试,在 fix 之前先让它失败(红),fix 之后变绿。没有这一步,Agent 不能宣称"修完了" |
| 二分隔离 | 在不知道 bug 是什么时候引入的,用 git bisect 让 Agent 自动二分到引入提交 |
| 倾向 | 背后的原因 |
|---|---|
| 看到报错立即 Edit | 训练目标是"即时回应",越快给出 patch 越像"好助手" |
| 跳过复现直接读代码猜 | 复现要跑命令、要看输出,比只读文本贵 |
| 把 stack trace 顶端当 root cause | stack trace 顶端是症状所在的位置,不是原因发生的位置 |
| 改完不写测试 | 写测试比写 fix 多一倍 token 和工具调用 |
| 上下文长了之后开始"总结结论" | 长 context 会触发模型的"复述偏好",开始凭印象而非实际验证 |
记住这一点对写调试 prompt 很关键:默认 Agent 会偷懒,你得在 prompt 里把每一步都写死。
假设你接到一个 bug 报告:"订单详情页偶尔会 500,触发条件不明"。下面是把 CLI Agent 拉进来做系统化调试的 prompt 模板,可以直接复制使用:
你是我的 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 把它标成"红色测试",跑测试套件时不会被忽略:
# 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 写这种"先红后绿"的测试有两个好处:
git bisect + Agent 联动如果你知道某个版本是好的、当前是坏的,但不知道哪一次提交引入了 bug,可以让 Agent 直接驱动 git bisect run:
# 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 小时,主要靠的就是这个机制。
Claude Code 的 PreToolUse hook 可以拦截 Edit / Write 工具调用。下面这段 settings.json 片段做了一件事:只要 Agent 想 Edit src/ 下的文件,就先检查 tests/regression/ 下有没有今天新加的测试,没有就直接拒绝。
{
"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 要先帮你搭测试脚手架 |
参考答案:
技术上当然可以,但工程上很危险,原因有三点。
第一,只有先红后绿才能证明你真的修对了。如果你先 fix 再写测试,那个测试很可能是"已经绿的状态下补的"——它跑过 1 次,但你不知道它在 bug 还活着的那一刻能不能真的失败。这是工程上典型的"幸存者偏差":测试存在 ≠ 测试有效。
第二,先写复现测试逼你先理解 bug。写一个能稳定触发 500 的测试,意味着你必须搞清楚"什么样的输入 + 什么样的状态"会触发,这个过程本身就是在做"最小复现集"那一步。跳过这一步,Agent 的 fix 通常是"看起来相关的改动",而不是"针对最小复现集的精准修补"。
第三,先红后绿提供了一个无歧义的完成判据。Agent 如果可以自己宣称"修好了",它就会在 stack trace 不再出现时停手。但 stack trace 不出现可能只是因为代码路径变了,bug 仍然在;只有"这个特定的复现测试在 fix 之前红、之后绿"这个事实,才能让"修完了"这件事不掺水分。
参考答案:
经验上的判据有三条,满足任一就可以停:
追到一个"不该被这样调用"的契约违反。比如追到"为什么 order.user 会是 null?因为 ORM 关联迁移漏了"——这一层已经触达了"代码契约本来约定 user 必须存在"的事实,再往下追就到组织流程层("为什么 PR review 没拦下")了,那是工程治理问题,不是这次 bug 的修复范围。
追到一个"修了它就不会再以任何形态复发"的层次。如果在第 2 层修能让所有变体复发都消失,第 3 层才修就是过度。判断方法:在拟定 fix 之后问自己"还有什么场景能触发同一个 root cause",如果想不出,那就是修对了层次。
追到一个不属于本次代码改动责任范围的边界。比如追到"为什么 Postgres 在网络抖动时返回 stale 数据"——再往下就是数据库内核问题了,这次 bug 应该在应用层做防御性处理(比如重试或一致性读),而不是去改 Postgres。
过度设计的信号:Why 链超过 5 层、每一层的回答都需要新假设、修复方案开始动到完全无关的模块。出现这些就是追深了,应该退回去看上一层那个"修了能止血"的位置。
实践里 3 层是个不错的默认值:第 1 层告诉你 bug 长什么样,第 2 层告诉你为什么这样发生,第 3 层告诉你为什么没被防住。修第 3 层通常是性价比最高的位置。
参考答案:
这种规模下不能靠每个开发者写 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第三层: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/permission-model。
git bisect 官方文档:https://git-scm.com/docs/git-bisectgit bisect run(LWN.net 经典文):https://lwn.net/Articles/317154/优先展示同分类且标签更接近的内容,方便继续串联学习。
用 CLI Agent 跑"红 - 绿 - 重构"循环:先写测试 → 让 Agent 写实现 → 自动跑测试 → 验证通过 → 重构的完整工作流
用 CLI Agent 审查 PR 的提示词模板、产出格式、严重度分级与 GitHub PR 的衔接,含本地 diff 与远程 PR 两种场景