1. 大模型推理为什么需要 Continuous Batching
第一次接触 Continuous Batching 这个概念,是在给一个内部知识库做推理服务的时候。当时用最朴素的方式跑一个 7B 级别的模型,单条请求响应还算能接受,但只要同时来三五个用户,排队时间就肉眼可见地往上涨,GPU 利用率却低得可怜。后来把请求攒成一批一起送进去,吞吐确实上去了,但新的问题又来了——短请求要等长请求跑完才能返回,首 token 延迟忽高忽低,用户体验非常割裂。Continuous Batching 就是在这个背景下进入视野的,它几乎是现代大模型推理框架的标配调度策略,vLLM、TensorRT-LLM、SGLang 这些主流引擎都把它作为核心卖点。
先把话说清楚:Continuous Batching(连续批处理)是一种在推理过程中动态调整批次组成的调度技术。传统的静态批处理(Static Batching)是等一批请求凑齐后一起送进模型,整批必须等最慢的那条序列生成完毕才能释放,中间即使有序列已经结束,空出来的算力也浪费掉了。Continuous Batching 的做法是:每一步解码(decode step)结束后,调度器检查哪些序列已经生成结束符,把它们踢出批次,同时把等待队列里的新请求补进来。批次在逻辑上始终是"满"的,GPU 的算力几乎不会因为某条序列提前结束而闲置。
这个技术解决的核心问题是吞吐与延迟的矛盾。静态批处理吞吐高但延迟抖动大,逐条推理延迟低但吞吐惨不忍睹。Continuous Batching 让两者取得了一个相当不错的平衡:在保证单条请求延迟可控的前提下,把整体吞吐拉高几倍甚至十几倍。它适合谁来学?我的判断是三类人:一是正在做推理服务部署、被并发问题折磨的工程师;二是想深入理解 vLLM 这类框架内部机制、准备做二次开发或调优的人;三是对调度算法本身感兴趣、想借鉴到其他在线服务场景的开发者。哪怕你只是偶尔跑跑本地模型,理解这套机制也能帮你更合理地设置并发参数,避免把服务跑崩。
需要提前说明的是,下面涉及的具体参数、调度流程,一部分来自官方文档和源码,一部分是我在实际压测中总结出来的经验值,不同模型、不同硬件上会有差异,请以你自己的实测为准。
2. 从静态批处理到连续批处理:调度思路的演进
2.1 静态批处理的瓶颈到底在哪
要理解 Continuous Batching 的价值,得先看清楚静态批处理的问题。假设一个批次里有 4 条请求,长度分别是 20、50、200、30 个 token。静态批处理会把它们 padding 到同一长度(200),然后一起做前向计算。问题立刻显现:padding 出来的那些位置全是无效计算,白白消耗算力;更糟的是,那条 20 token 的请求明明早就生成完了,却要陪着 200 token 的请求一直等到最后,它占用的显存和计算槽位在整个过程中都无法释放。
我实测过一个典型场景:8 条长度差异很大的请求做静态批处理,GPU 利用率看起来很高,但有效吞吐(按实际生成的 token 数算)只有理论值的 40% 左右。剩下的 60% 全花在 padding 和等待上了。这就是为什么早期很多推理服务宁可牺牲吞吐也要逐条处理——至少延迟是稳定的。
2.2 Continuous Batching 的核心思想
Continuous Batching 的破局点在于把调度的粒度从"批次"细化到"序列"。它不再要求整批同生共死,而是以单个 decode step 为单位做决策。每个 step 结束后,调度器做三件事:第一,扫描当前批次,把已经生成 EOS(结束符)或达到最大长度的序列标记为完成并移出;第二,从等待队列里按策略挑选新请求,填充到空出来的槽位;第三,为这一 step 重新组织注意力掩码和位置编码,保证新加入的序列不会"看到"不该看的内容。
这里有个关键点容易被忽略:新加入的序列在 prefill 阶段和已经在跑的序列在 decode 阶段,计算特性完全不同。Prefill 是计算密集型(一次处理整段 prompt),decode 是访存密集型(每次只处理一个 token)。如果调度器不加区分地把两者混在一起,反而可能拖慢整体效率。所以成熟的实现(比如 vLLM)会把 prefill 和 decode 做一定程度的分离或优先级处理,这也是后面要讲的调度策略重点。
2.3 为什么 vLLM 把它做成了招牌
vLLM 之所以能在众多推理框架里脱颖而出,Continuous Batching 加上 PagedAttention 是两大支柱。PagedAttention 解决了 KV Cache 的显存碎片问题,让显存可以像操作系统管理内存页一样按块分配;Continuous Batching 则解决了计算槽位的利用率问题。两者配合,才让"高并发 + 低延迟"这个组合真正落地。
我对比过同一台机器上 vLLM 和朴素 HuggingFace 推理的差距:在 20 并发、输入输出长度混合的场景下,vLLM 的吞吐大约是后者的 8 到 12 倍,首 token 延迟的 P99 也稳定得多。这个差距不是靠某个单点优化堆出来的,而是调度机制层面的代差。
3. 调度器内部到底在做什么
3.1 请求的生命周期与状态流转
在 vLLM 这类框架里,一个请求从进来到返回,大致经历这几个状态:WAITING(等待调度)→ RUNNING(正在生成)→ SWAPPED(被换出,显存不足时)→ FINISHED(完成)。调度器的核心工作就是在每个 step 决定哪些 WAITING 的请求可以进入 RUNNING,哪些 RUNNING 的请求因为显存压力需要被 SWAPPED 出去。
这里有个很实际的约束:KV Cache 的显存是有限的。每条正在运行的序列都要占用一块 KV Cache,序列越长占用越多。当显存不够时,调度器必须做取舍——要么拒绝新请求(让它们继续等待),要么把某些运行中的序列换出到 CPU 内存(swap),腾出显存给更紧急的请求。这个取舍策略直接决定了服务在高负载下的表现。
3.2 调度策略的几种常见取舍
不同框架、不同配置下,调度策略差异很大,我把它归纳成几个维度:
| 策略维度 | 常见做法 | 适用场景 | 代价 |
|---|---|---|---|
| 抢占策略 | 优先换出最长的序列 | 长短请求混合 | 长请求延迟升高 |
| 新请求准入 | 显存够就放行 | 追求吞吐 | 可能挤爆显存 |
| Prefill/Decode 混合 | 同批次混合处理 | 实现简单 | 计算效率打折 |
| Prefill/Decode 分离 | 分批次或分优先级 | 追求延迟稳定 | 实现复杂 |
| 换出目标 | CPU 内存 swap | 显存紧张 | 换入换出有开销 |
我个人的经验是:如果你的服务以短请求为主(比如对话、问答),优先保证新请求准入,让长请求适当等待;如果以长文本生成任务为主,则要控制并发数,避免频繁 swap。这个判断没有标准答案,得看你的业务分布。
3.3 一个 decode step 的完整流程
把流程拆开看会更清楚。假设当前批次里有 3 条序列正在 decode,等待队列里有 2 条新请求:
- 调度器先检查显存水位,计算当前还能容纳多少 token 的 KV Cache;
- 扫描运行中的序列,把这一步生成 EOS 的移出,释放其 KV Cache;
- 从等待队列按优先级取新请求,先做 prefill(如果显存允许),把它们的 KV Cache 建立起来;
- 为所有运行中的序列组织这一 step 的输入(每条序列上一个生成的 token);
- 执行前向计算,得到每条序列的下一个 token 分布;
- 采样,把新 token 追加到各序列,更新位置信息;
- 回到第 1 步,进入下一个 step。
这个循环每秒可能执行几十到上百次,调度器的决策开销必须足够小,否则会拖累整体性能。这也是为什么调度逻辑通常用高效的 C++/CUDA 实现,而不是 Python 层。
4. 动手实测:用 vLLM 观察 Continuous Batching 的效果
4.1 环境准备与基础部署
我用的是一台单卡 24G 显存的机器,模型选了一个 7B 级别的指令模型。部署命令大致如下:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --served-model-name local-model \ --max-model-len 4096 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 64 \ --port 8000几个参数值得单独说:--gpu-memory-utilization 0.90表示允许 vLLM 使用 90% 的显存,留一点给系统和其他进程;--max-num-seqs 64是单个批次里最多同时运行的序列数,这个值直接决定了 Continuous Batching 的"批"能开多大。设太小吞吐上不去,设太大显存容易爆,需要根据模型大小和显存实测调整。
提示:
--max-num-seqs不是越大越好。我试过把它设到 256,结果在长 prompt 场景下频繁触发 swap,吞吐反而下降了。建议从 32 或 64 起步,逐步往上压测。
4.2 压测脚本与关键指标
为了观察 Continuous Batching 的效果,我写了一个简单的并发压测脚本,用不同长度的 prompt 混合发送:
import asyncio import aiohttp import time import random URL = "http://localhost:8000/v1/completions" async def send_one(session, prompt_len, idx): prompt = "请解释一下" + "机器学习" * prompt_len payload = { "model": "local-model", "prompt": prompt, "max_tokens": 128, "temperature": 0.7, } start = time.time() async with session.post(URL, json=payload) as resp: data = await resp.json() elapsed = time.time() - start return idx, elapsed, len(data["choices"][0]["text"]) async def main(): lengths = [random.choice([1, 5, 20, 50]) for _ in range(40)] async with aiohttp.ClientSession() as session: tasks = [send_one(session, l, i) for i, l in enumerate(lengths)] results = await asyncio.gather(*tasks) total = sum(r[1] for r in results) print(f"总请求数: {len(results)}") print(f"平均延迟: {total/len(results):.2f}s") print(f"最大延迟: {max(r[1] for r in results):.2f}s") asyncio.run(main())跑下来最直观的感受是:40 条混合长度请求并发,平均延迟和单条请求的延迟差距并不夸张,但总吞吐比逐条处理高了一个数量级。这正是 Continuous Batching 在起作用——短请求生成完就退出,长请求继续跑,新请求不断补位,GPU 几乎没有空闲。
4.3 观察调度行为的几个技巧
想真正看清调度器在干什么,光看最终延迟不够。我常用的几个手段:
- 打开 vLLM 的日志,观察每个 step 的批次大小变化。如果批次大小长期远低于
max-num-seqs,说明请求量不够或准入策略太保守; - 监控 GPU 利用率和显存占用,如果利用率高但吞吐低,多半是 padding 或 swap 在拖后腿;
- 用不同长度分布做对比实验,固定并发数,只改 prompt 长度分布,看延迟曲线的变化。长度越均匀,静态批处理的劣势越小,Continuous Batching 的优势也越不明显——这反过来验证了它的价值所在。
5. 踩过的坑与常见问题排查
5.1 显存溢出与 swap 抖动
最常见的坑就是显存溢出。表现是服务跑着跑着突然变慢,日志里出现大量 swap 相关记录。根因通常是max-num-seqs或max-model-len设得太大,KV Cache 需求超过了显存容量。我的处理办法是:先把max-model-len降到业务实际需要的长度(很多场景 2048 就够,没必要开 8192),再逐步调max-num-seqs。如果还是紧张,就得考虑量化模型或者上多卡。
5.2 首 token 延迟忽高忽低
这个问题的典型原因是 prefill 和 decode 混在一起调度。当一条超长 prompt 进来做 prefill 时,会占用大量计算资源,导致正在 decode 的序列这一步被拖慢。解决办法有几个:一是限制单条 prompt 的最大长度;二是启用 chunked prefill(把长 prompt 分块处理,穿插在 decode 之间);三是干脆做 prefill/decode 分离部署。我实测 chunked prefill 对延迟稳定性的改善很明显,代价是配置稍复杂。
5.3 吞吐上不去但 GPU 也没跑满
这种情况往往是请求量不足或调度策略太保守。先确认并发数是否真的够高,再看max-num-seqs是否设得太小。还有一种可能是采样参数里的max_tokens设得太大,导致每条序列占用槽位时间过长。另外,如果用的是 OpenAI 兼容接口,客户端本身的连接池大小也可能成为瓶颈,别只盯着服务端。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| 服务变慢、日志有 swap | 显存不足 | 看显存占用和 swap 记录 | 降 max-model-len 或 max-num-seqs |
| 首 token 延迟抖动大 | prefill 抢占 | 看长 prompt 占比 | 启用 chunked prefill |
| 吞吐低、GPU 空闲 | 并发不足或批次小 | 看批次大小日志 | 提高并发、调大 max-num-seqs |
| 部分请求超时 | 长请求饿死 | 看等待队列长度 | 调整抢占策略、限制单请求长度 |
| 显存够但批次上不去 | 准入策略保守 | 看调度日志 | 检查显存水位计算逻辑 |
注意:调参时一次只改一个变量,否则出了问题根本不知道是哪个参数导致的。我吃过这个亏,同时改了三个参数,结果性能下降,排查了一整天才定位到是其中一个参数设反了。
6. 几个容易被忽略的实操心得
第一个心得是关于批次大小的动态性。很多人以为 Continuous Batching 就是"批次越大越好",其实不然。批次大小应该随负载动态变化:低负载时批次小、延迟低;高负载时批次大、吞吐高。vLLM 默认会根据等待队列长度自动调整,但你可以通过参数影响它的激进程度。我的建议是不要手动锁死批次大小,让调度器自己决策,除非你有非常明确的延迟 SLA 要求。
第二个心得是关于 KV Cache 的量化。如果显存实在紧张,可以考虑对 KV Cache 做量化(比如 FP8),这样同样的显存能容纳更多序列,批次能开得更大。代价是精度可能有轻微损失,需要评估对业务的影响。我在一些对精度不敏感的场景试过,吞吐提升相当可观。
第三个心得是关于监控。Continuous Batching 的效果高度依赖负载特征,没有监控就是盲调。至少要盯住这几个指标:批次大小、等待队列长度、swap 频率、首 token 延迟 P99、每 token 延迟 P99。这些指标能帮你快速判断瓶颈在哪。
第四个心得是关于模型选择。不是所有模型都适合高并发。有些模型结构对批次大小敏感,批次一大延迟就飙升。选模型时除了看效果,也要看它在目标框架下的并发表现,最好提前压测。
7. 从 Continuous Batching 延伸出去的思考
理解了 Continuous Batching,其实能迁移到很多其他在线服务场景。它的本质是在资源受限下,通过细粒度调度最大化资源利用率,同时控制尾延迟。这个思路在数据库连接池、任务队列、流式处理里都能找到影子。我自己在做其他服务时,经常拿这套思路去审视调度设计:批次是不是太粗?资源释放是不是太晚?新任务准入是不是太保守?
另外,Continuous Batching 和 PagedAttention 是配套的,单独理解一个容易片面。PagedAttention 解决的是"显存怎么高效用",Continuous Batching 解决的是"计算槽位怎么高效用",两者缺一不可。如果你在调优时发现显存利用率很高但吞吐上不去,问题多半在调度;如果显存碎片严重、批次开不大,问题多半在显存管理。
最后分享一个我常用的验证方法:拿一组固定的请求,分别在开启和关闭 Continuous Batching(或调整相关参数)的情况下跑,对比吞吐和延迟曲线。曲线交叉的那个点,就是你这个业务场景下的最优配置区间。这个方法比看任何文档都管用,因为你的业务分布只有你自己最清楚。