机构级 LLM Mass Gateway — 多租户高并发网关,真正难点是 streaming 下的配额计数 LLM 网关的配额难点不在计算方法,在计数时机。Streaming SSE 响应长度未知,事后扣必然超卖,事前扣必然误杀——而真正能落地的,是把「软约束的速率限制」和「硬约束的预算配额」拆成两套计数器,分别用本地双层桶和中心化 Lua 预扣+流式回补来处理。 🕐 只有 2 分钟? → 直接看 第 5 节「推荐方案」 📖 想理解原理? → 从 第 2 节「为什么这很难」 开始 🎤 准备面试? → 跳到 第 9 节「面试延伸」 🔧 直接看代码? → 第 6 节「完整 Demo」 1. Problem Background 某中大型企业内部,40 多条业务线接入了同一个 LLM 出口——客服、文档摘要、代码助手、风控解读。每条业务线是一个租户,按月切预算、按模型分权限,QPS 差异极大:代码助手几百 QPS 且短交互,客服峰值 3 万 QPS 且必须 streaming 出卡片。 统一出口要同时干三件事:省成本(把 GPT-4o 流量按租户灰度切到自研/开放源代码模型)、防某条业务线突发流量打爆共享配额、适配新 prompt 版本按租户百分比灰度并能秒级回滚。 听起来像普通 API 网关?不。致命的一次事故发生在双十一级别活动当天。 那天下午 4 点,网关把全公司 LLM 的月预算,在 2 小时内烧光。 复盘的根因只有一句话: 限流配额在 streaming 下是事后计数 。客服那种 streaming 请求,一个会话可能挂 30 秒,响应 token 边产生边吐。网关在请求返回后才拿真实 usage 去扣配额——慢请求堆积期间,新请求源源不断进来,配额计数器永远滞后于真实消耗。一瞬间几千个长连接各自「以为自己还有额度」,实际月底预算已经被透支。 事后补扣?已经晚了。要么硬切断正在流的会话(使用者体验崩塌、投诉炸锅),要么认账(财务季度报表难看)。两难,反复重演。 这种「明明限流算法都对,计数时机不对」的翻车,在传统请求响应型 API 上根本不会暴露,只有在 streaming LLM 上才会让它原形毕露。 2. 为什么这很难 最反直觉的一点是:问题不在限流算法,在计数时机。Streaming SSE 模式下,响应在请求返回前长度未知。token 数要等流收尾才确定。传统「请求返回后扣减」在万级 QPS 下要么超卖(慢请求堆积期间新请求照放)要么误杀(保守预扣按 max_completion 上限算,平均请求被高估)。 拆开看,难点堆了五层: 响应长度未知 。事后计数必然超卖,事前计数必然误杀,传统限流算法在这里失效。这不是限流的问题,是计费模型的问题。 多维度限流叠加 token 计费 。租户/Key/模型/用户四维限流,每维还要按 token 扣预算。所有维度如果都压到单一 Redis 计数点,1 万个 streaming 长连接会把那个 key 打成热 key,单点 RTT 把 P99 拉到秒级。 灰度要热加载 。新 prompt 版本要按租户百分比灰度,发现回归立刻秒级回滚,不能重新上线网关。策略求值在热路径上,不能每次请求都查 DB。 Provider 容错与预扣耦合 。上游超时已经预扣了配额,fallback 到另一个 Provider 时如果不回补,一次抖动掉一份额度;如果简单全量回补,原 Provider 已经吐了一半 token,又会双扣。 热路径上的鉴权 。PII 脱敏正则、JWKS 校验、模型 ACL 都在热路径。一个 8KB 的长 prompt 喂进未编译的正则,正则回溯能把整个 worker 拖死,直接变全局限速。 这其实暗示了一个很多人忽略的点: 这些难点本质上是两类约束在打架 。「速率限制」(RPM/TPM)是软约束,超一点不会破产,可以最终一致;「预算配额」(日额度/月预算)是硬约束+计费,不可最终一致,超了就是真金白银。把它们当一套计数器去设计,就是所有翻车的起点。把它们拆成两套,问题就清晰了。 3. 方案解读 如果你跳过了前面的内容:我们在解决 streaming LLM 网关的「配额既不超卖又不误杀」+「万级 QPS 不被 Redis 打死」问题,目前还没看任何方案。接下来从最简单的开始。 3.1 方案一:开源 LLM Proxy + 单 Redis 计数(V0,最简) 最省事的起点。LiteLLM / LiteGateway 起一个进程,后面挂一个 Redis 做计数,开箱即用日级上线。 核心思想:所有请求经 proxy 转发,用 Redis 记 RPM/TPM/日额度,请求结束后按真实 usage 扣预算。 架构: flowchart LR Client --> Proxy[LiteLLM Proxy] Proxy --> Redis[(Redis 计数)] Proxy --> Provider[Provider Pool] Provider -->|usage 回调| Proxy Proxy -->|事后扣减| Redis 优点: 上手最快,< 1K QPS 或 streaming 占比低时够用。 致命缺陷: 预估预扣简陋(多用保守估算或事后扣减),无两层令牌桶(每请求访问 Redis),灰度靠重启或 DB 轮询(分钟级)。万级 streaming 下必挂——正是文章开头那次事故的形态。 适用: 团队小、QPS < 1K、streaming 占比低、无硬预算约束。 不适用: 万级 streaming 灰度需求。 我会把它作为对照基线,而不是一个真要上的方案。 3.2 方案二:API 网关插件化 + 中心化 Redis(V1) 很多人会想:Kong/APISIX 不是有现成限流插件吗?< 3K QPS streaming 占比低确实顺手,多维限流用 plugin 组合。 但这里有两个坑会咬人。第一,同步 Redis 计数在 streaming + 万级 QPS 下成为热 key。第二,APISIX 单 worker 内令牌桶状态不跨进程共享,worker 间限流精度漂移,N 个 worker 加起来可能超发 N 倍。 更麻烦的是:token-based 的预估预扣、流式回补、Provider fallback 分摊,都没有现成插件,得自己写插件重写。重写工作量很快追平自研,又被架构体系拖累。 适用: 已有 Kong/APISIX 体系、QPS 较低、限流维度少。 不适用: 万级 streaming + token 计费——你要写的插件量,已经够你自研一个信息面了。 3.3 方案三:事件驱动网关 + CDC 对账(V2) 换一种思路:请求落 Kafka,下游消费者做完事后对账配额。数据面极轻(只转发+落日志),日志/对账完备,灰度还能走 Kafka 重放。 听起来很干净。但 eventual consistency 在 LLM 场景约等于「超卖」。单次以百 token 计费,延迟一秒计数偏差就能取得进展预算。事后拒绝更是灾难——流式到一半被切断,客服用户的卡片断在半句话,投诉炸锅。 适用: 内部低敏感、无硬预算、可容忍事后兜底的场景。 不适用: 有计费硬约束的正式网关。 3.4 方案四:控制面/数据面分离 + 两阶段预扣计数(推荐) 到这里,思路成熟了。核心是三个动作: 拆两套计数器 。RPM/TPM 软约束→本地双层令牌桶,热路径零 Redis;日额度预算硬约束→每请求一次 Redis Lua 原子预扣,已是最小 Redis 访问。 预估预扣 + 流式回补 (两套共用这套手法)。按 prompt token + max_completion 预估,预扣拿 prededuct_id ;流式累计真实 token;流结束按真实值幂等回补差额。 策略带 version_id 热下发 。控制面 pub/sub 推策略,数据面本地缓存,每请求校验期望版本号,不匹配触发全量拉取+兜底。 完整架构见第 6 节图。这里先放流式生命周期,因为它是整个方案的心脏: sequenceDiagram participant Client participant Gateway as Gateway Worker participant Local as LocalBucket(RPM/TPM) participant Redis participant Provider participant Kafka Client->>Gateway: POST /chat (streaming) Gateway->>Local: check RPM/TPM (本地内存, 零 Redis) Local-->>Gateway: ok Gateway->>Redis: Lua 预估预扣 (budget, 返回 prededuct_id) Redis-->>Gateway: prededuct_id Gateway->>Provider: open stream (记 provider_call_id) loop SSE chunks Provider-->>Gateway: delta usage Gateway-->>Client: SSE chunk end Gateway->>Redis: Lua 按 prededuct_id 幂等回补净差额 Gateway->>Kafka: 请求事件 (prededuct_id + usage) Note over Gateway,Provider: 超时路径: Router fallback
复用逻辑请求 id, 记新 provider_call_id
回补按各 call 真实 usage 分摊 Note over Redis: Redis 不可达: 预算切乐观预扣本地 ring buffer
RPM/TPM 进限免模式 30s Note over Redis: 预扣失败: 快速拒绝 429/402 优点: 同时解决超卖(两阶段预扣)与 Redis 热点(RPM/TPM 本地化);预算维度保留中心化原子预扣不超卖;灰度秒级回滚不重新发布;热路径 RPM/TPM 零外部 IO,P99 可压到 5ms(不含上游);Redis 不可达时两套降级分别设计有据;对账体系让本地桶超发可控。 缺点: 工程复杂度高,需要一套对账方(Flink/批 job 按租户写回)。本地桶会超发,靠控制面按租户硬上限二次兜底。这是主动接受的代价。 适用: 1K-10万 QPS、streaming 占比高、有硬预算的正式企业内部网关。 不适用: QPS < 500、租户数 < 5、无预算约束的小团队。 3.5 方案五:Sidecar 限流 + 中心化控制面(V3,你大概率不需要) 再往上推就是 Service Mesh 风格——每节点一个 Envoy sidecar 做限流,策略走 CRD。隔离最干净,灰度回滚走 K8s 声明式。 但是。LLM 特有的 token 配额、PII 脱敏、Provider fallback 塞进 sidecar 后,Envoy WASM 的性能和可维护性都成问题;更关键的是 sidecar 间本地令牌桶相互独立,配额误差被放大的倍数大致等于实例数。10 个 sidecar 各自按总量限,加起来可能超发 10 倍。 说实话,这个方案我只在两个前提同时成立时才会推:已有成熟 Service Mesh 体系,且团队对 WASM 熟练。 否则 > 10万 QPS 也别硬上,sidecar 复杂度会反过来吃掉你。 大部分团队到这里就该停了。 4. Tradeoff 分析 方案 配额一致性 streaming 超卖风险 灰度回滚速度 热路径延迟 复杂度 推荐 QPS 推荐指数 1 LiteLLM+单Redis 最终一致 高(事后/保守计) 分钟级(重启) <10ms 低 <1K ⭐⭐ 2 APISIX+中心Redis 最终一致 中(精度漂移) 秒-分钟 不稳定 低-中 <3K ⭐⭐ 3 Kafka+CDC 对账 最终一致 高(延迟超卖) 秒级(重放) 低(数据面轻) 中 <1K 弱预算 ⭐ 4 控制面+两阶段预扣 预算强一致/速率最终一致 低(预估+回补) 秒级(version_id) <5ms 高 1K-10万 ⭐⭐⭐⭐⭐ 5 Sidecar Mesh 低(误差随实例放大) 中 秒级(CRD) 极低 极高 >10万、租户>50 ⭐⭐⭐ 最关键的维度是**「约束硬度」**,这是新加进来的、也是 LLM 网关和普通 API 网关最大的区别。速率限制(RPM/TPM)软,可最终一致,本地化去 Redis 化是正解;预算配额硬+计费,不可最终一致,必须中心化原子预扣。方案长期被忽视的失误,就是把这两套当成一套——方案 1、2、3 都各有偏废。 意外的结果:方案 3 在纸面上数据面最轻、最优雅,实际却是超卖风险最高的一个——因为它的设计哲学是最终一致,而 LLM 计费对延迟一秒都不容忍。 scale 敏感性:100 QPS 下方案 1 最快上线该优先;1万 QPS streaming 下方案 2 的 Redis 热点立刻暴露、方案 3 的超卖无法接受,只剩 4 与 5 之争;10万 QPS 以上且租户数 > 50 时方案 5 的隔离收益才开始压过 sidecar 复杂度。推荐方案 4 的甜蜜区是「1K-10万 QPS、租户数 < 100」——这覆盖了 90% 企业内部网关的真实落点。 团队敏感度:3 人小团队别碰方案 4 的自研,直接用方案 1 或云厂商 gateway-as-a-service。方案 4 的对账体系需要一个专职 SRE 通道,50 人以上系统团队才划算。 vendor lock-in 要说一句:Redis 并非必须。任何提供原子条件递减(Postgres UPDATE ... RETURNING )、PubSub(etcd watch / Postgres LISTEN/NOTIFY )、低延迟线性读的协调存储都能替换。选 Redis 只因其在原子 Lua 与 PubSub 上的工程成熟度。 5. 推荐方案 推荐方案 4:控制面/数据面分离 + 两阶段预扣计数。 推荐它不是因为它最简单,而是因为它把两套约束放在了各自正确的位置: 同时解决两个问题 ——streaming 超卖用「预估预扣+流式回补」;Redis 热点靠 RPM/TPM 移到本地双层桶。两个问题不共用一个解法,但共用一个手法(预扣+回补)。 预算维度保留中心化原子预扣 ,硬约束+计费不可最终一致。这是不能让步的。 灰度策略带 version_id 热下发 ,秒级回滚不重新发布数据面。每请求校验期望版本号,不匹配触发全量拉取+兜底。 热路径 RPM/TPM 零外部 IO ,P99 可压到 5ms(不含上游延迟)。 Redis 不可达时分两种降级 :速率走限免、预算走乐观预扣+补登。不是一套降级套两套,是分别设计。 对账体系让超发可控 。本地桶会超发,由控制面按租户硬上限二次兜底。 为什么不是别的——方案 1 在万级 streaming 下预扣简陋、无双层桶、灰度靠重启,根本不进门槛;方案 3 那套最终一致在 LLM 计费里就等于超卖,事后切断流式输出会让客服卡片断在半句话。剩下 Kong/APISIX 和 Sidecar 我都给不了:前者要写的 token 预扣+流式回补+fallback 分摊插件量已经够自研一个数据面,还白搭一个 APISIX 单 worker 状态不共享的限流精度漂移;后者只在已有成熟 Mesh、团队 WASM 熟练、配额误差容忍度高的前提下才划算,否则 sidecar 复杂度反过来吃掉你。 什么时候不用方案 4: 租户数 < 5、QPS < 500、streaming 占比低、无预算硬约束的小团队——直接 LiteLLM Proxy 或云厂商 gateway-as-a-service。另一头,已有成熟 Service Mesh 且团队 WASM 熟练、配额误差容忍度高时,方案 5 在 > 10万 QPS 下值得评估。 一句话锚点: 速率本地化,预算中心化,预扣要激进,回补要快。 6. 完整 Demo 数据面用 Fastify(Express 同理),控制面独立 Node 进程。中间件 Redis(ioredis)/Kafka(kafkajs)/Postgres。为可在一台机器跑通,下面给真实 ioredis/kafkajs 连接、一份 docker-compose.yml ,以及关键文件。 llm-gateway/ ├── docker-compose.yml ├── package.json ├── tsconfig.json ├── src/ │ ├── gateway.ts # 数据面入口 │ ├── auth.ts # 鉴权 + PII 脱敏 │ ├── canary.ts # 灰度 version_id 热加载 │ ├── token-bucket.ts # 本地双层桶 RPM/TPM │ ├── quota-prededuct.ts # 预估预扣 + Lua │ ├── stream-reconcile.ts # 流式回补幂等 │ ├── router.ts # Provider 路由 + fallback │ └── control-plane.ts # 策略热下发 pub/sub └── scripts/ └── run.sh docker-compose.yml (基础设施): version: '3.8' services: redis: image: redis:7-alpine ports: [ "6379:6379" ] kafka: image: confluentinc/cp-kafka:7.5.0 environment: KAFKA_PROCESS_ROLES: "broker,controller" KAFKA_NODE_ID: "1" KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://localhost:9092" KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT" KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk" ports: [ "9092:9092" ] postgres: image: postgres:16-alpine environment: POSTGRES_PASSWORD: demo POSTGRES_DB: gateway ports: [ "5432:5432" ] package.json 关键 scripts 与依赖 : { "name" : "llm-gateway" , "scripts" : { "dev" : "tsx watch src/gateway.ts" , "cp" : "tsx watch src/control-plane.ts" } , "dependencies" : { "fastify" : "^4.2" , "ioredis" : "^5.3" , "kafkajs" : "^2.2" , "dotenv" : "^16.0" } , "devDependencies" : { "tsx" : "^4.0" , "typescript" : "^5.2" , "@types/node" : "^20.0" } } src/token-bucket.ts — 本地双层桶,热路径零 Redis: // 本地双层令牌桶: 每 200ms 从中心源领取额度, 热路径纯内存扣减. // 软约束: 本地桶超发由控制面按租户硬上限二次兜底. import Redis from 'ioredis' ; export class LocalBucket { private rpmTokens = 0 ; // 本地 RPM 余量 private tpmTokens = 0 ; // 本地 TPM 余量 private syncer : NodeJS . Timeout ; private circuitOpen = false ; // Redis 不可达 -> 限免模式 constructor ( private redis: Redis, private tenantId: string , private modelId: string , private rpmCap: number , private tpmCap: number , ) { this . syncer = setInterval ( () => this . syncFromCentral (), 200 ); } // 热路径: 纯内存, 零 Redis. 返回是否放行. tryConsume ( rpmCost : number , tpmCost : number ): boolean { if ( this . circuitOpen ) return true ; // 限免: 放行靠硬上限兜底 if ( this . rpmTokens < rpmCost || this . tpmTokens < tpmCost) return false ; this . rpmTokens -= rpmCost; this . tpmTokens -= tpmCost; return true ; } // 流式真实 token 回补到本地 TPM 桶 (差为正则归还, 为负则预扣不足) repay ( tpmDelta : number ): void { this . tpmTokens = Math . min ( this . tpmTokens + tpmDelta, this . tpmCap ); } // 周期批量领取: 一次 RedisRTT 拉一批额度, 不是每请求一次. private async syncFromCentral ( ) { const key = `rl: ${ this .tenantId} : ${ this .modelId} ` ; try { // INCR 后查 ttl 的粗暴版; 生产用 Lua 一次性领取并设过期. const got = await this . redis . hincrby (key, 'rpm' , - this . rpmCap / 5 ); await this . redis . hincrby (key, 'tpm' , - this . tpmCap / 5 ); this . rpmTokens = Math . min ( this . rpmTokens + this . rpmCap / 5 , this . rpmCap ); this . tpmTokens = Math . min ( this . tpmTokens + this . tpmCap / 5 , this . tpmCap ); this . circuitOpen = false ; if (got < 0 ) { // 中心已耗尽: 本地超发但记一次告警, 由硬上限二次兜底. metrics. inc ( 'gateway_local_bucket_exhaust_count' ); } } catch { this . circuitOpen = true ; // 限免 30s, 禁新灰度 metrics. inc ( 'gateway_local_buffer_redis_unreachable' ); } } stop ( ) { clearInterval ( this . syncer ); } } const metrics = { inc : ( k: string ) => { /* 接 Prometheus */ void k; }, }; src/quota-prededuct.ts — 预估预扣, Lua 原子, Redis 不可达切乐观预扣: import Redis from 'ioredis' ; // 预扣: 按 prompt token + max_completion 上限预扣日预算, 拿 prededuct_id. // 预算硬约束 + 计费, 不可最终一致, 必须全局持久化. 已是最小 Redis 访问. const PREDeduct _LUA = ` local budget = KEYS[1] local cost = tonumber(ARGV[1]) local remain = tonumber(redis.call('GET', budget) or '0') if remain < cost then return {-1, 0} end local id = redis.call('INCR', budget .. ':seq') redis.call('DECRBY', budget, cost) return {id, remain - cost} ` ; export interface PredeductResult { predeductId : string ; ok : boolean ; fallbackBuffered?: boolean ; // Redis 不可达, 走乐观预扣 } export class PredeductClient { private ring : string [] = []; // 不可达期间的乐观预扣, 恢复后补登 constructor ( private redis: Redis, private pgPool: any ) {} async prededuct ( tenantId : string , modelId : string , estTokens : number , ): Promise < PredeductResult > { const key = `budget: ${tenantId} : ${modelId} :daily` ; try { const [seq] = await this . redis . eval ( PREDeduct _LUA, 1 , key, estTokens, ) as number []; if (seq < 0 ) return { predeductId : '' , ok : false }; // 预算不足 -> 402 const id = `pd: ${tenantId} : ${seq} ` ; return { predeductId : id, ok : true }; } catch { // Redis 不可达: 乐观预扣写本地 ring buffer, 返回 prededuct_id 继续. const id = `pd:local: ${ Date .now()} : ${ this .ring.length} ` ; this . ring . push ( JSON . stringify ({ tenantId, modelId, estTokens, id })); if ( this . ring . length > 500 ) metrics. inc ( 'gateway_prededuct_fallback_local_buffer_lag' , this . ring . length ); return { predeductId : id, ok : true , fallbackBuffered : true }; } } // 恢复后补登 (后台 job). async flushBuffer ( ) { while ( this . ring . length ) { const item = this . ring [ 0 ]; await this . redis . eval ( PREDeduct _LUA, 1 , item, 0 ). catch ( () => null ); this . ring . shift (); } } } const metrics = { inc : ( k: string , _v = 1 ) => { void k; } }; src/stream-reconcile.ts — 流式回补, 按 prededuct_id 幂等, fallback 分摊: import Redis from 'ioredis' ; import { Kafka } from 'kafkajs' ; // 流结束按 (逻辑请求 id, 每个 provider_call_id) 聚合真实 usage, // 按 prededuct_id 幂等回补净差额. fallback 时各 Provider 部分成功 // 按各自真实 usage 分摊, 避免双扣. export class StreamReconciler { constructor ( private redis: Redis, private kafka: Kafka ) { this . producer = this . kafka . producer (); this . producer . connect (); } private producer : any ; private seen = new Set < string >(); // 幂等表 (生产用 SETNX 于 Redis) private overestimate : Record < string , number > = {}; // 滑动比率收敛 async reconcile ( args: { requestId: string ; // 逻辑请求 id (跨 fallback 复用) predeductId: string ; usageByCall: { providerCallId: string ; usage: number }[]; modelId: string ; tenantId: string ; } ) { const trueUsage = args. usageByCall . reduce ( ( s, c ) => s + c. usage , 0 ); const idempotencyKey = `recon: ${args.predeductId} ` ; if ( this . seen . has (idempotencyKey)) return ; // 幂等: 已回补 this . seen . add (idempotencyKey); // 回补净差额 = 预扣 - 真实. 正值归还预算, 负值说明预扣不足(补扣). await this . redis . eval ( `redis.call('INCRBY', KEYS[1], ARGV[1]); redis.call('SET', KEYS[2], '1', 'EX', 86400)` , 2 , `budget: ${args.tenantId} : ${args.modelId} :daily` , idempotencyKey, Math . round ( this . estimateDelta (args. predeductId , trueUsage)), ); // 滑动比率收敛: 维护真实/预扣, 稳态压到 P95*1.2. this . updateOverestimate (args. predeductId , trueUsage); // 各 provider_call_id 分摊明细落 Kafka, 下游 Flink 对账. await this . producer . send ({ topic : 'llm-requests' , messages : [{ value : JSON . stringify ({ requestId : args. requestId , predeductId : args. predeductId , usageByCall : args. usageByCall , trueUsage, })}], }); metrics. observe ( 'gateway_reconcile_drift' , this . drift (args. tenantId )); } private estimateDelta ( _id: string , trueUsage: number ) { return -trueUsage; // 简化: 真实值回补, 实现里减掉原始预扣额. } private updateOverestimate ( _id: string , _u: number ) {} private drift ( _t: string ) { return 0 ; } } const metrics = { observe : ( _k: string , _v: number ) => {} }; src/canary.ts — 灰度 + version_id 校验: // 策略带单调 version_id, pub/sub 推送; 每请求校验期望版本号, // 不匹配触发全量拉取并走兜底. 热路径零 IO. export interface CanaryRule { versionId : number ; // 单调递增 weights : { provider : string ; promptVer : number ; percent : number }[]; } export class CanaryEvaluator { private cache : CanaryRule | null = null ; private localVersion = 0 ; constructor ( watchPub: (cb: (r: CanaryRule) => void ) => void ) { watchPub ( ( r ) => { if (r. versionId > this . localVersion ) { this . cache = r; this . localVersion = r. versionId ; metrics. set ( 'canary_version_drift' , 0 ); } }); } // 热路径: 纯本地内存求值. 仅当期望 version_id 不匹配时才走兜底. evaluate ( tenantId: string , userId: string , expectVer: number ) { if (expectVer && expectVer !== this . localVersion ) { metrics. inc ( 'canary_version_drift' ); return { provider : 'fallback' , promptVer : 0 , percent : 100 }; // 兜底 } const rule = this . cache !; const h = hash ( ` ${tenantId} : ${userId} ` ); let acc = 0 ; for ( const w of rule. weights ) { acc += w. percent ; if (h % 100 < w. percent ) return w; } return rule. weights [rule. weights . length - 1 ]; } } function hash ( s: string ): number { let h = 0 ; for ( const c of s) h = (h * 31 + c. charCodeAt ( 0 )) | 0 ; return Math . abs (h); } const metrics = { inc : ( _k: string ) => {}, set : ( _k: string , _v: number ) => {} }; src/auth.ts — 鉴权 + PII 脱敏,大输入走 worker_threads: // 鉴权: API Key -> tenant/user/role, 模型 ACL, PII 脱敏. // PII 脱敏正则预编译池复用; 大输入(>4KB)走 worker_threads 异步脱敏, // 否则一次 8KB 全文回溯就能把整个 worker 拖死 (见坑 5). import Redis from 'ioredis' ; import { Worker } from 'worker_threads' ; import { resolve } from 'path' ; export const PII_REDACT_THRES = 4 * 1024 ; // 4KB 以上走异步脱敏 // 预编译池: 同一个正则只 new 一次, 不在热路径上重复编译. const PII_PATTERNS : { re : RegExp ; replace : string }[] = [ { re : /\b1[3-9]\d{9}\b/g , replace : '[手机号]' }, // 手机号 { re : /\b\d{15}(\d{2}[\dXx])?\b/g , replace : '[身份证]' }, // 身份证 { re : /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g , replace : '[邮箱]' }, // 邮箱 ]; export interface AuthCtx { ok : boolean ; body : string ; userId : string ; expectVer : number ; } export class Auth { // JWKS 缓存 + API Key -> tenant/user 解析, 生产里查 Redis/DB, demo 用内存表. private keyCache = new Map < string , { tenantId : string ; userId : string ; role : string }>(); constructor ( private redis: Redis ) {} parse ( apiKey : string | undefined , tenantId : string | undefined , body : any ): AuthCtx { if (!apiKey || !tenantId) return { ok : false , body : '' , userId : '' , expectVer : 0 }; const rec = this . keyCache . get (apiKey) ?? { tenantId, userId : `u- ${apiKey.slice(- 4 )} ` , role : 'user' }; this . keyCache . set (apiKey, rec); // 模型 ACL: 实际按 rec.role + 请求 model 校验, demo 放行. const text = typeof body === 'string' ? body : JSON . stringify (body); return { ok : true , body : text, userId : rec. userId , expectVer : body?. expect_ver ?? 0 }; } // 同步脱敏: 小输入在主线程做, 正则已预编译. redact ( text : string ): string { let out = text; for ( const p of PII_PATTERNS ) out = out. replace (p. re , p. replace ); return out; } // 异步脱敏: 大输入丢 worker_threads, 避免阻塞主线程事件循环. redactAsync ( text : string ): Promise < string > { return new Promise ( ( resolveFn, reject ) => { const w = new Worker ( resolve (__dirname, 'redact-worker.js' ), { workerData : text }); w. on ( 'message' , ( out: string ) => { resolveFn (out); w. terminate (); }); w. on ( 'error' , reject); }); } } // redact-worker.ts (同目录, 独立文件): // import { parentPort, workerData } from 'worker_threads'; // const PATTERNS = [ /* 同上 PII_PATTERNS */ ]; // let out: string = workerData; // for (const p of PATTERNS) out = out.replace(p.re, p.replace); // parentPort!.postMessage(out); src/router.ts — Provider 路由 + fallback 复用逻辑请求 id: // fallback 复用同一逻辑 request_id, 记新 provider_call_id. // 回补时按各 provider_call_id 真实 usage 分摊. export interface ProviderCall { providerCallId : string ; usage : number ; } export class Router { async stream ( args : { requestId : string ; provider : string ; prompt : string }, onChunk : ( delta: number ) => void ) : Promise <{ calls : ProviderCall []; timedOut : boolean }> { const calls : ProviderCall [] = []; const primaryId = ` ${args.requestId} #p1` ; try { const usage = await this . callProvider (args. provider , args. prompt , onChunk, 2000 ); calls. push ({ providerCallId : primaryId, usage }); return { calls, timedOut : false }; } catch { // 上游超时/降级: 切 fallback Provider, 复用逻辑 request_id, 新 call_id. const fbId = ` ${args.requestId} #p2` ; const usage = await this . callProvider ( 'self-hosted' , args. prompt , onChunk, 5000 ); calls. push ({ providerCallId : fbId, usage }); return { calls, timedOut : true }; } } private async callProvider ( _p : string , _prompt : string , _onChunk : ( d: number ) => void , _to : number ): Promise < number > { return Math . floor ( Math . random () * 800 + 200 ); // 真实累计 usage } } src/gateway.ts — 数据面入口,串起整条链路: import Fastify from 'fastify' ; import Redis from 'ioredis' ; import { Kafka } from 'kafkajs' ; import { LocalBucket } from './token-bucket.js' ; import { PredeductClient } from './quota-prededuct.js' ; import { StreamReconciler } from './stream-reconcile.js' ; import { Router } from './router.js' ; import { CanaryEvaluator } from './canary.js' ; import { Auth , PII_REDACT_THRES } from './auth.js' ; // 流式生命周期: 鉴权 -> canary -> 本地桶扣 RPM/TPM -> 预扣预算拿 prededuct_id // -> Provider 路由 streaming 记 provider_call_id -> 累计真实 token // -> 流结束按 prededuct_id 幂等回补 -> 落 Kafka. const redis = new Redis (process. env . REDIS_URL || 'redis://localhost:6379' ); const auth = new Auth (redis); const canary = new CanaryEvaluator ( ( cb ) => redis. subscribe ( 'canary' , ( _e, msg ) => cb ( JSON . parse (msg)))); const prededuct = new PredeductClient (redis, null ); const reconciler = new StreamReconciler (redis, new Kafka ({ brokers : [ 'localhost:9092' ] })); const router = new Router (); const buckets = new Map < string , LocalBucket >(); const app = Fastify (); app. post ( '/chat' , async (req, reply) => { const apiKey = req. headers [ 'x-api-key' ] as string ; const tenantId = req. headers [ 'x-tenant-id' ] as string ; // 1) 鉴权 + PII 脱敏 (>4KB 走 worker_threads) const ctx = auth. parse (apiKey, tenantId, req. body ); if (!ctx. ok ) return reply. code ( 401 ). send ({ error : 'auth' }); const body = ctx. body . length > PII_REDACT_THRES ? await auth. redactAsync (ctx. body ) : auth. redact (ctx. body ); // 2) canary 求版本 (本地无 IO, 校验期望 version_id) const pick = canary.evaluate(tenantId, ctx. userId , ctx. expectVer ); const modelId = pick. provider ; // 3) 预估 token + 本地桶扣 RPM/TPM (零 Redis) const est = estimateTokens (body) + (req. body . max_completion || 4096 ); const bKey = ` ${tenantId} : ${modelId} ` ; let bucket = buckets. get (bKey); if (!bucket) { bucket = new LocalBucket (redis, tenantId, modelId, 60000 , 200000 ); buckets. set (bKey, bucket); } if (!bucket. tryConsume ( 1 , est)) return reply. code ( 429 ). send ({ error : 'rate' }); // 4) 预扣日预算 (Redis Lua), 拿 prededuct_id const pd = await prededuct. prededuct (tenantId, modelId, est); if (!pd. ok ) return reply. code ( 402 ). send ({ error : 'budget' }); // 5) streaming 路由, 边吐边累计真实 token; 复用逻辑 request_id reply. raw . setHeader ( 'content-type' , 'text/event-stream' ); const requestId = ` ${tenantId} : ${ Date .now()} ` ; let trueTokens = 0 ; const { calls } = await router. stream ( { requestId, provider : modelId, prompt : body }, ( delta ) => { trueTokens += delta; reply. raw . write ( `data: ${delta} \n\n` ); }, ); reply. raw . end (); // 6) 流结束: 按 prededuct_id 幂等回补差额, 各 call 分摊 await reconciler. reconcile ({ requestId, predeductId : pd. predeductId , modelId, tenantId, usageByCall : calls. map ( c => ({ providerCallId : c. providerCallId , usage : c. usage })), }); bucket. repay (est - trueTokens); }); app. listen ( 3000 , () => console . log ( 'gateway on :3000' )); function estimateTokens ( s: string ) { return Math . ceil (s. length / 4 ); } // Naive 估算 如何运行: docker compose up -d pnpm install pnpm cp & # 控制面 pnpm dev # 数据面 curl -X POST http://localhost:3000/chat \ -H "X-Tenant-Id: tnt-1" -H "X-Api-Key: sk-xxx" \ -H "Content-Type: application/json" \ -d '{"max_completion":256,"messages":[{"role":"user","content":"hi, PII: 13800001111"}]}' # 预期: SSE 流输出 token chunks, 结束后台回补预算并落 Kafka. # 把 Redis 停掉再压一次: 速率进限免、预算走 ring buffer, 告警出现. 顺嘴说一句。上面 reconcile 里我用了一个进程内 Set 做幂等,这只是为了 demo 可读。生产里进程会重启、会多副本,幂等表必须落到 Redis 的 SETNX (像代码注释写的那样)。我见过有人照着 demo 抄进生产,节点重启后把同一笔回补做了两遍——多扣一份。 7. 生产实践 7.1 监控 指标 含义 阈值 告警级 gateway_prededuct_overestimate_ratio 预扣/真实滑动比率 每租户收敛到 1.05-1.2 P1 偏出区间 gateway_local_bucket_exhaust_count 本地桶耗尽跨 Redis 取额度次数 >单实例 50/min P1 gateway_reconcile_drift 预扣-回补总额(应近 0) 偏离 >0.5% 触发对账 P1 gateway_provider_fallback_rate 按 Provider/模型 fallback 率 >5% 持续 5min P1 sse_active_connections 活跃 SSE 长连接 接近 worker max 10万即扩容 P1 canary_version_drift 版本不一致 worker 数 >0 持续 1min P1 gateway_prededuct_fallback_local_buffer_lag 本地乐观预扣未补登条数 >单实例 500 P1 7.2 告警 P0 :某租户日额度 4 小时内耗尽且 critical;Provider fallback 率 >15% 持续 5min;Redis 主节点 CPU >80% 持续 2min。 P1 :reconcile_drift >0.5% 持续 10min;灰度版本不一致 worker 数 >0 持续 1min。 7.3 限流与背压 本地桶耗尽即 429。SSE 活跃连接接近 worker 上限拒绝新连接并触发 HPA 扩容。控制面禁止在限免窗口下发新灰度。 7.4 重试与幂等 预扣拿 prededuct_id 作为幂等键;回补用 SETNX recon:{prededuct_id} 防双重回补;fallback 用 provider_call_id 区分物理调用,前端重试须携带原 prededuct_id 避免重复预扣。 7.5 降级与兜底 Provider 集团故障 :自动降级到自研/开源兜底模型,经 SSE 推一条 system_message 告知用户。 Redis 不可达(速率) :本地桶限免 30s,按上次同步容量继续;期间禁新灰度。 Redis 不可达(预算) :乐观预扣本地 ring buffer(容量 = 峰值 30s),返回 prededuct_id 继续,恢复后补登,超发由控制面租户硬上限二次兜底。 控制面不可达 :沿用最后策略快照继续服务。 PII 脱敏 worker pool 打满 :大输入先放行后异步审计(权衡可审计 vs 实时脱敏的取舍)。 7.6 容量与混沌 扩容:worker 无状态水平扩,本地桶容量 = 租户容量 / 同步周期 × 缓冲系数 。 对账:Kafka 下游 Flink 按 prededuct_id 聚合,与 Provider 账单联表,偏差按租户写回 Postgres。 Chaos:周期性杀 Redis 主节点 90s 验证乐观预扣补登;灰度版本故意只发半数 worker 看版本不一致告警;单 worker 本地桶耗尽到跨 Redis 取额度回归。 说句实话,有一件事我没法给你一个精确的数:本地桶的「超发率随实例数线性放大」这个比率,我自己还没能在生产里精确测出来。理论上它约等于 worker 数 × 同步周期内峰值流入 / 粗户硬上限 ,但真实分布取决于每条业务线的流量抖动模式,我们线上只能靠对账兜底反推。同理, max_completion 预估在不同 Provider 上的偏差分布也没有公开数据可查,都得自己在灰度里采集。这部分我没有完美答案,如果你有更稳的估算方法,我很想知道。 8. 常见坑 ❌ 坑 1:明明还有额度,客服线频繁被拒 When 客服业务线高峰期。 Symptoms 配额面板显示还剩很多,却被拒(429/402)。 Root cause 预扣保守按 max_completion=4096 上限预估,实际平均 600。长期高估造成假性超额。 Temp fix 把 max_completion 上限调到 1024,误杀减少但偶发长回复被截断。 Permanent fix 维护真实/预扣滑动比率,稳态收敛到 P95 × 1.2 ,高估自动回落。 Lesson 两阶段预扣的灵魂不在预扣本身,在「回补要快、超估要收敛」。 ❌ 坑 2:灰度劣化,回滚延迟 40 秒,3% 流量仍打新版本 When 按租户灰度切自研模型发现效果劣化,触发回滚。 Symptoms 一批 worker 的本地缓存仍是旧版本号,pub/sub 推送没到达。 Root cause 某 worker GC 长停顿错过 pub event,本地版本没更新;没有版本号校验机制,请求继续打新版本。 Temp fix 手动重启漂移的 worker。 Permanent fix 策略带单调 version_id ,每请求校验期望版本号,不匹配触发全量拉取并走兜底。 Lesson 热加载下发任何策略都必须带版本号 + 不匹配兜底,别只依赖 pub/sub 一次投递。 ❌ 坑 3:活动 RPS 1.5 万,Redis CPU 100%,网关 P99 飙到 8 秒 When 大促活动峰值。 Symptoms 整体 P99 劣化到秒级,Redis 主节点满载。 Root cause RPM/TPM 限流用 Lua 每请求访问 Redis,15 个 worker 共用一个桶 key,Redis 成热 key。 Temp fix 临时把指令 HINCRBY 换异步、压并发但仍卡 Redis。 Permanent fix RPM/TPM 移到两层令牌桶本地化,每 200ms 批量领取,热路径零 Redis;预算配额仍每请求 Lua(硬约束不可本地化)。 Lesson 万级 QPS 下任何每请求访问 Redis 的限流都会把 Redis 打成瓶颈——速率本地化,预算保留中心化。 ❌ 坑 4:季度末对账累计扣费比实际花费高 3%(多扣) When 季度对账。 Symptoms 财务发现 usd 金额比 Provider 账单高约 3%,租户投诉。 Root cause Provider 超时触发 fallback,原 Provider 已部分计费但回补按全量算,原 Provider 已出的 token 没被回补,等于多扣了那部分。 Temp fix 事后人工冲账。 Permanent fix 预扣关联唯一逻辑请求 id;fallback 记新 provider_call_id ;双 Provider 部分成功按各自真实 usage 分摊回补。 Lesson 流式回补的幂等 key 必须区分「逻辑请求」与「物理 Provider 调用」。 ❌ 坑 5:某租户接入 PII 脱敏 8KB prompt,P99 600ms,全租户被限速 When 新租户接入带复杂脱敏正则。 Symptoms 该租户所有请求 P99 暴涨,worker 吞吐下降。 Root cause 脱敏正则没编译、没复用,同步在热路径对 8KB 全文匹配,正则回溯把 worker 拖慢。 Temp fix 临时关闭该租户 PII 规则,安全升级。 Permanent fix 小输入同步脱敏,大输入(>4KB)走 worker_threads 异步或下推 Provider 侧;正则预编译池复用。 Lesson 热路径上任何与输入规模线性相关的逻辑都必须设阈值 + 异步化。 9. 面试延伸 Q:streaming 模式下怎么保证配额既不超卖又不误杀? A:两阶段计数。按 prompt token(Naive tokenizer 估算)+ max_completion 预估,Lua 原子预扣拿到 prededuct_id ;流式累计真实 usage,结束后按真实值幂等回补净差额。再维护真实/预扣滑动比率,稳态压到 P95 而非 max,让高估自动收敛。 Q:万级 QPS streaming 长连接,Redis 限流会不会成瓶颈? A:会,所以要拆两套。RPM/TPM 软约束用两层令牌桶本地化,每 200ms 批量领取,热路径内存扣减,Redis 只当容量源+对账,本地桶超发由硬上限兜底。预算日额度硬约束+计费,仍每请求 Redis Lua,不可最终一致,靠 Kafka 对账收敛。 Q:灰度怎么秒级回滚? A:控制面/数据面解耦,策略带单调 version_id 经 pub/sub 推送,网关本地缓存策略,每请求校验期望版本号,不匹配触发全量拉取+兜底。回滚 = 新 version_id 指回旧版,worker 本地热刷无重启。关键是策略求值热路径零 IO。 Q:Redis 临时不可达 1 分钟,预算配额会怎样?速率限制呢?为什么会不一样? A:速率靠本地桶继续允许超发,由硬上限兜底,进限免模式 30s。预算切乐观预扣,本地 ring buffer 记 prededuct_id 继续,恢复后 sync job 补登,期间超发由控制面租户硬上限二次兜底。区别在约束硬度——速率软约束可最终一致,预算硬约束+计费需持久化不超卖,所以各自降级路径不同。 Q:Provider fallback 怎么避免预扣配额被双扣? A:预扣关联唯一逻辑请求 id,每个物理 Provider 调用记独立 provider_call_id 。fallback 复用逻辑请求 id、启新 provider_call_id ;回补按各 provider_call_id 真实 usage 分摊——原 Provider 已出 token 部分不回补,双 Provider 部分成功按各自真实 usage 分别的净差额回补,不重复扣同一份。 10. 总结 三句话带走: LLM 网关要把「速率限制(软约束、可最终一致)」和「预算配额(硬约束+计费、不可最终一致)」拆成两套计数器——前者本地双层桶本地化、热路径零 Redis;后者中心化 Lua 预扣,一次即是最小 Redis 访问。 streaming 超卖与误杀的全局解法是「预估预扣 + 流式回补」:按 prompt+max_completion 预估预扣拿 prededuct_id ,流式累计真实 token,结束按真实值幂等回补——灵魂在回补要快、超估要收敛。 规模决定选型:< 1K QPS 用 LiteLLM Proxy 或云托管;1K-10万 QPS、租户 < 100 用控制面/数据面分离 + 两阶段预扣(推荐);> 10万 QPS、租户 > 50 且已有 Mesh + WASM 熟练,才看 sidecar。 下次自检:你遇到 LLM 网关设计时,先问自己—— 我的速率计数和预算计数是不是一套?是不是该拆?硬约束走中心化、软约束本地化这条成立吗? 我的 QPS 量级、streaming 占比、租户数是多少?是不是其实 LiteLLM Proxy 就够了,在过度设计? 上游 Provider 抖动 fallback 时,我有没有把逻辑请求和物理调用分清,否则一定双扣? 热路径上还有没有和输入规模线性相关、且不异步的逻辑?PII 正则、JWKS、策略求值,哪个每请求还在打外部 IO? 什么时候别动这套——停在这里。 如果你的系统全司就 5 个租户、QPS < 500、streaming 占比低、且没有真金白银的硬预算约束,上面这些全是过度设计。直接一个 LiteLLM Proxy + 单 Redis,或者干脆用云厂商 gateway-as-a-service,一天能上完线,省下的工程成本够养半个 SRE。等你真的撞上 streaming 超卖的生产事故、或者月账单被某条业务线一次烧光,再回头照这套拆两套计数器——到那时,你会真正理解为什么两阶段预扣才是网关的脊柱,而不是限流算法本身。 决策框架一句话: 速率本地化,预算中心化,预扣要激进回补要快——这三条决定了你网关在万级 streaming 下是烧别人预算,还是烧自己半夜 pager。
公司级 LLM Mass Gateway — 多租户高并发网关,真正难点是 streaming 下的配额计数
机构级 LLM Mass Gateway — 多租户高并发网关,真正难点是 streaming 下的配额计数 LLM 网关的配额难点不在计算方法,在计数时机。Streaming SSE 响应长度未知,事后扣必然超卖,事前扣必然误杀——而真正能落地的,是把「软约束的速率限制」和「硬约束的预算配额」拆成两套计数器,分别用本地双层桶和中心化 Lua 预扣+流式回补来处理。 🕐 只有 2 分钟? → 直接...

