Function Calling(工具调用)是 LLM 和外部系统之间的桥:模型不再只能回文字,而是能决定调用你代码里的某个函数,并给出结构化参数。但很多人第一次用就误解了它的本质——模型自己并不会执行函数,它只是返回函数名和参数,真正跑函数的是你的应用。把这条链路理清楚,后面踩的坑会少一半。

模型只发指令

Function Calling 的本质是模型发指令、应用执行。模型不会碰你的数据库、不会调你的 API,它只做一件事:判断该调用哪个函数、把自然语言转成符合 JSON Schema 的参数,然后把结果交回给你。函数定义里的 description 是模型读的说明书,要写给读者看、不是写给编译器看——它决定了模型在多义的情况下能不能选对工具。参数定义用 JSON Schema,主流三家(OpenAI、Anthropic、Google Gemini)都沿用这一套,只是字段名略有差异。

一次调用的五个步骤

一次完整调用是五步的多轮对话:声明工具、模型返回 tool_calls、你的代码执行函数、把结果作为 tool 消息回传、模型据此生成最终回答。用 OpenAI 兼容接口写出来是这样:

import json
from openai import OpenAI

client = OpenAI()

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询指定城市的实时天气",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "城市名"},
                "unit": {"type": "string", "enum": ["c", "f"]},
            },
            "required": ["city"],
        },
    },
}]

def get_weather(city, unit="c"):
    return {"city": city, "temp": 23, "unit": unit}

messages = [{"role": "user", "content": "北京现在多少度?"}]
resp = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg)

for call in msg.tool_calls or []:
    if call.function.name == "get_weather":
        args = json.loads(call.function.arguments)
        result = get_weather(**args)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result),
        })

final = client.chat.completions.create(model="gpt-4o", messages=messages)
print(final.choices[0].message.content)

关键在两处:role 为 tool 的消息必须带上 tool_call_id,模型靠这个 ID 把参数和结果配对;第二次请求要把完整的 messages(含第一条 assistant 的 tool_calls 消息)原样带回去,缺了上下文模型就接不上。

并行调用

现在主流的模型支持一轮请求里发起多个工具调用——一个「北京天气怎么样,顺便查下明天」就可能触发两次 get_weather。所以循环里要遍历 tool_calls,别只取 tool_calls[0],更别假设一次只来一个。缺一个 tool 回传,下一轮模型就可能卡住或答非所问:

# 并行调用:一次请求可能返回多个 tool_calls,逐个执行、逐个回传
results = []
for call in msg.tool_calls or []:
    if call.function.name == "get_weather":
        args = json.loads(call.function.arguments)
        results.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(get_weather(**args)),
        })
messages.extend(results)

结果回传的细节

工具返回的内容是给模型看的素材,不是给用户的最终答案。回传时用 json.dumps 序列化成字符串放进 content,模型会基于它组织成自然语言。工具执行要包 try/except——模型给的参数是概率产物,可能类型不对、可能缺字段,把异常转成错误文本回传,让模型有机会自己纠正,而不是让整个 handler 崩掉:

def safe_execute(fn, args):
    try:
        return fn(**args)
    except Exception as e:
        return {"error": str(e)}   # 把错误喂回模型,让它重试或道歉

强制调用与结构化输出

默认情况下模型自己决定要不要调工具,这适合开放对话。但有两种场景需要显式控制:一是流程必须确定性——比如表单信息提取,你永远不希望模型回一句闲聊——这时用 tool_choice 强制它必须调用;二是要保证参数严格贴合 schema,用结构化输出(strict 模式)让生成的 arguments 要么完全符合、要么干净地报错。两者解决的是「模型偶尔不按套路出牌」的问题,把不确定性从「靠 prompt 祈祷」变成「靠机制保证」:

# 强制调用:让模型一定返回 get_weather 的参数,而不是自由回答
resp = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "get_weather"}},
)

注意 strict 模式对 schema 有额外要求,比如所有属性要标 required、不允许 additionalProperties,不满足的 schema 会被直接拒绝。这反而是好事——逼你在声明阶段就把参数说清楚。

常见坑

第一个坑是把参数当可信输入。模型返回的 arguments 一定要当成不可信的用户输入,执行前按 schema 校验、夹紧范围、拒绝未知的 enum 值,尤其涉及有副作用的操作。第二个坑是工具塞太多——把几十个工具一股脑传进上下文,会稀释模型选工具的判断力,按当前步骤只传相关的几个更稳。第三个坑是忽略工具选择策略:默认 auto 让模型自己决定要不要调,需要确定性流程时用 tool_choice 强制调用,或指定函数名强制调某一个。第四个坑是拿正则去解析模型输出的伪 JSON——Function Calling 的卖点恰恰是让解析从「猜」变成「要么成功、要么干净地失败」,配上结构化输出(strict 模式)还能进一步保证参数严格贴合 schema。把这几个坑避开,Function Calling 才算真正跑稳。