PD分离实战:Prefill与Decode解耦如何将LLM推理尾延迟降低70%

发布时间:2026/9/28 16:46:20
PD分离实战:Prefill与Decode解耦如何将LLM推理尾延迟降低70% 1. 从一次线上告警说起为什么PD分离值得聊去年冬天我负责的一个对话类推理服务在晚高峰突然出现尾延迟飙升P99从800ms直接冲到4秒多。排查下来发现GPU利用率其实只有60%出头显存却已经接近打满请求队列里堆着一大批长上下文请求。当时我们用的是传统的连续批处理Continuous Batching方案Prefill和Decode混在同一个批次里跑。问题就出在这里一个4096 token的Prefill请求和一堆Decode请求塞进同一批Prefill那一步的计算量把整个批次的耗时拉长Decode请求被迫等待而Decode阶段本身对延迟极其敏感。这就是典型的Prefill与Decode相互干扰问题。后来我们把架构改成了PD分离Prefill-Decode Disaggregation把两个阶段拆到不同的实例上跑尾延迟直接降到了1.2秒以内GPU利用率也提到了75%以上。这套方案不是什么新概念但在实际落地时有很多细节值得掰开讲。这篇文章就是把我从踩坑到跑通的全过程整理出来包括为什么要分离、怎么分离、KV Cache怎么传、参数怎么调、遇到问题怎么排查。不管你是刚接触推理优化的新手还是已经在做LLM Serving的工程师应该都能从中找到能直接用的东西。PD分离的核心思路一句话就能说清把大模型推理的两个阶段——Prefill预填充处理输入prompt生成KV Cache和Decode解码逐token生成输出——放到不同的计算资源上独立执行。听起来简单但背后涉及资源调度、KV Cache传输、负载均衡、显存管理等一系列工程问题。下面我按实际落地的顺序一层层拆开讲。2. 先搞懂Prefill和Decode到底在干什么2.1 两个阶段的本质差异很多人知道Prefill和Decode是推理的两个阶段但未必清楚它们在计算特性上的根本区别。这个区别是PD分离存在的全部理由所以必须先讲透。Prefill阶段处理的是用户输入的完整prompt。假设你输入一段512个token的问题模型需要把这512个token一次性送进Transformer计算每一层的Attention。这个阶段的特点是计算密集。所有token并行处理矩阵乘法的规模大GPU的算力能被充分利用属于典型的Compute-Bound场景。一次Prefill的耗时主要取决于prompt长度和模型大小跟batch里有多少条请求关系不大在合理范围内。Decode阶段是逐token生成的。每生成一个新token都要基于之前所有的KV Cache做一次Attention计算。这个阶段的特点是访存密集。每次只处理一个token计算量很小但需要把整个KV Cache从显存里读出来。GPU的算力大量闲置瓶颈在显存带宽上属于Memory-Bound场景。Decode的耗时跟已生成的序列长度和KV Cache大小强相关。用一个生活化的类比Prefill像是你一次性读完一整本书的目录和前言快速建立全局理解Decode像是你根据理解一个字一个字地写读后感每写一个字都要回头翻一下前面写的内容。前者是爆发式的高强度工作后者是持续性的低强度但高频率的查阅。2.2 混在一起跑会出什么问题理解了上面的差异就能明白为什么混跑会出问题。第一个问题是资源错配。Prefill需要算力Decode需要带宽。混在一个批次里GPU的算力和带宽都没法被最优利用。你为了照顾Decode的低延迟不敢把Prefill的batch开太大你为了提升Prefill的吞吐又会让Decode请求排队等待。两头不讨好。第二个问题是延迟干扰。这是最致命的。在一个连续批处理的迭代中如果当前批次里混入了一个长Prefill请求这一步的计算时间会被显著拉长。所有同批次的Decode请求都要等这一步跑完才能进入下一步。对于Decode来说每一步的延迟直接累加到用户感知的总延迟上。一个4096 token的Prefill可能让单步耗时从30ms涨到200msDecode请求的TPOTTime Per Output Token直接劣化。第三个问题是显存碎片。Prefill产生的KV Cache大小跟prompt长度成正比长短请求混在一起显存分配和回收的碎片化问题很严重。尤其是PagedAttention这类方案虽然缓解了碎片但在混合负载下仍然会有波动。注意如果你的服务QPS很低、请求长度都很短且均匀PD分离带来的收益可能覆盖不了它的复杂度。这套方案更适合高并发、请求长度差异大、对尾延迟敏感的场景。2.3 PD分离到底解决了什么把两个阶段拆开之后每个阶段可以用最适合自己的资源配置和调度策略。Prefill实例可以配置高算力的GPU用较大的batch来提升吞吐不用关心单步延迟。Decode实例可以配置大显存的GPU专注于低延迟的逐token生成batch策略以延迟优先。两者独立扩缩容Prefill扛不住就加Prefill实例Decode扛不住就加Decode实例资源利用率大幅提升。更关键的是尾延迟变得可控。Decode实例上不再有Prefill请求来捣乱每一步的耗时变得稳定可预测。这对于在线服务来说价值巨大因为用户感知的往往是P99而不是平均值。3. PD分离的三种主流架构方案3.1 方案一串行分离Simple Disaggregation这是最容易理解的方案。请求先送到Prefill实例Prefill完成后把KV Cache传给Decode实例Decode实例接着生成后续token。流程是这样的调度器收到请求根据当前负载决定分配给哪个Prefill实例Prefill实例处理完prompt后将KV Cache序列化并通过高速互联如NVLink、RDMA或共享内存传给指定的Decode实例Decode实例加载KV Cache后开始逐token生成直到遇到结束符。这个方案的优点是逻辑清晰、实现简单、调试方便。缺点是KV Cache传输会引入额外延迟而且Prefill和Decode实例需要成对匹配资源调度不够灵活。如果Prefill实例处理完了但Decode实例全忙KV Cache就得等着或者Prefill实例被阻塞。我最初就是用这个方案跑通的适合作为入门验证。在KV Cache传输量不大的情况下比如prompt平均512 token、模型7B传输延迟可以控制在几毫秒对整体影响不大。3.2 方案二池化分离Pooled Disaggregation这个方案把Prefill和Decode都做成资源池中间加一个KV Cache的存储层可以是显存池、主机内存池或分布式存储。Prefill实例处理完后把KV Cache写入存储层Decode实例从存储层读取。好处是Prefill和Decode完全解耦可以独立扩缩容调度灵活度高。坏处是KV Cache多了一次写入和读取延迟增加而且存储层的带宽容易成为新瓶颈。这个方案更适合Prefill和Decode资源需求差异极大、需要弹性伸缩的场景。3.3 方案三混合分离Hybrid / Chunked Prefill这个方案不是严格意义上的完全分离而是把Prefill切成小块Chunk和Decode请求混合调度但通过优先级和调度策略来减少干扰。比如vLLM的Chunked Prefill就是把长prompt切成多个chunk每个chunk和Decode请求一起批处理控制单步的计算量上限。它的本质是在吞吐和延迟之间找平衡不需要额外的KV Cache传输实现成本最低。但分离程度不如前两种彻底尾延迟的改善有限。如果你的场景对尾延迟要求不是极致这个方案性价比最高。方案实现复杂度尾延迟改善资源利用率适用场景串行分离中显著较高中等规模、请求长度差异大池化分离高显著最高大规模、弹性伸缩需求强混合分离低一般高对尾延迟要求不极致提示选方案不要一上来就追求最复杂的。我建议先用混合分离Chunked Prefill验证收益如果尾延迟还是达不到要求再上串行分离。池化分离留到规模真正上来之后再考虑。4. KV Cache传输PD分离最核心的工程问题4.1 KV Cache到底有多大要理解传输问题先得算清楚KV Cache的体量。KV Cache的大小公式是KV Cache大小 2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × dtype_size以一个7B模型为例假设32层、32个KV头、head_dim为128、FP16精度2字节单个token的KV Cache大小是2 × 32 × 32 × 128 × 2 524288 字节 ≈ 0.5 MB/token一个512 token的请求KV Cache就是256MB。如果是70B模型层数和头数更多单个请求的KV Cache可能达到几个GB。这个体量在实例间传输对带宽的要求非常高。4.2 传输介质的选择传输介质直接决定了PD分离的可行性。常见的选择有几种NVLink同一台机器内多卡之间的互联带宽可达数百GB/s延迟极低。如果Prefill和Decode实例在同一台机器的不同GPU上NVLink是最优选择。但受限于单机GPU数量扩展性有限。RDMA over InfiniBand/RoCE跨机高速网络带宽可达100-400Gb/s延迟在微秒级。这是大规模部署的主流选择。但需要专门的网卡和交换机成本较高。PCIe同机内GPU通过PCIe互联带宽比NVLink低一个数量级但比网络高。适合中小规模部署。共享内存/主机内存同机内通过CPU内存中转带宽受内存带宽限制延迟较高。适合验证阶段不适合生产。我的经验是如果KV Cache传输量超过单请求100MB就必须用NVLink或RDMA否则传输延迟会吃掉PD分离带来的所有收益。在验证阶段可以用共享内存先跑通逻辑但上生产前一定要换成高速互联。4.3 传输时机的优化KV Cache什么时候传也有讲究。有两种策略同步传输Prefill完成后立即传输Decode实例收到后才开始生成。逻辑简单但Prefill实例在传输期间被占用利用率下降。异步传输Prefill完成后立即释放实例去处理下一个请求KV Cache在后台传输。Decode实例收到后开始生成。这个方案需要额外的缓冲区来暂存待传输的KV Cache但能显著提升Prefill实例的吞吐。我实测下来异步传输能让Prefill实例的吞吐提升30%以上代价是需要额外的显存或内存来缓冲。如果显存紧张可以只缓冲元数据KV Cache分块传输。4.4 传输格式的压缩KV Cache传输前可以做量化压缩。比如把FP16的KV Cache量化成INT8传输量直接减半精度损失在可接受范围内实测困惑度上升不到0.1。更激进的可以量化到INT4但精度损失就比较明显了需要根据业务容忍度来定。另一个优化是只传必要的层。有些方案会把KV Cache分层传输Decode实例先收到前几层就开始计算后面的层边传边算用流水线的方式掩盖传输延迟。这个实现复杂度较高但效果很好。注意KV Cache量化压缩后Decode阶段的计算精度会受影响。如果你的业务对生成质量极其敏感比如代码生成、数学推理建议先做充分的精度评估再上线。5. 实操落地从零搭一套PD分离服务5.1 环境准备与依赖我以vLLM为基础来演示因为它对PD分离有较好的支持虽然不同版本支持程度不同建议用较新版本。环境准备如下# 基础环境 pip install vllm0.6.0 pip install torch2.4.0 pip install ray # 用于分布式调度 # 如果要用RDMA传输需要安装 pip install pyzmq # 并确保系统已配置好RDMA驱动和库硬件方面至少需要两台机器或一台多卡机器。Prefill实例建议用算力强的卡如A100/H100Decode实例建议用显存大的卡。如果只有一台机器可以用不同GPU分别充当Prefill和Decode。5.2 启动Prefill实例Prefill实例的启动参数需要针对计算密集做优化python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --port 8001 \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --enable-prefix-caching \ --disable-log-requests \ --kv-transfer-config {kv_connector:PyNcclConnector,kv_role:kv_producer,kv_rank:0,kv_parallel_size:2}关键参数说明--tensor-parallel-size根据GPU数量设置Prefill阶段算力需求大可以多用几张卡。--enable-prefix-caching开启前缀缓存对重复prompt能省掉重复的Prefill计算。--kv-transfer-config是PD分离的核心配置指定KV Cache的传输方式和角色。5.3 启动Decode实例Decode实例的参数针对低延迟和显存优化python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --port 8002 \ --tensor-parallel-size 1 \ --max-model-len 8192 \ --gpu-memory-utilization 0.85 \ --disable-log-requests \ --kv-transfer-config {kv_connector:PyNcclConnector,kv_role:kv_consumer,kv_rank:1,kv_parallel_size:2}注意Decode实例的tensor-parallel-size可以比Prefill小因为Decode是访存密集多卡并行的收益不如Prefill明显反而增加通信开销。gpu-memory-utilization留一些余量给KV Cache的接收缓冲。5.4 调度器的配置调度器负责把请求路由到Prefill实例再把KV Cache的接收信息传给Decode实例。如果用Ray做调度核心逻辑大致如下import ray from vllm import LLM ray.remote(num_gpus2) class PrefillWorker: def __init__(self): self.llm LLM(model/path/to/model, kv_transfer_config{...}) def process(self, request): # 执行Prefill返回KV Cache的元信息 return self.llm.prefill(request) ray.remote(num_gpus1) class DecodeWorker: def __init__(self): self.llm LLM(model/path/to/model, kv_transfer_config{...}) def generate(self, kv_meta): # 接收KV Cache并生成 return self.llm.decode(kv_meta)实际生产中调度器要处理负载均衡、故障转移、超时重试等逻辑。我建议先用简单的轮询调度跑通再逐步加上基于负载的动态调度。5.5 参数调优的实操记录我在一台8卡A100的机器上做了对比测试模型是Qwen2-7B请求长度分布是长尾的平均512 tokenP99是4096 token。测试结果如下配置吞吐(tokens/s)P50延迟(ms)P99延迟(ms)GPU利用率混合批处理2400620410062%Chunked Prefill2650580220068%PD分离(串行)2900550115076%PD分离(异步)3100540108081%可以看到PD分离对P99延迟的改善非常明显从4100ms降到1150ms降幅超过70%。吞吐也有提升但幅度没有延迟那么夸张。异步传输比同步传输吞吐高约7%延迟略低。调优过程中发现几个关键点Prefill实例的batch size可以开到很大我开到64因为Prefill对延迟不敏感Decode实例的batch size要控制我控制在16以内否则单步延迟会上升KV Cache传输的chunk size设为4MB左右比较合适太小了传输次数多太大了单次延迟高。6. 常见问题与排查技巧实录6.1 KV Cache传输超时这是最常见的问题。表现是Decode实例迟迟收不到KV Cache请求卡住。排查思路先确认网络连通性和带宽。用ibstat或nvidia-smi topo -m检查RDMA和NVLink状态。如果带宽只有预期的十分之一很可能是走了PCIe或TCP而不是RDMA。再检查KV Cache的大小是否超过了传输缓冲区如果单个请求的KV Cache超过缓冲区会被截断或丢弃。最后看是否有大量小请求导致传输频繁可以合并传输。我的经验是在传输层加一个监控记录每次传输的大小、耗时和成功率。这个监控能帮你快速定位是网络问题、缓冲区问题还是请求模式问题。6.2 Decode实例显存溢出Decode实例需要接收KV Cache如果显存规划不当很容易OOM。解决办法有几个降低gpu-memory-utilization给KV Cache留更多空间限制Decode实例同时处理的请求数对KV Cache做量化压缩及时释放已完成的请求的KV Cache。提示Decode实例的显存管理比Prefill更复杂因为KV Cache是动态增长的。建议用PagedAttention这类方案来管理显存减少碎片。6.3 负载不均导致实例空闲PD分离后Prefill和Decode的负载可能不匹配。比如Prefill很快但Decode很慢Decode实例排队Prefill实例空闲。这时候需要动态调整实例比例或者让空闲的Prefill实例临时充当Decode。我用的方案是给调度器加一个负载感知模块实时监控两边的队列长度动态调整请求分配比例。如果Decode队列超过阈值就暂停向Prefill发送新请求等Decode消化完再继续。6.4 常见问题速查表问题现象可能原因排查方法解决方案KV Cache传输超时网络带宽不足/缓冲区太小检查RDMA状态和传输日志换高速互联/增大缓冲区Decode实例OOM显存规划不足查看显存占用曲线降低utilization/量化KV Cache尾延迟仍然高Decode batch太大分析单步耗时分布减小Decode batch size吞吐上不去Prefill实例不足查看Prefill队列长度增加Prefill实例传输成功率低网络抖动/丢包检查网络错误计数加重试机制/换网络6.5 几个容易忽略的坑第一个坑是KV Cache的元数据管理。KV Cache传输不只是传数据还要传元数据比如序列长度、层数、block table等。元数据不一致会导致Decode阶段计算出错。我建议把元数据和数据一起传用一个统一的序列化格式。第二个坑是请求取消的处理。如果用户在Decode阶段取消了请求Prefill实例可能还在处理或者KV Cache还在传输。需要有一套取消传播机制及时释放资源。第三个坑是模型版本一致性。Prefill和Decode实例必须用完全相同的模型权重和配置否则KV Cache对不上。我在升级模型时踩过这个坑Prefill用了新权重Decode还是旧的结果生成的内容完全乱套。7. 一些实战心得和扩展思路PD分离这套方案我从验证到上线大概花了两个月中间踩了不少坑也积累了一些文档里不会写的经验。关于什么时候该上PD分离我的判断标准是如果你的服务P99延迟是P50的5倍以上且请求长度分布是长尾的那PD分离大概率能帮到你。如果请求长度很均匀或者QPS很低收益有限。关于KV Cache传输的优化除了前面说的量化和异步还有一个技巧是按层流水线传输。Decode实例不需要等所有层的KV Cache都到齐才开始可以先收到前几层就开始计算后面的层边传边算。这个方案能把传输延迟掩盖掉大半但实现复杂度高适合对延迟极致敏感的场景。关于监控PD分离后链路的可观测性变得更重要。我建议至少监控这几个指标Prefill队列长度、Decode队列长度、KV Cache传输耗时分布、传输成功率、两边的GPU利用率和显存占用。这些指标能帮你快速定位瓶颈在哪一环。最后分享一个扩展思路PD分离的架构其实可以进一步泛化。比如把多轮对话的历史KV Cache也纳入管理跨请求复用或者把不同模型的Prefill和Decode做交叉调度提升整体资源利用率。这些方向我还在探索有兴趣的可以一起交流。我在实际使用中发现PD分离最大的价值不是吞吐的提升而是延迟的可预测性。当你的服务延迟变得稳定可控容量规划和SLA承诺都会变得简单很多。这可能是它相比其他优化手段最被低估的优势。