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 與確認
對真正「危險」的動作,適合打造需要確認的流程。比如模型:
- 先呼叫一個準備購買資料的工具,回傳適合 UI 的資料(禮物名稱、價格、寄送地址)。
- 平台向使用者顯示帶有「確認購買」按鈕的小工具。
- 只有在按下按鈕後,才呼叫 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 可能在錯誤的脈絡或用錯誤參數呼叫它。每個工具都應該盡量狹窄,只做一件清楚的事——尤其牽涉到金錢或使用者個資時。
GO TO FULL VERSION