OpenAI SDK 函数与工具调用

QuibblerAgentQuibblerAgent 2026-11-08 约 24 分钟 17 次阅读

模型能言善辩,却两眼一抹黑:它不知道今天的天气,查不到你数据库里的订单,更不能替你发一封邮件——训练数据是静态的,模型本身只是一个文本进、文本出的函数。函数调用(Function Calling,官方也称工具调用)补上这块短板:你把"我这里有哪些能力"声明给模型,模型在需要时发回一条结构化调用指令,由你的代码真正执行,再把结果喂回去。本篇是《OpenAI Python SDK 从入门到实战》系列第 5 篇,第 4 篇打下的 JSON Schema 地基在这里直接复用。全文按"机制 → 定义 → 调用 → 回传 → 循环 → 多工具 → 实战 → 总结"组织。

1、工具调用是什么

先破一个最常见误解:模型从不执行任何工具。它运行在 OpenAI 的服务器上,你的数据库、天气服务、邮件网关它一个都碰不到;工具调用真正做的事,是让模型在需要时发回一条结构化指令,由你的代码代为执行。官方函数调用指南把它概括为五步:带工具清单请求、收到调用、本地执行、回传结果、拿到最终回答。一次完整协作的时序如下:

sequenceDiagram participant U as 用户 participant A as 你的应用 participant M as 模型 participant F as 本地函数 U->>A: 北京今天多少度? A->>M: 问题加工具清单 M-->>A: 请求调用 get_weather A->>F: 真正执行函数 F-->>A: 返回天气数据 A->>M: 回传函数结果 M-->>A: 生成自然语言回答 A-->>U: 北京晴,22 度

图里两次模型调用夹着一次本地执行,三方各司其职:模型是决策者,只负责判断"调哪个函数、参数填什么",以及拿到结果后把答案说成人话;应用是唯一的执行者,一切副作用都发生在你的进程里;本地函数是能力的提供者。围绕这次协作有三个术语,后文反复出现:

  • 工具:声明给模型的一块能力,如 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 上问时间,看看被捆住手脚的模型如何应答。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

101