AI 技术AI 推理 · 9/10
#Chunked Prefill#LLM Serving#TTFT#TPOT#Sarathi-Serve#调度器

AI 推理系列(九):Chunked Prefill 如何避免长 Prompt 阻塞在线大模型服务

解释长 Prompt Prefill 如何阻塞在线 Decode,以及 Chunked Prefill 如何通过分块和混合调度改善流式延迟,并分析 Chunk Size、Token Budget、TTFT、TPOT、Goodput 与 Prefill-Decode 分离之间的工程取舍。

一个在线聊天服务最容易暴露长 Prompt 带来的调度问题。用户 A 已经拿到首个 Token,前端正在稳定接收流式输出;这时用户 B 提交了一段几十万 Token 级别的代码、文档或 Agent 历史。如果推理引擎把 B 的整个 Prompt 作为一次巨大的 Prefill 工作提交给 GPU,A 的下一轮 Decode 就可能长时间排不到执行机会。用户看到的不是“首 Token 慢”,而是已经开始输出后突然停顿。

这类现象不能简单归因于模型太大或 GPU 太慢。问题出在在线服务把两种形态明显不同的工作放进同一个调度队列:Prefill 一次处理大量输入 Token,适合形成较大的矩阵计算;Decode 每轮只为每个活跃请求推进一个或少量 Token,却需要频繁、稳定地获得执行机会。Chunked Prefill 的核心价值,就是把一个不可抢占的长 Prefill 切成多个可调度单元,让 Decode 能在这些单元之间持续前进。

Prefill 和 Decode 为什么会争抢同一块 GPU

一次自回归请求进入模型后,大体经历两个阶段。Prefill 读取完整 Prompt,让所有输入位置经过 Transformer,并建立后续生成需要的 KV Cache;完成后,请求进入 Decode,每轮依据已有上下文生成新的 Token,并把新位置的 K/V 继续追加到缓存中。

两者虽然运行的是同一套模型参数,但计算形态并不相同。Prefill 可以一次处理很多 Token,矩阵尺寸较大,更容易利用 GPU 的算力;Decode 每个请求每轮只有很少的新 Query,需要频繁读取模型权重和历史 KV Cache,在许多配置下更容易受到内存带宽、batch 规模和调度间隔影响。这里的“Prefill 更偏计算、Decode 更偏带宽”是一种常见工程特征,不应理解为所有模型和硬件都严格属于单一瓶颈类型。

阶段主要输入形态用户最敏感的指标长 Prompt 带来的直接影响
Prefill一次处理大量输入 TokenTTFT输入越长,首 Token 前的计算越多
Decode每轮为活跃请求推进少量 TokenTPOT / TBT / ITL受其他工作长时间占用 GPU 时会出现输出抖动
在线调度将不同请求和阶段组成 batchGoodput、P99 延迟长短请求混合后容易产生队头阻塞

如果调度器采用接近“一个 Prefill 开始后就执行到结束”的方式,一个 50K 或 100K Token 请求就可能形成很长的 GPU 执行区间。已经处于 Decode 的请求并没有计算错误,它们只是等待下一次被调度。于是平均吞吐可能仍然不错,单个用户看到的 Token 间隔却出现明显尖峰。

这也是为什么只看 tokens/s 很容易误判一个在线推理系统。吞吐量描述单位时间完成多少工作,却无法说明已经开始输出的请求是否稳定。对于聊天、语音交互、Coding Agent 这类流式应用,连续 Token 之间的停顿往往比总完成时间更容易被感知。

Chunked Prefill 把长任务变成可插入的调度单元

Chunked Prefill 不改变 Transformer 必须处理完整 Prompt 的事实,也不会让一个尚未缓存的 100K Token 输入凭空变成 10K Token。它改变的是 Prefill 的执行粒度。

假设某个请求有 8192 个输入 Token。最直接的做法是一次把 8192 个位置送入 Prefill;分块后,则可以把它拆成若干块,例如每次推进一部分输入。每完成一个 Chunk,引擎都获得一个新的调度边界,可以选择继续该请求,也可以先让已经处于 Decode 的请求推进一轮。

flowchart TD
  A[长 Prompt 到达] --> B[切分 Prefill Chunk]
  B --> C[调度一个 Chunk]
  C --> D[更新该请求 KV Cache]
  D --> E{有 Decode 请求吗}
  E -->|有| F[执行 Decode Batch]
  E -->|无| G{Prefill 完成吗}
  F --> G
  G -->|未完成| C
  G -->|已完成| H[生成首 Token]
  H --> I[进入常规 Decode]

这里最重要的状态是 KV Cache 的连续增长。Chunk 1 处理完后,请求已经拥有第一段 Prompt 对应的缓存;Chunk 2 继续处理后续位置时,需要基于此前缓存执行 Attention,而不是把前面的输入重新计算一遍。只要实现保持位置、缓存索引和 Attention 语义正确,分块改变的是调度顺序,不是模型看到的最终上下文。

对于用户 A 和 B 的场景,B 的长 Prompt 不再形成一个巨大且连续的执行区间。调度器可以在 B 的 Chunk 之间穿插 A 的 Decode。B 的 TTFT 可能因为被多次让出 GPU 而变长一些,但 A 的 TPOT 会更稳定。Chunked Prefill 本质上就是用可控的 Prefill 完成时间,换取更好的并发公平性和输出延迟尾部表现。

更关键的是混合 Batch,而不只是把 Prompt 切碎

单纯切块仍然可能低效。如果每个 Chunk 都独占一次 GPU,而 Decode 又单独形成另一个 batch,系统只是增加了更多调度点。Sarathi-Serve 一类方案更进一步:尝试把 Decode Token 和 Prefill Chunk 放在同一个调度批次中,用固定 Token Budget 控制这一轮总工作量。

假设当前有多个请求正在 Decode,每个请求都只需要推进一个 Token。同时又有一个长 Prompt 尚未 Prefill 完成。调度器可以先为 Decode 保留预算,再使用剩余预算填入一部分 Prefill Token。这样,一个 batch 既包含延迟敏感的 Decode,也包含吞吐友好的 Prefill 工作。

这种混合并不是因为两种任务在数学上能够互相“抵消”。更准确地说,它利用了两者不同的执行特征,让调度器有机会构造更合适的批次,并避免一个超长 Prefill 长时间阻断 Decode。是否能提高 GPU 利用率以及提高多少,取决于具体 kernel、模型结构、序列长度和硬件,不能仅凭“计算型 + 带宽型”就推导出固定加速比。

Sarathi-Serve 将这一方向用于缓解在线 Serving 的吞吐与延迟矛盾。它关注的不是单条请求的理论最短时间,而是在给定首 Token 和生成阶段延迟要求下,一台服务器能够持续接纳多少请求。这个视角把优化目标从峰值吞吐转向了满足 SLO 后仍然有效的吞吐,也就是常说的 Goodput 或 Serving Capacity。

Chunk Size 和 Token Budget 实际上是在配置 SLO

Chunked Prefill 的一个核心参数是:一次允许 Prefill 推进多少 Token。这个数字过小和过大都会产生代价。

如果 Chunk 很小,Decode 获得调度机会会更加频繁,长 Prefill 很难形成持续数百毫秒甚至更长的阻塞区间。但一个大 Prompt 会被拆成更多小任务,调度次数、kernel 启动、元数据处理和 KV Cache 状态切换都会增加。小矩阵还可能降低 GPU 利用率,导致 Prefill 总完成时间上升。

如果 Chunk 很大,Prefill 更接近传统的大矩阵计算,吞吐和单请求 TTFT 往往更有优势,但每个 Chunk 本身又可能重新成为明显的阻塞区间。对于已经在流式输出的请求,Chunk 越大,最坏情况下需要等待的时间也越长。

配置倾向Decode 延迟长 Prompt TTFTGPU 执行效率更典型的目标
较小 Chunk / Token Budget更容易保持稳定可能增加可能因任务过碎下降实时聊天、语音、交互式 Agent
中等 Chunk延迟与吞吐折中中等通常更容易兼顾混合在线流量
较大 Chunk / Token Budget更容易出现长等待区间通常更有利大矩阵更容易跑满离线批处理、吞吐优先任务

因此 Chunk Size 并不是“模型最优参数”,而是服务调度参数。生产系统应该根据 TTFT、TPOT、P95/P99 Token 间隔、请求长度分布和目标并发共同选择,而不是从论文或框架默认值直接复制一个数字。

vLLM 一类推理框架通常也会暴露与每轮 batched token 数量相关的调度参数。它们的工程意义与上述权衡类似:增加一轮允许处理的 Token 数,可能有助于 Prefill 和整体吞吐,但也可能增加 Decode 请求等待下一次调度的时间。具体参数名称和默认值会随版本变化,部署时应以当前版本文档和实际 profiler 为准。

为什么平均延迟正常,P99 仍然可能很差

长 Prompt 对在线服务最危险的地方往往不是平均值,而是尾延迟。假设绝大多数请求只有几百 Token,偶尔出现一个 80K Token 请求。平均 Prompt 长度可能仍然不高,但那个长请求到达时,恰好处于 Decode 的一批用户会同时经历停顿。

从监控上看,系统可能出现以下组合:GPU 利用率一直很高,总 tokens/s 没有明显下降,平均 TPOT 也还可以,但 P99 TPOT 或最大 Token 间隔突然恶化。若只看设备利用率,很容易误认为服务运行健康。

因此,评估 Chunked Prefill 应按请求阶段拆指标。至少需要观察 Prompt 长度分布、Prefill 时间、TTFT、Decode TPOT/TBT、每轮调度等待时间、batch 中 Prefill 与 Decode Token 比例,以及不同长度桶下的 P95/P99。若业务有明确 SLO,还要统计满足 SLO 的请求吞吐,而不是只看理论最大 QPS。

Goodput 的价值就在这里。假设方案 A 能处理更多请求,但大量用户的 TPOT 超出目标;方案 B 峰值吞吐略低,却能让更多请求同时满足 TTFT 和 TPOT 限制,那么对实时产品而言,方案 B 可能才是真正更高的有效容量。

Head-of-Line Blocking 为什么会随着长上下文更严重

Chunked Prefill 解决的是典型的 Head-of-Line Blocking,也就是一个大任务排在执行路径上时,让后面本可快速完成的工作一起等待。传统请求长度较短时,这个问题可能并不明显;当 Agent、RAG、多模态和长上下文应用把输入从几百 Token 推到数万甚至更多时,单次 Prefill 的时间跨度快速扩大,调度器就越来越不能把所有请求视作大小接近的任务。

在线服务还存在长度不可预测性。一个队列里可能同时出现 300 Token 的聊天请求、4000 Token 的工具调用上下文、30K Token 的 RAG 文档和 100K Token 的代码仓库。如果按严格先来先服务执行完整 Prefill,小请求的延迟会高度依赖前面恰好来了什么请求,而不是自身工作量。

Chunking 相当于给大任务增加了抢占点。它不能让 100K Token 请求少做工作,却能防止这个请求一次占用过长的调度窗口。从调度理论看,这种设计把一个大 job 拆成可交错的小 job;从 GPU 视角看,则需要额外解决小 job 是否还能组成足够高效的 batch。

这两者之间的张力决定了 Chunked Prefill 的实际价值:分得太粗,没有解决阻塞;分得太细,GPU 执行效率下降。一个成熟的调度器通常需要根据实时 batch 状态、Token Budget、KV Cache 可用量和 SLO 做动态选择,而不是固定轮询所有请求。

Prefix Caching 与 Chunked Prefill 是前后两层优化

Prefix Caching 和 Chunked Prefill 经常同时出现在 Agent 或 RAG 服务中,但它们解决的问题不同。

Prefix Caching 先问:“这段前缀是否已经计算过?”若命中,就直接复用对应 KV Cache,减少需要执行的 Prefill Token。Chunked Prefill 再处理剩下无法避免的 Prefill:“既然必须算,应该以什么粒度和 Decode 交错执行?”

假设一个 Agent 请求有 100K Token,其中 70K 是稳定的 System Prompt、工具定义和历史上下文,而且缓存仍然有效。Prefix Cache 命中后,真正需要 Prefill 的可能只剩新增的 30K。Chunked Prefill 再把这 30K 分块调度。前者降低总工作量,后者改变剩余工作量的时间分布。

技术优化对象是否减少总 Prefill 计算主要收益
Prefix Caching已计算过的重复前缀降低 TTFT 和重复 GPU 工作
Chunked Prefill尚未计算的长 Prompt降低长 Prefill 对 Decode 的阻塞
Continuous Batching不同进度的活跃请求不一定提高在线批处理利用率
FlashAttentionAttention kernel 数据流不改变 Prompt Token 数降低 Attention IO 成本

组合这些技术时,调度器还需要考虑缓存命中后的实际剩余长度。一个原本 100K 的请求命中 95K Prefix Cache 后,只剩很短的后缀;继续按“超长请求”策略切很多小 Chunk,反而可能增加无意义开销。因此请求进入调度器时,最好基于实际待计算 Token 数而不是原始 Prompt 长度做决策。

Prefill-Decode 分离是另一种更彻底的资源隔离方式

Chunked Prefill 假设 Prefill 和 Decode 仍在同一组 GPU 上竞争时间,只是通过更细粒度的调度减少相互干扰。另一条路线是直接把两种阶段分到不同资源池。

Prefill-Decode Disaggregation 中,请求先进入 Prefill GPU 池完成输入处理并生成 KV Cache,然后把后续 Decode 所需状态传递给 Decode GPU 池。这样,一个突然到来的超长 Prompt 不会直接占用 Decode GPU 的执行时间,两类资源还可以采用不同并行策略和容量规划。

这种设计的代价是 KV Cache 必须跨设备或跨节点传输。上下文越长,需要移动的缓存状态越大;如果网络带宽和拓扑不足,原本消除的计算争抢可能变成数据传输瓶颈。系统还需要处理 Prefill 节点与 Decode 节点的负载均衡、缓存生命周期、失败恢复和请求状态交接。

DistServe 的研究就是围绕这种资源解耦展开。其论文在特定模型、硬件和延迟 SLO 下报告了相对共置式基线的 Serving Capacity 改善,但这些数字不应直接当作所有部署的固定收益。是否值得拆分,取决于服务规模、网络能力、Prompt/输出长度比例以及两阶段负载是否足够稳定。

可以把两条路线理解为不同成本结构:Chunked Prefill 的复杂性主要集中在单个调度器和 GPU batch 组织;Prefill-Decode 分离把干扰隔离得更彻底,却引入跨资源池的数据移动和集群编排。小规模服务通常更容易从前者开始,超大集群才更有条件承担后者的系统成本。

生产环境应该如何验证是否真的需要 Chunked Prefill

最直接的办法不是先打开某个开关,而是确认是否存在“长 Prefill 阻塞 Decode”的证据。可以先把请求按 Prompt 长度分桶,再观察长请求到达期间其他正在 Decode 请求的 TPOT/TBT 是否同步恶化。如果 Token 间隔尖峰与 Prefill 大任务高度相关,Chunked Prefill 才具有明确的优化目标。

随后做同流量对照测试。保持模型、GPU、请求到达率和 Prompt/输出长度分布一致,只改变 Chunked Prefill 与 Token Budget 配置。记录至少四类指标:TTFT 分布、TPOT/TBT 分布、总吞吐或 Goodput、GPU 利用率与 KV Cache 占用。不能只选择其中一个指标证明优化有效。

还有两个常见失败模式需要关注。第一,Chunk 太小导致大量小 kernel 和调度开销,TPOT 变稳定了,但总吞吐显著下降;第二,Token Budget 太大,名义上启用了 Chunked Prefill,实际每个 Chunk 仍然足够长,P99 Decode 延迟没有明显改善。

对于多租户服务,还要观察公平性。高频短请求是否长期抢占 Prefill,导致大 Prompt 饥饿;反过来,是否为了尽快完成大 Prompt 给了它过高预算,再次伤害实时 Decode。调度器最终需要的不只是“切块”,而是明确的优先级、配额和 SLO 策略。

Chunked Prefill 最值得记住的并不是某个固定 Chunk Size,而是它暴露了 LLM Serving 的一个核心事实:当在线流量同时包含短 Prompt、长 Prompt 和持续 Decode,请求的计算形态已经不再同质。GPU kernel 可以很快,模型也可以已经量化,但只要调度器让一个巨大 Prefill 长时间占据执行路径,用户仍然会看到明显卡顿。

因此,大模型服务的性能优化正在从“让一次矩阵乘更快”进一步走向“决定下一毫秒 GPU 应该处理谁”。Prefix Caching 减少不必重复的 Prefill,Chunked Prefill平滑必须执行的长 Prefill,Continuous Batching 组织不同进度请求,Prefill-Decode 分离则从资源层彻底隔离两种阶段。真正稳定的在线系统,需要把这些机制放在同一套 TTFT、TPOT、Goodput 和容量目标下评估,而不是分别追求每个局部 benchmark 的最高数字。

资料来源

  1. Sarathi-Serve: Taming Throughput-Latency Tradeoff in LLM Inference
  2. Performance and Tuning - vLLM
  3. Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve
  4. DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving