LLM 应用的 Rate Limit 工程实践:令牌桶、滑动窗口与 API 配额管理的生产设计

你以为 retry 几次就搞定了限流问题,直到某个高峰夜晚你的服务在大模型 API 429 上抖了整整两小时。

为什么 LLM 限流和普通 API 限流不一样

大多数工程师第一次遇到 LLM API 限流时,下意识的反应是:加个 exponential backoff 就行了。这在传统 REST API 限流场景确实够用。但 LLM 的限流有几个独特的维度,让这个思路在生产中很快会崩:

1. 双重计量:LLM API 通常同时限 RPM(请求/分钟)和 TPM(Token/分钟)。你的请求数没超,但一个超长 Prompt 把 TPM 打满,一样返回 429。

2. 计量不对称:你在请求发出前不知道 completion token 会有多少。你预估 200 token,模型可能吐 800。这让预防性限流比普通 API 困难得多。

3. 多租户隔离需求:你的 LLM 应用可能服务 100 个租户,但你买的是一个大模型厂商账户的配额。某个大客户打爆配额,其他所有人都跟着受影响。

4. 排队成本高:LLM 请求本身延迟就高(秒级),如果再在限流队列里等 30 秒,用户体验直接崩溃。

5. 成本放大效应:限流失败后的重试会产生额外 Token 消耗(重传 Prompt),在高并发场景下会造成成本雪崩。

本文从生产实战出发,讲清楚 LLM 限流的核心问题和工程设计。


一、理解你面对的限流类型

1.1 上游 API 限流(Provider-side)

国内外大模型厂商的限流是最直接的外部约束。以某主流大模型服务为例,标准账号有以下典型限制:

yaml 复制代码
高性能模型(如 DeepSeek-V3):
  RPM: 10,000
  TPM: 30,000,000
  RPD: 无限制

高速低成本模型(如 Qwen-Turbo):
  RPM: 30,000
  TPM: 150,000,000

这些限制是在 Org 级别的,不是单个 API key。你可以有多个 key,但共享同一个配额池。

1.2 自建应用层限流

为了保护下游(保护用户体验、控制成本、实现租户隔离),你还需要在自己的应用层构建限流。这是一个主动的工程选择,而不是被动的 429 处理。

1.3 两层限流的关系

css 复制代码
用户请求
    ↓
[应用层限流] ← 你控制的,用于租户隔离和用户体验保护
    ↓
[Provider 层限流] ← 大模型厂商控制的,你只能适应
    ↓
LLM 返回

好的设计应该让应用层限流在大多数情况下拦截住不必要的请求,使 Provider 层的 429 成为罕见事件。


二、常见限流算法及 LLM 场景适用性

2.1 固定窗口计数器(Fixed Window)

最简单:每分钟重置一次计数器。

python 复制代码
import time
import redis

class FixedWindowRateLimiter:
    def __init__(self, redis_client, limit: int, window_seconds: int = 60):
        self.redis = redis_client
        self.limit = limit
        self.window_seconds = window_seconds
    
    def is_allowed(self, key: str) -> bool:
        window_key = f"ratelimit:{key}:{int(time.time() // self.window_seconds)}"
        count = self.redis.incr(window_key)
        if count == 1:
            self.redis.expire(window_key, self.window_seconds * 2)
        return count <= self.limit

LLM 场景问题:窗口边界的突刺效应(burst)。59 秒时允许 10000 个请求,第 61 秒又允许 10000 个,短时间内实际 20000 个请求打到上游,直接触发 Provider 限流。

2.2 滑动窗口(Sliding Window)

用时间戳记录每次请求,检查"过去 N 秒内"的请求数。

python 复制代码
import time
import redis

class SlidingWindowRateLimiter:
    def __init__(self, redis_client, limit: int, window_seconds: int = 60):
        self.redis = redis_client
        self.limit = limit
        self.window_seconds = window_seconds
    
    def is_allowed(self, key: str) -> tuple[bool, int]:
        now = time.time()
        window_start = now - self.window_seconds
        pipe_key = f"sw:{key}"
        
        with self.redis.pipeline() as pipe:
            pipe.zremrangebyscore(pipe_key, 0, window_start)
            pipe.zcard(pipe_key)
            pipe.zadd(pipe_key, {str(now): now})
            pipe.expire(pipe_key, self.window_seconds * 2)
            results = pipe.execute()
        
        count = results[1]
        if count >= self.limit:
            # 移除刚刚加入的请求
            self.redis.zrem(pipe_key, str(now))
            return False, self.limit - count
        
        return True, self.limit - count - 1
    
    def get_remaining(self, key: str) -> int:
        now = time.time()
        window_start = now - self.window_seconds
        self.redis.zremrangebyscore(f"sw:{key}", 0, window_start)
        count = self.redis.zcard(f"sw:{key}")
        return max(0, self.limit - count)

LLM 场景问题:滑动窗口解决了突刺问题,但仍然是纯 RPM 限流,没有处理 TPM(Token 级限流)。

2.3 令牌桶(Token Bucket)

允许有限度的突发流量,同时保证长期平均速率。

python 复制代码
import time
import redis
import json

class TokenBucketRateLimiter:
    """
    令牌桶实现:支持 RPM 和 TPM 双维度限流
    """
    
    def __init__(
        self, 
        redis_client,
        rpm_limit: int,
        tpm_limit: int,
        refill_rate_rps: float = None,  # tokens/second for requests
        refill_rate_tps: float = None,  # tokens/second for LLM tokens
    ):
        self.redis = redis_client
        self.rpm_limit = rpm_limit
        self.tpm_limit = tpm_limit
        # 每秒补充速率
        self.refill_rate_rps = refill_rate_rps or (rpm_limit / 60.0)
        self.refill_rate_tps = refill_rate_tps or (tpm_limit / 60.0)
    
    def consume(self, key: str, estimated_tokens: int = 0) -> tuple[bool, dict]:
        """
        尝试消耗配额
        estimated_tokens: 预估 Token 用量(发送前不知道 completion 多少,用预估值)
        返回: (是否允许, 状态信息)
        """
        now = time.time()
        bucket_key = f"tb:{key}"
        
        # Lua 脚本保证原子性
        lua_script = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local rpm_limit = tonumber(ARGV[2])
        local tpm_limit = tonumber(ARGV[3])
        local rps = tonumber(ARGV[4])
        local tps = tonumber(ARGV[5])
        local estimated_tokens = tonumber(ARGV[6])
        
        local data = redis.call('GET', key)
        local bucket
        
        if data then
            bucket = cjson.decode(data)
        else
            bucket = {
                req_tokens = rpm_limit,
                llm_tokens = tpm_limit,
                last_refill = now
            }
        end
        
        -- 补充令牌
        local elapsed = now - bucket.last_refill
        bucket.req_tokens = math.min(rpm_limit, bucket.req_tokens + elapsed * rps)
        bucket.llm_tokens = math.min(tpm_limit, bucket.llm_tokens + elapsed * tps)
        bucket.last_refill = now
        
        -- 检查是否可以消耗
        if bucket.req_tokens >= 1 and bucket.llm_tokens >= estimated_tokens then
            bucket.req_tokens = bucket.req_tokens - 1
            bucket.llm_tokens = bucket.llm_tokens - estimated_tokens
            redis.call('SET', key, cjson.encode(bucket), 'EX', 120)
            return {1, bucket.req_tokens, bucket.llm_tokens}
        else
            redis.call('SET', key, cjson.encode(bucket), 'EX', 120)
            return {0, bucket.req_tokens, bucket.llm_tokens}
        end
        """
        
        result = self.redis.eval(
            lua_script, 1, bucket_key,
            now, self.rpm_limit, self.tpm_limit,
            self.refill_rate_rps, self.refill_rate_tps,
            estimated_tokens
        )
        
        allowed = bool(result[0])
        return allowed, {
            "req_tokens_remaining": float(result[1]),
            "llm_tokens_remaining": float(result[2])
        }
    
    def record_actual_usage(self, key: str, actual_tokens: int, estimated_tokens: int):
        """
        请求完成后,用实际 Token 修正令牌桶
        actual_tokens > estimated_tokens 时,追加扣减
        """
        delta = actual_tokens - estimated_tokens
        if delta <= 0:
            return  # 实际用量少于预估,不需要额外扣减
        
        bucket_key = f"tb:{key}"
        lua_script = """
        local key = KEYS[1]
        local delta = tonumber(ARGV[1])
        local data = redis.call('GET', key)
        if data then
            local bucket = cjson.decode(data)
            bucket.llm_tokens = math.max(0, bucket.llm_tokens - delta)
            redis.call('SET', key, cjson.encode(bucket), 'EX', 120)
        end
        return 1
        """
        self.redis.eval(lua_script, 1, bucket_key, delta)

注意:令牌桶中的"令牌"有两层含义------请求配额令牌和 LLM Token 配额令牌,不要混淆。

2.4 漏桶(Leaky Bucket)

以固定速率处理请求,超出的进入队列或直接丢弃。适合 LLM 场景中需要平滑输出的批处理任务,不适合对延迟敏感的交互式请求。


三、LLM 专属的 Token 级限流设计

3.1 预算估算问题

LLM 请求发出前,你知道 input token 数,但不知道 output token 数。常见的处理方式:

方案 A:保守预估

python 复制代码
def estimate_tokens(prompt: str, max_tokens: int) -> int:
    """
    保守估算:按最大可能消耗计算
    input_tokens + max_output_tokens
    """
    input_tokens = len(prompt) // 4  # 粗估,英文约 4 字符/token
    return input_tokens + max_tokens  # 最坏情况

# 优点:绝对不会超配额
# 缺点:Token 利用率低,实际用 200 token 却扣了 800

方案 B:历史均值预估

python 复制代码
import numpy as np

class TokenEstimator:
    def __init__(self, redis_client, percentile: int = 75):
        self.redis = redis_client
        self.percentile = percentile
    
    def estimate(self, model: str, prompt_hash: str, input_tokens: int) -> int:
        # 查历史完成比率
        ratio_key = f"token_ratio:{model}"
        ratios = self.redis.lrange(ratio_key, 0, 99)  # 最近 100 条
        
        if len(ratios) < 10:
            # 数据不足,用 2x 输入 Token 作为保守估计
            return input_tokens * 2
        
        completion_ratios = [float(r) for r in ratios]
        # 用 P75 而不是均值,更保守
        p75_ratio = np.percentile(completion_ratios, self.percentile)
        
        return int(input_tokens * p75_ratio)
    
    def record(self, model: str, input_tokens: int, output_tokens: int):
        ratio = output_tokens / max(input_tokens, 1)
        ratio_key = f"token_ratio:{model}"
        self.redis.lpush(ratio_key, ratio)
        self.redis.ltrim(ratio_key, 0, 499)  # 保留最近 500 条
        self.redis.expire(ratio_key, 86400)

方案 C:请求完成后修正(推荐配合令牌桶使用)

先按预估扣减,请求完成后拿到实际 Token 数再做差额修正。这正是上面 record_actual_usage 的用途。

3.2 分层 Token 配额

一个多租户 LLM 应用的 Token 配额通常分三层:

yaml 复制代码
┌─────────────────────────────────────┐
│     Org 级配额(来自 Provider)       │
│  TPM: 30,000,000                    │
└──────────────┬──────────────────────┘
               │ 按优先级/SLA 分配
    ┌──────────┴─────────────┐
    │                        │
┌───▼────────────┐  ┌────────▼────────┐
│ 高优先租户池    │  │  标准租户池      │
│ TPM: 20,000,000│  │ TPM: 10,000,000 │
└───┬────────────┘  └────────┬────────┘
    │ 每租户上限              │ 每租户上限
┌───▼────┐ ┌─────┐  ┌────────▼──┐
│租户A   │ │租户B│  │租户C/D/.. │
│TPM:5M  │ │TPM:3M│ │TPM:各1M  │
└────────┘ └─────┘  └───────────┘

实现时,每一层都是独立的限流器,请求需要同时通过所有层:

python 复制代码
class HierarchicalRateLimiter:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.org_limiter = TokenBucketRateLimiter(
            redis_client, rpm_limit=10000, tpm_limit=30_000_000
        )
        self.tier_limiters = {
            "premium": TokenBucketRateLimiter(
                redis_client, rpm_limit=6000, tpm_limit=20_000_000
            ),
            "standard": TokenBucketRateLimiter(
                redis_client, rpm_limit=4000, tpm_limit=10_000_000
            )
        }
        self.tenant_limits = {
            "premium": {"rpm": 500, "tpm": 5_000_000},
            "standard": {"rpm": 100, "tpm": 1_000_000}
        }
    
    def check(
        self, 
        tenant_id: str, 
        tenant_tier: str, 
        estimated_tokens: int
    ) -> tuple[bool, str]:
        # 1. Org 级检查
        ok, _ = self.org_limiter.consume("global", estimated_tokens)
        if not ok:
            return False, "org_limit_exceeded"
        
        # 2. Tier 级检查
        tier_limiter = self.tier_limiters.get(tenant_tier)
        if tier_limiter:
            ok, _ = tier_limiter.consume(f"tier:{tenant_tier}", estimated_tokens)
            if not ok:
                return False, f"tier_{tenant_tier}_limit_exceeded"
        
        # 3. 租户级检查
        tenant_limit = self.tenant_limits.get(tenant_tier, {})
        tenant_limiter = TokenBucketRateLimiter(
            self.redis,
            rpm_limit=tenant_limit.get("rpm", 100),
            tpm_limit=tenant_limit.get("tpm", 1_000_000)
        )
        ok, _ = tenant_limiter.consume(f"tenant:{tenant_id}", estimated_tokens)
        if not ok:
            return False, "tenant_limit_exceeded"
        
        return True, "allowed"

四、Provider 429 的正确处理姿势

4.1 读懂 429 响应头

主流大模型 API 的 429 响应头通常包含关键信息(以常见格式为例):

makefile 复制代码
x-ratelimit-limit-requests: 10000
x-ratelimit-limit-tokens: 30000000
x-ratelimit-remaining-requests: 0
x-ratelimit-remaining-tokens: 428943
x-ratelimit-reset-requests: 6ms
x-ratelimit-reset-tokens: 1.23s
retry-after: 2

注意区分:reset-requests 很短(毫秒级),但 reset-tokens 可能很长(秒级)。不读这些 header 就 retry,往往等的时间不对。

python 复制代码
import httpx
import asyncio
from dataclasses import dataclass

@dataclass
class RateLimitInfo:
    limit_requests: int = 0
    limit_tokens: int = 0
    remaining_requests: int = 0
    remaining_tokens: int = 0
    reset_requests_ms: float = 0
    reset_tokens_ms: float = 0
    retry_after_ms: float = 1000

def parse_rate_limit_headers(headers: dict) -> RateLimitInfo:
    info = RateLimitInfo()
    
    def parse_ms(value: str) -> float:
        """解析 '6ms', '1.23s', '2m30s' 等格式"""
        if not value:
            return 1000
        value = value.strip()
        total_ms = 0
        # 处理分钟
        if 'm' in value and 's' not in value.split('m')[-1]:
            parts = value.split('m')
            total_ms += float(parts[0]) * 60000
            return total_ms
        # 处理秒
        if 's' in value:
            if 'ms' in value:
                total_ms = float(value.replace('ms', ''))
            else:
                total_ms = float(value.replace('s', '')) * 1000
        return max(total_ms, 100)  # 最少等 100ms
    
    info.limit_requests = int(headers.get('x-ratelimit-limit-requests', 0) or 0)
    info.limit_tokens = int(headers.get('x-ratelimit-limit-tokens', 0) or 0)
    info.remaining_requests = int(headers.get('x-ratelimit-remaining-requests', 0) or 0)
    info.remaining_tokens = int(headers.get('x-ratelimit-remaining-tokens', 0) or 0)
    info.reset_requests_ms = parse_ms(headers.get('x-ratelimit-reset-requests', ''))
    info.reset_tokens_ms = parse_ms(headers.get('x-ratelimit-reset-tokens', ''))
    
    retry_after = headers.get('retry-after', '')
    if retry_after:
        try:
            info.retry_after_ms = float(retry_after) * 1000
        except ValueError:
            info.retry_after_ms = 1000
    
    return info

async def llm_request_with_smart_retry(
    client: httpx.AsyncClient,
    request_data: dict,
    max_retries: int = 3
) -> dict:
    for attempt in range(max_retries + 1):
        try:
            response = await client.post(
                "https://api.deepseek.com/v1/chat/completions",  # 以 DeepSeek 兼容接口为例
                json=request_data
            )
            
            if response.status_code == 429:
                rl_info = parse_rate_limit_headers(dict(response.headers))
                
                if attempt >= max_retries:
                    raise Exception(f"Rate limit exceeded after {max_retries} retries")
                
                # 智能等待:取 reset 时间和 retry-after 中的较大值
                wait_ms = max(
                    rl_info.reset_requests_ms,
                    rl_info.reset_tokens_ms,
                    rl_info.retry_after_ms
                )
                
                # 加一点 jitter 避免惊群
                import random
                jitter = random.uniform(0, wait_ms * 0.1)
                wait_s = (wait_ms + jitter) / 1000
                
                print(f"[RateLimit] 429 received, waiting {wait_s:.2f}s "
                      f"(remaining: {rl_info.remaining_requests} req, "
                      f"{rl_info.remaining_tokens} tokens)")
                
                await asyncio.sleep(wait_s)
                continue
            
            response.raise_for_status()
            return response.json()
            
        except httpx.TimeoutException:
            if attempt >= max_retries:
                raise
            await asyncio.sleep(2 ** attempt)
    
    raise Exception("Unexpected retry loop exit")

4.2 指数退避 + Jitter 的正确写法

常见错误写法:所有并发请求在 429 后同时等 2 秒,然后同时重试,再次打爆。

python 复制代码
# ❌ 错误:全部同时 sleep 相同时间
async def bad_retry(client, request_data, attempt):
    await asyncio.sleep(2 ** attempt)  # 无 jitter,惊群

# ✅ 正确:Full Jitter(AWS 建议的标准做法)
import random

async def good_retry(
    client, 
    request_data, 
    attempt: int,
    base_delay: float = 0.5,
    max_delay: float = 60.0
) -> dict:
    # Full Jitter: sleep 在 [0, min(cap, base * 2^attempt)] 之间随机
    cap = min(max_delay, base_delay * (2 ** attempt))
    wait = random.uniform(0, cap)
    await asyncio.sleep(wait)
    return await client.post(...)

五、应用层限流的降级策略

429 不仅来自 Provider,你自己的应用层限流触发时也需要定义降级行为,而不是直接返回 500。

5.1 四种降级策略

python 复制代码
from enum import Enum
from typing import Optional, Any

class DegradationStrategy(Enum):
    REJECT = "reject"           # 直接拒绝,返回错误
    QUEUE = "queue"             # 进入等待队列
    FALLBACK = "fallback"       # 降级到备用方案
    THROTTLE = "throttle"       # 限速但不拒绝

class LLMRateLimitedGateway:
    def __init__(self, limiter, queue, fallback_fn=None):
        self.limiter = limiter
        self.queue = queue
        self.fallback_fn = fallback_fn
    
    async def request(
        self,
        tenant_id: str,
        prompt: str,
        strategy: DegradationStrategy = DegradationStrategy.QUEUE,
        timeout_seconds: float = 30.0,
        **kwargs
    ) -> dict:
        estimated_tokens = self._estimate_tokens(prompt)
        allowed, reason = self.limiter.check(tenant_id, "standard", estimated_tokens)
        
        if allowed:
            return await self._do_llm_call(prompt, **kwargs)
        
        # 限流触发,按策略处理
        if strategy == DegradationStrategy.REJECT:
            raise RateLimitError(f"Rate limited: {reason}", retry_after=60)
        
        elif strategy == DegradationStrategy.QUEUE:
            # 进入优先级队列,等待配额
            job_id = await self.queue.enqueue(
                tenant_id=tenant_id,
                prompt=prompt,
                priority=self._get_tenant_priority(tenant_id),
                timeout=timeout_seconds
            )
            return await self._wait_for_result(job_id, timeout_seconds)
        
        elif strategy == DegradationStrategy.FALLBACK:
            if self.fallback_fn:
                # 降级到本地小模型、缓存结果或规则引擎
                return await self.fallback_fn(prompt, **kwargs)
            raise RateLimitError("Rate limited, no fallback available")
        
        elif strategy == DegradationStrategy.THROTTLE:
            # 计算需要等待的时间,sleep 后重试一次
            wait_time = self._calculate_wait_time(tenant_id)
            if wait_time > timeout_seconds:
                raise RateLimitError(f"Wait time {wait_time}s exceeds timeout")
            await asyncio.sleep(wait_time)
            return await self._do_llm_call(prompt, **kwargs)
        
        raise ValueError(f"Unknown strategy: {strategy}")
    
    def _estimate_tokens(self, prompt: str) -> int:
        return len(prompt) // 4 + 200  # 粗估
    
    def _get_tenant_priority(self, tenant_id: str) -> int:
        # 高级租户给更高优先级
        return 10 if tenant_id in self.premium_tenants else 5
    
    def _calculate_wait_time(self, tenant_id: str) -> float:
        # 从限流器状态估算恢复时间
        return 5.0  # 简化示例

5.2 队列设计的关键点

LLM 限流队列和普通任务队列有几点不同:

python 复制代码
import heapq
import asyncio
from dataclasses import dataclass, field
from typing import Optional

@dataclass(order=True)
class QueuedLLMRequest:
    priority: int          # 越小优先级越高(堆排序)
    enqueued_at: float     # 入队时间(用于同优先级 FIFO)
    deadline: float        # 绝对超时时间(不能无限等)
    tenant_id: str = field(compare=False)
    request_id: str = field(compare=False)
    prompt: str = field(compare=False)
    estimated_tokens: int = field(compare=False, default=0)

class LLMRequestQueue:
    def __init__(self, max_wait_seconds: float = 30.0):
        self._heap: list[QueuedLLMRequest] = []
        self._max_wait = max_wait_seconds
        self._results: dict[str, asyncio.Future] = {}
    
    async def enqueue(
        self, 
        tenant_id: str, 
        prompt: str, 
        priority: int = 5,
        timeout: float = 30.0
    ) -> str:
        import uuid, time
        request_id = str(uuid.uuid4())
        now = time.time()
        
        req = QueuedLLMRequest(
            priority=-priority,  # 取反:值越小 heapq 越先出
            enqueued_at=now,
            deadline=now + min(timeout, self._max_wait),
            tenant_id=tenant_id,
            request_id=request_id,
            prompt=prompt,
            estimated_tokens=len(prompt) // 4
        )
        
        heapq.heappush(self._heap, req)
        future = asyncio.get_event_loop().create_future()
        self._results[request_id] = future
        
        return request_id
    
    def get_next(self) -> Optional[QueuedLLMRequest]:
        """
        取下一个有效请求(跳过已超时的)
        """
        import time
        now = time.time()
        
        while self._heap:
            req = self._heap[0]
            if req.deadline < now:
                # 超时,丢弃并通知调用方
                heapq.heappop(self._heap)
                future = self._results.pop(req.request_id, None)
                if future and not future.done():
                    future.set_exception(TimeoutError(
                        f"Request {req.request_id} expired in queue"
                    ))
                continue
            return heapq.heappop(self._heap)
        
        return None

六、可观测性:限流不是黑盒

限流系统必须暴露足够的指标,否则你不知道是限流太松(浪费)还是太紧(影响用户)。

6.1 核心指标

python 复制代码
from prometheus_client import Counter, Histogram, Gauge

# 限流决策计数
rate_limit_decisions = Counter(
    'llm_rate_limit_decisions_total',
    'Total rate limit decisions',
    ['tenant_id', 'tier', 'decision', 'reason']
)

# 请求等待时间分布
rate_limit_wait_seconds = Histogram(
    'llm_rate_limit_wait_seconds',
    'Time spent waiting due to rate limiting',
    ['tenant_id'],
    buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60]
)

# 当前队列深度
rate_limit_queue_depth = Gauge(
    'llm_rate_limit_queue_depth',
    'Current depth of rate limit queue',
    ['tier']
)

# Token 使用率
token_utilization = Gauge(
    'llm_token_utilization_ratio',
    'Current token bucket utilization',
    ['tenant_id', 'limit_type']
)

class InstrumentedRateLimiter:
    def __init__(self, base_limiter):
        self.base = base_limiter
    
    def check(self, tenant_id: str, tier: str, estimated_tokens: int):
        import time
        start = time.time()
        allowed, reason = self.base.check(tenant_id, tier, estimated_tokens)
        elapsed = time.time() - start
        
        rate_limit_decisions.labels(
            tenant_id=tenant_id,
            tier=tier,
            decision="allowed" if allowed else "blocked",
            reason=reason
        ).inc()
        
        if not allowed:
            rate_limit_wait_seconds.labels(tenant_id=tenant_id).observe(0)
        
        return allowed, reason

6.2 关键 Alert 规则

yaml 复制代码
# Prometheus alert rules for LLM rate limiting
groups:
  - name: llm_rate_limit
    rules:
      # 某租户连续 5 分钟被限流率超 20%
      - alert: TenantHighRateLimitRate
        expr: |
          rate(llm_rate_limit_decisions_total{decision="blocked"}[5m])
          / rate(llm_rate_limit_decisions_total[5m]) > 0.2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Tenant {{ $labels.tenant_id }} rate limited >20% of requests"
      
      # Org 级 Token 利用率超 85%(快到配额上限了)
      - alert: OrgTokenUtilizationHigh
        expr: llm_token_utilization_ratio{tenant_id="global"} > 0.85
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Org token utilization at {{ $value | humanizePercentage }}"
      
      # 队列积压超过 100 个请求
      - alert: RateLimitQueueBacklog
        expr: llm_rate_limit_queue_depth > 100
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Rate limit queue backlog: {{ $value }} requests"

七、常见生产踩坑

7.1 多实例竞争(无中心化限流器)

python 复制代码
# ❌ 问题:每个实例维护本地计数器,N 个实例就是 N 倍的实际配额
class LocalRateLimiter:
    def __init__(self, limit):
        self.count = 0  # 只在本进程内有效!
        self.limit = limit
    
    def is_allowed(self) -> bool:
        if self.count >= self.limit:
            return False
        self.count += 1
        return True

# ✅ 解法:所有实例共享 Redis 中心化限流器(本文所有代码均如此)

7.2 Prompt Caching 影响计费但不影响 Rate Limit

部分大模型服务的 Prompt Caching 可以减少计费 Token,但不一定减少 Rate Limit 计数。一个被缓存的 10K Token 请求,在 Rate Limit 角度仍然可能消耗 10K TPM。很多工程师误以为命中缓存就不算配额,在做 Token 预算时遗漏了这一点。

7.3 Streaming 响应的 Token 计数问题

流式响应(SSE)中,usage 字段通常只在最后一个 chunk 出现(且需要 stream_options: {include_usage: true} 才有)。

python 复制代码
async def stream_with_token_tracking(client, request_data, limiter, tenant_id):
    actual_tokens = 0
    estimated_tokens = request_data.get("_estimated_tokens", 0)
    
    async with client.stream("POST", "/v1/chat/completions", json=request_data) as response:
        async for line in response.aiter_lines():
            if line.startswith("data: "):
                chunk = json.loads(line[6:])
                
                # 提取实际 usage(只在最后一个 chunk 中)
                if usage := chunk.get("usage"):
                    actual_tokens = usage.get("total_tokens", 0)
                
                yield chunk
    
    # 流结束后修正令牌桶
    if actual_tokens > 0 and actual_tokens != estimated_tokens:
        limiter.record_actual_usage(tenant_id, actual_tokens, estimated_tokens)

7.4 共享 API Key 的隐患

多个服务共用一个大模型服务账号(同一账户下的多个 Key),它们共享 Rate Limit 配额。一个批处理任务打满 TPM,会影响所有在线服务。

工程上的解法:

  1. Key 隔离:在线服务和批处理用不同的 Key(同一 Org 不同 Key,配额是独立的?注意:不一定,取决于 Provider)
  2. 时间隔离:批处理任务在业务低峰期(夜间)运行
  3. TPM 软限制:批处理任务主动把自己的 TPM 上限设为 Org 总配额的 50%,留给在线服务
python 复制代码
class BatchJobRateLimiter:
    """
    批处理任务自我限制,保留一半配额给在线服务
    """
    def __init__(self, redis_client, org_tpm: int, batch_tpm_ratio: float = 0.5):
        self.limiter = TokenBucketRateLimiter(
            redis_client,
            rpm_limit=int(10000 * batch_tpm_ratio),
            tpm_limit=int(org_tpm * batch_tpm_ratio)
        )
    
    async def process_batch(self, items: list) -> list:
        results = []
        for item in items:
            # 检查配额
            allowed, status = self.limiter.consume("batch", estimated_tokens=item.est_tokens)
            if not allowed:
                # 等待并自适应降速
                await asyncio.sleep(5)
                continue
            
            result = await self.process_item(item)
            results.append(result)
        
        return results

八、工程决策清单

在实现 LLM 限流时,这些问题应该先想清楚:

决策点 选项 推荐
限流维度 纯 RPM / RPM+TPM 双维度,TPM 是成本核心
限流算法 固定窗口 / 滑动窗口 / 令牌桶 令牌桶,支持有限突发
限流粒度 全局 / Tier / 租户 分层,至少到租户级
Token 预估 保守上限 / 历史均值 / 动态修正 历史 P75 + 实际修正
429 处理 固定 sleep / 指数退避 / Header-driven Header-driven + Full Jitter
降级策略 直接拒绝 / 队列 / Fallback 按用例:交互式排队+超时,批处理自我限速
存储后端 进程内 / Redis Redis,多实例一致性
可观测性 无 / 日志 / 指标+Alert 指标+Alert,限流是核心 SLO 指标

总结

LLM 限流的核心困难在于:计量不对称(output token 不可预知)、双维度限制(RPM+TPM)、多租户隔离需求,以及 Provider 429 的复杂处理逻辑。

一个成熟的 LLM 限流系统应该:

  1. 应用层主动限流:不依赖 Provider 429 驱动,用令牌桶在本地拦截超量请求
  2. 双维度计量:RPM 和 TPM 同时追踪,Token 预估采用历史 P75 + 完成后修正
  3. 分层配额:Org → Tier → 租户,隔离不同优先级的流量
  4. 智能 429 处理:读 Header,按 reset 时间等待,Full Jitter 避免惊群
  5. 降级有路:限流触发后有明确的降级路径(队列/Fallback/节流),不是直接崩溃
  6. 可观测:每次限流决策都有指标,告警规则覆盖高利用率和积压场景

限流不是"加个 retry",是 LLM 应用成本控制和用户体验保障的基础设施。

相关推荐
今日无bug2 小时前
MCP 入门实战:Tool 和 LLM 解耦?跨进程跨语言调用工具原来是这么回事
llm·agent·mcp
武子康2 小时前
拆开 Pi Monorepo:改模型、循环、产品和 UI 时,代码应该放在哪一层
人工智能·llm·agent
武子康2 小时前
一次 Agent 失败后,到底该改模型、Prompt 还是 Router?
人工智能·llm·agent
谢白羽3 小时前
SGLang模型加载过程笔记
笔记·llm·论文·agent·vllm·大模型部署·sglang
xing-xing4 小时前
AutoDL部署大模型dots.ocr
语言模型·llm
魔术师Grace12 小时前
Agent总出错,什么时候才该训练模型?
llm·agent·强化学习
Flynt17 小时前
上周我在OpenRouter上用了个匿名模型,结果账单告诉我它是国产的
llm·ai编程·chatglm (智谱)
@atweiwei20 小时前
用 Rust 构建 Agent 应用的高性能框架:langchainrust 架构全景
人工智能·架构·rust·langchain·llm·agent·ai编程
武子康20 小时前
看不见的 Reasoning State,为什么不能拥有看得见的工具权限
人工智能·llm·agent