Agentic Research
首頁/學習/Function Calling 入門:讓 LLM 真正使用工具

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

2026/09/3026 分鐘君澤智庫最後更新 2026/09/30
這篇屬於學習主題AI 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 裡不存在的名字——通常是宣告與實作兩邊的名稱不同步。別讓程式崩潰:查不到工具時,把「未知工具,可用清單如下」當結果餵回去,模型會自己改過來。

下一步