你经营着一个 LLM API 服务,同一瞬间来了三个请求:一个问 "1+1=?",一个让改写一段广告文案,最后一个让写一篇两万字的中篇小说。GPU 只有一块,你要怎么调度,才能让三个用户都觉得"这服务还凑合"?

最直觉的想法:把三个请求塞进同一个 batch,一起算。但马上会撞上一个尴尬的事实——第一个请求第 3 步就生成完了,第三个请求可能要跑几千步。中间这段路,GPU 要么空转,要么被最慢的那个请求拖着走。
这个问题有一个专业名字:静态批处理的低利用率问题。它的解法,就是标题里的 Continuous Batching(连续批处理)。
静态批处理:GPU 的算力被最慢的请求绑架
要理解解法,先得看清 LLM 生成的一个底层事实:模型不是一口气吐出一篇文章的。它是自回归的——每生成一个词,就把这个词接回上下文,再重新前向传播一次,预测下一个词。你给模型一段 prompt,它的"思考"节奏是固定的:一步一个 token。
这就导致批处理在 LLM 推理里有特殊的形态:一批请求被塞进同一个 forward pass,它们必须齐步走——每个请求在同一轮迭代里各生成一个 token,然后停下来,集体进入下一轮。
以 FasterTransformer 为代表的经典推理框架,用的策略很朴素:攒一批请求,跑,等这一批里最慢的那个生成完,整批才释放。这被称为静态批处理(static batching)。
静态批处理的浪费是双重的。第一,快的请求被慢的请求绑架。生成 10 个 token 的请求早就可以返回了,但它占用的 GPU 算力要陪着 2000 步的那个请求走完全程。第二,为了让矩阵运算形状对齐,需要在短序列后面填充无意义的 token,这部分计算完全不产生价值。
连续批处理的全部秘密:调度单位从"请求"改成"一个 token"
2022 年,一篇题为 Orca 的系统论文改变了局面。它提出的 Continuous Batching,核心思想一句话就能说清:不要等一个请求生成完才处理下一个,而是让调度粒度等于模型的一次迭代(一个 token)。
连续批处理的运行方式是这样的:
- 往 batch 里塞进几个排队中的请求;
- 跑一次 forward pass,batch 里每个请求各生成一个 token;
- 检查哪些请求产出了 EOS 标记(生成了结束符),把它们移出 batch;
- 从等待队列拉新的请求补进空位;
- 回到第 2 步。
while queue or batch:
# 移除生成完毕的请求,只留活着的
batch = [r for r in batch if not r.finished]
# 从等待队列补入新请求,直到容量上限
while len(batch) < capacity and queue:
batch.append(queue.pop())
logits = model.forward(batch)
for req, logit in zip(batch, logits):
req.append(sample_token(logit))
if req.last_token == EOS:
req.finished = True
我第一次读 Orca 论文时,预期会看到一个复杂的调度算法,比如某种新的矩阵分解。结果核心思想就这一句话:把等待的粒度从"整个请求"缩小到"一个 token"。简单到让人怀疑,但效果惊人——论文报告,在与当时先进的推理系统对比时,Orca 在相同延迟水平下把吞吐量提升了 11.4 倍。Orca 论文
为什么把等待粒度从请求降成一次迭代就能解决浪费?因为调度时机决定了一次前向传播的有效性。静态批处理只在请求结束时才调度;连续批处理在每一步生成后都调度。已经生成完的请求占着的算力位置,下一轮立刻被新请求顶上——GPU 只有在队列空的时候才有理由休息。
你可能会问,这和动态批处理有啥区别?
很多人把 Continuous Batching 和 Dynamic Batching 混在一起。我最早也混,后来发现差得很远:连续批处理不等于动态批处理。
动态批处理(Dynamic Batching)做的是"动态组批"——请求到达后不等太久,攒够一小批就启动新一轮推理。但一轮推理一旦启动,batch 内部的生成过程还是静态的:还是得等最慢的请求跑完,整个 batch 才释放。
打个比方:动态批处理像餐厅高峰期快速翻台——桌子的分配动态了,但每桌菜要一起做、一起上。连续批处理是:哪桌做好了直接上哪桌,不用等别人。
| 维度 | 静态批处理 | 动态批处理 | 连续批处理 |
|---|---|---|---|
| 组批时机 | 请求攒够一批 | 定时 / 攒够即开跑 | 每次前向传播之后 |
| batch 中途能否增减请求 | 否 | 否 | 是 |
| 快的请求要不要等慢的 | 要等 | 要等 | 不等 |
| GPU 利用率 | 低 | 中 | 高 |
让 batch 形状每毫秒都在变,工程上靠什么兜底?
你可能会问一个很实际的问题:把完成的请求移除、新请求插入,batch 的形状一直在变,矩阵运算还怎么跑?
一个 batch 的矩阵乘法形状是 (batch_size, seq_len, hidden_size)。batch_size 变了,kernel 就要重新分配、重新启动。旧框架不这么干,不是因为不会,而是因为频繁改变 batch 形状带来的调度开销,可能比省下的算力还多。
Orca 的解法叫 Selective Batching(选择性批处理),思路非常工程化:把"与请求状态无关"的运算和"与请求状态有关"的运算拆开。
- 矩阵乘法(FFN、投影层)这类运算是无状态的,不管 batch 里有谁,都可以合并成一个大矩阵算;
- 注意力计算和采样依赖每个请求自己的 KV cache 和位置信息,没法简单合并。
Orca 把前者统一批量执行,把后者按请求逐个处理。这样,batch 形状的变化就不再惩罚主要的计算量。形状随便变,GPU 大部分时间仍是在跑那种"多加一个请求只多一行矩阵"的廉价运算。这个机制让我真正理解了连续批处理的收益从哪来——不是调度算法在起作用,而是调度粒度和计算流水线一起变了。算法改动如果不配合系统能力,就是纸面优化。
KV cache:连续批处理藏起来的第二个瓶颈
连续批处理把 GPU 算力用满了,但另一个瓶颈马上浮出水面:内存。
每个请求在生成过程中,都会把自己已经生成的 token 对应的 Key/Value 向量缓存在显存里(也就是 KV cache),下次生成要用。一个请求的 KV cache 大小跟它的上下文长度成正比。于是:算力利用率提高了,显存压力也同步提高——batch 能容纳多少请求,不再取决于算力上限,而取决于显存上限。
这也是为什么 vLLM 的 PagedAttention 总是跟连续批处理一起被提及:PagedAttention 把请求的 KV cache 切成固定大小的"页",用页表索引,允许不连续地存储。内存碎片少了,batch 就能装下更多请求,连续批处理的收益才能被真正放大。vLLM 官方博客报告,结合 PagedAttention 和连续批处理后,相比原生 Transformers 的吞吐量提升可达 24 倍。vLLM 官方博客
连续批处理不是什么灵丹妙药
连续批处理这么好,是不是所有 LLM 推理框架都能无脑上?不是。
第一,调度有开销。每一轮生成结束后都要检查状态、拉新请求、调整内存,这些操作要占用 CPU 时间。在 batch 很大、每个请求都很短的场景,调度开销会吃掉一部分收益。
第二,它会牺牲一点单请求延迟。新请求插入 batch 后,batch 变大,每轮 forward pass 的时间变长;如果显存被填满,调度器还要做抢占(preemption),被迫把一些请求的 KV cache 换到 CPU 内存(swap)。生产系统的调度器,本质上是在吞吐量和延迟之间做权衡。
第三,它解决不了生成本身慢的问题。连续批处理让 GPU 保持忙碌,但一个长文本请求本身仍然要几百上千次前向传播。要再快,得靠投机解码、量化、并行策略等其他技术。
给一个判断:连续批处理是 LLM 服务系统的地基,不是天花板。今天的 vLLM、TensorRT-LLM、SGLang 都把连续批处理作为标配,但真正的系统竞赛早已转移到内存管理、调度策略和投机解码的配合上。
如果你想读这些推理框架的源码,从连续批处理入手是最正确的入口——它决定了一个推理系统最核心的节奏:谁在什么时候被计算。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/1545.html