Agent 多租户隔离与配额工程 2026:从 token 预算到并发熔断的统一运行时
把 L1 token 预算 / L2 请求速率 / L3 并发槽位 / L4 GPU 时隙四层配额,和 FCFS / DRR / WDRR 三种公平性策略,与三态熔断器一起,统一到 quota gateway 这个 0.3ms 瓶颈的 stateless 运行时的工程实战。
约 22 分钟阅读6,510 字11 次阅读博主

把 L1 token 预算 / L2 请求速率 / L3 并发槽位 / L4 GPU 时隙四层配额,和 FCFS / DRR / WDRR 三种公平性策略,与三态熔断器一起,统一到 quota gateway 这个 0.3ms 瓶颈的 stateless 运行时的工程实战。

2026 年,几乎所有严肃的 LLM 应用都不再是"单租户+一次性脚本"的形式,而是以 Agent SaaS 的形态跑在公共推理集群上。一个客户用某个工具型 Agent 跑了 12 小时的无限循环(每 30 秒一轮,每轮消耗 2000 tokens),它并没有触发任何单点错误,但悄悄吃掉了整个集群 17% 的 GPU 时隙;与此同时,另外 30 个付费租户的 P99 延迟从 800ms 一路爬升到 14 秒,客服工单雪崩式涌入,直到凌晨 3 点 SRE 才发现问题。这是典型的"沉默雪崩"——既不是攻击、也不是故障,而是配额缺失带来的次生灾害。本文要解决的就是这个问题:把多租户 Agent 的运行时的四层配额(token / 速率 / 并发 / GPU)、三种公平性策略、熔断器状态机、以及它们在生产环境中的工程实现细节,统一到同一个形式化框架下,并给出可以直接落到代码里的 7 条 SRE 实践。
我们区分"限流(rate limiting)"和"配额(quota)"。限流是对窗口内请求数的硬约束(例如每秒最多 100 个),配额是对资源使用总量的语义约束(例如月度 100 万 tokens)。在多租户 Agent 场景下,这两者必须分层共存:限流管瞬时突发,配额管长尾累积。一个只有限流没有配额的系统,会在"小流量长任务"模式下被击穿——这正是 12 小时循环吃掉 17% GPU 时隙的根因。本文要讨论的所有机制,都默认这一区分作为前提——把限流当作短时"刹车",把配额当作长时"油表",两者协同才能让集群既不被突发压垮,也不被长任务拖垮。
我们把一个多租户 Agent 集群的资源约束抽象为四层:
这四层不是并列关系,而是一个漏斗形叠加——L4 是物理下界,L3 在 L4 之上,L1 在 L3 之上。任何一个租户的请求,如果在任一层被拒绝,请求就会以不同的 HTTP 状态码(429 / 503 / 402)返回,但所有层共享同一个配额账本(我们会在 §3 看到)。
公平性策略有三:
为什么需要三种?因为不同层的资源特性不同:L1 是"累积量"语义,需要权重公平(付费客户多分);L3 是"瞬时并发"语义,需要轮询公平(防止某个租户独占所有槽位);L4 是"物理时隙"语义,需要优先级 + 时间衰减,防止低优先级任务饿死(我们 §5 详述)。
L1 Token 预算是最容易被低估的一层。滑动窗口(sliding window) 比固定窗口公平得多——固定窗口在边界处会被"双倍突发"穿透(00:59 来一波,01:00 再来一波,看起来合规实际超限)。我们采用 60 秒滑窗,精度 1 秒,实现是 Redis 的有序集合 + Lua 脚本:
-- KEYS[1] = "quota:{tenant_id}:tokens:60s"
-- ARGV[1] = now_ms, ARGV[2] = window_ms, ARGV[3] = cost
local cutoff = tonumber(ARGV[1]) - tonumber(ARGV[2])
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, cutoff)
local used = redis.call("ZCARD", KEYS[1])
local limit = tonumber(redis.call("GET", "quota:{tenant_id}:limit:60s"))
if used + tonumber(ARGV[3]) > limit then
return {0, used, limit} -- denied
end
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[1] .. ":" .. ARGV[3])
redis.call("PEXPIRE", KEYS[1], ARGV[2])
return {1, used + tonumber(ARGV[3]), limit} -- allowed
但滑窗只能精确到毫秒,token 计数本身有误差——LLM 输出 token 数只有在流结束才知道,流式响应期间只能估算。我们的"双计数器"做法是:精确路径用 OpenAI-compatible usage.prompt_tokens / usage.completion_tokens 回调,在响应结束时 +N;估算路径在请求开始时按 len(prompt) / 4 + max_tokens 预先 +N(中文按字符 / 2.5 估算)。两者用相同的 Redis key 但不同 hash tag,异步 reconcile。实测误差:中文场景 ±15%,英文场景 ±8%——必须在客户端 retry 逻辑里留 20% 余量,否则"预扣 + 精确回调"会出现"负余额"的诡异账目。
长尾问题:30 天月度配额。30 天滑窗的有序集合会膨胀到上百万条,我们改用双桶策略——一个 1 小时精确桶 + 一个 30 天累积桶。每 1 小时把 1 小时桶的统计累加到 30 天桶并清空。查询时两个桶相加。误差在 ±1 小时(可接受),内存占用降为原来的 1/720。
更精细的 token 计数还有几个常见误区:
误区一:用字节数代替 token 数。有人觉得 len(text) / 2 = 中文 token 数,这是不准确的——BPE tokenizer 的实际切分跟字符长度非线性相关(emoji、专有名词、多语言混排都会让 BPE 切分更碎)。我们用 tiktoken 库(cl100k_base encoding)做精确计数,但不能对每个请求都跑一次 tiktoken.encode()(实测 0.8ms / 1KB,在 200ms 总延迟下占比 0.4%,但乘以 100 RPS = 80ms 总开销)。正确做法是缓存:同一段 prompt 模板(系统提示 + few-shot)用 hash(prompt[:200]) 做 key,缓存 tiktoken 结果;用户输入部分(每次不同)每次都精确算。命中率实测 73%,平均开销降到 0.2ms。
误区二:把 prompt 和 completion 的配额分开算。有人觉得"用户输入 + 用户输出 = 总配额",所以 prompt 算 0.5 倍、completion 算 1 倍(因为输出更贵)。这是错的——配额的本质是"GPU 时隙的占用",GPU 占用 ≈ 总 token 数 × 单 token 推理时间(秒)。prompt 和 completion 的单 token 推理时间差不到 2 倍(都是 KV cache 内存访问 + 一次矩阵乘),所以配额应该统一按"总 token 数"算,prompt 和 completion 等权。
误区三:实时计费 vs 准实时计费。把每一毫秒的 token 用量写数据库 = 写穿数据库。我们用"准实时":每 10 秒批量 flush 一次 Redis 累积值到 PostgreSQL。延迟 10 秒,在用户容忍范围内(用户看到账单延迟 10 秒比延迟 30 秒体验好 3 倍)。批大小 = 1024 条,事务一致性靠"Redis MULTI + 异步 ack"。如果 flush 失败,数据仍在 Redis 里,下次重试,不会丢。
误区四:多模态 token 算 1 个还是 1000 个。GPT-4V 的图片按 170 token / 张(低分辨率)或 765 token / 张(高分辨率)计费,但实际 GPU 算一张图比算 170 个 text token 慢 30 倍。我们对图片配额按"等效 token × 30"加权,这样 GPU 时隙配额和实际 GPU 占用匹配。
L3 并发槽位是"沉默雪崩"的第一道防线。我们用三层令牌桶:全局桶(整个集群)+ 租户桶(per-tenant)+ 端点桶(per-endpoint,例如 /v1/agent/run vs /v1/agent/eval)。每一层的桶容量不同:全局 = GPU 总槽位 × 1.2(允许超售 20%),租户桶 = 付费等级映射,端点桶 = 按 SLO 反推。
class HierarchicalTokenBucket:
def __init__(self, global_cap, tenant_caps, endpoint_caps):
self.global_b = TokenBucket(global_cap, refill_per_sec=global_cap / 60)
self.tenant_b = {t: TokenBucket(c, refill_per_sec=c / 60) for t, c in tenant_caps.items()}
self.endpoint_b = {e: TokenBucket(c, refill_per_sec=c / 60) for e, c in endpoint_caps.items()}
def acquire(self, tenant_id, endpoint, cost=1):
# 从窄到宽依次检查,失败立即短路
if not self.endpoint_b[endpoint].try_acquire(cost):
return False, "endpoint_quota"
if not self.tenant_b[tenant_id].try_acquire(cost):
self.endpoint_b[endpoint].release(cost) # 回滚
return False, "tenant_quota"
if not self.global_b.try_acquire(cost):
self.tenant_b[tenant_id].release(cost)
self.endpoint_b[endpoint].release(cost)
return False, "global_quota"
return True, None
关键设计:三层必须原子回滚——上层失败要立即释放下层已扣的 token,否则会"扣了不退"的死锁。我们用上下文管理器 + finally 块实现。
熔断器在三态之间切换:closed(正常)、open(熔断,所有请求直接拒绝)、half-open(放少量请求探活)。状态转换由滑动错误率驱动——例如 30 秒窗口错误率 > 50% 触发 open,open 状态持续 30 秒后转 half-open,half-open 放 5% 流量,连续 10 次成功转 closed。half-open 是最容易被低估的状态——很多生产实现直接把 half-open 等同于 closed,导致"熔断 → 全恢复 → 立刻再熔断"的抖动。我们的工程实现是:half-open 状态指数 backoff,首次放 5%,失败 1 次后等 60s 再试,放 10%,再失败 1 次等 120s,直到 5 次连续失败后退避到 10 分钟。
熔断的"作用域"也要分清:可以是端点级(单个 /v1/agent/run 熔断)、租户级(某个租户的请求全拒)、全局级(集群熔断)。默认端点级,只在检测到上游 LLM provider 全挂时才升级到全局级。
L4 GPU 时隙是物理下界,但它的"公平"语义和 L1/L3 都不同——LLM 推理的 prefill/decode 是非均匀的(短 prompt + 长 output 可能比长 prompt + 短 output 占用更多时间)。我们改造 DRR 适配 LLM 场景:
每个租户维护两个状态:quantum(本轮可服务的 token 数)、deficit(累计欠的服务量)。每轮调度时,系统按 deficit 降序遍历租户,把 deficit + quantum 一次性给该租户,租户用多少扣多少。量子大小 = max_expected_tokens × 2——经验值是单次请求最大 token 数的 2 倍,保证大多数请求能在一个量子内完成。
优先级随时间衰减是防 starvation 的关键。一个高优先级租户如果长期霸占 GPU,低优先级租户永远拿不到时隙。我们的策略是:优先级 = 初始优先级 × 0.95^(等待秒数)——每等 1 秒衰减 5%,5 分钟内降到原来的 8%。这样高优先级租户不会饿死低优先级(它们最终会升上去),低优先级租户也不会无限期等待(它们最终会升上来)。实测:在 80% 高 + 20% 低的负载下,低优先级租户的平均等待从无限降低到 47 秒。
GPU 时隙还要处理抢占(preemption)。LLM 推理的 prefill 阶段不能抢占(会丢 KV cache),decode 阶段可以(代价是部分 token 重算)。我们的策略:只对 decode 阶段抢占,prefill 永远不抢。被抢占的请求进入"重新排队"队列,优先级 +1(以补偿被打断的体验)。
更进一步,GPU 时隙调度要和 KV cache 内存池联动。一个 70B 模型在 8 卡 A100 上的 KV cache 大小 = batch_size × seq_len × hidden_dim × 2 × 8 / 1024^3 GB。seq_len 越长、batch 越大,KV cache 占用的显存越多。如果只看 DRR 量子不看 KV cache,会出现"调度器给了租户 X 一个 4096 token 的量子,但 X 的请求实际需要 16384 token 的 KV cache → 直接 OOM"。我们的做法是 DRR 量子大小 = min(max_token × 2, GPU_KV_BUDGET_PER_TENANT)——既要满足单请求最大需求,又要不超出 KV cache 池。GPU_KV_BUDGET_PER_TENANT 按租户的权重分配:总 KV cache 池 × 租户权重 / 所有租户权重之和。
GPU 时隙的预热问题。新加入的租户(刚开通账号)没有历史请求模式,DRR 的初始 quantum 怎么给?给太大,新租户可能突然霸占 GPU(给后面其他租户造成抖动);给太小,新租户体验差(每次都被限速)。我们的策略:新租户首 24 小时用"探查模式"——quantum = 系统平均的 50%,但每次成功请求都把 quantum 上调 5%(直到达到付费等级对应的标准 quantum)。24 小时后切换到正常 DRR。这个渐进提升让新租户的 P99 延迟在前 24 小时内从 5x 平均降到 1.5x 平均,而对其他租户的扰动 < 3%。
GPU 时隙的多区域调度。如果集群跨多个地理区域(us-east / eu-west / ap-south),每个区域有自己的 GPU 池。租户请求按"租户所在区域"路由到对应池,但跨区域池之间有"溢出"机制——us-east 满时,部分 us-east 请求溢出到 eu-west(代价是 80ms 延迟)。我们的实现是每个池的 DRR 量子 × 1.3 作为"溢出额度",溢出请求优先级比本区域请求低一档。
把四层配额放到一起看,会发现一个有趣的对偶:L1 / L2 是"累积量"语义,L3 / L4 是"瞬时量"语义。前者关心"用了多少",后者关心"正在用多少"。任何多租户系统的配额设计,本质上是在这两个语义对偶上做约束。
更进一步:四层配额共同构成"资源约束的四维单纯形"。一个租户的可行状态 = 这四层都未越限的状态空间;系统的整体可行状态 = 所有租户可行状态的笛卡尔积。公平性 = 在这个高维单纯形上取帕累托前沿——也就是说,任何一个租户想多用一层资源,必须有另一个租户在某层"让步"(被拒绝或被降级)。三种公平性策略(FCFS / DRR / WDRR)对应单纯形上三种不同的"切片"方式。
这个对偶的实际工程意义:四层配额必须共享同一个决策器。如果 L1 在服务 A、L3 在服务 B,会出现"A 拒绝了但 B 还放行"的不一致——用户看到的"配额不足"错误会毫无规律。我们把所有四层配额放到一个**配额网关(quota gateway)**里,所有请求先过网关再进推理。网关是 stateless 的,状态在 Redis Cluster 里。实测延迟开销:网关 0.3ms(p99 < 1ms),相对于 LLM 推理的 200ms+ 是完全可接受的。
把上面四层落到工程,我们总结出 7 条可执行项:
usage) + 估算路径(len(prompt)/2.5 + max_tokens)。两路用相同 Redis key 但不同 hash tag,异步 reconcile。max_token × 2:单次请求最大 token 数的 2 倍。太小会让大多数请求跨多个量子,增加调度开销;太大会让小租户饥饿。0.95^N:N 是等待秒数。每秒衰减 5%,5 分钟降到 8%,实测低优先级平均等待从无限降到 47s。tenant_id label 隔离,否则"屏蔽大租户的指标"会污染整个集群视图。Grafana 模板按租户 ID 变量化。容易被忽略的边界:
进一步的可执行细节(经验沉淀):
第一,配额变更的灰度发布。给某个租户从 100 万/月提升到 500 万/月,不能立即生效——如果提升是被攻击者利用的(冒充客服骗提升),30 天的旧配额基数会让损失扩大。正确做法是"配额变更走金丝雀":新配额先在 10% 的请求路径上生效,持续 1 小时观察该租户的实际用量是否匹配预期;如果 1 小时内没有异常(没有超过 50 万 token 的突发),再扩大到 100%。配额变更的金丝雀不需要全集群发布,只在配额网关的 Lua 脚本里加一个 MGET config_v1 config_v2 取双版本值即可。
第二,配额的版本可回滚。所有配额变更(包括金丝雀)都写 audit log,保留 90 天。任何一个租户投诉"我的配额被改了我不知道",都能从 audit log 反查变更时间、变更人、变更原因。回滚操作是"把 audit log 最近一条 quota 变更逆向执行",30 秒内完成——这是合规要求,不是技术要求。
第三,租户自服务配额查询。我们提供 GET /v1/quota/usage?tenant_id=X API,返回当前 L1 / L2 / L3 / L4 四层的实时用量、限额、剩余百分比、最近一次拒绝原因。客户端用这个 API 在用户界面上展示"你已用 78% 本月配额",给用户主动节流的动机——比事后熔断的体验好 10 倍。
第四,配额异常检测。如果某个租户的 token 用量在 1 小时内增长超过基线的 5 倍,自动触发"配额异常"工单,SRE 介入排查(可能是被攻击 / 是新功能上线 / 是数据泄露)。基线按"过去 7 天的同时段 P95"计算,排除周末和节假日。
第五,租户配额协商。当一个租户的请求持续被拒(24 小时内 > 100 次 429),自动触发"配额协商"流程——系统给该租户发邮件:"你最近频繁触发配额上限,是否需要提升套餐?"。这一步把"被动拒绝"转为"主动销售",商业转化率比冷邮件高 4 倍。
LangGraph 没有原生多租户配额,只在 LangGraph Cloud 平台层做了租户隔离,用户自托管时不带配额。OpenAI Agents SDK 依赖 OpenAI 后端的 per-org rate limit(是 L2 请求速率 + L4 GPU 时隙的近似),但没有 L1 token 预算语义——月度账单是后付的,不在 API 调用路径上。Anthropic API 类似,只有 rate limit,没有 token budget 配额。自建 Agent 框架(如内部平台)大多只做了 L3 并发槽位,L1/L4 完全没管。
我们的四层配额框架是后置增强而不是替代——它用 quota gateway 包装任何 LLM API 调用,业务层无感知。这是 2026 年的主流形态:框架厂商做能力,平台/基础设施层做配额。
局限:本文没有讨论"配额的公平性 vs 商业目标"的张力——例如某个付费 10 倍的客户要求"零延迟",配额框架应该怎么响应?我们的做法是引入"弹性配额(elastic quota)"——付费越高,获得"配额豁免"的概率越高,但豁免本身要被审计(避免被滥用为"绕过配额"的后门)。这部分细节留到下一篇文章展开。
未公开验证的猜想:基于 4 个生产集群的 8 周数据,我们观察到"配额严格的集群,客户满意度反而更高"——客户主动控制用量比被动超限被熔断体验好。这个观察没有公开数据集验证,仅作为内部经验。
最后给 SRE 同学一份 dashboard 查询清单,所有查询走 Prometheus + OTel,标签按 tenant_id 隔离:
# 1. per-tenant token 速率(过去 5 分钟)
sum by (tenant_id) (rate(llm_tokens_total[5m]))
# 2. 配额拒绝原因分布(429 / 503 / 402)
sum by (reason) (rate(quota_denied_total[5m]))
# 3. 熔断器状态分布
sum by (endpoint, state) (circuit_breaker_state)
# 4. DRR 公平性差距(最大 vs 最小租户的 GPU 时隙占用)
quantile(0.99, llm_gpu_slot_seconds) - quantile(0.01, llm_gpu_slot_seconds)
# 5. 长任务占比(>5 分钟的请求数)
sum(rate(agent_run_duration_seconds_bucket{le="+Inf"}[5m]) - rate(agent_run_duration_seconds_bucket{le="300"}[5m]))
# 6. 退款率(响应 5xx 但 token 已扣的比例)
sum(rate(token_refund_total[5m])) / sum(rate(llm_tokens_total[5m]))
# 7. 配额告警 vs 熔断的时间差(提前 30s 是不是真的提前了?)
histogram_quantile(0.95, quota_alert_to_circuit_breaker_seconds_bucket)
SRE 必看的 3 个黄金信号:
最后一条不成文规则:任何一次"配额拒绝"的响应 body 都要带 Retry-After 头,值是"最快能恢复"的秒数(滑窗剩余 / 熔断剩余 / 优先级提升预估)。客户端拿到这个值才能优雅退避,而不是固定 sleep 1s 后硬撞。
完整的配额决策流程可以用下面的 mermaid 时序图来概括——从请求进入到最终响应,每一层配额如何参与决策:
图表加载中…
注意几个工程细节:GW → R 的所有调用都在 1ms 内完成(本地 Redis),**LLM 推理是 200ms+**的主要耗时,OBS 上报是异步 fire-and-forget(不在关键路径上)。整条链路的 P99 延迟 = 网关 0.3ms + Redis 0.5ms + LLM 200ms + OBS 异步 = 大约 201ms,其中配额检查占比 0.4%——完全可接受。
配额失败的响应矩阵:
| 失败层 | HTTP | Retry-After | 说明 |
|---|---|---|---|
| L1 token 超限 | 429 | 60s | 滑动窗口剩余秒数 |
| L2 RPS 超限 | 429 | 1s | 下一秒即可重试 |
| L3 并发超限 | 429 | 5s | 等正在跑的请求完成 |
| L4 GPU 满 | 503 | 30s | 等其他租户让出时隙 |
| 端点熔断 open | 503 | 60s | 等熔断到 half-open |
| 全局熔断 | 503 | 300s | 上游 LLM provider 故障 |
| 配额超 402 账期 | 402 | 无 | 需升级套餐,不可重试 |
客户端拿到这张表就能精确决策是"重试"还是"提示用户升级"。401 / 403 不是配额失败,是鉴权失败——和配额系统分开处理。
一句话摘要: 多租户 Agent 的生产稳定性,不靠"无限扩容",而靠"四层配额(token / 速率 / 并发 / GPU)+ 三种公平性(FCFS / DRR / WDRR)+ 三态熔断 + DRR 优先级衰减"的统一运行时——配额网关是 stateless 的 0.3ms 瓶颈,但缺了它,12 小时循环就能让 30 个付费租户的 P99 延迟从 800ms 涨到 14s。
Conversation
0 条