资讯动态

LLM推理调度优化:从Continuous Batching到缓存感知

发布时间:2026/9/15 3:21:56 来源:尧图企业网站定制
部署过 LLM 推理服务的人大概都见过这个场景并发压上来之后首 token 延迟直接破秒可nvidia-smi里 GPU 利用率还不到一半。你换更大的卡、调低并发问题依旧。这不是算力不够是调度没做对。单机推理的调度核心就两件事请求怎么排队、显存怎么分。排队策略决定了吞吐和延迟的平衡点显存策略决定了你能同时服务多少请求。从最早的串行推理到静态 Batching再到 Continuous Batching再到今天要重点聊的缓存感知调度每一次进步都是把调度单位拆得更细、把显存用得更狠。这篇就顺着这条线把单机推理调度完整捋一遍每个方案解决什么问题、付出什么代价、参数怎么调、坑在哪里。1. 单机推理卡在哪算力没吃满请求堵成狗1.1 串行推理的资源浪费率先看最朴素的方案一次只处理一个请求。一个请求进来GPU 先做 Prefill处理输入的 prompt生成第一轮 KV Cache然后进入 Decode逐 token 生成输出。等你把整个响应生成完下一个请求才能进来。这个方案的问题在于 Prefill 和 Decode 对 GPU 的利用方式完全不同。Prefill 是典型的计算密集型一次要处理成百上千个 token做的是大矩阵乘法GPU 的计算单元能跑得很满。Decode 是典型的内存密集型每步只算一个 token数据量小但要从显存里反复读取整个模型的权重和这个序列的全部 KV Cache瓶颈在显存带宽计算单元大量闲置。拿一张 7B 模型举例Prefill 一个 1024 token 的输入GPU 利用率能到 70% 以上但 Decode 阶段单序列跑利用率经常只有 20% 上下。一个典型对话请求Prefill 占 0.1 秒Decode 占 2 秒整段时间里 GPU 的平均利用率被 Decode 拖到惨不忍睹。多卡多机可以通过并行度分摊单机单卡这个问题尤其突出——你花大价钱买的算力大半时间在空转。1.2 排队的三种姿势都不够用有并发之后第一个想到的是排队。常见的排队策略有三种FCFS先来先服务实现最简单但队头阻塞严重。一个长请求在跑后面所有短请求都等着哪怕它们只需要 200ms。SPTF最短预计处理时间优先让短任务先跑能显著降低平均延迟。但 LLM 的请求长度你根本不知道只能拿 max_tokens 赌赌错了调度就崩。固定优先级给交互式请求高优先级给离线批处理低优先级。这个方向是对的但优先级不能解决资源维度的问题——高优先级的请求进来了GPU 也未必有能力立刻服务它。这些策略都停留在请求这个粒度上做决策没有触及底层资源的分配逻辑所以无论怎么排队都绕不开一个结构性问题一个请求从进入 GPU 到生成完毕占用的是一整块连续的算力和显存。1.3 静态 Batching 的木桶效应调度粒度太粗不行于是大家想到 Batching把多个请求打包一次 forward 同时推进。这就是静态 Batching——batch 一旦组好就要等 batch 里最慢的那个序列跑完整个 batch 才算结束。问题来了。比如一批 8 个请求7 个已经生成完了最后一个还在慢慢 decode。此时其余 7 个序列已经不再产生计算量但 batch 还没解散新的请求进不来。GPU 的算力没有归零因为 batch 里还在做那个序列的 forward只是利用率一路下滑从 8 条序列的并行度跌到 1 条。更麻烦的是如果每条请求的输入长度和输出长度差异很大这个木桶效应会让整个系统的吞吐持续波动QPS 一高延迟曲线就像过山车。静态 Batching 有没有办法缓解有比如设置最大 batch 后等齐了再发或者 batch 内做 padding 对齐长度。但 padding 本身就在浪费算力对齐到最长序列更是雪上加霜。本质问题在于batch 的生命周期绑定了请求这个粗粒度单位。2. Continuous Batching把调度单位从请求换成token2.1 迭代级调度每个 forward 之后重新组队2022 年微软的 Orca 论文第一次系统提出了迭代级调度Iteration-Level Scheduling后来 NVIDIA 的 FasterTransformer 和 TensorRT-LLM 把它叫做 In-Flight Batching。这个思路其实一点都不神秘不再等一个请求完整结束才腾位置而是在每次 forward一次迭代结束后立刻检查所有在跑序列的状态——谁生成完了就移除谁还没开始就加入然后重新组队进入下一次迭代。这个看似微小的改动把调度单位从请求降到了token 步。GPU 的每一次迭代都跑在此刻最合理的 batch上而不是一个请求完整生命周期上。空闲算力被即时填补批次解散和重组不再卡在单个慢请求上。实测下来同样的硬件从静态 Batching 切到 Continuous Batching吞吐通常能提升 2 到 10 倍具体取决于请求长度的方差——方差越大提升越明显因为静态批次的木桶效应被彻底干掉了。2.2 一个四请求的例子看清插队逻辑我举个例子方便你直观理解。假设 GPU 的显存最多能同时跑 2 条序列现在有四个请求排队R1输入 300 token输出 500 tokenR2输入 20 token输出 30 token短请求R3输入 100 token输出 200 tokenR4输入 50 token输出 100 token静态 Batching 下假设两个一组R1 和 R2 组第一批。R2 其实 50 步就完了但得等 R1 生成完 500 token整个 batch 才释放。R2 的用户体验极差GPU 的后面几百步都只有 R1 在跑。Continuous Batching 下R2 的 30 个输出 token 生成完的那一刻调度器直接把它踢出 batch把 R3 拉进来如果显存允许。R3 的 prefill 和 R1 的 decode 可以同时推进。等 R1 结束了R4 再进来。每个请求的平均等待时间和完成时间都显著下降GPU 永远在跑满两条序列的活。2.3 为什么 Prefill 和 Decode 混布能拉满利用率Continuous Batching 还有一个隐藏增益它天然允许一个 batch 里同时存在处于 Prefill 阶段的序列和处于 Decode 阶段的序列。这个混布意义重大。前面说了Prefill 吃算力、Decode 吃带宽。如果系统里只有 Decode 序列GPU 的计算单元大量空转如果只有 Prefill 序列带宽又闲着。把两者混在一起做同一轮 forwardPrefill 的大矩阵乘可以把计算单元撑满Decode 的小步进则把显存带宽利用起来两类资源互补整体利用率自然上去。代价是工程复杂度。batch 里每个序列的 token 数不同、位置编码不同、注意力掩码不同原来的简单拼接逻辑全要重写。所以你会发现支持 Continuous Batching 的推理引擎代码里都有大量 scatter/gather 逻辑、padding mask 处理、以及按序列记录各自位置的书签机制。这些复杂度的回报就是实打实的 2 到 10 倍吞吐提升。2.4 抢占调度满员时让谁出局迭代级调度还带来一个新的调度难题显存满的时候新请求要不要进进了之后谁被挤出去抢占Preemption机制是这么设计的GPU 显存里 KV Cache 是硬上限一个新请求要进来必须给它腾出 KV 空间。腾空间有两种手段。一是 Swap把某些序列的 KV Cache 从显存搬到 CPU 内存等显存有空位再搬回来。二是 Recompute把某些序列的整个计算过程抹掉等它重新轮到的时候从之前的某个 checkpoint 重新算 prefill。这两个方案各有代价Swap 受 PCIe 带宽限制搬进搬出很慢Recompute 浪费算力但不受带宽约束在 NVMe 和 PCIe 都吃紧的单机场景反而更稳。主流引擎现在默认倾向 Recompute——因为 GPU 算力往往比内存带宽富裕重算比换入换出更快。抢占谁也是个策略问题。朴素的实现是抢占最新进入的序列LIFO因为它们的 KV Cache 最小腾挪成本最低。更精细的做法是结合请求优先级被抢占的序列要重算重算时间要计入这个请求的总时延预算所以高优先级请求应该尽量不被抢占。很多引擎还会统计等待中的高优请求来决定抢占哪些低优序列。这里面没有银弹只有基于业务特征的取舍。3. KV Cache 显存账把调度逼到墙角的那只手3.1 每 token 的显存开销怎么算调度做得再好也得先有显存放 KV Cache。KV Cache 的占用是可以精确计算的公式如下每 token 的 KV Cache 字节数 2 × 层数 × KV 头数 × 每头维度 × 精度字节数乘以 2 是因为 K 和 V 各一份。拿一个现代 GQA 模型举例32 层、8 个 KV 头、每头 128 维、FP162 字节算下来2 × 32 × 8 × 128 × 2 131,072 字节 128 KB / token也就是说一个序列生成 4096 个 tokenKV Cache 就要吃掉 512 MB。如果 batch 里有 16 条这样的序列光 KV Cache 就是 8 GB。再看一张 24 GB 的卡模型权重就要占 14 GB7B FP16剩下 10 GB 给 KV Cache最多也就容纳 20 条长序列。你会发现算力还没到上限显存先把你按死了。所以单机推理的调度本质上很大程度是在调度显存。这里有个参数容易误导人模型的总注意力头数和 KV 头数不是一回事。GQA分组查询注意力和 MQA多查询注意力的 KV 头数远小于 Query 头数这直接决定 KV Cache 能省多少。你在算显存账的时候一定要看模型卡片上 KV heads 那一栏别拿 Q heads 去算结果会差好几倍。3.2 预分配方案的浪费与碎片最早的推理框架以及一些简化实现处理 KV Cache 的方式是预分配每个序列在开始时就按 max_tokens 上限分配一整块连续显存。假设配置里 max_tokens 是 2048每条序列预留 256 MB但实际平均只生成了 512 token那 75% 的显存就白挂着。如果你把 max_tokens 调小以省显存又会让超出长度的请求直接报错。预分配还带来碎片问题。不同序列分配的内存块有大有小申请和释放的时机完全随机显存会被撕成一块块碎片。等到新请求进来明明总剩余显存够用却找不到一块连续空间容纳它只能等。这是典型的内部碎片加外部碎片双重打击。3.3 分页管理与准入控制调度器的新权限vLLM 的 PagedAttention 把操作系统里的分页思想搬到了 KV Cache 管理上不再给序列分配连续内存而是按固定大小的页比如每页 16 个 token 的 KV分配页与页之间不需要物理连续。序列在生成过程中按需申请新页用完的页可以释放复用。分页直接消掉了内部碎片也大幅缓解外部碎片。但真正巧妙的是它给调度器新增了一个权限维度——准入控制。序列能不能进入当前 batch不再只看GPU 算力够不够而是看KV Cache 有没有足够的空闲页。具体规则通常是调度器维护一个空闲页池新请求进来时预估它需要的最大页数基于 max_tokens 减去已用长度页数够才允许进入。如果不够就触发抢占机制。这个准入控制做得好的引擎显存利用率能到 90% 以上做得糙的到 70% 就开始各种 OOM 和抢占抖动。4. 缓存感知调度给调度器装上天眼4.1 前缀命中的收益模型省的是 Prefill 算力Continuous Batching 解决的是算力不饱和分页解决的是显存不够用但这两者都默认了一个前提每个请求都必须从头计算完整的 Prefill。真实业务里这个前提经常不成立。最典型的场景是多轮对话。用户第 5 轮提问时输入的 prompt 里包含了前面 4 轮的全部对话历史可能几千个 token。这些 token 的 Prefill 结果KV Cache和上一轮请求计算出来的一模一样——因为同样的 token 序列经过同样的权重结果必然相同。如果每次都从头算等于把用户已经付过费的计算反复重做。系统提示词场景更明显。假设你的系统 prompt 有 2000 token每来一个请求不管用户问什么前面 2000 token 的 prefill 都要重算一遍。QPS 100 时这部分浪费的算力占比相当可观。前缀命中的收益模型很简单命中的前缀越长省下的计算越多TTFT 越低。假设 prefill 一条 2000 token 的请求需要 200ms其中 1900 token 是共享系统 prompt那么命中缓存后这个请求的 TTFT 可能直接降到 20ms 级别——一个数量级的提升。省下的不是显存而是实打实的 Prefill 算力和用户等待时间。4.2 命中检测的实现哈希键与基数树缓存感知调度的一切都建立在能否快速判断两个请求是否共享前缀之上。这个检测的工程实现有两代方案。第一代是哈希精确匹配。把 token ID 序列做成哈希键缓存里存token 哈希 → KV 块的映射。新请求进来时逐段计算哈希命中就直接复用。这个方案实现简单vLLM 的 Automatic Prefix Caching 就是类似思路但它要求前缀完全一致——中间夹一个时间戳或者用户 ID 的 token后面全部失配。第二代是基数树Radix Tree。SGLang 的 RadixAttention 是代表。它把当前所有活跃序列的 token 前缀组织成树结构公共前缀只存一份。新请求进来时在树上做最长公共前缀匹配能精确知道命中了多少 token、剩下多少 token 需要计算。树的妙处在于即使两个请求后缀完全不同只要共享任意长度的前缀就能复用对应长度的 KV。而且当匹配到的节点被多个请求共享时可以用引用计数管理最后一个请求结束后才真正释放。这两代方案的取舍很清楚哈希方案实现快、开销低适合系统 prompt 完全固定的场景基数树方案匹配粒度更细但实现复杂度高匹配过程本身也有 CPU 开销。单机场景 QPS 不高时两者差距不大但 QPS 上千后匹配开销会成为新瓶颈。4.3 插队与防饿死公平性约束怎么设有了命中检测调度策略就要回答一个问题命中了缓存的请求能插队吗直觉上应该让命中缓存的先跑因为便宜、快。但如果你真的这么干就会饿死那些前缀不命中的长请求——它们 prefill 成本高永远排在后面延迟 SLO 永远不达标。插队必须有约束。业界的常见做法是给调度分层交互式请求和离线批量请求走不同的队列交互式队列里再按优先级和等待时间加权。缓存命中只作为调度权重里的一个因子不是决定性因子。比如调度器给每个排队请求算一个分数调度分数 基础优先级 等待时间补偿 缓存命中奖励其中缓存命中奖励只在命中长度超过某个阈值时计入比如超过 512 token 才给加分避免短命中干扰调度。等待时间补偿随排队时间线性增长保证没人被饿死。另一个常用的公平手段是轮转窗口。调度器每次选一批请求进入 batch 时在高缓存命中请求和长等待请求之间按比例配额分配比如 70% 给命中优先、30% 给等待优先。这样既能吃到缓存红利又保证了最差情况下的公平性。4.4 缓存感知不是万能的适用场景与失效条件缓存感知调度听起来很美但它有明确的适用边界单机部署前一定得先评估。前缀池要够大缓存 KV 块本身占显存。如果活跃序列多、共享 prompt 短缓存池维护的成本可能超过收益。单机显存本来就紧缓存池和活跃 KV 的显存占比要专门调。共享前缀的稳定性系统 prompt 里如果拼了时间戳、Trace ID、随机生成的 session token每次请求前缀都变缓存永远命中不了。这类业务改缓存策略没用得先改 prompt 构造逻辑。缓存淘汰策略缓存池满了怎么办LRU 是默认选项但要注意最老的缓存不一定是最没价值的。系统 prompt 的 KV 应该基本常驻用户对话历史的 KV 则用后即弃。一些引擎支持给缓存前缀设置优先级值得用。beam search 或多采样同一前缀派生多个输出候选时缓存复用依然有效但调度器要处理多个序列共享同一份前缀 KV 的写冲突问题复杂度会上一个台阶。缓存感知调度不是银弹更准确的定位是它是在 Continuous Batching 之上的一个优化层前提是你的业务里确实存在重复的前缀。没有重复前缀的业务老老实实把 Continuous Batching 的参数调好就行不必硬上。5. 单机落地实测引擎配置、监控指标与踩坑记录5.1 三款主流引擎的调度器横评单机场景常用的推理引擎主要是 vLLM、SGLang、TensorRT-LLM三家的调度能力各不相同维度vLLMSGLangTensorRT-LLM连续批处理默认开启默认开启In-Flight Batching默认开启前缀缓存Automatic Prefix Caching哈希匹配RadixAttention基数树精确匹配支持但配置链路较繁琐抢占策略Recomputation 为主可选 SwapRecomputationSwap 和 Recomputation 都支持分块 Prefill支持--chunked-prefill默认支持支持显存管理PagedAttention 分页PagedAttention 分页KV Cache 显存管理典型调参入口max_num_seqs、max_num_batched_tokens、gpu_memory_utilization同 vLLM另有--chunked-prefill相关参数max_batch_size、KV Cache 比例选型建议很直接追求生态成熟和排查方便选 vLLM业务里共享前缀长且重复度高想榨干显存试试 SGLang模型已经用 TensorRT 导出、且你要深度控制显存布局留在 TensorRT-LLM 体系内更顺。单机场景三家的性能差距通常不会超过 20%真正拉开差距的是你对调度参数的理解。5.2 必盯的三个指标TTFT、ITL、有效吞吐调度调得好不好不能靠感觉要看指标。单机推理最核心的三个指标TTFTTime To First Token从请求发出到首个 token 返回的时间。它受排队等待、前缀命中、Prefill 耗时共同影响。命中缓存的请求 TTFT 应该远低于未命中的如果两者差距不明显说明前缀缓存没生效。ITLInter-Token Latency也叫 TPOT每生成一个 token 的平均间隔。在模型固定、batch 固定时它基本由显存带宽决定。ITL 抖动是抢占和混布异常的典型信号——如果 ITL 偶尔飚到平时的两三倍大概率是调度器在做抢占或 Swap。有效吞吐Goodput单位时间内成功完成且满足延迟 SLO 的请求数。它比裸吞吐每秒 token 数更能反映调度质量因为裸吞吐高但大量请求超时对业务毫无意义。部署时建议把这三个指标按分位数监控P50、P95、P99 分开看。P50 反映整体体验P99 反映尾部风险——调度参数不合理时P99 往往会先崩。5.3 我踩过的三个坑缓存失效、抢占抖动、参数打架最后分享几个我在单机部署时实际踩过的坑都属于文档里不太会写但生产环境必现的问题。第一个坑前缀缓存悄悄失效。线上部署 vLLM 开了--enable-prefix-caching监控里命中率却一直是 0。排查后发现是请求里带了temperature和seed之类的采样参数拼进了请求元数据但这不是缓存键的一部分。真正的原因更隐蔽——我们的网关在系统 prompt 末尾加了请求 ID。就一个 token 的变化整个前缀哈希全部失配。解决方式是把请求 ID 挪到用户消息里确保系统 prompt 全局唯一固定。所以前缀缓存上线后第一件事就是看监控里的命中率命中率为零时先检查 prompt 是不是真的完全一致。第二个坑抢占抖动导致吞吐雪崩。某次压测发现随着并发升高总吞吐不升反降GPU 利用率却很高。抓了日志发现调度器在疯狂抢占和重算显存刚好卡在临界点序列被抢占后重算重算完又被抢形成抖动循环。GPU 一直在算但算的全是重算的 Prefill新 token 几乎没产出。解法是给max_num_seqs留出 10%-15% 的余量别把显存推到极限同时把抢占策略明确设为 recompute并开启分块 Prefill让长 prefill 切成小块穿插在 decode 之间避免单个大 prefill 卡住整个批次。第三个坑参数之间互相打架。max_num_seqs、max_num_batched_tokens、gpu_memory_utilization这三个参数不是独立的。max_num_batched_tokens设得太小长 prefill 会被切得很碎CAS 效率下降设得太大decode 阶段序列之间的调度间隔变长ITL 会劣化。我现在的调法是先固定gpu_memory_utilization单卡通常 0.85 到 0.92再按期望的并发序列数 × 平均长度反推max_num_seqs最后用压测微调max_num_batched_tokens每轮只看 P99 ITL 有没有恶化。与其照抄别人的配置不如自己跑一轮压测看曲线。单机调度做到这个程度基本就到了工程的上限。再往上走就是多机场景下的请求路由、Prefix 的跨机共享这些分布式问题了——那是下一篇的话题。但单机这一层值得多花时间打磨因为它决定了你在任何规模下都能用上的那个底调度单位够细、显存账算得清、缓存红利吃得到这三件事做对了换多少卡、加多少机器逻辑都成立。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价