CodeGym /課程 /ChatGPT Apps /代理工具 (Tools):模式、路由、錯誤

代理工具 (Tools):模式、路由、錯誤

ChatGPT Apps
等級 12 , 課堂 1
開放

1. 代理的工具:它究竟是什麼

在前面的模組中,你已經從 Apps SDK 的角度看過工具——就像「後端函式」,ChatGPT 透過你的 App 呼叫它們。現在換個角度:我們從 Agents SDK 中代理的視角來看工具,並弄清代理如何選擇要呼叫什麼、以及如何處理錯誤。

在一般後端裡,你習慣用「端點」「控制器方法」「服務函式」來思考。在代理世界裡,行為的基本單位變成了工具(tool)代理的 tools 與 mcp-tools 是不同的,雖然兩者會有重疊。

嚴格地說:在 ChatGPT Agents SDK 的語境中,工具是模型可以請求執行的一個函式描述。模型本身不會執行程式碼;它會產生結構化的請求(通常是 JSON),而執行期(你的程式碼、MCP 伺服器或 Agents SDK)才會實際執行並回傳結果。

在 ChatGPT Agents SDK 的生態中,工具以設定描述:它有 namedescriptionparameters(引數的 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_ordersend_email)、純計算型(calculate_loan)以及系統/控制型(handoff_to_humanfinish_task)。

為了更清楚,來看一個小表格。

類別 GiftGenius 範例 副作用 風險
Data Retrieval
search_gifts, get_details
Action / Mutating
create_order, buy_gift
Computation
estimate_delivery_cost
System / Control
finish_recommendation
邏輯性

從架構角度,最重要的是:唯讀工具應該大量且便宜,而會變更狀態的工具要少且極度謹慎,搭配完整日誌、冪等性,並且常需要用戶確認。

接下來主要談資料擷取與 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"]
  }
}

這裡有幾個重點。

  • 第一,namedescription。對模型來說,它們是判斷何時使用此工具的主要訊號。語意路由的文件強調,工具描述實際上就是給模型看的 API:如果你把它命名為 func1 並寫上「做點有用的事」,模型就很難理解什麼時候該叫它。若命名為 search_gifts 並提供清楚描述,選擇就容易多了。
  • 第二,parameters。欄位名稱與其描述極度重要。對 LLM 來說,recipient_typetype 更容易理解。像「收禮者是誰……」這樣的描述能提示模型:這裡要填的是收禮者類型,而不是例如包裝格式。
  • 第三,required。這不僅是你端的驗證,也是給模型的提示:它會試著填滿必要欄位;若上下文不明確,則略過非必要欄位。這能減少「空」或不正確的 tool 呼叫。

Apps SDK 的官方指南也建議:讓工具窄而專一,具備單一職責、清楚的命名與描述,避免做成「禮物全能工具」那種把不同任務混在一起的設計。

4. 設計 GiftGenius 工具:從模式到程式碼

以 GiftGenius 為例,加入兩個幾乎在所有情境都會用到的 LLM 代理關鍵工具:

  • suggest_gifts(profile, budget)——回傳候選清單;
  • get_gift_details(gift_id)——取得某個禮物的詳細資訊。

我們的 suggest_giftsget_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)及其 namedescriptioninputSchema

當來了新的使用者訊息,模型會把請求語意與工具描述做比對(語意匹配)。若請求是「幫我挑同事的禮物,預算 30 美元,他喜歡桌遊」,suggest_gifts 的描述顯然更相關,代理就很可能選它。

官方指南強調兩件對路由品質影響很大的事。

  • 第一,避免語意重疊的工具:如果你同時有 search_giftsfind_gifts,而且描述差不多,模型會混淆。
  • 第二,遵循單一職責原則:一個 tool 只做一件清楚的事,而不是「挑禮物+建立訂單+寄信」。

不同的 LLM 代理通常提供工具選擇模式:例如「auto」(模型自行決定是否用工具)、「required」(必須呼叫工具)、「none」(關閉工具)。這對複雜流程(多步驟場景)很有幫助,例如在特定步驟你要強制呼叫 suggest_gifts,而不是讓模型閒聊。

GiftGenius 的語意路由示例

假設代理至少有兩個工具:suggest_giftsget_gift_details

  1. 使用者說:「幫我挑一個 30 美元以內、他喜歡桌遊、送同事的禮物。」
  2. 代理看見請求包含「挑禮物」的目標、預算與興趣。suggest_gifts 的描述完全吻合——呼叫它。
  3. 工具回傳五個候選禮物,包含 id、名稱與簡述。
  4. 使用者接著說:「多說說第三個。」代理把「第三個」對應到前一步結果的 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 例外與堆疊追蹤——只要清楚的 codemessage

系統錯誤

最後是系統錯誤。假設工具會呼叫外部的物流 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_giftsfind_gifts,且都「依興趣搜尋禮物」,模型就會隨機二選一,導致行為不穩:相同請求有時用這個,有時用那個。務必讓每個名稱與描述佔據獨立而清晰的「語意位置」。

錯誤 3:拙劣或缺失的描述與模式欄位。
名稱 func1、描述「Does something」、參數 data: string——這是讓代理變笨的經典套路。模型不是讀心術士,也讀不到你的原始碼。它仰賴模式中的 descriptionproperties 及其 description。若你不解釋 recipient_type 是什麼,模型就只能猜,然後出錯。

錯誤 4:只顧順利路徑(happy-path),忽略錯誤處理。
很多實作假設:「我們的引數一定正確、服務一定可用」。現實中,模型很容易產生錯誤參數,外部服務會掉線,資料庫偶爾也會說「timeout」。若不設計好錯誤格式並回給代理可理解的訊息,它就無法調整行為,不是默默失敗,就是開始幻覺。

錯誤 5:把原始 500 與堆疊追蹤丟給 LLM。
在 REST API 中,我們習慣記完整堆疊追蹤以便除錯。但在代理情境,把堆疊追蹤丟給模型同時是無用(模型不知道你特定函式庫的 SQLException 是什麼)且有風險(洩漏實作細節甚至機密)。更好的做法是攔截例外、把細節寫入日誌,然後只把整潔的 codemessage 傳給模型。

錯誤 6:對會變更狀態的工具缺乏冪等性。
沒有 idempotency‑key 的 create_order 幾乎註定會在網路波動與自動重試下產生重複訂單。若你的代理跑在商業情境,凡是牽涉金流的工具,都必須確保重複呼叫不會造成重複扣款或重複建立。

錯誤 7:把祕密或技術細節放進模式或描述。
開發者有時習慣在 description 寫:「內部會呼叫 https://internal-api.example.com 的服務 X」。模型不需要這些,用戶更不需要。模式與描述是提示詞的一部分,活在模型上下文中,不該放內部服務 URL、私有資料表名稱,更別說祕密了。

錯誤 8:把所有東西都塞進工具,而非設計良好的欄位集合。
很容易心想「直接把使用者的整段提示字串丟進去,裡面再處理」。這會失去 JSON Schema 帶來的結構化好處:模型不再清楚請求的哪些部分對邏輯重要,你也失去驗證與可預測性。更好的方式是從請求中抽出明確欄位(budgetinterestsuser_location),並把它們描述成工具契約的一部分。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION