Taking vLLM Apart: A Practical Guide to Disaggregated Serving

发表时间: 2026-09 · Blog post by Martin Hickey (IBM Research) (blog.vllm.ai)

原文: https://blog.vllm.ai/blog/2026-09-29-disaggregated-serving-guide

Martin Hickey (IBM Research)

速读

一句话结论
vLLM 通过将大模型推理拆分为独立的预填充(Prefill)、解码(Decode)和纯 CPU 前端渲染层,彻底消除了长提示词对并发生成任务的显存与算力抢占,在保持系统吞吐量的同时将长尾词间延迟降低了数倍。

要解决什么问题
传统的单体服务模式将三种特征完全不同的负载强行绑定在同一张 GPU 上,导致严重的资源互相干扰。首先,预填充阶段(处理提示词)是计算密集型的,需要一次性读取全部提示词,决定了首字母延迟(TTFT);而解码阶段(生成词)是访存带宽密集型的,逐词生成,决定了词间延迟(ITL)。当这两者共享 GPU 时,一旦系统接收到一个长提示词请求,庞大的预填充计算会瞬间霸占算力,导致该 GPU 上所有正在进行解码的并发流全部卡顿。其次,诸如聊天模板拼接、分词(Tokenization)、逆分词以及推理过程和工具调用的解析,本质上全是纯 CPU 计算,将这些任务留在 GPU 节点上运行,白白浪费了昂贵的加速器资源。

怎么做的
核心思路是实施分离式服务(Disaggregated Serving),将单体推理流水线物理切割为三个独立层级,各自独立扩展。第一部分是预填充与解码分离(P/D 分离)。预填充实例专门负责处理长提示词,计算出注意力机制所需的键值对(KV Cache),随后通过 KV 连接器(如 NIXL)经由 RDMA 或 PCIe 将这些显存块传输给解码实例。解码实例只负责运行纯粹的生成批处理,由于没有预填充任务的插入,生成流永远不会被中断。针对多轮对话,系统设计了双向传输机制,预填充节点可以从解码节点把上一轮的 KV 块拉回,只计算新增的 Token,避免重复计算历史上下文。第二部分是剥离无 GPU 需求的纯 CPU 前端。引入 /render 和 /derender 端点,将 OpenAI 格式的请求在 CPU 节点上直接转化为 Token ID,使得后端的 GPU 引擎变成纯粹的“进 Token、出 Token”黑盒。在实际调度中,代理路由会先调用渲染层获取 ID,接着向预填充节点下发仅生成 1 个 Token 的预算并要求其保留 KV 块,随后将指向这些块的参数(kv_transfer_params)传递给解码节点完成生成,最后由逆渲染层将输出的 ID 还原为包含内容、推理过程和工具调用的标准响应。

效果如何
核心对比实验在一台配备双 NVIDIA L40S GPU(48GB,PCIe,无 NVLink)的机器上进行。测试模型为 Qwen2.5-7B-Instruct,负载为 8K Token 提示词和 256 个输出 Token,按泊松分布发送 100 个请求。对比基线是传统的同位部署(Collocated)路线,即在两张卡上运行 vllm serve --data-parallel-size 2(代表数据并行的单体副本模式)。结果显示,在 0.4 req/s 的负载下,基线路线的 p99 词间延迟(ITL)从 23ms 暴增至 169ms,而分离式架构的 p99 ITL 始终稳定在 25 到 52ms 之间,长尾延迟降低了约 6 倍。在 AMD MI300X 集群上运行 Qwen3-235B-A22B-FP8 时,分离架构在满足严格延迟目标下的有效吞吐量(Goodput)是基线的 2.4 倍。但该方法有明确的代价和失效场景:KV 传输的耗时会全部叠加在首字母延迟(TTFT)上。在上述缺乏高速互联(如 NVLink 或 P2P)的 L40S 测试中,470MB 的 KV 传输耗时约 1.3 秒,导致分离架构的 TTFT 中位数高达 2.2 秒(基线仅为 0.7 秒),直接错失了 2 秒的 SLO 目标。此外,作者承认当前的流式逆渲染解析器存在严重的性能局限:由于推理和工具解析器的状态无法序列化,每个流式数据块都需要重建解析器并重放 Token 历史,其字符级计算复杂度达到了 $O(n^3)$,在长文本推理输出时会导致 CPU 负载飙升。

主要贡献

在默认情况下,单一的 vllm serve 进程需要同时处理三个互不相关的任务:处理提示词(Prefill阶段)、生成Token(Decode阶段)以及围绕这两者的大量CPU工作。由于Prefill阶段是计算密集型的,它会一次性读取整个提示词并决定了首Token时间(TTFT);而Decode阶段是内存带宽密集型的,每次只生成一个Token,决定了Token间延迟(ITL)。当它们共享同一块GPU时,一个长提示词的到达会阻塞正在进行的所有Decode流,导致输出严重卡顿。此外,如聊天模板化、分词、解分词以及推理和工具调用解析等纯CPU工作,也白白消耗了昂贵的GPU节点资源。

本文的核心贡献在于系统性地介绍了vLLM中的解耦服务(Disaggregated Serving),旨在将LLM推理的不同阶段分离开来:

  1. 分离Prefill与Decode(P/D解耦):只要KV Cache能在两者之间快速传输,就能阻止长提示词阻塞其他请求的输出。
  2. 分离无GPU前端(前端解耦):将分词和解析工作转移到纯CPU的前端(/render和/derender),将这些工作从GPU节点卸载,使得底层引擎完全只处理Token IDs。
    本文详细阐述了各种解耦模式的适用场景、在vLLM v0.31.0及以上版本中的具体运行方法、系统架构设计以及未来的改进方向。

背景知识与设计原则

解耦的维度与架构设计

共置的vLLM服务与四层解耦流水线的对比
图1. 单一进程处理所有任务与同一流水线被切分为四层的对比。

解耦的收益与成本权衡

方法细节

运行Prefill/Decode分离

# Prefiller on GPU 0
CUDA_VISIBLE_DEVICES=0 UCX_NET_DEVICES=all VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-0.6B --port 8100 \
  --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'
 
# Decoder on GPU 1
CUDA_VISIBLE_DEVICES=1 UCX_NET_DEVICES=all VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-0.6B --port 8200 \
  --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer"}'
 
# Proxy
python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py --port 8192 \
  --prefiller-hosts localhost --prefiller-ports 8100 \
  --decoder-hosts localhost --decoder-ports 8200
from openai import OpenAI
 
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的kv_transfer_params指向decode所需的块;在此基础上添加token IDs
decode = 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}})

多轮对话:停止重新计算对话

运行无GPU的前端

vllm launch render Qwen/Qwen3-0.6B --port 8100 \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser hermes
vllm serve Qwen/Qwen3-0.6B --tokens-only --port 8200
import httpx
 
MODEL = "Qwen/Qwen3-0.6B"
RENDER = "http://localhost:8100"  # vllm launch render
ENGINE = "http://localhost:8200"  # vllm serve --tokens-only
 
chat_request = {
    "model": MODEL,
    "messages": [{"role": "user", "content": "What is 17 * 23?"}],
    "max_tokens": 2048,
}
 
with httpx.Client(timeout=60.0) as client:
    # 1. request -> token IDs (no GPU)
    generate_request = client.post(f"{RENDER}/v1/chat/completions/render", json=chat_request).json()
 
    # 2. token IDs -> token IDs (GPU)
    generate_response = client.post(f"{ENGINE}/inference/v1/generate", json=generate_request).json()
 
    # 3. token IDs -> ChatCompletionResponse (no GPU)
    response = client.post(f"{RENDER}/v1/chat/completions/derender", json={
        "model": MODEL,
        "generate_response": generate_response,
        "prompt_tokens": len(generate_request["token_ids"]),
        "chat_request": chat_request,
    }).json()
 
message = response["choices"][0]["message"]
print(message["reasoning"], message["content"], sep="\n---\n")

流式输出(Streaming)

import json
 
chat_request = {**chat_request, "stream": True}
 
with httpx.Client(timeout=60.0) as client:
    generate_request = client.post(f"{RENDER}/v1/chat/completions/render", json=chat_request).json()
    prompt_token_ids = generate_request["token_ids"]
 
    stream_state = None  # 如果 n > 1,则每个choice index一个
    with client.stream("POST", f"{ENGINE}/inference/v1/generate", json=generate_request) as stream:
        for line in stream.iter_lines():
            if not line.startswith("data: ") or line == "data: [DONE]":
                continue
            derendered = client.post(f"{RENDER}/v1/chat/completions/derender", json={
                "stream": True,
                "model": MODEL,
                "generate_chunk": json.loads(line[len("data: "):]),
                "stream_state": stream_state,
                "prompt_tokens": len(prompt_token_ids),
                "prompt_token_ids": prompt_token_ids,
                "chat_request": chat_request,
            }).json()
            stream_state = derendered["stream_state"]
            for choice in derendered["chunk"]["choices"]:
                print(choice["delta"].get("content") or "", end="", flush=True)

部署Render层

将两种解耦结合(Putting Both Splits Together)

# Render and derender, no GPU
vllm launch render Qwen/Qwen3-0.6B --port 8000 \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser hermes
 
# Prefill on GPU 0
CUDA_VISIBLE_DEVICES=0 UCX_NET_DEVICES=all VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-0.6B --tokens-only --port 8100 \
  --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'
 
# Decode on GPU 1
CUDA_VISIBLE_DEVICES=1 UCX_NET_DEVICES=all VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-0.6B --tokens-only --port 8200 \
  --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer"}'

实验环境

实验结果

补充细节:实际应用生态

目前,解耦服务已经在多个生产级框架中得到了实际应用:

结论与未来展望

vLLM的解耦服务通过分离Prefill、Decode以及纯CPU的前端任务,成功解决了长提示词阻塞并发生成以及CPU任务抢占GPU资源的核心痛点。在具备高速互联硬件的支持下,解耦架构不仅能显著压平尾部延迟,还能大幅提升系统在严格SLO下的有效吞吐量(Goodput)。

然而,该架构仍有一些亟待解决的问题与未来工作:

  1. 流式Derender的性能瓶颈:目前带有解析器的流式Derender开销极大。因为推理和工具解析器持有无法序列化的状态,每个Chunk都需要重建一个全新的解析器,并将Token历史重新通过它重放。传输复杂度为 $O(n)$,重放解析的复杂度为 $O(n)$,而像Hermes和DeepSeek-R1这样的解析器在重新扫描累积文本时,会导致整个长生成过程产生 $O(n^3)$ 的字符级工作量。基准测试表明,在并发解析流下,其CPU消耗是进程内处理的9倍。社区计划引入缓存层([#57571])来解决此问题。
  2. 网络传输的冗余:长提示词在网络传输中占据主导。解析器路径在每个Chunk上都会完整重发prompt_token_ids。对于一个100k Token的提示词和1k的输出,提示词占据了每个请求体约99%的体积。
  3. 正确性与API完善:正在修复Batch Derender在解析工具调用时不总是返回finish_reason: "tool_calls"的问题([#47931]),以及引擎返回的Logprobs为字符串占位符的问题(提议返回真实的整数Token IDs,[#57574])。同时,社区正在讨论/inference/v1/generate是否应该直接返回文本或Derender后的输出,以省去不需要此步骤的调用者的第三跳开销([#56851])。
    未来的核心演进将围绕解分词批处理([#42729])、解分词流式处理([#47161])、Token进出机制([#22817])以及解耦前端([#34407])的RFC展开。