第 6 章 eval-driven 开发

传统软件有 TDD——先写测试再写代码。Agent 时代需要一个对应物:先写评测用例,再开发功能。这一章讲我怎么从"跑一遍看看"进化到"用 eval 驱动开发"。
开篇:跑一遍看看,是 Agent 开发里最危险的习惯
我早期开发 Agent 功能的时候,验证方式特别原始——跑一遍看看。
改完代码,在聊天框里发一条消息,看 Agent 回复得对不对。对了就算过了,不对就接着改。整个过程跟用手指戳电源开关差不多:通了,好;没通,再戳一次。
这个习惯在传统软件里勉强能用,因为传统程序的输出是确定性的——同样的输入永远得到同样的输出,你跑一遍够了。但在 Agent 开发里,它是危险的,原因有两个。
第一,Agent 的输出是概率性的。 你这次跑对了,不代表下次也会对。也许只是运气好,模型这次恰好猜中了你的意图。改一行代码、换一个时间、甚至什么都不换再跑一次,结果可能完全不同。靠"跑一遍看看"来判断对错,跟抛硬币差不多。
第二,你会不自觉地"对答案"。 当你亲眼看到 Agent 的回复时,你脑子里已经有一个"期望答案"了。Agent 说得跟你想的一样,你就觉得"过了";说得不一样,你就觉得"没过"。但这个判断是主观的、模糊的、不可复现的。你换一个人来看同一条回复,他可能觉得"没过"。这种主观判断没法沉淀成可回归的测试。
我就是被这两个问题折腾够了,才开始认真想:Agent 时代到底该怎么测?
什么是 eval
先搞清楚概念。Anthropic 对 eval 的定义很准确:
Anthropic 的定义
一个 eval 就是:给 AI 一个输入,然后对它的输出应用打分逻辑,来衡量成功与否。
就这么简单。输入 + 打分逻辑 = eval。传统单元测试也是这个结构——输入 + 断言 = 测试。区别在于"打分逻辑"。
传统测试的断言是精确的:assertEqual(result, expected)。结果要么等于期望值,要么不等于,非黑即白。
Agent 的 eval 不能这么干,因为 Agent 的输出是自然语言,你没法用 assertEqual 判断"这条回复对不对"。所以 eval 的打分逻辑需要更灵活的手段——这个后面讲。
eval-driven:先写 eval,再写功能
理解了 eval 是什么,就可以讲核心方法论了。
传统软件有 TDD(测试驱动开发)——先写测试,再写代码。Agent 时代有一个对应物,业界叫 EDD(Eval-Driven Development,评测驱动开发)。Braintrust 给它的定义是:"evaluations serve as the working specification"——评测就是工作的规格说明。
什么意思呢?就是在写任何 Agent 功能之前,你先写好评测用例。这个用例定义了"输入是什么、预期 Agent 怎么回应"。写完用例之后,你再去开发功能,直到 Agent 能通过这个用例。
这跟 TDD 的哲学一模一样:测试不是事后的验证,而是事前的规格。只不过 TDD 的测试是"函数输入→期望输出",EDD 的 eval 是"对话场景→期望行为"。
一个让我自己打脸的闭环
理论是这么个理论。但它到底灵不灵,得真的用一次才知道。我后来在一个项目里完整跑了一遍 eval-driven 的闭环,过程里被自己打了一次脸——而且是两次。
那个项目里,Agent 会定期给用户生成一些报告、做一些决策记录。设计上,用户回头找 Agent 聊的时候,Agent 应该"记得"这些事——比如用户问"上次让我关注的那个,现在怎么样了",Agent 不能装失忆,得能接上"上次报告里提过 X,目前还没动"。我把这种能力叫做"记忆引用"。
功能上线了,我想验证它到底灵不灵。按照这一章一直在讲的方法,这时候我应该直接去写 eval。但我没忍住——手痒,先用了老办法:顺着代码追了一圈调用链,又随手挑了两条 Agent 的回复自己读,读完心里有了个判断:"嗯,Agent 回复里没看到明显提到历史报告,这记忆引用大概是 latent 的(隐式起作用,看不太出来),效果不显著。"
这个判断是错的。而且它错得很有迷惑性——因为它听起来像个"经过分析的结论",让我差点就信了。
好在我没停在这一步,回头老老实实写了 eval。我设了两个用例,模拟用户分别去问"上次报告提的事"和"上次的决策",每个跑 5 次,再让一个独立裁判来判断:回复里到底有没有引用到具体的历史内容。
结果两个用例通过率都是 100%。Agent 每次都把历史内容拎了出来,而且不是含糊地提一嘴——它会说"上次报告里建议过 X,今天是第三天了,还没执行"。这种带时间、带具体内容的引用,根本不是我先前判断的"latent、看不出来"。
我先前的肉眼判断,被 eval 当场打脸。它不是 latent,是我肉眼没看出来。
但这还没完。第二个用例跑完后,我又加了第三个——这个用例问得更笼统,不是指着某一条历史记录问,而是问一个需要把好几条记录合起来看的问题。我想看看 Agent 遇到这种"没点名具体对象"的提问会怎样。
结果这个用例首跑通过率只有 20%。Agent 大部分时候答非所问——它退回去引用了一些八竿子打不着的泛泛信息,没碰到点子上。
这个 20% 一出来,问题的根因自己就浮出来了:我那套记忆引用的逻辑,是"用户提到某个具体对象,才把它对应的记录拉出来"。这条逻辑对前两个用例(用户点了名)管用,但对第三个用例(用户没点名,问的是一整片)就够不着。于是我改了逻辑——遇到这种笼统的提问时,把相关记录的概览一次性拉进来。改完再跑:20% → 100%。
这件事让我对 eval-driven 有了具体的体感,比方法论那句话实在得多。回看整个过程,eval 帮我发现了两个肉眼绝对发现不了的东西:
- 它证明我错了。 我那个"latent、不显著"的判断,是错的。Agent 引用得很好,是我没看出来。
- 它挖出一个我根本没意识到的缺口。 那个 20% 的笼统用例,是我写 eval 之前压根没想到要测的场景。如果不写 eval,我会一直以为这个功能"大概还行",永远不知道它在笼统提问时会哑火。
而我这一轮真正该吸取的教训,其实比"要写 eval"更难堪一点:我是先用了肉眼、读出个错结论,才回头补的 eval。 我明明知道该用 eval——这一轮本来就是为了用它——可手一痒还是退回了老习惯。这种"道理都懂、一动手就滑回原样"的惯性,比想象中顽固。LLM 输出质量这种事,肉眼读两条就下判断,既不准也不可复现,等你发现自己错了,可能已经顺着错判断走出一截了。
三种打分手段
上面那个打脸的故事里,eval 怎么判断"Agent 有没有引用历史内容"?光说"引用了"不够,得有个判法。这就要讲 eval 的核心——打分逻辑。前面说了,Agent 的 eval 不能用 assertEqual,那打分逻辑怎么写?三种手段,对应不同的指标类型。
手段一:代码断言(判"硬指标")
最直接的——用代码检查可量化的结果。比如"Agent 有没有调用某个工具""生成了几条记录""字段值对不对"。这些是查状态、查数据库、查调用日志就能确定的,跑一次就能断言。
代码断言适合判"有没有发生某件事"。它快、客观、可复现。但它判不了"回复得好不好"——你没法用代码断言判断一句话是否"自然""准确""合规"。
手段二:LLM 裁判(判"软指标")
对于代码断言搞不定的"软指标"——比如回复质量、内容是否准确、有没有违规——我用一个 LLM 来当裁判。
具体做法是:把 Agent 的回复和评判标准发给另一个 LLM(注意是另一个模型,不是 Agent 自己那个,原因下一章讲),让它判断这条回复达不达标。裁判的 prompt 要求输出一个分数和理由:
{"score": 0.5, "reason": "结论方向对了,但漏了风险提示"}LLM 裁判的好处是灵活——你可以在 prompt 里定义任何评判标准,它都能判。但 LLM 裁判有个绕不开的问题:它自己也是概率性的,也会判错。 这个问题严重到值得专门用一整章来讲(就是下一章),这里先说一个我实测的发现。
我做了一个裁判探针:准备三档回复(一档该通过、一档有明显瑕疵、一档完全离谱),看裁判分别打多少分。结果是:
| 回复 | 打分 | 裁判理由 |
|---|---|---|
| 该通过的(依据扎实、建议合理、含风险提示) | 1.00 | 准确基于数据,建议合理,含风险提示 |
| 有瑕疵的(方向对,但漏了风险提示) | 0.50 | 结论用对了,但缺风险提示 |
| 完全离谱的(编造数据、方向错误) | 0.00 | 虚构数据,无提示,方向错误 |
这个探针说明两件事。第一,裁判对轻微违规很灵敏——漏一个风险提示就被打到 0.50,不是含糊地放过。第二,裁判打分有分寸——部分正确给部分分(0.50),而不是非黑即白的 0 或 1。这第二点很重要,它意味着我们后面可以用"通过率阈值"(比如 5 次里过 4 次算通过)来判定,而不是要求每次都满分。
手段三:人工抽查(校准基准)
第三种是人工看。你亲自读 Agent 的回复,判断对不对。这是最准的——人的判断力目前还是金标准。但它太慢了,不可能每次跑 eval 都人工看一遍。
我的用法是把人工抽查当校准基准——定期从 eval 结果里抽样一批,人工复核,看看代码断言和 LLM 裁判的判断跟人的判断吻合度有多高。如果吻合度下降,说明要么是 Agent 退化了,要么是裁判飘了,需要查。
三种手段怎么配合:一张决策表
三种手段不是三选一,而是按指标类型分工配合。我把它整理成一张决策表:
| 指标类型 | 打分手段 | 例子 | 可信度 |
|---|---|---|---|
| 结构化字段(计数、状态、一致性) | 代码断言 | 有没有调用工具、生成几条记录、字段值对不对 | 高(确定性) |
| 叙事质量(准确性、合规、语气) | LLM 裁判 | 有没有瞎编、建议和依据是否一致、有没有风险提示 | 中(概率性) |
| 裁判本身可信度 | 人工抽查 | 定期校准裁判有没有飘 | 高(但慢) |
一个 eval 用例里可以同时有代码断言和 LLM 裁判——硬指标用代码判,软指标用裁判判,定期人工校准两者。判断顺序是自上而下的:一个指标如果能用代码量化,就别扔给裁判(又慢又飘);只有代码判不了的"好不好"问题,才用裁判;裁判准不准,定期人工抽查来兜底。
eval 用例怎么设计
写了十几个 eval 用例之后,我总结出几条设计原则。
原则一:无歧义。 预期行为必须写得足够具体,让任何人(或任何裁判 LLM)看了都能给出一致的判断。"回复要好"是歧义的,"回复应引用上次报告并说明当前进展"是无歧义的。
原则二:正反平衡。 这条要多说两句。eval 用例有两种基本形态——
- 正例:Agent 应该做出某个行为的场景。比如"用户问到历史报告,Agent 应该引用它"。正例测的是"该做的有没有做"。
- 反例:Agent 不应该做出某个行为的场景。比如"群聊里有人闲聊,Agent 不应该被触发"。反例测的是"不该做的有没有忍住"。
正反两种用例,重要性是一样的。光写正例,你只能保证"该响应时响应了",保证不了"不该响应时没响应"——而很多线上事故恰恰出在后者,Agent 在不该开口的时候瞎开口。所以每写一个正例,都想想对应的反例要不要补。
原则三:标注来源。 每个用例写清楚它是从哪来的——是 bug 驱动的("因为踩了 X 这个坑,所以写了这个用例"),还是场景驱动的("补盲区,这类场景还没覆盖")。这个标注帮你判断用例的优先级和覆盖面。
原则四:eval-driven 优先。 正例尽量在开发前写(eval-driven),反例往往是踩了坑之后补的(bug 驱动)。两种都要,但如果只能选一种,选 eval-driven——因为事前定义比事后补漏更省事。
我踩过的坑:eval 环境污染
讲一个真实的工程坑,它差点让我的 eval 框架变得不可信。
有一次我发现用例单独跑能过,连着跑就挂。查了很久,定位到是环境残留:我的 eval 是串行跑的,一个用例跑完会 reset 数据库,但 reset 只清了数据库,没清别的。上一个用例改过的某些状态,残留在那里,下一个用例启动时被它污染了。
这个问题的表现特别诡异,因为它不是 Agent 的 bug——Agent 行为没变,是测试环境脏了。
当时的临时解法很土:每个用例之间加一个 sleep(1s),等残留自己消化完。但这只是缓解,不是根治。真正的解法应该是 reset 的时候,把所有该清的状态都清干净。
这件事真正难的地方在于——"该清的状态"远不止数据库一个。我后来在一个项目里专门梳理过,发现光是模块级的可变状态就有二十多处,分成四类:
| 类型 | 是什么 | 为什么会污染下一个用例 |
|---|---|---|
| 单例缓存 | 全局只建一次的对象(配置、连接池) | 第一个用例创建后缓存住了,第二个用例改不动它 |
| 数据缓存 | 算过一次就记住的结果(比如某个查起来很贵的列表) | 第一个用例触发计算并缓存,第二个用例拿到的是旧缓存 |
| 运行时字典 | 记录"谁在运行""谁订阅了什么"的字典 | 第一个用例注册的订阅没清,第二个用例被旧订阅干扰 |
| 标志位 | "有没有初始化过"这种一次性开关 | 第一个用例把它翻成 true,第二个用例以为已经初始化了,跳过初始化 |
你看,这些状态比"一个数据库"或"一个消息队列"分散得多、隐蔽得多。你 reset 的时候很容易只想着"清数据库",把这一堆模块级缓存忘个精光。而它们每一个都可能让"单独跑能过、连着跑就挂"。
所以正确的 reset 规范不是"记得清数据库和队列",而是:
- 先扫一遍:用
global语句和顶层的xxx = 赋值把项目里所有模块级可变状态揪出来,列成清单——别凭记忆,凭记忆一定会漏。 - 分类处理:单例缓存要能重建、数据缓存要能失效、运行时字典要清空、标志位要重置——不同类,reset 的方式不一样。
sleep是治标:等残留"自己消化"是不可靠的,正确做法是主动 reset 那个状态本身。- 隔离也是一种解法:如果某些状态实在太难 reset,可以让 eval 用 mock 绕开真实路径(不触发那些全局状态)。代价是这部分的 eval 测的是"逻辑"而不是"真实链路",要清楚这个取舍。
这件事教会我一件事:eval 框架本身也需要测试。 你的 eval 结果不可信的时候,第一步不是怀疑 Agent,而是怀疑 eval 框架自己——环境干不干净、状态清没清、用例之间有没有互相串。
这一章的工具:eval 设计检查清单
🔧 eval 设计检查清单
设计或评审一个 Agent eval 用例时,过一遍:
用例本身
- [ ] 预期行为写得足够具体吗?换一个人来看,能给出一致判断吗?
- [ ] 有没有写反例("不该怎样")?
- [ ] 用例来源标注了吗?(bug 驱动 / 场景驱动)
打分手段
- [ ] 能用代码量化的指标,是不是都用了代码断言?(别什么都扔给 LLM 裁判)
- [ ] 用了 LLM 裁判的地方,评判标准写得够清楚吗?
- [ ] 有没有定期人工抽查校准?
工程卫生
- [ ] 用例之间是隔离的吗?跑完一个用例,会不会污染下一个?
- [ ] reset 清干净了吗?把模块级可变状态(单例/缓存/字典/标志位)都扫一遍,别只想着数据库
- [ ] eval 结果可复现吗?(同一代码版本、同一用例,跑两次结果一致吗?)
eval-driven 实践
- [ ] 新功能开发前,先写了 eval 用例吗?
- [ ] eval 通过后,用例沉淀到回归基线了吗?
小结
这一章的核心就一句话:先写 eval,再写功能。
这是 Agent 时代的 TDD。传统 TDD 是"先写单元测试再写代码",EDD 是"先写 eval 用例再开发 Agent 功能"。区别在于,传统测试的断言是确定性的(assertEqual),Agent 的 eval 打分需要三种手段配合(代码断言判硬指标、LLM 裁判判软指标、人工抽查做校准)。
但这一章我还想强调一件容易被忽略的事:eval 不只是事后的验证工具,它也是事前的澄清工具。 写 eval 用例的过程,逼着你把"成功长什么样"想清楚——想不清楚,就写不出无歧义的用例。而一旦你写出来了,它既是开发的规格,又是回归的基线。我那个 20% → 100% 的经历就是证明:eval 帮我发现的不是"功能没做好",而是"我压根没想清楚这个场景该怎么做"。
如果你还在用"跑一遍看看"的方式验证 Agent,这一章就是写给你的。那个方式不仅不可靠,还会让你不知不觉地"对答案"——用主观判断代替客观标准。eval-driven 的本质,就是把"成功长什么样"从事后的主观判断,变成事前的客观规格。
不过,就算 eval 写得再好,还有一个更深的问题等着你:Agent 的输出天生是概率性的,同一个用例这次过、下次可能就不过。怎么跟这种不确定性共处,是下一章的主题。
下一章
第 7 章 · 非确定性测试 —— 同一个用例这次过下次不过,怎么办?