Agent 评估(eval)——非确定性系统怎么测
- 理解为什么 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 输出打分。适合开放性答案、摘要质量、多步推理是否合理等规则写不出来的场景。
- 优点:能理解语义等价,覆盖规则够不到的场景
- 陷阱(重点):
- 位置偏见:judge 模型倾向于给第一个选项或更长的答案打高分,做 A/B 对比时要随机化顺序
- 自我偏袒:用同款模型既当 Agent 又当 judge,judge 会偏向给自己的输出打高分
- prompt 偷懒导致评分飘:judge 的 prompt 没写清楚 rubric,judge 自己"发挥",每次给分标准不统一——要在 judge prompt 里明确写出打分维度和每分对应的描述
- 只判最终输出,不看中间推理:Agent 可能推理过程完全走岔了但最终答案碰巧对,得到高分,掩盖了真实问题
实践中两者配合使用:能用规则判的先用规则判(快),规则判不了的再调 judge(贵但准)。
第四层:回归测试
这一层最容易被忽视,也是最有实际价值的一层。
你改了 system prompt,想知道有没有搞坏什么——跑一遍评测集,对比改前改后的分数。你换了模型版本——同样跑一遍。每次改动后有数据说话,而不是凭感觉说"感觉好了一点"。
怎么落地:
- 把评测集存成版本化文件(JSON / YAML),和代码一起进代码库
- 跑 eval 时记录时间戳、Agent 版本号(commit hash 或 prompt hash)、每道题的详细结果
- 设一个"质量门":比如基础用例完成率不低于 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")
用起来的三步:
- 把
EVAL_CASES替换成你真实的任务用例,写清楚expected - 把
run_agent替换成你自己的 Agent 实现(参考 用 Claude Agent SDK 写出你的第一个能跑的 Agent) - 每次改完 prompt 或换完模型,跑一遍,对比
eval_report_*.json的avg_score和pass_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 数字员工落地指南,或了解 数字员工搭建实战课。需要为企业落地方案,欢迎找我们聊 企业服务。