← 返回教程库

Agent 评估(eval)——非确定性系统怎么测

最后更新 2026-06-25
你将学到
  • 理解为什么 Agent 无法用传统单元测试方法覆盖,明确非确定性系统的测试难点
  • 掌握评测集的建法:任务设计、期望结果定义、评分标准制定
  • 学会选对判分方式:规则/精确匹配适用场景 vs LLM-as-judge 适用场景及各自陷阱
  • 复制并跑通一个最小 eval 脚本骨架,能在改 prompt/换模型后跑回归测试防退化

Agent 上线后翻车,多半是因为根本没测过——但它没法像普通函数那样测。

你可能碰到过这种情况:Agent 在 demo 里跑得很顺,上线第一天用户就反馈"它调错工具了""绕了七八步没干完""给出的结果跟期望差十万八千里"。你想重现,发给它同样的指令,它这次又答对了。你开始怀疑人生:这玩意儿到底能不能测?

能测,但得换一套思路。


为什么传统单元测试思路失效

写一个普通函数,你会这么测:给它固定输入,断言输出等于期望值。如果不等,测试失败。这套逻辑依赖一个前提:相同输入 → 相同输出

Agent 打破了这个前提,原因有三:

1. 输出本身就不确定

大语言模型默认带 temperature,同样的问题十次能给十种不同的措辞、不同的工具调用顺序、不同的中间推理路径——甚至 temperature=0 在某些边界情况下也不能保证完全一致。你没法用 assert output == "精确字符串" 来覆盖这种情况。

2. 路径不唯一,结果可以等价

一个信息检索 Agent,可以先搜关键词再过滤,也可以先缩小范围再搜,两条路径步数不同、中间状态不同,但最终结论可以同样正确。传统测试抓的是路径,在 Agent 里,你真正在乎的是最终结果的质量,而不是"它走了哪几步"。

3. 多步累积误差

Agent 要跑五六步才完成一个任务。第二步的输入是第一步的输出,一步出问题后面全受影响。单测每一个工具调用是有意义的,但测不到"五步链条整体能不能跑通"——你需要端到端的任务级别评估。

换句话说,传统单测测的是确定性函数,Agent eval 测的是概率性系统在真实任务上的表现。两件事,不同工具。


怎么测:四个层次

第一层:建评测集

好的评测集是 eval 的核心资产。建错了,后面全白费。

评测集里放什么:

  • 真实任务:从真实用户请求里挑,或者模拟最典型的 10-20 个场景。不要自己编"AI 肯定能做好"的题目——那是在给自己看戏。
  • 期望结果 / 评分标准:每道题要有"什么算对"。可以是精确答案(查某个 API 返回的具体字段),也可以是模糊标准("摘要需覆盖 3 个关键事实"),后者需要写成判分 rubric。
  • 难度分级:把用例分成"基础"和"边界"。基础用例跑不过说明 Agent 有根本问题;边界用例专门测你知道容易出错的场景。

多少条够用:

10 条根本不够发现规律,100 条能看趋势,300+ 条才能做可靠的回归对比。起步可以先做 30-50 条,覆盖最核心的场景,再逐步扩充。

两个常见错误:

评测集太小,改一个 prompt 结果波动 20% 是正常统计误差,你看不出来是进步还是退步。评测集题目太简单,平均分一直 95+,感觉良好,但用户遇到的边界问题全没覆盖——这叫"测了个寂寞"。


第二层:选对指标

不同任务关心不同维度,把指标分四类:

指标类别 具体例子 适用场景
任务完成率 最终结果是否达到用户目标(0/1 或 0-5 分) 所有 Agent,最核心指标
执行效率 完成任务用了几步、调了几次工具、总 token 消耗 成本敏感场景;步数过多说明 prompt 或工具 schema 有问题
工具调用正确性 是否调了正确的工具、参数是否合法、有没有不该调的工具被误触 工具多的 Agent;工具调错比没完成还危险
最终输出质量 准确性、完整性、是否有幻觉、格式是否符合预期 内容生成类 Agent;靠 LLM-as-judge 或人工评分

别只看平均分。一个 Agent 平均完成率 80%,可能是 95 条完美、5 条完全崩掉——对那 5 类用户来说体验是灾难性的。看尾部分布,找出最差的那 10% 的用例,优先修它们。


第三层:选对判分方式

判分有两条路,用哪条取决于"对不对"这件事能不能有确定答案。

规则 / 精确匹配

适合:输出有固定格式、答案是可枚举的、调用了特定工具等可程序验证的情况。

  • 优点:快、便宜、可重复
  • 陷阱:稍微改个措辞就判错("2026年" vs "2026"),假阴性率高,会把正确答案误判为错

LLM-as-judge

让另一个大语言模型(通常是能力更强的模型)对 Agent 输出打分。适合开放性答案、摘要质量、多步推理是否合理等规则写不出来的场景。

  • 优点:能理解语义等价,覆盖规则够不到的场景
  • 陷阱(重点):
    1. 位置偏见:judge 模型倾向于给第一个选项或更长的答案打高分,做 A/B 对比时要随机化顺序
    2. 自我偏袒:用同款模型既当 Agent 又当 judge,judge 会偏向给自己的输出打高分
    3. prompt 偷懒导致评分飘:judge 的 prompt 没写清楚 rubric,judge 自己"发挥",每次给分标准不统一——要在 judge prompt 里明确写出打分维度和每分对应的描述
    4. 只判最终输出,不看中间推理:Agent 可能推理过程完全走岔了但最终答案碰巧对,得到高分,掩盖了真实问题

实践中两者配合使用:能用规则判的先用规则判(快),规则判不了的再调 judge(贵但准)。


第四层:回归测试

这一层最容易被忽视,也是最有实际价值的一层。

你改了 system prompt,想知道有没有搞坏什么——跑一遍评测集,对比改前改后的分数。你换了模型版本——同样跑一遍。每次改动后有数据说话,而不是凭感觉说"感觉好了一点"。

怎么落地:

  1. 把评测集存成版本化文件(JSON / YAML),和代码一起进代码库
  2. 跑 eval 时记录时间戳、Agent 版本号(commit hash 或 prompt hash)、每道题的详细结果
  3. 设一个"质量门":比如基础用例完成率不低于 90%、平均步数不超过 8 步——低于门槛不让上线

这是把 eval 真正用起来的关键:它不是一次性的评估报告,而是持续集成的质量守卫


最小可跑 eval 脚本骨架

下面这段代码可以直接复制跑。它的结构是:一组用例 → 跑 Agent → 用 LLM 或规则判分 → 出报告

import json
import time
from typing import Any, Callable
import anthropic

# ──────────────────────────────────────────────────
# 1. 评测集:每条用例有 input 和 expected(评分标准)
# ──────────────────────────────────────────────────
EVAL_CASES = [
    {
        "id": "case_001",
        "input": "查一下今天上海的天气",
        "expected": "应包含天气描述(晴/阴/雨等)和温度信息",
        "judge_type": "llm",   # 用 LLM 判分
    },
    {
        "id": "case_002",
        "input": "把 42 转成十六进制",
        "expected": "2a",
        "judge_type": "exact",  # 精确匹配
    },
    {
        "id": "case_003",
        "input": "列出当前目录下的 Python 文件",
        "expected": "应调用 list_directory 工具且参数包含路径",
        "judge_type": "rule",   # 自定义规则
    },
]

# ──────────────────────────────────────────────────
# 2. 被评测的 Agent(替换成你自己的 Agent 函数)
# ──────────────────────────────────────────────────
def run_agent(user_input: str) -> dict:
    """
    返回格式:
    {
        "final_answer": str,      # Agent 最终给出的文字结果
        "tool_calls": list,       # 调了哪些工具 [{"name": ..., "input": ...}]
        "steps": int,             # 总步数
        "tokens": int,            # 总 token 消耗(输入+输出)
    }
    """
    # TODO: 替换成你的真实 Agent 实现
    # 这里是占位,模拟一个极简 Agent 直接返回
    client = anthropic.Anthropic()
    response = client.messages.create(
        model="claude-opus-4-5",  # 以官方文档为准
        max_tokens=512,
        messages=[{"role": "user", "content": user_input}],
    )
    return {
        "final_answer": response.content[0].text if response.content else "",
        "tool_calls": [],
        "steps": 1,
        "tokens": response.usage.input_tokens + response.usage.output_tokens,
    }


# ──────────────────────────────────────────────────
# 3. 判分器
# ──────────────────────────────────────────────────
def judge_exact(actual: str, expected: str) -> tuple[float, str]:
    """精确匹配:包含期望字符串则满分"""
    passed = expected.strip().lower() in actual.strip().lower()
    return (1.0 if passed else 0.0), f"精确匹配: {'通过' if passed else '未通过'}"


def judge_llm(actual: str, expected: str, question: str) -> tuple[float, str]:
    """LLM-as-judge:用更强模型对输出打 0-5 分"""
    client = anthropic.Anthropic()
    judge_prompt = f"""你是一个严格的 AI 系统评估专家。请根据以下标准对 Agent 回答打分。

用户问题:{question}

评分标准(期望):{expected}

Agent 实际回答:{actual}

请按照以下维度打分(每项 0-5 分,写出理由后给出总分 0-5):
- 准确性(0-5):回答是否符合评分标准的要求
- 完整性(0-5):是否覆盖了标准里提到的关键点

最后给出总分(0-5,精确到 0.5),格式:SCORE: X.X"""

    response = client.messages.create(
        model="claude-opus-4-5",  # judge 用更强模型,以官方文档为准
        max_tokens=256,
        messages=[{"role": "user", "content": judge_prompt}],
    )
    raw = response.content[0].text if response.content else ""

    # 解析分数
    score = 0.0
    for line in raw.split("\n"):
        if line.strip().startswith("SCORE:"):
            try:
                score = float(line.split(":")[1].strip()) / 5.0  # 归一化到 0-1
            except ValueError:
                pass
    return score, raw[:200]  # 只取前 200 字作为理由摘要


def judge_rule(actual: str, expected: str, case: dict) -> tuple[float, str]:
    """自定义规则:检查工具调用"""
    if "tool_calls" in case.get("check", {}):
        required_tool = case["check"]["tool_calls"]
        called = [tc["name"] for tc in case.get("_result", {}).get("tool_calls", [])]
        passed = required_tool in called
        return (1.0 if passed else 0.0), f"工具检查 {required_tool}: {'调用了' if passed else '未调用'}"
    # 默认退化为关键词检查
    passed = any(kw in actual for kw in expected.split())
    return (1.0 if passed else 0.0), f"关键词检查: {'通过' if passed else '未通过'}"


# ──────────────────────────────────────────────────
# 4. 主评测循环
# ──────────────────────────────────────────────────
def run_eval(cases: list[dict]) -> dict:
    results = []
    total_score = 0.0

    for case in cases:
        print(f"[{case['id']}] 跑中...", end=" ", flush=True)
        start = time.time()

        # 跑 Agent
        try:
            agent_result = run_agent(case["input"])
        except Exception as e:
            agent_result = {"final_answer": f"ERROR: {e}", "tool_calls": [], "steps": 0, "tokens": 0}

        elapsed = time.time() - start
        case["_result"] = agent_result  # 供 rule judge 用

        # 判分
        judge_type = case.get("judge_type", "llm")
        if judge_type == "exact":
            score, reason = judge_exact(agent_result["final_answer"], case["expected"])
        elif judge_type == "rule":
            score, reason = judge_rule(agent_result["final_answer"], case["expected"], case)
        else:
            score, reason = judge_llm(agent_result["final_answer"], case["expected"], case["input"])

        total_score += score
        result = {
            "id": case["id"],
            "input": case["input"],
            "score": round(score, 3),
            "steps": agent_result["steps"],
            "tokens": agent_result["tokens"],
            "elapsed_s": round(elapsed, 2),
            "reason": reason,
        }
        results.append(result)
        print(f"得分={score:.2f} | 步数={agent_result['steps']} | {elapsed:.1f}s")

    avg_score = total_score / len(cases) if cases else 0
    report = {
        "summary": {
            "total_cases": len(cases),
            "avg_score": round(avg_score, 3),
            "pass_rate": round(sum(1 for r in results if r["score"] >= 0.6) / len(cases), 3),
            "avg_steps": round(sum(r["steps"] for r in results) / len(cases), 1),
            "avg_tokens": round(sum(r["tokens"] for r in results) / len(cases)),
        },
        "details": results,
    }
    return report


# ──────────────────────────────────────────────────
# 5. 出报告
# ──────────────────────────────────────────────────
if __name__ == "__main__":
    print("=" * 50)
    print("Agent Eval 开始")
    print("=" * 50)

    report = run_eval(EVAL_CASES)

    print("\n── 汇总 ──")
    s = report["summary"]
    print(f"用例数: {s['total_cases']}  |  平均分: {s['avg_score']:.3f}  |  通过率: {s['pass_rate']:.1%}")
    print(f"平均步数: {s['avg_steps']}  |  平均 token: {s['avg_tokens']}")

    print("\n── 详情(最差的排在前面)──")
    for r in sorted(report["details"], key=lambda x: x["score"]):
        flag = "✗" if r["score"] < 0.6 else "✓"
        print(f"  {flag} [{r['id']}] {r['score']:.2f} | {r['reason'][:80]}")

    # 保存报告(带时间戳,用于回归对比)
    ts = int(time.time())
    with open(f"eval_report_{ts}.json", "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"\n报告已保存: eval_report_{ts}.json")

用起来的三步:

  1. EVAL_CASES 替换成你真实的任务用例,写清楚 expected
  2. run_agent 替换成你自己的 Agent 实现(参考 用 Claude Agent SDK 写出你的第一个能跑的 Agent
  3. 每次改完 prompt 或换完模型,跑一遍,对比 eval_report_*.jsonavg_scorepass_rate

LLM-as-judge 的陷阱,再说深一点

上面提到了几个陷阱,这里再展开最容易被忽视的两个:

陷阱一:judge prompt 的 rubric 没写够细

很多人写 judge prompt 就是"请给这个回答打 1-10 分"——这等于让 judge 自己发明评分标准,每次标准都不一样,分数没法对比。

正确做法:在 judge prompt 里写清楚每分对应的描述。比如"5 分 = 完全满足期望,没有遗漏;3 分 = 满足主要要求但有一项遗漏;1 分 = 答题方向错误"。这叫 rubric,它决定了评分的可重复性。

陷阱二:评测集分布和真实流量不匹配

你精心设计了 50 道题,发现 Agent 通过率 90%,很开心上线了——结果真实用户有 30% 的请求是带特殊字符、多语言混合、模糊意图的"脏"输入,你的评测集一道都没有。

解法:定期从真实日志里采样,把真实翻车的案例补进评测集。评测集是活的,不是一次性建完就放着。


故障排查表

症状 可能原因 排查方向
评测集跑分看起来稳定,但线上用户投诉多 评测集不代表真实流量分布;测了"容易题",漏了边界场景 从线上日志抽样,补充真实翻车案例进评测集;检查评测集难度分布
LLM judge 给分飘忽,同样输入分数不一样 judge prompt 没有明确 rubric;judge 模型 temperature 过高 在 judge prompt 里写 rubric(每分对应描述);judge 调用时设 temperature=0
改了一行 prompt 后,通过率骤降 20% 评测集太小,20% 的变化可能是统计噪音;也可能真改坏了 先增加评测集规模到 50+ 条再判断;逐题看得分下降的具体是哪类用例
Agent 步数稳定,但 token 消耗骤增 某个工具的返回内容暴增(比如查询结果突然变大);或 prompt 被改长了 检查工具返回内容长度;对比改动前后 system prompt token 数差异
精确匹配判分假阴性太多(明明对却判错) expected 字符串写得太严格,细微格式差异就报错 改用关键词包含检查或 LLM judge;或在精确匹配前做 normalize(小写、去空格)

常见问题

Q:eval 脚本要在 CI 里跑吗?还是手动跑就够了?

起步手动跑就够——你每次上线前跑一遍比根本不跑强。有条件的话,再把它接入 CI,每次合并 PR 前自动触发。但不要因为"接 CI 麻烦"就连手动 eval 都省了。建立习惯比工具完备更重要。

Q:judge 模型需要比被测 Agent 更强吗?

理论上 judge 模型应该有能力理解任务质量。如果你的 Agent 用 A 模型,judge 用同款 A 模型,会有自我偏袒问题(judge 倾向给自己风格的输出高分)。实践中,judge 用能力相当或更强的模型,同时关注 judge prompt 的 rubric 质量,比一味追求"judge 比 Agent 强"更有实际意义。

Q:我的 Agent 任务很长,每条用例跑一次要 30 秒,100 条 eval 太慢了怎么办?

几个加速方向:一是并发跑(asyncio 或多线程),IO 密集型任务并发效果明显;二是先跑最核心的 20 条"冒烟测试",全绿再跑全量;三是对长任务用短版本(缩减上下文、限制步数)做快速 eval,只对关键 case 跑完整版。速度和覆盖度之间的权衡,没有通用答案,根据你的改动频率决定。

Q:除了 LLM-as-judge,还有别的评分方法吗?

有:① 人工评分,最准但最贵,适合评测集初建时做"校准";② 参考答案相似度(BLEU、BERTScore 等),适合翻译/摘要类任务;③ 工具调用日志检查,程序性地验证工具序列是否符合预期路径;④ 双模型对比(new vs baseline),不打绝对分,只判"谁更好"——A/B 比较比打分更稳健,适合模型升级决策。


小结

  • Agent 测试不能用传统单测思路:输出不确定、路径不唯一、多步累积误差,需要任务级端到端评估。
  • 评测集是核心资产:真实任务 + 明确评分标准 + 难度分级,从 30 条起步,持续从真实日志补充。
  • 判分方式按场景选:规则/精确匹配适合可枚举答案,LLM-as-judge 适合开放性输出,用好 rubric 是 LLM judge 稳定性的关键。
  • 回归测试是 eval 的核心价值:改 prompt/换模型后有数据说话,不靠感觉。
  • 只看平均分是陷阱:盯尾部,找最差的那 10% 用例优先修。

下一步可以看 Agent 可观测性:追踪与日志(6.2 节),从 eval 离线打分,到线上实时追踪 Agent 的每一步行为——两者配合,才有完整的质量闭环。部署形态相关的内容看 Agent 部署形态:六种后端与 Cron(6.3 节)。

👉 看看 AI 数字员工落地指南,或了解 数字员工搭建实战课。需要为企业落地方案,欢迎找我们聊 企业服务

📄 来源 / 自校链接

本文为学习整理,关键步骤与代码请结合下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为学习与落地整理,AI 工具与平台更新较快,关键步骤请结合官方最新资料验证。见免责声明