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。
flowchart LR Client[Client] --> Router[PD Router] Router --> PService[Prefill Service] PService --> P[Prefill LWS Group] P -- KV Cache / NIXL --> D[Decode LWS Group] Router --> DService[Decode Service] DService --> D D --> Router DS[DisaggregatedSet] -. manages .-> P DS -. manages .-> D

这个边界很重要: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-demo Namespace。
  • 已有名为 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

验收标准应包含完整证据链:

  1. Router 接收请求,并先选中 Prefill 后端。
  2. Prefill 完成输入计算并初始化 KV 传输。
  3. Decode 收到对应请求的 KV 信息并继续生成 token。
  4. 没有 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 路由和可观测证据链。

参考链接