一个在线聊天服务最容易暴露长 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 | 一次处理大量输入 Token | TTFT | 输入越长,首 Token 前的计算越多 |
| Decode | 每轮为活跃请求推进少量 Token | TPOT / TBT / ITL | 受其他工作长时间占用 GPU 时会出现输出抖动 |
| 在线调度 | 将不同请求和阶段组成 batch | Goodput、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 TTFT | GPU 执行效率 | 更典型的目标 |
|---|---|---|---|---|
| 较小 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 | 不同进度的活跃请求 | 不一定 | 提高在线批处理利用率 |
| FlashAttention | Attention 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 的最高数字。