《AI Agent 开发入门》16.2 评测集与打分

本节把 minagent 推进到评测层:定义 Case 与 Scorer(工具命中率、答案关键点、引用可回溯率、步数、成本),实现 evaluate 返回 Report,并用假模型脚本化六条用例的正确与错误行为。真跑评测打印每条得分与汇总,作为 Agent 的离线回归网。

16.2 评测集与打分

上一节的 pytest 用例能告诉你"这一条行为对不对",但回答不了"这一版整体是不是变好了"。改动往往只影响一部分用例:换了个提示词,三道题变好、一道题变坏——单条断言看不到这种净效果。要衡量整体,需要把若干条用例打包成一个评测集,跑一遍、给每一条打分、再汇总成一个报告。

本节把 minagent 推进到评测层:定义 Case 与 Scorer,实现 evaluate(cases, registry, scorer) -> Report,用假模型脚本化六条用例的正确与错误行为,真跑一遍打印每条得分与汇总。

16.2.1 离线评测集是 Agent 的回归网

评测集和上一节的 pytest 用例是同一件事的两种粒度:

维度pytest 用例评测集
回答的问题“这条行为对不对”“这一版整体好不好”
粒度单点断言一批用例 + 一个分数
输出红/绿每条得分 + 汇总指标
用途防回归判断改动是否值得合并

“离线"两个字是关键。评测集里的用例全部由假模型脚本驱动——每条用例预先写死"模型该走什么轨迹”。这样评测不联网、不花钱、可复现,可以像跑单元测试一样在 CI 里每次提交都跑。它的角色就是回归网:你改了一个提示词、换了工具描述、调了步数上限,跑一遍评测集,分数掉没掉、掉在哪几条,一目了然。

一个常见误区是把评测集做得很小、很"漂亮"——全是能过的用例。那样分数永远是 100%,什么也发现不了。好的评测集必须包含已知的失败样例,让分数有区分度。本节故意留了两条"注定失败"的用例,就是为了让报告里的数字动起来。

用例从哪来?三个来源,按性价比排序:

  1. 线上踩过的坑——每修一个真实 bug,就把它固化成一条用例。这是最值钱的来源,因为它代表真实分布。
  2. 边界情况——空输入、超长输入、工具报错、引用缺失。这些真实模型未必稳定复现,但一旦出事就是事故。
  3. 能力清单——你声称 Agent 会做的事(算数、检索、带引用回答),逐项各写一条。

这三类凑在一起,评测集才既有"守住底线"的回归用例,又有"覆盖能力"的功能用例。

16.2.2 Case:一条用例长什么样

一条用例 = 输入 + 假模型脚本 + 若干期望。用 dataclass 描述:

from dataclasses import dataclass, field
from typing import Any

@dataclass
class Case:
    id: str
    input: str
    script: list[Any]                       # 脚本化这条的正确/错误行为
    expected_tools: list[str] = field(default_factory=list)
    expected_answer_contains: list[str] = field(default_factory=list)
    expected_citations: list[str] = field(default_factory=list)
    system: str = "你是一个严谨的助手。"

六个字段各管一摊:

字段含义打分时怎么用
input喂给 Agent 的用户输入驱动 run()
script假模型脚本,写死这条的轨迹构造 FakeModel
expected_tools期望调用的工具集合算工具命中率
expected_answer_contains终答必须包含的关键点算答案命中率
expected_citations终答必须出现的引用标记算引用可回溯率
system系统提示传进 run()

注意 expected_tools 用的是集合语义而非序列——本节只问"该调的工具调没调",不问顺序。顺序断言上一节的 pytest 用例已经管了;评测集关心的是覆盖率,用集合更稳。

16.2.3 Scorer:把轨迹折成几个指标

一次运行产出一条完整轨迹,Scorer 负责把它折成几个 0~1 的指标再加权。指标选取围绕"Agent 到底做对了几件事"。先定义一条用例的打分结果:

@dataclass
class Score:
    case_id: str
    converged: bool          # 是否成功收敛
    tool_hit: float          # 工具命中率
    answer_rate: float       # 答案关键点命中率
    citation_rate: float     # 引用可回溯率
    steps: int               # 步数(过程指标)
    tokens: int              # 估算 token
    cost: float              # 估算成本
    score: float             # 加权综合分
    ok: bool                 # 是否严格全过

然后是打分器本体:

import json

PRICE_PER_1K = 0.05  # 元 / 千 token,估算口径


def estimate_tokens(messages) -> int:
    """无真实 usage 时的字符数估算:约 4 字符 1 token。"""
    chars = 0
    for m in messages:
        chars += len(m.content)
        for tc in m.tool_calls:
            chars += len(tc.name) + len(json.dumps(tc.arguments, ensure_ascii=False))
    return chars // 4


class Scorer:
    """把一次运行的轨迹折成一个 0~1 的分数,外加过程指标。"""

    def __init__(self, w_tool: float = 0.4, w_answer: float = 0.4, w_cite: float = 0.2) -> None:
        self.w_tool, self.w_answer, self.w_cite = w_tool, w_answer, w_cite

    def score(self, case, result, converged: bool):
        actual = [tc.name for m in result.messages for tc in m.tool_calls]
        hit = (len(set(case.expected_tools) & set(actual)) / len(case.expected_tools)
               if case.expected_tools else 1.0)
        ans = (sum(k in result.final for k in case.expected_answer_contains)
               / len(case.expected_answer_contains) if case.expected_answer_contains else 1.0)
        cite = (sum(c in result.final for c in case.expected_citations)
                / len(case.expected_citations) if case.expected_citations else 1.0)
        tokens = estimate_tokens(result.messages)
        cost = tokens / 1000 * PRICE_PER_1K
        score = self.w_tool * hit + self.w_answer * ans + self.w_cite * cite
        ok = converged and hit == 1.0 and ans == 1.0 and cite == 1.0
        return Score(case.id, converged, round(hit, 3), round(ans, 3), round(cite, 3),
                     len(result.steps), tokens, round(cost, 5), round(score, 3), ok)

五个指标分两类。质量指标(工具命中率、答案命中率、引用可回溯率)是 0~1 的比例,加权的 score 是它们的综合;过程指标(步数、成本)不参与得分,但要单独报告——因为它们揭示"用多少代价换来的分数"。一个 100% 成功率但平均步数是别人三倍的 Agent,未必是赢家。

五个指标先摆成一张表,方便对照:

指标计算方式类型谁关心
工具命中率期望工具 ∩ 实际工具 / 期望工具质量流程对不对
答案命中率命中的关键点 / 全部关键点质量结论对不对
引用可回溯率终答出现的引用 / 期望引用质量有没有编造来源
步数len(result.steps)过程绕没绕远路
成本估算 token × 单价过程值不值

三处设计值得说明:

  • 期望为空即满分:若某条用例没写 expected_tools,命中率记 1.0,而不是 0。语义是"这条不考工具",而不是"这条工具全错"。
  • ok 是严格全过:只有三项都拿满分、且成功收敛,才算这条通过。score 给的是"离满分差多远",ok 给的是"过没过线",两者互补。
  • 成本是估算:假模型的 usage 是空的,所以用"字符数 ÷ 4"粗略估算 token。这是一个稳定的近似——它不追求和真实计费一致,只追求"不同版本之间可比"。真实模型下这里应换成 usage 里的真实 token 数。

16.2.4 evaluate:跑一遍出报告

有了 Case 和 Scorer,把它们串起来:

def evaluate(cases, registry, scorer, model_factory=None, max_steps=8):
    factory = model_factory or (lambda c: FakeModel(c.script))
    scores = []
    for case in cases:
        try:
            result = run(factory(case), registry, case.input, system=case.system,
                         max_steps=max_steps)
            scores.append(scorer.score(case, result, converged=True))
        except RuntimeError:  # 未收敛(脚本耗尽 / 超步数)计为失败
            empty = RunResult(messages=[], steps=[], final="")
            scores.append(scorer.score(case, empty, converged=False))
    n = len(scores)
    return Report(
        scores=scores,
        pass_rate=sum(s.ok for s in scores) / n,
        avg_score=sum(s.score for s in scores) / n,
        avg_steps=sum(s.steps for s in scores) / n,
        avg_cost=sum(s.cost for s in scores) / n,
    )

两个细节是刻意的。其一,未收敛不抛出,而是计为失败——评测的价值就在于把"跑挂了"和"跑错了"都变成报告里的一行,而不是中断整轮评测。其二,model_factory 可替换——默认从 case.script 造 FakeModel,但下一节的对比实验会换一个工厂来改变模型行为,这个参数就是为它留的。

Report 汇总四个数字:通过率、平均得分、平均步数、平均成本。前两个是质量,后两个是代价。

16.2.5 六条用例的评测报告

现在构造一个小评测集。四条"正确行为"、两条"注定失败",故意让分数有区分度:

def build_cases(cite: bool = False) -> list[Case]:
    return [
        Case("calc_two_step", "计算 (2+3)*4",
             script=[[ToolCall("c1", "add", {"a": 2, "b": 3})],
                     [ToolCall("c2", "mul", {"a": 5, "b": 4})], "结果是 20。"],
             expected_tools=["add", "mul"], expected_answer_contains=["20"]),
        Case("calc_single", "计算 6*7",
             script=[[ToolCall("c1", "mul", {"a": 6, "b": 7})], "结果是 42。"],
             expected_tools=["mul"], expected_answer_contains=["42"]),
        Case("search_plain", "RAG 是什么", script=search_plain,
             expected_tools=["search"], expected_answer_contains=["检索增强生成"],
             expected_citations=["doc1"]),
        Case("search_then_read", "详细讲讲 RAG",
             script=[[ToolCall("c1", "search", {"q": "RAG"})],
                     [ToolCall("c2", "read", {"doc_id": "doc1"})],
                     "根据 [doc1],RAG 是检索增强生成。"],
             expected_tools=["search", "read"], expected_answer_contains=["检索增强生成"],
             expected_citations=["doc1"]),
        Case("wrong_tool", "计算 2+3",
             script=[[ToolCall("c1", "mul", {"a": 2, "b": 3})], "结果是 6。"],
             expected_tools=["add"], expected_answer_contains=["5"]),
        Case("search_no_cite", "讲讲 RAG", script=search_no_cite,
             expected_tools=["search"], expected_answer_contains=["检索增强生成"],
             expected_citations=["doc1"]),
    ]

两条失败用例各有讲究:wrong_tool 让模型调错工具(用 mul 代替 add),于是工具命中率和答案命中率双双归零;search_plain 与 search_no_cite 答案对但没给引用,引用可回溯率归零,得分 0.8 但 ok 为假。这正是"部分正确"该有的样子——不是非黑即白,而是差在哪一项。

跑一遍,打印报告:

$ python -m eval.evaluate
id                    工具    答案    引用    步数     成本(元)     得分  结果
---------------------------------------------------------------
calc_two_step       1.00  1.00  1.00     3   0.00085   1.00  PASS
calc_single         1.00  1.00  1.00     2   0.00055   1.00  PASS
search_plain        1.00  1.00  0.00     2   0.00080   0.80  FAIL
search_then_read    1.00  1.00  1.00     3   0.00145   1.00  PASS
wrong_tool          0.00  0.00  1.00     2   0.00050   0.20  FAIL
search_no_cite      1.00  1.00  0.00     2   0.00080   0.80  FAIL
---------------------------------------------------------------
汇总:通过 3/6(50.0%)|平均得分 0.80|平均步数 2.3|平均成本 0.00082 元

读这张表要看三件事。先看汇总:通过率 50%、平均得分 0.80——平均得分高但通过率低,说明大多数用例只差临门一脚(两条差在引用)。再看 FAIL 行:wrong_tool 得分 0.20(工具和答案全错),两条 search 用例得分 0.80(只差引用),失败原因清清楚楚。最后看代价:search_then_read 成本最高(0.00145),因为它多走一步 read——多出来的成本买到了引用可回溯,这笔账值不值,要靠下一节的对比实验来算。

这张表就是回归网的读数:以后任何改动,只要重跑一遍,看通过率和平均得分是涨是跌、哪几条翻了颜色,就比"感觉好像变好了"靠谱得多。

16.2.6 本节小结

  • 评测集是 pytest 用例的批量化:回答"整体好不好",而不是"单条对不对";它是 Agent 的回归网。
  • Case = 输入 + 假模型脚本 + 期望;Scorer 把轨迹折成质量指标与过程指标。
  • 质量指标(工具、答案、引用)参与加权得分,过程指标(步数、成本)单独报告——因为分数要看代价。
  • evaluate 把未收敛计为失败而非抛出,让"跑挂了"也变成报告里的一行。
  • 评测集必须包含已知失败样例,否则分数没有区分度,什么也发现不了。
  • 报告要读三处:汇总、FAIL 行的失败原因、以及每条的成本代价。

16.2.7 真实模型下会怎样

真实模型下,script 字段会换成录制的真实轨迹——跑一次真实调用,把每轮的模型响应存成 JSON 夹具(附录 B 讲了加载器),评测时离线重放。这样评测集依然不联网、可复现,只是用例的来源从"手写脚本"变成"录制的真实行为"。

两个真实场景下的差异要提前想好。其一,真实模型会给出措辞不同的答案,expected_answer_contains 必须选稳定的关键点(数字、专有名词),而不是整句话——否则断言必然误报。其二,引用可回溯率在真实 RAG 里更重要:模型会编造引用(幻觉),所以"终答里的每个 [docN] 是否真的来自检索结果"要单独校验,这正是引用指标存在的意义。

还有一点:真实评测集的规模要足够大才有统计意义。六条用例只能发现"大方向对不对",要区分两个相差 2% 的版本,得几十上百条。这就是为什么工业界的评测集动辄几百条,且会持续扩充——评测集本身就是一项长期资产。

一句话:离线评测集回答"这一版整体比上一版好还是差",它是 Agent 的回归网。下一节 16.3 对比实验 会用它来回答更尖锐的问题——某次改动到底值不值得合并。想回看单条断言怎么写,翻 16.1 确定性测试 。

阅读导航:上一节:16.1 确定性测试 · 下一节:16.3 对比实验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距