Function Calling 工具调用接入实战
**Function Calling(工具调用)**让大模型在生成回复时可以主动触发你定义的函数,实现联网搜索、数据库查询、下单支付等结构化动作。调用方式:在请求中声明工具列表,模型决定调用哪个函数并返回参数,你执行后再把结果喂回模型。
你可能是从一个很具体的痛点摸到这篇文章的:客服机器人需要查订单状态、查物流,之前的做法是在 prompt 里塞一堆”如果用户问物流请按 XXX 格式回复”,模型时不时还是会自己编一个物流单号出来对付你。这就是没有工具调用能力的模型典型翻车现场——它不知道自己”不知道”,只会硬编答案。Function Calling 解决的正是这个问题:把”要不要查、查什么”这个决策权交给模型,但真正的查询动作永远由你的代码执行,模型只负责生成结构化的调用意图,从源头掐掉编造数据的可能性。
工作流程
先说清楚一个容易被绕晕的前提:模型本身不会联网、不会连数据库、不会执行代码。它只是根据你提供的工具描述(函数名、参数 schema、用途说明)判断”这一步该调用哪个函数、传什么参数”,然后把这个决策以 JSON 的形式吐给你。真正跑起来的是你自己的后端代码。这套机制本质上更接近一次 RPC 握手,而不是模型”真的会用工具”了。
Function Calling 分三个往返:
- 第一次请求:携带
tools定义 + 用户消息 → 模型返回tool_calls(含函数名和参数 JSON) - 本地执行:你根据
tool_calls运行真实函数,拿到结果 - 第二次请求:把
tool角色的结果追加到 messages → 模型生成最终自然语言回复
这三步里最容易踩坑的是第 3 步——很多人第一次接入时会漏掉一个动作:把模型返回的那条含 tool_calls 的 assistant 消息本身也追加进 messages,只顾着塞 tool 角色的结果。这样第二次请求会直接报 400(不同网关的报错文案不一样,常见的是类似 “messages with role ‘tool’ must be a response to a preceding message with ‘tool_calls’” 这种提示),因为服务端要靠这条 assistant 消息里的 tool_calls 才能对上后面 tool 消息的 tool_call_id,两者是强绑定的,缺一不可。
用户消息
↓
模型 → tool_calls: [{name:"get_weather", arguments:{"city":"北京"}}]
↓
你执行 get_weather("北京") → "晴,25°C"
↓
模型 → "北京今天晴天,25°C,适合出行。"
Python 示例
import os, json
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
)
# 1. 定义工具列表
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如北京"},
},
"required": ["city"],
},
},
}
]
# 2. 本地函数(实际接入天气 API)
def get_weather(city: str) -> str:
return f"{city}:晴,25°C" # 示例返回
messages = [{"role": "user", "content": "北京今天天气怎么样?"}]
# 3. 第一次请求
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
# 4. 处理 tool_calls
if msg.tool_calls:
messages.append(msg) # 追加助手消息(含 tool_calls)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = get_weather(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
# 5. 第二次请求获取最终回复
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
print(final.choices[0].message.content)
跑起来之后你应该看到什么:第一次 client.chat.completions.create 返回的 resp.choices[0].message 里 content 字段通常是 None,tool_calls 是一个列表,里面每一项的 function.arguments 是字符串而不是字典(这点很多新手会漏,直接当 dict 用会报 TypeError: string indices must be integers,必须先 json.loads 转一遍)。第二次请求打印出来的 final.choices[0].message.content 才是真正能展示给用户的自然语言回复,类似”北京今天晴,25°C,出门可以不带伞”。
代码里几个容易漏的细节,实际项目里都踩过:
messages.append(msg)这一步不能省,前面已经讲过原因,这里再强调一遍是因为它是最高频的接入 bug,没有之一。if msg.tool_calls:只是判断”有没有工具调用”,如果模型一次返回了两个甚至三个tool_calls(比如同时问天气和空气质量),for call in msg.tool_calls这个循环必须把每一个都执行完、每一个都对应追加一条tool消息,漏掉任意一个tool_call_id没有对应结果,第二次请求同样会报参数缺失的错误。json.loads(call.function.arguments)这一步建议包一层try/except:模型偶尔会吐出不完整的 JSON(尤其是参数里有中文引号、多层嵌套对象的时候),捕获到json.JSONDecodeError后与其让程序崩掉,不如把错误信息也按tool角色回传给模型,很多时候模型看到”你上次传的参数解析失败”会自己纠正重新生成一次,比你在代码里写一堆容错逻辑更省事。get_weather(**args)这种写法要求args里的 key 必须和函数入参名完全一致,如果 JSON Schema 里字段名和 Python 函数签名对不上(比如 schema 写的是city_name但函数参数是city),会直接抛TypeError: get_weather() got an unexpected keyword argument,建议 schema 里的字段名和后端函数签名保持一份共享定义,别两边各写一套。
Node.js 示例
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.lidayun.com/v1",
});
const tools = [
{
type: "function",
function: {
name: "get_weather",
description: "获取城市天气",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
];
function getWeather(city) {
return `${city}:晴,25°C`;
}
const messages = [{ role: "user", content: "北京今天天气怎么样?" }];
const resp = await client.chat.completions.create({
model: "gpt-4o-mini",
messages,
tools,
tool_choice: "auto",
});
const msg = resp.choices[0].message;
if (msg.tool_calls) {
messages.push(msg);
for (const call of msg.tool_calls) {
const args = JSON.parse(call.function.arguments);
const result = getWeather(args.city);
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
const final = await client.chat.completions.create({ model: "gpt-4o-mini", messages, tools });
console.log(final.choices[0].message.content);
}
Node 版本逻辑和 Python 一模一样,但有几个 JS 特有的坑值得单独说:JSON.parse(call.function.arguments) 同样可能因为模型输出的 JSON 不完整而抛异常,Node 里没有 Python 那种”捕获后直接把错误文本回传”的现成套路,得自己包一层 try/catch;另外如果你的工具函数是异步的(比如内部要 fetch 一个真实天气接口),for...of 循环里直接 await getWeather(args.city) 是可以的,但如果模型一次返回了多个 tool_calls 且你想并行加速,就不能用普通 for 循环顺序等待,得改成 Promise.all(msg.tool_calls.map(async call => {...})),否则三个工具调用会变成串行请求,接口响应时间直接翻三倍。
还有一个新手常见的低级错误:messages.push(msg) 里的 msg 是 SDK 返回的对象实例,如果你在中间用 JSON.stringify 序列化后又反序列化(比如存进 Redis 再取出来),某些 SDK 版本返回的对象里会带有不可枚举的内部字段丢失,导致下一次请求时 tool_calls 结构对不上而报错。稳妥的做法是只挑你需要持久化的字段(role、content、tool_calls、tool_call_id)手动组装一个纯 JSON 对象存储,不要图省事直接存 SDK 返回的完整实例。
关键参数说明
| 参数 | 可选值 | 说明 |
|---|---|---|
tool_choice | "auto" / "none" / {"type":"function","function":{"name":"xxx"}} | auto 由模型决定;none 禁用工具;指定名称强制调用 |
parallel_tool_calls | true / false | 允许模型在一次回复中并行调用多个工具(默认 true) |
strict | true / false | 开启后参数严格按 JSON Schema 输出,减少解析失败 |
tool_choice 该怎么选,别瞎设成 auto
很多接入教程只告诉你有这三个选项,但没告诉你什么场景该选哪个,实际项目里这是最容易埋雷的地方:
| 场景 | 推荐设置 | 原因 |
|---|---|---|
| 闲聊 + 偶尔查天气/查资料 | auto | 让模型自己判断要不要查,不查也能正常聊 |
| 用户点了”查询订单”按钮,前端已经明确意图 | 强制指定函数名 | 没必要再让模型猜一遍,直接省一次判断的不确定性,响应更快也更稳 |
| 支付、退款等有资损风险的动作 | 强制指定函数名 + 后端二次校验参数 | 绝对不能依赖模型”自觉”,哪怕是强制调用,参数(金额、账户)也必须在你的业务层再校验一遍,工具调用只是意图识别,不是权限校验 |
| 纯文本问答页面,不需要任何工具 | none | 显式关闭,避免模型误判去调用一个根本用不上的工具,浪费一次往返 |
parallel_tool_calls 默认开着,多数场景没问题,但如果你的工具之间有先后依赖(比如”先查用户 ID 再查订单”这种前一个结果是后一个输入的情况),并行调用反而会出问题——模型可能一次性把两个调用都发出来,但第二个调用需要的参数其实还没有,只能自己瞎编一个占位值。这种有依赖关系的工具链,建议要么在 description 里显式告诉模型”必须先调用 A 再调用 B”,要么干脆关掉 parallel_tool_calls 强制串行,用两轮往返分开处理。
常见问题
模型返回了 tool_calls 但我不想执行怎么办? 可以在检测到 finish_reason === "tool_calls" 后选择不执行,直接向用户说明无法完成,无需强制进入第二次请求。
参数 JSON 解析失败怎么处理? 建议开启 strict: true(支持该参数的模型),或在解析时捕获异常并将错误信息通过 tool 角色消息反馈给模型重试。
并行工具调用时 messages 怎么排列? 先追加包含完整 tool_calls 数组的助手消息,再按 tool_call_id 逐个追加对应的 tool 角色消息,顺序一一对应。
流式输出下如何处理 tool_calls? 流式时 tool_calls 以 delta 分帧推送,需在客户端拼接各帧的 index/id/function.name/function.arguments 字段,拼完后再执行。
进阶:多工具并行执行的正确姿势
前面 Python 示例里的 for call in msg.tool_calls 是顺序执行的,工具少、单个调用快的时候没什么感觉,但如果你注册了五六个工具,模型一次并行触发三个(比如同时查天气、查空气质量、查交通),顺序执行就是三次网络请求的耗时叠加。用 asyncio.gather 改成并发执行,实测在工具是外部 API 调用(平均 300ms 一次)的场景下,三个工具并行能把这一步的耗时从接近 1 秒压到 300 多毫秒:
import asyncio
async def execute_tool(call):
args = json.loads(call.function.arguments)
if call.function.name == "get_weather":
result = await async_get_weather(**args)
# ...其他工具的分发
return {"role": "tool", "tool_call_id": call.id, "content": result}
async def handle_tool_calls(msg):
tool_messages = await asyncio.gather(*(execute_tool(c) for c in msg.tool_calls))
return list(tool_messages)
这里有个隐藏坑:如果某一个工具执行时抛异常,asyncio.gather 默认会让整批任务都失败,导致其他明明执行成功的工具结果也拿不到。生产环境建议加 return_exceptions=True,拿到结果后再逐个判断是不是 Exception 实例,把失败的那个转成”该工具暂时不可用,请稍后再试”这类文字塞进 tool 消息里,而不是让一个工具挂了拖累整个请求失败。
工具执行失败后的重试策略
工具调用链路比普通对话多了一环真实的外部依赖(数据库、第三方 API),失败率天然更高。碰到超时或者限流,不建议在你的业务代码里无脑 while True 重试,容易在上游本身就在限流时把问题放大。更稳的做法是指数退避 + 最大重试次数封顶,比如首次失败等 0.5 秒,第二次等 1 秒,第三次等 2 秒,超过 3 次直接把”工具暂时不可用”作为结果回传给模型,让模型据实告诉用户”查询暂时失败,请稍后重试”,而不是卡住整个对话不给任何反馈。这个策略对接第三方天气 API、物流查询接口这类不受你控制的外部服务尤其重要,别指望它们永远 200。
这笔账要算清楚:工具调用会让 token 花费翻倍吗
接入之前很多人没意识到一件事:工具调用的两次往返,tools 这个完整的 schema 定义在两次请求里都要原样带上。如果你注册了十几个工具,每个工具的 description 写得比较啰嗦、参数字段又多,这部分 schema 本身可能占掉几百甚至上千 token,等于每次对话都要为这份”工具说明书”重复付费两遍。几个实际能省钱的做法:
description写到刚好够模型判断”什么时候该用这个工具”就行,别把使用文档整段搬进去,模型不需要知道你的函数内部实现细节。- 如果某些工具只在特定页面 / 特定用户角色下才可能用到,不要一次性把全部工具都塞进
tools数组,按场景动态过滤只传当次可能用到的那几个,既能省 token,也能减少模型选错工具的概率。 parameters里非必填的字段尽量给默认值或者干脆不写进 schema,字段越少、嵌套越浅,模型生成参数出错的概率也越低。
如果你想搞清楚一次带工具调用的对话到底花了多少 token,可以直接看接口返回的 usage 字段里 prompt_tokens 的变化——加上 tools 定义前后跑同一句话对比一下,差值基本就是这份 schema 的固定成本,心里有数之后再决定要不要精简。
动手自查一遍
把 Python 示例跑起来后,按下面几点核对,能少走很多弯路:
- 打印
resp.choices[0].finish_reason,第一次请求应该是"tool_calls"而不是"stop"——如果是stop说明模型没有触发工具调用,先检查tool_choice有没有被误设成了"none",或者description写得让模型觉得没必要调用。 - 打印
msg.tool_calls[0].function.arguments,应该是一个合法的 JSON 字符串(带双引号的那种),如果拿到的是空字符串或者截断的 JSON,多半是max_tokens设得太小,模型的参数还没生成完就被截断了,调大max_tokens或者干脆去掉这个限制再试。 - 第二次请求前确认 messages 数组的最后几条顺序是:assistant(带
tool_calls)→ tool → tool → …(每个tool_call_id都对应一条),顺序或者数量对不上,网关基本都会直接拒绝这次请求。
三点都过了,你的 function calling 链路就是稳的,剩下的就是往里面加更多工具、把参数 schema 写得更精确的事了。
更多接入基础见大模型 API 接入完全指南与接入教程专题。结构化输出可配合 JSON mode 使用;高并发场景请参考并发控制与速率限制。