all_lessons/kubernetes_genai/03lesson 3 / 18

Part I - Inference foundations

Model Servers and Controllers

In lesson 02 you stood up a single LLM by hand: a Deployment that ran a serving process, a Service in front of it, and a readiness probe so traffic only arrived once the model had loaded. It worked, but every decision was implicit — what runs the tokens, what owns the rollout, what knows the model is healthy. As soon as you have two models, two teams, and a canary to roll out, those decisions stop being implicit and start being architecture. This lesson names the three layers that were tangled together in that one manual deployment — the model server, the inference controller, and the programmable serving framework — and shows that they are not competitors on a shortlist but distinct kinds of ownership. Confusing them is the most common way a serving platform ends up both rigid and impossible to debug.

Source coverage
PDF Chapter 1 - model servers (vLLM, TGI, SGLang), the KServe inference controller, and the Ray Serve / KubeRay programmable serving framework.
Linear position
Prerequisite: Lesson 02 (First manual LLM deployment) — you can deploy one model as a Deployment + Service + readiness probe, and you know it loads weights into GPU memory and answers HTTP.
New capability: Choose between a model server, an inference controller, and a programmable serving framework by which kind of ownership the hard part of your problem demands — runtime ownership, lifecycle ownership, or composition ownership — without collapsing them into one layer.
The plan
Five moves. (1) Separate the three layers and define the one thing each one owns, so "it serves a model over HTTP" stops being a useful description. (2) Open the server layer and define the runtime mechanisms — prefill, decode, KV cache, continuous batching, prefix caching, speculative decoding, LoRA — that make a model server more than a Flask app around a model. (3) Open the controller layer with a concrete before/after: how one KServe InferenceService subsumes the entire manual Deployment + Service + probe from lesson 02. (4) Open the framework layer with a Ray Serve snippet, and draw the sharp line between KServe (Kubernetes-native, platform owns the API) and Ray Serve (Python-native, code owns the composition). (5) The decision rule, failure modes, and a checklist for keeping the boundaries explicit.

1 · Three layers, three kinds of ownership

Every LLM endpoint you will ever run answers HTTP and returns tokens. That shared surface is exactly why people flatten these tools into one comparison — "should we use vLLM or KServe or Ray Serve?" — as if picking one. That question is malformed. They sit at different layers and a serious platform often runs all three at once: vLLM inside a pod, a KServe controller managing that pod, and Ray Serve composing a multi-step app that calls it. The useful question is never "which one" but "which kind of ownership is the hard part of my problem."

Model serverOwns token execution. Turns a request into tokens efficiently: prefill, decode, KV cache, batching, adapters, sampling. vLLM, TGI, SGLang.
Inference controllerOwns lifecycle. Reconciles the Kubernetes objects around the server: Deployments, Services, revisions, rollout, autoscaling, status. KServe.
Serving frameworkOwns composition. Lets Python code wire models, preprocessing, routing, and distributed workers into one app. Ray Serve / KubeRay.

An analogy that keeps the layers apart: the model server is the engine — it converts fuel into motion and nothing else, but it must do so efficiently. The controller is the fleet manager — it does not turn a single crankshaft; it decides how many vehicles run, swaps them in and out safely, and reports which are healthy. The framework is the dispatcher writing custom routes in code — it composes several engines and stops into one journey. An engine cannot manage a fleet; a fleet manager cannot improve combustion. When a platform asks one layer to do another's job, that is where it breaks.

The symptom of a collapsed layer
The tell that two layers have been merged: you cannot answer "why is p99 latency high?" without reading code from a different layer. If diagnosing a slow token stream means digging through controller reconcile logs, your runtime metrics have been hidden behind a lifecycle abstraction — the controller absorbed a responsibility that belongs to the server. The reverse tell: an organization's rollout policy lives inside a vLLM flag. Each layer should expose its own observability and keep its own concerns.

2 · The server layer: what makes a model server, not a Flask app

In lesson 02 the Deployment ran "a serving process." That process was a model server, and it is doing far more than loading weights and calling model.generate() in a request handler. The reason a purpose-built server (vLLM, Hugging Face's TGI, or SGLang) exists at all is that naive serving wastes most of an expensive GPU. The runtime mechanisms below are the difference, defined at first use:

Prefill & decode
An LLM request runs in two phases. Prefill processes the whole prompt in parallel to produce the first token — compute-heavy, one big matrix pass. Decode then emits one token at a time, each step reading everything before it — memory-bandwidth-bound. They have opposite hardware profiles, so a good server schedules them differently.
KV cache & PagedAttention
During decode, the attention keys and values for every prior token are cached in GPU memory (HBM) so they are not recomputed each step. The KV cache grows with sequence length × concurrency and is usually the first resource you run out of — not compute. PagedAttention — vLLM's signature mechanism — stores that cache in fixed-size pages like virtual memory instead of one contiguous block per request, which removes the fragmentation that otherwise wastes much of HBM and lets far more sequences share the card.
Continuous batching
Instead of waiting to assemble a fixed batch, the server admits and retires requests every decode step, so a finished sequence frees its slot immediately for a waiting one. This keeps the GPU busy and is the single biggest throughput win over static batching.
Prefix caching
If many requests share a leading prefix (the same long system prompt, a shared document), its KV cache is computed once and reused, skipping redundant prefill. SGLang in particular is organized around aggressive prefix reuse.
Chunked prefill
A very long prompt's prefill is split into chunks interleaved with ongoing decode steps, so one giant prompt does not stall every other request's token stream. Smooths tail latency under mixed traffic.
Quantized KV cache
Storing the KV cache in 8-bit (or lower) instead of 16-bit roughly doubles how many concurrent sequences fit in HBM, trading a little accuracy for much higher concurrency on the same card.
Speculative decoding
A small cheap "draft" model proposes several tokens ahead; the big model verifies them in one pass and accepts the ones it agrees with. When acceptance is high this cuts the number of expensive forward passes per token, lowering latency.
LoRA adapters
A LoRA (low-rank adaptation) is a small set of fine-tuning weights layered on a shared base model. A server that hot-swaps LoRAs serves many fine-tunes from one set of base weights in HBM, instead of one full model per tenant.

vLLM is the cleanest mental anchor because it makes these concerns explicit — PagedAttention for KV-cache memory, continuous batching for scheduling — and exposes an OpenAI-compatible HTTP endpoint, so clients written against the OpenAI API work unchanged. TGI occupies the same model-server category with its own operational trade-offs and production hardening. SGLang pushes the unit of work toward programs with structured outputs and aggressive prefix reuse. The cross-track lesson SGLang vs vLLM compares those two in depth.

Worked number — why this layer is where the money is
Take one NVIDIA H100 (80 GB HBM) at roughly $3/GPU-hour serving a 13B model in 16-bit (~26 GB of weights, leaving ~54 GB for KV cache and overhead). A naive server that processes one request at a time might sustain ~400 output tokens/s; the GPU sits idle between steps. Turn on continuous batching and the same card holds, say, 32 concurrent sequences and sustains ~6,400 tokens/s — a 16× throughput gain on identical hardware. At $3/hr that is the difference between $3 / (400 × 3600) ≈ $2.08 per 1M output tokens and $3 / (6,400 × 3600) ≈ $0.13 per 1M tokens. None of that comes from Kubernetes or a controller — it lives entirely in the server. This is why the decision rule later says: if per-token efficiency is the hard part, you fix the runtime, not the platform.

The point of the worked number is not the exact figures — they shift with model, hardware, and traffic shape — but the location of the lever. Continuous batching, KV cache management, and quantization are server concerns. No amount of controller or framework sophistication recovers a 16× gap you left on the table inside the runtime. That is the first half of avoiding the most common failure mode: reaching for a controller to fix a problem that lives one layer down.

3 · The controller layer: one InferenceService instead of the lesson-02 stack

A model server is a process. Running one of them by hand was lesson 02. The trouble starts at scale: every team that wants an endpoint now repeats your Deployment, your Service, your probe, your autoscaler, your rollout dance — and each does it slightly differently, so the platform team cannot reason about any of them uniformly. An inference controller solves this the Kubernetes way: it adds a custom resource (a CRD) that describes the intent — "serve this model with this runtime" — and a control loop that reconciles the lower-level objects to match. KServe is the standard example.

Concretely, here is the manual stack from lesson 02 — a Deployment running vLLM, a Service, and a readiness probe — written out the long way:

# lesson 02, by hand: you own every object and every field
apiVersion: apps/v1
kind: Deployment
metadata: { name: llama-13b }
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          args: ["--model", "meta-llama/Llama-13b", "--port", "8000"]
          resources: { limits: { nvidia.com/gpu: 1 } }
          readinessProbe: { httpGet: { path: /health, port: 8000 } }
---
apiVersion: v1
kind: Service
metadata: { name: llama-13b }
spec: { selector: { app: llama-13b }, ports: [{ port: 80, targetPort: 8000 }] }
# ...plus an HPA, an Ingress, and a hand-rolled rollout when the model changes.

KServe collapses all of that into one declaration. You state the model and the runtime; the controller creates and owns the Deployment, the Service, the routing, the revisions, the autoscaling, and the status — and gives the platform team one uniform object to govern across every team:

# with a controller: you state intent, KServe reconciles the rest
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata: { name: llama-13b }
spec:
  predictor:
    model:
      modelFormat: { name: vllm }      # which runtime owns token execution
      storageUri: "s3://models/llama-13b"
      resources: { limits: { nvidia.com/gpu: 1 } }
    minReplicas: 2                       # autoscaling, revisions, rollout,
    maxReplicas: 8                       # networking, and status are now the
                                         # controller's job, not yours.

The lesson 02 objects did not disappear — KServe still produces a Deployment, a Service, and probes underneath. They became an implementation detail the controller owns. That is the whole value: a new revision rolls out by changing one field; the platform sets policy once (resource quotas, allowed runtimes, autoscaling defaults) and every team inherits it; and the controller's status reports readiness uniformly. KServe is Kubernetes-native in feel — you think in declarative resources, the platform reconciles them — which fits exactly when you want a standardized, platform-owned serving API and users mainly supply model and runtime configuration. (Autoscaling and request-aware routing for these objects is its own subject, covered later in this track.)

4 · The framework layer: composition in Python, and the KServe vs Ray Serve line

A controller is excellent when the unit of work is "an endpoint serving a model." But some applications are not one model behind one endpoint — they are a graph: preprocess, embed, retrieve, call a small model, then a big one, post-process, fan results back together. Expressing that as a pile of separate InferenceService objects wired by network calls is awkward, because the composition itself is the application logic, and it wants to live in code. A programmable serving framework owns that. Ray Serve (running on a Ray cluster, which on Kubernetes is provisioned by the KubeRay operator) lets you declare deployments as Python objects and compose them, while Ray manages the distributed workers underneath:

import ray
from ray import serve

@serve.deployment(num_replicas=2, ray_actor_options={"num_gpus": 1})
class Generator:
    def __init__(self):
        self.llm = load_vllm("meta-llama/Llama-13b")   # the server still owns tokens
    async def __call__(self, prompt: str) -> str:
        return await self.llm.generate(prompt)

@serve.deployment
class Pipeline:
    def __init__(self, gen):
        self.gen = gen                                  # composition lives in code
    async def __call__(self, req):
        ctx = await retrieve(req.query)                 # a RAG step, in Python
        return await self.gen.remote(build_prompt(ctx, req.query))

app = Pipeline.bind(Generator.bind())                   # wire the graph, then `serve.run`

Notice the layers are still distinct: inside Generator a real model server (vLLM here) still owns token execution. Ray Serve is not competing with vLLM — it is composing it. What Ray adds is Python-native composition plus distributed execution: the framework decides which worker runs which deployment, scales replicas, and handles the calls between them. That fits when application logic, custom multi-step composition, or data-science ownership of the serving code matters more than a narrow, declarative inference CRD.

The sharp contrast — and the question interviewers actually ask — is KServe versus Ray Serve:

KServe (Kubernetes-native) → you declare an InferenceService and the platform reconciles networking, revisions, autoscaling, and status. Ownership sits with the platform team; users supply config. Best when "many teams safely deploying standard endpoints" is the hard part.
Ray Serve / KubeRay (Python-native) → you write deployments in code and compose them; Ray manages distributed execution. Ownership sits with the application/data-science team. Best when "composing a multi-step app across distributed workers" is the hard part.

Treating these as interchangeable because both expose HTTP is the third failure mode. They differ in who owns the serving logic: a declarative platform contract versus a Python program. The bridge between a raw GPU runtime and these higher-level frameworks is explored further in the GPU serving framework bridge.

LayerUse whenWatch out for
Raw model server (vLLM/TGI/SGLang on a bare Deployment)You need maximum runtime control, want to tune batching/KV/quantization directly, or are debugging the runtime in isolation.Every team reinvents lifecycle — Deployments, probes, rollout, autoscaling — and the platform team cannot govern them uniformly.
Inference controller (KServe)You need a standardized, platform-owned inference lifecycle: declared endpoints, revisions, rollout, autoscaling, status across many teams.Unusual topologies (custom multi-model graphs, exotic routing) can fight the CRD's abstraction; you may need an escape hatch back to raw Deployments.
Serving framework (Ray Serve / KubeRay)You need Python-defined composition of multiple steps/models and distributed workers, owned by the application team.Its API is less familiar to Kubernetes-only operators; the platform now has a Ray cluster to run, and lifecycle governance lives partly in Python rather than in declarative resources.

5 · The decision rule: pick by the hard part, keep boundaries explicit

Because all three expose HTTP, the temptation is to pick by familiarity. Pick by where the difficulty lives instead:

The hard part is per-token efficiency (latency, throughput, $/1M tokens) → fix the runtime first. Choose and tune the model server. A controller or framework cannot recover throughput you lost in the runtime (§2).
The hard part is many teams safely deploying endpoints (rollout, autoscaling, multitenancy, a uniform team-facing API) → put a controller first. KServe standardizes the lifecycle (§3).
The hard part is composing a multi-step Python application across distributed workers → evaluate a framework. Ray Serve owns the composition; the server still owns tokens inside it (§4).

And whichever you adopt, keep the boundaries explicit. The controller must not obscure runtime metrics — you should still be able to read vLLM's own throughput and KV-cache occupancy without parsing reconcile logs. The runtime must not become the organization's policy system — rollout and quota are the controller's job, not a server flag. The framework must not silently re-implement lifecycle the platform already standardized. Layers that bleed into each other produce a platform that is simultaneously rigid (you cannot change one layer without the other) and hard to debug (no layer owns a clean answer).

6 · Failure modes and a checklist

Failure modes

  • Reaching for a controller to fix a runtime bottleneck. Latency is high, so the team adopts KServe expecting it to help — but the bottleneck was no continuous batching or an undersized KV cache. Signal: p99 does not move after the controller lands, while GPU utilization stays low. The fix lived one layer down, in the server (§2).
  • Letting data scientists own K8s lifecycle. Each model team hand-writes Deployments, probes, and rollout logic the platform should standardize, so policy drifts and incidents differ per team. Signal: two endpoints with different readiness semantics and no shared autoscaling defaults. A controller exists precisely to take this back (§3).
  • Treating Ray, KServe, and vLLM as interchangeable. "They all serve a model over HTTP, pick whichever." Signal: a comparison doc that ranks them in one flat list. It hides that they own different things; you can and often should run all three (§1, §4).
  • Forcing an unusual topology through a CRD. A custom multi-model graph is bent to fit InferenceService with annotations and sidecars until no one understands it. Signal: more YAML escaping the abstraction than using it — that workload wanted a framework, or a raw Deployment (§4).
  • No escape hatch to a raw Deployment. When the controller misbehaves there is no way to run the server directly to isolate the problem. Signal: a runtime bug can only be reproduced through the full controller path, multiplying debugging time.

Implementation checklist

  • Have you picked the runtime by workload shape — chat, long-context, structured-output, LoRA-per-tenant, multimodal, or batch — rather than by default familiarity?
  • Have you picked the controller by lifecycle needs — rollout, autoscaling, multitenancy, and a uniform team-facing API — and not to paper over a runtime problem?
  • Is it KServe when a standard, declarative inference lifecycle is what matters, and Ray Serve when Python-native composition plus distributed workers is what matters?
  • Can each layer expose its own observability — server throughput/KV occupancy, controller revisions/status — without reading the other layer's internals?
  • Is rollout and quota policy owned by the controller, not encoded in server flags?
  • Do you keep an escape hatch to run the raw model server on a bare Deployment for debugging the runtime in isolation?

Checkpoint exercise

Try it
Take a real workload: an internal assistant that must serve one 70B chat model to five product teams, each with its own LoRA fine-tune, behind a shared API, with weekly model-revision rollouts. Decide which layer the hard part lives in, then assign each concern — LoRA hot-swapping, KV-cache sizing, canary rollout, per-team quota, the request graph if there is one — to the server, the controller, or a framework. For any concern you are tempted to push into the wrong layer, name the symptom that would later reveal the mistake.

Where this points next

This lesson assumed the model weights were simply there — the KServe spec waved at storageUri: "s3://models/llama-13b" and the Ray deployment called load_vllm(...) as if loading a 26 GB artifact were free. It is not. Where the weights live, how they are versioned, how a pod pulls tens of gigabytes onto a GPU node before it can become ready, and how a controller decides which artifact a revision points at are their own discipline. Lesson 04, Model Artifacts and Registries, adds that machinery: model storage, registries, versioning, and the pull path that feeds the server layer you just learned to own.

Takeaway
A model server, an inference controller, and a programmable serving framework are not competitors on a shortlist — they own different things, and a real platform often runs all three at once. The server (vLLM, TGI, SGLang) owns token execution: prefill, decode, KV cache, continuous batching, prefix caching, quantization, speculative decoding, LoRA — this is where per-token efficiency and most of the cost live. The controller (KServe) owns lifecycle: one declarative InferenceService subsumes the manual Deployment + Service + probe from lesson 02 and gives the platform team a uniform, governable serving API. The framework (Ray Serve / KubeRay) owns Python-native composition across distributed workers — with a real server still owning tokens inside it. KServe is Kubernetes-native (platform owns the API); Ray Serve is Python-native (code owns the composition). Pick by the hard part: per-token efficiency → runtime first; many teams deploying safely → controller first; multi-step Python app → framework. Keep the boundaries explicit, because the layers that bleed together are the platforms that end up both rigid and impossible to debug.

Interview prompts