LLM 推理里的静态批处理、动态批处理和连续批处理,是决定 GPU 利用率、吞吐和响应延迟的关键机制。很多刚接触推理服务的人只记住“连续批处理更强”,却不清楚三种策略分别解决什么问题、各自要付出什么代价,结果要么用最简单的实现扛不住流量,要么换上新框架后参数调不明白,性能还不如原来。这篇文章按实际落地顺序把三种批处理方式拆开:它们怎么工作、瓶颈在哪、什么场景该选谁、调参和排错先看什么。
1. 批处理解决的核心问题:为什么 GPU 利用率会掉下去
1.1 LLM 生成方式决定了批处理的价值
LLM 生成一个响应通常分两个阶段。prefill 阶段把整段 prompt 并行算一遍,把中间状态写入 KV cache;decode 阶段一个 token 一个 token 地生成,每一步都要读取模型权重和上一轮产生的 KV cache。decode 阶段是典型的内存带宽瓶颈,不是算力瓶颈。一个请求独自生成下一个 token 的时候,GPU 的矩阵计算单元大部分时间闲着。
如果同时处理 8 个请求,每个请求都能在这条带宽上限附近推进自己的解码步骤,整体吞吐接近单人任务的数倍。批处理的核心价值就在这里:把多个请求的 decode 合并到同一轮迭代里,让显存带宽尽量满载。
一个值得记住的判断是:prefill 阶段消耗的是算力,decode 阶段消耗的是显存带宽。同一个请求在生成过程中会经历两种完全不同的资源压力;多个请求处于不同阶段的概率越高,调度方式就越要灵活。这也是为什么不能只靠“显存放得下”来推断并发效果——decode 每一轮都要把模型全部参数从显存里扫一遍,哪怕只生成一个 token 也一样。并行请求数越多,单位时间内扫参次数越多,吞吐收益越明显。
1.2 批处理本质上是三类约束的取舍
批处理不只是“多塞请求”,它同时受三样东西约束:
- 显存:每个序列的 KV cache 会随生成长度增长,能并行放多少序列,取决于内存池大小和单序列长度上限。
- 计算:prefill 是密集型矩阵计算,decode 是带宽型访问,二者混在一起时,调度策略会影响卡顿。
- 延迟:用户能接受的首 token 延迟和每 token 间隔是固定范围的,不能为了吞吐无限下压。
批处理策略本质上是在显存、计算、延迟之间选平衡点。静态、动态、连续三种方式,就是三个不同的平衡方案。理解它们,不需要先背框架,而是先理解“每一步迭代 GPU 在忙什么”。
2. 静态批处理:固定批次“同进同出”
2.1 工作流程与实现成本
静态批处理最好理解:服务端收集一批请求,凑满 N 个之后开始执行;每轮 decode 迭代,批次内所有请求同时前进一步;直到所有请求都生成结束,整个批次才释放,再处理下一批。
翻译成直白的话就是“同进同出”。实现成本很低,不需要复杂调度器,不需要动态内存管理。预处理时按最长 prompt padding 到同一长度,批量跑 forward 就行。很多离线评测脚本、本地试验代码、早期推理示例都采用这种写法。
优点是确定性强。一个批次占多少显存、大概多久结束,基本可以提前估算,代码里不容易出现队列积压或内存碎片。如果你只是做离线批量生成,比如给一批测试数据统一生成结果,并且 prompt 长度接近,静态批处理完全够用,没必要上复杂方案。
2.2 核心问题:木桶效应、padding 浪费和凑批等待
静态批处理的问题在输出长度不均匀时非常明显。一个批次里 8 个请求,7 个已经生成完,剩下一个还在输出,那 7 个请求的 KV cache 位置仍被占着,GPU 每轮仍要为整个批次做完整 forward。最慢的请求决定了批次结束时间,这就是木桶效应。
举个例子,一批 8 个请求,7 个只需要生成 20 个 token,第 8 个要生成 600 个 token。前 20 步之后,前面 7 个全部结束,但因为批次固定,它们仍然要陪跑到第 600 步。这段时间里 GPU 做的计算,有相当大比例是无效的。如果这种负载持续存在,静态批处理的资源浪费就是常态,不是偶然。
其次是 padding 浪费。prompt 长短不一时,批次要统一到最长长度,短 prompt 会把大量算力花在无效 token 上。再次是凑批等待:固定批次如果非要凑满才跑,流量不够时请求会在队列里等很久;不满也跑,吞吐又不稳定。
判断场景是否适合静态批处理,可以观察 decode 阶段 GPU 利用率和请求完成情况:如果陆续有请求结束,但利用率没有明显回升,说明大部分时间都在等少数慢请求,静态方案已经不匹配。
3. 动态批处理:把排队做精细,但没有打破批次边界
3.1 动态批处理改了什么
动态批处理把“凑批”从固定规则变成队列调度。请求到达后先进队列,调度器根据预设条件,比如最大批大小、最大等待时间、当前批次空位,在合适时机组成一个批次执行。批次结束后,再从队列取下一批。
直观理解:静态是“必须凑齐一桌才开饭”,动态是“人差不多就开饭,没坐满先到的人等一会儿也开饭”。这类思路在传统推理服务里已经成熟,Triton、TensorFlow Serving 等组件里的 dynamic batching 就是类似机制。
动态批处理相对静态的提升,集中在请求到达不均的场景。突发流量来了,队列能及时组成批次发出去,而不是死等固定数量;流量稀疏时,又能在等待窗口内尽量凑出大批次。它把“批次之间”的空闲时间压缩了。
3.2 收益天花板与适用场景
动态批处理解决的是“批次之间”的空闲等待,但没有解决“批次内部”的木桶效应。一个批次一旦开始执行,所有请求仍然同步解码,最慢请求不结束,批次不会释放。它的天花板依然是混合负载下 GPU 利用率下滑。
什么场景动态批处理就够?我观察下来是两类:一是 prompt 和输出长度相对均匀,比如固定模板摘要、固定宽度表格生成;二是并发量不高、GPU 没有吃满。这类场景下动态批处理实现简单、容易调优,已经能稳定服务。
如果你预判未来流量会变成“长尾巴”分布,比如大多数请求短,少数请求特别长,那动态批处理很快就会到天花板。此时再想靠调大 batch size 解决问题,效果有限。
3.3 典型参数与调试思路
动态批处理常见参数:max_batch_size(最大批大小)、max_queue_delay(队列最多等多久)、preferred_batch_size(优先批大小)。配置取决于请求到达率和可接受延迟。
如果 GPU 利用率低但队列没有积压,多半是 max_queue_delay 太短,批次没凑大就发出去了;如果平均等待时间飙升,多半是 max_queue_delay 太长或 max_batch_size 过大。还有一种常见错误:只调大 batch size,不调等待时间,结果批次经常不满,参数等于白改。排查时先看队列长度和平均等待延迟,再决定调哪个参数。
4. 连续批处理:迭代级调度,每一步都可以“换人”
4.1 核心思路是更细的调度粒度
连续批处理也叫迭代级调度(iteration-level scheduling),核心变化是把批次从请求级别缩小到迭代级别。每一轮 decode 迭代开始前,调度器重新决定当前迭代跑哪些请求:生成完的请求立刻退场,新请求立刻进场,新请求的 prefill 也可以和旧请求的 decode 交错安排。
这样就不存在“等整个批次结束”的阶段。请求结束的那一步,资源立刻释放,新请求马上顶上。GPU 每一轮都尽量“有多少显存就放多少活跃序列”,而不是“一个批次占多少坑就放多少”。
这套思路在 LLM 推理服务框架里已经很常见,比如 vLLM 的 continuous batching、TensorRT-LLM 的 in-flight batching、Hugging Face TGI 等,方向基本一致,区别主要在内存管理、prefill 调度和抢占策略。
4.2 为什么吞吐更高,代价是什么
连续批处理明显提升吞吐,原因是 decode 阶段是内存带宽瓶颈,它让更多序列同时处于解码状态。KV cache 允许几十个请求在同一轮迭代里各自生成下一个 token,而不是被一个慢请求困在同一个批次里。
代价是调度复杂度明显上升。KV cache 需要动态分配,否则碎片和分配开销会拖垮性能;prefill 和 decode 混在一起时,超长 prompt 的 prefill 会挤占当前迭代计算资源,导致其他请求 token 间隔瞬间变大。所以很多实现支持 chunked prefill,把 prefill 切成小块穿插到多个 decode 迭代里。
连续批处理不是“零成本白拿性能”。吞吐提升,一部分是用请求间延迟抖动换的。强实时业务要特别关注 p99 token 间隔,而不是只盯平均吞吐。
4.3 常见参数怎么理解
常见开源推理服务里,你会看到这类参数:
- gpu_memory_utilization:允许服务用多少比例显存。调高意味着更多 KV cache、更高并发,但给模型权重和运行时留的余量变小,容易 OOM。
- max_num_seqs:单步最多同时处理的序列数,相当于并发上限。设太大,延迟爆炸;设太小,吞吐上不去。
- max_model_len / max_seq_len:单序列最大长度,决定 KV cache 预留。设太大,KV cache 池被切碎,可并行序列反而变少;设太小,长请求直接报错。
- max_num_batched_tokens / chunked prefill 参数:控制单轮迭代最多处理多少 token,以及 prefill 是否切块。
调参顺序我建议固定成:先单条请求跑通,再逐步提高并发,观察显存和 token 间隔,最后放混合长度请求看 p99 延迟。不要一上来就把显存利用率拉满,也不要同时开最大并发和最大序列长度。
5. 三种方式怎么选:对比表和选型建议
5.1 一表看差异
| 对比维度 | 静态批处理 | 动态批处理 | 连续批处理 |
|---|---|---|---|
| 调度粒度 | 整个批次 | 整个批次(队列更灵活) | 单个迭代 |
| 实现复杂度 | 低 | 中 | 高 |
| GPU 利用率 | 输出长度不均时明显下降 | 批次之间下降缓解,批次内仍低 | 持续保持高位 |
| 吞吐表现 | 均匀负载下不错 | 中等流量下提升明显 | 混合负载下提升最明显 |
| 延迟稳定性 | 稳定但等待可能高 | 居中 | 用部分抖动换吞吐 |
| 内存效率 | 有 padding 和预留浪费 | padding 浪费仍在 | 按需动态分配,效率高 |
| 适合场景 | 离线批量、长度均匀、评测 | 简单在线服务、到达不均但输出均匀 | 生产级在线推理、混合长度、高并发 |
| 典型实现 | 早期示例、自写脚本 | Triton、TF Serving 动态批处理 | vLLM、TensorRT-LLM、TGI |
5.2 选型逻辑:先看负载,再看维护成本
选择不是“越高级越好”,而是看负载形状。离线评测、固定长度批量生成,静态批处理足够;在线服务但输出长度稳定、并发不高,动态批处理性价比不错;生产环境面对长尾请求、prompt 和输出长度都无法预测,连续批处理几乎是必须选项。
这张表背后有一个更底层的判断标准:你可以接受多少调度复杂度,来换取多少吞吐稳定性。静态方案几乎不需要排查调度问题;连续方案则需要持续盯显存、队列、token 间隔。如果你的服务是长期在线运行,调度复杂度高一点是值得的;如果是临时跑一批数据,完全没必要上连续批处理。
另外要注意,当前主流的 LLM 推理框架大多默认已经实现了连续批处理,所以在框架内部,你很可能没有太多“静态、动态、连续”之间的自由选择空间。真正决定最终效果的是参数:有多少显存给 KV cache、单步允许多少序列、单序列允许多长。这些才是需要你亲手调的部分。
6. 实测和排查:怎么判断批处理到底有没有生效
6.1 先跑三个小实验
第一个实验:单请求连续发 10 次,记录首 token 延迟和每 token 间隔,这是基线。第二个实验:并发 8 个请求,prompt 长度相近,输出长度故意做成 1:5 的差异,观察 GPU 利用率和完成时间。第三个实验:模拟突发流量,一次性压 50 个请求,观察队列长度、OOM 和 p99 延迟是否恶化。
这三个实验能暴露问题:如果并发后利用率上不去,说明并发没生效,检查 KV cache 和 max_num_seqs;如果利用率高但 p99 延迟暴涨,说明一批里挤了太多 prefill,考虑限流或 chunked prefill;如果偶发 OOM,优先检查 gpu_memory_utilization 和 max_model_len 之间的平衡。
6.2 常见误区
误区一:连续批处理一定比动态快。如果负载本身是均匀短输出,调度复杂度反而可能带来额外开销,提升不明显的例子很常见。判断标准是实测吞吐和延迟,不要只看框架宣传。
误区二:batch size 越大越好。decode 阶段受显存带宽限制,序列越多,每轮迭代耗时越长,单序列只分到一丁点带宽。batch size 超过合理范围后,吞吐没有提升,延迟反而迅速恶化。
误区三:只看平均吞吐,不看尾部延迟。连续批处理的一个特点就是平均吞吐好看、p99 波动大。生产环境关注 p95/p99 首 token 延迟和每 token 间隔,比平均吞吐更接近用户体验。
误区四:用固定长度 benchmark 验证调度收益。固定长度均匀负载会让静态批处理和连续批处理的差距变小,因为木桶效应没有被触发。要验证收益,必须用混合长度请求。
6.3 排查顺序
遇到问题别急着改参数,按顺序走:
- 看现象:是吞吐低、延迟高、卡住、还是 OOM。不同现象指向不同层。
- 看输入:请求长度分布、到达率、并发方式。很多“调度问题”实际上是输入负载本身的问题。
- 看配置:batch size、等待时间、max_num_seqs、KV cache 比例、max_model_len。
- 看资源:用 nvidia-smi 一类工具观察显存占用、GPU 利用率、显存是否有碎片化。
- 看框架版本:不同版本对连续批处理和 chunked prefill 的支持和默认行为差异很大,优先确认版本文档。
实测时我会把每轮实验的输入分布、参数、指标都记录下来。同一个负载,参数不同可能得出完全相反的结论。没有记录,很难判断到底是调度变了,还是恰好流量变化。
批处理策略选型,说到底是把“GPU 忙什么”这件事看清楚。静态、动态、连续三种方式,对应的是调度粒度从整批到单步的演进,也对应实现成本和调试难度的一次次上升。我个人更建议先把单请求、均匀并发、混合负载这三个实验做完,拿到基线数据后再决定用什么方案;很多问题不是模型能力不够,而是输入分布、显存预算和调度参数没有匹配好。