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 是计算密集型一次处理整段 promptdecode 是访存密集型每次只处理一个 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, jsonpayload) 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或调整相关参数的情况下跑对比吞吐和延迟曲线。曲线交叉的那个点就是你这个业务场景下的最优配置区间。这个方法比看任何文档都管用因为你的业务分布只有你自己最清楚。
