资讯详情

chunked-prefill 分块预填充:长上下文推理延迟优化与调度策略

📅 2026/10/26 4:12:24 | 华诺云谱 👁 阅读
chunked-prefill 分块预填充:长上下文推理延迟优化与调度策略
1. 从一次推理延迟抖动说起chunked-prefill 到底在解决什么第一次真正被 chunked-prefill 教育是在一个长上下文对话服务上。当时线上表现很怪短请求几百 token 的 prompt响应飞快但只要来一个几千甚至上万 token 的长 prompt整个服务的首 token 延迟TTFT就会突然飙起来而且更糟的是同一批次里其他本来很快的短请求也被一起拖慢。排查下来不是模型本身慢而是调度粒度太粗——一个超长 prompt 的 prefill 阶段一口气占满了整个计算批次后面的请求只能干等。chunked-prefill分块预填充就是冲着这个场景来的。它的核心思想一句话能说清把一次超长的 prefill 计算切成多个小块分多个调度步step逐步完成而不是在一个 step 里一次性算完。这样做的直接收益是长 prompt 不再独占整个批次短请求可以插进来一起算整体吞吐和尾延迟都更平滑。这篇内容我打算按“为什么这么设计 → 关键细节怎么抠 → 怎么落地实现 → 踩过哪些坑”的顺序展开。适合两类人看一类是做推理服务、被长上下文延迟折磨过的工程同学另一类是正在读相关调度代码、想搞明白每个参数背后意图的开发者。哪怕你只是好奇“为什么我的长 prompt 会拖垮整个服务”看完也能有个清晰的判断。需要先明确一点chunked-prefill 不是某个具体框架的专属功能它是一种调度策略。不同实现里叫法、参数名、默认行为都不一样但底层要解决的问题是共通的。下面讲的所有细节都是基于这类策略的常见工程实践来展开的具体到某个框架时你需要对照它的实际代码去验证。2. 为什么需要分块prefill 与 decode 的本质差异2.1 prefill 是计算密集型decode 是访存密集型要理解 chunked-prefill 的价值得先搞清楚 prefill 和 decode 这两个阶段在硬件层面的差异。prefill 阶段模型要一次性处理整个 prompt 的所有 token。这些 token 之间可以做完整的注意力计算矩阵乘法的规模很大GPU 的算力能被充分打满属于典型的计算密集型compute-bound。一个 4096 token 的 promptprefill 时就是一次大规模的并行矩阵运算算力利用率很高。decode 阶段则完全不同。每生成一个 token都要基于之前所有 token 的 KV 缓存做注意力但当前只有 1 个新 token 参与计算。矩阵乘法的“宽度”极小大部分时间花在把 KV 缓存从显存里读出来属于访存密集型memory-bound。这时候 GPU 的算力其实是闲置的瓶颈在显存带宽。这个差异带来一个关键结论prefill 和 decode 混在一起调度时它们的资源需求是错位的。prefill 想吃算力decode 想吃带宽。如果调度器不做区分一个长 prefill 进来就会把整个批次的计算资源占满decode 请求被迫等待表现为其他用户的 token 生成“卡住”。2.2 长 prompt 独占批次带来的三个问题不做分块时一个超长 prompt 的 prefill 会引发连锁反应我把它归纳成三个具体问题首 token 延迟TTFT被拉长长 prompt 自己算得久是必然的但问题是它算的这段时间里调度器没法把其他请求塞进来导致所有请求的 TTFT 都被推高。批次利用率忽高忽低长 prefill 独占时批次很“满”但它一结束批次又空了GPU 利用率像坐过山车平均下来并不高。尾延迟不可控线上最怕的不是平均延迟而是 P99。一个偶发的超长 prompt 就能把 P99 顶上去用户体验断崖式下跌。提示很多人第一反应是“那就限制 prompt 长度”。但限制长度是产品层面的妥协会直接砍掉长文档问答、长代码补全这类高价值场景。chunked-prefill 的价值就在于它让你在不砍场景的前提下把延迟曲线压平。2.3 分块带来的核心收益把 prefill 切块之后调度器在每个 step 里可以做一个更灵活的决定这个 step 是算某个长 prompt 的一个 chunk还是算一批短请求的完整 prefill还是混着来。收益体现在三个层面第一长 prompt 的 prefill 被摊到多个 step每个 step 只占一部分计算量其他请求有机会插进来TTFT 的抖动明显变小。第二批次填充更均匀。调度器可以把多个请求的 chunk 拼成一个规模合适的批次让每个 step 的计算量都接近目标值GPU 利用率更平稳。第三为连续批处理continuous batching打配合。连续批处理解决的是 decode 阶段的动态进出问题chunked-prefill 解决的是 prefill 阶段的粒度问题两者结合才能让整个推理服务的调度真正“细粒度化”。3. 核心机制拆解一个 chunk 是怎么被切出来又拼回去的3.1 chunk 的切分粒度怎么定切分粒度是 chunked-prefill 里第一个要拍板的参数。切得太粗等于没切长 prompt 还是独占批次切得太细调度开销和 kernel 启动开销会吃掉收益。常见的做法是设定一个单 step 的 token 预算token budget比如每个 step 最多处理 512 或 1024 个 token 的 prefill。一个 4096 token 的 prompt 就被切成 8 个 512 的 chunk分 8 个 step 算完。这里有个容易忽略的点chunk 大小不是固定值而是“剩余预算”。假设这个 step 的预算是 1024已经有一个 300 token 的短请求占了位置那长 prompt 这个 step 只能切 724 个 token。所以实现里通常是“能塞多少塞多少”而不是死板地按固定大小切。我实测下来的经验是token budget 设成和 decode 批次规模相匹配的量级比较合理。如果 decode 那边一个 step 大概处理几十到上百个 token那 prefill 的 chunk 也不宜过大否则一个 chunk 就把 step 时间撑长了decode 的节奏被打乱。3.2 KV 缓存怎么跨 chunk 累积这是 chunked-prefill 最核心也最容易出错的地方。prefill 的本质是计算每个 token 的 KV 并写入缓存同时算出最后一层的输出。分块之后第 2 个 chunk 的注意力计算需要能看到第 1 个 chunk 已经算好的 KV。所以实现上必须保证每个 chunk 算完后它的 KV 要正确写入该请求对应的缓存位置且位置偏移position offset要连续。第 2 个 chunk 的 token 在做注意力时query 是新的但 key/value 要覆盖“前面所有 chunk 当前 chunk”的范围。这里有个细节注意力掩码attention mask要正确处理。第 2 个 chunk 的 token 不能看到未来 chunk 的 token但必须能看到前面 chunk 的 token。如果掩码写错模型输出会悄悄变差而且不一定报错非常隐蔽。注意跨 chunk 的 KV 累积如果位置编码position embedding处理不当长 prompt 的后半段会“失忆”。我见过一个 bug 就是每个 chunk 都从 position 0 开始编码结果模型对后半段 prompt 的理解完全错乱但表面上看生成还挺流畅极难发现。3.3 调度器如何在多个请求间分配 chunk调度器是 chunked-prefill 的大脑。它每个 step 要做的事本质是一个装箱问题手里有一堆待处理的请求有的要 prefill有的要 decode每个 step 有固定的计算预算怎么装才能让吞吐最大、延迟最稳。一个常见的策略是优先级加预算的组合先保证已经在 decode 的请求能继续生成它们对延迟最敏感且计算量小。剩下的预算分给待 prefill 的请求长 prompt 按 chunk 切短 prompt 可以一次算完。如果预算还有富余再考虑把新的长 prompt 拉进来开个头。这个顺序背后的逻辑是decode 请求的边际成本低、延迟敏感度高优先满足它们性价比最高prefill 请求虽然计算量大但可以容忍被切成多步所以放在后面用剩余预算填。3.4 与连续批处理的协同关系很多人会把 chunked-prefill 和连续批处理混为一谈其实它们是两个正交的机制。连续批处理解决的是“批次成员动态变化”的问题一个请求生成完了就退出批次新请求随时可以加入不用等整批结束。它主要作用在 decode 阶段。chunked-prefill 解决的是“单个请求的计算量太大”的问题把大计算量拆小让调度器有更多机会做细粒度安排。两者结合后的效果是一个 step 里既有若干 decode 请求在生成 token又有某个长 prompt 的一个 chunk 在 prefill还有短请求的完整 prefill。调度器像一个交通指挥把不同“车型”合理地安排进同一条车道。这才是现代推理服务调度的完整图景。4. 落地实现从参数配置到代码骨架4.1 关键参数与取值思路落地时绕不开几个参数我把它们和取值逻辑整理成一张表方便对照参数含义常见取值思路取值不当的后果max_num_batched_tokens单 step 总 token 预算与 decode 批次规模同量级如 1024~4096太小则调度开销大太大则长 prompt 仍独占max_num_seqs单批次最大请求数按显存和并发目标定如 64~256太大易 OOM太小并发上不去chunk 切分阈值超过多少 token 才分块通常几百到一千 token阈值太低短请求也被切纯属浪费KV 缓存块大小显存分配的页大小常见 16 或 32 token 一页太小碎片多太大浪费显存取值没有标准答案得结合你的硬件、模型大小、请求分布来调。我的建议是先用一组保守值跑通再根据监控里的 TTFT 和吞吐曲线逐步调。4.2 一个简化的调度循环骨架下面这段伪代码展示了 chunked-prefill 调度循环的核心逻辑用 Python 风格写重点看结构而不是具体 APIdef schedule_step(waiting, running, token_budget): # 第一步优先满足正在 decode 的请求 batch [] used 0 for req in running: if req.is_decoding(): batch.append(req) used 1 # decode 每步只算 1 个 token # 第二步用剩余预算处理 prefill remaining token_budget - used for req in waiting: if remaining 0: break # 长 prompt 按剩余预算切 chunk chunk_size min(req.remaining_prefill, remaining) batch.append(req.make_prefill_chunk(chunk_size)) remaining - chunk_size if req.prefill_done(): req.move_to_running() return batch这段骨架里最关键的是chunk_size min(req.remaining_prefill, remaining)这一行。它体现了“能塞多少塞多少”的弹性切分思想而不是固定 chunk 大小。实际框架里还会涉及优先级队列、抢占、显存预分配等复杂逻辑但核心就是这个装箱过程。4.3 位置编码与掩码的正确处理前面提过跨 chunk 的位置编码和掩码是重灾区。落地时要注意两点第一位置偏移必须全局连续。第 2 个 chunk 的第一个 token它的 position id 应该是“前面 chunk 的总长度”而不是 0。这个偏移量要作为参数传进注意力计算。第二掩码要区分“块内因果”和“跨块可见”。块内的 token 之间是标准因果掩码只能看前面的但当前块的所有 token 都能看到前面所有块的全部 token。实现上通常是把历史 KV 的长度作为“可见前缀长度”传给 kernel。提示如果你用的是现成的注意力 kernel先确认它是否支持“带历史 KV 前缀”的调用方式。有些 kernel 只支持标准因果注意力硬套会导致跨 chunk 信息丢失。4.4 显存管理的配合分块之后KV 缓存的分配方式也要跟着调整。因为一个请求的 prefill 会跨多个 step它的 KV 缓存必须在整个过程中保持有效不能被回收。常见做法是用分页式 KV 缓存paged KV cache把缓存切成固定大小的页请求按需申请页页之间用页表关联。这样长 prompt 的 KV 可以分散在不连续的显存块里既避免了预分配大块连续显存又方便跨 step 保持。这里有个实操心得页大小和 chunk 大小最好有整除关系。比如页是 16 tokenchunk 是 512 token那一个 chunk 正好占 32 页分配和回收都很干净。如果两者不整除会出现“半个页”的浪费长上下文场景下累积起来很可观。5. 常见问题与排查技巧实录5.1 输出质量悄悄变差这是最阴险的问题。服务不报错延迟也正常但用户反馈“长文档问答答非所问”。排查下来往往是跨 chunk 的 KV 或位置编码出了问题。排查思路构造一个“答案藏在 prompt 前半段”的测试用例。如果模型对前半段的内容视而不见基本可以锁定是跨 chunk 可见性没处理好。再检查位置偏移是否连续掩码是否让当前块看到了历史块。5.2 吞吐不升反降分块理论上提升吞吐但有些配置下反而更慢。常见原因有两个一是chunk 切得太细每个 step 的 kernel 启动开销和调度开销超过了收益。这时候把 token budget 调大试试。二是调度器每步都在重新装箱开销过大。可以引入“粘性批次”让已经在一起的请求尽量保持减少重排。5.3 长 prompt 的 TTFT 还是很高如果分块后长 prompt 的 TTFT 没改善检查是不是调度优先级把长 prompt 排到了最后。有些实现里长 prompt 的 chunk 总是等所有短请求处理完才轮到导致它虽然被切了块但每块之间间隔很久总时长反而更长。合理的做法是给长 prompt 的 chunk 一个“保底配额”保证它每个 step 至少能推进一点而不是被无限期饿死。5.4 问题速查表现象可能原因排查方向输出质量下降跨 chunk KV 或位置编码错误检查位置偏移连续性、掩码可见范围吞吐不升反降chunk 过细或调度开销大调大 token budget引入粘性批次长 prompt TTFT 仍高chunk 被饿死检查调度优先级加保底配额显存碎片化页大小与 chunk 不整除调整页大小或 chunk 大小对齐偶发 OOM批次 token 预算超显存下调 max_num_batched_tokens5.5 几个踩过的坑第一个坑以为分块是免费的。分块本身有开销尤其是跨 chunk 的注意力要重新读取历史 KV访存量增加。在短 prompt 为主的场景下分块纯属负优化所以一定要设切分阈值。第二个坑忽略了 prefill 和 decode 的相互干扰。分块让它们能共存于一个批次但共存也意味着资源竞争。如果 decode 的延迟对 prefill 的 chunk 大小很敏感就要在预算分配上做权衡。第三个坑测试用例太单一。只用均匀长度的请求测看不出问题。一定要构造“长短混合、突发长 prompt”的流量才能暴露调度器的真实表现。6. 一些延伸思考与个人体会chunked-prefill 让我重新理解了“调度”这件事的本质。它不是简单地决定“谁先谁后”而是在计算资源、显存资源、延迟目标三者之间做动态平衡。分块只是手段真正的目标是让每个 step 的计算量可控、可预测。顺着这个思路其实还有很多可以延伸的方向。比如把 chunk 的切分和请求的优先级挂钩——高优先级请求的 chunk 给大一点低优先级的给小一点再比如根据实时的 GPU 利用率动态调整 token budget忙时切细、闲时切粗。这些在部分实现里已经有雏形但远没到成熟。我个人在实际操作中的体会是先把监控做扎实再谈调参。TTFT、每 step 的 token 数、批次利用率、KV 缓存占用这几个指标盯住了chunked-prefill 的绝大多数问题都能定位。反过来如果只凭感觉调参数很容易在“吞吐”和“延迟”之间反复横跳最后两头不讨好。最后分享一个小技巧调试跨 chunk 逻辑时把 chunk 大小临时设成 1让每个 token 单独走一个 step。这样任何位置编码或掩码的错误都会被放大到肉眼可见定位完再调回正常值。这个方法笨但极其有效。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑