企业级 LLM Mass Gateway — 多租户高并发网关,真正难点是 streaming 下的配额计数

LLM 网关的配额难点不在算法,在计数时机。Streaming SSE 响应长度未知,事后扣必然超卖,事前扣必然误杀------而真正能落地的,是把「软约束的速率限制」和「硬约束的预算配额」拆成两套计数器,分别用本地双层桶和中心化 Lua 预扣+流式回补来处理。
🕐 只有 2 分钟? → 直接看[第 5 节「推荐方案」](#第 5 节「推荐方案」 "#5-%E6%8E%A8%E8%8D%90%E6%96%B9%E6%A1%88")

📖 想理解原理? → 从[第 2 节「为什么这很难」](#第 2 节「为什么这很难」 "#2-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%99%E5%BE%88%E9%9A%BE")开始

🎤 准备面试? → 跳到[第 9 节「面试延伸」](#第 9 节「面试延伸」 "#9-%E9%9D%A2%E8%AF%95%E5%BB%B6%E4%BC%B8")

🔧 直接看代码? → [第 6 节「完整 Demo」](#第 6 节「完整 Demo」 "#6-%E5%AE%8C%E6%95%B4-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 方案四:控制面/数据面分离 + 两阶段预扣计数(推荐)

到这里,思路成熟了。核心是三个动作:

  1. 拆两套计数器。RPM/TPM 软约束→本地双层令牌桶,热路径零 Redis;日额度预算硬约束→每请求一次 Redis Lua 原子预扣,已是最小 Redis 访问。
  2. 预估预扣 + 流式回补 (两套共用这套手法)。按 prompt token + max_completion 预估,预扣拿 prededuct_id;流式累计真实 token;流结束按真实值幂等回补差额。
  3. 策略带 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<br/>复用逻辑请求 id, 记新 provider_call_id<br/>回补按各 call 真实 usage 分摊 Note over Redis: Redis 不可达: 预算切乐观预扣本地 ring buffer<br/>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:控制面/数据面分离 + 两阶段预扣计数。

推荐它不是因为它最简单,而是因为它把两套约束放在了各自正确的位置:

  1. 同时解决两个问题------streaming 超卖用「预估预扣+流式回补」;Redis 热点靠 RPM/TPM 移到本地双层桶。两个问题不共用一个解法,但共用一个手法(预扣+回补)。
  2. 预算维度保留中心化原子预扣,硬约束+计费不可最终一致。这是不能让步的。
  3. 灰度策略带 version_id 热下发,秒级回滚不重新发布数据面。每请求校验期望版本号,不匹配触发全量拉取+兜底。
  4. 热路径 RPM/TPM 零外部 IO,P99 可压到 5ms(不含上游延迟)。
  5. Redis 不可达时分两种降级:速率走限免、预算走乐观预扣+补登。不是一套降级套两套,是分别设计。
  6. 对账体系让超发可控。本地桶会超发,由控制面按租户硬上限二次兜底。

为什么不是别的------方案 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,以及关键文件。

bash 复制代码
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(基础设施):

yaml 复制代码
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 与依赖

json 复制代码
{
  "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:

typescript 复制代码
// 本地双层令牌桶: 每 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 不可达切乐观预扣:

typescript 复制代码
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 分摊:

typescript 复制代码
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 校验:

typescript 复制代码
// 策略带单调 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:

typescript 复制代码
// 鉴权: 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:

typescript 复制代码
// 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 --- 数据面入口,串起整条链路:

typescript 复制代码
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 估算

如何运行:

bash 复制代码
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 fixmax_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. 总结

三句话带走:

  1. LLM 网关要把「速率限制(软约束、可最终一致)」和「预算配额(硬约束+计费、不可最终一致)」拆成两套计数器------前者本地双层桶本地化、热路径零 Redis;后者中心化 Lua 预扣,一次即是最小 Redis 访问。
  2. streaming 超卖与误杀的全局解法是「预估预扣 + 流式回补」:按 prompt+max_completion 预估预扣拿 prededuct_id,流式累计真实 token,结束按真实值幂等回补------灵魂在回补要快、超估要收敛。
  3. 规模决定选型:< 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。

相关推荐
云技纵横2 小时前
一个 CompletableFuture,把 CPU 打满了:默认线程池的线上坑
后端
free352 小时前
垃圾回收(GC)入门:从手动管理到自动回收的底层逻辑与演进
后端·架构
太子釢2 小时前
从零搭建一个最小的 Agent Loop
人工智能
Black蜡笔小新2 小时前
企业AI算力工作站/AI大模型训练工作站DLTM企业私有化AI视觉中台建设方案设计
人工智能
花生智源2 小时前
Java实现Prompt工程的技巧——从模板到工程化,告别字符串拼接
java·人工智能
墨虹雨茶2 小时前
Loop Engineering 正在重新定义"程序员"这个职业
人工智能
夕小瑶2 小时前
字节把 PS 做进了生图模型里,实测 Seedream 5.0 Pro 指哪改哪
人工智能
量化吞吐机2 小时前
2026年AI量化工具评估,先分开学习开发和验证
人工智能·python
IvorySQL2 小时前
PG 日报|新增 VFD 缓存监控视图,精细化数据库调优
大数据·数据库·人工智能·缓存·postgresql·区块链