一、问题是怎么暴露的
我们的 LLM 平台上线三个月后,收到了第一封愤怒的邮件。
不是用户投诉 AI 回答质量差,而是某个大客户的 CTO 亲自写来的:"你们的系统今天早上把我们整个月的 token 配额烧完了,现在所有功能都 503 了,损失自己算。"
排查下来,原因很蔗汁:他们有个批量数据导出的脚本,前一天上线,循环调用了 LLM API,每次传入完整的上下文历史(约 8000 tokens),跑了三小时,把 30 天配额在凌晨全部耗尽。
而我们的系统:没有 per-tenant 配额,没有消耗速率告警,没有预算熔断。Provider 端的 TPM 限制保护的是我们自己的账号不被上游封禁,但对租户内部的配额完全没有控制。
这就是"Provider rate limit ≠ 租户 Quota"的经典教训。
本文记录我们后续设计的多租户 Token Quota 系统,从最简单的计数器开始,到分层熔断、用量预警和动态配额调整,把我们踩过的 5 个坑都写出来。
二、为什么 Provider 的限制解决不了你的问题
先把两个概念拆清楚:
Provider Rate Limit:大模型服务商对你整个 API Key 的限制,比如 1M TPM、5000 RPM。它保护的是 Provider 的服务,防止你的账号影响其他客户。
Tenant Quota:你对你的租户(客户/用户/团队)施加的限制,防止某个租户影响其他租户,或者烧穿你的预算。
| 维度 | Provider Rate Limit | Tenant Quota |
|---|---|---|
| 保护对象 | Provider 的服务稳定 | 你的多租户公平性 + 预算 |
| 粒度 | 账号级 / 项目级 | 租户级 / 用户级 / 功能级 |
| 可配置性 | 不可自定义(付费套餐决定) | 完全由你控制 |
| 窗口类型 | 固定分钟窗口(TPM/RPM) | 可自定义:天/月/配额包 |
| 超限行为 | HTTP 429(全账号影响) | 可降级、可预警、可熔断 |
简单说:Provider 的限制是你的天花板,Tenant Quota 是你给每个租户划的房间大小。
三、设计 Quota 系统前,先想清楚三件事
3.1 计什么单位?
请求数(RPM/RPS):最简单,但 LLM 里意义不大。一个 200 token 的请求和一个 8000 token 的请求,在请求数上是 1:1,在成本上可能是 1:40。
Token 数(TPM/TPD):最准确,直接对应成本和计算资源。但问题是输出 token 数只有请求完成后才知道,预估需要估算。
Cost(美元):最直观,但换模型时需要更新换算表,维护成本高。
推荐方案:
- 对外暴露给租户的配额单位用 Token(输入+输出总量)
- 内部追踪同时记录 Cost(用于告警阈值和账单)
- 短窗口保护用 RPM(防止突发并发),长窗口配额用 TPD/TPM(防止月度超支)
3.2 用什么存储?
Quota 系统对存储的要求:
- 高并发写(每个请求完成都要更新计数)
- 低延迟读(每个请求前要判断是否超限)
- 支持过期(日配额到期自动重置)
- 原子操作(防止并发导致的超扣)
Redis 是这个场景的标准答案:INCRBY 是原子操作,EXPIREAT 可以精确控制重置时间,读写延迟在 1ms 以内。
数据库(PostgreSQL/MySQL)可以做异步计账,不应该放在请求热路径上。
3.3 在哪里做 Quota 检查?
css
[Client] → [API Gateway] → [Quota Middleware] → [LLM Provider]
↓
[Quota Exceeded]
↓
[Return 429 / Degrade]
Quota 检查必须在 网关层,不能在业务代码里散落。原因:
- 业务代码里很难保证每个调用路径都检查
- 网关层可以统一处理预算、告警、降级,逻辑内聚
- 方便更换 LLM Provider 时不需要改业务代码
四、核心实现:两阶段 Token 计数
这是最反直觉的部分,也是我们踩的第一个坑。
问题:LLM 的输出 token 数量,在请求开始时不知道。你不能在发请求前就把预计的输出 token 扣掉,因为你根本不知道会输出多少。
方案:两阶段计数
vbscript
Phase 1 (Pre-request): 扣除 input_tokens(已知)+ max_tokens(最坏估算)
Phase 2 (Post-response): 退还 (max_tokens - actual_output_tokens) 的差值
python
import redis
import time
from dataclasses import dataclass
@dataclass
class QuotaResult:
allowed: bool
remaining: int
reset_at: int
reservation_id: str | None = None
class TokenQuotaManager:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def _quota_key(self, tenant_id: str, window: str) -> str:
"""构建配额 key,window 可以是 'daily', 'monthly', 'burst'"""
if window == "daily":
day = time.strftime("%Y%m%d")
return f"quota:{tenant_id}:daily:{day}"
elif window == "monthly":
month = time.strftime("%Y%m")
return f"quota:{tenant_id}:monthly:{month}"
elif window == "burst":
minute = int(time.time() // 60)
return f"quota:{tenant_id}:burst:{minute}"
def pre_request_check(
self,
tenant_id: str,
input_tokens: int,
max_output_tokens: int,
tenant_limits: dict
) -> QuotaResult:
"""
请求前检查并预扣配额
返回是否允许,以及预扣的 reservation_id(用于事后退还)
"""
# 保守估算:input + 最大可能输出
estimated_tokens = input_tokens + max_output_tokens
pipe = self.redis.pipeline()
daily_key = self._quota_key(tenant_id, "daily")
burst_key = self._quota_key(tenant_id, "burst")
# 原子性地检查 + 预扣
pipe.get(daily_key)
pipe.get(burst_key)
results = pipe.execute()
daily_used = int(results[0] or 0)
burst_used = int(results[1] or 0)
daily_limit = tenant_limits.get("daily_tokens", 1_000_000)
burst_limit = tenant_limits.get("burst_tpm", 50_000)
# 检查是否超限
if daily_used + estimated_tokens > daily_limit:
return QuotaResult(
allowed=False,
remaining=max(0, daily_limit - daily_used),
reset_at=self._next_day_ts(),
)
if burst_used + estimated_tokens > burst_limit:
return QuotaResult(
allowed=False,
remaining=max(0, burst_limit - burst_used),
reset_at=(int(time.time() // 60) + 1) * 60,
)
# 预扣 + 设置过期
reservation_id = f"{tenant_id}:{time.time_ns()}"
pipe = self.redis.pipeline()
pipe.incrby(daily_key, estimated_tokens)
pipe.expireat(daily_key, self._next_day_ts())
pipe.incrby(burst_key, estimated_tokens)
pipe.expire(burst_key, 60)
pipe.execute()
return QuotaResult(
allowed=True,
remaining=daily_limit - daily_used - estimated_tokens,
reset_at=self._next_day_ts(),
reservation_id=reservation_id,
)
def post_request_settle(
self,
tenant_id: str,
reservation_id: str,
estimated_tokens: int,
actual_tokens: int
):
"""
请求完成后,退还预扣多出来的 token
actual_tokens = input_tokens + actual_output_tokens
"""
delta = estimated_tokens - actual_tokens
if delta <= 0:
return # 实际消耗超过预估(不太可能),不退还
daily_key = self._quota_key(tenant_id, "daily")
# 退还差额(atomic decrby,不会低于 0)
self.redis.decrby(daily_key, delta)
def _next_day_ts(self) -> int:
import datetime
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
return int(datetime.datetime.combine(
tomorrow, datetime.time.min
).timestamp())
坑 #1:预扣逻辑要防止负数
DECRBY 可以让 Redis 的值变成负数,这会导致下一个计费周期刚开始就显示"已超配额"。解决方案:用 Lua 脚本保证 decr 时不低于 0:
lua
-- Redis Lua: safe_decrby.lua
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', key) or 0)
local new_val = math.max(0, current - delta)
redis.call('SET', key, new_val)
return new_val
五、分层 Quota 设计:租户 / 功能 / 用户三层
真实系统里,单层配额不够用。我们后来演化成三层结构:
scss
Tenant (客户公司)
└── Feature (功能模块,如 chat、summarize、code-review)
└── User (该租户下的具体用户)
为什么要分层?
- 租户 A 的
code-review功能在批处理,会吃掉大量配额,而租户 A 的chat用户完全没法用 - 某个高级用户(销售负责人)需要比普通用户高 5 倍的配额
- 某个功能(批量 summarize)应该在配额不足时自动降级,而不是直接 503
python
@dataclass
class QuotaConfig:
tenant_daily_tokens: int
tenant_burst_tpm: int
feature_limits: dict[str, int] # feature -> daily token 上限
user_limits: dict[str, int] # user_tier -> daily token 上限
class HierarchicalQuotaManager:
def check_all_layers(
self,
tenant_id: str,
feature: str,
user_id: str,
user_tier: str,
tokens: int,
config: QuotaConfig,
) -> tuple[bool, str]:
"""
检查三层配额,任意一层超限都拒绝
返回 (allowed, reason)
"""
checks = [
(f"quota:{tenant_id}:daily", config.tenant_daily_tokens, "tenant_daily"),
(f"quota:{tenant_id}:feature:{feature}:daily",
config.feature_limits.get(feature, config.tenant_daily_tokens),
"feature_daily"),
(f"quota:{tenant_id}:user:{user_id}:daily",
config.user_limits.get(user_tier, 100_000),
"user_daily"),
]
for key, limit, layer in checks:
used = int(self.redis.get(key) or 0)
if used + tokens > limit:
return False, f"quota_exceeded:{layer}:{key}"
return True, "ok"
坑 #2:feature 级配额的 key 设计
我们最初用 quota:{tenant}:{feature} 作为 key,但 feature 名称包含用户传入的字符串,被注入了 : 冒号导致 key 解析混乱。教训:feature 名称要做白名单验证 + URL encoding:
python
import re
import urllib.parse
ALLOWED_FEATURES = {"chat", "summarize", "code-review", "translate", "embedding"}
def safe_feature_key(tenant_id: str, feature: str) -> str:
if feature not in ALLOWED_FEATURES:
raise ValueError(f"Unknown feature: {feature}")
return f"quota:{urllib.parse.quote(tenant_id, safe='')}:feature:{feature}:daily"
六、用量预警:三级告警体系
"配额用完了"只是最后的防线,真正有用的是提前告警。我们设计了三级预警:
| 级别 | 触发阈值 | 通知对象 | 行为 |
|---|---|---|---|
| L1 预警 | 用量达到 70% | 租户管理员邮件/Webhook | 通知但不限流 |
| L2 告警 | 用量达到 90% | 租户管理员 + 我们的客成 | 启动软限流(降低并发数) |
| L3 熔断 | 用量达到 100% | 自动熔断,向租户 API 返回 429 | 写入熔断状态,超限请求立即拒绝 |
python
class QuotaAlertManager:
THRESHOLDS = [
(0.70, "L1", "warning"),
(0.90, "L2", "critical"),
(1.00, "L3", "exhausted"),
]
def check_and_alert(
self,
tenant_id: str,
used: int,
limit: int,
alert_config: dict,
):
ratio = used / limit
for threshold, level, severity in self.THRESHOLDS:
alert_key = f"quota_alert:{tenant_id}:{level}"
if ratio >= threshold and not self.redis.exists(alert_key):
# 标记已发送(避免重复告警),TTL 到下一个重置窗口
self.redis.setex(alert_key, 3600 * 24, "sent")
self._send_alert(tenant_id, level, severity, used, limit, alert_config)
if level == "L3":
# 写熔断标志,后续请求不再查 Redis 计数,直接拒绝
circuit_key = f"quota_circuit:{tenant_id}"
self.redis.setex(circuit_key, 3600 * 24, "open")
def is_circuit_open(self, tenant_id: str) -> bool:
"""快速判断熔断状态,不走计数查询"""
return bool(self.redis.exists(f"quota_circuit:{tenant_id}"))
def _send_alert(self, tenant_id, level, severity, used, limit, config):
payload = {
"tenant_id": tenant_id,
"level": level,
"severity": severity,
"used_tokens": used,
"limit_tokens": limit,
"usage_ratio": f"{used/limit:.1%}",
"timestamp": time.time(),
}
# 支持 webhook 告警(让租户自己接)
if webhook_url := config.get("webhook_url"):
import httpx
httpx.post(webhook_url, json=payload, timeout=5)
坑 #3:告警风暴
熔断发生后,如果每个被拒绝的请求都触发告警,在高并发场景下会产生告警风暴(一秒 5000 条告警)。解决方案是:
- 用
SETNX保证同一告警级别在一个计费周期内只发一次 - 熔断后的请求在内存中快速拒绝(不走 Redis 查询,更不触发告警)
- 告警 channel 本身也要做速率限制
python
# 在请求热路径上的快速检查:内存缓存熔断状态
from functools import lru_cache
import threading
class CircuitBreakerCache:
def __init__(self, ttl_seconds=5):
self._cache: dict[str, tuple[bool, float]] = {}
self._lock = threading.Lock()
self.ttl = ttl_seconds
def is_open(self, tenant_id: str, redis_client) -> bool:
now = time.time()
with self._lock:
if tenant_id in self._cache:
value, expiry = self._cache[tenant_id]
if now < expiry:
return value # 本地缓存命中,不访问 Redis
# 本地缓存未命中,查 Redis
is_open = bool(redis_client.exists(f"quota_circuit:{tenant_id}"))
with self._lock:
self._cache[tenant_id] = (is_open, now + self.ttl)
return is_open
这样在熔断状态下,每 5 秒才查一次 Redis,大量请求在内存层直接被拒绝,Redis 压力极小。
七、动态配额调整:不要硬编码配额上限
坑 #4:配额硬编码在代码里
早期我们的配额上限是代码里的常量,改一个租户的配额要发版。三个月后,不同租户的配额已经积累了 40+ 个不同的值,发版时需要同时修改配置和代码,噩梦。
正确做法:配额存 Redis/DB,代码只读取
python
# 配额配置存 Redis Hash
def get_tenant_config(tenant_id: str) -> dict:
config_key = f"tenant_config:{tenant_id}"
config = redis.hgetall(config_key)
if not config:
# fallback 到默认配置
return DEFAULT_QUOTA_CONFIG
return {
"daily_tokens": int(config.get("daily_tokens", 1_000_000)),
"burst_tpm": int(config.get("burst_tpm", 50_000)),
"alert_webhook": config.get("alert_webhook", ""),
"feature_limits": json.loads(config.get("feature_limits", "{}")),
}
# 管理 API:实时调整配额,无需发版
def update_tenant_quota(tenant_id: str, daily_tokens: int, burst_tpm: int):
config_key = f"tenant_config:{tenant_id}"
redis.hset(config_key, mapping={
"daily_tokens": daily_tokens,
"burst_tpm": burst_tpm,
"updated_at": time.time(),
})
# 如果当前租户已熔断,手动扩容后重置熔断状态
redis.delete(f"quota_circuit:{tenant_id}")
redis.delete(f"quota_alert:{tenant_id}:L3")
坑 #5:重置时机和时区
日配额在什么时候重置?UTC 0 点?租户所在时区的 0 点?
我们最初用 UTC,结果欧美租户的"昨日配额"到他们本地时间下午 8 点才重置,和他们"按天计费"的认知不符。后来改成按租户配置时区重置:
python
import pytz
from datetime import datetime, timedelta
def next_reset_timestamp(tenant_id: str, timezone_str: str = "Asia/Shanghai") -> int:
tz = pytz.timezone(timezone_str)
now = datetime.now(tz)
tomorrow = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
return int(tomorrow.timestamp())
并且在 key 的设计上,用租户本地时间的日期而非 UTC 日期:
python
def daily_key(tenant_id: str, timezone_str: str) -> str:
tz = pytz.timezone(timezone_str)
local_date = datetime.now(tz).strftime("%Y%m%d")
return f"quota:{tenant_id}:daily:{local_date}"
八、软降级:配额不足时的优雅处理
硬拒绝(直接返回 429)适合 API 层,但对最终用户体验不友好。我们在 L2 告警(90% 用量)后,加入了软降级策略:
python
class QuotaAwareLLMRouter:
def route_request(
self,
tenant_id: str,
messages: list,
model_preference: str,
quota_ratio: float, # 0.0 ~ 1.0
) -> dict:
if quota_ratio < 0.70:
# 正常:使用首选模型
model = model_preference
elif quota_ratio < 0.90:
# L1 预警:降级到更便宜的模型
model = self._get_cheaper_model(model_preference)
elif quota_ratio < 1.00:
# L2 告警:进一步降级 + 截断输入 context
model = self._get_cheapest_model()
messages = self._truncate_context(messages, max_tokens=2000)
else:
# 配额耗尽:拒绝或队列等待
raise QuotaExhaustedException(tenant_id)
return self._call_llm(model, messages)
def _get_cheaper_model(self, preferred: str) -> str:
"""把 premium 模型换成中等模型"""
downgrade_map = {
"deepseek-r1": "deepseek-v3",
"qwen-max": "qwen-turbo",
"glm-4-plus": "glm-4-flash",
}
return downgrade_map.get(preferred, preferred)
def _truncate_context(self, messages: list, max_tokens: int) -> list:
"""保留 system prompt 和最近 N 条消息,减少 token 消耗"""
system_msgs = [m for m in messages if m["role"] == "system"]
other_msgs = [m for m in messages if m["role"] != "system"]
# 从最新消息往前取,保证最近对话完整
truncated = []
total = 0
for msg in reversed(other_msgs):
est_tokens = len(msg["content"]) // 4 # 粗估
if total + est_tokens > max_tokens:
break
truncated.insert(0, msg)
total += est_tokens
return system_msgs + truncated
这样,在配额吃紧时,用户还能继续使用,只是响应质量略降(用了便宜模型,或者上下文被截断)。比直接 503 友好很多,续约率明显提升。
九、完整流程图
scss
[Request]
↓
[Circuit Breaker Check] ──── open ──→ [Return 429 immediately]
↓ closed
[Pre-request Quota Check]
├── burst exceeded ──→ [Return 429 + Retry-After header]
├── daily exceeded ──→ [Return 429 + reset_at timestamp]
└── ok (with reservation_id)
↓
[Route to LLM] ──── error ──→ [Release reservation, return error]
↓ success
[Post-request Settle] (退还多扣的 token)
↓
[Async: update usage DB, check alert thresholds]
↓
[If threshold crossed: send alert, maybe open circuit]
十、监控与可观测性
Quota 系统自身也需要被监控。我们在每个请求完成后,上报以下指标:
python
# 上报到 metrics 系统(Prometheus/Datadog/etc.)
def emit_quota_metrics(tenant_id: str, feature: str, tokens_used: int, quota_ratio: float):
metrics.increment(
"llm.quota.tokens_used",
value=tokens_used,
tags={"tenant": tenant_id, "feature": feature}
)
metrics.gauge(
"llm.quota.usage_ratio",
value=quota_ratio,
tags={"tenant": tenant_id}
)
if quota_ratio >= 0.90:
metrics.increment(
"llm.quota.near_exhaustion_requests",
tags={"tenant": tenant_id}
)
关键 Dashboard 指标:
- Top 10 Token 消耗租户(实时):谁在烧钱
- 配额使用率分布(直方图):多少租户快用完了
- 熔断事件计数:上周触发了几次,哪些租户
- 软降级触发率:L2 预警后有多少请求用了便宜模型
十一、总结:5 个核心设计决策
| 决策点 | 推荐方案 | 避坑原因 |
|---|---|---|
| 配额单位 | Token 数(TPD)+ 辅助 RPM | 请求数无法反映真实资源消耗 |
| 存储 | Redis INCRBY + EXPIREAT | 原子操作 + 低延迟,数据库做异步账单 |
| 计数时机 | 两阶段:预扣 + 事后结算 | 输出 token 数不可预知,预扣需退还 |
| 超限处理 | 三层:预警 / 软降级 / 熔断 | 硬拒绝伤用户体验,分层处理更优雅 |
| 配额配置 | Redis/DB 动态配置,运行时可改 | 硬编码在代码里改一次发一次版 |
Quota 工程看起来不性感,但它是多租户 LLM 应用稳定运行的地基。Provider 的限制只保护他们自己,你的租户隔离、预算控制、用量预警都需要你自己搭。
从一个 Redis key 开始,把这套逻辑一层层加上去,比等到大客户的 CTO 发怒邮件之后再补要好得多。
参考资料
- Portkey AI: Rate limiting for LLM applications
- AgentGateway: Provider Rate Limiting is Not Enough for Enterprise LLM Usage
- Red Hat Developers: Manage AI resource use with TokenRateLimitPolicy
- arxiv: Token Management in Multi-Tenant AI Inference Platforms
- dev.to: Rate Limiting in LLM Applications: Why You Need It and How to Build It