Skip to main content
Version: dev
Accelerator
Provider
Engine
community

Omni Model Serving

Overviewโ€‹

This guide serves models whose output is not only text (speech, images, or both) behind the llm-d Router, on vLLM-Omni (and, for the image tasks, SGLang). The TASK variable picks what the pool serves:

TASKModelEndpointsEnginesRouter scorer
omni (default)Qwen/Qwen3-Omni-30B-A3B-Instruct/v1/chat/completions (text and spoken answers)vLLM-Omniqueue-scorer
text-to-imageQwen/Qwen-Image (GPU); black-forest-labs/FLUX.2-klein-4B (XPU)/v1/images/generationsvLLM-Omni, SGLangactive-request-scorer
image-to-imageQwen/Qwen-Image-Edit-2511 (GPU); black-forest-labs/FLUX.2-klein-4B (XPU)/v1/images/edits (multipart/form-data)vLLM-Omni, SGLangactive-request-scorer
text-to-speechQwen/Qwen3-TTS-12Hz-1.7B-CustomVoice/v1/audio/speechvLLM-Omniactive-request-scorer

The tasks fall into two shapes:

  • An omni model: one checkpoint, many endpoints. Qwen3-Omni takes text, images, audio, or video in and answers with text and speech from a multi-stage pipeline (thinker โ†’ talker โ†’ code2wav). One InferencePool fronts every OpenAI endpoint the checkpoint serves. The router's request parser understands the chat, speech, and image-generation request bodies, so a single router and pool serve all of them; when the pool shares a Kubernetes Gateway with other pools, exact-path HTTPRoute rules send the modality endpoints to it (see Split endpoints across pools).
  • A single-endpoint generator: one model per pool. A diffusion model (Qwen-Image, Qwen-Image-Edit, or, on Intel XPU, FLUX.2-klein-4B) or a dedicated TTS model answers one endpoint. Each TASK deploys its own pool: 3 single-GPU replicas, or, for Intel XPU's text-to-image and image-to-image, 2 single-XPU replicas.

Either way the router routes on load. Diffusion models generate by iterative denoising rather than autoregressive token generation, and the Qwen3-Omni pipeline runs with prefix caching off, so prefix-cache and token-load scorers have no signal. The omni router picks the pod with the shortest queue (queue-scorer). The single-endpoint routers track how many requests they have dispatched to each pod and not yet seen complete, and prefer the least busy one (active-request-scorer); they need no model-server metrics, which SGLang's diffusion backend does not publish. Image requests log a token-estimation error (unsupported request body type) because the token producer cannot parse image bodies; this does not disable active-request-scorer, which tracks in-flight request counts independently of token estimation. The Qwen3-TTS talker is autoregressive, so prefix-aware TTS routing is possible in principle and is tracked in llm-d-router#2483.

Why vLLM-Omniโ€‹

Standard vLLM generates text output only: even when a model accepts images or audio (for example Qwen3-VL, see Multimodal Model Serving), the response is text. Models whose output is audio or images need vLLM-Omni, which extends vLLM with multi-stage pipelines and the endpoints that return audio or images. Which endpoints a vLLM-Omni pod answers depends on the checkpoint: the TASK table above lists them for the models this guide deploys.

EngineInputOutputExample models
vLLMText, images, audio, videoText onlyQwen3-VL, Llama
vLLM-OmniText, images, audio, videoText, audio, imagesQwen3-Omni, Qwen3-TTS, Qwen-Image, FLUX
SGLang (sglang serve โ†’ sglang.multimodal_gen)Text, imagesImagesQwen-Image, Qwen-Image-Edit

Architectureโ€‹

Client โ†’ Proxy (Envoy sidecar) โ†’ Router (EPP) โ†’ pod of the TASK's pool
TASK=omni /v1/chat/completions (and the checkpoint's other endpoints) queue-scorer
TASK=text-to-image /v1/images/generations active-request-scorer
TASK=image-to-image /v1/images/edits active-request-scorer
TASK=text-to-speech /v1/audio/speech active-request-scorer

The model server overlay creates one Deployment whose pods carry the llm-d.ai/guide=omni-serving label that the InferencePool selects. Every pod serves the same model and endpoints, so the router needs no filter: each request goes to the least loaded pod. That spreads long-running audio and image generations across the pool instead of round-robining them.

Supported Accelerators and Model Serversโ€‹

This guide includes configurations for the following accelerator and model server combinations:

AcceleratorACCELERATOR_TYPEServed modelvLLM-OmniSGLangNotes
NVIDIA GPUgpuQwen/Qwen3-Omni-30B-A3B-Instruct๐ŸŸก community๐ŸŸก communityServed model is TASK=omni's (others in the Overview table) ยท H100 80 GB reference ยท omni: 1 replica ร— 2 GPUs ยท text-to-image, image-to-image, text-to-speech: 3 replicas ร— 1 GPU ยท SGLang: text-to-image and image-to-image only
Intel XPUxpublack-forest-labs/FLUX.2-klein-4Bโ€”๐ŸŸก communitytext-to-image, image-to-image ยท Arc Pro B60 ยท 2 replicas ร— 1 XPU via DRA ยท memory offload (--performance-mode=memory)

โœ… validated: covered by a nightly E2E workflow ยท ๐ŸŸก community: maintained by the hardware vendor or community, not covered by nightly E2E ยท โŒ not supported: tracked in the linked issue ยท โ€” no configuration.

Prerequisitesโ€‹

  • Have the proper client tools installed on your local system to use this guide.

  • Ensure your cluster has enough accelerators for your configuration: 2 NVIDIA GPUs with 80 GB of HBM (e.g. H100) for omni (1 replica: thinker on the first GPU, talker and code2wav on the second), 3 for the other GPU tasks (3 replicas ร— 1 GPU), or 2 Intel XPUs (Arc Pro B60) for Intel XPU's text-to-image or image-to-image (2 replicas ร— 1 XPU, 64 GiB host memory requested per replica). Qwen-Image is a ~20B MMDiT plus a ~8B text encoder, about 57 GB in bf16, and fits one 80 GB GPU at full precision; Qwen3-TTS is small, so the H100 is a default there, not a requirement.

  • For the Intel XPU overlay, install the Intel GPU DRA driver and verify the gpu.intel.com DeviceClass is available; it requires the resource.k8s.io/v1 API (Kubernetes 1.34+).

  • Create a HuggingFace token and export it as HF_TOKEN in your shell.

  • (Optional) Install the monitoring stack if you plan to enable Prometheus monitoring.

Get the guideโ€‹

Every command below runs from a local clone of the llm-d repository: the manifests, Helm values, and Kustomize overlays it applies live next to this guide. Set the branch and clone the repo (if you already have a checkout, skip this and run the remaining commands from inside it):

export BRANCH=main
git clone https://github.com/llm-d/llm-d.git && cd llm-d && git checkout ${BRANCH}

Configure the environmentโ€‹

Set the guide-specific environment variables. Set TASK to what the pool serves and MODEL to that task's model (listed in the comment). MODEL_SERVER=sglang applies to text-to-image and image-to-image only:

export REPO_ROOT=$(realpath $(git rev-parse --show-toplevel))
export GUIDE_NAME=omni-serving
export NAMESPACE=llm-d-omni-serving
export MONITORING=false # options: false, true
export MONITORING_VALUES=
export TASK=omni # options: omni, text-to-image, image-to-image, text-to-speech
export ACCELERATOR_TYPE=gpu # options: gpu, xpu
export MODEL_SERVER=vllmomni # options: vllmomni, sglang
export INFRA_PROVIDER=base # options: base, gke
export MODEL=Qwen/Qwen3-Omni-30B-A3B-Instruct # the model the TASK's overlay serves: omni Qwen/Qwen3-Omni-30B-A3B-Instruct, text-to-image Qwen/Qwen-Image (GPU) or black-forest-labs/FLUX.2-klein-4B (XPU SGLang), image-to-image Qwen/Qwen-Image-Edit-2511 (GPU) or black-forest-labs/FLUX.2-klein-4B (XPU SGLang), text-to-speech Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
source ${REPO_ROOT}/guides/env.sh # defines GAIE_VERSION, ROUTER_CHART_VERSION, router chart URLs, and CURL_TEST_IMAGE

Install the Gateway API Inference Extension CRDs:

# GAIE_URL is automatically calculated from GAIE_VERSION at ${REPO_ROOT}/guides/env.sh
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/${GAIE_URL}/v1-manifests.yaml

Create a target namespace for the installation:

kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -

Create the llm-d-hf-token secret in your target namespace with the key HF_TOKEN matching a valid HuggingFace token to pull models:

kubectl create secret generic llm-d-hf-token \
--from-literal="HF_TOKEN=${HF_TOKEN}" \
--namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -

Installation Instructionsโ€‹

1. Deploy the llm-d Routerโ€‹

Prepare the paths to the helm values files for the llm-d router (used in the deployment command below). Select your task (TASK), which layers its router values on the shared base:

The omni router values schedule every request with the queue-scorer and max-score-picker only.

# only when TASK=omni:
# Paths to values files
export ROUTER_BASE_VALUES="${REPO_ROOT}/guides/recipes/router/base.values.yaml"
export ROUTER_VALUES="${REPO_ROOT}/guides/${GUIDE_NAME}/router/omni.values.yaml"
note

The router parses /v1/audio/speech requests since llm-d-router#2484, which landed after the v0.10.0 release. The default chart version (${ROUTER_CHART_VERSION} = v0) deploys the main-tagged router image, which includes it.

(Optional) Enable Prometheus monitoring on the llm-d router by defining the helm values file (requires installing the monitoring stack mentioned in Prerequisites):

# only when MONITORING=true:
export MONITORING_VALUES="-f ${REPO_ROOT}/guides/recipes/router/features/monitoring.values.yaml"

Deploy the router in Standalone Mode, with an Envoy sidecar in front of the router. Keep the release name ${GUIDE_NAME}: the verification steps look up the ${GUIDE_NAME}-epp Service by that name (and httproutes.yaml references the ${GUIDE_NAME} InferencePool). A single pool needs no HTTPRoute: the standalone proxy sends every path to the router. To front the router with a Kubernetes Gateway instead, see Gateway Mode in the Optimized Baseline and, for omni, Split endpoints across pools below.

helm install ${GUIDE_NAME} \
${ROUTER_STANDALONE_CHART} \
-f ${ROUTER_BASE_VALUES} \
${MONITORING_VALUES} \
-f ${ROUTER_VALUES} \
-n ${NAMESPACE} --version ${ROUTER_CHART_VERSION}

2. Deploy the Model Serverโ€‹

For model sources, caching, and startup optimization, see the Model Loading and Startup Acceleration operations guide.

Apply the Kustomize overlay for your task. The overlays live at modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/${TASK}/${INFRA_PROVIDER}/; the gke variants add the disable-gke-nccl-tuner-patch component on top of base.

Runs vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni on the vllm/vllm-omni image (the shared gpu-vllm-omni image component), 1 replica ร— 2 GPUs:

# only when TASK=omni:
kubectl apply -n ${NAMESPACE} \
-k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/omni/${INFRA_PROVIDER}/

To serve another vLLM-Omni checkpoint, change the model in patch-vllm-omni.yaml and size the GPU count to its deploy config; set MODEL to match.

note

Deploy one task and one engine at a time per namespace. Every overlay carries the same llm-d.ai/guide=omni-serving label, which is what the InferencePool selects on, so two overlays in one namespace would join one pool.

3. Enable Monitoring (optional)โ€‹

(Optional) Deploy the monitoring resources for model servers (requires installing the monitoring stack mentioned in Prerequisites and MONITORING=true when deploying the router). SGLang's diffusion backend publishes no metrics, so with MODEL_SERVER=sglang only the router's metrics are scraped:

# only when MONITORING=true and MODEL_SERVER=vllmomni:
kubectl apply -n ${NAMESPACE} -k ${REPO_ROOT}/guides/recipes/modelserver/components/monitoring

Split endpoints across pools (Gateway Mode)โ€‹

This section applies to TASK=omni. Path-based routing across pools is the one part of this path that needs a Kubernetes Gateway: a standalone router fronts a single pool. A typical setup puts a text model and a speech or image model behind one Gateway, sending the modality endpoints to the vLLM-Omni pool and everything else to the text pool:

/v1/audio/speech (Exact) โ†’ omni-serving pool
/v1/audio/transcriptions (Exact) โ†’ omni-serving pool
/v1/images/generations (Exact) โ†’ omni-serving pool
/v1/chat/completions (PathPrefix / catch-all) โ†’ text pool

Exact-path matches take precedence over PathPrefix matches per the Gateway API specification, so the modality requests reach the vLLM-Omni pool regardless of the catch-all. To set it up:

  1. Deploy the shared llm-d-inference-gateway and the text pool with Gateway Mode in the Optimized Baseline. Its router chart creates the PathPrefix: / catch-all HTTPRoute.
  2. Install this guide's router with the Gateway chart (${ROUTER_GATEWAY_CHART}, --set provider.name=<your gateway provider>) instead of the standalone chart, leaving out --set httpRoute.create=true so it does not add a second catch-all, and deploy the model server as above.
  3. Remove the path matches your model does not serve from httproutes.yaml, then apply it:
kubectl apply -n ${NAMESPACE} -f ${REPO_ROOT}/guides/${GUIDE_NAME}/httproutes.yaml
note

Path matching splits traffic by endpoint, not by model. It fits TTS and diffusion checkpoints, whose endpoints differ from the text pool's. Qwen3-Omni returns spoken answers through /v1/chat/completions, the path the text pool owns, so behind a shared Gateway give it its own endpoint (a separate namespace and Gateway listener, with the Gateway chart's own HTTPRoute) rather than a path rule. The same applies to two models that serve the same endpoint, such as two TTS models on /v1/audio/speech.

Verificationโ€‹

1. Get the IP of the Proxyโ€‹

export IP=$(kubectl get service ${GUIDE_NAME}-epp -n ${NAMESPACE} -o jsonpath='{.spec.clusterIP}')

2. Send Test Requestsโ€‹

Send a request to your task's endpoint and check that a valid response comes back:

Send a text chat request:

# only when TASK=omni:
# Text in, text out
kubectl run chat-test --rm -i --restart=Never \
--image=${CURL_TEST_IMAGE} \
--namespace="${NAMESPACE}" \
--env="IP=${IP}" \
--env="MODEL=${MODEL}" \
-- /bin/sh -c 'curl -sS -X POST "http://${IP}/v1/chat/completions" -H "Content-Type: application/json" \
-d "{\"model\": \"${MODEL}\", \"messages\": [{\"role\": \"user\", \"content\": \"What can you do, in one sentence?\"}], \"modalities\": [\"text\"], \"max_tokens\": 64}" \
| jq -r ".choices[0].message.content"'

The text request only shows that the thinker stage answers. Request text and audio output to exercise the whole pipeline:

# only when TASK=omni:
# Text in, text and speech out: the thinker writes the answer, the
# talker and code2wav stages turn it into audio (base64 WAV)
kubectl run chat-audio-test --rm -i --restart=Never \
--image=${CURL_TEST_IMAGE} \
--namespace="${NAMESPACE}" \
--env="IP=${IP}" \
--env="MODEL=${MODEL}" \
-- /bin/sh -c 'curl -sS -X POST "http://${IP}/v1/chat/completions" -H "Content-Type: application/json" \
-d "{\"model\": \"${MODEL}\", \"messages\": [{\"role\": \"user\", \"content\": \"Say hello to llm-d in one short sentence.\"}], \"modalities\": [\"text\", \"audio\"]}" \
| jq -r ".choices[].message | (if .content then \"text: \(.content)\" else empty end), (if .audio then \"audio: \(.audio.data | length) base64 characters\" else empty end)"'

Expect two lines: text: with the answer, and audio: with the size of the base64-encoded WAV the talker and code2wav stages generated. A missing audio: line means the speech stages did not run: check the model server logs for the talker stage.

3. Check where the requests landedโ€‹

Read the model server logs to confirm that the router scheduled each request onto a pod of the pool:

# The model servers' logs: every request above, by endpoint path,
# on the pods of the pool (each line prefixed with its pod name)
kubectl logs -n ${NAMESPACE} -l llm-d.ai/guide=${GUIDE_NAME} -c modelserver --tail=-1 --prefix \
| grep -E 'POST /v1/(chat/completions|images/generations|images/edits|audio/speech)' || true

Every request above appears on a pod of the pool (each line prefixed with its pod name) under its endpoint path: the router parsed the request body and scheduled it onto the pool, whatever the endpoint. Send several requests concurrently and they spread across the pods by load. The logs are the evidence that works for every engine; SGLang's diffusion backend publishes no metrics to read the same from.

Cleanupโ€‹

To remove the deployed components, delete your task's model server:

# only when TASK=omni:
kubectl delete -n ${NAMESPACE} -k ${REPO_ROOT}/guides/${GUIDE_NAME}/modelserver/${ACCELERATOR_TYPE}/${MODEL_SERVER}/omni/${INFRA_PROVIDER}

If you applied the exact-path rules in Gateway Mode, also delete them:

kubectl delete -n ${NAMESPACE} -f ${REPO_ROOT}/guides/${GUIDE_NAME}/httproutes.yaml --ignore-not-found=true

Then remove the router, the monitoring resources, and the namespace:

helm uninstall ${GUIDE_NAME} -n ${NAMESPACE}

kubectl delete -n ${NAMESPACE} -k ${REPO_ROOT}/guides/recipes/modelserver/components/monitoring --ignore-not-found=true
kubectl delete namespace ${NAMESPACE}