Education › AI Engineering › Stage 4: MLOps & AIOps

Serving models in production

APIs vs. self-hosting, GPUs, autoscaling, and rolling out model changes safely.

Advanced ~35 min read Module 13 of 16

A model in a notebook is an experiment. A model that answers production traffic is a service, with all the obligations that the SRE track described: an SLO, capacity, rollouts, and someone on call. Model serving has its own twist on each of them. The scarce resource is GPU memory and not CPU. Instances take minutes to start instead of seconds. And a "deploy" can change behaviour without changing a line of code. This module is where the three tracks of this site meet: you will serve a model behind an API, run it on Kubernetes, scale it on the right signal, and roll out model changes as carefully as any other release.

After this module you can
  • Weigh a hosted model API against self-hosting, on data control, cost, operations and latency
  • Explain what makes inference different from web serving: GPU memory, the KV cache, batching, and throughput against latency
  • Package a model as a service with health checks that respect its slow start-up
  • Schedule GPU workloads on Kubernetes, and autoscale them on queue depth or concurrency instead of CPU
  • Roll out model changes safely, with version pinning, shadow traffic, canaries and rollback

Hosted API or self-hosted?

The first decision is whether to run the model at all. Calling a provider's API and operating your own inference fleet are very different commitments, and for most teams, most of the time, the API is the right answer.

Hosted APISelf-hosted open-weight model
Time to first resultMinutesDays to weeks
Model qualityAccess to the most capable modelsLimited to open-weight models; often enough for narrow tasks
Cost modelPay per token; nothing when idlePay per GPU-hour, whether busy or not; cheaper only at high, steady utilisation
OperationsThe provider's problemYours: drivers, scheduling, scaling, upgrades, on-call
Data controlData leaves your network, under the provider's termsData stays in your environment
LatencyNetwork round trip, plus the provider's queueUnder your control; can sit next to your application
CustomisationPrompts, and the provider's fine-tuning optionsFull: fine-tune, quantise, change the serving stack
DependencyRate limits, price changes, model retirementGPU supply, and your own team's capacity

Self-hosting is justified by specific needs: data that may not leave your environment for legal or contractual reasons, very high and steady volume where your own GPUs work out cheaper, strict latency requirements, operation without internet access, or a small fine-tuned model that does one narrow job well. It is not justified by a vague wish for control. Be honest about the full cost, which includes idle GPUs at night and the engineers who keep the stack running.

Classical models are another matter. A gradient-boosted tree, or a small neural network that scores a fraud risk, is cheap to run on CPUs, has no hosted equivalent, and is nearly always served by your own team. The serving principles in the rest of this module apply to both kinds, and the GPU material applies to large models.

Tip

Many teams settle on a hybrid: a hosted API for the hard, general tasks, and a small self-hosted model for a high-volume, narrow, latency-sensitive step such as classification or embedding. Put both behind one internal interface, so that you can move a task between them by changing configuration.

Why inference is not web serving

A typical web service is limited by CPU and I/O, starts in seconds, and handles each request independently. Large-model inference breaks all three of those assumptions.

  • GPU memory is the constraint. The model's weights have to fit in GPU memory, and they are large: roughly two bytes per parameter at 16-bit precision, so a model with 7 billion parameters needs about 14 GB for its weights alone. On top of that comes the KV cache, the memory that holds the attention state of every token of every request being processed. It grows with the length of the context and with the number of concurrent requests, and it is what usually limits how many users one GPU can serve.
  • Two phases with different characters. Prefill processes the whole prompt in parallel, and is limited by computation. It determines the time to first token. Decode then generates one token at a time, and is limited by memory bandwidth. It determines the tokens per second that a user sees.
  • Batching is essential. A GPU processing one request at a time is mostly idle. Serving engines group requests, so that a single pass through the model advances many of them together. Continuous batching goes further: requests join and leave the batch at every step, without waiting for the others to finish, which raises throughput enormously.
  • Throughput trades against latency. Bigger batches serve more users per GPU, and make each user's tokens arrive a little more slowly. You choose a point on that curve, guided by your latency SLO.
  • Start-up is slow. Pulling an image of several gigabytes, loading tens of gigabytes of weights into GPU memory and warming up can take minutes. That changes how you scale, how you roll out, and how much headroom you keep.
text
GPU memory needed  ~  weights + KV cache + activations and overhead

weights            ~  parameters x bytes per parameter
                      7B params x 2 bytes (16-bit)  = about 14 GB
                      7B params x 1 byte  (8-bit)   = about  7 GB
                      7B params x 0.5 byte (4-bit)  = about  3.5 GB

KV cache           grows with: concurrent requests x tokens per request x layers x hidden size
                   -> long contexts and high concurrency are what exhaust memory

Quantisation stores the weights at lower precision, in 8 or 4 bits instead of 16. It cuts the memory and often raises the speed, at some cost in quality, which you must measure on your own evals instead of assuming. It is frequently what allows a model to fit on a smaller, cheaper GPU at all. Models too large for one GPU are split across several with tensor or pipeline parallelism, which the serving engine handles, at a cost in complexity and in communication between GPUs.

You rarely write this machinery yourself. Open-source inference servers such as vLLM, Hugging Face Text Generation Inference, and NVIDIA Triton implement continuous batching, efficient management of the KV cache, quantisation and streaming, and several of them expose an HTTP API compatible with the common chat-completion format, so that the client code barely changes.

Packaging a model as a service

Whatever the model, the service around it follows the same rules. Load the model once, at start-up, never on each request. Validate the inputs. Expose health endpoints that tell the truth about whether the model is ready. Report the model version with every response. This example serves a classical scikit-learn pipeline of the kind built in the core ML module.

app.py
python
import os
import time
from contextlib import asynccontextmanager

import joblib
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

MODEL_PATH = os.environ.get("MODEL_PATH", "/models/churn-pipeline.joblib")
MODEL_VERSION = os.environ.get("MODEL_VERSION", "unknown")
state = {"model": None}


@asynccontextmanager
async def lifespan(app: FastAPI):
    state["model"] = joblib.load(MODEL_PATH)      # load ONCE, before serving traffic
    yield
    state["model"] = None


app = FastAPI(lifespan=lifespan)


class Features(BaseModel):
    tenure_months: int = Field(ge=0, le=600)
    monthly_spend: float = Field(ge=0)
    support_tickets_90d: int = Field(ge=0)
    plan: str


@app.get("/healthz")
def liveness():
    return {"status": "alive"}                    # the process is up; nothing more


@app.get("/ready")
def readiness():
    if state["model"] is None:
        raise HTTPException(status_code=503, detail="model not loaded")
    return {"status": "ready", "model_version": MODEL_VERSION}


@app.post("/predict")
def predict(features: Features):
    started = time.perf_counter()
    frame = pd.DataFrame([features.model_dump()])
    probability = float(state["model"].predict_proba(frame)[0, 1])
    return {"churn_probability": probability, "model_version": MODEL_VERSION,
            "latency_ms": round((time.perf_counter() - started) * 1000, 1)}
  • Ship the whole pipeline, with its preprocessing, as a single artifact, so that serving transforms the inputs exactly as training did. A mismatch between the two is training-serving skew, a classic and silent cause of bad predictions.
  • Validate the inputs with a schema. Values that are out of range or missing should be rejected, or at least counted, since they are often the first sign of a fault upstream.
  • Return the model version with every prediction, and log it. When the behaviour changes, you need to know which model produced which answer.
  • Instrument it with the golden signals from the SRE track, together with model-specific ones: the distribution of predictions, the distributions of the inputs, and, for LLMs, the time to first token, tokens per second, queue time and tokens per request.
  • Keep the weights out of the image when they are large. Pull them at start-up from object storage or a model registry, onto a cached volume, so that images stay small and one image can serve many versions of a model.
  • Treat model files as untrusted code. Pickle-based formats, including joblib files, can execute arbitrary code when they are loaded. Load only artifacts that your own pipeline produced, and prefer safe formats such as safetensors for neural network weights.

GPUs on Kubernetes

Kubernetes schedules GPUs as an extended resource, which the vendor's device plugin advertises. A pod requests whole GPUs under limits, and the scheduler places it on a node that has one free. GPUs are not overcommitted: a GPU assigned to one pod is unavailable to every other, unless you deliberately configure a sharing mechanism.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-server
  namespace: ml
spec:
  replicas: 2
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0            # never reduce serving capacity during a rollout
  selector:
    matchLabels:
      app: llm-server
  template:
    metadata:
      labels:
        app: llm-server
    spec:
      terminationGracePeriodSeconds: 120      # let in-flight generations finish
      nodeSelector:
        accelerator: nvidia-gpu
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      containers:
        - name: server
          image: registry.example.com/ml/llm-server:1.8.0
          env:
            - name: MODEL_VERSION
              value: "support-assistant-2026-09-01"
          ports:
            - containerPort: 8000
          resources:
            requests:
              cpu: "4"
              memory: 32Gi
            limits:
              nvidia.com/gpu: 1
              memory: 32Gi
          startupProbe:                        # loading weights takes minutes
            httpGet:
              path: /ready
              port: 8000
            periodSeconds: 10
            failureThreshold: 60               # allow up to 10 minutes to start
          readinessProbe:
            httpGet:
              path: /ready
              port: 8000
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8000
            periodSeconds: 20
          volumeMounts:
            - name: model-cache
              mountPath: /models
      volumes:
        - name: model-cache
          persistentVolumeClaim:
            claimName: model-cache
GPU PODSstreamed responsesreadiness-gatedpull at start-up/metricscustom metricscale 2 to 8UsersServiceready pods onlyInference podreadyInference podreadyInference podloading: 4 minModel weightsobject storage, cachedAutoscalerin-flight requestsPrometheusTTFT, tokens/s, queue
Serving a model on Kubernetes: weights come from object storage onto a cached volume, GPU pods only receive traffic once a startup probe confirms the model is loaded, and the autoscaler watches requests in flight rather than CPU.
  • The startup probe is essential. Without one, the liveness probe kills the container while it is still loading its weights, and the pod never becomes ready. This is the most common failure when a model is first deployed.
  • Readiness must mean that the model is loaded, not merely that the process has started, or traffic will arrive at a pod that cannot serve it.
  • Taints and node selectors keep GPU nodes for GPU workloads, so that expensive hardware is not filled with ordinary pods.
  • maxUnavailable: 0 matters more than usual, because a replacement takes minutes to become ready, and removing a pod early leaves a long gap in capacity.
  • A generous termination grace period lets streaming responses complete before the pod is removed.
  • GPU nodes are expensive, and they are the natural target of the FinOps module: tag them, watch their utilisation, and scale them down when they are idle.

Scaling on the right signal

The autoscaling habit of targeting CPU utilisation fails for inference. The CPU of a GPU server tells you nothing, and GPU utilisation is misleading as well, because a GPU can report itself as fully busy at very different levels of real load. Scale on what reflects demand against capacity: queue depth, the number of requests in flight, or the time that requests spend waiting.

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-server
  namespace: ml
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-server
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Pods
      pods:
        metric:
          name: inference_requests_in_flight     # exposed through a custom metrics adapter
        target:
          type: AverageValue
          averageValue: "12"                     # tuned from a load test against the latency SLO
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
    scaleDown:
      stabilizationWindowSeconds: 600            # scale in slowly: a replacement costs minutes
  • Find the target with a load test, as the capacity module described. Raise the concurrency until the time to first token, or the tokens per second, breaches your SLO. The target is somewhat below that point.
  • Cold starts dominate. If a new replica takes five minutes to serve, the autoscaler cannot rescue you from a spike that arrives in thirty seconds. Keep more headroom than you would for a web service, pre-scale before known peaks, and cache the weights on the node or on a fast volume.
  • Bound the queue, and shed load. When you are saturated, reject quickly with 429 or 503, in preference to letting the queue grow without limit. A request that waits two minutes helps nobody. This is the backpressure of the failure-modes module.
  • Limit the work per request: a maximum input length, a maximum number of output tokens, and a timeout. One pathological request should not be able to occupy a GPU for minutes.
  • Scale to zero only for workloads that can tolerate a cold start of several minutes, such as internal batch tools. For interactive use, keep a warm minimum.
  • Separate traffic by kind. Interactive requests and bulk batch jobs have opposite needs. Give each its own pool, or a priority queue, so that a batch job cannot starve live users.

Rolling out model changes

A new model is a release that changes behaviour, and it is more dangerous than a code release because the failure is quiet. Nothing crashes, and no error rate rises. The answers simply become worse for some kinds of input. The same applies to a changed prompt, a new version of a provider's model, a different quantisation, or an upgraded serving engine. Apply the progressive delivery of the SRE track, with checks adapted to quality.

  1. Pin versions. Refer to an exact version of the model, or of the provider's snapshot, and never to a moving alias such as "latest". An unannounced change in a model is an unannounced production deploy.
  2. Pass the offline gate. The candidate must meet the thresholds of your eval suite, on quality, safety, latency and cost, before it goes near any traffic.
  3. Shadow it. Send a copy of real production requests to the candidate, discard its responses, and compare them offline with those of the current model. Users are not affected, and the traffic is real. This suits models well, because most predictions have no side effects.
  4. Canary it. Route a small share of users to the new model. Watch the system metrics, together with the quality signals: user feedback, the rate at which users retry or rephrase, the escalation rate, scores from a sampled judge, and the distribution of outputs.
  5. A/B test when the question is whether it is better for the business, and not only whether it is safe. Assign users consistently to one arm, and measure the outcome that you care about.
  6. Keep rollback instant. Leave the previous model loaded, or quick to load, and make switching a change of configuration. With start-up times of several minutes, a rollback that needs a cold start is too slow.
Watch out

Providers retire old model versions on a published schedule. Track the deprecation dates of every model you depend on, and rehearse the migration before the deadline, with your eval suite as the judge. A forced migration in the final week, with no evals, is how regressions in quality reach production.

Record the model version, the prompt version and the serving configuration with every prediction. When someone asks why the assistant said something last Tuesday, that record is the only route to an answer, and it is the starting point for the observability module that follows.

Hands-on practice

Serve a model like a production service

  1. Take the scikit-learn pipeline from the core ML module. Save it with joblib, and wrap it in the FastAPI service from this module, with /healthz, /ready and /predict, and with the model version in every response.
  2. Containerise it with a multi-stage build and a non-root user. Supply the model file through a mounted volume or an environment variable, and not baked into the image.
  3. Add a Prometheus histogram for prediction latency, a counter for requests by status, and a histogram of the predicted probability. Confirm that they appear at /metrics.
  4. Deploy it to your cluster with separate startup, readiness and liveness probes. Add a ten-second sleep before the model loads, to simulate a slow start. Remove the startup probe, watch the pod get killed in a loop, and then put the probe back.
  5. Load test it with an open-model tool, as in the capacity module. Find the concurrency at which p99 latency breaches your target, and set an autoscaling target below it.
  6. Train a second, slightly different model. Deploy it as a shadow: send each request to both, return only the current model's answer, and log both. Compare the two distributions of predictions.
  7. Route 10% of traffic to the new model as a canary. Define in advance which metrics would cause you to roll back, then practise the rollback, and time it.
  8. Optional, if you have access to a GPU: serve a small open-weight language model with an inference server such as vLLM. Measure the time to first token and the tokens per second at concurrency 1, 8 and 32, and observe the trade-off between throughput and latency.
Cheat sheet

Serving models in production — at a glance

Main things to focus on

  • Default to a hosted API. Self-host only for data control, steady high volume, strict latency, or a narrow fine-tuned model.
  • GPU memory is the constraint: weights plus the KV cache, which grows with context length and concurrency.
  • Prefill sets the time to first token, decode sets the tokens per second. Continuous batching trades a little latency for much more throughput.
  • Load the model once at start-up. Readiness means the model is loaded, and a startup probe covers the minutes of loading.
  • Ship preprocessing and model as one artifact, to avoid training-serving skew. Return the model version with every prediction.
  • Scale on queue depth or in-flight requests, not on CPU. Keep headroom, because cold starts take minutes.
  • Bound the queue, and limit input length, output tokens and time per request.
  • A model change is a release: pin versions, gate on evals, shadow, canary, and keep rollback instant.

Sizing rules of thumb

weights ~ params x bytes per param16-bit: 2 bytes. 8-bit: 1 byte. 4-bit: 0.5 byte.
7B at 16-bit ~ 14 GBBefore the KV cache and overhead
KV cache ~ requests x tokens x layers x hidden sizeWhat limits the concurrency on a GPU
latency = TTFT + output tokens / tokens per secondPrefill, then decode
larger batch = more throughput, slower per userChoose the point from your latency SLO
GPU cost = GPU-hours x price, busy or idleUtilisation decides whether self-hosting pays

Service essentials

load the model in the lifespan / start-up hookOnce per process, never per request
GET /healthzLiveness: the process is running
GET /ready -> 503 until the model is loadedReadiness: safe to receive traffic
pydantic BaseModel with Field(ge=, le=)Validate every input
"model_version" in every responseTraceability
joblib.load / safetensorsLoad only trusted artifacts; pickles can execute code
weights from object storage + a cached volumeSmall images and quicker restarts

Kubernetes for inference

resources.limits: nvidia.com/gpu: 1Request a whole GPU; GPUs are not overcommitted
startupProbe: failureThreshold x periodSecondsThe time allowed for loading the weights
readinessProbe -> /readyNo traffic until the model is loaded
nodeSelector + tolerationsKeep GPU nodes for GPU workloads
maxUnavailable: 0Replacements take minutes; never drop capacity
terminationGracePeriodSeconds: 120Let streaming responses finish
kubectl describe node NAME | grep -A5 nvidiaSee the allocatable and the allocated GPUs

Metrics to expose

time to first token (histogram)Responsiveness as the user perceives it
tokens per second, per requestThe speed of generation
requests in flight / queue depthThe signal for autoscaling
queue wait timeSaturation
input and output tokens per requestCost, and detection of abuse
GPU memory used, KV cache usageCloseness to the real limit
distribution of predictionsDetects behavioural drift
requests by model_versionEssential during rollouts

Rollout ladder

1. pin exact versionsNo moving aliases
2. offline eval gateQuality, safety, latency, cost
3. shadow trafficReal inputs, responses discarded, compared offline
4. canary at a small percentageSystem metrics together with quality signals
5. A/B testIs it better for the business?
6. instant rollbackPrevious model kept warm; a change of configuration

Common pitfalls

  • Self-hosting for the sake of control, then paying for idle GPUs and for an on-call rotation.
  • Loading the model inside the request handler, so that every request pays the loading time.
  • Omitting the startup probe, so that the liveness probe kills the pod while it is loading its weights.
  • Autoscaling on CPU utilisation, which says nothing about the load on a GPU inference server.
  • Letting the request queue grow without limit, instead of shedding load when saturated.
  • Pointing at a "latest" alias of a model, and receiving a behaviour change that nobody chose.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →