1. 为什么要保护 ChatGPT App 的外部边界
在传统 Web 应用中,用户就是浏览器,它会以相对可预测的方式访问你的端点。在 ChatGPT Apps 的世界里,会出现一种新的客户端:LLM 会自行决定何时调用哪些工具。
模型可能会:
- 在一个对话中连续多次调用同一个 tool;
- 做试验:“要不再调用一次 suggest_gifts,只是把参数稍微改改?”;
- 在数百位用户上并行工作。
再加上可能的机器人、测试脚本、你自己的代码错误(例如不断触发 tool‑call 的死循环),你就几乎拿到了一份“出于善意的 DoS”完美配方。
更糟的是成本。每一个 tool‑call 可能会:
- 拉取外部付费 API(快递、支付、目录);
- 调用其他 LLM(例如 RAG 检索);
- 启动重型的后台任务。
没有限制与边界防护,一个“不走运”的客户端可能会:
- 把 gateway 后面的所有后端服务打趴下(Gift API、Commerce API 等);
- 打爆外部 API 的配额;
- 并显著点燃你的模型预算。
本讲的目标是展示,如何用 gateway/proxy + rate limiting + 队列 + backpressure 把一场潜在灾难变成一套可控系统。
Insight
ChatGPT 平台不提供任何保护你 MCP 服务器免受外部流量攻击的机制。任何互联网客户端都可以向它发请求,包括像 MCP Jam 这样的工具。
ChatGPT 能提供的只有一件事——通过反向代理(例如 NGINX)配置 allowlist,按 IP 限制入站流量。如果未配置 IP 过滤,你的 MCP 服务器将完全暴露,这是不安全的——对你和你的用户都不安全。
2. 把 Proxy/Gateway 当作后端服务与代理前的一面“盾牌”
先回顾一张图,不过这次从防护视角看。
设想一个典型方案:
flowchart LR
ChatGPT["ChatGPT / 小部件"]
--> GW["MCP 网关(认证、限流、日志)"]
GW --> GiftAPI["Gift REST API (礼物推荐)"]
GW --> CommerceAPI["Commerce REST API (结账,ACP)"]
GW --> Analytics["Analytics 服务 / REST API"]
GW --> Queue["任务队列"]
Queue --> Worker["后台工作进程"]
Gateway 处在外部世界(ChatGPT、webhooks、测试客户端)与其他一切之间。它:
- 能看到所有入站请求;
- 首先校验 token 和请求格式;
- 能丢弃明显不可能的情况(非法 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 个请求”、“每个请求消耗一个 token,token 以每秒 X 的速度补充”等。具体实现通常由库或 API Gateway 来承担。
在哪些层放置限流
限流可以放在不同层次。
在反向代理层(Nginx、Cloudflare、AWS API Gateway)适合:
- 按 IP 拦截最野的流量;
- 限制请求体大小;
- 抵御简单的 DDoS 模式。
在MCP Gateway(应用)层做更“语义化”的限流会更好:
- 按用户(来自 token 的 userId);
- 按组织(tenantId);
- 按操作类型(比如对 create_checkout_session 强限流,对 search 更宽松);
- 按来源(webhook vs tool‑call)。
还可以在特定微服务中对代价特别高的操作再加一层限流,但那是下一层细化。
如何选择限流 key
最常见的错误是按 IP 限流。对 ChatGPT 来说,这几乎没用:
- 所有请求可能都来自 OpenAI 的同一段地址;
- 不同用户会“坐在同一个 IP 后面”。
我们更关心的是:
- userId——你应用中的具体用户;
- tenantId——组织(若做 B2B 且一个对话由多位员工共用);
- API token 或 clientId(如果你有多条集成)。
在 GiftGenius 中,通常用从 MCP 调用里 ChatGPT 传来的 token 提取的 userId + tenantId 就够了。
TypeScript 中的简单 rate limiting 实现
设想我们用 Express 写了一个小型 MCP Gateway。加上一个最简单的限流:对单个用户每分钟不超过 30 次 tool‑call。
// 原始限流:每个 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 的路由里:
// 把 middleware 应用于所有 MCP tool-calls
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;
- 模型可以读懂这个错误,知道发生了什么,进而向用户做出恰当解释,而不是开始胡乱臆测。
在真实生产中,计数器当然不会放在单个进程内存里,而是用 Redis 或其他共享存储,以在集群下正常工作。但理解原理到这里已足够。
Rate limiting 与网关层的限流能阻止请求雪崩,但它解决不了另一个问题——某些操作依然很重且执行很久。这时仅靠同步 HTTP 不够了,队列与异步任务要登场。
5. 队列与异步任务:当同步已不再可行
ChatGPT 的超时问题
即便你小心地配置了限流,ChatGPT(乃至所有 HTTP 客户端)也不喜欢等待很久的响应。平台会限制 tool‑call 的执行时间;如果你非要等一个“超级推荐”算法跑完,那么:
- 用户会看到永恒的 loading;
- 平台会因超时而中断请求;
- 模型会判断“出了点问题”,并开始编造解释。
解决方案:把重操作改为异步模式。经典套路:
- Gateway 接收请求。
- 把任务放入队列。
- 立刻返回 202 Accepted,并附带 jobId。
- 独立的 worker 从队列里取任务并处理。
- 客户端(我们的小部件,或甚至 ChatGPT 通过额外的 tool)按 jobId 轮询状态,或通过 MCP 事件收到进度通知。
在 ChatGPT App 的术语里,这通常是两个工具:第一个 tool 接受请求,把任务放队列并返回 jobId;第二个 tool 让模型或小部件按这个 jobId 获取状态并取回结果。进度事件也可通过 MCP 通知进行镜像推送。
GiftGenius 的迷你队列(代码示例)
假设我们有一个较重的工具 generate_large_gift_report,可能需要几十秒。在真实 App 中,它只返回 jobId,而独立的 get_report_status tool 让模型或小部件按 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" });
});
再写一个每 200 ms 取一条任务的原始 worker:
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 是限制内部的并发活动数量。比如:对某个后端/REST 服务(Gift API、Commerce API 等)同时不超过 50 个活跃 tool‑call。
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:当下游服务顶不住时,临时返回“503:请稍后再试”。
一个最简单的例子(思路示例,并非生产代码):
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)。
- 创建订单 / 结账(create_checkout_session、confirm_order)。
- 接收 webhooks(来自支付提供商与 ACP)。
对每种场景需要明确:
- 按什么 key 计数;
- 每分钟允许多少请求;
- 超限时如何处理。
例如:
| 场景 | 限流 key | 每分钟限额 | 超限行为 |
|---|---|---|---|
| 礼物搜索 | 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 不仅关乎安全与可靠,也关系到用户体验。相比一直看 loading 或突然冒出 “Internal Server Error”,听到助理说“服务正繁忙,我们一分钟后再试”要舒服得多。
9. 迷你实践:给我们的 MCP Gateway 加上防护
为了不让内容停留在理论层面,我们来做个你可以在练习项目中实现的迷你实践。
给所有 MCP tool‑calls 加限流
添加 rateLimit 中间件(如上)并挂到 /mcp/tools/call。先用一个很简单的阈值:每个 userId 每分钟 30 次。然后玩一玩:
- 把阈值调小,观察你的 App 与模型的反应;
- 做分类型限流,把 toolName 传给 middleware,让不同 tool 有不同阈值。
基于活跃调用数的最简单 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 或 token 限流,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:把所有计数与队列只放在单实例内存中。
对教学项目还好,但生产中当你把 Gateway 横向扩容,节点上的计数会“各自为政”,限流不再全局一致,节点重启还会清空队列。真实系统里应把限流状态与队列放在共享存储(Redis、云队列等)。我们会在扩容与生产化的讲次中继续讨论。
错误 6:因为 Gateway 是“中间人”就把业务逻辑塞进去。
容易产生诱惑:“反正请求都到 Gateway,就在这里决定推荐哪些礼物吧。”最终 gateway 变成带一堆逻辑的单体,同时做路由、业务大脑和日志,极大增加扩容与维护难度。Gateway 应保持网络/基础设施职责:认证、鉴权、限流、缓存、路由——可以;礼物推荐——不应该。
错误 7:觉得“我们很小,这些与我们无关”。
很多人会想:“我们没有百万用户,不用 gateway/限流也行。”事实是,只要客户端代码(或 prompt)里有一个 bug 让模型反复触发 tool,就能给你制造一次小而精准的“末日”。基础的 rate limiting 与至少最简单的 backpressure 不是奢侈品,而是生产环境的牙刷:从一开始就要用,别等到真的疼了才想起来。
GO TO FULL VERSION