工具呼叫與 Function Calling:讓 LLM 連接外部世界

深入 Agent 最重要的基礎設施——工具呼叫與 Function Calling,理解其運作原理、Schema 設計與常見陷阱。

工具呼叫與 Function Calling:讓 LLM 連接外部世界

前兩篇我們談了 Agent 的概念與核心循環:ReAct、Plan-and-Execute 與 Reflection。

但你有沒有發現,這些模式都預設了一個關鍵能力:Agent 能呼叫工具
沒有工具,Agent 再會推理、再會規劃,也只能「說」,不能「做」。

這一篇,我們要深入 Agent 最重要的基礎設施:工具呼叫(Tool Use)Function Calling,理解它們的運作原理、設計要點與常見陷阱。
下一篇,我們才會用這些知識,從零打造一個最基礎的 Agent。

LLM → Function Calling → External Tools

一、為什麼 LLM 需要工具?

LLM 很強大,但它有幾個根本限制:

1. 知識有截止日期

模型的知識來自訓練資料,它不知道今天的天氣、現在的股價、最新的新聞。

2. 無法精確計算

LLM 是機率模型,不是計算機。
你問它 12345 × 67890,它可能給你一個看起來合理但錯誤的答案。

3. 無法存取私有資料

它不知道你公司的內部文件、你的資料庫、你的 email。

4. 無法執行動作

它不能寄信、不能下單、不能操作你的電腦。

工具呼叫就是為了解決這些問題。
當 LLM 需要即時資訊、精確計算或實際行動時,它可以呼叫外部工具來完成。

二、工具呼叫的兩種實作方式

要讓 LLM 呼叫工具,目前有兩種主流做法:

方式一:文字格式(Prompt-based)

在 Prompt 中定義一套文字格式,要求 LLM 按照格式輸出工具呼叫請求。

例如 ReAct 風格:

Thought: 使用者想知道天氣,我需要查詢台北的天氣。
Action: get_weather
Action Input: {"city": "台北"}

然後開發者用正規表達式去解析 ActionAction Input

優點:

  • 不依賴特定 API,所有 LLM 都能用
  • 格式自由,可自訂

缺點:

  • 格式容易出錯,解析容易失敗
  • 不支援平行呼叫
  • 參數驗證困難
  • 需要自己處理各種邊界情況

方式二:Function Calling(API-based)

現代 LLM API 直接支援結構化的工具呼叫。
開發者定義好工具 Schema,LLM 回傳結構化的 tool_calls 物件。

{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"台北\"}"
      }
    }
  ]
}

優點:

  • 結構化輸出,可靠性高
  • 支援平行呼叫
  • Schema 自動驗證參數
  • 不需要寫解析器

缺點:

  • 需要 LLM API 支援
  • 格式受限於 API 規範

實務建議:
如果你的 LLM API 支援 Function Calling,優先使用它。
如果模型不支援(如某些開源模型),才退回文字格式。

三、Function Calling 的運作原理

Function Calling 的核心概念是:

把工具的定義當作額外的上下文,一起送給 LLM,讓模型在生成時「知道」有哪些工具可用。

具體流程如下:

步驟 1:開發者定義工具 Schema

每個工具包含:

  • 名稱:工具的唯一識別碼
  • 描述:這個工具做什麼、何時該用
  • 參數結構:每個參數的名稱、型別、描述、是否必填
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查詢指定城市與日期的天氣資訊",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名稱,例如:台北、台中、高雄"
                    },
                    "day": {
                        "type": "string",
                        "enum": ["今天", "明天", "後天"],
                        "description": "日期"
                    }
                },
                "required": ["city", "day"]
            }
        }
    }
]

步驟 2:把工具定義與使用者問題一起送給 LLM

messages = [
    {"role": "user", "content": "台北今天適合爬山嗎?"}
]

response = client.chat.completions.create(
    model="gpt-4",
    messages=messages,
    tools=tools,
    tool_choice="auto"  # 讓模型自行決定是否呼叫工具
)

步驟 3:LLM 決定是否呼叫工具

LLM 會根據使用者的問題與工具描述,判斷:

  • 需不需要呼叫工具?
  • 需要呼叫哪個工具?
  • 工具的參數是什麼?

如果需要,LLM 回傳結構化的 tool_calls

{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"台北\", \"day\": \"今天\"}"
      }
    }
  ]
}

如果不需要,LLM 直接生成最終回答。

步驟 4:開發者執行工具

開發者解析 arguments(是一個 JSON 字串),執行對應的函式:

function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
result = TOOL_FUNCTIONS[function_name](**arguments)

步驟 5:把結果回傳給 LLM

把工具結果包成 tool 訊息,送回給 LLM:

messages.append(response.choices[0].message)  # LLM 的工具呼叫請求
messages.append({
    "role": "tool",
    "tool_call_id": "call_abc123",
    "content": "多雲,降雨機率 60%,氣溫 22-28°C"
})

步驟 6:LLM 生成最終回答

LLM 根據工具結果,生成最終回答:

今天台北降雨機率 60%,氣溫 22-28°C,山區午後有陣雨機率。
整體來說,今天不是最適合爬山的時機。

這就是完整的 Function Calling 流程。

User → LLM → Tool Call → Execute → Result → LLM

四、Tool Schema 的設計要點

工具定義的品質,直接影響 LLM 能否正確使用工具。以下是幾個設計要點:

1. 名稱要清楚

  • get_weathersend_emailquery_database
  • tool1func_ado_stuff

2. 描述要具體

描述是 LLM 判斷「何時該用這個工具」的關鍵。

# 好
"description": "查詢指定城市與日期的天氣資訊,回傳溫度、降雨機率與天氣狀況"

# 差
"description": "查天氣"

3. 參數要明確

每個參數都應該有型別、描述,以及是否必填。

"parameters": {
    "type": "object",
    "properties": {
        "city": {
            "type": "string",
            "description": "城市名稱,例如:台北、台中、高雄"
        },
        "day": {
            "type": "string",
            "enum": ["今天", "明天", "後天"],
            "description": "日期"
        }
    },
    "required": ["city", "day"]
}

4. 用 enum 限制選項

如果參數只有幾種合法值,用 enum 限制,能大幅降低 LLM 出錯的機率。

"unit": {
    "type": "string",
    "enum": ["celsius", "fahrenheit"],
    "description": "溫度單位"
}

5. 參數數量要適中

參數太多會增加 LLM 出錯的機率。
如果一個工具需要超過 5 個參數,可以考慮拆成多個工具,或用巢狀結構。

6. 提供範例

在描述中加入範例,能幫助 LLM 理解使用時機。

"description": "查詢天氣。例如:get_weather(city='台北', day='今天')"

7. 工具要單一職責

一個工具只做一件事。
get_weather 只查天氣,不要同時查天氣和匯率。

五、平行工具呼叫

現代 LLM 支援一次輸出多個工具呼叫,稱為 平行工具呼叫(Parallel Tool Calling)

例如,使用者問:「比較台北和台中今天的天氣。」

LLM 可能一次回傳:

{
  "tool_calls": [
    {
      "id": "call_1",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"台北\", \"day\": \"今天\"}"
      }
    },
    {
      "id": "call_2",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"台中\", \"day\": \"今天\"}"
      }
    }
  ]
}

開發者可以平行執行這兩個工具,再把結果一起回傳給 LLM。
這能大幅減少延遲,提升效率。

from concurrent.futures import ThreadPoolExecutor

def execute_tools_parallel(tool_calls):
    """平行執行多個工具呼叫"""
    def run_one(tool_call):
        function_name = tool_call.function.name
        arguments = json.loads(tool_call.function.arguments)
        try:
            result = TOOL_FUNCTIONS[function_name](**arguments)
        except Exception as e:
            result = f"工具執行錯誤:{e}"
        return tool_call.id, result

    with ThreadPoolExecutor() as executor:
        results = list(executor.map(run_one, tool_calls))

    return results

然後在主迴圈中,把每個結果分別包成 tool 訊息回傳:

if message.tool_calls:
    results = execute_tools_parallel(message.tool_calls)
    for tool_call_id, result in results:
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call_id,
            "content": str(result),
        })

六、常見的錯誤與陷阱

1. 幻覺工具

LLM 可能呼叫一個不存在的工具。
解法:在執行前檢查工具名稱是否存在。

if function_name not in TOOL_FUNCTIONS:
    result = f"錯誤:找不到工具 {function_name}"

2. 參數錯誤

LLM 可能傳入錯誤的參數型別或缺少必填參數。
解法:用 Schema 驗證,並在執行前檢查必填參數。

import inspect

def safe_execute(function_name: str, arguments: dict):
    if function_name not in TOOL_FUNCTIONS:
        return f"錯誤:找不到工具 {function_name}"

    func = TOOL_FUNCTIONS[function_name]
    sig = inspect.signature(func)
    required_params = [
        name for name, param in sig.parameters.items()
        if param.default is inspect.Parameter.empty
    ]

    missing = [p for p in required_params if p not in arguments]
    if missing:
        return f"錯誤:缺少必填參數 {missing}"

    try:
        return func(**arguments)
    except Exception as e:
        return f"執行錯誤:{e}"

3. 工具執行失敗

工具本身可能因為網路、權限、資源等問題失敗。
解法:把錯誤訊息回傳給 LLM,讓它決定要重試、換工具,還是放棄。

messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": "錯誤:天氣 API 暫時無法連線,請稍後再試。"
})

4. 無限迴圈

如果 LLM 一直重複呼叫同一個工具卻無法成功,可能會陷入無限迴圈。
解法:設定最大迭代次數。

for i in range(max_iterations):
    # ...
    if i == max_iterations - 1:
        return "已達到最大迭代次數,任務未完成。"

5. 工具呼叫超時

如果某個工具執行時間過長,應該設定超時機制,避免整個 Agent 卡住。

import signal

def timeout_handler(signum, frame):
    raise TimeoutError("工具執行超時")

def execute_with_timeout(func, args, timeout_seconds=10):
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(timeout_seconds)
    try:
        result = func(**args)
    finally:
        signal.alarm(0)
    return result

6. 敏感資訊洩漏

工具回傳的內容可能包含敏感資訊(如密碼、個資)。
解法:在回傳給 LLM 之前,先過濾或脫敏。

import re

def sanitize_output(text: str) -> str:
    text = re.sub(r'\S+@\S+\.\S+', '[EMAIL]', text)
    text = re.sub(r'\d{4}-\d{3}-\d{3}', '[PHONE]', text)
    return text

七、工具呼叫的最佳實踐

1. 回傳結果要結構化

盡量回傳結構化的資料(如 JSON),而不是一大段文字。
這讓 LLM 更容易解析與使用。

# 好
return {"city": "台北", "temp": "22-28°C", "rain": "60%"}

# 差
return "台北今天多雲,降雨機率 60%,氣溫 22 到 28 度,風向東北風..."

2. 限制工具權限

不是所有工具都該讓 LLM 自由呼叫。
高風險操作(如刪除資料、發送郵件、轉帳)應該加入人類審核或權限控制。

HIGH_RISK_TOOLS = {"delete_file", "send_email", "transfer_money"}

def execute_tool_safely(function_name, arguments):
    if function_name in HIGH_RISK_TOOLS:
        if not human_approval(function_name, arguments):
            return "操作已被使用者拒絕"
    return TOOL_FUNCTIONS[function_name](**arguments)

3. 記錄所有工具呼叫

保留完整的工具呼叫日誌,方便除錯與審計。

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("agent")

def logged_execute(function_name: str, arguments: dict):
    logger.info(f"Tool call: {function_name}({arguments})")
    result = safe_execute(function_name, arguments)
    logger.info(f"Tool result: {result}")
    return result

4. 工具數量要控制

一次提供太多工具,會讓 LLM 難以選擇。
如果工具超過 20 個,可以考慮:

  • 分組,讓 LLM 先選類別再選工具
  • 用 RAG 動態檢索相關工具
  • 用多 Agent 分工,每個 Agent 只負責一組工具

八、Function Calling vs 文字格式:該用哪個?

面向文字格式(ReAct)Function Calling
格式可靠性低,容易解析失敗高,結構化輸出
平行呼叫不支援支援
參數驗證手動檢查Schema 自動驗證
模型支援所有 LLM需支援的 API
實作複雜度需寫解析器直接使用 API
靈活性高,可自訂格式受限於 API 規範

結論:

  • 如果你的 LLM API 支援 Function Calling,優先使用它。
  • 如果模型不支援(如某些開源模型),才退回文字格式。
  • 兩者可以混用:主要工具用 Function Calling,特殊需求用文字格式補充。

九、總結:工具是 Agent 的手腳

讓我們回顧這一篇的核心:

  • LLM 的限制:知識有截止日期、無法精確計算、無法存取私有資料、無法執行動作。
  • 兩種實作方式:文字格式(Prompt-based)與 Function Calling(API-based)。
  • Function Calling 流程:定義工具 → 送給 LLM → LLM 決定呼叫 → 執行工具 → 回傳結果 → LLM 生成回答。
  • Tool Schema 設計:名稱清楚、描述具體、參數明確、用 enum 限制選項、單一職責。
  • 平行工具呼叫:一次執行多個工具,減少延遲。
  • 常見陷阱:幻覺工具、參數錯誤、執行失敗、無限迴圈、超時、敏感資訊洩漏。
  • 最佳實踐:結構化回傳、權限控制、日誌記錄、工具數量控制。

工具呼叫是 Agent 從「會說」到「會做」的關鍵一步。
理解了 Function Calling 的原理與設計要點,你就具備了打造實用 Agent 的基礎。

下一篇,我們要把這些知識付諸實踐:從零打造一個最基礎的 Agent。
我們會用 LLM + 工具 + 一個迴圈,寫出一個真正能運作的 Agent。

下一篇預告

《從零打造一個最基礎的 Agent:LLM + 工具 + 迴圈》

我們會用最少的程式碼,從零實作一個能接收問題、決定是否呼叫工具、執行工具、並生成最終回答的 Agent。你會看到它的核心其實非常簡單:LLM + 工具 + 一個迴圈