1. 為什麼在 ChatGPT App 中要重視「韌性」
在一般的 Web 應用中,使用者至少看得到 URL、瀏覽器的轉圈圈,也能重新整理頁面。在 ChatGPT 裡,使用者只看到一個畫面:聊天與你的 App。若有東西變慢,他無法分辨是誰的錯——OpenAI、你的 Gateway、金流,或隔壁的分析微服務。對他而言,這一切都是「ChatGPT + 你的 App」。
當 tool-call 掛住 30–60 秒,模型一直等、一直等……充其量只會為延遲道歉;更糟的是,它可能用幻覺式答案取代你後端的資料。因此,韌性不只是關於 SRE 與 uptime,還關係到答案品質、模型語氣與 Store 指標。
在 ChatGPT App 生態裡,有數個彼此獨立的路徑:
- ChatGPT ↔ MCP Gateway。
- Gateway ↔ 你的 backend/REST 服務(Gift REST API、Commerce REST API、Analytics Service 等等)。
- 你的服務 ↔ 外部 API(LLM、支付、目錄)。
- 傳入的 Webhook(ACP、Stripe、各種整合)↔ 你的處理器。
問題在於,任一處的故障都可能觸發連鎖反應:Gateway 老實等待卡住的服務,worker 塞滿、連線用盡,客戶端開始重試,幾分鐘後你就遇到經典的「地獄模式」:同時著火又進水。今天要談的四個模式正是為了防這些狀況:
- Timeouts——我們從不無限等待。
- Circuit breaker——我們不對著關上的門猛撞。
- Bulkheads——把「艙」隔開,避免整艘船一起沉。
- Webhook 風暴防護——承認 Webhook 會重複、尖峰、重試,並預先設計好因應。
2. Timeouts:我們不會無限等待
什麼是 timeout,為何沒有它會很糟
Timeout 是你的程式願意等待相依元(資料庫、MCP 伺服器、外部 HTTP API、模型)回應的最長時間。若在設定時間內未回應——就視為失敗,釋放資源並回傳可理解的錯誤或 fallback。
沒有 timeout 的請求可能會:
- 永遠掛在等待中,
- 佔住連線與執行緒池,
- 阻塞後續請求,
- 引發連鎖故障。
基本原則:「與其 3–5 秒內的可預期失敗,不如 5 分鐘的莫名靜默」。
務必記得,timeout 存在於多個層級:
- 代理/負載平衡層(Cloudflare、Nginx),
- MCP Gateway 層(對微服務的 HTTP 客戶端),
- 服務內部(對資料庫、外部 API、LLM 的呼叫)。
對 ChatGPT 而言,整體 tool-call 的時間,常見作業以 5–10 秒為宜,特別重的最多 20–30 秒。更久幾乎保證是糟糕的 UX。
TypeScript 中的簡易 fetchWithTimeout
從實作開始。在 GiftGenius MCP Gateway 裡,我們有個輔助的 HTTP 客戶端,會呼叫 gift 選品、commerce 服務、分析服務。把標準的 fetch 包成帶逾時的函式:
// src/gateway/httpClient.ts
export async function fetchWithTimeout(
url: string,
opts: RequestInit & { timeoutMs?: number } = {}
) {
const { timeoutMs = 5000, ...rest } = opts;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...rest, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
現在在 Gateway 的程式裡,我們不再直接呼叫「裸」的 fetch,只透過這個 helper:
// src/gateway/giftClient.ts
import { fetchWithTimeout } from "./httpClient";
export async function callGiftService(path: string) {
const res = await fetchWithTimeout(
process.env.GIFT_SERVICE_URL + path,
{ timeoutMs: 4000 }
);
if (!res.ok) {
throw new Error(`gift_service_${res.status}`);
}
return res.json();
}
如此即便 gift 服務卡住,過了 4 秒我們就會中斷連線,將 MCP 錯誤回傳給 ChatGPT,而不是把連線硬撐到極限。
在 GiftGenius 中該把逾時設在哪裡
在我們的 GiftGenius 範例中:
- Gateway 層:對 Gift REST API、Commerce REST API、Analytics Service / REST API 的呼叫設置逾時。
- 各服務內部:對資料庫、ACP/金流、外部推薦 API 的呼叫設置逾時。
- Gateway 入口:對來自 ChatGPT 的整體請求設置總逾時,避免 tool-call 變成「永遠的轉圈」。
重點在於最上層的等待時間要比內部略長。例如 Gateway 等待後端 5 秒,而後端等待資料庫 3 秒,那就有處理與序列化結果的緩衝。
如何向 ChatGPT 模型說明逾時
對 ChatGPT 而言,回傳具語意的錯誤比默默斷線更好。與其抽象的 500,不如回傳結構化的 MCP 錯誤,讓模型能向使用者說明:「禮物選品服務目前過載,請稍後再試」,等等。
這表示在 Gateway 中遇到逾時時要:
- 捕捉 AbortError 或自訂的 timeout_…。
- 建立帶有意義代碼與簡短說明的 MCP 回應。
- 讓模型能決定如何對人解釋。
Timeout 解決的是「單次請求掛住」;但當某個相依開始大量故障時,僅有 timeout 仍擋不住一波波相同的失敗嘗試。這就需要下一層保護——circuit breaker。
3. Circuit breaker:對付垂死服務的「斷路器」
直覺:為什麼只有 timeout 還不夠
我們已能用 timeout 限制單次呼叫的等待時間。Timeout 保護的是單一呼叫。但若相依服務「徹底」掛了(例如 commerce 服務每個請求都因 OOM(Out Of Memory)而倒),我們仍會一直呼叫它、每次等 3–5 秒、接著抓錯、消耗網路與 CPU,然後再等一次。
Circuit breaker(斷路器)加入了記憶:它追蹤錯誤與逾時,當數量太多時,乾脆不再對該服務送出請求,改為立即回傳快速失敗或 fallback。過一段時間再以 half-open 模式小心嘗試。
斷路器的典型狀態:
- Closed——一切正常,請求照常發送。
- Open——服務視為「掛了」,不再送請求,直接回錯誤。
- Half-open——只嘗試少量請求;若成功則回到 closed,若再失敗就回到 open。
簡易的 circuit breaker 圖示
一張小圖:
stateDiagram-v2
[*] --> Closed
Closed --> Open: 錯誤過多
Open --> HalfOpen: 冷卻期已過
HalfOpen --> Closed: 連續多次成功
HalfOpen --> Open: 再次出錯
Open --> Open: 快速拒絕
TypeScript 中的迷你 circuit breaker 實作
在生產環境通常會用現成套件(Node.js 上有例如 opossum 或輕量自製方案),但理解其機制只需一個精簡類別。
以下是包住 commerce 模組呼叫的極簡 breaker 範例:
// src/gateway/circuitBreaker.ts
type State = "closed" | "open" | "half-open";
export class CircuitBreaker {
private state: State = "closed";
private failureCount = 0;
private nextAttemptAt = 0;
constructor(
private readonly failureThreshold = 5,
private readonly cooldownMs = 30_000
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
const now = Date.now();
if (this.state === "open") {
if (now < this.nextAttemptAt) {
throw new Error("circuit_open");
}
this.state = "half-open";
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = "closed";
}
private onFailure() {
this.failureCount++;
if (this.failureCount >= this.failureThreshold) {
this.state = "open";
this.nextAttemptAt = Date.now() + this.cooldownMs;
}
}
}
在 commerce 客戶端中的用法:
// src/gateway/commerceClient.ts
const commerceBreaker = new CircuitBreaker(3, 20_000);
export async function callCommerce(path: string) {
return commerceBreaker.call(async () => {
const res = await fetchWithTimeout(
process.env.COMMERCE_URL + path,
{ timeoutMs: 3000 }
);
if (!res.ok) throw new Error(`commerce_${res.status}`);
return res.json();
});
}
當 commerce 開始大量回錯或來不及在逾時前回應,累積數次失敗後,breaker 會進到 open。在 cooldownMs 期間,我們完全不再嘗試呼叫該服務,而是直接回傳 circuit_open 的快速錯誤。
當 breaker 切斷服務時,ChatGPT 應該看到什麼
對 ChatGPT 來說,更好的是你:
- 快速回傳 MCP 錯誤,例如「commerce_unavailable」或「gift_service_overloaded」。
- 附上清楚說明:「支付服務暫時不可用,我們稍後再試」。
- 不要用無限重試把錯誤藏起來。
這正是「快速且誠實的失敗」優於長時間掛住的情境。特別是在結帳流程:使用者比起盯著轉圈 40 秒後看到「出了點問題」,更能接受明確訊息。
Timeout 與 breaker 能保護我們免於「壞掉」或停擺的相依,但無法解決「某一類負載吃掉所有資源、扼殺系統其它部分」的問題。這就需要另一層——bulkheads。
4. Bulkheads:把「隔艙」做好,避免一處讓整艘船沉
船艙類比
Bulkhead 模式源自船艙的艙壁:若某一艙進水,水不會漫到整艘船。架構上的意涵是:分割資源到不同工作面向,避免某個過載的服務吃光所有資源——CPU、連線、各種池——並拖垮關鍵路徑。
在微服務中,通常透過獨立的:
- HTTP 連線池,
- 執行緒/worker 池,
- 佇列/主題,
- 甚至關鍵操作使用獨立資料庫叢集。
想法是:如果禮物推薦服務變慢、卡卡的,它最多耗盡自己的資源,不會把結帳與授權也拖下水。
在 Node.js 與 MCP Gateway 的 bulkhead 作法
在 Node.js 中沒有傳統意義的執行緒(有 event loop 與 workers),但我們可以限制每個方向的同時處理數量。
例如,Gateway 有三個外部相依:
- Gift 服務(禮物選品,LLM 負載重)。
- Commerce 服務(結帳、ACP)。
- Analytics 服務(事件記錄)。
我們可以為它們各自設定並行限制。
以下是一個限制並行度的小型「信號量」:
// src/gateway/bulkhead.ts
export class Bulkhead {
private active = 0;
private queue: (() => void)[] = [];
constructor(private readonly maxConcurrent: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.active++;
try {
return await fn();
} finally {
this.active--;
const next = this.queue.shift();
if (next) next();
}
}
}
用在各服務上:
// src/gateway/clients.ts
import { Bulkhead } from "./bulkhead";
const giftBulkhead = new Bulkhead(10); // 最多 10 個並行
const commerceBulkhead = new Bulkhead(3); // 結帳嚴格限制
const analyticsBulkhead = new Bulkhead(50); // 可以很多
export async function callGiftWithBulkhead(fn: () => Promise<any>) {
return giftBulkhead.run(fn);
}
export async function callCommerceWithBulkhead(fn: () => Promise<any>) {
return commerceBulkhead.run(fn);
}
因此,即使 GPT 決定同時請你做 30 個複雜的禮物選品,它們最多同時跑 10 個;而結帳仍可照常進行,因為有各自獨立的限制。
GiftGenius:我們要怎麼分「艙」
在 GiftGenius 中,合理的隔艙包括:
- 禮物選品(LLM 重、優先級較低,可放慢)。
- Checkout/ACP(超關鍵,需最大程度保護)。
- 分析/日誌(重要,但可接受些許延遲)。
進一步的設計甚至會把它們以不同叢集部署、給不同資源。但在本講座重點是概念:不要讓次要功能「吃掉」全部資源。
上述三個模式——timeouts、circuit breaker 與 bulkheads——都關於你如何向「外部」呼叫、存取相依。還有另一類韌性威脅:傳入事件流,即使你的對外呼叫配置完美,也可能被淹沒。最典型的就是 Webhook 風暴。
5. Webhook 風暴:當外界送進來的事件比你能處理的還多
Webhook 在真實環境的行為
韌性的第四個來源是傳入事件:來自 ACP、Stripe 等系統的 Webhook。即使你已設好 timeouts、circuit breaker 與 bulkhead,它們仍可能掀起真正的「風暴」。
Webhook 不是「按需」的 HTTP 請求,而是外部系統(Stripe、ACP、外部商店等)的「push」事件。它們有幾個不太友善的特性:
- 採用至少一次投遞(at-least-once)——代表重複是無可避免的。
- 不保證投遞順序。
- 遇錯會重試:先隔一秒,接著 10 秒,再來一分鐘……直到你回 2xx。
- 尖峰時段(例如特賣)會批次湧入,形成「風暴」。
若你的處理器不具冪等性且處理過久,就會變成瓶頸;整個佇列塞住,而重試只會加劇風暴。最終可能把資料庫、佇列、worker 池都搞掛,進而拖垮系統其它部分。
防禦風暴的基本原則
有幾個做法能大幅提高在風暴中的存活率:
首先,queue-first, process-later。理想上,傳入的 Webhook 不該在同步路徑做重工作。相反地,它應該儘快驗簽/驗格式,把工作丟進佇列,並回 200 OK。處理改由背景 worker 非同步進行。若需要給 ChatGPT「快速確認」,可維持獨立的通知路徑。
其次,處理器的冪等性。對同一操作的重複 Webhook 不應「再次建立訂單」或「重複扣款」。通常透過保存 idempotency key 或 eventId,並檢查事件是否已處理來達成。
第三,在接收端進行速率限制與 circuit breaker。即使送件端在狂風暴雨,你仍可以:
- 按 IP/訂閱/endpoint 做 RPS 限制,
- 暫時回 429 或 503,放慢對方重試,
- 在 downstream(如訂單資料庫)前使用 breaker,避免把流量倒進已故障的元件。
GiftGenius 中的 Next.js Webhook 處理器範例
假設我們有一個 ACP/金流系統,會把訂單狀態的 Webhook 送到 POST /api/commerce/webhook。我們希望:
- 快速接收事件並放入佇列,
- 不要在同步路徑處理,
- 不會被重複事件搞壞。
以下是簡化版(未包含簽章驗證與真實佇列——這些會放在安全與佇列模組裡):
// app/api/commerce/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
// 這裡本可接上 Redis/佇列,暫以陣列模擬
const inMemoryQueue: any[] = [];
const processedEvents = new Set<string>(); // 冪等性(示範用)
export async function POST(req: NextRequest) {
const event = await req.json();
const eventId = event.id as string;
if (processedEvents.has(eventId)) {
return NextResponse.json({ ok: true, duplicate: true });
}
// 在真實場景會驗證簽章與資料結構
inMemoryQueue.push(event); // 放入佇列,留給背景處理
// 背景 worker 稍後會處理並把 ID 標記為已處理
return NextResponse.json({ ok: true });
}
這只是示意,但有兩個重點:
- 同步區段要盡可能輕量。
- 圍繞 event.id 做冪等性設計。
在實務上你會:
- 使用外部佇列(SQS、RabbitMQ、Kafka),
- 在資料庫保存已處理事件,
- 驗證 Webhook 簽章與 payload 版本,
- 必要時在處理器周圍套上獨立的 Bulkhead/Breaker。
在 GiftGenius 的情境中會是什麼樣子
對於透過 Webhook 與 ACP/Stripe 整合的 GiftGenius,防範風暴在尖峰季節(新年、黑色星期五)特別重要。當時事件很多:
- intent 建立,
- 支付確認,
- 取消,
- 退款。
若你的處理器開始「變慢」(例如呼叫外部 API 造成),你可能會遇到:
- ACP 開始重試,
- 事件批次湧入,
- 訂單資料庫與 worker 池塞滿。
「queue first」+ 冪等性 + 入口速率限制,就是防範這類情境的保險。
6. 這些模式如何一起運作
現在把這些模式放進同一個流程,看看它們如何在真實路徑「選禮物並立即下單」中合奏。
以「ChatGPT → Gateway → Gift Service → Commerce → Webhook」這條鏈為例:
使用者在聊天中說:「幫我挑個禮物並直接下單」。
- 模型決定呼叫你的工具 suggest_and_checkout。
- Gateway 透過 fetchWithTimeout 與 gift 服務的 bulkhead 呼叫 gift 服務。
- 若 gift 服務掛住——觸發逾時;環繞它的 breaker 在一定錯誤數後進入 open,接下來的請求會直接得到 MCP 錯誤「gift_service_unavailable」。
- 若 gift 回應,Gateway 便呼叫 commerce 服務(同樣帶逾時與獨立的 bulkhead)。
- 任何 commerce 的問題觸發另一個 circuit breaker,設定比 gift 更嚴格(因為結帳至關重要)。
- 成功下單後,ACP 會將 Webhook 傳到你的 /api/commerce/webhook;該端點把事件丟進佇列並快速回覆,背景 worker 負責處理;對同一個 eventId 的重複 Webhook 會被視為重複而忽略。
最後結果:
- 卡住的選品服務不會拖垮結帳。
- 卡住的 commerce 不會把所有 tool-calls 變成一分鐘的轉圈——ChatGPT 會快速收到有意義的錯誤。
- Webhook 風暴不會破壞你的主要 HTTP 路徑。
- 你能控制降級的位置:寧可暫時關掉個人化推薦,也不要讓支付倒下。
7. 你的 App 的實用檢查清單(敘述式)
總結來說,在典型的 ChatGPT App(含 MCP/Gateway)中,值得逐步檢視以下問題。
首先,檢查所有對外呼叫是否都有逾時。所有 fetch、資料庫與 LLM 請求都該使用像 fetchWithTimeout 這樣的包裝與合適數值。務必避免有請求可能無限掛住的地方。
接著,找出最脆弱的相依。通常是金流、ACP、龐大的外部 API,有時還包括你自己的訂單資料庫。為它們加上 circuit breaker,以免在明知已死的服務上引發重試雪崩。同時事先規劃好當 breaker 處於 open 時,ChatGPT 應如何表現。
之後,把資源當作「船艙」檢視。是否所有東西都走同一個連線池與同一個 worker 池?關鍵操作(登入、結帳)是否有獨立於推薦與分析的並行限制?若沒有——至少加入最基本的 bulkhead 實作,哪怕只是粗略限制同時任務數。
最後,稽核所有傳入 Webhook。檢查是否有 idempotency key 或 eventId、是否在 HTTP 處理器裡同步做重工作,以及當 downstream 暫時故障時你是否能承受一波重試。若否——把邏輯搬到佇列與背景 worker。
即便沒有超複雜的基礎設施,這樣的一連串步驟也能帶來非常可觀的韌性提升。
8. 使用 timeouts、circuit breakers、bulkheads 與 Webhook 風暴防護時的常見錯誤
錯誤 №1:在「底層某處」缺少逾時。
開發者常只在 Gateway 或前端設逾時,卻忘了後端裡還有資料庫、外部 API、LLM。結果外部請求看似有 5 秒逾時,但內部一次資料庫或金流呼叫卻可能卡上好幾分鐘,堵住連線池並引發連鎖失敗。
錯誤 №2:為了保險而放超大逾時。
有時會把逾時設為 60–120 秒:「讓它跑完就好」。在 ChatGPT 情境幾乎總是壞主意。使用者離開、模型開始幻覺,而你的資源全被佔住。更好的做法是 5–10 秒內誠實地失敗,並附上可理解的說明。
錯誤 №3:沒有設計過 UX 的 circuit breaker。
有時為了「打勾」而加 breaker,但觸發時,丟給使用者或模型的是莫名的 500、「ECONNREFUSED」或「axios error」。結果 GPT 無法好好解釋,開始瞎掰。應該事先設計對人與模型都清楚的錯誤訊息。
錯誤 №4:沒有 bulkhead 思維地混用資源。
典型情境:某個推薦(或分析)服務開始變慢,吃掉所有資料庫連線池或 thread-pool,接著結帳與登入也跟著死。都因為資源未分艙。缺少任何程度的 bulkhead,會讓次要功能放倒整個生產環境。
錯誤 №5:把 Webhook 當成一般請求來處理。
新手常把 Webhook 處理器寫成一般控制器:長商業邏輯、對外部 API 的呼叫、沒有冪等性。在重試與重複的情況下,導致事件被處理兩次、訂單狀態怪異,且在風暴時因負載而掛掉。
錯誤 №6:在 commerce 情境忽視冪等性。
特別危險的是,支付 Webhook 可能再次建立訂單,或重複改變其狀態。若沒有檢查 idempotency key 與保存事件處理狀態,遲早會遇到重複扣款或奇怪的重複訂單。
錯誤 №7:試圖用 setTimeout 與「魔法延遲」修所有問題。
有人想用「等 100 ms 就好了」來繞過 race condition 與風暴。實務上只會讓系統更不穩,且對真正的故障毫無幫助。正確的路是明確的逾時、circuit breaker、佇列與冪等性,而不是用延遲在那邊作法。
錯誤 №8:沒有為關鍵路徑做優先級。
當結帳與登入與分析或推薦邏輯共享同樣限制時,任一過載都可能同樣地放倒關鍵與次要部分。在韌性設計上,checkout 與 auth 是「神聖不可侵犯」:給它們獨立資源、獨立限制、獨立告警與 SLO。
GO TO FULL VERSION