vLLM 分离式推理实战指南:把 prefill 和 decode 拆开跑
原文:vLLM Blog - 《Taking vLLM Apart: A Practical Guide to Disaggregated Serving》(https://vllm.ai/blog/2026-09-29-disaggregated-serving-guide)
一个 vllm serve 进程同时在干三件事:处理提示词(prefill)、生成 token(decode),以及围绕这两件事的一大堆 CPU 工作。这三件事会互相干扰。vLLM 官方博客 9 月底发了一篇实用指南,讲清楚分离式推理能在哪几个点上把工作拆开、什么时候拆划算、用 vLLM v0.30.0 及以上版本怎么跑起来,以及还差什么。
这篇把它整理成一条能照着做的路径:先理解拆的维度和代价,再动手跑三进程的示例,最后用官方给的基准方法验证自己的机器。
一、拆开解决的是什么问题
单个进程里,一次 8k token 的 prefill 会落在同一块正在 decode 的 GPU 上,把它上面所有正在输出的流全部卡住,直到这次 prefill 算完。指南里的实测很直观:在 0.4 req/s 的负载下,共置部署的 p99 ITL 跳到 169 ms,而 P/D 分离保持在 29 ms,且从未超过 52 ms;在 0.4 到 0.6 req/s 之间两者的中位数接近,共置的 p99 大约高出六倍。
结论就一句话:把长提示词的计算挪出 decode 那台机器,前提是 KV 缓存搬得足够快。
二、可以拆的维度
指南列出几个可以拆、也可以组合的点:
- Prefill 与 decode 拆成两个实例。它们之间传递的是 KV 缓存,也就是 decode 在输出第一个 token 之前必须先拿到的、提示词每个 token 的注意力键值。它很大:Llama-3.1-70B 用 BF16 时每 token 存 320 KiB,一个 1 万 token 的提示词就要给 decode 大约 3 GB;按 400 Gb/s 线速算,光是这部分就约 65 ms,而且全部落在 TTFT 上。搬运靠 KV connector,通常走 RDMA。上游现在有十多个 connector,包括 NIXL、LMCache、Mooncake、FlexKV 和 AMD 的 MoRI-IO,还有一个能把它们串起来的 MultiConnector;
- 前端整体搬离 GPU 机器。
/render把 OpenAI 请求变成 token ID,引擎只做 token 进、token 出,/derender再把输出 token ID 变回带 content、reasoning、tool_calls 的 OpenAI 响应。指南提到 derender 这一段是最近才补上的,补上之后这个来回才闭环; - P/D 之外,多模态有 encoder 拆分,MoE 有把 attention 与 FFN 拆开的 AFD 插件,思路上是同一件事换个位置做;P/D 自身现在也覆盖了带 Mamba 状态传输的混合 SSM 模型。
三、能换到什么,代价是什么
正面收益有三组官方数据:
- 同一台 8 卡 MI300X 节点上跑 Qwen3-235B-A22B-FP8、8 req/s,AMD 的 MoRI-IO 单节点基准里 100 个请求有 73 个同时满足 1 s TTFT 与 50 ms ITL,共置只有 30 个,等于同样硬件上约 2.4 倍 goodput;
- 集群规模上,llm-d 的 P/D 指南报告 gpt-oss-120b 在 16 张 H200 上,相比同样 GPU 做成聚合副本,平均端到端延迟降低约 59%,P95 降低 67%;
- CPU 那层很便宜:同一台机器上给 Qwen2.5-7B 模板化并分词一个 9k token 的聊天提示词,大约花 15 ms CPU;单个 render 服务在默认设置下只用略超一个核心就到了 73 req/s;在共置部署尾部崩掉的 0.4 req/s 这个量级上,渲染开销不到单核的 1%。
代价同样明确,而且指南写得比收益更细:
- 每一个首 token 都要为这次传输买单。测试机用的两块 L40S 不支持点对点拷贝(nvidia-smi topo -p2p r 报 NS),一个 8k 提示词约 470 MB 的 KV 缓存(Qwen2.5-7B 每 token 56 KiB)要花约 1.3 s 才到 decode;在 0.2 req/s 下 P/D 的中位 TTFT 是 2.2 s,共置是 0.7 s。ITL 的尾部虽然平了,但 TTFT 在所有速率上都没过 2 s 的目标;
- 指南提醒,光看带宽解释不了这 1.3 s:即使绕主机内存,PCIe 4.0 应该只需几十毫秒,多出来的是拷贝周边的开销,其中包括 decode 只在自己的前向步骤之间轮询、才会发现传输已经完成;
- 运维复杂度上升:从一个服务变成三到四个服务,KV 传输本身成了新的失败模式。指南明确说,很多部署场景里共置仍然是正确答案。
| 你的情况 | 建议 |
|---|---|
| 生产负载下 ITL p99 打不中 SLO | 拆分,这是主要用例 |
| 长提示词加高并发 | 拆分,前提是 KV 传输够快,prefill 干扰在这里最严重 |
| 聊天或 Agent 循环、上下文持续增长 | 拆分,但要开双向传输,并配合 KV offloading 或 LMCache、Mooncake 这类共享 KV 缓存 |
| GPU 节点的 CPU 画像里出现模板化、分词、解析 | 把 render 层拆出去 |
| TTFT 是硬约束 | 保持共置,或者先量一次,传输成本落在每一个首 token 上 |
| KV 传输很慢 | 先修传输或保持共置,它落在 TTFT 上也压住了吞吐;顺便查网络,配置错的网络通常也会拖慢集合通信 |
| 流量低、突发或对延迟不敏感 | 保持共置 |
四、动手:三个进程跑 P/D
指南的所有示例都要求 vLLM v0.30.0 或更高版本。例子用 Qwen3-0.6B 只是为了加载快,它太小,显不出 P/D 的好处,所以正式压测要换更大的模型。
# Prefiller,跑在 GPU 0CUDA_VISIBLE_DEVICES=0UCX_NET_DEVICES=allVLLM_NIXL_SIDE_CHANNEL_PORT=5600\vllm serve Qwen/Qwen3-0.6B--port8100\--kv-transfer-config'{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'# Decoder,跑在 GPU 1CUDA_VISIBLE_DEVICES=1UCX_NET_DEVICES=allVLLM_NIXL_SIDE_CHANNEL_PORT=5601\vllm serve Qwen/Qwen3-0.6B--port8200\--kv-transfer-config'{"kv_connector":"NixlConnector","kv_role":"kv_consumer"}'# Proxypython tests/v1/kv_connector/nixl_integration/toy_proxy_server.py--port8192\--prefiller-hosts localhost --prefiller-ports8100\--decoder-hosts localhost --decoder-ports8200把这三段连起来的是 proxy。对每个请求,它先调 prefill,带上一 token 的生成预算和标记远程解码的参数;prefill 算好 KV 缓存、把 block 留在自己那里,返回指向这些 block 的参数;proxy 再把原始请求连同这些参数转发给 decode,decode 先通过 NIXL 把 block 拉过来,然后才开始生成。
客户端只要指向 8192 端口,它看起来就是一个普通的 OpenAI 端点。指南提醒,tests 目录里这个 proxy 是示例,开发够用、生产不行;生产环境应该看 llm-d 或 Dynamo。
五、三个要早点知道的配置
- VLLM_NIXL_SIDE_CHANNEL_PORT 在同一台机器上必须每个 worker 各不相同,示例里 prefill 用 5600、decode 用 5601 就是这个原因;
- kv_lease_duration 在 kv_connector_extra_config 里设置,默认 30 秒,决定 prefiller 等 decoder 把 block 收走能等多久。高负载下,这就是你要调的 timeout;
- kv_load_failure_policy 决定传输失败时的行为:默认的 fail 让请求报错,recompute 则让 decode 自己重算缺失的 KV,更慢,但请求能活下来。
六、顺手省掉一次分词
如果跑的是 chat completions 接口,prefill 阶段其实已经把提示词分词过了,decode 阶段没必要再来一遍。让 prefill 返回 token ID,再通过传输参数把它们交给 decode 即可:
fromopenaiimportOpenAI MODEL="Qwen/Qwen3-0.6B"messages=[{"role":"user","content":"What is 17 * 23?"}]prefill_client=OpenAI(base_url="http://localhost:8100/v1",api_key="EMPTY")decode_client=OpenAI(base_url="http://localhost:8200/v1",api_key="EMPTY")prefill=prefill_client.chat.completions.create(model=MODEL,messages=messages,max_tokens=1,extra_body={"return_token_ids":True,"kv_transfer_params":{"do_remote_decode":True}})# prefill 返回的参数把 decode 指向它的 block,这里再补上 token IDdecode=decode_client.chat.completions.create(model=MODEL,messages=messages,stream=True,extra_body={"kv_transfer_params":{**prefill.kv_transfer_params,"prompt_token_ids":prefill.prompt_token_ids}})这段和 proxy 内部做的两步调用是同一件事,唯一多出来的就是 token ID。
七、多轮对话:别重复计算整段上下文
标准的 P/D 只把 KV 单向搬一次,对聊天和 Agent 循环很浪费:到第二轮时,decode 手里还留着它刚生成的那段答案的 KV,而 prefill 从没算过这段,于是 prefill 从头重算。
两个实例都打开双向 KV 传输之后,prefill 改成从 decode 把这些 block 拉回来,只计算新增的 token。proxy 需要按客户端传来的 conversation_id 跟踪哪些 block 属于哪段对话。指南同时给了一条推理模型上的警告:decode 的 block 里包含它生成的思考轨迹,如果下一轮的提示词把它们丢掉了(Qwen3 自己的聊天模板就会丢),prefill 的提示词就和 decode 的 block 对不上,结果是输出错,而不只是变慢。指南指出 vLLM 目前不检测这种不一致,开启之前先确认自己的聊天模板。
八、两个拆分合起来
四层的形态只需要三台服务,因为一个 render 服务同时提供 render 与 derender。指南提醒,目前还没有上游组件替你驱动这四跳,但组件是可组合的,推理接口同样接受 KV 传输参数。
# Render 与 derender,不需要 GPUvllm launch render Qwen/Qwen3-0.6B--port8000\--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser hermes# Prefill,GPU 0CUDA_VISIBLE_DEVICES=0UCX_NET_DEVICES=allVLLM_NIXL_SIDE_CHANNEL_PORT=5600\vllm serve Qwen/Qwen3-0.6B --tokens-only--port8100\--kv-transfer-config'{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'# Decode,GPU 1CUDA_VISIBLE_DEVICES=1UCX_NET_DEVICES=allVLLM_NIXL_SIDE_CHANNEL_PORT=5601\vllm serve Qwen/Qwen3-0.6B --tokens-only--port8200\--kv-transfer-config'{"kv_connector":"NixlConnector","kv_role":"kv_consumer"}'注意 --tokens-only:引擎只处理 token ID,模板化和解析都在 render 层完成。此时 render 与 derender 之间的编排要你自己写,proxy 那个示例只处理 completions 与 chat completions 两类接口。指南把 llm-d 和 Dynamo 列为 renderer 的预期调用方。
九、怎么在自己的机器上验证
指南给了一条务实的路径,可以照着抄:
- 有两块 GPU 就用 7B 级模型跑上面的 NIXL 例子。Qwen3-0.6B 预填充太快,几乎不打扰 decode,P/D 没什么可修的;
- 先量传输。单机上 nvidia-smi topo -p2p r 在 prefill 与 decode 两块 GPU 之间应该报 OK;然后一次发几条长提示词,读 decode 输出里的 KV Transfer metrics 那一行,如果平均传输时间在几百毫秒,先修它,别急着测其它;
- 做对比基准。同一对 GPU 上,把打到 8192 的 proxy 和单进程的 vllm serve --data-parallel-size 2 放在一起比 p99 ITL、TTFT 和 goodput,把请求速率往上加,直到共置那一侧的 p99 ITL 崩掉,测试机上加 8k 提示词时这个点是 0.4 req/s;
- 用官方的基准命令,并留意一个坑:
vllm bench serve--modelQwen/Qwen2.5-7B-Instruct--port8192\--dataset-name random --random-input-len8192--random-output-len256--ignore-eos\--request-rate0.4--num-prompts100\--percentile-metrics ttft,tpot,itl --metric-percentiles50,99\--goodputttft:2000 tpot:30这个坑是前缀缓存:如果你在多个速率上复用同一个 --seed,每个服务都要加 --no-enable-prefix-caching,否则后面的速率会重放服务器已经缓存的提示词,缓存命中正好把你想要测的 prefill 干扰掩盖掉。
- 如果跑的是聊天或 Agent,开双向 KV 传输,并且测第五轮的 TTFT,而不是第一轮;如果已经在 Kubernetes 上,从 llm-d 或 Dynamo 起步,不要自己写 proxy;如果给推理或工具调用模型开了 streaming derender,先压测 render 层再定容量,指南说这是最容易被低估的一块。
小结
分离式推理不是「拆了就快」,它是一次明确的交易:用更复杂的运维和一次 KV 传输,换掉长提示词对 decode 的干扰。收益成立的条件写得很清楚,ITL 的尾延迟在打 SLO、KV 传输够快、流量足够高;条件不满足时,共置依然是正确答案。指南里那句「先量传输」值得当成第一步,平均传输时间已经是几百毫秒的话,后面所有基准都是在量同一个瓶颈。
对写 Agent 的人,第七节最贴身。Agent 循环的上下文会一轮轮增长,第二轮开始 prefill 重算整段历史的浪费会随轮数放大,双向 KV 传输加共享 KV 缓存是官方点名的组合。这类优化不改变 Agent 的逻辑,只改变它跑起来的成本和延迟。
注:文中版本号与配置项均来自原文(vLLM v0.30.0 及以上),本机未验证最新版本,以官方文档为准。