CodeGym /課程 /ChatGPT Apps /閘道與周邊防護:proxy、rate limiting、佇列與 backpressure

閘道與周邊防護:proxy、rate limiting、佇列與 backpressure

ChatGPT Apps
等級 16 , 課堂 1
開放

1. 為什麼需要保護 ChatGPT App 的周邊

在典型的 Web 應用中,使用者就是瀏覽器,會以相對可預期的方式打你的端點。在 ChatGPT Apps 的世界裡,你會遇到一種新的客戶端:LLM 會自己決定何時、要呼叫哪些工具

模型可能會:

  • 在同一段對話中連續多次呼叫同一個 tool;
  • 做各種嘗試:『那如果再呼叫一次 suggest_gifts,只是參數稍微不同呢?』;
  • 同時為數百位使用者並行工作。

再加上可能的機器人、測試腳本、你自己程式中的錯誤(例如不停觸發工具呼叫的無限迴圈),你就得到幾乎是自願性的 DoS 完美配方。

更糟的是成本。每次 tool‑call 可能會:

  • 連到外部付費 API(快遞、付款、目錄),
  • 呼叫其他 LLM(例如 RAG 搜尋),
  • 啟動非常重的背景任務。

如果沒有任何限制與邊界防護,一個「不太幸運」的客戶端就可能:

  • 把 gateway 後面的所有 backend 服務打掛(Gift APICommerce 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_giftsget_product_detailscreate_checkout_session 等工具。模型產生它們的速度可以很快,尤其當底下還有 Agents。

第二,我們的後端對外部 API 的出去請求。服務裡對第三方系統(目錄、物流、金流)可能也有自己的限額。違反它們——就會遭封鎖、罰款,或品質下降。

第三,進來的 webhooks——來自 ACP、金流提供商(如 Stripe)、快遞的通知。它們與使用者活動無關。如果我們的 endpoint 變慢或回錯,外部系統會開始重試(retries),可能對你造成「風暴」般的重覆通知。

對 GiftGenius 而言,可能是這樣:

  • 使用者與模型頻繁呼叫 suggest_giftsfind_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 的執行時間;如果你一直等某個「超級推薦」演算法跑完,結果會是:

  • 使用者只看到無止盡的轉圈;
  • 平台因逾時而中斷請求;
  • 模型認為「出了點問題」,然後開始編理由。

解法:把重操作轉成非同步模式。典型的做法:

  1. Gateway 接收請求。
  2. 把任務丟進佇列。
  3. 立即回覆 202 Accepted,並附上 jobId
  4. 獨立的 worker 從佇列取出任務並處理。
  5. 客戶端(我們的小工具,甚至 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 APICommerce 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 層應該:

  1. 按來源限制進來的 webhooks:例如「同一個供應商的某個 event_type 每分鐘不超過 10 個」。
  2. 在解析 JSON 之前驗證簽名:HMAC 簽名或類似機制能過濾掉偽造請求。
  3. 讓事件處理具備冪等性:根據 event_id 或類似欄位,避免重覆事件造成重覆建立訂單或付款。
  4. 在嚴重風暴時啟用額外 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 中,可能如何落地。

想像三個關鍵情境:

  1. 搜尋禮物suggest_giftsfind_similar_gifts)。
  2. 建立訂單 / checkoutcreate_checkout_sessionconfirm_order)。
  3. 接收 webhooks——來自金流供應商與 ACP。

對每個情境,建議明確定義:

  • 以什麼鍵來計數;
  • 每分鐘允許多少請求;
  • 超限時如何處理。

例如:

情境 限流鍵 每分鐘上限 超限時行為
搜尋禮物 userId 30 429 + 建議「縮小搜尋條件」
建立訂單 userId + tenantId 5 429 + 訊息「嘗試次數過多,請檢查訂單」
進來的 webhooks provider + eventType 10 429/503、記錄、可能降級

對 webhooks,通常更合理的是以「供應商 + 事件類型」組合來限流,並用基於 event_id 的冪等性機制去除重覆。

在程式裡會變成不同的 middleware:rateLimitSearchrateLimitCheckoutrateLimitWebhook

對於重操作,例如「產出年度大型禮物 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——回傳狀態(pendingdoneerror,以及可能的結果);
  • worker 每隔 X 毫秒呼叫 processJob

這已足夠讓你理解未來如何整合 BullMQ 或其他佇列引擎。

10. 周邊防護的常見錯誤

錯誤 №1:只用 IP 做限流。
在 ChatGPT Apps 的世界中幾乎沒用:大部分請求都來自 OpenAI 的位址,你的所有使用者都會在同一個 IP 後面。結果就是有人幫大家把配額用光,真正的肇因卻找不到。正確做法是按 userIdtenantId 或權杖限流,IP 只在反向代理層做非常粗的過濾。

錯誤 №2:超限或過載時只回「裸」的 500
若在超限或過載時,你只回 500 Internal Server Error,模型完全不知道發生什麼事,會開始瞎猜。相反地,帶有錯誤碼的結構化錯誤(rate_limit_exceededgateway_overloaded)與清楚的人類可讀描述,能讓 LLM 正確對使用者解釋,並在需要時稍後再試。

錯誤 №3:做一個無限長的佇列,卻沒有 backpressure。
有時會想:「全部丟佇列就好,之後再處理」。實務上佇列會漲到上千任務、延遲暴增、記憶體爆掉,使用者卻一直看不到結果。永遠限制佇列大小與同時進行的操作數。寧可誠實回 503429,也別把佇列變成黑洞。

錯誤 №4:只靠 rate limiting,忽視 webhooks。
很多人只保護來自 ChatGPT 的進站流量,webhooks 就任其自生自滅。當金流供應商開始重試,真正的風暴常常發生在 webhooks。對 webhook 端點要有自己的限流、簽名驗證與冪等處理;否則很容易出現重覆的訂單。

錯誤 №5:把所有計數器與佇列只放在單一實例記憶體。
在教學專案尚可,但在 production 水平擴展 Gateway 後,每個節點的計數器會「各過各的生活」,限制不再是全域一致,重啟節點也會把佇列清空。在真實系統中,限流與佇列的狀態應該放在共用儲存(Redis、雲端佇列等)。我們會在規模化與上線的課程中再談。

錯誤 №6:因為 Gateway 是中間人,就把商務邏輯塞進去。
有時會想:「反正請求都進到 Gateway,就在這裡決定要顯示哪些禮物吧」。最後 gateway 變成塞滿邏輯的單體,同時是路由器、商務大腦、記錄器。這會大幅增加擴展與維護的難度。Gateway 應該維持為網路/基礎設施層:驗證、授權、限流、快取、路由——可以;挑選禮物——不要。

錯誤 №7:覺得「我們很小,不需要這些」。
很多人想:「我們又沒有百萬用戶,不需要 gateway/限流」。實際上,即便只是一個客戶端程式的 bug(或一個促使模型不停呼叫工具的 prompt),也能帶來小而猛烈的災難。基礎的 rate limiting 與至少原始的 backpressure,不是奢侈品,而是上線的牙刷——一開始就要用,免得之後更痛。

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