问答题的评测可以只比字符串。Agent 不行:同一个任务有无数条合法路径,中间还调了工具、改了数据库。于是"跑十次有八次对"这句话没有信息量——你不知道那两次是路径错了、工具调错了,还是参数少了一个字段。这篇给一套能落地的层次划分、轨迹字段和回归集组织方式。
1. 难在哪:三个不成立的假设
| 单次问答的假设 | Agent 的现实 |
|---|---|
| 答案唯一可比 | 路径不唯一,最终输出相同但过程可能违法 |
| 一次调用即一次判定 | 一次任务数十次调用,错一步全盘皆错 |
| 只看输出就够 | 副作用写在外部系统里,输出看不出来 |
第三点最容易被忽略:agent 说"已退款",钱可能没动,也可能动了两次。 评测必须能读到工具的真实结果,而不只是模型的自述。
2. 评测的四个层次
四层从下往上,下层不过就别评上层。
| 层次 | 评什么 | 判定方式 | 失败典型 |
|---|---|---|---|
| L1 最终结果 | 输出是否正确、格式是否合规 | 断言 / 规则 / 人工抽检 | JSON 缺字段 |
| L2 中间步骤 | 步骤序列是否合理、有无多余绕路 | 关键步骤存在性断言 | 该先校验却直接提交 |
| L3 工具调用 | 选得对不对、参数对不对、次数对不对 | 与期望调用序列比对 | 重复调用、漏调校验 |
| L4 成本与延迟 | token、调用次数、端到端耗时 | 阈值 + 趋势监控 | 一次任务几十次调用 |
只在 L1 打分,会出现"结果对但过程不可用":一次退款成功,但中间查了三次同一个订单、调了两次创建接口(第二次幂等挡掉了)。 这种系统在并发上来时会先崩。
3. 轨迹要记什么
| 字段 | 内容 | 用途 |
|---|---|---|
trace_id / span_id |
会话与步骤标识 | 串联、跨服务关联 |
step_index |
第几步 | 复现顺序 |
input_digest |
该步输入的摘要或全文 | 复盘"它当时看到了什么" |
tool.name |
选中的工具 | 判断选择是否正确 |
tool.args |
完整入参 | 没有它就无法复盘参数错误 |
tool.result |
结果(截断)与 isError |
判断是否真的成功 |
latency_ms / tokens |
每步开销 | 定位成本热点 |
model / version |
模型与提示词版本 | A/B 归因 |
step = {
"trace_id": trace_id, "span_id": new_span(), "step_index": i,
"agent": role, "model": MODEL_ID, "prompt_version": PROMPT_VER,
"input_digest": digest(payload),
"tool": {"name": name, "args": args, "result": truncate(result, 2000),
"is_error": is_error},
"latency_ms": elapsed, "tokens": usage,
}
tool.args 单独标粗:没有入参的日志,只能证明"调了",无法证明"调对了"。
4. 步骤级评测怎么写
不要只断言最终答案,对关键步骤下断言。
def assert_trajectory(trace: list[dict]) -> list[str]:
errs = []
names = [s["tool"]["name"] for s in trace if s.get("tool")]
if "verify_order" not in names:
errs.append("缺少前置校验 verify_order")
if names.count("create_refund") > 1:
errs.append("create_refund 被重复调用")
for s in trace:
if s["tool"]["name"] == "create_refund":
if "order_id" not in s["tool"]["args"]:
errs.append("create_refund 缺少 order_id")
return errs
断言分三类,按稳定性排序:
| 断言类型 | 例子 | 稳定性 |
|---|---|---|
| 必须发生 | 调用过 verify_order |
高,路径无关 |
| 禁止发生 | 未校验就 create_refund |
高 |
| 顺序约束 | verify 早于 create |
中,注意并行 |
| 次数约束 | 每个工具调用不超过 N 次 | 中 |
不要断言"完整步骤序列":模型换个顺序仍可能正确,断言太死会让评测集天天红。
5. 工具调用准确率怎么度量
把它拆成四个可分别统计的错误类型。
| 错误类型 | 定义 | 危害 |
|---|---|---|
| 漏调 | 该调没调 | 后续全错,静默失败 |
| 误调 | 不该调却调了 | 副作用、成本 |
| 参数错 | 调了但字段缺/值错 | 重试掩盖问题 |
| 重复调 | 同一动作多次执行 | 幂等挡得住,挡不住就事故 |
def tool_metrics(cases):
m = {"missing": 0, "extra": 0, "bad_args": 0, "dup": 0, "total": 0}
for c in cases:
called = [s["tool"]["name"] for s in c["trace"] if s.get("tool")]
m["total"] += 1
m["missing"] += len(set(c["expect_tools"]) - set(called))
m["extra"] += len(set(called) - set(c["expect_tools"]))
m["dup"] += sum(1 for n in set(called) if called.count(n) > 1)
m["bad_args"] += args_mismatch(c)
return {k: (v / max(m["total"], 1)) for k, v in m.items() if k != "total"}
四个指标要分开看:漏调上升通常是描述含糊,误调上升通常是工具语义重叠,重复调上升通常是重试策略没配幂等键。 合成一个"准确率"数字,就失去了定位能力。
6. 回归集怎么组织
回归集的来源只有一个:线上真实失败的轨迹。凭空编造的用例测不出系统的真实弱点。
| 来源 | 转化方式 | 优先级 |
|---|---|---|
| 线上失败轨迹 | 固化为用例(输入 + 期望工具集 + 期望结论) | 最高 |
| 用户投诉 | 补一条带边界的用例 | 高 |
| 模型/提示词升级后的偏差 | 新旧对比后取差集 | 中 |
| 人工构造的极端输入 | 空值、超长、权限不足 | 中 |
{
"case_id": "refund-8821-dup",
"input": {"user": "订单 8821 退款", "order_id": "8821"},
"expect_tools": ["query_order", "verify_order", "create_refund"],
"forbid": ["create_refund > 1"],
"expect_check": {"verdict": "pass"},
"source_trace": "trace-2026-11-03-0412"
}
保留 source_trace 字段:用例和原始轨迹的链接是复盘的地基,没有它,半年后没人知道这条用例为什么存在。
7. 可观测的三个视图
| 视图 | 粒度 | 回答的问题 |
|---|---|---|
| 单次会话时间线 | 一次 trace | 这次为什么失败 |
| 按环节聚合 | 按工具/按步骤 | 哪个环节最常失败 |
| 失败原因分布 | 按错误类型 | 该先修哪一类 |
单次视图用于排障,聚合视图用于决策。只有单次视图的团队,会陷入"每次都修个案"的循环。
# 聚合:按工具统计失败率(示例查询,字段名按你的存储调整)
# SELECT tool_name, count(*) FILTER (WHERE is_error) / count(*) AS fail_rate
# FROM steps WHERE ts > now() - interval '7 days' GROUP BY 1 ORDER BY 2 DESC;
8. 人工介入率要单独监控
人工介入率是 Agent 系统最诚实的一个指标:它同时反映能力上限与体验。
| 指标 | 定义 | 上升意味着 |
|---|---|---|
| 触发率 | 需要人确认的任务 / 总任务 | 模型判断力不足或权限过窄 |
| 采纳率 | 人确认通过的 / 触发确认的 | 建议质量差,人在反复否 |
| 平均等待 | 从触发到人处理 | 流程设计问题,不是模型问题 |
采纳率长期偏低说明"确认"变成了形式:人开始无脑点通过,这时候的确认环节既不安全也不高效,应该改规则而不是加提示词。
9. A/B 与灰度
| 做法 | 变量控制 | 适用 |
|---|---|---|
| 同任务双版本并行 | 只换模型或只换提示词 | 评测阶段 |
| 按流量比例灰度 | 只换一个版本参数 | 上线阶段 |
| 影子流量 | 新版本只跑不写 | 有副作用的任务 |
def run_ab(case, variant):
cfg = {"A": {"model": MODEL_A, "prompt": PROMPT_V1},
"B": {"model": MODEL_A, "prompt": PROMPT_V2}}[variant] # 只变一个
trace = run_agent(case["input"], **cfg)
return {"variant": variant, "trace": trace,
"pass": not assert_trajectory(trace)}
一次只改一个变量。 同时换模型和提示词,出问题后无法归因——这是 A/B 最常见的浪费。
有副作用的任务走影子流量:新版本照常推理、照常调工具,但工具层进入 dry-run,只记录不执行。
10. 排错:五个高频坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 评测通过率很高,线上仍然出错 | 只看最终答案,不看轨迹 | 加 L2/L3 断言,覆盖工具调用 |
| 每次修完都复发 | 评测集不含工具失败路径 | 把线上失败轨迹固化进回归集 |
| 复盘时说不清"它为什么这么调" | 日志没有工具入参 | 记录 tool.args 全文 |
| 一次成功就宣布能力达标 | 单次结果当稳定能力 | 固定种子/温度,跑 N 次看分布 |
| 改完提示词"感觉好多了" | 没有对照基线 | 固定回归集 + 同版本对比 |
第二条是整个评测体系的分水岭:评测集的价值等于它覆盖的失败模式数量,而不是用例条数。
11. 边界:这些别用评测框架解决
| 问题 | 该用什么 | 理由 |
|---|---|---|
| 工具本身有 bug | 单元测试 + 契约测试 | agent 评测会把它算成模型错 |
| 权限配置错 | 集成测试 | 属于系统配置 |
| 延迟高 | 链路追踪 + 性能剖析 | 评测只看分布,不定位 |
| 输出格式不稳 | 结构化输出约束 + schema 校验 | 采样评测治不了 |
| 提示词风格不满意 | 人工评审 | 主观项不适合自动断言 |
12. 自测
- 为什么"跑十次八次对"这句话没有信息量?缺了哪两个维度?
- L1 到 L4 四层,哪一层最容易被跳过,后果是什么?
- 轨迹里为什么必须记录
tool.args全文? - 漏调与误调的上升,分别指向什么根因?
- 回归集的第一优先级来源是什么?为什么不能靠人工编造?
- A/B 实验里同时改模型和提示词,会让你失去什么?