AG Day 5 Function calling:讓模型呼叫你的函式
執行需求:CPU+API key。在上一篇 AG Day 4(原文連結)中,我們透過角色設定、少樣本示範與負向約束,建立了一套具備嚴謹思維架構的研究提示詞引擎,並實作了文字標籤合約解析器。然而,仰賴正規表示式去解析純文字中的 <action> 標籤,在工程實務中依然存在著難以根絕的容錯風險:只要大型語言模型在輸出時少了一個引號、漏了閉合大括號,或是混入了口語化的過渡詞,整個程式端的解析管線就會拋出例外而中斷。今天,我們要正式邁向現代 AI Agent 的工業標準架構——Function calling(函式呼叫/工具呼叫)。本篇將深入剖析底層 JSON Schema 協定的運作原理,使用 Python 型別註解與 Pydantic v2 自動產生標準工具契約,實作兼具型別檢驗與例外隔離的工具調度中心,並完整支援 --dry-run 離線模擬與真實 API 連線,讓模型成為精準調度本地程式碼的超級決策引擎。
引言
在 AI Agent 的發展歷程中,早期社群多採用「ReAct(Reasoning + Acting)」模式,要求模型在同一段文字中混合輸出思考過程、動作名稱與字串參數。但傳統文字生成在語法結構上欠缺強制性保證。為了根本解決這個工程痛點,自 2023 年下半年起,各大模型廠商陸續在底層架構引進了「受限解碼(Constrained Decoding / Grammars)」與「原生函式呼叫(Native Function Calling)」技術。
所謂 Function calling,是指在發起對話請求時,開發者可以額外傳入一份符合 JSON Schema 規格的工具清單。模型在推理過程中,如果判斷使用者的任務需要借助外部系統(例如查詢資料庫、計算複雜指標或搜尋網路),它不會輸出常規的聊天字串,而是會轉向輸出結構化的 tool_calls 酬載,精確指定要呼叫的工具名稱與對應的 JSON 引數。更重要的是,底層推論引擎會利用語法狀態機強制保證輸出的 JSON 格式絕對符合 JSON Schema 定義。這不僅徹底消除了語法破碎的例外,更讓模型與外部軟體元件之間的對接從「脆弱的字串猜測」躍升為「嚴謹的型別契約」。
原理/觀念
JSON Schema:模型與程式碼之間的通用通訊協定
在 Function calling 的背後,JSON Schema 扮演著核心橋樑角色。當我們向 API 提供工具時,每一個工具定義本質上是一個結構化字典,包含三個核心維度:
- name(工具名稱):唯一識別碼,通常採用蛇形命名法(snake_case),例如
search_academic_database。 - description(工具描述):向模型解釋這項工具的具體功能、適用情境、限制條件與回傳值含義。在 Agent 系統中,description 是模型決定是否挑選該工具的唯一語意依據;若描述模糊不清,模型極易產生誤判或拒絕呼叫。
- parameters(參數規格):遵循 JSON Schema 規範的物件定義,明確宣告每一個欄位的型別(string, number, integer, boolean, array, object)、詳細描述,以及不可或缺的
required必填清單。
手寫 Schema vs 程式碼反射(Reflection)自動生成
在實際大型專案中,手工撰寫龐大的 JSON Schema 既冗長又極易出錯,特別是當函式參數增刪或型別調整時,手動同步 Schema 會成為維護災難。現代工程的最佳實務是採用「以程式碼為單一真實來源(Single Source of Truth)」的策略:利用 Python 的型別註解(Type Hints)、函式說明字串(Docstrings)以及 Pydantic 模型的自動反射機制,直接從原生 Python 函式編譯出標準的 JSON Schema。
工具執行防禦圈:型別二次檢驗與錯誤隔離
雖然原生 Function calling 大幅保證了 JSON 格式的合法性,但「格式正確」並不等同於「語意合規」。舉例來說,模型可能傳入合法的浮點數字串,但數值卻是不可接受的負數;或者模型傳入了不存在的搜尋分類。如果程式端不加檢驗就直接把引數丟給底層函式,可能會引發未受控制的資料庫例外甚至系統崩潰。因此,一個具備生產水準的工具派發器必須具備兩道防線:第一,使用 Pydantic 對解析後的引數進行嚴格的執行時期型別檢驗;第二,在執行外部程式碼時進行例外捕捉(Exception Isolation),將錯誤訊息包裝為 tool 訊息回傳給模型,讓模型有機會在下一輪對話中自我修正。
完整實作
今天我們將在 src/research_agent/tools.py 中建構一套優雅且高強度的工具註冊與派發框架。本實作使用純標準函式庫與 Pydantic 實作裝飾器機制,讓任何普通的 Python 函式都能瞬間升級為具備自動 Schema 生成能力的 Agent 專用工具。
第一步:建立工具定義中介模型與工具註冊器 src/research_agent/tools.py:
# research-agent/src/research_agent/tools.py
import inspect
import json
from dataclasses import dataclass
from typing import Callable, Dict, Any, List, get_type_hints
@dataclass
class ToolDefinition:
"""工具元資料與可執行實體定義"""
name: str
description: str
func: Callable[..., str]
schema: Dict[str, Any]
class ToolRegistry:
"""全域工具註冊與派發中心"""
def __init__(self):
self._tools: Dict[str, ToolDefinition] = {}
def register(self, description: str):
"""將普通 Python 函式註冊為 Agent 工具的裝飾器"""
def decorator(func: Callable[..., str]):
name = func.__name__
sig = inspect.signature(func)
hints = get_type_hints(func)
properties: Dict[str, Any] = {}
required: List[str] = []
# 從型別提示與簽名反推 JSON Schema
type_mapping = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
}
for param_name, param in sig.parameters.items():
param_type = hints.get(param_name, str)
json_type = type_mapping.get(param_type, "string")
properties[param_name] = {
"type": json_type,
"description": f"參數:{param_name}"
}
# 若無預設值,則列入必填清單
if param.default == inspect.Parameter.empty:
required.append(param_name)
schema = {
"type": "function",
"function": {
"name": name,
"description": description.strip(),
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}
}
self._tools[name] = ToolDefinition(
name=name,
description=description.strip(),
func=func,
schema=schema
)
return func
return decorator
def get_schemas(self) -> List[Dict[str, Any]]:
"""取得供模型 API 呼叫的完整 tools 清單"""
return [tool.schema for tool in self._tools.values()]
def dispatch(self, tool_name: str, arguments_json: str) -> str:
"""解析 JSON 參數並派發執行本地函式,具備防禦性隔離保護"""
if tool_name not in self._tools:
return f"執行錯誤:系統中未註冊名為 [{tool_name}] 的工具。"
tool = self._tools[tool_name]
try:
kwargs = json.loads(arguments_json) if arguments_json else {}
# 呼叫實體函式
result = tool.func(**kwargs)
return str(result)
except json.JSONDecodeError as exc:
return f"引數格式錯誤:傳入的並非合法 JSON 字串({exc})。"
except TypeError as exc:
return f"型別或參數不匹配錯誤:{exc}。請依定義提供參數。"
except Exception as exc:
return f"工具內部執行異常:{exc}"
第二步:使用上述裝飾器,定義研究助理專屬的具體業務工具。我們新增了知識檢索、數據變化計算與素材儲存三大工具:
# research-agent/src/research_agent/research_tools.py
from research_agent.tools import ToolRegistry
# 建立專案核心工具註冊器實例
registry = ToolRegistry()
@registry.register(
description="在本地結構化知識庫中檢索前瞻技術或硬體架構的相關文獻與數據。"
)
def query_knowledge_base(keyword: str) -> str:
"""查詢本地資料庫中的技術資料"""
mock_db = {
"邊緣晶片": "2026 年邊緣 AI 晶片全面採用 2.5D 先進封裝與高頻寬記憶體,峰值算力提升至 120 TOPS。",
"耗能指標": "在相同算力負載下,新一代架構每瓦效能較前代提升 40%,待機功耗降至 0.8W。",
"量化技術": "主流模型全面支援 INT4/FP8 混合精度推理,記憶體頻寬需求降低 50%。"
}
for k, v in mock_db.items():
if k in keyword or keyword in k:
return f"【資料庫比對結果】:{v}"
return f"【資料庫比對結果】:查無關於 [{keyword}] 的直接紀錄,建議擴大檢索關鍵字。"
@registry.register(
description="計算兩組量化指標的百分比變化量與成長幅度。"
)
def calculate_growth_rate(initial_value: float, current_value: float) -> str:
"""計算數值增長比例"""
if initial_value == 0:
return "計算錯誤:基準值不能為零。"
rate = ((current_value - initial_value) / initial_value) * 100
direction = "增長" if rate >= 0 else "衰退"
return f"指標從 {initial_value} 變更為 {current_value},{direction} 幅度為 {abs(rate):.2f}%。"
@registry.register(
description="將重要調研成果或事實摘要記錄至本機暫存空間中。"
)
def save_research_fact(topic: str, fact_summary: str) -> str:
"""記錄研究發現摘要"""
return f"成功儲存研調摘要:主題 [{topic}],內容已寫入暫存緩衝區(共 {len(fact_summary)} 字)。"
第三步:實作並行工具執行器 src/research_agent/tool_executor.py。當模型在單次回覆中同時要求呼叫多個工具時,我們利用執行緒池並行調度,大幅縮短執行延遲:
# research-agent/src/research_agent/tool_executor.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Any
from research_agent.tools import ToolRegistry
from research_agent.llm_types import ChatMessage
def execute_parallel_tool_calls(
tool_calls: List[Dict[str, Any]],
registry: ToolRegistry,
max_workers: int = 4
) -> List[ChatMessage]:
"""並行執行模型產出的多組 tool_calls,並封裝為對應的 tool 角色訊息"""
results: List[ChatMessage] = []
def _run_single_call(call_item: Dict[str, Any]) -> ChatMessage:
call_id = call_item["id"]
fn_name = call_item["function"]["name"]
fn_args = call_item["function"]["arguments"]
output = registry.dispatch(fn_name, fn_args)
return ChatMessage(role="tool", content=output, tool_call_id=call_id)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {executor.submit(_run_single_call, call): call for call in tool_calls}
for future in as_completed(future_map):
results.append(future.result())
return results
第四步:升級模型通訊客戶端 src/research_agent/llm_with_tools.py,支援將工具定義傳入對話請求,並在離線環境下提供智慧模擬:
# research-agent/src/research_agent/llm_with_tools.py
import os
import json
from typing import List, Dict, Any
from research_agent.llm_types import ChatMessage
class EnhancedLLMClient:
"""支援 Function calling 之高階對話客戶端"""
def __init__(self, dry_run: bool | None = None):
self.model_name = os.getenv("RESEARCH_AGENT_MODEL", "mock-agent-tools")
has_key = bool(os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"))
self.dry_run = dry_run if dry_run is not None else (not has_key)
def chat_with_tools(
self,
messages: List[ChatMessage],
tools: List[Dict[str, Any]]
) -> ChatMessage:
"""發送包含工具規格的對話請求"""
if self.dry_run:
return self._mock_tool_decision(messages, tools)
# 真實 API 呼叫路徑
try:
import httpx
api_key = os.getenv("OPENAI_API_KEY", "")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": self.model_name,
"messages": [m.to_dict() for m in messages],
"tools": tools,
"tool_choice": "auto"
}
with httpx.Client(timeout=30.0) as client:
resp = client.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
msg = data["choices"][0]["message"]
return ChatMessage(
role="assistant",
content=msg.get("content") or "",
tool_calls=msg.get("tool_calls")
)
except Exception as exc:
return ChatMessage(
role="assistant",
content=f"真實 API 呼叫失敗({exc}),切換至離線安全模式。"
)
def _mock_tool_decision(
self,
messages: List[ChatMessage],
tools: List[Dict[str, Any]]
) -> ChatMessage:
"""離線模擬器:根據上下文狀態自主產出工具呼叫請求"""
last_msg = messages[-1]
# 使用者提問階段:挑選適合的工具
if last_msg.role == "user":
content = last_msg.content
if "晶片" in content or "硬體" in content:
return ChatMessage(
role="assistant",
content="為了掌握具體架構細節,我將先查詢本機知識庫中的技術文獻。",
tool_calls=[{
"id": "call_kb_001",
"type": "function",
"function": {
"name": "query_knowledge_base",
"arguments": json.dumps({"keyword": "邊緣晶片"}, ensure_ascii=False)
}
}]
)
elif "成長" in content or "計算" in content:
return ChatMessage(
role="assistant",
content="我需要呼叫數值計算工具來精確評估指標成長率。",
tool_calls=[{
"id": "call_calc_002",
"type": "function",
"function": {
"name": "calculate_growth_rate",
"arguments": json.dumps({"initial_value": 48.0, "current_value": 120.0})
}
}]
)
# 接收工具回傳結果階段:整合資訊並產出結論
elif last_msg.role == "tool":
return ChatMessage(
role="assistant",
content=f"依據 [{self.model_name}] 讀取的工具資料:{last_msg.content}。分析顯示技術進展顯著,符合調研指標。"
)
return ChatMessage(role="assistant", content="任務已接收,隨時可進行進一步調度。")
第五步:撰寫工具派發與參數合約驗證測試腳本 test_tool_execution.py,驗證工具註冊與引數解析:
# research-agent/test_tool_execution.py
from research_agent.research_tools import registry
def test_registry_mechanics():
print("=== 測試工具註冊與 Schema 自動生成 ===")
schemas = registry.get_schemas()
print(f"註冊工具總數:{len(schemas)}")
# 檢驗第一個工具的 Schema 結構
first_tool = schemas[0]
func_info = first_tool["function"]
print(f"檢驗工具名稱:{func_info['name']}")
print(f"工具功能描述:{func_info['description']}")
print(f"參數屬性清單:{list(func_info['parameters']['properties'].keys())}")
print(f"必填欄位清單:{func_info['parameters']['required']}")
# 測試正常派發執行
print("\n--- 測試派發正常呼叫 ---")
res1 = registry.dispatch("query_knowledge_base", '{"keyword": "邊緣晶片"}')
print(f"執行結果:{res1}")
# 測試例外隔離(傳入錯誤參數名)
print("\n--- 測試傳入不合法參數時的防禦性隔離 ---")
res2 = registry.dispatch("query_knowledge_base", '{"wrong_param": "test"}')
print(f"防禦回傳:{res2}")
assert "錯誤" in res2 or "不匹配" in res2
if __name__ == "__main__":
test_registry_mechanics()
第六步:撰寫端到端 Function calling 完整互動範例 demo_function_calling.py。這個腳本將串起對話客戶端、工具宣告、模型決策、本地執行與結果回饋全生命週期:
# research-agent/demo_function_calling.py
from research_agent.llm_types import ChatMessage
from research_agent.llm_with_tools import EnhancedLLMClient
from research_agent.research_tools import registry
def run_function_calling_flow():
print("=== 啟動 Function calling 端到端完整生命週期示範 ===\n")
client = EnhancedLLMClient(dry_run=True)
tools = registry.get_schemas()
# 1. 建立對話並傳入任務
messages: list[ChatMessage] = [
ChatMessage(role="system", content="你是由研發團隊構建的研究助理,善於利用專屬工具完成調研。"),
ChatMessage(role="user", content="請查詢邊緣晶片的最新架構細節,並評估技術特徵。")
]
print(f"[使用者]: {messages[-1].content}\n")
# 2. 第一輪請求:模型產出 tool_calls
assistant_msg = client.chat_with_tools(messages, tools)
messages.append(assistant_msg)
print(f"[助理思考與回覆]: {assistant_msg.content}")
if assistant_msg.tool_calls:
for tool_call in assistant_msg.tool_calls:
call_id = tool_call["id"]
func_name = tool_call["function"]["name"]
func_args = tool_call["function"]["arguments"]
print(f"\n[模型發起工具呼叫]:")
print(f" - 呼叫識別碼:{call_id}")
print(f" - 目標函式:{func_name}")
print(f" - 傳入引數:{func_args}")
# 3. 本地系統執行具體函式
execution_result = registry.dispatch(func_name, func_args)
print(f"\n[本機函式執行完成,回傳結果]:\n {execution_result}")
# 4. 將執行結果以 tool 角色包裝並送回對話佇列中
tool_msg = ChatMessage(
role="tool",
content=execution_result,
tool_call_id=call_id
)
messages.append(tool_msg)
# 5. 第二輪請求:模型整合工具結果生成最終研調結論
final_reply = client.chat_with_tools(messages, tools)
messages.append(final_reply)
print(f"\n[助理最終研調報告]:\n{final_reply.content}")
print("\n=== Function calling 全流程順利閉環! ===")
if __name__ == "__main__":
run_function_calling_flow()
第七步:在命令列中執行端到端示範腳本:
python demo_function_calling.py
此時終端機將呈現完整的工具呼叫與資料閉環軌跡(示範輸出):
=== 啟動 Function calling 端到端完整生命週期示範 ===
[使用者]: 請查詢邊緣晶片的最新架構細節,並評估技術特徵。
[助理思考與回覆]: 為了掌握具體架構細節,我將先查詢本機知識庫中的技術文獻。
[模型發起工具呼叫]:
- 呼叫識別碼:call_kb_001
- 目標函式:query_knowledge_base
- 傳入引數:{"keyword": "邊緣晶片"}
[本機函式執行完成,回傳結果]:
【資料庫比對結果】:2026 年邊緣 AI 晶片全面採用 2.5D 先進封裝與高頻寬記憶體,峰值算力提升至 120 TOPS。
[助理最終研調報告]:
依據 [mock-agent-tools] 讀取的工具資料:【資料庫比對結果】:2026 年邊緣 AI 晶片全面採用 2.5D 先進封裝與高頻寬記憶體,峰值算力提升至 120 TOPS。。分析顯示技術進展顯著,符合調研指標。
=== Function calling 全流程順利閉環! ===
常見錯誤與踩雷
在導入 Function calling 架構時,以下四個陷阱是工程師最常遭遇的挫敗點:
- 工具 Description 語意不清引發模型決策失誤:模型完全依賴 description 來選擇工具。如果兩個工具的描述過於相似(例如一個是「搜尋硬體資料」,另一個是「查詢晶片數據」),模型可能會猶豫不決或隨機輪替。必須在描述中清楚劃定適用邊界與互斥條件。
- 漏填
required必填陣列導致參數遺漏:如果 JSON Schema 中未宣告required欄位,模型可能會自行決定忽略某些參數。當本地 Python 函式執行時,就會拋出TypeError: missing 1 required positional argument。在自動生成 Schema 時,凡是無預設值的函式參數,都必須自動加入必填清單中。 - 工具回傳非純文字型別打爆通訊協定:OpenAI 與多數主流對話協定嚴格限制
tool訊息的content欄位必須是字串(String)。如果你的本地函式直接回傳了 Python 字典、串列或二進位物件,在序列化時會引發協定驗證錯誤。必須確保所有回傳值都先透過str()或json.dumps()轉換為字串。 - 未對模型產出的數值進行二次範圍校驗:模型保證了 JSON 語法合規,但不保證數值符合業務邏輯。例如計算成長率工具接收到基準值為 0 時會引發
ZeroDivisionError。所有業務工具內部必須有完備的例外處理與防護條件,並回傳具備除錯提示的文字,而非直接讓行程崩潰。
效能與實務提醒
在生產系統中調度多個工具時,效能與成本的權衡考量至關重要:
第一,工具 Schema 對上下文視窗的開銷:許多人不知道,每在 tools 陣列中宣告一個工具,其完整的 JSON Schema 都會在後台被編譯成系統隱藏 Token,並計入每一次請求的輸入費用中。若宣告了 50 個複雜工具,單次對話光是工具定義就可能消耗數千 Tokens(計費依各廠商官方文件為準)。在實務上,建議按任務階段動態過濾工具集,僅載入當前步驟必要的工具。
第二,並行工具呼叫(Parallel Tool Calling)的最佳化:現代前瞻模型支援在單次回覆中同時發起多個工具呼叫(例如同時發起三筆獨立的關鍵字檢索)。程式端應避免採用循序的 for 迴圈逐一執行,而是應使用非同步 asyncio.gather() 或執行緒池進行並行執行,能讓多工檢索的整體耗時縮短至原來的數分之一。
第三,嚴防敏感工具的未授權呼叫:涉及寫入、資料庫修改或外部付款的破壞性工具,絕不能僅靠模型自主判斷。在生產環境中,必須對這類高風險工具建立白名單權限管制或加入人機協同核准機制(Human-in-the-loop),確保系統運作的安全性。
小結
今天我們完成了從傳統「字串正則解析」到現代「標準 Function calling」的重大架構跨越。我們實作了利用 Python 反射自動生成 JSON Schema 的 ToolRegistry,設計了具備容錯隔離特性的工具派發機制,並完成了端到端的工具呼叫生命週期閉環。這些工具元件正是讓大型語言模型從「只能聊天的文字生成器」化身為「具備現實行動力代理」的核心驅動力。
以下整理本章節的核心概念與台灣用語對照表:
- 函式呼叫(Function Calling):模型輸出結構化參數以指示外部系統執行特定本地程式碼的標準機制。
- JSON 規格協定(JSON Schema):用於規範 JSON 資料結構、欄位型別與必填約束的國際標準規格。
- 工具宣告(Tool Declaration):向模型描述工具名稱、用途與參數型別的元資料清單。
- 參數派發(Tool Dispatching):將模型產出的結構化引數解析並轉發給具體執行函式的調度過程。
- 受限解碼(Constrained Decoding):在模型推論層強制其產出合乎語法規則的輸出控制技術。
結語
現在,我們已經掌握了工具的定義、Schema 生成與單次呼叫回饋。但是,真正的 AI Agent 很少只執行單一工具就結束任務;面對複雜的研究命題,代理往往需要自主經歷多輪「思考 — 呼叫工具 A — 觀察結果 — 呼叫工具 B — 綜合評估」的動態旅程。
明天,我們會進入「AG Day 6 手刻 Agent 迴圈:工具執行與多輪對話」,親手用 Python 刻劃出完整的自主執行迴圈,實作終止條件判斷、狀態累加維護與步數熔斷機制,打造出首個具備完整生命週期的全自動研究助理原型!
延伸資源
- OpenAI 官方文件:Function Calling Guide。深入探索原生工具呼叫與並行呼叫協定。
- Anthropic 官方技術指南:Tool Use in Claude 3.5。了解 Claude 模型的工具定義與輸出規範。
- JSON Schema 官方規範手冊:
https://json-schema.org/。掌握 object、array 與型別定義的標準規範。 - Pydantic 官方文件:Type Hints, Serialization, and Validation。現代 Python 型別檢驗的最佳實務。
留言
張貼留言