你以为 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,会影响所有在线服务。
工程上的解法:
- Key 隔离:在线服务和批处理用不同的 Key(同一 Org 不同 Key,配额是独立的?注意:不一定,取决于 Provider)
- 时间隔离:批处理任务在业务低峰期(夜间)运行
- 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 限流系统应该:
- 应用层主动限流:不依赖 Provider 429 驱动,用令牌桶在本地拦截超量请求
- 双维度计量:RPM 和 TPM 同时追踪,Token 预估采用历史 P75 + 完成后修正
- 分层配额:Org → Tier → 租户,隔离不同优先级的流量
- 智能 429 处理:读 Header,按 reset 时间等待,Full Jitter 避免惊群
- 降级有路:限流触发后有明确的降级路径(队列/Fallback/节流),不是直接崩溃
- 可观测:每次限流决策都有指标,告警规则覆盖高利用率和积压场景
限流不是"加个 retry",是 LLM 应用成本控制和用户体验保障的基础设施。