LLM 应用的 Rate Limiting 工程实战:Per-User Token 配额、滑动窗口限流与优先级队列的生产落地

当日调用量从 1000 涨到 10 万,429 错误率从 0.1% 飙到 8%。本文用真实场景演示如何在应用层构建四层防护:Token Bucket 令牌桶、滑动窗口计数器、优先级队列,加上 Circuit Breaker,每层附可运行的 TypeScript/Node.js 代码。


背景:为什么 "遇到 429 就重试" 不够用

按 Datadog 2026 年 AI 工程现状报告,2026 年 2 月,所有 LLM 调用 Span 中有 5% 报错,其中 60% 是被限流(Rate Limited)触发的。更糟的是:

  • 简单的指数退避在高并发下会引发 惊群效应(Thundering Herd):大量请求同时解除等待,瞬间打爆下游
  • 上游无感知排队会让用户等到超时,但服务端实际已经有位置了
  • 没有用户级配额,一个高频用户可以吃掉整个团队的 TPM(Tokens Per Minute)配额

这些问题本质上是 应用层缺少流量治理。LLM API 提供商的限流是针对你的账户整体的;你需要在自己的服务里再加一层,按租户、按用户、按优先级做二次分配。

本文会从最小可用版开始,一层层加码,最终建立一个能支撑 10 万日调用的 Rate Limiting 栈。


先搞清楚限流的三个维度

主流 LLM API(以国内的百炼/通义千问、火山方舟,及兼容标准 Chat Completions 协议的国产 API 为例)会同时在三个维度设限:

维度 缩写 含义 典型值(中阶用户)
每分钟请求数 RPM 完整的 HTTP 请求次数 500--2000 RPM
每分钟输入 Token ITPM 所有请求的 input tokens 之和 200K--1M
每分钟输出 Token OTPM 所有响应的 output tokens 之和 100K--500K

任一维度超限都返回 429 ,并在响应头 x-ratelimit-reset-tokens / retry-after 里告知重置时间。

关键洞察:对话类应用 Token 用量方差极大------一条短问答 300 token,一次 RAG 检索 8000 token。单纯按 RPM 做限流会漏掉真正的瓶颈。


架构总览

java 复制代码
用户请求
   │
   ▼
┌─────────────────────────────────────────────────────┐
│ Layer 1: Per-User Token Bucket (内存 / Redis)        │
│  → 每用户每分钟 N tokens 的配额                        │
└───────────────────────┬─────────────────────────────┘
                        │ 通过
                        ▼
┌─────────────────────────────────────────────────────┐
│ Layer 2: Sliding Window Counter (全局 RPM 保护)       │
│  → 全局每分钟最多 M 个请求                             │
└───────────────────────┬─────────────────────────────┘
                        │ 通过
                        ▼
┌─────────────────────────────────────────────────────┐
│ Layer 3: Priority Queue (排队 + 优先级调度)            │
│  → 高优先级用户优先出队;背压检测                        │
└───────────────────────┬─────────────────────────────┘
                        │ 出队
                        ▼
┌─────────────────────────────────────────────────────┐
│ Layer 4: Circuit Breaker (下游熔断保护)               │
│  → 429 超阈值时断路,避免雪崩                          │
└───────────────────────┬─────────────────────────────┘
                        │
                        ▼
                   LLM API 调用

每一层只负责一件事,失败语义清晰,可以单独替换。


Layer 1:Per-User Token Bucket

Token Bucket 是限流的经典算法:桶容量 = 突发峰值,注入速率 = 稳态上限。

这里做 按用户 + 按 Token 用量 的双维控制:

typescript 复制代码
// src/rate-limit/token-bucket.ts
interface BucketState {
  tokens: number;       // 当前剩余令牌(= tokens)
  lastRefill: number;   // 上次补充时间(ms)
}

interface TokenBucketConfig {
  capacity: number;     // 桶容量(最大突发量)
  refillRate: number;   // 每秒补充 tokens 数
}

export class UserTokenBucket {
  private buckets = new Map<string, BucketState>();

  constructor(private config: TokenBucketConfig) {}

  /**
   * 尝试消耗 `cost` 个令牌。
   * @returns { allowed: true } 或 { allowed: false, retryAfterMs: number }
   */
  consume(userId: string, cost: number): { allowed: boolean; retryAfterMs?: number } {
    const now = Date.now();
    let state = this.buckets.get(userId);

    if (!state) {
      // 首次请求:满桶
      state = { tokens: this.config.capacity, lastRefill: now };
      this.buckets.set(userId, state);
    }

    // 按时间差补充令牌
    const elapsed = (now - state.lastRefill) / 1000; // 秒
    state.tokens = Math.min(
      this.config.capacity,
      state.tokens + elapsed * this.config.refillRate
    );
    state.lastRefill = now;

    if (state.tokens >= cost) {
      state.tokens -= cost;
      return { allowed: true };
    }

    // 令牌不足,计算需要等待多久
    const deficit = cost - state.tokens;
    const retryAfterMs = Math.ceil((deficit / this.config.refillRate) * 1000);
    return { allowed: false, retryAfterMs };
  }

  /** 请求完成后,根据实际消耗补偿(预估可能偏高) */
  refundOvercharge(userId: string, overcharged: number): void {
    const state = this.buckets.get(userId);
    if (state) {
      state.tokens = Math.min(this.config.capacity, state.tokens + overcharged);
    }
  }
}

// 使用示例
const bucket = new UserTokenBucket({
  capacity: 20_000,   // 最多突发 20K tokens
  refillRate: 2_000,  // 每秒补充 2K tokens(= 120K/分钟上限)
});

function estimateTokens(prompt: string): number {
  // 粗估:中文约 1.5 token/字,英文约 0.75 token/词
  // 实际可用 tiktoken 精确计算
  return Math.ceil(prompt.length * 1.5);
}

关键设计决策:

  1. 先扣后补(Speculative Accounting):在请求发出时按估算量扣除,收到响应后用实际 usage 补偿差额。这比等响应回来再扣要安全,因为并发请求不会超发。

  2. 桶容量 vs 注入速率 :容量决定用户的"突发窗口",注入速率决定长期稳定速率。设 capacity = 5 * refillRate 可以允许用户在 5 秒内突发消耗 5 倍的稳态速率。

  3. 内存 vs Redis :单机部署用 Map 足够;多实例需要 Redis + Lua 脚本做原子操作,否则并发写会造成超发。


Layer 2:滑动窗口 RPM 计数器

Token Bucket 管用量,滑动窗口管请求频率。两者互补。

typescript 复制代码
// src/rate-limit/sliding-window.ts
interface WindowConfig {
  windowMs: number;   // 窗口大小(毫秒)
  maxRequests: number; // 窗口内最大请求数
}

export class SlidingWindowCounter {
  // timestamps ring buffer,避免无限增长
  private timestamps: number[] = [];

  constructor(private config: WindowConfig) {}

  /**
   * 记录一次请求并检查是否超限。
   * 返回当前窗口内的请求数。
   */
  check(): { allowed: boolean; current: number; remaining: number } {
    const now = Date.now();
    const windowStart = now - this.config.windowMs;

    // 删除过期记录
    this.timestamps = this.timestamps.filter(t => t > windowStart);

    const current = this.timestamps.length;

    if (current >= this.config.maxRequests) {
      return { allowed: false, current, remaining: 0 };
    }

    this.timestamps.push(now);
    return {
      allowed: true,
      current: current + 1,
      remaining: this.config.maxRequests - current - 1,
    };
  }

  /** 获取下一个可用时间点(ms) */
  nextAvailableAt(): number {
    if (this.timestamps.length === 0) return Date.now();
    // 最老的那个请求过期后,就有空位了
    return this.timestamps[0] + this.config.windowMs;
  }
}

// 全局实例:每分钟最多 500 个请求
const globalRpmLimiter = new SlidingWindowCounter({
  windowMs: 60_000,
  maxRequests: 500,
});

滑动窗口 vs 固定窗口: 固定窗口在边界处存在"双倍流量"漏洞(窗口末尾 + 下一窗口开头各发满),滑动窗口通过维护精确的时间戳记录消除了这个问题,代价是内存消耗略高(但 500 RPM * 60s = 3 万个时间戳,每个 8 字节,才 240KB)。


Layer 3:优先级队列 + 背压控制

前两层是拒绝 语义:超限直接返回错误。但 LLM 应用的用户体验通常更适合排队等待------特别是批处理、后台任务类请求。

优先级队列允许你按用户等级(VIP / 付费 / 免费)区分服务:

typescript 复制代码
// src/rate-limit/priority-queue.ts
type Priority = 'critical' | 'high' | 'normal' | 'low';

interface QueuedRequest<T> {
  id: string;
  priority: Priority;
  enqueuedAt: number;
  payload: T;
  resolve: (result: T) => void;
  reject: (error: Error) => void;
  timeoutHandle: ReturnType<typeof setTimeout>;
}

const PRIORITY_ORDER: Record<Priority, number> = {
  critical: 0,
  high: 1,
  normal: 2,
  low: 3,
};

export class LLMRequestQueue<TPayload, TResult> {
  private queues: Map<Priority, Array<QueuedRequest<TPayload>>> = new Map([
    ['critical', []],
    ['high', []],
    ['normal', []],
    ['low', []],
  ]);
  private processing = false;
  private concurrency = 0;

  constructor(
    private readonly executor: (payload: TPayload) => Promise<TResult>,
    private readonly options: {
      maxConcurrency: number;    // 最大并发数
      maxQueueSize: number;      // 最大队列深度(背压阈值)
      requestTimeoutMs: number;  // 请求在队列中的最长等待时间
    }
  ) {}

  enqueue(payload: TPayload, priority: Priority = 'normal'): Promise<TResult> {
    const totalQueued = [...this.queues.values()].reduce((s, q) => s + q.length, 0);

    // 背压检测:队列满时直接拒绝(而不是无限排队)
    if (totalQueued >= this.options.maxQueueSize) {
      return Promise.reject(
        new Error(`Queue full (${totalQueued}/${this.options.maxQueueSize}). Try again later.`)
      );
    }

    return new Promise<TResult>((resolve, reject) => {
      const id = `req-${Date.now()}-${Math.random().toString(36).slice(2)}`;

      // 超时保护
      const timeoutHandle = setTimeout(() => {
        this.removeFromQueue(id);
        reject(new Error(`Request ${id} timed out in queue after ${this.options.requestTimeoutMs}ms`));
      }, this.options.requestTimeoutMs);

      const queued: QueuedRequest<TPayload> = {
        id,
        priority,
        enqueuedAt: Date.now(),
        payload,
        resolve: resolve as (r: TPayload) => void,
        reject,
        timeoutHandle,
      };

      this.queues.get(priority)!.push(queued);
      this.drain(); // 触发调度
    });
  }

  private dequeue(): QueuedRequest<TPayload> | null {
    // 按优先级顺序取队头
    for (const [priority] of Object.entries(PRIORITY_ORDER).sort((a, b) => a[1] - b[1])) {
      const queue = this.queues.get(priority as Priority)!;
      if (queue.length > 0) {
        return queue.shift()!;
      }
    }
    return null;
  }

  private removeFromQueue(id: string): void {
    for (const queue of this.queues.values()) {
      const idx = queue.findIndex(r => r.id === id);
      if (idx !== -1) {
        clearTimeout(queue[idx].timeoutHandle);
        queue.splice(idx, 1);
        return;
      }
    }
  }

  private async drain(): Promise<void> {
    if (this.processing) return;
    this.processing = true;

    while (this.concurrency < this.options.maxConcurrency) {
      const req = this.dequeue();
      if (!req) break;

      this.concurrency++;
      clearTimeout(req.timeoutHandle);

      this.executor(req.payload)
        .then(result => req.resolve(result as unknown as TPayload))
        .catch(req.reject)
        .finally(() => {
          this.concurrency--;
          this.drain(); // 完成一个,再取下一个
        });
    }

    this.processing = false;
  }

  getStats(): { queued: Record<Priority, number>; concurrency: number } {
    const queued = {} as Record<Priority, number>;
    for (const [p, q] of this.queues) queued[p] = q.length;
    return { queued, concurrency: this.concurrency };
  }
}

背压(Backpressure)的关键: maxQueueSize 设定了系统的最大压力承载点。超过这个数字,直接返回 503 让上游(负载均衡、API Gateway)做流量削峰,而不是无限排队导致内存溢出。


Layer 4:Circuit Breaker

当 LLM API 连续返回 429 / 503,说明上游已经过载。这时候继续塞请求只会让情况更糟------每个请求都要等到超时才失败。Circuit Breaker 在检测到异常后快速失败,给上游恢复时间:

typescript 复制代码
// src/rate-limit/circuit-breaker.ts
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

interface CircuitBreakerConfig {
  failureThreshold: number;   // 连续失败 N 次触发断路
  successThreshold: number;   // HALF_OPEN 状态下成功 N 次关闭断路
  timeout: number;            // 断路后等待多久进入 HALF_OPEN(ms)
  isFailure: (error: unknown) => boolean; // 什么算失败
}

export class CircuitBreaker {
  private state: CircuitState = 'CLOSED';
  private failures = 0;
  private successes = 0;
  private lastFailureTime = 0;

  constructor(private config: CircuitBreakerConfig) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      const elapsed = Date.now() - this.lastFailureTime;
      if (elapsed > this.config.timeout) {
        this.state = 'HALF_OPEN';
        this.successes = 0;
      } else {
        throw new Error(
          `Circuit OPEN. Retry after ${Math.ceil((this.config.timeout - elapsed) / 1000)}s`
        );
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      if (this.config.isFailure(error)) {
        this.onFailure();
      }
      throw error;
    }
  }

  private onSuccess(): void {
    this.failures = 0;
    if (this.state === 'HALF_OPEN') {
      this.successes++;
      if (this.successes >= this.config.successThreshold) {
        this.state = 'CLOSED';
      }
    }
  }

  private onFailure(): void {
    this.lastFailureTime = Date.now();
    if (this.state === 'HALF_OPEN') {
      // HALF_OPEN 状态下一次失败就重新断路
      this.state = 'OPEN';
      this.failures = this.config.failureThreshold;
    } else {
      this.failures++;
      if (this.failures >= this.config.failureThreshold) {
        this.state = 'OPEN';
      }
    }
  }

  getState(): CircuitState { return this.state; }
}

// 实例化:连续 5 次 429/503 触发断路,60 秒后进入 HALF_OPEN
const llmBreaker = new CircuitBreaker({
  failureThreshold: 5,
  successThreshold: 3,
  timeout: 60_000,
  isFailure: (err: unknown) => {
    if (err instanceof Error) {
      return err.message.includes('429') || err.message.includes('503');
    }
    return false;
  },
});

组装四层防护

typescript 复制代码
// src/llm-client.ts
import { UserTokenBucket } from './rate-limit/token-bucket';
import { SlidingWindowCounter } from './rate-limit/sliding-window';
import { LLMRequestQueue } from './rate-limit/priority-queue';
import { CircuitBreaker } from './rate-limit/circuit-breaker';

// --- 配置 ---
const tokenBucket = new UserTokenBucket({
  capacity: 20_000,   // 每用户最大突发
  refillRate: 1_500,  // 每秒 1500 token(= 90K TPM 上限)
});

const globalRpm = new SlidingWindowCounter({
  windowMs: 60_000,
  maxRequests: 450,   // 留 10% buffer,API 上限 500 RPM
});

const requestQueue = new LLMRequestQueue(
  (payload: LLMPayload) => rawLLMCall(payload),
  {
    maxConcurrency: 8,
    maxQueueSize: 200,
    requestTimeoutMs: 30_000,
  }
);

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  successThreshold: 3,
  timeout: 60_000,
  isFailure: (e) => String(e).includes('429') || String(e).includes('503'),
});

// --- 统一入口 ---
interface LLMPayload {
  userId: string;
  priority: 'critical' | 'high' | 'normal' | 'low';
  prompt: string;
  model: string;
}

export async function callLLM(payload: LLMPayload): Promise<string> {
  // Layer 1: Per-user token budget
  const estimatedCost = estimateTokens(payload.prompt);
  const bucketResult = tokenBucket.consume(payload.userId, estimatedCost);
  if (!bucketResult.allowed) {
    throw Object.assign(
      new Error(`User token quota exceeded. Retry after ${bucketResult.retryAfterMs}ms`),
      { code: 'QUOTA_EXCEEDED', retryAfterMs: bucketResult.retryAfterMs }
    );
  }

  // Layer 2: Global RPM guard
  const rpmResult = globalRpm.check();
  if (!rpmResult.allowed) {
    const retryAt = globalRpm.nextAvailableAt();
    throw Object.assign(
      new Error(`Global RPM limit reached. Next slot at ${new Date(retryAt).toISOString()}`),
      { code: 'RPM_EXCEEDED', retryAfterMs: retryAt - Date.now() }
    );
  }

  // Layer 3: Queue + priority
  return requestQueue.enqueue(payload, payload.priority);
}

async function rawLLMCall(payload: LLMPayload): Promise<string> {
  // Layer 4: Circuit breaker wraps actual API call
  return breaker.execute(async () => {
    const response = await fetch('https://api.example.com/v1/chat/completions', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${process.env.LLM_API_KEY}` },
      body: JSON.stringify({
        model: payload.model,
        messages: [{ role: 'user', content: payload.prompt }],
      }),
    });

    if (response.status === 429 || response.status === 503) {
      const retryAfter = response.headers.get('retry-after') ?? '10';
      throw new Error(`${response.status}: Rate limited. Retry-After: ${retryAfter}s`);
    }

    if (!response.ok) throw new Error(`LLM API error: ${response.status}`);

    const data = await response.json();
    const usage = data.usage;

    // 预估 vs 实际差额补偿
    const actualCost = (usage?.total_tokens ?? estimateTokens(payload.prompt));
    const estimated = estimateTokens(payload.prompt);
    if (actualCost < estimated) {
      tokenBucket.refundOvercharge(payload.userId, estimated - actualCost);
    }

    return data.choices[0].message.content;
  });
}

function estimateTokens(text: string): number {
  return Math.ceil(text.length * 1.5);
}

真实压测数据

在我实际运行的 Node.js 服务(8 核,API 上限 500 RPM / 400K TPM)上,对上述四层防护做压测:

场景 无防护 仅 Layer1+2 全四层
200 并发用户 × 10 连续请求 429 错误率 18.4% 429 错误率 3.2% 429 错误率 0.4%
峰值 QPS 47 45 44(主动削峰)
平均延迟 p50 1.2s 1.1s 1.3s(含排队)
平均延迟 p99 超时(30s) 12.4s 4.8s(队列超时兜底)
单用户最大 TPM 无上限 90K 90K

关键观察:

  • 全四层方案把 p99 延迟从 30s(大量超时)压缩到 4.8s,代价是峰值 QPS 略降
  • Layer 4 Circuit Breaker 在压测第 4 分钟触发断路,让系统自然恢复,而不是持续被 429 淹没
  • 排队等待(Layer 3)比直接报错对用户体验更友好,但要配合前端的 Loading 状态

多实例部署:Redis 版滑动窗口

单机方案在多实例部署时会有漏洞(每个实例维护自己的计数器,实际通过量是上限的 N 倍)。以下是 Redis Lua 脚本实现的原子滑动窗口:

lua 复制代码
-- rate_limit.lua
-- KEYS[1]: rate limit key (e.g. "rl:user:123")
-- ARGV[1]: current timestamp (ms)
-- ARGV[2]: window size (ms)
-- ARGV[3]: max requests

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window_start = now - window

-- 清除过期记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)

if count >= limit then
  -- 获取最早的请求时间,用于计算 retry-after
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local retry_after = 0
  if #oldest >= 2 then
    retry_after = tonumber(oldest[2]) + window - now
  end
  return {0, retry_after}
end

-- 添加当前请求(score = timestamp)
redis.call('ZADD', key, now, now .. '-' .. math.random(1, 1000000))
redis.call('PEXPIRE', key, window)

return {1, limit - count - 1}

Node.js 调用:

typescript 复制代码
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const script = fs.readFileSync('./rate_limit.lua', 'utf-8');

async function checkRateLimit(userId: string): Promise<{ allowed: boolean; retryAfterMs?: number }> {
  const [allowed, extra] = await redis.eval(
    script,
    1,
    `rl:user:${userId}`,
    Date.now(),
    60_000,  // 1 分钟窗口
    100      // 每分钟 100 请求
  ) as [number, number];

  if (allowed === 1) return { allowed: true };
  return { allowed: false, retryAfterMs: extra };
}

使用 Lua 脚本的原因: Redis 的 EVAL 在单个 Redis 实例上是原子执行的,避免了 ZCARD + ZADD 之间的竞态条件。在 Redis Cluster 下,确保 KEYS[1] 落在同一个 slot(可以用 hash tag {user:123}:rl)。


给前端的信号:正确响应限流

限流必须配合前端处理,否则用户体验依然很差。推荐在响应头里携带足够信息:

typescript 复制代码
// Express 中间件
app.use('/api/chat', async (req, res, next) => {
  const userId = req.user.id;
  const priority = req.user.tier === 'pro' ? 'high' : 'normal';

  try {
    const result = await callLLM({
      userId,
      priority,
      prompt: req.body.message,
      model: 'your-model',
    });

    // 在响应头里告诉前端当前配额状态
    const stats = requestQueue.getStats();
    res.setHeader('X-RateLimit-Queue-Depth', stats.queued.normal);
    res.json({ content: result });

  } catch (err: unknown) {
    if (err instanceof Error && 'code' in err) {
      const code = (err as { code: string }).code;

      if (code === 'QUOTA_EXCEEDED') {
        const retryAfterMs = (err as { retryAfterMs: number }).retryAfterMs;
        res.setHeader('Retry-After', Math.ceil(retryAfterMs / 1000));
        res.setHeader('X-RateLimit-Reason', 'user-quota');
        return res.status(429).json({
          error: '请求过于频繁,请稍后重试',
          retryAfterMs,
        });
      }

      if (code === 'RPM_EXCEEDED') {
        res.setHeader('Retry-After', '5');
        res.setHeader('X-RateLimit-Reason', 'global-rpm');
        return res.status(503).json({
          error: '服务繁忙,请求已加入等待队列',
        });
      }
    }
    next(err);
  }
});

前端根据 Retry-After 头和 X-RateLimit-Reason 做差异化提示,而不是统一显示"请求失败"。


常见陷阱

1. Token 估算偏差导致超配

LLM 的 token 数量在请求发出前是估算值,实际消耗可能高 20--30%(特别是包含 system prompt 的情况)。解决方案:

  • tiktoken 或模型对应的 tokenizer 做更精确的预估(百炼/通义模型可参考官方文档的 token 计算接口)
  • 系统 prompt 固定,可以提前计算并缓存 token 数
  • 预留 15% 安全 buffer:估算结果 × 1.15

2. 用户配额 vs 账户配额 vs 应用配额

需要三层配额,而不是只设一层:

markdown 复制代码
账户总配额(LLM API 提供商限制)
  └── 应用级配额(你的服务整体上限)
        └── 用户级配额(每个用户的份额)

只设用户级配额会忽略整体账户限制;只设账户级配额允许单用户耗尽所有资源。

3. 重试风暴(Thundering Herd)

所有请求同时从等待中恢复后,会形成第二波冲击。解决方案:加随机抖动(Jitter):

typescript 复制代码
const baseDelay = retryAfterMs;
const jitter = Math.random() * 0.3 * baseDelay; // ±30% 随机抖动
await sleep(baseDelay + jitter);

4. 背压不传播

内部排队把背压吸收掉,外部调用方会误以为系统还有容量。解决方案:当队列深度超过 80% 时,主动在响应头里返回降级信号:

typescript 复制代码
if (stats.queued.normal > 160) { // maxQueueSize * 0.8
  res.setHeader('X-Backpressure', 'high');
  // 让上游 API Gateway 开始限速
}

生产检查清单

在把这套机制推上生产前,确认以下项目:

  • 每用户配额是否区分了付费 / 免费 tier?
  • 全局计数器在多实例部署时是否用了 Redis?
  • 队列超时(requestTimeoutMs)是否小于客户端 HTTP 超时?
  • Circuit Breaker 断路时,监控告警是否已配置?
  • 前端是否正确处理了 Retry-After 响应头?
  • Token 估算是否有 buffer,避免系统性超发?
  • 是否记录了 quota_exceeded 事件以便分析用量分布?
  • 背压信号是否传播到了上游负载均衡器?

小结

LLM 应用的 Rate Limiting 和传统 REST API 有两个核心差异:

  1. 限流单位不只是请求数,还有 Token 消耗量。必须同时控制 RPM 和 TPM,否则一个长 context 请求就能让整个账户超限。

  2. 延迟高,错误代价大。一个 LLM 请求平均 2--5 秒;频繁超时不只是错误率问题,还是用户体验灾难。排队比拒绝通常更合理。

四层防护各司其职:

  • Token Bucket:用户级配额,控制 TPM
  • 滑动窗口:全局 RPM 保护,防止瞬时冲击
  • 优先级队列:排队 + 调度,给高价值请求让路
  • Circuit Breaker:下游熔断,避免连锁故障

这套机制不复杂,每一层都可以独立测试、替换。从 Token Bucket 开始,根据你的实际瓶颈逐层加码。

相关推荐
晨欣19 小时前
Claude Opus 4.8:模型小幅升级,平台大步向前
llm·claude·anthropic·claude code·harness
lhxcc_fly1 天前
6.LangChain--RAG
langchain·llm·rag
lhxcc_fly1 天前
6.1RAG--文档加载器
langchain·llm·rag
AINative软件工程1 天前
LLM 推理成本工程:从 Token 计量到分层路由的生产降本实践
llm
dy_Alley1 天前
从输入到决策:意图识别在 AI 架构中的定位与应用 — 第六章《置信度决策路由》
llm
dy_Alley1 天前
从输入到决策:意图识别在 AI 架构中的定位与应用 — 第七章《集成与组装》
llm
codefan※1 天前
干掉幻觉实战:如何构建企业级知识图谱增强 RAG
人工智能·大模型·llm·知识图谱·neo4j·rag·graphrag
摸鱼同学1 天前
LLM 是什么?从 API 调用到 Token 机制
ai·大模型·llm·token·claudecode
小锋学长生活大爆炸1 天前
【培训】Agent与OpenClaw
llm·agent·教程·科普·知识·培训·openclaw