当日调用量从 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);
}
关键设计决策:
-
先扣后补(Speculative Accounting):在请求发出时按估算量扣除,收到响应后用实际 usage 补偿差额。这比等响应回来再扣要安全,因为并发请求不会超发。
-
桶容量 vs 注入速率 :容量决定用户的"突发窗口",注入速率决定长期稳定速率。设
capacity = 5 * refillRate可以允许用户在 5 秒内突发消耗 5 倍的稳态速率。 -
内存 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 有两个核心差异:
-
限流单位不只是请求数,还有 Token 消耗量。必须同时控制 RPM 和 TPM,否则一个长 context 请求就能让整个账户超限。
-
延迟高,错误代价大。一个 LLM 请求平均 2--5 秒;频繁超时不只是错误率问题,还是用户体验灾难。排队比拒绝通常更合理。
四层防护各司其职:
- Token Bucket:用户级配额,控制 TPM
- 滑动窗口:全局 RPM 保护,防止瞬时冲击
- 优先级队列:排队 + 调度,给高价值请求让路
- Circuit Breaker:下游熔断,避免连锁故障
这套机制不复杂,每一层都可以独立测试、替换。从 Token Bucket 开始,根据你的实际瓶颈逐层加码。