Disaggregated Prefill/Decode Serving¶
Continuum Router supports disaggregated inference, where the prefill phase (prompt processing) and the decode phase (token generation) run on separate GPU workers. The serving engine transfers KV blocks computed during prefill directly between workers over its own transport, such as NIXL in a Dynamo deployment. The router never touches tensor bytes; it selects the prefill worker, the decode worker, and the routing path, then reports the decision in the response headers.
Overview¶
In standard (unified) inference, a single GPU worker handles both phases of a request:
- Prefill: compute key-value attention tensors for the full input prompt
- Decode: autoregressively generate output tokens, reading the KV cache computed in step 1
Disaggregated serving separates these phases across specialized workers:
- Prefill workers: high-throughput GPUs optimized for batch KV computation
- Decode workers: GPUs holding warm KV caches optimized for low-latency token generation
- Engine-owned KV transfer: the serving engine moves KV blocks from the prefill worker to the decode worker over NIXL, NCCL, or its storage tier, while the router selects which two workers participate
This separation is particularly effective when:
- Many requests share the same long system prompt (e.g., RAG documents, tool definitions)
- Prefill and decode workloads have different GPU memory requirements
- Token generation latency is the primary optimization target
Request Flow¶
The DisaggregatedOrchestrator selects a routing path once per incoming chat completion request, before the streaming (stream: true) and buffered pipelines diverge. It passes the chosen backend to the standard dispatch pipeline as a single-element allow-list, so every downstream check (health, circuit breaker, per-backend rate limit, and Hub credential binding) still applies.
The orchestrator queries the KV index using the 64-character hex SHA-256 prefix key, matching the key published by the KV listener. Because that key is computed only when prefix_routing.enabled is true, fast-decode routing requires prefix routing to be enabled. When prefix routing is off, the orchestrator skips the KV index lookup and proceeds directly to prefill-then-decode selection.
Routing Paths¶
| Path | Description | Response Header Value |
|---|---|---|
FastDecode | KV data already on a decode worker's GPU (GpuHot), or offloaded by the engine (StorageWarm) and reloadable by the least-loaded healthy decode worker | fast_decode |
PrefillThenDecode | A prefill worker and a decode worker are selected for a full prefill-then-decode cycle | prefill_then_decode |
Unified | A Dynamo shim dispatch with no prefill worker for the model (the aggregated fallback): one worker serves both phases | unified (Dynamo shim only) |
Fallback | Disaggregated backends unavailable; fell back to unified serving, or answered the 503 error envelope when fallback_to_unified is off | fallback |
The selected routing path is reported in the X-Continuum-Routing-Path response header. The generic prefill-then-decode wire protocol is not yet implemented in this release: when an engine-specific picker is not active, that path selects both workers and then returns 503 disaggregated_prefill_decode_unavailable or falls back to unified serving.
The unified path value is written only by Dynamo shim dispatch when no prefill worker serves the model and the router pins a decode or aggregated worker instead. This path requires a binary built with the dynamo feature and a configured dynamo.shim.backends pool.
When no backend is configured with a prefill or decode role, the orchestrator bypasses disaggregated routing before writing any headers or metrics, and the request proceeds through standard unified serving.
Response Headers¶
| Header | Description | Example |
|---|---|---|
X-Continuum-Routing-Path | Routing path taken; always written | prefill_then_decode |
X-Continuum-Prefill-Backend | Backend that executed the prefill phase; only when the request carried X-Backend | prefill-worker-1 |
X-Continuum-Decode-Backend | Backend that executed the decode phase; only when the request carried X-Backend | decode-worker-2 |
X-Served-Backend | Backend that answered; only when the request carried X-Backend | decode-worker-2 |
Backend name headers follow the router-wide X-Served-Backend disclosure policy: they are included only when the client sends an X-Backend request header, preventing unintended topology disclosure. Sending an empty X-Backend header opts in to backend name headers without expressing a routing preference.
This disclosure policy applies equally to buffered and streaming responses. X-Continuum-Routing-Path identifies a routing path rather than a backend and is always written when disaggregated routing runs.
Backend Roles¶
Each backend in the configuration can be assigned a role:
| Role | Description |
|---|---|
unified | Default. Backend handles both prefill and decode phases. Participates in all routing paths. |
prefill | Backend handles prefill computation only. Ineligible for decode-only routing. |
decode | Backend handles token generation only. Ineligible for prefill routing. |
When disaggregated serving is enabled, the backend pool registers RoleFilterScorer to enforce role assignment. This scorer assigns f64::NEG_INFINITY to backends that are incompatible with the active inference phase, while keeping unified backends eligible for all phases.
Because RoleFilterScorer reads roles from the live pool configuration, hot-reloading a backend's role (with the same name and URL) takes effect on the next request without draining or rebuilding the backend.
Hot-reloading models, weight, or request_extensions drains and rebuilds the pooled backend while preserving its engine-statistics snapshot. Changing a backend's URL resets both the engine-statistics snapshot and any control-plane capacity state.
KV Transfer¶
The serving engine manages KV transfer between the prefill worker and the decode worker. Dynamo deployments transfer KV blocks over NIXL, while other engines use NCCL or their own storage tier. The router never reads, writes, or addresses tensor data.
During a PrefillThenDecode flow, the router:
- Selects the least-loaded healthy prefill worker and the least-loaded healthy decode worker for the request
- Reports both workers in
X-Continuum-Prefill-BackendandX-Continuum-Decode-Backendwhen the request includesX-Backend - Delegates the KV transfer itself to the engine
Legacy external storage fields¶
The disaggregated_serving.default_external_storage and per-backend external_storage fields, along with the KvReference type ({endpoint}/{kv_namespace}/{prefix_hash}, storage path, prefix hash, token count, and tensor format), are retained for configuration compatibility and documented under Configuration Reference. No supported engine consumes that reference, and the request path ignores these fields.
Configuration¶
Top-Level Disaggregated Serving¶
disaggregated_serving:
enabled: false # Enable disaggregated prefill/decode serving
prefill_timeout: "30s" # Timeout for the prefill phase
kv_transfer_timeout: "10s" # Reserved; the engine owns the KV transfer
fallback_to_unified: true # Fall back to unified when disaggregated unavailable
# Legacy: default external storage for backends that do not specify their
# own. Accepted for compatibility; the request path does not read it.
default_external_storage:
endpoint: "http://storage-cluster:8080"
kv_namespace: "inference/kv-cache"
# credentials: "${STORAGE_CREDENTIALS}" # Optional, a single string
Per-Backend Role Assignment¶
Add role to each backend (external_storage is a legacy field, accepted but not read):
backends:
# Prefill worker - computes KV tensors
- name: prefill-worker-1
url: "http://vllm-prefill-1:8000"
role: prefill
# Decode workers - generate tokens using cached KV data
- name: decode-worker-1
url: "http://vllm-decode-1:8000"
role: decode
weight: 2
- name: decode-worker-2
url: "http://vllm-decode-2:8000"
role: decode
weight: 2
# Unified fallback backend (optional)
- name: unified-fallback
url: "http://vllm-unified:8000"
role: unified # or omit - unified is the default
Minimal Example¶
Prefix routing must be enabled for fast-decode routing to query the KV index; without it, every request uses prefill-then-decode selection.
prefix_routing:
enabled: true
kv_cache_index:
enabled: true
disaggregated_serving:
enabled: true
backends:
- name: prefill-gpu
url: "http://vllm-prefill:8000"
role: prefill
- name: decode-gpu-1
url: "http://vllm-decode-1:8000"
role: decode
- name: decode-gpu-2
url: "http://vllm-decode-2:8000"
role: decode
Configuration Reference¶
disaggregated_serving¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable disaggregated serving |
prefill_timeout | string | "30s" | Timeout for the prefill phase (supports ms, s, m suffixes) |
kv_transfer_timeout | string | "10s" | Reserved. The KV transfer between workers is engine-owned, and the request path does not read this value |
fallback_to_unified | bool | true | Fall back to unified serving when disaggregated backends are unavailable |
default_external_storage | object | null | Legacy. Default external storage config used by backends that do not define their own; accepted for compatibility, not read by the request path |
external_storage (per-backend, legacy)¶
Accepted for configuration compatibility with the earlier external-storage transfer design. The request path does not read these fields.
| Field | Type | Default | Description |
|---|---|---|---|
endpoint | string | required | External storage endpoint URL |
kv_namespace | string | "inference/kv-cache" | Namespace path for KV tensors |
credentials | string | null | Optional access credential, a single string (redacted in logs and debug output) |
role (per-backend)¶
| Value | Description |
|---|---|
unified | Default. Backend participates in both prefill and decode routing. |
prefill | Backend receives prefill-phase requests only. |
decode | Backend receives decode-phase requests only. |
Metrics¶
The global /metrics endpoint exports the disaggregated_ Prometheus metric family. For both buffered and streaming requests, disaggregated_requests_total increments once per request handled on a disaggregated path and does not increment when a request falls back to unified serving.
| Metric | Type | Labels | Incremented when |
|---|---|---|---|
disaggregated_requests_total | counter | routing_path = fast_decode, prefill_then_decode, unified, fallback | A request is answered on a disaggregated path, including the fallback error envelope, a streaming client error relayed from the routed backend, and a streaming failure returned with fallback_to_unified off (both labelled with the routed path) |
disaggregated_fallback_total | counter | none | fallback_to_unified sends a request to unified serving instead |
disaggregated_errors_total | counter | phase = prefill, decode, kv_transfer, orchestration | decode: the routed dispatch failed or a different backend served it (on the streaming arm a 4xx other than 408 and 429 is the request's own answer and does not count); orchestration: no disaggregated path exists (no healthy prefill or decode worker, or the prefill-then-decode wire protocol is unavailable) |
Label values outside the defined sets are recorded as unknown to bound metric cardinality. Because routing_path="unified" is emitted only by the Dynamo shim's aggregated fallback (see Routing Paths), deployments without a shim pool will never observe that label value.
The duration histograms disaggregated_prefill_duration_seconds{backend}, disaggregated_decode_duration_seconds{backend}, and disaggregated_kv_transfer_duration_seconds are registered for engine-specific pickers and are not populated by the generic orchestrator.
Integration with KV Cache Index¶
Disaggregated serving works alongside the KV Cache Index (Tier 4). The KV index tracks which decode workers hold warm GPU caches for specific prefix keys, allowing the orchestrator to skip the prefill phase when the prefix is already resident in a decode worker's GPU memory (GpuHot tier).
When storage_offloading.enabled is true in the KV cache index configuration, the orchestrator can also route to decode workers holding data in the StorageWarm tier and let the engine reload it on demand.
Backend Selection for Load Balancing¶
Within each phase, the orchestrator selects the least-loaded healthy backend using live pool state:
- Candidates: user-routable backends (excluding
internal: trueandenabled: false), filtered by the API key's allow-list and then byRoleFilterScorerfor the active phase. - Health: the health checker's current status per backend. Unhealthy workers are excluded; when health checks are disabled, all backends are treated as healthy.
- Load: the pool's active in-flight request gauge, shared with the Admin API and the CHWBL load cap. Ties are broken by configuration order.
- Engine statistics: when the
engine_statspoller has a fresh snapshot,waiting_requestsandkv_cache_usageare attached to each candidate for engine-aware pickers, while the default least-loaded selection uses the in-flight request count.
This keeps GPU utilization balanced independently across the prefill and decode worker pools.
Fallback Behavior¶
When fallback_to_unified: true (the default), the router falls back to unified serving when no disaggregated path is available or when the routed dispatch fails:
- Unified role filtering: Fallback requests are restricted to user-routable
unifiedbackends that the caller is permitted to reach. Prefill-only and decode-only workers are excluded from the fallback pass, and anyx-backendheader naming a role-specific worker is ignored. - Failed backend exclusion: The backend whose routed dispatch just failed is excluded from the fallback retry while other candidates remain. Dynamo shim pools are an exception: because a shim backend represents a pool of worker replicas rather than a single worker, it remains eligible.
- Readiness check: The
unified-only restriction applies only when at least oneunifiedbackend serves the model and passes current health, circuit-breaker, per-backend rate-limit, and Hub credential checks. Otherwise, the router falls back to the standard backend pool without role filtering (still excluding the failed backend unless it is the sole candidate). - Scope and allow-list intersection: Fallback filtering narrows the candidate set by intersecting with the API key's
allowed_backends, Hub routing decisions, and model-experiment pins; it never widens permissions. Model fallback hops (fallback.fallback_chains) evaluate the caller's original allow-list, so chain targets served only by role workers remain reachable. - Observability: Fallback responses report
fallbackinX-Continuum-Routing-Pathand incrementdisaggregated_fallback_total.
The readiness check in step 3 is a point-in-time snapshot taken before unified dispatch and does not evaluate the engine admission gate (routing.engine_load.admission). If a unified backend passes the snapshot check but fails or is rejected during dispatch, the request returns that selection or 5xx error (or advances a configured fallback chain) without retrying role-specific workers.
Behavior when fallback_to_unified: false¶
When fallback_to_unified is false, failed requests return a 503 error envelope (disaggregated_unavailable, disaggregated_prefill_decode_unavailable, disaggregated_backend_mismatch, or disaggregated_decode_failed) with X-Continuum-Routing-Path: fallback. If a selected decode worker fails on a streaming request before the stream starts, the error response preserves the attempted routing path in X-Continuum-Routing-Path.
Streaming vs. buffered error handling¶
On the streaming path, only transport failures, 5xx responses, 429, and 408 before stream start are treated as decode failures that trigger unified fallback. 429 and 408 reflect transient capacity or rate-limit state on the pinned backend, so another backend can still serve the request.
Any other 4xx response on the streaming path (such as a 400 validation error, 403, or 413) is returned directly to the client under the routed path and is not counted as a decode error. On the buffered path, any failed dispatch, including a 4xx response, is treated as a decode failure.
Guardrails interaction¶
When guardrails are configured, input evaluation runs once in the chat input gate before disaggregated routing begins. If a disaggregated dispatch fails and falls back to unified serving, the retry reuses the original input decision without running a second guardrail input check. On the buffered path, output guardrails evaluate whatever final response is returned to the client, including unified fallback responses.
Deployment Recommendations¶
- GPU allocation: Prefill workers benefit from high memory bandwidth; decode workers benefit from large GPU memory for KV cache residence.
- KV transfer sizing: Estimate the KV size the engine moves per request as
2 * num_layers * num_heads * head_dim * seq_len * 2 bytes(fp16). For a 7B model with 32 layers and 4096-token context: ~256 MB per request, which is what the inter-worker fabric (NIXL over RDMA, NCCL) must sustain. - Health checks: Configure backend health checks to detect GPU OOM and driver errors quickly so unhealthy phase workers leave the eligible candidate set.
- Decode pool size: More decode workers reduce queuing latency for the token generation phase. A 3:1 decode-to-prefill ratio is a common starting point.