手把手驯服 Agent 的工具调用:Function Calling 原理、实战与生产避坑
你的 Agent 是不是经常"嘴上答应调用工具,实际却编了一个答案"?或者调了工具却把嵌套 JSON 传成了字符串,让下游 API 直接 400?本文用一个电商客服 Agent 的真实场景,把 Tool Calling / Function Calling 从底层约定、五步闭环到生产级防坑,一次讲透。
开篇:从一个真实业务场景说起
你在做一个电商 AI 客服 Agent。用户问:"帮我查一下明天上海适不适合跑步,顺便看看有没有便宜一点的户外外套。"
你的第一版实现很简单:把这个问题丢给大模型,让它在 System Prompt 里"按固定格式输出"。结果模型一会儿输出 ACTION: get_weather(上海),一会儿输出 {"action": "weather", "city": "上海, 明天"},一会儿干脆用自然语言说"我建议您查询天气预报"。你用正则去抠,解析失败率居高不下,Agent 三天两头答非所问。
后来你换成了厂商原生的 Function Calling(函数调用)能力:把 get_weather、search_products 两个工具的 JSON Schema 声明给模型,模型乖乖返回结构化的 tool_calls,参数还是解析好的 JSON。问题似乎解决了——直到生产环境把你按在地上摩擦:模型在该并行调用时串行、把嵌套对象序列化成字符串、工具超时后整个 Agent 卡死、同一个 Schema 换一家模型就翻车……
这不是你的问题,是工具调用这件事本身,远比"把工具声明给模型"复杂得多。本文就围绕这五个问题展开:模型到底是怎么"调用"工具的?工具 Schema 该怎么设计?并行、重试、校验这些工程细节怎么落地?多模型怎么选型?生产怎么监控? 看完你就能写出一个稳定、可控、可观测的工具调用层。
技术背景与核心概念扫盲
一句话理解工具调用
大模型本质是一个文本生成器(LLM, Large Language Model),它根本不会执行任何函数、发不出任何 HTTP 请求。 所谓"Agent 调用了天气 API",真实发生的事情是:
- 你把工具的描述(JSON Schema)随请求一起发给模型;
- 模型在需要时,生成一段结构化文本——"我想调用
get_weather,参数是city=北京"; - 这段文本被 API 包装成专门的
tool_calls字段返回; - 你的代码负责真正执行这个函数,把结果再以
tool消息喂回给模型; - 模型看到工具结果,生成最终回答。
这个"模型负责决策(调什么、传什么参数),你的代码负责执行(真的去调)"的分工,是理解后面一切问题的基石。字节跳动团队在 Agent 工具设计实践中反复强调同一件事:工具调用的难点不在工具本身,而在"决策"——模型什么时候调、调哪个、顺序是什么、缺信息时要不要追问。
技术演进:从正则硬解析到一等公民
工具调用机制经历了三个阶段(这也是理解各家 API 差异的钥匙):
- 阶段一:纯 Prompt 硬解析(2022 及更早)。模型没有工具概念,开发者只能在提示词里写"如果需要查天气,请严格按
ACTION: get_weather(城市名)输出",然后用正则去抠。模型多打个空格、把中文括号写成英文、甚至用自然语言绕开格式,解析就崩。这是早期 Agent 不稳定的主因。 - 阶段二:原生 Function Calling(2023 年中,OpenAI 首推)。厂商把"工具"变成 API 的一等公民:请求里用专门的
tools字段传 JSON Schema,模型返回时用专门的tool_calls字段给出结构化调用意图,参数已是解析好的 JSON。模型为此做了专门微调,格式遵循度大幅提升。Anthropic 的 Tool Use、Google 的 Function Calling、阿里云百炼的 Function Calling 都是同一思路的平行实现。 - 阶段三:协议化与代码编排(2024 至今)。MCP(Model Context Protocol,模型上下文协议)把工具声明、发现、调用标准化成统一协议;OpenAI 在 2025 年 11 月又推出 Programmatic Tool Calling(PTC,程序化工具调用),让模型直接生成 Python/JavaScript 代码来编排工具调用,把循环、分支、聚合从"多轮 API 往返"压缩成"一段代码执行"。
核心概念速查
| 概念 | 英文 | 一句话说明 |
|---|---|---|
| 工具调用 | Tool Calling / Function Calling | 模型输出结构化调用意图,由应用执行 |
| 工具 Schema | Tool Schema | 用 JSON Schema 描述工具的名称、描述、参数 |
| 工具选择 | tool_choice | 控制模型何时必须/禁止调用工具 |
| 并行工具调用 | Parallel Tool Calls | 单轮响应返回多个工具调用,应用并发执行 |
| 工具消息 | Tool Message | 把工具执行结果以结构化消息回传给模型 |
| 结构化输出 | Structured Outputs | 强制模型输出符合指定 JSON Schema 的结果 |
底层原理深度拆解
五步闭环:工具调用是怎么发生的
以 OpenAI Responses API(2026 年官方推荐的新一代端点,取代旧的 Chat Completions API 与即将于 2026 年中期停用的 Assistants API)为例,官方把工具调用流程定义为五个高层步骤:
flowchart LR
A[1. 请求: input + tools 工具声明] --> B[2. 响应: 模型返回 tool_calls<br/>函数名 + 参数JSON]
B --> C[3. 应用侧执行工具<br/>调用真实函数/API]
C --> D[4. 二次请求: 携带 tool 消息<br/>回传执行结果]
D --> E{5. 模型再决策}
E -->|还有工具要调| B
E -->|完成| F[最终自然语言回复]
图 1:工具调用五步闭环(OpenAI 官方流程的工程化示意)。注意第 3 步永远发生在你的代码里,模型只负责第 2 步和第 5 步的"决策"。
这一步拆解很重要,因为它揭示了三个新手最容易忽略的真相:
- 工具调用是多轮对话,不是一次请求。一次工具调用至少要两次 API 往返(第一次拿到 tool_calls,第二次带着工具结果回去)。如果模型连续调了 3 个工具,就是 4 次往返。这也是为什么工具执行耗时占 Agent 总延迟的 35%–60%(编码类任务偏高,研究类居中)。
- 消息历史必须完整回传。第二次请求时,必须把模型上一条 assistant 消息(含 tool_calls)和工具执行结果按顺序都带上,否则模型不知道"这个结果对应我之前的哪个调用"。OpenAI 用
tool_call_id建立这种对应关系。 - 结果回传的质量直接决定后续决策质量。工具返回空字符串、返回一堆无关日志、返回异常堆栈,模型就会"看不懂",进而产生幻觉或死循环。阿里云百炼的官方文档专门提醒:工具执行是写操作(发邮件、传文件)时,建议返回状态描述信息(如"邮件发送完成")而非空结果,帮助模型理解执行状态。
模型端到底发生了什么
模型之所以能稳定输出符合 Schema 的 tool_calls,是因为厂商对这些模型做了专门的指令微调(Instruction Tuning):训练数据里包含"自然语言问题 → 工具调用 JSON"的配对样本。所以"格式遵循度"本质上是模型能力的一部分,不同模型差异巨大(后面性能实测部分会给出量化数据)。
模型端的关键参数是 tool_choice,它定义了模型调用工具的"自由度":
| tool_choice 取值 | 含义 | 生产建议 |
|---|---|---|
auto |
模型自己决定是否调用、调哪个 | 默认值,通用场景 |
none |
禁止调用任何工具 | 意图分类、纯对话场景 |
required |
必须调用工具(可从多个中选) | 关键链路兜底,防止模型跳过工具 |
{"type":"function","function":{"name":"xxx"}} |
强制调用指定工具 | 路由已明确的场景 |
腾讯云 900 用例横评里有个真实教训:Claude 在 auto 模式下约 3% 的概率会跳过工具直接文本回复,生产建议就是关键链路用 required 兜底。
工具注册表与 ReAct 循环:框架层怎么组织
在 OpenHands(开源编码 Agent 框架)里,工具调用不是散落的代码,而是围绕"动作 → 执行 → 观察"三层抽象构建的:
flowchart TB
LLM[大模型] -->|生成 tool_calls JSON| P[Pydantic 校验]
P -->|Action 对象| CTRL[AgentController 调度]
CTRL --> RT[Runtime 执行]
RT -->|执行结果| OBS[Observation 结构化反馈]
OBS -->|回传结果| LLM
REG[工具注册表 ToolRegistry] -.注册/查询.-> RT
MCP[MCP 工具] -.统一接入.-> REG
API[外部 API] -.封装.-> REG
图 2:Agent 框架中的工具调用架构(以 OpenHands 为例)。工具注册表(ToolRegistry)实现工具与 LLM 的解耦,LLM 只通过注册表感知工具的"声明信息",执行由调度层完成。
这套架构背后是 ReAct(Reasoning + Acting,推理与行动交替)循环:模型在推理中意识到"内部知识不足以支撑下一步决策",就调用工具获取外部事实,再基于结果继续推理。设计要点:
- 工具抽象与标准化:无论底层是函数、API、数据库还是另一个 Agent,都封装为"名称 + 用途描述 + 参数 Schema + 返回值格式"的统一对象。
- 结构化交互:LLM 与框架之间用 JSON 而非自然语言交互,避免歧义。
- 结果闭环:工具结果必须完整回传,形成"请求-决策-调用-反馈-再决策"的反思式推理。
手把手实战落地
下面我们用一个"订单查询 + 物流跟踪"客服 Agent 走通全流程。环境:Python 3.11+,openai>=2.25.0,模型 gpt-5.6(Responses API)。
第一步:环境准备与工具定义
# 安装最新版 OpenAI SDK(2026-08 当前稳定版本要求 openai>=2.x)
pip install -U openai pydantic tenacity
# 配置 API Key(生产环境请用环境变量或密钥管理服务,切勿硬编码)
export OPENAI_API_KEY="sk-..."
定义工具是整个链路中投入产出比最高的一环。字节跳动团队的建议是:用 Python 类型系统 + Pydantic 自动生成 Schema 并做数据校验,把 50% 的时间花在打磨 Docstring 上——因为 LLM 是"读说明书用工具"的,它不看你代码实现,只看你的描述。
from pydantic import BaseModel, Field
from openai import OpenAI
client = OpenAI()
# 工具1:查订单 —— 参数用类型注解 + Field 约束,自动生成 JSON Schema
def get_order_status(order_id: str = Field(description="订单号,如 ORD-20260801-001")) -> str:
"""
根据订单号查询订单当前状态。
返回 JSON 字符串,包含字段:status(处理中/已发货/已签收)、
courier(快递公司)、tracking_no(运单号)。
"""
# 真实项目中这里调用订单系统 API
return '{"status": "已发货", "courier": "顺丰", "tracking_no": "SF1234567890"}'
# 工具2:查物流轨迹
def get_tracking(tracking_no: str = Field(description="快递运单号")) -> str:
"""
根据运单号查询最新物流轨迹。
返回 JSON 字符串,包含字段:location(最新位置)、time(更新时间)、
description(物流描述)。若运单号不存在返回 {"error": "not_found"}。
"""
return '{"location": "上海转运中心", "time": "2026-08-10 06:00", "description": "快件已到达【上海转运中心】"}'
为什么返回值用 JSON 字符串而不是直接 return dict? Responses API 的 function tool 输出会作为文本注入上下文,结构化字符串(JSON)既能让模型精确理解,又能控制 Token 开销。返回纯文本描述也是允许的,但明确的结构化更利于模型做后续决策。
第二步:用 Responses API 完成一轮完整工具调用
OpenAI 官方推荐新项目直接用 client.responses.create。下面这段代码演示五步闭环的最小实现:
import json
# 步骤1:把两个工具组装成 tools 声明列表
tools = [
{
"type": "function",
"name": "get_order_status",
"description": "根据订单号查询订单当前状态",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号,如 ORD-20260801-001"}
},
"required": ["order_id"],
"additionalProperties": False, # 禁止模型塞多余参数
},
"strict": True, # 开启严格模式,强制输出符合 Schema 的 JSON
},
{
"type": "function",
"name": "get_tracking",
"description": "根据运单号查询最新物流轨迹",
"parameters": {
"type": "object",
"properties": {
"tracking_no": {"type": "string", "description": "快递运单号"}
},
"required": ["tracking_no"],
"additionalProperties": False,
},
"strict": True,
},
]
# 步骤1:发送请求,携带工具声明
response = client.responses.create(
model="gpt-5.6",
input="我的订单 ORD-20260801-001 发货了吗?到哪了?",
tools=tools,
tool_choice="auto", # 模型自主决定是否调用
parallel_tool_calls=True, # 允许并行调用(默认即为 True)
)
# 步骤2:解析模型返回的 tool_calls
output = response.output
for item in output:
if item.type == "function_call":
print(f"模型要调用: {item.name}")
print(f"参数: {item.arguments}")
# 步骤3:应用侧执行真实函数(这里按名字分发)
if item.name == "get_order_status":
args = json.loads(item.arguments)
result = get_order_status(args["order_id"])
elif item.name == "get_tracking":
args = json.loads(item.arguments)
result = get_tracking(args["tracking_no"])
else:
result = '{"error": "unknown_tool"}'
# 步骤4:携带工具结果发起第二次请求
response2 = client.responses.create(
model="gpt-5.6",
input=[
*response.output, # 必须回传上一条完整输出(含 function_call)
{
"type": "function_call_output",
"call_id": item.call_id, # 用 call_id 关联"哪个调用对应哪个结果"
"output": result,
},
],
tools=tools,
)
# 步骤5:打印模型基于工具结果的最终回答
print("最终回答:", response2.output_text)
运行输出类似:
模型要调用: get_order_status
参数: {"order_id": "ORD-20260801-001"}
模型要调用: get_tracking
参数: {"tracking_no": "SF1234567890"}
最终回答: 您的订单 ORD-20260801-001 已发货(顺丰,运单号 SF1234567890),
最新轨迹显示快件已于 2026-08-10 06:00 到达上海转运中心。
第三步:把手工流程封装成通用的工具循环
上面的代码手写分发逻辑,工具多了就乱。封装成一个通用 run_agent_with_tools 循环,支持多轮、并行、自动分发:
import asyncio, json
from typing import Callable, Awaitable
# 工具注册表:name -> (定义, 执行函数)
TOOL_REGISTRY: dict[str, tuple[dict, Callable]] = {
"get_order_status": (tools[0], get_order_status),
"get_tracking": (tools[1], get_tracking),
}
async def run_agent_loop(user_input: str, max_rounds: int = 5) -> str:
"""通用工具循环:发请求 → 执行工具 → 回传结果 → 直到模型不再调用工具"""
current_input = user_input
for _ in range(max_rounds): # 限制轮数,防止 Agent 死循环烧钱
resp = client.responses.create(
model="gpt-5.6", input=current_input, tools=tools,
)
calls = [i for i in resp.output if i.type == "function_call"]
if not calls:
return resp.output_text # 没有工具调用,返回最终答案
# 并发执行所有工具(并行工具调用落地)
results = await asyncio.gather(*[
execute_tool(c.name, c.arguments) for c in calls
])
# 组装下一轮输入:完整回传 assistant 输出 + 每个调用的结果
current_input = [
*resp.output,
*[
{"type": "function_call_output", "call_id": c.call_id, "output": r}
for c, r in zip(calls, results)
],
]
raise TimeoutError(f"超过 {max_rounds} 轮仍未结束,触发熔断")
async def execute_tool(name: str, arguments: str) -> str:
"""按注册表分发并执行工具,带参数解析兜底"""
if name not in TOOL_REGISTRY:
return '{"error": "unknown_tool", "message": f"工具 {name} 不存在"}'
_, func = TOOL_REGISTRY[name]
try:
args = json.loads(arguments)
return func(**args) # 解包参数调用真实函数
except TypeError as e:
return f'{{"error": "invalid_args", "detail": "{e}"}}'
第四步:工具执行的超时与重试
工具调用的是真实外部系统,网络抖动、下游 5xx 是家常便饭。没有超时保护,一个慢接口能拖死整个 Agent;没有重试,一次抖动就导致用户看到"系统繁忙"。OpenAI Agents SDK 为 function tool 提供了内置的 timeout 参数,配合 tenacity 做指数退避重试:
import asyncio
from tenacity import (
retry, stop_after_attempt, wait_exponential,
retry_if_exception_type,
)
class ToolTimeoutError(Exception): # 自定义超时异常,便于分类处理
pass
# 指数退避重试:最多 3 次,等待时间 1s -> 2s -> 4s(上限 10s)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, max=10),
retry=retry_if_exception_type((TimeoutError, ConnectionError)),
)
async def call_tracking_api(tracking_no: str) -> str:
"""模拟真实物流 API:可能超时或连接失败"""
await asyncio.sleep(0.1) # 模拟网络 IO
# 真实项目:async with httpx.AsyncClient(timeout=5) as client: ...
return '{"location": "上海转运中心", "time": "2026-08-10 06:00"}'
async def safe_tool_wrapper(name: str, arguments: str, timeout: float = 5.0) -> str:
"""工具统一包装:加超时 + 重试,任何异常都转成结构化错误返回给模型"""
try:
# asyncio.wait_for 兜底硬超时,防止重试也救不回来的慢接口
return await asyncio.wait_for(call_tracking_api(arguments), timeout=timeout)
except asyncio.TimeoutError:
return '{"error": "tool_timeout", "message": "物流接口响应超时,请稍后重试"}'
except Exception as e:
# 关键:不要直接抛异常终止 Agent,而是把错误信息反馈给模型
return f'{{"error": "tool_failed", "detail": "{type(e).__name__}: {e}"}}'
第五步:参数校验与"带反馈的重试"
模型生成的参数偶尔会出错——漏了必填字段、把嵌套对象序列化成字符串、日期格式不对。校验失败不要直接报错,而是把具体的校验错误信息反馈给模型,让它修正后重试。这是小红书面试题"Function Calling 的可靠性怎么保证"的标准答案,也是生产中修复大部分参数错误的通用手段:
from pydantic import BaseModel, ValidationError
class OrderQuery(BaseModel): # 用 Pydantic 定义严格的参数契约
order_id: str # 必填
# 可以加 Field(pattern=...) 约束格式,例如订单号正则
def validate_and_execute(name: str, arguments: str) -> str:
"""参数校验 → 执行 → 失败反馈模型修正,而非直接抛异常"""
# 1. 先做 JSON 解析与格式修复(见"踩坑指南"中的 sanitize 函数)
args = sanitize_tool_arguments(arguments)
# 2. Pydantic 校验
try:
if name == "get_order_status":
validated = OrderQuery(**args)
return get_order_status(validated.order_id)
except ValidationError as e:
# 3. 把校验错误详情反馈给模型,让它自己修正参数再试
return (f'{{"error": "validation_failed", '
f'"detail": "{e.errors()}"}}')
return '{"error": "unknown_tool"}'
第六步:生产级多模型路由与降级(可选进阶)
当你有多个模型供应商时,按请求复杂度动态选模型、失败时按降级链切换,是降本增效的关键:
async def call_with_fallback(primary_model: str, messages, tools):
"""主模型失败时按降级链切换到备用模型"""
fallback_chain = {
"gpt-5.6": ["claude-opus-4-6", "gemini-2.5-pro"],
"claude-opus-4-6": ["gpt-5.6", "gemini-2.5-pro"],
"gemini-2.5-pro": ["gpt-5.6", "claude-opus-4-6"],
}
models = [primary_model] + fallback_chain[primary_model]
for model in models:
try:
resp = await model_call(model, messages, tools)
if validate_against_schema(resp.arguments, tools):
return resp
except (json.JSONDecodeError, ValidationError) as e:
print(f"模型 {model} 调用失败: {e},切换降级模型")
continue
raise AgentError("所有模型均失败,触发人工兜底")
至此,一个具备"工具注册、多轮循环、并行执行、超时重试、参数校验、多模型降级"的工具调用层就成型了。整个实战流程如下:
flowchart LR
U[用户问题] --> A[工具循环引擎]
A -->|请求+工具声明| M[LLM gpt-5.6]
M -->|tool_calls| A
A -->|并发分发| R[工具注册表]
R -->|超时重试包装| T1[订单API]
R -->|超时重试包装| T2[物流API]
T1 -->|结构化结果| A
T2 -->|结构化结果| A
A -->|校验+反馈| M
M -->|最终回答| OUT[用户]
图 3:实战 Agent 的工具调用实现流程。工具循环引擎负责消息组装与轮数控制,注册表负责分发,超时/重试/校验全部收口在工具包装层。
关键细节与踩坑指南
坑 1:嵌套对象被序列化成字符串
这是多模型环境中最高频的坑。腾讯云横评实测数据:GPT 系列模型约 8.8% 的概率把嵌套 object 参数错误序列化成字符串:
// 期望
{ "price_range": { "min": 100, "max": 500 } }
// 实际(偶发)
{ "price_range": "{\"min\": 100, \"max\": 500}" }
解法:在 API Gateway 或工具入口统一做 sanitize(清洗)——检测字符串化的 JSON 并反序列化回来:
import json, re
def sanitize_tool_arguments(raw_args: str | dict) -> dict:
"""统一处理各家模型参数格式差异(实测 GPT 嵌套序列化 / Gemini JSON 格式问题)"""
if isinstance(raw_args, dict):
for key, value in raw_args.items():
# 修复"字符串化的嵌套对象"(GPT 偶发问题)
if isinstance(value, str) and value.strip().startswith("{"):
try:
raw_args[key] = json.loads(value)
except json.JSONDecodeError:
pass
return raw_args
if isinstance(raw_args, str):
# 修复 Gemini 常见的 JSON 格式问题:漏逗号、多余逗号、括号不匹配
cleaned = re.sub(r",\s*}", "}", raw_args) # 去掉对象末尾多余逗号
cleaned = re.sub(r",\s*]", "]", cleaned) # 去掉数组末尾多余逗号
return json.loads(cleaned)
raise ValueError(f"Unexpected argument type: {type(raw_args)}")
坑 2:并行调用暴露隐藏耦合——工具在并发下"悄悄变错"
很多团队开并行是为了提速(工具执行占 Agent 总延迟 35%–60%),结果一开就翻车。并行工具调用是模型做出的决定,不是你的编排层做出的决定——当模型在单轮响应中发出多个 tool_use 块时,你的 runner 应当同时分派所有调用,并统一返回整批结果,模型看不到中间结果。三个经典静默失败模式:
- 上下文依赖(Context dependency):工具 A 偷偷读取应由工具 B 填充的共享变量,并行时 A 读到空值,返回"看似正确实则错误"的结果,且不抛异常。
- 共享状态变更(Shared state mutation):两个工具对同一资源做"读-改-写",产生竞态,最终值悄悄丢失一个增量。
- 执行时序依赖(Execution timing dependency):工具 A 的副作用是工具 B 的前提(A 建记录,B 写关联记录),并行时前提被破坏。
解法:并行化之前先做幂等性审计,问三个问题——它是原子的吗(Is it atomic)?它是幂等的吗(Is it idempotent)?它能合并吗(Is it mergeable)?查询类工具(GET)可以并行;创建/通知类工具(POST、发邮件)默认不幂等,谨慎并行。有依赖关系的工具请用 DAG(有向无环图)编排,让依赖显式化,而不是依赖"顺序执行恰好掩盖了依赖"。
坑 3:各家模型行为不一致,别假设并行行为稳定
- OpenAI:
parallel_tool_calls默认true;但推理模型(o3、o4-mini 等)要么忽略、要么完全拒绝该参数,显式设置会返回 400 错误。 - Anthropic:没有直接开关,Claude 根据"工具看起来是否独立"自行决定,倾向保守(实测约 8.7% 概率在该并行时串行)。
- 多模型路由或版本升级时,编排层必须无条件处理多工具响应,无论你是否请求了并行。
坑 4:工具数量失控导致上下文混淆
一次性给模型超过 100 个工具会导致上下文混淆(Context Confusion),模型开始幻觉参数、张冠李戴。经验法则:
- 单 Agent 暴露的工具尽量控制在 10–20 个(OpenAI 官方 workshop 的建议),超过就要分层。
- 用命名空间(namespace) 分组:OpenAI Agents SDK 的
tool_namespace(name="crm", tools=[...]),让模型按需加载。 - 用延迟加载(defer_loading) +
ToolSearchTool:工具 Schema 不一次性全塞进请求,模型需要时才搜索加载子集,大幅节省 tool-schema tokens。
坑 5:工具返回垃圾内容毒化上下文
工具输出会原样进入模型上下文(记费、占窗口、干扰决策)。生产规范:
- 查询类工具限制返回行数(如
max_query_result_rows=50),必要时只返回摘要而非全文; - 写操作类工具返回状态描述而非空串("邮件发送完成");
- 大结果用
{"truncated": true, "summary": "..."}结构,防止 Context 溢出。
生产环境最佳实践
Schema 工程:description 是最便宜的优化
腾讯云横评的一个反直觉结论:把 Schema 的 description 写详细,GPT 的嵌套对象错误率从 8.8% 降到 2.1%,Gemini 的格式错误下降约 40%。这是投入产出比最高的优化。
{
"price_range": {
"type": "object",
"description": "价格筛选区间,JSON 对象格式。示例:{\"min\": 100, \"max\": 500}",
"properties": {
"min": {"type": "number", "description": "最低价格,单位人民币元"},
"max": {"type": "number", "description": "最高价格,单位人民币元"}
}
}
}
监控指标:四率一延迟
生产环境至少监控这些指标(数据来自小红书可靠性面试题总结与业界共识):
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 工具调用触发率 | 该调的时候是否调了 | 过低 → 模型跳过工具(考虑 required 兜底) |
| 参数校验通过率 | 参数质量 | 过低 → Schema 描述不清晰 / 工具过碎 |
| 工具执行成功率 | 下游服务健康度 | 关注 4xx/5xx 分布 |
| 重试/降级率 | 链路稳定性 | 持续上升 → 排查下游与网络 |
| 端到端延迟 | 用户体感 | 分解为"模型往返时间 + 工具执行时间" |
安全管控
- 最小权限原则:工具只暴露任务必需的参数与功能,去掉无意义或恒定的参数;
- Human-in-the-loop:关键写操作(转账、下单、发邮件)定义
require_confirmation,敏感操作执行前人工确认; - 允许调用者控制(allowed_callers):PTC 场景下,只有声明了
allowed_callers=["programmatic"]的工具才能被生成的代码调用,高影响工具保持 direct 调用,让每次动作都可审查。
成本控制
- 提示词缓存(Prompt Caching):工具 Schema 声明基本不变,是缓存命中率最高的部分;
- Batch API:非实时场景(批量订单处理)用离线批处理,价格低 50%;
- 路由降本:简单工具调用路由到便宜模型(如 Gemini Flash、GPT-5.6-mini 级别),复杂推理路由到旗舰模型,可降 80%–90% 成本且准确率损失极小。
横向对比与选型建议
三家主流厂商的 Function Calling 横向对比
基于腾讯云 900 用例 × 3 轮横评(2026-03)与 BFCL v3 榜单(2026-08-07)数据:
| 维度 | Claude Opus 4.6 | GPT-5.4 / 5.6 | Gemini 2.5 Pro |
|---|---|---|---|
| 综合得分(900 用例) | 94.5% | 94.6% | 89.7% |
| 嵌套复杂参数 | 96.7%(最稳) | 91.2%(8.8% 序列化坑) | 84.3%(15.7% JSON 格式错误) |
| 多接口并行编排 | 偏保守(8.7% 不必要串行) | 最强 | 一般 |
| 重试成本占比 | 3.6% | 3.4% | 17.0% |
| 适用场景 | 参数格式严格、安全敏感 | 高并发、多 API 编排 | 成本敏感简单场景 |
选型决策路径:
flowchart TD
Q{工具数量与复杂度} -->|>3 个且含嵌套对象| A[Claude 系<br/>嵌套处理最稳]
Q -->|>3 个无嵌套/多接口编排| B[GPT 系<br/>并行编排最强]
Q -->|简单 CRUD 单接口| C[Gemini Flash 等<br/>成本最优]
A --> D[统一 API 网关<br/>多模型路由 + 降级链]
B --> D
C --> D
图 4:多模型选型决策路径。别迷信单一模型的 benchmark 分数,在自己的 API Schema 上实测才是王道——腾讯云横评的结论。
原生 Function Calling vs MCP vs PTC
- 原生 Function Calling:单应用内、直接集成,延迟最低,适合内部工具;
- MCP(Model Context Protocol):标准化的工具接入协议,解决"一套工具被多家模型/框架复用"的生态问题,适合跨应用、跨团队的工具共享(站内已有 MCP 入门文章,本文不展开);
- PTC(Programmatic Tool Calling,2025-11 发布):模型生成代码编排工具调用,适合"循环、分支、聚合"型工作流,把多轮 API 往返压缩为一段沙箱代码执行,延迟与成本双降。
一句话建议:工具少、链路简单用原生 Function Calling 自己控制循环;工具生态要复用用 MCP;工作流含大量数据编排用 PTC。
性能实测与效果验证
实测一:并行调用对延迟的收益
工具执行占 Agent 总延迟 35%–60%。以三个独立的 200ms 工具调用为例:
| 执行模式 | 端到端耗时 | 说明 |
|---|---|---|
| 串行 | 600ms | 3 × 200ms 顺序执行 |
| 并行 | 200ms | 墙上时钟时间重叠,等待最慢的一个 |
| 并行 + 模型往返 | 约 400ms+ | 仍需一次额外模型调用整合结果 |
并行收益可观,但收益的前提是工具之间真正独立(见坑 2 的耦合测试)。
实测二:BFCL v3 最新榜单(截至 2026-08-07)
Berkeley Function Calling Leaderboard(伯克利函数调用排行榜)v3 是工具调用准确率的权威基准,覆盖函数名准确率、参数正确性、并行调用、多轮工具使用:
| 排名 | 模型 | BFCL v3 得分 |
|---|---|---|
| 1 | GLM 4.5 | 76.7% |
| 2 | Claude Opus 4.7 | 76.6% |
| 3 | Gemini 3.1 Flash Lite Preview | 76.5% |
| — | 23 个模型平均分 | 58.5%(标准差 17.5) |
注意两点:① 头部模型差距在 0.2% 以内,说明工具调用能力已趋同,选型应更多看成本与生态;② 榜单平均分仅 58.5%,顶尖模型在真实复杂场景也只有约 3/4 的正确率——这就是为什么"参数校验 + 带反馈重试 + 多模型降级"这三层防御缺一不可。
实测三:Schema 工程的效果量化(腾讯云横评)
| 优化项 | 优化前 | 优化后 |
|---|---|---|
| GPT 嵌套对象错误率 | 8.8% | 2.1%(写入详细 description) |
| Gemini JSON 格式错误 | 15.7% | 下降约 40% |
| Claude 不必要串行调用 | 8.7% | 减少 60%(System Prompt 声明并行规则) |
结论:工具调用可靠性 ≈ 模型能力(约 70%)+ Schema 工程(约 20%)+ 防御性编程(约 10%)。前两者决定上限,后者决定下限。
总结与未来展望
工具调用是 Agent 从"会聊天"到"能做事"的分水岭,其本质是"模型决策 + 应用执行"的结构化分工。要写好工具调用层,你需要抓住五件事:Schema 是给模型看的说明书(description 要精雕细琢)、循环必须完整回传消息历史并用 call_id 关联、并行要建立在幂等性审计之上、任何异常都要转成结构化错误反馈给模型而非直接终止、多模型路由 + 统一网关是生产可靠性的兜底。
展望未来,三个方向值得关注:一是 PTC 式的"代码编排工具"正在把工具调用从"逐轮对话"推向"程序化执行",对循环聚合类任务效率提升是数量级的;二是 MCP 协议持续迭代(已更新到第五版),工具生态标准化将进一步降低接入成本;三是 Agent 可观测性(Tracing、Eval)会像今天的 APM 一样成为标配——毕竟"工具调用失败不报错、自信地返回错误答案"这类静默失败,只能靠全链路观测去发现。工具调用不会消失,它只会越来越像一门严谨的工程学科。
延伸阅读
- OpenAI 官方 Function Calling 指南:Responses API 五步流程、strict 模式、tool_choice 全参数的最权威定义。
- OpenAI Agents SDK Tools 文档:function tool 的 timeout、namespace、defer_loading、PTC 的完整 Python 实现参考。
- Berkeley Function Calling Leaderboard:追踪各模型工具调用准确率最新排名的权威榜单。
- LLM Agent 中的并行工具调用:你可能尚未意识到的耦合测试:并行调用三种静默失败模式的深度剖析与幂等性审计方法论。
- AI Agent框架探秘:拆解 OpenHands 的 Function call:开源编码 Agent 框架的工具注册表、动作-执行-观察三层架构源码级拆解。