CodeGym /課程 /ChatGPT Apps /錯誤、冪等性與「安全」工具的設計

錯誤、冪等性與「安全」工具的設計

ChatGPT Apps
等級 4, 課堂 4
開放

1. ChatGPT App 中的錯誤與冪等性

在傳統的網頁世界,許多人仍停留在「使用者按下按鈕 → 一個 HTTP 請求 → 一個回應」的思維裡。在 LLM 的世界早已不是如此。模型可能決定多次呼叫你的工具,使用者按下 Regenerate 後可能會重新產生回應,可能會追問,或在途中遇到網路錯誤。結果就是,同一個工具很可能會被呼叫兩三次,且參數彼此非常相近。

同時,任何錯誤突然都有兩個「客戶」。一方面是模型:它需要機器可讀且明確的解釋,知道哪裡出錯,以便修正參數並再試一次。另一方面是使用者的 UI(小工具與聊天本身):要顯示給人看的訊息與建議的後續動作,而不是「Error: 500 (see logs)」。

還有一個重要點:傳統架構很少假設有人會大量按「重新產生回應」,從而放大重試次數。在 ChatGPT,這是常態。此外平台本身也可能在暫時性的網路問題下進行自動重試。因此,在這個生態系中,冪等性不是附加選項,而是基本要求,尤其對那些「真的會動到外部世界」的工具——建立訂單、扣款、寄信等。

這堂課正是要講:如何避免一次失敗的工具呼叫(tool call)毀了使用者的心情,也毀了你的 production。

洞見

ChatGPT 並不是「傳遞」參數給你的函式,而更像是在猜測一組符合你 schema 的參數。它會看 JSON Schema、對話脈絡,然後統計地挑選值——而且相當常會猜錯。像是「型別不對」、「遺漏必要欄位」、「參數互相矛盾」這類錯誤,是 tool-calls 生命週期中的正常部分,而不是天災。公開資料與遙測顯示,對複雜的 schema,這類失誤很容易就佔到約 30% 的呼叫。

對模型來說,這不成問題:它把你的回覆當成「參數不好」的訊號,然後再試一次,可能連續兩三次,稍微修改輸入。對你而言,這代表另一件事:每個工具都要用「幾乎一定會被重複呼叫」的前提來設計,而且參數會非常相似。

這就是為什麼冪等性如此重要。ChatGPT 會一再嘗試猜測應該用哪些參數呼叫你的函式。每次呼叫出現 2–3 次嘗試,是常態。

2. 小工具的安全設定:text/html+skybridge 與 _meta

在深入純伺服器端的議題(錯誤、重試、冪等性)之前,先處理 Apps SDK 中一個與 UI 安全相關的特殊點:如何讓你的小工具在聊天中安全地渲染,而不是像「網路上某個可怕的網頁」。

registerResource 與 MIME 類型 text/html+skybridge

對 ChatGPT 而言,你的小工具是一個特殊的 HTML 資源,它會進入 ChatGPT 用戶端的沙盒,而不是直接進入使用者的瀏覽器。為了讓平台理解這是小工具而不是一般 HTML,需要使用 MIME 類型 text/html+skybridge。

在 MCP/伺服器層面,你會這樣註冊資源(偽 TS):

// MCP 伺服器設定中的某處
registerResource({
  name: "giftgenius-widget",
  path: "/widget",
  mimeType: "text/html+skybridge", // 重要!
});

這個 mimeType 是給 ChatGPT 用戶端的訊號:「這不只是 HTML,而是用於內嵌小工具的元件化模板,需要在隔離環境中啟動」。如果指定為一般的 text/html,平台可能只顯示原始 HTML,甚至拒絕渲染。

_meta 與安全治理:CSP、網域與邊框

接著是與工具回應或資源一同傳遞的中繼資料——_meta。透過它你可以控制小工具能載入哪些外部資源、視覺上要怎麼呈現,甚至影響模型如何描述它。

典型結構:

const toolResult = {
  content: "<!-- 小工具的 HTML -->",
  _meta: {
    "openai/widgetCSP": "default-src 'self'; img-src https://cdn.example.com",
    "openai/widgetDomain": "https://chatgpt.com",
    "openai/widgetPrefersBorder": true,
    "openai/widgetDescription": "GiftGenius 以卡片形式顯示禮物建議。"
  }
};

逐一說明關鍵欄位。

  • openai/widgetCSP 設定小工具的 Content Security Policy。這是 ChatGPT 內部瀏覽器的小型防火牆:你要明確列出可載入腳本、樣式、圖片、XHR 等的來源。平台預期是嚴格政策,不要使用萬用字元 *,你需要明確列出用到的網域(聊天、你的 API、CDN)。
  • openai/widgetDomain 指定小工具運行時的 origin。通常是 ChatGPT 的網域;你不是把它換成自己網站,而是告訴平台在隔離環境中它該以何種樣子運作。
  • openai/widgetPrefersBorder 是純視覺旗標:是否在小工具周圍繪製邊框。對 GiftGenius 來說,保留邊框很合理,可將建議卡片區塊與一般聊天訊息視覺上區隔。
  • openai/widgetDescription 是給模型用的文字描述。與其讓模型自己「編」,它可以使用這段字串向使用者說明目前開啟的是什麼介面。這能降低模型產生奇怪或過度的評論。

實務結論:一次性把 mimeType 與 _meta 設定妥當,你就能得到安全、隔離的 UI,不會越權,且對使用者與平台而言都能有可預期的行為。前端安全部分搞定了:小工具活在沙盒中,只能往你允許的地方連。接下來聚焦後端——錯誤的種類,以及如何描述錯誤並讓工具具備冪等性。

洞見:小工具快取

ChatGPT 會在註冊應用時快取小工具的 HTML。ChatGPT 的 HTML 小工具不是「活的前端」,而是固定的建置產物。當你發布應用(Store 或 Dev Mode)時,平台會讀取(text/html+skybridge)的 HTML 資源,之後一律使用該版本。任何變更——即使只是一行文字或卡片的縮排——實質上都等於新版本發佈。

因此可知:修改 HTML 結構、slots、data-* 屬性,以及 structuredContent → DOM 的契約,都不是「快速修」;而是完整的前端遷移。如果你今天渲染的是 items[] 的清單,明天改成 results[],舊小工具不會知道:它仍會收到舊的 JSON,並產生不正確行為。

3. 工具中的錯誤類型

接下來進入重點:工具會有哪些錯誤,且從 UX 與後端角度它們有何差異。用四層來思考會很方便。

輸入驗證錯誤

最基礎的一層——當輸入參數根本不符合契約。

以下以我們的教學應用 GiftGenius 及其工具 suggest_gifts(依興趣與預算挑禮物)為例:

  • 年齡小於 0 或大於 120;
  • 預算是負數;
  • 缺少必要欄位 relationship_type;
  • budget_min > budget_max。

不符合 Schema 的 JSON 也屬於此類。理想情況下,Apps SDK 與 JSON Schema 會在進入你的程式以前就濾掉「非常糟」的呼叫,但像 budget_min/budget_max 這種商業邏輯驗證仍須自行處理。

商業邏輯錯誤

輸入看似正確,但依領域規則你無法給出正常結果。

常見情節:

  • 依指定興趣與預算找不到任何禮物;
  • 使用者超過每日建議次數上限;
  • 模型要求購買的商品已經下架。

這不是「伺服器掛了」,而是預期中的情況。應該以可理解的形式呈現給使用者與模型,而不是 500 Internal Server Error。

外部基礎設施錯誤

這一層是「技術地獄」:資料庫無法連線、外部 API 超時、你的程式中拋出了未處理的例外。

例如:

  • 查詢禮物目錄回傳 503 或沒有回應;
  • MongoDB 突然暫停;
  • 在禮物過濾程式碼中發生除以零。

從 UX 角度,通常應回覆「服務暫時不可用,請稍後再試」,有時可以做隱性重試。但重要的是不要默不作聲,也不要把原始 stack trace 丟給使用者看。

平台/網路錯誤

最後是發生在你程式之外的一層:tool-call 沒送達、回應中途連線中斷、串流過程中斷。這比你想像的還常見。比如使用免費通道時,高峰期速度會慢到 ChatGPT 的 tool calls 因為逾時而失敗。

你無法完全控制這些情況,但絕對可以把工具與小工具設計成,就算有重試與中斷也不會把系統弄亂。因此我們談的是冪等性與嚴謹的錯誤處理,而不是「try/catch 然後忘了它」。

4. 如何描述並回傳錯誤:同時給模型與 UI 使用

思維上的重要轉變:你的錯誤不只是你在 console.error 裡記錄的東西。它是工具契約的一部分,模型與介面都會據此工作。

錯誤結構

通常遵循一個簡單的結構就很方便:

type ToolError = {
  code: string;        // "VALIDATION_ERROR", "NO_RESULTS", "UPSTREAM_TIMEOUT"
  message: string;     // 人類可讀,或給模型的精簡訊息
  retryable: boolean;  // 是否有意義再嘗試一次
};

並可將工具結果包成具辨識標記的 union:

type SuggestGiftsResult =
  | { ok: true; gifts: GiftCard[] }
  | { ok: false; error: ToolError };

MCP 協定中還有額外的「這是錯誤」旗標,但在內部維持自己的格式很有用,讓 UI 與模型能以相同方式解讀發生了什麼。

「優雅失敗」(fail gracefully)策略

不是每個不理想情況都要當成「硬錯誤」。有時回傳空結果且不標錯,比較有用,再附上一段解釋即可。

例如找不到任何禮物時,合理的做法是回傳 ok: true,gifts: [],再加上一個給 UI 與模型用的 noResultsReason 欄位,而不是把它當作 "NO_RESULTS" 錯誤。這樣模型可以繼續對話:「我在這個預算找不到結果,要不要提高預算或更換興趣?」。

如果是外部 API 完全掛了,那就比較像 ok: false,code: "UPSTREAM_UNAVAILABLE",retryable: true,讓模型有機會稍後或用不同參數再試。

回到第 3 節的四層錯誤:驗證錯通常是 ok: false 且 retryable: false——模型不該用相同參數再呼叫。像「找不到結果」這樣的商業情境,多半用 ok: true 加空結果與說明。外部服務的基礎設施故障,則用 ok: false 且 retryable: true,讓模型可以安全地重試。至於平台/網路錯誤,可能發生在你程式的前後,實務上常表現為工具的重複呼叫——也因此我們才這麼強調冪等性,下一節會談怎麼做得更穩。

不要把內部細節外洩

在伺服器程式碼中,很容易貪圖方便,直接把 error.toString() 丟到回應裡。對 LLM 工具而言,這不是好主意:你會在對話中塞進垃圾訊息,還可能洩漏敏感細節(內部服務的 URL、stack trace、資料表名稱)。建議:攔截例外並轉換成緊湊的錯誤碼與乾淨的訊息。

最小包裝範例:

try {
  const gifts = await loadGiftsFromCatalog(input);
  return { ok: true, gifts };
} catch (err) {
  console.error("suggest_gifts failed", err);
  return {
    ok: false,
    error: {
      code: "UPSTREAM_ERROR",
      message: "Catalog service is unavailable",
      retryable: true
    }
  };
}

模型會看到乾淨的訊號,UI 看到可理解的文字,詳細資訊留在日誌裡。

在小工具中顯示錯誤

對 React 小工具而言,任務很單純:檢查 ok,如果是 false,就顯示友善訊息,並在可能時提供下一步的做法。

function GiftResults({ result }: { result: SuggestGiftsResult }) {
  if (!result.ok) {
    return (
      <div>
        <p>無法挑選禮物:{result.error.message}</p>
        {result.error.retryable && <p>請嘗試調整參數或重新發送請求。</p>}
      </div>
    );
  }

  if (result.gifts.length === 0) {
    return <p>依此條件找不到禮物。請嘗試調整預算或興趣。</p>;
  }

  return <GiftCardsList gifts={result.gifts} />;
}

這就是「簡單而誠實」的訊息能讓 UX 明顯更好的情境,比「出了點問題」有用得多。

我們已經同意,可以把一部分錯誤標示為 retryable: true,並提示使用者「再試一次」。只要系統中出現這類重試(UI 中的顯式重試,或平台側的隱式重試),下一個問題就來了:如果同一個工具用同一組資料被呼叫兩次,會怎樣?這就是冪等性的故事。

5. 冪等性:防止「再來一次完全相同的呼叫」

進入有趣的部分。形式上,冪等性指的是某個操作的性質:以相同輸入重複呼叫,不會改變系統狀態與結果。嚴格而言,這同時包含沒有重複副作用,以及一致的回應。在 ChatGPT Apps 的實務上,我們最在乎前者:重複呼叫不應破壞資料或產生新的實體,即使回應本身可能略有差異。

在 ChatGPT Apps 的語境裡,冪等性是用來抵禦所有來自重試、Regenerate 與難以預測的 LLM 邏輯所帶來的「驚喜」。

冪等性特別重要的地方

唯讀工具通常天生安全:不管你用相同參數呼叫 suggest_gifts 多少次,你只會再得到一份禮物清單。即使內容略有不同,也不會改變系統狀態或產生副作用。

關鍵在於會修改外部系統狀態的工具:

  • 建立訂單(create_order);
  • 處理付款(charge_card、submit_payment);
  • 寄送郵件與通知(send_email、send_sms);
  • 建立具副作用的實體(例如預訂)。

若這類工具連續被呼叫兩次,參數幾乎相同,你可能會得到重複的訂單、重複扣款,以及會計的「驚喜」。

idempotency_key 模式

典型做法:在工具中加入一個額外參數 idempotency_key——此操作的字串識別鍵。如果同樣的鍵已成功處理過,伺服器不會再執行動作,而是回傳已保存的結果。

以下為 GiftGenius 中假想工具 create_checkout_session 的擴充 schema 範例:

const CreateCheckoutSchema = {
  type: "object",
  properties: {
    giftId: {
      type: "string",
      description: "選定禮物的 ID"
    },
    idempotency_key: {
      type: "string",
      description: "此操作的唯一金鑰,用於防止重複"
    }
  },
  required: ["giftId", "idempotency_key"]
} as const;

在伺服器上,處理器大致會這麼做:

async function createCheckoutSession(input: CreateCheckoutInput) {
  const existing = await db.checkoutSessions.findOne({ idempotencyKey: input.idempotency_key });
  if (existing) {
    return existing; // 回傳舊結果
  }

  const session = await paymentProvider.createSession({ giftId: input.giftId });
  await db.checkoutSessions.insert({ idempotencyKey: input.idempotency_key, session });
  return session;
}

如果模型因某些原因用同一個 idempotency_key 再呼叫一次,使用者不會被第二次收款,只會看到同一個 checkout。

將 prepare 與 commit 分離

對特別敏感的動作(付款、不可逆變更),常用兩階段做法:用一個工具做準備(prepare_*),另一個工具做提交(commit_*)。

例如:

  • prepare_order——檢查存貨、計算金額、回傳「訂單草稿」;
  • commit_order——依草稿 ID 建立正式訂單並啟動付款。

這樣的設計帶來多個好處。首先,可以讓第一步完全冪等:用相同參數重複 prepare_order 會回傳相同草稿。其次,可以限制 commit_order 一定要在使用者明確確認後才允許呼叫,對 UX 與安全性都很有利。

6. 工具的安全設計

冪等性是必要條件,但不是安全性的全部。很多事情取決於你提供給模型的工具集本身如何設計。

最小權限原則

理念很簡單:每個工具只能做完成情境所需的事,不多也不少。不要做一個 do_anything_with_user_account 的大雜燴函式,它:

  • 可任意讀、改、刪;
  • 接收一個字串 operation 與一個隨意的 JSON payload。

最好把工具拆成清晰明確的多個工具:

  • get_user_profile;
  • update_user_preferences;
  • create_order;
  • cancel_order。

GiftGenius 同理:suggest_gifts 只負責挑選選項;create_checkout_session 不該管如何取消訂單或修改使用者的 e‑mail。

區分「read」與「write」工具

良好的模式是明確區分只讀與會變更資料的工具。查詢禮物目錄(search_products、suggest_gifts)本身就很安全,即使模型頻繁使用也無妨。但 create_order 或 charge_payment 就需要更謹慎的處理。

在這類工具的描述中,應明白寫出它們做什麼、在什麼情境下可呼叫。例如:

{
  "name": "create_checkout_session",
  "description": "為單一禮物建立新的付款工作階段。僅在使用者明確確認選擇後才呼叫。",
  "parameters": { /* ... */ }
}

這不是百分之百的防護(LLM 還是可能出錯),但至少你給了它清楚的風險訊號。

Human-in-the-loop 與確認

對真正「危險」的動作,適合打造需要確認的流程。比如模型:

  1. 先呼叫一個準備購買資料的工具,回傳適合 UI 的資料(禮物名稱、價格、寄送地址)。
  2. 平台向使用者顯示帶有「確認購買」按鈕的小工具。
  3. 只有在按下按鈕後,才呼叫 commit 工具做真正的付款。

如此一來,即使模型覺得「這樣做很聰明」,你也不會讓它在沒有使用者參與的情況下悄悄下單。

在描述與註解中體現風險語意

某些平台版本提供像 destructiveHint 之類的特殊註解,表示工具可能做出不可逆的動作。即使沒有這些欄位或仍未穩定,你也可以把這層語意直接放進 description 與參數名稱中。

例如,別這樣寫:

{
  "name": "delete_user_data",
  "description": "刪除使用者資料。"
}

而是改成:

{
  "name": "request_user_data_deletion",
  "description": "依照服務政策,將使用者帳號標記為刪除其個人資料。僅在使用者明確要求刪除後才使用。"
}

並同時以人性化的方式設計相關的確認 UX。

7. GiftGenius 的一個小實作改進

讓我們把一切與教學應用 GiftGenius(挑禮物的 App)串起來。假設我們新增一個工具——create_checkout_session,讓使用者不僅能挑禮物,還能走到結帳流程。

就 JSON Schema 與安全性而言,我們會這樣做。

首先,加入 idempotency_key 與清楚的描述:

const CreateCheckoutTool = {
  name: "create_checkout_session",
  description:
    "為單一選定禮物建立付款工作階段。 " +
    "僅在使用者已明確確認想購買該禮物後才呼叫。",
  parameters: {
    type: "object",
    properties: {
      gift_id: {
        type: "string",
        description: "來自 suggest_gifts 結果的禮物識別碼。"
      },
      idempotency_key: {
        type: "string",
        description: "唯一的作業金鑰。重試時請使用相同金鑰。"
      }
    },
    required: ["gift_id", "idempotency_key"]
  }
} as const;

其次,在伺服器實作冪等的處理器:

async function handleCreateCheckout(input: CreateCheckoutInput) {
  const existing = await db.checkout.findOne({ idempotencyKey: input.idempotency_key });
  if (existing) {
    return { ok: true, checkout: existing };
  }

  const checkout = await payments.createSession({ giftId: input.gift_id });
  await db.checkout.insert({ idempotencyKey: input.idempotency_key, ...checkout });

  return { ok: true, checkout };
}

第三,考量錯誤處理:

try {
  return await handleCreateCheckout(input);
} catch (err) {
  console.error("create_checkout_session failed", err);
  return {
    ok: false,
    error: {
      code: "PAYMENT_PROVIDER_ERROR",
      message: "無法建立付款工作階段。請稍後再試。",
      retryable: true
    }
  };
}

小工具中則顯示清楚的錯誤狀態,並可在 UI 層提供「重試」按鈕,觸發與模型的新一輪對話。

循序漸進,這個可愛的教學專案就不再只是「示範玩具」,而逐步變成理論上可以上線到 production 的東西。

8. 處理錯誤與工具冪等性的常見錯誤

錯誤 #1:錯誤 = 只會 throw 然後 500。
如果你的 tool 任何失敗都只是拋出例外,變成「出了點問題」,模型與 UI 會毫無資訊。模型不知道是否該用不同參數重試,使用者也不知道下一步怎麼做。更好的做法是回傳具結構的錯誤:含代碼、簡短訊息與 retryable 指示,詳細資訊留在伺服器日誌中。

錯誤 #2:不區分錯誤類型。
把驗證、商業與基礎設施錯誤混成一鍋是壞主意。結果「找不到任何東西」看起來會和「資料庫掛了」一樣,破壞 UX,也讓模型無法合理反應:它會從建議修改查詢,變成「抱歉,服務壞了」。這在你把第 3 節中的商業錯誤與基礎設施錯誤混在一起時尤其痛。

錯誤 #3:在重試世界裡的非冪等操作。
把 create_order 設計成「一定只會被呼叫一次」是通往訂單重複的直達車,尤其當使用者積極按 Regenerate,或連線半途斷掉時。只要工具有副作用,幾乎總該加入 idempotency_key 並保存結果,讓重複呼叫不會產生新實體。

錯誤 #4:單一怪物級「萬能」工具。
有時開發者會做一個接受 action 參數的超級工具,什麼都能做:查、增、改、刪。對 LLM 來說,這幾乎保證了不可預測行為:模型更難學會何時該呼叫什麼,錯誤的後果也更嚴重。正確做法是拆成小而清晰、盡可能唯讀的工具,另將變更資料的工具獨立設計並配合確認機制。

錯誤 #5:把內部細節洩漏到回應中。
把原始 stack trace 或完整例外訊息丟給模型與 UI 是典型的工程懶惰。這對使用者不友善,可能洩漏系統內部結構,也不利於模型自我修正。應攔截例外、映射為緊湊代碼與簡潔訊息,細節保留在日誌與監控系統。

錯誤 #6:錯誤處理沒有與小工具的 UX 對齊。
常見情況是伺服器端有好好的錯誤碼,但 UI 小工具卻卡在永遠的 spinner 或空白。使用者看到的是「什麼都沒發生」,模型看到 tool-call 完成,還照常繼續對話。更好的做法是設計 error 與 empty 等專門狀態,顯示清楚的訊息,並在可能時提示後續動作(調整參數、稍後再試)。

錯誤 #7:忽視最小權限原則。
即使你做了冪等與良好錯誤處理,但若仍提供像 execute_sql_anywhere 這般能做任何事的工具,風險仍舊很大。LLM 可能在錯誤的脈絡或用錯誤參數呼叫它。每個工具都應該盡量狹窄,只做一件清楚的事——尤其牽涉到金錢或使用者個資時。

1
任務
ChatGPT Apps, 等級 4, 課堂 4
上鎖
具冪等性的工具 add_favorite_jet,含 idempotency_key
具冪等性的工具 add_favorite_jet,含 idempotency_key
1
任務
ChatGPT Apps, 等級 4, 課堂 4
上鎖
兩階段 “Request quote”:prepare + commit、錯誤處理、冪等性與確認
兩階段 “Request quote”:prepare + commit、錯誤處理、冪等性與確認
1
問卷/小測驗
App 工具與 <code><span class="text-user">callTool</span></code>,等級 4,課堂 4
未開放
App 工具與 callTool
App 工具與 callTool:串連 UI ↔ 後端
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION