Token 级限流与 SLO 准入控制:LLM 生产服务的限流实战
传统的 RPM(每分钟请求数)限流在 LLM 推理场景下完全失效——因为一次简单问候可能消耗 500 token,而一次代码生成请求可能消耗 50,000 token。本文从实现层面构建一个生产级 Token 级限流引擎,涵盖分布式计数、滑动窗口、自适应降级和 SLO 感知准入控制。
一、为什么传统限流在 LLM 场景下失效
在经典 REST API 场景中,一次请求的成本相对均质,按 RPM 或 QPS 限流是合理的。但 LLM 推理的成本取决于输入上下文长度 + 生成长度,差异可达两个数量级。在一个典型的 GPT-4 类 API 服务中:
| 请求类型 | 输入 Token | 输出 Token | 总量 | 占比 |
|---|---|---|---|---|
| 简单问答 | 800 | 200 | 1,000 | ~60% |
| 代码补全 | 3,000 | 800 | 3,800 | ~25% |
| 长文摘要 | 40,000 | 4,000 | 44,000 | ~10% |
| 多轮改写 | 15,000 | 8,000 | 23,000 | ~5% |
如果按 RPM=60 限流,那么一台 GPU 服务器在处理 10 个长文摘要时就已饱和,却还能接受 60 个简单问答。这种"数量平权、成本盲视"的限流策略会导致:
- 尾部 SLO 受损:少量长请求耗尽资源,大量短请求反而被不公平排队
- 成本不可控:单个租户可能在限额内发出超高消耗请求
- 过载雪崩:准入预测失准→排队溢出→级联崩溃
解决方案是将限流维度从"请求数"下沉到"Token 数",并引入 SLO 感知的自适应准入控制。
二、Token 成本模型的先验与后验
构建限流器的第一个难题是:在请求到达时,我们还不知道它最终会消耗多少 Token。
先验策略(Pre-Estimation) 在准入阶段估算 Token 消耗:
- 输入 Token 可直接通过 tokenizer 计数得到精确值
- 输出 Token 需要预测——常用启发式方案:按请求者历史 P50/P95/cap 设置不同预估系数
- 定义安全上限
max_output_tokens,拒绝明显超限请求
后验策略(Post-Accounting) 在请求完成后按真实消耗结算:
- 精确计量输入 + 输出 Token
- 先预扣估算值,完成后按"多退少补"修正
- 关键实现:预扣值必须小于等于安全上限,否则超额请求会穿透限流器
最佳实践是混合模式:准入时先验预扣,完成后再验修正,并使用 Leaky Bucket 或 Token Bucket 控制净吞吐。
三、滑动窗口计数引擎:从固定窗口到衰减因子
简单的"每分钟 N 个 Token"计数器有个致命缺陷:窗口边界突刺。假设限流 100K Token/minute,59 秒时已消耗 99K,下一秒窗口重置——下一分钟内可以瞬间涌入 200K Token。
滑动窗口计数通过将窗口细分为多个子桶(如 10 个 6 秒桶)解决突刺问题。我们需要一个在 Redis 上可原子操作的版本:
-- sliding_window_check.lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 当前时间(毫秒)
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 子桶数量
-- ARGV[4]: 窗口内允许的最大 token 数
-- ARGV[5]: 本次请求预扣 token 数
-- ARGV[6]: 过期时间(TTL 基数)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local buckets = tonumber(ARGV[3])
local max_tokens = tonumber(ARGV[4])
local requested = tonumber(ARGV[5])
local ttl_base = tonumber(ARGV[6])
local bucket_ms = window_ms / buckets
local current_slot = math.floor(now / bucket_ms)
local window_start = now - window_ms
-- 清理过期桶
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- 统计当前窗口内已消耗的 token
local entries = redis.call('ZRANGEBYSCORE', key, window_start, '+inf', 'WITHSCORES')
local consumed = 0
for i = 2, #entries, 2 do
consumed = consumed + tonumber(entries[i])
end
if consumed + requested <= max_tokens then
-- 记录本次预扣
redis.call('ZADD', key, current_slot * bucket_ms, requested)
redis.call('EXPIRE', key, ttl_base)
return 1
else
return 0
end
上面的实现有两个工程要点值得说明:
- 用
ZSET的 score 存储时间戳、member 存储预扣 token 数,使得清理和求和都是 O(log N) - 所有操作在单个 Lua 脚本中原子执行,避免了 GET+SET 的竞态
在高并发场景下,还可以引入近似滑动窗口:不再求精确和,而是对每个子桶统计量按经过时间做指数衰减——桶越老权重越低,性能和精度兼得。
四、Rate Limiter 的 Rust 实现:双桶协同
生产级限流器通常维护两个并行的 Token Bucket:请求速率桶 和 Token 吞吐桶。两者的区别在于:
- 请求速率桶(以 req/s 计):保护下游不被突发请求打垮,无论请求大小
- Token 吞吐桶(以 token/s 计):保护计算资源,按实际计算量限制
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, Duration};
use tokio::sync::Mutex;
/// 基于 atomic 的无锁 Token Bucket
pub struct TokenBucket {
/// 桶容量(最大突发量)
capacity: u64,
/// 每秒补充速率
refill_per_sec: u64,
/// 当前令牌数(以 atomic 原子计数)
tokens: AtomicU64,
/// 上次补充时间
last_refill: Mutex<Instant>,
}
impl TokenBucket {
pub fn new(capacity: u64, refill_per_sec: u64) -> Self {
Self {
capacity,
refill_per_sec,
tokens: AtomicU64::new(capacity),
last_refill: Mutex::new(Instant::now()),
}
}
/// 尝试消耗指定数量的令牌
pub async fn consume(&self, amount: u64) -> bool {
self.refill().await;
loop {
let current = self.tokens.load(Ordering::Relaxed);
if current < amount {
return false;
}
match self.tokens.compare_exchange_weak(
current,
current - amount,
Ordering::SeqCst,
Ordering::Relaxed,
) {
Ok(_) => return true,
Err(_) => continue, // CAS 失败,重试
}
}
}
lazy_refill: async fn refill(&self) {
let mut last = self.last_refill.lock().await;
let now = Instant::now();
let elapsed = now.duration_since(*last);
let new_tokens = (elapsed.as_secs_f64() * self.refill_per_sec as f64) as u64;
if new_tokens > 0 {
let current = self.tokens.load(Ordering::Relaxed);
let updated = (current + new_tokens).min(self.capacity);
self.tokens.store(updated, Ordering::Relaxed);
*last = now;
}
}
}
/// 双桶协同:请求桶 + Token 桶
pub struct LlmRateLimiter {
request_bucket: TokenBucket,
token_bucket: TokenBucket,
/// Token 预估器(基于历史数据的指数加权移动平均)
token_estimator: TokenEstimator,
}
impl LlmRateLimiter {
pub fn new(req_per_sec: u64, tokens_per_sec: u64) -> Self {
Self {
request_bucket: TokenBucket::new(req_per_sec, req_per_sec),
token_bucket: TokenBucket::new(tokens_per_sec, tokens_per_sec),
token_estimator: TokenEstimator::new(),
}
}
/// 准入检查:双桶都必须有余量
pub async fn admit(&self, input_tokens: u64, max_output_tokens: u64) -> AdmissionResult {
let estimated_total = self.token_estimator.estimate(input_tokens, max_output_tokens);
// 检查请求速率桶
if !self.request_bucket.consume(1).await {
return AdmissionResult::RateLimited(Backoff::request_level());
}
// 检查 Token 吞吐桶
if !self.token_bucket.consume(estimated_total).await {
// 退回请求桶的令牌
self.request_bucket.tokens.fetch_add(1, Ordering::Relaxed);
return AdmissionResult::RateLimited(Backoff::token_level());
}
AdmissionResult::Admitted(estimated_total)
}
/// 请求完成后的真实结算
pub async fn finalize(&self, estimated: u64, actual: u64) {
let delta = actual as i64 - estimated as i64;
if delta > 0 {
// 实际消耗比预估多——从桶额外扣除
for _ in 0..delta {
self.token_bucket.consume(1).await;
}
} else if delta < 0 {
// 实际消耗比预估少——归还差额
self.token_bucket.tokens.fetch_add((-delta) as u64, Ordering::Relaxed);
let cap = self.token_bucket.capacity;
let cur = self.token_bucket.tokens.load(Ordering::Relaxed);
if cur > cap {
self.token_bucket.tokens.store(cap, Ordering::Relaxed);
}
}
}
}
上面的实现中,lazy_refill 采用了 lazy evaluation 策略——不维护后台 refill 任务,而是在每次 consume 或 release 时按需计算累积量。这是高性能限流器的常见做法,避免了时间轮精度与 CPU 开销的权衡。
五、Token 预估器:EWMA 与分位点预测
TokenEstimator 负责在准入阶段给出"这次请求大概会消耗多少 Token"的预测值。最简单的方案是取 min(max_output_tokens, 历史平均 × 1.5)。生产环境更精细的方案:
use std::collections::VecDeque;
use std::sync::RwLock;
pub struct TokenEstimator {
/// 按租户分桶的历史数据
buckets: RwLock<HashMap<String, TenantModel>>,
}
struct TenantModel {
/// 滑动窗口内的真实输出 token 序列
history: VecDeque<u64>,
/// EWMA 均值
ewma_mean: f64,
/// EWMA 方差
ewma_variance: f64,
/// 窗口大小
window_size: usize,
}
impl TenantModel {
fn new(window_size: usize) -> Self {
Self {
history: VecDeque::with_capacity(window_size),
ewma_mean: 256.0, // 冷启动默认值
ewma_variance: 10000.0,
window_size,
}
}
/// 更新统计量(请求完成后调用)
fn update(&mut self, actual_output_tokens: u64) {
let alpha = 0.1; // 平滑因子,越小越依赖历史
let x = actual_output_tokens as f64;
let new_mean = alpha * x + (1.0 - alpha) * self.ewma_mean;
let new_var = alpha * (x - self.ewma_mean).powi(2)
+ (1.0 - alpha) * self.ewma_variance;
self.ewma_mean = new_mean;
self.ewma_variance = new_var;
if self.history.len() >= self.window_size {
self.history.pop_front();
}
self.history.push_back(actual_output_tokens);
}
/// P95 分位点估算(用于接纳控制的保守预扣)
fn p95_estimate(&self) -> u64 {
if self.history.len() < 5 {
return (self.ewma_mean * 1.5) as u64;
}
let mut sorted: Vec<u64> = self.history.iter().copied().collect();
sorted.sort();
let idx = (sorted.len() as f64 * 0.95) as usize;
sorted[idx.min(sorted.len() - 1)]
}
/// 保守预扣 = max(用户设置上限, P95估算)
fn conservative_estimate(&self, user_max: u64) -> u64 {
let p95 = self.p95_estimate();
user_max.max(p95)
}
}
关键工程要点:P95 预扣比均值预扣安全得多——均值意味着 50% 的请求会在完成后被发现"超扣"(即预扣不足)。使用 P95 估算时只有 5% 的请求需要超额补扣,加之上限 user_max 兜底,系统几乎不可能被穿透。代价是略微保守的准入率——P95 预扣值比均值大约多 30-50%,但这是工程上值得的。
六、Redis 分布式限流的一致性工程
单机限流容易实现但无法应对横向扩展。多副本推理服务需要全局一致的 Token 消耗视图。Redis 方案的挑战在于原子性:扣减限额、记录计量、更新时间戳这三步必须作为一个事务执行。上面的 Lua 脚本解决了原子性问题,但在超高 QPS(>50K ops/s)场景下还需要额外的工程优化。
Pipeline 批处理——将多个独立的限流检查合并为一个 Pipeline 发送,减少网络 RTT。实现时注意:Pipeline 不保证原子性,但 KEY 分布无需有序,因此适合大量不同租户的并行准入。
本地缓存前置——在推理节点本地维护一个小 Cache,记录每个租户最近一次的剩余配额。准入时先检查本地 Cache,不足时再回源 Redis。这个方案的边界在于:本地 Cache 看到的"剩余配额"是全球的近似值,极端情况下可能多放 5-10% 的流量混入——在 GPU 排队缓冲能力内是可接受的。
分区模式——将不同租户的限流数据分散到多个 Redis 实例,每个实例只服务一部分租户。关键设计:使用一致性哈希确保同一租户的请求总是落在同一 Redis 节点,避免跨节点聚合查询。
七、SLO-Aware 准入:超越固定阈值
传统限流器使用"可用令牌 > 0"的二值判断。但 LLM SLO 通常定义为 P95 排队延迟 < T 秒或 P99 完成时间 < 2T 秒。我们需要一个动态准入函数,将当前负载状态映射为准入概率:
pub struct SloAdmission {
/// 目标 SLO:排队延迟 P95 < 200ms
target_queue_p95_ms: f64,
/// 准入控制器参数
gain: f64,
integral: f64,
}
impl SloAdmission {
/// 计算当前准入概率 [0.0, 1.0]
pub fn admission_probability(&mut self, current_queue_p95_ms: f64) -> f64 {
let error = current_queue_p95_ms - self.target_queue_p95_ms;
// PI 控制器:比例项 + 累积误差积分
let proportional = -self.gain * error;
self.integral += error * 0.01; // 积分衰减
self.integral = self.integral.clamp(-100.0, 100.0); // 防止积分饱和
let control_signal = proportional + self.integral;
// Sigmoid 映射到概率
let prob = 1.0 / (1.0 + (-control_signal / 50.0).exp());
prob.clamp(0.01, 1.0) // 保留 1% 最低准入,避免完全死锁
}
}
这是一个 PI(比例-积分)控制器的工程实现:当当前排队延迟超过目标时降低准入概率,当低于目标时缓慢恢复。Sigmoid 映射将连续的控制信号平滑地转换为概率值,避免了"全有全无"的跳变。保留 1% 最低准入是为了保证即使在极端过载时也能保留探针流量(healthcheck),避免系统出现"恢复了但没人发请求来触发恢复"的死锁。
工程要点:排队延迟的 P95 估算需要用一个滑动窗口的 T-Digest 或 HDR Histogram 存储——不能在每次准入时重新遍历整个队列。维护一个 O(1) 更新 + O(1) 查询的近似分位点数据结构是关键。
八、过载降级:优雅拒绝与请求分级
当限流触发时,直接返回 429 是最简单的策略,但未必最友好。生产系统应该分级响应:
L1:延迟排队(Wait Queue)——告知客户端预估等待时间,将请求放入优先级队列。适合对延迟不敏感的批请求。
L2:缩短输出(Truncate)——接受请求但限制 max_output_tokens,将输出截断到"保底可用"长度。适合交互式会话(至少给完整句子)。
L3:切换模型(Model Fallback)——用更轻量但精度较低的模型处理请求。例如 GPT-4 → GPT-3.5,或 70B → 13B。适合有预部署备选模型的场景。
L4:硬拒绝(Hard Reject)——返回 Retry-After 头。仅用于最后防线。
降级的触发不应仅基于限流器的 Token 耗尽,还应结合 GPU 利用率、KV Cache 占用率、排队深度等系统指标。设计一个"过载等级"信号聚合器:
pub enum OverloadLevel {
Normal, // 全量接受
SoftLimiting, // L1:排队降速
HardLimiting, // L2:截断输出
Emergency, // L3:切回小模型
CircuitBreak, // L4:直接拒绝
}
impl OverloadLevel {
pub fn from_signals(signals: &SystemSignals) -> Self {
let score = signals.gpu_util * 0.3
+ signals.kv_cache_pressure * 0.25
+ signals.queue_depth_normalized * 0.2
+ signals.token_bucket_depletion * 0.15
+ signals.network_io_pressure * 0.1;
match score {
s if s < 0.3 => OverloadLevel::Normal,
s if s < 0.6 => OverloadLevel::SoftLimiting,
s if s < 0.8 => OverloadLevel::HardLimiting,
s if s < 0.95 => OverloadLevel::Emergency,
_ => OverloadLevel::CircuitBreak,
}
}
}
九、工程验证:限流器有效性指标
限流器的设计目标不仅仅是"挡住"更多请求,而是在 SLO 约束下最大化吞吐。以下指标用于评估限流策略是否健康:
有效吞吐量比 = 实际完成请求中质量达标(P95 延迟内)的比例 / 总准入请求。理想值 >95%,如果偏低说明准入过于宽松(放行太多最终被降级的请求)。
误拒率 = 被限流拒绝、但实际系统有能力按时完成的请求比例。理想值 < 2%,偏高说明限流参数过于保守(桶容量偏小或 refill 速率偏低)。
SLO 达成率(按租户):每个租户 P95 实际排队延迟 < SLO 目标的比例。这是业务方最关心的北极星指标。
生产部署关键:限流器参数(桶大小、refill rate、SLO 目标)都需要一个运行时调参接口,能在线调整而不重启服务。因为 LLM 流量模式随时间变化显著——如节后开工日的 API 需求可能激增——静态配置很快就会失效。
十、总结与挑战
Token 级限流不是简单的"把 RPM 换成 Tokens_per_second"——它的工程挑战在于预测、一致性、动态三者的交叉博弈。预测不准会导致穿透或误拒,一致性不足会让分布式部署超限,自适应不足会把流量模式变化暴露为 SLO 雪崩。
当前的若干开放方向包括:
- LLM-native 限流语义:基于 Prompt 复杂度预测(长文本 with Tools调用 = 高成本)而非仅 token 数量
- 联邦限流:多个推理节点形成 gossip 网络,无需集中 Redis 实现近似全局视图
- GPU-aware 动态 refill:refill 速率跟随 GPU 实时算力的变化(如 NVLink 带宽波动、batch size 弹性),构建闭环控制
LLM 推理的 Token 经济体系正在形成,Token 级限流是生产服务的底座能力。没有它,再强的模型也只能在实验室里跑出漂亮的 benchmark。

发表评论 取消回复