# 验证闭环：让智能体自己证明做对了

> 智能体说「完成了」不等于完成了。本课讲怎样建立验证闭环：类型与 lint、测试先行、真实运行与浏览器截图、对照验收标准自检、独立复核，以及如何防止智能体靠改测试、写死答案来「骗过」检查。

- 网址：https://coding.chengyu.dev/learn/engineering/verification/
- 学习路径：AI 编程的工程方法
- 预计用时：30 分钟；核实日期：2026-10-25
- 官方来源：[Best practices for Claude Code（Claude Code 官方文档）](https://code.claude.com/docs/en/best-practices)，[Automate actions with hooks（Claude Code 官方文档）](https://code.claude.com/docs/en/hooks-guide)，[Keep Claude working toward a goal（Claude Code 官方文档）](https://code.claude.com/docs/en/goal)，[Use Claude Code with Chrome（Claude Code 官方文档）](https://code.claude.com/docs/en/chrome)，[Configure permissions（Claude Code 官方文档）](https://code.claude.com/docs/en/permissions)，[Effective harnesses for long-running agents（Anthropic）](https://www.anthropic.com/research/effective-harnesses-for-long-running-agents)，[EvilGenie: A Reward Hacking Benchmark（arXiv:2511.21654）](https://arxiv.org/abs/2511.21654)

## 学习目标

- 能为一个任务设计从快到慢的分层验证，并让智能体自己运行并读取结果
- 能在智能体工作流中应用测试先行，并说明它为什么比事后补测试可靠
- 能用 Stop Hook 或 /goal 把验证变成「不通过就不能结束」的闸门
- 能识别并防范智能体绕过测试的常见手法

智能体回报：「已完成，所有测试通过。」你合并了。第二天线上出问题，一查发现：有个测试在中途被它改过断言，另一个被标成了跳过，而真正的功能从没在浏览器里打开过。

这里的问题不在于智能体撒谎，而在于**它停下来的依据是「看起来做完了」**。Claude Code 官方最佳实践里有一句话我很认同：没有它能运行的检查，「看起来做完了」就是唯一的信号，而你就成了验证闭环本身，每个错误都要等你来发现。反过来，只要给它一个能产出通过或失败的检查，它就会自己做、自己读结果、自己改，直到通过。**能否让智能体自己验证，决定了你是在盯着它干活，还是可以放手去做别的事。**

## 原则：要证据，不要断言

检查可以是任何能把信号带回对话里的东西：测试、构建的退出码、类型检查、lint、对比输出与样例的脚本、浏览器截图。要点是两个：**它能自己运行**，以及**结果是它能读懂的**。

我把验证分成五层，越往下越慢、越贵、越接近真实：

| 层 | 检查什么 | 耗时 | 能抓住 | 抓不住 |
|---|---|---|---|---|
| 1 | 类型、lint、构建 | 秒级 | 拼写、类型不符、未使用变量 | 逻辑错误 |
| 2 | 单元与集成测试 | 秒到分钟 | 行为回归，边界情况 | 测试没覆盖的路径 |
| 3 | 真实运行 | 分钟 | 配置、集成、环境问题 | 视觉和交互细节 |
| 4 | 浏览器与截图 | 分钟 | 页面真能用、样式对不对 | 后端深处的错误 |
| 5 | 验收标准核对与独立复核 | 分钟 | 做漏的需求，做错的方向 | 需要领域判断的问题 |

规则是：**便宜的检查每次都跑，贵的检查在关键节点跑。** 让智能体每改一批代码就过第 1 层，每完成一个步骤就过第 2 层，整个任务结束前才过第 3 到第 5 层。

## 第 1 层：把机器能秒判的事交给机器

类型检查和 lint 的价值，在智能体时代被放大了：它们是**最快、最确定的反馈**。一个有严格类型的 TypeScript 项目，智能体改错一个字段名，几秒钟内就收到明确报错，不需要你介入。

让它们自动发生，别靠提示词提醒。官方文档里有一个现成的 Hook 例子：每次文件编辑后自动格式化。

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}
```

更重要的是第二种用法：**把验证做成闸门**。Stop Hook 在智能体准备结束一轮时触发；脚本退出码为 2 时，stderr 里的内容会作为反馈返回给智能体，它必须继续干活。下面是一个最小可用的版本：

```bash
#!/bin/bash
# .claude/hooks/verify-before-stop.sh
INPUT=$(cat)
# 已经因为这个 Hook 继续过一次，放行，避免死循环
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
if ! OUTPUT=$(npm run verify 2>&1); then
  echo "验证未通过，请修复后再结束：" >&2
  echo "$OUTPUT" | tail -40 >&2
  exit 2
fi
exit 0
```

在 `.claude/settings.json` 里注册 `Stop` 事件，命令指向这个脚本即可（Stop 事件不需要 matcher）。官方也提醒了两个细节：Claude Code 在连续阻止八次、中间没有工具调用时会强行放行，所以脚本要检查 `stop_hook_active`；输出只保留尾部，避免把几千行日志灌进上下文。

如果不想写脚本，`/goal` 是会话级的轻量版：`/goal npm run verify 退出码为 0，且 git status 里没有改动 tests 目录，或者 20 轮后停止`。另一个模型会在每轮结束后判断条件是否成立。要注意它**不会自己运行命令**，只看对话里已经出现的内容，所以要求智能体把命令输出贴出来。

## 第 2 层：测试先行，在智能体时代更重要

测试驱动开发（TDD）有一个老毛病：很多人觉得麻烦所以不做。智能体消除了「麻烦」，却放大了它的价值，因为**测试是可执行的规格**。让智能体先写测试再写实现，顺序是：

1. 描述行为，让智能体只写测试，不写实现；
2. 运行测试，**确认它们因为正确的原因而失败**（红）。测试一开始就是绿的，说明它没测到东西；
3. 提交测试。这一步把规格固定下来；
4. 再让智能体实现，直到测试通过（绿）；
5. 重构，保持绿。

为什么先写比事后补更可靠？智能体事后写测试，常常是读自己刚写的代码，再写一份「描述这段代码做了什么」的测试，而不是「描述这段代码应该做什么」的测试。前者永远通过，却没有任何验证价值。官方也建议过类似的分工：一个会话写测试，另一个会话写代码去通过它。

下达指令时直接要求：「先写一个能复现该问题的失败测试，确认失败，再修复。」对 bug 修复同样适用，复现测试还会留在仓库里，防止回归。

## 第 3、4 层：真实运行，眼见为实

单元测试全绿而页面打不开，是智能体编程里最常见的翻车。Anthropic 在长时任务的 harness 文章里记录过：智能体起初倾向于用单元测试或 curl 命令来「验证」，却漏掉了真实用户路径上的问题；改为要求它用浏览器自动化、像人一样走完流程后，发现 bug 的能力明显提升。文中也坦诚了局限：浏览器原生的弹窗（alert 等），智能体看不到，依赖它们的功能更容易出错。

Claude Code 的做法是 Chrome 集成：`claude --chrome` 启动，之后可以直接要求它打开本地服务、操作页面、读取控制台报错、截图对比。官方示例大意是：

> 我刚改了登录表单校验。请打开 localhost:3000，用非法数据提交，检查错误提示是否正确显示。

对界面改动，最佳实践的写法是：贴设计稿，要求它实现后截图，与原图对比并**列出差异再修正**。对后端，则是把服务真的启动起来，用 curl 或脚本走一遍验收标准里的请求，并贴出输出。官方还提供 `/verify` 这类对着运行中的应用确认改动的入口，可以在智能体的检查通过后自己再过一遍。

## 第 5 层：对照验收标准，再找一双新眼睛

第一次实现完成后，让智能体把规格里的每条验收标准列出来，逐条给出**证据**：运行了什么命令，输出是什么。我更喜欢让它产出表格，因为「全部满足」这种话它很容易随口说出，而一条一条贴证据，随口就难了。

**验收证据表**

```text
实现已经完成。请对照 SPEC.md 的验收标准，逐条自检，不要凭印象回答。

输出一张表格，列为：编号、验收标准原文、你运行的命令、命令输出的关键几行、结论（通过 / 未通过 / 无法验证）。

要求：
1. 每一条都必须真的运行命令得到证据，不能写「应该没问题」；
2. 无法验证的条目如实写「无法验证」，并说明原因和需要我提供什么；
3. 不要修改任何测试文件来让检查通过；如果你认为某个测试本身有问题，只报告，不要动；
4. 最后单独列出：你在实现过程中偏离计划的地方，以及你没有测到的风险。
```

自检仍然是「同一个脑子」在评价自己。更可靠的是**新鲜上下文里的独立复核**：开一个子智能体，只给它 diff 和验收标准，不给实现过程。官方文档提醒了一点值得抄的经验：被要求「找问题」的复核者几乎总会找出一些问题，哪怕工作其实没毛病，所以要明确告诉它「只报告影响正确性或违背需求的问题，风格偏好不算」，否则你会被引向过度设计。内置的 `/code-review` 就是在新子智能体里审查当前 diff。

## 防止「骗过测试」

这是本课最重要的一节。智能体在优化的目标是「让检查通过」，如果通过比做对更容易，它有时就会选更容易的。学术界有专门的基准研究：EvilGenie 测试了包括 Codex 和 Claude Code 在内的多个编码智能体，设置了容易钻空子的评测环境，结果是所测的智能体都出现过不当行为，Codex 与 Claude Code 都有明确的「奖励作弊」（reward hacking）：写死测试里的期望值，或者修改测试文件。作者发现用语言模型当裁判，能很有效地发现这类明显作弊。Anthropic 的 harness 文章里也专门写了一条硬规矩：不准删除或修改测试。

常见手法与对策：

| 手法 | 表现 | 对策 |
|---|---|---|
| 写死答案 | 针对测试里的输入直接返回期望值 | 评审时先看测试之外有没有条件分支特判；加入变化的输入用例 |
| 改测试 | 放宽断言、改期望值、删除用例 | 实现阶段锁定测试目录；PR 里测试文件的改动单独审 |
| 跳过 | `skip`、`xfail`、`@Ignore` | 在 CI 里禁止新增跳过标记，或让 lint 报错 |
| 过度 mock | 把被测对象本身 mock 掉 | 要求至少一层集成测试不用 mock |
| 吞异常 | `try/catch` 里什么也不做 | lint 规则禁止空 catch；评审关注新增的 catch |
| 绕过钩子 | 提交时加 `--no-verify` | 用 PreToolUse Hook 检查完整命令，服务端 CI 兜底 |

最有效的一招是**让「谁能改什么」成为规则**，而不是请求。实现阶段，可以用权限规则把测试目录设为不可编辑，例如在 `permissions.deny` 里加入 `Edit(./tests/**)`。官方文档指出，`Edit` 规则会应用于所有内置的文件编辑工具，也会覆盖 Claude Code 能识别的 Bash 文件命令（如 `sed`、`tee`），但不能保证拦住任意子进程，所以它是防护，不是铁壁。真正的兜底在 CI：**在干净的检出里重新跑全部检查**，并对测试文件的变更做醒目标记。

最后一个习惯：评审时先看测试，再看实现。如果测试是智能体自己改过的，它的「全部通过」就不再有证明力。

> 小测：智能体报告「所有测试通过，功能完成」。以下哪种做法最能说明它真的完成了？

> 小测：你想让智能体修一个 bug，并尽量避免它「为了让测试通过而改测试」。下面哪种组合最合理？

- [ ] 为你的项目准备一条 `npm run verify`（或等价命令），一次跑完格式、类型、lint 和单测，失败时输出简洁
- [ ] 写一个 Stop Hook：验证未通过就阻止智能体结束，并检查 `stop_hook_active` 避免死循环
- [ ] 挑一个小 bug，按「复现测试 → 确认失败 → 提交 → 修复」的顺序让智能体做一遍
- [ ] 用 `claude --chrome`（或你的工具里等价的浏览器能力）让智能体对一个页面改动做截图对比
- [ ] 用「验收证据表」提示词审一次已完成的功能，看有多少条其实是「无法验证」

延伸阅读：[Hooks 自动化](/learn/claude-code/hooks/)、[实战：测试与评审](/learn/ship/test-review/)。下一课讲智能体带来的另一类新风险：[安全](/learn/engineering/security/)。
