對一個 Agent 來說,工具調用(tool use)是極其重要的功能之一,而這個「工具」具體來說,其實就是一段「Python 的函數」,模型會指定要調用的工具,以及對應的參數,具體的邏輯如下:
而這幾篇(會分成上中下三篇),我們關注的只有工具函數,暫時不管要怎麼回到 agent.py 中給模型調用,也就是只要關注:
也就是「上下文協議」,由 Anthropic 提出的統一標準,可以理解為 AI 應用的「通用接口」,可以統一提示詞範本、做資源讀取以及工具調用。
我們先建立一個 MCP Server,
而在 Agent 調用工具時,通常會先問你「是否可以調用 ... 工具」,
在這裡,我把工具分成兩類:
need_approval 這個屬性,並註冊到 TOOL_REGISTRY,最後利用 .add_tool() 註冊到 MCP Server,達到「雙重註冊」。# src/meowgent/tool.py
from typing import Annotated, Callable, Dict, Any
from pathlib import Path
from mcp.server.mcpserver import MCPServer
import logging
mcp = MCPServer("Meowgent")
logging.getLogger().handlers.clear() # 刪去 mcp 做的日誌綁定
TOOL_REGISTRY: Dict[str, Callable] = {}
def tool_register(need_approval: bool = True):
"""函數裝飾器:同時註冊到 MCP 伺服器與內部字典"""
def decorator(func: Callable):
func.need_approval = need_approval # 標記是否需要審核
TOOL_REGISTRY[func.__name__] = func # 註冊給 agent.py 內部使用
mcp.add_tool(func) # 註冊給 MCP 協議外部調用
return func
return decorator
一般來說,常會看到的 MCP 註冊方式會是在工具函數上面掛上裝飾器 - @mcp.tool(),而因為我們需要做 need_approval 的屬性,還有後期在 agent.py 做的 .submit() 工具並發運行機制,所以雖然比較麻煩,我們不把工具執行交給 MCP server,而是等下自行設計工具執行邏輯。
關於刪去日誌綁定的部分:
當MCPServer建立 MCP 物件後,原始碼中會執行handlers.append(RichHandler(...))、logging.basicConfig(level="INFO", ..., handlers=handlers)可以理解為綁定了「強迫讓常態的運行狀態消息被顯示」的功能,而我們利用logging.getLogger().handlers.clear()來解除這樣的日誌綁定。
關於函數裝飾器 -
tool_register()的部分:
它的作用是在「不修改」原函數的情況下,為函數新增功能。
而在這裡,要做的三件事,加上標籤以及註冊身份:
- 用傳入的參數標記是否需要審核。
- 註冊到字典。
- 註冊到 MCP Server。
我們來一步一步看一下是如何進行的:
- 先執行了
tool_register(need_approval=False),把參數記住,並回傳內層的decorator()。@tool_register(need_approval=False) def my_tool(): ...
decorator()進到了內層函數,執行上面所說的三件事。- 內層函數回傳原
func,可以理解為把加上的標籤以及註冊的身份送到了my_tool()手上,就大功告成啦!
拿到工具名稱,我們進到註冊表裡查詢是否有此工具函數存在,有則傳入參數到函數內執行,無則回報。
# src/meowgent/tool.py
...
def execute_tool(tool_name: str, args: dict) -> str:
"""供 agent.py 調用執行的統一入口"""
if tool_name not in TOOL_REGISTRY:
return f"錯誤:找不到工具 '{tool_name}'"
try:
tool_func = TOOL_REGISTRY[tool_name]
return str(tool_func(**args))
except Exception as e:
return f"錯誤:執行工具 '{tool_name}' 失敗:{e}"
在這裡,不用去關注到「審核是否通過的問題」,這是在
agent.py關注的,如果未通過,就不會去呼叫execute_tool()。
接著,我們先來實作幾個簡單的工具吧!
有以下幾點要求:
@tool_register(bool)。""" ... """ 讓模型可以理解作用。Annotated[型別, "參數解釋"] 讓模型知道這是什麼參數。要記得,所有傳入的型別只有「字串以及數字」,回傳則只有「字串」。
這裡把 file_path 轉為 Path 物件,方便做路徑的處理以及讀取,
先用 .expanduser() 展開家目錄波浪號( ~/ 展開為絕對路徑),接著就可以直接用 .read_text() 讀取了,最後若報錯明確回報讀取失敗。
為什麼加上
encoding="utf-8"?
這是為了防止「解碼錯誤」,簡單來說就是電腦拿著「錯誤的密碼本(編碼格式)」,試圖把檔案裡的二進位位元組(Bytes)翻譯回人類看得懂的文字,結果對不上,導致程式直接崩潰或文字變成亂碼。
而 UTF-8 既相容了傳統的 ASCII 形式,又能以最優方式收錄全世界的語言與 Emoji,因此成為被廣泛使用的統一編碼。
# src/meowgent/tool.py
...
@tool_register(False)
def read_file(
file_path: Annotated[str, "要讀取的檔案路徑(支援相對路徑或以 ~ 開頭的路徑)"]
) -> str:
""" 讀取文字檔 """
try:
return Path(file_path).expanduser().read_text(encoding="utf-8")
except Exception as e:
return f"錯誤:讀取檔案 '{file_path}' 失敗:{e}"
路徑一樣轉 Path 並改為絕對路徑,Path(file_path).expanduser().parent.mkdir(parents=True, exist_ok=True) 是為了防止因為「資料夾不存在」而導致寫入失敗,parents=True 讓不管幾層資料夾不存在都能被建立好;exist_ok=True 讓資料夾存在的狀況下不衝突。
再來透過 .write_text() 寫入檔案,同樣指定 encoding="utf-8" 確保編碼儲存正常。
最後也加上報錯的回傳。
# src/meowgent/tool.py
...
@tool_register(True)
def write_file(
file_path: Annotated[str, "要寫入的目標檔案路徑"],
content: Annotated[str, "要寫入檔案的完整文字內容"]
) -> str:
""" 寫入到文字檔 """
try:
Path(file_path).expanduser().parent.mkdir(parents=True, exist_ok=True) # 建立上層資料夾
Path(file_path).expanduser().write_text(content, encoding="utf-8")
return f"成功寫入檔案 '{file_path}'(共 {len(content.splitlines())} 行)"
# content.splitlines() 字串分行拆成串列
except Exception as e:
return f"錯誤:寫入檔案 '{file_path}' 失敗:{e}"
今天這篇,我們瞭解了什麼是工具調用,寫了註冊函數、調用函數的邏輯,以及讀寫文字檔的工具。
下一篇,我們繼續來做更多的工具!