Skip to main content

04 - 多租户与配额:两条完全不同的路

数据快照 2026-08-19。代码引自当天拉取的 BerriAI/litellm@mainenvoyproxy/ai-gateway@main

模型账单只有一张,但用它的有十个团队。"这次请求算谁头上、还剩多少额度、超了怎么办" —— 这是网关从"路由器"变成"平台"的那一步。

LiteLLM 和 Envoy AI Gateway 在这件事上给出了两个完全不同的答案,而且分歧的根源不是偏好,是形态(见 01 - 四种形态)。

读这篇之前

前置01 - 网关是什么 里的虚拟密钥概念。

本篇回答:十个团队共用一张模型账单时,"还剩多少额度、这次算谁头上"是怎么被算准的。

会用到的词

  • TOCTOU(Time-Of-Check to Time-Of-Use):先检查再使用,中间被别人插了一脚 —— 限流被击穿的经典原因
  • Redis Lua 脚本:Redis 单线程执行 Lua,所以一段脚本里的多个操作天然原子,是实现分布式限流最常见的手段
  • descriptor(描述符):限流的对象标识,比如"研究团队这把密钥"或"gpt-4o 这个模型",一次请求可以同时携带多个
  • Redis Cluster / hash tag / CROSSSLOT:Redis 集群把 key 分散到不同分片,一次操作碰不到跨分片的多个 key(报 CROSSSLOT),用花括号 {} 标记的部分相同则保证落在同一分片

一、LiteLLM:把限流写成 Redis Lua 脚本

litellm/proxy/hooks/parallel_request_limiter_v3.py4,789 行,是整个 proxy 里最硬核的一个文件。它的核心不是 Python,是嵌在里面的两段 Redis Lua 脚本。

为什么必须是 Lua

限流的经典问题是 TOCTOU(check-then-act):读计数 → 判断 → 写计数,这三步之间别的副本插进来了,限额就被击穿。多副本部署下这不是理论问题,是必然发生的。

LiteLLM 的解法是把整个"检查并递增"塞进一个 Lua 脚本,靠 Redis 单线程执行保证原子性。脚本头部的注释把契约写得非常完整:

-- Atomic check-and-increment-by-N across one or more descriptors.
-- All-or-nothing: if any descriptor would exceed its limit, no counter is
-- modified.
--
-- Uses Redis server time (`redis.call('TIME')`) instead of a client-supplied
-- timestamp so that window resets are deterministic across replicas with
-- skewed wall-clocks. This prevents a clock-skew-induced reopening of the
-- TOCTOU window across multi-replica deployments.
--
-- KEYS layout: pairs of (window_key, counter_key), one pair per descriptor.
-- ARGV layout: per-descriptor 4-tuple, starting at ARGV[1]:
-- ARGV[(i-1)*4 + 1] = limit
-- ARGV[(i-1)*4 + 2] = increment
-- ARGV[(i-1)*4 + 3] = ttl_seconds (counter TTL when window resets)
-- ARGV[(i-1)*4 + 4] = window_size_seconds (sliding-window length)
--
-- Return on success:
-- { 0, new_counter_1, window_start_1, new_counter_2, window_start_2, ... }
-- Return on over-limit: { 1, descriptor_index, current_counter, limit }

三个设计点值得单独拎出来:

1. 用 redis.call('TIME') 而不是客户端时间戳。

local time_reply = redis.call('TIME')
local now = tonumber(time_reply[1])

如果时间由 Python 端传进来,多个副本的机器时钟哪怕只差几百毫秒,窗口重置就会在不同副本上发生在不同时刻 —— 等于重新打开了刚被 Lua 关上的 TOCTOU 窗口。注释里明确说了这是在防 "clock-skew-induced reopening"。这是那种只有真被线上打穿过才写得出来的注释。

2. 两趟扫描,全有或全无。

-- Pass 1: read state, validate. Abort without writing if any over limit.
local descriptor_state = {}
for i = 1, descriptor_count do
...
end

第一趟只读不写,任何一个描述符超限就整体中止;第二趟才真正递增。这样"key 的额度够但 team 的额度不够"时,不会出现 key 的计数被扣了而请求被拒的错账。

3. Redis Cluster 的 hash tag。

window_key = f"{{{descriptor_key}:{descriptor_value}}}:window"

那三层大括号在 Python f-string 里最终渲染成 {descriptor_key:descriptor_value}:window —— 花括号是 Redis Cluster 的 hash tag 语法,保证同一个描述符的 window 和 counter 落在同一个 slot 上。源码注释解释了后果:

Cluster-safety: each descriptor's keys all share a `{key:value}` hash
tag, so the Redis Lua path issues one Lua call per descriptor — every
... CROSSSLOT errors. Cross-descriptor atomicity is preserved via
refund-on-rollback: if descriptor i is OVER_LIMIT, descriptors 0..i-1

这里有个诚实的妥协:在 Redis Cluster 下,跨描述符的原子性做不到了(不同描述符在不同 slot,一次 Lua 调用碰不到),于是降级成"退款回滚"——先扣,发现后面的超限了再把前面扣的还回去。单实例 Redis 下是真原子,Cluster 下是补偿事务。这个区别在文档里不会写,只在源码注释里。

描述符:多租户的实际载体

限流的对象叫 descriptor,每个描述符是一个 (key, value) 对,带三种限额:

rpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "requests")
tpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "tokens")
# 以及 max_parallel_requests(走独立路径,不进 Lua 窗口)

一次请求会同时携带多个描述符 —— 虚拟密钥、用户、团队、终端用户、模型,每一层都有自己的额度。全有或全无的语义就是为这个服务的。

这套东西的完整形态就是"虚拟密钥"key_management_endpoints.py(280 KB)负责发放和管理这些密钥,user_api_key_auth.py(139 KB)负责在请求入口把密钥翻译成一组描述符,auth_checks.py(5,172 行)负责层层校验。

二、Envoy AI Gateway:把配额翻译成 Envoy 原生描述符

Envoy AI Gateway 完全不自己实现限流。它做的事是:把 K8s 里的 QuotaPolicy CRD 翻译成 Envoy 的 RateLimit 配置,然后交给 Envoy 的限流过滤器和外部限流服务去执行。

const (
quotaRateLimitClusterName = "ai_gateway_ratelimit_cluster"
quotaRateLimitFilterName = "envoy.filters.http.ratelimit/ai-gateway-quota"
defaultQuotaRateLimitServicePort = 8081

// quotaCostMetadataKey is the dynamic metadata key where ext_proc stores
// the computed quota cost for the current request.
quotaCostMetadataKey = "quota_cost"
)

注意过滤器名字带了 /ai-gateway-quota 后缀,注释解释了原因:要和 Envoy Gateway 自带的限流过滤器区分开,否则两套限流会互相覆盖。这是在别人生态里搭房子必须处理的细节。

关键机制:LLM 的成本在请求开始时是未知的

传统限流按"请求数"计费,一次请求就是一次。但 LLM 的配额要按 token 算,而 token 数在请求发出时根本不知道,输出 token 更是要等流式响应结束才知道。

Envoy 的解法是 HitsAddend —— 让一次请求按 N 计数,N 从动态元数据里读:

// quotaHitsAddend returns the HitsAddend that reads the quota cost from dynamic
// metadata stored by the ext_proc filter.
func quotaHitsAddend() *routev3.RateLimit_HitsAddend {
return &routev3.RateLimit_HitsAddend{
Format: fmt.Sprintf("%%DYNAMIC_METADATA(%s:%s)%%",
aigv1b1.AIGatewayFilterMetadataNamespace, quotaCostMetadataKey),
}
}

完整链路是这样的:

源码里对应的就是 request-time 和 stream-done 两套描述符条目:

// Request-time entries only. Stream-done is added once per model in enableQuotaRateLimitOnRoute.

先按估算扣一次,流结束后按实际再补一次。 这是把"成本未知"这个 LLM 特有的问题塞进 Envoy 既有限流模型的唯一办法。

描述符从哪来:动态元数据

func baseDescriptorActions() []*routev3.RateLimit_Action {
return []*routev3.RateLimit_Action{
{ActionSpecifier: &routev3.RateLimit_Action_Metadata{
Metadata: &routev3.RateLimit_Action_MetaData{
DescriptorKey: translator.BackendNameDescriptorKey,
MetadataKey: &metadatav3.MetadataKey{
Key: aigv1b1.AIGatewayFilterMetadataNamespace,
Path: []*metadatav3.MetadataKey_PathSegment{{
Segment: &metadatav3.MetadataKey_PathSegment_Key{
Key: "ai_service_backend_name",
}}},
},
Source: routev3.RateLimit_Action_MetaData_DYNAMIC,
}}},
// 第二个:model_name_override
}
}

两级描述符:backend_name + model_name_override。都来自 DYNAMIC 元数据,也就是 ext_proc 在处理请求时写进去的 —— 因为"这次请求实际会打到哪个模型"要解析请求体才知道,路由配置阶段填不出来。

三、两条路的对比

LiteLLMEnvoy AI Gateway
限流在哪执行自己实现,Redis LuaEnvoy 限流过滤器 + 外部限流服务
配额怎么配API / 数据库里的虚拟密钥K8s QuotaPolicy CRD
原子性单实例真原子;Cluster 下退款回滚交给限流服务保证
token 成本未知怎么办请求前后各更新一次计数request-time + stream-done 两套描述符
想改限流算法改 Lua 脚本换限流服务实现
依赖RedisKubernetes + Envoy + 限流服务

LiteLLM 是"我全都自己做",Envoy AI Gateway 是"我只做翻译"。

前者的代价是那 4,789 行里藏着的所有分布式细节都得自己维护 —— 时钟偏移、hash tag、退款回滚,每一条都是可能出错的地方。后者的代价是你必须先有一整套 Envoy + K8s + 限流服务的基础设施,而且遇到问题要在三个组件之间来回定位。

做过多租户平台的人会认出这个取舍:它就是"自研中间件"和"用云厂商托管服务"那个老问题在 AI 网关上的复现。我在 RAG Agent Platform 里选的是第一条路,代价是租户隔离的每个边界条件都得自己想清楚。

四、一个两家都没解决好的问题

流式请求的配额是滞后的。

不管哪种实现,输出 token 的真实数量都要等流结束才知道。这中间的窗口里:

  • 用户可以同时发起 100 个流式请求,每个都通过了 request-time 检查
  • 等它们陆续结束,配额已经超了几十倍
  • 而且这些 token 已经真实产生了费用,追不回来

Envoy 的 stream-done 描述符能把账记准,但记准不等于拦住。LiteLLM 的 max_parallel_requests 走独立路径不进 Lua 窗口,某种程度上是在补这个洞 —— 限制并发数,间接限制了滞后窗口内能溜进来的量。

真要解决,只能在流式响应过程中持续计费并中途掐断,代价是要改动响应处理的热路径。两家目前都没做。 这是 2026 年 AI 网关领域一个公开的未解问题。

下一篇04 - MCP 网关:一个客户端连多个 MCP 后端,会话、工具列表、通知流怎么合并。