模型能言善辩,却两眼一抹黑:它不知道今天的天气,查不到你数据库里的订单,更不能替你发一封邮件——训练数据是静态的,模型本身只是一个文本进、文本出的函数。函数调用(Function Calling,官方也称工具调用)补上这块短板:你把"我这里有哪些能力"声明给模型,模型在需要时发回一条结构化调用指令,由你的代码真正执行,再把结果喂回去。本篇是《OpenAI Python SDK 从入门到实战》系列第 5 篇,第 4 篇打下的 JSON Schema 地基在这里直接复用。全文按"机制 → 定义 → 调用 → 回传 → 循环 → 多工具 → 实战 → 总结"组织。
1、工具调用是什么
先破一个最常见误解:模型从不执行任何工具。它运行在 OpenAI 的服务器上,你的数据库、天气服务、邮件网关它一个都碰不到;工具调用真正做的事,是让模型在需要时发回一条结构化指令,由你的代码代为执行。官方函数调用指南把它概括为五步:带工具清单请求、收到调用、本地执行、回传结果、拿到最终回答。一次完整协作的时序如下:
图里两次模型调用夹着一次本地执行,三方各司其职:模型是决策者,只负责判断"调哪个函数、参数填什么",以及拿到结果后把答案说成人话;应用是唯一的执行者,一切副作用都发生在你的进程里;本地函数是能力的提供者。围绕这次协作有三个术语,后文反复出现:
- 工具:声明给模型的一块能力,如 get_weather
- 工具调用:模型返回的指令条目,类型为 function_call
- 工具调用输出:执行后回传的结果条目,类型为 function_call_output
第 1 篇讲过两条 API 线,这里有个值得先知道的口径:截至 2026 年 10 月,GPT-6 Astra 与 GPT-6.1 Sol 的工具调用仅支持 Responses API,官方的 Chat Completions 示例只能退回上一代的 GPT-5.6 Terra 演示。本篇沿系列主线走 Responses API,条目式的输入输出是它的原生形态;Chat Completions 的等价物是 assistant 消息上的 tool_calls 字段配 role 为 tool 的消息回传,第 2 篇排查 finish_reason 时已经打过照面,不再展开。
2、定义工具的 JSON Schema
工具在请求的 tools 参数里声明,一个工具对应数组里的一个字典。Responses API 的结构是扁平的:type 固定为 function,其余键直接平铺。仍以天气为例:
tools = [
{
"type": "function",
"name": "get_weather",
"description": "查询指定城市的当日天气。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如 北京"},
},
"required": ["city"],
"additionalProperties": False,
},
},
]
各字段的作用说明如下:
| 字段 | 作用 |
|---|---|
name |
函数的唯一标识,执行侧按它分发 |
description |
告诉模型这个函数做什么、何时该用 |
parameters |
参数的 JSON Schema,约束模型如何填参 |
strict |
是否强制参数严格贴合 schema |
三个关键字段里,name 是门牌号,模型点到它才找得到函数;description 是模型的选型依据——手头有几个功能相近的工具时,选哪个几乎完全由描述的清晰度决定;parameters 就是第 4 篇讲透的 JSON Schema,枚举、嵌套、可空类型那些写法原样通用,等于给参数上了一道结构化输出的保险。strict 建议常开:官方文档明说它底层复用的正是结构化输出的约束解码,要求也一致——所有对象 additionalProperties 设 false、字段全部列入 required,可选语义用 ["string", "null"] 这类可空类型表达。默认行为还有个细节:Responses 请求不写 strict 时,服务端会尽量把 schema 规范化成严格模式,实在不兼容才回退成尽力而为。给写过 Chat Completions 的读者提个醒:那条线的 name 与 description 要嵌套包进一层 function 键,两条线的定义格式不通用,搬运代码时记得改。
3、模型发出调用请求
定义就绪后,第一次调用与第 1 篇的最小示例只差一个 tools 参数,instructions、input 的用法不变。变化全在返回值里:模型不再直接给文本,而是在 output 列表中放一个 function_call 条目——第 3 篇讲流式时预告过 output 数组里"还可能装着函数调用条目",就是它。关键字段摘录如下:
{
"id": "fc_0a1b2c3d",
"call_id": "call_dE4f5g6h",
"type": "function_call",
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
解读四个字段:name 是模型点的菜;arguments 是它填好的参数,注意这是 JSON 编码的字符串而非对象,取用前要先 json.loads 一次;call_id 是这次调用的回执编号,回传结果时靠它配对;id 是条目自身的编号,配对认 call_id 而不是它。"北京"从用户问题里来——参数由模型自己从上下文推断,这正是它作为决策者的价值。还有个容易迷惑的现象:此时 response.output_text 是空的,模型这一轮的全部产出就是这条指令,用户要的答案还在后面。
4、执行函数并回传结果
接下来是应用的主场。先写好本地函数与一个按名分发的路由,官方示例也是这个形状:
def get_weather(city):
return {"city": city, "weather": "晴", "temp_c": 22}
def call_function(name, args):
if name == "get_weather":
return get_weather(**args)
raise ValueError(f"未知函数: {name}")
然后是回传的核心逻辑,第一次调用、执行、回传、再调用的完整链路如下:
input_list = [{"role": "user", "content": "北京今天适合穿什么?"}]
response = client.responses.create(
model="gpt-6.1-terra", tools=tools, input=input_list,
)
input_list += response.output # 模型的输出条目原样带回
for item in response.output:
if item.type != "function_call":
continue
result = call_function(item.name, json.loads(item.arguments))
input_list.append({
"type": "function_call_output",
"call_id": item.call_id, # 与调用条目一一配对
"output": json.dumps(result, ensure_ascii=False),
})
final = client.responses.create(
model="gpt-6.1-terra", tools=tools, input=input_list,
)
print(final.output_text)
这段代码有五个要点。其一,input_list += response.output 把模型这一轮的全部输出原样追加回输入——包括 function_call 条目,也包括夹带的推理条目,官方明确要求推理条目必须随调用一起带回,这个写法一并照顾到了。其二,function_call_output 三件套是 type、call_id、output,call_id 抄自对应调用条目,服务端靠它把结果和请求对上号。其三,output 的官方口径是"通常为字符串,格式随你",JSON、纯文本、错误码都可以,json.dumps 是最稳的选择;无返回值的函数(如发邮件)也要回一个 success 状态串,否则模型无从判断成败。其四,第二次调用仍然带上 tools,模型可能还要连环调用,别中途撤掉工具清单;同理,若首轮用了 instructions,它不进 input 列表,每轮都要重传。其五,这套拼接与第 2 篇的多轮对话同一思想——input 列表就是会话状态,工具条目只是列席的新成员。
5、调用循环与终止条件
第 4 节只转了一圈,真实任务里模型可能连环出招:先查两地天气、再算温差、最后组织答案。终止条件只有一条:模型不再返回 function_call 条目。把流程包进循环,就是官方推荐的循环骨架:
for _ in range(10): # 上限兜底,防打转
response = client.responses.create(
model="gpt-6.1-terra", tools=tools, input=input_list,
)
calls = [i for i in response.output if i.type == "function_call"]
if not calls: # 不再要工具,终局答案
print(response.output_text)
break
input_list += response.output
for item in calls:
result = call_function(item.name, json.loads(item.arguments))
input_list.append({
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(result, ensure_ascii=False),
})
每圈先调用再检查:output 里没有 function_call,说明模型信息攒够了,output_text 就是最终答案,跳出循环;有则逐个执行回传,进入下一圈。两点工程细节:模型一轮可能返回多个调用条目(并行调用),所以收集用列表、回传用 for,官方也建议按"可能多个"来写处理逻辑;range(10) 是防御上限,模型偶有在两个工具间打转的失常,封顶能防住死循环烧钱。这个不到 20 行的循环就是各类 Agent 框架的最小内核,剥开编排与记忆等包装,跑的都是它。
6、多工具与调用控制
多工具不需要新机制,往 tools 数组里多放几个定义即可,模型自行选择:问天气调 get_weather,问算术调 calculate,全凭第 2 节的 description 与参数 schema 说话。需要收紧自由度时用 tool_choice 控制,各取值如下:
| 取值 | 行为 |
|---|---|
auto |
默认,模型自行决定调零个、一个或多个 |
required |
强制至少调用一个工具 |
| 锁定某函数 | 传 {"type": "function", "name": "get_weather"},只调它 |
none |
禁止调用,效果等同不传 tools |
表外还有两个开关。parallel_tool_calls 设为 False 可禁止一轮多调,保证每步至多一个调用;流式场景下工具调用也有专属事件——output_item.added 先报函数名,function_call_arguments.delta 逐段拼参数(第 3 篇的事件清单里预告过),想在界面上实时显示"正在调用 get_weather"就用它,完整清单见流式事件参考。工程上还有三条经验来自官方建议:工具定义会注入上下文并按输入 token 计费,开场工具数量建议控制在 20 个以内;别让模型填你的代码已知的参数,能省的 token 都省下来;复杂多步的工具编排对模型能力要求高,第 2 篇的选型结论在这里同样适用——练习用 Terra 足够,正经 Agent 主脑再上 Sol 与 Astra。
注意:工具调用把执行权交回你的代码,也把风险带了回来——模型可能被恶意输入诱导去调危险函数。删除数据、转账一类动作务必在执行侧做权限校验与人工确认,别把安全寄托在模型的判断力上。
7、双工具助手实战
把全部零件组装起来:一个假数据天气工具加一个四则计算器,能答"两地温差"这种要先查数再计算的复合问题,与官方 SDK 仓库的示例结构一致。先写本地函数与分发路由:
def get_weather(city):
data = {"北京": ("晴", 22), "上海": ("多云", 25), "广州": ("雷阵雨", 30)}
weather, temp = data.get(city, ("暂无数据", None))
return {"city": city, "weather": weather, "temp_c": temp}
def calculate(a, b, op):
if op == "div" and b == 0:
return {"error": "除数不能为 0"}
result = {"add": a + b, "sub": a - b, "mul": a * b, "div": a / b}[op]
return {"result": result}
def call_function(name, args):
if name == "get_weather":
return get_weather(**args)
if name == "calculate":
return calculate(**args)
raise ValueError(f"未知函数: {name}")
再声明两个工具,schema 按 strict 的要求写齐:
tools = [
{
"type": "function",
"name": "get_weather",
"description": "查询指定城市的当日天气。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如 北京"},
},
"required": ["city"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "calculate",
"description": "四则运算计算器,输入两个数与运算类型。",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "第一个数"},
"b": {"type": "number", "description": "第二个数"},
"op": {"type": "string", "enum": ["add", "sub", "mul", "div"]},
},
"required": ["a", "b", "op"],
"additionalProperties": False,
},
},
]
最后是主程序,套用第 5 节的循环骨架提一个复合问题:
import json
from openai import OpenAI
client = OpenAI()
question = "北京和上海今天各是什么天气?两地温差多少度?"
input_list = [{"role": "user", "content": question}]
for _ in range(10):
response = client.responses.create(
model="gpt-6.1-terra", tools=tools, input=input_list,
)
calls = [i for i in response.output if i.type == "function_call"]
if not calls:
print(response.output_text)
break
input_list += response.output
for item in calls:
result = call_function(item.name, json.loads(item.arguments))
input_list.append({
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(result, ensure_ascii=False),
})
运行输出类似:
北京今天晴、22 度,上海多云、25 度,两地温差 3 度。
值得盯着的点在中间过程:第一轮模型并行发起两个 get_weather,call_id 各不相同,循环里的 for 逐个回传;第二轮它再发一个 calculate 算 25 减 22;第三轮才输出最终答案——三圈循环恰好把"查数、计算、作答"走完整。错误处理也一样走 output 通道:除数为 0 时回传 {"error": ...},模型看到错误会调整问法或如实转告,不会把程序带崩。
8、总结
工具调用的全景到此拼完:模型从不执行工具,只发 function_call 指令,应用是唯一执行者;定义靠 tools 数组的扁平结构,description 决定模型选不选它,parameters 复用第 4 篇的 JSON Schema 与 strict 约束解码;回传靠 function_call_output 以 call_id 配对,input_list += response.output 把模型的调用与推理条目一并带回;循环终止于模型不再要工具,这个骨架就是 Agent 的最小内核。对于想让模型接入自有数据与真实动作的开发者,建议从两个工具起步,把 description 当产品文案来打磨,并在执行侧守住权限校验这道闸。下一篇讲多轮记忆,把工具调用装进有上下文的对话里,助手的形态会完整很多。
留一道练习:给第 7 节加上第三个工具 get_time,返回当前时间。注意它没有参数——无参函数的 parameters 要写成 properties 与 required 均为空的对象。加好后问两句话:"现在几点了?"与"这个点北京还适合晨跑吗?",观察模型第一问只调 get_time、第二问却连环调用两个工具的自选行为;再试试用 tool_choice 把调用锁死在 get_weather 上问时间,看看被捆住手脚的模型如何应答。

