Agentic Research

This article is not yet available in English. You are reading the Traditional Chinese original. The English edition will appear here once it is translated.

Browse articles that do have an English edition

Function Calling 入門:讓 LLM 真正使用工具

2026/09/3026 min readBryan Chan閱讀中文原文
TopicsAI AgentToolsAPILLM

上一篇 你的第一個 Agent 裡,我們用 system prompt 跟模型「拜託」它按 JSON 格式輸出,然後自己剝圍欄、自己解析、自己處理它不守規矩的情況。這能跑,但你在做 API 供應商早就做過的事:主流供應商把這套機制內建成了,名字叫 Function Calling(OpenAI 系用語)或 Tool Use(Anthropic 系用語,概念相同、欄位名不同)。模型在訓練階段就專門練習過「看到工具宣告,輸出結構化的呼叫意圖」,格式可靠性遠高於純 prompt 約束。這篇講四件事:機制怎麼運作、工具 schema 怎麼寫、呼叫行為怎麼控制,以及最影響實際效果的一門手藝——工具描述。

運作方式:模型不執行任何工具

先建立最重要的一張圖。Function Calling 的一次完整往返是四步:

你 ──(1) messages + tools 宣告──→ API/模型
                                      │
        (2) 模型回傳 tool_calls        │
你 ←──「我想呼叫 get_weather,      ──┘
        參數 city=台北」
   │
   (3) 你的程式真正執行 get_weather("台北")
   │
你 ──(4) 把結果以 role="tool" 訊息加回歷史,再次呼叫 API
        → 模型根據結果給出最終回答,或再發一輪 tool_calls

三個關鍵認知:

第一,模型從頭到尾沒有執行任何工具。 它只輸出「我想呼叫哪個工具、參數是什麼」這個意圖。真正的執行永遠發生在你的程式裡。這不是實作細節,是安全邊界:哪些工具存在、參數合不合法、要不要真的執行、執行前要不要人工確認——決定權全在你的程式碼,不在模型。

第二,第 (2) 步是「二選一」。 模型看到你的請求後,要嘛直接回答(content 有文字、沒有 tool_calls),要嘛發出工具呼叫(tool_calls 有內容)。判斷哪種情況發生,是你寫 Agent 循環的分岔點。值得注意的是,模型「直接回答」不等於「回答正確」——它可能在該查資料的時候選擇憑記憶硬答,這是幻覺的高發區,也是為什麼工具描述的觸發條件要寫得夠明確。

第三,第 (4) 步的訊息格式有硬性規定。 工具結果必須以 role="tool" 的訊息回傳,並帶上對應的 tool_call_id;而且在它之前,必須先有一則「帶著 tool_calls 的 assistant 訊息」在歷史裡。順序錯了、id 對不上、少回一個,API 會直接回 400 錯誤。這是新手最常見的翻車點,後面踩坑一節會再展開。

還有一個名詞先講清楚:tools 宣告是每次請求都要帶的。 API 無狀態(上一篇講過),模型不會「記得」你有什麼工具——每一輪請求都要把完整工具清單重新傳過去。

工具 Schema:用 JSON Schema 描述工具

工具宣告的核心是一份 JSON Schema——一個用 JSON 描述「資料應該長什麼形狀」的行業標準格式。你不寫實作,只寫介面說明:工具叫什麼、做什麼用、收哪些參數、每個參數是什麼型別、哪些必填。

一個完整範例,逐欄位解釋:

weather_tool = {
    "type": "function",              # 目前各家的 function calling 都用這個型別
    "function": {
        "name": "get_weather",       # 模型呼叫時用的名字,動詞_名詞、全小寫底線
        "description": (             # 給模型看的用途說明——影響呼叫品質的最大變數
            "查詢指定城市的目前天氣,包含溫度與降雨機率。"
            "使用者問到某地天氣時使用;"
            "查歷史天氣、氣候統計不是本工具的用途。"
        ),
        "parameters": {              # 參數的 JSON Schema
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "城市名稱,例如「台北」。",
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],  # 用 enum 鎖死合法值
                    "description": "溫度單位,預設攝氏。",
                },
            },
            "required": ["city"],    # 必填參數;不在清單裡的是選填
        },
    },
}

tools = [weather_tool]  # 請求時傳入 tools=tools

寫 schema 的幾條規則,每條都對應一種實際故障:

  • name 要能望文生義:get_weather 好過 api1。模型對名字的語義有感覺,名字含糊會拉低選對工具的機率。
  • description 寫「何時用」,也寫「何時不用」:只寫功能,模型會在相鄰場景誤用;寫上排除項,等於幫它劃邊界。這是下一節的主角,先記住結論:description 是整個 schema 裡最值得花時間打磨的欄位。
  • 型別與 enum 是給模型的護欄:能用 enum 收斂的取值就不要留自由字串;type 寫對(string / number / boolean / array / object),模型填參數時會照著約束走。
  • required 如實標註:漏標必填,模型可能不傳,你的實作就得處理 None;把選填標成必填,模型則可能硬編一個值塞進來。
  • 格式約束進 schema,不要放 description:「日期格式 YYYY-MM-DD」這類規則寫在參數的 description 或 format/pattern 欄位裡,比在工具描述結尾補一句「請注意格式」有效得多。

控制呼叫行為:tool_choice 與平行呼叫

tool_choice:要不要呼叫,你說了算

預設情況下,呼叫與否由模型自行判斷(tool_choice="auto")。但 API 提供了一個參數把決定權拿回程式手裡:

# 四種用法,支援度以供應商文件為準
tool_choice="auto"      # 模型自己決定(預設)
tool_choice="none"      # 禁止呼叫工具,只能直接回答
tool_choice="required"  # 必須至少呼叫一個工具,禁止直接回答
tool_choice={"type": "function", "function": {"name": "get_weather"}}
                        # 指定這一輪必須呼叫這一個工具

什麼時候用哪個:

  • auto:一般 Agent 循環的預設,模型該查就查、該答就答。
  • none:純聊天場景,或工具宣告只是想給模型當背景知識、不想讓它真的呼叫時。也能省 token——工具清單本身算輸入。
  • required:你比模型更確定「這一步必須查資料」的管線。例如資訊抽取流程中,「先呼叫結構化工具、再生成」是寫死的流程,不給模型自由發揮的空間,行為就可預測。
  • 指定函式:多步管線裡一步一步餵,把模型當「執行器」而非「規劃者」用。

一個觀念要擺正:auto 模式下模型「該不呼叫卻不呼叫」「不該呼叫卻呼叫」都會發生,判斷依據就是工具描述與使用者訊息的語義匹配——所以描述品質與呼叫品質直接掛鉤。

平行工具呼叫(parallel tool calls)

使用者問「台北和高雄今天各自天氣如何」,兩個查詢互不依賴。模型可能一次回傳多個 tool_calls,而不是查完一個再查下一個。這對效能有實質意義:你只需要一次 API 往返就拿到兩個呼叫意圖。

處理規則只有一條但要遵守得乾淨利落:每一個 tool_call_id 都必須有一則對應的 role="tool" 訊息回應,一個都不能少。 少了任何一個,下一次請求直接報錯。執行本身可以串行也可以並行(例如用執行緒池同時打兩個真實 API),但回應必須湊齊。

如果你想禁用這個行為(工具之間有順序依賴、或你想簡化除錯),OpenAI 系 API 提供 parallel_tool_calls=False 參數,模型就退回一輪最多呼叫一個工具。各相容端點對此參數的支援不一,用之前查文件。

完整範例:把上一篇的 Agent 升級

把上一篇的天氣+計算機 Agent 改寫成原生 Function Calling 版。工具實作完全不變,變的是宣告方式與循環裡的解析邏輯。存成 fc_agent.py 直接執行:

"""原生 Function Calling 版 Agent:與手寫文字協議版做同樣的任務。

前置:pip install openai,環境變數 DEEPSEEK_API_KEY 已設定。
"""
import json
import os

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1",
)
MODEL = "deepseek-chat"  # 需支援 function calling 的模型,以供應商文件為準

# ── 工具實作:與上一篇完全相同 ──
FAKE_WEATHER = {
    "台北": "26°C,降雨機率 70%,悶熱",
    "台中": "28°C,降雨機率 20%,多雲",
    "高雄": "29°C,降雨機率 10%,晴天",
}

def get_weather(city: str) -> str:
    return FAKE_WEATHER.get(city, f"查無 {city} 的天氣資料")

ALLOWED_CHARS = set("0123456789+-*/(). ")

def calculate(expression: str) -> str:
    if not set(expression) <= ALLOWED_CHARS:
        return "錯誤:運算式含有非法字元"
    try:
        return str(eval(expression, {"__builtins__": {}}, {}))
    except Exception as e:
        return f"計算失敗:{e}"

TOOL_IMPL = {"get_weather": get_weather, "calculate": calculate}

# ── 工具宣告:文字協議版的 system prompt 工具說明,換成結構化 schema ──
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": (
                "查詢指定城市的今日天氣,包含溫度與降雨機率。"
                "使用者問到某地天氣、或任務需要判斷適不適合戶外活動時使用。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名,例如「台北」"},
                },
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "計算四則運算式的結果。涉及數值計算(預算、加總、換算)時使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "僅含數字與 + - * / ( ) 的運算式,例如 '(350*2)+120'",
                    },
                },
                "required": ["expression"],
            },
        },
    },
]

MAX_ITERATIONS = 10  # 保險絲照舊:原生機制不等於不需要終止條件

def run_agent(task: str) -> str:
    messages = [
        {"role": "system", "content": "你是能使用工具完成任務的助手,用使用者的語言回覆。"},
        {"role": "user", "content": task},
    ]

    for _ in range(MAX_ITERATIONS):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools,      # 每次請求都要帶工具宣告(API 無狀態)
            temperature=0,
        )
        msg = resp.choices[0].message

        # 關鍵一:不管有沒有 tool_calls,先把 assistant 訊息放回歷史。
        # 少了這一步,接下來的 tool 訊息會讓 API 回 400。
        messages.append(msg)

        # 分岔點:沒有 tool_calls = 模型認為任務完成
        if not msg.tool_calls:
            return msg.content

        # 關鍵二:可能一次回傳多個 tool_calls(平行呼叫),逐一處理、逐一回應
        for tc in msg.tool_calls:
            name = tc.function.name
            try:
                # arguments 是「JSON 字串」而不是物件,要自己 loads
                args = json.loads(tc.function.arguments)
            except json.JSONDecodeError:
                result = f"錯誤:無法解析參數 '{tc.function.arguments}'"
            else:
                func = TOOL_IMPL.get(name)
                if func is None:
                    result = f"錯誤:未知的工具 '{name}'"
                else:
                    try:
                        result = func(**args)
                    except TypeError as e:
                        result = f"參數錯誤:{e}"

            # 結果以 role="tool" 回傳,tool_call_id 必須與呼叫一一對應
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": str(result),
            })

    return f"任務未在 {MAX_ITERATIONS} 輪內完成,已強制終止"


if __name__ == "__main__":
    task = (
        "幫我查台北今天的天氣,判斷適不適合野餐;"
        "再算一下:兩個人每人花 350 元的話,800 元的預算夠不夠?"
    )
    print("最終答案:", run_agent(task))

跟上一篇的土砲版對照,差異一目了然:

項目文字協議(手寫版)原生 Function Calling
輸出格式的來源prompt 拜託模型遵守,可能違約模型受過結構化輸出訓練,API 層保證欄位
解析工作自己剝程式碼圍欄、處理廢話arguments 就是 JSON 字串,loads 即可
平行呼叫要自己設計協議與排程原生支援,tool_calls 是個清單
行為控制只能在 prompt 裡寫規則tool_choice、parallel_tool_calls 參數
模型相容性任何聊天模型都能跑需要供應商與模型支援,用前查文件
循環、執行、錯誤處理你的程式碼一樣是你的程式碼

最後一列是本節的重點:Function Calling 替換掉的只是「協議層」,Agent 循環本身一步都沒有少——決定做什麼的還是模型,執行工具的還是你的程式,餵回結果、判斷終止、設保險絲,全都還是你的責任。所謂 Agent 框架(LangChain、LangGraph 之流),做的就是把你這兩篇寫過的循環、加上記憶管理與生態整合,封裝成可重用的產品。現在你已經能看穿它們的抽象層了。

工具描述寫得好壞,決定呼叫品質

機制是標準的,schema 是死板的,真正拉開差距的是描述文案。原因很簡單:模型決定「要不要呼叫、呼叫哪個、參數填什麼」時,它能看到的全部資訊就是 name、description 與參數描述——你的實作程式碼它一行也看不到。描述就是這個工具的全部文件。

以下是實務上反覆驗證有效的寫法原則:

一、寫觸發條件,不只寫功能。 「查詢天氣」是功能;「使用者問到某地天氣、或任務需要判斷適不適合戶外活動時使用」是觸發條件。模型做的是語義匹配,你給的匹配線索越具體,它選得越準。

二、寫排除項。 工具最容易犯的錯不是「不會用」,而是「在不該用的時候用」。跟相鄰工具、相鄰場景劃清界線:「查歷史天氣與氣候統計請用 get_climate,不是本工具的用途」。

三、相似工具是災難之源。 如果 search_docs 與 search_web 的描述讀起來差不多,模型就會在兩者之間隨機漂流。解法要嘛合併成一個工具、用參數區分來源,要嘛把兩份描述寫成互斥的。

四、具體勝過抽象,短語勝過長篇。 「計算四則運算式的結果」好過「一個強大的數學助手,可以幫你處理各種數學問題」。描述不是寫給人類看的行銷文案,是寫給模型看的路標。

五、參數描述同樣重要。 模型填錯參數(格式不對、單位不對、該填 ID 填了名字),多數時候是因為參數描述沒講清楚。把格式範例直接寫進去:「日期,格式 YYYY-MM-DD,例如 2026-09-30」。

六、像除錯 prompt 一樣除錯描述。 這是最重要的一條方法論:把每一次 tool_call(工具名+參數+結果)寫進日誌;準備一組固定的測試輸入(涵蓋典型場景與邊界場景,並標注「期望呼叫哪個工具」);每次修改描述後重跑這組測試,比較選錯的次數。描述文案的迭代應該有回饋迴圈,而不是「感覺改得比較好」。這本質上就是 Prompt Engineering 的方法套用在 schema 上;規模化之後就是 LLM 評估 裡講的回歸測試。

七、工具數量本身是成本。 工具越多,模型每一步要讀的宣告越長(算輸入 token),選擇也越容易出錯。當你的工具清單長到幾十個,考慮分組:先讓模型選「類別」,再展開該類別的工具——或者把工具外掛成獨立服務,這正是 MCP 協議 要解決的問題。

常見錯誤與踩坑

以下七條按「新手遇到機率」排序,前五條幾乎人人都會撞上至少一次。好消息是它們全部有標準解法,壞消息是不撞過一次通常記不住。

一、忘記把帶 tool_calls 的 assistant 訊息放回歷史。 API 的硬性規定:role="tool" 的訊息前面,必須緊跟著那則「發出呼叫的 assistant 訊息」。只 append 工具結果、跳過 assistant 訊息,下一次請求直接 400,錯誤訊息通常是 "tool message must follow an assistant message with tool_calls" 之類的提示。範例程式裡 messages.append(msg) 那一行就是為此存在。

二、tool_call_id 對不上或漏回應。 平行呼叫回傳三個 tool_calls,你只回了兩個 tool 訊息,或 id 抄錯一個,一樣報錯。規則背下來:一次 tool_calls 清單,對應等量、id 一致的 tool 訊息。

三、把 arguments 當物件用。 tc.function.arguments 是 JSON 字串,不是 dict,直接 args["city"] 會炸。要 json.loads。而且它極少數情況下可能不是合法 JSON(生成截斷時),try/except 不能省。

四、只看 content,漏看 tool_calls。 模型可以在同一則訊息裡既說話又呼叫工具——content 寫「我來幫你查一下」,同時 tool_calls 裡發出呼叫。如果你的判斷寫成「content 非空就當最終答案」,Agent 會在半路把這句話當成結果回傳。正確的判斷順序:先看 tool_calls 有沒有,再看 content。

五、串流模式下的 tool_calls 是碎片。 開 stream=True 時,工具呼叫以增量片段(delta)送達,arguments 字串會被切成好幾塊,要按 index 自己累積拼接才能解析。這是進階主題,初學階段建議循環用非串流,把串流留給最終答案的展示。

六、以為所有模型都支援。 Function Calling 是「供應商 × 模型」層級的能力:同一家供應商底下,有的模型支援、有的不支援,支援的參數範圍(例如 tool_choice="required"、parallel_tool_calls)也不一致。以 DeepSeek 為例,deepseek-chat 支援 function calling,而 reasoning 系列模型的支援範圍以官方文件為準。上線前用最小請求實測一次,比讀十篇部落格可靠。

七、工具名稱打錯或沒註冊。 模型呼叫了一個 TOOL_IMPL 裡不存在的名字——通常是宣告與實作兩邊的名稱不同步。別讓程式崩潰:查不到工具時,把「未知工具,可用清單如下」當結果餵回去,模型會自己改過來。

下一步