1. 代理的工具:它究竟是什麼
在前面的模組中,你已經從 Apps SDK 的角度看過工具——就像「後端函式」,ChatGPT 透過你的 App 呼叫它們。現在換個角度:我們從 Agents SDK 中代理的視角來看工具,並弄清代理如何選擇要呼叫什麼、以及如何處理錯誤。
在一般後端裡,你習慣用「端點」「控制器方法」「服務函式」來思考。在代理世界裡,行為的基本單位變成了工具(tool)。代理的 tools 與 mcp-tools 是不同的,雖然兩者會有重疊。
嚴格地說:在 ChatGPT Agents SDK 的語境中,工具是模型可以請求執行的一個函式描述。模型本身不會執行程式碼;它會產生結構化的請求(通常是 JSON),而執行期(你的程式碼、MCP 伺服器或 Agents SDK)才會實際執行並回傳結果。
在 ChatGPT Agents SDK 的生態中,工具以設定描述:它有 name、description 和 parameters(引數的 JSON Schema)。代理能看見這一組工具,把它們放進自己的上下文,在推理(reasoning)過程中決定要呼叫哪個 tool 以及使用哪些引數。
代理(或作為宿主的 ChatGPT)收到這份清單,會將其「記住」在上下文中,並在推理過程(reasoning)中決定:針對用戶請求要呼叫哪個工具以及用什麼引數。因此規格文件總強調「tools are a contract」——工具是模型與你的程式之間的契約,而不只是「Python/TS 的一個函式」。
可以和傳統 API 類比。路由 /api/gifts/search 是純語法:URL、方法、本文格式。而 tool search_gifts 是語意:「依據個人檔案與預算搜尋禮物」。工具描述就像一個提示詞(prompt),只是它是結構化的,且面向 LLM,而非人類。
2. 工具的類型:LLM 代理究竟能做什麼
為了不陷入「什麼都能做的函式」的混亂,將工具視為幾個常見的類別很有幫助。這不是 SDK 的正式型別系統,而是一種有用的架構思維。
在我們的後端中,LLM 代理通常有三個工具來源。
- 在地(本地)商務工具。 存在於你的後端:資料庫操作、網域邏輯(篩選、推薦、評分)。例如,在 GiftGenius 中可以有工具從 PostgreSQL 的商品資料表取數,或計算個人化分數,評估「這份禮物對某人有多適合」。
- MCP 工具。 在這裡 MCP 伺服器扮演工具(tools)供應者:它註冊函式、資源與提示詞,並提供給用戶端(ChatGPT、LLM 代理)。透過 MCP 的工具可以呼叫外部 API、操作檔案,或提供提示詞範本。
- 整合型工具。 把你連到外部世界的一切:ACP/commerce(建立訂單與結帳)、寄送電子郵件、webhooks、寫入 CRM。此類工具(tools)常更具風險,因為會改變外部系統的狀態,因此在安全性與冪等性上需要特別嚴格。
還有另一個有用的分類——依據行為性質。在 LLM 工具研究中,常見的幾類是:資料擷取(搜尋、RAG、get_*)、具有副作用的動作型(create_order、send_email)、純計算型(calculate_loan)以及系統/控制型(handoff_to_human、finish_task)。
為了更清楚,來看一個小表格。
| 類別 | GiftGenius 範例 | 副作用 | 風險 |
|---|---|---|---|
| Data Retrieval | |
否 | 低 |
| Action / Mutating | |
是 | 高 |
| Computation | |
否 | 中 |
| System / Control | |
否 | 邏輯性 |
從架構角度,最重要的是:唯讀工具應該大量且便宜,而會變更狀態的工具要少且極度謹慎,搭配完整日誌、冪等性,並且常需要用戶確認。
接下來主要談資料擷取與 Action 類工具,因為 GiftGenius 的邏輯正是建立在它們之上。
3. JSON Schema 作為模型與你程式之間的契約
現在深入看看工具是如何描述的。在 ChatGPT Agents SDK(以及 Apps SDK)中,工具參數的標準描述格式是 JSON Schema:你描述一個 object 型別、它的 properties、欄位型別、必要欄位、限制條件等等。
重點是:JSON Schema 在這裡不僅僅是驗證。它是模型提示詞的一部分。OpenAI 在設計工具(tools)的官方指南中明確指出,代理表現的好壞高度取決於欄位命名與描述是否足夠詳盡且不含糊。
看看 GiftGenius 的一個例子,這在課程計畫中已出現過。
{
"name": "search_gifts",
"description": "根據收禮者類型、興趣與預算尋找禮物。",
"parameters": {
"type": "object",
"properties": {
"recipient_type": {
"type": "string",
"description": "收禮者是誰(例如,'男性'、'女性'、'兒童')。"
},
"interests": {
"type": "array",
"items": { "type": "string" },
"description": "關鍵興趣(運動、書籍、科技等)。"
},
"budget": {
"type": "number",
"description": "以使用者貨幣計的最高預算。"
}
},
"required": ["recipient_type", "budget"]
}
}
這裡有幾個重點。
- 第一,name 和 description。對模型來說,它們是判斷何時使用此工具的主要訊號。語意路由的文件強調,工具描述實際上就是給模型看的 API:如果你把它命名為 func1 並寫上「做點有用的事」,模型就很難理解什麼時候該叫它。若命名為 search_gifts 並提供清楚描述,選擇就容易多了。
- 第二,parameters。欄位名稱與其描述極度重要。對 LLM 來說,recipient_type 比 type 更容易理解。像「收禮者是誰……」這樣的描述能提示模型:這裡要填的是收禮者類型,而不是例如包裝格式。
- 第三,required。這不僅是你端的驗證,也是給模型的提示:它會試著填滿必要欄位;若上下文不明確,則略過非必要欄位。這能減少「空」或不正確的 tool 呼叫。
Apps SDK 的官方指南也建議:讓工具窄而專一,具備單一職責、清楚的命名與描述,避免做成「禮物全能工具」那種把不同任務混在一起的設計。
4. 設計 GiftGenius 工具:從模式到程式碼
以 GiftGenius 為例,加入兩個幾乎在所有情境都會用到的 LLM 代理關鍵工具:
- suggest_gifts(profile, budget)——回傳候選清單;
- get_gift_details(gift_id)——取得某個禮物的詳細資訊。
我們的 suggest_gifts 與 get_gift_details 是前述分類中的典型在地商務工具,主要屬於 Data Retrieval。
為 suggest_gifts 設計模式
先用純 JSON Schema,然後再看看 TypeScript 後端/代理執行期中的樣子。
{
"name": "suggest_gifts",
"description": "根據收禮者個人檔案與預算挑選禮物清單。",
"parameters": {
"type": "object",
"properties": {
"age": {
"type": "integer",
"minimum": 0,
"maximum": 120,
"description": "收禮者年齡(歲)。"
},
"relationship": {
"type": "string",
"enum": ["friend", "coworker", "partner", "family"],
"description": "與收禮者的關係:朋友、同事、伴侶、家人。"
},
"interests": {
"type": "array",
"items": { "type": "string" },
"description": "收禮者興趣(運動、書籍、科技等)。"
},
"budget": {
"type": "number",
"minimum": 1,
"description": "以使用者貨幣計的最高預算。"
}
},
"required": ["budget"]
}
}
這裡我們對 relationship 使用 enum,避免模型臨時編造像 "糟糕的同事" 之類的自由字串並一路傳到後續程式。用心設計模式同時幫助模型(看到允許的取值),也幫助開發者(執行期少驚喜)。
假設我們用 Node.js 寫了一個 MCP 伺服器,裡面有個假想的 McpServer。註冊工具可能長這樣:
// 簡化的 MCP 伺服器工具註冊範例
server.registerTool(
{
name: "suggest_gifts",
description: "依據個人檔案與預算挑選禮物。",
inputSchema: suggestGiftsSchema
},
async (input, ctx) => {
const gifts = await findGiftsInDb(input, ctx.userLocale);
return { items: gifts }; // 代理稍後會看到的 JSON
}
);
程式碼大幅簡化,但邏輯清楚:一處是契約描述(名稱、描述、模式),另一處是實作。
為 get_gift_details 設計模式
第二個幾乎所有情境都需要的工具:
{
"name": "get_gift_details",
"description": "根據禮物的識別碼取得完整資訊。",
"parameters": {
"type": "object",
"properties": {
"gift_id": {
"type": "string",
"description": "GiftGenius 資料庫中的禮物 UUID。"
}
},
"required": ["gift_id"]
}
}
對應的註冊:
server.registerTool(
{
name: "get_gift_details",
description: "回傳該禮物的詳細資訊。",
inputSchema: getGiftDetailsSchema
},
async ({ gift_id }) => {
const gift = await db.gifts.findById(gift_id);
if (!gift) return { notFound: true };
return { gift };
}
);
注意:我們直接表達工具可能回傳 notFound: true。這已經是語意錯誤(商務錯誤)的雛形,稍後會討論。代理可以看見「找不到禮物」並做出決策:例如嘗試另一個 id,或請用戶改選商品。
5. 代理如何選擇要呼叫哪個工具
接下來是重頭戲:路由。在傳統的 Web 應用中,路由很死板:URL → 特定控制器。而在 ChatGPT Apps 與代理的世界,工具選擇是一種語意、機率性的過程。
高層循環可以這樣表示:
flowchart TD
U[User message] --> M["模型(代理)"]
M -->|分析請求| C{需要 tool 嗎?}
C -->|否| T[文字回覆]
C -->|是| S[選擇工具]
S --> K[組裝 JSON 參數]
K --> R[執行工具]
R --> M2[模型看到結果]
M2 --> T2[最終回覆或下一步]
在每個步驟,代理能看到:
- 首先,system 指令(代理角色、限制);
- 其次,對話歷史;
- 最後,工具清單(tools)及其 name、description、inputSchema。
當來了新的使用者訊息,模型會把請求語意與工具描述做比對(語意匹配)。若請求是「幫我挑同事的禮物,預算 30 美元,他喜歡桌遊」,suggest_gifts 的描述顯然更相關,代理就很可能選它。
官方指南強調兩件對路由品質影響很大的事。
- 第一,避免語意重疊的工具:如果你同時有 search_gifts 與 find_gifts,而且描述差不多,模型會混淆。
- 第二,遵循單一職責原則:一個 tool 只做一件清楚的事,而不是「挑禮物+建立訂單+寄信」。
不同的 LLM 代理通常提供工具選擇模式:例如「auto」(模型自行決定是否用工具)、「required」(必須呼叫工具)、「none」(關閉工具)。這對複雜流程(多步驟場景)很有幫助,例如在特定步驟你要強制呼叫 suggest_gifts,而不是讓模型閒聊。
GiftGenius 的語意路由示例
假設代理至少有兩個工具:suggest_gifts 與 get_gift_details。
- 使用者說:「幫我挑一個 30 美元以內、他喜歡桌遊、送同事的禮物。」
- 代理看見請求包含「挑禮物」的目標、預算與興趣。suggest_gifts 的描述完全吻合——呼叫它。
- 工具回傳五個候選禮物,包含 id、名稱與簡述。
- 使用者接著說:「多說說第三個。」代理把「第三個」對應到前一步結果的 id,現在語意上該用 get_gift_details——於是呼叫它。
重點在:你並未在程式寫「如果請求中包含『挑』就呼叫 suggest_gifts」。這是模型根據你的描述與對話歷史自己完成的。你要做的是讓選擇對模型與人都一目了然。
6. 工具錯誤:不是 500,而是給模型的訊號
還記得在 get_gift_details 中我們示範過 notFound: true 嗎?這正是商務層錯誤的例子,代理應能看見並合理處理,而不是只收到赤裸裸的 500。
來到比較痛的部分。在一般 REST API 中,後端某處掛了——回 500 Internal Server Error,把堆疊追蹤寫進日誌——接著用戶自己想辦法。但對代理而言,這做法不太行得通。
Agents SDK 的實務指南建議把工具錯誤視為可觀察事件,而不是單純的崩潰。這常被稱為「Error as Observation」模式。
直白地說,你不該「無聲崩潰」;應回給模型一個結構化的回應,說明出了什麼問題,好讓模型調整行為:重述請求、詢問用戶、改用其他工具,等等。
錯誤類型通常分三組。
- 引數驗證錯誤。 模型可能產出不正確的參數:漏掉必要欄位、字串當數字、超出允許範圍。此時你的模式與驗證不只用來丟例外,更應產出可理解的回應:例如指出哪個欄位為何不對。
- 商務錯誤。 屬於預期情況,例如「商品不存在」「地區不可用」「此類禮物的預算太低」。對 API 而言也是錯誤,但應在正常回應中回傳——附上清楚的代碼與訊息,而不是像崩潰那樣拋出。
- 系統錯誤。 外部服務逾時、網路問題、資料庫故障。對代理來說,通常回傳謹慎、概括的訊息即可,例如「服務暫時不可用,請稍後再試」。不要提供堆疊追蹤、資料表名稱等模型用不到且可能有安全風險的細節。
Agents SDK 的官方材料甚至提供了 failure_error_function 之類的機制,用以優雅地組裝給模型看的錯誤文字,而不是直接把例外往上拋。
「友善」錯誤的結構
在代理工具(你的後端)中,你可以約定任何錯誤都以如下物件回傳:
type ToolError = {
code: string; // 'VALIDATION_ERROR', 'OUT_OF_STOCK', ...
message: string; // 給模型看的訊息
retryable: boolean;
};
而工具結果可設計成聯合型別:
type SuggestGiftsResult =
| {
ok: true;
items: GiftSummary[];
}
| {
ok: false;
error: ToolError;
};
模型(或代理執行期)看到這樣的 JSON,就能判斷:如果 retryable: true,可以稍作變更後再試;如果是不可重試的商務錯誤,就回到用戶處說明狀況。
7. 範例:驗證錯誤、商務錯誤與系統錯誤
回到我們的後端/代理工具,看看如何在程式碼中落實這些概念。
驗證錯誤
想像工具 suggest_gifts 收到模型送來的負預算。
async function handleSuggestGifts(input: SuggestGiftsInput)
: Promise<SuggestGiftsResult> {
if (input.budget <= 0) {
return {
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "budget 必須是正數。",
retryable: false
}
};
}
const items = await findGiftsInDb(input);
return { ok: true, items };
}
這裡我們刻意不丟例外,而是回傳結構化錯誤。代理能重新思考請求:也許它認為幣別搞錯,會詢問用戶;或直接說明在此預算下無法挑選。
商務錯誤
再看 get_gift_details。指定的 id 可能對應不到任何禮物。
async function handleGetGiftDetails(input: { gift_id: string }) {
const gift = await db.gifts.findById(input.gift_id);
if (!gift) {
return {
ok: false,
error: {
code: "GIFT_NOT_FOUND",
message: "找不到該識別碼對應的禮物。",
retryable: false
}
};
}
return { ok: true, gift };
}
你可以期待模型給出類似的回覆:「看起來這個禮物已不可用。我可以從相似分類再推薦幾個替代品嗎?」代理不需要看到 SQL 例外與堆疊追蹤——只要清楚的 code 與 message。
系統錯誤
最後是系統錯誤。假設工具會呼叫外部的物流 API,而該服務有時會「掉線」。
async function handleEstimateDelivery(input: EstimateDeliveryInput) {
try {
const eta = await callDeliveryApi(input);
return { ok: true, eta_days: eta };
} catch (e) {
return {
ok: false,
error: {
code: "DELIVERY_SERVICE_UNAVAILABLE",
message: "物流服務暫時不可用。",
retryable: true
}
};
}
}
代理可以判斷:「看起來物流服務現在不可用。我仍會先顯示禮物,但實際到貨時間可能不同。要繼續嗎?」
8. 工具的安全與冪等性(從 tools 視角的速覽)
安全與權限會在獨立章節詳談,但代理工具與它們密不可分,這裡先簡述。
首先,務必區分讀取工具與寫入工具。在描述、模式與權限上明確標示哪些 tools 只讀且完全安全,哪些會扣款、改單等。文件與論壇在代理情境中都強調 ReadOnly 與 Mutating 工具(tools)的分離。
其次,對會變更狀態的工具要考慮冪等性。代理或 MCP 用戶端可能會重試呼叫(例如因網路錯誤),你可不想 create_order 變成兩張訂單。典型作法包括:
- idempotency‑key 作為工具引數傳入;
- 在執行前檢查是否已存在相同操作;
- 把流程拆成「建立訂單草稿」與「確認訂單」。
這些都與你如何設計工具契約密切相關:若 JSON Schema 沒有 idempotency‑key,事後再補冪等性會痛很多。
9. 簡看 Agents SDK:代理執行期中的樣子
本節是給會使用偏 TypeScript 的 Agents SDK 的同學的一個小總覽。雖然課程主軸是 MCP,但了解 Agents SDK 如何看待工具、以及典型的執行期 tool 長什麼樣,仍很有幫助。
官方文件通常描述所謂的「函式型工具」:任何以設定物件(或輔助函式如 tool(...))描述並具型別的函式,都能自動變成工具;SDK 會為它產生 JSON Schema 與描述。
概念上和我們已經討論的相同:函式名稱、參數與註解/description 分別扮演工具的名稱、模式與描述。差別在於大部分「機械式」的工作由 SDK 與/或模式輔助庫(例如 Zod 或 JSON Schema)代勞。
來個假想範例(簡化的 pseudo-TypeScript):
type Gift = {
id: string;
title: string;
// ...
};
const suggestGifts = tool({
name: "suggest_gifts",
description: "依據收禮者類型與預算挑選禮物清單。",
parameters: {
type: "object",
properties: {
recipient_type: {
type: "string",
description: "收禮者是誰(例如,'男性'、'女性'、'兒童')。"
},
budget: {
type: "number",
description: "以使用者貨幣計的最高預算。"
}
},
required: ["recipient_type", "budget"]
}
}, async (args: { recipient_type: string; budget: number }): Promise<Gift[]> => {
// 域內的商務邏輯
return findGifts(args.recipient_type, args.budget);
});
SDK(或你的 tool 輔助函式)會根據 parameters 物件建立 JSON Schema 並傳給代理;執行期會處理引數的驗證與序列化/反序列化。概念上,這與你在 TypeScript MCP 伺服器手動做的事一樣,只是工具「掛在」代理執行期中。
重點不是背熟 tool 的語法,而是抓住核心觀念:良好的型別+清晰的描述/註解=高品質的工具。
總結一下:一個好的代理工具,是職責單一、描述清楚、JSON Schema 設計周全、對模型友善的錯誤處理。語意路由只有在工具語意不重疊時,才能運作良好。至於會變更狀態的操作,務必安全且具冪等性,否則代理在生產很快就會變成驚喜製造機。
10. 設計代理工具時的常見錯誤
錯誤 1:過度寬泛的「do_everything」工具。
有時會很想把所有事塞進一個 manage_gifts 工具:既搜尋、又看詳情、還建單、順便寄信。這會讓模型很痛苦:描述變得含糊,語意路由變差,代理開始「以防萬一」地到處叫它,即使只需要簡單搜尋。最好把任務拆成單一職責、容易理解的工具。
錯誤 2:語意互相覆蓋的工具。
如果你同時有 search_gifts 與 find_gifts,且都「依興趣搜尋禮物」,模型就會隨機二選一,導致行為不穩:相同請求有時用這個,有時用那個。務必讓每個名稱與描述佔據獨立而清晰的「語意位置」。
錯誤 3:拙劣或缺失的描述與模式欄位。
名稱 func1、描述「Does something」、參數 data: string——這是讓代理變笨的經典套路。模型不是讀心術士,也讀不到你的原始碼。它仰賴模式中的 description、properties 及其 description。若你不解釋 recipient_type 是什麼,模型就只能猜,然後出錯。
錯誤 4:只顧順利路徑(happy-path),忽略錯誤處理。
很多實作假設:「我們的引數一定正確、服務一定可用」。現實中,模型很容易產生錯誤參數,外部服務會掉線,資料庫偶爾也會說「timeout」。若不設計好錯誤格式並回給代理可理解的訊息,它就無法調整行為,不是默默失敗,就是開始幻覺。
錯誤 5:把原始 500 與堆疊追蹤丟給 LLM。
在 REST API 中,我們習慣記完整堆疊追蹤以便除錯。但在代理情境,把堆疊追蹤丟給模型同時是無用(模型不知道你特定函式庫的 SQLException 是什麼)且有風險(洩漏實作細節甚至機密)。更好的做法是攔截例外、把細節寫入日誌,然後只把整潔的 code 與 message 傳給模型。
錯誤 6:對會變更狀態的工具缺乏冪等性。
沒有 idempotency‑key 的 create_order 幾乎註定會在網路波動與自動重試下產生重複訂單。若你的代理跑在商業情境,凡是牽涉金流的工具,都必須確保重複呼叫不會造成重複扣款或重複建立。
錯誤 7:把祕密或技術細節放進模式或描述。
開發者有時習慣在 description 寫:「內部會呼叫 https://internal-api.example.com 的服務 X」。模型不需要這些,用戶更不需要。模式與描述是提示詞的一部分,活在模型上下文中,不該放內部服務 URL、私有資料表名稱,更別說祕密了。
錯誤 8:把所有東西都塞進工具,而非設計良好的欄位集合。
很容易心想「直接把使用者的整段提示字串丟進去,裡面再處理」。這會失去 JSON Schema 帶來的結構化好處:模型不再清楚請求的哪些部分對邏輯重要,你也失去驗證與可預測性。更好的方式是從請求中抽出明確欄位(budget、interests、user_location),並把它們描述成工具契約的一部分。
GO TO FULL VERSION