1. 為什麼在 ChatGPT App 中,錯誤是常態而非事故
在上一堂課我們談了如何把任務拆成步驟並在 ChatGPT App 中構建多步驟 workflow。現在加上現實:錯誤、逾時與使用者的中斷。
在傳統 Web 中,邏輯常圍繞「快樂路徑」構建,而錯誤被視為少見且緊急——紅色頁面 500 等等。於 ChatGPT App,情況不同:你在一個分散式系統中與 LLM、外部 API、MCP、Widget,以及可能隨時關掉分頁的使用者打交道。錯誤與中斷是日常。
有幾個特點會讓開發更複雜:
- 首先,LLM 具有非決定性。即使提示相同,它也可能做出稍微不同的決策:呼叫不同的工具、改變參數,或乾脆決定先「再確認」。
- 其次,網路與基礎設施限制。來自 ChatGPT 的 tool‑call 會有逾時(通常數十秒),你的 Next.js/Vercel 後端也一樣。若外部 API 變慢,一切可能半途而斷。
- 第三,有 UX 因素:使用者分心、關掉聊天、隔天才回來,而你不可能在資料庫裡把交易長時間保持開啟。
因此本講的主旨:
具備容錯的 workflow = 預設任何步驟都可能失敗,並且明確定義屆時要發生什麼。
錯誤不僅是顯示給使用者的訊息,更是給模型的訊號:模型可以改變策略、提出回滾、嘗試其他工具,或溫和地結束情境。
2. 工作流程中的錯誤版圖:有哪些類型
要妥善處理失敗,先要學會辨識它們。在基於 ChatGPT Apps 的 LLM 應用中,常見有幾個錯誤類別。
技術性錯誤。 這是分散式系統的老問題:網路逾時、你的或外部 API 回傳 5xx、MCP 伺服器崩潰、工具處理器(handler)中的 bug。舉例,在 GiftGenius 中,你的 MCP 工具 search_products 連到目錄,而對方回 503 Service Unavailable。這是自動重試(retry)的候選。
邏輯(模型)錯誤。 例如模型拒絕(覺得請求違反政策)、產生幻覺,或工具回覆時 JSON 壞掉。模型可能為 tool‑call 產生了不正確的參數,導致你的 JSON 驗證不通過。這通常是輸入資料錯誤,而非基礎設施問題。
商務錯誤。 關於業務語義:商品缺貨、使用者的預算對所選條件過低、折扣碼無效、預訂已過期。在 GiftGenius 中,可能是「在 500 位候選中沒有任何符合限制」。這裡重試很少有幫助:要麼調整參數,要麼告知使用者限制不切實際。
UX 中斷。 使用者主動中斷流程:關掉 ChatGPT,在 Widget 點「返回」、取消操作、改掉前一步的回答。這也應視為正常流程的一部分,而非錯誤。需能在這些情況下恢復與回滾狀態,我們稍後會談。
還有一種介於邏輯與技術錯誤之間的問題——代理的無限循環:模型收到錯誤,想著「嗯,再試一次」,又錯,再來,如此反覆,直到上下文或預算耗盡。防範這種行為是錯誤設計的重要部分。
3. 基本策略:retry、fail‑fast、rollback、讓使用者參與
任何錯誤都可視為分支點:要麼嘗試重試該步驟,要麼回滾,要麼拉入使用者。重要的是,這些策略可以組合。
對技術性與暫時性故障(網路閃退、API 回 503),合理做法是有限次數的重試並搭配退避。 對邏輯與商務錯誤(「驗證器不接受該預算」、「商品都售罄」),重試無意義,應快速失敗(fail‑fast)並請使用者更改輸入或參數。
對已經對外部世界產生變更的操作(建立訂單、預留庫存),需要回滾——可以是 UI/上下文上的「退一步」,也可以是實際補償動作(取消訂單、退款)。
最後,有些情況天生需要使用者參與:例如支付系統因「發卡銀行拒絕」而失敗,你無法自動修復。模型應清楚說明發生了什麼,並提供選項:換張卡、降低金額或放棄購買。
為了可靠的 workflow,最好針對每個步驟直接列出:可能出現哪些錯誤類型,你在每種情況下會做什麼——自動重試、回滾、詢問使用者,或僅記錄並結束該分支。
4. 重試(retry)與退避(backoff):何時以及如何執行
先從開發者最自然的反應開始:「那就再試一次吧」。想法沒錯,但細節決定成敗。
哪些錯誤可以重試
常見的整合實務準則是:網路錯誤與 5xx 可以延遲後重試,而 4xx 大多不該重試。
也就是說,如果你收到 503、504 或外部 API 一直沒回應,那麼在少許延遲後重試有意義。但若伺服器回 400 Bad Request 或 422 Unprocessable Entity,更可能是資料問題,帶同樣參數重試不會改變結果。
簡單的 TypeScript 工具函式 callWithRetry
我們來寫個小工具給 MCP 或後端層,在工具中可重用:
type RetryOptions = {
maxRetries: number;
baseDelayMs: number;
};
async function callWithRetry<T>(
fn: () => Promise<T>,
{ maxRetries, baseDelayMs }: RetryOptions
): Promise<T> {
let attempt = 0;
// 我們不需要無限迴圈
while (true) {
try {
return await fn();
} catch (err: any) {
attempt++;
const status = err?.status ?? err?.response?.status;
// 4xx 不重試
const isClientError = typeof status === "number" && status >= 400 && status < 500;
if (attempt > maxRetries || isClientError) {
throw err;
}
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 10_000);
// 加上小延遲避免同時重試造成「羊群效應」打爆 API
const jitter = Math.random() * 200;
await new Promise((r) => setTimeout(r, delay + jitter));
}
}
}
此函式會:
- 在限制次數內重試呼叫 fn;
- 使用指數退避並加上一點隨機抖動(jitter),避免「羊群效應」;
- 遇到 4xx 就停止重試。
它很適合放在 MCP 工具內部,例如連商品目錄或內部推薦 API 的地方。
該在哪一層做重試
常見錯誤是到處都重試,包含你無法掌控的層。在 ChatGPT 生態裡你有幾個位置可以放重試:
- 你自己的 backend/MCP 內部(像我們在 callWithRetry 那樣);
- 背景工作者/佇列裡(之後的模組會更深入講 job 佇列與 DLQ);
- 有時在 Widget 端,當只是「更新清單」這類沒有副作用的輕量請求。
重點是不要重複邏輯:如果你的工作佇列已做三次退避重試,就沒必要再在 Widget 上疊五次重試。當然,絕對不要寫 while(true) { try ... } —— 這是把自己 DDoS 的捷徑。
5. 步驟的冪等性:防止重複動作
重試帶來第二個問題:如何避免同一動作執行兩次。在 LLM 世界這特別棘手:模型可能不小心重複呼叫同一工具、ChatGPT 可能在逾時後重複 tool‑call、使用者點了「Regenerate」,接著 UI 或代理也可能再觸發一次呼叫。
冪等性的概念很簡單:若對相同步驟以相同輸入重複執行,不會產生額外的副作用。抓取 product feed — 可以;重算推薦 — 可以;但重複扣款或用相同資料建立第二筆訂單 — 絕對不行。
ChatGPT App 中的 idempotency key
典型做法:對每個有副作用的邏輯步驟生成 idempotency_key(通常是 UUID),透過模型傳給 MCP 工具,並在那裡保存「key → 結果」對應。若同一 key 再次被呼叫,工具不要重做動作,而是回傳已保存的結果。
在我們的 GiftGenius,有一個步驟 create_order。想像使用者按了「付款」,模型呼叫了工具,支付完成,但回應在途中遺失。模型或平台決定重複呼叫,如果沒有冪等性,就會得到重複訂單或雙重扣款。
TypeScript 實作:簡單的冪等工具
來做個極簡的 MCP 工具處理器 create_order,支援 idempotency key。為簡化起見用記憶體內的 Map,實務中會是資料庫或快取。
type CreateOrderInput = {
userId: string;
items: Array<{ sku: string; qty: number }>;
idempotencyKey: string;
};
type CreateOrderResult = { orderId: string; status: "created" };
const idempotencyStore = new Map<
string,
{ paramsHash: string; result: CreateOrderResult }
>();
export async function createOrderTool(input: CreateOrderInput): Promise<CreateOrderResult> {
const { idempotencyKey, ...rest } = input;
const paramsHash = JSON.stringify(rest);
const existing = idempotencyStore.get(idempotencyKey);
if (existing) {
// 如果此 key 已存在,需確認參數一致
if (existing.paramsHash !== paramsHash) {
throw new Error("Idempotency key reuse with different params");
}
return existing.result;
}
// 這裡執行實際的建立訂單與扣款
const result: CreateOrderResult = {
orderId: "order_" + Math.random().toString(36).slice(2),
status: "created",
};
idempotencyStore.set(idempotencyKey, { paramsHash, result });
return result;
}
在這裡我們:
- 在工具輸入中要求 idempotencyKey;
- 與 key 一起保存參數雜湊(此處簡化為 JSON.stringify);
- 若同一 key 但不同資料再次呼叫——視為錯誤;
- 若同一 key 且資料相同再次呼叫——直接回傳先前結果。
在真實專案中建議:
- 在資料庫保存 key 並設 TTL(避免表無限制成長);
- 將 idempotency_key 記錄到日誌,並放進 MCP 訊息的 _meta,方便用 Inspector 與儀表板追蹤。
6. 步驟回滾與 Saga 模式
冪等性能防重複,但無法解決另一個情況:若情境中某個中間步驟失敗該怎麼辦。
在電商中這是經典問題:你已建立訂單並在倉庫預留了商品,但在支付階段出錯。你不能就此「忘了它」——必須回到之前的狀態。
邏輯回滾 vs 技術回滾
在 ChatGPT 的 workflow 中有兩個層面的回滾。
邏輯回滾——回到情境中的前一個步驟並調整上下文。例如在「支付」步驟出錯,你決定回退至「選擇支付方式」,甚至「選擇禮物」。這時要:
- 更新後端的 WorkflowContext(目前步驟、選擇的參數);
- 透過 tool‑call/ToolOutput 告訴模型步驟改變,讓它「忘記」舊分支並調整後續行為;
- 更新 Widget 的 UI,讓步驟與按鈕反映新狀態。
技術回滾——這是業務層面:取消已建立的實體、補償外部副作用。例如:取消訂單、解除倉庫預留、啟動退款。這就是 Saga 模式:對每個「危險」步驟事先設計補償動作。
GiftGenius 的 forward/compensate 示意
針對簡化版的 GiftGenius 結帳流程,可畫如下序列:
flowchart TD A[步驟 1: create_order] --> B[步驟 2: reserve_items] B --> C[步驟 3: charge_card] C -->|成功| D[狀態: completed] C -->|錯誤| E[補償:cancel_reservation] E --> F[補償:cancel_order] F --> G[狀態: failed + 對使用者的訊息]
每個改變外部世界的動作(建立訂單、預留、扣款)對應一個補償動作(取消訂單、解除預留、退款)。它們不一定對稱,也不一定能一一對映,但原則如此。
帶補償的迷你程式碼範例
看看一小段執行這些步驟的程式:
async function completeCheckout(ctx: { userId: string }) {
const order = await createOrderInDb(ctx.userId);
try {
await reserveItems(order.id);
await chargeCard(order.id);
return { orderId: order.id, status: "paid" as const };
} catch (err) {
// 補償動作
await safeCancelReservation(order.id);
await safeCancelOrder(order.id);
throw err;
}
}
這裡:
- createOrderInDb、reserveItems、chargeCard 是 forward 步驟;
- safeCancelReservation 與 safeCancelOrder 是補償步驟,它們本身也應具備冪等性(如果嘗試取消已被取消的東西,不會出事)。
注意,發生錯誤時我們不把它吞掉,而是往外拋。模型(透過 ToolOutput)應收到可理解的錯誤,再以人類可讀方式向使用者說明並提出下一步。
7. 回滾與狀態同步:避免不同步
有一種容易低估的「錯誤」:UI、後端與模型之間的狀態不同步。
典型情境:
- 使用者走過步驟 1 → 2 → 3。
- 在步驟 3 出了點狀況,使用者在 Widget 按了「返回」。
- Widget 如實把自己的本地狀態回到步驟 2。
- 但模型「記得」我們在步驟 3,且已嘗試付款。下一則訊息仍在談付款,而使用者看到的卻是選擇禮物的畫面。
為避免這種情況,最好引入明確的「步驟回退」事件。由 Widget 傳給 MCP/模型——可作為工具呼叫或 ToolOutput。
例如可做一個簡單工具 user_navigated_to_step,用來記錄目前步驟與其狀態:
type NavigateInput = {
workflowId: string;
stepId: string;
};
export async function userNavigatedToStep(input: NavigateInput) {
await workflowRepo.setCurrentStep(input.workflowId, input.stepId);
return {
message: `User moved to step ${input.stepId}`,
};
}
Widget 在按下「返回」時呼叫這個工具;模型在工具呼叫歷史中看見它,就能理解接下來應按新步驟繼續對話。
UI 端大致像這樣的處理器:
async function handleBackClick() {
const { workflowId, prevStepId } = widgetState;
await window.openai.tools.call("user_navigated_to_step", {
workflowId,
stepId: prevStepId,
});
setWidgetState((s) => ({ ...s, currentStepId: prevStepId }));
}
重點:後端/代理是單一真實來源(source of truth)來判定目前步驟,而模型透過 tools 來得知。如此即使稍後恢復工作階段,也能正確同步上下文。
8. 錯誤的 UX:使用者看到什麼,模型看到什麼
我們已經會在技術層面挺過錯誤(重試、回滾、冪等、狀態同步)。接下來要讓使用者與模型都能有良好體驗。
即使重試與回滾做得完美,若錯誤 UX 像「老式 Java servlets」:紅色文字、堆疊追蹤、神祕的「Unexpected error」,一樣不行。
在 ChatGPT App 中,錯誤訊息有兩個受眾:
- 使用者:必須理解發生了什麼,並知道接下來可做什麼;
- 模型:需要足夠結構化的資訊好做決策——重試、改參數、給替代方案或結束流程。
良好實務:
- 在 MCP/工具層回傳結構化錯誤,包含代碼、型別、retryable 旗標與精簡技術描述;
- 把這個結構直接提供給模型(例如放在 result.structuredContent),而不是一長串堆疊追蹤;
- 在 UI 給使用者人類可讀、簡短的訊息。
工具回傳的錯誤結構(範例):
type ToolError = {
code: string; // 例如 "PAYMENT_TIMEOUT"
message: string; // 簡短的技術描述
retryable: boolean; // 是否可再嘗試
};
throw {
isError: true,
error: <ToolError>{
code: "PAYMENT_TIMEOUT",
message: "Payment provider did not respond in time",
retryable: true,
},
};
模型看到 retryable: true,即可嘗試其他工具或建議使用者再試一次。
在 Widget 端,將這些代碼對應到使用者可理解的文字:
function ErrorBanner({ code }: { code: string }) {
const text =
code === "PAYMENT_TIMEOUT"
? "金流服務未在時間內回應。請稍後再試一次。"
? "出了點問題。請再試一次。";
return <div className="error-banner">{text}</div>;
}
還有一點很重要:不要把例外堆疊、token、密鑰顯示給使用者。既不美觀也不安全。把技術資訊記錄在你那端的日誌裡,給使用者的只需簡短且安全的訊息。
Insight
在像 ChatGPT 這樣的 LLM 系統中,不正確的工具呼叫與其說是異常,不如說是常態。模型經常會產生不通過驗證的參數:型別混淆、缺少欄位、值不正確、結構壞掉。這不是傳統工程語義下的錯,而是隨機模型的本質,需要相應地重新設計整個錯誤介面。
關鍵想法:錯誤訊息不是「壞了」的信號,而是「修正下一次嘗試」的指令。其主要受眾是模型本身。若訊息結構化且包含精確指示,模型能自動調整參數並再次正確呼叫工具。這正是 Tool‑Reflection 技術的基礎:正確的回饋能在無人介入下提升代理的下一步行為。
建議遵守以下錯誤格式要求:
- 訊息應定位具體欄位未通過驗證——避免停留在「Invalid parameters」這種泛泛之詞;
- 明確描述預期格式或允許值,讓模型能選對;
- 訊息應簡潔、正式且結構化:如 error_type、field、expected、allowed_values 之類欄位能大幅幫助模型;
- 若可能請提供最小可行的正確輸入範例——常能提升模型的恢復精度。
理想的錯誤回饋給模型包含兩點:出了什麼問題,以及如何修正。
9. 工作流程錯誤的記錄與度量
就算錯誤的 UX 做得很優雅,若要知道實際在壞什麼,光靠使用者訊息還不夠。需要結構化日誌與步驟級的度量。
記錄每個 workflow 步驟時,最低可用的欄位集合:
- user_id 或至少 session_id;
- workflow_id 與 step_id;
- 步驟狀態(success、failed、retry、rolled_back);
- error_code(若有);
- idempotency_key 與 correlation_id(若與外部呼叫相關)。
在 MCP 與 Agents 中有 _meta 欄位;把 idempotency_key 與 correlation_id 放進去,能在日誌與 Inspector 中一目了然。
Node.js/TypeScript 的最簡記錄範例(可用 console,或 winston/pino):
function logStepFailure(params: {
userId?: string;
workflowId: string;
stepId: string;
errorCode: string;
idempotencyKey?: string;
}) {
console.error(
JSON.stringify({
level: "error",
event: "workflow_step_failed",
...params,
timestamp: new Date().toISOString(),
})
);
}
這樣的日誌易於解析、做儀表板並統計:
- 各步驟之間的轉換率;
- 最常見的錯誤類型;
- 以重試結束 vs 最終失敗的步驟占比。
不是每個錯誤都要觸發生產環境警報。嚴重的——MCP 掛了、系統性逾時、特定步驟大量失敗——才需要監控。像「找不到符合條件的禮物」——這是業務事件,而非事故。
10. 擴充 GiftGenius:更穩健的結帳步驟
現在把一切串起來:重試、冪等性、Saga、狀態同步、錯誤 UX 與日誌——以我們的教學應用 GiftGenius 的結帳步驟為例。
我們已有
到目前為止我們已:
- 具備多步驟 workflow:資訊蒐集 → 點子挑選 → 禮物選擇 → 結帳;
- 設定了工具閘門(tool gating):在結帳步驟只允許 commerce 工具(如 create_order、get_payment_methods 等);
- 有 WorkflowContext,保存選定禮物、預算、userId 與目前步驟。
本講新增
對結帳步驟導入:
- idempotency_key 給工具 create_order;
- 在金流供應商暫時性錯誤時做 retry;
- 補償 用於部分成功的操作;
- 正確的錯誤 UX 於 Widget。
在 Widget 按下「付款」時產生 idempotency key:
import { v4 as uuid } from "uuid";
async function handlePayClick() {
const idempotencyKey = uuid();
setWidgetState((s) => ({ ...s, idempotencyKey }));
await window.openai.tools.call("create_order", {
userId: widgetState.userId,
items: [/* ... */],
idempotencyKey,
});
}
在工具 create_order 端,就是我們上面寫的冪等處理器:保存 key 與結果,重複呼叫不會建立新訂單。
與金流 API 的互動程式碼可以包在 callWithRetry 中,以便在網路抖動時嘗試數次。別忘了在錯誤裡加上 retryable: true,讓模型理解可以建議重試。
若在成功建立訂單與扣款之後又出現問題(例如外部 webhook 遲遲未到),請用 correlation_id 與 workflow_id 記錄,接著:
- 嘗試背景重試(未來的佇列/事件模組會談);
- 或明確把步驟標記為 failed,執行補償動作,並向使用者解釋發生了什麼。
11. 設計容錯工作流程的常見錯誤
錯誤 №1:「把所有東西一直重試到成功為止」。
對任何步驟自動重試到成功,是自找麻煩。網路與 5xx 錯誤可以用退避與次數上限重試;但 4xx、商務錯誤與模型邏輯失敗應透過資料修正或與使用者互動來處理。否則你會得到不穩定行為、奇怪的帳單與混亂的日誌。
錯誤 №2:在金流與訂單等環節缺乏冪等性。
若像 create_order 或 charge_card 這類工具不具冪等性,任何重複呼叫(逾時、Regenerate、代理 bug)都可能產生重複。於 LLM 情境,重試比傳統 REST 前端更常見,因此 idempotency_key 不是「nice‑to‑have」,而是關鍵步驟(尤其金流)的必要條件。
錯誤 №3:沒有補償動作(缺少 Saga)。
建立了訂單、預留了庫存,在支付失敗時卻只顯示「出了點問題」。系統內會留下半成品訂單、預留、財務尾款。對每個會改變外部世界的步驟,都要想好下一步失敗時怎麼做:取消、退款、標記為「expired」等等。
錯誤 №4:讓代理陷入無限重試迴圈。
若不限制嘗試次數(例如在 helper 的 maxRetries 或代理邏輯的 max_iterations)且不在不該重試的地方標記 retryable: false,模型可能無限循環:「再試一次……再一次……」。這會燒 token、燒時間、也燒耐心。
錯誤 №5:回滾時 UI 與模型狀態不同步。
開發者常只做 UI 的「返回」按鈕,卻忘了同步後端與模型。結果使用者看到步驟 2,而模型仍停在步驟 3,提出莫名建議。解法——像 user_navigated_to_step 這樣的明確事件,並在每次切換時更新 WorkflowContext。
錯誤 №6:把技術訊息丟給使用者、卻沒有給開發者的日誌。
使用者看到「Error: ECONNRESET at TcpSocket.onEnd…」,而你對哪個 workflow_id、哪個步驟壞了卻一無所知。正確做法:給使用者——短、清楚、含接下來可以做什麼;給開發者——結構化日誌,包含 workflow_id、step_id、error_code、idempotency_key、correlation_id。
錯誤 №7:沒有警報策略。
要嘛所有事情都警報,包含「你的過度嚴苛條件下沒有禮物可選」;要嘛什麼都不警報,連 MCP 真掛了也沒反應。請區分關鍵系統故障(服務掛掉、大量逾時、webhook 丟失)與預期的業務事件。前者進監控與 on‑call,後者進分析統計即可。
GO TO FULL VERSION