Agent 评测:为什么"跑通了"不等于"可靠"
前言
Agent 的效果评估有个很典型的场景:开发时演示十次有九次成功,上线后用户投诉率却高得离谱。
通常的解释是"线上环境复杂"。这不算错,但不够具体。真正的原因可以算出来——多步任务的成功率是指数衰减的,而 demo 只验证了单步。
这篇文章把这件事拆开:为什么通过率会骗人、该评什么、怎么建评测集。
一、Demo 通过率的错觉
假设一个 Agent 单步成功率 90%(这已经是不错的水平)。任务是 5 步:查订单 → 查物流 → 判断是否延迟 → 生成说明 → 发送通知。
直觉上"每步 90%,整体应该也差不多"。实际是:
0.9^5 ≈ 0.59
59%。 也就是说演示时那种"十次成一两次"的体感,在 5 步任务上是必然的,不是你运气差。
如果任务有 10 步:
0.9^10 ≈ 0.35
三分之一的成功率。这就是为什么复杂 Agent 上线后看起来"完全不能用"——它可能真的只有 35% 的全流程成功率,而每次单独测某一步都是好的。
1.1 单步评测掩盖了什么
单步测试的盲区在于:它验证的是"这一步能不能做对",而没验证"该不该做这一步"。
Agent 在长流程里最常犯的错不是参数填错,而是:
- 顺序错(先发货再校验库存)
- 跳步(省掉了必须的确认环节)
- 重复(同一个查询做了三次)
- 越界(调用了不该在这个场景用的工具)
这四种错误在单步评测里全都看不出来——每一步单独看都是成功的。
二、三个层次的评测
评测要分层,因为三层的成本和价值完全不同。
| 层次 | 评什么 | 成本 | 能发现的问题 |
|---|---|---|---|
| 结果层 | 最终答案/产物对不对 | 低 | 明显错误 |
| 轨迹层 | 中间步骤是否合理 | 中 | 顺序错、跳步、重复、越界 |
| 成本层 | 轮数、token、耗时 | 低 | 效率退化、死循环 |
只做结果层是最常见的做法,也是最有欺骗性的——结果对不代表过程对。一个 Agent 可能通过反复试错撞出了正确答案,但同样的逻辑换个输入就会失败。
反过来,只做轨迹层也不行:轨迹合理但结论错的例子也不少(推理链条对,最后算错)。
三层都要看,但优先级是:结果层作为门禁(不通过就失败)、轨迹层作为诊断(通过但过程可疑要报警)、成本层作为回归指标(防止悄悄劣化)。
三、pass@k 和 pass^k 不是一回事
这两个指标经常被混用,但它们回答的问题完全不同。
- pass@k:跑 k 次,至少有一次成功 →
1 - (1-p)^k - pass^k:跑 k 次,全都成功 →
p^k
前者衡量"能不能做到",后者衡量"稳不稳定"。
| 单次成功率 p | pass@5 | pass^5 | pass@10 | pass^10 |
|---|---|---|---|---|
| 0.90 | 0.99999 | 0.59 | ≈1.0 | 0.35 |
| 0.95 | ≈1.0 | 0.77 | ≈1.0 | 0.60 |
| 0.99 | ≈1.0 | 0.95 | ≈1.0 | 0.90 |
看这张表:p = 0.95 时,pass@10 已经接近 100%,但 pass^10 只有 60%。
demo 演示用的是 pass@k,生产需要的是 pass^k。 这是"能演示"和"能上线"的量化分界。
所以评测报告里如果只有一个数字,选 pass^k 而不是 pass@k。评估一次任务跑 5 遍,5 遍全过才算通过——这个标准比"跑 5 遍有一次成功"严格得多,也更接近用户的实际体验。
四、轨迹评测的四个指标
轨迹(trajectory)指 Agent 的完整动作序列。评测它要把一次执行转成结构化记录:
{
"steps": [
{"tool": "query_order", "args": {"order_id": "SO-20261026-0001"}, "ok": true},
{"tool": "track_shipment", "args": {"order_id": "SO-20261026-0001"}, "ok": true},
{"tool": "send_notice", "args": {"channel": "sms"}, "ok": true}
]
}
有了这个结构,四个指标都能算。
4.1 工具选择正确率
期望工具序列 与 实际工具序列 的匹配程度。
不必要求完全一致——多条路径可能都对。做法是定义"必须出现的工具"和"禁止出现的工具"两个集合:
MUST = {"query_order", "track_shipment"}
MUST_NOT = {"refund", "cancel_order"} # 这个场景不能出现
这样既允许合理的路径差异,又能拦住原则性错误。
4.2 参数正确率
工具调对了但参数错,是高频问题。典型的是:
- 用了上一轮的值(上下文串味)
- 枚举值拼错(
"SF"写成"顺丰") - 该传 ID 传了名称
这一项要逐字段比对,不能只看工具名。
4.3 无效循环
同一个工具 + 同样参数被调用两次以上,几乎一定是循环。
def has_loop(steps, threshold=2):
seen = {}
for s in steps:
key = (s["tool"], json.dumps(s["args"], sort_keys=True))
seen[key] = seen.get(key, 0) + 1
if seen[key] > threshold:
return True
return False
这个检测实现简单、收益极高——它能抓住绝大多数"卡住了"的情况,而且不需要标注数据。
4.4 越权与危险动作
按工具的风险等级分档,高风险的调用必须在轨迹里显式检查是否经过了审批环节。
这一项不是"效果"指标,是"安全"指标,应该做成硬性门禁:出现一次未审批的高危调用,整个用例判失败。
五、评测集怎么建
5.1 真实流量采样
从线上真实请求里采样,比重造数据靠谱得多。要注意按意图分布分层采样——如果简单查询占 80%,随机采样会让评测集里全是简单用例,测不出复杂场景的问题。
建议比例:常见意图 60%、长尾意图 30%、已知难点 10%。
5.2 失败案例回流
每个线上失败都应该回流成用例。这是评测集增长的主动力,也是最有效的部分。
工程上要做的是让回流成本足够低:失败时自动保存输入、轨迹、期望结果,一条命令加入评测集。如果需要人工重新构造,就没人会做。
5.3 边界用例
系统性地补这几类:
- 空结果(查不到数据时会不会编)
- 超长输入(工具返回爆量时会不会崩)
- 歧义输入("那个订单"指的是哪个)
- 权限不足(拿不到数据时会不会瞎猜)
- 工具故障(下游超时、500)
边界用例通常只占 10%~20%,但能拦住大部分线上事故。
5.4 别过拟合
一个隐蔽的陷阱:反复针对评测集调 prompt,最后评测集上的分数很高,真实效果没变——因为你在拟合那批样本。
两个缓解手段:
- 留出集:评测集分开发/验证两部分,只用验证集报数
- 定期换血:每月替换 20% 的用例,保持新鲜度
六、回归机制
评测的价值在持续跑,不在跑一次。
触发条件建议三条:
- 每次改 system prompt
- 每次改工具定义(增删工具、改参数)
- 每次换模型或模型版本
前两条是最高频的,且最容易被忽略——"只加了一个工具"听起来无害,但工具定义变了,模型的选择行为可能整体偏移。
成本上,全量跑可能很贵。可行的是分层回归:先跑一个 30 条的核心集(覆盖主流程),通过后再跑全量。核心集不通过就直接拦住,省掉全量开销。
做了什么
把评测从"看几个例子"改成三层:
- 结果层做门禁,每个用例跑 5 遍,5 遍全过才算通过(pass^5),单次通过率只作参考
- 轨迹层记录工具序列,检查必须出现的工具、禁止出现的工具、参数逐字段比对、重复调用
- 成本层把轮数、token、耗时作为回归指标,超过历史基线 50% 报警
改完之后最直接的收获是:那些"演示时好好的、一上线就被投诉"的用例,现在能提前复现出来了——因为跑 5 遍时必然会暴露不稳定。
一些体会
通过率要标明是 pass@k 还是 pass^k。 报告里写"成功率 95%"而不说怎么测的,基本没有信息量。同一份数据,pass@5 和 pass^5 能差 30 个百分点。
多步任务的可靠性要乘着算,不能取平均。 看到 5 步任务就下意识地按 0.9^5 估一遍,能避免很多乐观误判。
无效循环检测是性价比最高的评测项。 不需要标注、不需要人工判断、实现十几行,却能抓住大部分严重故障。
失败回流要自动化到"一条命令"。 需要人工整理失败案例的评测体系,通常三个月后就不再更新了。
别用同一批数据既调优又验证。 这不是方法论洁癖,是过拟合唯一可靠的防线。
代码
一个轨迹评分器,四个指标都覆盖:
import json
from collections import Counter
from dataclasses import dataclass
@dataclass
class TrajectoryScore:
result_ok: bool
must_hit: bool
must_not_violated: bool
arg_errors: list
looped: bool
tool_count: int
@property
def passed(self) -> bool:
return (self.result_ok and self.must_hit
and not self.must_not_violated
and not self.arg_errors and not self.looped)
def score_trajectory(steps: list, expect: dict) -> TrajectoryScore:
used = [s["tool"] for s in steps]
must = set(expect.get("must_tools", []))
must_not = set(expect.get("forbidden_tools", []))
# 1. 必须出现的工具是否都出现了
must_hit = must.issubset(set(used))
# 2. 禁止出现的工具是否被调用
violated = bool(must_not & set(used))
# 3. 参数逐字段比对
arg_errors = []
for want in expect.get("arg_checks", []):
# 找到第一个该工具的调用
call = next((s for s in steps if s["tool"] == want["tool"]), None)
if call is None:
arg_errors.append(f"{want['tool']} 未被调用")
continue
for k, v in want["args"].items():
if call["args"].get(k) != v:
arg_errors.append(
f"{want['tool']}.{k}: 期望 {v!r},实际 {call['args'].get(k)!r}")
# 4. 无效循环:同工具 + 同参数出现超过 2 次
keys = [(s["tool"], json.dumps(s["args"], sort_keys=True)) for s in steps]
looped = any(c > 2 for c in Counter(keys).values())
return TrajectoryScore(
result_ok=bool(expect.get("result_ok", True)),
must_hit=must_hit,
must_not_violated=violated,
arg_errors=arg_errors,
looped=looped,
tool_count=len(steps),
)
跑 k 遍拿 pass^k:
def pass_pow_k(run_once, k: int = 5) -> float:
"""k 次全部通过才算 1,衡量稳定性(不是 pass@k)"""
all_ok = all(run_once()["passed"] for _ in range(k))
return 1.0 if all_ok else 0.0
# 更实用的是统计整批用例
def eval_set(cases, run_once, k=5):
detail = []
for c in cases:
oks = [run_once(c)["passed"] for _ in range(k)]
detail.append({
"case": c["id"],
"pass_at_k": any(oks),
"pass_pow_k": all(oks),
"unstable": any(oks) and not all(oks), # 时好时坏,最危险
})
n = len(detail)
return {
"pass@k": sum(d["pass_at_k"] for d in detail) / n,
"pass^k": sum(d["pass_pow_k"] for d in detail) / n,
"unstable_cases": [d["case"] for d in detail if d["unstable"]],
}
unstable_cases 这个输出比两个总体指标更有用——它直接指出"哪些用例时好时坏"。这些用例是当前最该修的,因为它们代表的是逻辑上的不确定性,而不是能力边界。
- 点赞
- 收藏
- 关注作者
评论(0)