1. 為什麼需要保護 ChatGPT App 的周邊
在典型的 Web 應用中,使用者就是瀏覽器,會以相對可預期的方式打你的端點。在 ChatGPT Apps 的世界裡,你會遇到一種新的客戶端:LLM 會自己決定何時、要呼叫哪些工具。
模型可能會:
- 在同一段對話中連續多次呼叫同一個 tool;
- 做各種嘗試:『那如果再呼叫一次 suggest_gifts,只是參數稍微不同呢?』;
- 同時為數百位使用者並行工作。
再加上可能的機器人、測試腳本、你自己程式中的錯誤(例如不停觸發工具呼叫的無限迴圈),你就得到幾乎是自願性的 DoS 完美配方。
更糟的是成本。每次 tool‑call 可能會:
- 連到外部付費 API(快遞、付款、目錄),
- 呼叫其他 LLM(例如 RAG 搜尋),
- 啟動非常重的背景任務。
如果沒有任何限制與邊界防護,一個「不太幸運」的客戶端就可能:
- 把 gateway 後面的所有 backend 服務打掛(Gift API、Commerce API 等),
- 打爆外部 API 的限額,
- 並大幅燃燒模型的費用預算。
本講的目標——說明如何用 gateway/proxy + rate limiting + 佇列 + backpressure,把潛在災難變成可控的系統。
Insight
ChatGPT 平台不提供任何防護機制來保護你的 MCP 伺服器免於外部流量。任何網路客戶端都可以向它發送請求,包括像 MCP Jam 這樣的工具。
ChatGPT 唯一能提供的是,透過設定反向代理(例如 NGINX)並使用 allowlist 來限制來源 IP。如果沒有設定 IP 過濾,你的 MCP 伺服器就完全暴露,這並不安全——對你與你的使用者都一樣。
2. Proxy/Gateway 作為後端服務與 agents 前的「盾牌」
先回顧一張圖,不過這次從防護的角度看。
想像一個典型架構:
flowchart LR
ChatGPT["ChatGPT / 小工具"]
--> GW["MCP Gateway (Auth, Rate Limit, Logs)"]
GW --> GiftAPI["Gift REST API (禮物推薦)"]
GW --> CommerceAPI["Commerce REST API (checkout, ACP)"]
GW --> Analytics["Analytics Service / REST API"]
GW --> Queue["任務佇列"]
Queue --> Worker["Background workers"]
Gateway 站在外部世界(ChatGPT、webhooks、測試用客戶端)與其他一切之間。它:
- 看見所有進來的請求;
- 第一時間檢查權杖與請求格式;
- 能丟棄明顯不合理的內容(陌生 host、怪異的 path、過大的 body);
- 決定該把請求轉給哪個內部 REST‑/HTTP‑服務才有意義。
在這一層還會有:
- rate limiting——限制在一定時間內可發出的請求數;
- 最簡單的 backpressure——當底下服務已經快要淹沒時直接拒絕;
- 轉為非同步——重工作直接丟進佇列,回應客戶端:「已接收,等待中」。
也就是說,gateway 不只是「router」,還是「防彈背心」。重點是別把它變成「業務的大型單體」,這在上一講已經談過。
3. 需要管控哪些流量
在 ChatGPT App 生態中,從限制與防護的角度,我們主要關注三種流量。
第一,來自 ChatGPT 的 MCP tool‑calls。也就是透過 MCP 協議進來的一切:呼叫 suggest_gifts、get_product_details、create_checkout_session 等工具。模型產生它們的速度可以很快,尤其當底下還有 Agents。
第二,我們的後端對外部 API 的出去請求。服務裡對第三方系統(目錄、物流、金流)可能也有自己的限額。違反它們——就會遭封鎖、罰款,或品質下降。
第三,進來的 webhooks——來自 ACP、金流提供商(如 Stripe)、快遞的通知。它們與使用者活動無關。如果我們的 endpoint 變慢或回錯,外部系統會開始重試(retries),可能對你造成「風暴」般的重覆通知。
對 GiftGenius 而言,可能是這樣:
- 使用者與模型頻繁呼叫 suggest_gifts 與 find_similar_gifts;
- checkout 工具呼叫 ACP/商務後端;
- 付款完成後,金流會送 webhooks payment.succeeded / payment.failed。
所有這些流都匯聚到同一個點——Gateway,因此最合理的作法,就是在那裡放「計數器、濾器與紅綠燈」。
4. Rate limiting:基礎防護與省錢
在我們的情境中,什麼是 rate limiting
Rate limiting 是一種機制,用來限制特定客戶端在單位時間內能發出的請求數。這個點子跟網際網路一樣古老,但在 ChatGPT Apps 的脈絡裡,它立刻解決三件事:
- 不讓單一客戶端(或某個 bug)把你的服務打掛;
- 協助遵守外部 API 的限額;
- 保護你的錢包,避免模型被無限制呼叫。
經典演算法:
- 固定視窗(Fixed Window),
- 滑動視窗(Sliding Window),
- 權杖桶(Token Bucket)。
概念上了解即可:「每分鐘不超過 N 個請求」、「每個請求消耗 1 個 token,token 以每秒 X 的速度補充」等。實作通常交給函式庫或 API Gateway。
該在哪裡設置限制
可以在不同層級設置限制。
在反向代理層(Nginx、Cloudflare、AWS API Gateway)方便:
- 按 IP 擋下最瘋狂的流量;
- 限制請求本文大小;
- 防禦簡單的 DDoS 模式。
在MCP Gateway(應用)層,適合做更「有意義」的 rate limiting:
- 按使用者(權杖中的 userId),
- 按組織(tenantId),
- 按操作類型(例如嚴格限制 create_checkout_session,對 search 寬鬆些),
- 按來源(webhook vs tool‑call)。
也可以在特定微服務裡,對特別昂貴的操作再加限制——這是下一層的細節。
如何選擇限流鍵
最常見的錯誤——按 IP 位址限流。對於 ChatGPT 幾乎沒用:
- 所有請求可能都來自同一個 OpenAI 範圍;
- 不同使用者會「坐在」同一個 IP 後面。
更有用的是:
- userId——你應用中的特定使用者;
- tenantId——組織(若你做 B2B,且一個聊天被多名員工共用);
- API 權杖或 clientId(若你有多個整合)。
在 GiftGenius 通常以 userId + tenantId(由 ChatGPT 在 MCP 呼叫裡傳遞的權杖解出)就足夠。
TypeScript 中的簡易 rate limiting 實作
假設我們有一個小型的 Express MCP Gateway。加上最簡單的 rate limiting:每位使用者每分鐘最多 30 次 tool‑calls。
// 簡單的 rate limiting:每個 userId 每分鐘最多 N 次請求
const WINDOW_MS = 60_000;
const MAX = 30;
const hits = new Map<string, { ts: number; count: number }>();
function rateLimit(req: Request, res: Response, next: NextFunction) {
const userId = (req.headers["x-user-id"] as string) ?? "anonymous";
const now = Date.now();
const rec = hits.get(userId) ?? { ts: now, count: 0 };
if (now - rec.ts > WINDOW_MS) { // 視窗已過期——重新開始
rec.ts = now;
rec.count = 0;
}
rec.count += 1;
hits.set(userId, rec);
if (rec.count > MAX) {
return res.status(429).json({
error: "rate_limit_exceeded",
retryAfterSec: 60,
message: "Too many tool calls, please retry later."
});
}
next();
}
現在把它用在 MCP 路由上:
// 對所有 MCP 工具呼叫套用 middleware
app.post("/mcp/tools/call", rateLimit, async (req, res) => {
const result = await callBackendForTool(req.body); // 呼叫 Gift/Commerce/Analytics API 的 REST
res.json(result);
});
關鍵點:
- 我們回傳有意義的錯誤(error: "rate_limit_exceeded"),而不是單純的 500;
- 模型能讀懂錯誤、理解發生了什麼,並正確向使用者解釋,而不是開始瞎猜。
在真實的 production,計數器當然不會放在單一行程記憶體,而是放在 Redis 或其他共用儲存,讓叢集化時也能運作。但理解原理這樣就夠了。
Rate limiting 與 gateway 層的限制能擋下請求雪崩,但它們解不了另一個問題——個別操作可能仍然很重且耗時很長。此時只靠同步 HTTP 不夠,該上場的是佇列與非同步任務。
5. 佇列與非同步任務:何時同步已經不行
ChatGPT 的逾時問題
即使你仔細設定了 rate limiting,ChatGPT(以及一般 HTTP 客戶端)也不喜歡等太久才收到回應。平台會限制 tool‑call 的執行時間;如果你一直等某個「超級推薦」演算法跑完,結果會是:
- 使用者只看到無止盡的轉圈;
- 平台因逾時而中斷請求;
- 模型認為「出了點問題」,然後開始編理由。
解法:把重操作轉成非同步模式。典型的做法:
- Gateway 接收請求。
- 把任務丟進佇列。
- 立即回覆 202 Accepted,並附上 jobId。
- 獨立的 worker 從佇列取出任務並處理。
- 客戶端(我們的小工具,甚至 ChatGPT 經由另一個 tool)定期用 jobId 詢問狀態,或透過 MCP 事件收到通知。
在 ChatGPT App 的語境裡,這通常是兩個工具:第一個 tool 接收請求、把任務丟進佇列並回傳 jobId;第二個 tool 讓模型或小工具用這個 jobId 查詢狀態並取回結果。另外,也可用 MCP 通知來傳遞進度事件。
GiftGenius 的迷你佇列(程式碼範例)
假設我們有個很重的工具 generate_large_gift_report,可能要跑數十秒。在真實 App 中,它只會回傳 jobId,而另一個 tool get_report_status 讓模型或小工具透過這個 jobId 查詢狀態與取得結果。在 Gateway 層替它做一個帶佇列的專屬 endpoint。
type Job = { id: string; payload: any };
const queue: Job[] = [];
const MAX_QUEUE = 100;
app.post("/mcp/tools/generate_report", (req, res) => {
if (queue.length >= MAX_QUEUE) {
return res.status(503).json({
error: "system_busy",
message: "System is busy, please retry later."
});
}
const job: Job = { id: crypto.randomUUID(), payload: req.body };
queue.push(job);
res.status(202).json({ jobId: job.id, status: "accepted" });
});
再加上一個原始的 worker,每 200 ms 取出一項任務:
async function processJob(job: Job) {
// 在這裡透過 REST 呼叫真正的後端服務或 agent 工作流程
await handleHeavyGiftReport(job.payload);
}
setInterval(async () => {
const job = queue.shift();
if (!job) return;
await processJob(job);
}, 200);
當然這是大幅簡化的示範:
- 在真實世界中,佇列住在 Redis、SQS、Kafka 等等;
- 任務狀態會存放在其他地方以便查詢;
- 通常會有多個 worker。
但概念已經清楚:Gateway 不會把請求一直掛著等全部完成;它接收、排入處理,並快速回應。
6. Backpressure:別被自己的佇列淹沒
Backpressure 與 rate limiting 有何不同
Rate limiting 首先回答的是:「單一客戶端在一段時間內可以發出多少請求?」它是在防止「某個過度活躍的使用者」或特定客戶端的 bug。
Backpressure 則回答:「我們的系統同時能夠承受多少總任務/請求而不崩潰?」它關注的是總體負載,不論來源。
例如:
- rate limiting:「使用者呼叫 suggest_gifts 的頻率不可超過每分鐘 30 次」;
- backpressure:「佇列中未完成的任務不可超過 100 個,否則開始拒絕新請求」。
理想情況下,兩者相輔相成:rate limit 管住客戶端,backpressure 在真正人多時保護整體系統。
限制同時處理中的任務數(簡單實作)
最簡單的 backpressure 之一——限制底層同時進行的呼叫數。例如:對某個 backend/REST 服務(Gift API、Commerce API 等),同時進行的 tool‑calls 不超過 50。
let activeCalls = 0;
const MAX_ACTIVE = 50;
app.post("/mcp/tools/call", async (req, res) => {
if (activeCalls >= MAX_ACTIVE) {
return res.status(429).json({
error: "gateway_overloaded",
message: "Gateway is temporarily overloaded, please retry later."
});
}
activeCalls += 1;
try {
const result = await callBackendForTool(req.body); // 呼叫 Gift/Commerce/Analytics API 的 REST
res.json(result);
} catch (err) {
console.error("Tool call error", err);
res.status(500).json({ error: "internal_error" });
} finally {
activeCalls -= 1;
}
});
這裡發生了什麼:
- 只要同時執行中的請求數少於 MAX_ACTIVE,就放行新的呼叫;
- 若達到上限,立即回覆有意義的錯誤;
- 務必在 finally 中遞減計數,避免錯誤時遺失「名額」。
這就是最簡單的 backpressure:我們誠實地告訴客戶端:「現在不行,稍後再試」;而不是盲目接單直到崩潰。
後續可以:
- 針對不同操作設置不同的 MAX_ACTIVE(例如幾乎總是放行 checkout,而更嚴格限制報表生成);
- 依負載指標動態調整限制。
7. Webhooks 與「風暴」:保護進來的事件
前面我們主要看由我們或 ChatGPT 主動發起的請求(tool‑calls、出去的請求、非同步任務)。但在實務上,對 Gateway 的另一個重要負載來源是——外部系統送進來的 webhooks。
Webhooks 是事情的另一面:tool‑calls 是我們(透過模型)發起,webhooks 則是外部服務發起。這就是第 4 節所說的第三類流量——我們無法控制它的時間與頻率,但必須能在不當機的情況下承受。金流、ACP、物流——它們在每個重要事件時都會打到我們的 endpoint:「付款成功」、「訂單建立」、「配送狀態更新」。
問題從以下情況開始:
- 我們的 endpoint 回覆很慢;
- 回覆錯誤;
- 偶爾不可用。
於是外部服務(遵循最佳實務)開始重試。如果運氣不好,你就會遭遇「webhook 風暴」——數十甚至上百個重覆事件,試圖不計代價地打到你這裡。
為了不被這種「關心」弄死,在 Gateway 層應該:
- 按來源限制進來的 webhooks:例如「同一個供應商的某個 event_type 每分鐘不超過 10 個」。
- 在解析 JSON 之前驗證簽名:HMAC 簽名或類似機制能過濾掉偽造請求。
- 讓事件處理具備冪等性:根據 event_id 或類似欄位,避免重覆事件造成重覆建立訂單或付款。
- 在嚴重風暴時啟用額外 backpressure:若 downstream 服務來不及處理,暫時回覆「503:請稍後再試」。
最簡單的示例(概念,非 production 程式):
app.post("/webhooks/stripe", rateLimitWebhook, (req, res) => {
const sig = req.headers["stripe-signature"] as string;
if (!isValidSignature(req.rawBody, sig)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
if (isAlreadyProcessed(event.id)) {
return res.json({ received: true }); // 具備冪等性
}
handleStripeEvent(event);
res.json({ received: true });
});
在 Gateway 層我們:
- 對 webhooks 套用獨立的 rate limiting 策略;
- 在信任內容之前驗證簽名;
- 透過 isAlreadyProcessed 防止重覆處理。
8. 套用到 GiftGenius:限流與佇列的示例策略
現在從抽象回到具體,看看在我們的教學專案 GiftGenius 中,可能如何落地。
想像三個關鍵情境:
- 搜尋禮物(suggest_gifts、find_similar_gifts)。
- 建立訂單 / checkout(create_checkout_session、confirm_order)。
- 接收 webhooks——來自金流供應商與 ACP。
對每個情境,建議明確定義:
- 以什麼鍵來計數;
- 每分鐘允許多少請求;
- 超限時如何處理。
例如:
| 情境 | 限流鍵 | 每分鐘上限 | 超限時行為 |
|---|---|---|---|
| 搜尋禮物 | userId | 30 | 429 + 建議「縮小搜尋條件」 |
| 建立訂單 | userId + tenantId | 5 | 429 + 訊息「嘗試次數過多,請檢查訂單」 |
| 進來的 webhooks | provider + eventType | 10 | 429/503、記錄、可能降級 |
對 webhooks,通常更合理的是以「供應商 + 事件類型」組合來限流,並用基於 event_id 的冪等性機制去除重覆。
在程式裡會變成不同的 middleware:rateLimitSearch、rateLimitCheckout、rateLimitWebhook。
對於重操作,例如「產出年度大型禮物 PDF 報表」,我們採用佇列 + 非同步模式(上面已示範)。Gateway 在這種情況下:
- 接收來自 ChatGPT 的請求;
- 把任務丟進佇列;
- 回傳 jobId 與讓模型取得狀態的提示;
- 限制佇列大小(backpressure),避免系統被塞爆。
請記得:rate limiting 與 backpressure 不僅關乎安全性與可靠性,也關乎 UX。比起一直等到逾時或看到「Internal Server Error」,聽到助理說「服務現在很忙,我們一分鐘後再試」要好太多。
9. 迷你實作:替我們的 MCP Gateway 加上防護
為了避免內容只停留在理論,來做個你可以在自己教學專案裡動手試的迷你實作。
對所有 MCP tool‑calls 做 rate limiting
新增 middleware rateLimit(如上)並掛到 /mcp/tools/call。先用很簡單的限制:每位使用者(userId)每分鐘 30 次。然後試著調整:
- 把上限調小,觀察你的 App 與模型如何反應;
- 為不同類型的 tools 設不同上限,例如把 toolName 傳進 middleware 做判斷。
以同時進行中的呼叫數做最簡 backpressure
新增計數器 activeCalls 與限制 MAX_ACTIVE。試著模擬負載(例如寫個腳本一口氣送一堆請求),看看 Gateway 何時開始回覆 gateway_overloaded。
這裡重點在於行為:你不等一切都崩潰才處理,而是選擇不再接新任務,清楚告知客戶端現在太擁擠。
替重工具加上佇列
挑一個重操作(或人工讓它「變重」——插入 setTimeout/很慢的 fetch),然後改成「佇列 + jobId」的模式。最小化需求:
- endpoint POST /mcp/tools/generate_report——把任務丟進佇列並回傳 jobId;
- endpoint GET /jobs/:id——回傳狀態(pending、done、error,以及可能的結果);
- worker 每隔 X 毫秒呼叫 processJob。
這已足夠讓你理解未來如何整合 BullMQ 或其他佇列引擎。
10. 周邊防護的常見錯誤
錯誤 №1:只用 IP 做限流。
在 ChatGPT Apps 的世界中幾乎沒用:大部分請求都來自 OpenAI 的位址,你的所有使用者都會在同一個 IP 後面。結果就是有人幫大家把配額用光,真正的肇因卻找不到。正確做法是按 userId、tenantId 或權杖限流,IP 只在反向代理層做非常粗的過濾。
錯誤 №2:超限或過載時只回「裸」的 500。
若在超限或過載時,你只回 500 Internal Server Error,模型完全不知道發生什麼事,會開始瞎猜。相反地,帶有錯誤碼的結構化錯誤(rate_limit_exceeded、gateway_overloaded)與清楚的人類可讀描述,能讓 LLM 正確對使用者解釋,並在需要時稍後再試。
錯誤 №3:做一個無限長的佇列,卻沒有 backpressure。
有時會想:「全部丟佇列就好,之後再處理」。實務上佇列會漲到上千任務、延遲暴增、記憶體爆掉,使用者卻一直看不到結果。永遠限制佇列大小與同時進行的操作數。寧可誠實回 503 或 429,也別把佇列變成黑洞。
錯誤 №4:只靠 rate limiting,忽視 webhooks。
很多人只保護來自 ChatGPT 的進站流量,webhooks 就任其自生自滅。當金流供應商開始重試,真正的風暴常常發生在 webhooks。對 webhook 端點要有自己的限流、簽名驗證與冪等處理;否則很容易出現重覆的訂單。
錯誤 №5:把所有計數器與佇列只放在單一實例記憶體。
在教學專案尚可,但在 production 水平擴展 Gateway 後,每個節點的計數器會「各過各的生活」,限制不再是全域一致,重啟節點也會把佇列清空。在真實系統中,限流與佇列的狀態應該放在共用儲存(Redis、雲端佇列等)。我們會在規模化與上線的課程中再談。
錯誤 №6:因為 Gateway 是中間人,就把商務邏輯塞進去。
有時會想:「反正請求都進到 Gateway,就在這裡決定要顯示哪些禮物吧」。最後 gateway 變成塞滿邏輯的單體,同時是路由器、商務大腦、記錄器。這會大幅增加擴展與維護的難度。Gateway 應該維持為網路/基礎設施層:驗證、授權、限流、快取、路由——可以;挑選禮物——不要。
錯誤 №7:覺得「我們很小,不需要這些」。
很多人想:「我們又沒有百萬用戶,不需要 gateway/限流」。實際上,即便只是一個客戶端程式的 bug(或一個促使模型不停呼叫工具的 prompt),也能帶來小而猛烈的災難。基礎的 rate limiting 與至少原始的 backpressure,不是奢侈品,而是上線的牙刷——一開始就要用,免得之後更痛。
GO TO FULL VERSION