LLM 多租户 Quota 工程实践:Token 配额、用量预警与自动熔断的生产设计

一、问题是怎么暴露的

我们的 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 检查必须在 网关层,不能在业务代码里散落。原因:

  1. 业务代码里很难保证每个调用路径都检查
  2. 网关层可以统一处理预算、告警、降级,逻辑内聚
  3. 方便更换 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 条告警)。解决方案是:

  1. SETNX 保证同一告警级别在一个计费周期内只发一次
  2. 熔断后的请求在内存中快速拒绝(不走 Redis 查询,更不触发告警)
  3. 告警 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 发怒邮件之后再补要好得多。


参考资料

相关推荐
懿路向前1 小时前
【HarmonyOS学习笔记】2026-08-05 | 端插件卡片绑定与跨上下文判断
笔记·学习·ai编程·harmonyos
Ivanqhz1 小时前
php7 闭包实现
开发语言·后端·rust·php
Lei活在当下7 小时前
如何在Windows环境选择适合自己的 AI Agent
chatgpt·agent·ai编程
有梦不弃9 小时前
模块化单体架构设计方案:DDD + 六边形架构落地实践
后端·架构
To_OC9 小时前
我把《天龙八部》塞进向量数据库后,终于搞懂了 RAG 到底是个啥
人工智能·llm·agent
ajassi200011 小时前
AI语音智能体开发日记(十一)为智能设备“声”临其境——详解音频资源自动化生成流程
人工智能·ai·ai编程
GoAI12 小时前
# AI Agent 记忆框架横向对比报告总结
人工智能·大模型·llm·多模态
viva517213 小时前
Vibe/各规则定义的顺序和内容
ai编程
江南十四行13 小时前
Spring框架核心(上)——IoC控制反转与DI依赖注入详解
java·后端·spring