cd ../back to blog
$Guide//June 4, 2026//7 min read

在 Claude 與 Gemini 之間共用工具呼叫:一份程式碼,一個 base_url

用標準的 OpenAI function schema 定義工具,讀取 tool_calls,跑完多輪迴圈並處理平行呼叫 — 同一份程式碼透過 Brievio 同時驅動 Claude 與 Gemini。

工具呼叫 — 也叫函式呼叫 — 就是把一個聊天模型,變成能真正動手做事的東西:查一筆紀錄、打一支 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 欄位不是裝飾 — 它們是模型 用來判斷何時、以及如何呼叫的唯一依據。把它們當成在替一位資淺工程師 寫 docstring 那樣寫:

define_tool.py
# 用標準的 OpenAI「function」schema 定義一個工具,再讀回
# 模型回傳的 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 字串,不是 dict — 你永遠要對它 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,就是你用過的每一個代理(agent)的引擎。一個 模型可以串接工具 — 呼叫 search、讀取結果、再對最佳結果 呼叫 get_details、然後作答 — 而這個迴圈不必為任何情況 特例處理,就能應付任意深度。加上一個輪數計數器當作護欄,免得一個 搞混的模型無止盡地空轉;對多數應用來說,8~10 輪是個合理的上限。

關於可移植性,有個誠實的提醒:那套協定在 Claude 與 Gemini 之間 完全相同,但行為並不是一個模子刻出來的。不同的模型家族會挑 不同的工具、用不同的措辭組織參數,而且在「動手呼叫」與「憑既有知識 作答」之間,踴躍程度也各有差異。程式碼不必改;改變的是判斷力。請對 你打算上線的每一個模型分別測試你的提示,別假設一個能完美移植到 另一個身上。

第 3 步 — 平行工具呼叫

當一個問題需要好幾次互不相干的查詢 — 三座城市的天氣、五個 SKU 的 庫存 — 一個有能力的模型可以在單一輪 assistant 回應裡,把所有呼叫 一次回傳。你把它們執行掉(若工作受 I/O 限制就併行),再為每一個 id 各回一則 tool 訊息,然後才再次提問:

parallel_calls.py
# 平行工具呼叫:一輪 assistant 回應可以一次要求好幾個函式。
# 你把它們執行掉(想的話可以併行),再為每一個 call 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。同樣地,嚴格 schema 強制(保證合法的 JSON、 駁回多餘的鍵)也並非到處一致 — 不論是哪個模型產生的,都請持續在 伺服器端驗證參數。

為什麼「一個 base_url」才是真正的勝利

沒有閘道的話,要同時支援 Claude Gemini,意味著兩套 SDK、 兩套驗證機制、兩種酬載形狀,以及兩組工具結果的管線 — 一邊是 Anthropic 的 tool_use/tool_result 內容區塊,另一邊是 Google 的 function-call parts。在 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 等),尊重原生的工具呼叫,並回報誠實的權杖 數;如果你想親自確認這一點,請看 如何確認你的 Claude 真的是 Claude

一份簡短的實戰檢查清單

  • 永遠要解析參數。 function.arguments 是一個字串;先 json.loads 它並驗證,再拿去用。
  • 把 id 回應回去。原封不動地附加 assistant 訊息,再為 每一次呼叫各附加一則 tool 訊息,帶上對得上的 tool_call_id。在下一次請求之前,全部都要附加好。
  • 走訪整個清單。永遠別假設一輪只有一個呼叫 — 要能 處理零個、一個與多個。光是這一個習慣,就能讓平行與序列的模型 都直接運作無虞。
  • 替輪數設上限。一個輪數計數器能防止無止盡的工具 呼叫螺旋,也替你的成本設下界線。
  • 什麼都別信。參數是模型的輸出。請像對待使用者輸入 那樣,逐一驗證型別、範圍與權限。

把這五件事做對,你就有了一個工具呼叫代理,能在 Claude 與 Gemini 之間 原封不動地運作,還能選擇按每次請求的成本或能力來路由。要注意的是,在 Brievio 上失敗的 4xx/5xx 呼叫不計費,所以你在把工具定義調對的過程裡,那些難免要做的 schema 微調迭代都是免費的。等你準備好挑選要放在工具背後的模型時, 閘道選擇指南 會帶你走過那些在正式環境裡真正重要的取捨。