详解限流与准入控制的本质区别,以及在 LLM 请求成本差异巨大场景下,为什么仅靠计数限流不够用。
限流是我们为 API 添加的第一层保护之一。
一个客户端每分钟可以发 100 个请求。一个租户可以发 1,000 个。也许昂贵的端点会有更严格的限制。当请求超出配额时,该请求会被拒绝。
这解决了一个重要的问题。
但它没有回答另一个问题:
当容量变得稀缺时,哪些工作实际上应该被允许消耗它?
这就是准入控制(admission control)发挥作用的地方。
这个区别对于 LLM 工作负载越来越重要——两次请求即使都算作一次请求,它们的成本也可能截然不同。
从高层次来看,限流器回答的是类似这样的问题:
这个调用方在一段时间内可以发送多少流量?
令牌桶可能允许每分钟 100 个请求,并带有一定的突发能力。
这可以防止:
HTTP 甚至有一个与此相关的状态码。RFC 6585 定义了 429 Too Many Requests,表示用户在给定时间内发送了太多请求。
限流是非常有用的。
但速率只是资源压力的一个维度。
想象一个限制为每分钟 100 个请求的 LLM 服务。
现在考虑两种工作负载。
交互式请求:
input: 1,000 tokens
max output: 300 tokens
latency: user is waiting
input: 30,000 tokens
max output: 4,000 tokens
latency: nobody is waiting
两次请求都计为:
1 request
但从底层系统的角度来看,它们并不等价。
它们可能占用提供商并发的时间长度不同。它们消耗的 token 预算也可能完全不同。它们可能有完全不同的延迟要求。
如果限流器的计量单位仅仅是每秒请求数或每分钟请求数,它就无法看到这些。
因此,流量可能完全在其配置的限流范围内,却仍然耗尽稀缺资源。
假设我们添加一个并发限制:
maximum in-flight requests = 20
现在应用程序无法用无限并行的请求压垮下游服务。
这是一个改进。
但想象一下,20 个后台请求占用了全部 20 个槽位。
一秒后,一个来自等待答案的用户的交互式请求到达。
限流器说:
allowed
并发限制器说:
no capacity
系统得到了保护,但它不一定保护了最重要的工作。
我们从速率问题变成了分配问题。
在本文中,我是在更狭窄的容量感知意义上使用"准入控制"这个术语。
术语并不是通用的。有些系统对"准入控制"的使用足够宽泛,甚至包含了限流本身。这里有用的区别在于:控制流量可以多快地到达,与决定某个特定请求是否应该消耗当前稀缺的执行容量。
在这个定义下,准入控制问的是:
根据当前可用的容量,这个特定的工作是否应该被允许开始?
这个决定可以纳入比传统限流器更多的信息:
current concurrency
current resource utilization
estimated request cost
request priority
tenant
workload class
reserved capacity
queue depth
deadlines
不仅仅是计算到达的请求数量,而是决定稀缺容量应该如何分配。
假设一个服务有 32 个执行槽位。
两种工作负载类共享它们:
interactive
batch
如果没有额外的控制,批处理可能会消耗全部 32 个槽位。
并发限制器仍然可以防止系统超过 32 个请求,但交互式流量现在要在没人等待的工作后面排队等待。
一种替代方案是静态划分容量:
interactive: 28 slots
batch: 4 slots
这保护了交互式流量,但可能会浪费容量。
如果只有 10 个交互式请求在运行,18 个交互式槽位就会空闲,而批处理工作却在等待。
更灵活的准入策略可以说:
交互式流量有受保护的容量。
批处理流量可以借用未使用的容量。
当交互式需求增加时,新的批处理准入会停止,
直到受保护的容量恢复。
现在系统可以同时追求两个目标:
仅靠每分钟请求限制无法表达这种策略。
这个问题存在于普通的分布式系统中,但 LLM API 使其特别明显。
请求成本差异巨大。
包含短聊天消息的请求与请求模型处理大文档且有大输出预算的请求并不等价。
因此,LLM 准入控制器可能会同时跟踪并发和近似的飞行中 token 预算。
request A:
input estimate = 1,200
max output = 400
reserved budget = 1,600
request B:
input estimate = 24,000
max output = 3,000
reserved budget = 27,000
现在,准入决策可以考虑资源压力,而不仅仅是请求计数。
预留甚至不需要完美预测最终 token 使用量就能有用。
它可以在准入时保守地预留,并在知道实际使用量后协调预留。
这将准入变成了资源分配问题,而不仅仅是流量计数问题。
还有另一个重要的区别。
限流通常代表关于传入流量的策略:
tenant A may send 50 requests/second
但分布式系统的安全速率并不一定是恒定的。
也许下游提供商已经变慢了。
通常在 500 毫秒内完成的请求现在需要 8 秒。
即使到达速率没有变化,并发也开始累积:
arrival rate stays constant
↓
requests take longer
↓
in-flight work grows
↓
queues grow
↓
latency rises
↓
timeouts trigger retries
↓
even more work arrives
Google 的 SRE 指南明确警告,简单的限流可能无法考虑整体服务健康状况,因此可能无法阻止已经开始的故障。它建议在系统接近过载时拒绝工作,并在资源耗尽导致级联故障之前卸下负载。
这是一种不同的故障模式,与客户端只是发送了太多请求不同。
用准入控制替换限流。
用每个机制处理它擅长的故障模式。
生产路径大致如下:
request
│
▼
authentication
│
▼
rate limit / quota
│
▼
admission control
│
▼
downstream service
if (!rateLimiter.allow(tenant)) {
return tooManyRequests();
}
const reservation = admissionController.tryAcquire({
workloadClass: request.workloadClass,
estimatedCost: estimateCost(request),
});
if (!reservation) {
return overloaded();
}
try {
return await callDownstream(request);
} finally {
reservation.release();
}
限流器保护服务免受流量策略违规的影响。
准入控制器保护稀缺的执行容量。
这些不是相同的工作。
我发现用这种方式来框架化这个区别很有用。
限流问:
这个调用方可以发送多少流量?
并发限制问:
可以同时执行多少工作?
准入控制问:
哪些工作应该现在消耗稀缺容量?
当系统接近过载时,我们应该停止接受哪些工作?
这些机制有重叠,真实系统经常将它们结合使用。边界并不是完全清晰的。
但它们回答的问题足够不同,以至于将所有这些都视为"限流"可能会隐藏重要的设计决策。
Agent 系统使分配问题变得更加有趣。
单个用户操作可以创建多个下游模型调用。
后台 Agent 可能持续执行。
重试可以成倍增加请求。
工具调用可能产生额外的模型调用。
长上下文操作可能消耗比短期交互式请求多得多的容量。
所以最终问题不再是:
我们应该允许每分钟多少个请求?
而是:
当需求超过可用容量时,哪些工作可以继续?
这是一个调度和资源分配问题。
仅靠限流无法回答它。
过载并不总是看起来像崩溃。
有时候每个组件在技术上仍然健康,而错误的工作消耗了可用容量。
交互式请求在批处理作业后面等待。
重试增加压力。
最终,用户会经历故障,尽管系统仍在按照设计处理请求。
因此,可靠的系统需要的不仅仅是最大请求速率。
它们需要一个关于稀缺的策略。
这就是准入控制试图解决的问题。
我一直在构建 async-bulkhead-llm 和 MoFlux 时探索这个问题,特别是在 token 感知的准入和保护交互式流量同时允许低优先级工作负载使用空闲容量方面。
我越深入这个问题,这个区别就越有用:
限流控制有多少流量到达。准入控制决定当流量到达时,哪些工作值得稀缺容量。