LLM推理调度优化:从Continuous Batching到缓存感知
2026/9/15 3:21:45 网站建设 项目流程

部署过 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:把调度单位从"请求"换成"token"

2.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 token
  • R2:输入 20 token,输出 30 token(短请求)
  • R3:输入 100 token,输出 200 token
  • R4:输入 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 序列,带宽又闲着。把两者混在一起做同一轮 forward,Prefill 的大矩阵乘可以把计算单元撑满,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 维、FP16(2 字节),算下来:

2 × 32 × 8 × 128 × 2 = 131,072 字节 = 128 KB / token

也就是说,一个序列生成 4096 个 token,KV Cache 就要吃掉 512 MB。如果 batch 里有 16 条这样的序列,光 KV Cache 就是 8 GB。再看一张 24 GB 的卡,模型权重就要占 14 GB(7B 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_seqsmax_num_batched_tokensgpu_memory_utilization同 vLLM,另有--chunked-prefill相关参数max_batch_size、KV Cache 比例

选型建议很直接:追求生态成熟和排查方便,选 vLLM;业务里共享前缀长且重复度高,想榨干显存,试试 SGLang;模型已经用 TensorRT 导出、且你要深度控制显存布局,留在 TensorRT-LLM 体系内更顺。单机场景三家的性能差距通常不会超过 20%,真正拉开差距的是你对调度参数的理解。

5.2 必盯的三个指标:TTFT、ITL、有效吞吐

调度调得好不好,不能靠感觉,要看指标。单机推理最核心的三个指标:

  • TTFT(Time To First Token):从请求发出到首个 token 返回的时间。它受排队等待、前缀命中、Prefill 耗时共同影响。命中缓存的请求 TTFT 应该远低于未命中的;如果两者差距不明显,说明前缀缓存没生效。
  • ITL(Inter-Token Latency,也叫 TPOT):每生成一个 token 的平均间隔。在模型固定、batch 固定时它基本由显存带宽决定。ITL 抖动是抢占和混布异常的典型信号——如果 ITL 偶尔飚到平时的两三倍,大概率是调度器在做抢占或 Swap。
  • 有效吞吐(Goodput):单位时间内成功完成且满足延迟 SLO 的请求数。它比裸吞吐(每秒 token 数)更能反映调度质量,因为裸吞吐高但大量请求超时,对业务毫无意义。

部署时建议把这三个指标按分位数监控,P50、P95、P99 分开看。P50 反映整体体验,P99 反映尾部风险——调度参数不合理时,P99 往往会先崩。

5.3 我踩过的三个坑:缓存失效、抢占抖动、参数打架

最后分享几个我在单机部署时实际踩过的坑,都属于文档里不太会写但生产环境必现的问题。

第一个坑:前缀缓存悄悄失效。线上部署 vLLM 开了--enable-prefix-caching,监控里命中率却一直是 0。排查后发现是请求里带了temperatureseed之类的采样参数,拼进了请求元数据,但这不是缓存键的一部分。真正的原因更隐蔽——我们的网关在系统 prompt 末尾加了请求 ID。就一个 token 的变化,整个前缀哈希全部失配。解决方式是把请求 ID 挪到用户消息里,确保系统 prompt 全局唯一固定。所以前缀缓存上线后第一件事,就是看监控里的命中率,命中率为零时先检查 prompt 是不是真的完全一致。

第二个坑:抢占抖动导致吞吐雪崩。某次压测发现随着并发升高,总吞吐不升反降,GPU 利用率却很高。抓了日志发现调度器在疯狂抢占和重算:显存刚好卡在临界点,序列被抢占后重算,重算完又被抢,形成抖动循环。GPU 一直在算,但算的全是重算的 Prefill,新 token 几乎没产出。解法是给max_num_seqs留出 10%-15% 的余量,别把显存推到极限;同时把抢占策略明确设为 recompute,并开启分块 Prefill,让长 prefill 切成小块穿插在 decode 之间,避免单个大 prefill 卡住整个批次。

第三个坑:参数之间互相打架。max_num_seqsmax_num_batched_tokensgpu_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 的跨机共享这些分布式问题了——那是下一篇的话题。但单机这一层值得多花时间打磨,因为它决定了你在任何规模下都能用上的那个"底":调度单位够细、显存账算得清、缓存红利吃得到,这三件事做对了,换多少卡、加多少机器,逻辑都成立。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询