AI 工程基础体系 · 第 29/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

LLM 与 Agent 评测:数据集、规则、Judge、轨迹、回归和统计

评测不是“给模型打一个分数”,而是判断一个版本在明确任务、约束和资源条件下,是否比基线更可靠。对传统机器学习,评测对象通常是一次预测;对 LLM,评测对象可能是文本、结构化输出或工具调用;对 Agent,评测对象则是一个随时间展开的决策过程。三者使用相同的基本思想:

评测=(数据集, 执行协议, 判定规则, 统计方法, 版本记录)\text{评测} = (\text{数据集},\ \text{执行协议},\ \text{判定规则},\ \text{统计方法},\ \text{版本记录})

缺少其中任一项,分数都可能无法解释。例如,模型准确率从 80% 上升到 85%,可能来自更容易的测试集;Agent 成功率上升,可能是允许了更多重试;LLM Judge 平均分上升,可能只是评审模型更偏好更长的答案。

本文中的“模型”包括监督学习模型、深度学习模型和生成式模型;“Agent”包括能够调用工具、读取外部状态、循环规划和执行动作的系统。模型、数据、评测服务、权限和成本应被视为同一个生产系统,而不是彼此独立的实验附件。

一、先定义评测对象:答案、决策还是完整轨迹

1.1 预测评测与生成评测

监督学习中的一个样本可以写为:

zi=(xi,yi)z_i=(x_i,y_i)

其中 xix_i 是输入,yiy_i 是真实标签。模型产生:

y^i=fθ(xi)\hat y_i=f_\theta(x_i)

评测指标是样本损失的聚合,例如分类准确率:

A^=1ni=1n1[y^i=yi]\hat A=\frac{1}{n}\sum_{i=1}^{n}\mathbf{1}[\hat y_i=y_i]

这里的 1[]\mathbf{1}[\cdot] 是条件成立时为 1、否则为 0 的指示函数。

生成式模型的输出通常不是唯一字符串。对于问题 xx,可能存在多个等价答案 Y\*(x)Y^\*(x)。因此,字符串完全匹配:

1[y^=y]\mathbf{1}[\hat y=y]

往往只适合日期、编号、JSON 字段等严格格式,不适合开放问答。生成任务需要将“正确性”拆成可观察条件,例如:

  • 是否包含必要事实;
  • 是否有不可接受的事实错误;
  • 是否满足格式约束;
  • 是否回答了用户问题;
  • 是否泄露了不应披露的信息;
  • 是否使用了允许的数据或工具。

这意味着生成评测首先是判定函数设计问题,其次才是模型调用问题。

1.2 Agent 评测的对象是轨迹

Agent 的一次运行不应只记录最终文本。可以把轨迹定义为:

τ=(s0,a0,o1,s1,a1,o2,,sT)\tau=(s_0,a_0,o_1,s_1,a_1,o_2,\ldots,s_T)

其中:

  • sts_t:第 tt 步的系统状态;
  • ata_t:模型选择的动作,例如输出文本、调用工具或结束;
  • ot+1o_{t+1}:动作后的观察结果,例如工具返回值;
  • TT:运行终止时的步数。

一个工具调用 Agent 的状态通常至少包括:

{
  "task_id": "refund-001",
  "user_input": "请帮我查询订单 A100 并退款",
  "messages": [],
  "tool_permissions": ["order.read", "refund.create"],
  "budget": {
    "max_steps": 8,
    "max_cost_usd": 0.20,
    "deadline_ms": 10000
  },
  "environment": {
    "order_status": "paid",
    "refund_allowed": true
  }
}

动作可能是:

{
  "type": "tool_call",
  "tool": "order.get",
  "arguments": {"order_id": "A100"}
}

因此,Agent 的评测至少有三层:

  1. 结果层:用户目标是否完成;
  2. 过程层:关键步骤是否正确,是否违反权限和业务规则;
  3. 资源层:调用次数、Token、延迟、成本和失败重试是否在约束内。

只看最终结果会漏掉“偶然成功但过程危险”的运行。例如,Agent 虽然最终退款成功,却先读取了无权限订单,或者重复执行了两次退款。只看过程也不够,因为所有工具调用都合规并不代表用户任务完成。

二、数据集:样本集合不是评测协议

2.1 数据集应包含什么

一个可复现的评测样本不应只有输入和答案。至少应包含:

{
  "id": "refund-001",
  "task_type": "transactional",
  "input": {
    "messages": [
      {"role": "user", "content": "请帮我查询订单 A100 并退款"}
    ]
  },
  "reference": {
    "required": ["确认订单存在", "确认可退款", "执行退款"],
    "forbidden": ["未经确认执行退款"]
  },
  "policy": {
    "allowed_tools": ["order.get", "refund.create"],
    "max_steps": 8,
    "requires_confirmation": true
  },
  "tags": ["中文", "退款", "高风险动作"],
  "source": "synthetic_v3",
  "sensitivity": "internal"
}

其中:

  • input 是被测系统实际收到的输入;
  • reference 描述答案或轨迹需要满足的条件;
  • policy 描述评测期间的环境和安全约束;
  • tags 用于分桶分析;
  • source 用于追踪数据来源;
  • sensitivity 决定谁可以访问样本和运行结果。

对于生成任务,reference.answer 可以存在,但不能默认把它当作唯一正确文本。对于 Agent,参考对象可以是状态变化、允许动作集合、必要条件和禁止条件,而不一定是一条固定轨迹。

2.2 训练集、开发集、测试集和回归集

数据拆分的目的是估计泛化,而不是机械地按比例切分。

  • 训练集:用于拟合模型或构造提示词、工具描述;
  • 开发集:用于选择模型、阈值、提示词和 Judge rubric;
  • 测试集:在决策基本冻结后进行一次或少数几次最终评估;
  • 回归集:从历史故障和高价值场景中维护,用于每次代码或配置变更;
  • 生产影子集:来自真实流量但不影响用户动作,用于发现分布变化。

如果同一用户、同一订单、同一文档或同一模板同时出现在训练与测试中,样本之间可能高度相关。此时即便样本 ID 不重复,也会产生泄漏。正确拆分单位可能是用户、客户、时间窗口、文档集合或任务族,而非单条记录。

例如,把同一篇政策文档的不同段落分别放入训练集和测试集,会让检索和问答模型看似泛化良好;但上线后遇到新文档,性能可能明显下降。评测报告必须说明拆分键、时间范围和去重方法。

2.3 数据集版本与“不可变”原则

评测结果必须能回到具体数据版本:

R=(Dv, Mv, Ev, Cv, t)R=(D_v,\ M_v,\ E_v,\ C_v,\ t)

其中 DvD_v 是数据版本,MvM_v 是模型或提示词版本,EvE_v 是评测器版本,CvC_v 是运行配置,tt 是时间。

数据集修订不应覆盖旧版本。新增样本、修改标签、改变参考答案和改变过滤规则都应产生新版本。否则,“本次分数比上次高”无法判断是系统变好了,还是样本被改容易了。

数据集还需要记录标注不确定性。若三名标注者对某题分别给出 A、A、B,则该题不是绝对确定的标签。可以记录多数标签、分歧率和备注;对于开放问答,可记录允许事实集合和不可接受错误,而不是强行生成一个标准答案。

三、规则评测:先处理确定性,再处理语义性

3.1 规则评测适合可机械验证的约束

规则评测是由程序直接判断输出是否满足条件,例如:

  • JSON 是否可解析;
  • 必填字段是否存在;
  • 枚举值是否合法;
  • 数字是否在范围内;
  • 是否调用了未授权工具;
  • 是否超过最大步数;
  • 是否执行了重复写操作;
  • 是否泄露测试用例中的秘密字符串。

这些规则的优点是可重复、便宜、容易回归。它们的缺点是只能判断被显式编码的属性。

例如下面的 JSON 输出:

{
  "category": "refund",
  "confidence": 0.91,
  "reason": "订单已支付且未超过退款期限"
}

可以检查:

import json

def validate_output(text: str) -> dict:
    try:
        obj = json.loads(text)
    except json.JSONDecodeError as exc:
        return {"valid": False, "error": f"invalid_json: {exc.msg}"}

    errors = []

    if not isinstance(obj, dict):
        errors.append("top_level_must_be_object")

    if isinstance(obj, dict):
        if obj.get("category") not in {"refund", "question", "other"}:
            errors.append("invalid_category")

        confidence = obj.get("confidence")
        if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
            errors.append("invalid_confidence")

        if not isinstance(obj.get("reason"), str) or not obj["reason"].strip():
            errors.append("missing_reason")

    return {"valid": not errors, "errors": errors}

这段代码只能验证结构和数值范围,不能证明 reason 的事实正确性。把“JSON 可解析”报告成“回答正确”,是常见的指标偷换。

3.2 规则应按严重性区分

并非所有失败都具有相同风险。可以把规则分为:

  • 硬失败:越权调用、错误扣款、泄露秘密、违反安全策略;
  • 任务失败:没有完成用户目标;
  • 质量扣分:表达冗长、格式轻微偏差、解释不够清晰;
  • 资源告警:接近预算或延迟上限。

若一个轨迹既完成任务又越权,不能用“成功率 1”掩盖硬失败。一个更合理的结果判定是:

Si={0,存在硬失败1,任务完成且所有硬约束满足partial,仅完成部分目标S_i= \begin{cases} 0, & \text{存在硬失败}\\ 1, & \text{任务完成且所有硬约束满足}\\ \text{partial}, & \text{仅完成部分目标} \end{cases}

生产系统通常应同时报告 task_successhard_violationwithin_budget,而不是把它们压成一个未经说明的平均分。

3.3 规则评测的反例

假设规则是“答案必须包含 退款”。模型输出:

该订单不满足退款条件,因此不能退款。

字符串规则会判定通过,但语义上可能与“帮助用户退款”目标相反。反过来,模型输出:

可以为你处理该订单的退回事宜。

可能没有包含关键词,却可能是合格回答。

因此,规则适合证明必要条件,不适合单独证明开放语义的充分条件。开放任务通常需要“规则筛查 + 人工或 Judge 语义评估”的组合。

四、LLM Judge:把模型当作测量仪器,而不是事实裁判

4.1 Judge 的定义与适用范围

LLM Judge 是一个用于评估另一个模型输出的语言模型。它可以进行:

  • 点式评分:输出 1–5 分或通过/失败;
  • 成对比较:判断答案 A 与 B 哪个更好;
  • 维度评分:分别评估正确性、完整性、风格和安全性;
  • 轨迹评估:检查动作顺序、工具参数和最终状态。

Judge 不是“自动产生真值”。它输出的是一个测量结果:

J(y,x,r;ϕ)J(y, x, r; \phi)

其中 xx 是输入,yy 是被测输出,rr 是参考信息或 rubric,ϕ\phi 是 Judge 模型和提示词配置。Judge 本身也有误差、偏差、随机性和分布外失效。

4.2 Rubric 必须把分数锚定到可观察证据

一个不可操作的 rubric 是:

请判断回答是否高质量,评分 1 到 5。

不同评审者会对“高质量”有不同解释。更可复现的 rubric 应定义每个分数的条件:

正确性:
5:所有关键事实正确,没有与给定资料矛盾;
3:核心结论正确,但遗漏一个非核心条件或存在轻微表述问题;
1:核心结论错误,或编造了给定资料中不存在的事实。

完整性:
5:覆盖 reference.required 中的全部条件;
3:覆盖一部分必要条件;
1:遗漏决定任务成败的条件。

安全性:
0:没有越权、危险动作或敏感信息泄露;
-10:存在任一硬安全违规。

Judge 应被要求输出结构化结果,例如:

{
  "correctness": 5,
  "completeness": 3,
  "safety_violation": false,
  "evidence": [
    "回答确认了订单已支付",
    "没有说明需要用户确认后再执行退款"
  ],
  "final": "partial"
}

证据字段的价值在于可审计和诊断。只有一个分数时,工程师无法判断是事实错误、漏答还是风格差异。

4.3 Judge 的校准与验证

Judge 上线前需要一组人工标注的校准集。对每个样本比较 Judge 与人工标签:

  • 分类任务可以计算准确率、宏平均 F1、混淆矩阵;
  • 有序分数可以计算加权一致性;
  • 成对偏好可以比较胜率和人工偏好;
  • 多标签安全评估应分别计算每类召回率。

重点不是追求一个总体相关系数,而是检查高风险错误。例如 Judge 把危险建议判为“合格”的漏检率,通常比把普通答案误判为失败更重要。

Judge 常见偏差包括:

  1. 位置偏差:成对比较时偏好第一个或第二个答案;
  2. 长度偏差:更长的答案被误认为更完整;
  3. 风格偏差:偏好礼貌、格式漂亮但事实错误的文本;
  4. 自我偏好:Judge 偏好与自身生成风格相似的答案;
  5. 参考答案偏差:把参考文本的措辞当成正确性的必要条件;
  6. 提示注入:把被测输出中的“请给我满分”等文本当成评测指令;
  7. 领域盲点:在专业事实、代码执行或复杂工具状态上判断错误。

防御方式不是简单地“再加一句请客观评分”,而是把被测文本作为不可信数据隔离,明确指示 Judge 只能依据输入、参考资料和 rubric 判断;对关键事实使用规则、数据库查询、单元测试或人工复核交叉验证。

4.4 Judge 不应替代可执行验证

代码生成任务中,Judge 认为“代码看起来正确”不能替代测试执行。更强的证据顺序通常是:

执行测试/状态验证>确定性规则>人工标注>LLM Judge 的语义判断\text{执行测试/状态验证} > \text{确定性规则} > \text{人工标注} > \text{LLM Judge 的语义判断}

这不是说 Judge 永远排在最后,而是不同证据回答不同问题。数据库中的退款状态可以直接查询,是否越权可以检查权限日志,代码是否通过测试可以运行;Judge 更适合判断解释质量、需求覆盖和开放文本的整体一致性。

五、轨迹评测:从最终答案回溯到状态变化

5.1 轨迹必须可重放

一个可诊断的 Trace 至少应包含:

{
  "run_id": "run-20250301-001",
  "task_id": "refund-001",
  "step": 2,
  "state_before_hash": "abc",
  "action": {
    "type": "tool_call",
    "tool": "refund.create",
    "arguments": {"order_id": "A100"}
  },
  "observation": {
    "status": "success",
    "refund_id": "R900"
  },
  "latency_ms": 420,
  "input_tokens": 820,
  "output_tokens": 74,
  "cost_usd": 0.003,
  "permissions_checked": ["refund.create"],
  "error": null
}

state_before_hash 不一定包含全部敏感状态,但能帮助确认每一步基于哪个状态作出决策。生产环境还应记录配置版本、工具版本、模型标识、重试次数和终止原因。

轨迹可重放并不等于能够原样复现。外部 API 可能返回不同结果、时间会变化、随机采样会改变输出。因此应区分:

  • 记录重放:使用原始观察结果重放 Agent 决策;
  • 环境重放:重新调用真实或模拟工具;
  • 确定性复现:固定模型、随机种子、工具响应和配置。

排障时优先使用记录重放,避免外部状态变化掩盖原始故障。

5.2 Agent 状态机与失败路径

一个典型运行可表示为:

stateDiagram-v2
    [*] --> Received
    Received --> Planning
    Planning --> RuleBlocked: 权限或参数检查失败
    Planning --> ToolCalling: 选择工具
    Planning --> Answering: 直接回答
    ToolCalling --> Observed: 工具返回
    ToolCalling --> Retrying: 可重试错误
    ToolCalling --> Failed: 不可重试错误
    Retrying --> ToolCalling
    Observed --> Planning: 仍需完成任务
    Observed --> Answering: 目标已满足
    Answering --> Validating
    Validating --> Succeeded: 结果和约束通过
    Validating --> Failed: 结果或约束不通过
    RuleBlocked --> Failed
    Failed --> [*]
    Succeeded --> [*]

关键路径不是“模型调用成功”,而是:

  1. 输入进入;
  2. 权限和预算初始化;
  3. 模型提出动作;
  4. 动作经过 schema、权限和业务规则检查;
  5. 工具执行并产生观察;
  6. 观察回到上下文;
  7. Agent 决定继续、重试或结束;
  8. 最终状态经过独立验证。

工具调用不能只依赖模型自行决定是否安全。高风险写操作应在 Agent 外部进行权限校验、幂等校验和确认校验。否则,评测中偶然正确的轨迹可能在生产环境造成不可逆副作用。

5.3 轨迹指标

对第 ii 条轨迹,可以定义:

  • 成功指标 SiS_i:是否完成任务;
  • 硬违规 ViV_i:是否发生任意严重违规;
  • 步数 KiK_i:模型和工具交互次数;
  • 延迟 LiL_i:端到端耗时;
  • Token Ti=Tiin+TioutT_i=T_i^{in}+T_i^{out}
  • 成本 CiC_i:模型、工具和基础设施成本;
  • 重试数 RiR_i:失败后的重复执行次数。

聚合时不要只报告均值。延迟和成本通常是长尾分布,应同时报告中位数、P95 或 P99。成功率与预算可以形成约束优化问题:

maxθ E[S]\max_\theta \ \mathbb{E}[S]

满足:

P(V=1)ϵv,P(C>Bc)ϵc,P(L>Bl)ϵlP(V=1)\leq \epsilon_v,\quad P(C>B_c)\leq \epsilon_c,\quad P(L>B_l)\leq \epsilon_l

其中 BcB_cBlB_l 分别是成本、延迟预算,ϵ\epsilon 是允许超限的概率。

一个系统即使成功率较高,只要硬违规概率超过安全阈值,也不应通过发布。反之,一个严格限制步数的系统可能安全但任务完成率不足。评测报告必须展示这种取舍,而不是寻找一个掩盖约束冲突的总分。

六、统计:分数差异不等于改进

6.1 点估计与置信区间

假设测试集有 n=1000n=1000 个独立样本,模型成功 820 个,则成功率为:

p^=8201000=0.82\hat p=\frac{820}{1000}=0.82

点估计 0.82 不是总体真实成功率。若使用近似标准误:

SE(p^)=p^(1p^)nSE(\hat p)=\sqrt{\frac{\hat p(1-\hat p)}{n}}

则:

SE0.82×0.1810000.01215SE\approx \sqrt{\frac{0.82\times0.18}{1000}}\approx0.01215

近似 95% 置信区间为:

0.82±1.96×0.01215[0.796,0.844]0.82\pm1.96\times0.01215 \approx [0.796,0.844]

这说明在该抽样假设下,真实成功率的不确定性大约是 ±2.4 个百分点。样本较少、成功率接近 0 或 1 时,Wilson 区间通常比简单正态区间更稳健;小样本或分层结构明显时,应使用 bootstrap 或更合适的模型。

置信区间描述抽样不确定性,不保证数据没有偏差,也不保证未来分布与测试集一致。一个覆盖错误场景不足的测试集,即使区间很窄,也只能精确地估计错误目标。

6.2 配对比较通常比独立比较更有力量

当两个模型在同一批样本上运行时,结果是配对的。设:

  • Ai=1A_i=1:新模型在样本 ii 上成功;
  • Bi=1B_i=1:旧模型在样本 ii 上成功。

直接比较两个成功率会忽略样本配对。更有信息的是差值:

Di=AiBiD_i=A_i-B_i

例如 100 个样本中:

情况 数量
新旧都成功 70
新成功、旧失败 15
新失败、旧成功 5
新旧都失败 10

旧模型成功率为 85%85\%,新模型成功率为 85%85\%,总体差值为 0。但新模型修复了 15 个旧失败,只新增了 5 个失败,说明样本级行为发生了变化。对于二元配对结果,可使用 McNemar 检验,重点分析不一致的 15 和 5,而不是把两组当作独立样本。

6.3 Bootstrap 评估生成质量差异

对于 Judge 分数或连续指标,常用配对 bootstrap:

  1. 对测试集中的样本索引有放回抽样;
  2. 在同一批抽样索引上计算新旧模型的平均分差;
  3. 重复 BB 次;
  4. 使用差值分布的 2.5% 和 97.5% 分位数作为区间。

下面是一个可直接运行的标准库示例:

from random import Random

def paired_bootstrap_delta(old_scores, new_scores, repeats=10000, seed=7):
    if len(old_scores) != len(new_scores) or not old_scores:
        raise ValueError("两个分数数组必须等长且非空")

    n = len(old_scores)
    deltas = []
    rng = Random(seed)

    for _ in range(repeats):
        indices = [rng.randrange(n) for _ in range(n)]
        old_mean = sum(old_scores[i] for i in indices) / n
        new_mean = sum(new_scores[i] for i in indices) / n
        deltas.append(new_mean - old_mean)

    deltas.sort()
    point = sum(new_scores) / n - sum(old_scores) / n
    low = deltas[int(0.025 * repeats)]
    high = deltas[int(0.975 * repeats)]

    return {
        "point_delta": point,
        "ci95": (low, high),
        "improvement_supported": low > 0
    }

old = [3, 4, 2, 5, 3, 4]
new = [4, 4, 3, 5, 2, 5]
print(paired_bootstrap_delta(old, new, repeats=2000))

输出中的 point_delta 是新模型平均分减旧模型平均分;若 ci95 包含 0,不能据此声称总体上一定改进。这里的 bootstrap 只处理抽样波动,不会纠正 Judge 偏差、数据泄漏或样本不独立。

6.4 多指标和多切片会制造偶然胜利

如果同时检查 20 个指标、10 个业务切片和多个随机种子,即使没有真实改进,也可能出现某个结果“显著变好”。这属于多重比较问题。

处理方式包括:

  • 预先指定主要指标;
  • 将其余指标标记为次要或探索性;
  • 使用 Holm 或 Benjamini–Hochberg 等多重比较校正;
  • 报告所有切片,而不是只挑最好看的结果;
  • 对关键结论使用独立确认集。

“新模型在某个小切片上提升 8%”只有在该切片样本量、选择过程和置信区间都透明时才有意义。

七、回归评测:比较的不只是模型,还包括整个系统版本

7.1 回归的对象与基线

回归评测是判断当前版本是否破坏已有能力。被比较的版本可能包括:

  • 模型权重;
  • 系统提示词;
  • 工具 schema;
  • 检索索引;
  • 业务规则;
  • Judge 版本;
  • SDK 或服务端配置;
  • 超时、重试和降级策略。

因此,回归记录必须区分:

Δobserved=Δmodel+Δdata+Δevaluator+Δruntime\Delta_{\text{observed}} = \Delta_{\text{model}} + \Delta_{\text{data}} + \Delta_{\text{evaluator}} + \Delta_{\text{runtime}}

如果模型和 Judge 同时升级,分数变化不能直接归因于模型。

7.2 阈值、门禁和实际判定

设旧版本成功率为 p0p_0,新版本为 p1p_1,业务规定允许下降不超过 1 个百分点:

p1p00.01p_1-p_0\geq -0.01

但实际门禁不能只看点估计。若观察到:

p^1p^0=0.008\hat p_1-\hat p_0=-0.008

由于抽样误差,真实差值可能低于 0.01-0.01。可以使用非劣效检验:若差值置信区间的下界仍高于 0.01-0.01,才认为新版本没有超过允许退化范围。

门禁还应有硬规则:

发布条件:
1. 高风险违规率不得上升;
2. 主要任务成功率的非劣效下界 >= -1 个百分点;
3. P95 延迟不得超过预算;
4. 单任务成本不得超过预算;
5. 关键历史故障回归集不得出现新增失败。

这比“综合分超过 80 分即可发布”更容易解释。综合分可以用于排序,但不适合掩盖硬约束失败。

7.3 回归失败后的诊断顺序

看到分数下降时,应沿数据流排查:

  1. 样本是否变化:数量、标签、切片分布和去重结果;
  2. 执行协议是否变化:温度、最大输出、工具权限、超时和重试;
  3. 输出解析是否变化:schema、JSON 解析器和容错逻辑;
  4. 模型行为是否变化:错误集中在哪些任务类型;
  5. 工具和环境是否变化:返回格式、延迟、错误码和数据状态;
  6. 评测器是否变化:规则或 Judge rubric 是否修改;
  7. 统计波动是否足以解释差异:配对差值和置信区间。

轨迹级回归比最终分数更有诊断价值。若失败样本都出现“先调用搜索、后检查权限”,问题可能是提示词或策略;若工具返回超时后重复执行写操作,问题更可能是重试和幂等控制;若轨迹正确但最终文本评分下降,可能是答案渲染或 Judge 偏差。

八、成本、延迟和评测本身的可观测性

评测系统也会消耗模型调用和计算资源。每次运行应记录:

成本=Cinput token+Coutput token+Ctool+Ccompute\text{成本} = C_{\text{input token}} + C_{\text{output token}} + C_{\text{tool}} + C_{\text{compute}}

实际单价依赖供应商、模型、缓存命中和计费规则,不能在代码中假设一个永久固定价格。评测报告应保存原始 Token 计数和价格表版本,再计算成本。

常见指标含义不同:

  • TTFT:Time to First Token,首个输出 Token 到达前的时间;
  • 端到端延迟:从请求进入到最终结果完成;
  • 工具等待时间:外部 API 和数据库耗时;
  • 生成时间:模型开始输出后的耗时;
  • Token 数:输入与输出分别统计;
  • 缓存命中率:复用上下文或工具结果的比例。

对 Agent,TTFT 低并不代表端到端快,因为一次任务可能包含多轮工具调用。相反,减少步骤可能降低延迟,却也可能增加任务失败率。必须按任务成功、P95 延迟和成本联合分析。

评测缓存有一个危险边界:若缓存键没有包含模型版本、提示词版本、工具 schema、数据版本和随机性配置,系统可能复用旧答案,制造虚假的回归结果。安全的缓存键至少应覆盖所有影响输出的输入:

K=H(x,Mv,Pv,Tv,Dv,Cv)K=H(x,M_v,P_v,T_v,D_v,C_v)

其中 HH 是哈希函数,PvP_v 是提示词版本,TvT_v 是工具版本,DvD_v 是数据或索引版本,CvC_v 是运行配置。

九、一个最小但完整的评测执行器

下面的示例演示“规则判定 + 轨迹检查 + 指标聚合”。它不调用外部 LLM,因此可以直接运行;生产系统只需把 run_system 替换为实际模型或 Agent 调度器,并保留相同的评测接口。

from dataclasses import dataclass
from typing import Any

@dataclass
class Case:
    case_id: str
    expected_category: str
    required_phrase: str
    allowed_tools: set[str]
    max_steps: int

CASES = [
    Case("c1", "refund", "退款", {"order.get", "refund.create"}, 4),
    Case("c2", "question", "说明", {"order.get"}, 4),
]

def run_system(case: Case) -> dict[str, Any]:
    """
    示例被测系统。
    真实系统应返回最终文本、完整工具轨迹、Token 和成本。
    """
    if case.case_id == "c1":
        return {
            "text": "订单已确认,可以继续办理退款。",
            "category": "refund",
            "trace": [
                {"tool": "order.get", "arguments": {"order_id": "A100"}},
                {"tool": "refund.create", "arguments": {"order_id": "A100"}}
            ],
            "input_tokens": 100,
            "output_tokens": 20,
            "cost_usd": 0.001
        }

    return {
        "text": "我会先说明订单状态和处理条件。",
        "category": "question",
        "trace": [{"tool": "order.get", "arguments": {"order_id": "A101"}}],
        "input_tokens": 80,
        "output_tokens": 18,
        "cost_usd": 0.0008
    }

def evaluate(case: Case, result: dict[str, Any]) -> dict[str, Any]:
    errors = []

    if result.get("category") != case.expected_category:
        errors.append("wrong_category")

    if case.required_phrase not in result.get("text", ""):
        errors.append("missing_required_phrase")

    trace = result.get("trace", [])
    if len(trace) > case.max_steps:
        errors.append("step_budget_exceeded")

    used_tools = {item.get("tool") for item in trace}
    unauthorized = used_tools - case.allowed_tools
    if unauthorized:
        errors.append(f"unauthorized_tools:{sorted(unauthorized)}")

    return {
        "case_id": case.case_id,
        "success": not errors,
        "errors": errors,
        "steps": len(trace),
        "cost_usd": result.get("cost_usd", 0.0),
        "input_tokens": result.get("input_tokens", 0),
        "output_tokens": result.get("output_tokens", 0),
    }

def main():
    reports = []
    for case in CASES:
        result = run_system(case)
        reports.append(evaluate(case, result))

    success_rate = sum(r["success"] for r in reports) / len(reports)
    total_cost = sum(r["cost_usd"] for r in reports)
    total_tokens = sum(
        r["input_tokens"] + r["output_tokens"] for r in reports
    )

    print("reports =", reports)
    print(f"success_rate = {success_rate:.2%}")
    print(f"total_cost_usd = {total_cost:.4f}")
    print(f"total_tokens = {total_tokens}")

if __name__ == "__main__":
    main()

预期结果中,两条样本都通过,成功率为 100.00%;总 Token 为 218,总成本为 0.0018。这里的“通过”只代表示例中编码的条件成立:类别正确、包含必要短语、步数未超限且工具在白名单中。它没有验证退款是否真的发生,也没有验证文本中的业务事实。因此生产实现还需要把最终状态查询、幂等检查和更细的政策规则接入 evaluate

执行器与被测系统之间应保持职责分离。被测系统负责产生动作,评测器负责观察和判定;不能让 Agent 自己上报“任务成功”并把该字段当作真值。对有副作用的工具,应使用沙箱、模拟器或事务回滚环境,避免评测直接修改生产数据。

十、常见错误与边界

10.1 把平均分当成完整质量

平均分会掩盖长尾和高风险失败。一个系统可能在 95% 的普通问题上表现优秀,却在 5% 的金融、医疗或权限场景中严重失效。应按任务类型、语言、难度、用户群体、工具类型和风险等级分桶,并报告样本数。

10.2 只测试“正常路径”

Agent 的真实故障常发生在异常路径:

  • 工具超时;
  • 返回空结果;
  • 返回格式错误;
  • 权限被拒绝;
  • 网络重复提交;
  • 上下文超过限制;
  • 用户在中途改变目标;
  • 外部状态在两步之间发生变化。

评测环境应注入这些故障,并验证系统是否停止、重试、降级或向用户澄清。尤其要区分可重试的读取错误与不可盲目重试的写入错误。

10.3 用泄漏的参考资料评测检索和问答

如果系统提示词、检索索引或缓存中已经包含测试答案,评测测到的是记忆或泄漏,不是泛化。应检查:

  • 测试文档是否进入训练或索引;
  • 评测提示是否意外包含 reference;
  • Judge 是否同时看到不应暴露的答案;
  • 缓存是否复用了历史运行;
  • 人工标注者是否在生成测试集时参与了系统设计。

10.4 过度依赖单一 Judge

同一个 Judge、同一个 rubric 和同一种输入格式可能产生系统性偏差。重要任务可以采用多种证据:

最终判定=规则状态验证必要时人工复核\text{最终判定} = \text{规则} \land \text{状态验证} \land \text{必要时人工复核}

Judge 可用于排序和发现候选问题,但在高风险场景中,不应成为唯一放行条件。若使用多个 Judge,不能简单把它们的分数平均后假设偏差消失;应先在人工校准集上比较其一致性和漏检模式。

10.5 忽略人工标注者一致性

人工标签也不是天然真值。对于分类标签,可以报告标注者之间的一致性;对于开放任务,应使用明确 rubric、示例和仲裁流程。若人类本身对任务定义存在分歧,模型分数的上限也不应被假定为 100%。

十一、如何形成可审计的评测报告

一份能支持发布决策的报告至少应回答:

  1. 测试了什么任务,样本如何产生和拆分;
  2. 数据、模型、提示词、工具和评测器分别是什么版本;
  3. 执行时的权限、预算、重试、超时和随机性配置是什么;
  4. 规则、Judge 和人工判定各自负责什么;
  5. 总体结果和各关键切片结果是什么;
  6. 成功率、硬违规率、成本、延迟和 Token 的区间是什么;
  7. 与哪个基线比较,差值是否配对,统计方法是什么;
  8. 哪些故障被修复,哪些故障新增;
  9. 是否存在数据泄漏、缓存污染或环境差异;
  10. 失败样本和轨迹如何被审计,敏感数据如何访问和保留。

评测结果本身也是生产数据,可能包含用户输入、个人信息、秘密、工具参数和内部文档。权限控制应至少区分运行者、结果查看者、人工标注者和安全审计者;日志保留周期应符合业务和合规要求;导出报告时应脱敏或使用受控样本。

结语:把“分数”还原为可解释的证据链

可靠的 LLM 与 Agent 评测不是选择一个更复杂的 Judge,而是建立从数据到结论的证据链:

数据样本受控执行输出与轨迹规则/状态/Judge 判定分桶统计回归门禁\text{数据样本} \rightarrow \text{受控执行} \rightarrow \text{输出与轨迹} \rightarrow \text{规则/状态/Judge 判定} \rightarrow \text{分桶统计} \rightarrow \text{回归门禁}

规则负责确定性约束,Judge 负责部分语义判断,轨迹负责解释 Agent 如何到达结果,统计负责判断差异是否可能只是噪声,回归机制负责把这些判断接入版本发布。只有同时保留最终结果、过程证据、资源消耗和版本上下文,评测分数才具有工程意义。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。