AIAgent 設計:Tool Calling/Function Calling 完全解析,Agent 如何呼叫外部工具
如何讓模型能呼叫外部工具,並親手實作「模型選工具 → 執行 → 回輸 → 答覆」的完整迴圈 核心原則
Tool Calling 是 Agent 的底層——Agent 不過是「會自己決定何時呼叫哪個工具的迴圈」
模型本身只會產生文字,不會真的算數、查資料、打 API。Tool Calling 的機制是:

1. 把工具的 JSON Schema 連同問題一起送給模型
2. 模型回答「我要呼叫 calculator,參數 {expression:'...'}」
3. 而程式(不是模型)真的去執行該函式
4. 再把執行結果回輸給模型
5. 模型用結果產出最終自然語言答覆
執行工具的是你的程式碼,不是模型。模型只負責「決定呼叫什麼、用什麼參數」。
理解這個執行的迴圈,下個模組 Agent(ReAct)其實就是把這個迴圈自動跑很多輪。
工具定義與 JSON Schema
印出三個工具(calculator / get_weather / search_kb)的完整 Schema,這是模型實際看到的東西
python tool_calling_lab.py tools
# ─────────────────────────────────────────────────────────────────────────────
# 工具實作:每個工具是一個普通 Python 函式(帶型別標註與 docstring)
# ─────────────────────────────────────────────────────────────────────────────
def calculator(expression: str) -> str:
"""計算一個算術運算式,例如 (23 + 19) * 3。只支援 + - * / ( ) 與數字。"""
if not re.fullmatch(r"[\d\s+\-*/().]+", expression):
return "錯誤:運算式含不支援的字元"
try:
return str(eval(expression, {"__builtins__": {}}, {})) # 已用白名單字元限制,安全
except Exception as e:
return f"錯誤:{e}"
def get_weather(city: str) -> str:
"""查詢城市目前天氣。city 為城市名稱,例如 台北。"""
fake = {"台北": "28°C 多雲", "東京": "22°C 晴", "高雄": "31°C 晴時多雲"}
return fake.get(city, f"{city}:查無資料(mock)")
def search_kb(keyword: str) -> str:
"""在知識庫搜尋關鍵字並回傳摘要。"""
return f"關於「{keyword}」的知識庫摘要(mock):這是一段示範用的檢索結果。"
TOOLS = {fn.__name__: fn for fn in (calculator, get_weather, search_kb)}
def build_schema(fn):
sig = inspect.signature(fn)
props, required = {}, []
for name, param in sig.parameters.items():
jtype = _PY2JSON.get(param.annotation, "string")
props[name] = {"type": jtype, "description": f"{name} 參數"}
if param.default is inspect.Parameter.empty:
required.append(name)
return {
"type": "function",
"function": {
"name": fn.__name__,
"description": (fn.__doc__ or "").strip().split("\n")[0],
"parameters": {"type": "object", "properties": props, "required": required},
},
}
def all_schemas():
return [build_schema(fn) for fn in TOOLS.values()]
def cmd_tools(_args):
print("已註冊工具(模型實際看到的 JSON Schema):\n")
print(json.dumps(all_schemas(), ensure_ascii=False, indent=2))
print("\n工具設計原則:名稱動詞化、描述寫清楚『何時用』、參數最小化、回傳要可被模型理解。")
執行結果:
註冊工具(模型實際看到的 JSON Schema):
[
{
"type": "function",
"function": {
"name": "calculator",
"description": "計算一個算術運算式,例如 (23 + 19) * 3。只支援 + - * / ( ) 與數字。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "expression 參數"
}
},
"required": [
"expression"
]
}
}
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查詢城市目前天氣。city 為城市名稱,例如 台北。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "city 參數"
}
},
"required": [
"city"
]
}
}
},
{
"type": "function",
"function": {
"name": "search_kb",
"description": "在知識庫搜尋關鍵字並回傳摘要。",
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "keyword 參數"
}
},
"required": [
"keyword"
]
}
}
}
]
工具設計原則:名稱動詞化、描述寫清楚『何時用』、參數最小化、回傳要可被模型理解。
示範 build_schema() 如何從 Python 函式的型別標註 + docstring 自動推導出 Schema
python tool_calling_lab.py schema --fn calculator
def cmd_schema(args):
fn = TOOLS.get(args.fn)
if not fn:
print(f"無此工具:{args.fn},可用:{list(TOOLS)}"); return
print(f"從函式 `{args.fn}{inspect.signature(fn)}` 推導出的工具定義:\n")
print(json.dumps(build_schema(fn), ensure_ascii=False, indent=2))
執行結果:
{
"type": "function",
"function": {
"name": "calculator",
"description": "計算一個算術運算式,例如 (23 + 19) * 3。只支援 + - * / ( ) 與數字。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "expression 參數"
}
},
"required": [
"expression"
]
}
}
}
這裏有幾個點需要注意
- 工具設計原則:名稱動詞化、description 寫清楚「何時該用這個工具」
- 參數最小化、回傳要簡潔且可被模型理解。描述寫不好,模型就會選錯工具。
完整 Tool-Calling 迴圈
python tool_calling_lab.py loop --query "(23 + 19) * 3 等於多少?" --offline
# ─────────────────────────────────────────────────────────────────────────────
# 真實 API(OpenAI 相容 function calling;Kimi 同此介面)
# ─────────────────────────────────────────────────────────────────────────────
def _client():
if os.environ.get("KIMI_API_KEY"):
from openai import OpenAI
return OpenAI(api_key=os.environ["KIMI_API_KEY"],
base_url=os.environ.get("KIMI_BASE_URL", "https://api.moonshot.cn/v1")), \
os.environ.get("KIMI_MODEL", "kimi-k2.6")
p = Path(__file__).resolve().parent.parent / "kimi.json"
if p.exists():
c = json.loads(p.read_text(encoding="utf-8"))
if c.get("KIMI_API_KEY"):
from openai import OpenAI
return OpenAI(api_key=c["KIMI_API_KEY"],
base_url=c.get("KIMI_BASE_URL", "https://api.moonshot.cn/v1")), \
c.get("KIMI_MODEL", "kimi-k2.6")
if os.environ.get("OPENAI_API_KEY"):
from openai import OpenAI
return OpenAI(), "gpt-4o-mini"
return None, None
def real_loop(query):
cli, model = _client()
if cli is None:
raise RuntimeError("無 OpenAI 相容憑證")
messages = [{"role": "user", "content": query}]
for _ in range(5): # 限制最多 5 輪,避免無限工具循環
resp = cli.chat.completions.create(model=model, messages=messages, tools=all_schemas())
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = TOOLS[tc.function.name](**args)
print(f" [工具] {tc.function.name}({args}) → {result}")
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
return "(達到工具呼叫上限)"
def mock_decide_tools(query):
"""回傳 [(tool_name, args_dict), ...];空 list 代表不需工具、直接回答。"""
calls = []
if re.search(r"[\d]+\s*[+\-*/]", query): # 含算式
expr = re.search(r"[\d\s+\-*/().]+", query.replace("等於", "")).group().strip()
calls.append(("calculator", {"expression": expr}))
for city in ("台北", "東京", "高雄"): # 並行:多城市各發一個 weather 呼叫
if city in query and "天氣" in query or (city in query and ("度" in query or "幾度" in query)):
calls.append(("get_weather", {"city": city}))
if not calls and ("查" in query or "什麼是" in query):
kw = query.replace("查", "").replace("什麼是", "").strip("??")
calls.append(("search_kb", {"keyword": kw}))
return calls
def mock_final_answer(query, results):
joined = ";".join(f"{name} → {out}" for name, out in results)
return f"根據工具結果({joined}),回答你的問題「{query}」。" if results \
else f"這題不需要工具,直接回答:{query}"
def _run_loop(query, offline, parallel=False):
use_offline = offline or _client()[0] is None
if not use_offline:
print("→ 走真實 API function calling\n")
print("最終答覆:", real_loop(query)); return
print("→ offline mock 模式\n")
calls = mock_decide_tools(query)
if parallel and len(calls) > 1:
print(f"模型決定**並行**呼叫 {len(calls)} 個工具:")
results = []
for name, args in calls:
out = TOOLS[name](**args)
print(f" [工具] {name}({args}) → {out}")
results.append((name, out))
if not calls:
print(" (模型判斷不需要工具)")
print("\n最終答覆:", mock_final_answer(query, results))
輸出結果:
(1)離綫模式
→ offline mock 模式
[工具] get_weather({'city': '台北'}) → 28°C 多雲
最終答覆: 根據工具結果(get_weather → 28°C 多雲),回答你的問題「台北今天天氣如何?」。
(2)API模式
→ 走真實 API function calling
[工具] get_weather({'city': '台北'}) → 28°C 多雲
最終答覆: 台北今天的天氣是 **28°C**,**多雲**。天氣還算舒適,但外出記得帶件薄外套以防室內冷氣較強喔!
(3)測試另外case
python tool_calling_lab.py loop --query "什麼是RAG?
→ 走真實 API function calling
[工具] search_kb({'keyword': 'RAG'}) → 關於「RAG」的知識庫摘要(mock):這是一段示範用的檢索結果。
最終答覆: **RAG** 的全名是 **Retrieval-Augmented Generation(檢索增強生成)**,是一種結合「資訊檢索」與「文本生成」的 AI 技術架構,主要用來提升大型語言模型(LLM)回答的準確性與時效性。
---
### 核心概念
傳統的語言模型只能依賴訓練時學到的「參數記憶」來回答問題,這會導致兩個問題:
1. **知識時效性不足**:模型不知道訓練資料截止後發生的事。
2. **幻覺(Hallucination)**:模型會「腦補」出不存在的資訊。
RAG 的做法是:**在生成回答之前,先從外部知識庫(如文件、資料庫、網頁)檢索相關資料,再把這些資料連同原本的問題一起餵給模型,讓模型基於檢索到的證據來生成答案。**
---
### 運作流程
大致可分為三步驟:
1. **檢索(Retrieval)**
將使用者的問題轉換成向量(Embedding),到向量資料庫或搜尋引擎中找出最相關的若干篇文件或段落。
2. **增強(Augmentation)**
把檢索到的內容與使用者的問題組合成一個更豐富的提示(Prompt),例如:「請根據以下資料回答問題:[檢索結果];問題是:[使用者問題]」。
3. **生成(Generation)**
語言模型根據這個提示,引用檢索到的資料來產生最終回答。
...
### 主要優勢
| 優勢 | 說明 |
|------|------|
| **減少幻覺** | 答案有外部資料佐證,可信度更高。 |
| **知識可更新** | 不用重新訓練模型,只需更新外部資料庫即可。 |
| **可解釋性** | 可以回朔模型參考了哪些文件來得出答案。 |
| **成本效益** | 相較於動輒數百萬美元的模型微調,建立向量資料庫的成本低很多。 |
簡單來說,RAG 就像給語言模型配了一位「圖書館管理員」:先幫它找到相關書籍,再請它根據書本內容作答,而不是憑空想像。這也是目前企業導入生成式 AI 最常採用的架構之一。
並行工具調用
python tool_calling_lab.py parallel --query "台北跟東京現在幾度?"
→ 走真實 API function calling
[工具] get_weather({'city': '台北'}) → 28°C 多雲
[工具] get_weather({'city': '東京'}) → 22°C 晴
最終答覆: 台北現在 **28°C**,多雲;東京現在 **22°C**,晴天。