This document explains how Dynamo’s Key-Value (KV) cache routing optimizes large language model inference by intelligently directing requests to workers with the most relevant cached data, while maintaining load balance through worker utilization metrics.
To enable KV cache aware routing start the frontend node like this:
When KV blocks are created or removed, the engine notifies the Dynamo router, which then identifies the worker with the best matching blocks and routes traffic accordingly.
To evaluate the benefits of KV-aware routing, compare your workload’s performance using --router-mode random|round-robin against KV-aware routing.
The main KV-aware routing arguments:
--kv-overlap-score-weight: Controls the importance of prefix cache overlaps in prefill cost calculations. Higher values improve Time To First Token (TTFT) at the cost of Inter-Token Latency (ITL). When set to 0, the router ignores prefix caches and uses pure load balancing. Defaults to 1.
--router-temperature: Controls worker selection randomness through softmax sampling of router cost logits. A value of 0 (default) ensures deterministic selection of the lowest-cost worker, while higher values introduce more randomness.
--no-kv-events: Disables KV event tracking. By default (when this flag is not provided), the router uses KV events to monitor block creation and deletion from workers. When disabled with this flag, the router predicts cache state based on routing decisions with TTL-based expiration (default 120s) and pruning. Use this flag if your backend doesn’t support KV events (or you are not confident in the accuracy or responsiveness of the events).
--router-replica-sync: Disabled by default. Enables NATS-based synchronization of local routing decisions between router replicas. When enabled, routers share their active sequence information and local predictions of block usage, improving routing consistency across instances. Note that this does not sync the radix tree or cached KV block states themselves - those are synchronized through JetStream events
--router-reset-states: When specified, resets the router state on startup by clearing both the JetStream event stream and NATS object store, starting with a fresh state. By default (when this flag is not provided), the router persists state across restarts, downloading any available snapshot from NATS object store and continuing to consume events from where it left off. This enables routers to maintain KV cache awareness across restarts. Warning: Using --router-reset-states can bring existing router replicas into an inconsistent state. Only use this flag when launching the first router replica in a component, or consider using a different namespace/component for a clean slate.
--router-snapshot-threshold: Sets the number of messages in the JetStream before triggering a snapshot. When the message count exceeds this threshold, a router will attempt to purge acknowledged messages from the stream and create a snapshot of the current radix tree state in NATs object store. Defaults to 1000000. This helps manage stream size and provides faster initialization for routers that restart.
--no-track-active-blocks: Disables tracking of active blocks (blocks being used for ongoing generation/decode phases). By default, the router tracks active blocks for load balancing. Disable this when routing to workers that only perform prefill (no decode phase), as tracking decode load is not relevant. This reduces router overhead and simplifies state management.
--active-decode-blocks-threshold: Initial threshold (0.0-1.0) for determining when a worker is considered busy based on KV cache block utilization. When a worker’s KV cache active blocks exceed this percentage of total blocks, it will be marked as busy and excluded from routing. If not set, blocks-based busy detection is disabled. This feature works with all routing modes (--router-mode kv|round-robin|random) as long as backend engines emit ForwardPassMetrics. The threshold can be dynamically updated at runtime via the /busy_threshold HTTP endpoint (see Dynamic Threshold Configuration).
--active-prefill-tokens-threshold: Literal token count threshold for determining when a worker is considered busy based on prefill token utilization. When active prefill tokens exceed this threshold, the worker is marked as busy. If not set, tokens-based busy detection is disabled.
--router-ttl: Time-to-live in seconds for blocks in the router’s local cache predictions. Blocks older than this duration will be automatically expired and removed from the router’s radix tree. Defaults to 120.0 seconds when --no-kv-events is used. This helps manage memory usage by removing stale cache predictions that are unlikely to be accurate.
--router-max-tree-size: Maximum tree size (number of blocks) before pruning is triggered. When the total number of blocks in the radix tree exceeds this threshold, the router will prune the least recently used blocks. Defaults to 1048576 (2^20 blocks) when --no-kv-events is used. This prevents unbounded memory growth in long-running deployments.
--router-prune-target-ratio: Target size ratio to prune down to when --router-max-tree-size is exceeded. For example, with a value of 0.8 (default) and max tree size of 1048576, the router will prune down to approximately 838860 blocks when the threshold is exceeded. Defaults to 0.8 when --no-kv-events is used. This creates headroom before the next pruning cycle.
State persistence depends on the event transport mode:
--enable-local-indexer on workers): State persists on workers—router rebuilds state by querying workers on startup.--no-kv-events): State persistence is not supported.Request plane is independent of KV event transport.
DYN_REQUEST_PLANE controls how requests are sent (TCP/HTTP/NATS), but KV-aware routing still uses NATS for KV events in both JetStream and NATS Core + Local Indexer modes.
When KV events are enabled (default), NATS is automatically initialized. You can optionally set NATS_SERVER=nats://... to specify a custom NATS server; otherwise, it defaults to localhost:4222.
Use --no-kv-events to disable KV events and remove the NATS requirement entirely (with request plane being tcp or http).
When --kv-overlap-score-weight is set to 0, no KvIndexer is created and prefix matching is disabled (pure load balancing). When --no-kv-events is set, a KvIndexer is still created but no event subscriber is launched to consume KV events from workers. Instead, the router predicts cache state based on its own routing decisions with TTL-based expiration and pruning.
Backend Configuration: When using --no-kv-events, configure your backend workers to disable KV event publishing:
--kv-events-config '{"enable_kv_cache_events": false}'--kv-events-config--publish-events-and-metricsThe cli args --router-ttl, --router-max-tree-size, and --router-prune-target-ratio control local cache management when the router operates without receiving events from workers. When KV events are enabled (default), the router relies on worker-side eviction events and these parameters are ignored.
KV Router Requirements: The KV router currently works only with dynamic endpoints that are registered via register_llm() with model_input=ModelInput.Tokens. Your backend handler receives pre-tokenized requests with token_ids instead of raw text.
Current Limitations (WIP):
What this means for your setup:
register_llm() with model_input=ModelInput.Tokens (see Backend Guide or example implementations)token_ids, not raw text or multimodal inputs--static-endpoint mode with KV routing (use dynamic discovery instead)For basic model registration without KV routing, you can use --router-mode round-robin or --router-mode random with both static and dynamic endpoints.
Dynamo supports disaggregated serving where prefill (prompt processing) and decode (token generation) are handled by separate worker pools. When you register workers with ModelType.Prefill (see Backend Guide), the frontend automatically detects them and activates an internal prefill router.
The prefill router is automatically created when:
register_llm() with ModelType.Chat | ModelType.Completions)ModelType.PrefillKey characteristics of the prefill router:
track_active_blocks=false) since prefill workers don’t perform decodeWhen both workers are registered, requests are automatically routed.
The unified frontend with automatic prefill routing is currently enabled for vLLM and TensorRT-LLM backends. For SGLang (work in progress), you need to launch a separate standalone router as the prefill router targeting the prefill endpoints. See example script: examples/backends/sglang/launch/disagg_router.sh.
The following diagram shows an overview of the major components in disaggregated serving:
The KV-aware router operates on two key principles to optimize request routing:
KV events from engines are collected by the router to maintain a global view of cached blocks across all workers. The router supports two event transport modes:
KV events are sent to a persistent NATS JetStream. Each KV router/indexer replica acts as a durable consumer, pulling messages from this shared stream. This architecture ensures consistency across router replicas and persistence across restarts.
When workers are started with --enable-local-indexer, each worker maintains its own local radix tree (local indexer) and publishes events over NATS Core (fire-and-forget pub/sub) instead of JetStream. Each worker assigns monotonically increasing event IDs to its events. The router detects gaps in event sequences and recovers missed events by querying the worker’s local indexer directly.
--enable-local-indexer flag on workers (vLLM, mocker)How gap detection works:
event_id > last_id + 1, the router detects a gap[last_id+1, event_id-1]Startup behavior:
The router automatically selects the transport mode based on worker configuration. If all connected workers have enable_local_indexer=true, the router uses NATS Core mode. Otherwise, it uses JetStream mode.
Second, in addition to cached blocks, each router replica needs to track active blocks (blocks being used for ongoing generation) as load metrics. Since this information is highly time-sensitive, it should be predicted immediately when:
This is managed locally in each router via a “slot manager”. To maintain consistency across the system, router replicas synchronize these local predictions with each other through NATS core messaging.
This dual-layer approach—persistent global KV cache state via JetStream and ephemeral active block synchronization via router replicas—enables the system to make optimal routing decisions that balance cache reuse with load distribution.
Dynamo supports several routing strategies when sending requests from one component to another component’s endpoint.
First, we must create a client tied to a components endpoint, we can do this using the labels defined above. Here we are getting a client tied to the generate endpoint of the VllmWorker component.
We can then use the default routing methods exposed by the client class to send requests to the VllmWorker component.
client.generate() or client.random()client.round_robin()client.direct(input, component_id)KV Cache routing uses direct routing with a special worker selection algorithm.
For improved fault tolerance, you can launch multiple frontend + router replicas. Since the frontend and router are currently tied together, you’ll need to use different HTTP ports for each instance. (The separation of the frontend and Router is WIP.)
The KV Router tracks two types of state (see KV Router Architecture for details):
Prefix blocks (cached KV blocks): Maintained in a radix tree, tracking which blocks are cached on each worker. This state is persistent - backed by NATS JetStream events and object store snapshots. New router replicas automatically sync this state on startup, ensuring consistent cache awareness across restarts.
Active blocks (decoding blocks): Tracks blocks currently being used for active generation requests. This state is ephemeral - when a new router replica starts, it begins with zero active block knowledge but becomes eventually consistent as it handles requests.
The --router-replica-sync flag enables active block synchronization between replicas:
Without this flag, each replica maintains its own isolated view of active blocks, potentially leading to suboptimal routing.
Persistence behavior depends on which event transport mode is active:
JetStream Mode (default):
NATS Core with Local Indexer Mode:
If you need to start with a fresh state in JetStream mode, you have two options:
--router-reset-states flag, which will purge the entire stream and radix snapshot. This should only be done when launching the first router replica in a component, as it can bring existing router replicas into an inconsistent state.The leading Large Language Models (LLMs) today are auto-regressive and based off of the transformer architecture. One key inference optimization technique is to cache the already computed keys and values and to reuse them for the future tokens. This is called the KV Cache.
Every inference framework will have a KV Cache for each worker. A popular inference framework library is vLLM where a key contribution was PagedAttention, which allowed them to manage KV Cache in an efficient way by chunking requests into blocks.
Another popular inference framework, SGLang, contributed RadixAttention which introduced a prefix tree which allows for efficient matching, inserting and eviction of KV Cache blocks. The prefix tree structure popularized KV Cache reuse.
In Dynamo, we introduce a KVPublisher which emits KV Cache events that occur at each worker and a KVIndexer which keeps track of these events globally.
To get a feel for how KV Cache management works on a single worker with KV Cache reuse turned on and where the KVPublisher gets plugged in, we can walk through the KV Block management flow:
Further details can be found for: TRT-LLM, vLLM and SGLang.
KV Cache reuse introduces complexity to LLM serving load balancing. While it can significantly reduce computation costs, routing strategies that ignore worker-specific KV states can lead to:
The router uses a cost function that considers both the prefill cost (influenced by cached blocks) and the decode load to make optimal routing decisions:
Prefill blocks: Calculated by dividing the number of tokens requiring prefill processing by the block size. The system predicts this based on input tokens and available cached blocks per worker, updating the count when the first output token signals prefill completion.
Decode blocks: Estimated from the request’s input tokens and each worker’s active sequences. The count updates when requests complete and their blocks are freed.
Cost formula: cost = overlap_score_weight * prefill_blocks + decode_blocks
overlap_score_weight balances cache hit optimization against load distributionThe router selects the worker with the lowest cost. When router_temperature is set to a non-zero value, the router uses softmax sampling on the normalized cost logits to introduce randomness in the selection, which can help with load distribution.
Example calculation with overlap_score_weight = 1.0:
The KVPublisher can be initialized and then called in the inference framework where blocks are allocated and removed.
The two types of events are:
The publisher can be initialized and used through C bindings or Python bindings.
Engines do not need to emit deterministic block identifiers in KV events, as the router uses local block hashes (computed from token content) for tracking and matching blocks across workers. However, it is strongly preferred that engines do emit deterministic block identifiers, as this keeps the KvIndexer’s internal lookup table smaller and more efficient. To ensure deterministic behavior, all workers should use identical engine versions/configuration. If your engine relies on Python’s builtin hash() for any event IDs, set PYTHONHASHSEED=0; otherwise this setting has no effect.
The KVIndexer builds and maintains a global view of cached blocks in a prefix tree. We modify the original prefix tree by also storing the worker id on each node. This is so we can return the number of matched blocks for each worker.
The KVIndexer has a method find_matches_for_request, which takes in tokens and returns a dictionary with keys of worker id and values of the number of matched KV Blocks.
In distributed deployments with multiple routers, each router maintains visibility over only a portion of the total requests. To ensure consistent routing decisions, routers synchronize their states through three event types:
AddRequest: Notifies other routers when a request is assigned to a worker. Includes request ID, worker ID, token sequence blocks, and overlap score to track block usage across the system.
MarkPrefillCompleted: Signals when a request moves from prefill to decode phase, allowing routers to update their worker load calculations by excluding completed prefill tokens.
Free: Indicates request completion and resource release, enabling accurate block reference counting across all routers.
Each event carries a unique router ID to prevent self-event processing. This asynchronous communication system ensures optimal routing decisions by maintaining consistent KV cache state across all routers, even as they handle different request streams.
Instead of launching the KV Router via command line, you can create a KvPushRouter object directly in Python. This allows per-request routing configuration overrides.
Multiple Routers in Same Process: If you need to run multiple KvPushRouter instances for fault tolerance or load distribution, you must launch them in separate processes (e.g., using python -m dynamo.frontend with different ports). Creating multiple KvPushRouter objects in the same Python process is not supported - they share the same cancellation token from the component’s primary lease, so dropping one router will cancel all routers in that process. For in-process routing, use a single KvPushRouter instance.
The KvPushRouter provides the following methods:
generate(token_ids, model, ...): Route and execute a request, returning an async stream of responses. Automatically handles worker selection, state tracking, and lifecycle management.
best_worker(token_ids, router_config_override=None, request_id=None): Query which worker would be selected for given tokens. Returns (worker_id, dp_rank, overlap_blocks).
request_id: Query-only, doesn’t update router staterequest_id: Updates router state to track the request. Note: If used with request_id, you must call mark_prefill_complete() and free() at the appropriate lifecycle points to maintain accurate load trackingbest_worker_id(token_ids, router_config_override=None, request_id=None): [DEPRECATED - use best_worker() instead] Query which worker would be selected for given tokens. Returns (worker_id, overlap_blocks).
request_id: Query-only, doesn’t update router staterequest_id: Updates router state to track the request. Note: If used with request_id, you must call mark_prefill_complete() and free() at the appropriate lifecycle points to maintain accurate load trackingget_potential_loads(token_ids): Get detailed load information for all workers, including potential prefill tokens and active decode blocks. Returns a list of load dictionaries.
mark_prefill_complete(request_id): Signal that a request has completed its prefill phase. Only used for manual lifecycle management when using best_worker_id() for manual routing instead of generate().
free(request_id): Signal that a request has completed and its resources should be released. Only used for manual lifecycle management when using best_worker_id() for manual routing instead of generate().
dump_events(): Dump all KV cache events from the router’s indexer as a JSON string. Useful for debugging and analysis.
First, launch your backend engines:
The KvPushRouter supports multiple usage patterns depending on your control requirements:
Call generate() directly and let the router handle everything:
Use best_worker_id(request_id=...) to select and track, then manage the request yourself:
mark_prefill_complete() and free() at correct lifecycle pointsQuery without state updates, then route through a chosen router:
Use get_potential_loads() to implement custom routing logic:
All patterns support router_config_override to adjust routing behavior per-request without recreating the router.
Here’s an example of using get_potential_loads() to implement custom routing that minimizes Time To First Token (TTFT) by selecting the worker with the least prefill work:
This approach gives you complete control over routing decisions, allowing you to optimize for different metrics based on your specific requirements. As some examples:
potential_prefill_tokensbest_worker_id() which considers both prefill and decode loadspotential_prefill_tokens and potential_decode_blocks togetherSee KV Router Architecture for performance tuning details.
The busy thresholds can be updated at runtime without restarting the frontend. The frontend exposes HTTP endpoints at /busy_threshold:
Get or set a model’s thresholds (POST):
List all configured thresholds (GET):