工程师视角:不是讲"限流是什么",而是讲你在生产环境里怎么把 吞吐、延迟、成本、稳定性 同时守住。
0. 为什么 LLM 场景的限流更难
传统 Web 的限流,多数是为了防爬/防爆破/防 DDoS;而 LLM 的限流更像是 算力/配额/预算管理:
- 你被上游供应商限流:每分钟请求数、每分钟 Token 数、并发连接数
- 你也要限自己:防止某个租户/某个功能把队列打爆
- 你还要限"成本":一旦把并发开大,token 生成速度跟不上,延迟飙升、重试放大、账单爆炸
更麻烦的是:LLM 请求的"大小"不是请求体字节数,而是 输入 tokens + 输出 tokens 的随机变量。
这一篇我们用一套可落地的工程方案,把下面这些问题一起解决:
- 429 / 503 出现时怎么重试才不会雪崩
- 如何做"双维度"限流:Requests/min + Tokens/min
- 如何做"背压":队列、排队、超时、降级
- 如何做"全链路并发控制":入口、模型路由、供应商连接池
- 如何做"多租户公平性":不让大客户把小客户饿死
- 如何做"成本护栏":把最坏情况的 token 花费钉住
代码示例以 Node.js(TypeScript)为主,Python 给出关键片段。
1. 先定清楚:你到底在限什么?
LLM 服务里我们一般至少要控 4 个指标:
- RPS(请求数):防止 CPU/IO/连接爆
- TPM(tokens per minute):对齐供应商配额
- 并发(in-flight):限制同时在跑的请求数,防止排队失控
- 队列长度 / 排队时间:给背压一个明确的阈值
关键是:不要只做 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 背压三件套
- 最大排队时间(例如 2s)
- 最大队列长度(例如 200)
- 超时即降级(返回缓存、返回小模型、返回"稍后重试")
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 必须做的两条
- 强制 max_output_tokens
- 强制预算 :每个请求允许的
maxInputTokens、maxTotalTokens
否则你的"限流"只是帮你更快烧钱。
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. 一套"能上线"的组合拳
把前面的模块组合起来,一个典型的请求路径如下:
- 入口:API Gateway 做租户级 RPS 限流
- 应用层:WorkerPool 控并发与队列(背压)
- 调用前:DualRateLimiter 按 expectedTokens 申请 TPM
- 上游:per-provider semaphore 控并发
- 失败:withRetry(尊重 Retry-After)
- 超时:maxQueueDelay + overall deadline
- 降级:小模型 / 缓存 / 返回"稍后重试"
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 必须做的两件事
- 断连取消:客户端断开 → 立刻 cancel 上游
- 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,让用户稍后拉取
降级要注意两点:
- 降级路径也要限流,否则降级本身会成为新的热点
- 监控要能区分:正常响应 vs 降级响应
14. 一个真实的"事故复盘"模板(你可以照着用)
当你线上出现大面积 429 / 延迟飙升时,建议按这个顺序排查:
- 先看入口:是否突然流量暴涨?是否某租户异常?
- 看队列:队列长度是否持续上升?排队时间 P99 是否突破阈值?
- 看上游:429 是 local 还是 upstream?Retry-After 多大?
- 看重试放大:失败率 5% 是否被重试放大成 15% 的额外流量?
- 看 token:是否出现长 prompt/长输出导致 TPM 触顶?
- 看并发: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"}
到这里,这篇文章的核心思想就完整了:限流不是一个点,而是一条链。