Skip to content

第 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 应该用 supersedes_id 建立替代关系,而不是新建一条矛盾的记忆。

开发这个功能之前,我先写了一个 eval 用例:

yaml
name: triggered-correction
description: 用户修正已有信息时,应创建替代关系而非重复记忆
source: 场景驱动
input:
  messages:
    - sender: "阿大"
      content: "小助手,周六去香山爬山"
      triggered: true
    - sender: "阿大"
      content: "改成周日吧"
      triggered: true
expect:
  memory_count: 2              # 应有 2 条记忆(原记忆 + 替代记忆)
  memory_supersedes: true      # 新记忆应指向旧记忆
  response_quality: true       # 回复应确认改期

这个用例就是我的"规格"——它说清楚了:输入两轮对话,预期产生 2 条记忆、有替代关系、回复确认改期。在写一行功能代码之前,"成功长什么样"已经定义好了。

然后我才开始开发。开发过程中反复跑这个 eval,直到它通过。通过之后,这个 eval 就永久留在用例库里——以后改任何代码,都要回归跑一遍,确保这个场景没退化。

这就是 eval-driven 的完整闭环:先写 eval 定义成功标准 → 开发直到通过 → eval 沉淀为回归基线

三种打分手段

前面说了,Agent 的 eval 不能用 assertEqual,那打分逻辑怎么写?我在项目里用了三种手段,对应不同的可信度需求。

手段一:代码断言(快速、客观、但只能判"硬指标")

最直接的——用代码检查可量化的结果。比如上面那个用例里的 memory_count: 2,就是查数据库里记忆条数对不对。memory_supersedes: true 就是检查新记忆的 supersedes_id 字段有没有指向旧记忆。

代码断言适合判"有没有发生某件事":记没记住、建没建项目、回没回复。它快、客观、可复现。但它判不了"回复得好不好"——你没法用代码断言判断一句话是否"自然""简洁""语气对"。

手段二:LLM 裁判(灵活、能判"软指标"、但自己也不稳定)

对于代码断言搞不定的"软指标"——比如回复质量、记忆内容是否准确——我用了一个 LLM 来当裁判。

具体做法是:把 Agent 的回复和预期标准发给另一个 LLM(我用的是和 Agent 本身不同的模型),让它判断这条回复达不达标。裁判的 prompt 要求输出严格 JSON:

json
{"pass": true, "reason": "回复确认了改期,语气自然,长度适中"}

LLM 裁判的好处是灵活——你可以在 prompt 里定义任何评判标准("语气是否友好""是否包含风险提示""是否过度承诺"),它都能判。这比代码断言强太多了。

但 LLM 裁判有个绕不开的问题:它自己也是概率性的,也会判错。 我后面会专门讲这个问题(第 7 章的主题),这里先说一个真实案例。我有一次跑 eval,同一个用例跑了两次,裁判第一次给的 reason 是"应判断为通过。但仔细看…所以通过",纠结了半天判了 PASS;第二次同样的回复,裁判直接判了 FAIL,理由还完全不同。裁判自己都在摇摆。

所以 LLM 裁判不是"自动评分机",更像是"一个不太稳定的同事帮你审稿"。有用,但不能完全信。

手段三:人工抽查(最准、最慢、作为校准基准)

第三种是人工看。你亲自读 Agent 的回复,判断对不对。这是最准的——人的判断力目前还是金标准。但它太慢了,不可能每次跑 eval 都人工看一遍。

我的用法是把人工抽查当校准基准——定期从 eval 结果里抽样一批,人工复核,看看代码断言和 LLM 裁判的判断跟人的判断吻合度有多高。如果吻合度下降,说明要么是 Agent 退化了,要么是裁判飘了,需要查。

三种手段怎么配合

判断需求

  ├── 能用代码量化吗?(记没记住、条数对不对、有没有替代关系)
  │     → 代码断言(快、准、每次都跑)

  ├── 需要判断"好不好"吗?(回复质量、语气、准确性)
  │     → LLM 裁判(灵活、每次都跑、但结果有波动)

  └── 裁判可信吗?退化了吗?
        → 人工抽查(定期校准、不每次跑)

这三种手段不是三选一,而是配合使用。一个 eval 用例里可以同时有代码断言和 LLM 裁判——硬指标用代码判,软指标用裁判判,定期人工校准两者。

eval 用例怎么设计

写了十几个 eval 用例之后,我总结出几条设计原则。

原则一:无歧义。 预期行为必须写得足够具体,让任何人(或任何裁判 LLM)看了都能给出一致的判断。"回复要好"是歧义的,"回复应确认改期并说明新日期"是无歧义的。

原则二:正反平衡。 不光写"应该怎样"的正向用例,还要写"不应该怎样"的反向用例。比如我有一个用例专门测"群聊里有人聊天气,Agent 不应该被触发"——这种"不该响应时没响应"的用例,和"该响应时响应了"一样重要。

原则三:标注来源。 每个用例写清楚它是从哪来的——是 bug 驱动的("因为踩了 X 这个坑,所以写了这个用例"),还是场景驱动的("补盲区,这类场景还没覆盖")。这个标注帮你判断用例的优先级和覆盖面。

原则四:eval-driven 优先。 正向用例尽量在开发前写(eval-driven),反向用例往往是踩了坑之后补的(bug 驱动)。两种都要,但如果只能选一种,选 eval-driven——因为事前定义比事后补漏更省事。

我踩过的坑:eval 队列污染

讲一个真实的工程坑,它差点让我的 eval 框架变得不可信。

我的 eval 是串行跑的——一个用例跑完,reset 数据库,跑下一个。但 reset 只清了数据库,没清消息队列。结果上一个用例的消息残留在队列里,下一个用例启动时,Agent 先把残留消息处理了一遍,污染了测试环境。

这个问题的表现特别诡异——用例单独跑能过,连着跑就挂。我查了很久才定位到是队列残留。

临时解法很土:每个用例之间加一个 sleep(1s),等队列消化完。但这只是缓解,不是根治。真正的解法应该是 reset 的时候把队列也清了,或者每个用例用完全隔离的环境。

这件事教会我一件事:eval 框架本身也需要测试。 你的 eval 结果不可信的时候,第一步不是怀疑 Agent,而是怀疑 eval 框架自己有没有 bug。

这一章的工具:eval 设计检查清单

🔧 eval 设计检查清单

设计或评审一个 Agent eval 用例时,过一遍:

用例本身

  • [ ] 预期行为写得足够具体吗?换一个人来看,能给出一致判断吗?
  • [ ] 有没有写反向用例("不该怎样")?
  • [ ] 用例来源标注了吗?(bug 驱动 / 场景驱动)

打分手段

  • [ ] 能用代码量化的指标,是不是都用了代码断言?(别什么都扔给 LLM 裁判)
  • [ ] 用了 LLM 裁判的地方,评判标准写得够清楚吗?
  • [ ] 有没有定期人工抽查校准?

工程卫生

  • [ ] 用例之间是隔离的吗?跑完一个用例,会不会污染下一个?
  • [ ] reset 清干净了吗?(数据库、队列、缓存、文件——全清了吗?)
  • [ ] eval 结果可复现吗?(同一代码版本、同一用例,跑两次结果一致吗?)

eval-driven 实践

  • [ ] 新功能开发前,先写了 eval 用例吗?
  • [ ] eval 通过后,用例沉淀到回归基线了吗?

小结

这一章的核心就一句话:先写 eval,再写功能

这是 Agent 时代的 TDD。传统 TDD 是"先写单元测试再写代码",EDD 是"先写 eval 用例再开发 Agent 功能"。区别在于,传统测试的断言是确定性的(assertEqual),Agent 的 eval 打分需要三种手段配合(代码断言判硬指标、LLM 裁判判软指标、人工抽查做校准)。

如果你还在用"跑一遍看看"的方式验证 Agent,这一章就是写给你的。那个方式不仅不可靠,还会让你不知不觉地"对答案"——用主观判断代替客观标准。eval-driven 的本质,就是把"成功长什么样"从事后的主观判断,变成事前的客观规格。

下一章我们讲一个更深层的问题:就算 eval 写得再好,Agent 的输出天生是概率性的,你怎么跟这种不确定性共处?

下一章

第 7 章 · 非确定性测试 —— 同一个用例这次过下次不过,怎么办?