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涨到200ms,Decode请求的TPOT(Time 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 vllm>=0.6.0 pip install torch>=2.4.0 pip install ray # 用于分布式调度 # 如果要用RDMA传输,需要安装 pip install pyzmq # 并确保系统已配置好RDMA驱动和库硬件方面,至少需要两台机器或一台多卡机器。Prefill实例建议用算力强的卡(如A100/H100),Decode实例建议用显存大的卡。如果只有一台机器,可以用不同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_gpus=2) 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_gpus=1) 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 token,P99是4096 token)。测试结果如下:
| 配置 | 吞吐(tokens/s) | P50延迟(ms) | P99延迟(ms) | GPU利用率 |
|---|---|---|---|---|
| 混合批处理 | 2400 | 620 | 4100 | 62% |
| Chunked Prefill | 2650 | 580 | 2200 | 68% |
| PD分离(串行) | 2900 | 550 | 1150 | 76% |
| PD分离(异步) | 3100 | 540 | 1080 | 81% |
可以看到,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承诺都会变得简单很多。这可能是它相比其他优化手段最被低估的优势。