iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Engineering

從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理系列 第 15 篇

什麼才算一個「工具」?Tool 的介面設計

  • 分享至 

  • xImage
  •  

昨天我們寫出了一個能查天氣的 get_weather() 函式。它能動,錯誤也處理得不錯。

但它還不算是一個工具(Tool)。

今天要來談一個看似抽象、但整個 Agent 架構都建立在上面的問題:

對 AI 來說,一個「可以被呼叫的工具」,需要具備什麼?


一、Function 和 Tool 的差別

Day 7 我們學了 Function——把重複的邏輯包起來,給它一個名字。

Tool 是 Function 的超集合。它多了三樣東西:

Function Tool
可以被呼叫 ✅ ✅
有機器可讀的說明 ❌ ✅
有明確的參數規格 ❌(只有 Python 的型別提示) ✅(JSON Schema)
失敗時回傳可讀的錯誤,而不是崩潰 不一定 ✅

為什麼需要這三樣?因為呼叫它的不是人,是 AI。

人看到 get_weather(city, api_key, use_cache=True) 這個簽名,配上原始碼,大概就知道怎麼用了。但 AI 看不到你的原始碼——它只看得到你明確告訴它的東西。

所以我們需要把「這個函式是幹嘛的、要給什麼參數」寫成一份 AI 讀得懂的規格。


二、工具的規格:JSON Schema

回想 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 描述。

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 是你對 AI 的 Prompt

這點我想特別強調,因為它跟一般寫程式的直覺不一樣。

description 不是註解,它是 prompt。

AI 決定「要不要用這個工具」、「什麼時候用」,唯一的依據就是這段文字。寫得不好,AI 就會用錯、或該用的時候不用。

比較一下:

# ❌ 太簡略
"description": "查天氣"

# ❌ 寫給工程師看的
"description": "呼叫 CWA F-C0032-001 端點取得 36h 預報,回傳 parsed dict"

# ✅ 寫給 AI 看的
"description": (
    "查詢台灣某個縣市未來 36 小時的天氣預報,"
    "包含天氣現象、氣溫範圍、降雨機率與舒適度。"
    "當使用者詢問天氣、要不要帶傘、穿什麼衣服,"
    "或在安排戶外行程時,使用這個工具。"
    "只支援台灣的縣市,不支援國外城市或鄉鎮層級。"
)

好的 description 包含四件事:

  1. 這個工具做什麼(查天氣預報)
  2. 回傳什麼(天氣現象、氣溫、降雨機率、舒適度)
  3. 什麼時候該用(問天氣、問要不要帶傘、安排行程時)
  4. 什麼時候不該用 / 限制(只支援台灣縣市,不支援鄉鎮)

第 4 點常常被漏掉,但它可以避免很多問題。如果不寫,AI 看到「東京天氣如何」可能就傻傻地呼叫這個工具了。

我自己的心得是:當 Agent 表現不如預期,第一個該檢查的往往不是程式碼,而是 tool description。


四、把工具包成 Class

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 與 @abstractmethod
ABC(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)

七、工具設計的幾個原則

寫了幾個工具之後,歸納一下我覺得重要的原則:

1. 一個工具做一件事

不要做一個 manage_todo(action, ...) 然後用 action 參數分流。拆成 add_todo、list_todos、complete_todo 三個工具,AI 選起來更準確。

2. 參數越少越好

每多一個參數,AI 就多一個填錯的機會。能有合理預設值的就給預設值。

3. 回傳的文字要「自我說明」

# ❌ AI 不知道這是什麼
return "26, 31, 20"

# ✅ 一看就懂
return "臺北市今天 26–31°C,降雨機率 20%"

4. 錯誤訊息要能指引下一步

# ❌ AI 不知道該怎麼辦
raise ToolError("查詢失敗")

# ✅ AI 知道可以重試
raise ToolError("查詢天氣逾時,請稍後再試")

# ✅ AI 知道不該重試,該問使用者
raise ToolError("找不到「東京」,本工具只支援台灣縣市")

5. 有副作用的工具要說清楚

如果工具會改變狀態(新增資料、發送訊息、刪除東西),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 架構裡「手」的部分。接下來幾天我們要去做「腦」。


小結

  • Tool = Function + 機器可讀的說明 + 參數規格 + 不會崩潰的執行
  • description 不是註解,是你對 AI 的 prompt——要寫清楚做什麼、回傳什麼、何時用、何時不用
  • input_schema 用 JSON Schema 描述參數,enum 可以有效限制 AI 亂填
  • safe_run() 保證不拋出例外,把失敗變成 AI 讀得懂的文字
  • 工具回傳的是給模型讀的文字,不是 Python 物件
  • 一個工具做一件事、參數越少越好、錯誤訊息要能指引下一步

明天換個主題,來處理資料的整理與分析——排序、篩選、統計,讓 Agent 不只能拿到資料,還能從資料裡看出東西。


上一篇
第一個真實資料來源:串接中央氣象署天氣 API
下一篇
從一堆資料到一句結論:排序、篩選與統計
系列文
從 Prompt 到自主決策:用 Python × Agentic Workflow 實作生活助理 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言