Agent 评测:为什么"跑通了"不等于"可靠"

举报
yd_232401777 发表于 2026/10/29 07:49:57 2026/10/29
【摘要】 一个 Agent 单步成功率 90%,跑 5 步完成的任务全对概率只有 59%。这就是"demo 能跑、上线就废"的数学解释。这篇文章讲三层次的评测框架、pass@k 与 pass^k 的区别、轨迹评测的四个指标,以及一套不会过拟合的评测集建法。

前言

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

前者衡量"能不能做到",后者衡量"稳不稳定"。

单次成功率 ppass@5pass^5pass@10pass^10
0.900.999990.59≈1.00.35
0.95≈1.00.77≈1.00.60
0.99≈1.00.95≈1.00.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% 的用例,保持新鲜度

六、回归机制

评测的价值在持续跑,不在跑一次。

触发条件建议三条:

  1. 每次改 system prompt
  2. 每次改工具定义(增删工具、改参数)
  3. 每次换模型或模型版本

前两条是最高频的,且最容易被忽略——"只加了一个工具"听起来无害,但工具定义变了,模型的选择行为可能整体偏移。

成本上,全量跑可能很贵。可行的是分层回归:先跑一个 30 条的核心集(覆盖主流程),通过后再跑全量。核心集不通过就直接拦住,省掉全量开销。

做了什么

把评测从"看几个例子"改成三层:

  1. 结果层做门禁,每个用例跑 5 遍,5 遍全过才算通过(pass^5),单次通过率只作参考
  2. 轨迹层记录工具序列,检查必须出现的工具、禁止出现的工具、参数逐字段比对、重复调用
  3. 成本层把轮数、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 这个输出比两个总体指标更有用——它直接指出"哪些用例时好时坏"。这些用例是当前最该修的,因为它们代表的是逻辑上的不确定性,而不是能力边界。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。