首页
/ vLLM 分离式编码器(EPD)实战:以 examples/disaggregated/disaggregated_encoder 为例搭建 Encoder-Prefill-Decode 多模态推理集群

vLLM 分离式编码器(EPD)实战:以 examples/disaggregated/disaggregated_encoder 为例搭建 Encoder-Prefill-Decode 多模态推理集群

2026-09-06 22:15:09作者:傅爽业Veleda

本文围绕 vLLM 仓库中 disaggregated_encoder 示例目录 展开,带你完整跑通 EPD(Encoder-Prefill-Decode)分离式架构:从两种拓扑(1E+1PD、1E+1P+1D)的启动脚本与全部可覆盖的环境变量,到 --ec-transfer-config--kv-transfer-config 的逐项配置,再到 disagg_epd_proxy.py 代理的路由与请求改写逻辑。读完后,你将能够在本仓库环境下独立搭建并调优一套编码器独立扩展的多模态推理集群,并理解其背后的 EC(Encoder Cache)传输机制。

1. EPD 要解决什么问题

分离式编码器(Disaggregated Encoder) 把多模态 LLM 的视觉编码阶段从 prefill/decode 阶段拆出来,部署在独立的 vLLM 实例中。该设计带来三个实际收益(详见 docs/features/disagg_encoder.md):

  1. 独立、细粒度的扩缩容:视觉编码器很轻,语言模型大得多。语言侧可以并行扩展而不影响编码器集群,编码器节点也可以独立增删;
  2. 更低的 TTFT:纯文本请求完全不经过视觉编码器;编码器输出只注入到需要的注意力层,缩短了 prefill 关键路径;
  3. 跨进程复用与缓存:进程内编码器把复用限制在单个 worker 内,而远程共享缓存让任意 worker 都能取回已有 embedding,消除重复计算。

Disaggregated Encoder Flow

在 EPD 中,Prefill 实例接收缓存的方式与上述编码器分离流程完全一致:Prefill 实例执行 1 步(prefill 产出 1 个 token)后,通过 KV 传输把 KV cache 移交给 Decode 实例完成剩余生成。KV 传输完全发生在 PD 实例执行之后。

2. 示例目录文件清单与拓扑

examples/disaggregated/disaggregated_encoder/ 目录包含 4 个文件:

文件 作用
disagg_epd_proxy.py 代理脚本,演示 XeYpZd 拓扑(X 个编码实例、Y 个 prefill 实例、Z 个 decode 实例)。目前 1e1p1d 配置最稳定
disagg_1e1p1d_example.sh 搭建 1 Encoder + 1 Prefill + 1 Decode 三实例拓扑,跑 VisionArena 基准测试,并用本地图片发一个单请求
disagg_1e1pd_example.sh 搭建 1 Encoder + 1 合并 Prefill/Decode 双实例拓扑,同样跑基准测试和本地图片请求
README.md 本文的原始文档

两种拓扑对应代理的两条执行路径:

  • E → PD(disagg_1e1pd_example.sh:代理中 --prefill-servers-urls "disable",跳过独立 prefill 阶段,请求直接转发到合并实例;
  • E → P → D(disagg_1e1p1d_example.sh:代理先把请求发给 Prefill 实例(max_tokens=1),拿到 kv_transfer_params 后再附带该参数转发给 Decode 实例。

3. 可覆盖的自定义配置

两个脚本的所有关键项都通过环境变量覆盖(${VAR:-default} 形式),直接继承自脚本源码:

# 使用指定 GPU
GPU_E=0 GPU_PD=1 GPU_P=1 GPU_D=2 bash disagg_1e1p1d_example.sh

# 使用指定端口
ENDPOINT_PORT=10001 bash disagg_1e1p1d_example.sh

# 使用指定模型
MODEL="Qwen/Qwen2.5-VL-3B-Instruct" bash disagg_1e1p1d_example.sh

# 使用指定存储路径
EC_SHARED_STORAGE_PATH="/tmp/my_ec_cache" bash disagg_1e1p1d_example.sh

# 在 XPU 上运行;脚本会把 CUDA_VISIBLE_DEVICES 切换为 ZE_AFFINITY_MASK
DEVICE_PLATFORM=xpu GPU_E=0 GPU_PD=1 bash disagg_1e1pd_example.sh

从脚本源码看,各变量默认值如下:

变量 默认值(1e1p1d) 默认值(1e1pd) 说明
MODEL Qwen/Qwen2.5-VL-3B-Instruct 同左 多模态模型
ENCODE_PORT / PREFILL_PORT / DECODE_PORT 19534 / 19535 / 19536 19534 / 19535(合并) 各实例端口
PROXY_PORT 10001 10001 代理监听端口
GPU_E / GPU_P / GPU_D 2 / 2 / 3 0 / 1(GPU_PD 设备亲和性,经 CUDA_VISIBLE_DEVICESZE_AFFINITY_MASK 生效
EC_SHARED_STORAGE_PATH /tmp/ec_cache 同左 EC 缓存的共享磁盘目录,脚本每次运行前会清空重建
TIMEOUT_SECONDS 12000 300 wait_for_server 轮询超时
NUM_PROMPTS 100 100 VisionArena 基准的提示数量
GPU_MEMORY_UTILIZATION_E 0.01 0.01 编码器实例显存利用率(仅跑编码器,可极低)
GPU_MEMORY_UTILIZATION_P / _D_PD 0.7 / 0.7 0.7 语言侧实例显存利用率
MAX_NUM_SEQS 128 128 最大并发序列数
MAX_MODEL_LEN 32768 32768 最大模型长度
DEVICE_PLATFORM cuda cuda 设为 xpu 时脚本改用 ZE_AFFINITY_MASK 选卡
LOG_PATH ./logs ./logs 各实例与代理日志目录

DEVICE_PLATFORM 默认 cuda;在 Intel GPU(XPU)上运行时设为 xpu,脚本即改用 ZE_AFFINITY_MASK 而不是 CUDA_VISIBLE_DEVICES 做设备选择。

1e1p1d 脚本还额外导出 UCX 相关环境变量供 NixlConnector 使用:

export UCX_TLS=all
export UCX_NET_DEVICES=all
# 且 P/D 实例分别设置了 NIXL side channel 端口:
VLLM_NIXL_SIDE_CHANNEL_PORT=5559   # prefill
VLLM_NIXL_SIDE_CHANNEL_PORT=6000    # decode

4. 编码器实例的启动参数

编码器引擎必须带以下标志启动(README 原文要求):

标志 说明
--enforce-eager必需 当前 EPD 实现仅兼容以该模式运行的编码器实例
--no-enable-prefix-caching必需 编码器实例不消费 KV cache;禁用 prefix caching 以避免与其他特性冲突
--max-num-batched-tokens=<大值>(默认 2048) 控制每个解码步的 token 调度预算,与纯编码器实例无关。应设为极高的值(等效无限制)以绕过调度器限制,实际 token 预算由编码器缓存管理器负责。示例脚本使用 114688
--mm-encoder-only(可选) 如可行,初始化时跳过语言模型,降低设备内存占用
--allowed-local-media-path <MEDIA_PATH> 支持本地图片输入(见下节)

1e1p1d 脚本中编码器实例的完整启动命令(节选,来自 disagg_1e1p1d_example.sh):

env "$DEVICE_AFFINITY_ENV=$GPU_E" vllm serve "$MODEL" \
    --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION_E" \
    --port "$ENCODE_PORT" \
    --enforce-eager \
    --enable-request-id-headers \
    --no-enable-prefix-caching \
    --max-num-batched-tokens 114688 \
    --max-num-seqs "$MAX_NUM_SEQS" \
    --allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
    --ec-transfer-config '{
        "ec_connector": "ECExampleConnector",
        "ec_role": "ec_producer",
        "ec_connector_extra_config": {
            "shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
        }
    }'

注意 --enable-request-id-headers:代理通过 x-request-id 头把父请求 ID 派生出的子请求 ID(<parent>:<index>:<random-short>)传给编码器,便于全链路追踪日志。

5. 本地媒体输入

要支持本地图片输入(来自你的 MEDIA_PATH 目录),给编码器实例加:

--allowed-local-media-path $MEDIA_PATH

vLLM 实例和 disagg_encoder_proxy 均支持以 {"url": "file://'"$MEDIA_PATH_FILENAME"'"} 形式的本地 URI 作为多模态输入。每个 URI 从代理原封不动地传给编码器实例,由编码器在本地加载媒体。示例脚本最后一步即向代理发送一条携带本地图片的非流式请求:

curl http://127.0.0.1:"${PROXY_PORT}"/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
    "model": "'"${MODEL}"'",
    "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": [
        {"type": "image_url", "image_url": {"url": "file://'"${GIT_ROOT}"'/tests/v1/ec_connector/integration/hato.jpg"}},
        {"type": "text", "text": "What is in this image?"}
    ]}
    ]
    }'

其中 hato.jpg 就是仓库自带的集成测试图片。

6. EC connector 与 KV 传输配置

6.1 ECExampleConnector:编码器缓存落盘与传输

参考路径使用 ECExampleConnector(实现位于 vllm/distributed/ec_transfer/ec_connector/example_connector.py),它把编码器缓存暂存到本地磁盘并在两实例间传递。启用方式:

# 编码器实例:
--ec-transfer-config '{
    "ec_connector": "ECExampleConnector",
    "ec_role": "ec_producer",
    "ec_connector_extra_config": {
        "shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
    }
}'

# Prefill / Prefill+Decode 实例:
--ec-transfer-config '{
    "ec_connector": "ECExampleConnector",
    "ec_role": "ec_consumer",
    "ec_connector_extra_config": {
        "shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
    }
}'

$EC_SHARED_STORAGE_PATH 是 EC connector 暂存缓存的共享路径。源码印证了这一点:ECExampleConnector.__init__ec_transfer_config 读取 shared_storage_path(缺省 /tmp),并通过 save_caches / start_load_cachessafetensors 格式把 encoder cache 写盘/读盘。缓存目录结构是每个 mm_hash 一个子目录,内含 encoder_cache.safetensors(可用 ls -la $EC_SHARED_STORAGE_PATH/ 验证)。

相关配置类是 vllm/config/ec_transfer.py 中的 ECTransferConfig,字段包括 ec_connectorec_roleec_connector_extra_config、可选的 ec_connector_module_path;校验逻辑要求指定 ec_connector 时必须同时给出 ec_roleis_ec_producer / is_ec_consumer 属性即由 ec_role 派生。

6.2 ECConnector 的调度器/Worker 双角色

所有 EC 相关代码位于 vllm/distributed/ec_transfer/。从 ECConnectorBase 的接口注释可以看到 connector 分两个角色运行:

  • Scheduler role(调度器侧)has_cache_item() 检查某媒体的编码器缓存是否存在;update_state_after_alloc() 在块分配后决定是否加载缓存;build_connector_meta() 为每一步构建下发给 worker 的元数据;request_finished() 在请求结束时释放缓存;
  • Worker role(worker 侧)start_load_caches() 把缓存从 connector 载入 vLLM 的 encoder cache(在 _gather_mm_embeddings 之前调用);save_caches() 把 worker 本地编码器缓存写出;get_finished() 回报异步传输完成的请求 ID。

从源码结构看,ECExampleConnector 只是最简单的磁盘实现(代码注释自述为 “Simple debug implementation”),同目录下 cpu/ 子包还提供了基于 ZMQ 控制面 + NIXL 数据面的生产级 connector(含 embedding_cachedescriptor_bufferssession 等组件),并配套了 tests/v1/ec_connector/unit/cpu/ 下的单元测试。

6.3 NixlConnector:PD 分离的 KV 传输

如果启用了独立 prefill 实例(即 --prefill-servers-urls 未设为 disable),还需要 --kv-transfer-config 来支撑 PD 分离。当前使用 NixlConnector(位于 vllm/distributed/kv_transfer/kv_connector/v1/nixl/),更多 PD 分离示例可参考 tests/v1/kv_connector/nixl_integration

# Prefill 实例:
--kv-transfer-config '{
    "kv_connector": "NixlConnector",
    "kv_role": "kv_producer"
}'

# Decode 实例:
--kv-transfer-config '{
    "kv_connector": "NixlConnector",
    "kv_role": "kv_consumer"
}'

代理侧的对应逻辑见 disagg_epd_proxy.pymaybe_prefill / process_prefill_stage:向 prefill 发送 max_tokens=1 且带 kv_transfer_paramsdo_remote_decode: True)的请求,从响应里取回 kv_transfer_params 并原样附到发往 decode 实例的请求上,由 NixlConnector 完成 P→D 的 KV cache 移交。

7. 代理脚本(disagg_epd_proxy.py)详解

7.1 命令行参数

Flag 说明
--encode-servers-urls 逗号分隔的编码器端点列表。请求中抽取出的每个多模态项会以轮询(round-robin)方式分发到其中一个 URL
--prefill-servers-urls 逗号分隔的 prefill 端点列表。设为 disablenone"" 时跳过独立 prefill 阶段,走 E+PD 模式
--decode-servers-urls 逗号分隔的 decode 端点列表。非流式与流式路径都在该列表上轮询
--host--port 代理自身绑定地址,默认 0.0.0.0:8000
--no-rewrite 诊断开关:原样把媒体转发给解码器,仅用于阶段计时 A/B 对比

两种拓扑的启动示例(README 原文):

# E + PD 拓扑
$ python disagg_encoder_proxy.py \
      --encode-servers-urls "http://e1:8001,http://e2:8002" \
      --prefill-servers-urls "disable" \
      --decode-servers-urls "http://pd1:8003,http://pd2:8004"

# E + P + D 拓扑
$ python disagg_encoder_proxy.py \
      --encode-servers-urls "http://e1:8001,http://e2:8001" \
      --prefill-servers-urls "http://p1:8003,http://p2:8004" \
      --decode-servers-urls "http://d1:8005,http://d2:8006"

7.2 请求处理主流程

代理暴露 OpenAI 兼容的 POST /v1/chat/completions(另提供 GET /v1/modelsGET /health/start_profile/stop_profile 剖析转发端点)。/health 会并行探测三个集群的健康状态,任一群组异常时返回 503。核心转发路径 forward_non_stream / forward_stream 分三步:

Step 1:编码器扇出(fanout_encoder_primer

  • messages 中抽取所有 image_url / audio_url / input_audio / video_url 项;纯文本请求直接跳过编码阶段(这正是 EPD 降低 TTFT 的机制);
  • 每个媒体项构造一个去掉全部文本的子请求(x-request-id 为派生的子 ID),并发发到编码器集群。编码器不采样、不做生成,prompt 编码完成并对外发布 embedding 后即返回;
  • 编码器分配采用跨请求持久化的游标式轮询encoder_rr_assignment),避免单媒体项请求每次都打到 e_urls[0] 造成热点;
  • 缓存键 content_uuid内容派生的(对 URL 做 SHA-256),而非请求派生——否则每个请求都会造成 EC 缓存 miss,丢失跨请求复用;
  • 编码器在响应的 ec_transfer_params 中回报每个媒体项的元数据(其 mm_hash 键、processor 实际产出的 grid 等),代理据此把原始媒体项改写image_embeds/audio_embeds/video_embeds 类型的“仅元数据引用”:解码器不需要像素,只需 grid 就能算出占位区间,embedding 则由解码侧 connector 按同一 mm_hash 从 EC 传输通道取回(rewrite_for_decode)。

Step 2:Prefill(可选):如 6.3 节所述,带 kv_transfer_params 完成 P→D 移交。

Step 3:Decode 转发:改写后的请求转发到随机选中的 decode 实例,非流式返回 JSON,流式以 text/event-stream 逐块透传。代理还会打印 encode / rewrite / decode 各阶段耗时,便于定位瓶颈。

7.3 正确性验证

仓库提供了完整的 EPD 正确性集成测试:tests/v1/ec_connector/integration/README.md 描述的流程是先跑单实例 baseline,再分别跑 1E+1PD 与 1E+1P+1D,断言分拆后的输出与 baseline 逐字节一致temperature=0.0, seed=42 确定性生成)。注意 PD 分离本身可能与单实例有细微数值差异,因此 1E+1P+1D 的 baseline 取 1P+1D。运行方式:

./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
# 纯文本快速自检:
USE_MM_PROMPTS=0 ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh

单元测试则集中在 tests/v1/ec_connector/unit/,覆盖 metadata、protocol、session、NIXL producer/consumer 调度器、CPU connector 等;其中 test_epd_proxy_round_robin.py 专门验证上文提到的编码器轮询游标行为。

8. 小结

  • EPD 把多模态推理拆成 E / P / D 三类实例,编码器可独立扩缩容,纯文本请求零编码开销,编码器输出可跨进程缓存复用;
  • 落地只需三件事:给编码器实例配 --enforce-eager--no-enable-prefix-caching、超大 --max-num-batched-tokensec_producer 角色;给 PD 实例配 ec_consumer 角色(如需 PD 分离再加 NixlConnector 的 KV 角色);最后用 disagg_epd_proxy.py 按 XeYpZd 列表把三者串起来;
  • 示例脚本全部参数可用环境变量覆盖,1e1pd 拓扑是当前的快速验证路径,1e1p1d 拓扑用于验证完整的 E→P→D 链路,正确性以 tests/v1/ec_connector/integration 的 baseline 对比测试为准。
登录后查看全文
热门项目推荐
相关项目推荐