Skip to main content
BlogAI/ML

KServe LLMInferenceService Guide: llm-d, WVA and vLLM

A close reading of KServe v0.17 and v0.18 LLMInferenceService: llm-d routing, WVA autoscaling, LeaderWorkerSet, and where it beats raw vLLM Deployments.

Matthews Wong

Software Engineer

October 202612 min read
KServe LLMInferenceService Guide: llm-d, WVA and vLLM

Key Takeaway

KServe's LLMInferenceService replaces a hand-built vLLM Deployment, Service and autoscaler with one custom resource that provisions a Gateway, an llm-d endpoint picker routing on KV-cache state, optional prefill and decode pools, and WVA autoscaling. v0.17 made it production-ready and v0.18 added Ray-free multi-node scaling, but the API is alpha, so pin versions.

I went looking for a short answer to a plain question: if you already run vLLM on Kubernetes with a Deployment and a Service, what does KServe's LLMInferenceService give you that is worth a migration? The release posts for v0.17 and v0.18 are dense lists of pull requests, and the samples in the repository do not all agree with each other.

This is a close reading, not a field report. I read the v0.17 and v0.18 release posts, the Go types for the CRD at the v0.18.0 tag, the sample manifests at that tag, the vLLM Kubernetes guide and the v0.21.0 release notes. I did not run any of this on a GPU cluster, and where a manifest below is my own edit, it says so.

What LLMInferenceService replaces, and what it does not

LLMInferenceService is a separate custom resource from InferenceService. The v0.17 post calls it GenAI-first, as opposed to InferenceService, which targets predictive models, and says it supports distributed inference, KV-cache-aware scheduling, disaggregated prefill and decode, Gateway API Inference Extension integration and token-based rate limiting. Its minimal example creates the Deployment, Service, Gateway, HTTPRoute, InferencePool, InferenceModel and the endpoint picker pod from one object.

Compare that with the plain route. The vLLM guide for Kubernetes shows a PVC, a Secret for the Hugging Face token, a Deployment, a ClusterIP Service mapping port 80 to container port 8000, an in-memory emptyDir at /dev/shm for tensor parallelism, and HTTP probes on /health. It also lists KServe, llm-d, LeaderWorkerSet, KubeRay and several other stacks as alternatives, which tells you where the maintainers expect the plain manifests to stop.

ConcernRaw vLLM manifestsLLMInferenceService
Objects you writePVC, Secret, Deployment, Service, probes, shared memoryOne resource with model, replicas, router and template
Request routingA ClusterIP Service in front of the podsGateway, HTTPRoute and an endpoint picker scoring pods by queue depth and KV-cache state
Prefill and decode splitNot part of the guide; two Deployments and a KV connector you wire yourselfspec.prefill is a second pool with its own replicas and scaling
Multi-node servingListed as an integration (LeaderWorkerSet, KubeRay), not as manifestsA worker spec produces a LeaderWorkerSet; parallelism fields feed vLLM
AutoscalingNot covered by the guidespec.scaling with WVA and a KEDA or HPA actuator
Stability of the APICore Kubernetes objects, stableserving.kserve.io v1alpha1 at v0.18.0; the v0.21.0 notes move samples to v1alpha2

The honest reading of the table is that you are trading stable, boring objects for a controller that does a great deal for you. If you run one model on a few GPUs behind a Service, the left column is enough and the right column is overhead. The right column starts to pay when routing, autoscaling or multi-node placement is the thing you keep rebuilding.

What v0.17 built: the llm-d pieces inside KServe

The v0.17 post, published on 2026-03-13, is the one that made the resource production-ready. Its contents, in the order I would care about them:

  • KV-cache-aware scheduling: the endpoint picker tracks cache state across vLLM instances through ZMQ BlockStored and BlockRemoved events, and scores pods through an EndpointPickerConfig plugin pipeline instead of round-robin.
  • Disaggregated prefill and decode: compute-heavy prefill and memory-heavy decode become separate workloads that scale separately, with KV blocks moved between pods through the NixlConnector over RDMA-based RoCE.
  • Parallelism through LeaderWorkerSet: tensor, data, data-local and expert parallelism, configured with a worker spec.
  • Envoy AI Gateway integration: an AIGatewayRoute defines llmRequestCosts for input, output and total tokens, and a BackendTrafficPolicy rate-limits per x-user-id header.
  • Autoscaling with the Workload Variant Autoscaler (WVA), which watches KV-cache utilisation and queue depth through Prometheus and emits wva_desired_replicas for KEDA or HPA to act on.

The same release split the Helm chart from one into ten and says plainly that upgrading from v0.16 needs the upgrade guide rather than a plain helm upgrade. It reports 167 merged pull requests. The structural change matters more than the count: kserve, llmisvc and localmodel are now independent components, so you can install the generative stack without the predictive one.

The smallest manifest that does something

The sample below is the repository's own single-node GPU example at the v0.18.0 tag, trimmed, with my comments on the parts that surprised me. The empty router fields are the notable design choice: an empty object asks for the managed default rather than turning the component off.

# From docs/samples/llmisvc/single-node-gpu at the v0.18.0 tag (trimmed).
# One object. The controller derives the Deployment, Service, Gateway,
# HTTPRoute, InferencePool and the endpoint picker from it.
apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceService
metadata:
  name: qwen2-7b-instruct-single
spec:
  model:
    uri: hf://Qwen/Qwen2.5-7B-Instruct   # where weights come from
    name: Qwen/Qwen2.5-7B-Instruct       # the name clients put in "model"
  replicas: 3
  router:
    scheduler: {}   # {} means "use the managed default", not "disabled"
    route: {}
    gateway: {}
  template:
    containers:
      - name: main
        resources:
          limits:   { cpu: "4", memory: 32Gi, nvidia.com/gpu: "1" }
          requests: { cpu: "2", memory: 16Gi, nvidia.com/gpu: "1" }
        livenessProbe:
          httpGet: { path: /health, port: 8000, scheme: HTTPS }  # HTTPS, not HTTP
          initialDelaySeconds: 120   # model load time, not a typo
          periodSeconds: 30
          timeoutSeconds: 30
          failureThreshold: 5

The overview page lists the other top-level fields. spec.template is the single-node or decode workload, spec.worker is the multi-node worker pod that triggers a LeaderWorkerSet, spec.prefill is the prefill-only workload, and spec.parallelism carries tensor, data and expert sizes. The Go types add pipeline and dataLocal to that list.

Notice what is missing: no Service, no Deployment, no HTTPRoute. If you apply this and then list objects in the namespace, the comparison from the first section becomes concrete, because everything the raw route made you write shows up as controller output rather than your manifest.

Routing on KV-cache state, and the config you must keep in sync

The routing sample at v0.18.0 is where the design pays and where it bites. The scheduler config is an inline EndpointPickerConfig. Several values have to agree with the vLLM pods: the block size and hash seed, which the sample's comments call out, plus the hashing algorithm and ZMQ event endpoint that the sample sets on the vLLM side. If they drift, the scheduler's picture of which pod holds which prefix can be wrong, and it routes on that wrong picture.

# Based on precise-prefix-cache-routing/llm-inference-service-qwen2-7b-gpu-kv-cache-routing.yaml
# at v0.18.0. Values marked "must equal" have to match the vLLM pods.
spec:
  router:
    scheduler:
      config:
        inline:
          apiVersion: inference.networking.x-k8s.io/v1alpha1
          kind: EndpointPickerConfig
          plugins:
            - type: single-profile-handler
            - type: precise-prefix-cache-scorer
              parameters:
                tokenProcessorConfig:
                  blockSize: 64      # must equal vLLM --block-size (default 16)
                  hashSeed: "42"     # must equal PYTHONHASHSEED in the vLLM pods
                kvEventsConfig:
                  zmqEndpoint: "tcp://*:5557"   # vLLM publishes block events here
            - type: queue-scorer
            - type: kv-cache-utilization-scorer
            - type: max-score-picker
          schedulingProfiles:
            - name: default
              plugins:
                - pluginRef: queue-scorer
                  weight: 2
                - pluginRef: kv-cache-utilization-scorer
                  weight: 2
                # Wrong (as shipped in the sample): pluginRef: prefix-cache-scorer
                # Right: reference the plugin you actually declared above.
                - pluginRef: precise-prefix-cache-scorer
                  weight: 3
                - pluginRef: max-score-picker
  template:
    containers:
      - name: main
        env:
          - name: PYTHONHASHSEED
            value: "42"
          - name: VLLM_ADDITIONAL_ARGS   # the full sample also adds --kv-events-config (zmq publisher)
            value: "--prefix-caching-hash-algo sha256 --block-size 64"

The sample's comments state two of the constraints directly: blockSize must match vLLM's --block-size, whose default is 16, and hashSeed must match PYTHONHASHSEED in the vLLM pods. The manifest sets block size 64, hash seed 42 and sha256 prefix hashing on the vLLM side. The weights in the sample are queue-scorer 2, kv-cache-utilization-scorer 2 and a prefix scorer 3, so prefix affinity carries the highest weight.

Do not paste the routing sample unchanged. At the v0.18.0 tag it declares a plugin of type precise-prefix-cache-scorer but the scheduling profile references prefix-cache-scorer, which is not declared in that file. My block above uses the declared name. I have not run it, so treat it as a reading of the file, and let kubectl apply with --dry-run=server and the scheduler logs be the judge.

The v0.17 post shows a different plugin vocabulary again: prefix-cache-scorer at weight 2.0, load-aware-scorer at weight 1.0 and a max-score-picker. The names changed between the blog and the samples, and v0.21.0 lists a change migrating the EndpointPickerConfig apiVersion. Scorer names are version-specific configuration, not a stable vocabulary.

Prefill and decode pools, multi-node and what v0.18 added

Disaggregation in this API is two pools in one object. The top-level template and replicas describe decode, and spec.prefill describes prefill with its own replica count and pod template. The sample moves KV blocks with the NixlConnector and asks for a RoCE network through a CNI annotation and an rdma/roce_gdr resource limit. That is real hardware: without an RDMA-capable network the pools can exist but the transfer path the sample assumes does not.

# Disaggregated prefill/decode, from single-node-gpu/...-pd-qwen2-7b-gpu.yaml
# at v0.18.0 (trimmed). Top level is the decode pool; spec.prefill is its own pool.
metadata:
  annotations:
    k8s.v1.cni.cncf.io/networks: roce-p2   # RoCE network for the KV transfer
spec:
  replicas: 1          # decode
  prefill:
    replicas: 2        # prefill scales independently
    template:
      containers:
        - name: main
          env:
            - name: KSERVE_INFER_ROCE
              value: "true"
            - name: VLLM_NIXL_SIDE_CHANNEL_HOST
              valueFrom: { fieldRef: { fieldPath: status.podIP } }
            - name: VLLM_ADDITIONAL_ARGS
              value: "--kv_transfer_config '{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_both\"}'"
            - name: UCX_TLS
              value: "rc,sm,self,cuda_copy,cuda_ipc"
          resources:
            limits: { nvidia.com/gpu: "1", rdma/roce_gdr: 1 }

# Multi-node: spec.parallelism plus a worker pod spec becomes a LeaderWorkerSet.
# From the dp-ep sample: 16-way data parallel, 8 per node, expert parallel on.
spec:
  parallelism: { data: 16, dataLocal: 8, expert: true, tensor: 1 }

The v0.18 post, published on 2026-04-29, lists these additions that matter for this resource:

  • Multi-node inference without Ray: a multinode/executor-backend annotation on the ServingRuntime selects ray, the default, or mp. In mp mode the node count comes from pipeline parallelism and GPUs per node from tensor parallelism, and the controller injects PIPELINE_PARALLEL_SIZE, TENSOR_PARALLEL_SIZE and WORKER_SVC into worker containers.
  • LeaderWorkerSet as a scale target: LLMInferenceService can autoscale multi-node workloads through WVA v0.6.0, where earlier validation blocked combining spec.worker with spec.scaling. The release also upgrades llm-d to v0.6.
  • The OpenAI Responses API: /v1/responses is now routed through the HTTPRoute.
  • The restricted Pod Security Standards profile is enforced in the default template, and I would expect it to be the item that breaks an existing pod spec you carry over.
  • A LocalModelNamespaceCache CRD for namespace-scoped model caches, vLLM upgraded to v0.19.0, and 130 merged pull requests from 27 contributors, 10 of them first-time.

One boundary to respect: the Ray-free annotation is described against the ServingRuntime, so it appears to belong to the InferenceService multi-node path. For LLMInferenceService the multi-node mechanism in the same post is the LeaderWorkerSet. I would not assume one flag covers both.

Autoscaling with WVA, read from the schema

The Go types at v0.18.0 are the most precise description of autoscaling I found, and they carry the validation rules as comments. The shapes below come straight from them. The numbers in my block are legal values, not tuned ones, and I did not measure scale-up latency for any model.

# Field names and rules read from the v0.18.0 Go types
# (pkg/apis/serving/v1alpha1/llm_inference_service_types.go).
# The numbers are mine, chosen to be legal, not tuned.
spec:
  # replicas and scaling are mutually exclusive (CEL rule on the workload).
  scaling:
    minReplicas: 2        # defaults to 1, minimum 1
    maxReplicas: 8
    wva:                  # required whenever scaling is set
      variantCost: "10.0" # default; relative cost per replica of this variant
      keda:               # exactly one of keda or hpa
        pollingInterval: 15          # seconds
        cooldownPeriod: 300          # seconds after the last active trigger
        initialCooldownPeriod: 600   # let the model load before KEDA judges it
        idleReplicaCount: 1          # must be below minReplicas, and minimum is 1
  # prefill.scaling takes the same shape, so each pool scales on its own.

Three rules fall out of the CEL validations. replicas and scaling are mutually exclusive on a workload. wva is required whenever scaling is set, and exactly one of hpa or keda must sit under it. And idleReplicaCount must be less than minReplicas while both have a minimum of 1, so using idleReplicaCount means minReplicas of at least 2.

That last rule contradicts the v0.17 post, which says the KEDA actuator supports idle scale-to-zero through idleReplicaCount. At the v0.18.0 tag the field has a minimum of 1, so it is an idle floor above zero. The v0.17 claim may describe an intent or a different version; the CRD at the tag is what the admission webhook enforces, so I trust that. Per the types, the controller also creates a VariantAutoscaling resource and a ServiceMonitor alongside the actuator.

initialCooldownPeriod exists for exactly the LLM case: the types describe it as the wait after the ScaledObject is created so KEDA does not judge a pod that is still loading weights. Set it above your measured model load time, and measure that time first.

Where it breaks: alpha APIs and moving parts

Everything above can be correct and still cost you a week, because the surface is moving. The evidence is in the releases themselves.

  • The API version moved. The samples at v0.18.0 use serving.kserve.io/v1alpha1; the v0.21.0 notes include a change updating llmisvc samples from v1alpha1 to v1alpha2. A manifest from a blog post may not apply to your cluster.
  • The autoscaling wiring moved. The v0.18.0 types describe a VariantAutoscaling resource created by the controller; v0.21.0 lists migrating WVA from the VA CRD to annotation-based discovery. Dashboards or policies that referenced the old object would break.
  • The tokenizer path moved. v0.21.0 lists replacing the UDS tokenizer sidecar with a vLLM render deployment. Anything that scraped or patched the sidecar needs a second look on upgrade.
  • The install moved. v0.17 split one Helm chart into ten and requires the upgrade guide from v0.16, and v0.21.0 lists restructuring CRD management for independent installation.

The newest release I could confirm is v0.21.0, published on 2026-09-25, with v0.19.0 on 2026-06-14 and v0.20.0 on 2026-08-06 between it and the v0.18 post. So a guide built on v0.18 is three minor versions old. I checked the tags and the release notes, but I did not read the v0.19 and v0.20 notes in full, so I make no claim about what they changed.

What to do on Monday

Treat this as an evaluation, not a migration. The order below is cheapest-first and each step either ends the evaluation early or earns the next one.

  1. Decide whether you need it. One model, a handful of replicas, no multi-node: stay on the vLLM Deployment. Needing KV-cache-aware routing, a prefill and decode split, multi-node or autoscaling on inference metrics is the signal to continue.
  2. Pin the version and read the CRD your cluster serves, using the commands below, before copying any YAML from a post, including this one.
  3. Start with the single-node sample and an empty router. Confirm the controller generates the objects you expected before you touch scheduler config.
  4. Add routing config only with a measurement in hand: your own prefix-sharing workload, the block size, hash seed and hashing algorithm set identically on both sides, and the scheduler logs open while you test.
  5. Add scaling last, with initialCooldownPeriod above your model load time and minReplicas of at least 2 if you want an idle floor. Re-run the dry-run apply after every KServe upgrade.
# 1. Which release is actually installed? Do not trust the blog post's version.
kubectl get deploy -A -o wide | grep -i kserve

# 2. Which API version does the CRD serve here? v1alpha1 on v0.18, v1alpha2 in newer samples.
kubectl get crd llminferenceservices.serving.kserve.io \
  -o jsonpath='{.spec.versions[*].name}{"\n"}'

# 3. Read the schema your cluster enforces, not the one you remember.
kubectl explain llminferenceservice.spec.scaling --recursive

# 4. Let the admission webhook judge the manifest before anything is created.
kubectl apply --dry-run=server -f my-llmisvc.yaml

# 5. After applying, see what the controller generated from your one object.
kubectl get llminferenceservice,deploy,svc,httproute,inferencepool -n my-ns

The commands are plain kubectl. kubectl explain reads the schema from the installed CRD, which makes it the one source that cannot be out of date for your cluster.

My rule after reading all of it: adopt LLMInferenceService for the routing, disaggregation and autoscaling you would otherwise build, keep a plain vLLM Deployment for everything simpler, and treat every YAML snippet, including mine, as a claim about one version that your own CRD has to confirm.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is KServe LLMInferenceService?

It is a KServe custom resource aimed at generative models, separate from InferenceService. From one object the controller creates the workload, Gateway, HTTPRoute, InferencePool and an endpoint picker, and it supports prefill and decode pools, multi-node serving and WVA autoscaling. The v0.17 release post describes it as production-ready.

When should I use LLMInferenceService instead of a plain vLLM Deployment?

Use it when routing on KV-cache state, a prefill and decode split, multi-node serving or inference-metric autoscaling is something you would otherwise build yourself. For one model on a few GPUs behind a Service, a plain Deployment is simpler and its objects are stable. The API here is alpha, which is part of the cost.

What did KServe v0.18 add to LLMInferenceService?

The v0.18 post lists LeaderWorkerSet as an autoscaling target with WVA v0.6.0, llm-d v0.6, routing of the OpenAI Responses API at /v1/responses, and the restricted Pod Security Standards profile in the default template. It also adds a Ray-free multi-node option and a LocalModelNamespaceCache CRD.

Can LLMInferenceService scale to zero?

The v0.17 post says the KEDA actuator supports idle scale-to-zero through idleReplicaCount. In the v0.18.0 Go types, idleReplicaCount has a minimum of 1 and must be below minReplicas, so it behaves as an idle floor. Check the CRD your cluster serves with kubectl explain before relying on zero.

Is the LLMInferenceService API stable?

Not yet. The samples at v0.18.0 use serving.kserve.io/v1alpha1, and the v0.21.0 release notes list moving samples to v1alpha2, migrating WVA discovery to annotations and replacing the tokenizer sidecar. Pin your KServe version and re-run a server-side dry-run apply after each upgrade.