This document describes the architecture and request lifecycle for enabling disaggregated inference execution in the llm-d Router. llm-d supports multiple disaggregation topologies:
- EPD (no disaggregation) – a single node handles all three functions (encode, prefill, and decode). This is the default mode when no disaggregation is configured.
- P/D (Prefill/Decode) – separates the prefill and decode stages onto different workers. This is functionally equivalent to EP/D, since prefill workers also handle encoding (multimodal processing) as part of the prefill stage.
- E/PD (Encode/Prefill-Decode) – offloads multimodal encoding to dedicated workers while a single worker handles prefill and decode.
- E/P/D (Encode/Prefill/Decode) – the full three-stage pipeline where each stage runs on a specialized worker.
Note
The Encode (E) stage is only relevant for requests with multimodal content (images, video, or audio). For text-only requests, the encode stage is skipped regardless of the configured topology.
Warning
Encode disaggregation (E/PD and E/P/D) is under active development in both vLLM and llm-d-router. The implementation described here is a proof of concept (PoC) and is subject to change.
All topologies are driven by the unified disagg-profile-handler plugin, which selects active stages based on configuration, the user request (e.g., presence of multimodal content), and the system status (e.g., KV-cache hit ratio on the selected decode pod). The architecture aims to improve flexibility, scalability, and performance by enabling separation of inference stages onto different workers.
- Enable routing of encode, prefill, and decode to different workers
- Maintain low latency and high throughput
- Improve resource utilization by specializing pods for each stage
- Support multimodal workloads by offloading encoding to dedicated workers
- Align with GIE-compatible architectures for potential upstreaming
| Component | Role |
|---|---|
| Encode Worker | Handles multimodal encoding (images, video, audio) for E/PD and E/P/D |
| Prefill Worker | Handles prefill stage using vLLM engine; in EP/D configuration, also handles encoding for multimodal requests |
| Decode Worker | Handles decode stage and contains the sidecar for coordination |
| Sidecar (Decode) | Orchestrates communication with encode/prefill workers and manages lifecycle |
| Envoy Proxy | Accepts OpenAI-style requests and forwards them to EPP |
| EPP | Endpoint Picker, makes scheduling decisions |
- User Request – Sent via OpenAI API to the Envoy Proxy
- EPP Scheduling Decision – The
disagg-profile-handlerruns stages in order:- Decode: always runs first, selects a decode pod
- Prefill (optional): the PD decider evaluates prompt length and prefix-cache hit; if disaggregation is warranted, a prefill pod is selected
- Execution – Request lands on Decode Worker:
- If
x-prefiller-host-portheader doesn't exist → runs both stages locally - If
x-prefiller-host-portheader exists → sidecar sends prefill to the selected Prefill Worker, then runs decode locally
- If
- Response Flow – decode sidecar → Envoy → EPP → User
For multimodal requests (images, video, audio), the encode stage can be disaggregated to dedicated workers:
- User Request – Multimodal request sent via OpenAI API
- EPP Scheduling Decision – The
disagg-profile-handlerruns stages in order:- Decode: selects a decode pod
- Encode (optional): the encode decider checks for multimodal content; if present, an encode pod is selected
- Execution – Request lands on Decode Worker:
- If encode was scheduled → sidecar sends encoding work to the selected Encode Worker(s) via the
x-encoder-hosts-portsheader - Encode Worker processes multimodal content and returns encoding metadata (embedding references)
- Decode Worker reads embeddings via EC_Connector and runs prefill + decode locally
- If encode was scheduled → sidecar sends encoding work to the selected Encode Worker(s) via the
- Response Flow – decode sidecar → Envoy → EPP → User
The full three-stage pipeline combines both encode and prefill disaggregation:
- User Request – Multimodal request sent via OpenAI API
- EPP Scheduling Decision – The
disagg-profile-handlerruns all three stages in order:- Decode: selects a decode pod
- Encode (optional): if multimodal content is detected, an encode pod is selected
- Prefill (optional): if the PD decider determines disaggregation is beneficial, a prefill pod is selected
- Execution – Request lands on Decode Worker:
- If encode was scheduled → sidecar sends encoding work to the selected Encode Worker(s) via the
x-encoder-hosts-portsheader - Encode Worker processes multimodal content and returns encoding metadata (embedding references)
- If prefill was scheduled → sidecar sends prefill to Prefill Worker via the
x-prefiller-host-portheader - Prefill Worker reads embeddings via EC_Connector and executes prefill operation
- Decode Worker runs decode locally
- If encode was scheduled → sidecar sends encoding work to the selected Encode Worker(s) via the
- Response Flow – decode sidecar → Envoy → EPP → User
sequenceDiagram
participant C as Client
participant I as Inference Gateway
participant DS as Decode Worker Sidecar
participant D as Decode Worker(vLLM)
participant P as Prefill Worker(vLLM)
C->>I: Inference Request
I->>DS: Request is sent to the Decode Worker Sidecar <br/> with the selected Prefill worker set in a header.
DS->>P: Remote Prefill with prompt(max_tokens=1)
P-->>P: Run prefill
P->>DS: Remote kv parameters
DS->> D: Request is sent to the Decode Worker (vLLM) with remote_prefill true, <br/>prefill ID and memory block IDs
D-->>P: Read kv-cache
D-->>D: Schedule decode into queue & run decode
D->>DS: Inference Response
DS->>I: Inference Response
I->>C: Inference Response
sequenceDiagram
participant C as Client
participant I as Inference Gateway
participant DS as Decode Worker Sidecar
participant E as Encode Worker
participant D as Decode Worker(vLLM)
C->>I: Multimodal Inference Request
I->>DS: Request with x-encoder-hosts-ports header
DS->>E: Send multimodal content for encoding
E-->>E: Process images/video/audio
E->>DS: Encoding metadata (embedding references)
DS->>D: Request with encoding metadata
D-->>E: Read embeddings via EC_Connector
D-->>D: Run prefill + decode locally
D->>DS: Inference Response
DS->>I: Inference Response
I->>C: Inference Response
sequenceDiagram
participant C as Client
participant I as Inference Gateway
participant DS as Decode Worker Sidecar
participant E as Encode Worker
participant P as Prefill Worker(vLLM)
participant D as Decode Worker(vLLM)
C->>I: Multimodal Inference Request
I->>DS: Request with x-encoder-hosts-ports <br/> and x-prefiller-host-port headers
DS->>E: Send multimodal content for encoding
E-->>E: Process images/video/audio
E->>DS: Encoding metadata (embedding references)
DS->>P: Remote Prefill with prompt and encoding metadata (max_tokens=1)
P-->>E: Read embeddings via EC_Connector
P-->>P: Run prefill
P->>DS: Remote kv parameters
DS->>D: Request with remote_prefill true, <br/>prefill ID and memory block IDs
D-->>P: Read kv-cache
D-->>D: Schedule decode into queue & run decode
D->>DS: Inference Response
DS->>I: Inference Response
I->>C: Inference Response
- Receives EPP metadata (decode pod, optional encode pod(s), optional prefill pod)
- If encode endpoints are present, sends multimodal content to Encode Worker(s), waits for results and validates them
- If prefill endpoint is present, sends prefill request to Prefill Worker, waits for results and validates them
- Launches local decode job
- Sends final response
Note
No sidecar or coordination logic is needed on the prefill or encode nodes.
- Decode Worker: Prefer longest prefix match / kv-cache utilization (depends on available scorers) and low load
- Prefill Worker: Same scoring criteria as decode
- Encode Worker: Selected when multimodal content is detected in the request
Skip prefill when:
- Prefix match / kv-cache hit is high
- Prompt is very short
Skip encode when:
- Request contains no multimodal content (text-only)
- Encode decider rejects the request
- Slight increase in TTFT for disaggregated P/D and E/P/D
- Additional network hops for E/P/D (encode → prefill → decode)
- Possibility of stranded memory on prefill crash
- The need for timeout and retry logic
- Flexibility: Enables per-request specialization and resource balancing
- Scalability: Clean separation of concerns for easier ops and tuning
- Upstream-ready: Follows GIE-compatible request handling
- Minimal Changes: Only decode node includes orchestration sidecar
- Cache coordination (we can talk about 3 different types of cache: KV-cache, embeddings, and multimedia content)
- Pre-allocation of kv blocks in the decode node, push cache from the prefill to the decode worker during calculation
- More sophisticated encode worker selection (e.g., load-aware scheduling, cache content, locality-aware placement)
The llm-d Router supports integration with external disaggregated encode/prefill/decode (E/P/D) workloads or other inference frameworks that follow the same E/P/D separation pattern but use different Kubernetes Pod labeling conventions.
By default, llm-d uses the label key llm-d.ai/role with values:
"encode"→ encode-only pods (multimodal encoding)"prefill"→ prefill-only pods"decode"→ decode-capable pods"encode-prefill"→ pods capable of both encode and prefill (EP/D or P/D)"encode-decode"→ pods capable of both encode and decode (E/PD, rare)"prefill-decode"→ pods capable of both prefill and decode"encode-prefill-decode"→ pods capable of all three stages"both"→ deprecated (use"prefill-decode"instead)
However, external systems may use alternative labels like:
role: encode
role: prefill
role: decodeTo accommodate this without code changes, you can configure the EndpointPickerConfig to use the generic label-selector-filter plugin instead of the hardcoded encode-filter / prefill-filter / decode-filter.
Note
The previous filter type by-label is deprecated. Use label-selector-filter with standard Kubernetes label selector syntax instead.
Below is a minimal EndpointPickerConfig for P/D disaggregation using custom labels:
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins:
# Prefill selection: match Pods with label role=prefill
- type: label-selector-filter
name: "prefill-pods"
parameters:
matchExpressions:
- key: "role"
operator: In
values: ["prefill"]
# Decode selection: match Pods with label role=decode
- type: label-selector-filter
name: "decode-pods"
parameters:
matchExpressions:
- key: "role"
operator: In
values: ["decode"]
- type: prefix-cache-scorer
parameters:
autoTune: false
blockSizeTokens: 5
maxPrefixBlocksToMatch: 256
lruCapacityPerServer: 31250
- type: max-score-picker
- type: prefix-based-pd-decider
parameters:
nonCachedTokens: 8
promptTokens: 0
- type: disagg-profile-handler
parameters:
profiles:
prefill: prefill
decode: decode
deciders:
prefill: prefix-based-pd-decider
schedulingProfiles:
- name: prefill
plugins:
- pluginRef: "prefill-pods"
- pluginRef: "max-score-picker"
- pluginRef: "prefix-cache-scorer"
- name: decode
plugins:
- pluginRef: "decode-pods"
- pluginRef: "max-score-picker"
- pluginRef: "prefix-cache-scorer"Below is an EndpointPickerConfig for full E/P/D disaggregation using custom labels:
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins:
# Encoding selection: match Pods with label role=encode
- type: label-selector-filter
name: "encode-pods"
parameters:
matchExpressions:
- key: "role"
operator: In
values: ["encode"]
# Prefill selection: match Pods with label role=prefill
- type: label-selector-filter
name: "prefill-pods"
parameters:
matchExpressions:
- key: "role"
operator: In
values: ["prefill"]
# Decode selection: match Pods with label role=decode
- type: label-selector-filter
name: "decode-pods"
parameters:
matchExpressions:
- key: "role"
operator: In
values: ["decode"]
- type: prefix-cache-scorer
parameters:
autoTune: false
blockSizeTokens: 5
maxPrefixBlocksToMatch: 256
lruCapacityPerServer: 31250
- type: max-score-picker
- type: always-disagg-multimodal-decider
- type: prefix-based-pd-decider
parameters:
nonCachedTokens: 8
promptTokens: 0
- type: disagg-profile-handler
parameters:
profiles:
encode: encode
prefill: prefill
decode: decode
deciders:
encode: always-disagg-multimodal-decider
prefill: prefix-based-pd-decider
schedulingProfiles:
- name: encode
plugins:
- pluginRef: "encode-pods"
- name: prefill
plugins:
- pluginRef: "prefill-pods"
- pluginRef: "max-score-picker"
- pluginRef: "prefix-cache-scorer"
- name: decode
plugins:
- pluginRef: "decode-pods"
- pluginRef: "max-score-picker"
- pluginRef: "prefix-cache-scorer"TODO: add E/P/D diagram
Deciders are handler plugins responsible for determining whether a disaggregated stage should be executed for a given request.
PD deciders determine whether prefill should be offloaded to a separate worker, based on the properties of the request prompt.
The prefix-based-pd-decider plugin makes the disaggregation decision according to the length of the non-cached suffix of the prompt relative to tokens already cached on the selected decode pod.
How It Works
-
Once a decode pod is selected, the decider checks how many tokens from the incoming prompt have already been sent to this pod
-
If the prompt length is shorter than the configured prompt length threshold (promptTokens), the full request runs locally on the decode worker without remote prefill
-
If the remaining non-cached suffix length is at least the configured threshold (nonCachedTokens), disaggregation is triggered: the prefill will run remotely on a prefill pod, and decode locally on the decode pod
-
If the non-cached suffix is shorter than the threshold, the full request runs locally on the decode worker without remote prefill
Configuration
- type: prefix-based-pd-decider
parameters:
nonCachedTokens: 8
promptTokens: 0Parameter:
nonCachedTokens: Number of non-cached tokens that trigger disaggregation- If set to 0, disaggregation never occurs for any request
promptTokens: Minimum prompt length in tokens before prefix-cache-based disaggregation logic is applied- If set to 0, the prompt-length gate is disabled
- If set to a positive value, requests with fewer prompt tokens run locally on the decode worker without remote prefill
The always-disagg-pd-decider is a simpler alternative used mainly for testing or benchmarking.
It always triggers disaggregation, regardless of prefix cache state or prompt characteristics.
Configuration example:
- type: always-disagg-pd-deciderNote
This plugin accepts no parameters.
It’s useful for validating end-to-end prefill/decode splitting and comparing system performance under forced disaggregation.
Encode deciders determine whether multimodal encoding should be offloaded to dedicated encode workers.
The always-disagg-multimodal-decider triggers encode disaggregation whenever the request contains multimodal content (images, video, or audio). Text-only requests are never sent to encode workers.
Configuration example:
- type: always-disagg-multimodal-deciderNote
This plugin accepts no parameters.
It checks for the presence of image_url, audio_url, video_url, or input_audio content blocks in the chat-completions request body. If any multimodal content is found, the encode stage is activated.
The disagg-profile-handler plugin is the entry point for all disaggregation topologies. Active stages are determined by which deciders are configured.
profiles(optional): names of the scheduling profiles to use.decode(default:decode)prefill(default:prefill)encode(default:encode)
deciders(optional): decider plugins that control whether each stage runs.prefill: enables P/D disaggregation when set.encode: enables E disaggregation when set.
No deciders are configured -- all requests are handled by the decode profile alone.
- type: disagg-profile-handler- type: disagg-profile-handler
parameters:
deciders:
prefill: prefix-based-pd-deciderCustom profile names (if your scheduling profiles are not named decode/prefill):
- type: disagg-profile-handler
parameters:
profiles:
decode: my-decode
prefill: my-prefill
deciders:
prefill: prefix-based-pd-decider- type: disagg-profile-handler
parameters:
deciders:
encode: always-disagg-multimodal-decider- type: disagg-profile-handler
parameters:
deciders:
prefill: prefix-based-pd-decider
encode: always-disagg-multimodal-deciderThe decode sidecar proxy is responsible for coordinating KV cache transfers between vLLM instances during disaggregated inference. It must be configured with the correct connector protocol matching the vLLM kv_connector used on the serving pods.
Specifies which KV transfer protocol the sidecar uses to coordinate prefill/decode disaggregation. This flag corresponds to the vLLM-side kv_connector value set in --kv-transfer-config on the serving pods, but uses its own naming convention.
--kv-connector value |
vLLM kv_connector |
Description |
|---|---|---|
nixlv2 (default) |
NixlConnector |
NIXL-based KV transfer using RDMA/GPU-direct |
shared-storage |
SharedStorageConnector |
KV transfer via shared filesystem |
sglang |
— | SGLang disaggregation protocol |
mooncake |
MooncakeConnector |
Mooncake KV transfer using RDMA |
offloading |
OffloadingConnector |
KV transfer over the vLLM CPU offloading tier. The decoder pulls KV from the prefiller via the p2p secondary tier. |
With offloading, the sidecar dispatches prefill and decode concurrently. It
injects role-keyed kv_transfer_params, each key named for the remote party it
describes: the prefiller receives {"remote_decoder": {"kv_request_id": <id>}}
(no peer address), and the decoder receives {"remote_prefiller": {"kv_request_id": <id>, "remote_host": <prefiller host>, "remote_port": <p2p-connector-port>}} so it can pull KV from the prefiller. The prefiller host
comes from the x-prefiller-host-port header; the port is
--p2p-connector-port.
When the request also carries the x-kv-cache-source-host-port header (set by
the EPP p2p-source-producer to a peer holding more cached prefix than the pod
computing the prefix), the sidecar injects an additional remote_kv_source key
so vLLM pulls that cached prefix over the P2P tier instead of recomputing it.
Under disaggregation the prefiller leg carries {"remote_decoder": {...}, "remote_kv_source": {"kv_request_id": <own id>, "remote_host": <source host>, "remote_port": <p2p-connector-port>}} (the only supported multi-key
combination); without a prefiller the decoder-only request carries
{"remote_kv_source": {...}} alone. A malformed or disallowed source header is
ignored and the request proceeds unchanged, as is any source header on a
connector that cannot pull over the P2P tier: only offloading, or NIXLv2 with
--enable-p2p-pull, honors it. For the pulled blocks to be servable, the source
pod must offload its generated (decode-phase) KV: set offload_prompt_only: false in its kv_connector_extra_config (the default true offloads only
prefill blocks).
Both prefill and decode pods require the following --kv-transfer-config:
{
"kv_connector": "OffloadingConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {
"spec_name": "TieringOffloadingSpec",
"cpu_bytes_to_use": <bytes>,
"secondary_tiers": [{"type": "p2p", "host": "<POD_IP>", "port": <p2p-connector-port>}]
}
}host must be the pod's own IP at runtime (use the Kubernetes downward API env var status.podIP). port must match --p2p-connector-port (default 7777). cpu_bytes_to_use controls the CPU KV offload buffer size; size it to hold the KV for the expected concurrent in-flight transfers. OffloadingConnector is available in vLLM nightly builds from 2026-06-30 onward (commit bec232a, PR #42285).
Restriction: --kv-connector=offloading requires --data-parallel-size=1. Wide-EP pods (DP > 1) are rejected at startup: every DP rank would bind the same POD_IP:<p2p-connector-port>. DP-aware support is not yet implemented.
| Flag | Env var | Values | Default | Description |
|---|---|---|---|---|
--enable-tls |
— | prefiller, decoder, encoder (comma-separated or repeated) |
none | Enable TLS for the specified stages. Example: --enable-tls=prefiller,decoder |
--tls-insecure-skip-verify |
— | prefiller, decoder, encoder (comma-separated or repeated) |
none | Skip TLS certificate verification for the specified stages. Example: --tls-insecure-skip-verify=prefiller |
--enable-prefiller-sampling |
ENABLE_PREFILLER_SAMPLING |
true / false |
false |
If true, the prefill instance is selected randomly from the provided prefill host values. |
--enable-ssrf-protection |
— | true / false |
false |
Enable SSRF protection using InferencePool allowlisting. |
| Connector | Flag | Env var | Default | Description |
|---|---|---|---|---|
mooncake |
--mooncake-bootstrap-port |
MOONCAKE_BOOTSTRAP_PORT |
8998 |
Port used to query the Mooncake bootstrap endpoint on prefill pods. Corresponds to vLLM's VLLM_MOONCAKE_BOOTSTRAP_PORT. |
sglang |
— | SGLANG_BOOTSTRAP_PORT |
8998 |
Port used for the SGLang bootstrap endpoint on prefill pods. |
offloading |
--p2p-connector-port |
P2P_CONNECTOR_PORT |
7777 |
Prefiller's OffloadingConnector P2P tier listening port, injected as remote_port on the decode leg so the decoder can pull KV. |
nixlv2 |
--enable-p2p-pull |
— | false |
Declare the OffloadingConnector P2P tier available for cached-prefix pulls when the PD connector is NIXLv2, i.e. the engines run MultiConnector(NixlConnector + OffloadingConnector). NIXL moves KV prefill to decode while the OffloadingConnector pulls the cached prefix named by x-kv-cache-source-host-port. Rejected at startup with any other connector; offloading provides the tier natively and needs no flag. |
- vLLM: Disaggregated Prefill
- vLLM: Encode Prefill Decode Disaggregation Design
- vLLM: Disaggregated Encoder
- vLLM: [RFC]: Prototype Separating Vision Encoder to Its Own Worker
- vLLM: Encoder Disaggregation for Scalable Multimodal Model Serving
- Mooncake: MooncakeConnector Usage Guide
- vLLM: OffloadingConnector / P2P secondary tier (PR #42285)
