vLLM 分离式编码器(EPD)实战:以 examples/disaggregated/disaggregated_encoder 为例搭建 Encoder-Prefill-Decode 多模态推理集群
本文围绕 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):
- 独立、细粒度的扩缩容:视觉编码器很轻,语言模型大得多。语言侧可以并行扩展而不影响编码器集群,编码器节点也可以独立增删;
- 更低的 TTFT:纯文本请求完全不经过视觉编码器;编码器输出只注入到需要的注意力层,缩短了 prefill 关键路径;
- 跨进程复用与缓存:进程内编码器把复用限制在单个 worker 内,而远程共享缓存让任意 worker 都能取回已有 embedding,消除重复计算。
在 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_DEVICES 或 ZE_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_caches 以 safetensors 格式把 encoder cache 写盘/读盘。缓存目录结构是每个 mm_hash 一个子目录,内含 encoder_cache.safetensors(可用 ls -la $EC_SHARED_STORAGE_PATH/ 验证)。
相关配置类是 vllm/config/ec_transfer.py 中的 ECTransferConfig,字段包括 ec_connector、ec_role、ec_connector_extra_config、可选的 ec_connector_module_path;校验逻辑要求指定 ec_connector 时必须同时给出 ec_role,is_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_cache、descriptor_buffers、session 等组件),并配套了 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.py 的 maybe_prefill / process_prefill_stage:向 prefill 发送 max_tokens=1 且带 kv_transfer_params(do_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 端点列表。设为 disable、none 或 "" 时跳过独立 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/models、GET /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-tokens与ec_producer角色;给 PD 实例配ec_consumer角色(如需 PD 分离再加NixlConnector的 KV 角色);最后用disagg_epd_proxy.py按 XeYpZd 列表把三者串起来; - 示例脚本全部参数可用环境变量覆盖,1e1pd 拓扑是当前的快速验证路径,1e1p1d 拓扑用于验证完整的 E→P→D 链路,正确性以
tests/v1/ec_connector/integration的 baseline 对比测试为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
