深度对比OpenAI/Claude/Gemini等模型的函数调用实现差异,提供基准测试方法。多模型Agent开发和集成的实战指南。
LLM 函数调用:可复现的提供商对比 | Agent Lab Journal
Agent Lab Journal
指南
术语表
实践评估 · 中级
阅读时长 35 分钟
中级
更新于 2026 年 8 月 1 日
向多个模型提供相同的函数描述,它们可能会选择不同的
工具、遗漏不同的参数、以不同方式规范化值,或者以互不兼容的
方式从错误中恢复。因此,不能仅凭一次成功的演示来判断
集成是否可靠。它需要一套可重复的测试,将模型行为、
提供商协议、模式验证和应用端恢复区分开来。
为什么相同的工具会有不同的表现
为什么相同的工具会有不同的表现
具体测试用例
具体测试用例
定义与提供商无关的契约
定义与提供商无关的契约
创建测试数据集
创建测试数据集
规范化提供商响应
规范化提供商响应
构建基准测试运行器
构建基准测试运行器
为工具选择、参数和恢复能力评分
为工具选择、参数和恢复能力评分
验证可复现性
验证可复现性
为什么相同的工具会有不同的表现
函数调用是一种协议,
在该协议中,模型请求宿主应用使用结构化参数执行一个具名操作。
模型本身并不运行函数。它会生成一个建议调用;你的应用负责验证它、
执行操作,并且可以将结果返回给模型。
在实践中,LLM 函数调用并不存在一种通用的传输
格式。各提供商在请求字段、支持的
JSON Schema 特性、工具选择
控制、响应封装、流式事件、并行调用行为,以及返回工具结果所需的
消息等方面都存在差异。模型又增加了另一层变数:
它们会以不同方式理解描述和含糊的用户请求。
外围的 LLM API 同样会产生影响。提供商可能会在推理之前拒绝
不受支持的模式关键字,也可能静默忽略它,或者接受该
模式,但模型随后又违反它。不应将这些结果笼统地归入
单一的“函数调用失败”指标。
一种有效的比较方法会区分以下四个阶段:
请求接受:提供商是否接受了工具模式?
请求接受:提供商是否接受了工具模式?
工具决策:模型是调用了工具、选择不调用,还是选错了工具?
工具决策:模型是调用了工具、选择不调用,还是选错了工具?
参数构造:提供的值在结构和语义上是否正确?
参数构造:提供的值在结构和语义上是否正确?
恢复:在发生验证错误或执行错误之后,模型是否恰当地修复了调用?核心原则:记录原始请求和响应,但应对规范化后的表示进行评分。两者缺一不可,否则提供商语法可能会扭曲比较结果,而规范化过程中的错误也可能变得不可见。
恢复:在发生验证错误或执行错误之后,模型是否恰当地修复了调用?
核心原则:记录原始请求和响应,但应对
规范化后的表示进行评分。两者缺一不可,否则提供商语法可能会扭曲
比较结果,而规范化过程中的错误也可能变得不可见。
具体案例:客服运营助手
我们将针对一个虚构的客服工作流测试三个只读工具。其中不包含任何真实的
客户数据、凭据或提供商实测结果。当你使用获准使用的模型
运行这套基准测试时,它会自行生成证据。
{
"tools": [
{
"name": "lookup_order",
"description": "Find one order by its exact public order ID.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": "^ORD-[0-9]{6}$",
"description": "Public order ID, for example ORD-104821."
}
},
"required": ["order_id"],
"additionalProperties": false
}
},
{
"name": "search_orders",
"description": "Search orders when an exact order ID is not available.",
"parameters": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Customer email address."
},
"status": {
"type": "string",
"enum": ["processing", "shipped", "delivered", "cancelled"]
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
}
},
"required": ["email"],
"additionalProperties": false
}
},
{
"name": "get_shipping_quote",
"description": "Estimate shipping price and delivery window for a destination.",
"parameters": {
"type": "object",
"properties": {
"country_code": {
"type": "string",
"pattern": "^[A-Z]{2}$",
"description": "ISO-style two-letter uppercase country code."
},
"postal_code": {
"type": "string",
"description": "Postal code as user-provided text; preserve leading zeros."
},
"weight_grams": {
"type": "integer",
"minimum": 1,
"maximum": 50000
},
"service": {
"type": "string",
"enum": ["standard", "express"]
}
},
"required": ["country_code", "postal_code", "weight_grams"],
"additionalProperties": false
}
}
]
}
这组工具无需执行破坏性操作,便可暴露一些常见问题:
功能重叠的查询与搜索函数、带格式的标识符、枚举、有边界限制的
整数、可选默认值、大写规范化,以及必须
保持字符串类型的邮政编码。
lookup_order 与
search_orders 之间的区别尤其有价值。一个仅仅注意到
“order”这个词的模型无法持续通过测试;它必须判断用户是否
提供了精确的标识符。
首先定义与提供商无关的契约
不要一开始就在整个应用中照搬某个提供商的请求对象。
应先定义一个小型的内部表示,再在每个
LLM API 的边界处进行转换。这样,基准测试就只有一个事实来源。
export type CanonicalTool = {
name: string;
description: string;
parameters: Record<string, unknown>;
};
export type CanonicalToolCall = {
id: string | null;
name: string;
arguments: unknown;
};
export type CanonicalOutcome =
| { kind: "tool_calls"; calls: CanonicalToolCall[] }
| { kind: "text"; text: string }
| { kind: "refusal"; text: string | null }
| { kind: "provider_error"; code: string; message: string };
在完成验证之前,应将解析后的参数保留为 unknown。如果适配器
将 "750" 转换为 750、删除意外属性,或者
在评分前补充默认值,就会掩盖模型的真实输出。
在主要比较中使用模式交集
各提供商支持的模式子集并不相同。主基准测试应从
保守的结构开始:object、string、integer、number、boolean、array、
properties、required、enum 和简单的边界限制。将高级关键字放在
单独的兼容性测试套件中。
维护两个模式文件:
tools.core.json 包含用于比较模型的可移植模式。
tools.core.json 包含用于比较模型的可移植模式。
tools.compatibility.json 用于探测 pattern、联合类型、嵌套约束和严格性控制等关键字。例如,如果某个提供商不接受 pattern,只应在该提供商的兼容性适配器中移除它,并记录这项转换。此后不要再声称所有模型收到的约束在语义上完全相同。
tools.compatibility.json 用于探测 pattern、联合类型、嵌套约束和严格性控制等关键字。
例如,如果某个提供商不接受 pattern,只应在
该提供商的兼容性适配器中移除它,并记录这项转换。此后不要再
声称所有模型收到的约束在语义上完全相同。
固定指令
You are a support operations assistant.
Use a tool only when the user's request requires information supplied by that tool.
Never invent missing required values.
If exactly one required value is missing, ask one concise clarification question.
Use lookup_order only when an exact public order ID is present.
Use search_orders when the user supplies an email address but no exact order ID.
Do not call tools for greetings, explanations of policy, or unsupported actions.
一个测试行需要不仅仅是预期的函数名。它应该说明是否预期调用、确切可接受的参数,以及为什么替代行为是错误的。
{"id":"select-lookup-01","prompt":"Where is order ORD-104821?","expected":{"kind":"tool","name":"lookup_order","arguments":{"order_id":"ORD-104821"}},"tags":["selection","exact-id"]}
{"id":"select-search-01","prompt":"Show my shipped orders. My email is sam@example.test.","expected":{"kind":"tool","name":"search_orders","arguments":{"email":"sam@example.test","status":"shipped"}},"tags":["selection","enum"]}
{"id":"abstain-01","prompt":"Hello! What can you help me with?","expected":{"kind":"text"},"tags":["abstention"]}
{"id":"clarify-01","prompt":"How much is express shipping to Germany for 750 grams?","expected":{"kind":"clarification","missing":["postal_code"]},"tags":["missing-required"]}
{"id":"types-01","prompt":"Quote standard shipping for 750 grams to US postal code 02139.","expected":{"kind":"tool","name":"get_shipping_quote","arguments":{"country_code":"US","postal_code":"02139","weight_grams":750,"service":"standard"}},"tags":["types","leading-zero"]}
{"id":"normalize-01","prompt":"Shipping quote for 1 kg to ca, M5V 3L9.","expected":{"kind":"tool","name":"get_shipping_quote","arguments":{"country_code":"CA","postal_code":"M5V 3L9","weight_grams":1000}},"tags":["normalization","unit-conversion"]}
{"id":"unsupported-01","prompt":"Cancel every order on my account.","expected":{"kind":"text"},"tags":["unsupported","safety"]}
{"id":"conflict-01","prompt":"Find ORD-104821. My email is sam@example.test.","expected":{"kind":"tool","name":"lookup_order","arguments":{"order_id":"ORD-104821"}},"tags":["selection","overlap"]}
使用保留域名(如 example.test)和合成标识符。由于第一个评估阶段在建议调用后停止,fixture 不需要实时订单数据库。
正向选择:请求明确映射到恰好一个函数。
正向选择:请求明确映射到恰好一个函数。
易混淆选择:两个工具共享词汇,但只有一个满足请求。
易混淆选择:两个工具共享词汇,但只有一个满足请求。
弃权:不需要或没有可用的工具。
弃权:不需要或没有可用的工具。
参数压力:值需要转换、规范化或保留字符串格式。
参数压力:值需要转换、规范化或保留字符串格式。
缺失信息:模型必须询问而不是猜测必需值。添加释义,但保留每个原始提示。随时替换用例会使历史比较变得不可能。实用的约定是在每个运行清单中包含 dataset_version: "1.0.0"。
缺失信息:模型必须询问而不是猜测必需值。
添加释义,但保留每个原始提示。随时替换用例会使历史比较变得不可能。
实用的约定是在每个运行清单中包含 dataset_version: "1.0.0"。
每个提供商适配器应该有三个责任:
将规范工具和消息转换为提供商请求。
将规范工具和消息转换为提供商请求。
提取文本、拒绝、错误和每个建议的工具调用。
提取文本、拒绝、错误和每个建议的工具调用。
为诊断保留未修改的请求和响应。保持其余部分与提供商无关。以下接口足以进行初次基准测试:
为诊断保留未修改的请求和响应。
保持其余部分与提供商无关。以下接口足以进行初次基准测试:
export interface ModelAdapter {
provider: string;
model: string;
run(input: {
system: string;
prompt: string;
tools: CanonicalTool[];
toolChoice: "auto" | "required" | "none";
temperature?: number;
}): Promise<{
outcome: CanonicalOutcome;
rawRequest: unknown;
rawResponse: unknown;
latencyMs: number;
usage?: {
inputTokens?: number;
outputTokens?: number;
};
}>;
}
名为 auto 的模式可以允许文本或调用。名为 required 的模式可能会强制任何工具、特定工具或至少一个工具,具体取决于提供商。在每个适配器旁边记录实际语义。
使用最接近的可用 auto 等效项运行主选择测试。将强制工具行为作为单独的实验运行。否则,具有强制调用的提供商在生成弃权情况下的不可用调用时,可能看起来具有更好的召回率。
实现函数调用 OpenAI 支持时,将当前请求和响应转换隔离在其自己的适配器中。不要使规范格式依赖于提供商特定的消息角色、调用 ID、严格性开关或流式事件名称。对每个其他提供商应用相同的规则。本文章故意避免固定端点和模型标识符,因为这些操作细节可能会改变;在运行清单中 pin 您环境中可用的确切值。
export function normalizeArguments(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return {
__parse_error: true,
__raw: value
};
}
}
解析 JSON 字符串是协议规范化。转换单位、修复枚举拼写、删除未知键或更改类型是语义修复。如果您的生产堆栈执行这些修复,请分别对它们进行评分。
运行器针对每个配置的模型执行每个用例、重复用例、写入仅追加记录,并且在选择阶段不调用任何真实业务工具。
type TestCase = {
id: string;
prompt: string;
expected:
| { kind: "tool"; name: string; arguments: Record<string, unknown> }
| { kind: "text" }
| { kind: "clarification"; missing: string[] };
tags: string[];
};
for (const adapter of adapters) {
for (const testCase of cases) {
for (let repeat = 0; repeat < config.repeats; repeat++) {
const startedAt = new Date().toISOString();
try {
const result = await adapter.run({
system: fixtures.system,
prompt: testCase.prompt,
tools: fixtures.tools,
toolChoice: "auto",
temperature: 0
});
await appendRecord({
runId,
startedAt,
datasetVersion: config.datasetVersion,
provider: adapter.provider,
model: adapter.model,
caseId: testCase.id,
repeat,
expected: testCase.expected,
outcome: result.outcome,
latencyMs: result.latencyMs,
usage: result.usage,
rawRequest: redact(result.rawRequest),
rawResponse: redact(result.rawResponse)
});
} catch (error) {
await appendRecord({
runId,
startedAt,
provider: adapter.provider,
model: adapter.model,
caseId: testCase.id,
repeat,
harnessError: serializeError(error)
});
}
}
}
}
温度为零可以减少变化,但不能证明确定性行为。提供商可能会改变服务基础设施、模型修订或解码内部结构。重复每个用例足够多次以观察不稳定性,而不是假设它不存在。
function-benchmark/
├── fixtures/
│ ├── system.txt
│ ├── tools.core.json
│ ├── tools.compatibility.json
│ └── cases.jsonl
├── src/
│ ├── adapters/
│ │ ├── provider-a.ts
│ │ └── provider-b.ts
│ ├── normalize.ts
│ ├── validate.ts
│ ├── score.ts
│ └── run.ts
├── results/
├── package.json
├── tsconfig.json
└── benchmark.config.json
mkdir function-benchmark
cd function-benchmark
npm init -y
npm install ajv
npm install --save-dev typescript tsx @types/node
npx tsc --init
npx tsx src/run.ts --config benchmark.config.json
仅安装你实际使用的适配器所需的提供商 SDK,并在锁文件中锁定其版本。
优先使用限定到各个提供商作用域的环境变量名。切勿将密钥写入测试夹具、
结果文件、控制台快照或版本控制系统。
{
"datasetVersion": "1.0.0",
"repeats": 5,
"temperature": 0,
"timeoutMs": 30000,
"concurrency": 1,
"models": [
{
"provider": "provider-a",
"model": "replace-with-pinned-model-id"
},
{
"provider": "provider-b",
"model": "replace-with-pinned-model-id"
}
]
}
从并发数 1 开始。这样更容易诊断速率限制和响应顺序问题。只有在基线稳定后
才提高并发数,并将其作为实验配置的一部分记录下来。
分别对工具选择和参数评分
单一的通过率会掩盖重要差异。模型可能选择了正确的工具,却虚构了一个必需参数;
另一个模型也可能为错误的操作生成完全正确的参数。应将它们视为不同的失败类型。
记录 llm api 是否接受了请求。建议使用以下类别:
harness_error 不要将身份验证、速率限制或测试框架故障计为模型决策错误。应将它们报告为覆盖率损失。
不要将身份验证、速率限制或测试框架故障计为模型决策错误。
应将它们报告为覆盖率损失。
使用以下结果构建混淆矩阵:
预期不调用工具时却调用了工具;
预期不调用工具时却调用了工具;
预期调用工具时却未调用;
预期调用工具时却未调用;
预期恰好调用一次时却进行了多次调用;
预期恰好调用一次时却进行了多次调用;
测试夹具预期请求澄清时进行了澄清;
测试夹具预期请求澄清时进行了澄清;
已经具备足够信息时仍请求澄清。最后这一区分很重要。提出一个安全但不必要的问题,或许可以避免格式错误的调用,但仍会降低用户体验。
已经具备足够信息时仍请求澄清。
最后这一区分很重要。提出一个安全但不必要的问题,或许可以避免格式错误的调用,
但仍会降低用户体验。
根据规范 schema 验证模型未经修改的参数。使用 Ajv:
import Ajv from "ajv";
const ajv = new Ajv({
allErrors: true,
strict: false,
useDefaults: false,
coerceTypes: false,
removeAdditional: false
});
export function validateCall(
schema: Record<string, unknown>,
args: unknown
) {
const validate = ajv.compile(schema);
const valid = validate(args);
return {
valid: Boolean(valid),
errors: validate.errors ?? []
};
}
禁用类型强制转换、默认值插入和属性移除是有意为之。
这样可以防止验证器在测量输出之前对其进行改善。
schema 有效是必要条件,但并不充分。weight_grams: 1 是一个有效整数,
但当用户说“1 kg”时,它就是错误的。验证完成后,将值与测试夹具进行比较。
export function compareExpected(
expected: Record<string, unknown>,
actual: Record<string, unknown>
) {
const missing = Object.keys(expected).filter((key) => !(key in actual));
const wrong = Object.entries(expected)
.filter(([key, value]) => key in actual && actual[key] !== value)
.map(([key, value]) => ({
key,
expected: value,
actual: actual[key]
}));
const unexpected = Object.keys(actual)
.filter((key) => !(key in expected));
return {
exact: missing.length === 0 && wrong.length === 0 && unexpected.length === 0,
missing,
wrong,
unexpected
};
}
某些可选参数可能存在多个正确输出。应编码明确的断言,
而不是强制要求对象完全相等:
{
"arguments": {
"requiredEquals": {
"email": "sam@example.test",
"status": "shipped"
},
"optional": ["limit"],
"constraints": {
"limit": { "integer": true, "minimum": 1, "maximum": 20 }
}
}
}
对于每个模型和测试用例,使用稳定哈希对重复运行后经过规范化的结果进行分组。
一个简单的稳定性度量方式是:
stability = count(most_common_normalized_outcome) / successful_repeats
同时报告分子和分母。如果不提供成功重复运行的次数,单独一个数值可能具有误导性,
尤其是在提供商错误导致部分观测结果缺失时。
建议的报告列
字段
揭示的内容
已接受的请求
协议和 schema 兼容性
选择准确率
是否选择了正确的工具或正确决定不调用工具
schema 有效的调用
修复前的结构合规性
语义完全正确的调用
参数是否与用户请求一致
澄清准确率
是否在不猜测的情况下处理了缺失数据
恢复成功率
失败的调用是否得到了正确修复
按测试用例统计的稳定性
重复运行的结果是否一致
提供商错误数量
计划中的测试实际得到了多少观测
将验证和执行错误作为对话进行测试
生产环境中的 llm 函数调用并不会在发出调用后结束。
应用程序可能拒绝参数,目标系统可能拒绝操作,或者返回的结果可能表明模型需要使用另一个工具。
添加第二套测试,用于执行确定性的伪工具。
模拟工具应返回可预测的结果和错误,而不应连接外部服务。
export function fakeLookupOrder(args: unknown) {
const validation = validateCall(lookupOrderSchema, args);
if (!validation.valid) {
return {
ok: false,
error: {
code: "INVALID_ARGUMENTS",
message: "The tool arguments did not match the schema.",
details: validation.errors