工具调用 —— 也叫函数调用 —— 就是把一个聊天模型变成能真正动手做事的东西:查一条记录、请求一个 API、跑一段计算、 查询你的数据库。模型并不运行代码;它告诉你该调用哪个函数、带上哪些 参数,你来运行,再把结果交回去,它就能把答案写完。好消息是:OpenAI 的 tools 结构已是事实标准,而通过 Brievio,完全相同的代码就能 在一个 base_url 后面同时驱动 Claude 和 Gemini。改一下 model 字符串, 其余一概不动。
这篇文章是实战版:定义一个工具、读取 tool_calls、把多轮循环从头到尾跑通,再处理并行调用。每一段 代码都能用 OpenAI Python SDK 对着 https://api.brievio.com/v1 直接运行。我会标出少数几处模型 家族之间行为确实有别的地方,免得你在生产环境里被打个措手不及。
心智模型:是循环,不是一次魔法调用
函数调用是一场对话,不是一次性的请求。它始终遵循同样的四个节拍:
- 你发出用户消息,外加一份模型获准使用的工具清单。
- 模型做决定。它要么用文字作答,要么返回一个或多个
tool_calls—— 一个函数名加上一串 JSON 字符串形式的参数 —— 然后停下。 - 你在自己的代码里运行该函数,并把结果作为一条
tool消息追加回消息列表。 - 你带着更长的历史再次调用模型。它读取结果,要么作答, 要么再请求一个工具。重复,直到不再有工具调用为止。
模型从不碰你的系统。它永远只是提议;处置的是你的代码。这条边界就是 工具调用安全性的全部要义 —— 把模型发来的每一个参数都当成不可信的输入, 像对待一个表单字段那样去校验它。
第 1 步 —— 定义一个工具并读取调用
一个工具就是一段被 {"type": "function", ...} 包起来的 JSON Schema。 其中的 description 字段不是摆设 —— 它们是模型用来判断 何时、如何调用的唯一依据。写它们的时候,就当你在给一位初级工程师 写文档注释:
# 用标准的 OpenAI "function" 模式定义一个工具,再把
# 模型返回的 tool_calls 读出来。Claude 和 Gemini 的结构完全一致。
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "e.g. 'Tokyo'"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit.",
},
},
"required": ["city"],
},
},
}
]
messages = [{"role": "user", "content": "What's the weather in Tokyo?"}]
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # 换成 "gemini-2.5-pro" —— 下面的代码原封不动
messages=messages,
tools=tools,
tool_choice="auto", # 让模型自己决定要不要调用
)
msg = resp.choices[0].message
# 模型没有用文字回答 —— 它要你去运行一个函数。
if msg.tool_calls:
for call in msg.tool_calls:
print(call.function.name) # "get_weather"
print(call.function.arguments) # '{"city": "Tokyo", "unit": "celsius"}'
args = json.loads(call.function.arguments) # 永远是一个 JSON 字符串 —— 要解析它
else:
print(msg.content) # 普通回答,不需要工具这里有两点会绊倒不少人。第一, function.arguments 是一个 JSON 字符串, 而不是字典 —— 你总要先 json.loads 它。第二,模型可能选择不调用任何工具,这时 tool_calls 为空,而 content 里装着一个正常的回答。两种情况都要分支 处理。无论你把 model 设成 claude-sonnet-4-6 还是 gemini-2.5-pro, 这都完全一样;Brievio 把请求转交给真正的第一方模型,并返回原生的 工具调用 —— 它不会重塑或伪造它们。
第 2 步 —— 多轮循环
现在把这趟往返接起来。关键的结构是:把 assistant 消息原封不动地追加进去(它携带着调用 id),然后每个调用追加一条 tool 消息,各自回带它的 tool_call_id。 一旦 id 对不上,下一个请求就会 400。下面就是整个循环,用一个函数 同时服务两家供应商:
# 多轮循环:模型发起请求 -> 你运行函数 ->
# 你把结果喂回去 -> 模型写出最终答案。
def run_get_weather(city: str, unit: str = "celsius") -> dict:
# 你真实的实现:一次 HTTP 调用、一次数据库查询,随便什么都行。
return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}
TOOL_IMPLS = {"get_weather": run_get_weather}
def answer(question: str, model: str) -> str:
messages = [{"role": "user", "content": question}]
while True:
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
# 没有请求工具 -> 这就是最终答案。结束。
if not msg.tool_calls:
return msg.content
# 1. 把 assistant 这一轮原封不动地追加进去(它携带着
# 后续消息必须引用的 tool_call id)。
messages.append(msg)
# 2. 运行每一个被请求的函数,每次调用追加一条 tool 消息,
# 并回带对应的 tool_call_id。
for call in msg.tool_calls:
fn = TOOL_IMPLS[call.function.name]
args = json.loads(call.function.arguments)
result = fn(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id, # 必须与该次调用的 id 匹配
"content": json.dumps(result), # 把结果序列化成字符串
})
# 3. 继续循环:模型现在看到工具输出,接着往下进行。
# 同一个函数在这一个 base_url 后面对两家供应商都管用:
print(answer("What's the weather in Tokyo?", "claude-sonnet-4-6"))
print(answer("What's the weather in Tokyo?", "gemini-2.5-pro"))那个 while True 正是你用过的每一个智能体的引擎。一个 模型可以把工具串起来 —— 调用 search、读结果,再对排在 最前面的那条命中调用 get_details,然后作答 —— 而这个 循环无需特判就能处理任意深度。加一个轮次计数器当护栏,免得一个 糊涂的模型无限转下去;对多数应用来说,8~10 轮是个稳妥的上限。
关于可移植性,有一句老实话要说:协议在 Claude 和 Gemini 之间 完全一致,但行为并不是一个模子刻出来的。不同的模型家族会 挑选不同的工具、用不同的措辞组织参数,在「主动调用」还是「凭已有知识 作答」之间也各有偏好。代码不变;变的是判断。把你的提示词拿去对着每一个 你打算上线的模型测一遍,别想当然地以为一个能完美迁移到另一个上。
第 3 步 —— 并行工具调用
当一个问题需要好几次彼此独立的查询 —— 三座城市的天气、五个 SKU 的 库存 —— 一个有能力的模型可以在 assistant 的一轮里把所有调用都返回。 你把它们运行掉(如果是 I/O 密集型,就并发地跑),并为每个 id 返回一条 tool 消息,然后再次发问:
# 并行工具调用:assistant 的一轮可以一次请求好几个函数。
# 你把它们运行掉(愿意的话可以并发),并为每个调用 id 返回
# 一条 tool 消息。模型会不会把调用批量打包各不相同 —— 所以
# 永远遍历这个列表,而不是假定只有一个。
messages = [{"role": "user",
"content": "Compare the weather in Tokyo, Paris and Cairo."}]
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
messages.append(msg)
# msg.tool_calls 此时可能装着三个 id 各不相同的 get_weather 调用。
from concurrent.futures import ThreadPoolExecutor
def handle(call):
args = json.loads(call.function.arguments)
result = TOOL_IMPLS[call.function.name](**args)
return {
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
}
with ThreadPoolExecutor() as pool:
tool_msgs = list(pool.map(handle, msg.tool_calls or []))
messages.extend(tool_msgs) # 在发出下一个请求前,先追加全部结果
final = client.chat.completions.create(
model="claude-sonnet-4-6", messages=messages, tools=tools,
)
print(final.choices[0].message.content)
# 注意:如果某个模型一次只返回一个调用、而不是批量打包,上一段
# 代码里的循环会自动处理 —— 它只是多跑几轮而已。这里正是模型家族差异最大的地方,所以别把假设写死。一个给定的模型会 在一轮里发出并行调用,还是分好几轮一个一个地走,因家族而异, 有时甚至因请求而异。修法很简单,而且上面的代码里已经有了:遍历 tool_calls,需要时就让循环多跑几轮。 对返回列表做遍历的代码在两种情况下都正确;假定恰好只有一个调用的代码 才是 bug。同样地,严格模式校验(保证 JSON 合法、拒绝多余的键)也并非 统一标配 —— 无论参数出自哪个模型,都要在服务端坚持校验。
为什么一个 base_url 才是真正的胜利
没有网关的话,要同时支持 Claude 和 Gemini 就意味着两套 SDK、 两套鉴权方案、两种载荷结构,外加两套工具结果的管线 —— 一边是 Anthropic 的 tool_use/tool_result 内容块,另一边是 Google 的 function-call 片段。在 Brievio 这个 OpenAI 兼容端点后面,两者都讲你上面看到的 Chat Completions tools 方言,于是在两个模型之间做 A/B 测试只是一行 diff, 而你的工具层只用写一次。完整的请求/响应契约 —— 包括那些工具字段 —— 都在 Chat Completions 文档里,带有精确 id 的 在线模型清单则在 模型页面上。
有一点值得明说:只有当另一端的模型是真货时,这份价值才成立。工具调用 其实是一个很有用的真实性信号 —— 一个货真价实的旗舰模型,在非平凡的 schema 上能可靠地产出格式良好、参数合理的 tool_calls, 而一个更廉价的冒牌替身往往会把 JSON 搞砸,或者干脆无视工具。Brievio 提供的是货真价实的第一方模型(Claude Sonnet 4.6、Opus 4.7、 Gemini 2.5 Pro/Flash 等等),尊重原生的工具调用,并如实上报 token 数;如果你想亲自确认这一点,请看 如何核实你的 Claude 真的是 Claude。
一份简短的实战清单
- 永远解析参数。
function.arguments是一个字符串;先json.loads它,并在使用前校验。 - 回带那些 id。原样追加 assistant 消息,然后每个调用 追加一条
tool消息,并带上匹配的tool_call_id。全部追加完,再发下一个请求。 - 遍历这个列表。永远别假定每轮只有一个调用 —— 要能 处理零个、一个和多个。仅这一个习惯,就能让并行模型和顺序模型都 照样跑通。
- 给轮次设上限。一个轮次计数器能防止无限的工具调用 螺旋,也给你的成本兜了底。
- 什么都别信。参数是模型的输出。像对待用户输入一样, 校验它的类型、取值范围和权限。
把这五条做对,你就有了一个工具调用智能体,它在 Claude 和 Gemini 之间 原封不动地运行,并能按每个请求的成本或能力来选路。注意,在 Brievio 上 失败的 4xx/5xx 调用不会计费,所以在你把工具定义打磨好的过程中,那些躲不掉的 schema 调试迭代是免费的。等你准备好挑选要放到工具背后的模型时, 网关选型指南 会带你走一遍那些在生产环境里真正要紧的取舍。