背景:共享模型池最怕”看起来正常”的大请求
企业内部做大模型平台时,很容易先把注意力放在模型部署、GPU 利用率、推理吞吐、Prompt 模板和 API 兼容上。等平台开始被多个团队接入后,真正麻烦的问题才会出现:一个租户突然跑批量总结任务,一个租户把上下文窗口拉满,一个租户在失败后无节制重试,另一个租户把流式输出挂住很久。
这些流量在 HTTP 层看起来都是普通请求,但对 LLM Serving 来说差异巨大。短问答可能只占用几百 Token,合同审查、客服会话压缩、代码分析、长文档抽取可能一次就消耗数万 Token。更复杂的是,输入 Token、输出 Token、并发连接、队列等待、KV Cache 占用和模型实例调度会同时影响成本与延迟。
所以,多租户 LLM 平台不能只做”每秒多少请求”的限流。它需要一个更贴近推理成本的 LLM 配额网关:在请求进入模型池之前,先完成租户识别、预算检查、Token 估算、速率限制、优先级分配、队列准入和成本归因。
核心原理:配额不是一个阈值,而是一组预算账本
第一层:身份和租户上下文
网关收到请求后,首先要确定请求属于哪个租户、哪个应用、哪个环境和哪个成本中心。不要只依赖 API Key 字符串本身,而要解析成结构化身份:
{
"tenant_id": "team-risk-control",
"app_id": "contract-reviewer",
"env": "prod",
"plan": "gold",
"cost_center": "legal-ai",
"priority": "interactive"
}
这个身份会贯穿后续全链路:限流、调度、日志、账单、告警和审计都应该使用同一套租户上下文。如果只在入口鉴权阶段识别身份,后续队列、模型池、日志系统没有携带租户字段,最终仍然无法解释”谁把 GPU 打满了”。
第二层:多维配额
LLM 配额至少应分成四类:
| 配额类型 | 典型指标 | 用途 |
|---|---|---|
| 请求速率配额 | RPM、RPS、每用户短窗口 | 控制请求频率,防止突发冲击 |
| Token 预算配额 | 每分钟输入/输出 Token、每天总 Token、单请求上下文上限 | 贴近 LLM 成本,反映真实资源消耗 |
| 并发和队列配额 | 每租户最大 inflight 请求、最大排队请求、最大流式连接 | 防止一个租户占满所有 replica 的 ongoing request |
| 成本预算配额 | 每天美元预算、每月预算、每模型预算、每业务线预算 | 防止离线任务和异常调用把账单打爆 |
请求速率配额:Envoy 的 rate limit 过滤器就是典型模式。请求路由或虚拟主机上的配置会生成 descriptor,发送给 rate limit service;如果 descriptor 超限,会返回 429。descriptor 也可以由多个动作组合生成,这适合表达 tenant、app、model、route 等多维限流键。
Token 预算配额:比 RPS 更贴近 LLM 成本,因为不同请求的资源消耗差异可能相差几个数量级。
并发和队列配额:Ray Serve 的 autoscaling 文档中把 target_ongoing_requests 和 max_ongoing_requests 作为定义系统稳态和队列上限的关键参数,这个思路同样适合放到租户维度:不要让一个租户把所有 replica 的 ongoing request 填满。
成本预算配额:不是实时性能保护的唯一依据,但能防止离线任务和异常调用把账单打爆。
第三层:准入控制和公平调度
配额检查不是简单的”通过或拒绝”。生产系统通常需要四种决策:
type AdmissionDecision =
| { action: "allow"; priority: number; budgetSnapshot: BudgetSnapshot }
| { action: "queue"; queue: "interactive" | "batch"; ttlMs: number }
| { action: "degrade"; maxOutputTokens: number; model: string; reason: string }
| { action: "reject"; status: 429 | 402 | 503; retryAfterMs?: number };
交互式请求可以保留较高优先级,批处理任务可以排入低优先级队列;超出日预算但属于关键业务的请求可以降级到小模型或缩短输出长度;明显异常的重试风暴应直接拒绝,并返回明确的 Retry-After。
NVIDIA Triton 的 rate limiter 文档强调,rate limiter 可以跨模型控制请求调度,并通过资源和优先级决定模型实例的执行时机。这说明网关层的租户公平只是第一步,模型服务内部也需要知道哪些请求应该先执行,哪些模型实例需要被资源约束。
工程落地:从”限流器”升级为”控制面 + 数据面”
数据面:每个请求都必须先记账再放行
LLM 请求进入数据面后,可以按以下顺序处理:
- 解析 API Key 或 JWT,得到租户上下文。
- 预估输入 Token,并读取请求声明的
max_tokens、model、stream、tool_choice等参数。 - 查询短窗口配额:RPS、RPM、TPM、并发、队列长度。
- 查询长窗口预算:日预算、月预算、模型级预算、成本中心预算。
- 生成准入决策:
allow、queue、degrade或reject。 - 请求完成后,用真实输入 Token、输出 Token、延迟、状态码和模型价格回写账本。
一个简化的 TypeScript 伪代码如下:
async function admitLLMRequest(req: LLMRequest): Promise<AdmissionDecision> {
const identity = await resolveTenantIdentity(req.apiKey);
const estimatedInputTokens = estimateTokens(req.messages);
const quotaKey = {
tenantId: identity.tenantId,
appId: identity.appId,
model: req.model,
priority: identity.priority,
};
const quota = await quotaStore.snapshot(quotaKey);
if (quota.inflight >= quota.maxInflight) {
return {
action: "queue",
queue: identity.priority === "interactive" ? "interactive" : "batch",
ttlMs: 30_000,
};
}
if (
quota.tokensPerMinute.used + estimatedInputTokens >
quota.tokensPerMinute.limit
) {
return {
action: "reject",
status: 429,
retryAfterMs: quota.tokensPerMinute.resetInMs,
};
}
if (quota.dailyCost.usedUsd > quota.dailyCost.softLimitUsd) {
return {
action: "degrade",
model: "small-fast-model",
maxOutputTokens: Math.min(req.maxTokens ?? 1024, 512),
reason: "daily_cost_soft_limit_exceeded",
};
}
return {
action: "allow",
priority: priorityToWeight(identity.priority),
budgetSnapshot: quota,
};
}
这里的重点不是代码本身,而是顺序:先估算、再准入、最后按真实消耗结算。只在请求完成后记账,无法阻止大请求进入队列;只在请求进入前记账,容易因为输出 Token 估算不准导致账本漂移。两者必须结合。
控制面:配额配置要可发布、可回滚、可审计
配额不应该写死在代码里。控制面至少要支持这些配置:
tenants:
team-risk-control:
plan: gold
models:
gpt-large:
rpm: 120
input_tpm: 200000
output_tpm: 80000
max_context_tokens: 32000
max_output_tokens: 4096
max_inflight: 24
daily_budget_usd: 300
gpt-small:
rpm: 600
input_tpm: 500000
output_tpm: 200000
max_inflight: 80
queues:
interactive:
weight: 8
max_wait_ms: 3000
batch:
weight: 2
max_wait_ms: 60000
这些配置需要经过审批、灰度和回滚。尤其是给某个租户临时提高配额时,必须记录原因、操作人、生效时间、过期时间和影响范围。否则,临时配置会变成永久风险。
调度层:避免”有钱租户”饿死其他租户
多租户公平调度不是让所有租户完全平均,而是让共享资源按权重可解释地分配。常见做法有三种:
| 调度策略 | 原理 | 适用场景 |
|---|---|---|
| 加权队列 | 每个租户或等级有权重,调度器按权重从队列取请求 | 简单可解释,适合租户等级明确的场景 |
| Token-aware 调度 | 按预估输入/输出 Token 和模型类型折算工作单元 | 请求复杂度差异大的场景 |
| 优先级 + 老化 | 高优先级先执行,低优先级等待过久后逐步提升 | 混合在线/离线任务场景 |
近期 LLM Serving 调度研究也开始把客户端优先级、SLO 和请求收益纳入调度目标,而不是只按 FCFS 处理。
适用场景
- 企业内部模型平台:多个业务团队共享同一组模型服务和 GPU 池。每个团队的调用模式不同,必须隔离预算和并发。
- 对外 SaaS 产品:免费版、标准版、企业版用户共享同一套 LLM 能力。没有租户级配额,低价套餐可能挤占高价套餐的服务质量。
- Agent 平台:Agent 往往会循环调用模型、工具和检索系统,失败时还可能自动重试。必须用配额网关限制最大步骤数、最大 Token、最大工具调用数和最大耗时。
- 混合在线与离线任务的平台:交互式任务强调延迟,离线任务强调成本和吞吐。二者不能进入同一个无优先级队列。
常见误区
误区一:只按 RPS 限流
RPS 只能描述请求数量,不能描述 Token 消耗、输出长度、上下文窗口、流式连接时长和 GPU 占用。对 LLM 平台来说,RPS 必须和 TPM、并发、队列长度、成本预算一起使用。
误区二:把限流放在模型服务后面
如果请求已经进入推理队列,限流的保护效果就大幅下降。正确位置是在模型池之前,在接收请求、解析身份、估算 Token 后立即做准入判断。
误区三:只拒绝,不降级
配额网关不应该只有 429。对于低风险请求,可以缩短 max_tokens、切换小模型、关闭高成本工具、转入异步队列或提示用户稍后查看结果。拒绝只是最后手段。
误区四:不记录拒绝原因
没有拒绝原因,业务团队只会看到”模型不好用”。每次限流、排队、降级都应该记录原因码,例如 tenant_tpm_exceeded、daily_budget_soft_limit、model_pool_overloaded、queue_timeout。
上线检查清单
- 每个请求是否都能解析出
tenant_id、app_id、model、priority和cost_center。 - 是否同时具备 RPS、TPM、并发、队列长度、单请求上限和成本预算。
- 输入 Token 是否在准入前估算,输出 Token 是否在完成后按真实值结算。
- 429、402、503、degrade、queue 是否有明确原因码和可观测指标。
- 是否有租户级、模型级、队列级和全局级的仪表盘。
- 配额配置是否支持审批、灰度、过期时间和回滚。
- 高优先级租户是否会无限挤占低优先级租户;低优先级租户是否有老化机制避免饥饿。
- 批处理任务是否和交互式任务隔离队列。
- 是否能按租户回放最近 1 小时的请求账本,解释账单和限流原因。
- 是否做过压测:短请求风暴、长上下文请求、流式请求、失败重试风暴、单租户突增和多租户同时突增。
FAQ
Q1:Token 预算应该按输入算,还是按输出算?
都要算。输入 Token 决定上下文处理成本和部分显存压力;输出 Token 决定 decode 时间、流式连接时间和最终账单。准入前只能估算输出 Token,因此建议按 max_tokens 或历史 P95 输出长度预扣,完成后再按真实输出修正。
Q2:租户超预算后应该直接停用吗?
不建议一刀切。可以分软限制和硬限制。软限制触发降级、告警或转异步;硬限制才拒绝。关键业务租户还可以有应急额度,但必须带审批和过期时间。
Q3:公平调度和优先级调度冲突吗?
不冲突。公平调度保证资源长期分配可解释,优先级调度保证关键请求在短时间内先被处理。生产系统通常使用加权公平:高优先级租户权重大,但低优先级租户不会被永久饿死。