LLM 应用的限流、背压与并发控制:一次把 429 和排队讲透(工程实战)

工程师视角:不是讲"限流是什么",而是讲你在生产环境里怎么把 吞吐、延迟、成本、稳定性 同时守住。

0. 为什么 LLM 场景的限流更难

传统 Web 的限流,多数是为了防爬/防爆破/防 DDoS;而 LLM 的限流更像是 算力/配额/预算管理

  • 你被上游供应商限流:每分钟请求数、每分钟 Token 数、并发连接数
  • 你也要限自己:防止某个租户/某个功能把队列打爆
  • 你还要限"成本":一旦把并发开大,token 生成速度跟不上,延迟飙升、重试放大、账单爆炸

更麻烦的是:LLM 请求的"大小"不是请求体字节数,而是 输入 tokens + 输出 tokens 的随机变量

这一篇我们用一套可落地的工程方案,把下面这些问题一起解决:

  • 429 / 503 出现时怎么重试才不会雪崩
  • 如何做"双维度"限流:Requests/min + Tokens/min
  • 如何做"背压":队列、排队、超时、降级
  • 如何做"全链路并发控制":入口、模型路由、供应商连接池
  • 如何做"多租户公平性":不让大客户把小客户饿死
  • 如何做"成本护栏":把最坏情况的 token 花费钉住

代码示例以 Node.js(TypeScript)为主,Python 给出关键片段。


1. 先定清楚:你到底在限什么?

LLM 服务里我们一般至少要控 4 个指标:

  1. RPS(请求数):防止 CPU/IO/连接爆
  2. TPM(tokens per minute):对齐供应商配额
  3. 并发(in-flight):限制同时在跑的请求数,防止排队失控
  4. 队列长度 / 排队时间:给背压一个明确的阈值

关键是:不要只做 RPS。你很容易遇到这种情况:

  • RPS 很低,但每个请求要生成 3000 tokens
  • 或者 prompt 极长,输入 10k tokens

结果就是:RPS 还没到阈值,TPM 已经超了,上游 429。

1.1 LLM 请求的"大小"估算

实践里我们需要一个 上限估算,用于 token 预算:

  • inputTokens = estimate(prompt)(可用 tiktoken/bpe 近似,或者按字符数估)
  • maxOutputTokens 必须显式设置(否则你没法控成本)
  • expectedTokens = inputTokens + maxOutputTokens

工程建议:所有线上请求必须带 max_output_tokens(或等价参数),否则你无法做成本上限。


2. 429 重试:别再用固定 sleep 了

你在 LLM 里看到的 429 一般有三种语义:

  • Too Many Requests(RPS 触发)
  • Rate limit exceeded(TPM 触发)
  • Quota exceeded(余额/配额没了,重试无意义)

2.1 正确的重试策略

  • 指数退避 + jitter:避免同步重试
  • 遇到 Retry-After 头必须尊重
  • 区分可重试/不可重试:余额不足就直接失败/降级

TypeScript:通用重试器

ts 复制代码
export type RetryableError = {
  status?: number;
  code?: string;
  retryAfterMs?: number;
  message?: string;
};

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function backoffMs(attempt: number, base = 200, cap = 5000) {
  // attempt 从 0 开始
  const exp = Math.min(cap, base * Math.pow(2, attempt));
  const jitter = exp * (0.2 + Math.random() * 0.2); // 20%~40%
  return Math.round(exp + jitter);
}

function isRetryable(e: RetryableError) {
  if (e.status === 429 || e.status === 503 || e.status === 502) return true;
  if (e.code === "ETIMEDOUT" || e.code === "ECONNRESET") return true;
  return false;
}

export async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 5) {
  let lastErr: any;
  for (let i = 0; i < maxAttempts; i++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      const retryAfter = err?.retryAfterMs;
      if (!isRetryable(err)) throw err;
      if (i === maxAttempts - 1) break;
      const wait = retryAfter ?? backoffMs(i);
      await sleep(wait);
    }
  }
  throw lastErr;
}

工程建议:重试必须有 全局预算(见第 4 节背压),否则慢请求会把队列拖死。


3. 双维度限流:RPS + TPM(Tokens/Minute)

这部分是 LLM 工程的核心。

3.1 Token Bucket(令牌桶)的正确用法

RPS 用令牌桶大家都熟:每秒补充 N 个 token,拿到 token 才能发请求。

LLM 的 TPM 也是一样,只是"每次消耗的 token 数"不是 1,而是:

  • 预估的 expectedTokens

3.2 一个可用的双桶实现(TypeScript)

ts 复制代码
class TokenBucket {
  private capacity: number;
  private tokens: number;
  private refillPerMs: number;
  private lastRefill: number;

  constructor(capacity: number, refillPerSecond: number) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillPerMs = refillPerSecond / 1000;
    this.lastRefill = Date.now();
  }

  private refill() {
    const now = Date.now();
    const delta = now - this.lastRefill;
    if (delta <= 0) return;
    this.tokens = Math.min(this.capacity, this.tokens + delta * this.refillPerMs);
    this.lastRefill = now;
  }

  tryTake(amount: number) {
    this.refill();
    if (this.tokens >= amount) {
      this.tokens -= amount;
      return true;
    }
    return false;
  }

  // 计算大概多久可用
  msUntilAvailable(amount: number) {
    this.refill();
    if (this.tokens >= amount) return 0;
    const need = amount - this.tokens;
    return Math.ceil(need / this.refillPerMs);
  }
}

export class DualRateLimiter {
  private rps: TokenBucket;
  private tpm: TokenBucket;

  constructor(opts: { rps: number; tpm: number }) {
    this.rps = new TokenBucket(opts.rps, opts.rps); // 每秒补 rps
    this.tpm = new TokenBucket(opts.tpm, opts.tpm / 60); // 每秒补 tpm/60
  }

  async acquire(expectedTokens: number, timeoutMs = 2000) {
    const start = Date.now();
    while (true) {
      const okRps = this.rps.tryTake(1);
      const okTpm = this.tpm.tryTake(expectedTokens);
      if (okRps && okTpm) return;

      // 回滚(简单做法:失败就把成功的那桶加回去;这里省略,生产建议用事务/两阶段)

      const wait = Math.max(
        this.rps.msUntilAvailable(1),
        this.tpm.msUntilAvailable(expectedTokens)
      );

      if (Date.now() - start + wait > timeoutMs) {
        throw Object.assign(new Error("rate limited (local)"), { status: 429 });
      }
      await new Promise((r) => setTimeout(r, Math.min(wait, 50)));
    }
  }
}

注意:上面为了讲清楚思路省略了"回滚"。生产实现建议:先计算两桶都满足再扣减,或加锁。


4. 背压:别让队列替你做决定

很多团队的"限流"是这样发生的:

  • 入口不挡
  • 请求疯狂进来
  • 服务自己排队
  • 直到内存爆、GC 抖、P99 延迟 30s

这不是限流,这是 把问题推给队列

4.1 背压三件套

  1. 最大排队时间(例如 2s)
  2. 最大队列长度(例如 200)
  3. 超时即降级(返回缓存、返回小模型、返回"稍后重试")

4.2 以"工作池"形式做并发与队列

ts 复制代码
type Task<T> = {
  run: () => Promise<T>;
  enqueueAt: number;
  resolve: (v: T) => void;
  reject: (e: any) => void;
};

export class WorkerPool {
  private queue: Task<any>[] = [];
  private running = 0;

  constructor(
    private concurrency: number,
    private maxQueue: number,
    private maxQueueDelayMs: number
  ) {}

  submit<T>(run: () => Promise<T>): Promise<T> {
    if (this.queue.length >= this.maxQueue) {
      return Promise.reject(Object.assign(new Error("queue full"), { status: 429 }));
    }

    const task: Task<T> = {
      run,
      enqueueAt: Date.now(),
      resolve: () => {},
      reject: () => {},
    };

    const p = new Promise<T>((resolve, reject) => {
      task.resolve = resolve;
      task.reject = reject;
    });

    this.queue.push(task);
    this.pump();
    return p;
  }

  private pump() {
    while (this.running < this.concurrency && this.queue.length > 0) {
      const task = this.queue.shift()!;
      const delay = Date.now() - task.enqueueAt;
      if (delay > this.maxQueueDelayMs) {
        task.reject(Object.assign(new Error("queue timeout"), { status: 408 }));
        continue;
      }

      this.running++;
      task
        .run()
        .then(task.resolve)
        .catch(task.reject)
        .finally(() => {
          this.running--;
          this.pump();
        });
    }
  }
}

这就是"背压"的最小实现:

  • 队列满:直接拒绝
  • 排队太久:直接超时
  • 并发受控:不会无限堆积 in-flight

5. 多租户公平:别让大客户饿死小客户

最常见的事故:

  • 某大客户开了一个批处理任务
  • 短时间 10k 请求
  • 其他所有用户都 429

解决办法:按租户切分队列 + 轮询调度(WFQ/DRR)

5.1 简化版 DRR(Deficit Round Robin)

ts 复制代码
type TenantId = string;

class TenantQueue {
  tasks: Task<any>[] = [];
  deficit = 0;
  weight: number;
  constructor(weight: number) {
    this.weight = weight;
  }
}

export class FairScheduler {
  private tenants = new Map<TenantId, TenantQueue>();
  private tenantOrder: TenantId[] = [];
  private idx = 0;

  constructor(private quantum = 100) {}

  ensureTenant(id: TenantId, weight = 1) {
    if (this.tenants.has(id)) return;
    this.tenants.set(id, new TenantQueue(weight));
    this.tenantOrder.push(id);
  }

  enqueue(id: TenantId, task: Task<any>) {
    this.ensureTenant(id);
    this.tenants.get(id)!.tasks.push(task);
  }

  dequeue(): Task<any> | null {
    const n = this.tenantOrder.length;
    if (n === 0) return null;

    for (let k = 0; k < n; k++) {
      const id = this.tenantOrder[this.idx];
      this.idx = (this.idx + 1) % n;

      const q = this.tenants.get(id)!;
      q.deficit += this.quantum * q.weight;

      if (q.tasks.length === 0) continue;
      // 这里把"成本"简化为 1;真实场景可用 expectedTokens 作为成本
      const cost = 1;
      if (q.deficit >= cost) {
        q.deficit -= cost;
        return q.tasks.shift()!;
      }
    }
    return null;
  }
}

生产里建议:

  • cost 用 expectedTokens(越大越消耗 deficit)
  • weight 控制 VIP 租户

6. 供应商连接池:你不控并发,上游会帮你控(用 429)

很多"莫名其妙的 429"来自于:

  • 你并发开太大
  • 上游的并发限制更小

解决:每个供应商/每个模型实例都有独立并发池。

6.1 per-provider concurrency gate

ts 复制代码
class Semaphore {
  private used = 0;
  private waiters: (() => void)[] = [];

  constructor(private size: number) {}

  async acquire(timeoutMs = 2000) {
    if (this.used < this.size) {
      this.used++;
      return;
    }
    await new Promise<void>((resolve, reject) => {
      const t = setTimeout(() => reject(new Error("semaphore timeout")), timeoutMs);
      this.waiters.push(() => {
        clearTimeout(t);
        this.used++;
        resolve();
      });
    });
  }

  release() {
    this.used--;
    const w = this.waiters.shift();
    if (w) w();
  }
}

const providerSem = new Semaphore(16);

async function callProvider(req: any) {
  await providerSem.acquire(1500);
  try {
    return await actuallyCall(req);
  } finally {
    providerSem.release();
  }
}

这个 gate 的意义在于:

  • 你可以把"并发"变成一个可配置的 SLO 旋钮
  • 避免上游把你打成 429

7. 成本护栏:把最坏情况钉住

7.1 必须做的两条

  1. 强制 max_output_tokens
  2. 强制预算 :每个请求允许的 maxInputTokensmaxTotalTokens

否则你的"限流"只是帮你更快烧钱。

7.2 预算型拒绝

ts 复制代码
function enforceBudget(inputTokens: number, maxOutputTokens: number) {
  const maxInput = 12000;
  const maxOut = 2000;
  const maxTotal = 14000;

  if (inputTokens > maxInput) {
    throw Object.assign(new Error("prompt too long"), { status: 413 });
  }
  if (maxOutputTokens > maxOut) {
    throw Object.assign(new Error("max output too large"), { status: 400 });
  }
  if (inputTokens + maxOutputTokens > maxTotal) {
    throw Object.assign(new Error("total tokens budget exceeded"), { status: 400 });
  }
}

生产里建议把这些阈值做成:按租户/按 API key 的配额配置。


8. 一套"能上线"的组合拳

把前面的模块组合起来,一个典型的请求路径如下:

  1. 入口:API Gateway 做租户级 RPS 限流
  2. 应用层:WorkerPool 控并发与队列(背压)
  3. 调用前:DualRateLimiter 按 expectedTokens 申请 TPM
  4. 上游:per-provider semaphore 控并发
  5. 失败:withRetry(尊重 Retry-After)
  6. 超时:maxQueueDelay + overall deadline
  7. 降级:小模型 / 缓存 / 返回"稍后重试"

8.1 端到端示例(伪代码)

ts 复制代码
const pool = new WorkerPool(64, 200, 2000);
const limiter = new DualRateLimiter({ rps: 200, tpm: 2_000_000 });
const providerSem = new Semaphore(16);

async function handle(req: { tenantId: string; prompt: string; maxOut: number }) {
  const inputTokens = estimateTokens(req.prompt);
  enforceBudget(inputTokens, req.maxOut);
  const expected = inputTokens + req.maxOut;

  return pool.submit(async () => {
    await limiter.acquire(expected, 1500);

    return withRetry(async () => {
      await providerSem.acquire(1500);
      try {
        return await callLLM({ prompt: req.prompt, maxOut: req.maxOut });
      } finally {
        providerSem.release();
      }
    }, 4);
  });
}

9. 观测:没有指标就没有限流

上线后你至少要有:

  • 429 分布:local 429 / upstream 429 / quota 429
  • 队列长度、排队时间 P50/P95/P99
  • in-flight 并发
  • TPM 消耗曲线
  • 每租户的拒绝率

你会发现很多"限流不生效"其实是:

  • 你的限流点放错了位置
  • 重试把流量放大了 3 倍
  • 或者排队时间比你想象的长

10. 结语

LLM 场景的限流,不是一个"中间件开关",而是一套完整的 资源管理系统

  • 入口挡洪水
  • 背压控队列
  • 双维度对齐供应商配额
  • 并发 gate 防止 429
  • 重试要克制
  • 成本要有护栏

如果你愿意做得更进一步:

  • 引入全局"token 预算调度器"(跨模型/跨供应商)
  • 把公平调度做成 WFQ
  • 对不同业务场景做分级:交互/批处理/离线

到这里,你的 LLM 服务才算真的"可运营"。

11. 细节坑:流式输出(SSE)会让限流更"阴险"

很多团队第一次做 LLM 流式接口(SSE / chunked response)时,会低估它对限流的影响。

11.1 流式不是"更快",它只是"更早看到"

流式输出让用户更快看到第一个 token,但对后端来说:

  • 连接会保持更久(长连接占用)
  • 你的 in-flight 会被放大
  • 如果客户端中途断开,你要及时取消上游请求,否则你在白烧 token

11.2 必须做的两件事

  1. 断连取消:客户端断开 → 立刻 cancel 上游
  2. stream budget:限制每个连接的最大持续时间、最大输出 tokens

下面是一个 Node.js(fetch + AbortController)的最小示例:

ts 复制代码
import { setTimeout as delay } from "node:timers/promises";

export async function streamChat(req: { prompt: string; deadlineMs: number }) {
  const ac = new AbortController();
  const deadline = Date.now() + req.deadlineMs;

  // 到点强制取消
  const killer = (async () => {
    while (Date.now() < deadline) await delay(50);
    ac.abort();
  })();

  try {
    const r = await fetch("https://provider.example/v1/chat", {
      method: "POST",
      body: JSON.stringify({ prompt: req.prompt, stream: true }),
      headers: { "content-type": "application/json" },
      signal: ac.signal,
    });

    // 把 r.body 直接 pipe 到 SSE(省略)
    return r;
  } finally {
    // killer 是后台循环,这里只需要保证请求结束时能退出
    // 更完整的实现建议用 AbortSignal.any + setTimeout
  }
}

工程建议:如果你无法可靠取消上游(有些 SDK 不支持),那你必须在成本预算里把"断连浪费"算进去。


12. 端到端 deadline:让所有重试都服从同一个时间预算

很多"雪崩式重试"不是因为重试次数多,而是因为:

  • 每一层都有自己的 timeout
  • 互相不知道
  • 最终一次请求在系统里活了 30 秒

正确做法:每个请求只定义一个 overall deadline,所有内部操作都要基于它计算剩余时间。

ts 复制代码
function remainingMs(deadline: number) {
  return Math.max(0, deadline - Date.now());
}

async function callWithDeadline(deadline: number) {
  const left = remainingMs(deadline);
  if (left <= 0) throw Object.assign(new Error('deadline exceeded'), { status: 504 });

  return withRetry(async () => {
    const t = remainingMs(deadline);
    if (t <= 0) throw Object.assign(new Error('deadline exceeded'), { status: 504 });
    // 把 t 传给 acquire / semaphore / upstream timeout
    return actuallyCall({ timeoutMs: Math.min(1500, t) });
  }, 4);
}

13. 降级策略:别让"失败"只有一种形态

限流/背压的目标不是把用户赶走,而是把系统活下来。常见降级选项:

  • 返回缓存:对相同 prompt(或 hash)短 TTL 缓存
  • 返回小模型:把生成长度压短,或者换更快更便宜的模型
  • 返回部分结果:例如只返回提纲/关键点
  • 异步化:把请求转成任务,返回 task_id,让用户稍后拉取

降级要注意两点:

  1. 降级路径也要限流,否则降级本身会成为新的热点
  2. 监控要能区分:正常响应 vs 降级响应

14. 一个真实的"事故复盘"模板(你可以照着用)

当你线上出现大面积 429 / 延迟飙升时,建议按这个顺序排查:

  1. 先看入口:是否突然流量暴涨?是否某租户异常?
  2. 看队列:队列长度是否持续上升?排队时间 P99 是否突破阈值?
  3. 看上游:429 是 local 还是 upstream?Retry-After 多大?
  4. 看重试放大:失败率 5% 是否被重试放大成 15% 的额外流量?
  5. 看 token:是否出现长 prompt/长输出导致 TPM 触顶?
  6. 看并发:in-flight 是否超过 provider 的实际并发上限?

然后给出三类动作:

  • 立刻止血:降并发、缩 max_output_tokens、关闭某些高成本功能
  • 一周内修复:加公平调度、加 deadline、加断连取消
  • 长期优化:token 预算调度、模型分层、批处理/请求合并

15. 附:Python(FastAPI)里怎么做同样的事

Python 生态里常见的坑是:

  • asyncio 的并发很容易开爆
  • httpx 连接池默认配置不一定匹配你上游

下面给一个最小可用的组合:

  • asyncio.Semaphore 控并发
  • 简单令牌桶控 RPS/TPM(思路同 TS)
  • tenacity 做指数退避
py 复制代码
import asyncio
import time
from dataclasses import dataclass

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_per_sec = refill_per_sec
        self.last = time.time()
        self.lock = asyncio.Lock()

    async def try_take(self, amount: float) -> bool:
        async with self.lock:
            now = time.time()
            delta = now - self.last
            self.tokens = min(self.capacity, self.tokens + delta * self.refill_per_sec)
            self.last = now
            if self.tokens >= amount:
                self.tokens -= amount
                return True
            return False

@dataclass
class Limits:
    rps: int
    tpm: int

class DualLimiter:
    def __init__(self, limits: Limits):
        self.rps = TokenBucket(limits.rps, limits.rps)
        self.tpm = TokenBucket(limits.tpm, limits.tpm / 60)

    async def acquire(self, expected_tokens: int, timeout: float = 2.0):
        start = time.time()
        while True:
            ok1 = await self.rps.try_take(1)
            ok2 = await self.tpm.try_take(expected_tokens)
            if ok1 and ok2:
                return
            if time.time() - start > timeout:
                raise RuntimeError('rate limited')
            await asyncio.sleep(0.05)

provider_sem = asyncio.Semaphore(16)
limiter = DualLimiter(Limits(rps=200, tpm=2_000_000))

async def call_llm(prompt: str, expected_tokens: int):
    await limiter.acquire(expected_tokens)
    async with provider_sem:
        # await httpx_client.post(...)
        return {"text": "ok"}

到这里,这篇文章的核心思想就完整了:限流不是一个点,而是一条链

相关推荐
AINative软件工程15 小时前
LLM 推理加速工程实战:从 KV Cache 到 Continuous Batching,把吞吐拉满但不把延迟搞崩
llm
虎鲸不是鱼16 小时前
LM Studio使用MTP的qwen3.6-27B-以7840hs的780M为例
大模型·llm·qwen·lm studio·mtp
数据智能老司机17 小时前
领域专用小型语言模型——端到端 Transformer 微调
llm
风雨中的小七17 小时前
和AI一起搞事情#6. 如何实现图片文字元素编辑?
人工智能·llm
Komorebi_999918 小时前
LangChain Day2 课程:提示词模板 + Chain 链精讲
大模型·llm
程序员三明治18 小时前
【AI】Tika:一次文档解析引擎的工程实践
java·人工智能·大模型·llm·后端开发·rag·tika文件解析
冬奇Lab1 天前
Agent系列(四):工具调用深度解析——Agent 的手和眼
人工智能·llm
冬奇Lab1 天前
一天一个开源项目(第111篇):Understand Anything - 把代码库变成可探索知识图谱的 AI 引擎
人工智能·开源·llm
养肥胖虎1 天前
完整学习LLM(四):Token是什么
大模型·llm·token·学习路线