AI Agent 工具调用准确性评测:选择错误与参数错误分开测
原文:OpenRouter Blog - 《How to Test Tool-Calling Accuracy in AI Agents》(https://openrouter.ai/blog/tutorials/how-to-test-tool-calling-accuracy-in-ai-agents/)
Agent 上线以后最常听到的一句反馈是"它有时候不调工具"。这句话没法直接拿来优化,因为"不调"背后其实是两类完全不同的失败。OpenRouter 在 2026 年 9 月 30 日发了一篇教程,把这个含糊的问题拆成了两个可测的维度,并给了一套能直接跑的评测脚手架。这篇按它的思路整理成一份可落地的测试方案。
一、先分清两类失败
Agent 用工具时只有两个地方会出错:选错工具,或者选对了工具但参数传错。
- 工具选择错误:该调 refund_order 却调了 lookup_order;
- 参数错误:工具选对了,但 order_id 传成了另一单。
这两类失败的修法完全不同。前者要改工具描述和工具数量,后者要改参数 schema 和示例。混在一个"准确率"里算,等于把两个 bug 平均成一个数字。原文提到,DeepEval 把工具正确性和参数正确性做成两个独立指标,Phoenix 也单独提供工具选择评估器,思路是一致的。
工具选择这一侧还有个很容易漏的用例:不需要调工具的请求同样要测。如果只检查回复里有没有工具调用,一个多调了无关工具的模型照样能通过。所以用例集里必须包含"模型已经有足够信息、应该直接回答"的样本,以及需要两步才能完成的样本------比如处理 ord_7281 的退款,正常路径是先查单再发起退款。
参数这一侧要分两步看:结构对不对,以及值对不对。结构层面要拦住的是非法 JSON、缺必填字段、类型写错、枚举值越界、多传了工具不认识的参数。值层面则是另一回事,原文举的例子很典型:一个只带 order_id 的调用,值写成 ord_7282 完全符合 schema,因为 order_id 本来就是字符串,但用户问的是 ord_7281------结构合法不等于值正确。
二、三种评测方法,按能不能机械判断来选
| 方法 | 检查什么 | 适合什么场景 |
|---|---|---|
| 无参考答案的 LLM 评委 | 在具体语境下,这个工具选择或参数值是否合理 | 无法机械判断、多种选择都成立的决策 |
| JSON Schema 校验 | JSON 结构、必填字段、类型、枚举、未声明字段 | 返回调用的结构合法性 |
| 轨迹比对 | 调了哪些工具、必要时是否按顺序 | 有已知标准路径的工作流 |
LLM 评委要喂三样东西:用户的请求、可用的工具列表、模型的实际输出,然后问"选这个工具合不合适",包括"是否本就不该调工具"。它适合搜索类场景,比如一个研究 Agent 同时有 web_search 和 search_internal_docs,哪个更合适取决于用户到底想问什么。用它的时候要固定两样东西:判断标准和评委模型,否则跨模型比较就没意义。原文还补了一条原则:能靠等值比较、schema 校验或业务规则判定的,就别再花一次模型调用。
Schema 校验的关键技巧是复用同一份 schema。发给模型的那份工具定义,直接拿来校验它返回的参数,不用另写一套。工具定义里要显式写 additionalProperties: false,否则多传字段不会被拦下来。
轨迹比对针对多步流程。像查单、校验退款资格、发起退款这种有强制顺序的链路,只看单次调用没用,得看整体序列。原文提到 LangSmith 的轨迹评估器支持严格、无序、子集、超集四种匹配方式;另一套基准的做法更宽松,它把参考动作列表重放一遍得到目标数据库终态,只要某个序列能推出等价终态就算通过。这个判据很实用:如果两个工具谁先谁后都行,就别因为参考轨迹用了另一种顺序而判失败。
三类检查也可以叠加在同一个用例里:比工具名、用 JSON Schema 校参数结构、再比已知参数值。
三、一份可以直接跑的评测脚本
原文给了一套跨模型评测的最小脚手架,先装两个包:
bash
pip install openai jsonschema
主流程完整保留如下:
python
import json, os
from jsonschema import Draft7Validator, ValidationError
from openai import OpenAI
client = OpenAI(base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"])
tools = [{
"type": "function",
"function": {
"name": "lookup_order",
"description": "Look up an order by its ID.",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
"additionalProperties": False,
},
},
}]
# 直接复用发给模型的 schema,不再另写一套
tool_schemas = {t["function"]["name"]: t["function"]["parameters"] for t in tools}
test_cases = [
{
"name": "known order",
"messages": [{"role": "user", "content": "Check the status of order ord_7281."}],
"expected_calls": [{"name": "lookup_order", "arguments": {"order_id": "ord_7281"}}],
},
{
"name": "no tool needed",
"messages": [{"role": "user", "content": "What does an order status of 'shipped' mean?"}],
"expected_calls": [],
},
]
def grade_case(model, case):
response = client.chat.completions.create(
model=model,
messages=case["messages"],
tools=tools,
tool_choice="auto",
extra_body={
"reasoning": {"effort": "low"},
"provider": {"require_parameters": True},
},
)
calls = response.choices[0].message.tool_calls or []
expected = case["expected_calls"]
# 1) 工具选择:整数组比对,而不是只看第一个
tool_selection = [c.function.name for c in calls] == [e["name"] for e in expected]
# 2) 结构:用同一份 schema 校验参数
schema_ok, parsed = [], []
for call in calls:
schema = tool_schemas.get(call.function.name)
if schema is None:
schema_ok.append(False)
continue
try:
args = json.loads(call.function.arguments)
Draft7Validator(schema).validate(args)
except (json.JSONDecodeError, ValidationError):
schema_ok.append(False)
continue
schema_ok.append(True)
parsed.append({"name": call.function.name, "arguments": args})
schema_valid = all(schema_ok) if calls else None
# 3) 取值:结构合法之后再比对具体参数值
values_ok = None
if expected:
values_ok = schema_valid is True and parsed == expected
passed = tool_selection if not expected else (
tool_selection and schema_valid is True and values_ok is True
)
return {"tool_selection": tool_selection, "schema_valid": schema_valid,
"argument_values": values_ok, "passed": passed}
几个参数值得单独说清楚:
- tool_choice 设为 auto,这是配了工具之后的默认行为,保持默认才能测出真实的自主选择能力;
- extra_body 里的 reasoning.effort 统一设成 low,避免候选模型默认推理档位不同带来的干扰;
- provider.require_parameters 设为 true,保证请求只路由到支持全部参数的供应商,否则被测的就不是你写的那份参数了;
- 刻意不设 temperature,因为部分模型不在 supported_parameters 里声明它;同理不设 max_tokens,截断会切掉工具调用返回的 JSON,制造出假的 JSON 解析失败。
原文还提醒,每个用例都要跑多次,用例集里要补上难例:缺参数、工具描述高度相似、一次要调多个工具、以及根本不该调工具的请求。这套脚手架评估的是单轮工具调用,多步流程要在完整 trace 上收集调用再评分。
四、跨模型比较时,别把变量也一起换了
脚本最后会打印每个模型通过多少条用例,但只有在用例、评分逻辑、模型设置、路由配置四样都保持一致时,这个数字才有可比性。原文在常见错误里列了四条,都是踩出来的:
- 只测干净请求:真实用户会缺信息、会问两个相似工具该用哪个、会问一句根本不需要工具的话,这些都必须进用例集;
- 只看第一个工具调用:一次回复可能带多个工具调用,要整数组比对,否则漏判;
- 把合法调用当成正确调用:schema 只管结构,值对不对------哪个客户、哪一单、什么日期、多少钱------它管不了;
- 在不同模型之间改评测:工具、提示词、评委、设置、路由改任何一样,比较就作废。
还有一条路由细节容易被忽略:在带工具的请求上,平台默认会启用按工具调用错误率重排供应商的机制。想测真实生产链路就保持默认;想测某一个具体端点,就用 order 字段钉住供应商并关掉回退。另外,即使平台侧已经有工具调用错误率的统计,把结构失败分成非法 JSON、未知工具名、schema 不匹配三类,本地 harness 里的 schema 校验仍然要留着,因为两者测的层级不同。
五、和同系列另外两篇的配合
OpenRouter 同期还发了另外两篇教程,讲的正好是这套脚手架的前后两步。一篇是从生产流量构建 golden 评测集,建议先抽 20 到 50 条真实请求人工复核,再扩到 100 到 1000 条完整回归集,流程是抽样、去重聚类、补预期输出、首轮评估修正评分标准、提交 Git 接入 CI,核心观点是用真实流量而不是合成数据,才能保住请求的分布和失败模式。另一篇讲提示词、模型、工具定义或检索设置变更之后,重跑锁定的用例集对照书面行为契约做回归。
三篇串起来就是一条完整链路:用真实流量建集,把工具调用拆成两个维度评,每次变更后回归重跑。
回到最开始那句"它有时候不调工具",现在可以拆成三个能出数字的问题:工具选择错了多少、参数结构错了几条、参数值错了几条。数字分开之后,该改描述、该改 schema 还是该改模型,一眼就能看出来。