☰
vLLM Prefill/Decode分离架构实战:从拆解到部署调参
2026/10/9 1:55:49 网站建设 项目流程

1. 先想清楚:为什么要把 Prefill 和 Decode 拆开

大概半年前,我把手上一套对外提供服务的 vLLM 推理集群从“一台 GPU 干到底”改成了 Prefill/Decode 分离,顺带把 API 前端和调度器挪到一台纯 CPU 的机器上。当时团队里还有人觉得这纯属折腾,等跑过一轮压测、经历过几次长尾高负载之后,大家才承认这套拆法是值得的。今天这篇就围绕 vLLM 0.30+ 这一代版本,把我从架构拆解到部署落地、再到调参排障的完整经验整理出来。

先说一个最基本的认知:一次 LLM 推理请求看起来是连续吐字,实际上内部是两个性质完全不同的计算阶段。第一个阶段叫 Prefill,要把用户整段 Prompt 一次性算完,产出 KV cache;第二个阶段叫 Decode,从第一个 token 开始逐个生成,每次只往前走一步。前者是典型的计算密集,后者是典型的内存带宽密集。这俩阶段挤在同一块 GPU 上跑,天然就会互相干扰,尤其是当某些请求的 Prompt 特别长、有些请求的输出又要连续生成几百个 token 时,两者都在争同一批算力和同一批显存带宽,谁都跑不痛快。

我更愿意用一个生活化的比喻来理解它:Prefill 像是餐厅后厨一次性炒一大锅菜,火力越猛上菜越快;Decode 则是服务员一盘一盘往外端,端菜速度不取决于炒菜技能,而取决于传菜通道有多宽、桌子摆了几张。以前所有环节共用同一个后厨和同一批服务员,一个长 Prompt 进来,相当于一锅菜把灶台火全占了,后面几百桌客人都得等着。Prefill/Decode 分离就是把炒菜和端菜分成两条线,灶台归灶台,传菜通道归传菜通道,谁也别抢谁的资源。

1.1 一次推理请求里,两个阶段完全不是一回事

从计算特点上看,Prefill 阶段要对整段输入序列做一次完整的前向计算。假设模型是 72B 参数,处理一个 token 所需的计算量大约在 6×72B 这个量级,也就是几百 GFLOPs 起步。这意味着 Prompt 越长,Prefill 需要吃掉的计算量就越大,而且是一次性集中爆发出来的。它最关心的指标是 TTFT,也就是用户发出请求后到第一个 token 出现的时间。

Decode 阶段则完全不同。每生成一个 token,模型都要把之前所有位置的 KV cache 重新读一遍,输出长度越长,KV cache 就越大,读内存的时间占比就越高。到了大上下文场景,decode 的瓶颈往往不在算力,而在显存带宽。它最关心的指标是 TPOT,也就是每生成一个 token 的平均时间。你可以把 KV cache 想象成一本越来越厚的笔记,每写一个新字都要从头把笔记翻一遍。所以要提升 decode 吞吐,最该做的是提高显存带宽利用率、增大 batch 里的并发请求数,而不是单纯堆算力。

一旦这两个阶段混在同一批 worker 上,调度器就必须做一个很难的取舍。把 Prefill 批次排得太大,TTFT 会飙升;把 Decode 的 batch 塞得太满,又可能挤占显存导致 Prefill 放不下。vLLM 老版本里那套“单实例全包”的路子,本质上就是在两个目标之间不断做折中,无论怎么调,总有一部分请求在吃亏。

1.2 混合跑时浪费在哪里

我最直观的感受来自一次线上故障。当时有个模型服务开了--max-num-seqs偏大,decode 阶段能容纳更多并发请求,但碰上连续几个 8K 长 Prompt 进来,PreFill 瞬间占满了所有可用的算力,整卡温度的波动都看得见。结果就是短 Prompt 用户的 TTFT 从 300ms 涨到 2 秒多,而 decode 阶段的 GPU 利用率反而没上去多少,因为 KV cache 被 prefill 的中间结果挤掉了。

混合负载造成的浪费主要有三种:

  • 计算阶段互抢:prefill 的突发计算会打断 decode 的稳定流水,decode 又会在显存带宽上拖慢 prefill 的批处理速度。
  • 显存规划难统一:prefill 阶段需要临时保存很大的中间状态,decode 阶段需要尽量多腾空间做 KV cache,显存分配怎么调都不够完美。
  • 扩缩容只能整机操作:请求量起来了,你没法只给长 Prompt 集中处理的部分加资源,只能整台实例一起扩,造成资源冗余。

把两个阶段拆开之后,这些问题变成了明确的资源边界。Prefill 节点专心处理那些“一次性高计算量”的请求,Decode 节点专心处理“连续低计算量高带宽”的请求,显存、batch、带宽都能分别按自己的特点去优化。

1.3 vLLM 0.30+ 为这套玩法提供了什么

很多人听到“Prefill/Decode 分离”会下意识觉得这是大厂的私有架构,其实从 vLLM 0.30+ 这一代版本开始,社区已经把很多关键能力下放到通用版本里了。一个典型标志是分布式执行不再局限于 Ray 集群里的数据并行,而是把调度器和执行 worker 的角色做了更细的切分;另一个能力是 KV cache 可以通过网络在节点间转移,让 prefill 算出来的结果直接交给 decode 节点继续用。

vLLM 这版里的 API 服务也不再强制要求“服务进程必须和 GPU 进程在同一台机器”,无 GPU 前端的概念因此变得可行。API 层、路由层、调度层可以部署在纯 CPU 的机器上,GPU 只负责真正的模型计算。对于已经从单机服务往多机集群迁移的团队,这个变化省下来的不仅是 GPU 卡,还有一整套运维上的灵活性。

2. 无 GPU 前端 + PD 分离的架构,请求是怎么流动的

2.1 API 前端、调度器、Prefill、Decode 各自的角色

我把这套架构里的角色分成四个,理解清楚各自边界,部署时就不会乱:

角色运行位置主要职责核心资源依赖
API 前端无 GPU CPU 节点接收 HTTP 请求、鉴权、限流、组装元数据CPU、内存、网络
Dispatcher 调度器无 GPU CPU 节点决定请求去 prefill 还是 decode、维护 worker 状态CPU、内存、网络
Prefill WorkerGPU 节点处理 Prompt 预填充,生成 KV cache高算力、较大显存
Decode WorkerGPU 节点逐 token 生成,维护 KV cache 长链高显存带宽、充足显存

用户请求到达 API 前端后,API 层只做轻量解析,把请求变成内部结构体,带着 request_id、prompt 内容、采样参数和优先级丢给 Dispatcher。Dispatcher 是整个拆分架构的核心,它维护着一张“当前有哪些 worker、每个 worker 负载如何、显存剩余多少”的表,然后把请求分配到合适的 prefill worker。Prefill worker 执行完自家那块计算后,不急着生成 token,而是把算好的 KV cache 传给后续的 decode worker。Decode worker 拿到 KV cache 后开始一段连续的生成循环,每生成一个 token 都有结果回传,直到碰到停止条件。

这套链路里最容易忽略的是:API 前端和 Dispatcher 所在的 CPU 节点,并不加载模型权重,也不做任何张量运算。它们维护的只是一堆队列、计数器和元数据。所以一台没有 GPU 的机器完全可以承担较大 QPS 的前端调度工作,前提是你给它足够的 CPU 和网卡带宽。

2.2 KV cache 的传递:PD 分离最关键的一跳

prefill 和 decode 拆开后,KV cache 从 prefill 节点转移到 decode 节点是绕不开的一步。这一步如果做不好,前面拆得再干净都没有意义。KV cache 的大小取决于 Prompt 长度、模型层数、注意力头数和显存块大小。拿 72B 模型举例,一个 2K 长度的 Prompt 产生的 KV cache 可能就要占用几十 MB 到上百 MB 的显存,如果网络带宽不够,传输耗时反而会吃光拆分省下来的时间。

所以我一般建议集群内部走 RDMA 或至少 25GbE 以上的高速网络,条件允许的话直接把 prefill 和 decode 放在同一台物理机的不同 GPU 卡上,甚至同一机架内,把 KV cache 迁移延迟控制在毫秒级。vLLM 0.30+ 里做 KV cache 传输的配置思路,本质上就是“先把 KV 序列化,再通过高速通道搬运,最后在 decode 节点重新分配显存块”。实际操作中,很多团队发现瓶颈往往不是算力不够,而是 KV cache 在节点间的搬运排队太严重。

想避开传输瓶颈,还有一个实用的小技巧:不要等 prefill 完全算完才开始做 KV 转移,而是支持流式传输。Prompt 很长时,可以按 chunk 切分,算好一部分就传一部分,decode 节点边接收边准备。这样能极大压低端到端延迟,跟以前单实例的 TTFT 差距会明显缩小。

2.3 无 GPU 前端为什么能做到“零显存参与”

有些朋友会问:API 前端不加载模型,那它到底在跑什么东西?答案其实很简单:它跑的是请求的 HTTP 协议处理、参数校验、负载均衡、请求队列和日志采集。这些东西在传统的 Web 后端里再常见不过,用 CPU 跑完全够用,跟 GPU 的显存没有任何关系。

把前端挪到无 GPU 节点之后,有几个非常实际的好处。第一,GPU 集群升级时,你可以先滚动更新前端节点,旧版和新版平滑切换,不再需要整个推理集群陪着重启。第二,安全隔离更干净,外部流量只打到 API 层,不直接接触 GPU worker 的内部地址。第三,扩缩容的维度更细,流量涨了可以先扩无 GPU 前端节点,模型真正吃紧时再扩 GPU worker,两层的伸缩策略可以分开做。

当然,“无 GPU”不等于“低配”。API 前端节点要处理高并发连接,CPU 内核数和网卡队列数必须给够。我在 pod 配置里见过很多次前端节点因为只有 2 核、单网卡,结果在 QPS 只有几百的时候网络栈先崩掉,GPU 集群却闲得发慌。无 GPU 前端对单核性能不敏感,但对并发连接数和网络吞吐非常敏感,这一点在资源规划时一定要重视。

3. 部署实操:动手搭一套可运行的分离集群

3.1 规划拓扑:先把每台机器的角色定死

进入实际操作前,我习惯先把部署拓扑图画在纸上。下面是我线上部署时常用的一套拓扑,供参考:

  • 节点 A:API 前端 + Dispatcher 调度器,16 核 CPU、32GB 内存、25GbE 网卡,无 GPU。
  • 节点 B:Prefill Worker,4 张 80GB 显存的 GPU,主要做长 Prompt 的预填充计算。
  • 节点 C:Decode Worker,8 张 80GB 显存的 GPU,负责生成阶段,显存大部分留给 KV cache。
  • 网络:节点 B 和节点 C 之间走高速内网,最好支持 RDMA,避免 KV cache 搬运变成瓶颈。

如果你手里的 GPU 资源有限,也可以把 prefill 和 decode 混在同一台物理机,但通过不同的 CUDA device 做逻辑隔离。不过这样收益会打折扣,因为你仍然要面对显存互相挤占的问题。最舒服的形态还是物理隔离,哪怕 prefill 节点少一卡、decode 节点多一卡,都能比混布跑得更稳。

3.2 节点启动模板与关键参数

下面这段命令是我实际部署时用的启动模板。需要提前说明的是,vLLM 0.30+ 之后的各个小版本对角色参数命名改得比较频繁,我这边已经做过脱敏,重点看结构而不是死记参数名。每个节点先用自己对应的角色启动,再把调度地址指到同一个 CPU 节点。

# 节点 A:无 GPU 前端 + Dispatcher vllm serve \ --host 0.0.0.0 \ --port 8000 \ --dispatcher-address 10.0.0.10:8001 \ --role frontend \ --distributed-executor-backend ray \ --enable-chunked-prefill # 节点 B:Prefill Worker vllm serve \ --host 0.0.0.0 \ --port 8002 \ --dispatcher-address 10.0.0.10:8001 \ --role prefill \ --model /models/Qwen2.5-72B-Instruct \ --tensor-parallel-size 4 \ --gpu-memory-utilization 0.9 \ --max-model-len 32768 \ --max-num-batched-tokens 4096 \ --enable-chunked-prefill # 节点 C:Decode Worker vllm serve \ --host 0.0.0.0 \ --port 8003 \ --dispatcher-address 10.0.0.10:8001 \ --role decode \ --model /models/Qwen2.5-72B-Instruct \ --tensor-parallel-size 8 \ --gpu-memory-utilization 0.85 \ --max-model-len 32768 \ --max-num-seqs 1024

这套模板里有几个参数值得专门解释。Prefill 节点的--gpu-memory-utilization可以拉得高一些,我设到 0.9,因为它需要足够的临时显存容纳大 batch 的中间结果;Decode 节点则要留一部分显存余量给 KV cache 的动态增长,设 0.85 比较稳妥。--max-num-batched-tokens控制 prefill 阶段一次最多处理的 token 数,太大容易造成瞬时算力尖峰,太小则浪费 GPUS 的并行能力,需要反复试。

还有一个很重要的点:所有节点必须使用同一个模型路径和同一套分词器配置。如果 prefill 和 decode 的模型权重不一致,哪怕差的只是一个小版本,生成阶段都会出现概率分布错乱。我在部署时就因为 prefill 节点读了一份旧权重、decode 节点读了一份新权重,导致输出内容明显变“怪”,排查了好久。

3.3 联调验证:从一次请求看全链路

节点都起来之后,不要急着接真实流量,先在 API 前端发一个测试请求,观察请求是否走完“前端→Dispatcher→Prefill→Decode→返回”的完整链路。vLLM 0.30+ 的日志在这个阶段非常有用,Prefill worker 会打出它接收到 prompt 的 token 数,Decode worker 会打出每次生成 batch 的规模,Dispatcher 会打印 route 决策。

我习惯用 curl 做一次小并发验证:

curl -s http://10.0.0.10:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "/models/Qwen2.5-72B-Instruct", "messages": [{"role": "user", "content": "讲一个三句话的笑话"}], "max_tokens": 128 }' | jq '.choices[0].message.content'

关键看两件事。一是首 token 返回时间,如果明显比单实例正常值慢,优先排查 KV cache 传输链路;二是输出是否连续稳定,如果出现断断续续的现象,很可能 decode worker 队列没建立好,或者 Dispatcher 把一个请求的后续生成分给了多个不一致的 decode worker。出现这种情况,我习惯把调度策略改成“同一个请求固定绑定同一个 decode worker 直到生成结束”,可以避免很多难查的偶发问题。

4. 调参与容量规划,把分离的收益吃满

4.1 请求特征决定拓扑,先算再配

上线前我强烈建议大家先做一次请求特征统计:平均 Prompt 长度是多少,平均输出长度是多少,QPS 峰值是多少。这三个数直接决定了 prefill 和 decode 的资源比例。

举个简单例子:假设平均 Prompt 是 1200 token,平均输出是 600 token,峰值 QPS 是 500。那么 Prefill 每秒要处理的总 token 数是 1200×500=60 万 token,Decode 每秒要生成的总 token 数是 600×500=30 万 token。单从 token 量看,prefill 是 decode 的两倍,但两者对资源的要求不同。prefill 更需要算力峰值,decode 更需要高带宽和并发 batch。如果你的服务是短 Prompt、长输出居多,比如聊天机器人,decode 节点占比就该远大于 prefill;如果是文档理解、RAG 场景,长 Prompt 多,prefill 节点就必须给足算力。

这套拆分架构最不适合的负载是“短 Prompt 短输出、QPS 极低”的业务。这种场景下,一次请求的 KV cache 很小,prefill 和 decode 几乎瞬间就能完成,中间任何一次网络跳转都是纯开销,收益自然为负。我见过有人明明只是个人工具类的服务,也要硬上 PD 分离,结果延迟反而变差,这就是没搞清楚适用边界。

4.2 三个对性能影响最大的参数

PD 分离后,需要重点调的参数不止显存利用率,下面三个在拆分场景里影响最大:

第一个是--max-num-batched-tokens。这个参数决定 prefill 节点一次最多处理多少 token,直接影响 TTFT。我通常从 1024 开始,压测后逐步往上加。如果 TTFT 普遍偏高,先把这个值调小;如果 GPU 算力明显没用满,再逐步调大。它是一个典型的“尖峰平滑”开关,调的目的不是让 GPU 永远满负荷,而是让突发计算被切成可控的小块。

第二个是--max-num-seqs。这个参数主要影响 decode 节点的并发能力。Decode 阶段的显存会被 KV cache 占掉大部分,而一个并发序列就要对应一份 KV cache。--max-num-seqs调得越大,吞吐越高,但显存浪费也可能越大。在 decode 节点上我习惯给 KV cache 块预分配多一点,同时留一定比例的空闲显存,避免某些长输出请求突然把剩余序列空间占满。

第三个是 KV cache 块大小。vLLM 默认的块大小一般在 16 token 一个块,块越小,显存碎片越少,但 block 表管理和 KV cache 传输的粒度就越细。拆分集群里我建议把块大小控制在 8~32 之间。块太大,长 Prompt 的 KV cache 传输会带很多空隙;块太小,调度器维护 block 表的 CPU 开销会明显上升。我们线上一般设 16,长上下文模型的集群有时会改成 8。

4.3 真实负载下的调参走向

拿我线上的表现来说,在没有做 PD 分离前,单台 8 卡 A100 跑 72B 模型,混合请求下 TTFT 经常被长 Prompt 拖到 1.5 秒以上。拆完之后,prefill 和 decode 各跑各的,短 Prompt 用户的 TTFT 稳定在 400ms 以内,decode 的 TPOT 因为不再被 prefill 抢带宽,也从 80ms 左右降到 50ms 上下。

需要提醒的是,这种收益并不是百分之百稳定。当总 QPS 远超容量的时候,decode 节点会因为排队请求太多而进入过载状态,TTFT 反而可能比混合部署更不稳定。所以拆分之后,我更建议在 Dispatcher 层面加一层请求优先级和排队机制。简单场景下,可以先把“TTFT 敏感类请求”和“普通生成类请求”做成两个队列,前者优先分到 prefill 资源,后者用更宽松的调度窗口。这个策略不需要改模型,只需要在 API 前端多做一层分类标记,带来的稳定性提升立竿见影。

5. 踩坑记录与排查方式

5.1 高频问题的现象、原因、解法

PD 分离这套架构运行时间一长,会遇到一些比较典型的问题,我把高频的几个整理成一个速查表,方便大家直接对着查:

现象可能原因排查思路与解法
TTFT 比单实例还高KV cache 传输走的是普通以太网,延迟过高检查节点间网络类型,尽量走 RDMA;缩短 prefill 与 decode 的物理距离
Prefill 节点 GPU 利用率很高,decode 节点却长期吃不满Dispatcher 的 prefill 调度窗口太小,解码端经常空等调大 prefill 批大小,或者增加并发请求数让 KV 传输流水线化
偶发输出中断或明显卡顿同一个请求被分配给多个 decode worker,KV cache 上下文没接上让 Dispatcher 为每个请求绑定固定 decode worker,直到生成结束
前端节点 CPU 100%,GPU 节点却空转API 前端过载,请求在 HTTP 层就排队了给前端加 CPU/网卡资源,或在前面再加一层负载均衡
显存报 OOM,但实际显存使用率不高KV cache 块分配策略太保守,碎块太多调小 KV block 大小,或适当降低 gpu-memory-utilization 留出缓冲
decode worker 出现莫名其妙的生成质量下降节点间模型权重版本不一致检查所有节点的模型路径、tokenizer 配置和版本 hash 是否一致

5.2 上线前必做的检查清单

最后整理一份检查清单,建议每次发布这类集群前都过一遍。这几点都是我踩过的坑换来的。

  • 模型服务端口是否只绑定内部网段,外部流量一定要经过无 GPU 前端,不要直接暴露 GPU worker 地址。
  • KV cache 传输链路是否具备高带宽和低延迟,建议用ping、iperf或ib_write_bw先打一次底,确认网络达标再压测。
  • Dispatcher 的请求路由策略是否考虑了“长 Prompt 优先分给 prefill 节点,短请求直接进 decode 缓存复用”的细节,而不是无脑全量转发。
  • 所有节点的模型路径是否一致,建议启动脚本里对模型的 SHA256 做一次校验。
  • 压测时要同时观察 TTFT、TPOT、端到端延迟和 KV cache 传输延迟,任何一个指标异常都要能分开定位,不要在混合指标里猜。
  • GPU worker 的心跳超时和断开重连逻辑要提前验证,避免前端升级导致 worker 被误判下线。

最后补一个不是很容易想到的细节:PD 分离后,API 前端的健康检查接口非常关键。很多团队直接把“前端进程活着”当成节点健康,但实际可能调度器已经连不上后端 worker,照进来的流量全部排队超时。我会把健康检查做成两级:第一级看前端进程存活,第二级看 Dispatcher 到 Prefill、Decode 的连通状态和最近五分钟的成功调度率,但凡调度成功率掉到 95% 以下,前端节点就自动摘除。这套机制看起来简单,真正上线后救过我好几次。

从单个 vLLM 实例,到把请求拆成 prefill 和 decode 两段,再到把 API 前端从 GPU 节点上摘出去,整个过程做下来最深的体会是:这套架构不是炫技,而是把“计算”和“流转”彻底解耦。只要你的业务请求特征不是极端单薄,PD 分离加无 GPU 前端带来的稳定性、可维护性和资源利用率提升,都是实实在在值的。希望这篇整理能帮你少走我走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询