PD 分离不是把同一个推理容器复制成两个 Pod。一个可工作的系统至少有三部分:Prefill 实例负责处理输入并生成 KV Cache,Decode 实例接收 KV Cache 后继续生成 token,路由层负责把同一个请求依次送到两端。LWS 解决的是成组 Pod、角色生命周期和拓扑管理;KV 传输与请求编排仍由 vLLM、SGLang 和对应路由组件完成。
本文先用单 Prefill、单 Decode、每个角色一张 GPU 跑通链路。传输后端统一选 NIXL,便于对比两个框架。示例的目标是得到可验证的实验发布,不是直接复制到生产环境。
先判断是否真的需要 PD 分离
适合尝试:
- 长提示词占比高,TTFT 明显受 Prefill 队列影响。
- Decode 正在生成 token 时,经常被新的长 Prompt 打断,ITL/TPOT 抖动明显。
- Prefill 和 Decode 需要使用不同 GPU 型号、并行度或副本数。
- 已经能采集 TTFT、ITL、队列长度、KV 传输耗时和失败率。
不适合直接上:
- 小模型、短 Prompt、单卡已经能满足延迟和吞吐。
- 节点间网络连通性、RDMA/UCX 或 NIXL 环境还没验证。
- 团队只能看到 HTTP 200,看不到请求落在哪个角色、KV 是否成功传输。
vLLM 官方文档也明确提醒:Disaggregated Prefilling 的主要目标是分别控制 TTFT 与 ITL,不保证提高吞吐,而且当前存在功能组合限制。先用真实流量做基线,不要把“架构更复杂”误当成“性能一定更高”。
LWS 在这套架构里负责什么
LWS v0.9.0 提供两个相关 API:
LeaderWorkerSet:管理一个分布式角色内部的 leader/worker Pod 组。DisaggregatedSet:把 Prefill、Decode 等多个角色组合成一个推理拓扑,每个角色底层仍对应独立的LeaderWorkerSet。
这个边界很重要:LWS 不会替框架传 KV,也不会自动理解 OpenAI 请求。它负责让成组实例一起创建、更新和恢复,并给 Pod 注入 LWS_LEADER_ADDRESS、LWS_GROUP_SIZE、LWS_WORKER_INDEX 等组内信息。
安装与预检
截至 2026 年 9 月 3 日,LWS 最新正式版是 v0.9.0。下面按这个版本写;切换版本时先用 kubectl explain 核对已安装 CRD,不能直接套用 main 分支文档。
SGLang 官方 LWS 示例目前仍把 Prefill 和 Decode 发布为两个独立的 LeaderWorkerSet。本文改用 LWS v0.9.0 已包含的 DisaggregatedSet,把相同角色组合成一个拓扑。部署前仍应执行 kubectl explain disaggregatedset.spec,确认集群 CRD 与 Controller 版本一致。
helm install lws \
oci://registry.k8s.io/lws/charts/lws \
--version 0.9.0 \
--namespace lws-system \
--create-namespace \
--wait
kubectl api-resources | grep -E 'LeaderWorkerSet|DisaggregatedSet'
kubectl get pods -n lws-system
kubectl get nodes -o custom-columns='NODE:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu'
LWS 0.9 Chart 默认安装并启用 DisaggregatedSet,不需要额外开关。
如果是升级已有 LWS,不要只执行 helm upgrade:Helm 不会更新 Chart crds/ 目录中的 CRD。应先按对应版本的 LWS Chart README 显式更新 LeaderWorkerSet 与 DisaggregatedSet CRD,再升级 Controller。
示例假设:
- 已有
pd-demoNamespace。 - 已有名为
model-cache的 PVC,模型位于/models/Qwen2.5-7B-Instruct;该卷支持跨节点只读挂载,如ReadOnlyMany/ReadWriteMany。只有ReadWriteOnce时,应改用每节点模型缓存或为两个角色准备独立 PVC。 - Prefill 与 Decode 节点分别带有
inference-role=prefill、inference-role=decode标签。 - 镜像已经包含匹配版本的 CUDA、UCX、NIXL 和推理框架。
kubectl create namespace pd-demo
kubectl label node <prefill-node> inference-role=prefill
kubectl label node <decode-node> inference-role=decode
生产环境不要使用浮动 latest。下面的 <your-registry>/sglang-nixl:<pinned-version> 必须替换成经过验证并固定 digest 的镜像。
用 DisaggregatedSet 启动 SGLang Prefill 与 Decode
保存为 sglang-pd.yaml:
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: sglang-pd
namespace: pd-demo
spec:
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
restartPolicy: RecreateGroupOnPodRestart
workerTemplate:
metadata:
labels:
app: sglang-pd
spec:
nodeSelector:
inference-role: prefill
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: server
image: <your-registry>/sglang-nixl:<pinned-version>
command: ["bash", "-lc"]
args:
- |
exec python3 -m sglang.launch_server \
--model-path /models/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 30000 \
--disaggregation-mode prefill \
--disaggregation-transfer-backend nixl \
--disaggregation-bootstrap-port 8998 \
--mem-fraction-static 0.80
env:
- name: UCX_NET_DEVICES
value: all
ports:
- name: http
containerPort: 30000
- name: bootstrap
containerPort: 8998
startupProbe:
tcpSocket:
port: http
failureThreshold: 120
periodSeconds: 10
readinessProbe:
tcpSocket:
port: http
periodSeconds: 10
resources:
limits:
nvidia.com/gpu: "1"
volumeMounts:
- name: model
mountPath: /models
readOnly: true
- name: dshm
mountPath: /dev/shm
volumes:
- name: model
persistentVolumeClaim:
claimName: model-cache
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 8Gi
- name: decode
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
restartPolicy: RecreateGroupOnPodRestart
workerTemplate:
metadata:
labels:
app: sglang-pd
spec:
nodeSelector:
inference-role: decode
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: server
image: <your-registry>/sglang-nixl:<pinned-version>
command: ["bash", "-lc"]
args:
- |
exec python3 -m sglang.launch_server \
--model-path /models/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 30000 \
--disaggregation-mode decode \
--disaggregation-transfer-backend nixl \
--mem-fraction-static 0.85
env:
- name: UCX_NET_DEVICES
value: all
ports:
- name: http
containerPort: 30000
startupProbe:
tcpSocket:
port: http
failureThreshold: 120
periodSeconds: 10
readinessProbe:
tcpSocket:
port: http
periodSeconds: 10
resources:
limits:
nvidia.com/gpu: "1"
volumeMounts:
- name: model
mountPath: /models
readOnly: true
- name: dshm
mountPath: /dev/shm
volumes:
- name: model
persistentVolumeClaim:
claimName: model-cache
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 8Gi
---
apiVersion: v1
kind: Service
metadata:
name: sglang-prefill
namespace: pd-demo
spec:
selector:
disaggregatedset.x-k8s.io/name: sglang-pd
disaggregatedset.x-k8s.io/role: prefill
leaderworkerset.sigs.k8s.io/worker-index: "0"
ports:
- name: http
port: 30000
targetPort: http
- name: bootstrap
port: 8998
targetPort: bootstrap
---
apiVersion: v1
kind: Service
metadata:
name: sglang-decode
namespace: pd-demo
spec:
selector:
disaggregatedset.x-k8s.io/name: sglang-pd
disaggregatedset.x-k8s.io/role: decode
leaderworkerset.sigs.k8s.io/worker-index: "0"
ports:
- name: http
port: 30000
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sglang-pd-router
namespace: pd-demo
spec:
replicas: 1
selector:
matchLabels:
app: sglang-pd-router
template:
metadata:
labels:
app: sglang-pd-router
spec:
containers:
- name: router
image: <your-registry>/sglang-nixl:<pinned-version>
command: ["python3", "-m", "sglang_router.launch_router"]
args:
- --pd-disaggregation
- --prefill
- http://sglang-prefill:30000
- --decode
- http://sglang-decode:30000
- --host
- 0.0.0.0
- --port
- "8000"
ports:
- name: http
containerPort: 8000
readinessProbe:
tcpSocket:
port: http
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: sglang-gateway
namespace: pd-demo
spec:
selector:
app: sglang-pd-router
ports:
- name: http
port: 8000
targetPort: http
这里故意使用静态 Service,方便先跑通。它会跨 revision 选择 Pod;滚动更新时可能把不同版本的 Prefill 和 Decode 混在一起。生产路由应使用 DisaggregatedSet 自动生成的 revision-aware Service,或按 disaggregatedset.x-k8s.io/revision 成对发现后端,不能照搬这个静态选择器。
示例使用 hostNetwork 简化 NIXL/UCX 网络验证,并通过节点标签把 P、D 放到不同节点。扩到多个副本前,必须增加 Pod 反亲和或改用 RDMA CNI/独立网络;否则同一节点上的实例会争用相同端口。
启动后先验证资源,不要马上压测
kubectl apply -f sglang-pd.yaml
kubectl get disaggregatedset -n pd-demo
kubectl get leaderworkerset -n pd-demo \
-l disaggregatedset.x-k8s.io/name=sglang-pd
kubectl get service -n pd-demo \
-l disaggregatedset.x-k8s.io/name=sglang-pd \
-L disaggregatedset.x-k8s.io/role,disaggregatedset.x-k8s.io/revision
kubectl get pod -n pd-demo -o wide
kubectl get endpointslice -n pd-demo \
-l kubernetes.io/service-name=sglang-prefill
kubectl get endpointslice -n pd-demo \
-l kubernetes.io/service-name=sglang-decode
kubectl wait --for=condition=Ready pod \
-n pd-demo -l app=sglang-pd --timeout=30m
kubectl rollout status deployment/sglang-pd-router \
-n pd-demo --timeout=5m
Pod 是 Running 不代表链路可用。至少确认两端 Service 都有 Ready Endpoint,Prefill 与 Decode 使用同一模型和 tokenizer,NIXL/UCX 没有连接或注册内存错误。
发请求并证明请求真的经过 P 和 D
kubectl port-forward -n pd-demo svc/sglang-gateway 8000:8000
另开终端发送一个较长 Prompt:
curl -sS http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "/models/Qwen2.5-7B-Instruct",
"messages": [{
"role": "user",
"content": "请用五个步骤解释 Kubernetes 控制器如何让实际状态收敛到期望状态。"
}],
"max_tokens": 128,
"temperature": 0
}'
同时观察三个日志面:
kubectl logs -n pd-demo -f deployment/sglang-pd-router
kubectl logs -n pd-demo \
-l 'disaggregatedset.x-k8s.io/name=sglang-pd,disaggregatedset.x-k8s.io/role=prefill' \
--prefix --since=5m
kubectl logs -n pd-demo \
-l 'disaggregatedset.x-k8s.io/name=sglang-pd,disaggregatedset.x-k8s.io/role=decode' \
--prefix --since=5m
验收标准应包含完整证据链:
- Router 接收请求,并先选中 Prefill 后端。
- Prefill 完成输入计算并初始化 KV 传输。
- Decode 收到对应请求的 KV 信息并继续生成 token。
- 没有 bootstrap timeout、KV transfer timeout、UCX endpoint 或显存注册错误。
vLLM:复用同一 LWS 拓扑
vLLM 的 Kubernetes 拓扑不需要重做,替换 Prefill、Decode 容器启动参数和 Service 端口即可。两端必须固定相同的模型版本、tokenizer、--block-size 和 vLLM/NIXL 构建版本。
跨节点时不能让 NIXL 侧信道监听默认的 localhost。先在 Prefill 和 Decode 容器中通过 Downward API 注入可路由的 Pod IP;本文使用了 hostNetwork,此时该地址就是节点 IP:
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: UCX_NET_DEVICES
value: all
Prefill:
export VLLM_NIXL_SIDE_CHANNEL_HOST="${POD_IP}"
export VLLM_NIXL_SIDE_CHANNEL_PORT=5600
exec vllm serve /models/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 8100 \
--block-size 128 \
--max-model-len 8192 \
--gpu-memory-utilization 0.85 \
--enforce-eager \
--kv-transfer-config \
'{"kv_connector":"NixlConnector","kv_role":"kv_producer","kv_load_failure_policy":"fail"}'
Decode:
export VLLM_NIXL_SIDE_CHANNEL_HOST="${POD_IP}"
export VLLM_NIXL_SIDE_CHANNEL_PORT=5600
exec vllm serve /models/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 8200 \
--block-size 128 \
--max-model-len 8192 \
--gpu-memory-utilization 0.85 \
--enforce-eager \
--kv-transfer-config \
'{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"}'
验证环境显式设置 kv_load_failure_policy=fail。这样 Decode 无法加载远端 KV 时会直接失败,不会让一个正常返回的 HTTP 响应掩盖传输问题。--enforce-eager 也只用于缩小首次验证变量;完成正确性验证后,应结合目标模型重新评估 CUDA Graph 与吞吐配置。
vLLM 官方仓库提供 toy_proxy_server.py 验证 P/D 顺序调用。把该脚本放进与 vLLM 源码版本一致的实验镜像后启动:
python3 /workspace/vllm/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py \
--host 0.0.0.0 \
--port 8000 \
--prefiller-hosts vllm-prefill \
--prefiller-ports 8100 \
--decoder-hosts vllm-decode \
--decoder-ports 8200
先检查代理发现的实例数,再发 OpenAI 请求:
curl -sS http://127.0.0.1:8000/healthcheck
curl -sS http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "/models/Qwen2.5-7B-Instruct",
"messages": [{"role":"user","content":"解释 Prefill 与 Decode 的性能差异。"}],
"max_tokens": 128,
"temperature": 0
}'
再单独转发 Decode 的指标端口,确认传输字节数增加,失败计数没有同步增长:
kubectl port-forward -n pd-demo svc/vllm-decode 8200:8200
另开终端:
curl -sS http://127.0.0.1:8200/metrics | \
grep -E 'vllm:nixl_(bytes_transferred|num_failed_transfers|num_kv_expired_reqs)'
至少执行一次请求前后对比。vllm:nixl_bytes_transferred 应增长;vllm:nixl_num_failed_transfers 不应增长。若镜像没有暴露这些指标,先核对 vLLM 版本与 NIXL Connector 是否实际启用,不要只依赖响应文本判断。
toy_proxy_server.py 只适合功能验证。它会先向 Prefill 发一个短生成请求,读取返回的 kv_transfer_params,再把这些参数交给 Decode。生产环境需要使用 vLLM Production Stack 的 PD 路由模式或自研等价组件,并补齐健康检查、负载均衡、重试、鉴权和 revision-aware 服务发现。
多节点角色怎么接 LWS
单角色需要跨多节点 TP 时,把对应角色的 leaderWorkerTemplate.size 调大。SGLang 可以直接消费 LWS 注入的组信息:
python3 -m sglang.launch_server \
--model-path /models/DeepSeek-R1 \
--disaggregation-mode prefill \
--dist-init-addr "${LWS_LEADER_ADDRESS}:5000" \
--nnodes "${LWS_GROUP_SIZE}" \
--node-rank "${LWS_WORKER_INDEX}" \
--tp-size 16
size 表示一个角色组里的 Pod 数,不是 GPU 总数;总 TP 仍取决于每个 Pod 的 GPU 数和框架参数。vLLM 多节点启动要按所选分布式后端生成对应入口,不能直接照抄 SGLang 的 --nnodes 参数。
常见失败怎么定位
| 症状 | 优先检查 |
|---|---|
| DisaggregatedSet 创建了但 Pod Pending | GPU requests、nodeSelector、taint/toleration、PVC 和镜像拉取。 |
| P、D 都 Ready,但 Router 返回 5xx | Service Endpoint、模型名、框架版本、KV 参数是否被 Router 原样传递。 |
| Prefill 完成后一直超时 | NIXL/UCX 网卡选择、RDMA CNI、端口、防火墙、Pod IP 可达性。 |
| Decode OOM | KV Cache 余量、最大上下文、并发请求、显存利用率参数。 |
| 更新后偶发失败 | 静态 Service 混入不同 revision 的 P/D 实例。 |
| HTTP 200,但无法证明 PD 生效 | 同时查看 Router、Prefill、Decode 日志和 KV 传输指标。 |
上生产前的最低检查线
- 镜像固定到 digest,P/D/Router 版本一起进入发布记录。
- 模型、tokenizer、KV layout、block size 和传输后端保持兼容。
- 路由按 revision 成对选择 Prefill 与 Decode,滚动更新不混版本。
- Prefill 按输入 token 队列和 TTFT 扩缩,Decode 按活跃序列、KV 占用和 ITL 扩缩。
- 持续记录 TTFT、ITL/TPOT、端到端延迟、KV 传输耗时、超时率和 OOM。
- 做一次 Prefill Pod、Decode Pod、Router Pod 和网络中断演练,确认失败范围与恢复时间。
- 外部入口补齐鉴权、TLS、限流和请求大小限制,不直接暴露实验 Router。
PD 分离的价值在于 Prefill 与 Decode 可以按各自瓶颈独立配置和扩缩。LWS 让这两个分布式角色在 Kubernetes 里更容易管理;能否稳定上线,最终取决于 KV 传输、revision-aware 路由和可观测证据链。