下面是三份 Agent 的完工汇报,来自三次不同的会话。逐份判断:这份汇报里,哪些是断言、哪些是证据?然后写出每一份还缺哪几样具体的证据,才够你把它当成「验收通过」。
Level 1:分清断言和证据不用写代码,用文字回答。
汇报 A
汇报 B
汇报 C
学习目标:
- 说清 Claude 为什么会在「工作看起来做完了」的地方停下来,以及这时候验证这份活儿落到了谁头上
- 用确定性系统和非确定性系统的对照,解释传统测试「输入 X 走路径 Y 得到输出 Z」的假设在 Agent 身上为什么不成立
- 拿到一份完工汇报,能分清哪几句是断言、哪几句是证据,并指出还缺什么才够验收
前置要求:完成本系列前 9 门课,能手写
stop_reason驱动的 harness 循环、理解检查点与恢复机制 | 下一课 第 2 课 >>
周二下午,你让 Agent 给内部后台加一个「批量导入用户」的功能:上传 CSV,解析,校验字段,写库。需求你交代得挺清楚,然后就去开会了。
回来的时候会话已经停了。最后一条消息长这样:
你翻了下 diff。函数拆得干净,命名和旁边的模块对得上,边界情况看着也想过——空文件返回了明确的错误,邮箱正则不是那种一眼就有毛病的写法。你合了,上线了。
周五下午,运营在群里发了一句:「导入进去 400 个空用户是怎么回事?」
原因很朴素。运营那份 CSV 是从 Excel「另存为」出来的,文件开头带一个 BOM——三个字节的不可见字符 ,Excel 习惯往 UTF-8 文件头上加。于是第一列的列名被解析成了 email 而不是 email,字段映射整个落空,每一行都成了「所有字段都是 undefined」。那校验那层呢?校验的是「邮箱格式对不对」,undefined 走的是另一条分支,被当成「这列没填」放过去了。
这事儿里没有谁偷懒。Agent 写的代码是能跑的,它自测时用的 CSV 是它自己造的——它造的 CSV 当然不带 BOM。你审 diff 的时候看的是「这段代码写得对不对」,不是「这段代码碰上真实世界的输入会怎么样」。两边都尽了力,中间照样漏。
问题出在它停下来的那一刻。Agent 停下的时候,手里的信息是「我写完了、我读了一遍、看着没问题」。它没有停在「我确认做完了」,它停在「看起来做完了」。而这两件事,从会话记录里你是读不出区别的。
Claude Code 官方文档把这件事讲得很直白:Claude 在工作看起来做完的时候就会停下来;在没有一个它自己能跑的检查的情况下,「看起来做完了」就是它唯一拿得到的信号,于是你自己变成了那个验证环节——每一个错误,都得等你去发现1。
这句话值得逐字读两遍。它说的不是「Claude 偶尔会偷懒」,也不是「模型能力还不够」。它说的是一个结构性事实:如果整条链路上没有任何东西能给出一个客观结果,那么「看起来做完了」是这个系统里存在的唯一信号。 模型只能拿这个信号做决策,它没有别的可拿。
同一份文档给这个现象起了名字:trust-then-verify gap——Claude 产出一份看起来可信的实现,而这份实现不处理边缘情况1。翻成大白话就是「先信、后验,中间那段空档」。你先信了(代码看着挺好),验的动作要么没发生,要么发生得太晚(周五下午,运营在群里)。上面那个 BOM 的例子,就是这个空档的标准形态:不是代码写错了,是没人问过「Excel 导出的文件会怎么样」。
这里还有一层容易被忽略的东西。文档给的修法后半句是:验证不了的,就别上线1。这句话的重心不在「验证」,在「别上线」——它承认了有些东西你就是验不了。验不了的时候,正确的动作不是「那就凭感觉信一次」,是缩小范围、改需求、或者干脆先不发。
回头看那条完工消息。把它拆成一句一句,然后对每句问同一个问题:这句话我能不能不看代码、只看它贴出来的东西,就核实一遍?
src/importer/parseCsv.js」——能核实。文件在不在,一看便知。这是证据(虽然是最弱的那种)。差别在哪儿?证据是可以被第二个人原样重跑一遍的东西:一条命令加它的原样输出、一个退出码、一组失败用例的名字、一张截图、一个 before/after 的数字对比。断言是只能选择信或不信的东西:「逻辑是对的」「应该没问题」「已优化」「不会再出现类似情况」。
官方文档给的建议正是这条线:让 Claude 拿证据出来,而不是口头宣称成功——测试的输出、它跑了哪条命令以及那条命令返回了什么、或者结果的截图;审证据比你自己把验证重跑一遍快,而且对你压根没在旁边看着的那些会话同样有效1。
最后半句是关键。你要是全程盯着,「断言 vs 证据」这个区分不太值钱——你自己看见了。但只要你离开过屏幕,会话记录里剩下的就只有文字,而文字里断言和证据长得一样自信。
传统软件里我们也会遇到「看起来对但其实错了」,为什么到了 Agent 这儿要单开一门课?
因为传统测试建立在一个假设上,而 Agent 不满足这个假设。
先看定义。在计算领域,确定性系统在给定相同输入时每次都产出相同输出;而非确定性系统——比如 Agent——即使起始条件相同,也可能生成不一样的响应2。这不是「有 bug 所以不稳定」,这是它的工作方式。就算你的提示词一个字都没改,两次运行的决策也不确定3。
于是传统评估的那个前提塌了。传统评估往往假设 AI 每次都走一样的步骤:给定输入 X,系统应该沿路径 Y 产出输出 Z3。多 Agent 系统不这么工作。即便起点完全相同,Agent 也可能走出完全不同、但都合法的路径去达成同一个目标——一个 Agent 搜三个来源,另一个搜十个;或者它们用不同的工具找到同一个答案3。
具体是什么样?大概是这样:
这两条轨迹你没法说哪条「不对」。第二次多读了一个文件、多改了一处、测试跑了两遍,可能是它绕了远路,也可能是它发现了第一次漏掉的耦合。你要是写一个断言去卡「必须先 read schema.sql」,第二次就红了——但第二次说不定做得更好。
照着预设步骤核对轨迹这条路,在这里是走不通的:因为我们并不总知道正确的步骤是什么,通常没法只检查 Agent 有没有遵循我们事先规定的「正确」步骤3。
再加一层:错误在 Agent 系统里是复利的。传统软件里的小毛病,到了 Agent 身上能把整件事带沟里——一步失败会让 Agent 转去探索一条完全不同的轨迹,结果变得没法预测3。这跟传统程序里「一个函数返回了错值、往上传一层」不是一回事。Agent 拿到一个坏结果之后,会基于这个坏结果重新做决策:文件读错了,它可能得出「这个模块不存在」,然后新建一个;然后围绕这个新建的模块继续干活。等你看到最终产出的时候,错误已经不在原来那个位置了,它长成了另一副样子。
Anthropic 自己的结论也在这儿:Agent 的自主性意味着更高的成本和复利式错误的可能,因此建议在沙箱环境里做充分测试,配上恰当的护栏4。还有一句更直接的——模型可能会连续跑很多轮,你必须对它的决策有某种程度的信任4。
注意「某种程度的信任」这个措辞。它不是说「你得信它」,是说这份信任必须有个来源。而信任的来源只有两种:你亲眼看着(那 Agent 就没帮你省下什么),或者有个东西替你看着。这门课整个就是在讲第二种。
前面那一堆铺垫,收到一句话上:给 Claude 一个它能跑的检查——测试、构建、一张用来比对的截图。这是「一个你得盯着的会话」和「一个你可以走开的会话」之间的差别1。
差别是怎么产生的?给 Claude 一个能产出 pass 或 fail 的东西,循环就能自己闭合:Claude 干活、跑检查、读结果、然后迭代到检查通过为止1。
这句话在本系列第 7 门课的 harness 循环上是能对上号的。先看你现在这个循环停在哪儿:
end_turn 是什么意思?它的意思是模型自己觉得这一轮说完了。仅此而已。 它不代表活干对了,甚至不保证话说完了——这个循环只认 tool_use,stop_reason 变成别的任何值它都会退出,包括输出被 max_tokens 拦腰截断的那种。退出条件里,压根没有任何一项跟「产出的质量」有关。
那把检查接进来是什么样?两个位置都能接。
位置一,把检查做成一个它能调的工具,让它在循环里自己跑:
位置二,在循环退出之后加一道门,不信它的自述、自己跑一遍:
代码本身没什么花样,关键是退出条件换人了:从「模型说它不想再调工具了」换成了「一段确定性代码返回了 0」。前者是模型的自我评价,后者不是。
那「检查」可以是什么?官方文档给的范围比想象中宽:任何能返回一个 Claude 在对话里读得到的信号的东西——一套测试、一个构建的退出码、一个 linter、一个把输出和固定样本(fixture)做 diff 的脚本、或者一张跟设计稿比对的浏览器截图1。
fixture 这个词解释一下:就是一份你事先存好的「标准答案文件」,跑完拿产出跟它比,一个字符都不能差。听起来笨,但在「输出格式必须稳定」这类任务上,它是最省事也最可靠的一种检查。
这条思路跟 Anthropic 对 Agent 执行过程的建议是一致的:在执行期间,让 Agent 在每一步都从环境里拿到「ground truth」(真实反馈,比如工具调用的结果或者代码执行的结果)来评估自己的进展,这一点很关键4。注意「从环境里拿」——不是从自己的推理里拿。模型的推理是它自己生成的,环境的返回值不是。
有了「给它一个能跑的检查」这条主线,剩下的问题就具体了。
第 2 课:验什么。 既然照着预设步骤核对轨迹走不通,那验哪儿?答案是终态优先——评判它有没有到达正确的最终状态,而不是有没有遵循某个特定过程;对复杂流程,把评估拆成若干个「此处应该发生某个状态变化」的检查点3。这一课还会讲怎么把一个模糊的需求变成可测量的成功标准。
第 3 课:确定性验证器。 能跑出 pass/fail 的检查怎么挑、怎么写。精确匹配、脚本比对、测试套件各自适合什么场合,以及一个反直觉的坑:验证器过严会把对的答案判错。具体的验证器清单和取舍顺序在那一课,这里先不展开。
第 4 课:LLM 当裁判。 自由文本没法用字符串比对,只能请一个模型来打分。量表怎么写、输出格式怎么限定、先推理还是先给分、以及为什么干活的那个模型不该自己当裁判——本课那道题里已经碰到过这条了。量表的具体设计放在第 4 课。
第 5 课:评测集。 一条检查管一个任务,一组任务才叫评测集。怎么从真实用法里攒用例、边缘情况怎么补、留出集是干嘛的、以及「多少条才算够」——这些都在第 5 课回答,答案可能比你以为的小得多。
第 6 课:动手搭。 把前面五课接起来:一条评测任务一个 harness 循环,跑完出报告,改一版提示词就能看到分数变没变。
看到这儿,容易走到另一个极端:以为每个任务都得配测试、配裁判、配评测集。不是的。
Anthropic 的原话是:只有当复杂度带来可证明的改进时,才值得加这份复杂度4。同一篇文章还有一句更具体的路线建议——从简单的提示词开始,用充分的评估去优化它,只有在更简单的方案不够用的时候,才加上多步的 Agent 系统4。
对应到验证这件事上,判断标准差不多是这几条:
还有一种情况值得单说:有些检查你已经有了,只是没接给 Agent。 项目里那套测试、那个 lint 命令、那个构建脚本,多半早就存在。把它写进任务描述、或者做成一个工具,成本几乎是零,但会话的性质就变了。这是投入产出比最高的一步,也是本课程后面几课的起点。
Best practices for Claude Code — Claude Code 官方文档 — https://code.claude.com/docs/en/best-practices ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12
Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents ↩ ↩2
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
不用写代码,用文字回答。
汇报 A
汇报 B
汇报 C
写一个脚本,把
data/contacts.csv里
假设你把这个任务交给 Agent,它跑完了,报告「已完成,重复行已去除」。
请你设计一份证据清单:验收这个任务时,你要求看到哪几样东西?每一样写清楚是什么形式(一条命令加它的输出?一段 before/after 对比?一个文件?)。然后回答第二个问题:这几样里,哪一个能让循环自己闭合——也就是能让 Agent 自己跑、自己读结果、自己改到过为止,不需要你在场?
不用写完整代码,命令和伪代码就够。
记下想法、痛点、没懂的地方。只写进这门课的附录,正课文件不动。
已完成。
- 新增 src/importer/parseCsv.js,负责解析与字段映射- 新增 src/importer/validate.js,校验邮箱格式与必填项- 在 POST /api/users/import 上接好了批量写入
实现遵循了现有模块的代码风格,运行过程中没有出现错误。同一个任务,同一段提示词,跑两次
第一次:read_file(schema.sql) → grep("user_id") → edit(models/user.js) → run_tests → 完成
第二次:list_dir(src/) → read_file(models/user.js) → read_file(models/order.js) → edit(models/user.js) → edit(models/order.js) → run_tests → run_tests → 完成let response = await client.messages.create({ tools, messages });
while (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const toolResults = await runToolUses(response.content);
messages.push({ role: "user", content: toolResults });
response = await client.messages.create({ tools, messages });
}
// 循环停在这里:stop_reason 从 "tool_use" 变成了 "end_turn"
const tools = [
...editTools,
{
name: "run_checks",
description:
"运行 importer 模块的测试套件。返回退出码与失败用例名。" +
"修改 src/importer/ 下任何文件之后都必须调用一次。",
input_schema: { type: "object", properties: {}, required: [] },
},
];
// while 循环跑完了,先别急着当成完成
messages.push({ role: "assistant", content: response.content }); // 模型的收尾回复也要进历史
let verdict = await runChecks(); // { exitCode, failed: ["BOM 头解析", ...] }
while (verdict.exitCode !== 0) {
messages.push({
role: "user",
content: `检查没通过,失败用例:${verdict.failed.join("、")}。修完再报告。`,
});
response = await client.messages.create({ tools, messages });
// ……回到上面那个 tool_use 循环继续跑,直到它再次收尾……
messages.push({ role: "assistant", content: response.content }); // 每次收尾回复同样要进历史
verdict = await runChecks();
}
已完成。我重构了 src/importer.js,把字段映射抽成了独立函数 mapFields(),逻辑比之前清晰不少。改动没有影响现有行为。已完成。运行 npm test -- importer 后输出:
PASS test/importer.test.js Tests: 14 passed, 14 total Time: 1.842 s
新增的 3 个用例分别是 "handles BOM in header"、"rejects duplicate email"、"errors on missing column"。未运行完整测试套件,只跑了 importer 这一组。已完成。修复了 BOM 头导致字段映射失败的问题。我又检查了一遍代码,逻辑是对的,应该不会再出现类似问题了。另外顺手优化了一下解析性能。