昨天我們寫出了一個能查天氣的 get_weather() 函式。它能動,錯誤也處理得不錯。
但它還不算是一個工具(Tool)。
今天要來談一個看似抽象、但整個 Agent 架構都建立在上面的問題:
對 AI 來說,一個「可以被呼叫的工具」,需要具備什麼?
Day 7 我們學了 Function——把重複的邏輯包起來,給它一個名字。
Tool 是 Function 的超集合。它多了三樣東西:
| Function | Tool | |
|---|---|---|
| 可以被呼叫 | ✅ | ✅ |
| 有機器可讀的說明 | ❌ | ✅ |
| 有明確的參數規格 | ❌(只有 Python 的型別提示) | ✅(JSON Schema) |
| 失敗時回傳可讀的錯誤,而不是崩潰 | 不一定 | ✅ |
為什麼需要這三樣?因為呼叫它的不是人,是 AI。
人看到 get_weather(city, api_key, use_cache=True) 這個簽名,配上原始碼,大概就知道怎麼用了。但 AI 看不到你的原始碼——它只看得到你明確告訴它的東西。
所以我們需要把「這個函式是幹嘛的、要給什麼參數」寫成一份 AI 讀得懂的規格。
回想 Day 10 學的 JSON。工具的定義長這樣:
WEATHER_TOOL = {
"name": "get_weather",
"description": (
"查詢台灣某個縣市未來 36 小時的天氣預報,"
"包含天氣現象、氣溫範圍、降雨機率與舒適度。"
"當使用者詢問天氣、要不要帶傘、穿什麼衣服,"
"或在安排戶外行程時,使用這個工具。"
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "台灣的縣市名稱,例如「臺北市」、「高雄市」、「新竹縣」",
}
},
"required": ["city"],
},
}
三個部分:
name:工具的名字。AI 要呼叫時就是報這個名字。用英文、小寫、底線分隔。
description:這是最重要的欄位,等一下會單獨講。
input_schema:參數的規格,用 JSON Schema 描述。
"input_schema": {
"type": "object", # 參數整體是一個物件
"properties": { # 有哪些參數
"city": {
"type": "string",
"description": "縣市名稱",
},
"days": {
"type": "integer",
"description": "要查幾天",
"minimum": 1,
"maximum": 7,
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"], # 只能是這幾個值
"description": "溫度單位",
},
"include_hourly": {
"type": "boolean",
"description": "是否包含逐小時資料",
},
"cities": {
"type": "array",
"items": {"type": "string"},
"description": "多個縣市名稱",
},
},
"required": ["city"], # 哪些是必填
}
常用的型別:string、integer、number、boolean、array、object。
enum 特別好用——它把 AI 能填的值限制在你列的選項裡,大幅減少「AI 亂填」的機率。
這點我想特別強調,因為它跟一般寫程式的直覺不一樣。
description 不是註解,它是 prompt。
AI 決定「要不要用這個工具」、「什麼時候用」,唯一的依據就是這段文字。寫得不好,AI 就會用錯、或該用的時候不用。
比較一下:
# ❌ 太簡略
"description": "查天氣"
# ❌ 寫給工程師看的
"description": "呼叫 CWA F-C0032-001 端點取得 36h 預報,回傳 parsed dict"
# ✅ 寫給 AI 看的
"description": (
"查詢台灣某個縣市未來 36 小時的天氣預報,"
"包含天氣現象、氣溫範圍、降雨機率與舒適度。"
"當使用者詢問天氣、要不要帶傘、穿什麼衣服,"
"或在安排戶外行程時,使用這個工具。"
"只支援台灣的縣市,不支援國外城市或鄉鎮層級。"
)
好的 description 包含四件事:
第 4 點常常被漏掉,但它可以避免很多問題。如果不寫,AI 看到「東京天氣如何」可能就傻傻地呼叫這個工具了。
我自己的心得是:當 Agent 表現不如預期,第一個該檢查的往往不是程式碼,而是 tool description。
Day 11 我們做過一個 Tool 基底類別的雛形。現在把它寫完整:
"""tools/base.py — 工具的共通介面。"""
from abc import ABC, abstractmethod
class ToolError(Exception):
"""工具執行失敗,訊息會被回報給模型。"""
class Tool(ABC):
"""所有工具的基底類別。
子類別必須定義 name、description、input_schema,並實作 run()。
"""
name: str = ""
description: str = ""
input_schema: dict = {"type": "object", "properties": {}}
@abstractmethod
def run(self, **kwargs) -> str:
"""執行工具,回傳一段給模型閱讀的文字。"""
raise NotImplementedError
def to_schema(self) -> dict:
"""轉成 API 需要的工具定義格式。"""
return {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}
def safe_run(self, arguments: dict) -> dict:
"""執行工具並捕捉所有例外。
回傳 {"ok": bool, "content": str},永遠不會拋出例外。
"""
try:
content = self.run(**arguments)
return {"ok": True, "content": str(content)}
except ToolError as e:
return {"ok": False, "content": f"工具執行失敗:{e}"}
except TypeError as e:
# 參數不符合簽名,通常是模型給錯了
return {"ok": False, "content": f"參數錯誤:{e}"}
except Exception as e:
return {
"ok": False,
"content": f"未預期的錯誤({type(e).__name__}):{e}",
}
def __repr__(self):
return f"<Tool {self.name}>"
幾個設計重點:
ABC 與 @abstractmethodABC(Abstract Base Class)讓這個類別變成「抽象類別」——不能直接建立實體,而且子類別一定要實作 run(),否則建立時就會報錯。比 Day 11 的 raise NotImplementedError 更早發現問題。
safe_run():絕對不會炸的執行入口
這是整個設計的核心。Agent 的主迴圈會呼叫 safe_run(),而它保證不拋出例外。
為什麼這麼重要?因為 Agent 在執行一個任務時可能呼叫十次工具。如果第三次失敗就整個崩潰,前面兩次的成果就白費了。有了 safe_run(),失敗只會變成一段文字回報給 AI,AI 可以決定要重試、換工具、還是跟使用者說做不到。
回傳字串,不是物件run() 回傳的是給模型讀的文字。模型讀的是文字,不是 Python 物件。所以工具的最後一步一定要把資料轉成文字——這就是昨天 summarize() 存在的理由。
"""tools/weather_tool.py"""
import config
from tools.base import Tool, ToolError
from tools.weather import get_weather, summarize, WeatherError
class WeatherTool(Tool):
name = "get_weather"
description = (
"查詢台灣某個縣市未來 36 小時的天氣預報,"
"包含天氣現象、氣溫範圍、降雨機率與舒適度。"
"當使用者詢問天氣、要不要帶傘、穿什麼衣服,"
"或在安排戶外行程時,使用這個工具。"
"只支援台灣的縣市,不支援國外城市或鄉鎮層級。"
)
input_schema = {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": (
"台灣的縣市名稱,例如「臺北市」、「高雄市」、「新竹縣」。"
"若使用者沒有指定,使用預設值。"
),
}
},
"required": ["city"],
}
def __init__(self, api_key=None, default_city=None):
self.api_key = api_key or config.CWA_API_KEY
self.default_city = default_city or config.DEFAULT_CITY
def run(self, city=None):
city = city or self.default_city
try:
data = get_weather(city, self.api_key)
except WeatherError as e:
raise ToolError(str(e)) from e
return summarize(data)
試跑:
tool = WeatherTool()
print(tool.to_schema())
print()
print(tool.safe_run({"city": "台中"}))
print()
print(tool.safe_run({"city": "東京"})) # 氣象署查不到
print()
print(tool.safe_run({"wrong_arg": "台北"})) # 參數名稱錯誤
輸出:
{'name': 'get_weather', 'description': '查詢台灣某個縣市...', 'input_schema': {...}}
{'ok': True, 'content': '【臺中市】未來 36 小時天氣\n09-29 12:00~18:00 多雲...'}
{'ok': False, 'content': '工具執行失敗:氣象署沒有回傳任何地點資料,請確認城市名稱(要用「臺」)'}
{'ok': False, 'content': "參數錯誤:run() got an unexpected keyword argument 'wrong_arg'"}
**三種情況,程式都沒有崩潰,而且每一種都給出了 AI 看得懂的訊息。**這就是我們要的。
有了基底類別,加新工具變得很快。
看起來很蠢,但這是必備的——AI 模型不知道「現在」是什麼時候。它的訓練資料有截止日期,而且它也沒有時鐘。
"""tools/datetime_tool.py"""
from datetime import datetime, timedelta
from tools.base import Tool
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四",
"星期五", "星期六", "星期日"]
class DateTimeTool(Tool):
name = "get_current_datetime"
description = (
"取得現在的日期、時間與星期。"
"在處理任何跟「今天」、「明天」、「這週」、「下週」相關的請求之前,"
"都應該先呼叫這個工具確認目前時間。"
)
input_schema = {"type": "object", "properties": {}}
def run(self):
now = datetime.now()
tomorrow = now + timedelta(days=1)
return (
f"現在是 {now.strftime('%Y年%m月%d日 %H:%M')}"
f"({WEEKDAYS[now.weekday()]})。\n"
f"明天是 {tomorrow.strftime('%Y年%m月%d日')}"
f"({WEEKDAYS[tomorrow.weekday()]})。"
)
注意 description 裡那句「在處理任何跟今天、明天相關的請求之前,都應該先呼叫這個工具」——這是在教 AI 一個行為習慣。這種寫法在工具設計裡很常見。
把 Day 10 寫的待辦清單包起來:
"""tools/todo_tool.py"""
from tools.base import Tool, ToolError
from todo_store import load_todos, save_todos # Day 10 寫的
class AddTodoTool(Tool):
name = "add_todo"
description = (
"新增一筆待辦事項到使用者的清單中。"
"當使用者說「提醒我…」、「記一下…」、「我要做…」時使用。"
"只負責記錄,不會設定鬧鐘或發送通知。"
)
input_schema = {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "待辦事項的內容,例如「買牛奶」",
},
"priority": {
"type": "string",
"enum": ["高", "中", "低"],
"description": "優先度。使用者沒說明時填「中」。",
},
},
"required": ["title"],
}
def run(self, title, priority="中"):
if not title.strip():
raise ToolError("待辦事項的內容不能是空的")
todos = load_todos()
todos.append({
"id": max([t["id"] for t in todos], default=0) + 1,
"title": title.strip(),
"priority": priority,
"done": False,
})
save_todos(todos)
return f"已新增待辦事項「{title}」(優先度:{priority}),目前共 {len(todos)} 筆。"
class ListTodosTool(Tool):
name = "list_todos"
description = (
"列出使用者目前所有的待辦事項,包含編號、內容、優先度與完成狀態。"
"當使用者問「我有什麼事要做」、「待辦清單」,"
"或在安排行程時需要知道有哪些事項時使用。"
)
input_schema = {
"type": "object",
"properties": {
"only_pending": {
"type": "boolean",
"description": "是否只列出未完成的項目。預設為 true。",
}
},
}
def run(self, only_pending=True):
todos = load_todos()
if only_pending:
todos = [t for t in todos if not t["done"]]
if not todos:
return "目前沒有待辦事項。"
lines = [f"共 {len(todos)} 筆待辦:"]
for t in todos:
mark = "✓" if t["done"] else "□"
lines.append(f"{mark} #{t['id']} {t['title']}({t['priority']})")
return "\n".join(lines)
寫了幾個工具之後,歸納一下我覺得重要的原則:
不要做一個 manage_todo(action, ...) 然後用 action 參數分流。拆成 add_todo、list_todos、complete_todo 三個工具,AI 選起來更準確。
每多一個參數,AI 就多一個填錯的機會。能有合理預設值的就給預設值。
# ❌ AI 不知道這是什麼
return "26, 31, 20"
# ✅ 一看就懂
return "臺北市今天 26–31°C,降雨機率 20%"
# ❌ AI 不知道該怎麼辦
raise ToolError("查詢失敗")
# ✅ AI 知道可以重試
raise ToolError("查詢天氣逾時,請稍後再試")
# ✅ AI 知道不該重試,該問使用者
raise ToolError("找不到「東京」,本工具只支援台灣縣市")
如果工具會改變狀態(新增資料、發送訊息、刪除東西),description 要明講。這樣 AI 才會謹慎使用,而不是隨便試試看。
到今天為止,我們有了:
tools/
├── base.py ← Tool 基底類別、safe_run
├── weather.py ← 天氣查詢的底層邏輯
├── weather_tool.py ← 包成工具
├── datetime_tool.py ← 時間工具
└── todo_tool.py ← 待辦工具
**但這些工具目前還是我們自己呼叫的。**AI 還沒進場。
從 Day 18 開始,AI 會加入。而到了 Day 21–22,我們會把今天寫的 to_schema() 丟給 AI,讓它自己決定要用哪個工具、填什麼參數——那時候今天寫的 description 就會開始發揮作用。
所以今天的內容,是 Agent 架構裡「手」的部分。接下來幾天我們要去做「腦」。
description 不是註解,是你對 AI 的 prompt——要寫清楚做什麼、回傳什麼、何時用、何時不用input_schema 用 JSON Schema 描述參數,enum 可以有效限制 AI 亂填safe_run() 保證不拋出例外,把失敗變成 AI 讀得懂的文字明天換個主題,來處理資料的整理與分析——排序、篩選、統計,讓 Agent 不只能拿到資料,還能從資料裡看出東西。