Router Routing
This page describes the router routing features and capabilities in Kthena, based on real-world examples and configurations.
Overview
Kthena Router provides sophisticated traffic routing capabilities that enable intelligent forwarding of inference requests to appropriate backend models. The routing system is built around two core Custom Resources (CRs):
- ModelServer: Defines backend inference service instances with their associated pods, models, and traffic policies. It uses workloadSelector.matchLabels to select which pods belong to it (pods must match all given labels); this applies to every ModelServer.
- ModelRoute: Defines routing rules based on request characteristics such as model name, LoRA adapters, HTTP headers, and weight distribution
For a detailed definition of the ModelServer and ModelRoute CRs, please refer to the ModelServer and ModelRoute Reference pages.
The router supports various routing strategies, from simple model-based forwarding to complex weighted distribution, header-based routing, and PD-Disaggregated (Prefill/Decode) routing. This flexibility allows for advanced deployment patterns including canary releases, A/B testing, load balancing across heterogeneous model deployments, and PD-Disaggregated inference for lower latency and better resource utilization.
Preparation
Before diving into the routing configurations, let's set up the environment and understand the prerequisites. All the configuration examples in this document can be found in the examples/kthena-router directory of the Kthena repository.
Environment Setup
To simplify deployment and reduce the requirements for demonstration environments, we use a mock LLM server instead of deploying real models. This mock server implements the vLLM standard interface and returns mock data, making it perfect for testing and learning purposes.
Prerequisites
- Kubernetes cluster with Kthena installed
- Access to the Kthena examples repository
- Basic understanding of router CRDs (ModelServer and ModelRoute)
Getting Started
-
Deploy mock LLM inference engine if you do not have a real GPU/NPU environment at the moment. mock deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B and mock deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
-
Deploy all kinds of ModelServer, such as
deepseek-r1-1-5b,deepseek-r1-7b, etc., as backends of different routing strategies. -
All routing examples in this guide use these mock services, so you can experiment with different routing strategies without the overhead of real model deployment.
Routing Scenarios
1. Simple Model-Based Routing
Scenario: Direct all requests for a specific model to a single backend service.
Traffic Processing: When a request comes in for model "deepseek-r1", the router matches this criterion and forwards all traffic to the 1.5B ModelServer. This is the most straightforward routing pattern.
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: deepseek-simple
namespace: default
spec:
modelName: "deepseek-simple"
rules:
- name: "default"
targetModels:
- modelServerName: "deepseek-r1-1-5b"
Flow Description:
- Request arrives for model name "deepseek-r1"
- Router matches the modelName field in the ModelRoute
- 100% of traffic is directed to
deepseek-r1-1-5b - The ModelServer serves requests using vLLM inference engine with 10s timeout
Try it out:
export MODEL="deepseek-simple"
curl http://$ROUTER_IP/v1/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"prompt\": \"San Francisco is a\",
\"temperature\": 0
}"
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B!"}],"created":1756366365,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":218,"prompt_tokens":1,"time":0.0,"total_tokens":219}}
2. LoRA-Aware Routing
Scenario: Route requests requiring specific LoRA adapters to specialized ModelServers optimized for LoRA workloads.
Traffic Processing: When a request specifies LoRA adapters (lora-A or lora-B), the router routes it to ModelServers configured to handle these specific adapters.
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: deepseek-lora
namespace: default
spec:
loraAdapters:
- "lora-A"
- "lora-B"
rules:
- name: "lora-route"
targetModels:
- modelServerName: "deepseek-r1-1-5b"
Flow Description:
- Request arrives with LoRA adapter requirement (
lora-Aorlora-B) - Router matches the LoRA adapter against the supported list
- Routes to
deepseek-r1-1-5bModelServer configured for LoRA workloads - ModelServer efficiently handles LoRA adapter loading and inference
Try it out:
export MODEL="lora-A"
curl http://$ROUTER_IP/v1/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"prompt\": \"San Francisco is a\",
\"temperature\": 0
}"
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from lora-A!"}],"created":1756366636,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"lora-A","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":120,"prompt_tokens":1,"time":0.0,"total_tokens":121}}
3. Weight-Based Traffic Distribution
Scenario: Gradually roll out new model versions by splitting traffic between different versions using weighted distribution.
Traffic Processing: The router uses weighted round-robin to distribute requests. For every 100 requests, approximately 70 will go to version 1 and 30 to version 2. This allows safe validation of new model versions with controlled risk.
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: deepseek-subset
namespace: default
spec:
modelName: "deepseek-subset"
rules:
- name: "deepseek-r1-route"
targetModels:
- modelServerName: "deepseek-r1-1-5b-v1"
weight: 70
- modelServerName: "deepseek-r1-1-5b-v2"
weight: 30
Flow Description:
- Request arrives for model "deepseek-r1"
- Router applies weighted distribution algorithm
- 70% of requests →
deepseek-r1-1-5b-v1(stable version) - 30% of requests →
deepseek-r1-1-5b-v2(new version being tested) - This enables controlled testing of new model versions
NOTE: This scenario need to deploy canary version of ModelServer and mock deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B to test.
Try it out:
export MODEL="deepseek-subset"
for i in $(seq 1 100);
do
curl http://$ROUTER_IP/v1/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"prompt\": \"San Francisco is a\",
\"temperature\": 0
}";
done
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":313,"prompt_tokens":1,"time":0.0,"total_tokens":314}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":313,"prompt_tokens":1,"time":0.0,"total_tokens":314}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":47,"prompt_tokens":1,"time":0.0,"total_tokens":48}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":118,"prompt_tokens":1,"time":0.0,"total_tokens":119}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":285,"prompt_tokens":1,"time":0.0,"total_tokens":286}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v2","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":409,"prompt_tokens":1,"time":0.0,"total_tokens":410}}
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1!"}],"created":1756371124,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B-v1","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":343,"prompt_tokens":1,"time":0.0,"total_tokens":344}}
...
4. Header-Based Multi-Model Routing
Scenario: Route traffic to different model sizes based on user tier, enabling premium users to access more powerful models.
Traffic Processing: The router evaluates incoming requests in the order rules are defined. Premium users (identified by user-type: premium header) are routed to the 7B model, while regular users fall back to the 1.5B model.
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: deepseek-multi-models
namespace: default
spec:
modelName: "deepseek-multi-models"
rules:
- name: "premium"
modelMatch:
headers:
user-type:
exact: premium
targetModels:
- modelServerName: "deepseek-r1-7b"
- name: "default"
targetModels:
- modelServerName: "deepseek-r1-1-5b"
Flow Description:
- Request arrives for model "deepseek-r1" with headers
- Router first checks if
user-type: premiumheader exists with exact match - If premium header found → Routes to
deepseek-r1-7b(7B model using SGLang) - If no premium header → Falls back to
deepseek-r1-1-5b(1.5B model using vLLM) - Premium users get access to the more powerful 7B model for better performance
Try it out:
export MODEL="deepseek-multi-models"
curl http://$ROUTER_IP/v1/completions \
-H "Content-Type: application/json" \
-H "user-type: premium" \
-d "{
\"model\": \"$MODEL\",
\"prompt\": \"San Francisco is a\",
\"temperature\": 0
}"
{"choices":[{"finish_reason":"length","index":0,"logprobs":null,"text":"This is simulated message from deepseek-ai/DeepSeek-R1-Distill-Qwen-7B!"}],"created":1756367891,"id":"cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7","model":"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B","object":"text_completion","system_fingerprint":"fp_44709d6fcb","usage":{"completion_tokens":71,"prompt_tokens":1,"time":0.0,"total_tokens":72}}
5. PD-Disaggregated Routing
Scenario: Route inference requests to Prefill/Decode disaggregated backends, where prefill instances handle prefill and decode instances handle decode stages independently.
Traffic Processing: When a request arrives for a PD-Disaggregated model, the router selects prefill and decode pods via pdGroup, proxies prefill to the prefill instance and decode to the decode instance, coordinating KV state exchange between them.
ModelServer (with pdGroup):
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelServer
metadata:
name: deepseek-r1-1-5b-pd-disaggregation
namespace: default
spec:
workloadSelector:
matchLabels:
app: deepseek-r1-1-5b
pdGroup:
groupKey: "modelserving.volcano.sh/group-name"
prefillLabels:
modelserving.volcano.sh/rolename: "P-instance"
decodeLabels:
modelserving.volcano.sh/rolename: "D-instance"
workloadPort:
port: 8000
model: "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
inferenceEngine: "vLLM"
trafficPolicy:
timeout: 10s
How workloadSelector works
Two layers: matchLabels (general) selects which pods belong to this ModelServer; pdGroup (PD-only) then groups and roles them.
- ModelServer: Defines backend inference service instances with their associated pods, models, and traffic policies. It uses workloadSelector.matchLabels to select which pods belong to it (pods must match all given labels); this applies to every ModelServer.
- matchLabels: Pods must match all key-value pairs. Used by every ModelServer.
- pdGroup (only for PD-Disaggregated):
- groupKey — Label key whose value defines a PD group. Prefill and decode pods with the same value are paired (shared KV state).
- prefillLabels / decodeLabels — Among those pods, match these labels to classify as prefill or decode.
The router sends prefill and decode for a request to a paired prefill pod and decode pod (same groupKey value).
ModelRoute:
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: deepseek-r1-1-5b-pd-disaggregation
namespace: default
spec:
modelName: "deepseek-r1-1-5b-pd-disaggregation"
rules:
- name: "default"
targetModels:
- modelServerName: "deepseek-r1-1-5b-pd-disaggregation"
Flow Description:
- Request arrives for the PD-Disaggregated model
- Router matches the ModelRoute and resolves the target ModelServer
- Via
pdGroup, router selects a prefill-pod and a decode-pod (samegroupKeyvalue) - Prefill runs on prefill instance; decode runs on decode instance, with KV state exchanged between them (configure
kvConnectorfor nixl/mooncake when needed) - Response returned to client
NOTE: Deploy ModelServing with PD roles first, then apply ModelServer and ModelRoute.
OpenAI Responses API
Kthena Router routes POST /v1/responses the same way it routes /v1/chat/completions and /v1/completions: through the same ModelRoute matching, scheduling, authentication, rate limiting, and access logging described throughout this guide. The Router forwards Responses requests as received — it does not translate a Responses request into Chat Completions or vice versa — so the selected upstream model server must natively implement POST /v1/responses. A backend that only implements /v1/chat/completions will not work here, even if the request carries a model field; that field is still required for ModelRoute matching, as with any other protocol.
Supported request forms:
inputas a plain string, or as a structured list of message items ({"role": "...", "content": [...]}).instructions.max_output_tokens— the Responses equivalent of the Chat Completionsmax_tokens/max_completion_tokensfields.- Opaque or provider-specific fields such as
previous_response_id,tools,metadata, andreasoningare forwarded unchanged. When aModelServerrewritesmodel, only themodelfield changes; the rest of the request body is preserved.
Streaming: stream: true requests are forwarded as Server-Sent Events without modification. The Router recognizes response.completed, response.incomplete, and response.failed as terminal events and does not require or inject a Chat Completions data: [DONE] marker.
Usage accounting: The Router reads usage.input_tokens, usage.output_tokens, and usage.total_tokens — from the response body, or from whichever terminal event carries usage in a streamed response — and feeds them into the same output-token rate limiting, access logs, and kthena_router_tokens_total metric used for Chat Completions.
Routing coverage: Responses requests are supported through every backend path described in this guide, provided the upstream natively supports /v1/responses:
- Aggregated
ModelServertargets (see Simple Model-Based Routing above). InferencePool/ Gateway API targets — see Gateway API Support and Gateway API Inference Extension Support.- PD-Disaggregated targets (see PD-Disaggregated Routing above), including the default HTTP connector and the SGLang and NIXL KV connectors. The prefill request caps generation with
max_output_tokensinstead of the Chat Completionsmax_tokens/max_completion_tokensfields. PD mode additionally requires the upstream's Responses response to carry the selected connector's transfer metadata: the NIXL connector needskv_transfer_paramsin the prefill response to hand the KV cache off to the decode Pod. As of vLLM v0.10.0, its Responses API response does not populatekv_transfer_params, so NIXL-based PD-Disaggregated routing is not currently usable with vLLM Responses endpoints; the default HTTP connector and SGLang's bootstrap-based connector do not depend on this field and are unaffected.
Try it out (using the deepseek-simple ModelRoute from Simple Model-Based Routing):
export MODEL="deepseek-simple"
curl http://$ROUTER_IP/v1/responses \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"input\": \"San Francisco is a\"
}"
Multi-turn conversations: Responses requests can chain turns with previous_response_id or a conversation identifier. If a backend keeps per-conversation state on the Pod that produced a response, follow-up turns need to reach that same Pod to use it. Kthena Router does not derive Pod affinity from these fields, and Session Boost affects queue ordering only. To keep a conversation's turns on one Pod, enable session sticky routing and send the configured session key (for example an X-Session-ID header) on every turn. If that Pod is no longer available, the router moves the session to another Pod, and state kept only on the old Pod is not carried over.
This comprehensive routing system enables flexible, scalable, and maintainable model serving infrastructure that can adapt to various deployment patterns and user requirements.