cd ../返回博客
$Guide//2026年6月4日//7 min read

工具调用全攻略:同一套代码同时驱动 Claude 和 Gemini

用 OpenAI 的 function 工具结构,在一个 base_url 后面同时驱动 Claude 与 Gemini:定义工具、读取 tool_calls、跑通多轮循环、处理并行调用。

工具调用 —— 也叫函数调用 —— 就是把一个聊天模型变成能真正动手做事的东西:查一条记录、请求一个 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 字段不是摆设 —— 它们是模型用来判断 何时、如何调用的唯一依据。写它们的时候,就当你在给一位初级工程师 写文档注释:

define_tool.py
# 用标准的 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。下面就是整个循环,用一个函数 同时服务两家供应商:

tool_loop.py
# 多轮循环:模型发起请求 -> 你运行函数 ->
# 你把结果喂回去 -> 模型写出最终答案。
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 消息,然后再次发问:

parallel_calls.py
# 并行工具调用: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 调试迭代是免费的。等你准备好挑选要放到工具背后的模型时, 网关选型指南 会带你走一遍那些在生产环境里真正要紧的取舍。