企业级 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。

相关推荐
江畔柳前堤3 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术3 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客3 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
阿里云大数据AI技术4 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO4 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术5 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
陈随易5 小时前
moon,apt和yum之外linux系统命令安装新选择
前端·后端·程序员
维基框架5 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github
冬奇Lab5 小时前
代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好
人工智能
AKAMAI5 小时前
你的源服务器可能是你做出的最昂贵决定
运维·人工智能·云计算