← 返回资讯

Function Calling 工具调用接入实战

2026-06-22

**Function Calling(工具调用)**让大模型在生成回复时可以主动触发你定义的函数,实现联网搜索、数据库查询、下单支付等结构化动作。调用方式:在请求中声明工具列表,模型决定调用哪个函数并返回参数,你执行后再把结果喂回模型。

你可能是从一个很具体的痛点摸到这篇文章的:客服机器人需要查订单状态、查物流,之前的做法是在 prompt 里塞一堆”如果用户问物流请按 XXX 格式回复”,模型时不时还是会自己编一个物流单号出来对付你。这就是没有工具调用能力的模型典型翻车现场——它不知道自己”不知道”,只会硬编答案。Function Calling 解决的正是这个问题:把”要不要查、查什么”这个决策权交给模型,但真正的查询动作永远由你的代码执行,模型只负责生成结构化的调用意图,从源头掐掉编造数据的可能性。

工作流程

先说清楚一个容易被绕晕的前提:模型本身不会联网、不会连数据库、不会执行代码。它只是根据你提供的工具描述(函数名、参数 schema、用途说明)判断”这一步该调用哪个函数、传什么参数”,然后把这个决策以 JSON 的形式吐给你。真正跑起来的是你自己的后端代码。这套机制本质上更接近一次 RPC 握手,而不是模型”真的会用工具”了。

Function Calling 分三个往返:

  1. 第一次请求:携带 tools 定义 + 用户消息 → 模型返回 tool_calls(含函数名和参数 JSON)
  2. 本地执行:你根据 tool_calls 运行真实函数,拿到结果
  3. 第二次请求:把 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].messagecontent 字段通常是 Nonetool_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 结构对不上而报错。稳妥的做法是只挑你需要持久化的字段(rolecontenttool_callstool_call_id)手动组装一个纯 JSON 对象存储,不要图省事直接存 SDK 返回的完整实例。

关键参数说明

参数可选值说明
tool_choice"auto" / "none" / {"type":"function","function":{"name":"xxx"}}auto 由模型决定;none 禁用工具;指定名称强制调用
parallel_tool_callstrue / false允许模型在一次回复中并行调用多个工具(默认 true)
stricttrue / 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 示例跑起来后,按下面几点核对,能少走很多弯路:

  1. 打印 resp.choices[0].finish_reason,第一次请求应该是 "tool_calls" 而不是 "stop"——如果是 stop 说明模型没有触发工具调用,先检查 tool_choice 有没有被误设成了 "none",或者 description 写得让模型觉得没必要调用。
  2. 打印 msg.tool_calls[0].function.arguments,应该是一个合法的 JSON 字符串(带双引号的那种),如果拿到的是空字符串或者截断的 JSON,多半是 max_tokens 设得太小,模型的参数还没生成完就被截断了,调大 max_tokens 或者干脆去掉这个限制再试。
  3. 第二次请求前确认 messages 数组的最后几条顺序是:assistant(带 tool_calls)→ tool → tool → …(每个 tool_call_id 都对应一条),顺序或者数量对不上,网关基本都会直接拒绝这次请求。

三点都过了,你的 function calling 链路就是稳的,剩下的就是往里面加更多工具、把参数 schema 写得更精确的事了。


更多接入基础见大模型 API 接入完全指南接入教程专题。结构化输出可配合 JSON mode 使用;高并发场景请参考并发控制与速率限制