Ch 2 — Model Deployment¶
Chapter Meta
Level: Advanced | Reading time: 90 min | Volume: 9 — MLOps
Learning Objectives¶
By the end of this chapter you will be able to:
- Select the appropriate serving pattern — online, batch, or streaming — for a given ML use case based on latency requirements, throughput, and cost constraints.
- Build a production-ready LLM serving endpoint with FastAPI, including async streaming via Server-Sent Events, Pydantic validation, and JWT authentication middleware.
- Export a model to ONNX or TorchScript and understand when to use SafeTensors for safe, cross-framework weight storage.
- Apply quantisation (INT8, INT4/GPTQ), continuous batching, Flash Attention, and KV cache management to reduce LLM inference cost and latency.
- Implement blue-green and canary deployment strategies with traffic splitting, monitoring hooks, and automated rollback procedures.
Serving Patterns¶
Choosing the right serving pattern before building infrastructure saves weeks of rework. The three fundamental patterns each make different trade-offs across latency, throughput, and cost:
| Dimension | Online (Real-time) | Batch | Streaming |
|---|---|---|---|
| Latency | < 100–500 ms (p99) | Minutes to hours | 1–5 s (near-real-time) |
| Throughput | Low to medium req/s | Very high (millions/run) | Medium events/s |
| Trigger | Synchronous HTTP request | Scheduled job / pipeline event | Message queue (Kafka, Kinesis) |
| Infrastructure | Load-balanced REST API | Batch compute (Spark, Ray) | Stream processor (Flink, Spark Streaming) |
| Use cases | Fraud detection, chatbots, search ranking | Nightly churn scoring, bulk embeddings | Real-time recommendations, anomaly detection on event streams |
| Cost model | Pay for peak capacity (always-on GPUs) | Pay only for compute time | Pay for stream processing + compute |
Latency Cliff
Online serving at p99 < 100 ms leaves almost no room for model loading, serialisation overhead, or remote feature lookup. Profile the full request path — not just model inference time — before committing to a latency SLO.
REST API with FastAPI for LLM Serving¶
The following production endpoint serves an LLM with streaming output, JWT authentication, Pydantic validation, and async handling. Every line is typed and PEP 8 compliant.
"""
serve.py — Production LLM serving endpoint with FastAPI.
Requirements:
pip install fastapi uvicorn[standard] pydantic python-jose[cryptography] vllm
"""
from __future__ import annotations
import json
import logging
import os
import time
from typing import AsyncIterator
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel, Field
from vllm import AsyncLLMEngine, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
logger = logging.getLogger(__name__)
security = HTTPBearer()
# ── Auth ──────────────────────────────────────────────────────────────────────
SECRET_KEY: str = os.environ["JWT_SECRET_KEY"]
ALGORITHM: str = "HS256"
def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""Validate JWT bearer token; raise 401 on failure."""
try:
return jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
) from exc
# ── Request / response models ─────────────────────────────────────────────────
class GenerateRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=32_000)
max_tokens: int = Field(default=512, ge=1, le=4096)
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
top_p: float = Field(default=0.95, ge=0.0, le=1.0)
stream: bool = Field(default=False)
class GenerateResponse(BaseModel):
text: str
tokens_generated: int
latency_ms: float
# ── App + engine ──────────────────────────────────────────────────────────────
app = FastAPI(title="LLM Inference API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["POST", "GET"],
allow_headers=["Authorization", "Content-Type"],
)
engine: AsyncLLMEngine | None = None
@app.on_event("startup")
async def load_model() -> None:
global engine
engine = AsyncLLMEngine.from_engine_args(
AsyncEngineArgs(
model=os.environ.get("MODEL_ID", "meta-llama/Llama-3-8B-Instruct"),
dtype="bfloat16",
max_model_len=8192,
gpu_memory_utilization=0.90,
)
)
logger.info("vLLM engine ready")
# ── SSE streaming helper ──────────────────────────────────────────────────────
async def token_stream(
request_id: str,
prompt: str,
params: SamplingParams,
) -> AsyncIterator[str]:
async for output in engine.generate(prompt, params, request_id): # type: ignore[union-attr]
for completion in output.outputs:
yield f"data: {json.dumps({'token': completion.text})}\n\n"
yield "data: [DONE]\n\n"
# ── Endpoints ─────────────────────────────────────────────────────────────────
@app.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
claims: dict = Depends(verify_token),
) -> GenerateResponse | StreamingResponse:
if engine is None:
raise HTTPException(status_code=503, detail="Model not loaded")
params = SamplingParams(
temperature=body.temperature,
top_p=body.top_p,
max_tokens=body.max_tokens,
)
request_id = f"{claims.get('sub', 'anon')}-{time.monotonic_ns()}"
if body.stream:
return StreamingResponse(
token_stream(request_id, body.prompt, params),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
t0 = time.monotonic()
outputs = []
async for out in engine.generate(body.prompt, params, request_id):
outputs = out.outputs
text = outputs[0].text if outputs else ""
return GenerateResponse(
text=text,
tokens_generated=len(outputs[0].token_ids) if outputs else 0,
latency_ms=round((time.monotonic() - t0) * 1000, 2),
)
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "model_loaded": engine is not None}
# Single Uvicorn worker per GPU — vLLM handles concurrency internally
uvicorn serve:app --host 0.0.0.0 --port 8080 --loop uvloop
Model Serialisation¶
ONNX Export from PyTorch¶
ONNX produces a hardware-agnostic graph representation that ONNX Runtime can execute with CPU or GPU-specific optimisations (TensorRT, OpenVINO).
"""
export_onnx.py — Export a PyTorch classifier to ONNX with dynamic batch size.
Requirements:
pip install torch onnx onnxruntime
"""
from __future__ import annotations
import numpy as np
import onnxruntime as ort
import torch
import torch.nn as nn
def export_to_onnx(
model: nn.Module,
sample_input: torch.Tensor,
output_path: str = "model.onnx",
opset_version: int = 17,
) -> None:
"""Export model to ONNX with dynamic batch size and sequence length."""
model.eval()
with torch.no_grad():
torch.onnx.export(
model,
sample_input,
output_path,
opset_version=opset_version,
input_names=["input_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch_size", 1: "sequence_length"},
"logits": {0: "batch_size"},
},
do_constant_folding=True,
)
print(f"Exported ONNX model → {output_path}")
def run_onnx_inference(
model_path: str,
input_ids: np.ndarray,
) -> np.ndarray:
"""Run inference with ONNX Runtime (CUDA preferred, CPU fallback)."""
session = ort.InferenceSession(
model_path,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
return session.run(output_names=["logits"], input_feed={"input_ids": input_ids})[0]
TorchScript (torch.jit.trace)¶
TorchScript compiles a model to a static graph, enabling deployment without a Python runtime and C++ embedding.
import torch
import torch.nn as nn
def export_torchscript(
model: nn.Module,
sample_input: torch.Tensor,
output_path: str = "model_scripted.pt",
) -> None:
"""Trace model to TorchScript for C++ or mobile deployment."""
model.eval()
with torch.no_grad():
scripted = torch.jit.trace(model, sample_input)
scripted.save(output_path)
def load_torchscript(model_path: str, x: torch.Tensor) -> torch.Tensor:
loaded = torch.jit.load(model_path)
loaded.eval()
with torch.no_grad():
return loaded(x)
SafeTensors¶
SafeTensors is Hugging Face's format for model weights. It is safe (no pickle, no code execution on load), fast (memory-mapped), and cross-framework. It is now the default for models on the Hugging Face Hub.
from safetensors.torch import load_file, save_file
import torch
# Save
save_file(model.state_dict(), "model.safetensors")
# Load — zero-copy memory map, fast and safe
state_dict = load_file("model.safetensors", device="cuda")
model.load_state_dict(state_dict)
Inference Optimisation Techniques¶
Quantisation¶
Quantisation reduces the numerical precision of weights, shrinking GPU memory footprint and increasing throughput.
INT8 weight quantisation (PyTorch):
import torch
from torch.quantization import quantize_dynamic
# Dynamically quantise Linear layers to INT8
quantised = quantize_dynamic(
model,
qconfig_spec={torch.nn.Linear},
dtype=torch.qint8,
)
# Memory comparison
fp32_mb = sum(p.numel() * 4 for p in model.parameters()) / 1e6
int8_mb = sum(p.numel() * 1 for p in model.parameters()) / 1e6
print(f"FP32: {fp32_mb:.0f} MB → INT8: {int8_mb:.0f} MB (4× reduction)")
INT4 quantisation with GPTQ:
GPTQ (Generalised Post-Training Quantisation) compresses LLM weights to 4 bits using layer-wise second-order weight correction on a calibration dataset.
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
from transformers import AutoTokenizer
quant_config = BaseQuantizeConfig(bits=4, group_size=128, desc_act=False)
tokeniser = AutoTokenizer.from_pretrained("meta-llama/Llama-3-8B-Instruct")
model = AutoGPTQForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B-Instruct",
quantize_config=quant_config,
)
# Calibration data (representative production queries)
calibration = [
tokeniser("Summarise the following document.", return_tensors="pt").input_ids,
tokeniser("What are the key risks?", return_tensors="pt").input_ids,
]
model.quantize(calibration)
model.save_quantized("llama3-8b-int4-gptq")
Memory savings for Llama-3-8B:
| Precision | Bytes/param | Model memory | Reduction vs BF16 |
|---|---|---|---|
| FP32 | 4 | 32 GB | — |
| BF16 | 2 | 16 GB | baseline |
| INT8 | 1 | 8 GB | 2× |
| INT4 (GPTQ) | 0.5 | 4 GB | 4× |
Continuous Batching¶
Naive static batching pads all sequences in a batch to the longest, wasting compute on padding tokens. Continuous batching (iteration-level scheduling) evicts completed sequences and inserts new requests into the freed KV cache slots at each decode step — no padding required.
Static batching (batch of 4):
Lengths: [128, 512, 64, 1024] → all padded to 1024
Wasted FLOPs: 896 + 512 + 960 = 2,368 padding token steps
Continuous batching:
At step 64: sequence 3 finishes → new request inserted
At step 128: sequence 1 finishes → new request inserted
GPU utilisation: ~90% vs ~40–60% for static batching
Flash Attention¶
Standard attention materialises the full N×N attention matrix in HBM (high-bandwidth memory), requiring O(N²) memory. Flash Attention (Dao et al., 2022) tiles the computation in SRAM, reducing memory complexity to O(N) and eliminating redundant HBM reads/writes.
| Metric | Standard Attention | Flash Attention 2 |
|---|---|---|
| Memory complexity | O(N²) | O(N) |
| HBM reads/writes | O(N²) | O(N) |
| Speed-up on A100 | 1× baseline | 2–4× |
from transformers import AutoModelForCausalLM
import torch
# Flash Attention 2 — enabled via attn_implementation flag
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B-Instruct",
attn_implementation="flash_attention_2",
torch_dtype=torch.bfloat16,
device_map="auto",
)
KV Cache¶
During autoregressive decoding, the Key (K) and Value (V) tensors from previous tokens are cached to avoid recomputation at each step. KV cache is the primary factor limiting maximum context length and batch size.
KV cache memory formula:
Example — Llama-3-8B (32 layers, 32 heads, head dim 128) at BF16, context 4096, batch 8:
On a 16 GB GPU with the BF16 model weight at 16 GB, this is not feasible — INT4 quantisation of weights (4 GB) makes it viable.
vLLM: PagedAttention¶
vLLM implements PagedAttention, managing the KV cache in fixed-size pages (analogous to OS virtual memory paging). Benefits:
- Sequences of different lengths share GPU memory without pre-allocating worst-case contiguous blocks.
- KV cache utilisation approaches 100% of available HBM.
- Throughput is 3–24× higher than naive HuggingFace
generate()under concurrent load.
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-3-8B-Instruct",
dtype="bfloat16",
max_model_len=8192,
gpu_memory_utilization=0.90,
)
prompts = [
"Explain the attention mechanism in transformers.",
"What is MLOps?",
"Summarise the EU AI Act in three bullet points.",
]
params = SamplingParams(temperature=0.7, max_tokens=512)
outputs = llm.generate(prompts, params)
for output in outputs:
print(output.outputs[0].text[:100])
Horizontal Scaling¶
nginx Load Balancer Configuration¶
# /etc/nginx/conf.d/llm-api.conf
upstream llm_backends {
least_conn; # route to backend with fewest active connections
server llm-pod-0.ml-serving:8080;
server llm-pod-1.ml-serving:8080;
server llm-pod-2.ml-serving:8080;
keepalive 64;
}
server {
listen 80;
server_name api.example.internal;
location /generate {
proxy_pass http://llm_backends;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
proxy_buffering off; # required for SSE streaming
}
location /health {
proxy_pass http://llm_backends;
access_log off;
}
}
Kubernetes Deployment with HPA (Target Latency SLO)¶
Scale to maintain p95 latency < 2 s. The HPA reacts before the SLO is breached by scaling at 1.8 s:
# llm-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-hpa
namespace: ml-serving
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-inference
minReplicas: 2
maxReplicas: 16
metrics:
- type: Pods
pods:
metric:
name: http_p95_latency_ms # custom metric via Prometheus adapter
target:
type: AverageValue
averageValue: "1800" # 1.8 s — headroom before 2 s SLO
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
Blue-Green Deployment¶
Blue-green deployment maintains two complete, identical environments. Traffic switches atomically. Rollback is a single command: flip the Service selector back to the stable version.
# Service initially routes all traffic to blue (v1)
apiVersion: v1
kind: Service
metadata:
name: llm-inference-svc
namespace: ml-serving
spec:
selector:
app: llm-inference
version: blue
ports:
- port: 80
targetPort: 8080
Traffic switch (zero-downtime):
# Validate green is healthy before switching
kubectl rollout status deployment/llm-inference-green -n ml-serving
# Atomic switch: all traffic from blue → green
kubectl patch svc llm-inference-svc -n ml-serving \
--patch '{"spec": {"selector": {"version": "green"}}}'
# Verify new endpoints
kubectl get endpoints llm-inference-svc -n ml-serving
Rollback procedure:
# Revert to blue in < 5 seconds
kubectl patch svc llm-inference-svc -n ml-serving \
--patch '{"spec": {"selector": {"version": "blue"}}}'
# Keep green running for 48 h in standby; then scale down
kubectl scale deployment/llm-inference-green --replicas=0 -n ml-serving
Canary Deployment¶
Canary releases gradually shift traffic to a new model version, allowing real-user validation before full commitment. The risk surface is proportional to the traffic fraction sent to the canary.
# Two Deployments sharing the same Service selector
# Ratio of replicas controls the traffic split: 9:1 = 10% canary
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-v1
namespace: ml-serving
spec:
replicas: 9 # 90% of traffic
selector:
matchLabels:
app: llm-inference
version: v1
template:
metadata:
labels:
app: llm-inference
version: v1
spec:
containers:
- name: server
image: my-registry/llm-serve:1.0.0
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-v2
namespace: ml-serving
spec:
replicas: 1 # 10% canary traffic
selector:
matchLabels:
app: llm-inference
version: v2
template:
metadata:
labels:
app: llm-inference
version: v2
spec:
containers:
- name: server
image: my-registry/llm-serve:1.1.0
---
apiVersion: v1
kind: Service
metadata:
name: llm-inference-svc
spec:
selector:
app: llm-inference # matches BOTH v1 and v2 Pods
ports:
- port: 80
targetPort: 8080
Progressive rollout cadence:
| Time | Canary % | Monitor |
|---|---|---|
| T+0h | 5% | Error rate, p99 latency |
| T+2h | 10% | Add: quality metric (faithfulness) |
| T+8h | 25% | Add: business metrics (task completion) |
| T+24h | 50% | A/B test for statistical significance on quality |
| T+48h | 100% | Full rollout; decommission v1 |
Automatic rollback triggers
Define rollback criteria before starting: error rate increase > 1%, p99 latency increase > 20%, or LLM quality metric drop > 5%. Use Argo Rollouts or Flagger for automated canary analysis and rollback.
Exercises¶
-
FastAPI endpoint extension: Add a
/v1/embeddingsendpoint toserve.pythat accepts a list of strings (max 100, each max 8,192 chars) and returns their embeddings from asentence-transformersmodel. Include authentication, Pydantic validation, and a Redis-backed rate limit of 100 requests/minute per JWT token. -
ONNX benchmarking: Export a fine-tuned BERT-base classifier to ONNX. Benchmark p50 and p99 latency (over 1,000 requests, batch size 1) for: (a) PyTorch eager CPU, (b) ONNX Runtime CPU, © ONNX Runtime with
CUDAExecutionProvider. Report throughput (samples/sec) for each configuration. -
Quantisation analysis: Load a 7B parameter LLM in BF16, INT8, and INT4 (GPTQ). Compare: peak GPU memory, throughput (tokens/sec at batch size 8), and perplexity on a held-out corpus. At what precision does perplexity increase by more than 3% relative to BF16?
-
KV cache sizing exercise: Llama-3-70B has 80 layers, 64 attention heads, and head dimension 128. You want to serve it in BF16 with context length 8,192 and batch size 4. Calculate: (a) KV cache memory requirement, (b) model weight memory in BF16, © whether this fits on two A100 80 GB GPUs, (d) whether INT4 quantisation of model weights (only) makes it feasible on one A100.
-
Canary rollout runbook: Write a step-by-step runbook (suitable for a junior engineer to follow at 2 AM) for a canary rollout of a new LLM version (v1 → v2) in Kubernetes. Include: pre-deployment smoke test commands, the replica ratio change sequence, monitoring commands to run at each stage, rollback commands and criteria, and the definition of "deployment success".
Summary¶
- Serving patterns — online, batch, and streaming — each optimise for different latency/throughput/cost tradeoffs; the choice must be made before designing infrastructure.
- FastAPI with vLLM provides a production-grade async endpoint with SSE streaming, JWT auth, and Pydantic validation in under 100 lines of typed Python.
- Model serialisation formats (ONNX, TorchScript, SafeTensors) trade portability against performance; SafeTensors is now the safe default for LLM weights on the Hugging Face Hub.
- Inference optimisation — quantisation (INT8, INT4/GPTQ), continuous batching, Flash Attention, and KV cache management — can achieve 4–24× cost reduction with careful tuning.
- vLLM's PagedAttention eliminates KV cache fragmentation and is the current performance standard for open-model LLM serving.
- Blue-green deployments enable instant atomic rollback; canary deployments enable risk-limited progressive validation with real users; both are essential for production model lifecycle management.