公司动态

大模型推理批处理全解析:从静态到连续批处理的调度演进

📅 2026/8/30 13:45:23
大模型推理批处理全解析:从静态到连续批处理的调度演进
在大模型推理服务里批处理batching是影响吞吐量和单位 Token 成本最直接的因素之一。静态批处理、动态批处理和连续批处理continuous batching是三种最常见的请求调度方式。它们的目标都是让 GPU 尽量少空闲但调度粒度完全不同。很多人把这几个概念混在一起以为只是 batch 参数设置不同实际上一旦理解调度粒度很多部署问题——比如为什么并发很高但 GPU 利用率低、为什么短请求总是被长请求拖住、为什么 vLLM 这类框架要引入 PagedAttention——都会串起来。这篇文章面向已经开始或准备自己部署大模型推理服务的开发者、算法工程师和运维人员。读完后你能说清楚三种批处理的执行流程、各自的资源收益和代价能根据业务场景做选型也能在线上遇到吞吐低、延迟高、显存溢出等问题时按合理顺序排查。1. 为什么 LLM 推理必须关心“批处理”1.1 解码循环决定了推理服务的瓶颈大模型生成文本不是一次前向传播就能完成的。给定输入模型先进入 prefill 阶段一次性处理所有输入 Token得到第一个输出 Token之后进入 decode 阶段每生成一个 Token 都要拿上一个 Token 拼到输入里再做一次前向传播直到输出结束符或达到最大长度。整个流程叫自回归解码。这个流程带来一个很明显的问题单个请求的 decode 阶段每次只生成一个 Token但 GPU 仍然要把模型权重从显存读一遍。以 7B 模型为例FP16 权重约占 14GB而生成一个 Token 的算术量很小。结果就是 GPU 在“搬运权重”上花了大量时间真正计算占比很低。单条请求独占一张 GPU 跑在线服务吞吐会非常难看。1.2 批处理的收益摊薄权重读取提高算力利用批处理的核心收益是把多个序列放到同一次前向传播里计算。假设 batch size 为 8模型权重还是读一次但一次计算同时处理 8 个序列的 Token算术密度大幅提高。权重读取成本被摊薄到多个请求上GPU 的算力利用率随之上升。批处理不是没有代价。每个进行中的序列都需要保存 KV cache也就是历史 Token 的键值张量供后续生成使用。序列越长KV cache 越大批量越大显存消耗增长越快。同时请求进入批次后并不能立刻开始生成排队本身会抬高首 Token 延迟。吞吐和延迟在这里形成一对矛盾三种批处理方式都是在调这个矛盾。1.3 三种 batching 的本质差异调度粒度不同静态批处理、动态批处理和连续批处理本质区别不在“批大小设置多少”而在调度粒度。静态批处理是请求级调度攒够 N 个请求整批跑完再跑下一批。动态批处理也是请求级调度但批次不是预先固定而是由队列里的请求动态组成满足数量或超时条件后启动。连续批处理是迭代级调度每个 decode step 结束后完成的序列当场退出新请求立刻补进来批次内容每步都在变。一句话概括静态和动态批处理把“一个请求”当成不可分割的调度单位连续批处理把“一次 forward”当成调度单位。这个差别决定了三种方案的吞吐上限和实现复杂度。2. 静态批处理把请求攒成固定批次一次跑完2.1 工作流程与最小实现静态批处理的执行顺序很直观收集 N 个请求把它们的输入 Token 补齐到相同长度组成一个 batch整批执行 prefill 和 decode直到批次里所有序列都结束再开始下一批。def run_static_batch(requests, model, tokenizer, batch_size8): for start in range(0, len(requests), batch_size): batch_requests requests[start:start batch_size] # 把输入补齐到本批内最长长度 input_ids [tokenizer.encode(text) for text in batch_requests] max_len max(len(ids) for ids in input_ids) padded [ids [PAD_ID] * (max_len - len(ids)) for ids in input_ids] # prefill整批一次前向 kv_cache model.prefill(padded) generated [ids[:] for ids in padded] finished [False] * len(batch_requests) # decode每步整批前向直到整批全部结束 while not all(finished): logits model.forward(generated, kv_cache) next_tokens sample(logits) for i, token in enumerate(next_tokens): if token EOS_ID or len(generated[i]) MAX_NEW_TOKENS: finished[i] True else: generated[i].append(token)注意这段代码里的 while 循环条件not all(finished)。只要批次里有一个序列没有结束整批就要继续 forward。静态批处理的执行单元是“整批”不是一个一个序列这直接导致了下面的资源浪费。2.2 静态批处理浪费 GPU 的三个地方第一个浪费是 padding。请求输入长度通常不一致为了组成固定矩阵必须把短输入补到本批最长长度。补出来的 PAD Token 也会参与前向计算属于纯浪费。输入长度差异越大浪费越明显。第二个浪费是“最长序列拖尾”。假设一个 batch 有 8 个序列7 个在生成 50 个 Token 后结束剩下 1 个要生成 250 个 Token。那么后面的 200 步里GPU 只为一个序列计算但整体批次被拖着无法结束也无法接收新请求。短请求被长请求阻塞这就是典型的 straggler problem。第三个浪费是尾部空闲。请求总量不一定整除 batch size最后一批只有几个请求甚至一个请求GPU 利用率骤降。静态批处理还要求新请求必须等待当前批次全部完成中间出现的请求只能积压。2.3 静态批处理仍然适用的场景静态批处理实现简单调度逻辑几乎没有 overhead适合请求形态固定的离线任务。比如批量的文本分类、向量化、固定长度摘要、评测集跑分所有请求的输入输出长度接近padding 和拖尾问题不严重。这类任务用静态批处理完全够用引入复杂调度器反而没有必要。3. 动态批处理队列攒批按数量或超时触发3.1 工作流程与触发条件动态批处理在请求和引擎之间加了一层队列。请求进入队列后调度器判断是否启动一个批次。常见触发条件是二选一队列里的请求数达到max_batch_size或者距上次启动批次已经超过max_wait超时时间。def dynamic_batching_server(req_queue, model, max_batch_size8, max_wait_ms50): while True: queue_full len(req_queue) max_batch_size timeout wait_since_last_batch() max_wait_ms if queue_full or timeout: batch_requests req_queue.pop(max_batch_size) # 批次一旦启动内部仍然按静态批处理整批跑完 results generate_until_done(batch_requests, model) for req, result in zip(batch_requests, results): send_response(req, result) else: sleep(1)“动态”二字体现在批次是运行时从队列里组合出来的不提前固定。但一旦批次启动进入引擎内部后执行方式仍然和静态批处理一样整批跑完中途不能插入新请求。3.2 相比静态批处理改进在哪动态批处理解决了静态批处理的“攒批”问题。静态批处理要求业务方按固定批次一次性提交请求动态批处理则把攒批逻辑下沉到服务内部调用方只要发请求即可。max_wait参数让服务能在“等一个更大的批次”和“让请求尽快开始”之间做选择。请求到达密集时批次很快攒满吞吐优先请求稀疏时超时触发避免请求无限等待。相比静态批处理同一时刻 GPU 上的活跃请求数更稳定队列空转时间更短。3.3 动态批处理仍然存在的问题动态批处理没有解决真正的问题请求一旦进入批次就要等到整批结束。运行中的批次不能被拆开短序列结束后必须继续陪着长序列跑。新请求到达时如果批次已满只能在队列里等哪怕批次里已经只剩一两个长序列GPU 的实际利用率已经很低新请求也不能补进去。所以动态批处理比静态批处理好但没解决本质矛盾。它把调度的灵活度从“预先攒批”提升到“队列触发”但批次的内部结构依然是铁板一块。要解决这个问题必须把调度粒度从请求级别降到迭代级别。4. 连续批处理把调度粒度从请求降到迭代4.1 核心思路每个 decode step 都重新安排批次连续批处理又称迭代级调度iteration-level scheduling在 NVIDIA TensorRT-LLM 中叫 in-flight batching。它的核心思想是不要把一个请求当成不可分割的单元而是在每个生成步结束后检查当前批次里有没有序列已经结束。如果结束了立刻释放它的 KV cache从等待队列里取新请求执行 prefill补进下一轮 forward。这样一个长序列不再阻塞短序列。短序列生成完就退出长序列继续留在批次里新请求只要 GPU 有空位就能加入。批次内容每生成一个 Token 就变化一次GPU 上的空闲 slot 被持续填补。4.2 一个最小调度伪代码class ContinuousBatchScheduler: def __init__(self, max_batch_size8): self.active [] # 正在 decode 的序列 self.pending [] # 等待进入引擎的请求 def step(self, model): # 1. 当前 active 里的所有序列共同做一次 decode forward tokens model.decode(self.active) still_active [] for seq, token in zip(self.active, tokens): if token EOS_ID or seq.reach_max_len(): # 2. 序列结束释放 KV cache空出 slot model.free_kv_cache(seq) else: seq.append(token) still_active.append(seq) # 3. 空出的 slot 立刻由 pending 请求补上 free_slots self.max_batch_size - len(still_active) if free_slots 0 and self.pending: new_requests self.pending[:free_slots] self.pending self.pending[free_slots:] still_active.extend(model.prefill(new_requests)) self.active still_active这段伪代码省略了显存分配和调度策略但表达了连续批处理的关键结束判断和补充判断放在每个 step 里而不是整个批次结束后。短请求的延迟因此大幅降低长请求也不会占用整个批次直到结束。4.3 连续批处理依赖的工程能力KV cache 管理与抢占连续批处理能跑起来依赖一个前提可以高效地分配和释放不同长度序列的 KV cache。静态批处理里KV cache 按 batch 整体分配连续批处理里序列随时进出KV cache 必须按序列粒度管理。这就是 PagedAttention 一类方案出现的背景。vLLM 把 KV cache 切成固定大小的 block序列占用的 block 可以按需申请和回收显存碎片更少利用率更高。TensorRT-LLM 的 in-flight batching 也有类似的 KV cache 管理机制。连续批处理还带来一个静态和动态批处理没有的问题抢占。当显存不足以容纳新请求时调度器必须决定是否把一个正在 decode 的序列踢出批次释放它的 KV cache 给新请求。被抢占的序列之后可能要重新计算recompute或从 CPU 换回 KV cache这个过程会引入额外延迟。抢占策略、优先级调度、显存上下限都是工程实现里必须处理的细节。4.4 收益、代价与主流框架收益很直接相同 GPU 上连续批处理通常能显著提升吞吐尤其是长短混合、随机到达的在线请求场景。短请求不再被长请求拖住GPU 利用率更平滑队列积压更少。代价是实现复杂度。调度器要维护 active 集合、等待队列、KV cache 分配表还要处理抢占和 prefill/decode 混跑时的显存竞争。自己从零写一个生产级连续批处理调度器不现实绝大多数项目应该直接使用成熟框架。vLLM 较早实现并推广了 continuous batching 与 PagedAttention 的组合NVIDIA TensorRT-LLM 把类似思路称为 in-flight batchingHugging Face TGI 的较新版本也已切换到连续批处理。选择推理框架时优先确认它是否支持连续批处理这基本决定了在线服务的吞吐上限。5. 三种批处理方式对比与选型5.1 核心对比表对比维度静态批处理动态批处理连续批处理调度粒度请求级请求级队列攒批迭代级每个 decode step 调度批次形成时机预先攒够 N 个请求队列数量达标或超时触发每个生成步动态调整批次中途能否进出不能不能可以每步都有序列退出和加入主要资源浪费padding、最长序列拖尾、尾部空闲最长序列拖尾、运行中批次不可插入调度与显存管理开销吞吐量低到中中高短请求延迟差被长请求阻塞中等好短请求可及时退出实现复杂度低中高典型场景离线批量、固定长度任务中等负载在线服务高并发、长短混合在线服务典型框架Transformers 批量接口、自研脚本早期自研推理服务vLLM、TensorRT-LLM、TGI这个表不是要说明连续批处理在所有场景都最优。离线任务用静态批处理更简单请求形态非常均匀时动态批处理也能提供足够的吞吐。差距主要体现在在线、随机长度、高并发场景。5.2 选型逻辑业务形态和工程成熟度先看业务形态。如果是离线批量处理请求以文件或任务队列形式成批到达长度相对固定静态批处理足够。如果是在线服务请求到达时间和生成长度都不可控连续批处理基本是必选项。再看工程成熟度。团队如果已经在用 vLLM 或 TensorRT-LLM 这类框架默认就使用连续批处理不需要手动实现。如果只是学习推理原理、写一个小型演示服务建议从静态批处理开始理解 padding 和 KV cache 的问题后再逐步加入队列和动态触发最后再尝试迭代级调度。直接跳到连续批处理容易在显存管理上迷失。5.3 常见参数怎么调参数含义调小影响调大影响建议max_num_seqs引擎内同时处理的最大序列数显存安全吞吐低吞吐高显存压力大结合实际显存逐步压测gpu_memory_utilization允许用于模型权重和 KV cache 的显存比例KV cache 少抢占频繁预留系统显存不足可能 OOM从 0.85 起测观察抢占次数max_wait动态批处理攒批超时小批次多吞吐下降请求等待时间变长按业务延迟要求设置batch_size静态批处理的批大小吞吐低显存溢出风险高按最长输入和最大输出估算显存max_model_len单序列最大上下文长度长请求被拒绝KV cache 占用增大按业务真实上下文上限设置注意max_num_seqs这类参数在不同框架里名称可能不同落地前先查对应框架文档。调参的目标不是把某个参数调到最大而是让 GPU 利用率、延迟和显存三者达到业务可接受的平衡。6. 从学习环境到生产环境部署与调优6.1 学习环境用最小模型做基线验证想直观感受三种批处理的差异可以先用一个 0.5B 到 1B 的小模型搭基线。以下示例使用 vLLM 的离线接口实际项目要结合自己的模型路径和版本确认 API 参数。from vllm import LLM, SamplingParams prompts [ 用一句话解释什么是数据库索引, 写一个 Python 快速排序函数, 总结上面这段代码的作用, ] * 20 sampling_params SamplingParams( temperature0.7, max_tokens128, ) # max_num_seqs 控制引擎内同时处理的序列数 llm LLM( modelQwen/Qwen2.5-0.5B-Instruct, dtypefloat16, max_num_seqs8, gpu_memory_utilization0.85, ) outputs llm.generate(prompts, sampling_params) for output in outputs: print(output.prompt, -, output.outputs[0].text)这个示例里外部看起来是一次性提交了一批 prompt但 vLLM 内部按迭代级调度处理生成短的 prompt 先结束生成长的后结束空出的 slot 会被队列里的新请求补上。你可以改变prompts的长度分布或调大max_num_seqs对比 tokens/s 和总耗时就能直观看到连续批处理的吞吐优势。在学习阶段不要追求复现论文级别的对比数据。目标是把“请求到达分布”“批次大小”“KV cache 占用”这几个变量之间的关系跑明白为生产调参积累直觉。6.2 推理服务和调用方必须同机吗不需要。LLM 推理服务通常以独立进程部署对外提供 OpenAI 兼容的 HTTP 接口或 gRPC 接口调用方可以是另一台机器上的应用服务也可以是同一台机器上的其他进程甚至可以是 ComfyUI 这类 AIGC 工具。ComfyUI 负责节点编排和结果渲染LLM 推理服务负责生成 Token两者通过 API 解耦不一定非要装在同一台电脑上。但部署拓扑会影响推理表现。分开部署时网络延迟成为每个请求的额外成本适合请求文本量小、对延迟不敏感的场景同机部署时网络开销几乎为零但 GPU 显存会被多个任务竞争推理服务的gpu_memory_utilization必须给其他任务留出空间否则连续批处理的 KV cache 容量会被压缩抢占次数上升。判断标准不是“是否能同机”而是“谁占了 GPU、谁消耗显存、谁能接受多少网络延迟”。6.3 线上要监控哪些指标指标含义理想状态异常时排查方向TTFT首 Token 延迟短且波动小队列堆积、prefill 阶段慢ITL / TPOT每 Token 生成间隔稳定批过大、调度开销高吞吐量tokens/s 或请求数/分钟随并发上升后到达平台期达到平台期后考虑扩实例抢占次数连续批处理中序列被踢出低KV cache 预留不足KV cache 利用率实际使用量 / 预留量高且稳定max_num_seqs和显存配置不匹配这些指标里抢占次数是最容易被忽略的。连续批处理框架里如果gpu_memory_utilization设置过低KV cache 不足调度器会频繁抢占序列这些被抢占的序列之后要重新计算吞吐和延迟都会恶化。看到 GPU 利用率不低但 ITL 波动大优先查抢占日志。7. 常见问题排查与最佳实践7.1 高频问题与排查表问题现象常见原因检查方式处理建议并发高但 GPU 利用率低请求在排队实际 active 批小看队列长度、引擎日志、并发和吞吐曲线调大max_num_seqs确认批次真的形成短请求被长请求拖住使用静态或动态批处理请求级调度观察长输出请求是否长期占用 slot换连续批处理或在业务层限制max_tokens频繁抢占、ITL 波动大KV cache 显存预留不足查日志中的 preempt 计数提高gpu_memory_utilization或降低max_num_seqsCUDA OOMbatch 过大、上下文过长看 CUDA 报错日志和模型上下文参数降 batch、降max_model_len、开启量化TTFT 突然变大队列积压、prefill 阶段被 decode 挤占分开统计 TTFT 和 ITL拆分 prefill/decode 节点或增加实例排查时要先确认“现象是发生在 prefill 阶段还是 decode 阶段”。TTFT 异常说明 prefill 或排队有问题ITL 异常说明 decode 阶段批次组成有问题两者混在一起排查会走很多弯路。7.2 一条可复用的排查链路当线上推理服务出现“吞吐低”或“延迟高”时按以下顺序排查查队列长度。队列积压大说明请求到达速度超过处理速度先确认不是突发流量。查 active 批次大小。连续批处理下如果 active 序列数长期远小于max_num_seqs说明请求稀疏或调度参数过严。查 GPU 利用率和显存占用。利用率低但显存满多半是 KV cache 预留不足导致无法容纳更多序列。查抢占次数。抢占频繁时先调显存配置再考虑降并发。查 TTFT 和 ITL 的分段统计确认问题在 prefill 还是 decode。最后确认业务侧请求的max_tokens设置。过大的max_tokens会人为制造超长序列挤压其他请求的 slot。链路里的每一步都要有数据支撑不要凭感觉调参。先量化再改动改完再量化。7.3 上线前检查清单已确认推理框架支持连续批处理且 KV cache 有独立内存管理。gpu_memory_utilization已按机器总显存和同机其他任务预留量计算。max_num_seqs已按业务峰值和最大上下文长度压测而不是拍脑袋设定。max_model_len小于模型支持上限且与业务最长输入输出匹配。日志中包含 TTFT、ITL、吞吐、抢占次数方便问题回溯。已测试长请求和短请求混合场景下的延迟分布而不只测平均延迟。已为推理服务配置独立的显存监控和告警而不是等 OOM 才处理。8. 扩展方向连续批处理之后的优化空间8.1 chunked prefill进一步拆解 prefill连续批处理解决了 decode 阶段的调度问题但 prefill 阶段还存在头部阻塞。一个很长的输入 prompt 在 prefill 时要计算大量 Token会暂时占满资源让同一时刻的 decode 请求变慢。chunked prefill 把 prefill 拆成多个小 chunk穿插在 decode step 之间执行降低长 prompt 对整体延迟的影响。vLLM 等框架已支持该能力适合长文档问答场景。8.2 prefill 和 decode 分离部署另一种思路是把 prefill 和 decode 放到不同 GPU 上。prefill 阶段是计算密集decode 阶段是访存密集两者对硬件资源的需求不同。分离部署后两类请求互不抢占资源但会引入跨节点传输 KV cache 的网络开销。这个方案工程复杂度更高一般在高并发生产环境才值得做。8.3 speculative decoding 与批处理的配合投机解码speculative decoding用一个小模型先草拟多个候选 Token再用大模型一次性验证减少串行 decode 步数。它改变的是单个序列的生成路径但和批处理是正交关系投机解码降低每步耗时连续批处理提高每步处理多少序列两者可以叠加使用。实际收益受草稿模型接受率影响需要针对性压测。回到最核心的判断三种批处理方式的差别不是参数而是调度粒度。静态批处理和动态批处理调度“请求”连续批处理调度“迭代”。理解这一点再看框架文档里的调度参数、显存配置和抢占日志就不会再迷惑。下一步最值得做的练习是用一个小模型搭一个只支持静态批处理的推理脚本跑一遍再换成支持连续批处理的框架跑同样的请求记录 GPU 利用率、总耗时和延迟分布。这个对比做完你对批处理的理解会比读十篇概念文章都扎实。