CodeGym /課程 /ChatGPT Apps /容錯性:步驟回滾、重試、錯誤控制

容錯性:步驟回滾、重試、錯誤控制

ChatGPT Apps
等級 11 , 課堂 3
開放

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 大多不該重試。

也就是說,如果你收到 503504 或外部 API 一直沒回應,那麼在少許延遲後重試有意義。但若伺服器回 400 Bad Request422 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;
  }
}

這裡:

  • createOrderInDbreserveItemschargeCard 是 forward 步驟;
  • safeCancelReservationsafeCancelOrder 是補償步驟,它們本身也應具備冪等性(如果嘗試取消已被取消的東西,不會出事)。

注意,發生錯誤時我們不把它吞掉,而是往外拋。模型(透過 ToolOutput)應收到可理解的錯誤,再以人類可讀方式向使用者說明並提出下一步。

7. 回滾與狀態同步:避免不同步

有一種容易低估的「錯誤」:UI、後端與模型之間的狀態不同步

典型情境:

  1. 使用者走過步驟 1 → 2 → 3。
  2. 在步驟 3 出了點狀況,使用者在 Widget 按了「返回」。
  3. Widget 如實把自己的本地狀態回到步驟 2。
  4. 但模型「記得」我們在步驟 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_typefieldexpectedallowed_values 之類欄位能大幅幫助模型;
  • 若可能請提供最小可行的正確輸入範例——常能提升模型的恢復精度。

理想的錯誤回饋給模型包含兩點:出了什麼問題,以及如何修正

9. 工作流程錯誤的記錄與度量

就算錯誤的 UX 做得很優雅,若要知道實際在壞什麼,光靠使用者訊息還不夠。需要結構化日誌與步驟級的度量。

記錄每個 workflow 步驟時,最低可用的欄位集合:

  • user_id 或至少 session_id;
  • workflow_idstep_id
  • 步驟狀態(successfailedretryrolled_back);
  • error_code(若有);
  • idempotency_keycorrelation_id(若與外部呼叫相關)。

在 MCP 與 Agents 中有 _meta 欄位;把 idempotency_keycorrelation_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_orderget_payment_methods 等);
  • WorkflowContext,保存選定禮物、預算、userId 與目前步驟。

本講新增

對結帳步驟導入:

  1. idempotency_key 給工具 create_order
  2. 在金流供應商暫時性錯誤時做 retry
  3. 補償 用於部分成功的操作;
  4. 正確的錯誤 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_idworkflow_id 記錄,接著:

  • 嘗試背景重試(未來的佇列/事件模組會談);
  • 或明確把步驟標記為 failed,執行補償動作,並向使用者解釋發生了什麼。

11. 設計容錯工作流程的常見錯誤

錯誤 №1:「把所有東西一直重試到成功為止」。
對任何步驟自動重試到成功,是自找麻煩。網路與 5xx 錯誤可以用退避與次數上限重試;但 4xx、商務錯誤與模型邏輯失敗應透過資料修正或與使用者互動來處理。否則你會得到不穩定行為、奇怪的帳單與混亂的日誌。

錯誤 №2:在金流與訂單等環節缺乏冪等性。
若像 create_ordercharge_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_idstep_iderror_codeidempotency_keycorrelation_id

錯誤 №7:沒有警報策略。
要嘛所有事情都警報,包含「你的過度嚴苛條件下沒有禮物可選」;要嘛什麼都不警報,連 MCP 真掛了也沒反應。請區分關鍵系統故障(服務掛掉、大量逾時、webhook 丟失)與預期的業務事件。前者進監控與 on‑call,後者進分析統計即可。

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