同一个模型,同样的显卡,两个推理服务端给出的吞吐却可能差出好几倍。这个差距很少来自模型权重本身,更多来自推理引擎怎么安排请求的执行顺序。今天这篇只聊一件事情:LLM 推理里的 Static Batching、Dynamic Batching、Continuous Batching 三种批处理策略到底差在哪,为什么 Continuous Batching 几乎成了现代 LLM 推理框架的标配,以及你自己部署推理服务时应该怎么选。
这篇不是纯概念科普。我会先讲清三种策略的执行模型和代码差异,再对比它们对吞吐、显存、延迟的实际影响,然后结合 vLLM、TensorRT-LLM 这类常用推理框架说明它们各自落在哪个位置,最后给出一套本地部署时的选型建议和压测方法。适合正在做模型服务化、API 封装、性能调优的读者,也适合那些刚接触 vLLM、TGI、TensorRT-LLM 时对 continuous batching 概念反复犯迷糊的人。
1. 核心能力速览
| 维度 | Static Batching | Dynamic Batching | Continuous Batching |
|---|---|---|---|
| 调度粒度 | 整批请求 | 整批请求 | 请求级 / 迭代级 |
| 请求同步性 | 同进同出 | 同进同出 | 各自独立推进 |
| Padding 开销 | 高,按批次内最长序列补齐 | 较高,仍按批内长度对齐 | 低,按请求实际长度推进 |
| 吞吐潜力 | 低 | 中等 | 高 |
| 实现复杂度 | 低 | 中等 | 高 |
| 队列调度 | 无 | 有等待窗口凑批 | 请求级状态机 + 优先级调度 |
| 典型场景 | 离线批量推理、实验脚本 | 早期 Web 推理服务 | 现代在线 LLM 推理框架 |
| 代表实现 | 直接循环model.generate() | Triton Dynamic Batcher、早期 TF Serving | vLLM、TensorRT-LLM In-flight Batching、TGI |
需要先说明一点:这三种策略没有绝对的谁替代谁。静态批处理在离线全量任务里仍然简单可靠,动态批处理适合请求量不大但需要自动聚合的场景,连续批处理则是面对高并发在线推理时吞吐最优的选择。
2. 为什么 Batching 是 LLM 推理的关键
要理解三种批处理策略,先得明白 LLM 推理本身的特殊性。
LLM 生成一个回答,通常分为两个阶段:
- Prefill(预填充):把用户输入的 prompt 一次性喂给模型,并行计算,生成第一个 token。这个过程计算密集。
- Decode(解码):逐个生成后续 token,每个 token 都要依赖前面所有 token 的 KV Cache。这个过程是内存带宽密集,而不是计算密集。
真正让 GPU 头痛的是 decode 阶段。每个请求单步只生成一个 token,计算量很小,但要把整个模型权重和 KV Cache 从显存里搬一遍。如果 GPU 只服务一个请求,显存带宽会被大量浪费,GPU 利用率很低。
Batching 的作用,就是把多个请求的 decode 步骤合并成一次前向计算。模型权重只需要从显存读一次,多个请求共享这次读取,从而把吞吐拉起来。
问题也随之而来:请求什么时候到?请求长度差多少?先到的请求要不要等后到的请求?这就是静态批处理、动态批处理、连续批处理三种策略要回答的核心问题。
3. 静态批处理 Static Batching
静态批处理是最直观、最容易实现的方式。
它的执行模型是:把一批请求打包成一个固定 batch,模型对这批请求统一执行 prefill 和 decode,整个 batch 必须全部生成结束后,才开始下一批请求。
这段逻辑用伪代码看更清楚:
def run_static_batch(requests, batch_size): for start in range(0, len(requests), batch_size): batch = requests[start:start + batch_size] # 统计当前批次内最长的 token 序列 max_len = max(len(req["input_ids"]) + req["max_new_tokens"] for req in batch) # 长度不足的请求需要用 padding 补齐 padded_batch = [pad_to(req, max_len) for req in batch] # 整个 batch 同步生成,所有请求结束后才返回 outputs = model.generate(padded_batch) save_outputs(outputs)静态批处理最明显的问题有两个:
第一是 padding 浪费。LLM 推理的 decode 阶段长度取决于每个请求的生成进度,但静态批处理在开始时就把批次内所有请求补齐到相同长度。短的请求早就生成完了,但必须陪着最长的请求一起等,中间这段显存和计算资源全部浪费。
第二是队头阻塞。如果批次里有一个请求生成长文本,其他请求可能几十个 token 就结束,但整个 batch 必须等它全部完成才能释放。之后释放的显存也只能等下一批凑齐,GPU 会有明显的空闲气泡。
从工程角度看,静态批处理并不是一无是处。它的实现简单,行为可预测,不会出现请求被反复抢占导致的延迟抖动。离线批量任务、不需要响应实时性的场景,静态批处理仍然够用。PyTorch 原生写推理脚本时,model.generate()走的基本就是这条思路。
4. 动态批处理 Dynamic Batching
动态批处理在静态批处理的基础上加了一个关键机制:请求队列和等待窗口。
动态批处理器维护一个请求队列,新请求到达后先进入队列,调度器等待一个可配置的时间窗口。窗口内如果凑够了 batch 大小,就启动一批;如果等待超时,也先用当前已到达的请求组成 batch 启动。这样就不需要所有请求刚好同时到达,降低了对请求到达节奏的敏感度。
核心逻辑可以简化成下面这样:
import time def dynamic_batching_scheduler(request_queue, max_batch_size, wait_timeout_ms): batch = [] deadline = None while True: req = request_queue.get() batch.append(req) if deadline is None: deadline = time.time() + wait_timeout_ms / 1000.0 if len(batch) >= max_batch_size or time.time() >= deadline: # 触发一次推理 yield batch batch = [] deadline = None动态批处理解决的问题是“请求到达不均匀”。它允许请求在时间维度上聚合成批,而不是像静态批处理那样必须整整齐齐同时到达。
它的问题依然存在:批次内仍然按最长的序列 padding。只要 batch 里有一个长请求,其他短请求就要陪跑到最后。动态批处理并没有从根本上消除解码阶段的等待浪费,只是把“同时到达”的要求放宽成了“一个时间窗口内到达”。
动态批处理适合请求并发量不高、延迟要求不极端的场景。比如内网工具类服务,平均每秒几个请求,动态批处理就可以把零散请求聚合成批,获得不错的吞吐提升。很多早期推理服务框架默认采用这种策略,配置项里通常叫max_batch_size和wait_timeout。
5. 连续批处理 Continuous Batching
连续批处理又叫迭代级调度,或者 In-flight Batching,是目前 LLM 推理框架的主流方案。
它的核心思想是:不再把整个 batch 当作一个不可拆分的整体,而是把调度粒度缩小到每一次迭代,或者说每一个 decode 步骤。
每个请求在推理引擎内部是一个独立的状态对象。调度器每个 step 都会重新决定:下一轮前向计算要包含哪些请求。有的请求刚刚完成 prefill,有的请求已经 decode 了 40 个 token,有的请求已经结束需要释放显存,有的请求还在等待队列里。这些状态可以交错执行。
class ContinuousScheduler: def __init__(self, max_running_seqs=16): self.waiting = [] self.running = [] self.max_running_seqs = max_running_seqs def step(self): # 每次迭代前,尝试让等待队列里的请求进入执行集合 while len(self.running) < self.max_running_seqs and self.waiting: seq = self.waiting.pop(0) seq.prefill() # 新请求先做 prefill self.running.append(seq) # 每个请求只前进一步 for seq in self.running: seq.decode_one_token() # 只保留未完成的请求 self.running = [seq for seq in self.running if not seq.is_finished()]这段伪代码省略了大量工程细节,但核心逻辑已经体现出来了:
- 没有整批同步等待。
- 每个请求在 running 列表里都有独立进度。
- 新请求可以在任意迭代加入,完成的请求马上释放资源。
- 不存在批次内 padding。每个请求按自己的实际 token 长度推进,GPU 算力被不同进度的请求填满。
连续批处理要真正高效,还需要解决几个配套问题。
第一是显存管理。每个请求的 KV Cache 长度会随生成过程动态增长,不能按固定最大长度预留,否则显存浪费会很严重。vLLM 提出的 PagedAttention 就是把 KV Cache 按固定大小的块管理,类似操作系统里的分页。腾讯、英伟达等框架也有类似的 KV Cache 管理器。
第二是抢占与优先级。当并发请求非常多、显存放不下所有请求的 KV Cache 时,调度器必须决定先跑哪些请求。通常新请求的 prefill 开销大,decode 请求已经处于尾段,直接全部挤掉会让延迟剧烈抖动。工程上常见的做法是给请求划分优先级池,或者控制max_num_seqs来限制同时运行的请求数。
第三是调度开销。连续批处理每个迭代都要做一次请求集合决策,如果调度器本身写得不够高效,调度本身会成为新的瓶颈。这也是为什么现代推理框架通常用 C++/CUDA 实现调度核心,而不是简单用 Python 循环。
从效果看,连续批处理能让 GPU 在 decode 阶段始终保持高利用率,不同的请求交错填充计算间隙,整体吞吐通常明显高于静态和动态批处理。付出的代价是调度器复杂度显著上升。
6. 三种策略横向对比
| 对比维度 | Static Batching | Dynamic Batching | Continuous Batching |
|---|---|---|---|
| 调度触发时机 | 批次完全凑齐 | 等待窗口或 batch 满 | 每次迭代 |
| 请求是否可以中途加入 | 否 | 否 | 是 |
| 请求是否可以提前离开 | 否 | 否 | 是 |
| Padding 浪费 | 严重 | 较严重 | 基本消除 |
| GPU 利用率 | 低 | 中等 | 高 |
| 平均响应延迟 | 可能被长请求拖累 | 等待窗口内波动 | 稳定但受抢占影响 |
| 是否适合高并发在线服务 | 否 | 勉强 | 是 |
| 实现成本 | 低 | 中 | 高 |
| 工程系统复杂度 | 简单 | 中等 | 高,包含显存管理和调度器 |
这里要特别强调一个问题:连续批处理提升的是吞吐,不代表每个请求的延迟都会变好。在一些极端抢占场景下,先到的请求可能被新请求的 prefill 挤占资源,导致尾延迟上升。在线服务部署时,需要结合优先级队列和并发上限来控制这种波动。
7. 从原理到常用推理框架
理解了三种策略之后,再看主流 LLM 推理框架就非常清晰了。
- vLLM:Continuous Batching 的代表实现,配合 PagedAttention 管理 KV Cache,是当前开源社区最常用的高吞吐推理框架之一。
- TensorRT-LLM:英伟达的方案,把连续批处理叫做 In-flight Batching。它在显存规划、算子融合上做得非常细,更适合生产化部署。
- Hugging Face TGI:原生支持 Continuous Batching,封装比较友好,适合快速把模型包装成服务。
- SGLang:在连续批处理基础上进一步优化了前缀复用,多个请求共享相同 prompt 前缀时,KV Cache 可以复用,这正好和 continuous batching 的逐迭代调度天然契合。
这几个框架通常都已经默认开启连续批处理。你在部署时看到max_num_seqs、max_running_requests、max_batch_size之类的配置,本质都是在控制同时运行的请求数量,也就是连续批处理的并发窗口大小。
配置思路通常是:max_num_seqs设置得越大,GPU 越容易被填满,但 KV Cache 显存占用也越高。设置太小,显存还有富余但吞吐上不去。需要根据模型大小、输入输出长度和显存容量一起调整。
这里还可以回答一个常见的本地部署疑问:ComfyUI 和 LLM 推理服务是否必须部署在同一台机器上。答案是不一定。如果 LLM 服务通过 HTTP API 对外提供能力,ComfyUI 工作流里只需要一个 API 调用节点,两者完全可以分机部署。只有当 ComfyUI 插件直接把模型加载在 ComfyUI 进程内,或者需要共享显存、共享模型文件时,才要求它们落在同一台机器上。是否必须同机,取决于工作流的设计方式,而不是模型本身。
8. 本地部署时的显存与吞吐观察方法
很多人关心“我应该用哪种批处理策略”,实际上在主流框架里,策略往往已经被框架固定了,你需要做的是调参和验证。下面给出一套通用验证流程,不依赖具体框架。
第一步,准备一组长度统一的测试请求。建议固定输入长度和输出长度,比如输入 64 token,输出 128 token。这样测试结果更好对比。第二步,用压测脚本并发发送请求,从 1 个并发逐步增加到 64 个。第三步,观察四类指标:吞吐、平均延迟、P99 延迟、GPU 利用率和显存占用。
一个通用的并发压测脚本模板如下:
import asyncio import aiohttp async def send(session, url, prompt, max_tokens=128): payload = { "prompt": prompt, "max_tokens": max_tokens } async with session.post(url, json=payload) as resp: return await resp.json() async def main(): url = "http://127.0.0.1:8000/v1/completions" prompt = "Write a paragraph about LLM inference batching." # 构造 32 个并发请求 tasks = [] async with aiohttp.ClientSession() as session: for _ in range(32): tasks.append(send(session, url, prompt, max_tokens=128)) results = await asyncio.gather(*tasks) print("completed:", len(results)) asyncio.run(main())这个脚本只负责触发并发,实际框架的接口路径和参数名需要按你部署的服务调整。如果有 OpenAI 兼容接口,一般可以直接用/v1/completions或/v1/chat/completions。
观察时需要重点区分两个阶段:prefill 阶段 GPU 利用率比较高,但耗时取决于输入长度;decode 阶段每个请求生成 token 的速度接近,并发越高,总吞吐越高,但单请求的生成速度会因为显存带宽竞争而下降。
更稳妥的判断方式是横向对比。在同一个框架里,分别用小并发、中并发、大并发压测,找到吞吐增长曲线拐点。拐点出现的位置往往就是max_num_seqs设置过大、KV Cache 占用过多、或者服务端已经开始排队的位置。
显存占用建议直接用nvidia-smi观察,核心看两行:Memory-Usage和Volatile GPU-Util。如果显存还有大量剩余但 GPU 利用率不高,说明并发开小了;如果显存吃满但吞吐停滞,说明并发和 KV Cache 配额已经到顶;如果显存频繁溢出,说明需要调低max_num_seqs或限制最大生成长度。
连续批处理框架通常还会提供一个/metrics端点,暴露排队的请求数、正在运行的请求数、KV Cache 使用率等关键指标。这些指标比只看显存占用准得多。部署后建议优先确认这些指标能不能正常读到。
9. 选型建议:什么时候用哪种策略
如果你是自己部署推理服务,可以参考下面这套判断逻辑。
离线批量任务可以选择静态批处理。比如离线跑一批文本摘要、标签抽取、数据增强,不需要实时响应,静态批处理实现简单、行为稳定,不存在复杂的调度问题。PyTorch 原生的model.generate()已经够用。
请求量很小、延迟不敏感的内网工具可以选择动态批处理。比如内部知识库问答系统,每分钟几十个请求,动态批处理器可以把它们聚合成批,减少显存带宽浪费。注意把等待超时控制在一个合理范围,避免请求排队时间过长。
面向外部用户、高并发、需要稳定 P99 延迟的在线服务,优先选择连续批处理。vLLM 或 TensorRT-LLM 这类框架已经帮你把调度器实现好了,你只需要调好max_num_seqs和 KV Cache 上限。
显存受限的场景要特别注意。连续批处理虽然总体上更省显存,但它的 KV Cache 采用按块分配机制,块大小需要配置。如果块设得过大,小请求也会占用大块显存,浪费反而严重;如果块设得过小,分配次数增加,调度开销上升。具体块大小必须根据实际模型和请求长度测试。
延迟敏感型应用要额外考虑优先级。连续批处理在新请求加入时,需要做 prefill,prefill 计算量远大于单步 decode,如果调度器不做控制,新请求的 prefill 会抢占正在 decode 的旧请求。这个时候可以通过请求级优先级、prefill 和 decode 分池等方式保障旧请求的尾部延迟。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 并发一高就显存溢出 | max_num_seqs设置过大,或 KV Cache 预留不足 | 查看服务日志和显存监控 | 调低并发上限,限制最大生成长度 |
| GPU 利用率很低但显存充足 | 并发太小,批大小不足 | 观察nvidia-smi的 GPU-Util | 增大并发请求数或调高max_num_seqs |
| 单请求延迟忽高忽低 | 新请求 prefill 抢占 decode,或不同长度请求混跑 | 查看 P99 延迟曲线 | 设置请求优先级,限制并发窗口 |
| 排队时间过长 | 等待窗口设置不合理,或服务吞吐已达到上限 | 查看框架 metrics 中的排队数量 | 调整 batch 等待超时,或扩容 |
| 输出速度越来越慢 | decode 阶段显存带宽饱和,并发请求过多 | 观察 N 个并发下的单个 token 生成速度 | 适度降低并发,减少尾部阻塞 |
| 相同模型在 A 框架快、B 框架慢 | 框架调度策略不同,padding 和显存管理差异 | 对比两套框架的请求级日志 | 按场景选择框架,不要盲目迁移 |
| 显存碎片化严重 | KV Cache 分配块策略不合理 | 查看显存碎片率和不连续分配次数 | 调整 KV Cache 块大小,或切换显存分配策略 |
如果你的服务出现“并发一高就 OOM”,优先检查是不是一次性分配的 KV Cache 太大。很多推理框架支持按请求限流的配置,调低后可以避免显存剧烈波动。出现“单请求延迟抖动”时,不要急着怀疑模型,先看看是不是调度器频繁抢占引起的。用 metrics 里的排队请求数做判断,比猜更准确。
11. 最佳实践与使用建议
第一,第一次部署先跑最小配置。用 1 个并发请求验证模型能正常生成,再逐步增加并发。不要上来就把并发拉到 128,否则显存问题、调度问题、接口问题会混在一起,很难定位。
第二,保留一套最小可运行配置。把模型路径、max_num_seqs、最大生成长度、KV Cache 上限固定下来,遇到环境变动时先回到这一套配置验证能跑通,再继续调优。
第三,测试数据要覆盖不同长度。纯短文本请求和纯长文本请求测出来的结论不同。建议按短输入短输出、短输入长输出、长输入短输出三类组合分别压测,三种策略对输入输出长度的敏感度差异会非常明显。
第四,本地部署涉及业务数据时需要确认边界。LLM 服务如果只在本机内存和显存里处理数据,隔离性相对可控;一旦通过 API 开放给其他机器或外部工具调用,数据会跨节点传输,需要根据业务合规要求确定是否允许。涉及隐私数据、未公开资料、人脸声音素材时,更要确认使用授权和存储边界。
第五,批量任务要加日志和失败重试。虽然连续批处理提升了吞吐,但请求越长、并发越高,单个请求失败的概率也会增加。建议在客户端记录请求 ID、输入长度、输出长度、耗时和错误码,超时任务做指数退避重试。
第六,监控指标比猜重要。现代推理框架一般都会暴露吞吐、排队长度、KV Cache 使用率、prefill/decode 时长等指标。把这些指标接入 Prometheus 或简单的定时脚本,比盯着nvidia-smi本地观察要靠谱得多。
第七,多框架对比时不要只看一个数字。某些框架在小并发下可能不如另一个,但高并发下吞吐优势明显。对比时要固定相同的并发档位、相同的输入输出长度,最好测一个并发梯度,而不是单点比较。
第八,商用或发布前要做效果复核。批处理策略优化的是性能和资源利用率,不会改变模型本身的生成质量,但高并发下模型可能因为显存压缩、KV Cache 限制等原因产生不同于单机测试的输出。发布前建议挑一批典型样本重新审查输出质量。
12. 总结与下一步
这篇文章的核心可以压缩成三句话。静态批处理把所有请求捆绑成同一个批次,简单但浪费严重。动态批处理引入等待窗口,让请求在时间上聚合成批,但批内长度对齐问题没有解决。连续批处理把调度粒度缩小到迭代级,每个请求独立推进,基本消除了 padding 浪费,代价是调度器和显存管理复杂度大幅上升。
如果你用的是 vLLM、TensorRT-LLM、TGI 这类现代推理框架,默认策略大概率已经是连续批处理。你真正需要做的不是重新实现一套调度器,而是理解max_num_seqs、KV Cache 上限、优先级队列这些配置背后的含义,然后针对自己的请求长度分布做压测和调参。
最容易踩的坑是并发开太高导致 KV Cache 溢出,以及只有 GPU 利用率低却不知道是并发不够还是 padding 浪费。建议上手先跑一个小规模的并发梯度,把吞吐随并发变化的曲线打出来,再谈后续优化。
后续可以继续扩展的方向包括:prefix caching 复用公共前缀的 KV Cache、speculative decoding 用小模型辅助大模型生成、prefill 和 decode 分阶段部署。这些技术和 continuous batching 并不冲突,它们是在调度器已经足够高效之后,进一步压榨推理吞吐的工程手段。先把 batching 策略的原理吃透,再看这些高级优化,思路会顺畅很多。