面向多轮 Agent 工作流的评估体系设计:任务成功率、工具调用质量与失败归因的可复现实践
面向多轮 Agent 工作流的评估体系设计:任务成功率、工具调用质量与失败归因的可复现实践
多轮 Agent 一旦进了业务,评估就不能只看一句“答得像不像”。真实系统里,模型要拆任务、选工具、传参数、处理异常、回收中间结果,最后才到最终回复。只看最终答案,很多问题会被埋住。
我在最近一个内部项目里,给客服辅助 Agent 和工单处理 Agent 共用了一套评估框架。结果很直接:同样是 78% 的最终任务成功率,两套策略的失败原因完全不是一回事。一套主要死在工具参数错误,另一套主要死在中间决策漂移。短句说完:只看终局,容易误判。
这篇文章把这套方案拆开讲,重点放在可复现、实测结果、工程细节,包含:评估指标怎么定义、数据集怎么组织、回放器怎么写、失败归因怎么落地,以及一次真实对比实验的结果。
1. 先说问题:为什么 Agent 评估比普通问答难
普通问答任务,常见做法是拿标准答案算准确率,或者做语义相似度评分。Agent 不一样。它的输出不只是文本,还包括一串动作轨迹:
- 是否做了正确的任务拆解
- 是否调用了正确工具
- 工具参数是否完整且合法
- 工具返回异常时有没有恢复
- 最终结果是否满足业务约束
这里最麻烦的地方,不在模型“会不会答”,而在“每一步是不是走对了”。有时最终答案看着没问题,但路径走得很危险;有时中间有瑕疵,最后却误打误撞得到正确结果。说实话,这类样本我一开始低估了,回放 200 条之后才发现占比不小。
所以我现在会把 Agent 评估拆成两层:
结果层评估:任务有没有完成。
过程层评估:动作轨迹是否可靠。
这两层必须分开记。
2. 一个可落地的评估框架
我实际采用的是四段式结构:
- 任务集定义:每条样本包含输入、上下文、期望结果、允许工具集合、关键约束。
- 轨迹采集:记录每轮思考结果、工具调用、参数、返回值、耗时、异常。
- 指标计算:任务成功率、工具调用质量、轨迹偏移率、恢复能力等。
- 失败归因:把失败归到固定类型,便于版本对比。
结构不复杂,但执行时细节很多。尤其是采集层,如果日志字段没收全,后面基本没法分析。
2.1 样本数据结构
先定义一条评测样本。建议不要只放 question 和 answer,至少补齐任务约束和工具白名单。
{
"case_id": "ticket_00128",
"user_input": "帮我查询订单A123的物流,如果延迟超过3天就帮我创建催单工单,并把结果总结给我",
"context": {
"user_id": "u_9001",
"channel": "app",
"current_time": "2026-04-10T10:00:00+08:00"
},
"expected": {
"final_status": "success",
"must_use_tools": ["query_order_logistics"],
"optional_tools": ["create_ticket"],
"constraints": {
"delay_threshold_days": 3,
"ticket_required_if_delayed": true
},
"expected_slots": {
"order_id": "A123"
}
},
"golden_path": [
{
"tool": "query_order_logistics",
"args": {"order_id": "A123"}
},
{
"tool": "create_ticket",
"args": {"type": "urge_delivery", "order_id": "A123"},
"condition": "if delayed > 3 days"
}
]
}
这里的 golden_path 不要求完全唯一,它更像参考轨迹。Agent 允许存在多条合理路径,只要满足约束即可。
2.2 运行日志结构
评估的核心,不是 prompt,而是日志。日志不全,很多结论都站不住。
我通常记录下面这些字段:
{
"case_id": "ticket_00128",
"run_id": "run_20260410_0001",
"agent_version": "agent_v3.2.1",
"model": "gpt-4.1",
"steps": [
{
"step_id": 1,
"type": "tool_call",
"tool_name": "query_order_logistics",
"arguments": {"order_id": "A123"},
"tool_latency_ms": 182,
"tool_result": {"delay_days": 4, "status": "delayed"},
"status": "success"
},
{
"step_id": 2,
"type": "tool_call",
"tool_name": "create_ticket",
"arguments": {"type": "urge_delivery", "order_id": "A123"},
"tool_latency_ms": 205,
"tool_result": {"ticket_id": "T9008"},
"status": "success"
},
{
"step_id": 3,
"type": "final_answer",
"content": "订单A123物流已延迟4天,我已为你创建催单工单T9008。"
}
],
"final_result": "success",
"total_latency_ms": 2410,
"total_tokens": 1832
}
如果有条件,我建议把“模型原始工具调用意图”和“工具执行后的标准化结果”分开存。这样后面分析参数修正器、工具网关、重试器时会省很多事。
3. 指标设计:别只报一个成功率
我现在做 Agent 评估,一般会固定输出一张指标表,最少包含下面几类。
3.1 任务成功率
最基础的指标,但定义要写清楚。
严格成功率
满足业务目标,且没有违反强约束。
例如:用户要求“延迟超 3 天则创建工单”,如果 Agent 查到了延迟 4 天,却没创建工单,即使最后总结写得很像,也算失败。
宽松成功率
最终结果对用户可接受,但轨迹不完全符合参考路径。
这个指标适合看系统上限。严格成功率更适合上线门槛。
代码示例:
def calc_task_success(run, expected):
constraints = expected.get("constraints", {})
must_use_tools = set(expected.get("must_use_tools", []))
used_tools = {
step["tool_name"]
for step in run["steps"]
if step.get("type") == "tool_call" and step.get("status") == "success"
}
if not must_use_tools.issubset(used_tools):
return False
if constraints.get("ticket_required_if_delayed"):
delayed_days = None
created_ticket = False
for step in run["steps"]:
if step.get("tool_name") == "query_order_logistics":
delayed_days = step.get("tool_result", {}).get("delay_days")
if step.get("tool_name") == "create_ticket" and step.get("status") == "success":
created_ticket = True
if delayed_days is not None and delayed_days > constraints["delay_threshold_days"] and not created_ticket:
return False
return run.get("final_result") == "success"
这个函数不长,但足够表达一个原则:Agent 成功率必须和业务规则绑定。不能只看模型自己说“我完成了”。
3.2 工具调用质量
这是我在多轮工作流里最常看的部分。因为大量失败不是出在“不会选工具”,而是出在“参数看着差不多,实际不能用”。
我会把工具调用质量拆成以下几个分数:
- 工具选择准确率
- 参数完整率
- 参数合法率
- 工具调用成功率
- 无效调用率
- 重复调用率
注意一下。无效调用率和重复调用率,经常比成功率更能反映 Agent 是否稳定。
参数合法率示例:
TOOL_SCHEMAS = {
"query_order_logistics": {
"required": ["order_id"]
},
"create_ticket": {
"required": ["type", "order_id"],
"enum": {
"type": ["urge_delivery", "refund", "complaint"]
}
}
}
def score_tool_arguments(tool_name, args):
schema = TOOL_SCHEMAS.get(tool_name, {})
required = schema.get("required", [])
enum_fields = schema.get("enum", {})
missing = [k for k in required if k not in args or args[k] in [None, ""]]
invalid = []
for field, candidates in enum_fields.items():
if field in args and args[field] not in candidates:
invalid.append(field)
return {
"is_complete": len(missing) == 0,
"is_valid": len(missing) == 0 and len(invalid) == 0,
"missing_fields": missing,
"invalid_fields": invalid
}
上线前,我会单独拉一张“缺参 Top10”报表。很有用。通常看两轮数据,就能知道 prompt 该改,还是 tool schema 该补默认值。
3.3 过程一致性指标
多轮 Agent 有一个常见问题:前面判断对了,后面又绕回去了。
比如:
- 第 2 步已经查到用户身份
- 第 4 步又重复校验一次
- 第 5 步忘了之前查到的限制条件
- 第 6 步调用了冲突工具
这种问题最终会体现在耗时、费用、失败率上。我一般用下面两个指标抓它:
轨迹偏移率
对比参考轨迹,统计是否出现不该有的动作,或漏掉关键动作。
状态一致性得分
检查上下文变量在多步中是否一致,例如 order_id、user_id、工单类型、时间条件。
示例:
def calc_state_consistency(run):
tracked_fields = ["order_id", "user_id", "type"]
values = {field: set() for field in tracked_fields}
for step in run["steps"]:
args = step.get("arguments", {})
for field in tracked_fields:
if field in args and args[field] not in [None, ""]:
values[field].add(str(args[field]))
inconsistent = {
field: list(v)
for field, v in values.items()
if len(v) > 1
}
return {
"score": 1.0 - len(inconsistent) / max(len(tracked_fields), 1),
"inconsistent_fields": inconsistent
}
这个指标很朴素,但实测很有价值。尤其在带记忆的 Agent 里,字段漂移问题很常见。
3.4 恢复能力
真实环境里,工具超时、接口 500、字段缺失都很常见。Agent 遇到异常后怎么处理,是不是能补问用户、是不是会自动重试,这部分建议单独测。
可记录:
- 异常后恢复成功率
- 平均恢复步数
- 错误升级率
- 误恢复率
所谓误恢复率,就是本来应该停下来问用户,结果它自己编了个参数继续跑。这类问题风险很高。
4. 失败归因:没有分类,评估就只是报数
很多团队做完评测,会得出一句话:A 版本 74%,B 版本 79%。然后呢?不知道该改哪里。
我建议固定一套失败类型,所有版本都按同一口径打标签。别太多。太多就没人用了。我常用的是下面这套:
| 失败类型 | 定义 | 例子 |
|---|---|---|
| planning_error | 任务拆解错误 | 该先查订单却先创工单 |
| tool_selection_error | 工具选错 | 查物流用了订单详情工具 |
| argument_error | 参数缺失或非法 | order_id 漏传 |
| state_drift | 中间状态漂移 | 前后两次使用不同订单号 |
| tool_execution_error | 工具执行失败未恢复 | 接口超时后直接结束 |
| response_error | 最终回复不满足要求 | 没有告知工单编号 |
| policy_violation | 违反业务约束 | 未授权情况下发起退款 |
这套分类里,我个人最看重 argument_error 和 state_drift。因为这两类最容易被表面成功率掩盖。
归因逻辑可以半自动化:先走规则,再让 LLM 辅助补标签,但最终落盘要是固定枚举值。
示例:
def classify_failure(run, expected):
used_tools = [
step for step in run["steps"]
if step.get("type") == "tool_call"
]
for step in used_tools:
score = score_tool_arguments(step["tool_name"], step.get("arguments", {}))
if not score["is_valid"]:
return "argument_error"
consistency = calc_state_consistency(run)
if consistency["inconsistent_fields"]:
return "state_drift"
failed_tools = [s for s in used_tools if s.get("status") == "failed"]
if failed_tools:
return "tool_execution_error"
required_tools = set(expected.get("must_use_tools", []))
actual_tools = {s["tool_name"] for s in used_tools}
if not required_tools.issubset(actual_tools):
return "tool_selection_error"
return "response_error"
这不是完美归因,但够稳定。评估框架先追求稳定,再慢慢加细。
5. 离线评测集怎么构造,结果才有参考价值
评测集如果都是“标准输入 + 标准路径”,最后往往会得到一个偏乐观的结论。生产流量不是这样的。
我会把数据分成几类,按比例混合:
- 正常任务样本:流程清楚,参数完整
- 缺信息样本:缺订单号、缺时间范围、缺用户身份
- 干扰样本:一句话里夹多个任务,或含无关信息
- 异常样本:工具超时、返回空值、返回脏字段
- 长程样本:8 步以上,有跨轮状态传递
每类样本都要有明确占比。比如我最近一套 600 条评测集,分布大致是:正常任务 260 条,缺信息 110 条,干扰输入 90 条,异常注入 80 条,长程任务 60 条。
数字不大,但够用了。关键是每次版本对比都用同一套集,或者固定 train/dev/test 切分。
5.1 异常注入很有必要
这一点很多人会省掉,但我建议别省。
如果工具层永远返回正确值,你测不出 Agent 的恢复能力。我通常会在回放器里注入几类故障:
- 超时
- 5xx 错误
- 空字段
- 枚举值越界
- 数据冲突
示例:
import random
def maybe_inject_fault(tool_result, fault_rate=0.1):
if random.random() > fault_rate:
return {"status": "success", "result": tool_result}
fault_type = random.choice(["timeout", "empty_field", "server_error"])
if fault_type == "timeout":
return {"status": "failed", "error": "timeout"}
if fault_type == "empty_field":
corrupted = dict(tool_result)
if corrupted:
first_key = list(corrupted.keys())[0]
corrupted[first_key] = None
return {"status": "success", "result": corrupted}
return {"status": "failed", "error": "server_error"}
有了这类数据,你才能看到 Agent 是“真恢复”还是“瞎继续”。
6. 一套最小可用评测流水线
下面给一个简化版工程结构,我自己也差不多按这个拆:
agent_eval/
├── datasets/
│ ├── cases_dev.jsonl
│ └── cases_test.jsonl
├── runners/
│ ├── agent_runner.py
│ └── replay_runner.py
├── evaluators/
│ ├── task_metrics.py
│ ├── tool_metrics.py
│ └── failure_classifier.py
├── reports/
│ └── generate_report.py
└── configs/
└── eval_config.yaml
6.1 评测主程序
import json
from collections import Counter
def load_jsonl(path):
with open(path, "r", encoding="utf-8") as f:
return [json.loads(line) for line in f]
def evaluate(cases, runner):
results = []
failure_counter = Counter()
for case in cases:
run = runner(case)
success = calc_task_success(run, case["expected"])
consistency = calc_state_consistency(run)
if not success:
failure_type = classify_failure(run, case["expected"])
failure_counter[failure_type] += 1
else:
failure_type = None
results.append({
"case_id": case["case_id"],
"success": success,
"latency_ms": run.get("total_latency_ms", 0),
"tokens": run.get("total_tokens", 0),
"consistency_score": consistency["score"],
"failure_type": failure_type
})
success_rate = sum(r["success"] for r in results) / len(results)
avg_latency = sum(r["latency_ms"] for r in results) / len(results)
avg_tokens = sum(r["tokens"] for r in results) / len(results)
avg_consistency = sum(r["consistency_score"] for r in results) / len(results)
return {
"summary": {
"success_rate": round(success_rate, 4),
"avg_latency_ms": round(avg_latency, 2),
"avg_tokens": round(avg_tokens, 2),
"avg_consistency": round(avg_consistency, 4)
},
"failure_breakdown": dict(failure_counter),
"details": results
}
6.2 输出报表
我一般会输出两份:
- 面向研发的明细 CSV
- 面向版本比较的聚合报表 JSON
聚合报表可以长这样:
{
"agent_version": "v3.2.1",
"dataset": "cases_test_v5",
"summary": {
"success_rate": 0.7817,
"avg_latency_ms": 2432.5,
"avg_tokens": 1910.4,
"avg_consistency": 0.9133
},
"failure_breakdown": {
"argument_error": 41,
"state_drift": 27,
"tool_execution_error": 18,
"response_error": 13,
"tool_selection_error": 9
}
}
这个格式很适合接到 dashboard 或 CI。
7. 实测对比:只改 Prompt 不够,工具层约束也得上
这里给一组我做过的缩略版实验数据,场景是工单处理 Agent,评测集 600 条,多轮任务平均 5.8 步。
对比了两个版本:
- V1:纯 Prompt 约束,工具参数只给文字描述
- V2:Prompt 保持接近,增加 JSON Schema 校验、参数纠错器、失败重试规则
结果如下:
| 指标 | V1 | V2 |
|---|---|---|
| 严格任务成功率 | 72.3% | 81.5% |
| 宽松任务成功率 | 79.8% | 85.7% |
| 工具选择准确率 | 91.2% | 92.6% |
| 参数合法率 | 76.4% | 89.1% |
| 状态一致性得分 | 0.88 | 0.93 |
| 平均耗时 | 2210ms | 2485ms |
| 平均 token | 1684 | 1819 |
我当时的第一反应很直接:没想到提升最大的不是“选工具”,而是“参数合法率”。
这很说明问题。很多时候 Agent 看起来像在“思考错误”,实际是工程层没把参数约束收紧。V2 的代价也很清楚,耗时多了 275ms,token 多了 135。这个代价我能接受,因为成功率提升了 9.2 个点。
再看失败归因分布:
| 失败类型 | V1 | V2 |
|---|---|---|
| argument_error | 58 | 23 |
| state_drift | 31 | 19 |
| tool_execution_error | 16 | 17 |
| response_error | 21 | 15 |
| tool_selection_error | 19 | 14 |
可以看到,V2 把 argument_error 压得很明显,但 tool_execution_error 没降。这说明下一步该查工具网关和重试策略,而不是继续死抠 prompt。
这种结论,只有做了失败归因才看得出来。
8. 把评估接进 CI,版本回归才有意义
如果评测只在出问题时手动跑一次,后面很容易失效。我比较推荐接到 CI 或定时任务里。
一个简单做法:
- 每次 Agent Prompt、Planner、Tool Router 变更后自动跑 dev 集
- 每天凌晨固定跑 test 集
- 成功率下降超过阈值时拦截发布
- 某类失败激增时发告警
阈值别设太死。我的经验是:
- 严格成功率下降超过 1.5 个点,阻断
policy_violation或argument_error上升超过 20%,阻断- 平均耗时上升 15% 以上,提示人工复核
GitHub Actions 示例:
name: agent-eval
on: [push]
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install deps
run: pip install -r requirements.txt
- name: Run eval
run: python runners/run_eval.py --config configs/eval_config.yaml
- name: Check regression
run: python reports/check_regression.py --report reports/latest.json --baseline reports/baseline.json
这部分工程量不大,但收益稳定。回归问题会早很多暴露。
9. 一个常见误区:让 LLM 当总裁判
很多人会直接让另一个 LLM 给 Agent 打分,问它“这次任务有没有完成”。这不是不能用,但别全交给它。
我自己的做法是:
- 规则优先:工具是否调用、参数是否合法、约束是否满足,这些用代码判
- LLM 辅助:最终回复是否覆盖了关键信息、失败描述是否合理,这类可用模型辅助
原因很简单。规则类问题交给模型,结果会飘,而且不同模型版本之间评分口径不稳定。评估系统本身也要可复现。
短句提醒:裁判也得可测。
10. 我现在推荐的最小实践
如果你正准备给多轮 Agent 上评测,我建议先别追求大而全,先做一个能稳定跑起来的版本:
- 定义 200 到 500 条有代表性的任务样本
- 固定运行日志结构,把每一步工具调用记录全
- 先算四个核心指标:严格成功率、参数合法率、状态一致性、失败归因分布
- 给工具层补 schema 校验和异常注入
- 接入 CI 做版本回归
这套东西跑通之后,再去加 LLM-as-a-judge、轨迹相似度、分场景报表,会轻松很多。
唯一的小缺点是前期样本标注比较费时间。我这边第一次整理 600 条数据,两个工程师花了接近 3 天。但后面版本对比就省事了,基本都是增量维护。
11. 结尾
多轮 Agent 的评估,核心不是“分数算得多花”,而是你能不能把失败定位到可修改的工程点。任务成功率回答的是“有没有做成”,工具调用质量回答的是“哪里容易出错”,失败归因回答的是“下一步该改什么”。
这三块一旦分开记录,版本演进会清楚很多。尤其在业务环境里,很多看上去像模型能力问题的 case,最后查出来其实是工具定义、参数校验、异常恢复没处理好。
如果你也在做 Agent 工作流评测,可以把你现在用的指标贴出来,我可以下一篇继续写一版:如何给 Agent 设计分层 benchmark,区分 Planner、Tool Router 和 Memory 模块的贡献。
更多推荐



所有评论(0)