
你以为“把 batch size 调大”就能榨干 GPU,其实在 LLM 上,这个直觉经常是错的。很多团队一味调大批次,却发现吞吐量没上去,p99 延迟还莫名其妙飙升。问题往往不在模型,而是出在调度器:到底是谁在决定每一步前向里塞哪些请求、塞多少 token?
连续批处理(continuous batching)就是为了解决这个“批次被慢请求拖死”的老问题。vLLM、SGLang、TGI、TensorRT-LLM 等主流引擎几乎都把它当成默认配置,用同样的模型,在同一块 A100 上,能做到传统 Hugging Face 推理 20 倍以上的吞吐差距。要理解这件事,得先把传统机器学习里的“批处理 = 一个矩阵”这个思维放下。
传统批处理:一个简单的矩阵故事
批处理在经典模型里到底做了什么
在传统的机器学习推理中,批处理通常可以被看成一个矩阵。每个输入样本是一行,如果输入长度不一,就先填充或截断到统一长度,再堆叠成一个张量,一次前向传播输出每行一个预测结果。


每一行的计算成本基本相同,所有行同时开始、同时结束,而且在前向传播启动前,整个张量的形状是完全已知的。对调度器来说,这更像是一个“怎么把样本打包成张量”的问题,而不是一个复杂的时间调度问题。
据不少线上服务的经验数据,简单把 batch size 从 1 提到 64,就能带来 5~10 倍的吞吐提升,原因并不是算力变强,而是权重读取被摊薄了。
批处理真正的意义在于摊薄权重从高带宽内存(HBM)加载的固定成本。把 64 行输入堆在一次前向里,大家共享同一次权重读取,吞吐量自然上去:


传统调度器的“傻瓜式”工作流
分类器接受固定宽度输入,返回一个标签;序列模型则把输入填充到最大长度,并用 mask 掩掉填充部分。无论是哪种方式,输入张量形状在执行前就确定了,输出张量的行数也一一对应。
部署时的逻辑非常直接:填满一个批次,丢给 GPU 跑完,拿回结果,再开始下一个批次。请求之间没有状态传递,也不会跨批次存在,调度器只需要关心“什么时候凑够一个 batch”,而不用考虑“谁先谁后、谁长谁短”。
这种简单模型在 LLM 上就不太行了,因为 LLM 解码过程直接打破了这些前提假设。

LLM推理:批处理假设被逐条打碎
解码过程为什么和你想的不一样
LLM 的解码过程不再是“一次前向,算完所有输出”。底层机制是:每次前向传播为每个序列只生成一个 token,因此一个请求需要的前向次数等于它最终输出的 token 数,而这个数量在模型发出停止 token 之前是未知的。

填充在这里帮不上忙,因为不匹配的不是“输入宽度”,而是“每个请求占用 GPU 的时间长度”。一个生成 30 个 token 的请求和一个生成 400 个 token 的请求,如果被放在同一个固定批次里,前者会在第 30 步就逻辑上结束,但它的那一行位置会一直占到第 400 步,后面 370 步都在白白占坑。
有用户反馈,在真实业务流量中,输出长度差异可以轻松拉到 10 倍以上:有的只要几十 token,有的动辄上千 token,这种差异一旦用静态批处理,浪费就会被放大到肉眼可见。
GPU 仍然需要为这个“几乎空闲”的批次支付完整的权重读取成本,吞吐量被最慢的那个请求锁死。输出长度差异越大,浪费越严重:

三个关键差异:状态、步数和不确定性
如果把传统推理和 LLM 推理放在一起对比,会发现 LLM 解码打破了三个核心假设:
- 每次前向传播每个序列只生成一个 token,而不是一次性吐完结果。
- KV 缓存把状态从当前前向传递到下一次前向,请求是跨步“活着”的。
- 请求需要多少次前向传播,只有在生成停止 token 时才知道,步数是随机变量。
这意味着:

- 批次里的请求不会“整齐划一”地一起结束;
- 批次形状在时间维度上是动态变化的;
- 静态批处理会被长尾请求拖慢,GPU 利用率和延迟都变得不可控。
连续批处理就是在这个背景下被发明出来的,它的目标不是“把 batch 做大”,而是“让 batch 里的坑永远有人在干活”。
连续批处理:每一步都在重建批次
迭代级调度:批次不再一次性决定
连续批处理是一种被大多数 LLM 推理引擎采用的解决方案,vLLM、SGLang、TGI 和 TensorRT-LLM 均默认启用此机制。

核心思想是:批次成员不是在“批次开始时”一次性决定,而是在“每次前向传播前”动态决定。完成的请求在下一次前向传播时离开,等待队列里的请求立刻补位,避免空位被已经完成的工作占用。


从系统视角看,这叫“迭代级调度”:
- 调度器在每一步前向传播前拿回控制权;
- 重新决定哪些请求参与本步计算,以及每个请求分配多少 token;
- 同时在决策阶段就预留好 KV 缓存块,保证 GPU 真正执行时不会“中途没内存”。
这听起来有点像操作系统的进程调度:每一轮时间片分配给谁、分多少,都由调度器说了算,只不过这里的“时间片”换成了“token 预算”。
选择性批处理:能一起算的才打包
单纯在每一步重建批次还不够,因为不同请求此时的张量形状可能完全不同。比如:

- 一个请求刚在做 4096 token 的预填充;
- 另一个请求正在解码第 900 个 token;
这两者没法像传统那样直接堆成一个规整的矩阵。为了解决这个问题,引擎采用了“选择性批处理”的策略:只对那些“可以一起算”的操作做批处理。

在每个调度步骤中,所有要参与本步计算的 token,无论来自哪个请求,都会被展平成一个长序列,形状为(总 token 数 × 隐藏层大小):


- 层归一化、QKV 投影、前馈网络等操作对每个 token 独立工作,不关心 token 属于哪个请求,可以一次性高效处理整个展平序列;
- 注意力机制则不行,每个 token 只能关注同一请求的早期 token,而每个请求的 KV 缓存长度不同。
所以在注意力层,展平张量会被按请求拆分:

每个请求独立执行注意力操作,得到的结果再合并回展平序列,供后续可以批处理的层继续使用。说实话,这套拆分-合并逻辑实现起来并不轻松,但换来的好处是:
- 绝大部分算子仍然可以吃到“大 batch”的红利;
- 又不会被不同请求的长度差异卡死在注意力层。
一次调度步骤:vLLM V1的四步棋
调度器要回答的核心问题
调度器在两次前向传播之间,其实只是在回答一个问题:

下一步要运行哪些请求,每个请求分配多少 token?
以 vLLM 的 V1 调度器为例,这个决策过程被拆成四步:

第一步:设定本步预算
调度器先设定本步的计算预算:

max_num_batched_tokens:本次调度可分配的 token 总数;max_num_seqs:本步最多同时运行的序列数。
这两个参数既是“安全阀”,也是性能旋钮。它们决定了 GPU 这一步到底要干多少活,也决定了单步延迟的大致上限。
第二步:优先照顾正在运行的请求
正在运行的请求会优先获得预算。每个请求记录:
- 已计算的 token 数
num_computed_tokens; - 目标 token 数
num_tokens_with_spec(包括预填充和解码目标)。
调度器会分配 token 来缩小这两者的差距,直到预算被用完:
# 简化自 vllm/v1/core/sched/scheduler.py
tokens_this_step = min(
request.num_tokens_with_spec - request.num_computed_tokens,
remaining_token_budget,
)
预填充和解码在这里被统一处理,分块预填充和前缀缓存也不需要额外分支逻辑。如果预算不足以一次性分配完所有目标 token,就分多步慢慢补齐:


第三步:为这些 token 预留 KV 缓存块
在确定了每个请求本步要算多少 token 之后,调度器会立刻为这些 token 预留 KV 缓存块。关键点在于:
- 预留时机发生在调度决策期间,而不是 GPU 执行之后;
- 如果发现缓存块不足,会从最近运行的请求中回收,触发抢占逻辑。
这种“先预留再执行”的策略,避免了 GPU 跑到一半才发现没内存的尴尬,但也引入了新的权衡:抢占会带来重复计算成本。
第四步:用剩余预算启动新请求
当正在运行的请求都分配完本步 token 后,如果还有剩余预算,调度器会用这些预算去启动等待队列中的新请求:

- 每个新请求会拿到一部分 token 预算,用于预填充或首轮解码;
- 如果预算已经耗尽,本步就不会再启动新请求。
最终,调度器会输出一个“执行计划”给 GPU,包括:
- 每个请求本步要计算的 token 数;
- 对应的 KV 缓存块列表和张量布局信息。

Token预算:在延迟和吞吐之间拉扯
一个参数,两个维度的影响
每步执行时间大致取决于本步的 token 数,所以 max_num_batched_tokens 同时是延迟参数和吞吐量参数。这里有个容易被忽略的点:
- 这个预算是“本步所有请求 token 数之和”,不是单个请求的上限;
- 解码请求每步只贡献 1 个 token,预填充请求则贡献本步分配的 token 数。
当预算设在大约 2048 token 时:

- 单步执行时间短,交互式对话的延迟体验更好;
- 但 GPU 经常处于“吃不饱”的状态,整体吞吐偏低。
当预算拉到大约 16384 token 时:
- GPU 更容易被打满,吞吐量显著提升;
- 批次中的所有请求都要等这一步算完,单步延迟明显变长。

在 vLLM 的默认策略下,如果启用了分块预填充,调度器会优先满足正在解码的请求,把剩余预算分配给预填充。这种偏好让交互式请求的体验更稳定,但批量预填充任务的尾部延迟会被拉长。
一个实战观察:调太大也会“翻车”
我自己在压测时踩过一个坑:一开始为了追求吞吐,把 max_num_batched_tokens 调得很激进,结果发现 p99 延迟突然抬头,甚至比默认配置还差。后来对比 Prometheus 指标才发现,抢占次数在高负载时暴涨,GPU 在重复做大量预填充的工作。

这也是一个小小的信息差:很多人只盯着 QPS 和平均延迟,却忽略了 token 预算和抢占之间的联动关系。预算调得太大,反而可能把系统推到一个“频繁抢占、疯狂重算”的不稳定区间。
抢占机制:吞吐提升背后的隐形成本
抢占是怎么发生的
当 KV 缓存块分配失败时,调度器会触发抢占:
- 释放某个正在运行请求的缓存块;
- 把该请求标记为“被抢占”;
- 将
num_computed_tokens重置为 0,清除推测 token; - 把它重新放回等待队列的前端。

重置为 0 的代价很大。想象一个请求已经预填充了 3900 个 token,一旦被抢占,下次再被调度时,这 3900 个 token 需要全部重新计算。vLLM V1 默认采用“重新计算抢占”模式,弃用了旧版本的交换路径。

从外部看,这会表现为:
- GPU 利用率很高,看起来“很忙”;
- p99 延迟在高负载时明显上升;
- 直觉上像是“需要多加几块卡”,但其实很多算力在做重复工作。

如何发现并缓解抢占问题
vLLM 通过 Prometheus 指标 total_cumulative_preemption_cnt 监控抢占次数。p99 延迟升高时,优先看这个指标往往比盯着 QPS 更有用。
p99 表示最慢 1% 请求的延迟,这部分请求最容易被抢占影响。平均延迟往往掩盖了问题,因为大多数请求并没有被抢占。
常见的三个调优方向:
- 提高
gpu_memory_utilization:让更多显存用于 KV 缓存,减少因空间不足触发的抢占; - 降低
max_num_seqs:减少并发序列数,降低 KV 缓存峰值占用; - 提高
tensor_parallel_size:把权重跨多块 GPU 分片,每块卡能腾出更多空间给 KV 缓存。
需要提醒的是,这些调整都有副作用:
gpu_memory_utilization太高,可能挤压到其他显存需求;max_num_seqs太低,会限制并发度,吞吐下降;tensor_parallel_size增大,会引入更多跨卡通信开销。
我也不太确定哪一组参数在所有场景下都是“最优解”,这更多是一个结合业务流量特征反复压测、迭代的过程。
性能增量:23倍吞吐不是模型变快了
一个典型基准:静态批处理 vs vLLM
把前面的机制串起来,会发现连续批处理本质上就是一个循环:
- 调度器在每次前向传播前重建批次;
- 按固定预算为每个请求分配 token;
- 在决策阶段预留 KV 缓存块;
- GPU 按这个计划执行,完成后再把控制权交还给调度器。
Anyscale 在 OPT-13B 上的基准测试给了一个很直观的对比:
- 当输出长度差异很大时,静态批处理的吞吐量会掉到约 81 tokens/s;
- 在同一块 A100 上,vLLM 能达到传统 Hugging Face 推理约 23 倍的吞吐提升。

这里没有任何“模型加速魔法”,模型本身并没有变快,真正被优化的是:
- 批次里不再有大量“空跑”的行;
- 权重读取被更多有效 token 摊薄;
- KV 缓存被更精细地管理,减少了无谓的重算。
如果你现在正被吞吐量或 p99 延迟卡住,调度器层面的排查往往比“换更大模型/更大显存”更划算。抢占计数器是一个成本极低的起点,它直接反映 KV 缓存池是否匹配你配置的并发度。
把这些判断方法记下来,在你下次扩容或调参时,很可能比问一圈身边人更有用。等哪天你看到 GPU 利用率 90% 但吞吐上不去,就知道该先去看谁在悄悄被抢占、谁在重复预填充了。
常见问题
Q:如何判断我的服务是否需要启用连续批处理?
A:如果你的请求输出长度差异很大,或者同时存在大量预填充和交互式对话请求,连续批处理几乎是刚需。原因在于静态批处理会被最长的请求拖慢,短请求在后半程只是在“占坑不干活”,GPU 利用率被严重浪费。你可以通过两类信号来判断:一是监控不同输出长度区间的 p95/p99 延迟是否差异巨大;二是观察在提高 batch size 后,吞吐量提升是否明显“失灵”。一旦出现“GPU 很忙但 QPS 上不去”的情况,就应该考虑切换到支持连续批处理的引擎。
Q:max_num_batched_tokens 应该怎么选,才不会一调就把延迟搞炸?
A:可以从一个偏保守的值起步,比如让单步执行时间控制在几十毫秒级,然后逐步上调观察。这个参数越大,GPU 越容易被打满,但批次中所有请求的单步等待时间也会变长。比较靠谱的做法是:先在压测环境中固定请求分布,逐步放大 max_num_batched_tokens,同时监控三件事:整体吞吐、p99 延迟、抢占次数。如果发现吞吐提升开始变缓,而 p99 和抢占次数却明显上升,就说明已经接近“甜点区”边缘,可以适当回调一点,留出安全余量。
Q:抢占导致的重复预填充有多严重,值得为它专门调参吗?
A:在长上下文场景下,抢占的代价非常高,完全值得单独优化。原因是预填充通常涉及上千甚至上万 token,一旦被抢占,之前的计算全部作废,下一次调度要从头再来。你可以通过 total_cumulative_preemption_cnt 结合 p99 延迟来评估影响:如果在高负载时该指标快速增长,而平均延迟变化不大,说明少数长请求被频繁重算,尾部体验会非常差。建议优先尝试提高 gpu_memory_utilization 和适度降低 max_num_seqs,在不牺牲太多吞吐的前提下,把抢占频率压到一个可接受的水平。
Q:选择性批处理会不会让实现变得太复杂,不如直接按请求分开跑?
A:实现确实更复杂,但收益也非常可观。直接按请求分开跑,相当于把每个请求都当成 batch size=1 的小任务,权重读取几乎无法摊薄,GPU 会大量时间花在“搬运而不是计算”上。选择性批处理的关键增量在于:把能一起算的算子(如 LayerNorm、FFN、QKV 投影)全部打包成一个大 batch,只在注意力层按请求拆分。这样既保留了大部分批处理带来的吞吐优势,又兼容了不同请求长度和 KV 缓存的差异。实现成本主要由框架作者承担,对使用者来说,只是多了几个需要理解和调优的参数。
Q:在多模型、多租户场景下,连续批处理还适用吗?
A:适用,但调度器需要更聪明的策略。多模型、多租户意味着请求的长度分布、优先级和延迟容忍度都不一样,如果简单把所有请求混在一个连续批处理中,可能会出现“低优先级长任务拖慢高优先级短任务”的情况。比较实用的做法是:按模型或租户划分队列,为不同队列配置不同的 token 预算和并发上限;同时在调度器中引入优先级权重,让交互式请求在预算分配上占优势。近期不少云厂商在做的“多租户 LLM 服务”,本质上就是在连续批处理之上再叠一层多队列调度逻辑,这块还有很多可以继续打磨的空间。





