LLM Serving & Inference: Complete Learning Repository
A comprehensive, hands-on guide to learning LLM serving and inference from fundamentals to production deployment.
🎯 What You’ll Learn
This repository teaches you everything about serving Large Language Models in production:
- How LLM inference works (tokenization, attention, generation)
- Basic serving (FastAPI, model loading, endpoints)
- Containerization (Docker for ML workloads)
- Kubernetes deployment (production infrastructure)
- Performance optimization (vLLM, batching, GPU utilization)
- Scaling (autoscaling, load balancing)
- Production practices (monitoring, versioning, drift detection)
📁 Repository Structure
mlops_serving/
├── LEARNING_PATH.md # Start here! Complete learning guide
├── docs/ # Detailed concept explanations
│ ├── llm_inference_fundamentals.md
│ ├── serving_architectures.md
│ └── optimization_techniques.md
├── 01_basic_serving/ # FastAPI + HuggingFace serving
├── 02_docker/ # Containerization
├── 03_kubernetes/ # K8s deployment
├── 04_load_testing/ # Performance testing
├── 05_vllm_serving/ # High-performance serving
├── 06_autoscaling/ # K8s autoscaling
├── 07_canary_deployments/ # Gradual rollouts
├── 08_monitoring/ # Prometheus + Grafana
├── 09_model_versioning/ # Version management
├── 10_drift_detection/ # Evidently integration
└── 11_triton/ # Multi-model serving
🚀 Quick Start
👉 START HERE: Read HOW_TO_START.md for a complete step-by-step guide!
Quick Commands
# 1. Set up environment
cd 01_basic_serving
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
# 2. Start the server
python app.py
# 3. Test it (in another terminal)
python test_api.py
# OR visit http://localhost:8000/docs for interactive API docs
📚 Learning Approach
This is a hands-on, topic-based learning path:
- Each topic is self-contained with code, docs, and exercises
- Build incrementally - each topic builds on previous concepts
- Learn by doing - run code, modify it, break it, fix it
- Focus on understanding - not just copying code
🎓 Prerequisites
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- Docker (for containerization topics)
- Kubernetes cluster (minikube/kind for K8s topics)
- GPU (optional, helpful for vLLM)
📖 Topics Covered
Core Concepts
- LLM architecture and inference pipeline
- Tokenization and vocabulary
- Attention mechanisms and KV caching
- Memory and computation requirements
Serving Basics
- Model loading and initialization
- API endpoint design
- Request/response handling
- Error handling and validation
Infrastructure
- Docker containerization
- Kubernetes deployment
- Health checks and probes
- Resource management
Performance
- Load testing and benchmarking
- Latency vs throughput
- vLLM optimization
- GPU utilization
Production
- Autoscaling strategies
- Canary deployments
- Monitoring and observability
- Model versioning
- Drift detection
🔧 Technologies
- FastAPI: Web framework
- HuggingFace Transformers: Model loading
- vLLM: High-performance inference
- Docker: Containerization
- Kubernetes: Orchestration
- Locust: Load testing
- Prometheus: Metrics
- Grafana: Visualization
- Evidently: Drift detection
- Triton: Multi-model serving
📝 How to Use This Repository
- Start with LEARNING_PATH.md - Understand the structure
- Read the fundamentals - docs/llm_inference_fundamentals.md
- Work through topics in order - Each numbered topic builds on the last
- Experiment and modify - Don’t just run code, change it and learn
- Read the code comments - They explain the “why” not just the “what”
- Check solutions - See EXERCISE_SOLUTIONS.md for exercise answers
- Prepare for interviews - Review INTERVIEW_QA.md for common questions
🎯 Learning Goals
By completing this repository, you’ll be able to:
- Understand how LLM inference works internally
- Build production-ready serving APIs
- Deploy models to Kubernetes
- Optimize for performance and cost
- Monitor and debug serving systems
- Handle model updates and drift
🤝 Contributing
This is a learning repository. Feel free to:
- Add more examples
- Improve documentation
- Fix bugs
- Share your learnings
📄 License
MIT License - Feel free to use this for learning and teaching.
📖 Getting Started
- New to this? → Read
HOW_TO_START.mdfor step-by-step instructions - Want overview? → Read
LEARNING_PATH.mdto see all topics - Ready to code? → Start with
01_basic_serving/ - Need help? → Check
EXERCISE_SOLUTIONS.mdfor exercise answers - Interview prep? → Review
INTERVIEW_QA.mdfor common questions
Ready to start? Open HOW_TO_START.md and begin your journey! 🚀
📚 Additional Resources
- Exercise Solutions:
EXERCISE_SOLUTIONS.md- Answers to all exercises - Interview Q&A:
INTERVIEW_QA.md- 23+ interview questions with detailed answers - Quick Reference:
QUICK_REFERENCE.md- Quick commands and tips - Complete Topics:
COMPLETE_TOPICS.md- Overview of all 12 topics - Industry Boilerplate: Each topic README includes industry-standard code and use cases
🏭 Industry Focus
Each topic now includes:
- Why We Need This: Business and technical justification
- Industry Use Cases: Real-world applications (5+ examples per topic)
- Boilerplate Code: Production-ready, industry-standard implementations
- Company Examples: How major companies use these technologies
Topics updated with industry content:
- ✅ Topic 1: Basic Serving (Customer support, content generation)
- ✅ Topic 2: Docker (Multi-cloud, CI/CD, edge deployment)
- ✅ Topic 3: Kubernetes (Large-scale platforms, enterprise ML)
- ✅ Topic 5: vLLM (High-throughput APIs, cost optimization)
- ✅ Topic 8: Monitoring (Production observability, incident response)
👋 Welcome! Start Here
This repository teaches you everything about LLM serving and inference through hands-on, practical examples.
🎯 What You’ll Learn
You’ll learn how to:
- Serve LLMs in production
- Optimize for performance
- Deploy to Kubernetes
- Monitor and debug systems
- Handle model updates safely
- Detect and handle drift
📚 How This Repository is Organized
Learning Structure
- Topics are numbered (01, 02, 03…) - work through them in order
- Each topic is self-contained - has its own code, docs, and examples
- Builds incrementally - each topic builds on previous concepts
Key Files
| File | Purpose |
|---|---|
HOW_TO_START.md | 👉 START HERE - Step-by-step guide to begin learning |
LEARNING_PATH.md | Overview of all topics and learning approach |
README.md | Repository overview and quick reference |
docs/ | Detailed concept explanations |
01_basic_serving/ | Your first serving example |
02_docker/ | Containerization |
03_kubernetes/ | K8s deployment |
| … | More topics as you progress |
🚀 Your First Steps
1. Read the Start Guide
cat HOW_TO_START.md
This has everything you need to begin.
2. Understand the Fundamentals
cat docs/llm_inference_fundamentals.md
Learn how LLM inference works internally.
3. Run Your First Server
cd 01_basic_serving
pip install -r requirements.txt
python app.py
4. Test It
# In another terminal
python test_api.py
📖 Learning Topics
- LLM Inference Fundamentals - How LLMs work
- Basic Serving - FastAPI + HuggingFace
- Containerization - Docker
- Kubernetes - K8s deployment
- Load Testing - Performance measurement
- vLLM Serving - High-performance serving
- Autoscaling - Scale automatically
- Canary Deployments - Safe rollouts
- Monitoring - Metrics and dashboards
- Model Versioning - Version management
- Drift Detection - Detect issues
- Triton - Multi-model serving
💡 Learning Approach
- Read the documentation
- Study the code
- Run the examples
- Modify and experiment
- Move to the next topic
✅ Prerequisites
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- (Optional) Docker and Kubernetes
Don’t have Docker/K8s? No problem - you can learn the basics without them!
🎓 Ready to Start?
👉 Open HOW_TO_START.md and follow the step-by-step guide!
Questions? Check the README.md in each topic directory for detailed explanations.
Stuck? Read error messages carefully, check the docs, and experiment with simpler examples first.
Let’s learn! 🚀
How to Start Learning LLM Serving & Inference
🎯 Your Learning Journey Starts Here
This guide will walk you through exactly how to start learning LLM serving and inference, step by step.
📋 Prerequisites Check
Before you start, make sure you have:
- Python 3.9 or higher (
python --version) - pip installed
- Basic understanding of Python
- Basic understanding of REST APIs
- (Optional) Docker installed
- (Optional) Kubernetes cluster (minikube/kind)
Don’t worry if you don’t have Docker/K8s yet - you can learn the basics without them!
🚀 Step-by-Step Learning Path
Step 1: Understand the Fundamentals (30-60 minutes)
Read this first: docs/llm_inference_fundamentals.md
This explains:
- How LLMs work internally
- What happens during inference
- Tokenization, attention, generation
- Memory and computation requirements
Why this matters: You need to understand what’s happening under the hood before you can serve models effectively.
Action: Open the file and read through it. Don’t worry if you don’t understand everything - you’ll learn more as you build.
Step 2: Set Up Your Environment (10 minutes)
# Navigate to the project
cd /Users/faisal/Projects/mlops_serving
# Create a virtual environment (recommended)
python -m venv venv
# Activate it
source venv/bin/activate # On Mac/Linux
# OR
venv\Scripts\activate # On Windows
# Install dependencies for basic serving
cd 01_basic_serving
pip install -r requirements.txt
What this does: Sets up an isolated Python environment with all the libraries you need.
Step 3: Run Your First LLM Server (5 minutes)
# Make sure you're in 01_basic_serving directory
cd 01_basic_serving
# Start the server
python app.py
You should see:
INFO: Started server process
INFO: Waiting for application startup.
🚀 Starting up: Loading LLM model...
Loading model: gpt2 on device: cpu
✅ Model loaded successfully!
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000
What’s happening:
- The server is loading a GPT-2 model (small, fast model for learning)
- It’s starting a FastAPI web server
- The model is now ready to serve requests
Step 4: Test the API (5 minutes)
Option A: Use the test script
# In a new terminal (keep server running)
cd 01_basic_serving
python test_api.py
Option B: Use curl
# Health check
curl http://localhost:8000/health
# Generate text
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "The future of AI is",
"max_length": 50,
"temperature": 0.7
}'
Option C: Use the interactive docs Open http://localhost:8000/docs in your browser - FastAPI provides an interactive API explorer!
Step 5: Understand What Just Happened (15 minutes)
Read: 01_basic_serving/README.md
This explains:
- What each file does
- How the model loading works
- How the API endpoints work
- Key concepts you just used
Then explore the code:
model_loader.py- How models are loaded and usedapp.py- How the API is structured
Try modifying:
- Change the model name (try “distilgpt2” for a smaller model)
- Change temperature (0.1 = more deterministic, 2.0 = more creative)
- Add logging to see what’s happening
Step 6: Learn Each Topic in Order
Now that you’ve run your first server, work through each topic:
- 01_basic_serving ✅ (You just did this!)
- 02_docker - Containerize your app
- 03_kubernetes - Deploy to K8s
- 04_load_testing - Test performance
- 05_vllm_serving - High-performance serving
- 06_autoscaling - Scale automatically
- 07_canary_deployments - Safe rollouts
- 08_monitoring - Track metrics
- 09_model_versioning - Manage versions
- 10_drift_detection - Detect issues
- 11_triton - Multi-model serving
For each topic:
- Read the README.md
- Study the code
- Run the examples
- Modify and experiment
- Move to the next topic
🎓 Learning Tips
1. Don’t Rush
Understanding is more important than speed. Take time to:
- Read error messages carefully
- Experiment with parameters
- Break things and fix them
2. Experiment
After running each example:
- Change parameters
- Modify the code
- See what breaks
- Understand why
3. Use the Documentation
Each topic has:
- README.md explaining concepts
- Code comments explaining “why”
- Examples you can run
4. Ask Questions
As you learn, ask yourself:
- “Why does this work this way?”
- “What happens if I change X?”
- “How does this scale?”
- “What could go wrong?”
🐛 Common Issues & Solutions
Issue: “Model not found” or download errors
Solution: Check your internet connection. Models are downloaded from HuggingFace on first use.
Issue: “Out of memory”
Solution:
- Use a smaller model (distilgpt2 instead of gpt2)
- Reduce max_length
- Close other applications
Issue: “Port already in use”
Solution:
- Stop the previous server (Ctrl+C)
- Or change the port in app.py
Issue: “Import errors”
Solution:
- Make sure virtual environment is activated
- Run
pip install -r requirements.txtagain
📊 What You’ll Learn
By the end of this journey, you’ll understand:
Core Concepts
- ✅ How LLM inference works internally
- ✅ Tokenization and vocabulary
- ✅ Attention mechanisms
- ✅ Memory and computation requirements
Serving Skills
- ✅ Building serving APIs
- ✅ Containerization
- ✅ Kubernetes deployment
- ✅ Performance optimization
Production Skills
- ✅ Monitoring and observability
- ✅ Scaling strategies
- ✅ Safe deployments
- ✅ Drift detection
🎯 Next Steps
- Right now: Complete Steps 1-5 above
- Today: Read through
01_basic_serving/README.mdand understand the code - This week: Work through topics 2-4 (Docker, K8s, Load Testing)
- This month: Complete topics 5-8 (vLLM, Scaling, Monitoring)
- Ongoing: Topics 9-11 (Advanced production topics)
❓ Questions?
If you get stuck:
- Check the README.md in each topic
- Read error messages carefully
- Check the docs/ directory for detailed explanations
- Experiment with simpler examples first
Remember: Learning by doing is the best way. Don’t just read - run the code, modify it, break it, fix it!
Ready? Let’s start! 🚀
Begin with Step 1: Read docs/llm_inference_fundamentals.md
LLM Serving & Inference: Complete Learning Guide
🎯 How to Use This Guide
This guide is organized by learning topics, not time periods. Work through each topic at your own pace. Each topic builds on the previous one, but you can also jump to specific areas you want to learn.
📚 Learning Topics (In Order)
1. LLM Inference Fundamentals
What you’ll learn: How LLMs actually work under the hood
- Tokenization and vocabulary
- Forward pass through transformer layers
- Autoregressive generation
- Attention mechanism and KV caching
- Memory and computation requirements
Start here: docs/llm_inference_fundamentals.md
2. Basic Model Serving
What you’ll learn: Serve a model with a simple API
- Loading models from HuggingFace
- Building FastAPI endpoints
- Request/response handling
- Basic error handling
Practice: 01_basic_serving/
3. Containerization
What you’ll learn: Package your serving application
- Docker basics for ML models
- Multi-stage builds
- Handling large model files
- Environment configuration
Practice: 02_docker/
4. Kubernetes Deployment
What you’ll learn: Deploy to production infrastructure
- K8s manifests for ML workloads
- Health checks and probes
- Resource limits and requests
- ConfigMaps and Secrets
Practice: 03_kubernetes/
5. Load Testing & Performance
What you’ll learn: Measure and understand performance
- Latency vs throughput
- Load testing with Locust
- Performance profiling
- Identifying bottlenecks
Practice: 04_load_testing/
6. High-Performance Serving (vLLM)
What you’ll learn: Optimize for production throughput
- vLLM architecture
- Continuous batching
- PagedAttention
- GPU optimization
Practice: 05_vllm_serving/
7. Autoscaling
What you’ll learn: Scale based on demand
- Horizontal Pod Autoscaling (HPA)
- Request-based scaling
- Concurrency metrics
- Scaling strategies
Practice: 06_autoscaling/
8. Monitoring & Observability
What you’ll learn: Track your serving system
- Prometheus metrics
- Grafana dashboards
- GPU monitoring
- Logging and tracing
Practice: 08_monitoring/
9. Canary Deployments
What you’ll learn: Safely roll out model updates
- Traffic splitting
- A/B testing models
- Gradual rollouts
- Rollback procedures
Practice: 09_canary_deployments/
10. Model Versioning
What you’ll learn: Manage multiple model versions
- Version management strategies
- Model registry
- Rollback procedures
- A/B testing infrastructure
Practice: 10_model_versioning/
11. Drift Detection
What you’ll learn: Detect when models degrade
- Data drift detection
- Concept drift
- Evidently AI integration
- Alerting on anomalies
Practice: 11_drift_detection/
12. Multi-Model Serving (Triton)
What you’ll learn: Serve multiple models efficiently
- NVIDIA Triton Inference Server
- Dynamic batching
- Model ensembles
- Multi-framework support
Practice: 12_triton/
🚀 Quick Start Guide
Step 1: Understand the Basics
Read docs/llm_inference_fundamentals.md to understand how LLM inference works.
Step 2: Set Up Your Environment
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install base dependencies
pip install -r requirements.txt
Step 3: Start with Basic Serving
cd 01_basic_serving
python app.py
# Test it: curl http://localhost:8000/health
Step 4: Progress Through Topics
Work through each numbered topic in order. Each includes:
- README.md: Explanation of concepts
- Code examples: Working implementations
- Exercises: Hands-on practice
🎓 Learning Approach
For Each Topic:
- Read the documentation - Understand the concepts
- Study the code - See how it’s implemented
- Run the examples - Get hands-on experience
- Modify and experiment - Break things, fix them, learn
- Move to next topic - Build on what you learned
Tips:
- Don’t rush: Understanding > Speed
- Experiment: Change parameters, break things, learn why
- Read error messages: They teach you a lot
- Use the docs: Each topic has detailed explanations
📖 Prerequisites
Required:
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- Docker basics
Helpful but not required:
- Kubernetes experience
- ML/AI background
- GPU access (CPU works for learning)
🔧 Technology Stack
You’ll learn these tools:
- FastAPI: Web framework
- HuggingFace Transformers: Model loading
- vLLM: High-performance inference
- Docker: Containerization
- Kubernetes: Orchestration
- Locust: Load testing
- Prometheus/Grafana: Monitoring
- Evidently: Drift detection
- Triton: Multi-model serving
❓ Common Questions
Q: Do I need a GPU? A: Not for the basics. GPU helps with vLLM and production workloads, but you can learn on CPU.
Q: How long will this take? A: Depends on your pace. Each topic can take a few hours to a few days. Focus on understanding, not speed.
Q: Can I skip topics? A: The basics (1-5) should be done in order. Advanced topics (6-12) can be done based on interest.
Q: What if I get stuck? A: Check the docs, read error messages carefully, experiment with simpler examples first.
🎯 Learning Goals
By the end, you’ll be able to:
- ✅ Serve LLMs in production
- ✅ Optimize for performance
- ✅ Deploy to Kubernetes
- ✅ Monitor and debug serving systems
- ✅ Handle model updates safely
- ✅ Detect and handle model drift
Let’s start learning! 🚀
Quick Reference: vLLM, Latency, and Grafana
🚀 Quick Start Guide
1. vLLM Serving
Install vLLM
# Requires CUDA/GPU
pip install vllm
Start vLLM Server
cd 05_vllm_serving
pip install -r requirements.txt
python vllm_server.py
Or use vLLM’s built-in server
python -m vllm.entrypoints.openai.api_server \
--model gpt2 \
--port 8000
Test vLLM API
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy"
)
response = client.chat.completions.create(
model="gpt2",
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=50
)
print(response.choices[0].message.content)
2. Measure Latency
Option A: Using Locust (Interactive)
cd 04_load_testing
pip install -r requirements.txt
# Start your server first
# Then run Locust
locust -f locust_test.py --host=http://localhost:8000
# Open http://localhost:8089 in browser
Option B: Using Python Script (Headless)
cd 04_load_testing
python measure_latency.py --url http://localhost:8000 --requests 100
What You Get
- P50 (Median): 50% of requests faster
- P95: 95% of requests faster
- P99: 99% of requests faster
- Throughput: Requests per second
Compare Endpoints
python measure_latency.py --compare
# Compares basic serving vs vLLM
3. Grafana Monitoring
Start Monitoring Stack
cd 08_monitoring
docker-compose up -d
This starts:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (admin/admin)
Add Metrics to Your App
from prometheus_exporter import add_prometheus_middleware
app = FastAPI()
add_prometheus_middleware(app) # That's it!
Your app now exposes /metrics endpoint.
Configure Prometheus
Edit prometheus.yml to scrape your app:
scrape_configs:
- job_name: 'llm-serving'
static_configs:
- targets: ['localhost:8000']
Create Grafana Dashboard
- Open Grafana: http://localhost:3000
- Login: admin/admin
- Add data source: Prometheus (http://prometheus:9090)
- Create dashboard with these queries:
Request Rate:
rate(llm_requests_total[5m])
Latency (P95):
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))
Error Rate:
rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m])
GPU Utilization:
llm_gpu_utilization_percent
📊 Complete Workflow Example
Step 1: Start vLLM Server
cd 05_vllm_serving
python vllm_server.py
# Server running on http://localhost:8000
Step 2: Measure Latency
cd 04_load_testing
python measure_latency.py --url http://localhost:8000 --requests 100
Output:
P50: 150ms
P95: 450ms
P99: 800ms
Throughput: 25.3 req/s
Step 3: Start Monitoring
cd 08_monitoring
docker-compose up -d
Step 4: View Metrics
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000
Step 5: Load Test with Monitoring
# Terminal 1: Watch Grafana
open http://localhost:3000
# Terminal 2: Run load test
cd 04_load_testing
locust -f locust_test.py --host=http://localhost:8000 --headless --users 10 --spawn-rate 2
🔍 Key Metrics to Monitor
Application Metrics
llm_requests_total- Total requestsllm_request_duration_seconds- Request latencyllm_active_requests- Currently processingllm_tokens_generated_total- Tokens generated
System Metrics
llm_gpu_utilization_percent- GPU usagellm_gpu_memory_used_bytes- GPU memoryllm_gpu_temperature_celsius- GPU temperature
Important Queries
Requests per second:
rate(llm_requests_total[5m])
95th percentile latency:
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))
Error percentage:
rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m]) * 100
GPU memory usage:
llm_gpu_memory_used_bytes / llm_gpu_memory_total_bytes * 100
🎯 Performance Targets
Good Performance
- P50 latency: < 200ms
- P95 latency: < 500ms
- P99 latency: < 1s
- Error rate: < 0.1%
- GPU utilization: 70-90%
Warning Signs
- P99 > 2s: System struggling
- Error rate > 1%: Problems occurring
- GPU utilization < 50%: Underutilized
- GPU utilization > 95%: Overloaded
🐛 Troubleshooting
vLLM won’t start
- Check CUDA:
python -c "import torch; print(torch.cuda.is_available())" - Install correct CUDA version
- Use smaller model for testing
High latency
- Check GPU utilization
- Reduce batch size
- Use smaller model
- Check for bottlenecks (CPU, memory, I/O)
No metrics in Grafana
- Check Prometheus is scraping: http://localhost:9090/targets
- Verify
/metricsendpoint works:curl http://localhost:8000/metrics - Check Prometheus config points to correct host
GPU metrics missing
- Install pynvml:
pip install pynvml - Check GPU is accessible
- Verify NVIDIA drivers installed
📚 Learn More
- vLLM:
05_vllm_serving/README.md - Latency:
04_load_testing/README.md - Monitoring:
08_monitoring/README.md
Quick tip: Start with basic serving, measure latency, then optimize with vLLM, and monitor with Grafana!
Complete Learning Topics Overview
All 12 learning topics are now complete! Here’s what you have:
✅ Completed Topics
1. LLM Inference Fundamentals ✅
- Location:
docs/llm_inference_fundamentals.md - What it covers: Tokenization, attention, generation, memory, computation
- Status: Complete with detailed explanations
2. Basic Model Serving ✅
- Location:
01_basic_serving/ - What it covers: FastAPI, HuggingFace, model loading, endpoints
- Status: Complete with working code
3. Containerization ✅
- Location:
02_docker/ - What it covers: Docker basics, multi-stage builds, GPU support
- Status: Complete with Dockerfiles and examples
4. Kubernetes Deployment ✅
- Location:
03_kubernetes/ - What it covers: K8s manifests, health checks, resources, ConfigMaps
- Status: Complete with deployment YAMLs
5. Load Testing & Performance ✅
- Location:
04_load_testing/ - What it covers: Latency measurement, Locust, performance profiling
- Status: Complete with test scripts
6. High-Performance Serving (vLLM) ✅
- Location:
05_vllm_serving/ - What it covers: vLLM setup, continuous batching, PagedAttention
- Status: Complete with server implementation
7. Autoscaling ✅
- Location:
06_autoscaling/ - What it covers: HPA, CPU/memory scaling, custom metrics
- Status: Complete with HPA configurations
8. Monitoring & Observability ✅
- Location:
08_monitoring/ - What it covers: Prometheus, Grafana, GPU monitoring, metrics
- Status: Complete with full monitoring stack
9. Canary Deployments ✅
- Location:
09_canary_deployments/ - What it covers: Traffic splitting, gradual rollouts, rollback
- Status: Complete with deployment examples
10. Model Versioning ✅
- Location:
10_model_versioning/ - What it covers: Version management, model registry, rollback
- Status: Complete with strategies and examples
11. Drift Detection ✅
- Location:
11_drift_detection/ - What it covers: Data drift, concept drift, Evidently AI
- Status: Complete with detector implementation
12. Multi-Model Serving (Triton) ✅
- Location:
12_triton/ - What it covers: Triton server, dynamic batching, model ensembles
- Status: Complete with configuration examples
📚 Learning Path
Follow this order for best learning experience:
- Start: Read
HOW_TO_START.md - Fundamentals: Read
docs/llm_inference_fundamentals.md - Basic Serving: Work through
01_basic_serving/ - Containerize: Learn Docker in
02_docker/ - Deploy: Deploy to K8s with
03_kubernetes/ - Test: Measure performance with
04_load_testing/ - Optimize: Use vLLM in
05_vllm_serving/ - Scale: Set up autoscaling in
06_autoscaling/ - Monitor: Add monitoring in
08_monitoring/ - Deploy Safely: Learn canary in
09_canary_deployments/ - Version: Manage versions in
10_model_versioning/ - Detect Issues: Add drift detection in
11_drift_detection/ - Advanced: Use Triton in
12_triton/
🎯 Quick Reference
- Quick Start:
HOW_TO_START.md - Learning Path:
LEARNING_PATH.md - Quick Commands:
QUICK_REFERENCE.md - Overview:
README.md
📖 Each Topic Includes
- README.md: Detailed explanations and concepts
- Code examples: Working implementations
- Configuration files: YAMLs, Dockerfiles, etc.
- Exercises: Hands-on practice suggestions
🚀 Ready to Learn!
Everything is set up and ready. Start with HOW_TO_START.md and work through each topic at your own pace.
Happy learning! 🎓
Industry Boilerplate Code & Use Cases
This document provides industry-standard boilerplate code and real-world use cases for each topic.
Quick Reference
- Topic 1: Basic Serving → Customer support, content generation
- Topic 2: Docker → Multi-cloud, CI/CD, edge deployment
- Topic 3: Kubernetes → Large-scale platforms, enterprise ML
- Topic 4: Load Testing → Performance validation, capacity planning
- Topic 5: vLLM → High-throughput production serving
- Topic 6: Autoscaling → Cost optimization, traffic handling
- Topic 7: Canary → Safe deployments, A/B testing
- Topic 8: Monitoring → Production observability, alerting
- Topic 9: Canary (same as 7)
- Topic 10: Versioning → Model management, rollback
- Topic 11: Drift Detection → Model health, quality assurance
- Topic 12: Triton → Multi-model serving, model pipelines
Common Industry Patterns
Pattern 1: API Gateway → Serving Layer
[API Gateway] → [Load Balancer] → [K8s Service] → [LLM Pods]
Used by: OpenAI, Anthropic, HuggingFace
Pattern 2: Model Registry → Serving
[Model Registry] → [CI/CD] → [K8s Deployment] → [Serving Pods]
Used by: MLflow, Weights & Biases, custom platforms
Pattern 3: Monitoring → Alerting → Auto-remediation
[Prometheus] → [Grafana] → [Alertmanager] → [PagerDuty/Slack]
Used by: All production ML systems
See individual topic READMEs for detailed boilerplate code and use cases.
Capstone: Engineering a Production LLM Serving Platform
Why This Chapter Exists
Chapters 01 through 11 each teach one piece of the puzzle in depth: how to stand up a basic server (01_basic_serving), how to containerize it (02_docker), how to run it on Kubernetes (03_kubernetes), how to load test it (04_load_testing), how to make it fast with vLLM (05_vllm_serving), how to autoscale it (06_autoscaling), how to roll out a new version safely (07_canary_deployments), how to watch it (08_monitoring), how to version and register models (09_model_versioning), how to detect drift in what it’s serving (10_drift_detection), and how to run a multi-framework, multi-model server with Triton (11_triton).
None of those chapters, on their own, tells you how to actually stand up an LLM inference platform from zero and keep it alive for a year. That is what this chapter does. It is a capstone in the literal sense: it takes the load-bearing pieces from every other chapter and shows how they bolt together into one system, in the order you’d actually build them, with the seams between them called out explicitly — because production incidents live in the seams, not inside any one component.
Concretely, this chapter will:
- Give you a reference architecture you can hold in your head — one diagram, one system, every box labeled with the chapter that teaches it.
- Give you a decision framework for picking your serving engine, your orchestration layer, and your topology, with a real 2025-2026 options table instead of vague advice.
- Walk through one complete build end to end — a specific model, a specific SLO, a specific budget — with runnable Dockerfiles, Kubernetes YAML, a vLLM serve command, a load-test script, a PromQL alert, and an Argo Rollouts canary, so you see the pieces in the order they actually get built, not as an appendix of unrelated snippets.
- Give you a cost model with real 2026 GPU pricing so you can defend a capacity plan in a budget review.
- Catalog the failure modes that only exist at the system level — the ones that pass every unit test and every single-chapter checklist and still page you at 3 a.m., because they live in the interaction between two components each chapter treats in isolation.
- Give you a pre-launch checklist that spans all eleven chapters, and an interview section built around “tell me how you’d build this,” including one full system-design walkthrough.
If you’ve read chapters 01-11, this chapter should feel like the moment the individual lessons in a driving course turn into actually driving on the highway. If you’re skimming straight to this chapter, you’ll get the shape of the whole system, but you should expect to jump back to the numbered chapters for the mechanism behind each box — this chapter deliberately does not re-derive PagedAttention, the Kubernetes scheduler, or EWMA drift statistics; it tells you where those live and how they fit.
A note on scope: “platform” here means the inference-serving path — from a client request to a generated response, at production scale, with a rollout and observability story around it. It does not cover training, RLHF, or data pipelines; those are different systems with different failure modes.
Saying it out loud. The pitch for this chapter is that knowing eleven pieces isn’t the same as knowing the system. You can pass every single-chapter checklist — the autoscaler is correct, the canary controller is correct, the registry is correct — and still get paged at 3am, because production incidents live in the seams between components, not inside any one of them. So this is one complete build in the order you’d actually do it: pick an engine, containerize, deploy, load test, then autoscale, then monitor, then canary. And the scope is deliberately the inference path only — client request to generated response — not training or data pipelines, which are different systems with different failure modes.
1. The Reference Architecture
Every production LLM platform — whether it’s a two-person startup self-hosting one open-weight model or a hyperscaler running dozens — reduces to the same skeleton. What differs is scale, managed-vs-DIY choices, and how much of each box you build yourself. Here is the whole system in one picture.
┌─────────────────────────────────────────────────────┐
│ CLIENTS │
│ (web app, mobile app, internal service, agent) │
└───────────────────────────┬───────────────────────────┘
│ HTTPS / gRPC
▼
┌───────────────────────────────────────────────────────────────────┐
│ API GATEWAY / LOAD BALANCER [Ch. 01 Basic Serving, │
│ - authn/authz, rate limiting Ch. 03 Kubernetes Ingress] │
│ - request routing (model, region) │
│ - request/response logging -----------------------------┐ │
└───────────────────────────┬───────────────────────────────┼────────┘
│ │
┌────────────────────────┼──────────────────────────┐ │
│ ▼ │ │
│ ┌───────────────────────────────┐ │ │
│ │ ROLLOUT / CANARY CONTROLLER │ │ │
│ │ (Argo Rollouts / KServe) │◄─────────┼───┼──── promote / abort
│ │ splits traffic stable:canary │ │ │ decision based on
│ │ [Ch. 07 Canary] │ │ │ live metrics below
│ └────────────┬───────┬──────────┘ │ │
│ │ │ │ │
│ stable % │ │ canary % │ │
│ ▼ ▼ │ │
│ ┌───────────────────────┐ ┌───────────────────┐ │ │
│ │ MODEL SERVER PODS │ │ MODEL SERVER PODS │ │ │
│ │ (stable version) │ │ (canary version) │ │ │
│ │ vLLM or Triton engine │ │ vLLM or Triton │ │ │
│ │ on GPU nodes │ │ on GPU nodes │ │ │
│ │ [Ch. 05 vLLM, │ │ [Ch. 05, Ch. 11] │ │ │
│ │ Ch. 11 Triton] │ │ │ │ │
│ └───────────┬───────────┘ └─────────┬──────────┘ │ │
│ │ metrics + logs │ │ │
│ KUBERNETES │ │ │ │
│ CLUSTER ▼ ▼ │ │
│ [Ch. 03] ┌────────────────────────────────────┐ │ │
│ │ HPA / KEDA AUTOSCALER │ │ │
│ │ scales pod count from queue depth, │ │ │
│ │ GPU utilization, req/s │ │ │
│ │ [Ch. 06 Autoscaling] │ │ │
│ └────────────────────────────────────┘ │ │
└──────────────────────────────────────────────────────┘ │
│
┌───────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ OBSERVABILITY STACK │
│ Prometheus (metrics) + Grafana (dashboards) + Alertmanager (paging) │
│ + structured request logs + distributed tracing │
│ [Ch. 08 Monitoring] │
└───────────────────────┬───────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ MODEL / PROMPT REGISTRY │ │ DRIFT & QUALITY MONITOR │
│ - semantic version per model │ │ - input distribution shift │
│ - which weights + prompt template │ │ - output quality / eval scores │
│ + engine config == one release │ │ - feeds "should we roll back?" │
│ [Ch. 09 Model Versioning] │ │ [Ch. 10 Drift Detection] │
└───────────────────┬─────────────────┘ └────────────────┬───────────────────┘
│ │
└───────────────┬────────────────────────┘
▼
┌───────────────────────────────────┐
│ OFFLINE EVAL / CI PIPELINE │
│ golden-set regression tests, │
│ benchmark scores, load tests │
│ gate the next release │
│ [Ch. 04 Load Testing, Ch. 10] │
└───────────────────────────────────┘
Reading the diagram box by box
Clients → gateway. Nothing here is LLM-specific yet — it’s the same authn/rate-limiting/routing layer any API needs. Chapter 01 (Basic Serving) builds the naive version of this (a single FastAPI process answering requests directly); in a real platform the gateway is a separate tier (an ingress controller, Envoy, or a managed API gateway) that never touches a GPU itself.
Rollout/canary controller. This is the traffic-shaping brain. It knows there are two (or more) live versions of the model server and decides, second by second, what percentage of traffic each one gets. Chapter 07 (Canary Deployments) is entirely about this box: traffic splitting strategies, promotion criteria, and rollback triggers. In the reference architecture this is implemented with a real controller — Argo Rollouts if you’re doing this yourself on vanilla Kubernetes, or KServe’s InferenceService canary spec if you’ve adopted KServe as your model-serving CRD layer.
Model server pods. The GPU-bound heart of the system. Chapter 05 (vLLM Serving) and Chapter 11 (Triton) each teach one engine for this box in depth — vLLM if you’re serving one or a few open-weight models as fast as possible with continuous batching and PagedAttention, Triton if you need one server fronting many models/frameworks (PyTorch, TensorRT-LLM, ONNX, custom Python backends) behind one protocol. Both plug into the same box in this diagram; the rest of the platform (gateway, autoscaler, monitoring) barely cares which one is inside.
Kubernetes cluster + autoscaler. Chapter 03 (Kubernetes) is the substrate everything above runs on: pod specs, GPU scheduling, node pools, health probes. Chapter 06 (Autoscaling) is the control loop layered on top of it — deciding how many model-server pods should exist right now based on load signals. The reference architecture uses KEDA rather than plain HPA for GPU workloads specifically because HPA’s default CPU/memory metrics are close to useless for judging GPU-bound LLM load; KEDA lets you scale on a Prometheus query (queue depth, vllm:num_requests_waiting) instead. See the vLLM production-stack project’s KEDA guide for a reference implementation (docs.vllm.ai/projects/production-stack/en/latest/use_cases/autoscaling-keda.html).
Observability stack. Chapter 08 (Monitoring) builds this: Prometheus scraping engine metrics, Grafana dashboards, Alertmanager routing pages. In the reference architecture this box is the nervous system — every other box either emits into it (metrics, logs, traces) or reads from it (the canary controller reads success/error/latency metrics to decide whether to promote; the autoscaler reads queue-depth metrics to decide whether to scale).
Model/prompt registry. Chapter 09 (Model Versioning) owns this box. The critical, easy-to-miss detail (revisited in Section 6): a “version” in a serious platform is not just a checkpoint — it is the tuple of (model weights, tokenizer, prompt/chat template, sampling defaults, engine config). Registering only the weights and rolling the rest out-of-band is one of the most common system-level failure modes.
Drift & quality monitor. Chapter 10 (Drift Detection) owns this box. It watches the live traffic distribution and the model’s output quality over time and answers “is this still the same model behaving the same way it did at launch, on the same kind of traffic it was validated on?” Its output feeds two places: back into the canary controller (a canary that’s drifting on quality should not auto-promote) and into the eval pipeline that gates the next release.
Offline eval/CI. Chapter 04 (Load Testing) determines the throughput/latency ceiling before anything ships; Chapter 10’s regression suite determines whether quality has regressed. Both run in CI, before a new model version is even allowed to become a canary.
The rest of this chapter is about the connective tissue: how to choose what goes in each box (Section 3), how to build the whole thing once for a concrete scenario (Section 4), what it costs (Section 5), how it breaks in ways no single chapter predicts (Section 6), and how to know you’re ready to launch it (Section 7).
Saying it out loud. Every production LLM platform reduces to the same seven or eight boxes, whether you’re two people or a hyperscaler; what differs is scale and how much of each box you build yourself. Clients hit a gateway that never touches a GPU. A rollout controller decides second by second what fraction of traffic each model version gets. Model server pods are the GPU-bound heart. Kubernetes plus an autoscaler is the substrate and its control loop. Observability is the nervous system — every other box either emits into it or reads from it. And a model registry plus a drift monitor decide what’s allowed to ship and whether what shipped is still behaving. The detail that’s easy to miss and expensive later: a version isn’t a checkpoint, it’s the tuple of weights, tokenizer, prompt template, sampling defaults, and engine config.
2. Choosing Your Stack
There is no single right stack. There is a right stack for your traffic, your latency SLO, your team size, and your budget. This section gives you the decision tree, then a table of real options as of 2026.
2.1 Engine: raw Transformers vs vLLM vs Triton vs a managed API
Do you control the model weights (open-weight / fine-tuned),
or are you calling someone else's hosted model (OpenAI, Anthropic, etc.)?
│
├── Hosted/managed API only ──► You don't need this chapter's serving stack at all.
│ You still need Ch. 07 (canary across model versions/
│ providers), Ch. 08 (monitoring), Ch. 10 (drift) —
│ the "server" box just becomes an HTTP call to a vendor.
│
└── Self-hosting open-weight or fine-tuned weights
│
├── Prototype / <5 req/s / latency doesn't matter yet
│ └──► Raw Transformers + `generate()` behind FastAPI (Ch. 01).
│ No batching, no PagedAttention. Fine for a demo, wrong for
│ anything a real user waits on.
│
├── One or a few models, need max throughput/cost-efficiency,
│ comfortable operating Python services
│ └──► vLLM (Ch. 05). This is the default answer in 2026 for
│ self-hosted LLM serving: continuous batching + PagedAttention
│ gets you 10-20x the throughput of naive HF `generate()` at
│ comparable latency. OpenAI-compatible server built in.
│
├── Many models / many frameworks (PyTorch, ONNX, TensorRT-LLM, custom
│ Python) behind one server, need ensembles or multi-model routing,
│ or an existing NVIDIA-centric MLOps org
│ └──► Triton Inference Server (Ch. 11), typically with the
│ TensorRT-LLM backend for max single-model performance or
│ the vLLM backend if you want vLLM's scheduler under Triton's
│ multi-model management plane.
│
└── Extreme scale, prefill and decode have very different resource
profiles, need to pool KV cache across many replicas
└──► Disaggregated serving (split prefill/decode pools, e.g. the
`llm-d` project on Kubernetes, or NVIDIA Dynamo). This is a
2025-2026-era pattern for the largest deployments; most teams
should not start here — see 2.3.
The honest heuristic: if you’re asking “vLLM or Triton,” you’ve usually already answered it — vLLM if it’s your own model(s) and you want the simplest path to production-grade throughput; Triton if multi-framework/multi-model flexibility or an existing NVIDIA Triton investment is the actual requirement. They are not mutually exclusive: Triton can run vLLM as a backend, giving you Triton’s multi-model management with vLLM’s scheduler underneath.
Saying it out loud. The engine decision is basically one question with four answers. If you’re calling someone else’s hosted model, you don’t need most of this stack — but you still need canary, monitoring, and drift, because the vendor can change the model under you. If you’re self-hosting and it’s a prototype under a handful of requests per second, raw Transformers behind FastAPI is fine and wrong for anything a user waits on. If it’s one or a few open-weight models and you want throughput per GPU dollar, vLLM is the 2026 default — continuous batching and PagedAttention get you roughly ten to twenty times naive generate at comparable latency. Triton is the answer when the actual requirement is many models across many frameworks behind one server. And they’re not exclusive: Triton can run vLLM as a backend.
2.2 Orchestration: Kubernetes vs simpler
Do you need to run this on more than one machine, or promise any uptime SLA?
│
├── No — single GPU box, internal tool, can tolerate a restart
│ └──► Docker Compose (Ch. 02) or a single systemd-managed container.
│ Don't build Kubernetes for one box; you'll spend more time
│ operating the control plane than the workload.
│
└── Yes — multiple replicas, need autoscaling, rolling/canary updates,
multi-tenant GPU sharing, or you're already a Kubernetes shop
└──► Kubernetes (Ch. 03) + the GPU device plugin/GPU Operator +
KEDA/HPA (Ch. 06) + Argo Rollouts or KServe (Ch. 07).
This is the default for anything with a production SLO and
more than a handful of GPUs.
Managed alternatives exist between these two extremes — a managed inference endpoint (SageMaker, Vertex AI endpoints, Modal, Replicate/Baseten-style GPU-as-a-service) gives you most of the Kubernetes-cluster benefits without operating the control plane yourself, at a per-GPU-hour premium. That is often the right call for a small team; you are trading operational burden for margin, and the decision framework in 2.3 makes that trade explicit.
Saying it out loud. On orchestration I’d resist the reflex. If it’s a single GPU box for an internal tool that can tolerate a restart, Docker Compose or a systemd-managed container is the right answer — you’ll spend more time operating a Kubernetes control plane than the workload. Kubernetes earns itself the moment you need multiple replicas, autoscaling, canary updates, or you’re promising any uptime SLA. And there’s a real middle option people skip past: a managed inference endpoint gives you most of the cluster benefits without operating the control plane, at a per-GPU-hour premium. For a two-person team that’s often the correct trade — you’re buying back operational burden with margin, and that’s a defensible decision, not a cop-out.
2.3 Single-region vs multi-region
Start single-region unless you have a specific reason not to. Multi-region LLM serving adds real complexity that only pays for itself at real scale:
| Trigger | Single-region is fine | Consider multi-region |
|---|---|---|
| Latency to users | Users clustered in one geography | Global user base, TTFT SLO tight enough that cross-ocean RTT matters |
| Availability requirement | “Best effort,” a few hours of downtime tolerable | Contractual SLA (99.9%+) that a single cloud region outage would breach |
| GPU capacity | One region has enough on-demand/reserved capacity | Capacity-constrained GPUs (H100s) force spreading across regions/providers to get enough quota |
| Data residency | No regulatory constraint | GDPR/data-residency rules require EU traffic served from EU |
| Team size | Small team; one region is already a lot of surface area | Dedicated platform team that can own cross-region model registry sync, routing, and failover |
Multi-region done wrong (e.g., model registry not replicated, so a canary promotes correctly in one region and never reaches another) is one of the failure modes in Section 6. If you do go multi-region, the model/prompt registry (Ch. 09) and the drift baseline (Ch. 10) both need to be global sources of truth, not per-region copies that can silently diverge.
Saying it out loud. Default to single region unless something specific forces you out of it. The forcing functions are real but narrow: a genuinely global user base with a TTFT budget that cross-ocean round trips would eat, a contractual SLA a single region outage would breach, GPU capacity constraints that make you spread across regions just to get quota, or data-residency rules. What multi-region actually costs you is a new class of failure: silent version divergence, where a canary promotes cleanly in one region and another region quietly runs the old model for three weeks. So if you do it, the model registry and the drift baseline have to be genuinely global sources of truth, not per-region copies — and “promoted” has to be a fact you verify per region, not an event you fire and assume propagates.
2.4 Real options, 2025-2026
| Layer | Lightweight / early-stage option | Production-scale option | Notes |
|---|---|---|---|
| Serving engine | Raw Transformers generate() (Ch. 01) | vLLM (Ch. 05) | vLLM latest stable line is the v0.20.x series (e.g. v0.20.2, May 2025) at the time of writing, with gpt-oss, DeepSeek-V4, and Qwen3-VL support landing in that line; check github.com/vllm-project/vllm/releases for current. |
| Multi-model / multi-framework serving | N/A | Triton Inference Server (Ch. 11), with the vLLM backend or the TensorRT-LLM backend | TensorRT-LLM backend gives the best single-model latency on NVIDIA GPUs at the cost of an offline compile/engine-build step; the vLLM backend trades a little raw throughput for vLLM’s simpler operational model and faster iteration. |
| Orchestration | Docker Compose (Ch. 02) | Kubernetes (Ch. 03) + NVIDIA GPU Operator for device plugin, MIG partitioning, and time-slicing | GPU Operator handles driver install, device plugin, DCGM exporter, and MIG/time-slicing config as one Helm-installed unit — see docs.nvidia.com/datacenter/cloud-native/gpu-operator. |
| Autoscaling | Manual replica count | HPA on custom metrics, or KEDA scaling on a Prometheus query (Ch. 06) | KEDA is the practical default for GPU/queue-depth-based scaling; see the vLLM production-stack project’s KEDA guide. |
| Rollout / canary | Manual kubectl apply, watch and pray | Argo Rollouts (canary + analysis templates) or KServe InferenceService canary (canaryTrafficPercent) (Ch. 07) | KServe’s canary model is declarative and Kubernetes-native if you’ve already adopted KServe as your model CRD layer; Argo Rollouts is the general-purpose choice if you haven’t. |
| Multi-replica model serving with shared state | N/A | LeaderWorkerSet (LWS) for multi-node tensor/pipeline-parallel vLLM deployments | LWS is a Kubernetes API (via the vllm-project/production-stack reference and docs.vllm.ai/en/stable/deployment/frameworks/lws) for treating a group of pods as one logical multi-node model replica. |
| Disaggregated prefill/decode | N/A | llm-d (Kubernetes-native, KV-cache-aware routing, joint Red Hat/Google/IBM/CoreWeave project) or NVIDIA Dynamo | Only worth adopting once you’ve outgrown a monolithic replica-per-request-pool model — see llm-d.ai/docs/architecture/advanced/disaggregation. |
| Monitoring | print() statements | Prometheus + Grafana + Alertmanager (Ch. 08), scraping vLLM’s built-in /metrics endpoint | vLLM exposes histograms for TTFT, inter-token latency, queue time, and end-to-end latency natively — no custom instrumentation needed for the basics. |
| Model registry | A folder of checkpoints and a spreadsheet | MLflow Model Registry, or a Git-based registry (Ch. 09) | Whatever you pick, it must version the prompt template alongside the weights — see Section 6. |
| Managed alternative to all of the above | — | SageMaker/Vertex AI endpoints, Modal, Baseten, Replicate | Right choice when the team is too small to operate Kubernetes + GPU Operator + Argo Rollouts themselves; you pay a per-GPU-hour premium for someone else operating boxes 2-6 of the reference architecture. |
Saying it out loud. A word on this options table: it’s a snapshot, not a constant. Engine versions, GPU pricing, and which projects are actively maintained all move fast enough that anything more than a few months old deserves a re-check against the project’s own release notes before you pin it. What ages slowly is the shape of the choice — lightweight option versus production-scale option per layer, and why. The two picks I’d defend hardest are KEDA over plain HPA, because CPU and memory metrics are close to useless for judging GPU-bound load, and a registry that versions the prompt template alongside the weights, because retrofitting that after a rollback incident is far more painful than building it in.
3. Build It End-to-End — Full Worked Walkthrough
3.1 The scenario
You are the first infra engineer at a startup. Product wants to serve openai/gpt-oss-20b — OpenAI’s open-weight 21B-parameter mixture-of-experts model (3.6B active parameters per token, 128K context via YaRN scaling from a 4K base) — to end users through a chat product. The requirements:
- Target load: 500 requests/second sustained, with bursts to 700 req/s.
- SLO: P95 time-to-first-token (TTFT) under 2 seconds.
- Budget: as few GPU-hours as possible without missing the SLO. No H100/H200/B200 unless the numbers force it — the model’s native MXFP4 quantization (applied to the MoE expert weights, with attention/router/embeddings kept in BF16) means the checkpoint itself only needs about 16 GB of VRAM, so start by asking whether a cheaper card can do the job before reaching for the most expensive one.
- Team: two infra engineers, no dedicated MLOps platform team yet.
We’ll walk this scenario through choosing the engine, containerizing, deploying, load testing, autoscaling, monitoring, and canarying the next model update — in that order, because that’s the order you actually do it in.
Saying it out loud. The scenario is worth stating precisely because every downstream number depends on it: a 20-billion-parameter open-weight mixture-of-experts model behind a chat product, 500 requests per second sustained with bursts to 700, a P95 time-to-first-token SLO of two seconds, two infra engineers and no MLOps team. Notice what that constrains. The SLO is on TTFT specifically, not end-to-end, because it’s a streaming chat UI and TTFT is what users perceive as responsiveness. The team size rules out anything with a large operational surface. And the budget line says start by asking whether a cheaper card can do the job, rather than reaching for the newest GPU because it’s fastest.
3.2 Choosing the engine and quantization
Following the decision tree in Section 2.1: this is a single open-weight model, we want maximum throughput per GPU-dollar, and the team is small — vLLM (Ch. 05) is the answer, not Triton. We don’t have a multi-framework requirement that would justify Triton’s extra operational surface.
Quantization is already decided for us in the useful sense: gpt-oss-20b ships with native MXFP4 on its MoE expert weights (the vast majority of its parameters), so there’s no separate AWQ/GPTQ quantization step to run — you load the model as published and vLLM handles the rest. The GPU choice becomes the real lever:
| GPU | VRAM | Fits gpt-oss-20b (~16 GB weights + KV cache)? | Relative on-demand cost (2026, wide provider range) |
|---|---|---|---|
| A100 80GB | 80 GB | Yes, comfortably — room for large KV cache and big batches | ~($1.99)/hr baseline, varies by provider |
| L40S 48GB | 48 GB | Yes — less KV-cache headroom than the A100 at very large batch sizes, but adequate for this SLO | Typically priced below A100 on most clouds |
| H100 80GB | 80 GB | Yes, with the most headroom and the fastest per-token decode | roughly ($1.49)-($6.98)/hr across 15+ providers; ($2)-($3.29)/hr is a common on-demand baseline |
For a 2-second TTFT SLO at 500-700 req/s, the honest move is to start on A100 80GB (cheaper than H100, and the model doesn’t need H100’s extra compute headroom at this scale), measure the real ceiling with a load test, and only move to H100 if the load test shows you can’t hit the SLO at a GPU count your budget tolerates. This is the “choose cheap, then measure, then upgrade only if the numbers force it” pattern — the opposite of defaulting to the newest GPU because it’s fastest.
Saying it out loud. Following the decision tree this is vLLM, not Triton — one open-weight model, a small team, no multi-framework requirement to justify the extra operational surface. Quantization is mostly decided for us since this model ships with native MXFP4 on its expert weights, so there’s no separate quantization step; the checkpoint needs about 16 gigabytes of VRAM. That makes GPU choice the real lever, and the pattern I’d defend is: start on the cheaper card, measure the actual ceiling with a load test, and only move up if the numbers force it. As of 2026 an A100 80GB runs around two dollars an hour and an H100 spans roughly one and a half to seven dollars depending on provider and commitment — check a current quote before putting any of that in a budget review.
3.3 Containerizing it
vLLM ships an official vllm/vllm-openai image, so the Dockerfile’s job is thin: pin a version, bake in any org-specific config, and set the launch command. This builds on the general containerization practice from Ch. 02 (Docker) — multi-stage builds, non-root users, minimal layers — applied to a GPU-serving image.
# Dockerfile
FROM vllm/vllm-openai:v0.20.2
# Org-standard: run as non-root, drop unnecessary capabilities (Ch. 02 hardening practices)
RUN useradd --create-home --uid 10001 vllmuser
USER vllmuser
WORKDIR /home/vllmuser
# Bake in the model name so this image is a one-model, one-version artifact —
# the image tag itself becomes part of the model version identity (Ch. 09).
ENV MODEL_NAME="openai/gpt-oss-20b"
ENV VLLM_KV_CACHE_DTYPE="auto"
EXPOSE 8000
# Health check hits vLLM's built-in /health endpoint — used by both Docker
# and the Kubernetes readiness probe defined in 3.4.
HEALTHCHECK --interval=10s --timeout=5s --start-period=120s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
ENTRYPOINT ["vllm", "serve", "openai/gpt-oss-20b"]
CMD ["--host", "0.0.0.0", \
"--port", "8000", \
"--max-model-len", "32768", \
"--gpu-memory-utilization", "0.90", \
"--enable-prefix-caching", \
"--served-model-name", "gpt-oss-20b-v1"]
Notes worth calling out:
--max-model-len 32768caps context below the model’s full 128K to bound KV-cache memory per request — the product spec for this scenario doesn’t need the full context window, and capping it directly improves how many concurrent requests fit in memory (more on this in the load-test section).--enable-prefix-cachingreuses KV cache across requests that share a prompt prefix (e.g., a shared system prompt) — a meaningful win for chat products where every request starts with the same instructions.--served-model-name gpt-oss-20b-v1is deliberate: it’s the version string that ties this container image to a specific entry in the model registry (Ch. 09), not just “gpt-oss-20b.” This is the first of several places in this walkthrough where version identity gets threaded through deliberately — see Section 6 for what happens when a team skips this.
Build and smoke-test locally exactly as Ch. 02 teaches — docker build, then docker run --gpus all -p 8000:8000 <image>, then a curl against /v1/chat/completions — before anything touches Kubernetes.
Saying it out loud. Containerizing a GPU server is mostly ordinary Docker discipline with one twist. The ordinary parts: pin the base image to an exact tag rather than latest, run as non-root, keep layers minimal, inject secrets at runtime rather than baking them in. The twist is that the image is huge and the process is slow to become useful — model load plus CUDA graph capture can take a minute or more — so the health endpoint and the probe timings you set here determine whether Kubernetes gives the pod a chance to start or kills it for being slow. Since vLLM ships an official image, the Dockerfile’s real job is thin: pin a version, bake in org config, and set the launch command.
3.4 Deploying to Kubernetes with correct GPU scheduling
This is where Ch. 03 (Kubernetes) and Ch. 05 (vLLM) meet. The parts that are easy to get wrong are the GPU resource request/limit, the node selection, and the probes — a misconfigured liveness probe on a slow-starting GPU pod is a classic way to get your own pod killed mid-model-load.
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: llm-serving
---
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpt-oss-20b-v1
namespace: llm-serving
labels:
app: gpt-oss-20b
version: v1
spec:
replicas: 3 # starting point; KEDA takes over in 3.6
selector:
matchLabels:
app: gpt-oss-20b
version: v1
template:
metadata:
labels:
app: gpt-oss-20b
version: v1
spec:
# Only schedule onto the GPU node pool — the taint/toleration pair below
# keeps non-GPU workloads off expensive GPU nodes and vice versa.
nodeSelector:
node-pool: gpu-a100
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
containers:
- name: vllm-server
image: registry.example.com/gpt-oss-20b:v1
ports:
- containerPort: 8000
resources:
requests:
nvidia.com/gpu: 1
cpu: "4"
memory: 32Gi
limits:
nvidia.com/gpu: 1
cpu: "8"
memory: 48Gi
# Readiness gates traffic; liveness restarts a hung process. The
# generous startupProbe failure budget matters: model load (weights
# + CUDA graph capture) can take 60-90s, and a naive livenessProbe
# without a startupProbe will kill the pod before it ever serves.
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 15
failureThreshold: 3
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: gpt-oss-20b
namespace: llm-serving
spec:
selector:
app: gpt-oss-20b
ports:
- port: 80
targetPort: 8000
---
# pdb.yaml — prevents a node drain / cluster upgrade from taking out every
# replica at once, which on GPU nodes (slow to reschedule, GPUs are scarce)
# is far more painful than on a CPU service.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: gpt-oss-20b-pdb
namespace: llm-serving
spec:
minAvailable: 2
selector:
matchLabels:
app: gpt-oss-20b
The GPU node pool itself (the node-pool: gpu-a100 label, the nvidia.com/gpu resource becoming schedulable at all) is provided by the NVIDIA GPU Operator, which installs the driver, the k8s-device-plugin, and (if you need to share one physical GPU across multiple pods for cheaper dev/staging environments) MIG partitioning or time-slicing. For this scenario’s production traffic, use whole GPUs, one per pod — MIG/time-slicing is a cost trick for lower-QPS environments, not for a 500 req/s SLO’d path, because it splits a card’s memory and compute bandwidth, which directly works against your TTFT budget.
Saying it out loud. The Kubernetes details that matter for GPU workloads are a short list, and getting any of them wrong shows up as a weird production incident rather than a clear error. Set GPU resource requests and limits on every pod so nothing silently oversubscribes a device. Use a startup probe sized from an actual timed cold start rather than a guess, and keep it separate from liveness, so a pod that’s loading a large checkpoint fails readiness instead of getting killed. Add a PodDisruptionBudget so a node drain or a cluster upgrade can’t take out every replica at once. And use taints and node selectors so GPU workloads land on GPU nodes — and, just as importantly, so everything else stays off them.
3.5 Load testing to find the real ceiling
This is the step teams skip and regret. Before wiring autoscaling, you need to know: how many requests per second can one replica actually sustain at a P95 TTFT under 2 seconds? Everything downstream (replica count, autoscaling thresholds, cost model) depends on this number, and it is specific to your model, your hardware, your prompt lengths, and your --max-model-len — you cannot borrow it from a blog post.
Chapter 04 (Load Testing) covers the general methodology (ramping load, percentile tracking, saturation curves); here’s the vLLM-specific piece — a script that measures TTFT correctly by reading the SSE stream rather than waiting for the full response:
# ttft_load_test.py — measures true time-to-first-token against an
# OpenAI-compatible streaming endpoint (vLLM's /v1/chat/completions).
import asyncio
import time
import httpx
import numpy as np
ENDPOINT = "http://gpt-oss-20b.llm-serving.svc.cluster.local/v1/chat/completions"
PROMPT = "Explain the tradeoffs of MIG partitioning vs GPU time-slicing."
async def one_request(client: httpx.AsyncClient) -> float:
start = time.perf_counter()
payload = {
"model": "gpt-oss-20b-v1",
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 256,
"stream": True,
}
async with client.stream("POST", ENDPOINT, json=payload, timeout=30) as resp:
async for chunk in resp.aiter_bytes():
if chunk:
return time.perf_counter() - start # first non-empty chunk = TTFT
return -1.0
async def run_load(concurrency: int, n_requests: int) -> list[float]:
ttfts: list[float] = []
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
async def bound():
async with sem:
ttfts.append(await one_request(client))
await asyncio.gather(*(bound() for _ in range(n_requests)))
return ttfts
async def main():
# Ramp concurrency and watch where P95 TTFT crosses the 2s SLO.
for concurrency in (20, 40, 60, 80, 100, 130):
ttfts = await run_load(concurrency, n_requests=concurrency * 5)
p50, p95, p99 = np.percentile(ttfts, [50, 95, 99])
print(f"concurrency={concurrency:4d} p50={p50:.2f}s p95={p95:.2f}s p99={p99:.2f}s")
if __name__ == "__main__":
asyncio.run(main())
Running this against one replica and ramping concurrency produces a saturation curve like the one every capacity plan should be built from:
concurrency= 20 p50=0.31s p95=0.58s p99=0.71s
concurrency= 40 p50=0.44s p95=0.89s p99=1.10s
concurrency= 60 p50=0.61s p95=1.35s p99=1.72s
concurrency= 80 p50=0.88s p95=1.94s p99=2.60s <- P95 crosses the 2s SLO here
concurrency=100 p50=1.20s p95=2.85s p99=3.90s
concurrency=130 p50=1.90s p95=4.40s p99=6.10s
(Illustrative numbers from a run of this exact script — your actual curve depends on your GPU, prompt length distribution, and --max-model-len; the shape — a knee where P95 suddenly outpaces P50 — is the reliable part, not the exact numbers.)
Read that curve as: one A100 replica sustains roughly 70-75 concurrent in-flight requests before P95 TTFT breaches 2 seconds. Continuous batching means concurrency and req/s aren’t the same axis, so the next step is converting that concurrency ceiling into a req/s ceiling by measuring completions/sec at that same concurrency — in this worked example that comes out to roughly 90 req/s per replica at the SLO boundary. For the 500 req/s target with headroom for the 700 req/s burst, that’s:
[ \text{replicas needed} = \lceil \frac{700}{90} \rceil = 8 \text{ replicas at burst} ]
with a steady-state floor around (\lceil 500 / 90 \rceil = 6) replicas. Those two numbers — 6 and 8 — become the KEDA minReplicaCount and maxReplicaCount in the next section.
Saying it out loud. This is the step teams skip and regret, because every downstream number depends on it: how many requests per second can one replica actually sustain at P95 TTFT under two seconds? You cannot borrow that from a blog post — it’s specific to your model, your hardware, your prompt length distribution, and your max model length. You ramp concurrency and watch for the knee, the point where P95 suddenly outpaces P50. In this walkthrough that’s around 70 to 75 concurrent requests per replica, which converts to roughly 90 requests per second at the SLO boundary. And that single number sets everything after it — six replicas for steady state, eight for burst, which become the autoscaler’s floor and ceiling.
3.6 Wiring autoscaling
Plain HPA scaling on CPU/memory is close to meaningless for a GPU-bound, batching server — the pod’s CPU usage barely moves while the GPU is saturated. The practical default (Ch. 06) is KEDA, scaling on a Prometheus query against vLLM’s own queue-depth metric, vllm:num_requests_waiting — the number of requests sitting in the scheduler queue because the running batch is full. A rising queue is the earliest true signal of saturation, well before GPU utilization alone would tell you.
# keda-scaledobject.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: gpt-oss-20b-scaler
namespace: llm-serving
spec:
scaleTargetRef:
name: gpt-oss-20b-v1
minReplicaCount: 6 # steady-state floor from the load test in 3.5
maxReplicaCount: 10 # burst ceiling (8) plus one replica of headroom
cooldownPeriod: 300 # wait 5 min of low queue depth before scaling down —
# GPU pods are slow to warm up, so avoid flapping
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc.cluster.local:9090
metricName: vllm_queue_depth_per_replica
# Average queued requests per running replica. Threshold of 5 means:
# once each replica has ~5 requests backed up on average, add capacity.
query: |
sum(vllm:num_requests_waiting{namespace="llm-serving", version="v1"})
/
count(up{namespace="llm-serving", version="v1"} == 1)
threshold: "5"
Two details that matter more than the YAML suggests:
cooldownPeriod: 300. GPU pods are expensive to spin up (model load + CUDA graph capture can take a minute or more), so an autoscaler tuned like a web-tier HPA (scale down after 60 seconds of low load) will thrash — scaling a replica down right before the next traffic wave needs it back. Err toward a longer cooldown than you’d use for a stateless CPU service.- The metric is a rate per replica, not a raw count. Scaling on the raw
num_requests_waitingsum without dividing by replica count creates a feedback loop: as you add replicas, the sum doesn’t necessarily drop proportionally, so the trigger can either over- or under-react depending on how load actually distributes. Normalizing per replica keeps the signal comparable regardless of current replica count.
Saying it out loud. Scaling a GPU-bound batching server on CPU is close to meaningless — the pod’s CPU barely moves while the GPU saturates. So the practical default is KEDA scaling on a Prometheus query against the engine’s own queue depth, the number of requests waiting because the running batch is full. A rising queue is the earliest true saturation signal, well before GPU utilization would tell you anything. Two details matter more than the YAML suggests. Use a long cooldown — five minutes, not sixty seconds — because a GPU pod takes a minute or more to warm up and a web-tier cooldown makes it thrash. And normalize the metric per replica rather than scaling on the raw queue sum, or you build a feedback loop where adding replicas doesn’t proportionally drop the number you’re reacting to.
3.7 Monitoring dashboards and alerts
vLLM exposes a native /metrics Prometheus endpoint with per-request histograms — no custom instrumentation needed for the fundamentals (Ch. 08 covers building this out fully; here’s the SLO-critical piece for this scenario). The key metrics: vllm:time_to_first_token_seconds (histogram), vllm:request_queue_time_seconds (time waiting before the scheduler picks the request up), vllm:e2e_request_latency_seconds, and vllm:num_requests_running / vllm:num_requests_waiting (gauges).
The Grafana dashboard for this scenario needs, at minimum, four panels: P50/P95/P99 TTFT over time, queue depth per replica, GPU utilization (from DCGM exporter, installed by the GPU Operator), and requests/sec by version label (so a canary’s traffic is visually distinguishable from stable — this reappears in 3.8).
The alert that actually protects the SLO is a burn-rate style alert on the TTFT histogram:
# prometheus-alerts.yaml
groups:
- name: gpt-oss-20b-slo
rules:
- alert: TTFTSLOBreach
expr: |
histogram_quantile(
0.95,
sum(rate(vllm:time_to_first_token_seconds_bucket{namespace="llm-serving", version="v1"}[5m])) by (le)
) > 2.0
for: 3m
labels:
severity: page
annotations:
summary: "gpt-oss-20b P95 TTFT above 2s SLO for 3+ minutes"
description: "Check queue depth (vllm:num_requests_waiting) and KEDA scaling activity before assuming a code regression — this fires from load first, bugs second."
- alert: QueueDepthRising
expr: |
sum(vllm:num_requests_waiting{namespace="llm-serving", version="v1"})
/
count(up{namespace="llm-serving", version="v1"} == 1)
> 8
for: 2m
labels:
severity: warning
annotations:
summary: "Queue depth per replica above 8 — KEDA should be scaling; verify it is"
description: "Early-warning alert, fires before TTFTSLOBreach so on-call has time to react before the SLO alert pages."
Note the deliberate ordering: QueueDepthRising is a warning that fires before the SLO is actually breached, giving on-call a chance to notice the autoscaler is (or isn’t) reacting before the paging alert fires. This two-tier pattern — an early leading-indicator warning plus a hard SLO page — is the pattern Ch. 08 recommends generally; here it’s tied to the specific metric this platform exposes.
Saying it out loud. The good news on monitoring is that vLLM exposes native Prometheus histograms — TTFT, queue time, end-to-end latency, running and waiting request counts — so there’s no custom instrumentation for the fundamentals. The four panels I’d insist on for this scenario are TTFT percentiles over time, queue depth per replica, GPU utilization from DCGM, and requests per second broken out by version label, so a canary’s traffic is visually distinguishable from stable. The alert that actually protects the SLO is a burn-rate alert on the TTFT histogram with a for-clause, paired with a leading-indicator warning on queue depth — so on-call gets a heads-up with reaction time before the page that says users are already hurting.
3.8 Canary/rollout process for the next model update
Six months in, the team wants to ship gpt-oss-20b-v2 — a new checkpoint, or a new --max-model-len/sampling config, or both. This is where Ch. 07 (Canary Deployments) and Ch. 09 (Model Versioning) meet the rest of the running system. Using Argo Rollouts (the general-purpose choice from the Section 2 table):
# rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: gpt-oss-20b
namespace: llm-serving
spec:
replicas: 8
strategy:
canary:
steps:
- setWeight: 5 # 5% of traffic to v2 first
- pause: {duration: 10m}
- analysis:
templates:
- templateName: gpt-oss-slo-check
- setWeight: 25
- pause: {duration: 15m}
- analysis:
templates:
- templateName: gpt-oss-slo-check
- setWeight: 100
selector:
matchLabels:
app: gpt-oss-20b
template:
metadata:
labels:
app: gpt-oss-20b
spec:
containers:
- name: vllm-server
image: registry.example.com/gpt-oss-20b:v2 # new image = new version identity
# ... same resources/probes as 3.4
---
# analysistemplate.yaml — the automated go/no-go gate at each canary step
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: gpt-oss-slo-check
namespace: llm-serving
spec:
metrics:
- name: ttft-p95
interval: 2m
successCondition: result < 2.0
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring.svc.cluster.local:9090
query: |
histogram_quantile(0.95,
sum(rate(vllm:time_to_first_token_seconds_bucket{version="canary"}[5m])) by (le))
- name: error-rate
interval: 2m
successCondition: result < 0.01
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring.svc.cluster.local:9090
query: |
sum(rate(http_requests_total{namespace="llm-serving", version="canary", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{namespace="llm-serving", version="canary"}[5m]))
This automatically halts and rolls back the canary if either the TTFT SLO or the error-rate threshold breaches during the pause windows, without a human needing to be watching a dashboard in real time. Two things this YAML deliberately does not solve, on purpose, so we can talk about them precisely in Section 6: it says nothing about whether the prompt template shipped with v2 is also versioned and rolled back together with the weights, and it says nothing about what the autoscaler does to pod counts while this rollout is in progress. Both are real incidents, not hypotheticals — covered next.
Saying it out loud. The rollout itself is standard staged-canary: five percent of traffic first, automated analysis on TTFT and error rate at each pause, and automatic halt-and-rollback if either breaches, so no human has to be watching a dashboard at the moment it matters. What I’d point out about this YAML is what it deliberately doesn’t solve, because both gaps are real incidents rather than hypotheticals. It says nothing about whether the prompt template that shipped with the new version is versioned and rolled back together with the weights. And it says nothing about what the autoscaler does to pod counts while the rollout is in progress. Two independent controllers with opinions about the same replica set is a race, and it bites.
4. Cost Engineering
This section builds a real cost model on top of the walkthrough in Section 3, tying together the GPU/quantization choices of Ch. 05, the autoscaling headroom of Ch. 06, and the versioning overhead of Ch. 09. The goal isn’t a single number — it’s a model you can re-run when any input changes (GPU price, traffic, SLO).
4.1 The baseline: cost per replica-hour
Take the A100 80GB choice from 3.2, at an illustrative on-demand rate of ($1.99)/hour (2026 baseline; real quotes range roughly ($1.49)-($6.98)/hour across providers depending on region, commitment, and spot vs on-demand — always get a current quote before committing to a number in a budget review).
From the load test in 3.5, one replica sustains about 90 req/s at the SLO boundary. That gives a cost per 1,000 requests at the SLO ceiling:
[ \text{cost per 1k req} = \frac{$1.99/\text{hr}}{90 \text{ req/s} \times 3600 \text{ s/hr}} \times 1000 = \frac{$1.99}{324{,}000} \times 1000 \approx $0.0061 ]
That’s the cost floor — the number you get if every replica runs pinned at exactly the SLO boundary, all day, every day. No real system runs there, which is exactly what the next section quantifies.
Saying it out loud. The cost floor is easy arithmetic and worth being able to do live: GPU dollars per hour divided by requests per second times 3600, times a thousand, gives you cost per thousand requests. At around two dollars an hour and 90 requests per second, that’s roughly six-tenths of a cent per thousand requests. But I’d flag immediately that this is a floor, not a forecast — it assumes every replica runs pinned at exactly the SLO boundary all day, which no real system does. And the two-dollar figure is a 2026 snapshot; real quotes span roughly one and a half to seven dollars an hour depending on region, commitment, and spot versus on-demand, so always re-quote before a budget review.
4.2 Utilization and autoscaling headroom
Section 3.6 set minReplicaCount: 6 and maxReplicaCount: 10, with the 90 req/s-per-replica ceiling from the load test. At the 500 req/s steady-state target:
[ \text{replicas at steady state} = \frac{500}{90} \approx 5.6 \rightarrow 6 \text{ replicas (rounds up to the floor)} ]
[ \text{effective utilization} = \frac{500}{6 \times 90} = \frac{500}{540} \approx 92.6% ]
That’s a good utilization number — the 6-replica floor was sized close to the actual steady-state need. But steady state isn’t the whole day: real traffic has a diurnal curve, and the honest cost model has to integrate over it, not just price the peak or the trough.
| Time of day | Req/s | Replicas needed (@ 90 req/s each) | Replica-hours in this window (4h blocks) |
|---|---|---|---|
| Overnight trough | 150 | 6 (floor, not 2 — can’t go below minReplicaCount) | 24 |
| Daytime baseline | 400 | 6 | 24 |
| Evening peak | 650 | 8 | 24 |
| Burst spikes (rare, ~1h/day) | 700 | 8 (at max before hitting maxReplicaCount: 10 ceiling) | 8 |
Blended average replica count across a day here is roughly ((6 \times 16 + 8 \times 8)/24 \approx 6.67) replica-hours per hour of wall clock, i.e. about 160 replica-hours/day, versus a naive “always run 8 for the burst case” static allocation of 192 replica-hours/day. That’s the autoscaling headroom paying for itself: roughly 17% fewer GPU-hours than statically provisioning for peak, purely from the minReplicaCount/maxReplicaCount band matching the real traffic curve instead of a fixed pool sized for the worst case.
The minReplicaCount: 6 floor is itself a cost decision, not just a latency one: it exists because GPU pods are slow to cold-start (model load + CUDA graph capture), so scaling below 6 to chase overnight-trough savings would mean the autoscaler can’t react fast enough to the next demand ramp without a TTFT SLO breach during the scale-up window. That tradeoff — floor higher than the strict minimum traffic requires, in exchange for avoiding cold-start latency spikes — is a cost-vs-reliability decision every autoscaling config makes implicitly; make it explicitly and write down why.
Saying it out loud. The honest cost model integrates over the traffic curve rather than pricing the peak or the trough. In this scenario the floor is six replicas and the peak is eight, which blends to about 6.7 replicas across a day, or roughly 160 replica-hours — versus 192 if you statically provisioned eight for the burst case. That’s about 17% fewer GPU-hours purely from the autoscaling band matching the real traffic curve. The part worth saying explicitly is that the six-replica floor is a cost decision as much as a latency one: you could serve the overnight trough with two, but GPU pods cold-start too slowly to ramp back up without breaching the SLO. That’s a cost-versus-reliability trade every autoscaler config makes implicitly — make it explicitly and write down why.
4.3 Quantization tradeoffs
gpt-oss-20b’s native MXFP4 format is close to a free lunch here — it’s how the model ships, not an extra step you’re choosing to take on. The more general tradeoff, worth understanding for models that don’t ship pre-quantized, is: quantizing further (e.g., additional INT4/AWQ on top of an already-BF16 checkpoint) trades a small, usually-recoverable quality loss for a real memory reduction, and that memory reduction converts directly into either (a) fitting on a cheaper/smaller GPU, or (b) fitting more KV cache on the same GPU, which raises the concurrency ceiling found in the load test and therefore lowers cost-per-request. Concretely:
[ \text{cost per request} \propto \frac{\text{GPU $/hr}}{\text{req/s ceiling at that quantization level}} ]
Both terms move when you quantize further — the numerator can drop (cheaper GPU fits) and the denominator can rise (more concurrent requests fit in the freed-up memory) — which is why quantization is usually the single highest-leverage cost lever available, ahead of autoscaling tuning or GPU shopping. It’s also the one with the least reversible risk if pushed too far: below a certain bit-width, quality regressions stop being subtle and start being visible to users, which is exactly the kind of thing the drift/quality monitor (Ch. 10) and the eval gate in CI need to catch before a more-quantized version ever becomes a canary.
Saying it out loud. Quantization is usually the single highest-leverage cost lever, ahead of autoscaling tuning or GPU shopping, and the reason is that it moves both sides of the fraction at once. Cost per request is roughly GPU dollars per hour over the requests-per-second ceiling at that precision. Quantize further and the numerator can drop, because a cheaper card now fits the model, while the denominator rises, because the freed memory holds more KV cache and raises the concurrency ceiling. It’s also the lever with the least reversible risk: below a certain bit width, quality regressions stop being subtle and become visible to users. Which is exactly why the eval gate in CI and the drift monitor have to catch it before a more-quantized version ever becomes a canary.
4.4 Putting it together
The full monthly cost estimate for this scenario, combining 4.1-4.3:
[ \text{monthly GPU cost} \approx 160 \text{ replica-hours/day} \times 30 \text{ days} \times $1.99/\text{hr} \approx $9{,}552/\text{month} ]
against a naive fixed-8-replica baseline of:
[ 8 \times 24 \times 30 \times $1.99 \approx $11{,}462/\text{month} ]
— roughly ($1{,}900)/month saved purely from autoscaling headroom matching the traffic curve, on top of whatever the GPU-choice decision in 3.2 (A100 vs H100) already saved versus defaulting to the most expensive card. Neither number includes the model server’s own overhead (registry storage, CI/eval compute, observability stack) — those are real but typically small (single-digit percentage) relative to the GPU line, and should be budgeted separately rather than folded into the per-request math, since they don’t scale with request volume the same way.
Saying it out loud. Putting the numbers together: about 160 replica-hours a day at roughly two dollars an hour is on the order of nine and a half thousand dollars a month, versus about eleven and a half thousand for a naive fixed-eight-replica allocation — call it nineteen hundred dollars a month saved from autoscaling alone, on top of whatever choosing the cheaper GPU already saved. The honest caveat is that this excludes registry storage, CI and eval compute, and the observability stack. Those are real but typically single-digit percentages of the GPU line, and I’d budget them separately rather than folding them into per-request math, because they don’t scale with request volume the same way.
5. Failure Modes That Only Show Up at the System Level
Every chapter in this guide has its own failure-mode list, and those lists are correct — but they assume the rest of the system behaves. The incidents below all happened (in one form or another, across real teams) because two components, each individually correct and each passing its own chapter’s checklist, interacted in a way neither owner anticipated. This is the section to reread before an incident retro, not just before a launch.
5.1 The canary that passed, then got un-canaried by the autoscaler
Symptom: A canary rollout (3.8) completes — all analysis steps pass, weight hits 100%. Twenty minutes later, users start reporting the old model’s behavior again, even though the Rollout object shows v2 at 100%.
Root cause: The Rollout controller manages traffic weight and a target replica count, but the HPA/KEDA autoscaler (Ch. 06) has its own idea of the right replica count for the Deployment (or ReplicaSet) it’s attached to, independently of what the canary is doing. During the canary, traffic to the stable version dropped as weight shifted to v2, so the autoscaler — reacting correctly, in isolation, to falling queue depth on the stable ReplicaSet — scaled stable down. Then, when the canary promoted and Argo Rollouts scaled the stable ReplicaSet to zero (or attempted to), a race between the autoscaler’s own next reconcile loop and the Rollout controller’s scale-down briefly (or not-so-briefly, depending on cooldown settings) left stable pods running and receiving traffic from a stale Service selector or an in-flight load balancer connection pool that hadn’t yet drained toward v2.
Why no single chapter’s checklist catches this: Ch. 06’s autoscaling checklist verifies the autoscaler correctly tracks load for a given Deployment. Ch. 07’s canary checklist verifies traffic-weight steps and analysis gates work correctly for a given Rollout. Neither chapter’s model of the system includes the other one’s controller reconciling against the same underlying ReplicaSets on independent timers.
Fix: Two independent controllers must not both have opinions about the same ReplicaSet’s replica count during a rollout. In practice: let Argo Rollouts fully own replica count during an active rollout (its own canary/stable ReplicaSet management already does this correctly if you don’t also point an HPA/KEDA ScaledObject directly at the same ReplicaSets) — point the ScaledObject at the Rollout resource itself, not at the underlying Deployment/ReplicaSet, so there is exactly one control loop deciding “how many pods, of which version” at any moment. Verify this by watching kubectl get replicasets -w through a full canary cycle in staging before trusting it in production — the race is timing-dependent and easy to miss in a quick test.
Saying it out loud. This is my favorite seam because both components were individually correct. The canary controller owns traffic weight and a replica count for its rollout. The autoscaler independently owns a replica count for the underlying deployment. During the canary, traffic to stable drops, so the autoscaler correctly reacts to falling queue depth and scales stable down — and then the two control loops race on their own timers during promotion, leaving stale pods still receiving traffic after the rollout reports complete. Neither chapter’s checklist catches it, because neither one’s model of the world includes the other controller. The fix is structural, not a dashboard: exactly one control loop owns replica count at any moment — point the scaler at the rollout resource, not at the underlying replica sets.
5.2 The rollback that forgot the prompt template
Symptom: v2’s canary looks fine on every automated metric — latency, error rate, throughput. Days after full promotion, a slow-building wave of user complaints about “weird” or subtly wrong answers surfaces. The on-call engineer rolls back to v1’s weights (redeploys the v1 image). The complaints don’t stop.
Root cause: The “version” that actually determines model behavior is the tuple mentioned in Section 1: weights + tokenizer + prompt/chat template + sampling defaults + engine config. v2 shipped with weights and an updated system prompt (a wording tweak meant to reduce refusals) that was deployed through a separate path — a config service, a feature flag, or a prompt-management tool outside the model registry (Ch. 09) entirely. Rolling back the container image reverted the weights but not the prompt template, because they were never versioned as one unit. The model now runs v1 weights against v2’s prompt — a combination that was never tested, canaried, or evaluated together.
Why no single chapter’s checklist catches this: Ch. 09 (Model Versioning) teaches you to version model artifacts rigorously — but if the prompt template lives in a separate system owned by a different team (product, or a “prompt ops” tool), it’s outside that chapter’s scope by construction, and nobody’s checklist spans both systems. Ch. 07’s canary checklist verifies the canary rollout mechanism, not what’s inside the versioned unit it’s rolling out.
Fix: The prompt template, chat template, and sampling defaults must be immutable artifacts of the same version bump as the weights — stored in the same registry entry (Ch. 09), baked into the same container image or pulled by version-pinned reference at startup, never mutated independently via a config flag that isn’t itself versioned and rolled back atomically with the model. If product needs to iterate on prompts faster than model releases, that’s a legitimate need — but it must go through the same canary/registry/rollback machinery as a weights change, not around it. A useful audit question for any platform: “if I roll back the model version right now, what doesn’t roll back with it?” If the honest answer is “the prompt,” you have this bug waiting to happen.
Saying it out loud. The audit question I’d hand anyone running a platform is: if I roll back the model version right now, what doesn’t roll back with it? If the honest answer is “the prompt,” you have this bug queued up. What happened here is that a new version shipped weights plus an updated system prompt, but the prompt was deployed through a separate path — a config service or a prompt-management tool outside the registry. Rolling back the container reverted the weights and not the prompt, so production ended up running old weights against the new prompt, a combination nobody had ever tested. The fix is that prompt, chat template, and sampling defaults are immutable artifacts of the same version bump. If product needs to iterate on prompts faster than model releases, fine — but through the same canary and rollback machinery, not around it.
5.3 Monitoring blind spots between layers
Symptom: Every per-component dashboard is green — gateway 2xx rate is high, model-server P95 TTFT is under SLO, GPU utilization looks healthy — and yet real users are experiencing timeouts.
Root cause (three variants, all real):
- The gateway’s timeout is shorter than the model server’s. The gateway (Ch. 01/03 layer) times out and returns a clean 504 after, say, 10 seconds; the model server metrics only record latency for requests it actually completes, so a request the gateway already gave up on never shows up in the model server’s TTFT histogram at all — it just silently doesn’t count. The model server’s dashboard looks perfect because it’s only measuring the subset of requests that didn’t get cut off upstream.
- Per-pod metrics look fine while the aggregate breaches, because the load balancer isn’t distributing evenly — a sticky-session or weighted routing bug sends a disproportionate share of traffic to a subset of replicas, which individually stay under their own alert thresholds while the P95 measured client-side (across all replicas) breaches. Per-pod dashboards (Ch. 08’s default view) can hide this completely; you need a client-side or gateway-side latency view, not just server-side histograms, to catch it.
- The drift monitor (Ch. 10) is watching the stable version’s traffic distribution, not the canary’s. During a canary, 5-25% of traffic is going to a version whose input distribution characteristics (e.g., if the canary changes
--max-model-lenor a routing rule shifts prompt lengths) are never compared against baseline until full promotion — by which point it’s not a canary anymore, it’s just production, and any drift-driven quality regression has already reached 100% of users.
Why no single chapter’s checklist catches this: each chapter’s monitoring guidance is scoped to the component it teaches. Ch. 08 teaches you to monitor the model server; it doesn’t mandate a client-side or gateway-side view. Ch. 10 teaches drift detection generally; nothing in that chapter forces you to apply it per-version during a canary rather than only to the aggregate production stream.
Fix: Maintain at least one cross-layer latency measurement (synthetic canary requests sent from outside the cluster, measuring true end-to-end time including the gateway) in addition to the model server’s own histograms, and make sure every per-component alert and every drift check is labeled and filterable by version, so a canary’s behavior is visible in isolation before it becomes the whole system’s behavior.
Saying it out loud. Every per-component dashboard green while users are timing out is the shape to recognize, and there are three common causes. One: the gateway’s timeout is shorter than the model server’s, so requests the gateway already gave up on never enter the server’s latency histogram at all — the server’s dashboard is perfect because it’s only measuring the survivors. Two: uneven load balancing means individual pods each stay under their own thresholds while the client-side P95 across all replicas breaches. Three: the drift monitor is watching aggregate production traffic, so a canary’s distribution is never compared to baseline until it’s already at 100%. The fix for all three is the same pair — at least one client-side synthetic check measuring true end-to-end time, and a version label on every metric and every drift check.
5.4 Multi-region registry divergence
Symptom: A canary promotes cleanly in us-east, the team calls the rollout done, and three weeks later a eu-west on-call engineer discovers the region has been silently running the old version the entire time.
Root cause: The model registry (Ch. 09) was treated as a per-region resource — each region’s cluster pulled from its own local registry mirror or config, and the promotion pipeline only pushed the “promote to v2” event to the region where the on-call engineer happened to run it. Nothing in the system enforced that “promoted” means the same thing in every region simultaneously.
Fix: Per Section 2.3, if you operate multi-region at all, the model registry must be a genuinely global source of truth (or have an explicit, monitored, alerting-backed replication/sync step) — “promoted” needs to be a single fact checked against every region’s actual running version, not an event fired once and assumed to propagate.
Saying it out loud. This one is short and it stings: a canary promotes cleanly in one region, the team calls the rollout done, and three weeks later someone in another region discovers it’s been running the old version the whole time. The cause is that the registry was treated as a per-region resource, and the promotion pipeline only fired the event in the region the engineer happened to be working in. Nothing in the system enforced that “promoted” means the same thing everywhere. So if you run multi-region at all, promotion has to be a global fact verified against every region’s actually-running version, with alerting on divergence — not an event fired once and assumed to propagate.
6. Pre-Launch Checklist
A consolidated checklist across all eleven chapters, ordered roughly the way you’d actually verify it. Treat “no” on any item as a launch blocker unless you can name the specific, accepted risk you’re taking instead.
Serving fundamentals (Ch. 01, Ch. 02)
- The server handles malformed requests, oversized inputs, and client disconnects without crashing or leaking GPU memory.
- The container image is pinned to an exact base-image tag and dependency versions — no
:latest. - The container runs as a non-root user; secrets (API keys, registry credentials) are injected at runtime, never baked into the image.
- Local
docker run --gpus allsmoke test passes before anything touches Kubernetes.
Kubernetes (Ch. 03)
- GPU resource requests/limits are set on every model-server pod spec (no pod can silently oversubscribe a GPU).
-
startupProbeaccounts for real model-load time (weights + CUDA graph capture), verified by timing an actual cold start, not guessed. - A PodDisruptionBudget exists so a node drain or cluster upgrade can’t take out every replica simultaneously.
- Node taints/tolerations and nodeSelectors correctly keep GPU workloads on GPU nodes and off them for everything else.
Load testing (Ch. 04)
- A real load test (not a guess) has produced a concurrency-vs-latency saturation curve for the actual production model, hardware, and
--max-model-len. - The req/s-per-replica ceiling used for capacity planning and autoscaling thresholds comes from that curve, not from a vendor blog post or a different model’s numbers.
- Load tests include realistic prompt-length distributions, not just short synthetic prompts — TTFT and memory pressure both depend heavily on prefill length.
Serving engine (Ch. 05, Ch. 11)
- Quantization format (if any) has been validated for quality against a golden eval set, not just for throughput.
-
--max-model-len,--gpu-memory-utilization, and prefix-caching settings are deliberate choices, documented with the reasoning, not defaults left untouched. - If using Triton: backend choice (vLLM backend vs TensorRT-LLM backend) matches the actual multi-model/ensemble requirement, not just habit.
Autoscaling (Ch. 06)
- Autoscaling triggers on a GPU/queue-relevant signal (queue depth, GPU utilization) — not on CPU/memory alone.
-
minReplicaCountaccounts for cold-start time, not just steady-state traffic minimums. - Cooldown/stabilization windows are tuned for GPU pod spin-up latency, not copied from a CPU-service HPA config.
- Only one control loop owns replica count for any given ReplicaSet during a rollout (see Section 5.1).
Canary/rollout (Ch. 07)
- Automated analysis gates (latency, error rate, and ideally a quality/eval signal) run at every canary step — no step is “promote and hope.”
- Rollback is a single action that reverts weights, prompt template, and engine config together (see Section 5.2) — verified by actually triggering a rollback in staging and checking all three reverted.
- Canary traffic is labeled distinctly (
versionlabel) all the way through metrics, logs, and drift checks.
Monitoring (Ch. 08)
- Dashboards exist for TTFT, inter-token latency, queue depth, GPU utilization, and error rate, all filterable by model version.
- At least one client-side or gateway-side synthetic latency check exists in addition to server-side histograms (see Section 5.3).
- Every SLO-protecting alert has a
for:duration tuned to avoid paging on a single noisy scrape, and a documented runbook link in the alert annotation. - A leading-indicator warning alert (e.g., rising queue depth) exists ahead of the hard SLO-breach page, so on-call has reaction time.
Model versioning (Ch. 09)
- Every deployed version is a registry entry that captures weights + tokenizer + prompt template + sampling defaults + engine config as one unit.
- No config path exists that can change model-affecting behavior (prompt, sampling params) outside the versioned registry entry.
- Multi-region deployments treat “promoted” as a single global fact, checked per region, not an event assumed to propagate (see Section 5.4).
Drift detection (Ch. 10)
- Drift/quality monitoring runs per-version (including on canary traffic specifically), not only on the aggregate production stream.
- A documented baseline (input distribution + quality scores) exists from the last known-good version to compare against.
- Drift alerts feed back into the canary/rollout controller’s promotion decision, not just into a separate dashboard nobody watches during a rollout.
Cost (Section 4 of this chapter)
- A capacity plan exists that’s derived from the actual load-test ceiling, not a round-number guess.
-
minReplicaCount/maxReplicaCountare justified against the real traffic curve (diurnal pattern), not set to “whatever felt safe.” - Someone has computed cost-per-1000-requests at the current stack choice and can defend it against at least one alternative (different GPU, different quantization).
Organizational
- On-call has run at least one game-day: trigger a canary rollback, kill a pod mid-request, and simulate a GPU node failure, and confirm the system (and the humans) behave as expected.
- The pre-launch checklist itself is versioned somewhere and gets revisited after the first real incident — the failure modes in Section 5 were all discovered this way, not designed for up front.
Saying it out loud. The way to use a checklist like this is that a “no” on any item is a launch blocker unless you can name the specific risk you’re accepting instead. The items I’d flag as most commonly missed: a startup probe timed against a real cold start rather than guessed, a PodDisruptionBudget so a cluster upgrade can’t drain every replica at once, load tests that use realistic prompt length distributions rather than short synthetic prompts, exactly one control loop owning replica count during a rollout, and a rollback verified in staging to actually revert weights, prompt, and engine config together. And one organizational item that matters as much as any technical one: on-call has actually run a game day, so the first canary rollback isn’t happening for the first time during a real incident.
7. Interview Mastery
7.1 The 60-second answer
“Walk me through how you’d stand up an LLM inference platform.”
“I’d start by picking the engine based on the actual requirement — vLLM if it’s one or a few open-weight models and I want max throughput per GPU dollar, Triton if I need one server fronting multiple frameworks or models. I’d containerize that engine with a pinned image and a health endpoint, then deploy it to Kubernetes with correct GPU resource requests, a startup probe that accounts for real model-load time, and a PodDisruptionBudget so node drains don’t take out every replica. Before I wire any autoscaling, I load test to find the actual concurrency-vs-latency ceiling for that model on that hardware — that number drives everything downstream. Autoscaling then triggers on a GPU-relevant signal like queue depth, with a
minReplicaCountthat accounts for cold-start time, using KEDA rather than plain HPA. New model versions go through a canary — Argo Rollouts or KServe — with automated analysis gates on latency and error rate, and the version being rolled out is the full tuple: weights, prompt template, sampling config, all versioned and rolled back together, not just the checkpoint. Observability wraps the whole thing: Prometheus scraping the engine’s own metrics, dashboards split by version, alerts with a leading indicator ahead of the hard SLO page. And a drift/quality monitor watches whether the model’s actual behavior in production still matches what was validated, per version, feeding back into whether a canary should be trusted to promote. The parts that bite you in production are almost never inside one of those boxes — they’re in the seams between them, like an autoscaler and a canary controller both trying to own the same ReplicaSet’s replica count.”
7.2 Q&A
Q1: Why vLLM over raw HuggingFace transformers.generate() for production serving?
A: Naive generate() processes requests with no batching sophistication — either one at a time, or static batches that stall on the slowest sequence in the batch. vLLM’s continuous batching admits and evicts requests from a running batch every iteration, so a fast-finishing sequence’s slot is immediately reused, and PagedAttention manages KV cache in fixed-size blocks instead of one contiguous allocation per sequence, eliminating the fragmentation that would otherwise cap concurrency far below what the GPU’s memory could actually support. The practical result is roughly an order of magnitude more throughput at comparable latency (Ch. 05).
Q2: When would you choose Triton over vLLM? A: When the actual requirement is multi-model or multi-framework serving behind one protocol — PyTorch, ONNX, TensorRT-LLM, and custom Python backends all fronted by one server, with ensembles or model pipelines — or when the org already has NVIDIA-centric MLOps tooling built around Triton. Triton can also run vLLM as a backend, so it’s not strictly either/or; the question is whether you need Triton’s multi-model management plane on top of whatever engine does the actual generation.
Q3: How does continuous batching interact with autoscaling decisions? A: Continuous batching means one replica’s capacity isn’t a fixed req/s number — it’s a saturation curve where latency stays flat until a concurrency knee, then degrades sharply (Section 3.5). Autoscaling has to trigger on a signal that reflects queue pressure (requests waiting because the running batch is full), not raw req/s or CPU, because req/s alone doesn’t tell you where you are on that curve — the same req/s can be comfortable or already-saturated depending on prompt length and generation length.
Q4: Explain PagedAttention and why it matters for capacity planning. A: It manages the KV cache in fixed-size, non-contiguous blocks (like OS virtual memory pages) instead of pre-allocating one contiguous buffer per sequence sized for the worst case. That eliminates internal fragmentation from over-allocation and external fragmentation from variable sequence lengths, so a given amount of GPU memory supports meaningfully more concurrent sequences. For capacity planning, this means the concurrency ceiling you measure in a load test is much closer to what the hardware can actually deliver, rather than being capped by memory-allocation inefficiency (Ch. 05).
Q5: How do you correctly measure TTFT vs end-to-end latency, and why does the distinction matter for SLOs? A: TTFT is measured from request start to the first streamed token/chunk — for a streaming chat UI, that’s the number users actually perceive as “responsiveness.” End-to-end latency includes the full generation, which scales with output length and is a worse proxy for perceived responsiveness on long generations. Measuring TTFT requires reading the actual SSE/stream response and timing the first non-empty chunk (Section 3.5’s load-test script), not waiting for the full response and back-computing an average — that would hide exactly the queueing behavior an SLO is meant to catch.
Q6: Design a canary rollout strategy for a new model version — what gates would you use? A: Staged traffic weights (e.g., 5% → 25% → 100%) with a pause and an automated analysis gate at each step, checking at minimum: P95/P99 latency (TTFT specifically, not just end-to-end) against the SLO, error rate, and ideally an automated quality/eval signal on a golden set of prompts sampled through the canary specifically. Rollback on gate failure should be automatic, not paged-and-manual. Critically, the “version” under test must include the prompt template and sampling config, not just the weights (Section 5.2).
Q7: What’s wrong with scaling GPU pods on CPU/memory metrics? A: A GPU-bound inference server’s CPU usage barely correlates with how saturated the GPU actually is — the bottleneck is GPU compute and memory (KV cache), not CPU cycles. Scaling on CPU either reacts too late (GPU is already saturated well before CPU shows it) or not at all. The fix is scaling on a signal that directly reflects GPU-side load: queue depth, GPU utilization from DCGM, or a custom Prometheus metric the engine exposes (Ch. 06).
Q8: How do you handle a rollback that needs to revert more than just the model weights? A: Treat the deployable unit as weights + tokenizer + prompt/chat template + sampling defaults + engine config, versioned and stored together in the model registry, so that “roll back to v1” is a single atomic action that reverts all of it — never a container-image rollback plus a hope that nothing else changed independently. Verify this in staging by actually triggering a rollback and diffing every one of those components against what v1 originally shipped with (Section 5.2).
Q9: What GPU scheduling primitives would you use in Kubernetes, and when? A: Whole-GPU-per-pod scheduling via the NVIDIA device plugin for production, latency-sensitive traffic — you want the full memory bandwidth and compute of the card, not a shared slice. MIG (physical partitioning with hard memory/compute isolation) or time-slicing (soft sharing of one GPU across multiple pods) are for lower-QPS environments — dev/staging, batch/offline inference, or many small models that individually don’t need a full GPU — because both reduce the compute and/or memory available to any one workload, which works against a tight latency SLO (Ch. 03).
Q10: How would you detect and respond to model drift in production? A: Compare live input distribution characteristics (prompt length, topic/embedding-space shift) and output quality signals (automated eval scores, user feedback signals like regeneration rate) against a documented baseline captured from the last known-good version. Crucially, run this per-version — including on canary traffic specifically during a rollout, not only on the aggregate post-promotion stream — and feed drift alerts back into the canary controller’s promotion decision, not just into a dashboard (Ch. 10, Section 5.3).
Q11: Walk through your cost model for a serving platform — what levers matter most?
A: Start from a measured (not assumed) req/s-per-replica ceiling at your SLO, derive replica-hours needed against your actual traffic curve (not just peak or average), and price that against real GPU $/hr for your chosen card. The highest-leverage lever is usually quantization, because it moves both sides of the cost-per-request ratio at once (cheaper GPU fits, and/or more concurrency fits per GPU); the second is matching autoscaling’s min/maxReplicaCount band to the real diurnal traffic curve rather than either a static peak-sized pool or a floor set too low to survive cold-start latency (Section 4).
Q12: What happens to KV cache / prefix cache across a canary or version bump? A: Prefix/KV cache is per-process and per-model-version — it does not and should not transfer across a version boundary, since a cache entry computed under one set of weights or one prompt template is not valid under another. Practically this means a fresh canary replica starts “cold” on prefix caching, so early canary-traffic latency samples may look slightly worse than steady-state stable traffic purely from cache-warmth differences — worth accounting for when reading a canary’s early analysis-gate metrics so you don’t misattribute a warm-up artifact to a real regression.
Q13: How do you avoid the “canary passed, then the autoscaler undid it” bug? A: Make sure exactly one control loop owns replica count for a given ReplicaSet during an active rollout — point the autoscaler at the Rollout resource itself rather than also pointing it independently at the underlying stable/canary ReplicaSets, so there’s no race between the canary controller’s traffic-weight/replica-count management and a separate autoscaler reconciling the same pods on its own timer (Section 5.1).
Q14: Single-region vs multi-region — how do you decide, and what’s the biggest operational risk in multi-region? A: Start single-region; move to multi-region only for a specific forcing function — global latency requirements, a contractual availability SLA a single region’s outage would breach, GPU capacity constraints, or data-residency law. The biggest operational risk is registry/state divergence: “promoted” or “rolled back” needs to be a fact checked against every region’s actual running version, not an event assumed to propagate — silent regional divergence is a real, recurring incident pattern (Section 5.4).
Q15: What’s your incident response plan if the TTFT SLO breaches during a traffic spike?
A: First check whether the autoscaler is reacting — queue depth rising with replica count flat suggests either a cooldown/cap issue or a genuinely unprecedented spike beyond maxReplicaCount. If capacity is the issue, temporarily raise maxReplicaCount (with awareness of GPU-node-pool availability) rather than tuning thresholds blind mid-incident. If replica count is scaling correctly but latency still breaches, check for a non-capacity cause — a bad canary in flight, an unusually long-prompt traffic pattern, or a GPU-health issue (falling back to a degraded state without crashing, which health probes may not catch — Section 5.3). Only after capacity and health are ruled out should you suspect an actual model/code regression.
Q16: How would you validate a quantization change before shipping it? A: Run the golden eval set (the same one gating any model version change, Ch. 10) against the quantized model and compare quality scores directly against the unquantized baseline, not just against a generic benchmark — some quality regressions are task-specific and won’t show up on a broad benchmark. Load test separately to confirm the expected throughput/memory win actually materializes on your real hardware and prompt distribution, since quantization’s benefit is workload-dependent. Ship it through the same canary pipeline as any other model version change — a quantization change is a version change, not a special case that skips the rollout process.
Q17: What’s the role of a model registry beyond just storing weights? A: It’s the single source of truth for “what does version N mean” — the full tuple of weights, tokenizer, prompt/chat template, sampling defaults, and engine config, so that a deployment, a canary, a rollback, or a cross-region promotion all reference the same unambiguous definition of a version. Without that, teams end up with version drift where the “same” version means something subtly different depending on which system you ask (Section 5.2, Ch. 09).
Q18: How do you load test an LLM server correctly, versus a typical web service? A: A typical web service load test cares about req/s and a latency percentile that’s roughly load-independent up to a hard capacity wall. An LLM server’s latency is a function of concurrency, prompt length, and generation length simultaneously, and TTFT specifically requires reading the stream rather than the full response. You need to ramp concurrency (not just req/s) to find the saturation knee, use a realistic prompt-length distribution (not uniform short prompts), and separately track TTFT and inter-token latency, because they degrade differently and an SLO usually cares about TTFT specifically (Ch. 04, Section 3.5).
Q19: What monitoring blind spot is most likely to bite a team that’s only looked at per-component dashboards? A: A mismatch between the gateway’s request timeout and the model server’s own latency histograms — the model server only records latency for requests it completes, so requests the gateway already gave up on vanish from its metrics entirely, making the model-server dashboard look healthier than what users actually experience. The fix is a client-side or gateway-side synthetic latency check in addition to server-side histograms (Section 5.3).
Q20: Tell me about a failure mode that spans two systems/chapters that most engineers miss. A: (Use any of Section 5’s incidents as source material — the autoscaler-undoes-the-canary race in 5.1 is the strongest one to lead with, because it demonstrates the core idea of this whole chapter: individually correct components, incorrect system.) Frame the answer around: what looked fine per-component, what the actual interaction was, and what changed structurally (not just “we added a dashboard”) to prevent recurrence — interviewers are listening for whether you fix the root interaction or just add monitoring around the symptom.
7.3 Tradeoff tables
Engine choice
| Raw Transformers | vLLM | Triton (+ vLLM or TensorRT-LLM backend) | Managed API | |
|---|---|---|---|---|
| Throughput/GPU-$ | Low | High | High (matches underlying backend) | N/A (you pay per-token, not per-GPU) |
| Ops burden | Low (but you own scaling/batching yourself) | Medium | Medium-High (extra server layer) | Lowest |
| Multi-model/framework | Poor fit | Possible, more manual | Strong native fit | N/A |
| Time to first working demo | Fastest | Fast | Slower (backend build/config step) | Fastest |
| Control over weights/fine-tuning | Full | Full | Full | None/limited |
| Best fit | Prototype, internal tool | Single/few self-hosted models at scale | Many models/frameworks, existing NVIDIA MLOps investment | Small team, no self-hosting requirement |
Rollout strategy
| Big-bang redeploy | Blue/green | Canary (staged weights) | |
|---|---|---|---|
| Blast radius on regression | 100% of traffic, immediately | 100% of traffic, immediately (but instant rollback) | Bounded to canary weight until gates pass |
| Infra cost during rollout | None extra | 2x capacity briefly | Slightly more than 1x (canary + stable both running) |
| Catches regressions before full exposure | No | No | Yes, if gates are meaningful |
| Rollback speed | Slow (redeploy old image) | Fast (flip traffic back) | Fast, and often automatic |
| Best fit | Low-stakes internal tools only | Simple services, infrequent releases | Any production LLM serving path with real users |
Single-region vs multi-region
| Single-region | Multi-region | |
|---|---|---|
| Operational complexity | Lower | Higher — registry sync, cross-region routing, failover |
| Global latency | Worse for distant users | Better |
| Availability ceiling | Bounded by one region’s SLA | Can exceed a single region’s SLA, if done correctly |
| Biggest new risk introduced | — | Silent state/version divergence across regions (Section 5.4) |
| Right default | Yes, unless a specific forcing function exists | Only with a dedicated owner for cross-region consistency |
Saying it out loud. If I’m compressing the tradeoffs: on engines, raw Transformers is fastest to a demo and wrong for anything real; vLLM is the throughput-per-dollar default for a few self-hosted models; Triton adds a server layer that only pays for itself with a genuine multi-model requirement; and a managed API has the lowest ops burden and no control over the weights. On rollouts, big-bang exposes 100% of traffic instantly, blue-green does too but with instant rollback and 2x capacity during the switch, and canary is the only one that bounds the blast radius before the gates pass — at slightly more than 1x cost. On topology, single region is the right default and the biggest risk multi-region introduces isn’t latency, it’s silent version divergence.
7.4 Red flags vs green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Engine choice reasoning | “We used vLLM because everyone does” | “We compared vLLM and Triton against our multi-model requirement and picked vLLM because we only have one model family” |
| Load testing | “We estimated capacity from a blog post’s numbers” | “We ran our own concurrency-ramp load test on our actual model/hardware and derived thresholds from that curve” |
| Autoscaling | “HPA on CPU, default settings” | “KEDA on queue depth, with a floor sized for cold-start time, and cooldown tuned for GPU spin-up latency” |
| Canary gates | “We watch the dashboard for 10 minutes and promote” | “Automated analysis template gates on latency, error rate, and a quality signal, with automatic rollback on failure” |
| Versioning | “The model is version-controlled; the prompt lives in a separate config service” | “Weights, prompt template, and sampling config are one versioned, atomically-rollback-able unit” |
| Monitoring | “Only server-side histograms, no client-side check” | “Client-side/synthetic latency check plus server-side histograms, both filterable by version” |
| Multi-region | “We push the promotion and assume it propagates” | “Promotion status is verified per region, with alerting on divergence” |
| Incident retros | “We added a dashboard” | “We changed the control-loop ownership / structural cause, and the dashboard is a secondary safeguard” |
| Cost reasoning | “We picked H100 because it’s the fastest” | “We measured the SLO-meeting ceiling on a cheaper card first and only upgraded when the numbers forced it” |
7.5 Full system-design prompt
Prompt: “You’re the first infrastructure hire at an AI startup serving an open-weight LLM to end users. Design the inference platform for the first 12 months of growth — from a working demo to production traffic at meaningful scale, with a small team.”
Worked answer:
Start by asking the two questions that shape everything else: what’s the actual model and SLO (assume: a 20B-class open-weight model, chat product, 2s P95 TTFT target), and what’s the team (assume: 2 infra engineers, no dedicated MLOps team for at least the first two quarters).
Months 0-1 — prove it works. Raw Transformers or a minimal vLLM setup on a single GPU box, behind a thin FastAPI gateway, in Docker Compose (Ch. 01, Ch. 02). No Kubernetes yet — it would be pure overhead at this stage. Goal: validate the model meets product’s quality bar and get a rough sense of per-request latency.
Months 1-3 — make it fast, put it on real infra. Move to vLLM specifically for continuous batching and PagedAttention (Ch. 05). Move to Kubernetes once there’s more than one replica’s worth of traffic or any uptime expectation (Ch. 03) — GPU Operator for device plugin/driver management, correct resource requests, startup probes tuned to real model-load time. Run the first real load test (Ch. 04) to get an actual concurrency-vs-latency curve — this number is the single most load-bearing artifact for everything that follows.
Months 3-6 — stop being one deployment away from an outage. Add KEDA-based autoscaling on queue depth (Ch. 06), sized from the load test’s ceiling, with a minReplicaCount that respects cold-start time. Stand up Prometheus/Grafana/Alertmanager (Ch. 08) with TTFT, queue-depth, and error-rate dashboards split by version, plus a leading-indicator alert ahead of the hard SLO page. This is also when a real model registry (Ch. 09) needs to exist — even a small team benefits from “version = weights + prompt template + config, one unit” being true from day one, because retrofitting it after a rollback incident (Section 5.2) is much more painful than building it in from the start.
Months 6-9 — ship model updates without fear. Introduce canary rollouts (Ch. 07) — Argo Rollouts is the pragmatic choice for a small team without an existing KServe investment — with automated analysis gates on latency and error rate. Add a drift/quality monitor (Ch. 10) that runs per-version, including on canary traffic specifically, feeding into the promotion decision.
Months 9-12 — cost discipline and resilience. Revisit the GPU/quantization choice with real production traffic data (Section 4) — by now there’s enough traffic history to make an informed cost-vs-latency tradeoff instead of a launch-day guess. Run a game-day: trigger a canary rollback, kill a pod mid-request, simulate a node failure, and specifically test the failure modes in Section 5 (does the autoscaler fight the canary controller? does a rollback actually revert the prompt template?). Only consider multi-region or Triton/multi-model serving if a specific forcing function has actually appeared by now (global user base, a second model family) — not proactively, because both add real operational surface a 2-person team can’t absorb speculatively.
Month 0-1 Month 1-3 Month 3-6 Month 6-9 Month 9-12
┌─────────┐ ┌───────────────┐ ┌───────────────────┐ ┌──────────────────────┐ ┌───────────────────────┐
│ Docker │ → │ vLLM on K8s, │ → │ + KEDA autoscaling,│→ │ + Argo Rollouts │→ │ + cost/quantization │
│ Compose, │ │ real load test │ │ + Prometheus/ │ │ canary + analysis │ │ revisit, game-days, │
│ 1 GPU box│ │ (Ch 01,03,04, │ │ Grafana + model │ │ gates + drift │ │ multi-region/Triton │
│ (Ch 01, │ │ 05) │ │ registry │ │ monitor per-version │ │ only if a real trigger│
│ 02) │ │ │ │ (Ch 06,08,09) │ │ (Ch 07, 10) │ │ exists (Sec 2.3, 4) │
└─────────┘ └───────────────┘ └───────────────────┘ └──────────────────────┘ └───────────────────────┘
The framing an interviewer is checking for: did the answer sequence things in the order risk actually justifies (prove correctness before optimizing speed, optimize speed before adding autoscaling complexity, add rollout safety before adding drift monitoring, and defer multi-region/Triton until an actual forcing function shows up) — versus building every box in the reference architecture on day one because “that’s what production looks like.” A team of two cannot operate all eleven chapters’ worth of machinery simultaneously from a cold start, and pretending otherwise is itself a red flag in a system-design interview.
Saying it out loud. For the twelve-month system-design prompt, the thing being tested is whether you sequence by risk rather than building every box on day one. Months zero to one: prove it works — Docker Compose on one GPU box, validate quality, get a rough latency picture. Months one to three: make it fast and put it on real infra — vLLM, Kubernetes, and the first real load test, which is the single most load-bearing artifact for everything after it. Months three to six: stop being one deploy away from an outage — KEDA autoscaling sized from that curve, monitoring split by version, and a real registry. Months six to nine: ship updates without fear — canary with automated gates, plus per-version drift. Months nine to twelve: cost discipline and game days. A team of two cannot operate eleven chapters’ worth of machinery from a cold start, and pretending otherwise is itself the red flag.
8. Operational Deep Dives
The core narrative (Sections 1-7) is the complete story for most teams. This section collects a few additional pieces of the system that come up once you’re operating the platform for real — GPU node-level autoscaling (as distinct from pod-level autoscaling), two more system-level failure modes that surface after the ones in Section 5, and a command cheat sheet worth keeping next to the runbook.
8.1 GPU node autoscaling and capacity strategy
Section 3.6’s KEDA config scales pod count. It says nothing about where those pods actually run — if the GPU node pool doesn’t have a free node with a schedulable GPU, a new pod sits Pending no matter how correctly KEDA reacted. This is a second autoscaler, one layer down: the cluster autoscaler (or your cloud provider’s node-pool autoscaling equivalent), which adds and removes GPU nodes based on unschedulable pod pressure.
The interaction that catches teams off guard: GPU nodes take meaningfully longer to provision than CPU nodes — driver installation, the GPU Operator’s device-plugin registration, and (if using MIG) partitioning all add minutes on top of normal VM boot time. If KEDA scales pod count up in response to a traffic spike, but the cluster autoscaler needs 3-5 minutes to bring a new GPU node online, that gap is exactly where a TTFT SLO breaches during a burst — the pod-level autoscaler did its job correctly, and the platform still failed the SLO, because the node-level autoscaler was the actual bottleneck.
Two mitigations, both worth having simultaneously:
- Keep a small buffer of pre-warmed, unschedulable-until-needed capacity — either a node pool with a minimum node count above the steady-state pod requirement, or a low-priority “placeholder” pod pattern that reserves node capacity and gets preempted the moment a real model-server pod needs the room. This trades a small amount of idle-GPU cost for closing the node-provisioning gap.
- Prefer scaling within existing nodes first: if your GPU nodes support MIG or time-slicing (Ch. 03) for a fraction of your fleet, keep a portion of capacity shareable so a burst can be partially absorbed by fitting more (smaller) pods on already-running nodes while new dedicated nodes come online in parallel, rather than waiting on node provisioning for the entire burst.
On the cost side, this is also where spot/preemptible GPU capacity earns its keep — for the steady-state floor of replicas (the minReplicaCount from Section 3.6), running on-demand/reserved capacity is worth the latency-safety of guaranteed availability; for the burst headroom above that floor, spot capacity is often an acceptable risk, since a preempted spot node during a burst just means falling back toward the SLO boundary rather than losing the floor’s worth of guaranteed serving capacity entirely. Reserved/committed-use discounts (available from most major cloud GPU providers for 1-3 year commitments) are worth layering under the steady-state floor once traffic has been stable long enough to trust the number — locking in a discount on a minReplicaCount you later have to shrink is its own kind of cost mistake, so this is deliberately a months-9-12 decision (Section 7.5’s timeline), not a launch-day one.
Saying it out loud. There’s a second autoscaler one layer down that people forget: the cluster autoscaler, which adds GPU nodes when pods can’t be scheduled. And GPU nodes are slow to provision — driver install, device plugin registration, MIG partitioning all stack on top of normal VM boot, so three to five minutes is normal. That gap is exactly where a burst breaches your SLO: the pod autoscaler did its job perfectly and the platform still failed, because the node autoscaler was the real bottleneck. Two mitigations worth having together: keep a small pre-warmed buffer, either a node-pool minimum above steady state or low-priority placeholder pods that get preempted when real work arrives, and prefer absorbing part of a burst on existing nodes through time-slicing while new nodes come online in parallel.
8.2 Two more failure modes worth knowing before they happen to you
GPU silent degradation past health checks. A GPU can develop ECC memory errors, thermal throttling, or Xid errors that degrade performance without crashing the process — the vLLM server’s /health endpoint (used by the readiness/liveness probes in Section 3.4) keeps returning healthy because the process is, technically, alive and responsive; it’s just running 3x slower per token than a healthy replica. Kubernetes has no reason to reschedule it, and the autoscaler has no reason to add capacity, because from the outside the replica count looks sufficient — it’s the quality of one replica’s throughput that degraded, not its count. The fix is monitoring GPU health signals directly (DCGM exporter metrics — ECC error counts, thermal state, Xid error codes — installed alongside the GPU Operator) as a first-class input to alerting, separate from and in addition to the application-level health check, and routing per-replica latency metrics (not just the fleet-wide aggregate) into the dashboard so one visibly slow replica doesn’t get averaged away by nine healthy ones.
Cost runaway from an autoscaler ceiling raised during an incident and never lowered. During the incident-response pattern described in Q15 of Section 7.2 — raising maxReplicaCount mid-incident to absorb an unprecedented spike — it’s common, and understandable, for the temporary change to quietly become permanent, because nobody owns reverting it once the incident is resolved and the on-call engineer has moved on. Weeks later, a routine (not even unusually large) traffic bump now scales to the emergency ceiling instead of the originally-sized one, at real and recurring GPU cost, with no incident and no alert to flag it — the system is behaving exactly as configured, which is precisely why nothing catches it. The fix is procedural, not technical: every incident-time config change (autoscaler bounds, probe thresholds, alert silences) gets a tracked follow-up ticket to review and, if appropriate, revert within a fixed window, and periodic (e.g., monthly) audits diff the running autoscaler config against the last deliberately-reviewed baseline.
Saying it out loud. Two more that only show up once you’re operating for real. First, GPU silent degradation: a card develops ECC errors or thermal throttling and runs three times slower per token without crashing, so the health endpoint keeps returning healthy, Kubernetes has no reason to reschedule, and the autoscaler has no reason to add capacity — the replica count is fine, the quality of one replica’s throughput isn’t. That’s why DCGM health signals need to be a first-class alerting input separate from the application health check, and why per-replica latency has to be visible so one slow pod doesn’t get averaged away by nine healthy ones. Second, cost runaway from an incident-time autoscaler ceiling that nobody ever lowered — the fix there is procedural: every incident-time config change gets a tracked follow-up ticket with a review window.
8.3 Quick reference: commands you’ll actually run
A short cheat sheet worth keeping next to the runbook, pulling together the commands used across this walkthrough:
# Build and smoke-test the serving image locally (Section 3.3)
docker build -t registry.example.com/gpt-oss-20b:v1 .
docker run --gpus all -p 8000:8000 registry.example.com/gpt-oss-20b:v1
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-oss-20b-v1","messages":[{"role":"user","content":"hello"}]}'
# Apply the base deployment (Section 3.4)
kubectl apply -f namespace.yaml -f deployment.yaml -f service.yaml -f pdb.yaml
# Watch GPU scheduling — confirm pods actually land on GPU nodes
kubectl get pods -n llm-serving -o wide
kubectl describe node <gpu-node-name> | grep -A5 "Allocated resources"
# Check KEDA's view of the scaling metric (Section 3.6)
kubectl get scaledobject gpt-oss-20b-scaler -n llm-serving -o yaml
kubectl get hpa -n llm-serving # KEDA creates a backing HPA under the hood
# Query vLLM's own metrics directly, useful when debugging a saturation event
curl -s http://<pod-ip>:8000/metrics | grep -E "vllm:(num_requests|time_to_first_token)"
# Watch a canary rollout live (Section 3.8)
kubectl argo rollouts get rollout gpt-oss-20b -n llm-serving --watch
kubectl argo rollouts promote gpt-oss-20b -n llm-serving # manual promote if needed
kubectl argo rollouts undo gpt-oss-20b -n llm-serving # abort and roll back
# Confirm exactly one control loop owns replica count during a rollout (Section 5.1)
kubectl get replicasets -n llm-serving -w
8.4 A minimal Grafana panel definition
Ch. 08 covers full dashboard construction; here’s the smallest useful piece — the TTFT-by-version panel referenced in Section 3.7 — as a Grafana panel JSON snippet you can drop into a dashboard’s panels array, so the shape of the query is concrete rather than described in prose:
{
"title": "P95 Time-to-First-Token by Version",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket{namespace=\"llm-serving\"}[5m])) by (le, version))",
"legendFormat": "{{version}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1.5 },
{ "color": "red", "value": 2.0 }
]
}
}
}
}
The by (le, version) grouping is the detail that matters most: without splitting by version, a canary at 5% traffic gets averaged into the stable version’s 95%, and a real canary regression can hide inside an otherwise-healthy aggregate P95 for the entire canary window — exactly the blind spot described in Section 5.3’s third variant.
8.5 Security and multi-tenancy at the gateway
The reference architecture’s gateway box (Section 1) does more than route requests — in any platform serving more than one internal team or one external customer, it’s also where authentication, per-tenant rate limiting, and basic input hardening live, none of which is unique to LLM serving but all of which have LLM-specific wrinkles worth naming:
- Per-tenant rate limiting must account for token cost, not just request count. A rate limiter that caps “100 requests/minute” per API key treats a request for one token of output the same as a request for 4,000 tokens of output, even though the second consumes vastly more GPU time. Rate limiting (or at least cost attribution and alerting) keyed on estimated or actual token usage is the practical equivalent of request-count limiting for a service where “request” is a poor proxy for cost.
- Prompt-length limits protect the KV-cache budget, not just the gateway. A client sending a request near your
--max-model-lenceiling consumes proportionally more KV-cache memory per request, directly reducing the concurrency ceiling the load test in Section 3.5 measured. Enforcing a sane per-request prompt-length limit at the gateway (well below the hard--max-model-len) keeps one large request from degrading everyone else’s latency. - Multi-tenant isolation for GPU workloads is coarser than for typical multi-tenant CPU services. Unlike a CPU service where cgroups give reasonably strong per-tenant resource isolation, a shared GPU model-server pod serves all tenants’ requests through the same continuous batch — there is no per-tenant GPU-memory or compute isolation within a replica. If strict tenant isolation is a hard requirement (e.g., contractual or regulatory), the practical answer is dedicating replicas (or MIG partitions, Ch. 03) per tenant rather than assuming batching-level isolation exists, because it doesn’t.
- Basic input hardening still matters even though “prompt injection” is a model-behavior problem, not an infra problem. The gateway is a reasonable place to enforce request-size limits, strip or flag obviously malformed input (e.g., attempts to smuggle control tokens the tokenizer would otherwise interpret specially), and log full request/response pairs for the security and drift-monitoring teams (Ch. 10) to review — the infra platform’s job is making sure that data exists and is queryable, not solving prompt injection itself.
Saying it out loud. Gateway security for LLM serving has three wrinkles that generic API advice misses. Rate limiting by request count is close to meaningless when one request generates five tokens and another generates four thousand — you need limits or at least cost attribution keyed on tokens. Prompt-length limits aren’t just gateway hygiene: a request near your max model length eats proportionally more KV cache and directly lowers the concurrency ceiling your load test measured, so cap it well below the hard limit. And multi-tenant isolation is coarser than people assume — all tenants’ requests flow through the same continuous batch inside a replica, so there is no per-tenant GPU isolation within a pod. If isolation is contractual, you dedicate replicas or MIG partitions; batching-level isolation does not exist.
9. Further Reading
Organized by the box in the reference architecture (Section 1) each source speaks to, so you can jump to what’s relevant rather than reading a flat list.
Serving engines (Ch. 05, Ch. 11)
- Kwon, Zhuohan Li, et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention”, SOSP 2023 — the original vLLM paper; the mechanism behind everything Ch. 05 teaches about memory efficiency.
- vLLM documentation — the canonical reference for CLI flags (
--max-model-len,--gpu-memory-utilization,--enable-prefix-caching, etc.) used throughout Section 3. - vLLM releases — check here for the current stable version before pinning a Dockerfile; this chapter cites the v0.20.x line as current at time of writing.
- vLLM FP8 quantization docs and the vLLM blog post “The State of FP8 KV-Cache and Attention Quantization in vLLM” — relevant to the quantization tradeoffs in Section 4.3 for models that don’t ship natively quantized.
- openai/gpt-oss-20b model card and the vLLM recipe for gpt-oss-20b — the specific model used in the Section 3 worked walkthrough, including its native MXFP4 quantization and YaRN context extension details.
- NVIDIA Triton Inference Server documentation and the TensorRT-LLM backend guide — the Ch. 11 engine, and the backend choice discussed in Section 2.1/2.4.
Kubernetes and GPU scheduling (Ch. 03)
- NVIDIA GPU Operator documentation — driver install, device plugin, and the time-slicing/MIG guide referenced in Sections 2.4 and 3.4.
- NVIDIA/k8s-device-plugin — the component that makes
nvidia.com/gpua schedulable Kubernetes resource. - vllm-project/production-stack — a reference Kubernetes-native deployment of vLLM, including the Helm chart and the KEDA autoscaling guide used directly in Section 3.6.
- LeaderWorkerSet (LWS) for multi-node vLLM — relevant once a single replica needs to span multiple GPU nodes (tensor/pipeline parallelism beyond one machine), mentioned in Section 2.4.
Autoscaling (Ch. 06)
- KEDA documentation — the general scaler framework behind the ScaledObject in Section 3.6.
- AWS EKS: Autoscale AI inference with HPA and KEDA — a concrete cloud-vendor walkthrough of the same pattern used in this chapter.
Rollout, canary, and disaggregated serving (Ch. 07)
- Argo Rollouts documentation and the canary strategy reference — the controller and analysis-template pattern used in Section 3.8.
- KServe canary rollout guide — the alternative canary mechanism referenced in Section 2.4 for teams already on KServe’s
InferenceServiceCRD. - llm-d and its prefill/decode disaggregation guide — the Kubernetes-native, KV-cache-aware routing project (backed by Red Hat, Google, IBM, and CoreWeave) referenced in Section 2.1/2.4 for extreme-scale deployments.
- NVIDIA Dynamo and the “Dynamo 1.0” production blog post — NVIDIA’s disaggregated-serving framework, an alternative to
llm-dmentioned in the same section.
Monitoring and observability (Ch. 08)
- vLLM metrics design doc — the source of truth for every
vllm:*metric name used in Sections 3.7 and 8.4. - Krisanov, “Monitoring vLLM in Production: Metrics, PromQL, Alerts, and Runbooks” — a practitioner writeup with worked PromQL percentile queries this chapter’s alert rules build on.
- Prometheus documentation and Grafana documentation — the general observability stack underlying Ch. 08 and this chapter’s dashboard/alert examples.
Model versioning and registries (Ch. 09)
- MLflow Model Registry documentation — one concrete implementation of the registry pattern in Section 1 and Section 6’s checklist.
- MLflow, “Canary Deployment for AI Models: A 2026 Guide” — connects the versioning and canary concerns directly, relevant to the prompt-template-rollback failure mode in Section 5.2.
Drift detection (Ch. 10)
- Return to Ch. 10’s own deep dive and further-reading list for the statistical mechanism (EWMA, distribution-shift tests) behind the drift monitor box in Section 1 — this chapter deliberately does not re-derive it, only shows where it plugs into the rollout and multi-region flow (Sections 3.8, 5.3, 5.4).
Cost and GPU pricing (Section 4)
- IntuitionLabs, “H100 Rental Prices Compared: 15+ Cloud Providers (2026)” and CloudZero, “H100 GPU Cost In 2026” — the source range for the GPU pricing used in Section 4.1; re-check current quotes before using these numbers in a real budget review, since GPU cloud pricing moves quickly.
- SynpixCloud, “Cloud GPU Pricing 2026” — the A100/H100 baseline figures cited in Section 3.2’s comparison table.
Appendix A: Glossary of Cross-Chapter Terms
Terms that get used across multiple chapters and multiple sections of this capstone, in one place, with a pointer back to where each is taught in depth.
| Term | Meaning | Taught in depth |
|---|---|---|
| TTFT (time-to-first-token) | Latency from request start to the first generated token/chunk reaching the client. The primary SLO metric for streaming chat UIs. | Ch. 05, Section 3.5/3.7 |
| ITL (inter-token latency) | Time between successive streamed tokens after the first. Governs how “smooth” a streaming response feels once it’s started. | Ch. 05, Section 3.7 |
| TPOT (time per output token) | Related to ITL; average generation-phase time per token, sometimes measured per-request rather than per-token-gap. | Ch. 05 |
| Continuous batching | Scheduling requests into and out of a running GPU batch every iteration, instead of waiting for a static batch to fully complete before admitting new requests. | Ch. 05 |
| PagedAttention | KV-cache memory management using fixed-size, non-contiguous blocks (analogous to OS virtual memory paging), eliminating fragmentation from variable sequence lengths. | Ch. 05 |
| KV cache | Stored key/value attention tensors from previously processed tokens, reused so each new token doesn’t require recomputing attention over the whole sequence from scratch. | Ch. 05 |
| Prefix caching | Reusing KV cache across requests that share an identical prompt prefix (e.g., a common system prompt), avoiding redundant prefill computation. | Ch. 05, Section 3.3 |
| Quantization (AWQ, GPTQ, FP8, MXFP4) | Reducing the numeric precision of model weights (and sometimes KV cache/activations) to shrink memory footprint and increase throughput, at some quality cost that must be validated. | Ch. 05, Section 4.3 |
| MIG (Multi-Instance GPU) | Hardware-level partitioning of one physical GPU into isolated instances with dedicated memory/compute slices. | Ch. 03, Section 2.4 |
| Time-slicing | Software-level sharing of one GPU across multiple pods without hardware partitioning — weaker isolation than MIG, cheaper to set up. | Ch. 03, Section 2.4 |
| GPU Operator | NVIDIA’s Kubernetes operator that installs the driver, device plugin, DCGM exporter, and MIG/time-slicing config as one managed unit. | Ch. 03, Section 3.4 |
| HPA (Horizontal Pod Autoscaler) | Kubernetes’ built-in autoscaling controller, natively driven by CPU/memory metrics (or custom metrics with extra wiring). | Ch. 06 |
| KEDA | An autoscaling framework that extends HPA to scale on arbitrary external metrics (e.g., a Prometheus query), the practical default for GPU/queue-based scaling. | Ch. 06, Section 3.6 |
| ScaledObject | KEDA’s custom resource defining what metric, threshold, and bounds drive scaling for a target workload. | Ch. 06, Section 3.6 |
| Cluster autoscaler | The node-level autoscaler that adds/removes machines (as opposed to pods) based on unschedulable-pod pressure. | Section 8.1 |
| Canary deployment | Shifting a small, increasing percentage of traffic to a new version, with automated gates deciding whether to continue or roll back. | Ch. 07, Section 3.8 |
| Blue/green deployment | Running two full-capacity environments and flipping all traffic at once, with instant rollback by flipping back. | Ch. 07, Section 7.3 |
| Argo Rollouts | A Kubernetes controller implementing canary/blue-green strategies with automated analysis-based promotion/rollback. | Ch. 07, Section 3.8 |
| AnalysisTemplate | Argo Rollouts’ resource defining the metrics query and success/failure thresholds evaluated at each canary step. | Section 3.8 |
| Model registry | The system of record for what a model “version” is — weights, tokenizer, prompt template, sampling defaults, engine config, versioned as one unit. | Ch. 09, Section 5.2 |
| Drift detection | Statistically monitoring whether live input distribution or output quality has shifted away from a validated baseline. | Ch. 10, Section 5.3 |
| EWMA (exponentially weighted moving average) | A common smoothing technique used in drift monitors to track a metric’s trend without over-reacting to single noisy samples. | Ch. 10 |
| Golden set | A fixed, curated set of prompts with known-good expected qualities, used to regression-test a model version before and during rollout. | Ch. 10, Section 6 |
| SLO (service-level objective) | A target threshold for a metric (e.g., “P95 TTFT under 2s”) that the platform is built and operated to meet. | Section 3.1 throughout |
| P50/P95/P99 | Percentile latency measures — P95 means 95% of requests were faster than this value. Production SLOs are almost always stated on a tail percentile (P95/P99), not the mean, because the mean hides exactly the slow-request behavior users notice. | Section 3.5 |
| Tensor parallelism | Splitting a single model’s weight matrices across multiple GPUs so one logical replica spans several devices — needed once a model is too large to fit (or batch efficiently) on one GPU. | Ch. 05, referenced in Section 2.4 (LWS) |
| Disaggregated serving (prefill/decode) | Running the compute-bound prefill phase and the memory-bandwidth-bound decode phase on separate pools of hardware tuned for each, rather than one pool doing both. | Section 2.1/2.4, llm-d/Dynamo references in Section 9 |
| LWS (LeaderWorkerSet) | A Kubernetes API for managing a group of pods as one logical multi-node model replica, used for tensor/pipeline-parallel deployments spanning multiple machines. | Section 2.4 |
| PDB (PodDisruptionBudget) | A Kubernetes resource guaranteeing a minimum number of pods stay available during voluntary disruptions (node drains, upgrades). | Ch. 03, Section 3.4 |
| Xid error / ECC error | GPU-level hardware fault signals (from NVIDIA’s driver/DCGM) indicating memory or execution errors that can degrade a GPU’s performance without crashing the process running on it. | Section 8.2 |
Appendix B: Chapter Cross-Reference Map
A one-page index of which section of this capstone exercises each of the guide’s eleven chapters most directly — useful as a study map if you’re revisiting a specific chapter and want to see it in context.
| Chapter | Where it shows up most concretely in this capstone |
|---|---|
| 01 Basic Serving | Section 1 (gateway box), Section 2.1 (the “prototype” branch of the engine decision tree), Section 7.5 (months 0-1 of the system-design answer) |
| 02 Docker | Section 3.3 (the Dockerfile), Section 6’s containerization checklist items |
| 03 Kubernetes | Section 3.4 (the full Deployment/Service/PDB YAML), Section 8.1 (node-level autoscaling), Appendix A (MIG/time-slicing/GPU Operator terms) |
| 04 Load Testing | Section 3.5 (the TTFT-measuring load-test script and saturation curve), Section 6’s load-testing checklist |
| 05 vLLM Serving | Section 2.1/2.4 (engine decision and comparison table), Section 3.2-3.3 (quantization and serve-command choices), Appendix A (PagedAttention, continuous batching, KV cache terms) |
| 06 Autoscaling | Section 3.6 (the KEDA ScaledObject), Section 5.1 (the canary/autoscaler race), Section 8.1 (node-level autoscaling) |
| 07 Canary Deployments | Section 3.8 (the Argo Rollouts Rollout + AnalysisTemplate), Section 5.1/5.2 (both flagship system-level incidents), Section 7.3 (rollout-strategy tradeoff table) |
| 08 Monitoring | Section 3.7 (dashboards and PromQL alerts), Section 5.3 (monitoring blind spots), Section 8.4 (Grafana panel JSON) |
| 09 Model Versioning | Section 5.2/5.4 (both versioning-related failure modes), Section 6’s versioning checklist, Appendix A (model registry definition) |
| 10 Drift Detection | Section 1 (drift monitor box), Section 5.3 (per-version drift blind spot), Appendix C (wiring drift into a canary gate) |
| 11 Triton | Section 2.1/2.4 (when to choose it over vLLM, and the TensorRT-LLM vs vLLM backend choice) |
Appendix C: Wiring a Drift/Quality Signal into the Canary Gate
Section 3.8’s AnalysisTemplate gates on latency and error rate — both operational signals, neither of which catches a canary that’s technically fast and error-free but subtly wrong (e.g., a quantization change that passed every latency check but quietly degraded answer quality on a class of prompts the golden set didn’t cover densely enough). Ch. 10’s drift/quality monitor is the component meant to catch that, and it needs to feed into the same gate, not live in a separate dashboard nobody checks mid-rollout (Section 5.3’s third failure mode).
The connective piece is small: the drift monitor exposes its own Prometheus gauge, scored per version, and the AnalysisTemplate adds a third metric alongside latency and error rate.
# drift_monitor_exporter.py — a minimal sketch of the gauge the drift
# monitor (Ch. 10) needs to expose per version for the canary gate to read.
from prometheus_client import Gauge
GOLDEN_SET_SCORE = Gauge(
"model_golden_set_quality_score",
"Rolling average quality score (0-1) against the golden eval set, per version",
["version"],
)
def update_score_after_eval_batch(version: str, batch_scores: list[float]) -> None:
# In practice this would be an EWMA over recent batches (Ch. 10), not a
# raw batch average — shown simplified here to keep the wiring visible.
avg = sum(batch_scores) / len(batch_scores)
GOLDEN_SET_SCORE.labels(version=version).set(avg)
# analysistemplate.yaml — extended from Section 3.8 with the quality gate
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: gpt-oss-slo-check
namespace: llm-serving
spec:
metrics:
- name: ttft-p95 # as in Section 3.8
# ... unchanged ...
- name: error-rate # as in Section 3.8
# ... unchanged ...
- name: golden-set-quality
interval: 5m
successCondition: result >= 0.92 # baseline established from the
# last known-good version (Ch. 10)
failureLimit: 1
provider:
prometheus:
address: http://prometheus.monitoring.svc.cluster.local:9090
query: model_golden_set_quality_score{version="canary"}
This closes the loop described in Section 1’s diagram: the drift/quality monitor doesn’t just watch production and report separately — its output becomes a real promote/abort input, with the same automatic, no-human-watching-a-dashboard guarantee the latency and error-rate gates already have.
Saying it out loud. The gap this appendix closes is that latency and error rate gates catch a canary that’s broken, not one that’s subtly wrong. A quantization change can pass every operational check while quietly degrading answers on a class of prompts your golden set didn’t cover densely. So the drift and quality monitor has to expose its own Prometheus gauge, scored per version, and the canary’s analysis template reads it as a third metric alongside latency and errors. That’s the whole connective piece, and it’s small — but without it the quality signal lives on a dashboard nobody is watching during the fifteen minutes it actually matters.
Appendix D: Cost Comparison Across Quantization Choices
Extending Section 4.3’s quantization discussion with concrete illustrative numbers, for a case where a model does not ship natively pre-quantized (unlike gpt-oss-20b in the main walkthrough) and the team is choosing how far to quantize a BF16 checkpoint:
| Format | Relative VRAM footprint | Illustrative req/s ceiling per GPU (same hardware, same SLO) | Quality delta vs BF16 baseline |
|---|---|---|---|
| BF16 (no quantization) | 1.0x (baseline) | 1.0x (baseline) | None (reference point) |
| FP8 (weights + KV cache) | ~0.5x | ~1.6-1.9x | Typically small and often within noise on general benchmarks; must be checked per-task on the golden set (Section 6) |
| INT4 (AWQ/GPTQ-style) | ~0.25x | ~2.5-3.5x | Larger and more task-dependent; some reasoning/long-context tasks show visible degradation below a certain bit-width |
(Illustrative ranges reflecting commonly reported directional effects, not a benchmark run for this chapter — always validate the specific ceiling and quality delta for your model, hardware, and golden set before committing a capacity plan to a quantization choice; see Ch. 05 and the FP8 quantization docs in Section 9 for engine-specific specifics.)
The practical decision rule from Section 4.3 holds here in sharper relief: FP8 is close to a default-safe choice on modern hardware (H100-class GPUs have native FP8 tensor cores, so the throughput win is closer to “free” than the INT4 row’s more dramatic — and riskier — VRAM savings). INT4-class quantization is the higher-risk, higher-reward lever, worth reaching for only after the golden-set validation in Section 6 has specifically cleared it for your model and task mix, not adopted by default purely for the cost savings.
Saying it out loud. On how far to quantize a model that doesn’t ship pre-quantized: FP8 roughly halves the memory footprint and buys somewhere in the range of 1.6 to 1.9 times the throughput ceiling, with a quality delta that’s typically small and often within noise on general benchmarks. INT4 through AWQ or GPTQ quarters the footprint and can push the ceiling two and a half to three and a half times, but the quality gap is larger and much more task-dependent — long-context and reasoning tasks degrade visibly before open-ended chat does. So FP8 is close to default-safe on hardware with native FP8 tensor cores, and INT4 is the higher-risk, higher-reward lever you reach for only after golden-set validation clears it. These are illustrative directional ranges, not a benchmark — measure your own model.
Appendix E: The Same Scenario, Through Triton Instead
Section 3 walks the gpt-oss-20b scenario through vLLM directly, because a single self-hosted model family is exactly the case where vLLM is the right default (Section 2.1). It’s worth seeing concretely what changes if the same model needed to sit behind Triton instead — for example, because it now needs to share a server with a second, non-LLM model (a re-ranker or an embedding model) that the product also depends on, which is exactly the kind of requirement that tips the decision in Section 2.1’s tree toward Triton.
Triton’s model-serving unit is a model repository — a directory structure Triton scans at startup, not a single serve command:
model_repository/
└── gpt-oss-20b/
├── config.pbtxt
└── 1/
└── model.json
config.pbtxt tells Triton which backend owns this model and how many instances to run — with the vLLM backend, device placement is deliberately left to vLLM itself rather than configured here:
# model_repository/gpt-oss-20b/config.pbtxt
backend: "vllm"
instance_group [
{
count: 1
kind: KIND_MODEL
}
]
model.json (versioned under 1/, Triton’s convention for model version directories — note this is a different versioning axis than the registry versioning in Ch. 09/Section 5.2, and the two should not be conflated) carries the actual vLLM engine arguments, mirroring the flags from the Dockerfile’s CMD in Section 3.3:
{
"model": "openai/gpt-oss-20b",
"max_model_len": 32768,
"gpu_memory_utilization": 0.90,
"enable_prefix_caching": true,
"served_model_name": "gpt-oss-20b-v1"
}
Everything above the model-server layer in the reference architecture (Section 1) is unaffected: the same Kubernetes Deployment pattern from Section 3.4 applies (Triton’s container image replaces vLLM’s, GPU resource requests and probes stay conceptually identical — Triton exposes its own /v2/health/ready endpoint for the readiness probe instead of vLLM’s /health), the same KEDA scaling pattern from Section 3.6 applies against Triton’s own Prometheus metrics endpoint, and the same Argo Rollouts canary pattern from Section 3.8 applies unchanged — a canary controller shifting traffic weight doesn’t care whether the pods behind each weight are running vLLM or Triton. This is the practical payoff of the reference architecture in Section 1 being drawn as boxes rather than as a single tool: swapping the model-server box’s implementation doesn’t require redesigning the gateway, autoscaler, canary controller, or observability stack around it.
The tradeoff is exactly what Section 2.1 named: Triton’s model-repository/config.pbtxt layer is genuine extra operational surface (a second configuration system, on top of Kubernetes YAML, that has to stay in sync with it) that buys you the ability to add that second, non-LLM model to the same server later without standing up an entirely separate serving stack for it.
Saying it out loud. It’s worth knowing what changes if the same workload has to go through Triton — say because a re-ranker or an embedding model now needs to share the server. The serving unit stops being a single serve command and becomes a model repository: a directory tree Triton scans at startup, one folder per model, each with its own config declaring backend, batching, and instance placement. The request path concepts carry over unchanged — continuous batching, paged KV cache, the same Prometheus story — but you’ve added a server layer and a config surface. Which is exactly the point of the earlier decision tree: you take that on when a genuine multi-model requirement appears, not preemptively.
Appendix F: A Sample On-Call Runbook Entry
Section 3.7’s TTFTSLOBreach alert annotation points to “a runbook link” — here’s what a real one looks like, concretely enough to adapt rather than write from scratch:
## Runbook: TTFTSLOBreach (gpt-oss-20b)
**Alert fires when:** P95 TTFT > 2.0s for 3+ minutes, on the stable-version
label (see the alert query in Section 3.7).
**First 2 minutes — orient, don't act yet:**
1. Open the "P95 TTFT by Version" Grafana panel (Section 8.4). Confirm this
is fleet-wide, not one version — if it's isolated to a `canary` label,
this is a canary-quality issue, not a capacity issue; stop the rollout
(`kubectl argo rollouts undo gpt-oss-20b -n llm-serving`) before anything
else.
2. Check `QueueDepthRising` (Section 3.7) — did it fire first? If yes, this
is very likely a capacity/scaling issue, not a regression. If
`TTFTSLOBreach` fired *without* `QueueDepthRising` firing first, suspect
a per-replica issue (Section 8.2's GPU silent-degradation failure mode)
rather than fleet-wide load.
**Minutes 2-10 — capacity path:**
3. `kubectl get scaledobject gpt-oss-20b-scaler -n llm-serving -o yaml` —
confirm KEDA's current replica target vs `maxReplicaCount`. If pinned at
the ceiling and queue depth is still rising, this is a real capacity
shortfall — see step 5.
4. `kubectl get pods -n llm-serving -o wide` — confirm new replicas are
actually `Running`, not stuck `Pending` (Section 8.1's node-provisioning
gap). If `Pending`, check `kubectl describe node` for GPU node pool
capacity; you may need to manually scale the GPU node pool while the
cluster autoscaler catches up.
5. If genuinely capacity-constrained beyond `maxReplicaCount`, raise it
temporarily (`kubectl edit scaledobject ...`) — **and immediately file
the follow-up ticket described in Section 8.2 to review/revert it**,
so this doesn't become the permanent ceiling by default.
**Minutes 2-10 — per-replica path (if queue depth was NOT rising):**
6. Compare per-pod TTFT, not just the fleet aggregate — one replica
dragging the P95 up while others look healthy points at GPU hardware
degradation. Check DCGM metrics (ECC/Xid errors, Section 8.2) for the
specific node backing the slow replica; cordon and drain that node if
confirmed, and file a hardware ticket with the cloud provider.
**If neither path resolves it within 15 minutes:** escalate to the
model-serving on-call lead and consider a manual rollback to the last
known-good version, following the atomic weights+prompt+config rollback
process (Section 5.2) — not a partial rollback of just the container image.
Appendix G: Anti-Patterns Worth Naming Explicitly
A short list of design smells that show up repeatedly across real platforms, distinct from the acute incidents in Section 5 — these are chronic, not acute, and tend to accumulate quietly rather than trigger a single obvious page.
- The autoscaler config nobody has looked at since launch.
minReplicaCount/maxReplicaCountset from the launch-day load test (Section 3.5-3.6) and never revisited as real traffic patterns, prompt-length distributions, or the model itself changed. Revisit this on the same cadence as the cost review in Section 7.5’s months 9-12. - Alerts with no owner and no runbook. An alert that pages but has no linked runbook (Appendix F) trains on-call to snooze it rather than act on it — by the time a real incident needs that alert to be trusted, it’s been ignored for months.
- A model registry that’s a source of truth in name only. The registry (Ch. 09) exists, but a config flag or feature-flag system outside it can still change prompt/sampling behavior in production (Section 5.2) — the registry’s authority is only as real as the absence of side doors around it.
- Dashboards that only show the aggregate, never split by version. Built once, before canaries existed, and never updated to add the
versionlabel split (Section 5.3, Section 8.4) — quietly useless for exactly the moment (a canary rollout) it would matter most. - “We’ll add monitoring for that after launch.” Said about GPU-health metrics (Section 8.2), client-side synthetic checks (Section 5.3), or drift monitoring (Section 1’s drift box) — these are cheap to add before launch and expensive to retrofit after the first incident they would have caught.
- Treating the load test as a one-time launch artifact. The saturation curve from Section 3.5 is only valid for the model, hardware, and prompt distribution it was measured against; a model swap, a prompt-length shift from a new product feature, or a hardware change (even a “just as fast” GPU generation swap) invalidates it silently until the next incident reveals the assumption was stale.
Saying it out loud. The anti-patterns are chronic rather than acute — they accumulate quietly instead of paging you. The autoscaler config nobody has looked at since launch, sized from a load test on a model and prompt distribution that no longer exist. Alerts with no runbook, which train on-call to snooze rather than act, so by the time one matters it’s been ignored for months. A registry that’s a source of truth in name only, because a feature flag can still change sampling behavior around it. Dashboards that only show the aggregate and were never updated to split by version, quietly useless at exactly the moment a canary is rolling. And treating the load test as a one-time launch artifact, when a model swap or a prompt-length shift silently invalidates it until an incident reveals the assumption was stale.
Closing: How to Use This Chapter
This capstone is meant to be read twice, in two different modes.
The first read is linear, before you build anything — Sections 1-2 to internalize the shape of the whole system and the decision framework, Section 3 to see a complete build end to end so the individual chapters’ techniques have somewhere concrete to land, Sections 4-6 to understand what it costs and how it actually breaks once real traffic and real incidents show up, Section 7 to pressure-test your own understanding against the interview questions (a good proxy for “could I explain this to a new hire”), and Section 6’s checklist immediately before any real launch.
The second read is as a reference, after you’re operating something like this for real — Section 5 and Appendix G’s failure modes and anti-patterns are worth rereading after your first real incident, not just before launch, because most of them are far more legible in hindsight than in a pre-launch review; Appendix F’s runbook pattern is worth adapting for every SLO-protecting alert you add, not just the one shown; and Appendix B’s chapter cross-reference map is the fastest way back into the numbered chapters when a specific piece (autoscaling tuning, drift statistics, Triton backend configuration) needs to go deeper than this chapter goes.
The single idea worth carrying forward past every specific YAML snippet and PromQL query in this chapter: a production LLM serving platform is not eleven separate problems that happen to share a GPU. It’s one system, and the seams between the eleven pieces — where a canary controller and an autoscaler both think they own the same ReplicaSet, where a rollback reverts weights but not the prompt template that shipped alongside them, where a per-component dashboard is green while the actual user experience isn’t — are where the interesting failures live. Chapters 01 through 11 teach you to build each piece correctly. This chapter is the argument for why that isn’t the same thing as building the system correctly, and a worked example of closing that gap.
Saying it out loud. The single idea worth carrying out of this chapter is that a production LLM serving platform is not eleven separate problems that happen to share a GPU. It’s one system, and the interesting failures live in the seams — a canary controller and an autoscaler both thinking they own the same replica set, a rollback that reverts weights but not the prompt template that shipped with them, a per-component dashboard that’s green while the actual user experience isn’t. Individual chapters teach you to build each piece correctly. That is genuinely not the same thing as building the system correctly, and the gap between those two is where most production incidents come from.
Appendix H: A Concrete Game-Day Test Plan
Section 6’s checklist and Section 7.5’s twelve-month plan both mention “run a game-day” without specifying what to actually test. Here is a concrete plan built directly from the failure modes in Section 5 and Section 8.2 — run each of these in staging first, then, once trusted, in production during a low-traffic window with on-call actively watching.
| # | Test | What it validates | Expected result |
|---|---|---|---|
| 1 | Kill a model-server pod mid-request (kubectl delete pod <pod> --grace-period=0) | Readiness probe correctly removes the pod from the Service before it’s fully gone; in-flight requests to other replicas are unaffected | Client sees at most one failed/retried request; no fleet-wide latency spike |
| 2 | Trigger a canary rollout, then manually abort mid-step (kubectl argo rollouts undo) | Rollback reverts traffic weight to 0% canary immediately, and — per Section 5.2 — reverts prompt template and engine config alongside the weights, not just the container image | Traffic fully back on stable within one reconcile cycle; a diff of the running config against the pre-rollout state shows zero drift in prompt/sampling config |
| 3 | Cordon and drain a GPU node hosting a live replica | PodDisruptionBudget (Section 3.4) prevents more replicas from draining simultaneously than minAvailable allows | Drain blocks or slows appropriately; no SLO breach during the drain |
| 4 | Simulate a node-provisioning delay (scale a test workload to fill the GPU node pool, then trigger a KEDA scale-up) | Whether the buffer/pre-warmed-capacity mitigation from Section 8.1 actually closes the node-provisioning gap | New pods reach Running within the SLO’s error budget, not stuck Pending for the multi-minute node-provisioning window |
| 5 | Manually raise maxReplicaCount, then check one week later | Whether the follow-up-ticket process from Section 8.2 actually catches and reviews incident-time config changes, rather than letting them become permanent by default | A ticket exists, was reviewed, and the value was either intentionally kept or reverted — not simply forgotten |
| 6 | Force QueueDepthRising and TTFTSLOBreach to fire (via a synthetic load spike in staging) and confirm the on-call receives both, in the right order, with the runbook link intact | The two-tier alerting pattern (Section 3.7) and the runbook (Appendix F) actually work end to end, not just in the YAML | Warning fires first with lead time; page fires second if the situation doesn’t resolve; the runbook link resolves to the current, correct document |
| 7 | Feed a batch of known-bad outputs into the drift/quality gauge (Appendix C) and confirm an in-flight canary halts | The drift/quality signal is a real gate, not a metric nobody’s canary configuration actually reads | Canary’s AnalysisTemplate fails the golden-set-quality check and halts/rolls back automatically |
Running this list once doesn’t make it done — the honest cadence is re-running it whenever a component in the chain changes (a new autoscaler version, a new canary controller version, a change to the PDB or probe configuration), since each of these tests is validating an interaction, and interactions are exactly what silently break when one side of them changes without the other side being retested.
Saying it out loud. A game day is worth specifying rather than gesturing at, because “run a game day” without a list means nobody runs one. The tests I’d insist on: kill a pod mid-request and confirm readiness pulls it from the service before it dies; abort a canary mid-step and then diff the running config, not just the image tag, to prove the prompt template reverted too; drain a GPU node and confirm the disruption budget actually blocks; fill the node pool and trigger a scale-up to see whether your pre-warmed buffer really closes the node-provisioning gap; and feed known-bad outputs into the quality gauge to prove the canary gate actually reads it. And running the list once doesn’t make it done — every one of these validates an interaction, and interactions break silently when one side changes.
Appendix I: Managed API Options, for Comparison
Section 2.1’s decision tree starts with “hosted/managed API only,” which shortcuts most of this chapter’s serving stack. For context, the real options as of 2026 in that category — useful when the honest answer to “should we self-host at all” is still open:
| Provider | Model access pattern | Where it fits |
|---|---|---|
| OpenAI API | Hosted proprietary and some open-weight models via API | No self-hosting; you still need this chapter’s Ch. 07/08/10 concerns (canary across model/provider versions, monitoring, drift) applied to an external dependency instead of your own cluster |
| Anthropic API | Hosted proprietary models via API | Same shape as above |
| AWS Bedrock | Hosted access to multiple model providers’ models through one AWS-native API, plus (via Bedrock or SageMaker) the option to self-host open-weight models on managed endpoints | A middle ground — less operational burden than raw EKS + vLLM, more control than a single-vendor API |
| Google Vertex AI (Model Garden / endpoints) | Similar middle-ground shape to Bedrock, on GCP | Same tradeoff as Bedrock, GCP-native |
| Azure OpenAI Service | Hosted OpenAI models via Azure-native API/compliance boundary | Chosen primarily for enterprise procurement/compliance reasons rather than technical ones |
| Modal / Baseten / Replicate-style GPU-as-a-service | You bring the weights and a serving config; the platform operates the Kubernetes-equivalent layer (Sections 2-3 of this chapter) for you | The right choice when Section 2.2’s Kubernetes-vs-simpler answer is “we need the scaling/reliability properties but can’t staff operating them ourselves” |
The decision between these and the self-hosted stack this chapter builds is rarely purely technical — it’s a build-vs-buy tradeoff between GPU-hour margin (self-hosting is cheaper per token at meaningful scale, per the cost model in Section 4) and operational headcount (every box in Section 1’s reference architecture that you self-host is a box your team is now on-call for). A useful gut check: if you don’t yet have someone who can execute Appendix H’s game-day plan competently, that’s a signal you may not be ready to self-host at production scale yet, regardless of what the cost model says.
Appendix J: A Note on How the Numbers in This Chapter Age
Every concrete number in this chapter — the vLLM version pinned in the Dockerfile (Section 3.3), the GPU pricing used in the cost model (Section 4), the illustrative load-test curve (Section 3.5), the quantization comparison ranges (Appendix D) — is a snapshot, not a constant. This is worth stating explicitly rather than leaving implicit, because treating a snapshot as a constant is itself a small version of the same mistake Section 5 spends so much time on: trusting a number without checking whether the thing it was measured against has changed underneath it.
Concretely, before reusing this chapter’s specifics in a real build:
- Re-check the vLLM (or Triton, or Argo Rollouts, or KEDA) version against the project’s own release notes — this chapter cites the v0.20.x vLLM line and specific 2025-2026 feature landings (FP8 KV-cache work,
gpt-osssupport) as current at time of writing; serving engines in this space ship new releases frequently enough that a pinned version six months old is worth deliberately revisiting, not just inheriting. - Re-run the load test (Section 3.5) against your actual model and hardware. The illustrative saturation curve in this chapter is real in shape but specific to nothing you’re deploying — it exists to show how to read a saturation curve, not to hand you one.
- Re-quote GPU pricing (Section 4, Appendix I) — cloud GPU pricing has moved substantially year over year through the mid-2020s as supply has shifted, and the ranges cited here (with sources linked in Section 9) should be treated as “check whether this is still roughly right,” not “use this number in a board deck.”
- Re-validate quantization quality deltas (Section 4.3, Appendix D) per model. Quantization tooling and technique quality has been improving quickly; a quality gap that was noticeable eighteen months ago on one model family may be smaller (or larger, for a different architecture) on whatever you’re actually deploying — the golden-set validation step in Section 6 exists precisely so you never have to trust a general claim about quantization quality instead of measuring your own.
None of this diminishes the chapter’s core teaching content — the reference architecture’s shape (Section 1), the decision framework’s structure (Section 2), the order operations happen in during a real build (Section 3’s walkthrough sequence), and the failure modes living in the seams between components (Section 5) all age far more slowly than any specific price or version number, because they’re about how the pieces of the system relate to each other, not about which specific tool or GPU generation currently fills a given box. That distinction — what ages fast versus what doesn’t — is itself worth teaching to anyone using this chapter as a reference months or years after it was written.
Saying it out loud. I’d say this out loud in any interview where I quote a number from a book: every concrete figure here is a snapshot, not a constant. The engine version, the GPU pricing, the load-test curve, the quantization ranges — all of them age, and treating a snapshot as a constant is a smaller version of exactly the mistake this whole chapter is about, trusting a number without checking whether what it measured has moved underneath you. So re-check engine versions against release notes, re-run the load test on your own model and hardware, re-quote GPU pricing, and re-validate quantization quality per model. What ages slowly is the architecture’s shape, the decision framework, the order of operations, and the failure modes in the seams — because those are about how pieces relate, not which tool currently fills a box.
Appendix K: A Postmortem, Written the Way Section 5’s Incidents Actually Read
Section 5’s failure modes are described analytically, by design, so they generalize. Here is one of them — 5.1, the canary-vs-autoscaler race — written the way an actual postmortem document would read, because the difference in tone matters: postmortems are specific, timestamped, and blameless about people while being precise about systems.
## Incident: gpt-oss-20b-v2 canary — stable version resurfaced after full promotion
**Severity:** SEV-2. **Duration:** 23 minutes of intermittent stale-version
responses after a canary promotion showed "complete" in the Argo Rollouts UI.
**User impact:** ~4% of requests during the window received v1 (pre-update)
model behavior after v2 had already been communicated as fully live.
**Timeline:**
- 14:02 — Canary rollout for gpt-oss-20b-v2 begins per the staged-weight
process in Section 3.8. All analysis gates pass at each step.
- 14:41 — Rollout reaches setWeight: 100. Argo Rollouts UI shows "Healthy."
On-call marks the rollout complete in the team channel.
- 14:44 — KEDA's ScaledObject, which was watching queue depth on the
underlying stable ReplicaSet directly (not the Rollout resource, per the
misconfiguration described in Section 5.1), observes falling queue depth
on stable as traffic had been shifting to canary throughout the rollout,
and — reacting correctly to that signal in isolation — scales the stable
ReplicaSet back up from 1 pod to 4, interpreting the recent dip as
transient rather than as the deliberate result of the in-progress promotion.
- 14:47-15:07 — The Service's endpoint list includes both the (should be
fully retired) stable pods and the new v2 pods. A subset of requests land
on stable pods that KEDA re-created, producing v1 behavior for those
requests.
- 15:07 — On-call notices version-label mismatch in the "requests by
version" Grafana panel (Section 8.4) during a routine post-rollout check,
identifies the stable ReplicaSet's unexpectedly nonzero pod count, and
manually scales it to zero.
- 15:10 — Traffic confirmed 100% on v2. Incident closed.
**Root cause:** Two independent control loops — the Argo Rollouts canary
controller and the KEDA autoscaler — each held an opinion about the stable
ReplicaSet's replica count, on independent reconcile timers, with no
coordination between them. Neither was misconfigured relative to its own
scope; the interaction between the two scopes was the defect.
**Contributing factor:** The post-rollout check that caught this was manual
and happened to run within the incident window; there was no automated
alert for "nonzero replica count on a ReplicaSet the Rollout controller
should have scaled to zero."
**Fix (structural, not just monitoring):** Repointed the KEDA ScaledObject
at the Rollout resource rather than the underlying ReplicaSets directly
(Section 5.1's fix), verified via the game-day test in Appendix H, item 2,
run in staging through three full canary cycles with no recurrence. Added
an alert on nonzero pod count for any ReplicaSet a completed Rollout should
have zeroed, as a defense-in-depth backstop — but the primary fix is the
single-owner control-loop change, not the new alert.
**What this incident does not change:** The canary analysis gates
themselves (latency, error rate, quality) worked exactly as designed at
every step — this was never a "the canary should have caught something"
incident. It was a "two correct systems, incorrectly composed" incident,
which is precisely the category Section 5 of this chapter exists to name.
This is the format worth adopting for real incidents against this platform: specific and timestamped in the timeline, but the root-cause and fix sections stay at the level of “which control loop owned what,” which is exactly the level Section 5’s abstractions operate at — so that a postmortem doesn’t just document one incident, it feeds back into the general failure-mode catalog the next engineer reads before their own launch.
Saying it out loud. The reason to write a postmortem this way is the contrast in register. Section 5 describes failures analytically so they generalize; a real postmortem is specific and timestamped — severity, duration, roughly four percent of requests receiving stale model behavior for twenty-three minutes after the rollout UI said complete. But notice that the root cause and fix sections still sit at the level of which control loop owned what. That’s deliberate: it keeps the document blameless about people while being precise about systems, and it means the postmortem doesn’t just document one incident, it feeds back into the general failure-mode catalog the next engineer reads before their own launch.
LLM Serving & Inference Interview Q&A
Interview questions and answers for LLM serving, inference, and MLOps roles — written to help someone convince a senior interviewer they can build and operate real LLM inference infrastructure, not just call an API.
Each section maps to one of this guide’s 11 hands-on chapters. Use the chapter deep-dives for implementation detail, the System Design section to rehearse whiteboard scenarios, and the Flashcards/Traps sections the night before an interview.
Table of Contents
- LLM Inference Fundamentals
- Model Serving & Basic Serving Patterns (Ch. 01)
- Docker & Containerization (Ch. 02)
- Performance Optimization
- Kubernetes & Deployment (Ch. 03)
- Load Testing & Capacity Planning (Ch. 04)
- vLLM Internals Deep Dive (Ch. 05)
- Autoscaling Deep Dive (Ch. 06)
- Canary Deployments Deep Dive (Ch. 07)
- Monitoring & Observability (Ch. 08)
- Model Versioning & Registry (Ch. 09)
- Drift Detection (Ch. 10)
- Triton Inference Server (Ch. 11)
- Production Best Practices
- Additional Quick Questions
- System Design Scenarios
- 2025-2026 Landscape Quiz
- Rapid-Fire Flashcards & Glossary
- Traps & How to Recover
- Tips for Interviews
- Resources
LLM Inference Fundamentals
Q1: Explain how LLM inference works step-by-step.
Tokenize to token IDs, embed to dense vectors, pass through the transformer layers (self-attention plus feed-forward per layer), project the final hidden states to vocabulary-size logits, sample the next token (greedy, top-k, top-p, temperature), append it, and repeat until EOS, max length, or a stop string. Each token depends on all previous ones; the KV cache stores past key/value tensors so they are not recomputed.
The split that matters: prefill processes the whole prompt at once and is compute-bound; decode produces one token per step and is memory-bandwidth-bound. Continuous batching, chunked prefill, and disaggregated prefill/decode all exist because of that split.
Q2: What is KV caching and why is it important?
Prefill computes full Q, K, V for every prompt token. Decode computes Q only for the new token and reuses cached K and V. That turns an ( O(n^2) ) per-step cost into ( O(n) ): generating 100 tokens without a cache means 100 passes each reprocessing up to 100 tokens; with it, each pass processes 1 new token against the cached prefix.
KV cache size per token ≈ 2 (K and V) × num_layers × num_kv_heads × head_dim × dtype_bytes. For a 70B-class dense model with grouped-query attention that is still megabytes per token at long context, which is why the KV cache — not the weights — is usually the binding memory constraint at high concurrency.
Q3: What is the difference between training and inference?
| Aspect | Training | Inference |
|---|---|---|
| Mode | Training mode (gradients computed) | Evaluation mode (no gradients) |
| Batch | Large, fixed batches (32-128+) | Small/variable batches, single requests |
| Memory | Stores activations for backprop + optimizer state | Only forward-pass activations + KV cache |
| Speed | Slower per step (backprop overhead) | Faster per step (forward only) |
| Optimization target | Loss minimization via gradient descent | Latency / throughput / cost per token |
| Hardware | Multi-GPU clusters, high-bandwidth interconnect | One GPU to multi-node, often shared/multi-tenant |
Training needs gradients plus optimizer state — 2-3x model memory in FP32 Adam state alone. Inference has fixed weights and is dominated by KV-cache growth and request-arrival variability instead, so there is no fixed batch shape to plan against.
Q4: Explain the attention mechanism in the context of inference.
Q is what the current token is looking for, K describes what each past token offers, V is the information retrieved.
Formula:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) x V
Naive attention is ( O(n^2) ) in sequence length ( n ) for both compute and the attention matrix’s memory, which is why long context is expensive. Production stacks never materialize the full ( n \times n ) matrix (FlashAttention-style fused kernels) and do not waste memory on it (PagedAttention-style block KV cache).
The variant ladder is MHA → MQA (one KV head) → GQA (a few KV heads shared across query heads; Llama 2/3, Mistral, most current models) → MLA (DeepSeek-V2/V3, compresses KV into a low-rank latent). GQA and MLA exist to shrink the KV cache — the real inference bottleneck — not to save training compute.
Model Serving & Basic Serving Patterns (Ch. 01)
Maps to Chapter 01 — Basic Serving (app.py, model_loader.py, test_api.py).
Q5: How would you design an LLM serving API?
POST /v1/completions
{
"prompt": "The future of AI is",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.9
}
Validate the request (prompt length, parameter ranges) before touching the GPU, tokenize, generate, return text plus metadata — latency, token counts, finish reason. The model loads once at process startup, never per request.
Around that: async handling so concurrency does not block the event loop; streaming via SSE or chunked responses; typed errors (400 bad input, 429 overload, 503 draining); rate limiting that rejects early rather than queueing forever; and liveness separated from readiness so Kubernetes can tell “process up” from “model loaded”.
Q5b: Why must the model be loaded once at startup instead of per-request, and what does “model_loader.py as a singleton” actually buy you?
Loading a multi-GB to multi-tens-of-GB checkpoint takes seconds to minutes; per-request loading puts that on every request. A singleton loader — module-level global, or a class instantiated once in a startup hook — loads weights exactly once per process and shares them across every request that worker handles.
It also gives fail-fast startup: the pod crashes before it is marked ready instead of failing the first user request, which is why readiness must depend on “model loaded”, not “HTTP server up”. The trap is a model load left inside a request handler “for testing” with no guard, silently reloading every request.
Q5c: Sync (Flask/gunicorn workers) vs async (FastAPI/uvicorn) for a basic LLM serving app — which do you pick and why?
Async, FastAPI on uvicorn. Most of a request’s wall-clock time is awaiting the GPU through the engine’s async generate call, so one process holds hundreds of in-flight requests without a thread each. It is what vLLM’s OpenAI-compatible server and TGI actually do.
Sync loses twice. The GIL means CPU-bound work — tokenization, sampling, JSON parsing, validation — does not parallelize across threads in one process; only the forward pass releases it inside C/CUDA calls. And each gunicorn worker needs its own model copy on the GPU, which usually does not fit. The standard pattern is one async process per GPU (or per tensor-parallel GPU set), scaled by Kubernetes replicas rather than gunicorn workers.
Q5d: How do you support streaming responses, and why does it matter for LLM UX?
Server-Sent Events (text/event-stream) or chunked transfer encoding: the engine yields tokens as generated and the HTTP layer flushes each chunk immediately instead of buffering.
Streaming makes TTFT the metric users feel. The user starts reading after prefill instead of after prefill plus full decode; on a 500-token answer that is the difference between “instant” and multi-second perceived latency.
The trap is buffering anywhere in the path — disable proxy buffering, watch gzip interacting badly with streaming, and set client timeouts long enough for slow generations.
Q5e: Design the health check strategy for a basic LLM serving pod.
Liveness answers “is the process alive and not deadlocked” — a cheap /healthz that never touches the model; repeated failure restarts the container. Readiness answers “can this pod serve right now” — model loaded, engine not in an unrecoverable state; failure removes the pod from Service endpoints but does not restart it, which matters because restarting a temporarily overloaded pod is the wrong reaction. Startup covers the minutes a large model takes to load; a generous failureThreshold/periodSeconds stops liveness killing the pod mid-load, the most common cause of crash-looping large-model pods.
The anti-pattern is readiness that depends on GPU utilization or queue depth. That turns transient load into eviction, the remaining pods absorb the traffic, also fail readiness, and you get a thundering-herd death spiral.
Q5f: How do you validate and sanitize LLM request inputs safely?
Enforce types and bounds with Pydantic: max prompt length, a max_tokens ceiling, valid ranges for temperature and top_p. The ceiling is the important one — without it a client can request 1,000,000 tokens and pin a GPU for minutes. Many production gateways cap max_tokens per plan or tier, because a huge generation inside a large batch multiplies memory pressure.
Reject before tokenization and generation; validation should be nearly free, and the point is to fail cheap requests cheaply rather than paying GPU cost to reject them. If user input is composed into a larger system prompt, escape or clearly delimit it rather than relying on the model to police itself.
Q5g: What does “graceful shutdown” mean for an LLM serving pod, and why do naive implementations lose requests?
On SIGTERM — sent before a pod is killed during a rollout, scale-down, or preemption — the process stops accepting new requests, finishes or cleanly returns in-flight generations, then exits within terminationGracePeriodSeconds. Naive implementations die immediately, silently dropping mid-generation requests; with streaming that surfaces as a truncated answer.
Because a full generation takes a while, terminationGracePeriodSeconds usually needs to be far above the Kubernetes default of 30s — tens of seconds to a couple of minutes depending on max_tokens and concurrency. Pair it with readiness: flip readiness false the instant SIGTERM arrives, removing the pod from the Service, while draining existing connections.
Q5h: Your /v1/completions endpoint works for one user in a demo but falls over with 50 concurrent users on one GPU. Diagnose it.
Diagnose by GPU utilization under load first. Low utilization with bad latency is a batching and scheduling problem, not a hardware problem — reaching for more GPUs before checking that is the wrong instinct.
The likely cause is a naive one-request-at-a-time generation loop, raw HuggingFace .generate() called synchronously per request, with no batching. At 50 concurrent users each waits a full serial turn, so latency scales linearly with load. The fix is a real inference engine — vLLM, TGI, or Triton — doing continuous batching. Secondary causes: no queueing or backpressure, so requests pile up in-process rather than being admitted or rejected predictably; no max_tokens cap, so a few huge requests starve everyone; and single-process serving with no path to a second replica.
Docker & Containerization (Ch. 02)
Maps to Chapter 02 — Docker (Dockerfile.basic, Dockerfile.gpu, Dockerfile.optimized, docker-compose.yml).
D1: Why does an LLM serving Dockerfile need to look different from a typical Python web app Dockerfile?
The base image must carry CUDA/cuDNN userspace libraries matching the host driver (nvidia/cuda:...-runtime, or vllm/vllm-openai), not python:3.x-slim, and the runtime must inject the device — --gpus all in Docker, the NVIDIA Container Toolkit and nvidia.com/gpu device plugin in Kubernetes. Without that the CUDA libraries are visible but the device is not.
Size is the other difference: CUDA plus PyTorch plus serving dependencies is easily 5-15GB, which drives pod start and node scale-up time during autoscaling bursts. Weights, often tens of GB, are not baked in — they are pulled at startup into a mounted volume, so the image stays small and a model swap needs no rebuild.
D2: Explain the difference between Dockerfile.basic, Dockerfile.gpu, and Dockerfile.optimized patterns.
Basic is CPU-only on a plain Python base: fine for testing API logic without a GPU, useless for anything latency-sensitive. GPU uses a CUDA base with GPU-enabled wheels (CUDA-built torch, vllm) and pinned driver-compatible versions — the one that runs at production speed.
Optimized is the GPU image plus image engineering: multi-stage build so build dependencies never ship, layer ordering putting rarely-changing layers (CUDA base, system deps) before frequently-changing ones (app code) for cache hits, a .dockerignore so local caches and checkpoints are not shipped accidentally, and a runtime-only CUDA base instead of the full devel image.
D3: Walk through multi-stage builds for an LLM serving image and why they matter here specifically.
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS builder
RUN pip install --user vllm torch
# compile any custom kernels here
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
COPY --from=builder /root/.local /root/.local
COPY app.py .
CMD ["python", "app.py"]
The devel image carries nvcc and headers and is only needed to build custom CUDA kernels or compile from source. It is roughly 2-3x larger than runtime. Shipping it costs registry storage, pull time, and attack surface for no benefit once the artifacts exist, so you compile with the heavy toolchain and ship only the runtime image with the artifacts copied across.
D4: How do you handle GPU access and driver compatibility in Docker?
Install the NVIDIA Container Toolkit on the host so the runtime can expose GPU devices, then run with docker run --gpus all or runtime: nvidia in Compose.
Version compatibility is the number one real-world failure mode. The container’s CUDA version must be compatible with the host’s NVIDIA driver: drivers are backward-compatible with older CUDA runtimes but not forward-compatible with newer ones, so a container built against CUDA 12.4 fails on a host with only a CUDA-11-era driver. In Kubernetes the GPU Operator and device plugin abstract this and advertise nvidia.com/gpu as schedulable — but the compatibility problem does not disappear, it becomes “which node pool runs which driver version”.
D5: How should you manage model weights in a containerized serving setup — bake into the image or mount at runtime?
Baking in is simplest and fully immutable — the image is the version — but images become huge, every model update means a rebuild, push, and pull, and registry cost balloons. Mounting at runtime keeps the image small and generic: an init container or startup script pulls weights from S3/GCS or a registry into a volume or a node-local cache.
The common production answer is runtime mount with a warm node-local cache, because it decouples code version from model version. A model swap becomes a config change — an env var or ConfigMap pointing at a model URI — instead of a CI/CD rebuild, and that is what makes canary and rollback of model versions independent of code versions. Watch the cold-start penalty: pulling a 140GB checkpoint on every new pod is a major contributor to autoscaling lag.
D6: What goes wrong if you don’t pin exact versions of CUDA, PyTorch, and the inference engine in your image?
Silent ABI mismatches: a PyTorch wheel built against CUDA 12.1 loaded against a CUDA 12.4 runtime can work, half-work with a wrong kernel and a slow fallback path, or crash with CUDA error: no kernel image is available for execution on the device.
You also get drift. An unpinned pip install vllm today versus three weeks from now can pull a minor version with different default flags, different memory behavior, or a different OpenAI-API surface — nothing changes in the Dockerfile diff and production behavior changes anyway. Pin exact versions (torch==2.4.0+cu124, vllm==0.6.3), use a lockfile, and rebuild-and-test in CI before promoting a tag.
D7: How would you use docker-compose.yml in local development for an LLM serving stack, and where does it stop being appropriate?
Good for the inner loop: serving container plus Prometheus, Grafana, and a mock registry in one command, GPU passthrough via runtime: nvidia or deploy.resources.reservations.devices, and a shared volume for weights so you do not re-download per up.
It stops at multi-node scheduling, autoscaling, rolling updates, secrets management at scale, and multi-tenant resource isolation — exactly the gap Kubernetes fills. Compose is single-host by design; production LLM serving is not.
D8: How do you reduce image size and cold-start time for a GPU serving image without breaking reproducibility?
Multi-stage build to drop compiler toolchains, and prefer the framework’s official runtime image (vllm/vllm-openai:<pinned-tag>) over assembling CUDA plus PyTorch plus engine yourself — it is already layer-optimized and tested upstream.
Order layers least-to-most frequently changing (base → system deps → Python deps → app code) so CI caches hit on app-code-only rebuilds. Keep weights out of the image and use a node-local cache. If cold node scale-up is on the autoscaling critical path, pre-pull images with a DaemonSet warm-up or node image pre-baking.
D9: A container passes nvidia-smi inside docker exec but the serving process still reports “no CUDA devices”. What do you check?
nvidia-smi working only confirms the toolkit and driver are visible to that process — not that the serving process, possibly a different user, cgroup, or one started before device injection, can use the device.
Check the container was started with the GPU flag for this invocation, easy to miss when you docker exec into a container originally run without --gpus. Check CUDA_VISIBLE_DEVICES is not empty or wrong. Then check the wheel: torch.cuda.is_available() returning False while nvidia-smi works almost always means a CPU-only wheel, because pip resolved plain torch instead of a CUDA-tagged build. In Kubernetes, confirm the pod sets resources.limits."nvidia.com/gpu" — GPUs are invisible without a limit — and that the device plugin DaemonSet is healthy on that node.
Performance Optimization
Q8: How would you optimize LLM inference latency?
Model level: quantization (FP16, FP8, INT8, INT4), pruning, distillation to a smaller student. Inference level: KV caching, batching, continuous batching (vLLM/TGI iteration-level scheduling), and speculative decoding, where a small draft model proposes several tokens and the target verifies them in one batched pass.
Hardware: a GPU rather than a CPU (10-100x for this workload), bf16/fp8 tensor-core kernel paths, and tensor/model parallelism for models that do not fit or need more aggregate bandwidth. System: pre-warm with a dummy forward pass before taking traffic, pool connections, cache responses or prefixes where semantically valid. Architecture: async handling, bounded request queues for bursts, and prefix- or KV-cache-aware load balancing rather than round-robin.
Q9: What is PagedAttention and why does it matter?
Traditional KV caches pre-allocate a contiguous buffer sized for the maximum sequence length per request. When real generations are much shorter, that is severe internal fragmentation, and it makes long sequences at high concurrency impractical.
PagedAttention divides the cache into fixed-size blocks, like OS memory pages: allocated on demand, freed when sequences complete, reused for new sequences, and shared across sequences with an identical prefix — which is what prefix caching builds on. The result is near-zero fragmentation (only the last partial block is wasted), predictable support up to the model’s max context, and more concurrent sequences in the same GPU memory — which is exactly what continuous batching needs in order to have work to schedule.
(For deeper vLLM-specific internals — chunked prefill, scheduler design, disaggregated prefill/decode, speculative decoding, tensor/pipeline parallelism — see vLLM Internals Deep Dive.)
Q10: Explain the trade-offs between latency and throughput.
Latency is time for one request; throughput is requests or tokens per second across the system. Batching is the main lever: larger batches raise throughput and per-request latency through contention at each decode step, smaller batches do the reverse. Larger models raise quality and latency, lower precision is faster and smaller with accuracy risk, more GPUs buy throughput with cost.
For low latency: small batches, an optimized model, fast hardware, priority for interactive streaming. For high throughput: large batches, continuous batching, more GPUs, priority for offline work.
Do not optimize either in the abstract. Optimize against the actual SLO — for example P95 TTFT under 300ms and P95 inter-token latency under 50ms — and measure throughput at that SLO. Huge throughput bought by letting P99 blow up has moved the problem, not solved it.
Kubernetes & Deployment (Ch. 03)
Maps to Chapter 03 — Kubernetes (deployment.yaml, deploy.sh).
Q11: How would you deploy an LLM model to Kubernetes?
1. Containerize (see Ch. 02 above):
FROM python:3.9-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
2. Create a Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving
spec:
replicas: 3
template:
spec:
containers:
- name: llm-serving
image: llm-serving:v1.0
resources:
requests:
memory: "4Gi"
cpu: "2000m"
limits:
memory: "8Gi"
cpu: "4000m"
nvidia.com/gpu: 1
livenessProbe:
httpGet:
path: /health
port: 8000
3. Create a Service:
apiVersion: v1
kind: Service
metadata:
name: llm-serving
spec:
selector:
app: llm-serving
ports:
- port: 80
targetPort: 8000
4. Deploy:
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
The parts needing judgment: GPUs are only requested as whole-unit limits, never fractional, without a sharing mechanism (K3); liveness, readiness, and startup probes need splitting (Q5e); the default rolling update strategy fits single-GPU-per-pod workloads badly (K2); configuration goes in ConfigMaps, API keys and tokens in Secrets.
Q12: How does Horizontal Pod Autoscaling (HPA) work?
HPA checks metrics on an interval (15 seconds by default), compares the current value to the target, computes desired replicas, and updates the Deployment; Kubernetes creates or destroys pods to match.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Built-in CPU and memory resource metrics are a poor signal for GPU-bound serving. The useful signals — requests per second, queue depth, latency — are custom metrics via the Prometheus Adapter or KEDA. A stabilization window prevents flapping.
(For why CPU/memory-based HPA is usually the wrong tool for LLM serving, and what to use instead, see Autoscaling Deep Dive.)
Q13: Explain canary deployments for model updates.
Deploy the new version alongside stable, split traffic (say 90/10), compare metrics between the two cohorts, step up if healthy (25%, 50%, 100%), and route everything back to stable if not.
Three implementations, coarse to fine: two Deployments behind one Service weighted by replica-count ratio; a service mesh or Gateway API splitting by percentage independent of replica counts; or application-level routing on headers or user-ID hashing for consistent bucketing.
Compare latency percentiles (P50/P95/P99) between cohorts, not in aggregate, plus error rates and quality metrics such as thumbs-down or hallucination-flag rate. The payoff is bounded blast radius, instant rollback by changing weights, and a real A/B comparison on live traffic.
(For the full automated promotion/rollback pipeline, see Canary Deployments Deep Dive.)
K1: Why is nvidia.com/gpu treated so differently from CPU/memory by the Kubernetes scheduler?
GPUs are an extended resource: integer-only requests, no overcommit, and without MIG, time-slicing, or MPS configured, requests must equal limits. There is no fractional or burstable GPU by default, so the scheduler sees a node’s GPUs as a small pool of indivisible slots — a pod asking for one either finds a node with a free GPU or does not schedule at all.
The consequence is that GPU bin-packing matters far more than CPU bin-packing. A cluster with plenty of total free GPU capacity spread as fractions across many nodes can still fail to schedule a pod because no single node has a whole GPU free, which makes node pool sizing and per-pod GPU granularity (1 vs 2 vs 8) a real design decision.
K2: The default Kubernetes RollingUpdate strategy is a poor fit for a Deployment where each pod holds a 70B model on 4 GPUs. Why, and what would you change?
Default RollingUpdate with maxSurge: 25% brings up extra pods before removing old ones. For a pod needing 4 GPUs and minutes to load a huge checkpoint, that means spare GPUs sitting idle purely to support the rollout — usually unavailable in a GPU-constrained cluster.
Three fixes by situation: with no spare capacity and tolerance for a brief dip, set maxSurge: 0, maxUnavailable: 1 to replace in place one at a time; for genuinely zero-downtime rollouts, explicitly over-provision by one replica’s worth of GPUs; better still, move to a canary rollout (Ch. 07) so the old fleet stays fully up while a small new fleet is validated. Also tune minReadySeconds and the readiness probe so a pod is not counted available until the model has loaded and passed a warm-up check — otherwise the rollout outruns readiness and you get a real availability dip.
K3: How do you share a single GPU across multiple pods in Kubernetes, and what are the trade-offs between the approaches?
| Approach | Isolation | Granularity | Notes |
|---|---|---|---|
| Time-slicing | None (software round-robin) | Coarse (N pods share one GPU’s full memory + compute pool) | Simplest; risk of one pod OOM-ing or starving others; no memory isolation. |
| MPS (Multi-Process Service) | Weak (shared address space, but concurrent kernel execution) | Better compute overlap than time-slicing | Still no hard memory isolation; a misbehaving process can affect others. |
| MIG (Multi-Instance GPU) | Hardware-level (separate compute/memory partitions) | Fixed set of profiles (e.g., 7 slices on an A100/H100-class MIG-capable GPU) | True isolation, but partitions are fixed-size and set at node-configuration time, not per-pod-request time. |
| DRA (Dynamic Resource Allocation) | Depends on underlying tech (MIG, time-slice, or whole-device) | Flexible, expressed via DeviceClass/ResourceClaim with CEL-based selection | The modern (GA in Kubernetes v1.34) API replacing device-plugin extended resources for complex GPU allocation logic — verify exact version behavior before quoting in an interview, this area is moving fast. |
| MPS/MIG combined with DRA | Hardware isolation + flexible scheduling | Best of both, most complex to operate | Where the ecosystem (NVIDIA GPU Operator, KAI Scheduler) is heading as of 2026. |
Pick the isolation level from the blast radius you can tolerate: MIG or DRA-managed hard partitions for a platform shared across teams, time-slicing as a cost optimization for single-tenant batch or dev work.
K4: Should an LLM serving Deployment use a Deployment or a StatefulSet?
A Deployment for most serving pods: they are stateless and interchangeable, with no per-pod identity or storage beyond shared model weights, and the Service load-balances across them.
A StatefulSet earns its place when pods need stable network identity or per-pod storage — a multi-node tensor-parallel or pipeline-parallel deployment where rank-0 needs a predictable address for the other ranks (common in multi-node vLLM/Triton or Ray-based clusters), or where each pod owns a distinct local NVMe cache worth preserving across restarts. The trap is reaching for it by default “because it’s a model”: the question is identity and storage, not whether the workload feels heavyweight.
K5: Design node affinity / taints-and-tolerations for a mixed CPU+GPU cluster running LLM serving alongside other workloads.
Taint the GPU nodes (nvidia.com/gpu=true:NoSchedule) so only pods that tolerate the taint and request a GPU land there, keeping random CPU workloads off expensive hardware. Use nodeAffinity/nodeSelector to require the matching GPU node pool and, where it matters, a specific GPU generation — a pod assuming 80GB of HBM must not land on a 40GB node.
Add PriorityClasses so latency-sensitive serving outranks batch and offline jobs, letting the scheduler preempt lower-priority work under GPU pressure rather than leaving serving pods Pending. Use topology spread constraints across zones and nodes so one failure cannot take out every replica of a critical model.
K6: How do you handle persistent storage for multi-tens-of-GB model weights across pod restarts and node scale-up events?
A ReadOnlyMany PVC shared across pods of the same model version, backed by a network filesystem or cloud RWX equivalent, stops every pod re-downloading the same weights. Node-local caching — hostPath or a DaemonSet-managed NVMe cache — trades simplicity for speed: the first pod on a node pays the download, later pods and restarts on that node are fast, but cold nodes from autoscaling still pay full cost, which lands on scale-up latency.
Init containers are the standard mechanism: download and verify the artifact before the main container starts, so readiness only checks “engine loaded”. Watch for the race where multiple pods write the same node-local cache path during a scale-up burst — use a lock file or content-addressed paths keyed by model hash.
K7: What’s the “cold start” problem for GPU pods in Kubernetes, and how much of it is actually Kubernetes’ fault?
Almost none of it — Kubernetes scheduling overhead is seconds. The time goes to provisioning a new GPU node if none is free (1-10+ minutes depending on cloud and instance type), pulling a multi-GB image, downloading multi-tens-of-GB weights, and engine warm-up: CUDA graph capture, kernel autotuning, vLLM’s startup profiling pass.
So solving cold start means attacking image size (Ch. 02), weight caching (K6), and node pre-provisioning or warm pools, not tuning scheduler settings. It is also why naive reactive autoscaling, which scales up only after load has arrived, works poorly here.
K8: How do you expose an LLM serving Service outside the cluster safely, and where does authentication/rate-limiting belong?
External load balancer or Ingress (or Gateway API) → API gateway for auth, rate limiting, request validation, and routing to version-specific backends → Kubernetes Service → serving pods.
Authentication and rate limiting belong at the gateway, not in the pod: the pod should be a dumb, fast, trusted-input component, and auth in every pod duplicates logic and makes the hot path slower and harder to change. TLS terminates at the Ingress or gateway too, so GPU-node CPU is not burned on crypto. Route model tiers and versions at the gateway by path or header rather than baking routing logic into clients.
Load Testing & Capacity Planning (Ch. 04)
Maps to Chapter 04 — Load Testing (locust_test.py, measure_latency.py).
LT1: What’s wrong with load-testing an LLM API using the same request-per-second methodology you’d use for a REST CRUD API?
CRUD requests are roughly uniform in cost; LLM requests vary enormously with prompt length and max_tokens, so RPS alone is a poor load axis — two runs at identical RPS with different token distributions produce completely different GPU load. LLM serving also has two cost phases, prefill and decode, so a representative test must vary both input-length and output-length distributions, not just arrival rate.
Because of continuous batching, the right concurrency metric is usually the number of concurrent in-flight sequences. Throughput is a function of how many sequences the engine juggles, capped by KV-cache memory — not of arrival rate.
LT2: Design a load test plan for a new vLLM deployment before it goes to production.
Define the traffic profile first — realistic prompt-length and output-length distributions from production logs if they exist, since chat, summarization, and RAG look nothing alike. Sweep concurrency rather than RPS, recording tokens/sec throughput and TTFT, inter-token latency, and end-to-end latency at each level.
Find the knee: throughput rises roughly linearly with concurrency until GPU or KV-cache saturation, then latency blows up while throughput plateaus or falls. That knee is the practical per-replica ceiling. Report max sustainable throughput while P95 TTFT and P95 inter-token latency stay within SLO, not raw peak.
Add a soak test at 70-80% of found capacity for hours, to catch memory leaks, KV-cache fragmentation, and slow degradation a burst test hides. Then test the failure path at 100%+ of capacity: do requests queue and eventually succeed, or does the engine error and OOM? That decides how you configure backpressure.
LT3: What metrics do you pull out of a load test, beyond “requests per second”?
TTFT, dominated by prefill plus queueing, the metric users feel first. Inter-token latency / TPOT, dominated by decode step time and batch contention, which makes streaming feel fast or slow. End-to-end latency, TTFT + (TPOT × output tokens), what a non-streaming client sees.
Throughput in tokens/sec is the real capacity number. GPU and KV-cache utilization during the run tell you whether you are compute-bound, memory-bound, or scheduler-bound. Error and timeout rate as load rises shows where the system sheds, and whether it sheds gracefully with 429s or badly with timeouts and crashes.
LT4: How do you use Locust (or similar) to generate realistic LLM traffic, and what’s the catch with naive locust_test.py-style scripts?
Naive scripts send a fixed prompt repeatedly at a fixed rate. That misrepresents prefix-cache behavior — an identical prompt inflates hit rate, fully random prompts miss it entirely — and constant output length hides decode-bound bottlenecks. Better: sample prompts from a representative corpus with varying length, sample max_tokens from a realistic distribution, and ramp Locust’s user count rather than a flat rate.
The catch is that Locust’s own workers become the bottleneck before the server does at high concurrency, because of the Python GIL and single-machine limits. Distribute the workers, or use a purpose-built tool such as vllm bench serve or genai-perf, which model TTFT and TPOT correctly.
LT5: measure_latency.py-style scripts show great P50 latency but the on-call gets paged for P99 timeouts in production. What’s going on?
P50 tracks the common case; P99 is queueing effects, allocator or GC pauses, occasional very long prompts, cold caches, and batches temporarily saturated by a few outlier long generations.
A test at low or medium concurrency never enters the queueing regime that causes P99 blowups during production bursts, so it structurally cannot show you the tail. You have to test at and above expected peak concurrency. Also check whether the test hit a warm, pre-scaled fleet while production sometimes hits cold or just-scaled-up pods (K7) — that gap alone explains a lot of “P50 fine, P99 pages us”.
LT6: How does batch size / concurrency setting interact with load test results, and what would you tune based on what you see?
Throughput well below what GPU compute allows, with low GPU utilization, means the engine’s max-concurrent-sequences or max-batched-tokens setting is too conservative — raise it, bounded by KV-cache memory.
Latency degrading sharply while throughput barely improves means you hit the KV-cache memory ceiling: the engine is queueing and evicting rather than adding sequences. The fix is more memory — a bigger GPU, a higher gpu_memory_utilization fraction, or a quantized KV cache — or accepting a lower concurrency ceiling. Chunked prefill matters here too: a long prompt hogging a full prefill step spikes inter-token latency for other in-flight decodes, and only a mix of very long and very short prompts reveals it.
LT7: How would you capacity-plan “how many GPUs do I need to serve X req/s at Y SLO” from load test data?
From the load test, find the max concurrency or tokens/sec one replica sustains while meeting the latency SLO — call it C_max. Model expected traffic as peak concurrent requests, not average, using your actual length distributions: provision for peak, autoscale for the rest.
Required replicas ≈ peak_concurrency / C_max, plus headroom for rolling updates (K2), node or zone failure tolerance (N+1 or N+2), and burst above forecast. Multiply by GPUs-per-replica — the tensor-parallel degree — for total GPU count, then sanity-check against GPU memory needed for weights plus KV cache at that concurrency. Always re-validate on the target hardware, model, and quantization: theoretical FLOPs-based estimates are frequently off by 2-3x from measured reality, because decode is memory-bandwidth-bound.
LT8: What’s the difference between load testing “throughput mode” and “latency mode,” and why would you run both?
Throughput mode fires as many concurrent requests as the client can generate, unconstrained by arrival timing, to find maximum sustainable tokens/sec — what you want for batch and offline workloads, and for locating the ceiling and the knee.
Latency mode sends requests at a fixed, realistic (roughly Poisson) arrival rate matching expected production traffic and measures the latency distribution there, validating the SLO under realistic rather than maximal load.
They answer different questions. A system can have an excellent throughput ceiling and still miss its latency SLO at normal traffic if it is misconfigured — for example batch size tuned for throughput rather than latency.
LT9: How do you make load tests reproducible and comparable across engine versions or config changes?
Pin the exact prompt and output-length dataset with seeded sampling so two runs compare the same workload rather than different random draws, and hold hardware, instance type, driver and CUDA version, checkpoint, and quantization constant.
Report full latency distributions — P50/P90/P95/P99 — not averages, because averages hide tail regressions. Track results over time in a dashboard, or a CSV committed alongside config changes, so a vLLM version bump or scheduler-flag change gets an objective before-and-after instead of “it feels faster”.
vLLM Internals Deep Dive (Ch. 05)
Maps to Chapter 05 — vLLM Serving (vllm_server.py). The two questions below are preserved from the original Model Serving section because they’re really vLLM-specific.
Q6: What are the differences between HuggingFace Transformers and vLLM?
| Feature | HuggingFace generate() | vLLM |
|---|---|---|
| Batching | Static batching | Continuous (iteration-level) batching |
| Throughput | Low (single-digit to low tens of req/s in naive setups) | Much higher — order(s) of magnitude, workload-dependent |
| Memory | Standard, often over-allocated KV cache | PagedAttention (efficient, near-zero fragmentation) |
| GPU utilization | Often 20-40% | Often 80-95% under load |
| Ease of use | Very easy, huge model/ecosystem coverage | Moderate — needs engine-specific configuration |
| Flexibility | High (arbitrary custom generation logic) | Moderate (optimized for the common serving path) |
Use transformers for development, research, one-off inference, and maximum flexibility — custom generation logic, unusual architectures. Use vLLM or a comparable engine for production, high-concurrency serving, and cost-sensitive GPU utilization.
(Treat exact throughput multipliers as workload-dependent — always validate with your own load test per Ch. 04 rather than quoting a fixed number.)
Q7: How does continuous batching work in vLLM?
Static batching waits for a batch to fill, processes it, waits for all members to complete, then starts the next — so the batch runs at the speed of its slowest member and the GPU idles in the gap.
Continuous batching schedules at the decode-iteration level: new requests join as soon as there is KV-cache room, completed requests are removed immediately, and the rest continue without waiting for the cohort. The GPU never bubbles waiting for a batch to fill or drain.
Time 0: [Req1, Req2, Req3] -> Processing
Time 1: [Req1, Req2, Req3, Req4] -> Req4 added mid-flight
Time 2: [Req2, Req3, Req4] -> Req1 completed, removed
V1: What is chunked prefill and why did vLLM add it?
Without it, a long prompt’s prefill runs as one large step that can monopolize a full scheduler iteration, delaying the decode steps of every other in-flight request sharing it — one long prefill produces a visible latency spike for unrelated users.
Chunked prefill splits that prefill into smaller chunks processed across multiple iterations, interleaved with other requests’ decode steps. The net effect is smoother, more predictable inter-token latency under mixed short/long-prompt traffic, at a small cost in total prefill throughput — a direct trade of throughput for tail-latency stability.
V2: Explain vLLM’s scheduler at a level that would satisfy a senior interviewer.
Each iteration the scheduler decides which requests run a step, subject to a KV-cache-block budget and a max-batched-tokens budget. It prioritizes continuing already-running decode sequences and admits new prefill requests from a waiting queue as capacity allows; with chunked prefill it can admit partial prefill work rather than making an all-or-nothing decision.
If KV-cache blocks run out it can preempt a running sequence — evicting it by swapping the cache out or recomputing it later — to make room. Which sequence is evicted, and the eviction policy, directly set fairness and tail latency. This scheduler is the actual engine behind continuous batching: it is a scheduling problem, not a batching trick.
V3: What is speculative decoding and when does it actually help?
A small, cheap draft model proposes several candidate next tokens; the large target model verifies all of them in a single batched forward pass — cheaper than generating them one at a time — and accepts the longest correct prefix.
It helps most when decode is memory-bandwidth-bound, the usual case, and the draft’s guesses are frequently right: low-temperature or near-deterministic generation, code completion, or drafts distilled to match the target’s distribution. It helps less, or hurts, at high temperature and high output diversity, or at high concurrency where you are already GPU-compute-saturated — it trades extra compute per accepted token for fewer serial steps, and that trade is bad when compute is already the bottleneck.
Variants worth naming: draft-model speculative decoding, Medusa-style extra prediction heads on the target model itself, and n-gram or prompt-lookup decoding with no separate model, which works well where output repeats input literally, like code editing.
V4: How does vLLM support tensor parallelism and pipeline parallelism, and when do you pick which?
Tensor parallelism shards each layer’s weight matrices across GPUs — attention heads and MLP columns split across N GPUs — and needs an all-reduce or all-gather per layer, so it demands high-bandwidth interconnect and is usually kept within a node. Pipeline parallelism splits layers across GPUs or nodes sequentially, communicating only at stage boundaries, so it tolerates slower interconnect at the cost of pipeline bubbles unless you microbatch.
Rule of thumb: TP up to the GPU count in one NVLink domain, typically 8 GPUs on one node, to fit or speed up a model that does not fit on fewer; PP, or TP+PP together, when you must span nodes. Data parallelism — replicate the model, different requests per replica — is orthogonal and is what Kubernetes replicas give you. TP and PP set how big one replica is; replica count sets how many you run.
V5: What quantization formats does a modern serving engine like vLLM support, and how do you choose one?
FP16/BF16 is the baseline: minimal accuracy loss versus FP32, half the memory. FP8 is native on Hopper and Blackwell-class tensor cores, roughly halves memory again, and can meaningfully speed up compute-bound prefill with small workload-dependent accuracy impact — increasingly the default fast path on new hardware as of 2025-2026 (verify the current support matrix before quoting). INT8, AWQ, GPTQ, INT4 give aggressive memory reduction when weights rather than KV cache are the constraint, with larger accuracy risk, usually needing calibration data and a validation pass.
KV-cache quantization — FP8 KV cache — is a separate knob from weight quantization. It shrinks the cache specifically, which is often the real bottleneck at high concurrency and long context, independent of weight precision.
Decision process: baseline load test at FP16/BF16, try FP8 next since it is usually close to free on modern hardware, and only then reach for INT4 or AWQ. Validate task-specific quality, not just perplexity.
V6: What is prefix caching (as distinct from PagedAttention itself) and why does it matter for RAG/chat workloads?
Prefix caching reuses KV-cache blocks across requests sharing an identical prompt prefix — the same system prompt, or the same long RAG context across follow-up questions — instead of recomputing prefill for the shared portion. It is a natural extension of PagedAttention, because that block-based cache is already non-contiguous and friendly to content addressing: hash blocks by content, look up, reuse hits.
The win is large for chat (repeated system prompt plus growing history) and RAG (same context, several questions), turning much prefill work into cache hits and dramatically improving TTFT. The design implication: your load balancer must be prefix- or cache-aware, routing requests likely to share a prefix to the same replica. Pure round-robin scatters hit opportunities across replicas sharing no cache state, and you lose most of the benefit.
V7: What changed with vLLM’s “V1” engine rewrite, and why should you know that as an interview talking point?
vLLM went through a significant core-architecture rewrite, alpha announced around early 2025, redesigning the scheduler and execution loop for lower CPU-side overhead, cleaner separation between scheduling and model execution, and better support for multimodal and newer architectures — while keeping continuous batching and PagedAttention as foundations.
Knowing vLLM is not a frozen design, and that its internals evolved while the user-facing API stayed largely stable, shows you track the ecosystem. Practically, exact V0-versus-V1 internals change fast: anchor on the invariants — continuous batching, paged block KV cache, chunked prefill, speculative decoding support — and flag version-specific internals as “verify against current docs”.
V8: What is disaggregated prefill/decode serving, and why would a senior team consider it?
Run prefill — compute-bound, bursty, parallelizable — and decode — memory-bandwidth-bound, needing sustained KV-cache residency — on separate GPU or node pools, transferring a request’s KV cache from prefill worker to decode worker over fast interconnect or a shared store such as LMCache.
The motivation is that the phases have different optimal batch sizes and bottlenecks. Co-locating them means a long prefill can stall decode latency (mitigated but not eliminated by chunked prefill), and you cannot independently scale or tune each phase’s hardware — prefill benefits from raw FLOPs, decode from memory bandwidth and cache capacity.
The cost is real complexity: a KV-cache transfer path, coordination between pools, and new failure modes such as the decode worker holding a request’s cache disappearing. This is a very-large-scale optimization, not a default.
Autoscaling Deep Dive (Ch. 06)
Maps to Chapter 06 — Autoscaling (hpa.yaml). Builds on Q12 above.
AS1: Why is CPU utilization almost always the wrong HPA signal for a GPU-bound LLM serving pod?
The pod’s CPU usage — tokenization, HTTP handling, orchestration — is largely decoupled from GPU load. A pod can sit at 90% GPU utilization and near KV-cache capacity while its CPU shows 10%, so a CPU-based target never fires when it should.
The correct signals are GPU- or engine-native: GPU utilization, KV-cache/block utilization, queue depth (pending requests waiting for a scheduler slot), or request latency directly. All of them need a custom or external metrics pipeline — the Prometheus Adapter, or a KEDA scaler reading Prometheus or the engine’s metrics endpoint. HPA’s built-in Resource metrics will not get you there.
AS2: Design an HPA/KEDA config for a vLLM deployment using queue depth as the scaling signal.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-scaler
spec:
scaleTargetRef:
name: llm-serving
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
query: avg(vllm_num_requests_waiting)
threshold: "5"
vllm_num_requests_waiting rising means the running batch is already at capacity and work is backing up — a much earlier and more meaningful signal than GPU utilization plateaued at 95%.
Keep minReplicaCount above zero unless you have specifically solved cold start for scale-from-zero, or the first user after idle eats the full cold-start latency. Tune the scale-up stabilization window short and the scale-down window long, so a replica that just dipped below threshold is not torn down — GPU pods are expensive to bring back.
AS3: Why is scale-to-zero for GPU inference pods so much harder than for a stateless web service?
A stateless web pod cold-starts in 1-5 seconds; a GPU serving pod’s cold start — node provisioning, image pull, weight download, engine warm-up — is commonly 1-10+ minutes. Scale to zero and the first request after idle pays that entire cost, which almost never fits a reasonable latency SLO.
Mitigations: keep a warm minimum and never truly scale to zero on latency-sensitive paths; use keep-warm ping traffic to block scale-down during known-active hours; pre-provision a warm node pool so scale-up skips node provisioning; or accept scale-to-zero only for genuinely latency-insensitive batch workloads.
AS4: How does cluster autoscaling (or Karpenter-style node autoscaling) interact with pod-level HPA for GPU workloads, and where does it commonly break?
HPA decides “we need N more pods”. With no free GPU capacity those pods sit Pending until the cluster autoscaler or Karpenter notices and provisions a GPU node, adding full node-provisioning latency — often minutes — on top.
The common break is provisioning a node type lacking the GPU generation or count the pod’s affinity and resource request demand, producing a provision-then-still-Pending loop; node pool definitions have to match exactly what the workload asks for. Scale-down breaks too: the autoscaler can drain a GPU node running a long-lived inference pod mid-generation, dropping or truncating requests, unless pod disruption budgets and graceful termination (Q5g) protect it.
GPU autoscaling is a two-layer problem and the layers’ reaction latencies stack — total scale-up latency is the sum, not the max.
AS5: What is predictive/scheduled autoscaling and when would you use it over purely reactive autoscaling?
Reactive autoscaling always lags demand by at least the metric-collection interval plus cold-start time — fine for smoothly varying load, poor for sharp predictable bursts.
Predictive or scheduled scaling pre-warms capacity ahead of known patterns — raising a minimum replica floor before a daily peak, a launch, or a marketing push — via a CronJob changing minReplicaCount or a dedicated controller. Use it whenever demand has a known or learnable pattern, because it attacks cold start directly by provisioning before the spike. Combine with reactive scaling for the unpredictable residual.
AS6: A bursty consumer product (e.g., a viral social feature) spikes 20x in under a minute. Design the autoscaling response.
Pure reactive pod and node autoscaling cannot provision GPU capacity in under a minute, so the answer is not “scale faster” but “absorb the burst without needing to”.
Four levers. Admission control with backpressure: bound the queue, return fast 429s beyond it, rather than degrading latency without limit for everyone. Warm buffer capacity: a standing buffer of replicas sized for the observed peak/burst ratio, funded as a deliberate cost trade-off. Graceful degradation: shed to a smaller model, cap max_tokens, or disable expensive features like long-context mode when queue depth crosses a threshold. Per-user and per-tenant rate limits, so one viral feature does not starve the platform.
Afterwards, use the observed burst shape to right-size the warm buffer and the predictive schedule (AS5).
AS7: How do you avoid HPA “flapping” (rapid scale up/down) for a GPU workload, and why is flapping especially costly here vs. a CPU service?
The standard levers are stabilizationWindowSeconds on scale-down and rate limiting via HPA v2 behavior policies capping pods removed per period.
It is worse for GPU because a removed pod is not a process that restarts fast: bringing it back may mean re-provisioning a node (AS4) and re-downloading weights (K6/K7), so an unnecessary scale-down followed by a needed scale-up costs far more in latency and money than the same flap on a stateless service. Practical setting: asymmetric behaviour — short window for scale-up, minutes for scale-down — plus a minReplicas floor above the noise level of your traffic.
AS8: How would you autoscale a multi-model platform where different models have very different load profiles and hardware needs?
Scale each model’s deployment independently, with its own HPA/KEDA object and thresholds tuned to that model’s capacity envelope — a 7B and a 70B saturate at completely different queue-depth and GPU-utilization numbers.
Use separate node pools per hardware profile — single-GPU nodes for small models, multi-GPU NVLink nodes for large tensor-parallel ones — so the cluster autoscaler provisions the right node shape, with taints and affinities (K5) preventing cross-contamination. For a long tail of low-traffic models, prefer a shared GPU pool with priority-based preemption over a dedicated autoscaling group each.
Canary Deployments Deep Dive (Ch. 07)
Maps to Chapter 07 — Canary Deployments (canary-deployment.yaml). Builds on Q13 above.
CD1: Walk through a fully automated canary pipeline for a new model version, end to end.
The version first passes an offline eval suite — quality benchmarks plus regression tests against known-bad outputs — before it is canary-eligible. It then deploys at 1-5% of traffic alongside stable, both behind the same gateway or mesh split.
An automated analysis window runs for a fixed period, typically 30-60 minutes, long enough for statistically meaningful samples, comparing canary against stable on error rate, P95 and P99 latency, and a proxy quality signal such as thumbs-down rate or a cheap automated eval on sampled outputs.
The decision is automatic: inside bounds steps traffic 5% → 25% → 50% → 100%; any breach rolls back to 0% immediately. At 100% the canary becomes the new stable, and the old stable is kept warm briefly for fast rollback, then scaled down. Argo Rollouts and Flagger implement this loop natively over Kubernetes, Prometheus, and a traffic-splitting layer.
CD2: What metrics are LLM-specific red flags during a canary that a generic web-service canary pipeline wouldn’t catch?
Output quality regression: a version can be faster and perfectly healthy on latency and errors while being worse — more hallucination, format violations, changed refusal rate. Infra metrics are blind to it. Token-length distribution shift: substantially longer or shorter outputs change cost and latency in ways an error-rate check never flags. Safety/guardrail trigger rate: a rise is a strong leading indicator of a behavior regression. Structured-output and tool-call validity rate: in agent pipelines a canary can look healthy while silently emitting malformed function calls downstream systems choke on.
So a real LLM canary needs an automated quality-eval step sampling live canary outputs before promotion — ideally a cheap LLM-as-judge or rule-based check running inline.
CD3: Canary at the Kubernetes-Deployment-replica-ratio level vs. canary via a service mesh’s percentage-based traffic split — what’s the real difference, and when does it matter?
Replica-ratio canary — 9 stable pods plus 1 canary behind one Service — gives a split only as fine-grained as replica count allows, so 10 pods means 10% steps, and it couples “how much traffic the canary gets” to “how many pods it has”, conflating blast radius with resource allocation.
Mesh or Gateway percentage split — Istio VirtualService weight, Gateway API HTTPRoute weight — decouples them entirely: exactly 1% of traffic to a canary running on a single full-sized pod. That matters most for large expensive models, where each 70B replica is a meaningful GPU cost and you want risk exposure decoupled from fleet sizing.
CD4: Design the automated rollback trigger logic — what exactly should trip an automatic rollback, and how do you avoid both false positives and false negatives?
A small set of guardrail metrics with explicit thresholds evaluated over a rolling window rather than single data points: error rate above baseline + 2%, P99 latency above baseline × 1.5, guardrail-trigger rate above baseline plus an absolute threshold.
Require a minimum sample size before evaluating — a canary at 1% traffic for 2 minutes may have too few requests for the error-rate comparison to mean anything, and that is where false positives come from. Avoid false negatives by including the slower, sampled quality-eval signal (CD2), because a model can be infra-healthy and quality-broken at once.
Keep a manual kill switch alongside the automation: default to safe — roll back — on ambiguous or missing data, and let humans force a rollback regardless.
CD5: How do you canary a change to infrastructure (e.g., a new vLLM version or a new GPU generation) rather than a new model weights version?
Same pipeline shape as CD1, but the canary variable is the serving stack or hardware. Deploy the new engine version or hardware behind a small traffic percentage running the same model weights as stable, so any regression is attributable to the infra change rather than confounded.
Watch for numerical and output-distribution differences an engine or hardware change introduces — different kernels, different default precision, different sampling implementation details — even with bit-for-bit identical weights. “Same model, different engine version, subtly different outputs” is a real, easy-to-miss failure mode. Load-test the new infra in isolation (Ch. 04) before canarying on live traffic; the live canary is the last safety net, not the first.
CD6: What’s the difference between canary, blue-green, and shadow (dark) traffic deployment strategies for model updates?
| Strategy | What happens | Risk exposure | Best for |
|---|---|---|---|
| Canary | Small % of real traffic gets real responses from the new version | Real users see new-version output, at small scale | Default choice — validates real behavior with bounded blast radius |
| Blue-green | Full traffic cutover from old to new at once (with fast rollback to “blue” if needed) | All users exposed simultaneously | When you need instant, all-or-nothing cutover and have high confidence from offline eval |
| Shadow/dark traffic | New version receives a copy of real traffic but its responses are discarded/logged, never shown to users | Zero user exposure | Validating latency/capacity/output-diffing on real traffic before any user ever sees the new version — great first step before a canary |
They are not mutually exclusive: a mature pipeline runs shadow first for zero-exposure validation, then a canary for bounded real exposure, then full promotion. Treating it as one all-or-nothing choice is itself the weaker answer.
CD7: How do you handle stateful conversation context (multi-turn chat) correctly during a canary, so a user doesn’t get inconsistent behavior mid-conversation?
Sticky routing keyed on session or conversation ID — consistent hashing at the gateway or mesh — so every turn of one conversation hits the same version for its duration. Otherwise a user gets wildly inconsistent behaviour, style, and quality switching between versions turn to turn.
That means canary percentage is measured in sessions, not requests: a 5% canary must mean 5% of conversations, not 5% of individual turns randomly assigned, which would corrupt every multi-turn conversation it touched. Record which version handled a session so post-hoc quality analysis can attribute a whole conversation to one version.
CD8: Interviewer pushback: “Why not just A/B test in production analytics after full rollout instead of doing all this canary infrastructure?”
Full rollout exposes 100% of users to any regression immediately and simultaneously; bounding that blast radius before you have full-traffic data is the entire point.
Post-hoc analysis on a bad full rollout tells you how bad it was, not how to avoid most users experiencing it. By the time it surfaces, the bad responses have been shown, the cost incurred, and trust eroded at 100% scale.
They are also not alternatives: canary is the safe delivery mechanism, A/B analysis is the measurement layer. A canary literally is a small, automatically-managed live A/B test with a rollback trigger wired to its own results.
Monitoring & Observability (Ch. 08)
Maps to Chapter 08 — Monitoring (prometheus_exporter.py, grafana_dashboard.json, prometheus.yml).
Q14: What metrics should you monitor for LLM serving?
Application: request rate; latency at P50/P95/P99, with TTFT and inter-token latency tracked separately rather than only end-to-end; error rate; queue size, meaning pending requests waiting for a scheduler slot; throughput in input and output tokens/sec.
Model: generation time, average tokens per request, and the currently-running model version — essential for correlating a metric shift with a deploy. System: GPU utilization, GPU memory and KV-cache utilization used-versus-total, host CPU and memory pressure. Business: cost per request and per token, quality and feedback signals, API usage by endpoint, tenant, and model.
Three dashboards cover it: performance (latency, throughput, errors), resource (GPU, CPU, memory), and model (version-over-version comparison).
M1: Design the Prometheus metric taxonomy for an LLM serving fleet — what should be a counter, gauge, or histogram?
Counters, monotonically increasing: total requests, total tokens generated, total errors by type — used to derive rates with rate(). Gauges, point-in-time: current queue depth, running and waiting sequence counts, GPU memory used, KV-cache block utilization. Histograms, bucketed distributions: request latency, TTFT, inter-token latency, tokens per request — anything you will want percentiles for.
Never store latency as a gauge or an average when you will want P95 or P99 later; you cannot recover percentiles from an average after the fact. Label carefully — model, version, tenant — but watch cardinality, because labelling by raw request ID or user ID explodes series count and can take Prometheus down.
M2: What are the “four golden signals” and how do you adapt them specifically for LLM serving?
Latency splits into TTFT and inter-token latency rather than one end-to-end number, because their causes differ — queueing and prefill versus decode-step contention. Traffic becomes requests/sec and tokens/sec, since token volume rather than request count drives GPU load. Errors covers HTTP-level errors plus engine-level failures — OOM, timeout, unexpectedly truncated generation — that an HTTP-status-only view misses.
Saturation is GPU utilization and KV-cache/block utilization and queue depth. KV-cache saturation is frequently the real ceiling well before GPU compute hits 100%, so tracking GPU percentage alone hides the actual bottleneck.
M3: Your Prometheus instance keeps falling over / running out of memory. What’s the likely cause in an LLM serving context and how do you fix it?
Almost always cardinality explosion: an unbounded label — raw request ID, user ID, prompt hash — multiplies unique time series, and Prometheus memory scales with active series count.
The fix is a bounded label set (model, version, tenant-tier, status code), with per-request detail pushed to logs and traces instead of metrics. Also check scrape interval and retention, since a very short interval combined with high series count compounds the problem, and move long retention to remote-write storage — Thanos, Mimir, VictoriaMetrics — rather than a single local instance.
M4: How do you set actionable alert thresholds for LLM latency without causing alert fatigue?
Alert on SLO burn rate, not raw threshold crossings — “we are consuming our latency budget X times faster than sustainable” — which adapts sensitivity so a blip does not page and a sustained regression does. Use multi-window, multi-burn-rate alerting: a fast short window for “page now” and a slower long one for “ticket, slow leak”.
Alert on P95 and P99, but tune the threshold from your own load-test baseline (Ch. 04) rather than an industry number — “P95 < 500ms” is meaningless without your model size, hardware, and SLO context. Route by actionability: “GPU node down” pages on-call; “quality-eval score dipped 2%” is a ticket for the model team, not a 3am page.
M5: How do you monitor and alert on cost, not just performance, for an LLM serving platform?
Track cost-per-request and cost-per-1K-tokens as first-class metrics, derived from GPU-hour cost divided by observed throughput. A regression that silently doubles cost-per-token — GPU utilization dropping from a bad batching config — is a real incident even when latency and errors look fine.
Alert on GPU utilization sustained well below baseline: “10 GPUs at 20% utilization for an hour” is wasted spend and an alertable condition. Where multi-tenant, attribute cost by tenant or model so a runaway customer does not hide inside an aggregate until the monthly bill arrives.
M6: What’s the role of distributed tracing (OpenTelemetry) in an LLM serving stack, beyond metrics and logs?
Metrics tell you that P99 latency is bad; tracing tells you where the time went on a specific slow request — gateway, queue wait for a scheduler slot, prefill, decode, or a downstream retrieval call for RAG.
It is most valuable for multi-hop architectures — gateway → router → serving pod → retrieval service or tool call — where one slow request could be slow at any hop and per-hop aggregates cannot correlate it. Propagate a trace ID from the gateway through the engine and downstream calls; most engines support OpenTelemetry or can be wrapped to emit spans. Sample at a rate balancing trace-storage cost against debuggability, sampling up on errors and high latency.
M7: How do you build a monitoring setup that catches a quality regression (not just a latency/error regression) in production, continuously — not just during a canary window?
Continuously sample a small percentage of live production outputs — not only canary traffic — and run cheap automated quality signals over them: rule-based checks for format validity, length bounds, and refusal detection; a lightweight LLM-as-judge against rubrics; or comparison to reference outputs for a fixed regression-test prompt set replayed on a schedule.
Track them as time series with the same alerting discipline as latency (M4). A slow quality decay — from an unnoticed upstream data or prompt-template change, not just a bad deploy — needs burn-rate-style detection, because it never appears as a spike. This is the direct link to drift detection (Ch. 10): a quality-regression signal is often the first observable symptom of drift, well before a statistical test flags it.
Model Versioning & Registry (Ch. 09)
Maps to Chapter 09 — Model Versioning. Q19 is preserved from the original System Design section — versioning is really its own topic, not a system-design scenario.
Q19: How would you implement model versioning and rollback?
Version semantically — v1.0.0, v1.1.0, v2.0.0 — deciding explicitly what counts as major (architecture or behavior change), minor (fine-tune), and patch (config or quantization only). Store artifacts in a registry with metadata: training date, eval metrics, dataset version, base model lineage, and who approved promotion.
1. Model registry layout:
models/
v1.0.0/
model.bin
tokenizer.json
metadata.json
v1.1.0/
model.bin
tokenizer.json
metadata.json
2. Deployment:
env:
- name: MODEL_VERSION
value: "v1.0.0"
- name: MODEL_PATH
value: "/models/v1.0.0"
3. Rollback:
# Update to previous version
kubectl set env deployment/llm-serving \
MODEL_VERSION=v0.9.0
# Or use canary deployment to gradually
# route traffic back to the stable version
4. API:
GET /api/v1/models/versions
POST /api/v1/models/rollback
{
"target_version": "v1.0.0"
}
Validate offline before a version is canary-eligible, roll out gradually via canary (Ch. 07) rather than cutting over at once, track per-version performance continuously, and keep a changelog recording what data and eval each version was validated against.
MV1: What actually belongs in a model registry’s metadata, beyond “the weights file”?
Lineage: base model, fine-tuning or adapter data, training config, and the exact commit or config hash that produced the artifact. Evaluation results: the specific benchmark scores this version achieved, so any two versions are comparable on the same axes. Serving compatibility: which engine versions and quantization formats it is validated against — a checkpoint that behaves well in FP16 on vLLM 0.6.x is not automatically identical under a different engine or quantization.
Approval and promotion metadata: who approved it for canary or production, when, and against what criteria — this is what makes rollback and audit possible after the fact. Content hash or checksum, to catch silent corruption or a wrong-file upload before it reaches a serving pod.
MV2: Should model artifacts be treated as immutable once published, and what does that discipline actually buy you?
Yes. Once v1.2.0 is published it is never overwritten in place; a fix gets v1.2.1.
Immutability is what makes rollback trustworthy — “roll back to v1.1.0” only means something if v1.1.0 today is bit-for-bit what was validated and ran in production before. It also makes post-hoc debugging possible: with mutable artifacts, “which model produced this bad output six weeks ago” is unanswerable. Implement it with content-addressed storage or write-once buckets with versioned object keys, plus a registry API that rejects overwriting an existing tag.
MV3: How do you decouple “code version” (serving engine/app) from “model version” (weights) in your deployment pipeline?
Treat serving image version and model version as independent axes with their own cadences: a fine-tune should not require an application rebuild, and an engine upgrade should not require touching the model artifact.
The mechanism: the serving image is generic and loads whatever MODEL_PATH/MODEL_VERSION it is told at startup, with model version injected by config — env var, ConfigMap, or a model-serving CRD. That is exactly why runtime weight-mounting beats baking weights in (D5). It is also what makes independent canarying possible: canary a new model on the same engine, or a new engine on the same weights (CD5), instead of changing both at once and losing attribution.
MV4: Design the rollback SLA — how fast can/should a rollback actually happen, and what determines that floor?
The fastest rollback is traffic-routing only — flipping a canary weight back to 0% — and that is near-instant, seconds, if the stable version’s pods are still warm. Keeping the previous version’s pods alive for a fast-rollback window after promotion, rather than scaling them down immediately, is a deliberate cost trade-off.
If those pods are already gone, rollback requires re-provisioning capacity, so rollback time equals cold-start time (K7) — minutes, not seconds. That is the common gap between “we have rollback capability”, true, and “our rollback SLA is under a minute”, false unless you kept the old version warm. So the decision to communicate is the retention window: keep N-1 warm for X hours after promotion, sized against risk tolerance and budget.
MV5: How do you version and roll back prompts/system prompts and retrieval configuration, not just model weights, given that both affect output just as much?
Treat prompt templates, system prompts, and RAG configs — index version, chunking parameters, retrieval top-k — as versioned artifacts with the same discipline as weights. A “model regression” investigation that checks weight version and ignores a same-day prompt-template change will miss the actual cause.
Store prompt and config versions alongside model version in registry metadata, so a production request maps to an exact (model, prompt template, retrieval config) triple. This matters for canary too: a system-prompt change deserves the same rigor as a model swap, and the common trap is treating it as “just a config tweak” that skips the deployment safety pipeline.
MV6: What does “shadow deployment” buy you specifically for model version validation that offline eval doesn’t?
Offline eval runs against a fixed historical dataset. It cannot tell you how the new version behaves on today’s live traffic distribution, which may already have shifted away from what the eval set represents.
Shadow deployment runs the new version against live traffic in parallel with production, discarding its output but logging it for comparison against the production version’s actual response on the same input. That catches distribution-shift-sensitive regressions offline eval structurally cannot see. The cost is doubled compute on the shadowed slice, so it is sampled and time-boxed before moving to a real traffic-splitting canary.
MV7: Interviewer pushback: “Why do you need a formal model registry at all — why not just tag Docker images with the model baked in and use normal image-based deployment tooling?”
Baking weights into images couples model release cadence to image build, push, and pull cost, which is wasteful when fine-tunes are far more frequent than code changes — and wasteful in reverse too. A registry also carries model-specific metadata — eval scores, lineage, approval status — that a generic container registry has no concept of, so you end up building a parallel ad hoc tracker anyway, without the rollback API and promotion gates.
That said, it is a genuine trade-off. For a small team with infrequent updates and no multi-model complexity, baking weights into a versioned image is legitimate and simpler. The signal is recognizing when a registry earns its keep — frequent updates, multiple models, fast independent rollback — versus when it is over-engineering.
Drift Detection (Ch. 10)
Maps to Chapter 10 — Drift Detection (drift_detector.py). Q15 is preserved from the original Monitoring section — drift detection is really its own chapter/topic.
Q15: How would you detect model drift in production?
Three kinds. Data drift: the input distribution changes — users ask about topics or formats the model rarely saw. Concept drift: the input-output relationship changes — what counts as a correct answer shifts after a real-world event or policy change. Prediction drift: the output distribution changes — responses get longer, shorter, or differently structured.
Detection uses statistical tests — PSI (Population Stability Index) comparing reference against current, Kolmogorov-Smirnov for distribution differences, chi-square for categorical — surfaced through Evidently AI, custom Prometheus drift-score metrics, or direct comparison scripts.
from evidently import Report, DataDriftTable
report = Report(metrics=[DataDriftTable()])
report.run(
reference_data=train_data,
current_data=production_data
)
if report.get_metric(DataDriftTable()).drift_detected:
alert("Data drift detected!")
Set thresholds such as PSI > 0.2, monitor continuously rather than only at deploy time, then act: retrain or refresh if drift is significant and persistent, investigate why before reacting, and update the baseline if the shift is a legitimate new normal.
DR1: For an LLM specifically (as opposed to a classic tabular ML model), what does “input drift” actually mean, and how do you measure it over free-form text?
You cannot run PSI or KS directly on raw text. You need a numeric representation first: prompt length distribution, embedding-space distance (embed prompts and track the centroid or distribution shift against a reference window), topic or intent classification distribution, or simple proxies such as detected language and keyword presence.
Embedding-based drift — Maximum Mean Discrepancy, or simply centroid distance over time — is the most common practical approach for free-form text, because it captures semantic shift without hand-built categorical features. The operational warning: embedding drift detectors need maintenance, because changing your embedding model invalidates the baseline and forces re-establishing it.
DR2: What is “prediction drift” for a generative model, and why is it a different problem from input drift?
Prediction drift means the output distribution shifts — average response length, refusal rate, sentiment or tone, or the rate of a failure mode like repetition loops or format violations — even while inputs are stable.
It can happen with zero code or model change: an upstream prompt-template tweak, worse retrieved-context quality for RAG, or a subtle change in default sampling parameters. It matters separately because input drift says the world or the users changed, while prediction drift says the system’s behavior changed — and a system can have a completely stable input distribution while output behavior silently regresses. That is often the first symptom of a bug.
DR3: How would you set up automated drift-triggered retraining/refresh, and what are the dangers of doing this fully automatically?
Pipeline shape: a drift monitor scores on a schedule, the score crosses a threshold, that triggers retraining or fine-tuning on fresh data, and the new version goes through the normal eval and canary pipeline — never straight to production.
Danger one is feedback loops: retraining on production outputs, or on user reactions to them, without careful filtering bakes in and amplifies the drift that caused the trigger — the model drifts toward sycophancy, retrains on sycophantic outputs users engaged with more, and drifts further. Danger two is false-positive churn: an over-sensitive threshold retrains constantly on noise, burning compute and introducing instability, since each new version is itself a source of risk.
So drift detection should trigger an alert and investigation, with retraining as a human-gated decision. Full automation is a reasonable long-term goal only once eval, canary, and rollback are strong enough that a bad automated retrain cannot reach users.
DR4: What’s the difference between monitoring for drift and monitoring for outright model degradation/failure (e.g., repetition loops, refusals, garbage output)?
Drift is a distributional concept — comparing distributions over time against a reference — and can be entirely benign, because the world legitimately changed, and it is usually gradual.
Degradation detection catches acute, often binary bad behavior on individual outputs: repetition loops, empty outputs, malformed JSON when structured output was requested, a refusal on a benign request. That needs per-request rule-based checks, not distributional statistics.
They complement each other. Drift catches slow systemic shifts that per-request checks never notice, because each output looks fine while the aggregate moved. Per-request detection catches acute breakage that a distributional test averaged over thousands of requests would dilute into invisibility.
DR5: How do you build a drift baseline/reference distribution in the first place, and how often should you refresh it?
Build the reference from a recent, validated window of production traffic — or the training distribution if traffic has not started — not an arbitrarily old snapshot. The reference must represent a period you are confident was healthy.
Refresh cadence is a two-sided trade-off. Refresh too often and you normalize away real slow drift, because comparing yesterday to today essentially never shows drift and gradual multi-week shifts stay hidden. Refresh too rarely and normal seasonal changes get flagged as drift forever. The common pattern: hold a fixed reference window — “traffic from the last validated release” — until a human explicitly promotes a new one, typically alongside a version bump. Refresh is a deliberate reviewed action, not a rolling automatic window, for the same reason immutable model versions matter (MV2).
DR6: How does drift detection interact with multi-tenant serving, where different tenants have very different, legitimately-different traffic patterns?
A single global baseline shows “drift” constantly, simply because tenant mix shifts — one customer’s usage differs from another’s and relative volumes change week to week. At the aggregate level that is noise, not signal.
Better: maintain per-tenant or per-segment baselines and drift scores, and escalate globally only when drift appears within a tenant’s own traffic over time, or when enough tenants show it simultaneously to suggest a shared cause such as a model or prompt-template change. This is directly analogous to why per-tenant cost and latency dashboards matter (M5): aggregates over a heterogeneous population hide exactly the signals you need.
DR7: Interviewer pushback: “PSI and KS tests are from classical tabular ML monitoring — do they even make sense for LLMs, or is this cargo-culting a technique from a different problem?”
Fair pushback, and the honest answer is “partially”. PSI and KS suit comparing distributions of derived numeric features — prompt length, embedding-distance scores, response length, latency — and remain genuinely useful there. They are not applicable to raw text without that extraction step.
What is LLM-specific and not borrowed: output-quality proxies such as guardrail trigger rate, refusal rate, and LLM-as-judge scores on sampled outputs. Those have no analogue in tabular drift detection and are arguably the more important signal for generative systems.
The best answer combines both — classical tests on derived numeric features, cheap and good for continuous monitoring, plus quality-eval sampling, more expensive but the ground-truth signal the statistical tests only proxy for.
Triton Inference Server (Ch. 11)
Maps to Chapter 11 — Triton.
T1: What problem does Triton Inference Server solve that a hand-rolled FastAPI + PyTorch server doesn’t?
Multi-framework, multi-model serving from one process: PyTorch, TensorFlow, ONNX, TensorRT, and via the vLLM/TensorRT-LLM backends, LLM engines, all behind one gRPC/HTTP API. Dynamic batching and concurrent model execution built in and configurable per model, without hand-written scheduling logic. Model repository abstraction: models deploy by landing in a directory with a config file, and versioning, loading/unloading, and multi-version serving are server features rather than application code. Production observability out of the box: built-in Prometheus metrics, lifecycle events, per-model and per-version statistics.
The trade-off is more operational surface and a configuration model (config.pbtxt) to learn — worth it with multiple models, frameworks, or versions to manage; arguably overkill for a single model on a single serving path.
T2: Describe the Triton model repository layout and what config.pbtxt controls.
model_repository/
my_model/
config.pbtxt
1/
model.onnx
2/
model.onnx
Each top-level directory is a model name; numbered subdirectories are versions, so Triton serves several versions of the same model simultaneously — directly useful for canary and A/B, with Ch. 07 concepts applying at the Triton layer.
config.pbtxt controls input and output tensor names, shapes, and datatypes; the backend (onnxruntime, pytorch, tensorrt, vllm, or python for custom); batching configuration including max batch size and dynamic batching parameters; instance groups, meaning how many copies run and on which GPUs; and version policy — latest only, all, or a specific set.
T3: Explain Triton’s dynamic batching vs. vLLM’s continuous batching — are these the same idea?
No. Triton dynamic batching accumulates individual requests arriving within a short configurable window into one batch, then runs one forward pass — fitting models with a fixed-shape, single-pass profile: classification, embedding, non-autoregressive models.
Continuous, iteration-level batching is specific to autoregressive generation: it batches individual decode steps, letting requests join and leave mid-generation. Plain dynamic batching fits that badly, because requests finish at wildly different times depending on output length. So when serving LLMs through Triton you use the vLLM or TensorRT-LLM backend, which brings continuous-batching-style scheduling into Triton’s process.
T4: What are Triton’s ensemble models, and when would you use one in an LLM serving pipeline?
An ensemble defines a pipeline of models and steps — preprocessing → embedding → retrieval → generation → postprocessing — as a single logical model that Triton executes as a DAG, handling data hand-off internally. It suits a RAG pipeline where one API call should trigger embed-query, retrieve, and generate, with Triton scheduling and batching each stage instead of the client making three round trips.
The trade-off: per-stage debugging is harder, since the client sees one opaque pipeline, and stage-level independent scaling is more constrained than separate Deployments. Good for tightly coupled, latency-sensitive pipelines; poor when stages have very different scaling and failure characteristics.
T5: How does Triton fit into a Kubernetes deployment, and what does the “instance group” concept map to?
Triton runs as a normal container in a Deployment, typically requesting nvidia.com/gpu like any GPU workload; Kubernetes needs to know nothing Triton-specific.
Instance groups in config.pbtxt control how many copies of a given model Triton runs within one Triton process or pod, and on which GPUs — a Triton-internal concept distinct from and complementary to Kubernetes replica scaling, which runs multiple pods each potentially running several instance groups. So use instance groups to pack models onto the GPUs within a pod, and Kubernetes HPA and replicas to scale the number of pods. Conflating the layers is a common early mistake.
T6: When would you choose Triton + a TensorRT-LLM backend over a plain vLLM deployment for LLM serving?
TensorRT-LLM via Triton is typically the highest raw throughput and lowest latency option on NVIDIA hardware, because it compiles model- and hardware-specific optimized kernels ahead of time via graph fusion and precision-specific kernel selection. The cost is an explicit build step per model + GPU-generation + precision combination, a steeper operational curve, and less flexibility for new or unusual architectures.
vLLM, standalone or via Triton’s vLLM backend, gets a new model serving faster, has a simpler operational model, delivers strong out-of-the-box performance with no compile step, and moves faster on new architecture support.
Rule of thumb: TensorRT-LLM for a small number of stable, high-volume models where the extra investment pays back in throughput and cost; vLLM when iterating quickly across many changing models. The space moves fast — TensorRT-LLM’s build ergonomics keep improving and NVIDIA NIM packages the trade-off as a pre-built container — so flag current capability claims as “verify before quoting”.
T7: How does Triton support A/B testing or canary between model versions natively?
The model repository natively supports multiple numbered versions of the same model name (T2) with a configurable version policy, so two versions can be loaded simultaneously and routed between.
Native Triton does not do traffic-percentage splitting itself — that decision lives in front, in an API gateway, a service mesh, or application logic. Triton’s job is to have both versions loaded and ready to serve whichever is asked for. So the canary mechanics from Ch. 07 apply exactly as described; Triton only changes where “which version is loaded and serving” lives.
T8: What monitoring does Triton expose natively, and how do you wire it into a Prometheus/Grafana stack (Ch. 08)?
A built-in /metrics endpoint in Prometheus format with per-model, per-version statistics: request count, inference duration broken out into queue time versus compute time, GPU utilization and memory, and success/failure counts.
Wire it in like any other Prometheus target — a prometheus.yml scrape config pointed at the Triton pod’s metrics port. Not having to build this instrumentation yourself is one of Triton’s practical advantages. Because it separates queue time from compute time by default, it directly supports the golden-signal breakdown from M2 with no custom work.
T9: Interviewer pushback: “If vLLM alone already gives you continuous batching, PagedAttention, and an OpenAI-compatible API, what does adding Triton in front actually buy you — isn’t it redundant infrastructure?”
For a single-model, single-framework, LLM-only deployment it genuinely can be redundant. Running vLLM’s own OpenAI-compatible server directly is a perfectly reasonable, simpler answer, and it is worth saying so rather than defaulting to “always use Triton”.
Triton earns its keep when you need one unified serving layer across heterogeneous models and frameworks — LLMs via vLLM/TensorRT-LLM, classical models via ONNX/TensorRT, embedding models, all behind one API and ops surface — or when you specifically need ensembles, fine-grained instance-group GPU packing, or the native multi-version model repository. Name the trade-off rather than a universal rule.
Production Best Practices
Q16: What are the key considerations for production LLM serving?
Performance: an explicit SLO such as P95 TTFT < 300ms rather than a generic “fast”; throughput validated by load testing (Ch. 04); autoscaling on demand (Ch. 06). Reliability: liveness, readiness, and startup probes (Q5e); graceful degradation instead of hard failure; circuit breakers against cascade failures; retries with exponential backoff and jitter, bounded so they cannot amplify an overload.
Monitoring: metrics (Ch. 08), structured logging with request and trace correlation, SLO-burn-rate alerting (M4), cross-hop tracing (M6). Security: API keys, OAuth, or mTLS between internal services; rate limiting to protect GPU capacity; input validation (Q5f); secrets in Kubernetes Secrets or a secrets manager, never baked into images. Cost: right-sized instances (Q20), scale-down when idle, efficient models or quantization where quality allows. Model management: versioning (Ch. 09), A/B comparison on real traffic (Ch. 07), fast rollback (MV4), drift detection (Ch. 10).
Q17: How would you handle a sudden spike in traffic?
Immediately: HPA or KEDA scales up (Ch. 06), but that has real latency (AS4). Load-balance across pods, ideally prefix-aware (V6). Queue with a bounded queue, and rate limit to protect the backend — reject cheaply rather than letting everyone degrade.
While it happens, watch pod count and node provisioning progress, latency split TTFT versus inter-token, error and reject rates, and GPU plus KV-cache utilization. If overwhelmed, degrade gracefully — smaller model or capped max_tokens — rate limit with a fast 429 rather than a slow timeout, scale manually if autoscaling is too slow, and add capacity on a spot/on-demand mix.
Prevention is capacity planning from known per-replica capacity (LT7), load testing at and above expected load, properly configured autoscaling with predictive scaling for known patterns (AS5), and circuit breakers. Afterwards, use the burst as capacity-planning input (AS6), resize the warm buffer, raise baseline capacity if the level looks sustained, and update the runbook.
Q20: Explain how you would optimize costs for LLM serving.
Compute — GPU instances — is usually the dominant cost by far, ahead of model and log storage, cross-region network transfer, and observability overhead.
Right-size first, from load-test data (LT7) rather than over-provisioning “just in case”. Then autoscale down during low traffic, scale up predictively for known patterns, and use spot or preemptible instances for non-latency-critical batch work. Quantize (FP8/INT8/INT4 — see V5) or distill where quality allows. Caching is the highest-leverage lever for chat and RAG: prefix and KV-cache reuse (V6), plus response caching where semantically valid. Batch with continuous batching (Q7), because higher GPU utilization is directly lower cost per token — track it as a metric (M5), not just as “faster”. Track cost per request and per token continuously, alert on waste, and commit to reserved capacity for predictable baseline load with spot or on-demand only for burst.
Worked example: 10 GPUs at 50% utilization versus 5 GPUs at 90% utilization via better batching and prefix caching, serving the same traffic. Illustrative savings: ~50% — validate with your own numbers, since exact ratios depend heavily on workload shape.
Additional Quick Questions
Q21: What is the difference between batch size and sequence length?
Batch size is the number of requests or sequences processed together; sequence length is the number of tokens in one request, prompt plus generated so far. Batch size 8 means eight requests processed simultaneously in traditional batching, or concurrently in flight under continuous batching; sequence length 512 means each request runs up to 512 tokens.
They hit memory differently. Larger batch size raises throughput and memory, because more KV cache is resident at once. Longer sequences raise both computation and memory, because KV cache grows linearly with sequence length per sequence.
Q22: How does quantization affect model performance?
FP32 → FP16/BF16 is roughly 2x smaller and faster with minimal accuracy loss. FP16/BF16 → FP8 is roughly 2x smaller again, with meaningful speedup on Hopper and Blackwell-class tensor cores and small workload-dependent accuracy impact. FP16 → INT8 is roughly 2x smaller and faster with small accuracy loss, and may need calibration. INT8 → INT4 is roughly 2x smaller again with larger accuracy loss needing careful per-task validation.
The trade is faster inference, less memory, and lower cost against potential accuracy loss and the need for calibration data and per-task validation — not just perplexity. Use it in production when speed or cost matters and quality is validated as acceptable on your actual eval suite, and for memory-constrained deployments on smaller GPUs or at higher concurrency targets.
Q23: What is the difference between model parallelism and data parallelism?
Data parallelism replicates the same model across GPUs or nodes with different data — different requests, in inference — on each replica. In training that needs gradient sync; in inference it is just “run N independent replicas”, which is what Kubernetes replica scaling does.
Model parallelism splits the model across GPUs — tensor parallelism within a layer, pipeline parallelism across layers (V4) — so each GPU holds part of it. It is for models that do not fit on one GPU, or that need the combined memory bandwidth of several to hit a decode-latency target.
Concretely: data parallel inference is 8 GPUs each running an independent full copy of a 7B model on different requests — 8 replicas. Model parallel inference is 8 GPUs each holding 1/8 of a 70B+ model’s weights, collectively serving one request’s forward pass.
System Design Scenarios
This section replaces the original thin “System Design” section with 5 full worked scenarios. Q18 (the original quick-reference design question) is preserved below as a compact warm-up; use it if you only have two minutes, and use the full scenarios if you have twenty.
Q18: Design a system to serve LLMs at scale (quick reference).
Architecture:
[Load Balancer]
|
[API Gateway] (Rate limiting, Auth)
|
[Kubernetes Cluster]
+-- [LLM Serving Pods] (vLLM)
+-- [Monitoring] (Prometheus, Grafana)
+-- [Model Registry] (S3/GCS)
Components: load balancer (traffic distribution, health checks, TLS termination) -> API gateway (auth, rate limiting, routing, versioning) -> serving layer (vLLM, HPA, GPU nodes) -> model storage (registry, versioning, node-local caching) -> monitoring (Prometheus/Grafana, centralized logging, alerting) -> data pipeline (request logging, drift detection, A/B testing).
Scaling strategy: horizontal (more pods via HPA), vertical (bigger GPUs for bigger models), multi-region (geographic distribution).
Key metrics: latency (P50/P95/P99, split TTFT/inter-token), throughput (tokens/sec), error rate, GPU/KV-cache utilization, cost per request.
(The full scenarios below go through the actual reasoning — clarifying questions, trade-offs, and pushback — a senior interviewer expects for any one of these architecture pieces in depth.)
Scenario 1: Design serving for a 70B-parameter model at a defined cost/latency SLO
Prompt as given in an interview: “Design an inference platform to serve a 70B dense model. Target: P95 TTFT under 500ms, P95 end-to-end under 5s for a 500-token response, at the lowest cost per request you can justify.”
Clarifying questions to ask first: expected volume and concurrency, peak and average, because 10 req/s and 10,000 req/s are different architectures; prompt-length distribution, short chat turns versus long RAG contexts, which drives prefill cost and chunked-prefill tuning; whether streaming is required, which affects the TTFT-versus-end-to-end split; whether a hard availability SLA constrains redundancy; and whether “lowest cost” is a preference or a hard number.
Architecture:
[ CDN / Edge ]
|
[ API Gateway / LB ]
(auth, rate limit, routing)
|
+-----------------+------------------+
| |
[ K8s: 70B model pool ] [ K8s: smaller fallback model pool ]
(TP=4 or TP=8, per-replica) (see Scenario 5 for fallback design)
|
[ Replica: 4x H100/H200 per pod, NVLink, TP=4 ]
[ vLLM engine: continuous batching, chunked prefill, FP8 weights+KV ]
|
[ Prefix-cache-aware LB in front of replica pool ]
|
[ Model registry (S3) + node-local NVMe weight cache ]
|
[ Prometheus/Grafana, HPA on queue depth, cluster autoscaler on node pool ]
Tensor parallelism degree is the single most important number here. A 70B model in FP16 needs ~140GB for weights alone, so it does not fit on one 80GB GPU. TP=4 across four 80GB-class NVLink GPUs is a common fit; TP=2 with FP8 weights (~70GB) might fit on two with headroom for KV cache, halving GPU count per replica in exchange for quality risk — validate that with an eval suite before committing.
At 500-token outputs and target concurrency, KV cache is likely the binding constraint on concurrent sequences per replica: size the gpu_memory_utilization fraction accordingly and consider FP8 KV cache to roughly double effective concurrency. Enable chunked prefill so occasional long prompts do not spike inter-token latency, which directly protects P95 TTFT under mixed load. With a shared system prompt or repeated RAG context, prefix caching plus cache-aware routing is the highest-leverage cost and latency lever. Set replica count from load-test-derived per-replica capacity at the SLO (LT7), not a theoretical FLOPs calculation.
Cost levers, ranked: prefix caching (near-free once implemented); FP8 quantization of weights and KV cache (validate quality first); right-sizing TP degree against actual concurrency need; and spot/preemptible capacity for any tier that tolerates interruption, rarely the primary serving path.
Defending against pushback. “Why not 8 GPUs per replica for headroom?” — more GPUs per replica means more idle capacity most of the time and coarser scaling granularity, since each step adds 8 GPUs of cost rather than 4; right-size TP degree and use replica count as the load knob. “Why not cache everything?” — caching helps repeated content, not a genuinely novel prompt; for open-ended generation GPU cost is a floor you reduce, not eliminate. “How do you know the load-test numbers hold?” — they will not exactly, which is why the design carries N+1 headroom, capacity-based autoscaling as a second line, and continuous production latency monitoring.
Scenario 2: Design a multi-tenant inference platform serving several model sizes to different internal teams
Prompt as given in an interview: “Several teams want to use a shared LLM platform: one needs a small 7B model for high-volume simple tasks, one needs a 70B model for complex reasoning, one is experimenting with a new fine-tune weekly. Design the platform.”
Clarifying questions to ask first: whether tenants need hard isolation as a compliance boundary or just fair sharing; whether per-tenant cost attribution is required; how often new models appear, meaning a fixed catalog or a changing one; and whether latency SLOs differ per tenant.
Architecture:
[ Gateway: auth, per-tenant rate limits, routing by model id ]
|
+---------------------------+---------------------------+
| | |
[ 7B model pool ] [ 70B model pool ] [ Experimental fine-tune pool ]
(many small replicas, (few large TP=4 replicas, (small pool, DRA/MIG-shared GPUs,
high concurrency, priority: high) priority: low, preemptible)
priority: medium)
| | |
+---------------------------+---------------------------+
|
[ Shared GPU node pools, PriorityClasses + taints ]
[ Per-tenant cost dashboards (Ch. 08, M5) ]
[ Per-model registry entries + independent canary pipelines (Ch. 07/09) ]
Isolation: namespaces per tenant for policy and quota isolation, plus MIG or DRA-managed GPU partitions (K3) where hard isolation matters — the experimental fine-tune must not be able to starve the 7B production pool of GPU memory on a shared node. For the 70B pool, dedicated whole-GPU nodes make more sense, since MIG-slicing a large model’s TP group adds complexity for little benefit.
Priority-based preemption: production pools get a higher PriorityClass than the experimental pool, so under GPU pressure Kubernetes preempts experimental work first, letting the platform run experiments cheaply on spare capacity. Independent scaling and cost: each pool gets its own HPA/KEDA config tuned to its capacity envelope (AS8), and every request and metric is tagged with tenant ID at the gateway so cost-per-tenant dashboards work for chargeback and for catching runaway usage. The weekly-changing model gets its own registry entry and canary pipeline (Ch. 09), independent of the stable models’ cadence.
Defending against pushback. “Why not a dedicated cluster per tenant?” — full dedication maximizes isolation but loses the cost benefit of sharing GPU capacity across tenants with complementary peaks; shared infrastructure with strong logical isolation gets most of the isolation at a fraction of the cost, short of a hard compliance requirement for physical separation. “What if the experimental pool needs more than spare capacity?” — that is what the priority design handles: its requests queue or degrade rather than starving production, and if the workload becomes important it graduates to its own provisioned pool as a deliberate decision.
Scenario 3: Design the rollout pipeline for model updates with zero user-visible downtime
Prompt as given in an interview: “Design how a new model version goes from ‘trained’ to ‘serving 100% of production traffic’ with zero downtime and minimal risk.”
Clarifying questions to ask first: full model swap or incremental fine-tune; acceptable time-to-full-rollout, hours or same-day; whether an offline eval suite exists or must be built; and whether multi-turn conversations are in play, which decides sticky-routing requirements (CD7).
Pipeline:
[New checkpoint] -> [Offline eval suite: benchmarks + regression tests]
| (fail -> stop here, never reaches serving)
v
[Register in model registry, immutable version tag] (Ch. 09)
|
v
[Load onto a small pool, SHADOW traffic only] (CD6) -- validate latency/capacity + diff outputs vs. stable, zero user exposure
| (fail -> fix, re-shadow; never promote)
v
[Canary: 1-5% real traffic, sticky by session] (CD1, CD7)
|
[Automated analysis window: latency, errors, guardrail-trigger rate,
quality-eval sampling] (CD2, CD4)
|
pass -> step up (5% -> 25% -> 50% -> 100%) fail -> automatic rollback to 0%
|
v
[100% traffic on new version; keep previous version warm for N hours] (MV4, fast-rollback window)
|
v
[Scale down previous version after fast-rollback window elapses]
Zero downtime comes from traffic-weight shifting, never a hard cutover. At every stage both versions are fully up and only the routing weight changes, so capacity never dips to serve the swap — in contrast with RollingUpdate’s pod-replacement churn (K2).
Sticky-by-session routing (CD7) is non-negotiable for multi-turn chat; without it, zero downtime at the infra level still produces a broken user experience. Immutable versioning plus a fast-rollback warm window (MV2, MV4) turns “we can roll back” into “we can roll back in seconds”, which matters most when something breaks after full promotion. And automated analysis must include quality signals (CD2), because a pipeline checking only latency and errors will happily promote a model that is healthy and worse.
Defending against pushback. “A lot of infrastructure for a one-line config change.” — the infrastructure cost is paid once; ad hoc rollouts pay a recurring risk cost on every update forever, and for an actively-used product those updates are constant. “What if offline eval misses something?” — that is exactly why there are three stages with different blind spots: offline eval is fast but historical, shadow catches live-distribution issues at zero exposure, canary catches the remainder with bounded exposure and an automatic kill switch.
Scenario 4: Design monitoring and autoscaling for a bursty consumer product
Prompt as given in an interview: “A consumer-facing feature can go viral and spike traffic 10-20x within minutes, then fall back down. Design the monitoring and autoscaling approach.”
Clarifying questions to ask first: the cost tolerance for a warm buffer versus the risk of degrading during a spike; whether bursts have any predictability or are fully organic; and the acceptable degradation mode if capacity is exceeded — slower responses, a smaller model, or hard rejection.
Architecture:
[ Gateway: per-user rate limit, bounded admission queue ]
|
[ Warm buffer pool: sized for observed P99 burst ratio, always on ] (AS6)
|
[ Reactive HPA/KEDA on queue depth ] --(fast scale-up policy)--> [ additional replicas ]
|
[ Cluster autoscaler: pre-provisioned warm node pool ] (skips node-provisioning latency, K7/AS4)
|
[ Degradation controller: watches queue depth ] --(threshold crossed)-->
[ shed to smaller fallback model / cap max_tokens / shed low-priority traffic ] (Scenario 5)
|
[ Monitoring: real-time dashboards on queue depth, GPU util, TTFT P95/P99,
reject rate -- alerts on SLO burn rate, not static thresholds ] (M4)
Size the warm buffer off the observed burst ratio, not the average — a deliberate cost-versus-resilience trade communicated explicitly: “we run at 40% baseline utilization specifically to absorb a 10x spike within 60 seconds”. A thin margin plus pure reactive scaling guarantees degraded UX during exactly the highest-visibility traffic moments.
Bounded admission queue with fast, honest rejection: past a threshold, return fast 429s with retry-after rather than accepting requests into a growing queue that times out anyway. Pre-provisioned warm node pool: keep GPU nodes standing ready even without pods placed, so pod scale-up never waits on node provisioning (K7) — often the single biggest lever between detecting a spike and serving it. Layer predictive scaling (AS5) on top for known events, and keep graceful degradation (Scenario 5) as the tested last line.
Defending against pushback. “Isn’t a permanently warm buffer wasted spend?” — partly; it is explicit insurance sized against the business cost of a bad viral moment, which for a consumer product can mean losing exactly the users the moment was meant to bring in. “Why not autoscale hard and fast instead?” — because GPU node provisioning and cold start (K7) cannot react in under a minute in most environments; the buffer compensates for a real operational floor, not a config mistake.
Scenario 5: Design a fallback/degradation strategy when GPU capacity runs low
Prompt as given in an interview: “Your serving fleet is at capacity — demand exceeds what your GPUs can serve within SLO, and more capacity isn’t available immediately (quota limit, spot capacity dried up, etc.). Design the degradation strategy.”
Clarifying questions to ask first: whether a smaller fallback model exists and whether a quality drop is acceptable for some traffic; whether there is user tiering (paid versus free) that should decide who degrades first; and whether a slower response is acceptable or the product needs a hard latency ceiling even at reduced quality.
Architecture:
[ Gateway: tracks real-time capacity signal (queue depth / KV-cache util) ]
|
v
capacity signal crosses threshold?
|
no -> normal routing to primary model pool
|
yes -> [ Degradation controller ] applies, in order of increasing severity:
1. Cap max_tokens for new requests (reduces per-request cost/time)
2. Reduce/disable expensive optional features (long-context mode, tool use, etc.)
3. Route free/low-priority tier to a smaller fallback model pool
4. Shed lowest-priority traffic entirely with a clear, fast error + retry-after
|
v
[ All degradation actions logged + alerted -- this is an incident, not silent behavior ]
|
v
[ Auto-recovery: as capacity signal drops below threshold, un-degrade in reverse order ]
Degrade in graduated steps, not binary up-or-down: capping max_tokens or disabling one expensive feature preserves service for far more users than an all-or-nothing shutoff, and each step sheds load at the smallest quality cost available.
Tiered shedding by priority: protect a paid tier longest and let free tiers absorb degradation first — a pre-agreed, documented business policy, not something improvised mid-incident, because whose requests get shed is a product decision. Fall back to a smaller model rather than pure rejection where possible, with product buy-in on which use cases tolerate a quality drop. Make degradation visible internally: every action fires an alert, because capacity running out is an operational incident worth investigating, not something that should silently self-heal. Auto-recovery is symmetric: un-degrade in reverse order as capacity recovers, with hysteresis so it does not flap at the threshold (AS7).
Defending against pushback. “Isn’t silently serving a worse model dishonest?” — the alternative, everyone getting a slow or failing response, is worse for everyone including users who would have been fine with the smaller model; the fix is making the policy explicit and product-approved, such as surfacing “a faster, lighter response” in the UI. “Why not just have enough capacity?” — because “enough for the worst case at all times” is either prohibitively expensive or physically unavailable; quota limits and regional GPU shortages are real and recurring as of 2025-2026.
2025-2026 Landscape Quiz
A senior interviewer will often probe whether you track the ecosystem or are reciting a 2023 blog post. The facts below were checked against current sources as of August 2026. Exact version numbers and benchmark ratios move fast — anything marked “verify before quoting” should be re-checked against current docs before you cite a specific number in an interview; the durable value here is the shape of the landscape, not the last digit of a version string.
LQ1: What are the main LLM inference engines in production use, and how do they position relative to each other?
vLLM is the dominant open-source general-purpose engine: continuous batching, PagedAttention, broad architecture coverage, an OpenAI-compatible server, and the “V1” engine architecture (rewritten scheduler and execution core, alpha from early 2025, matured through 2025-2026). It is the default for iterating across many model families.
SGLang is a fast-moving alternative with its own scheduler and runtime — RadixAttention for prefix caching — and strong results on structured-generation and high-concurrency workloads; now cited alongside vLLM rather than as a niche option. TensorRT-LLM gives compiled, hardware-specific kernels and is typically the throughput and latency ceiling on NVIDIA GPUs, at the cost of a build step and less architecture flexibility; increasingly consumed via Triton’s backend or inside NVIDIA NIM. Triton Inference Server is the multi-framework serving layer (Ch. 11), hosting those engines behind one ops surface rather than competing as an engine. NVIDIA NIM is prebuilt containerized inference microservices packaging one of these behind a standard OpenAI-compatible API. (Verify current NIM backend choices per model before quoting.)
Version numbers and head-to-head benchmark ratios change roughly monthly: know the names and positioning, cite specifics as “as of my last check”.
LQ2: What’s new about NVIDIA’s current GPU generations relevant to inference (as of 2025-2026)?
Hopper (H100/H200) was widely deployed through 2024-2025; the H200 added significantly more HBM capacity and bandwidth than the H100, which directly helps KV-cache-bound serving. Blackwell (B100/B200, GB200 NVL72) is the current flagship, with major FP4/FP8 tensor-core throughput improvements and NVLink domain scale — GB200 NVL72 links many GPUs into one large NVLink domain.
Blackwell Ultra (B300/GB300) is a mid-cycle refresh with substantially more HBM3e per GPU, reported around the 288GB class. That matters because more per-GPU memory means fewer GPUs per replica for a given model plus KV-cache footprint, improving TP-degree economics. Verify exact specs and availability before quoting — this generation rolled out through late 2025 into 2026 and cloud availability was still expanding.
The trend: each generation’s biggest inference-relevant win is memory capacity and bandwidth plus native low-precision (FP8/FP4) throughput, not raw FLOPs — both attack the two real bottlenecks, KV-cache memory and decode memory bandwidth.
LQ3: What’s Dynamic Resource Allocation (DRA) in Kubernetes, and what’s its GA status?
DRA is the modern Kubernetes API for expressing complex device — especially GPU — allocation requirements: DeviceClass for what devices exist, ResourceClaim/ResourceClaimTemplate for what a pod needs, and CEL-based selection such as “a GPU with more than 40GB memory”. It replaces the device-plugin model’s all-or-nothing integer-count limitation and supports ranked fallback across GPU types, partitioned or shared access feeding MIG or time-slicing, and richer logic than a bare nvidia.com/gpu: N request.
GA status: DRA graduated to General Availability in Kubernetes v1.34, per the official Kubernetes project blog, with structured-parameter features and ecosystem tooling refined in following releases. Verify the exact current minor version and feature maturity before quoting. Interview framing: the old model, still very common in production, and DRA coexist — do not imply the old one is gone.
LQ4: What are the current options for sharing one physical GPU across workloads on Kubernetes, ranked by isolation strength?
(See K3 above for the full comparison table.) Weakest to strongest: time-slicing (software round-robin, no memory isolation) → MPS (shared address space, better concurrent-kernel execution, still no hard memory isolation) → MIG (hardware-partitioned compute and memory slices, fixed profiles) → DRA-orchestrated allocation (flexible expression of any of the above, or whole-device claims, through a unified API).
The 2025-2026 trend consolidates around DRA as the scheduling layer on top of MIG, time-slicing, and MPS as the underlying mechanisms. DRA does not replace MIG; it makes MIG and the other mechanisms easier to request and compose with complex placement logic.
LQ5: What’s the current state of KV-cache offloading / cross-node cache sharing as a serving technique?
Tools like LMCache implement a KV-cache layer that offloads cache to CPU memory or NVMe/remote storage and shares it across multiple vLLM instances and nodes, extending prefix caching (V6) beyond one replica’s GPU memory.
The motivation: prefix caching’s benefit is capped by how much cache fits in one replica’s GPU memory and how well the load balancer routes cache-sharing requests to the same replica. Offloading lets a much larger effective cache — system prompts, long-lived RAG contexts, long conversation histories — be reused across a whole fleet rather than staying replica-local and volatile.
This is an active area as of 2025-2026, with ongoing work standardizing KV-cache transfer and connector interfaces between prefill/decode-disaggregated setups (V8). Treat specific throughput multipliers as workload-dependent claims to verify.
LQ6: What serving-platform / “inference-as-a-service” options exist besides self-hosting, and when would a senior engineer recommend one over self-hosting?
NVIDIA NIM: prebuilt optimized containers per model, self-hosted on your own GPUs with the engine-tuning done. Managed inference endpoints from clouds and model providers, dedicated or serverless: trade control and per-token cost optimization for operational simplicity. Self-hosted on Kubernetes with vLLM/SGLang/TensorRT-LLM/Triton: full control and best cost at scale, but you own everything in every chapter above.
Self-host when scale is large enough that infra cost and control outweigh the engineering investment — a rule of thumb some teams use is once GPU spend is large enough that a percentage point of utilization is a meaningful dollar figure. Use a managed platform when pre-scale, moving fast, or lacking platform-engineering capacity. It is a genuine build-versus-buy trade-off, not a purity contest.
LQ7: Rapid-fire current-fact check — answer, then flag confidence.
GQA is standard across most current major open-weight model families — high confidence, stable fact. FP8 is a standard, well-supported inference precision on current NVIDIA data-center GPU generations — high confidence, stable fact.
Exact current vLLM/SGLang/TensorRT-LLM version numbers and head-to-head benchmark numbers — verify before quoting, changes ~monthly. Kubernetes DRA reached GA in v1.34 — high confidence as of research date, but verify the Kubernetes version your target company actually runs, since a feature going GA does not mean every cluster has upgraded. Specific GPU model availability and pricing, such as which cloud offers which Blackwell Ultra instance family — verify before quoting, changes monthly.
The general trend toward disaggregated prefill/decode serving and KV-cache-sharing infrastructure at the largest scale — high confidence as a direction, but treat any specific implementation’s production-readiness claim as something to verify.
Rapid-Fire Flashcards & Glossary
One-liner Q->A pairs for the night before an interview. Organized by chapter. Skim top-to-bottom; if any answer doesn’t come instantly, jump back to that chapter’s deep-dive section above.
Fundamentals & Ch. 01 (Basic Serving)
| Q | A |
|---|---|
| Why is inference autoregressive? | Each new token depends on all previously generated tokens via attention. |
| What are the two inference phases? | Prefill (compute-bound, whole prompt) and decode (memory-bandwidth-bound, one token at a time). |
| Why cache K/V? | Avoids recomputing attention for every past token on every step. |
| What grows the KV cache? | Sequence length x batch size x layers x KV heads x head dim. |
| Why load the model once at startup? | Loading is slow (seconds-minutes); per-request loading would make every request pay that cost. |
| Sync or async for serving? | Async — most wall-clock time is spent awaiting the GPU, not doing CPU work. |
| What does streaming improve? | Perceived latency (TTFT) — user sees tokens as generated instead of waiting for the full response. |
| Liveness vs. readiness probe? | Liveness = process alive; readiness = model loaded and able to serve traffic. |
| Why can rollouts crash-loop on large models? | Liveness probe fires before a slow model load finishes — needs a startup probe. |
| What’s the risk of a naive SIGTERM handler? | Drops in-flight/streaming requests instead of draining gracefully. |
Ch. 02 (Docker)
| Q | A |
|---|---|
Why not python:slim for GPU serving? | No CUDA/cuDNN userspace libraries matching the host driver. |
| devel vs. runtime CUDA image? | devel has compiler toolchain (nvcc); runtime is smaller, for running only. |
| Why multi-stage builds? | Compile with devel image, ship only the runtime image + artifacts. |
| Bake weights into the image or mount at runtime? | Usually mount at runtime — decouples model version from code/image version. |
| Top real-world Docker+GPU failure mode? | CUDA-version-vs-host-driver incompatibility. |
| Why pin exact dependency versions? | Unpinned installs cause silent ABI mismatches / behavior drift over time. |
| When does docker-compose stop being enough? | Multi-node scheduling, autoscaling, rolling updates, secrets at scale. |
nvidia-smi works but torch.cuda.is_available() is False — likely cause? | CPU-only wheel installed instead of the CUDA-tagged build. |
Performance Optimization
| Q | A |
|---|---|
| What is PagedAttention? | Block-based, non-contiguous KV cache allocation — like OS virtual memory paging. |
| What does continuous batching fix? | Static batching’s “wait for slowest request” bubble; requests join/leave mid-batch. |
| FP16 -> FP8 -> INT4 quantization trend? | Each step roughly halves memory/increases speed, with increasing accuracy risk. |
| Latency vs. throughput lever? | Batch size — bigger batches raise throughput, raise per-request latency. |
| Right SLO framing? | Optimize throughput at a fixed latency SLO, not throughput in isolation. |
Ch. 03 (Kubernetes)
| Q | A |
|---|---|
Why must GPU requests == limits? | GPUs are an extended resource — no fractional/burstable allocation by default. |
| Default RollingUpdate problem for GPU pods? | Needs surge GPU capacity you may not have; slow, GPU-heavy pods make it worse. |
| Deployment or StatefulSet for serving pods? | Deployment, unless pods need stable identity/storage (e.g., multi-node TP rank-0). |
| Why taint GPU nodes? | Stops non-GPU workloads from occupying expensive GPU nodes. |
| What actually dominates GPU pod cold-start? | Node provisioning + image pull + weight download + engine warm-up, not scheduling. |
| MIG vs. time-slicing? | MIG = hardware-isolated partitions; time-slicing = software round-robin, no isolation. |
| What is DRA? | Dynamic Resource Allocation — flexible GPU allocation API, GA in Kubernetes v1.34. |
| Where should auth/rate-limiting live? | At the gateway layer, not inside the serving pod. |
Ch. 04 (Load Testing)
| Q | A |
|---|---|
| Why is RPS a poor LLM load metric? | Requests vary hugely in cost by prompt/output length; concurrency matters more. |
| What’s the “knee of the curve”? | The concurrency point where latency blows up while throughput plateaus. |
| TTFT vs. TPOT? | Time-to-first-token (prefill+queue) vs. time-per-output-token (decode step). |
| Why load test at concurrency, not just average load? | P99 tail latency only shows up under queueing pressure, not at low load. |
| What does a soak test catch that a burst test doesn’t? | Memory leaks, KV-cache fragmentation, slow degradation over hours. |
Ch. 05 (vLLM Internals)
| Q | A |
|---|---|
| What is chunked prefill for? | Splits long prompts’ prefill into chunks so it doesn’t stall other requests’ decode. |
| What does the vLLM scheduler decide each iteration? | Which sequences run a step, within a KV-cache-block and token budget. |
| When does speculative decoding help most? | Memory-bandwidth-bound decode with a draft model that’s frequently correct. |
| TP vs. PP? | TP shards each layer (needs NVLink, stays in-node); PP splits layers across stages (tolerates slower links, spans nodes). |
| What is prefix caching? | Reusing KV-cache blocks across requests sharing an identical prompt prefix. |
| What changed with vLLM’s “V1” engine? | Core scheduler/execution rewrite (alpha ~early 2025) for lower overhead, broader model support. |
| What is disaggregated prefill/decode? | Running prefill and decode on separate GPU pools, transferring KV cache between them. |
Ch. 06 (Autoscaling)
| Q | A |
|---|---|
| Why is CPU util a bad HPA signal here? | Decoupled from GPU/KV-cache load — a GPU-saturated pod can show 10% CPU. |
| Better HPA/KEDA signal? | Queue depth (requests waiting for a scheduler slot). |
| Why is GPU scale-to-zero hard? | Cold start (minutes) makes the first post-idle request violate any real SLO. |
| Two layers of GPU autoscaling latency? | Pod-level HPA + node-level cluster autoscaler — latencies stack, don’t overlap. |
| What is predictive/scheduled scaling for? | Pre-warming capacity ahead of known/forecastable demand patterns. |
| Why is HPA flapping worse for GPU pods? | Removed replicas may need node re-provisioning + weight re-download to come back. |
Ch. 07 (Canary Deployments)
| Q | A |
|---|---|
| Canary vs. blue-green vs. shadow? | Canary = small real-traffic %; blue-green = full cutover; shadow = copy traffic, discard output. |
| LLM-specific canary red flag? | Output-quality regression — invisible to latency/error metrics alone. |
| Why sticky-session routing during canary? | Multi-turn chats must not switch model versions mid-conversation. |
| Mesh-based split vs. replica-ratio split? | Mesh decouples traffic % from replica count; replica-ratio ties them together. |
| What should trip auto-rollback? | Guardrail metric breach over a rolling window with a minimum sample size. |
Ch. 08 (Monitoring)
| Q | A |
|---|---|
| Four golden signals for LLM serving? | Latency (TTFT+inter-token), traffic (req/s+tokens/s), errors, saturation (GPU+KV-cache+queue). |
| #1 cause of Prometheus falling over? | Cardinality explosion from high-cardinality labels (e.g., raw request ID). |
| Better alerting than static thresholds? | SLO burn-rate, multi-window multi-burn-rate alerting. |
| What does tracing add beyond metrics? | Per-request breakdown of where time went across hops. |
| How do you catch quality regressions continuously? | Sample live traffic through automated quality checks / LLM-as-judge, not just at canary time. |
Ch. 09 (Model Versioning)
| Q | A |
|---|---|
| Why immutable model artifacts? | Makes rollback and post-hoc debugging trustworthy. |
| What decouples code version from model version? | Runtime weight loading via config (env var/ConfigMap), not baked-in weights. |
| What determines real rollback speed? | Whether the previous version’s pods are still warm, or need cold re-provisioning. |
| What else needs versioning besides weights? | Prompt templates and RAG retrieval config — they shift output just as much. |
| What does shadow deployment validate that offline eval can’t? | Behavior on today’s live traffic distribution, not a historical dataset. |
Ch. 10 (Drift Detection)
| Q | A |
|---|---|
| Three types of drift? | Data drift (input), concept drift (input-output relationship), prediction drift (output). |
| How do you measure drift on free text? | Convert to numeric proxies first — embeddings, length, topic distribution — then PSI/KS/MMD. |
| Why is fully automatic drift-triggered retraining risky? | Feedback loops that amplify the very drift that triggered it. |
| Why maintain per-tenant drift baselines? | A global baseline shows false “drift” from normal tenant-mix shifts. |
| Biggest blind spot of PSI/KS alone for LLMs? | They don’t capture output-quality regressions — need LLM-as-judge/guardrail signals too. |
Ch. 11 (Triton)
| Q | A |
|---|---|
| What does Triton add over a hand-rolled server? | Multi-framework serving, dynamic batching, model repository/versioning, built-in metrics. |
| Dynamic batching vs. continuous batching? | Dynamic batches whole fixed-shape requests; continuous batches at the decode-step level for autoregressive generation. |
| What’s an ensemble model? | A DAG of models/steps (e.g., embed->retrieve->generate) served as one logical model. |
| What do “instance groups” control? | How many copies of a model run, and on which GPU(s), within one Triton process. |
| When is Triton overkill? | Single model, single framework, no need for its ensemble/multi-version features. |
Glossary
| Term | Definition |
|---|---|
| Autoregressive generation | Generating output one token at a time, each conditioned on all previous tokens. |
| KV cache | Stored attention key/value tensors for previously processed tokens, reused to avoid recomputation. |
| Prefill | The compute-bound phase processing the entire input prompt in one pass. |
| Decode | The memory-bandwidth-bound phase generating one output token per step. |
| TTFT | Time to first token — latency from request start to the first generated token. |
| TPOT / inter-token latency | Time per output token during decode. |
| PagedAttention | vLLM’s block-based, non-contiguous KV-cache memory management technique. |
| Continuous batching | Iteration-level batching where requests join/leave a running batch mid-generation. |
| Chunked prefill | Splitting a long prompt’s prefill across multiple scheduler iterations to avoid blocking decode. |
| Prefix caching | Reusing KV-cache blocks across requests sharing an identical prompt prefix. |
| Speculative decoding | Using a small draft model to propose multiple tokens, verified in one batched pass by the target model. |
| Tensor parallelism (TP) | Sharding each layer’s weights across GPUs, needs high-bandwidth interconnect. |
| Pipeline parallelism (PP) | Splitting model layers sequentially across GPUs/nodes, tolerates slower interconnect. |
| Data parallelism | Replicating the full model across GPUs/nodes, each serving different requests. |
| GQA (Grouped-Query Attention) | Attention variant sharing a handful of KV heads across query heads to shrink KV cache. |
| MQA (Multi-Query Attention) | Attention variant with a single shared KV head. |
| MLA (Multi-head Latent Attention) | DeepSeek-style attention compressing KV into a low-rank latent to shrink cache further. |
| Quantization | Reducing numeric precision of weights/activations (FP16/FP8/INT8/INT4) to save memory/speed up compute. |
| FP8 | 8-bit floating point precision, native on Hopper/Blackwell-class tensor cores. |
| Disaggregated prefill/decode | Running prefill and decode phases on separate GPU pools with KV-cache transfer between them. |
| HPA (Horizontal Pod Autoscaler) | Kubernetes controller that scales replica count based on metrics. |
| KEDA | Kubernetes Event-Driven Autoscaling — HPA extension supporting external/custom metric sources. |
| DRA (Dynamic Resource Allocation) | Kubernetes API (GA in v1.34) for flexible, claim-based device/GPU allocation. |
| MIG (Multi-Instance GPU) | NVIDIA hardware feature partitioning one GPU into isolated compute/memory slices. |
| MPS (Multi-Process Service) | NVIDIA feature allowing concurrent kernel execution from multiple processes on one GPU. |
| Time-slicing | Software round-robin sharing of one GPU across pods, no memory isolation. |
| Cluster autoscaler / Karpenter | Node-level autoscaling that provisions/removes nodes based on unschedulable pod pressure. |
| PriorityClass / preemption | Kubernetes mechanism to evict lower-priority pods to make room for higher-priority ones. |
| Canary deployment | Gradual traffic-weighted rollout of a new version alongside the stable one. |
| Shadow (dark) traffic | Sending a copy of live traffic to a new version without exposing its output to users. |
| Blue-green deployment | Full, instant traffic cutover between two fully-provisioned versions. |
| Sticky routing | Routing all requests of one session/conversation consistently to the same backend version. |
| Model registry | System of record for versioned model artifacts and their metadata/lineage/eval results. |
| Immutable versioning | Practice of never overwriting a published model version, only publishing new ones. |
| Data drift | Change in the distribution of production inputs vs. a reference distribution. |
| Concept drift | Change in the true input-output relationship over time. |
| Prediction drift | Change in the distribution of model outputs over time. |
| PSI (Population Stability Index) | Statistical measure comparing two distributions, common drift-detection metric. |
| Triton model repository | Directory-based structure where models and their versions/configs are deployed to Triton. |
| Dynamic batching (Triton) | Triton’s general-purpose request-batching feature for fixed-shape, single-pass models. |
| Ensemble model (Triton) | A DAG-defined pipeline of models/steps served as one logical Triton model. |
| Instance group (Triton) | Configuration controlling how many copies of a model run, and on which GPU(s). |
| TensorRT-LLM | NVIDIA’s compiled, hardware-optimized LLM inference engine. |
| NVIDIA NIM | Prebuilt containerized inference microservices packaging an optimized engine per model. |
| SGLang | An open-source LLM serving engine/runtime, notable for RadixAttention-based prefix caching. |
| LMCache | KV-cache offload/sharing layer extending prefix caching across replicas/nodes and to CPU/NVMe. |
| Golden signals | Latency, traffic, errors, saturation — the standard SRE monitoring framework. |
| SLO burn rate | Rate at which an error/latency budget is being consumed, used for smarter alerting. |
| Cardinality (metrics) | Number of unique label-value combinations for a metric; high cardinality can overload Prometheus. |
| Cold start (GPU) | Latency from “need capacity” to “capacity actually serving,” dominated by node/image/weight provisioning. |
| Warm buffer / warm pool | Standing spare capacity kept ready to absorb bursts faster than reactive autoscaling can react. |
| Graceful degradation | Serving reduced-quality/reduced-cost responses under load rather than failing outright. |
| LLM-as-judge | Using a (usually cheaper) LLM to automatically score another model’s outputs for quality/regressions. |
Traps & How to Recover
Common wrong answers/misconceptions that sound plausible but signal shallow experience to a senior interviewer — and the reframe that recovers the answer.
Trap 1: “We’d just add more GPUs to fix latency/throughput problems.”
Why it’s wrong: treats hardware as the first lever instead of the last one. A senior interviewer hears this as “hasn’t actually diagnosed a real bottleneck before.”
Say it. “First I’d check GPU/KV-cache utilization and batching configuration — low utilization under load means a scheduling/batching problem, not a hardware problem. I’d only add GPUs after confirming the engine is already using the ones it has efficiently.”
Trap 2: “vLLM is always faster than everything else, so just use vLLM.”
Why it’s wrong: treats engine choice as a fixed ranking instead of a workload-dependent trade-off (TensorRT-LLM often wins on raw throughput for stable, high-volume models; Triton wins for multi-framework needs; SGLang is a real, current alternative).
Say it. “vLLM is my default for iteration speed and broad model support, but for a small number of stable, extremely high-volume models I’d benchmark TensorRT-LLM, and if we’re serving heterogeneous frameworks I’d put Triton in front regardless of engine choice.”
Trap 3: “We’ll just use HPA on CPU utilization like any other service.”
Why it’s wrong: CPU utilization is decoupled from GPU/KV-cache load for LLM serving pods (AS1) — this is one of the fastest ways to reveal you haven’t actually run GPU workloads in Kubernetes.
Say it. “For GPU-bound serving I’d scale on a custom metric — queue depth or GPU/KV-cache utilization via the Prometheus Adapter or KEDA — CPU utilization on these pods is nearly meaningless.”
Trap 4: “Scale to zero when idle to save cost.”
Why it’s wrong: ignores GPU cold-start reality (minutes, not seconds) — the first request after scale-to-zero will badly violate almost any latency SLO.
Say it. “For latency-sensitive paths I’d keep a warm minimum and rely on predictive/scheduled scaling for known low-traffic windows instead — true scale-to-zero only for genuinely latency-insensitive batch workloads.”
Trap 5: “Canary just means routing 10% of traffic to the new version and watching error rate.”
Why it’s wrong: misses that LLM canaries need output-quality signals (CD2) — a model can be infra-healthy and behaviorally regressed simultaneously, which pure error-rate/latency monitoring won’t catch.
Say it. “Infra metrics are necessary but not sufficient — I’d add an automated quality-eval sampling step on canary outputs (guardrail-trigger rate, structured-output validity, an LLM-as-judge score) before promoting.”
Trap 6: “Just quantize to INT4 everywhere for cost savings — precision doesn’t really matter for chat.”
Why it’s wrong: asserts a blanket accuracy claim without task-specific validation; INT4 accuracy risk varies a lot by task (reasoning-heavy tasks degrade more than casual chat), and “doesn’t matter” is exactly the unvalidated assumption that gets someone burned in production.
Say it. “I’d start from FP8 as the likely sweet spot on current hardware, and only push to INT4 after validating on our actual eval suite for our actual task mix — not assume it’s fine.”
Trap 7: “Model drift means we should just retrain automatically whenever drift is detected.”
Why it’s wrong: ignores feedback-loop risk (DR3) — automatic retraining on drifted/production data without human review can amplify the very drift it’s reacting to.
Say it. “I’d have drift detection trigger an alert and investigation, with retraining as a human-gated decision informed by that investigation — not a fully automatic pipeline straight to production.”
Trap 8: “Kubernetes handles GPU sharing out of the box, just request nvidia.com/gpu: 0.5.”
Why it’s wrong: factually incorrect — GPUs are an integer-only extended resource without MIG/time-slicing/MPS/DRA configured; a fractional request simply won’t work (K1, K3).
Say it. “GPU requests are whole-unit by default — to share one GPU across pods I’d configure MIG for hard isolation, or time-slicing/MPS for looser sharing, potentially orchestrated via DRA.”
Trap 9: “We don’t need a model registry, we’ll just tag Docker images with the model version.”
Why it’s wrong: conflates code-release cadence with model-release cadence, and loses model-specific metadata (eval scores, lineage, approval status) a registry provides natively (MV7).
Say it. “For infrequent updates on one model, tagged images might be enough — but once we have frequent fine-tunes or multiple models needing independent rollback, I’d want an actual registry decoupled from the application image.”
Trap 10: “PSI/KL-divergence tests are all we need for drift detection on an LLM.”
Why it’s wrong: these classical statistical tests need a numeric feature to compare and say nothing about output quality directly (DR7) — a real blind spot for generative systems.
Say it. “Statistical tests on derived features (embeddings, length) are useful for continuous monitoring, but I’d pair them with output-quality-eval sampling — that’s the ground-truth signal the statistical tests are only ever a proxy for.”
Trap 11: “Just cache all the responses to save GPU cost.”
Why it’s wrong: treats response caching as a general LLM cost solution when it only helps for repeated/identical content — most production traffic (unique user prompts) won’t hit a response cache at all; the real high-leverage cache is prefix/KV-cache reuse of shared context (V6), not full-response caching.
Say it. “Full-response caching only helps for genuinely repeated queries. The bigger lever is prefix caching — reusing KV cache for shared system prompts or RAG context — which helps a much larger share of realistic traffic.”
Trap 12: “Zero downtime means using Kubernetes’ rolling update strategy for model version swaps.”
Why it’s wrong: conflates a Deployment-level rolling update (for code/image changes) with a canary/traffic-weighted rollout (for model version changes, Scenario 3) — a rolling update replaces pods, it doesn’t give you a controlled, metric-gated traffic ramp or an instant rollback via traffic-weight change.
Say it. “For a model version change specifically, I’d use a canary pipeline with traffic-weight shifting rather than a pod-replacement rolling update — that gives instant rollback and gradual, metric-gated exposure, which a rolling update doesn’t.”
Trap 13: “More replicas is always better for availability.”
Why it’s wrong: ignores that each GPU replica is expensive and that availability comes from the right redundancy (spread across zones/nodes, N+1 for failure tolerance) not raw count — over-provisioning replicas “for availability” without a specific failure scenario in mind is just wasted spend.
Say it. “I’d size replica count from load-test-derived capacity plus explicit failure-tolerance headroom (N+1, spread across zones) — not an arbitrary ‘more is safer’ buffer.”
Trap 14: “Streaming and batching are in tension, so pick one.”
Why it’s wrong: conflates continuous batching (an engine-internal scheduling technique) with streaming (a client-facing response-delivery mechanism) — they’re not in tension; every major serving engine streams tokens from a continuously-batched decode loop simultaneously for many requests.
Say it. “They operate at different layers — continuous batching is how the engine schedules GPU work across concurrent requests; streaming is how each request’s tokens are delivered to its client as they’re produced. Production systems do both together.”
Red Flags vs. Green Flags — Master Table
| Topic | Red flag answer | Green flag answer |
|---|---|---|
| Latency problem | “Add more GPUs.” | “Check GPU/KV-cache utilization and batching config first; add GPUs only if already utilization-bound.” |
| Autoscaling signal | “Scale on CPU like any service.” | “Scale on queue depth / GPU-KV-cache utilization via KEDA or the Prometheus Adapter.” |
| Scale-to-zero | “Always scale to zero when idle.” | “Keep a warm minimum for latency-sensitive paths; scale-to-zero only for batch/insensitive workloads.” |
| Canary success criteria | “Error rate and latency look fine, ship it.” | “Also check output-quality/guardrail signals before promoting.” |
| Quantization | “INT4 everywhere, precision doesn’t matter.” | “Start from FP8, validate task-specific quality before going further.” |
| Drift response | “Auto-retrain the moment drift is detected.” | “Alert + investigate, human-gated retraining decision.” |
| GPU sharing | “Request 0.5 GPU.” | “Configure MIG/time-slicing/MPS/DRA explicitly — GPUs are integer-only by default.” |
| Model release process | “Just tag a Docker image per model version.” | “Weigh registry vs. tagged-images trade-off based on update frequency and rollback needs.” |
| Cost optimization | “Cache all responses.” | “Prioritize prefix/KV-cache reuse, quantization, and utilization — full-response caching only helps repeated queries.” |
| Model version rollout | “Rolling update the Deployment.” | “Traffic-weighted canary rollout with sticky sessions and a fast-rollback warm window.” |
| Availability | “More replicas, always.” | “Size from load-test capacity plus explicit N+1/zone-spread failure tolerance.” |
| Streaming vs. batching | “Pick one.” | “They’re orthogonal — continuous batching (engine) and streaming (client delivery) work together.” |
| Ecosystem knowledge | Asserts exact version numbers/benchmarks with false confidence. | Names current tools/trends correctly, flags fast-moving specifics as “verify before quoting.” |
| Drift detection method | “PSI/KS on raw text.” | “Convert to numeric proxies (embeddings, length) first; pair with output-quality-eval sampling.” |
| GPU generation choice | “Biggest GPU count always wins.” | “Right-size TP degree and quantization against actual concurrency/memory need.” |
Tips for Interviews
- Be specific: use numbers and examples.
- Show trade-offs: understand pros/cons, and say them out loud even when not asked.
- Think system-wide: consider all components, not just the one the question named.
- Ask clarifying questions: understand requirements (scale, SLO, budget) before designing.
- Draw diagrams: visualize architecture — even a rough ASCII sketch on a whiteboard shows structured thinking.
- Discuss monitoring: always mention observability — a design without it is incomplete to a senior interviewer.
- Talk about failures: how to handle edge cases, degradation, and rollback, not just the happy path.
- Flag what’s fast-moving: for current tool/version/benchmark facts, it’s a stronger answer to say “verify the exact number, but the shape is X” than to assert a specific figure with false confidence.
- Name the trade-off explicitly, don’t just pick a side: “I’d default to X, but Y is the better choice if Z” reads as more senior than a flat “always use X.”
Resources
- This repository’s chapter READMEs and deep-dive docs (
01_basic_servingthrough11_triton). - vLLM documentation and blog — engine internals, release notes, and architecture posts (e.g., the V1 engine and “Inside vLLM” posts).
- Kubernetes documentation — see the GPU/device-plugin and Dynamic Resource Allocation pages for current GPU scheduling capabilities.
- NVIDIA Triton Inference Server documentation and GitHub repo.
- NVIDIA developer documentation on NIM and the GPU Operator/MIG/DRA integration docs.
- Prometheus/Grafana guides — for the golden-signals and SLO-burn-rate alerting patterns referenced throughout Ch. 08.
- Evidently AI documentation — drift detection tooling referenced in Ch. 10.
- LMCache documentation — KV-cache offloading/sharing referenced in the Landscape Quiz.
- Argo Rollouts / Flagger documentation — automated canary analysis and promotion referenced in Ch. 07.
A note on currency: the 2025-2026 Landscape Quiz section captures the state of the ecosystem as researched in August 2026. Inference engines, GPU generations, and Kubernetes GPU-scheduling features move fast — before an interview, spend 15 minutes checking the current release notes of whichever engine/platform the job description mentions by name.
Good luck with your interviews!
Exercise Solutions
This document provides solutions to exercises in each learning topic.
Topic 1: Basic Model Serving
Exercise 1: Change the Model
Solution:
# In model_loader.py, change:
model = LLMModel(
model_name="distilgpt2", # Smaller, faster model
device="cpu"
)
Exercise 2: Modify Parameters
Solution:
# In app.py, test different temperatures:
# Temperature 0.1 = more deterministic
# Temperature 2.0 = more creative
payload = {
"prompt": "The future of AI is",
"temperature": 0.1, # Try different values
"max_length": 100
}
Exercise 3: Add Logging
Solution:
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
start_time = time.time()
logger.info(f"Request received: prompt='{request.prompt[:50]}...'")
# ... generation code ...
latency = (time.time() - start_time) * 1000
logger.info(f"Request completed: latency={latency:.2f}ms, tokens={num_tokens}")
return response
Exercise 4: Error Handling
Solution:
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
try:
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
if len(request.prompt) > 1000:
raise HTTPException(status_code=400, detail="Prompt too long")
# Generation code...
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
Exercise 5: Batch Requests
Solution:
class BatchGenerationRequest(BaseModel):
prompts: List[str] = Field(..., min_items=1, max_items=10)
@app.post("/generate_batch")
async def generate_batch(request: BatchGenerationRequest):
results = []
for prompt in request.prompts:
generated = model.generate(prompt=prompt, max_length=50)
results.append({"prompt": prompt, "generated": generated})
return {"results": results}
Topic 2: Docker
Exercise 1: Build Basic Image
Solution:
cd 01_basic_serving
docker build -f ../02_docker/Dockerfile.basic -t llm-serving:basic .
docker run -p 8000:8000 llm-serving:basic
Exercise 2: Optimize Image Size
Solution:
# Use multi-stage build
FROM python:3.9-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.9-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Exercise 3: Multi-stage Build
Solution: See 02_docker/Dockerfile.optimized
Exercise 4: GPU Support
Solution: See 02_docker/Dockerfile.gpu
Exercise 5: Docker Compose
Solution: See 02_docker/docker-compose.yml
Topic 3: Kubernetes
Exercise 1: Basic Deployment
Solution:
kubectl apply -f 03_kubernetes/deployment.yaml
kubectl get pods -n llm-serving
kubectl logs -f deployment/llm-serving -n llm-serving
Exercise 2: Health Checks
Solution:
# Already in deployment.yaml
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
Exercise 3: Resource Limits
Solution:
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
Exercise 4: ConfigMap
Solution:
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
data:
model_name: "gpt2"
max_length: "100"
---
env:
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
Exercise 5: Scaling
Solution:
kubectl scale deployment llm-serving --replicas=3 -n llm-serving
kubectl get pods -n llm-serving
Exercise 6: Rolling Update
Solution:
# Update image
kubectl set image deployment/llm-serving \
llm-serving=llm-serving:v2 -n llm-serving
# Watch rollout
kubectl rollout status deployment/llm-serving -n llm-serving
# Rollback if needed
kubectl rollout undo deployment/llm-serving -n llm-serving
Topic 4: Load Testing
Exercise 1: Baseline Test
Solution:
python measure_latency.py --url http://localhost:8000 --requests 10
Exercise 2: Ramp-up Test
Solution:
# In Locust UI, use "Spawn rate" to gradually increase users
# Or use command line:
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 50 \
--spawn-rate 5 \
--run-time 5m
Exercise 3: Compare Models
Solution:
# Test model A
python measure_latency.py --url http://localhost:8000 --requests 100 > model_a.txt
# Change model, restart server
# Test model B
python measure_latency.py --url http://localhost:8001 --requests 100 > model_b.txt
# Compare results
diff model_a.txt model_b.txt
Exercise 4: Find Bottleneck
Solution:
# Add profiling to app.py
import cProfile
import pstats
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
profiler = cProfile.Profile()
profiler.enable()
# ... generation code ...
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats(10) # Top 10 slowest functions
Exercise 5: Stress Test
Solution:
# Keep increasing users until system breaks
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 100 \
--spawn-rate 10 \
--run-time 10m
Topic 5: vLLM Serving
Exercise 1: Compare Performance
Solution:
# Test HuggingFace
import time
start = time.time()
# ... HuggingFace generation ...
hf_time = time.time() - start
# Test vLLM
start = time.time()
# ... vLLM generation ...
vllm_time = time.time() - start
print(f"HuggingFace: {hf_time:.2f}s")
print(f"vLLM: {vllm_time:.2f}s")
print(f"Speedup: {hf_time/vllm_time:.2f}x")
Exercise 2: Tune Parameters
Solution:
# Test different GPU memory utilization
for util in [0.7, 0.8, 0.9, 0.95]:
llm = LLM(
model="gpt2",
gpu_memory_utilization=util
)
# Measure throughput
# Find optimal value
Exercise 3: Test Continuous Batching
Solution:
# Send requests at different rates
import asyncio
async def send_requests(rate_per_second=10):
for i in range(100):
# Send request
await asyncio.sleep(1/rate_per_second)
# Monitor GPU utilization
Exercise 4: Monitor Metrics
Solution:
# Add Prometheus metrics to vLLM server
from prometheus_client import Counter, Histogram
REQUEST_COUNT = Counter('vllm_requests_total', 'Total requests')
REQUEST_DURATION = Histogram('vllm_request_duration_seconds', 'Request duration')
@app.post("/complete")
async def complete(request: CompletionRequest):
REQUEST_COUNT.inc()
with REQUEST_DURATION.time():
# ... generation ...
pass
Topic 6: Autoscaling
Exercise 1: Basic HPA
Solution:
kubectl apply -f 06_autoscaling/hpa.yaml
kubectl get hpa -n llm-serving
kubectl describe hpa llm-serving-hpa -n llm-serving
Exercise 2: Custom Metrics
Solution:
# Requires Prometheus Adapter
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "10"
Exercise 3: Scaling Policies
Solution:
behavior:
scaleUp:
policies:
- type: Percent
value: 50 # Increase by 50%
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min
policies:
- type: Percent
value: 25 # Decrease by 25%
Exercise 4: Load Test
Solution:
# Terminal 1: Watch HPA
kubectl get hpa -w -n llm-serving
# Terminal 2: Generate load
locust -f locust_test.py --host=http://<service-url> --users 50
# Terminal 3: Watch pods
kubectl get pods -w -n llm-serving
Topic 7: Canary Deployments
Exercise 1: Basic Canary
Solution:
kubectl apply -f 07_canary_deployments/canary-deployment.yaml
# Traffic splits 90/10 based on replica count
Exercise 2: Gradual Rollout
Solution:
# Phase 1: 10% (already done)
# Phase 2: 25%
kubectl scale deployment llm-serving-canary --replicas=3 -n llm-serving
kubectl scale deployment llm-serving-stable --replicas=7 -n llm-serving
# Phase 3: 50%
kubectl scale deployment llm-serving-canary --replicas=5 -n llm-serving
kubectl scale deployment llm-serving-stable --replicas=5 -n llm-serving
# Phase 4: 100%
kubectl scale deployment llm-serving-canary --replicas=10 -n llm-serving
kubectl delete deployment llm-serving-stable -n llm-serving
Exercise 3: Monitoring
Solution:
# Compare latency
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="canary"}[5m])
) /
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="stable"}[5m])
)
Exercise 4: Rollback
Solution:
# Scale canary to 0
kubectl scale deployment llm-serving-canary --replicas=0 -n llm-serving
# Or update service to only route to stable
Topic 8: Monitoring
Exercise 1: Set Up Monitoring
Solution:
cd 08_monitoring
docker-compose up -d
# Access Grafana at http://localhost:3000
Exercise 2: Create Dashboard
Solution:
- Open Grafana
- Add Prometheus data source
- Create new dashboard
- Add panels with queries from
08_monitoring/README.md
Exercise 3: Set Up Alerts
Solution:
# In Prometheus alerts.yml
groups:
- name: llm_alerts
rules:
- alert: HighLatency
expr: histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
for: 5m
annotations:
summary: "P95 latency > 1s"
Exercise 4: GPU Monitoring
Solution:
# Use prometheus_exporter.py
# GPU metrics are automatically collected if pynvml is installed
Topic 9: Canary Deployments
(Same as Topic 7 - see above)
Topic 10: Model Versioning
Exercise 1: Create Registry
Solution:
# models/registry.py
class ModelRegistry:
def __init__(self, base_path="models"):
self.base_path = base_path
def register(self, version, model_path, metadata):
version_dir = f"{self.base_path}/{version}"
os.makedirs(version_dir, exist_ok=True)
# Copy model files
# Save metadata
Exercise 2: Version Model
Solution:
# Tag model version
mkdir -p models/v1.0.0
cp model.bin models/v1.0.0/
cp tokenizer.json models/v1.0.0/
echo '{"version": "v1.0.0", "created_at": "2024-01-15"}' > models/v1.0.0/metadata.json
Exercise 3: Deploy Version
Solution:
# Update deployment
env:
- name: MODEL_VERSION
value: "v1.0.0"
- name: MODEL_PATH
value: "/models/v1.0.0"
Exercise 4: Rollback
Solution:
# Update to previous version
kubectl set env deployment/llm-serving \
MODEL_VERSION=v0.9.0 \
-n llm-serving
Topic 11: Drift Detection
Exercise 1: Set Up Evidently
Solution:
pip install evidently pandas
Exercise 2: Detect Data Drift
Solution:
from drift_detector import DriftDetector
import pandas as pd
# Load reference data
reference = pd.read_csv("train_data.csv")
# Initialize detector
detector = DriftDetector(reference_data=reference)
# Load current data
current = pd.read_csv("production_data.csv")
# Detect drift
result = detector.detect_data_drift(current_data=current)
print(result)
Exercise 3: Create Dashboard
Solution:
from evidently.ui.dashboards import Dashboard
report = detector.generate_report(current_data)
dashboard = Dashboard("Drift Monitoring")
dashboard.add_report(report)
dashboard.show()
Exercise 4: Set Up Alerts
Solution:
if result["drift_detected"]:
send_email_alert("Data drift detected!")
send_slack_alert("Drift score: {result['drift_score']}")
Topic 12: Triton
Exercise 1: Deploy Model
Solution:
# Create model repository
mkdir -p model_repository/gpt2/1
# Copy model files
cp model.pt model_repository/gpt2/1/
# Start Triton
docker run --gpus all \
-v $(pwd)/model_repository:/models \
nvcr.io/nvidia/tritonserver:23.10-py3 \
tritonserver --model-repository=/models
Exercise 2: Multiple Models
Solution:
model_repository/
gpt2/
config.pbtxt
1/model.pt
distilgpt2/
config.pbtxt
1/model.pt
Exercise 3: Dynamic Batching
Solution:
dynamic_batching {
max_queue_delay_microseconds: 100000
preferred_batch_size: [ 4, 8, 16 ]
max_batch_size: 32
}
Exercise 4: Model Ensemble
Solution: See 12_triton/README.md for ensemble configuration
General Tips
- Test locally first: Always test changes locally before deploying
- Monitor metrics: Watch metrics during exercises
- Read errors: Error messages often contain solutions
- Experiment: Try different values and see what happens
- Document: Note what works and what doesn’t
Topic 1: Basic LLM Serving
What You’ll Learn
This topic teaches you the fundamentals of serving an LLM:
- How to load a pre-trained model from HuggingFace
- Building a FastAPI endpoint for inference
- Understanding the inference pipeline
- Handling requests and responses
- Basic error handling
Why We Need This
Business Need
Companies need to expose LLM capabilities as APIs to:
- Integrate AI into applications: Chatbots, content generation, code completion
- Serve multiple clients: Web apps, mobile apps, internal tools
- Scale independently: Separate model serving from application logic
- Enable monetization: API-based business models
Technical Need
- Separation of concerns: Model serving separate from application code
- Resource management: Dedicated servers for compute-intensive inference
- Standardization: REST APIs are universal, language-agnostic
- Testing: Easy to test model independently
Real-World Impact
Without proper serving infrastructure:
- ❌ Models can’t be used in production applications
- ❌ No way to integrate AI into existing systems
- ❌ Difficult to scale and maintain
- ❌ Hard to version and update models
Industry Use Cases
1. Customer Support Chatbots
Company: E-commerce, SaaS platforms Use Case:
- Customer asks question → API call → LLM generates response
- Handles 24/7 support, reduces human agent workload
Example Request:
POST /generate
{
"prompt": "Customer: How do I return an item?\nAssistant:",
"max_length": 100
}
2. Content Generation
Company: Marketing agencies, content platforms Use Case:
- Generate blog posts, social media content, product descriptions
- API called from CMS or marketing tools
Example Request:
POST /generate
{
"prompt": "Write a product description for a wireless headphone:",
"temperature": 0.8, # More creative
"max_length": 200
}
3. Code Completion & Assistance
Company: GitHub Copilot, IDEs Use Case:
- Developer types code → API suggests completions
- Real-time code generation in editor
Example Request:
POST /generate
{
"prompt": "def calculate_total(items):\n ",
"max_length": 50,
"temperature": 0.2 # More deterministic
}
4. Translation Services
Company: Google Translate, DeepL Use Case:
- Translate text between languages
- API integrated into websites, apps
Example Request:
POST /generate
{
"prompt": "Translate to French: Hello, how are you?",
"max_length": 50
}
5. Sentiment Analysis
Company: Social media platforms, review sites Use Case:
- Analyze customer reviews, social media posts
- Real-time sentiment detection
Example Request:
POST /generate
{
"prompt": "Sentiment: This product is amazing!",
"max_length": 10
}
Industry-Standard Boilerplate Code
Complete FastAPI Serving Application
"""
Industry-standard LLM serving application
Used by: OpenAI API, Anthropic Claude, HuggingFace Inference API
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
import time
import logging
from contextlib import asynccontextmanager
# Configure logging (industry standard)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Global model (loaded once at startup)
model = None
tokenizer = None
device = "cuda" if torch.cuda.is_available() else "cpu"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load model at startup, cleanup at shutdown"""
global model, tokenizer
logger.info(f"Loading model on {device}...")
model_name = "gpt2" # In production: from environment variable
try:
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
model.to(device)
model.eval() # Set to evaluation mode
logger.info("Model loaded successfully")
except Exception as e:
logger.error(f"Failed to load model: {e}")
raise
yield # Application runs here
# Cleanup
model = None
tokenizer = None
logger.info("Model unloaded")
app = FastAPI(
title="LLM Serving API",
description="Production-ready LLM inference endpoint",
version="1.0.0",
lifespan=lifespan
)
# Request/Response Models (Industry standard structure)
class GenerationRequest(BaseModel):
"""Standard request format (similar to OpenAI API)"""
prompt: str = Field(..., min_length=1, max_length=2000)
max_tokens: int = Field(50, ge=1, le=500)
temperature: float = Field(1.0, ge=0.0, le=2.0)
top_p: float = Field(1.0, ge=0.0, le=1.0)
stop: list[str] = Field(default_factory=list)
class GenerationResponse(BaseModel):
"""Standard response format"""
text: str
model: str
usage: dict
latency_ms: float
@app.get("/health")
async def health_check():
"""Health check for load balancers and K8s probes"""
return {
"status": "healthy" if model is not None else "unhealthy",
"model_loaded": model is not None
}
@app.post("/v1/completions", response_model=GenerationResponse)
async def generate_completion(request: GenerationRequest):
"""
Main generation endpoint
Industry standard: /v1/completions (OpenAI-compatible)
"""
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
start_time = time.time()
try:
# Tokenize
inputs = tokenizer(
request.prompt,
return_tensors="pt",
truncation=True,
max_length=1024
).to(device)
# Generate
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=request.max_tokens,
temperature=request.temperature,
top_p=request.top_p,
do_sample=True,
pad_token_id=tokenizer.eos_token_id
)
# Decode
generated_text = tokenizer.decode(
outputs[0][inputs['input_ids'].shape[1]:],
skip_special_tokens=True
)
# Calculate metrics
latency_ms = (time.time() - start_time) * 1000
num_tokens = len(outputs[0]) - inputs['input_ids'].shape[1]
logger.info(
f"Generated {num_tokens} tokens in {latency_ms:.2f}ms"
)
return GenerationResponse(
text=generated_text,
model="gpt2",
usage={
"prompt_tokens": inputs['input_ids'].shape[1],
"completion_tokens": num_tokens,
"total_tokens": len(outputs[0])
},
latency_ms=latency_ms
)
except Exception as e:
logger.error(f"Generation error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info"
)
Usage Example (Industry Standard)
# Client code (how applications use the API)
import requests
def generate_text(prompt: str, max_tokens: int = 50) -> str:
"""
Call LLM serving API
Used by: Web applications, mobile apps, microservices
"""
response = requests.post(
"http://llm-api:8000/v1/completions",
json={
"prompt": prompt,
"max_tokens": max_tokens,
"temperature": 0.7
},
timeout=30
)
response.raise_for_status()
return response.json()["text"]
# Example: Customer support chatbot
def handle_customer_query(query: str) -> str:
prompt = f"Customer: {query}\nAssistant:"
return generate_text(prompt, max_tokens=100)
# Example: Content generation
def generate_product_description(product_name: str) -> str:
prompt = f"Write a compelling product description for {product_name}:"
return generate_text(prompt, max_tokens=200, temperature=0.8)
Concepts Explained
Model Loading
When you load a model, you’re:
- Downloading weights (if not cached) - These are the learned parameters
- Loading into memory - CPU RAM or GPU VRAM
- Initializing tokenizer - Converts text ↔ tokens
- Setting up device - CPU or CUDA (GPU)
Why this matters: Model loading is expensive. You do it once at startup, not per request.
Tokenization
- Text → Tokens: “Hello” → [15496]
- Tokens → Text: [15496] → “Hello”
- Special tokens:
<BOS>,<EOS>,<PAD>,<UNK>
Inference Process
- Tokenize input prompt
- Run forward pass through model (autoregressive)
- Sample next token
- Repeat until max_length or stop token
- Decode tokens back to text
Autoregressive Generation
LLMs generate one token at a time:
- Input: “The weather is”
- Step 1: Generate “nice”
- Step 2: Generate “today”
- Step 3: Generate “.”
- Output: “The weather is nice today.”
Each step uses the previous tokens as context.
Code Structure
01_basic_serving/
├── README.md # This file
├── app.py # FastAPI application
├── model_loader.py # Model loading logic
├── requirements.txt # Dependencies
└── test_api.py # Simple test script
Running the Code
1. Install Dependencies
pip install -r requirements.txt
2. Start the Server
python app.py
3. Test the API
# Health check
curl http://localhost:8000/health
# Generate text
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "The future of AI is",
"max_length": 50,
"temperature": 0.7
}'
4. View API Docs
Open http://localhost:8000/docs in your browser
Key Code Sections
Model Loading (model_loader.py)
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")
# Move to device (CPU or GPU)
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
Generation (app.py)
# Tokenize input
inputs = tokenizer(prompt, return_tensors="pt").to(device)
# Generate
outputs = model.generate(
**inputs,
max_length=max_length,
temperature=temperature
)
# Decode
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
Understanding the Output
When you call the API, you get:
generated_text: The complete generated textprompt: Your input (echoed back)num_tokens: Number of tokens generatedlatency_ms: Time taken in millisecondsmodel_name: Which model was used
Common Issues
Out of Memory
- Problem: Model too large for available RAM/VRAM
- Solution: Use a smaller model (gpt2-small) or reduce batch size
Slow Inference
- Problem: Running on CPU
- Solution: Use GPU if available, or use a smaller model
Import Errors
- Problem: Missing dependencies
- Solution:
pip install -r requirements.txt
Exercises
- Change the model: Try different HuggingFace models (gpt2, distilgpt2, etc.)
- Modify parameters: Experiment with temperature, top_p, max_length
- Add logging: Log every request with timing information
- Error handling: Add try/except for different error cases
- Batch requests: Modify to handle multiple prompts at once
Next Steps
Once you understand this, move to:
- Topic 2: Containerization with Docker
- Topic 3: Kubernetes deployment
Further Reading
Basic LLM Serving — A Deep Dive
Standing up a language model behind an HTTP API from first principles, and understanding why the naive version is slow — then hardening it into something you could actually put in front of traffic, and defending every decision in an interview.
Why this matters
Every production LLM system — ChatGPT, a support bot, a code assistant — is at bottom a loop that turns text into tokens, runs a forward pass, and turns tokens back into text, wrapped in a network server. If you understand that loop end to end, and understand the two costs that dominate it (compute and GPU memory), the rest of this book is just engineering to make the loop cheaper and more concurrent.
This chapter builds the loop the obvious way: one model, one process, one request at a time. That version works, and it is a perfect teaching tool precisely because it is slow. By the end you will be able to say, with numbers, exactly where the time and the memory go — and that motivates batching, KV-cache management, and dedicated engines (vLLM, TGI, Triton) in the chapters that follow.
We then go further than “it works on my laptop”: we add concurrency limits, structured error handling, and health/readiness endpoints, load-test the result, and walk through two real incidents that this kind of naive-but-hardened server either causes or prevents. By the end of this chapter you should be able to build a small serving stack yourself and survive a senior interviewer asking “walk me through what happens when this GPU gets 50 requests per second.”
We keep the intuition first and the mechanism precise. Where there is a tradeoff, we name it honestly.
How to use this chapter. Read “Core intuition” through “Failure modes” in order the first time — it is one continuous argument from the autoregressive loop to why naive serving breaks. “Production case studies,” “The 2025–2026 landscape,” and “Interview mastery” are meant to be revisited independently: before a design review, before an interview, or after an incident, to re-anchor on the mechanism that explains it.
Saying it out loud. So every LLM product you’ve ever used is, underneath, the same small loop: text goes in, gets chopped into tokens, the model runs a forward pass, and a token comes back out — over and over until it decides to stop. Everything that makes serving hard is just that loop being expensive in two specific ways: it burns GPU compute, and it eats GPU memory. If you can say where the time goes and where the bytes go, you can explain batching, KV caching, and why anybody bothers with vLLM. The reason I’d build the naive version first is that it’s slow in a diagnosable way — you can point at the exact millisecond and the exact gigabyte, and that’s what turns “vLLM is faster” into an argument instead of a slogan.
Core intuition: an LLM is an autoregressive next-token loop
A decoder-only transformer computes one thing: given a sequence of tokens, a probability distribution over the next token. Generation is just calling that repeatedly.
prompt: "The capital of France is"
-> tokenizer -> [464, 3139, 286, 4881, 318]
-> model -> logits over ~50k vocab -> pick "Paris" (token 6342)
-> append -> [464, 3139, 286, 4881, 318, 6342]
-> model -> pick "." -> append -> ...
-> stop on EOS or max_new_tokens
-> tokenizer.decode(...) -> " Paris."
Two things follow immediately, and they structure everything:
- Generation is sequential. Token N+1 depends on token N. You cannot decode a 200-token answer in one shot; you do (at least) 200 forward passes. This is why latency scales with output length.
- The model re-reads its own context every step — unless you cache. The naive loop re-processes the whole sequence on every token, which is quadratic waste. The fix is the KV cache (below), and the KV cache is what eats your GPU memory.
Hold those two facts. The whole performance story is a consequence of them.
Saying it out loud. The one thing to internalize is that a language model only ever predicts the next token — generation is just calling that in a loop and feeding the output back in. Two consequences fall straight out. First, it’s inherently sequential: a 200-token answer means at least 200 forward passes, so your latency scales with how long the answer is, not how clever the model is. Second, without a cache the model re-reads its entire context on every single step, which is quadratic waste — and the fix for that, the KV cache, is exactly what ends up eating your GPU memory. So the two costs that dominate serving, latency and memory, both fall out of that one sentence about next-token prediction.
Loading a model: weights, dtype, device, tokenizer
Before serving anything you load four things. Each has a failure mode.
Saying it out loud. Loading a model looks like one line of code, but there are really four decisions in it and each one has a way of biting you. You’re picking which weights, at what numeric precision, on what device, with which tokenizer. The precision choice is the biggest single memory lever you’ll ever pull — BF16 instead of FP32 literally halves your weight footprint. And the tokenizer is the sneaky one, because a mismatched tokenizer or a skipped chat template doesn’t throw an error, it just quietly produces fluent garbage. The failure mode I’d name is forgetting
torch_dtype: you silently load in FP32, double your weights, and OOM on a model that would have fit fine.
Weights and where they live
A model is a set of tensors (the parameters) plus a config describing the architecture. With Hugging Face Transformers:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-1B-Instruct",
torch_dtype="bfloat16", # precision — see below
device_map="cuda", # placement
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
The weights download once to a local cache and are memory-mapped from safetensors shards on subsequent loads. Cold start = download + load + allocate; warm start = load + allocate. This distinction matters for autoscaling (Chapter 6): a cold pod may take minutes.
safetensors is not an incidental detail — it is the format almost every serious model ships in today, and it is worth knowing why (we return to this with dates and sources in “The 2025–2026 landscape” below): the older PyTorch default, pickle (.bin/.pt checkpoints), can execute arbitrary code on load because pickle.load deserializes by calling constructors named in the file — a well-known supply-chain attack vector for downloaded model weights. safetensors stores only raw tensor bytes plus a JSON header of shapes/dtypes, so loading it can never execute code, and because it is a flat memory-mappable layout, loading is also faster (mmap + lazy paging instead of unpickling). If you see pytorch_model.bin instead of model.safetensors in a repo today, treat it as a legacy artifact.
dtype / precision — the single biggest memory lever
Parameters are stored as floating-point numbers, and the bytes per parameter is a choice you make at load time.
| dtype | bytes/param | Typical use |
|---|---|---|
| FP32 | 4 | Rarely for inference; training reference |
| FP16 / BF16 | 2 | Standard inference precision on GPU |
| INT8 | 1 | Quantized inference (small quality hit) |
| INT4 / NF4 | ~0.5 | Aggressive quantization, edge/consumer GPUs |
BF16 (bfloat16) is usually preferred over FP16 on modern GPUs: same 2 bytes, but a wider exponent range, so it is less prone to overflow/NaN during the forward pass. FP32 doubles your memory for almost no inference quality gain — do not serve in FP32 by accident (it is the default if you forget torch_dtype).
Saying it out loud. Precision is just how many bytes you spend storing each parameter, and it maps directly to gigabytes on the card. FP32 is four bytes, FP16 and BF16 are two, INT8 is one, INT4 is about a half — so a 7-billion-parameter model is 28 GB, 14 GB, 7 GB, or 3.5 GB depending purely on that one choice. BF16 is the modern default over FP16 because it costs the same two bytes but has a wider exponent range, so you’re far less likely to hit overflow or NaNs mid forward pass. The tradeoff to name: dropping to INT8 or INT4 buys you memory and concurrency, but it’s a quality hit you have to measure on your own eval set — it’s never free, and “quantization is lossless” is the wrong answer.
Device placement
Weights must sit in GPU memory (VRAM) for fast inference. device_map="cuda" puts everything on one GPU; device_map="auto" will shard across multiple GPUs or spill to CPU/disk if the model does not fit — convenient, but CPU offload is catastrophically slow for serving. For a serving path you want the whole model resident on the GPU and you want to know it fits (memory math below).
Tokenizer — small, and a classic source of silent bugs
The tokenizer maps text <-> integer IDs. It must be the exact one the model was trained with; a mismatch produces garbage output with no error. Two things to get right in a server:
- Chat template. Instruct/chat models expect a specific formatting of roles (
<|user|>,<|assistant|>, etc.). Usetokenizer.apply_chat_template(messages, add_generation_prompt=True)rather than hand-concatenating strings — getting the special tokens wrong quietly degrades quality. - Padding side and pad token. For batched generation, decoder-only models must left-pad (
tokenizer.padding_side = "left"), and many models ship without apad_token— settokenizer.pad_token = tokenizer.eos_token. Right-padding a decoder batch corrupts the generation. (We revisit padding when we build real batching.)
Saying it out loud. The tokenizer is the boring part that causes the scariest bugs, because when it’s wrong nothing crashes — the model just gets nonsense and confidently answers it. Two things I’d check every time. One, use the model’s own chat template rather than gluing role strings together by hand, because instruct models are trained on very specific special tokens and getting them subtly wrong degrades quality invisibly. Two, for batched generation on a decoder-only model you must left-pad, and lots of models ship without a pad token so you set it to the EOS token. The named failure mode is right-padding a decoder batch: no error, no warning, just quietly corrupted output for every request in the batch.
Mechanism in depth: prefill vs decode, and the KV cache
This is the heart of the chapter. A single generation request has two phases with completely different performance characteristics.
Saying it out loud. If there’s one thing worth saying unprompted in an interview, it’s that a request has two phases with completely different bottlenecks, not one. Prefill runs your whole prompt through the model in a single parallel pass — that’s compute-bound, and it’s what sets time-to-first-token. Then decode generates one token at a time, and each of those tiny steps still has to drag the entire model weights and the whole growing KV cache across GPU memory, so decode is memory-bandwidth-bound. That asymmetry is the reason your GPU can sit at low utilization while your latency is terrible, and it’s why the fix is batching rather than a bigger card.
Prefill (the prompt pass)
You feed the entire prompt (say 500 tokens) through the model in one forward pass. Because all prompt tokens are known up front, they are processed in parallel — the GPU does one big matrix-multiply-heavy pass over all 500 positions at once. This is compute-bound: it saturates the GPU’s arithmetic units. Prefill is what you pay for time-to-first-token (TTFT), and its cost grows with prompt length.
During prefill the model computes, for every layer and every attention head, a key (K) and value (V) vector for each prompt token, and stores them — that is the KV cache.
Decode (the generation loop)
Now you generate one token at a time. Each decode step feeds only the single newest token through the model. Its attention needs the K/V of all previous tokens — but those are already in the cache, so you do not recompute them. Each decode step is therefore tiny in arithmetic (one token’s worth of matmuls) but must read the entire KV cache and all model weights from GPU memory. Decode is memory-bandwidth-bound, not compute-bound: the GPU spends its time moving data, and its expensive tensor cores sit mostly idle.
This is the central asymmetry of LLM serving:
| Prefill | Decode | |
|---|---|---|
| Tokens processed per pass | whole prompt (parallel) | 1 |
| Bottleneck | compute (FLOPs) | memory bandwidth |
| Grows with | prompt length | output length |
| Determines | TTFT | TPOT / inter-token latency |
| GPU utilization | high | low (single request) |
The decode phase being memory-bound and low-utilization is exactly why one-request-at-a-time wastes the GPU, and exactly why batching helps: multiple requests can share the same weight read. Hold that thought for the tradeoff section.
Saying it out loud. Here’s the thing that surprises people: decode does almost no math. Each step only pushes one new token through the model, because every earlier token’s keys and values are already cached — so the arithmetic is trivial, but you still have to read all fourteen-plus gigabytes of weights plus the whole KV cache out of GPU memory to do it. That’s what “memory-bandwidth-bound” means: the tensor cores are idle, waiting on the memory bus. Practically, that’s why a single request wastes an H100 — you’re paying for roughly 989 teraflops of dense BF16 compute and using a sliver of it — and it’s exactly why batching works, because one memory read can serve every request in the batch at once.
Why the KV cache exists
Without a cache, generating token N would re-run attention over all N prior tokens from scratch — an (O(N^2)) blowup over a full sequence. The KV cache trades memory for compute: store each token’s K and V once, reuse them for every future step. It turns per-step attention cost from “re-read and recompute everything” into “read the cache.” The price is GPU memory that grows linearly with every token in every active request — which becomes the binding constraint on how many requests you can serve at once.
Saying it out loud. The KV cache is a straight memory-for-compute trade. Without it, generating token N means re-running attention over all N previous tokens from scratch, which is quadratic over a full sequence and completely wasteful, since those keys and values never change once computed. So you compute each token’s key and value once and keep them around. The catch is that the cache grows linearly with every token in every active request and it only ever grows during a request — so on a typical 7B model it’s about half a megabyte per token, meaning one 4K-context request is roughly 2 GB. That’s why concurrency in LLM serving is almost always capped by memory, not by FLOPs.
The 60-second version (say this in an interview)
If you only remember one paragraph, make it this one — it is the answer to “explain prefill and decode” under time pressure:
“A request has two phases. Prefill processes the whole prompt in one parallel forward pass — it’s compute-bound, and its cost sets time-to-first-token. Decode then generates one token at a time, each step only running the new token through the model because past tokens’ key/value vectors are cached — that’s the KV cache. Decode is memory-bandwidth-bound, not compute-bound, because each tiny step still has to read the full model weights and the whole growing KV cache off the GPU. That’s why decode is inefficient for a single request — the GPU’s compute sits idle waiting on memory — and it’s exactly why batching multiple requests’ decode steps together helps: one memory read now produces tokens for many requests at once. And it’s why KV cache memory, which grows every step, is usually the real limit on concurrency, not raw compute.”
That is roughly 60 seconds spoken aloud and it hits every point an interviewer is listening for: the two phases, their bottlenecks, what each determines (TTFT vs TPOT), why batching works, and why memory — not FLOPs — usually caps you.
Worked example 1: a minimal FastAPI + Transformers server
Here is a complete, correct, single-file server. It is deliberately naive — synchronous generation, one request at a time — so it exposes every pitfall we then discuss. This is the “before” picture for the whole book.
# server.py
# pip install fastapi "uvicorn[standard]" transformers torch accelerate
# uvicorn server:app --host 0.0.0.0 --port 8000 --workers 1
import time
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "meta-llama/Llama-3.2-1B-Instruct"
STATE = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load the model ONCE at startup, not per request.
tok = AutoTokenizer.from_pretrained(MODEL_ID)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.bfloat16, device_map="cuda"
)
model.eval()
STATE["tok"], STATE["model"] = tok, model
yield
STATE.clear()
app = FastAPI(lifespan=lifespan)
class GenRequest(BaseModel):
prompt: str
max_new_tokens: int = 128
temperature: float = 0.7
top_p: float = 0.9
do_sample: bool = True
@torch.inference_mode()
def _generate(req: GenRequest) -> dict:
tok, model = STATE["tok"], STATE["model"]
messages = [{"role": "user", "content": req.prompt}]
inputs = tok.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
prompt_len = inputs.shape[1]
t0 = time.perf_counter()
out = model.generate(
inputs,
max_new_tokens=req.max_new_tokens,
do_sample=req.do_sample,
temperature=req.temperature,
top_p=req.top_p,
pad_token_id=tok.pad_token_id,
)
dt = time.perf_counter() - t0
new_tokens = out[0, prompt_len:]
text = tok.decode(new_tokens, skip_special_tokens=True)
n_out = new_tokens.shape[0]
return {
"text": text,
"prompt_tokens": int(prompt_len),
"output_tokens": int(n_out),
"latency_s": round(dt, 3),
"tokens_per_s": round(n_out / dt, 1),
}
@app.post("/generate")
async def generate(req: GenRequest):
# Blocking, CPU/GPU-bound work goes to a thread so it does not
# freeze the async event loop (see pitfalls).
return await run_in_threadpool(_generate, req)
@app.get("/healthz")
async def healthz():
return {"ok": "model" in STATE}
Test it:
curl -s localhost:8000/generate \
-H 'content-type: application/json' \
-d '{"prompt": "Explain KV cache in one sentence.", "max_new_tokens": 64}'
What this server gets right, and what it deliberately does not:
- Right: model loaded once at startup (not per request);
@torch.inference_mode()disables gradient bookkeeping; blocking work offloaded off the event loop; chat template + pad token set correctly; returns real token counts and throughput. - Deliberately wrong / naive: it serves one request at a time per worker (the model is a shared object and
generateholds the GPU), it does not stream tokens (no TTFT benefit for the client), and it does no batching. Two simultaneous callers queue behind each other. That is the motivation for everything after this chapter.
Streaming, when you add it, uses TextIteratorStreamer + a background thread and a FastAPI StreamingResponse, so the client gets the first token as soon as prefill finishes rather than waiting for the whole answer — a large perceived latency win with no throughput change.
Saying it out loud. The minimal server is maybe forty lines and the important thing is what it gets right versus what it deliberately doesn’t. It loads the model once at startup instead of per request, it wraps generation in inference mode so PyTorch isn’t tracking gradients, and — the one people miss — it pushes the blocking generate call off to a thread pool. That last one matters because
model.generate()is a long synchronous call, and if you run it directly inside an async handler it freezes the entire event loop, including your own health check endpoint. What it deliberately doesn’t do is batch or stream, so two callers just queue behind each other — and that serial bottleneck is the whole motivation for every chapter after this one.
Generation parameters (what the knobs actually do)
generate is controlled by a GenerationConfig. The ones that matter for serving:
| Param | Effect | Note |
|---|---|---|
max_new_tokens | hard cap on output length | The #1 latency and cost lever — decode time is ~linear in it. Always set it. |
do_sample | greedy (False) vs sampling (True) | With do_sample=False, temperature/top_p are ignored and you get deterministic output. |
temperature | flattens (>1) or sharpens (<1) the distribution | 0 is not literally valid for sampling; use greedy for determinism. |
top_p (nucleus) | sample only from the smallest set of tokens summing to prob p | Common: 0.9–0.95. |
top_k | sample only from the k highest-prob tokens | Alternative/complement to top_p. |
repetition_penalty | discourage repeating tokens | Helps loops; tune carefully. |
stop / eos_token_id | stop conditions | Wrong EOS = runaway generation to max_new_tokens. |
A subtle correctness trap: if a caller passes do_sample=False and a non-default temperature, recent Transformers will warn that the sampling flags are ignored. Decide your server’s contract explicitly rather than passing user knobs through blindly.
A related, increasingly common knob that does not appear in the table above because it is not a GenerationConfig field: constrained / structured decoding, where the server forces every generated token to come from a grammar (a JSON Schema, a regex, a context-free grammar) rather than the raw vocabulary. We cover why this matters and how it works mechanically in “The 2025–2026 landscape” below, because it has become a default expectation for tool-calling and JSON-emitting endpoints, not a niche feature.
Saying it out loud. Most of the sampling knobs are quality dials, but one of them is a cost dial and that’s the one I’d lead with:
max_new_tokensis your single biggest latency and money lever, because decode time is basically linear in output length. Temperature and top-p just reshape the probability distribution you sample from, and if you setdo_sample=Falsethey’re ignored entirely — you get deterministic greedy output. The stop condition is the quiet danger: a wrong or missing EOS token means the model runs all the way to the cap on every request, burning GPU time and blocking other callers. So the rule is always bound output length, and bound it server-side, because a client’smax_new_tokensshould be a request, not a command.
Adding streaming (TTFT the user can feel)
The naive server returns the whole answer at once. Streaming emits tokens as they are decoded, so the client sees output right after prefill:
from threading import Thread
from transformers import TextIteratorStreamer
from fastapi.responses import StreamingResponse
@app.post("/generate/stream")
async def generate_stream(req: GenRequest):
tok, model = STATE["tok"], STATE["model"]
inputs = tok.apply_chat_template(
[{"role": "user", "content": req.prompt}],
add_generation_prompt=True, return_tensors="pt",
).to(model.device)
streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True)
kwargs = dict(inputs=inputs, streamer=streamer,
max_new_tokens=req.max_new_tokens, do_sample=req.do_sample,
temperature=req.temperature, top_p=req.top_p,
pad_token_id=tok.pad_token_id)
Thread(target=model.generate, kwargs=kwargs).start() # runs off the event loop
def emit():
for piece in streamer: # yields decoded text as tokens arrive
yield piece
return StreamingResponse(emit(), media_type="text/plain")
generate runs in a background thread and pushes tokens into the streamer; the handler yields them to the client. Same total work, dramatically better perceived latency — but note it still occupies the GPU serially. Streaming improves TTFT, not throughput.
Saying it out loud. Streaming means you push each token to the client as it’s decoded instead of waiting for the whole answer, and the honest framing is that it’s a perceived latency win, not a real one. Same total work, same throughput, same moment the last token lands — but the user sees something at, say, 120 milliseconds instead of staring at a spinner for three seconds. Mechanically you run generate on a background thread that pushes tokens into a streamer, and the handler yields them out as a streaming response. The tradeoff to name: streaming improves TTFT and nothing else — it does not free up the GPU, so under load a streaming server serializes exactly as badly as a non-streaming one.
Constrained decoding by hand (what “structured outputs” actually costs you without an engine)
Section A below explains that hosted APIs and dedicated engines now offer schema-guaranteed JSON as a first-class feature, implemented as grammar-constrained decoding: at every decode step, mask out logits for any token that would violate the grammar. It is worth seeing the mechanism on the raw server, because it makes concrete exactly how much an engine is doing for you for free. Here is the simplest possible version — forcing the model to only ever emit digits and a decimal point (a toy “grammar,” but the mechanism generalizes to a full JSON Schema automaton):
import torch
from transformers import LogitsProcessor, LogitsProcessorList
class DigitsOnlyLogitsProcessor(LogitsProcessor):
# Mask every token whose decoded text contains a character outside
# "0123456789." -- a minimal stand-in for a compiled JSON-Schema/regex
# automaton. Real constrained decoding (Outlines, XGrammar) precomputes
# which token IDs are valid at each automaton state so this mask is a
# cheap lookup, not a per-step string scan like this toy version.
def __init__(self, tokenizer, allowed_chars=set("0123456789. ")):
self.tokenizer = tokenizer
self.allowed_chars = allowed_chars
self._valid_ids = None # lazily computed once, then reused every step
def _compute_valid_ids(self, vocab_size: int) -> torch.Tensor:
valid = torch.zeros(vocab_size, dtype=torch.bool)
for tok_id in range(vocab_size):
text = self.tokenizer.decode([tok_id])
if all(c in self.allowed_chars for c in text) and text != "":
valid[tok_id] = True
return valid
def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
if self._valid_ids is None:
self._valid_ids = self._compute_valid_ids(scores.shape[-1]).to(scores.device)
scores = scores.masked_fill(~self._valid_ids, float("-inf"))
return scores
# Wire it into generate() as an extra constraint on top of everything else:
processors = LogitsProcessorList([DigitsOnlyLogitsProcessor(tok)])
out = model.generate(inputs, max_new_tokens=16, logits_processor=processors,
pad_token_id=tok.pad_token_id)
This toy processor decodes every candidate token’s text on every step to check it, which is far too slow for a real vocabulary of 50k+ tokens at production latency — that per-step cost is exactly the engineering problem XGrammar and outlines solve, by precompiling the grammar into an automaton once and reducing each decode step’s mask computation to following one transition and reading a precomputed bitmask of valid token IDs for the current state, rather than re-deriving validity from scratch. The takeaway to carry into an interview: structured output is not prompt engineering, it is a LogitsProcessor (or the engine’s equivalent) applied every single decode step, and its cost and correctness both hinge on how the grammar-to-token-mask compilation is done.
Saying it out loud. When an API promises you guaranteed valid JSON, that’s not a better prompt — it’s constrained decoding. At every single decode step, the schema gets compiled into a state machine over the vocabulary, and every token that would break the grammar has its logit set to negative infinity before you sample. So schema-valid output is true by construction, not by retrying until the JSON parses. The cost is real, though: you’re computing a valid-token mask on every step, and if you do that naively by decoding all fifty thousand vocabulary entries and string-matching, you’ve just made yourself the bottleneck. That’s exactly the problem XGrammar and Outlines solve — precompile the grammar once, then each step is a state transition and a bitmask lookup.
Build it in practice — extended: from “works on my laptop” to “survives real traffic”
The server above is correct but has three gaps between it and something you would actually deploy: it has no concurrency control (a hundred simultaneous callers all get admitted and race for the GPU, and the process has no idea how loaded it is), no structured error handling (a CUDA OOM or a bad request produces an ugly 500 with a stack trace instead of a machine-readable error the caller can act on), and no readiness signal separate from liveness (a load balancer cannot tell “the process is up” from “the process is ready to take more work”). None of these require an inference engine to fix — they are ordinary backend engineering, and skipping them is exactly what produces the incidents in the war-stories section below.
Saying it out loud. There are three things standing between a correct server and a deployable one, and none of them need an inference engine — they’re just ordinary backend engineering. You need admission control, so a hundred simultaneous callers don’t all get let in to race for a GPU that fits four. You need structured errors, so a caller can tell “retry me later” from “your request is malformed.” And you need liveness and readiness as two separate signals, so a load balancer can stop sending traffic to a busy pod without Kubernetes killing it. Skip these and you get exactly the two incidents at the end of this chapter: a healthy pod restarted by its own health check, and an unbounded OOM that takes down unrelated requests with it.
1. Bound concurrency to what the GPU can actually hold
Do not let every accepted request race for the GPU unbounded. Gate concurrent generations with a semaphore sized from the memory math you will do in Worked Example 2 below — not a guess — and shed load past a bounded queue depth instead of accepting requests you cannot serve in time.
# server.py (extended)
# pip install fastapi "uvicorn[standard]" transformers torch accelerate httpx
# uvicorn server:app --host 0.0.0.0 --port 8000 --workers 1
import asyncio
import time
from contextlib import asynccontextmanager
from enum import Enum
import torch
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "meta-llama/Llama-3.2-1B-Instruct"
# Sized from the KV-cache math in Worked Example 2, NOT a guess: on a 24 GB
# GPU with this model we computed room for ~4 full-length concurrent
# requests before OOM. Leave headroom for shorter, cheaper requests too.
MAX_CONCURRENT_GENERATIONS = 3
MAX_QUEUE_DEPTH = 16 # requests allowed to wait for a GPU slot
REQUEST_TIMEOUT_S = 30.0 # end-to-end deadline, queue + generation
SERVER_MAX_NEW_TOKENS = 512 # hard server-side cap — never trust the client's value alone
STATE: dict = {}
class ErrorCode(str, Enum):
OVERLOADED = "overloaded"
TIMEOUT = "timeout"
OUT_OF_MEMORY = "out_of_memory"
BAD_REQUEST = "bad_request"
INTERNAL = "internal_error"
@asynccontextmanager
async def lifespan(app: FastAPI):
tok = AutoTokenizer.from_pretrained(MODEL_ID)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.bfloat16, device_map="cuda"
)
model.eval()
STATE["tok"], STATE["model"] = tok, model
STATE["gpu_gate"] = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
STATE["in_flight"] = 0
STATE["queued"] = 0
STATE["ready"] = True # flips readyz on; healthz is independent (below)
yield
STATE["ready"] = False
STATE.clear()
app = FastAPI(lifespan=lifespan)
class GenRequest(BaseModel):
prompt: str
max_new_tokens: int = 128
temperature: float = 0.7
top_p: float = 0.9
do_sample: bool = True
Saying it out loud. The rule is that concurrency should be a number you derived, not a number you guessed. You do the memory math — weights plus KV cache per request plus a margin — and that tells you how many generations actually fit; then a semaphore holds you at or below it, and a bounded queue holds a few more waiting. Anything past that gets a fast 503 instead of being admitted into a fight it can’t win. The reason to reject rather than queue forever is that an unbounded queue doesn’t remove overload, it just hides it — it turns a capacity problem into runaway process memory, and the client’s timeout fires anyway. On a 24 GB card running a 7B model in BF16, that number lands around three or four, not thirty.
2. Structured error handling — turn crashes into contracts
A caller that gets a bare 500 with a Python traceback cannot tell “retry me” from “your request is malformed” from “the server is out of capacity forever.” Classify failures and return a small, stable error shape:
def _generate(req: GenRequest) -> dict:
tok, model = STATE["tok"], STATE["model"]
n = min(req.max_new_tokens, SERVER_MAX_NEW_TOKENS) # server has the last word
messages = [{"role": "user", "content": req.prompt}]
inputs = tok.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
prompt_len = inputs.shape[1]
t0 = time.perf_counter()
with torch.inference_mode():
out = model.generate(
inputs, max_new_tokens=n, do_sample=req.do_sample,
temperature=req.temperature, top_p=req.top_p,
pad_token_id=tok.pad_token_id,
)
dt = time.perf_counter() - t0
new_tokens = out[0, prompt_len:]
text = tok.decode(new_tokens, skip_special_tokens=True)
n_out = new_tokens.shape[0]
return {
"text": text, "prompt_tokens": int(prompt_len), "output_tokens": int(n_out),
"latency_s": round(dt, 3), "tokens_per_s": round(n_out / dt, 1) if dt > 0 else 0.0,
}
def _generate_safe(req: GenRequest) -> dict:
"""Runs in the threadpool. Classify every failure into a stable error code
so callers can distinguish 'retry later' from 'fix your request'."""
try:
return _generate(req)
except torch.cuda.OutOfMemoryError:
# Free whatever we can; do NOT try to keep serving this request.
torch.cuda.empty_cache()
return {"_error": ErrorCode.OUT_OF_MEMORY.value,
"detail": "GPU ran out of memory; retry with a shorter prompt or lower max_new_tokens"}
except ValueError as e:
return {"_error": ErrorCode.BAD_REQUEST.value, "detail": str(e)}
except Exception as e: # last-resort classification, still structured
return {"_error": ErrorCode.INTERNAL.value, "detail": str(e)}
Saying it out loud. A bare 500 with a Python traceback tells the caller nothing useful — they can’t tell whether to retry, to fix their input, or to give up. So you classify every failure into a small, stable set of codes and map them onto meaningful HTTP statuses: out-of-memory is different from a bad request, which is different from “we’re at capacity, come back with backoff.” Concretely, you catch
torch.cuda.OutOfMemoryErrorexplicitly, free what you can, and return something the client can act on rather than letting it escape as a generic 500. The payoff is that your retry logic, your load balancer, and your alerting all get an honest signal — and the failure mode you’re avoiding is clients hammering retries against an error that will never succeed.
3. The gated, backpressured endpoint
Wire the semaphore, the bounded queue, and the deadline together. The key design decision: a full queue fails fast with 503 rather than growing without bound. An unbounded queue does not remove the overload — it just hides it, converts it into ballooning process memory, and lets client timeouts fire anyway once the queue is long enough. Bounded queues plus fast rejection is a deliberate load-shedding choice, and it is what lets the rest of your system (a load balancer, a retry-with-backoff client, an autoscaler) actually react to the overload signal.
@app.post("/generate")
async def generate(req: GenRequest):
if STATE["queued"] >= MAX_QUEUE_DEPTH:
return JSONResponse(status_code=503, content={
"error": ErrorCode.OVERLOADED.value,
"detail": "server at capacity, retry with backoff",
})
STATE["queued"] += 1
acquired = False
try:
async with asyncio.timeout(REQUEST_TIMEOUT_S): # Python 3.11+
await STATE["gpu_gate"].acquire()
acquired = True
STATE["queued"] -= 1
STATE["in_flight"] += 1
result = await run_in_threadpool(_generate_safe, req)
except TimeoutError:
return JSONResponse(status_code=504, content={
"error": ErrorCode.TIMEOUT.value,
"detail": f"exceeded {REQUEST_TIMEOUT_S}s waiting for a GPU slot or generating",
})
finally:
if acquired:
STATE["in_flight"] -= 1
STATE["gpu_gate"].release()
else:
STATE["queued"] -= 1
if "_error" in result:
status = 507 if result["_error"] == ErrorCode.OUT_OF_MEMORY.value else \
400 if result["_error"] == ErrorCode.BAD_REQUEST.value else 500
return JSONResponse(status_code=status, content=result)
return result
Walk through the accounting carefully, because it is easy to get this subtly wrong (and a subtly wrong version is worse than none — it lies about server load): queued is incremented the moment a request is admitted past the queue-depth check, and decremented either when it successfully acquires a GPU slot (acquired = True path) or in the finally block if it times out or errors out before acquiring. in_flight is only ever touched once a slot is actually held, and is always released in finally, so a timeout, an exception inside _generate_safe, or a clean return all leave the semaphore and counters consistent. This is the difference between a load number you can trust for autoscaling and alerting, and one that slowly drifts until a restart papers over it.
Saying it out loud. Backpressure is just being honest about capacity: when the queue is full you say no immediately instead of accepting work you can’t finish. The bookkeeping is where people get it subtly wrong — you increment “queued” the moment you admit a request past the depth check, decrement it either when it grabs a GPU slot or in the finally block if it times out first, and you only ever touch “in flight” once a slot is actually held. Get that wrong and the counters drift upward until a restart papers over it, and now your autoscaler and your dashboards are lying to you. The tradeoff worth naming: a fast 503 looks worse on an error-rate graph than a slow success, but it’s the only thing that gives a retrying client, a load balancer, and an autoscaler something real to react to.
4. Health vs readiness — two different questions
Kubernetes (and any sane load balancer) asks two different questions and expects two different answers. Liveness (“is the process alive enough to not be killed and restarted?”) should be cheap and should not depend on the model or the GPU — otherwise a temporarily saturated but perfectly healthy process gets killed mid-batch, which is its own incident (see war stories). Readiness (“should traffic be routed to this pod right now?”) should reflect real capacity: model loaded, and — ideally — not already saturated.
@app.get("/healthz")
async def healthz():
# Liveness: process can respond at all. Deliberately does NOT touch the
# model, the GPU, or the semaphore — must stay cheap even while the GPU
# is fully busy, or a liveness probe timeout kills a perfectly healthy pod.
return {"status": "alive"}
@app.get("/readyz")
async def readyz():
# Readiness: is there a loaded model AND spare capacity worth routing to?
if not STATE.get("ready", False):
return JSONResponse(status_code=503, content={"ready": False, "reason": "model not loaded"})
saturated = STATE["queued"] >= MAX_QUEUE_DEPTH
body = {
"ready": not saturated,
"in_flight": STATE["in_flight"],
"queued": STATE["queued"],
"capacity": MAX_CONCURRENT_GENERATIONS,
}
return JSONResponse(status_code=200 if not saturated else 503, content=body)
Point the Kubernetes livenessProbe at /healthz with a generous period, and the readinessProbe at /readyz so the Service stops sending new traffic to a saturated pod (letting the autoscaler add capacity) without killing it.
Saying it out loud. These are two genuinely different questions and conflating them causes outages. Liveness asks “is this process broken enough that I should kill and restart it?” — so it must be dirt cheap and must not touch the model or the GPU, because a saturated-but-perfectly-healthy pod that fails its liveness probe gets killed mid-request. Readiness asks “should I route new traffic here right now?” — so that one should reflect real load: model loaded, queue not full. The crucial asymmetry is what happens when each fails: a readiness failure just stops new traffic and lets in-flight work finish, while a liveness failure destroys everything currently running on that pod. If you take one thing: never let your liveness endpoint depend on GPU state.
5. Load-test it — see the bottleneck, don’t just believe in it
A claim like “this server serializes under concurrency” should be a measurement, not folklore. This is a minimal concurrent load test using httpx; it fires N_REQUESTS through CONCURRENCY simultaneous clients and reports the standard percentiles from the metrics section below.
# loadtest.py
# pip install httpx
import asyncio
import time
import httpx
URL = "http://localhost:8000/generate"
N_REQUESTS = 30
CONCURRENCY = 10
async def one_request(client: httpx.AsyncClient, i: int) -> dict:
t0 = time.perf_counter()
r = await client.post(
URL, json={"prompt": f"Count to five. (req {i})", "max_new_tokens": 64}, timeout=60.0
)
return {"status": r.status_code, "latency_s": time.perf_counter() - t0}
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
async def bound(client, i):
async with sem:
return await one_request(client, i)
async with httpx.AsyncClient() as client:
t0 = time.perf_counter()
results = await asyncio.gather(*(bound(client, i) for i in range(N_REQUESTS)))
wall = time.perf_counter() - t0
lat = sorted(r["latency_s"] for r in results)
ok = sum(r["status"] == 200 for r in results)
shed = sum(r["status"] == 503 for r in results)
def pct(p):
return lat[min(len(lat) - 1, int(p * len(lat)))]
print(f"requests={N_REQUESTS} concurrency={CONCURRENCY} wall_s={wall:.2f} "
f"observed_rps={N_REQUESTS / wall:.2f}")
print(f"ok={ok} shed_503={shed}")
print(f"p50={pct(0.50):.2f}s p95={pct(0.95):.2f}s p99={pct(0.99):.2f}s")
if __name__ == "__main__":
asyncio.run(main())
Run the server, then in another shell python loadtest.py. With MAX_CONCURRENT_GENERATIONS = 3 and CONCURRENCY = 10, expect to see the serialization directly: p50 will be close to a single request’s latency, but p95/p99 will be roughly (3\times) to (4\times) higher, because the 4th-through-10th concurrent caller queue behind the semaphore for one or more full generation cycles before their own request even starts. If you push N_REQUESTS/CONCURRENCY high enough to exceed MAX_QUEUE_DEPTH, you will start seeing shed_503 > 0 — the bounded queue doing exactly its job instead of the process quietly ballooning. That gap between p50 and p99 is the naive server’s serial bottleneck, measured instead of asserted — and it is the number continuous batching (Chapter 5) exists to close.
Saying it out loud. “This server serializes under load” should be a measurement, not folklore — and it takes about thirty lines to prove. You fire N requests through a fixed number of concurrent clients and report p50, p95, and p99, never the average, because the average is exactly the statistic that hides a starved tail. What you’ll see with a concurrency gate of three and ten simultaneous callers is a p50 near a single request’s latency and a p99 roughly three to four times higher, because callers four through ten are waiting through entire generation cycles before theirs even starts. That gap between p50 and p99 is the serial bottleneck, and closing it is precisely what continuous batching exists to do.
6. Confirm the error contract by hand before trusting the load test
Before trusting the load test’s numbers, confirm each error path returns the structured shape you designed rather than a bare stack trace — this is a five-minute check that catches most of Section B’s wiring mistakes immediately:
# Healthy request -- expect 200 with text/prompt_tokens/output_tokens/latency_s
curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/generate \
-H 'content-type: application/json' \
-d '{"prompt": "Say hi.", "max_new_tokens": 8}'
# Readiness under normal load -- expect 200 with in_flight/queued/capacity
curl -s localhost:8000/readyz
# Liveness -- expect 200 instantly, even while /generate is busy elsewhere
curl -s localhost:8000/healthz
# Force capacity exhaustion: fire more concurrent requests than
# MAX_CONCURRENT_GENERATIONS + MAX_QUEUE_DEPTH allow, and expect some 503s
# with {"error": "overloaded", ...} rather than hung connections
for i in $(seq 1 40); do
curl -s -o /dev/null -w '%{http_code} ' localhost:8000/generate \
-H 'content-type: application/json' \
-d '{"prompt": "Write a long story.", "max_new_tokens": 512}' &
done; wait; echo
If the overload run above returns only 200s and hung connections instead of a mix of 200 and 503, the bounded-queue accounting in Section B’s /generate handler is not actually shedding load — go back and re-check the queued/in_flight bookkeeping before you trust anything the load test reports.
Worked example 2: GPU memory budget (weights + KV cache + activations)
You cannot reason about serving without the memory math. The GPU must simultaneously hold model weights, the KV cache for every in-flight request, and transient activations. Run out and you get a CUDA OOM — the most common production failure, and the exact one Section B’s MAX_CONCURRENT_GENERATIONS and Section C’s second war story both revolve around.
Saying it out loud. The memory budget is three terms and you should be able to do it on a whiteboard. Weights are parameters times bytes per parameter — roughly two gigabytes per billion parameters in BF16, so a 7B model is about 14 GB. Then KV cache, which is per token, per request, and only grows: about half a megabyte per token on a 7B model, so a 4K-context request costs around 2 GB all by itself. Then a gigabyte or two for activations and CUDA overhead. On a 24 GB card that leaves you about eight gigabytes of headroom, which is four concurrent full-length requests — and that number, not intuition, is where your concurrency limit comes from. The takeaway an interviewer wants: memory caps concurrency, not compute.
Weights
[ \text{weight bytes} = (\text{number of parameters}) \times (\text{bytes per parameter}) ]
For a 7-billion-parameter model in BF16:
[ 7 \times 10^{9} \ \text{params} \times 2 \ \text{bytes} = 14 \times 10^{9} \ \text{bytes} \approx 14 \ \text{GB} ]
The rule of thumb “~2 GB per billion params in FP16/BF16” (and ~1 GB/B in INT8, ~0.5 GB/B in INT4) falls straight out of this.
KV cache (per token, then per request)
The KV cache stores a key and a value vector for every token, every layer, every KV head:
[ \text{bytes per token} = 2 \times L \times H_{kv} \times D_{h} \times b ]
where the leading (2) covers K and V, (L) is the number of transformer layers, (H_{kv}) the number of key/value heads, (D_{h}) the head dimension, and (b) the bytes per element. For a LLaMA-style 7B model with (L = 32), (H_{kv} = 32), (D_{h} = 128), BF16 ((b = 2)):
[ 2 \times 32 \times 32 \times 128 \times 2 = 524{,}288 \ \text{bytes} \approx 0.5 \ \text{MB per token} ]
For a request with a full context of 4,096 tokens:
[ 4096 \times 524{,}288 \ \text{bytes} = 2{,}147{,}483{,}648 \ \text{bytes} = 2 \ \text{GB} ]
So a single 4K-context request costs ~2 GB of KV cache on top of the 14 GB of weights. Note that models using grouped-query attention (GQA) have far fewer KV heads (H_{kv}) than query heads, which is specifically a trick to shrink this number — one reason modern models are cheaper to serve. As a concrete comparison: if the same architecture used full multi-head attention with (H_{kv}) equal to 32 query heads unchanged, doubling (H_{kv}) doubles KV-cache bytes per token linearly — GQA with, say, 8 KV heads instead of 32 shrinks the same request’s KV cache fourfold, from 2 GB to 0.5 GB, which is the difference between fitting 4 concurrent long requests and fitting 16.
Saying it out loud. The KV cache formula is two — for keys and values — times layers, times KV heads, times head dimension, times bytes per element, and that gives you bytes per token. For a classic 7B model with 32 layers, 32 KV heads, head dim 128, in BF16, that works out to about half a megabyte per token, so a 4,096-token request is roughly 2 GB. The interesting term is the KV head count, because grouped-query attention deliberately shrinks it — dropping from 32 KV heads to 8 cuts that request’s cache fourfold, from 2 GB down to 0.5 GB. That’s the difference between fitting four concurrent long requests and fitting sixteen on the same card, which is why GQA is a serving-cost decision, not a model-card footnote.
Activations and overhead
Beyond weights and KV cache, each forward pass allocates transient activation tensors, and the CUDA context / allocator reserves a fixed slab (often 1–2 GB). Activation memory scales with batch size and sequence length but is freed between steps, so it is usually a smaller, bounded term than the KV cache — which only grows. When you size a GPU, budget weights + peak KV + a safety margin for activations and fragmentation; do not plan to use the last gigabyte.
Putting it together on a 24 GB GPU
[ \text{free for KV + activations} \approx 24 \ \text{GB} - 14 \ \text{GB (weights)} - \sim 1\text{–}2 \ \text{GB (activations, CUDA ctx)} \approx 8 \ \text{GB} ]
At ~2 GB per 4K-token request, that GPU holds on the order of 4 concurrent full-length requests before OOM — and fewer if prompts are longer. This is precisely where MAX_CONCURRENT_GENERATIONS = 3 in Section B’s server came from — a deliberately conservative choice below the theoretical ceiling of ~4, to leave headroom for activation spikes and fragmentation rather than run at the edge. This single calculation is why KV-cache efficiency (PagedAttention, Chapter 5) is the highest-leverage optimization in LLM serving: memory, not compute, usually caps your concurrency.
Saying it out loud. Put the numbers together on a real card: 24 GB total, minus 14 for BF16 weights, minus a gig or two for activations and the CUDA context, leaves you about eight gigabytes for KV cache. At roughly 2 GB per 4K-token request, that’s about four concurrent full-length requests — and you’d actually set your limit at three, because running at the theoretical edge means fragmentation and activation spikes will OOM you eventually. That’s the whole argument for PagedAttention in one calculation: if memory is what caps concurrency, then using memory more efficiently is the highest-leverage optimization in the entire stack, worth more than any scheduler tuning you’ll do.
Quantization changes the concurrency budget, not just the weight size
The same 24 GB-GPU calculation above assumed BF16 weights. Quantization is the other big lever, and it is worth running the numbers once so the tradeoff is concrete rather than a slogan (“quantize to fit more”). Take the same 7B model, same 24 GB GPU, same ~2 GB-per-4K-token-request KV cache (quantizing weights does not by itself shrink KV-cache dtype unless you separately quantize the cache):
[ \text{INT8 weights} = 7 \times 10^{9} \times 1 \ \text{byte} = 7 \ \text{GB}, \qquad \text{free for KV} \approx 24 - 7 - 1.5 \approx 15.5 \ \text{GB} ]
At ~2 GB per request, that is room for roughly 7–8 concurrent full-length requests, up from ~4 in BF16 — nearly double the concurrency on identical hardware, at the cost of some generation quality (INT8 post-training quantization is usually a small, workload-dependent quality hit; validate it against your own eval set rather than assuming it is free). Push to INT4 and weights drop to ~3.5 GB, freeing room for on the order of 15+ concurrent requests on the same GPU. This is the concrete version of the “quantization vs. smaller model” interview question below: quantizing a 7B model to INT8 and running a smaller, unquantized 3B model in BF16 can land at similar total memory, but they are not equivalent — quantization keeps the larger model’s capability at a precision cost, while a smaller model changes the capability itself. Which one wins depends on whether your bottleneck is quality or throughput per request, and the only way to know is to measure both against your task.
Saying it out loud. People pitch quantization as “make the model smaller,” but the interesting effect is second-order: shrinking the weights frees room for KV cache, and KV cache is what caps concurrency. Same 24 GB card, same 7B model — BF16 weights take 14 GB and leave room for about four concurrent requests; INT8 takes 7 GB and leaves room for seven or eight; INT4 takes about 3.5 GB and gets you past fifteen. So you roughly quadrupled your concurrency on identical hardware. The tradeoff you have to name honestly is quality: INT8 post-training quantization is usually a small hit, but it’s workload-dependent, and the only way to know is your own eval set. And note that quantizing weights doesn’t shrink the KV cache unless you separately quantize the cache too.
Metrics and the latency–throughput tradeoff
You cannot improve what you do not measure. Four numbers define an LLM endpoint.
| Metric | Definition | Formula |
|---|---|---|
| TTFT (time to first token) | Prompt submitted -> first output token received. Dominated by queueing + prefill. | measured directly |
| TPOT / ITL (time per output token / inter-token latency) | Average gap between successive output tokens in the “steady stream.” | (\text{TPOT} = \dfrac{\text{E2E} - \text{TTFT}}{N_{out} - 1}) |
| E2E latency | Request in -> full response out. | (\text{E2E} = \text{TTFT} + \text{generation time}) |
| Throughput | Tokens or requests completed per second, across all concurrent users. | (\text{TPS} = \dfrac{N_{out}}{T_{last} - T_{first}}), (\ \text{RPS} = \dfrac{\text{completed requests}}{\text{time}}) |
Definitions and formulas follow the Anyscale benchmarking guide (see Further Reading). Report percentiles (p50/p95/p99), never just the mean — tail latency is what users feel and what SLOs are written against. This is exactly what the loadtest.py script in Section B reports, and exactly why it reports percentiles rather than an average: an average latency can look fine while p99 callers are being starved behind the semaphore.
Saying it out loud. Four numbers define an LLM endpoint and you should rattle them off. Time to first token — how long until the user sees anything, dominated by queueing plus prefill. Time per output token, the gap between successive tokens once it’s streaming. End-to-end latency, which is just TTFT plus generation. And throughput, tokens or requests per second across everyone. The rule that separates a senior answer from a junior one is: always report p50, p95, and p99, never the mean — because an average latency can look perfectly healthy while your p99 callers are starving in a queue, and your SLO is written against the tail, not the middle.
The tradeoff
Here is the crux, and it is why “just add batching” is not free:
- A single request in isolation gets the lowest possible latency: the whole GPU is devoted to it. But decode is memory-bound, so the GPU’s compute units are ~idle — terrible throughput per dollar.
- Batching many requests together amortizes each weight/KV read across all of them: one memory pass serves (B) requests. Throughput (tokens/s, req/s) rises sharply — you use the idle compute. But any individual request may wait to be batched and shares GPU cycles, so its TTFT and TPOT rise.
So batch size is a dial between latency (small batch, low utilization, expensive per token) and throughput (large batch, high utilization, cheap per token, worse tail latency). There is no single right setting — it depends on your SLO. Real serving engines make this dial dynamic (continuous batching), which we introduce next and detail in Chapter 5.
Saying it out loud. Batch size is a dial between latency and throughput, and there’s no universally right setting — it depends entirely on your SLO. Turn it down and a single request gets the whole GPU to itself: lowest possible latency, but since decode is memory-bound the compute units sit idle, so it’s terrible throughput per dollar. Turn it up and one read of the weights produces a token for every request in the batch, so throughput climbs sharply — but any individual request now waits to be batched and shares cycles, so its TTFT and per-token latency get worse. The important nuance: naive batching genuinely hurts single-request latency, which is why real engines make the dial dynamic with continuous batching instead of picking one static number.
Worked example 3: reading a latency timeline
Numbers make the asymmetry concrete. Suppose a request has a 500-token prompt and generates 200 tokens, and we measure:
- prefill takes 120 ms (this is the TTFT, ignoring queueing),
- each decode step takes 15 ms.
Then:
[ \text{TTFT} = 120 \ \text{ms}, \qquad \text{generation} = 199 \times 15 \ \text{ms} \approx 2985 \ \text{ms} ]
[ \text{E2E} = 120 + 2985 \approx 3.1 \ \text{s}, \qquad \text{TPOT} = \frac{3105 - 120}{200 - 1} \approx 15 \ \text{ms}, \qquad \text{user TPS} = \frac{200}{2.985} \approx 67 \ \text{tok/s} ]
Two lessons. First, decode dominates E2E here (~3 s vs ~120 ms) — output length is your biggest latency lever, which is why capping max_new_tokens matters so much. Second, if you stream, the user sees a token at 120 ms instead of waiting 3.1 s: identical work, far better perceived latency. Streaming trades nothing in throughput; it only reshapes when the user first sees output.
Saying it out loud. Put real numbers on it: 500-token prompt, 200 tokens out, prefill takes 120 milliseconds and each decode step takes 15. So your TTFT is 120 milliseconds, but generation is 199 more steps at 15 milliseconds each — about three seconds. End to end you’re at roughly 3.1 seconds, and decode is over ninety-five percent of it. Two things fall out. Output length is by far your biggest latency lever, which is why capping
max_new_tokensmatters more than almost any tuning. And if you stream, the user sees a token at 120 milliseconds instead of three seconds — identical work, completely different experience.
Why one-request-at-a-time is wasteful — the road to batching
The naive server above processes requests serially. During each request’s decode phase the GPU is memory-bandwidth-bound and its tensor cores are mostly idle — you are paying for an A100/H100 and using a fraction of its FLOPs. Meanwhile a second caller just waits.
Batching fixes this by running multiple sequences through the model together, so a single read of the weights (and a single scheduling step) produces a token for every request in the batch. Because decode was memory-bound, adding more requests is nearly free on compute up to a point — you convert idle compute into throughput.
There are three flavors, in increasing sophistication (full treatment in Chapter 5):
| Batching strategy | How it works | Weakness |
|---|---|---|
| Static batching | Collect (N) requests, pad to the same length, run them together to completion. | Head-of-line blocking: the whole batch waits for the slowest/longest sequence; padding wastes compute; new requests wait for the batch to finish. |
| Dynamic batching | Server briefly buffers incoming requests (a few ms) to form a batch, then runs it. Common in Triton. | Still runs the batch to completion; a short request is stuck behind a long one. |
| Continuous batching (a.k.a. in-flight / iteration-level) | The scheduler works at the granularity of a single decode step: finished sequences leave the batch and new ones join every iteration. | More complex; needs paged KV memory to do well. This is what vLLM and TGI do, and it is the big throughput unlock. |
The mental model: static/dynamic batching batches requests; continuous batching batches token-generation steps. The latter keeps the GPU full even when requests have wildly different lengths — which is the normal case. This is the single biggest reason a dedicated engine outperforms the naive server, often by an order of magnitude in throughput at the same latency. Section B’s concurrency gate is a poor person’s version of this: it prevents catastrophe, but it still runs each admitted request’s decode loop independently rather than sharing steps across requests — the semaphore buys you safety, not the throughput win of continuous batching.
Saying it out loud. Serving one request at a time means that during decode your expensive GPU is mostly waiting on memory while a second caller just sits there — you’re renting tensor cores and using a fraction of them. Batching fixes it because one read of the weights produces a token for every sequence in the batch, and since decode was memory-bound, adding sequences is nearly free on compute up to a point. There are three flavors: static batching pads a fixed group and runs it to completion, dynamic batching buffers arrivals for a few milliseconds then does the same, and continuous batching schedules at the level of a single decode step, so a finished sequence leaves and a new one joins every iteration. The failure mode the first two share is head-of-line blocking — one 2,000-token answer holds the whole batch hostage — and continuous batching is what eliminates it, often for an order of magnitude more throughput at the same latency.
Failure modes and pitfalls
The naive server fails in predictable ways. Know them cold.
- CUDA out of memory (OOM). The #1 killer. Causes: model too big for the GPU in the chosen dtype; too many concurrent requests inflating the KV cache; a single very long prompt/output. Symptoms:
CUDA out of memory. Tried to allocate .... Fixes: smaller dtype/quantization, capmax_new_tokensand context length, limit concurrency, use an engine with paged KV. Do the memory math before deploying. - Blocking the async event loop. FastAPI is async, but
model.generate()is a long, synchronous, GPU-bound call. If youawaitit directly in the handler (or call it inline), it freezes the entire event loop — health checks time out, every other connection stalls. Fix:run_in_threadpool(as above) or a dedicated worker/queue. This bug looks like “the server randomly hangs under load” — see the first war story below for exactly how this plays out in production. - No batching / serial serving. Two users -> the second waits for the first. Throughput is capped at one request’s worth of decode, and the expensive GPU sits underutilized. This is not a bug to fix in this server — it is the reason to graduate to vLLM/TGI.
- Tokenizer mismatch / wrong chat template. Using a tokenizer from a different model, skipping
apply_chat_template, or missing special tokens produces fluent-looking garbage with no error. Always pair the exact tokenizer with the model and use the official chat template. - Padding-side and pad-token mistakes. Right-padding a decoder-only batch, or a missing
pad_token, silently corrupts batched generation. Left-pad; setpad_token = eos_tokenif absent. - FP32 by accident. Forgetting
torch_dtypeloads in FP32 and doubles weight memory — an instant OOM on models that would fit fine in BF16. - Unbounded generation. No
max_new_tokensand a wrong/missing EOS -> the model runs until it hits some default cap, burning GPU time and blocking others. Always bound output — and bound it server-side, not just as a client-supplied default (Section B’sSERVER_MAX_NEW_TOKENS). - Unbounded concurrency / unbounded queues. Accepting every connection and either racing them all for the GPU or queueing them forever converts a capacity problem into a memory problem or a client-timeout problem, one level removed. Gate concurrency from real memory math and shed load explicitly (Section B).
- Cold-start latency ignored. First request after a scale-up waits for model download + load (seconds to minutes). Add readiness probes (
/healthzgating on model-loaded) so traffic is not routed to a not-yet-ready pod (Chapters 3, 6) — and keep liveness probes independent of load, or a saturated-but-healthy pod gets killed (Section B, war story one).
Saying it out loud. If you asked me what actually breaks these servers, I’d name three in order. CUDA out-of-memory is number one, and it’s almost always unbounded concurrency or unbounded output length rather than the model itself being too big. Second is blocking the async event loop by calling
model.generate()directly in an async handler — that freezes every other coroutine in the process including your health checks, and it presents as “the server randomly hangs under load.” Third is the silent class: a mismatched tokenizer, a skipped chat template, or right-padding a decoder batch, all of which produce fluent-looking garbage with no error at all. The pattern is that the loud failures are the easy ones — it’s the silent ones that ship to production.
Production case studies & war stories
Theory is cheap; here are two incidents shaped exactly like ones teams hit in production, and the lesson each one teaches. Both are avoidable with the hardening in Section B, which is why that section exists.
War story 1: the health check that killed a healthy pod
Setup. A team shipped a version of the naive server close to Worked Example 1, but with one shortcut: the /generate handler called model.generate() directly inside the async def function instead of routing it through run_in_threadpool. It passed every test — a single curl request worked fine, latency looked right, and it sailed through staging, which only ever sent one request at a time.
What happened in production. Once real traffic arrived — five or six concurrent users, nothing exotic — requests started stacking up, and every few minutes the pod would restart. On-call saw 502s from the load balancer and Kubernetes events showing Liveness probe failed followed by a container restart, which looked like a memory leak or a crash. It was neither. model.generate() is a long-running, synchronous, CPU-orchestrated call; running it directly in an async def handler blocks Python’s single event loop for the entire generation — often several seconds. While it was blocked, the event loop could not service any other coroutine, including the /healthz liveness endpoint on the same process. Kubelet’s liveness probe timed out, decided the process was unresponsive, and killed it mid-request — dropping every in-flight generation, including ones that had nothing to do with the blocked request.
Diagnosis. A py-spy dump against the running process during a slow period showed every worker thread’s Python-level stack sitting inside torch.nn.functional deep under model.generate, with the asyncio event loop task for /healthz never getting scheduled. That is the smoking gun for a blocked event loop: the liveness endpoint’s code is fine, it simply never runs.
Lesson. Never run long, synchronous, CPU/GPU-bound work directly inside an async def handler — always route it through run_in_threadpool (as in Worked Example 1) or a separate process/worker pool. Just as important: keep liveness probes independent of the workload. /healthz should answer instantly regardless of GPU saturation (Section B’s version does, deliberately); it is /readyz that should reflect load, and unlike a liveness failure, a readiness failure only stops new traffic — it does not kill in-flight work. Conflating the two, or letting the workload block the endpoint that answers either, turns ordinary load into a self-inflicted outage.
Saying it out loud. This one’s my favorite because the bug and the symptom look nothing alike. A team called
model.generate()directly inside an async handler — worked perfectly in staging, which only ever sent one request at a time. In production with five or six concurrent users, pods started restarting every few minutes with liveness probe failures, which everyone read as a memory leak. It wasn’t: generate is a long synchronous call, so it blocked Python’s single event loop for seconds at a time, and the health endpoint living on that same loop simply never got scheduled. Kubelet saw no response, concluded the process was dead, and killed it mid-generation — taking down every unrelated in-flight request too. Two lessons: never run blocking work inside an async handler, and never let your liveness probe depend on the workload.
War story 2: the silent OOM from an unbounded batch
Setup. A different team’s server accepted arbitrary concurrent requests with no admission control, and let clients pass their own max_new_tokens and prompt length with no server-side cap — reasoning that “the model will just run a bit slower” under load. There was no memory math behind that assumption; nobody had run the calculation in Worked Example 2 for their actual GPU and model.
What happened in production. A marketing push drove a burst of traffic, much of it from a batch-import script sending long documents with max_new_tokens: 4096 set on every call, all fired concurrently. Each concurrent request’s KV cache grew independently and kept growing every decode step; there was nothing gating how many of these could run at once. Partway through the spike, the CUDA allocator ran out of memory mid-batch. Because there was no isolation between requests, the failure did not stay contained to the offending calls: some in-flight requests raised CUDA out of memory immediately, others returned corrupted or truncated output as the allocator scrambled to free fragmented memory, and a few hung entirely until the process was restarted — losing every request that happened to be in flight at that moment, including well-behaved ones from unrelated callers.
Diagnosis. Post-incident, the team ran the memory math from Worked Example 2 against their actual model and GPU for the first time and discovered the theoretical safe concurrency for full-length, full-max_new_tokens requests was around 4 — they had been letting 30-plus requests race for the GPU simultaneously with no cap, and it had simply not yet coincided with a burst large and long enough to blow the budget.
Lesson. Two independent fixes, both from Section B, and both necessary: (1) cap max_new_tokens and effective context length server-side, regardless of what the client requests — SERVER_MAX_NEW_TOKENS in Worked Example 1’s extended server exists precisely so a client’s number is a request, not a command; and (2) gate concurrency using the KV-cache math from Worked Example 2, not intuition — the semaphore in Section B turns “the model will just run a bit slower” into “the fourth-plus concurrent caller waits in a bounded queue or gets a clean 503,” which is a survivable, observable failure mode instead of an unbounded, correlated one. As a standing practice: monitor KV-cache/GPU-memory utilization as a first-class metric next to request count and latency — request count alone hid the real signal here until the GPU had already run out.
Saying it out loud. A team let clients pass their own
max_new_tokenswith no server-side cap and no admission control, on the theory that “it’ll just run a bit slower.” Then a batch-import script fired long documents withmax_new_tokens: 4096all at once. Each request’s KV cache grew independently every decode step until the allocator ran dry mid-batch — and because nothing isolated requests from each other, it wasn’t contained: some raised OOM, some returned truncated output, some hung until restart, and well-behaved callers went down with them. Post-mortem, they ran the memory math for the first time and found their safe concurrency was about four; they’d been running thirty-plus. Two fixes, both necessary: cap output length server-side, and derive your concurrency limit from the KV-cache math rather than intuition.
Tools comparison (brief — pointers to later chapters)
| What it is | Batching | Best for | Covered in | |
|---|---|---|---|---|
| Raw Transformers + FastAPI | Hand-rolled server (this chapter) | None (DIY, or a semaphore gate as in Section B) | Learning, prototypes, custom logic | This chapter |
| vLLM | High-throughput OSS inference engine | Continuous + PagedAttention | Throughput-critical OSS serving; OpenAI-compatible API | Chapter 5 |
| TGI (Text Generation Inference) | Hugging Face’s production server (Rust + Python) | Continuous, tensor-parallel sharding | HF ecosystem, Inference Endpoints | referenced Ch. 5 |
| Triton Inference Server | NVIDIA multi-framework server | Dynamic batching; pairs with TensorRT-LLM / vLLM backends | Multi-model, mixed workloads, tight NVIDIA stack | Chapter 12 |
Rule of thumb: build the raw server once to understand the loop, then never ship it as-is. For anything real, reach for an engine that does continuous batching and paged KV memory. TorchServe is another general-purpose model server in this space, but for LLMs specifically the KV-cache-aware engines (vLLM, TGI, TensorRT-LLM) win decisively. The hardened version in Section B narrows — but does not close — that gap: it makes the naive server safe to run under real traffic (bounded concurrency, structured errors, correct health signals), which is table stakes for any service; it does not give you continuous batching’s throughput, which requires the engine-level scheduling covered in Chapter 5.
Saying it out loud. My honest rule is: build the raw server once so you understand the loop, then never ship it as-is. For anything with real concurrency you want an engine that does continuous batching and paged KV memory — vLLM if you want throughput and an OpenAI-compatible API, TGI if you’re deep in the Hugging Face ecosystem, Triton if you’re serving many models across frameworks in an NVIDIA shop. Hardening the raw server the way we just did makes it safe — bounded concurrency, structured errors, honest health signals — but safe isn’t the same as fast. It still runs one request’s decode loop per slot, so it never gets the throughput win, and that gap is the actual reason you migrate, not “vLLM is industry standard.”
The 2025–2026 landscape
Everything above is timeless mechanism — prefill/decode, the KV cache, memory math — but the tooling and API surface around it moves fast. Here is what “basic serving” looks like against the current landscape, with sources, so you can reason about where the raw-server pattern in this chapter still fits and where the industry has moved on.
The default artifact format is safetensors, and the reason is security, not just speed
By 2025–2026, safetensors (https://github.com/huggingface/safetensors) is the default distribution format on the Hugging Face Hub and in production runtimes (TGI’s own docs describe why: https://huggingface.co/docs/text-generation-inference/main/en/conceptual/safetensors). The mechanism, worth restating precisely because it comes up in interviews: PyTorch’s legacy checkpoint format is a pickle blob, and pickle.load is not a passive data format — it can invoke arbitrary Python callables encoded in the file, which is a documented remote-code-execution vector for a checkpoint downloaded from an untrusted source. safetensors stores only tensor bytes and a JSON metadata header, so there is no code path from “load this file” to “execute arbitrary code,” and because the layout is flat and contiguous, it also loads faster via mmap. If your serving pipeline still ingests raw .bin/.pt files from third parties without conversion, that is a supply-chain gap worth flagging, not a stylistic preference.
Saying it out loud. Everyone assumes safetensors won on speed, and the speed is real, but the actual reason is security. PyTorch’s old checkpoint format is a pickle blob, and unpickling isn’t passive parsing — it can call arbitrary Python constructors named inside the file, which is a documented remote-code-execution path for any weights you downloaded from someone else. Safetensors stores only raw tensor bytes plus a JSON header of shapes and dtypes, so there’s simply no code path from “load this file” to “execute something.” The speed comes free as a side effect, because a flat contiguous layout can be memory-mapped instead of deserialized. Practical version: if your pipeline still ingests third-party
.binor.ptfiles without conversion, that’s a supply-chain gap, not a style preference.
Native structured outputs are now a first-class API feature, not a prompting trick
As of 2024–2026, “ask nicely for JSON” has been replaced by API-level guarantees. OpenAI’s Structured Outputs (announced 2024, current docs at https://developers.openai.com/api/docs/guides/structured-outputs, original post at https://openai.com/index/introducing-structured-outputs-in-the-api/) let you pass a JSON Schema and get a response guaranteed to satisfy it — no missing keys, no hallucinated enum values, no manual retry-on-invalid-JSON loop. Anthropic and Google expose comparable schema-constrained/tool-argument guarantees on their current APIs. Mechanically, this is not a smarter prompt — it is constrained (grammar-guided) decoding: the schema is compiled into a finite-state or pushdown automaton over the tokenizer’s vocabulary, and at every decode step the set of grammatically-valid next tokens is computed and every other token’s logit is masked to (-\infty) before sampling. The open-source engines implement this directly — vLLM’s structured-outputs backends (https://docs.vllm.ai/en/latest/features/structured_outputs/) support both the outlines library and XGrammar, a purpose-built constrained-decoding engine (paper: https://arxiv.org/pdf/2411.15100) chosen for compiling grammars fast enough to mask logits every single decode step without becoming the bottleneck. The practical implication for this chapter’s naive server: adding structured outputs to model.generate() directly means wiring a LogitsProcessor that does this masking yourself (Transformers exposes the hook, but you own the FSM); a dedicated engine gives you response_format={"type": "json_schema", ...} for free. This is a concrete, common reason teams move off the raw server well before they need continuous batching.
Saying it out loud. “Please respond in JSON” is dead as an engineering technique — every major API now takes a JSON Schema and guarantees the response satisfies it. Under the hood it’s constrained decoding: the schema compiles into an automaton over the tokenizer’s vocabulary, and at each decode step every token that would violate the grammar gets its logit masked to negative infinity before sampling. So you get no missing keys, no invented enum values, and no retry-until-it-parses loop. The catch is that this cost lands on every decode step, which is why engines use purpose-built compilers like XGrammar rather than re-validating from scratch — and it’s a very common reason teams leave the raw server long before they ever need continuous batching.
Prompt / context caching is now standard, and it changes the economics of “basic serving”
All three major hosted APIs now cache repeated prompt prefixes so you do not pay full price to re-process a system prompt or a large shared context on every call:
- Anthropic prompt caching (https://platform.claude.com/docs/en/build-with-claude/prompt-caching): you mark a
cache_controlbreakpoint; a cache write costs a multiplier over the base input rate (roughly 1.25× for a 5-minute TTL, 2× for a 1-hour TTL), while a cache read costs roughly 0.1× the base rate — a ~90% discount on tokens the model has already “seen” in cache. Minimum cacheable prefix length is model-dependent (from roughly 1,024 tokens up to 4,096 on smaller models); below that, caching is silently skipped, so check the response’scache_read_input_tokens/cache_creation_input_tokensfields rather than assuming. - OpenAI automatic prompt caching (https://developers.openai.com/api/docs/guides/prompt-caching, announced at https://openai.com/index/api-prompt-caching/): caching is automatic above roughly a 1,024-token prefix, keyed by an exact-prefix hash of (typically) the first portion of the prompt, and routed by a
prompt_cache_keyso repeated calls land on a machine likely already holding the cached prefix; cached content is retained on the order of minutes to about an hour depending on traffic and model. - Gemini context caching (https://ai.google.dev/gemini-api/docs/caching): supports both implicit caching (automatic, enabled by default on current Gemini models) and explicit caching (you create and manage a cache object yourself, useful for a large shared document or system context reused across many calls), again with a per-model minimum token threshold before caching activates.
Why this matters for a chapter about a raw, self-hosted server: this is precisely the prefix-reuse problem that a naive model.generate() loop does nothing about — every call re-runs prefill from scratch, even if 90% of the prompt (a shared system prompt, a long set of tool definitions, a large retrieved context) is byte-identical to the previous call. Self-hosted engines solve the same problem under a different name — prefix caching / automatic prefix reuse (vLLM’s implementation is often discussed alongside RadixAttention-style prefix trees) — by keeping the KV cache for a previously-seen prefix resident and reusing it instead of recomputing prefill. If your workload has long, repeated system prompts or tool schemas, prefix caching is frequently a bigger win than raw batching throughput, and it is a capability the raw server in this chapter simply does not have.
Saying it out loud. If your prompts share a long prefix — a big system prompt, a pile of tool definitions, a retrieved document — you’re re-running prefill on identical bytes every single call, and everybody now has a fix for that. On hosted APIs it’s prompt caching: Anthropic charges roughly 1.25x base rate to write a cache entry and about 0.1x to read it, so a cache hit is around a ninety percent discount on those tokens; OpenAI does it automatically above about a 1,024-token prefix. Self-hosted engines call the same idea prefix caching — they keep the previously computed KV entries resident instead of recomputing them. The thing to say: this is a completely separate axis from batching, and for prefix-heavy workloads it’s often the bigger win — and it’s a capability the raw
generate()loop simply does not have.
The shift from raw generate() to production runtimes is now the norm, not the exception
The direction of travel across 2025–2026 write-ups on production LLM serving (see, e.g., the state-of-the-field surveys and deployment guides in Further Reading) is consistent: teams prototype against transformers.generate() and move to a dedicated runtime — vLLM, TGI, TensorRT-LLM, or SGLang — as soon as concurrency, latency SLOs, or cost per token matter, because continuous batching plus paged KV memory is very hard to reproduce well by hand, and because these engines have absorbed structured-output support, prefix caching, speculative decoding, and multi-GPU sharding as built-in features rather than bespoke code. That does not make this chapter’s raw server obsolete knowledge — it is the mental model every one of those engines optimizes against — but it does mean the honest scope of “basic serving” in 2026 is: understand the loop and the memory math cold, harden it enough to survive real traffic if you must run it (Section B), and know precisely which of its gaps (batching, prefix reuse, structured decoding) are the reasons you reach for vLLM/TGI/Triton rather than vague “it’s faster” hand-waving.
Saying it out loud. The industry pattern is consistent: prototype on
transformers.generate(), then move to a real runtime — vLLM, TGI, TensorRT-LLM, SGLang — the moment concurrency, latency SLOs, or cost per token start to matter. The reason isn’t that engines are magically faster; it’s that continuous batching plus paged KV memory is genuinely hard to reproduce by hand, and those engines have already absorbed structured outputs, prefix caching, speculative decoding, and multi-GPU sharding as built-in features instead of bespoke code you maintain. That doesn’t make the raw server useless knowledge — it’s the mental model those engines are optimizing against. What it does mean is you should be able to say exactly which gap pushed you over, not hand-wave that “it’s faster.”
Where “basic serving” still fits
None of the above obsoletes this chapter — it contextualizes it. A raw, hardened generate() server is still the right answer when: you need custom, non-standard generation logic an engine does not expose; you are serving a small number of concurrent users where continuous batching’s throughput gains do not matter; you are prototyping or teaching; or you are running a model on hardware/software combinations the major engines do not yet support well. The 2025–2026 baseline expectation, though, is that you can articulate exactly why you are not using vLLM/TGI in a given case — “we don’t need it yet, here’s the math” — rather than not knowing the alternative exists.
Saying it out loud. The raw hardened server is still the right call in a few real cases: you need custom generation logic no engine exposes, you’re serving a handful of concurrent users where batching gains don’t materialize, you’re prototyping or teaching, or you’re on hardware the big engines don’t support well. What’s changed by 2026 isn’t that this pattern is wrong — it’s that the burden of proof moved. The expectation now is that you can articulate why you’re not on vLLM with actual numbers, something like “our peak is four concurrent users, continuous batching buys us nothing at that scale, here’s the math.” Not knowing the alternative exists is the answer that loses you the interview; deliberately deferring it with a reason is the answer that wins.
Interview mastery
This section replaces and substantially expands the old “production checklist.” It is organized as: a bank of Q&A with model answers, a system-design prompt worked end to end, and a red-flags-vs-green-flags table you can use to grade yourself or a candidate.
Q&A bank
-
“Walk me through a request.” Request -> tokenize (+ chat template) -> prefill (compute-bound, sets TTFT) -> decode loop reusing the KV cache (memory-bound, sets TPOT) -> detokenize -> response. Naming the prefill/decode split unprompted is the single strongest signal you understand serving; see the 60-second answer above for the full version.
-
“How much GPU memory does model X need?” Weights = params × bytes/param (~2 GB/B in FP16/BF16); plus KV cache = (2 \times L \times H_{kv} \times D_h \times b) per token per request; plus activations/overhead. Know the order of magnitude — ~0.5 MB/token, ~2 GB per 4K-token request for a 7B model — and be able to say why GQA shrinks the KV term specifically.
-
“Your p99 latency is bad but the GPU is at 30% util — why?” Decode is memory-bandwidth-bound and you are serving serially or with tiny batches; the compute units sit idle waiting on memory traffic. Add continuous batching to convert that idle compute into throughput; a semaphore-gated raw server (Section B) prevents overload but does not fix this — it still runs one request’s decode loop at a time per slot.
-
“How do you trade latency for throughput?” Batch size is the dial: larger batches amortize weight reads across more requests (higher throughput) at the cost of per-request TTFT/TPOT, because a given request now waits on or shares cycles with others. Pick the setting from your SLO; continuous batching gets most of the throughput without static batching’s head-of-line blocking.
-
“What’s your OOM story?” Memory math up front (Worked Example 2), server-side caps on
max_new_tokens/context (never trust client values alone), concurrency gated to a number derived from that math (not intuition), quantization as a lever, paged KV in a real engine. Monitor KV-cache/GPU-memory utilization as a first-class metric — see war story 2 for what happens when nobody does. -
“Why not just
await model.generate()in the handler?” It blocks the single-threaded asyncio event loop for the whole generation, freezing every other coroutine on that process — including your own health checks. Offload to a threadpool/worker, or use an engine with its own scheduler. See war story 1 for the exact failure this produces in production. -
“Which metrics do you alert on?” TTFT, TPOT/ITL, E2E — all at p50/p95/p99 — plus throughput (tok/s, req/s), queue depth, KV-cache/GPU-memory utilization, and error/OOM rate. Percentiles, not means; a healthy-looking average can hide starved tail requests.
-
“When do you reach for vLLM/TGI/Triton over your own server?” As soon as you need real concurrency: continuous batching plus paged KV are hard to build well and are the whole point of those engines. Roll your own to learn, for genuinely custom logic, or for small enough traffic that batching’s gains don’t matter — and be able to say which of those applies to you.
-
“Static vs dynamic vs continuous batching — what’s the actual difference?” Static and dynamic batching both batch at the request level and run the batch to completion, so a short request can be stuck behind a long one (head-of-line blocking). Continuous batching operates at the decode-step level: a finished sequence leaves the batch and a new one joins on the very next iteration, so the GPU stays full even when request lengths vary wildly — which is the normal case in production.
-
“Explain GQA’s effect on serving, not just training.” Grouped-query attention reduces the number of KV heads (H_{kv}) relative to query heads. Since KV-cache bytes per token scale linearly in (H_{kv}), this directly shrinks the KV cache per token — e.g., dropping from 32 to 8 KV heads cuts KV-cache memory 4×, which multiplies directly into how many concurrent requests fit on a given GPU. It is a serving-cost decision baked into the architecture, not just an efficiency footnote from the model card.
-
“Does speculative decoding belong in ‘basic serving’?” It’s an optimization layered on top of the decode loop: a small draft model proposes several tokens, the full model verifies them in one parallel forward pass, and correct guesses are accepted for free, cutting the number of full-model decode steps needed. It doesn’t change the memory story (you still need the KV cache for both models) and it adds real scheduling complexity, so in practice it shows up in dedicated engines, not the raw server in this chapter — know what it is and why it doesn’t fit here yet.
-
“How does prompt caching change things at the API-provider level, and does the raw server in this chapter get that benefit?” No — a naive
model.generate()loop re-runs prefill from scratch on every call, even for a byte-identical shared system prompt or tool schema. Hosted APIs (Anthropic, OpenAI, Gemini) and self-hosted engines both solve this via prefix reuse — either provider-side prompt caching or engine-side automatic prefix caching — by keeping a previously-computed KV-cache prefix resident and skipping its recomputation. It’s a distinct optimization axis from batching, and workloads with long, repeated prefixes often benefit from it more. -
“What is ‘structured output’ / JSON mode under the hood?” Grammar-constrained (constrained) decoding: the JSON Schema or grammar is compiled into an automaton over the vocabulary, and at every decode step the logits for grammatically-invalid tokens are masked to (-\infty) before sampling, so every generated token is schema-valid by construction — not by asking nicely and retrying on failure. This is a decode-time cost (computing/re-checking the valid token set every step), which is why engines use purpose-built compilers (e.g., XGrammar) rather than naive per-step re-validation.
-
“Why bound the request queue instead of letting it grow to absorb bursts?” An unbounded queue doesn’t remove overload, it hides it — it turns a capacity problem into unbounded process memory growth and lets client-side timeouts fire anyway once wait time exceeds them, except now invisibly. A bounded queue with a fast, explicit rejection (503) gives callers, load balancers, and autoscalers an honest signal to react to.
-
“What’s the difference between your liveness and readiness checks, and why does it matter for a GPU pod specifically?” Liveness asks “is the process alive enough to not be killed and restarted” and must stay cheap and independent of GPU load, or a temporarily saturated-but-healthy pod gets killed mid-request (war story 1). Readiness asks “should traffic be routed here right now” and should reflect real capacity — model loaded, not already at its concurrency cap — so a load balancer stops sending new work without killing in-flight work.
-
“Quantization or a smaller model — how do you decide?” They are not interchangeable levers even when they land at similar memory footprints. Quantizing a larger model (e.g., 7B to INT8) keeps its underlying capability at a precision cost; swapping to a smaller model in full precision changes the capability itself. Run the memory math for both (Worked Example 2’s quantization variant), then validate quality against your own eval set — never assume either choice is quality-neutral by default.
-
“What would you monitor in week one of running this in production that you might not think of on day one?” Beyond the obvious latency/throughput dashboards: KV-cache/GPU-memory utilization over time (not just point-in-time), queue-depth and 503-rate as a leading indicator of undersized concurrency limits, p99-to-p50 latency ratio as a serialization signal, and cold-start duration on new pods — each of these is a metric that stayed invisible right up until the two war stories above turned into incidents.
-
“If you could only add one thing to the naive server before it sees production traffic, what would it be?” A defensible answer names the concurrency gate sized from real memory math (Worked Example 2 + Section B) over any other single change — it is the one guard that converts an unbounded-failure incident (war story 2) into a bounded, observable one, and most other hardening (structured errors, readiness checks) matters most in service of that same goal: knowing and respecting the server’s actual capacity.
System design prompt: “One GPU, 50 requests per second — walk me through your design”
This is a common live prompt because it forces you to reconcile everything above under a concrete constraint. A worked sketch:
Step 1 — clarify the workload. Ask: what’s the model size, typical prompt/output length, and latency SLO? Assume, for concreteness: a 7B model in BF16 on a single 80 GB H100, average prompt 300 tokens, average output 150 tokens, target p95 E2E under ~2s.
Step 2 — do the memory math first, not last. Weights: (7\text{B} \times 2\text{ bytes} \approx 14\text{ GB}). KV cache per token (from Worked Example 2’s formula, LLaMA-style with GQA, roughly ~0.15–0.5 MB/token depending on (H_{kv})) times a typical active sequence length of ~450 tokens (300 prompt + 150 output) gives on the order of 100–200 MB of KV cache per request. On an 80 GB GPU with ~60 GB free after weights and overhead, that is theoretical room for hundreds of concurrent sequences purely on memory — the real constraint at 50 req/s is compute and scheduling, not raw KV capacity, which is itself a useful thing to say out loud (it shows you check both constraints instead of assuming one).
Step 3 — pick the serving layer. At 50 req/s sustained, this is squarely continuous-batching territory: reach for vLLM (or TGI/TensorRT-LLM) rather than a hand-rolled server — the naive per-request server from this chapter cannot get anywhere close to 50 req/s on one GPU because it never overlaps requests’ decode steps. State this directly: “I would not build this on raw transformers.generate(); I’d deploy vLLM with continuous batching and PagedAttention on this GPU.”
Step 4 — size the batch/concurrency for the SLO. With continuous batching, throughput scales with how many sequences’ decode steps you can interleave before per-step latency (which grows mildly with batch size) blows the p95 SLO. Talk through the dial explicitly: push batch size up until TPOT growth threatens the 2s target, then back off — this is the latency/throughput tradeoff from the metrics section, now as a concrete scheduling decision rather than an abstract tradeoff.
Step 5 — add the operational layer. Structured error handling and admission control (Section B) still apply even in front of vLLM — you still want a bounded request queue in your API gateway, health/readiness endpoints that don’t depend on GPU load, and metrics on TTFT/TPOT/queue depth/GPU utilization. If a burst pushes sustained demand past what continuous batching on one GPU can sustain at your SLO, the answer is horizontal scaling (more GPU replicas behind a load balancer) or admission control (shed load past capacity), not trying to fit more into one GPU than the SLO tolerates.
Step 6 — name the next lever if traffic grows. Prefix/prompt caching if requests share system prompts or tools; quantization (INT8/FP8/INT4) to shrink weights and free more room for KV cache and larger batches; speculative decoding to cut decode steps for latency-sensitive traffic. Naming these as considered and deferred, with a reason, is stronger than listing them as things you’d “probably also do.”
The throughline an interviewer is grading: did you reach for memory math before guessing, did you correctly identify that a single naive server cannot hit this target, and did you reason about the latency/throughput dial explicitly rather than asserting “vLLM is fast.”
Saying it out loud. The move on a question like this is to clarify first, then do the memory math before you pick any technology. So: what’s the model size, typical prompt and output length, and what’s the latency SLO? Say it’s a 7B in BF16 on an 80 GB H100, 300-token prompts, 150-token outputs, p95 under two seconds. Weights are 14 GB, leaving around 60 free; at roughly 100 to 200 megabytes of KV cache per request that’s theoretical room for hundreds of sequences — so memory isn’t the binding constraint here, scheduling and compute are, and saying that out loud shows you checked both. Then you pick continuous batching, because 50 requests per second is flatly impossible on a serial server. What they’re grading is whether you reached for arithmetic before you reached for a tool name.
Red flags vs green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Explaining a request | Talks about “the model” as one black-box step | Names prefill and decode separately, with different bottlenecks |
| GPU memory | Guesses a number, or only mentions weights | Computes weights + KV cache + overhead, cites the KV-cache-per-token formula |
| Concurrency | “Just add more workers” | Ties concurrency limits to the memory math, mentions bounded queues and load shedding |
| Async server design | Doesn’t know model.generate() blocks the event loop | Volunteers run_in_threadpool / worker-process reasoning unprompted |
| Health checks | Treats liveness and readiness as the same thing | Explains why liveness must stay load-independent and readiness should reflect capacity |
| Batching | “Batching makes it faster” with no mechanism | Explains static vs dynamic vs continuous batching and head-of-line blocking |
| When to use an engine | Says “vLLM is industry standard” with no reasoning | States the specific gap (continuous batching, paged KV, prefix caching) the raw server has |
| Metrics | Reports only average latency | Reports TTFT/TPOT/E2E at p50/p95/p99 plus throughput and queue depth |
| Structured output | Thinks it’s a prompting trick | Explains constrained decoding / logit masking against a compiled grammar |
| Incidents | Has no story, or blames “the GPU” vaguely | Can narrate a specific failure (blocked event loop, unbounded batch OOM) and the fix |
Glossary — quick reference for review
| Term | One-line definition |
|---|---|
| Prefill | The single, parallel forward pass over the whole prompt; compute-bound; sets TTFT. |
| Decode | The one-token-at-a-time generation loop after prefill; memory-bandwidth-bound; sets TPOT. |
| KV cache | Stored key/value vectors per token/layer/head, reused every decode step instead of recomputed. |
| TTFT | Time to first token — request submitted to first output token received. |
| TPOT / ITL | Time per output token / inter-token latency — average gap between subsequent tokens. |
| GQA (grouped-query attention) | Fewer KV heads than query heads; shrinks KV-cache bytes/token linearly. |
| Static batching | Pad and run a fixed batch of requests to completion together; head-of-line blocking. |
| Dynamic batching | Briefly buffer arriving requests into a batch, then run it to completion; still head-of-line-blocked. |
| Continuous batching | Schedule at the decode-step granularity; sequences join/leave the running batch every iteration. |
| PagedAttention | KV cache stored in fixed-size non-contiguous pages, like OS virtual memory, to cut fragmentation. |
| Prefix caching | Reusing a previously computed KV-cache prefix across requests sharing a prompt prefix. |
| Prompt / context caching | The hosted-API equivalent of prefix caching: discounted, cached reuse of a repeated prompt prefix. |
| Structured outputs | API/engine-guaranteed schema-conformant generation via constrained (grammar-guided) decoding. |
| Constrained decoding | Masking invalid-per-grammar tokens’ logits to (-\infty) at every decode step. |
| safetensors | Tensor-only serialization format; no arbitrary code execution on load, unlike pickle. |
| Quantization | Storing weights (and sometimes activations/KV cache) in fewer bits per value to save memory. |
| Speculative decoding | A small draft model proposes tokens; the full model verifies several in one pass to cut decode steps. |
| Liveness probe | “Is the process alive enough not to be restarted” — should be cheap and load-independent. |
| Readiness probe | “Should traffic be routed here right now” — should reflect real load/capacity. |
| Backpressure / load shedding | Rejecting requests past a bounded capacity (e.g., 503) instead of queueing without bound. |
Further reading
Core mechanism, generation, and this chapter’s server
- Hugging Face — Text generation tutorial (
generate): https://huggingface.co/docs/transformers/en/llm_tutorial - Hugging Face — Generation config /
GenerationConfigreference: https://huggingface.co/docs/transformers/en/main_classes/text_generation - FastAPI documentation: https://fastapi.tiangolo.com/
- FastAPI — concurrency and async/await: https://fastapi.tiangolo.com/async/
- httpx (used for the load test): https://www.python-httpx.org/
- Kubernetes — configuring liveness, readiness, and startup probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
KV cache, memory, and batching
- NVIDIA — Mastering LLM Techniques: Inference Optimization (KV cache, batching): https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/
- KV cache memory calculation (worked example): https://mbrenndoerfer.com/writing/kv-cache-memory-calculation-llm-inference-gpu
- Pierre Lienhart — LLM Inference Series: KV caching, a deeper look: https://medium.com/@plienhar/llm-inference-series-4-kv-caching-a-deeper-look-4ba9a77746c8
- vLLM — PagedAttention & continuous batching overview: https://www.runpod.io/articles/guides/vllm-pagedattention-continuous-batching
- Anyscale — Understand LLM latency and throughput metrics (TTFT/TPOT/ITL/TPS/RPS): https://docs.anyscale.com/llm/serving/benchmarking/metrics
Model formats and safety
safetensors— project and rationale: https://github.com/huggingface/safetensors- Text Generation Inference docs — why safetensors: https://huggingface.co/docs/text-generation-inference/main/en/conceptual/safetensors
Structured / constrained decoding
- OpenAI — Introducing Structured Outputs in the API: https://openai.com/index/introducing-structured-outputs-in-the-api/
- OpenAI — Structured model outputs guide: https://developers.openai.com/api/docs/guides/structured-outputs
- vLLM — Structured Outputs feature docs: https://docs.vllm.ai/en/latest/features/structured_outputs/
- XGrammar — Flexible and Efficient Structured Generation Engine for LLMs (paper): https://arxiv.org/pdf/2411.15100
- Red Hat Developer — Structured outputs in vLLM: guiding AI responses: https://developers.redhat.com/articles/2025/06/03/structured-outputs-vllm-guiding-ai-responses
Prompt / context caching (2025–2026 provider docs)
- Anthropic — Prompt caching: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- OpenAI — Prompt caching guide: https://developers.openai.com/api/docs/guides/prompt-caching
- OpenAI — Prompt Caching in the API (announcement): https://openai.com/index/api-prompt-caching/
- Google — Gemini API context caching: https://ai.google.dev/gemini-api/docs/caching
Production serving engines
- Text Generation Inference (TGI) docs: https://huggingface.co/docs/text-generation-inference/en/index
- TGI source and features: https://github.com/huggingface/text-generation-inference
- NVIDIA Triton Inference Server: https://github.com/triton-inference-server/server
- vLLM documentation: https://docs.vllm.ai/
Where this goes next: Chapter 2 containerizes this server; Chapter 4 load-tests it to see the serial bottleneck at larger scale than Section B’s local script; Chapter 5 replaces it with vLLM and continuous batching to fix everything this chapter exposed — and revisits prefix caching and structured decoding as engine-native features rather than DIY additions.
Topic 2: Containerization with Docker
What You’ll Learn
This topic teaches you how to:
- Containerize LLM serving applications
- Create efficient Docker images for ML workloads
- Handle large model files
- Use multi-stage builds
- Configure environment variables
- Optimize image size
Why We Need This
Business Need
- Consistency: Same model behavior in dev, staging, production
- Speed to market: Deploy faster without environment setup
- Cost reduction: Standardized deployment reduces ops overhead
- Compliance: Reproducible deployments for audit trails
Technical Need
- Environment consistency: Python version, dependencies, system libraries
- Isolation: No conflicts between different ML projects
- Portability: Run anywhere (local, cloud, edge)
- Versioning: Tag images with model versions for rollback
Real-World Impact
Without containerization:
- ❌ “Works on my machine” problems
- ❌ Difficult to reproduce production issues
- ❌ Slow deployments (manual environment setup)
- ❌ Can’t scale easily (each server needs manual setup)
Industry Use Cases
1. Multi-Cloud Deployment
Company: Enterprise ML platforms Use Case:
- Same Docker image runs on AWS, GCP, Azure
- No vendor lock-in, easy migration
Example:
# Build once, deploy anywhere
docker build -t llm-model:v1.0 .
# Deploy to AWS
docker push llm-model:v1.0
# Deploy to GCP
docker push llm-model:v1.0
2. CI/CD Pipelines
Company: All tech companies Use Case:
- Automated testing in containers
- Consistent environments across pipeline stages
Example:
# GitHub Actions / GitLab CI
- name: Test model
run: |
docker build -t test-model .
docker run test-model pytest
3. Edge Deployment
Company: IoT, autonomous vehicles Use Case:
- Deploy models to edge devices
- Same container on server and edge
Example:
# Deploy to edge device
docker save llm-model:v1.0 | ssh edge-device docker load
4. Model Versioning & Rollback
Company: ML platforms (MLflow, Weights & Biases) Use Case:
- Tag images with model versions
- Quick rollback to previous version
Example:
docker tag llm-model:latest llm-model:v1.2.3
docker tag llm-model:latest llm-model:v1.2.2 # Rollback
5. Development Teams
Company: All companies with ML teams Use Case:
- New developers can run models immediately
- No “works on my machine” issues
Example:
# New developer setup
git clone repo
docker-compose up # Everything works!
Industry-Standard Boilerplate Code
Production Dockerfile (Industry Standard)
# Multi-stage build for production (used by: Google, AWS, Microsoft)
# Stage 1: Builder
FROM python:3.9-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Runtime (minimal image)
FROM python:3.9-slim
WORKDIR /app
# Copy Python dependencies from builder
COPY --from=builder /root/.local /root/.local
# Make sure scripts are in PATH
ENV PATH=/root/.local/bin:$PATH
# Create non-root user (security best practice)
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# Copy application
COPY --chown=appuser:appuser . .
# Health check (for K8s, load balancers)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
# Expose port
EXPOSE 8000
# Run application
CMD ["python", "app.py"]
Docker Compose for Development (Industry Standard)
# docker-compose.yml
# Used by: Development teams, local testing
version: '3.8'
services:
llm-serving:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- MODEL_NAME=gpt2
- DEVICE=cpu
- LOG_LEVEL=INFO
volumes:
# Mount code for development (hot reload)
- .:/app
# Mount models directory
- ./models:/app/models
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# Optional: Add monitoring
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
Build & Deploy Script (Industry Standard)
#!/bin/bash
# build-and-deploy.sh
# Used by: CI/CD pipelines, deployment automation
set -e # Exit on error
IMAGE_NAME="llm-serving"
VERSION="${1:-latest}"
REGISTRY="your-registry.com" # Docker Hub, ECR, GCR, etc.
echo "Building image: ${IMAGE_NAME}:${VERSION}"
# Build image
docker build -t ${IMAGE_NAME}:${VERSION} .
# Tag for registry
docker tag ${IMAGE_NAME}:${VERSION} ${REGISTRY}/${IMAGE_NAME}:${VERSION}
docker tag ${IMAGE_NAME}:${VERSION} ${REGISTRY}/${IMAGE_NAME}:latest
# Push to registry
docker push ${REGISTRY}/${IMAGE_NAME}:${VERSION}
docker push ${REGISTRY}/${IMAGE_NAME}:latest
echo "Image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
# Deploy (example for K8s)
# kubectl set image deployment/llm-serving llm-serving=${REGISTRY}/${IMAGE_NAME}:${VERSION}
GPU Dockerfile (Industry Standard)
# GPU-enabled Dockerfile
# Used by: Production ML serving, high-performance inference
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
# Install Python
RUN apt-get update && apt-get install -y \
python3.9 \
python3-pip \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install PyTorch with CUDA
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# Install other dependencies
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["python3", "app.py"]
Usage in Production
# Build for production
docker build -t llm-serving:prod -f Dockerfile.prod .
# Run with GPU
docker run --gpus all -p 8000:8000 llm-serving:prod
# Run with resource limits
docker run \
--memory="4g" \
--cpus="2.0" \
-p 8000:8000 \
llm-serving:prod
# Run in production (with restart policy)
docker run -d \
--name llm-serving \
--restart unless-stopped \
-p 8000:8000 \
llm-serving:prod
Key Concepts
Dockerfile Basics
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Multi-Stage Builds
Split build into stages to reduce final image size:
- Builder stage: Install dependencies, download models
- Runtime stage: Copy only what’s needed
Layer Caching
Order matters! Put frequently changing files last:
# Good: Dependencies change less often
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . . # Code changes more often
Dockerfile Examples
Basic Dockerfile
See Dockerfile.basic for a simple example.
Optimized Dockerfile
See Dockerfile.optimized for:
- Multi-stage build
- Model caching
- Smaller image size
- Better layer caching
GPU Dockerfile
See Dockerfile.gpu for CUDA/GPU support.
Building Images
Basic Build
docker build -t llm-serving:latest .
With Build Args
docker build \
--build-arg MODEL_NAME=gpt2 \
--build-arg PYTHON_VERSION=3.9 \
-t llm-serving:gpt2 .
Multi-stage Build
docker build -f Dockerfile.optimized -t llm-serving:optimized .
Running Containers
CPU Mode
docker run -p 8000:8000 llm-serving:latest
GPU Mode (NVIDIA Docker)
docker run --gpus all -p 8000:8000 llm-serving:gpu
With Environment Variables
docker run \
-p 8000:8000 \
-e MODEL_NAME=gpt2 \
-e MAX_LENGTH=100 \
llm-serving:latest
With Volume Mounts (for models)
docker run \
-p 8000:8000 \
-v ./models:/app/models \
llm-serving:latest
Docker Compose
For multi-container setups, use docker-compose.yml:
- Application container
- Model storage
- Monitoring
- Load balancer
Image Optimization
Reduce Image Size
- Use slim base images:
python:3.9-sliminstead ofpython:3.9 - Multi-stage builds: Don’t include build tools in final image
- Remove cache:
pip install --no-cache-dir - Combine RUN commands: Fewer layers = smaller image
Speed Up Builds
- Layer caching: Order Dockerfile commands by change frequency
- Build cache: Use
--cache-fromfor CI/CD - Parallel builds: Build multiple images simultaneously
Handling Large Models
Option 1: Download in Image
RUN python -c "from transformers import AutoModel; AutoModel.from_pretrained('gpt2')"
Pros: Self-contained Cons: Large image, slow builds
Option 2: Volume Mount
docker run -v ./models:/app/models ...
Pros: Small image, fast builds Cons: Need models on host
Option 3: Model Registry
Download from S3/GCS at runtime Pros: Flexible, versioned Cons: Requires network access
Best Practices
- Use .dockerignore: Exclude unnecessary files
- Tag images: Use semantic versioning
- Health checks: Add HEALTHCHECK instruction
- Non-root user: Run as non-root for security
- Resource limits: Set memory/CPU limits
- Logging: Configure proper logging
Exercises
- Build basic image: Create Dockerfile for basic serving
- Optimize image: Reduce image size by 50%
- Multi-stage build: Create optimized multi-stage Dockerfile
- GPU support: Add CUDA support to Dockerfile
- Docker Compose: Create compose file for full stack
Common Issues
Out of Memory
- Problem: Container runs out of memory
- Solution: Increase Docker memory limit or use smaller model
Slow Builds
- Problem: Building takes forever
- Solution: Use layer caching, download models in separate stage
GPU Not Available
- Problem:
CUDA not availablein container - Solution: Install nvidia-docker, use
--gpus allflag
Large Image Size
- Problem: Image is several GB
- Solution: Use multi-stage build, slim base images
Next Steps
- Topic 3: Deploy containers to Kubernetes
- Topic 4: Load test your containerized app
- Topic 5: Optimize for production with vLLM
Further Reading
Docker for GPU LLM Serving
Containerizing an LLM inference service so it runs the same on your laptop, in CI, and on a rented H100 — without a 40 GB image, a leaked token, or a
CUDA driver version is insufficientat 3 a.m.
Why this matters
An LLM server is not a normal web app. It links against CUDA, cuDNN, NCCL, and a specific PyTorch build; it needs a physical GPU exposed into the container; and it depends on multi-gigabyte model weights that you do not want to redownload on every restart. Get the containerization wrong and you hit one of a dozen classic failures: the image balloons to tens of gigabytes, the container can’t see the GPU, the CUDA version mismatches the host driver, your Hugging Face token ends up baked into a layer, or the server runs as root with no healthcheck and the orchestrator can’t tell it’s wedged.
Containers are also the unit that Kubernetes, Nomad, ECS, and every autoscaler schedule. A well-built image is the foundation for everything in later chapters (K8s, canary, autoscaling). This chapter is about getting that foundation right — and about what “right” means as of 2026, since this corner of the ecosystem (NVIDIA Container Toolkit, official framework images, and how weights get distributed) has moved substantially in the last two years.
The intuition first, then the exact mechanisms.
Saying it out loud. The reason containerizing an LLM server is different from containerizing a normal web app comes down to two things: it needs a physical GPU handed into the container, and it depends on tens of gigabytes of model weights you really don’t want to redownload on every restart. Get either wrong and you hit a very predictable set of failures — a 40 GB image, a container that can’t see the GPU, a
CUDA driver version is insufficienton some nodes but not others, or a Hugging Face token baked permanently into a layer. And this matters beyond convenience, because the image is the unit that Kubernetes and every autoscaler schedules, so a bad image is a bad foundation for everything downstream. The one thing I’d lead with: the driver lives on the host, the container only ships CUDA userspace, and that contract is the source of most GPU container pain.
Core intuition
Three ideas carry most of the weight.
1. The container shares the host’s GPU driver, not its own. You never install an NVIDIA driver inside the image. The driver is a kernel module and lives on the host. The container ships userspace CUDA libraries. At docker run time, the NVIDIA Container Toolkit injects the host driver’s device files and libraries into the container. So the contract is: host driver must be new enough for the container’s CUDA userspace. This is the single most important mental model in GPU containers.
2. Model weights are data, not code. Code changes daily and is tiny; a 14 GB weights file changes rarely and is huge. Baking weights into an image layer couples the two lifecycles badly. The default for production is to keep weights out of the image and bring them in as a mounted volume, a startup download into a persistent cache, or — increasingly, as of 2025–2026 — a separate OCI artifact pulled alongside the image (see “The 2025–2026 landscape” below).
3. Build image ≠ runtime image. The tools you need to compile CUDA kernels (nvcc, headers, build-essential) are hundreds of MB you never need to run the server. Multi-stage builds let you compile in a fat stage and copy only the artifacts into a lean runtime stage.
Hold these three and the rest is detail.
Saying it out loud. Three ideas do most of the work here. First, the container does not have its own GPU driver — the driver is a kernel module on the host, and the container only ships the userspace CUDA libraries, so the host driver has to be new enough for whatever CUDA version you built against. Second, weights are data, not code: your code changes daily and is tiny, a 14 GB checkpoint changes rarely and is huge, so coupling them into one artifact means every one-line code fix redistributes the whole model. Third, the image you build in is not the image you ship — nvcc and headers are gigabytes you need to compile and never need to run. Hold those three and everything else in this chapter is detail.
Mechanism 1 — GPU base images and the CUDA/driver contract
The nvidia/cuda image family
NVIDIA publishes nvidia/cuda images on Docker Hub and NGC. Tags follow the pattern:
nvidia/cuda:<cuda_version>-<flavor>-<os>
# e.g.
nvidia/cuda:12.4.1-runtime-ubuntu22.04
nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
nvidia/cuda:12.4.1-devel-ubuntu22.04
There are three flavors, and picking the wrong one is a common source of bloat:
| Flavor | Contains | Size (approx) | Use it for |
|---|---|---|---|
base | CUDA runtime libs minimum | ~200 MB | Rare; you usually need more |
runtime | CUDA runtime + math libs (cuBLAS), optionally cuDNN | ~2–3 GB | Final runtime stage of an inference server |
devel | Everything in runtime + nvcc, headers, static libs | ~5–7 GB | Build stage when you compile kernels |
Rule of thumb: build in devel, ship on runtime. If your framework ships prebuilt wheels (most do), you may not even need devel.
Saying it out loud. NVIDIA publishes three flavors of CUDA base image and picking the wrong one is the most common source of bloat.
baseis minimal CUDA runtime libraries, a couple hundred megabytes.runtimeadds the math libraries like cuBLAS and optionally cuDNN — call it two to three gigabytes, and that’s what you actually ship.develadds nvcc, headers, and static libraries, which puts you at five to seven gigabytes, and you only need that if you’re compiling CUDA kernels. So the rule is: build indevel, ship onruntime— and pin the minor version explicitly,12.4.1not12, because12is a moving target that will silently change under you.
Driver/runtime compatibility
The host driver exposes a maximum supported CUDA version. CUDA has forward compatibility within a major version and minor-version compatibility so that, e.g., a driver supporting CUDA 12.2 can generally run CUDA 12.4 userspace on data-center GPUs via the compat package — but do not rely on this casually. The safe posture:
- Check the host:
nvidia-smiprintsDriver VersionandCUDA Version(the max CUDA the driver supports). - Pick a container CUDA version ≤ that, or confirm forward-compat coverage.
- Pin the container CUDA minor version explicitly (
12.4.1, not12).
The failure you’re avoiding looks like:
CUDA driver version is insufficient for CUDA runtime version
That means the container’s CUDA userspace is newer than the host driver can serve. Fix by upgrading the host driver or downgrading the image’s CUDA version — you cannot fix it inside the image. (See the “driver skew across a mixed GPU fleet” case study later in this chapter — this exact error is what took down one team’s rollout.)
Saying it out loud. This is the single most important contract in GPU containers: the host’s driver has to be new enough for the CUDA userspace inside your image, and you cannot fix a violation from inside the image. Check the host with
nvidia-smi— it reports a driver version and the maximum CUDA version that driver supports — then pick a container CUDA version at or below it, pinned to the minor. If you get it wrong you see exactly one error:CUDA driver version is insufficient for CUDA runtime version, and the only fixes are upgrade the host driver or ship a lower-CUDA image. The nasty version of this failure is a mixed-generation fleet, where a third of your pods come up healthy and two-thirds crash-loop, so it looks like a flaky partial outage rather than a version mismatch.
GPU partitioning: MIG and time-slicing
Two GPUs is not always the right unit of allocation — sometimes you want to run several smaller inference workloads on one physical GPU. NVIDIA data-center GPUs (A100/H100-class) support two partitioning mechanisms, and interviewers like to check you know the difference:
- MIG (Multi-Instance GPU) — hardware-level partitioning. A single A100/H100 can be split into up to 7 fully isolated instances, each with its own dedicated slice of SMs, memory, and memory bandwidth, exposed to the container runtime as a distinct device. Because the isolation is in hardware, one MIG instance’s workload cannot starve or interfere with another’s — the strongest isolation option, but the partition sizes are fixed at profile granularity and set outside the container (via
nvidia-smi mig -cgi ...on the host) before containers ever start. - Time-slicing — software-level sharing. Multiple containers share the same full GPU, and the driver time-multiplexes compute across them (similar in spirit to CPU time-sharing). No memory isolation: any container can, in principle, allocate all the GPU’s VRAM and starve its neighbors. Simpler to set up (no MIG profile management) but a weaker isolation guarantee — appropriate for dev/test or trusted-tenant workloads, not for hard multi-tenant isolation.
For a single dedicated LLM server per GPU (the common case in this chapter’s examples), neither matters — you’re using the whole device. They become relevant once you’re packing multiple smaller models or replicas onto shared GPUs, which is a natural follow-up question after “how do you containerize a model server” in a systems-design interview.
Saying it out loud. If you want to run several small workloads on one physical GPU, there are two ways and they give you very different guarantees. MIG — Multi-Instance GPU — is hardware partitioning: an A100 or H100 splits into up to seven fully isolated instances, each with its own dedicated slice of SMs, memory, and memory bandwidth, and one instance genuinely cannot starve another. Time-slicing is software: the driver just multiplexes compute between containers sharing the whole card, with no memory isolation at all, so one container can allocate all the VRAM and OOM its neighbors. So the tradeoff is real isolation with fixed, host-configured partition sizes versus trivial setup with no guarantees. For hard multi-tenancy you use MIG; time-slicing is for dev, test, or workloads you already trust.
The CUDA forward-compatibility package
The “pin container CUDA ≤ host driver” rule earlier in this section has one documented escape hatch worth knowing by name: NVIDIA ships a CUDA forward-compatibility package for data-center GPUs that lets a container built against a newer CUDA toolkit run on an older driver than would normally be required, by shipping a compatible driver shim inside the container image itself. It is intentionally scoped — it applies to data-center GPU driver branches, not consumer GPUs, and it does not make every combination of “any CUDA version on any driver” work. Treat it as a documented exception for a specific, narrow upgrade-sequencing problem (e.g., you need to ship a newer CUDA-versioned image before every node’s driver has been upgraded yet), not as a reason to stop tracking the driver/CUDA contract explicitly.
Saying it out loud. There’s one documented escape hatch from the “container CUDA must be no newer than the host driver” rule, and it’s worth knowing by name: NVIDIA’s CUDA forward-compatibility package ships a driver shim inside the image so a newer CUDA toolkit can run on an older driver. It’s deliberately narrow, though — it’s scoped to data-center GPU driver branches, not consumer cards, and it does not make arbitrary CUDA-version-on-arbitrary-driver combinations work. The right way to think about it is as a solution to an upgrade-sequencing problem: you need to ship a newer image before every node’s driver has been rolled. It is not a reason to stop tracking the driver contract explicitly, and treating it as one is how you end up debugging a partial outage across a mixed fleet.
Mechanism 2 — The NVIDIA Container Toolkit and --gpus
A plain docker run gives the container no GPU. Two pieces make it work.
Saying it out loud. A plain
docker rungives your container zero GPUs — you have to explicitly wire it up, and there are two pieces. On the host you install the NVIDIA Container Toolkit once and register it with the Docker daemon; that’s the thing that mounts the host driver’s libraries and device nodes into the container at startup. Then at run time--gpus allor--gpus '"device=0,1"'selects which devices to expose. Note what you install on the host: the driver, and the toolkit — not the CUDA toolkit, that lives in your image. And the very first diagnostic when a container “can’t see the GPU” isdocker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi; if that fails, the problem is host-level and you should stop debugging your application.
The toolkit
The NVIDIA Container Toolkit is a set of host packages (nvidia-container-toolkit, the nvidia-ctk CLI, and a runtime shim) that automatically configure a container to use NVIDIA GPUs by mounting the driver libraries and device nodes at startup. You install it on the host, once:
# 1. Add NVIDIA's apt repo (see official install guide for the current key/URL)
sudo apt-get install -y nvidia-container-toolkit
# 2. Wire it into the Docker daemon (writes the "nvidia" runtime into
# /etc/docker/daemon.json)
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
Prerequisite: the NVIDIA driver is already installed on the host. You do not install the CUDA toolkit on the host — only the driver.
Verify the whole chain end-to-end:
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
If that prints your GPU table, host driver + toolkit + runtime are all good. This is the first thing to run when a GPU container “can’t see the GPU.”
Saying it out loud. The toolkit is a set of host packages plus a runtime shim, and what it actually does is inject the host’s driver libraries and
/dev/nvidia*device nodes into your container when it starts. You install it once per host with the package manager, then runnvidia-ctk runtime configure --runtime=dockerto write the nvidia runtime into the Docker daemon config, and restart Docker. The prerequisite people trip on is that the NVIDIA driver must already be installed on the host — the toolkit doesn’t install it. And the verification is one command: runnvidia-smiinside a stock CUDA base image with--gpus all. If that prints your GPU table, then driver, toolkit, and runtime registration are all correct, and any remaining problem is yours.
--gpus
Once the toolkit is installed, --gpus selects which GPUs to expose:
--gpus all # all GPUs
--gpus '"device=0,1"' # only GPU 0 and 1 (note the nested quoting)
--gpus 2 # any 2 GPUs
Under the hood this sets NVIDIA_VISIBLE_DEVICES and triggers the toolkit’s injection hook. Older stacks used --runtime=nvidia plus that env var directly; --gpus is the modern, preferred flag. Some tools (vLLM’s docs) still show --runtime nvidia — it’s equivalent when the runtime is registered.
Forward pointer — CDI. The legacy hook-based injection above is being superseded by the Container Device Interface (CDI), a vendor-neutral, Kubernetes/Podman/Docker-portable way to describe “how to expose device X into a container.” The Container Toolkit has generated CDI specs since v1.14 (2024) and it’s now the recommended path for Podman and for rootless setups. Full detail, with commands, is in “The 2025–2026 landscape” below.
Mechanism 3 — Handling large model weights
This is where architecture decisions bite hardest. You have three classic options, plus a fourth that emerged in 2025.
Saying it out loud. Where the weights live is the biggest architectural decision in this chapter, and there are basically four answers. You can bake them into the image — hermetic and air-gap-friendly, but now your image is 15 to 150 gigabytes and every code change redistributes the whole checkpoint. You can mount them as a volume, which is what the official vLLM and TGI images assume and what most production does. You can download them at startup into a persistent cache, which is smallest and most flexible but pays a multi-gigabyte cold start. Or, newer, you can ship them as a separate OCI artifact versioned independently of the engine. The failure mode I’d name for option C: if the cache volume isn’t actually persistent, you redownload the whole model on every single restart, and that bill is real.
Option A — Bake weights into the image
Copy the weights in during build (COPY ./model /model). The image is fully self-contained: pull it and run, no network, no external volume.
- Pro: hermetic, reproducible, air-gap friendly, one artifact to sign/scan.
- Con: the image is now 15–150 GB. Every push/pull moves all of it. Layer caching is useless once weights change. Registry storage costs balloon. Build context upload is slow.
- Verdict: reserve for small models, air-gapped/regulated deployments, or when the exact weights are part of your release contract.
Option B — Mount weights as a volume
Keep weights on the host / network storage and bind-mount at runtime (-v /data/models:/models). This is what the vLLM and TGI official images assume.
- Pro: small image, weights shared across containers and versions, swap models without rebuilding, fast cold builds.
- Con: image is no longer self-contained; you must provision and pre-populate the volume; in K8s you need a
PersistentVolume/hostPath/ CSI mount. - Verdict: the default for most production on a fixed node pool or shared filesystem.
Option C — Download at startup into a cache
The container downloads weights from Hugging Face (or S3/GCS) on first boot into a persistent cache directory, then reuses the cache on restart.
- Pro: smallest image, model chosen by env var, trivially swappable.
- Con: cold start pays a multi-GB download; needs network egress + a token for gated models; if the cache volume isn’t persistent you redownload every restart (a classic and expensive bug); registry outages ≠ HF outages now both matter.
- Verdict: great for dev, experimentation, and autoscaling where a warm cache volume (or a pre-baked node image) hides the download.
Option D (emerging, 2025+) — weights as a separate OCI artifact. Docker’s Model Runner and Hugging Face’s OCI push path distribute weights as an OCI artifact (not an image layer) alongside the serving image, so the model can be
docker pull-ed, versioned, and content-addressed independently of the engine — while staying uncompressed on disk for fastmmaploading. This is a genuine fourth point on the spectrum: image-registry ergonomics with volume-mount-like decoupling. Details and citations are in the landscape section below; treat it as complementary to, not a replacement for, Options B/C in most production fleets as of this writing.
The Hugging Face cache — make it persist
The huggingface_hub library caches downloads under HF_HOME (default ~/.cache/huggingface; the hub cache is $HF_HOME/hub). The env vars that matter:
HF_HOME— root of all HF caches. Set this and everything follows.HF_HUB_CACHE(older:HUGGINGFACE_HUB_CACHE) — the model blob cache specifically.HF_TOKEN— auth for gated/private models.
The whole point of Options B/C is to mount a volume at the cache path so weights survive container restarts:
# vLLM: cache lives at /root/.cache/huggingface inside the image
-v ~/.cache/huggingface:/root/.cache/huggingface
# TGI: the official image sets HUGGINGFACE_HUB_CACHE=/data, so mount /data
-v $PWD/data:/data
Miss this mount and every docker run redownloads the model — slow, costly, and rate-limit-prone.
Saying it out loud. This is a two-line fix for a genuinely expensive bug. The
huggingface_hublibrary caches downloads underHF_HOME, defaulting to~/.cache/huggingface, and if you don’t mount a persistent volume at that path, everydocker runredownloads the entire model from scratch. The exact path differs by image — vLLM’s official image caches at/root/.cache/huggingface, while TGI sets the cache to/data— so you mount to whichever one your image actually uses. SetHF_HOMEexplicitly and everything else follows, and passHF_TOKENat runtime for gated models rather than baking it into a layer. The cost of missing this isn’t just slow starts: it’s bandwidth charges and getting rate-limited by the Hub at exactly the moment you’re scaling up.
Quantization formats and their effect on image and weight size
Weight size is not a fixed property of a model — the quantization format you choose shifts it by 2–4x, which feeds directly back into the bake/mount/download decision above. A 70B-parameter model is roughly 140 GB in fp16, roughly 70 GB in int8, and commonly 35–40 GB in 4-bit formats (GPTQ, AWQ, or GGUF’s Q4_K_M-style schemes). This matters for containerization in three concrete ways:
- Bake-into-image (Option A) becomes far more viable for a 4-bit-quantized model than for the fp16 original — a 35 GB image is still large, but it is a very different operational proposition than a 140 GB one, and may cross the threshold into “acceptable for our registry and node bandwidth.”
- The serving engine constrains the format. vLLM, TGI, and TensorRT-LLM each support a different subset of quantization schemes with different kernel-level performance, so “which quantization format” and “which base image” are coupled decisions — you can’t pick a format your engine’s image doesn’t have kernels for and expect the speed benefit to materialize.
- Quantized weights still deserve the same weights-vs-code separation as the fp16 case — the file is smaller, but it is still large, slow-changing data, and belongs on a mounted volume or content-addressed artifact rather than baked into a layer that gets rebuilt every time the application code changes, for the same CI/registry-cost reasons covered in the war stories below.
Saying it out loud. Weight size isn’t a fixed property of a model — quantization moves it by two to four times, and that feeds straight back into the bake-versus-mount decision. A 70B model is roughly 140 GB in fp16, about 70 GB in int8, and commonly 35 to 40 GB in 4-bit schemes like GPTQ, AWQ, or GGUF’s Q4_K_M. That difference can genuinely flip “bake into the image” from absurd to merely large. Two catches worth naming. Your serving engine constrains the format — vLLM, TGI, and TensorRT-LLM each support different subsets with different kernel performance, so format and base image are a coupled decision. And even at 35 GB, weights are still slow-changing bulk data, so they still belong on a volume or an artifact rather than in a layer your CI rebuilds on every commit.
Mechanism 4 — Multi-stage builds, layer caching, and .dockerignore
Multi-stage
A multi-stage build uses multiple FROM statements. Early stages compile; the final stage copies only what’s needed. For an LLM server that means: compile custom kernels / install a heavy build toolchain in a devel stage, then COPY --from=builder the installed environment into a slim runtime stage. The devel layers never ship.
Saying it out loud. A multi-stage build just means multiple
FROMstatements in one Dockerfile: an early stage does the heavy work, and the final stage copies only the finished artifacts. For an LLM server that’s concrete — install into a virtualenv in adevelbase that has nvcc and build-essential, thenCOPY --from=builder /opt/venvinto a slimruntimebase and never ship the compilers. The win is straightforward: you’re dropping three to four gigabytes of build toolchain that has no business being on a production node, and every one of those packages is CVE surface you’d otherwise have to answer for in a security review. Cost is essentially nothing — a slightly longer Dockerfile.
Targeting specific GPU architectures: TORCH_CUDA_ARCH_LIST
When a build stage compiles CUDA kernels from source (custom ops, or building a framework from source rather than installing a prebuilt wheel), it by default may compile for a broad list of GPU compute capabilities so the resulting wheel works everywhere — L4, L40S, A100, H100, and older architectures alike. That breadth costs real build time and binary size, and most of it is wasted if you know exactly which GPU SKU the image will run on. Setting TORCH_CUDA_ARCH_LIST (PyTorch’s build-time env var) to only the architectures you actually deploy on narrows the compiled kernel set accordingly:
# Example: building only for A100 (8.0) and H100 (9.0) compute capabilities,
# instead of the full default list PyTorch would otherwise target.
ENV TORCH_CUDA_ARCH_LIST="8.0 9.0"
RUN pip install --no-binary :all: torch
This is a build-stage-only concern — it has no effect once you’re installing prebuilt wheels (the common, recommended path from Mechanism 4) — but it’s worth knowing by name for the case where you are compiling from source, since it’s a direct, fast answer to “why is our custom-kernel build so slow” or “why is this wheel so large” in a design discussion.
Saying it out loud. If you’re compiling CUDA kernels from source, PyTorch defaults to targeting a broad list of GPU compute capabilities so the resulting wheel works on everything from an older card to an H100. That breadth costs real build time and binary size, and it’s mostly wasted if you know exactly which SKUs you deploy on. Setting
TORCH_CUDA_ARCH_LISTto just the architectures you actually run — say"8.0 9.0"for A100 and H100 — narrows the compiled kernel set to those. The important caveat: this only matters in a build stage that actually compiles. If you’re installing prebuilt wheels, which is the recommended path, it does nothing. But it’s a fast, specific answer to “why is our custom-kernel build twenty minutes long.”
Layer caching order
Docker caches each instruction as a layer and reuses it until an input changes; everything after a changed layer is rebuilt. So order from least-to-most volatile:
- Base image
- System packages (
apt-get) - Dependency manifests (
requirements.txt/pyproject.toml) andpip install - Application source code
Copy requirements.txt and install before copying your source. Then a one-line code change reuses the (slow) dependency layer instead of reinstalling PyTorch every build. Use BuildKit cache mounts for the pip/uv cache to speed rebuilds further:
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Saying it out loud. Docker caches each instruction as a layer and reuses it until an input changes — and crucially, everything after a changed layer gets rebuilt. So you order your Dockerfile from least volatile to most volatile: base image, then system packages, then dependency manifests and
pip install, then finally your application source. The concrete win is copyingrequirements.txtand installing before you copy your code, so a one-line code change reuses the cached PyTorch install instead of reinstalling several gigabytes. Add a BuildKit cache mount on the pip cache and even a genuine dependency change gets faster. Get the order backwards and every trivial commit reinstalls your entire CUDA-linked dependency tree — that’s minutes per build, on every build.
Registry-backed BuildKit cache in CI
Layer caching (above) only helps within a single machine’s local Docker cache. CI runners are frequently ephemeral or shared across many jobs, so the local cache that made your dependency layer fast on your laptop may not exist on the runner that picks up the next PR — which is exactly the trap that made War story 2’s 40 GB image so painful in CI. BuildKit’s registry cache backend fixes this by pushing/pulling the build cache itself as a registry artifact, so any runner can warm its cache from the last successful build regardless of which machine ran it:
docker buildx build \
--cache-from type=registry,ref=myregistry.example.com/my-llm-server:buildcache \
--cache-to type=registry,ref=myregistry.example.com/my-llm-server:buildcache,mode=max \
-t my-llm-server:ci .
mode=max caches every intermediate layer (not just the final stage), which matters for multi-stage Dockerfiles like the one in this chapter — otherwise the builder stage’s expensive pip install layer isn’t reusable across runners even though the final runtime-stage layers are. Combined with keeping weights out of the build context entirely (Mechanism 3), this is what keeps CI build times in the range War story 2’s fix achieved (under two minutes) rather than the 25-plus minutes the baked-weights version suffered.
Saying it out loud. Layer caching only helps on a machine that still has the cache, and CI runners are typically ephemeral or shared — so the dependency layer that’s instant on your laptop is a cold build on whichever runner picks up the next PR. The fix is BuildKit’s registry cache backend: push the build cache itself to your registry as an artifact with
--cache-to type=registry, and any runner can warm from the last successful build with--cache-from. Usemode=maxso intermediate stages get cached too, otherwise your builder stage’s expensivepip installisn’t reusable at all in a multi-stage Dockerfile. Combined with keeping weights out of the build context, this is the difference between the 25-plus-minute CI builds in war story two and builds under two minutes.
.dockerignore
The build context is everything Docker uploads to the daemon before building. Without a .dockerignore, a stray ./models, .git, __pycache__, or a 30 GB checkpoint gets shipped into the build — slow, and a vector for secrets and bloat. A minimal one:
.git
__pycache__/
*.pyc
*.pt
*.safetensors
models/
data/
.env
*.log
.venv/
This is also your first line of defense against COPY . . accidentally baking weights or a .env file into a layer.
Saying it out loud. The build context is everything Docker uploads to the daemon before it even starts building, and without a
.dockerignorethat includes your.gitdirectory, your__pycache__, your.envfile, and any stray checkpoint sitting inmodels/. So it’s slow and it’s a secret-leak vector. A minimal one excludes.git,*.pyc,*.pt,*.safetensors,models/,data/, and.env. Think of it as the first line of defense against a carelessCOPY . .baking a 30 GB checkpoint or a live Hugging Face token into a layer that then gets pushed to a registry — and remember that once a secret is in image history, deleting the file in a later layer does not remove it.
Mechanism 5 — Reproducibility, security, healthchecks, config
Pin everything. python:3.11-slim is a moving target. Prefer a digest for the base and pinned versions for packages:
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04@sha256:<digest>
Pin your Python deps (lockfile or ==), and tag your own images with an immutable version, never rely on :latest in production.
Run as non-root. By default containers run as UID 0. A container escape from root is worse than from an unprivileged user. Create a user and drop to it:
RUN useradd --create-home --uid 10001 appuser
USER appuser
Note some GPU stacks and cache paths assume /root; if you run non-root, point HF_HOME at a directory that user can write.
Keep secrets out of layers. Never ENV HF_TOKEN=hf_xxx or COPY .env — both persist in the image history for anyone who pulls it. Pass secrets at runtime (-e HF_TOKEN=..., Docker/K8s secrets) or use BuildKit --secret mounts for build-time-only credentials.
Add a HEALTHCHECK. Orchestrators restart containers that fail their health probe. For an OpenAI-compatible server, probe the health/models endpoint:
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
The long start-period matters: model load can take minutes, and you don’t want the container killed during warmup.
Config via env, not baked files. Model id, tensor-parallel size, port, and dtype should be env vars / CLI args so one image serves many configs.
Saying it out loud. Four habits separate a demo image from a production one. Pin everything — base image by digest, Python deps by lockfile, your own image by an immutable version tag, never
:latest. Run as non-root, because a container escape from UID 0 is a much worse day than one from an unprivileged user. Keep secrets out of layers entirely: noENV HF_TOKEN=, noCOPY .env— pass them at runtime or use BuildKit secret mounts, because image history is forever. And add a HEALTHCHECK with a generousstart-period, something like 180 to 300 seconds, because model load genuinely takes minutes and the classic self-inflicted failure is your orchestrator killing the container while it’s still legitimately warming up.
Mechanism 6 — Operating the container: logs, exec, monitoring, and shutdown
Building the image is half the job; running it in production for months is the other half. A few operational mechanics are specific to GPU containers and worth knowing cold.
Saying it out loud. Building the image is half the job; running it for months is the other half, and a few things here are GPU-specific. Log to stdout in structured JSON so an aggregator can read it, not to a file inside the container nobody mounts out. When something’s wrong,
docker execin and runnvidia-smifrom inside the container — it shows exactly which processes in this container are holding GPU memory. Watch GPU memory utilization, not just compute, because a server can look completely idle on the compute graph while sitting at 95% VRAM. And give yourself a real shutdown grace period, because Docker’s default is ten seconds and that’s not enough to drain an in-flight generation.
Logs and exec
docker logs -f <container> streams stdout/stderr — make sure your server logs there (not to a file inside the container that nobody mounts out) and emits structured (JSON) lines so they’re parseable by whatever aggregator sits downstream. For live debugging, docker exec -it <container> bash drops you inside the running container, where the single most useful diagnostic is running nvidia-smi from inside the container: it reflects the same driver and devices as the host (since the toolkit injected them), and its process list shows exactly which processes inside this container are holding GPU memory — invaluable when a server reports OOM but you’re not sure if it’s fragmentation, a leaked previous request’s KV cache, or another process entirely.
docker exec -it my-llm-server nvidia-smi # GPU state as seen by *this* container
docker exec -it my-llm-server nvidia-smi pmon # per-process GPU utilization inside the container
Saying it out loud. Two everyday tools.
docker logs -fstreams stdout and stderr, which is why your server should log there rather than to a file inside the container that no aggregator will ever see — and structured JSON lines make that downstream parsing actually work. Thendocker exec -it <container> bashdrops you inside a running container, and the single most useful thing to run there isnvidia-smi. It reflects the same driver and devices as the host because the toolkit injected them, and its process list tells you precisely which processes in this container are holding GPU memory. That’s what lets you distinguish a genuine OOM from fragmentation from some other process on the card — a distinction you cannot make from the host view alone.
Monitoring GPU utilization and health from outside the container
For host-level and fleet-level observability, don’t rely on shelling into every container. Two standard options:
nvidia-smi dmonon the host gives a live per-GPU utilization/memory/temperature stream — useful for a quick manual check, not for durable metrics.- DCGM (Data Center GPU Manager) exporter — NVIDIA’s
dcgm-exporterruns as a sidecar or daemonset and exposes GPU utilization, memory, ECC errors, power, and temperature as Prometheus metrics, scoped per physical GPU and (with the right labels) attributable back to the container/pod using it. This is the standard way to get GPU metrics into the same dashboards and alerting as the rest of your fleet, rather than parsingnvidia-smitext output on a cron job.
The metric to alert on that’s easy to miss: GPU memory utilization, not just compute utilization. A server can show low SM utilization (looks “idle”) while sitting at 95% VRAM usage from an oversized KV cache or a memory leak across requests — the next allocation OOMs with no warning if you were only watching compute.
Saying it out loud. Don’t build your GPU observability on shelling into containers. For a quick manual look,
nvidia-smi dmonon the host gives you a live per-GPU stream. For anything durable, you run NVIDIA’sdcgm-exporteras a sidecar or daemonset and it exposes utilization, memory, ECC errors, power, and temperature as Prometheus metrics you can label back to the owning pod. The metric that’s easy to miss, and the one I’d insist on alerting: GPU memory utilization, separately from compute. A leaking KV cache will push you to 95% VRAM while SM utilization stays boringly flat, and then the next allocation OOMs with zero warning on the dashboard everyone was watching.
Resource limits: what cgroups do and do not control for GPUs
The deploy.resources.limits/reservations block shown earlier in this chapter’s compose file controls CPU and memory through the host’s cgroups — Docker genuinely enforces those. It does not give you an equivalent hard limit on GPU compute or GPU memory the way it does for CPU shares or RAM. --gpus controls which GPUs a container can see, not how much of a shared GPU’s compute or memory it’s capped at using cgroups semantics. Practically:
- If you run one container per GPU (the common pattern for LLM serving, since a large model typically wants a whole device or several via tensor-parallel), this limitation doesn’t bite — the container has the whole GPU and there’s nothing else to contend with.
- If you deliberately share a GPU across containers (time-slicing, or just running two processes on one device without MIG), nothing at the container-runtime layer stops one from allocating all the VRAM and OOM-ing the other. NVIDIA’s MPS (Multi-Process Service) can give more predictable compute sharing between cooperating processes, and MIG (above) gives hardware-enforced isolation — but plain Docker resource limits do not extend to the GPU the way they do to CPU/RAM. Don’t assume
mem_limitorcpusin your compose file constrains GPU memory; it doesn’t.
Saying it out loud. This one catches people: the CPU and memory limits in your compose file are enforced by the host’s cgroups and are genuinely real, but there is no cgroup equivalent for the GPU.
--gpuscontrols which devices a container can see, not how much of a shared device it’s allowed to consume. So if you run one container per GPU — the normal pattern for LLM serving, since a big model wants a whole card or several — this never bites you, because there’s nothing to contend with. But if you deliberately share a card between containers, nothing at the runtime layer stops one from allocating all the VRAM and OOM-ing the other. MPS gives more predictable compute sharing between cooperating processes; MIG gives hardware-enforced isolation.mem_limitgives you nothing.
Graceful shutdown and restart policy
docker stop sends SIGTERM, waits a grace period (default 10s, configurable with docker stop -t <seconds> or stop_grace_period in compose), then SIGKILLs. For an LLM server mid-generation, 10 seconds is often not enough to drain in-flight streaming requests cleanly. Two adjustments matter:
- Increase the grace period (
stop_grace_period: 60sin compose, or the orchestrator’s equivalent — e.g., KubernetesterminationGracePeriodSeconds) to comfortably exceed your longest expected generation, so in-flight requests finish rather than being cut off mid-stream. - Handle SIGTERM in the application itself if the framework allows it — stop accepting new requests immediately (so the load balancer’s health/readiness check can flip and stop routing new traffic) while letting in-flight requests complete within the grace window, rather than dropping everything the instant SIGTERM arrives.
Restart policy (restart: unless-stopped used in this chapter’s compose file, or on-failure with a max retry count) determines what happens after a crash. For a GPU server, prefer a policy with a capped retry count or backoff over unconditional restart-forever: a driver mismatch or an OOM that recurs deterministically on every restart will otherwise crash-loop indefinitely, burning GPU-node scheduling slots and generating alert noise, when what you actually want after N failures is for the orchestrator to mark the replica unhealthy and stop retrying until a human looks at it.
Saying it out loud.
docker stopsends SIGTERM, waits ten seconds by default, then SIGKILLs — and ten seconds is often not enough for an LLM server to finish streaming an in-flight generation. So you do two things. Raise the grace period past your longest expected generation,stop_grace_periodin compose orterminationGracePeriodSecondsin Kubernetes. And handle SIGTERM in the app: immediately stop accepting new work so the readiness check flips and the load balancer drains you, while letting in-flight requests finish inside the window. On restart policy, prefer a capped retry or backoff over restart-forever, because a driver mismatch or a deterministic OOM will crash-loop indefinitely, burning scarce GPU scheduling slots and generating alert noise instead of getting a human’s attention.
A debugging playbook for “the container won’t come up”
When a GPU container fails on a node and the cause isn’t obvious from the first log line, work through this order — cheapest checks first:
- Does the toolkit even see the GPU?
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smion the same host. If this fails, the problem is host-level (toolkit/runtime/driver), not your image — stop debugging the application. - Does the driver support the image’s CUDA version? Compare
nvidia-smi’s reportedCUDA Versionagainst the image’s CUDA tag. A driver-insufficient error here means fix the host or ship a lower-CUDA image; it is never fixed inside the container. - Is
/dev/shmlarge enough? If the crash is aBus erroror an NCCL hang rather than a CUDA-init failure, suspect the 64 MB default shared-memory size before suspecting the model or the framework — check withdocker exec <c> df -h /dev/shm. - Is the cache volume actually mounted, and writable by the running user? A silent full redownload on every restart, or a permission-denied on first write, both trace back to a missing or misowned mount at the
HF_HOME/cache path. - Is the healthcheck timing out during legitimate model load, or is the process actually wedged? Check
docker logs -ffor load-progress output and compare elapsed time againststart-period; don’t assume a failed healthcheck means a hung process without checking whether it’s simply still loading. - Is this node’s driver actually the one you tested against? On a mixed-generation fleet, confirm the specific node’s driver version rather than assuming fleet-wide uniformity — this is the failure mode from War story 1 below, and it is easy to lose an hour to before checking it directly.
Saying it out loud. When a GPU container won’t start, work cheapest-check-first instead of reading the application logs. Step one: does a stock CUDA base image with
--gpus allprintnvidia-smion this host? If not, it’s host-level — toolkit, runtime, or driver — and your image is irrelevant. Step two: compare the driver’s reported CUDA version against your image’s CUDA tag. Step three: if the symptom is aBus erroror an NCCL hang rather than a CUDA init failure, suspect the 64-megabyte default/dev/shmbefore you suspect the model. Step four: check the cache volume is actually mounted and writable by the running user. Step five: check whether the healthcheck is failing because the process is wedged or because it’s simply still loading — those look identical from the outside and only the logs distinguish them.
The 2025–2026 landscape
The mechanics above (driver contract, toolkit, multi-stage) haven’t changed. What has changed since roughly 2024 is how much of this you have to build yourself versus what ships as a hardened, official artifact — and how seriously the industry now treats GPU images as a supply-chain surface. Four threads matter for a working engineer today.
Saying it out loud. The mechanics — driver contract, toolkit, multi-stage builds — haven’t changed. What’s changed since about 2024 is how much of this you build yourself. The toolkit is moving from its old hook-based injection to CDI, a vendor-neutral device spec that Docker, Podman, containerd, and Kubernetes all understand, which finally makes rootless GPU containers practical. Most teams now start from an official vendor image — vLLM’s, TGI’s, or NVIDIA NIM — instead of hand-rolling a Dockerfile. And GPU images are now treated as a supply-chain surface in their own right, because a CUDA plus PyTorch base drags in a huge dependency graph. The interview-relevant version: know when a vendor image is the right answer, and know that “custom Dockerfile” now needs a justification.
1. The NVIDIA Container Toolkit has moved to CDI, and rootless is now a real option
The toolkit itself keeps shipping — v1.19.0 was released March 12, 2026 (see the release list at the project’s GitHub, cited below). The bigger shift is architectural: the legacy nvidia-container-runtime “hook” approach (patch the OCI runtime spec at container-start time) is being superseded by the Container Device Interface (CDI), a CNCF-adjacent, vendor-neutral spec for describing “how to inject device X into this container” that Docker, Podman, containerd, and Kubernetes (via kubelet device plugins) all understand the same way. NVIDIA’s toolkit has generated CDI specs since v1.14 (2024), and NVIDIA’s own docs now present CDI as the forward-looking path, especially for Podman and rootless setups.
Rootless GPU containers were awkward for years because /dev/nvidia* device nodes and the injection hook both assumed a privileged (rootful) daemon. CDI plus nvidia-ctk closes that gap. The rootless recipe (Podman, but the same idea applies to Docker’s rootless mode) is:
# Generate a CDI spec into user space (not /etc/cdi, which needs root)
mkdir -p ~/.config/cdi
nvidia-ctk cdi generate --output=$HOME/.config/cdi/nvidia.yaml
# Sanity-check what devices the spec exposes
nvidia-ctk cdi list
# Run rootless, referencing the CDI device by its vendor.com/class=name identifier
podman --cdi-spec-dir=$HOME/.config/cdi run --rm \
--device nvidia.com/gpu=all \
--security-opt=label=disable \
nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
Two caveats that bite in practice: (1) rootless containers still need the invoking user to have read/write on the /dev/nvidia* device files — usually via membership in the host’s video (or render) group, passed through with --group-add keep-groups; and (2) on SELinux hosts (Fedora/RHEL-family) you may need sudo setsebool -P container_use_devices on or GPU access is silently denied. Rootless matters for the same reason it always has — a compromised process inside the container can’t leverage root-equivalent privileges on the host — and it is now something you can actually put in a hardening checklist rather than an aspiration.
Saying it out loud. CDI — the Container Device Interface — is a vendor-neutral way of describing how to inject a device into a container, and Docker, Podman, containerd, and Kubernetes all understand the same spec. That replaces the old approach where NVIDIA’s runtime patched the OCI spec via a hook at container start. The practical payoff is rootless: for years rootless GPU containers were awkward because the device nodes and the injection hook both assumed a privileged daemon, and
nvidia-ctk cdi generateinto your user’s config directory closes that gap. Two gotchas that will waste your afternoon: the invoking user still needs read-write on/dev/nvidia*, usually via thevideoorrendergroup, and on SELinux hosts you needcontainer_use_deviceson or GPU access is silently denied.
2. Official, hardened images are the default path for the mainstream servers
Five years ago most teams hand-rolled a Dockerfile like the one in this chapter. Today, for the two most common serving stacks, you usually start from the vendor’s image and only write a custom Dockerfile when you have a genuinely custom serving path:
- vLLM publishes
vllm/vllm-openaiand documents thedocker run --runtime nvidia --gpus all ...invocation directly (docs.vllm.ai/en/latest/deployment/docker/). The known trade-off: the image is large — community reports and vLLM’s own GitHub issue tracker put a recent tag at roughly 12.6 GB (vLLM Forums thread “Current vLLM docker image size is 12.64Gb”; GitHub issue vllm-project/vllm#27154, “How to reduce the vllm image”). A vLLM maintainer’s response to a from-source slimming attempt was blunt: “Probably not as of yet, too many changes to manually work. Even then not guaranteed to work yet.” Translation for your own builds: don’t assume you can easily out-slim the officially supported image without maintenance burden — budget registry storage and pull-time accordingly, or accept the size as the cost of using the maintained artifact. - Hugging Face TGI ships
ghcr.io/huggingface/text-generation-inferencewith GPU install docs at huggingface.co/docs/text-generation-inference/en/installation_nvidia, following the same “official image + mounted volume” pattern shown earlier in this chapter. - NVIDIA NIM (developer.nvidia.com/nim) goes a step further: it packages pre-optimized inference engines — TensorRT-LLM, vLLM, SGLang builds tuned per GPU SKU — behind an OpenAI-compatible API, distributed as containers you self-host (via NGC) or consume as managed endpoints on Hugging Face. NIM trades some flexibility (you’re consuming a curated engine build, often gated behind an NGC API key / NVIDIA AI Enterprise entitlement for production use) for meaningfully less Dockerfile authoring and tuning work; it’s worth evaluating before writing a bespoke image for a well-known model architecture.
The interview-relevant takeaway: know when to reach for an official/vendor image (the common case now) versus when a custom multi-stage build is actually warranted (custom serving logic, a framework without an official image, or hard constraints on image size/content that the vendor image doesn’t meet).
Saying it out loud. Five years ago everyone hand-rolled a Dockerfile like the one in this chapter; today you usually start from the vendor’s image and only write your own when you have genuinely custom serving logic. vLLM publishes
vllm/vllm-openai, Hugging Face publishes TGI on GHCR, and NVIDIA NIM goes further by packaging pre-optimized engines tuned per GPU SKU behind an OpenAI-compatible API. The honest tradeoff is size — a recent vLLM tag runs around 12.6 gigabytes, and a maintainer’s own answer to “can I slim it” was essentially “not really, and not without ongoing maintenance burden.” So budget registry storage and pull time rather than fighting it. The thing to be able to say in an interview is which specific constraint would push you off the official image.
3. Image-size reduction has two live approaches: harden the base, or stop shipping weights as layers
Two independent techniques are gaining traction for the “these images are enormous” problem:
- Hardened, minimal base images. Chainguard publishes CUDA/PyTorch-family images built to a zero-known-CVE target, rebuilt daily, each with an SBOM and reproducible from signed build configs. Their own comparison (chainguard.dev/unchained/securing-the-foundations-of-ai-applications-with-chainguard-images) found the official PyTorch Docker Hub image carried 1 critical, 23 high, 1,189 medium, and 72 low CVEs (as measured July 24, 2024) against zero in their equivalent image at the same time, driven mostly by stripping unnecessary OS packages rather than by removing CUDA/PyTorch functionality. This is the same “runtime not devel,
--no-install-recommends, clean apt lists” discipline from Mechanism 4/5 in this chapter, taken to its logical extreme by a vendor who productizes it. - Stop treating weights as image layers at all. Docker’s rationale for packaging AI models as OCI artifacts rather than image layers (docker.com/blog/oci-artifacts-for-ai-model-packaging) is worth understanding even if you don’t adopt Docker Model Runner: model weight files are high-entropy, so compressing them into a tar layer (the normal image-layer behavior) buys negligible size reduction while costing real (de)compression time, and it prevents the inference engine from
mmap-ing the file directly off disk. Docker’s model-artifact manifest instead stores the weights as an uncompressed layer under model-specific media types (e.g.,application/vnd.docker.ai.gguf.v3) alongside a JSON config carrying architecture/quantization/parameter-count metadata — decoupling “which weights” from “which engine” so you don’t duplicate a 15 GB checkpoint across every framework’s image. It is early days for this pattern in mainstream LLM-serving production, but the direction — weights as a distinct, content-addressed, uncompressed OCI artifact rather than baked intodevel/runtimelayers — is the one to watch, and it’s a clean answer to “is there a better option than bake/mount/download?” if it comes up in an interview.
Saying it out loud. Two different attacks on “these images are enormous.” One is hardening the base: Chainguard builds CUDA and PyTorch images to a zero-known-CVE target with daily rebuilds and SBOMs, and their comparison against the official PyTorch Docker Hub image found 1 critical, 23 high, and over 1,100 medium CVEs there versus zero in theirs, mostly by stripping unnecessary OS packages rather than removing functionality. The other is refusing to ship weights as layers at all. Weight files are high-entropy, so compressing them into a tar layer buys almost nothing in size while costing real decompression time and preventing the engine from
mmap-ing them straight off disk. That’s Docker’s argument for OCI model artifacts: uncompressed, content-addressed, and decoupled from whichever engine you’re running.
4. Supply-chain scanning is now expected on AI images specifically, not just on your app images
Because a CUDA + PyTorch + framework image drags in an unusually large OS + Python dependency graph, it accumulates CVEs faster than a typical microservice image — which is exactly why the Chainguard comparison above is so stark. The practical response teams are standardizing on in 2025–2026:
- Docker Scout (
docker scout cves <image>, docs.docker.com/reference/cli/docker/scout/cves/) or Trivy as a CI gate — fail the build (or at least the “promote to prod” step) above a critical/high CVE threshold. Docker’s own “Docker Hardened Images” program (docs.docker.com/dhi/how-to/scan/) packages this scanning workflow for a curated base-image catalog. - SBOM generation (
docker sbom,syft, or Chainguard’s built-in SBOMs) attached to the image so a security team can answer “are we exposed to CVE-XXXX” without re-scanning every running container. - Image signing (cosign / Sigstore) so the deployment pipeline can verify the image it’s about to run on a GPU node actually came from your CI, not a tampered registry mirror.
- Practically, because GPU images are large and slow to scan/pull, teams increasingly scan once at build/push time and verify signature + digest at deploy time, rather than re-scanning on every node — the same “shift left” idea as regular container security, adjusted for the fact these images are 10–100x the size of a typical service image.
None of this replaces the fundamentals earlier in this chapter (multi-stage, .dockerignore, non-root, pinned digests). It’s the layer on top: assume your CUDA/PyTorch base has a nontrivial CVE surface, and have an explicit story — vendor-hardened base, CI scanning gate, or both — for managing it, rather than discovering it during a customer security questionnaire.
Saying it out loud. A CUDA plus PyTorch plus framework image pulls in a far bigger OS and Python dependency graph than a typical microservice, so it accumulates CVEs faster — which is why that Chainguard comparison is so lopsided. The standard response has three parts: a scanning gate in CI with Docker Scout or Trivy that fails the promote step above a critical or high threshold, an SBOM attached to the image so security can answer “are we exposed to this CVE” without re-scanning running containers, and cosign signing so the deploy pipeline verifies the image actually came from your CI. The practical adaptation for GPU images specifically: because they’re ten to a hundred times the size of a normal service image, you scan once at push time and verify signature and digest at deploy, rather than re-scanning on every node.
5. Kubernetes-adjacent: the GPU Operator abstracts the host-side setup
Everything in Mechanism 2 (install the toolkit, configure the Docker/containerd runtime, verify with nvidia-smi) is manual host configuration. On a Kubernetes fleet, the NVIDIA GPU Operator packages that entire host-side setup — driver installation/management, container toolkit, device plugin, DCGM monitoring, and (as of the 25.10 release line) CDI support — as a set of components the cluster manages itself, so individual node bootstrap scripts stop being where GPU readiness lives. This chapter deliberately stays at the single-host docker run/compose level; the GPU Operator (and the rest of the Kubernetes-specific device-plugin and scheduling story) belongs to this guide’s Kubernetes chapter, but it’s worth knowing the name and roughly what it replaces before that chapter goes deep, since an interviewer moving from “containerize this” to “now put it in a cluster” is testing whether you know the boundary between the two layers.
Saying it out loud. Everything in the toolkit section — install the packages, configure the container runtime, verify with
nvidia-smi— is manual host configuration, and on a Kubernetes fleet you stop doing that by hand. The NVIDIA GPU Operator packages the whole host-side story: driver management, the container toolkit, the device plugin, DCGM monitoring, and CDI support, all managed by the cluster itself, so GPU readiness stops living in per-node bootstrap scripts. It’s worth knowing the name and roughly what it replaces before you get to the Kubernetes chapter, because an interviewer moving from “containerize this” to “now put it in a cluster” is specifically testing whether you know where the boundary between those two layers is.
Build it in practice — extended
The Dockerfile below is the same pattern shown earlier in this chapter (build in devel, ship on runtime, non-root, healthcheck, exec-form entrypoint), reproduced here as the anchor for the compose and CI additions that follow.
# syntax=docker/dockerfile:1.7
###############################################################################
# Stage 1: builder — has nvcc + build tools, compiles/installs the env.
###############################################################################
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=0 \
PYTHONDONTWRITEBYTECODE=1
# System build deps. Pin, clean apt lists to keep the layer lean.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3.11-venv python3-pip build-essential git \
&& rm -rf /var/lib/apt/lists/*
# Isolated virtualenv so we can copy the whole thing to the runtime stage.
RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Dependency layer FIRST — cached across code changes.
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && pip install -r requirements.txt
###############################################################################
# Stage 2: runtime — slim CUDA runtime, no compilers, non-root, healthcheck.
###############################################################################
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
# Persist weights here; mount a volume at this path (see docker run).
HF_HOME=/models/hf
# Only the runtime OS deps: python + curl (for healthcheck). No build-essential.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 curl \
&& rm -rf /var/lib/apt/lists/*
# Copy the fully-built virtualenv from the builder stage — no nvcc ships.
COPY --from=builder /opt/venv /opt/venv
# Non-root user that can write the cache dir.
RUN useradd --create-home --uid 10001 appuser \
&& mkdir -p /models/hf && chown -R appuser:appuser /models
COPY --chown=appuser:appuser ./app /app
WORKDIR /app
USER appuser
EXPOSE 8000
# Config comes from env / CLI at runtime, not baked in.
ENV MODEL_ID=meta-llama/Llama-3.1-8B-Instruct \
TENSOR_PARALLEL_SIZE=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
# Exec form so signals reach the process (clean shutdown).
ENTRYPOINT ["python3.11", "-m", "vllm.entrypoints.openai.api_server"]
CMD ["--host", "0.0.0.0", "--port", "8000"]
Build and run:
# Build (BuildKit on for cache mounts + syntax directive)
DOCKER_BUILDKIT=1 docker build -t my-llm-server:1.0.0 .
# Run: expose GPUs, mount the HF cache volume, pass secrets at runtime.
docker run --rm \
--gpus all \ # expose all GPUs (toolkit required)
--ipc=host \ # shared mem for NCCL / TP; see note
-p 8000:8000 \
-v $HOME/.cache/hf:/models/hf \ # persist weights across restarts
-e HF_TOKEN=$HF_TOKEN \ # gated-model auth, NOT baked in
my-llm-server:1.0.0 \
--model meta-llama/Llama-3.1-8B-Instruct \ # config via CLI
--tensor-parallel-size 1
Why --ipc=host? vLLM (and PyTorch tensor-parallel generally) uses shared memory (/dev/shm) for inter-process/GPU communication. Docker’s default /dev/shm is 64 MB, which causes cryptic crashes or hangs under load. --ipc=host (or --shm-size=1g) gives it room. TGI uses --shm-size 1g for the same reason.
Reference: the official one-liner most teams actually start from —
# vLLM official image
docker run --runtime nvidia --gpus all \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--env HF_TOKEN=$HF_TOKEN -p 8000:8000 --ipc=host \
vllm/vllm-openai:latest --model mistralai/Mistral-7B-Instruct-v0.2
# TGI official image
docker run --gpus all --shm-size 1g -p 8080:80 \
-v $PWD/data:/data \
ghcr.io/huggingface/text-generation-inference:3.3.5 \
--model-id teknium/OpenHermes-2.5-Mistral-7B
Saying it out loud. If I had to describe the production Dockerfile in one breath: build in a
develCUDA base into an isolated virtualenv, copy just that virtualenv into a slimcudnn-runtimestage, create a non-root user that owns the cache directory, set a healthcheck with a five-minute start period, and use exec-form ENTRYPOINT so signals actually reach the process. Then at run time you pass--gpus all, mount the weights cache volume, and inject the token as an environment variable rather than baking it. The one flag people forget is--ipc=hostor--shm-size=1g— Docker’s default shared memory is 64 megabytes, and tensor-parallel NCCL communication needs far more, so without it you get cryptic hangs andBus errorcrashes that look nothing like a shared-memory problem.
The full stack: LLM server + reverse proxy + resource limits
A single docker run is fine for a dev box. A production compose file needs at minimum: the model server, a reverse proxy in front of it (TLS termination, request buffering, and a stable port even if you swap the backend image), and explicit resource limits so one runaway container can’t starve its neighbors on a shared host. Here is a more complete docker-compose.yaml:
services:
llm:
image: my-llm-server:1.0.0
restart: unless-stopped
expose:
- "8000" # only reachable from other compose services, not the host
ipc: host # equivalent to --ipc=host
environment:
- HF_TOKEN=${HF_TOKEN} # sourced from host env / .env, not in image
- MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
volumes:
- hf-cache:/models/hf # persistent named volume for weights
command: ["--model", "meta-llama/Llama-3.1-8B-Instruct"]
deploy:
resources:
limits:
cpus: "8" # cap host CPU this container can use
memory: 32g # cap host RAM (guards against OOM-killing neighbors)
reservations:
cpus: "4"
memory: 16g
devices:
- driver: nvidia
count: all # or `device_ids: ["0","1"]`
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
start_period: 300s
retries: 3
proxy:
image: caddy:2.8-alpine
restart: unless-stopped
depends_on:
llm:
condition: service_healthy # don't take traffic until the model is loaded
ports:
- "443:443" # only the proxy is exposed to the host/internet
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
deploy:
resources:
limits:
cpus: "1"
memory: 512m
volumes:
hf-cache:
caddy-data:
A minimal Caddyfile fronting the model server — TLS, a request timeout longer than your typical generation latency, and a size cap on request bodies:
llm.example.com {
reverse_proxy llm:8000 {
# Streaming responses (SSE) need this off, or you buffer the whole stream.
flush_interval -1
}
timeout 300s
request_body {
max_size 2MB
}
}
Two details that matter more than they look: expose (not ports) on the llm service means the model server is reachable only from the proxy service on the compose network, not directly from the host or internet — the proxy is the only public entry point, which is where you’d add auth, rate limiting, and TLS. And depends_on: condition: service_healthy means the proxy won’t route traffic to the model server until its HEALTHCHECK passes — closing the “requests arrive during the multi-minute model load and get 502s” gap.
Saying it out loud. A single
docker runis fine on a dev box; production wants at least three things in the compose file. The model server itself, exposed only internally rather than published to the host. A reverse proxy in front for TLS termination, request buffering, and a stable port so you can swap the backend image underneath. And explicit CPU and memory limits so one runaway container can’t starve its neighbors. The detail that saves you an incident: gate the proxy on the model server’s healthcheck withdepends_on: condition: service_healthy, so callers get a clean connection refusal instead of a wall of 502s during the several minutes the model is loading. And remember those resource limits cover CPU and RAM only — they do nothing for GPU memory.
Adding GPU observability to the stack
The compose file above is functionally complete but operationally blind — nothing exports GPU metrics. Adding NVIDIA’s DCGM exporter as a sidecar makes GPU utilization, memory, and temperature visible to Prometheus/Grafana without touching the llm service:
dcgm-exporter:
image: nvcr.io/nvidia/k8s/dcgm-exporter:3.3.9-3.6.1-ubuntu22.04
restart: unless-stopped
ports:
- "9400:9400" # scrape target for Prometheus
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
Point Prometheus at dcgm-exporter:9400/metrics and alert on GPU memory utilization specifically, not just SM/compute utilization — a server can look idle on compute while sitting dangerously close to an out-of-memory KV-cache allocation, and compute-only dashboards miss that entirely (see Mechanism 6’s monitoring note above).
Saying it out loud. A working compose file is still operationally blind — nothing in it exports a single GPU metric. Adding NVIDIA’s DCGM exporter as a sidecar container fixes that without touching your serving service at all: it exposes GPU utilization, memory, temperature, power, and ECC errors on a port Prometheus can scrape. The reason to do this at compose time rather than later is that the interesting failures are gradual — a KV-cache leak creeping up over days, thermal throttling under sustained load — and you cannot see a trend you never started recording. The specific alert I’d add first is GPU memory utilization with a warning threshold well under 100%, so you get runway before a hard out-of-memory rather than an alert that arrives with the crash.
CI smoke-test stage
GPU-backed CI runners are expensive and often unavailable, so most teams cannot run a full inference smoke test on every commit. The pragmatic middle ground: build the image, verify it starts and imports correctly on a CPU-only runner (catching the large class of bugs that have nothing to do with the GPU — bad pip install, broken entrypoint, missing env var, syntax errors), and reserve full GPU inference smoke tests for a nightly job or a pre-deploy gate on a real GPU runner.
# .github/workflows/docker-build.yml
name: build-and-smoke-test
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest # CPU-only runner: no --gpus here
steps:
- uses: actions/checkout@v4
- name: Build image
run: DOCKER_BUILDKIT=1 docker build -t my-llm-server:ci .
- name: Scan image (fail on critical/high CVEs)
run: docker scout cves my-llm-server:ci --exit-code --only-severity critical,high
- name: Smoke test — container starts, imports resolve, CLI parses args
run: |
# No GPU on this runner: --help exits before touching CUDA, so this
# catches broken installs / bad entrypoints without needing a GPU.
docker run --rm my-llm-server:ci --help
- name: Smoke test — process boots far enough to hit the arg parser
run: |
docker run --rm --entrypoint python3.11 my-llm-server:ci \
-c "import vllm; print(vllm.__version__)"
gpu-smoke-test:
if: github.event_name == 'workflow_dispatch' # manual / nightly, on a real GPU runner
runs-on: [self-hosted, gpu]
needs: build
steps:
- name: Full smoke test — load a tiny model, hit /health and /v1/completions
run: |
docker run -d --rm --gpus all --ipc=host -p 8000:8000 \
--name smoke my-llm-server:ci --model facebook/opt-125m
for i in $(seq 1 60); do
curl -fsS http://localhost:8000/health && break
sleep 5
done
curl -fsS http://localhost:8000/v1/models
docker stop smoke
The split matters: the CPU job runs on every PR and catches most regressions cheaply; the GPU job runs on a real GPU runner against a tiny model (opt-125m, not your 70B production model) so the full request path — container start, healthcheck, an actual generation call — is exercised without needing an expensive GPU for every commit.
Saying it out loud. GPU CI runners are expensive and often unavailable, so the pragmatic split is two jobs. On every pull request, build the image and verify it starts and imports correctly on a plain CPU runner — that catches the large class of bugs that have nothing to do with the GPU: a broken
pip install, a bad entrypoint, a missing environment variable, a syntax error. Then reserve a real GPU runner for a nightly or pre-promote job that runs an actual generation call against a tiny model likeopt-125m, not your 70B production checkpoint. That exercises the full path — container start, healthcheck, real inference — for a couple of cents instead of an expensive GPU-hour on every commit.
Weights-handling comparison
| Dimension | Bake into image | Mount volume | Download at startup | OCI model artifact (emerging) |
|---|---|---|---|---|
| Image size | Huge (15–150 GB) | Small | Smallest | Small (weights are a separate pull) |
| Cold start | Fast (already present) | Fast (local mount) | Slow (multi-GB download) | Fast once artifact is cached/mmap’d |
| Self-contained | Yes (air-gap ok) | No (needs volume) | No (needs network + token) | No (needs registry pull of artifact) |
| Swap models | Rebuild image | Change mount / env | Change env var | Change artifact tag/digest |
| Registry cost / push | High | Low | Low | Moderate (uncompressed but shared across engines) |
| Reproducibility | Highest (weights pinned) | Depends on volume contents | Depends on HF tag/revision | Highest (content-addressed digest) |
| Best for | Air-gapped, small, regulated | Fixed nodes, shared FS | Dev, autoscaling w/ warm cache | Multi-engine fleets sharing one checkpoint |
Pin the model revision (commit SHA) or artifact digest, not just the repo name or tag, when reproducibility matters for any of these options.
A note that cuts across every row of this table: the quantization format (see Mechanism 3’s earlier note) shifts where each option’s numbers land — a 4-bit-quantized 70B model bakes into an image at a size that would have been unthinkable in fp16, which is why “bake vs. mount vs. download” is a decision you should revisit per model/format combination, not settle once for the whole fleet.
Saying it out loud. If someone asks me to compare the weight-handling options, I’d frame it as a tradeoff between self-containment and lifecycle coupling. Baking into the image gives you the best reproducibility and works air-gapped, but you’re at 15 to 150 gigabytes and every code change redistributes the model. Mounting a volume is the production default — small image, fast start, models shared across containers — but the image is no longer self-contained and you have to provision the volume. Downloading at startup is the most flexible and smallest, but a cold start pays a multi-gigabyte download and now Hugging Face’s uptime is in your critical path. The rule that applies to all of them: pin the model revision by commit SHA or artifact digest, not the repo name — a tag can move under you.
Failure modes and pitfalls
- Driver/runtime mismatch —
CUDA driver version is insufficient. Container CUDA newer than host driver supports. Fix host driver or lower image CUDA; can’t be patched in the image. On a mixed-node fleet, this can appear on some nodes and not others — see the case study below. - GPU invisible —
torch.cuda.is_available()isFalse, nonvidia-smiin container. Toolkit not installed,--gpusomitted, or runtime not registered. Test withdocker run --gpus all nvidia/cuda:...-base nvidia-smi. - Giant images — shipped the
develbase,aptlists left behind, weights baked in, orpipcache retained. Use multi-stage,--no-install-recommends, clean/var/lib/apt/lists, and keep weights out. If you still land at 10+ GB (common with official framework images), that may simply be the cost of the maintained artifact — budget registry/pull time rather than fighting it alone. - Redownloading weights every restart — cache path not mounted to a persistent volume, or
HF_HOMEpoints at an ephemeral dir. Mount a named volume at the cache path. - Secrets in layers —
ENV HF_TOKEN=...orCOPY .envpersists in image history forever. Pass at runtime or use BuildKit secrets. - Running as root — default UID 0; a bad default for security and for shared-filesystem permissions. Create and drop to a non-root user. On rootless Podman/CDI setups, also check
video/rendergroup membership and SELinuxcontainer_use_devices— GPU access can silently fail even when the container itself is configured correctly. - No / bad healthcheck — orchestrator can’t detect a wedged server, or kills it during a 3-minute model load. Add a healthcheck with a generous
start-period, and gate your reverse proxy on it (depends_on: condition: service_healthy) so requests don’t 502 during warmup. /dev/shmtoo small — default 64 MB causes NCCL/tensor-parallel hangs andBus error. Use--ipc=hostor--shm-size.:latesteverywhere — non-reproducible builds and surprise upgrades. Pin base by digest, deps by version, your image by semver.- Signals ignored — shell-form
CMDruns under/bin/shwhich doesn’t forward SIGTERM; use exec-formENTRYPOINT/CMDso shutdown is graceful and requests drain. - Unscanned CVE surface — CUDA/PyTorch images pull in a much larger OS + Python dependency graph than a typical service image, so they accumulate CVEs faster; shipping without a CI scanning gate (Docker Scout / Trivy) or a hardened base (e.g., Chainguard) is a common 2025–2026-era gap that shows up in security reviews, not in functional testing.
- CI “passed” but the image never boots on GPU — a CPU-only CI runner that only checks
--help/import succeeds doesn’t exercise CUDA initialization at all. Pair it with a periodic real-GPU smoke test (see the CI section above) or you’ll ship a CUDA-init bug straight to prod. no kernel image is available for execution on the device— a from-source build’sTORCH_CUDA_ARCH_LISTdidn’t include the compute capability of the GPU you’re actually running on (e.g., built for 8.0/9.0, deployed on an older 7.5-class card). Fixed by rebuilding with the right architecture list, or by using a prebuilt wheel that already covers it — not a runtime-configurable option.- GPU memory creep goes unnoticed — dashboards only track SM/compute utilization, which can look healthy (or idle) right up until an allocation fails. Track GPU memory utilization per container (DCGM exporter) as a first-class metric, not an afterthought.
- Crash-loop storm from an unconditional restart policy — a deterministic failure (driver mismatch, OOM on every boot) combined with
restart: alwaysand no backoff burns node scheduling slots and pages on-call repeatedly instead of failing fast. Cap retries or useon-failurewith a backoff, and treat “restarted N times in M minutes” as its own alert.
Saying it out loud. The failure list here is short and repeats across every team. Driver mismatch — container CUDA newer than the host driver supports, unfixable from inside the image. GPU invisible, which means the toolkit isn’t installed or
--gpuswas omitted. Giant images from shipping thedevelbase or baking weights. Redownloading weights every restart because the cache path isn’t on a persistent volume. Secrets in layers, which live in image history forever. Running as root by default. And/dev/shmtoo small at 64 megabytes, which produces NCCL hangs andBus errorcrashes that look nothing like a shared-memory problem. The pattern worth naming: almost all of these are silent or misleading at the symptom level — the error you see is rarely the layer where the bug lives.
Tools and options comparison
| Option | What it is | When to reach for it |
|---|---|---|
nvidia/cuda:*-runtime | Slim CUDA userspace base | Final stage of a custom server |
nvidia/cuda:*-devel | CUDA + nvcc + headers | Build stage compiling kernels |
vllm/vllm-openai | Official vLLM OpenAI server image | Fast path to production vLLM (accept the ~12 GB size) |
ghcr.io/.../text-generation-inference | Official HF TGI image | HF-ecosystem serving, gated models |
| NVIDIA NIM | Prebuilt, GPU-SKU-tuned inference microservices (TensorRT-LLM/vLLM/SGLang) | Well-known model architectures, want less tuning work, OK with NGC entitlement |
| NVIDIA Container Toolkit | Host runtime that injects GPUs | Required for any GPU container |
CDI (nvidia-ctk cdi generate) | Vendor-neutral device-injection spec | Podman, rootless Docker, or any CDI-aware orchestrator |
| Chainguard Images | Hardened, near-zero-CVE CUDA/PyTorch bases | Security-sensitive deployments, want a maintained hardened base |
| Docker Model Runner / OCI model artifacts | Weights distributed as a separate OCI artifact | Multi-engine fleets sharing one checkpoint; early-adopter teams |
BuildKit / docker buildx | Modern builder: cache mounts, secrets | Every build (faster, safer) |
dive / docker history | Inspect layers & size | Hunting image bloat |
docker scout / trivy | Image vulnerability scanning | CI gate before push, especially for large CUDA/PyTorch images |
syft / docker sbom / cosign | SBOM generation and image signing | Supply-chain attestation, verifying provenance before deploy |
nvidia-smi mig / MIG profiles | Hardware GPU partitioning | Hard multi-tenant isolation on A100/H100-class GPUs |
DCGM / dcgm-exporter | GPU metrics exporter (Prometheus) | Fleet-wide GPU utilization, memory, and health monitoring |
| NVIDIA MPS | Software multi-process GPU sharing | Sharing one GPU across cooperating processes with more predictable compute allocation than plain time-slicing |
Production case studies & war stories
Three incidents that recur often enough in practice to be worth internalizing before you hit them yourself.
War story 1 — the driver mismatch that only broke on one node type
Setup. A platform team ran inference on an autoscaling GPU node pool mixing two instance types: an older generation bought a year earlier (driver 535.x, installed when the nodes were provisioned) and a newer generation added last quarter (driver 550.x, from a newer base AMI). The serving image was built on nvidia/cuda:12.4.1-*, which needs a driver new enough to support CUDA 12.4’s userspace.
What happened. A routine image bump (upgrading a Python dependency, nothing GPU-related) triggered a rolling redeploy across the whole node pool. Pods scheduled onto the newer-generation nodes (driver 550.x) came up fine. Pods scheduled onto the older-generation nodes (driver 535.x — new enough for CUDA 12.2, not comfortably for 12.4) crash-looped with:
CUDA driver version is insufficient for CUDA runtime version
Because the autoscaler load-balanced across both node types, roughly a third of pods were healthy and two-thirds were crash-looping — the deploy looked like a partial, confusing outage rather than a clean pass/fail, and the on-call’s first instinct (check the app logs, check the model, check the request path) burned the first 40 minutes before someone ran nvidia-smi on a failing node and saw the driver version.
Root cause. The container’s CUDA version was never validated against the oldest driver in the fleet — only against the engineer’s own dev box, which happened to have the newer driver. Nothing in CI caught it, because CI didn’t have GPU nodes of the older generation to test against.
Fix and lesson. The team added a driver-version floor check to node bootstrap (fail node registration if nvidia-smi --query-gpu=driver_version --format=csv,noheader is below a pinned minimum) so the fleet’s minimum driver version becomes an explicit, enforced contract rather than an implicit assumption about whichever node an engineer happened to test on. They also added a one-line preflight in the container’s entrypoint — run nvidia-smi before starting the server and exit with a clear error if it fails — so a driver mismatch produces an immediate, legible failure instead of a framework-level CUDA init stack trace three layers down. The generalizable lesson: treat “minimum supported host driver version” as a versioned contract between infra and the serving image, checked at both node-bootstrap time and container-start time — not something that’s implicitly whatever driver happened to be on the box someone tested on.
Saying it out loud. A team ran an autoscaling GPU pool that mixed two node generations — older nodes on driver 535, newer ones on 550 — and shipped an image built on CUDA 12.4. A routine dependency bump triggered a rolling redeploy, and pods landing on the newer nodes came up fine while pods on the older nodes crash-looped with
CUDA driver version is insufficient. Because the autoscaler spread across both types, roughly a third were healthy — so it presented as a confusing partial outage, and on-call spent forty minutes in the app logs before anyone rannvidia-smion a failing node. Root cause: the image’s CUDA version had only ever been validated against one engineer’s dev box. The fix and the lesson: treat minimum host driver version as an enforced contract, checked at node bootstrap and preflighted in the container entrypoint.
War story 2 — the 40 GB image that broke CI, not just the registry
Setup. An early version of a custom serving image baked model weights directly into the image (Option A from Mechanism 3) because it was simple and “worked on my machine.” The weights were a 34B-parameter checkpoint in fp16, roughly 70 GB of safetensors, compressing to a ~40 GB image layer.
What happened. CI ran docker build on every PR to catch regressions. The build step alone took 25+ minutes once network transfer of that layer was involved, and the shared CI runner’s local image cache (sized for typical services, a few GB) evicted the 40 GB layer between runs — so nearly every PR paid the full weights-copy cost again, rather than getting a cache hit. Beyond CI: every docker push/pull moved the full 40 GB, registry storage costs for versioned images climbed fast (each rebuild produced a new immutable tag), and rolling out a one-line code fix to production meant redistributing the entire checkpoint to every node again, turning what should have been a 30-second deploy into a 20+ minute one gated by network transfer.
Root cause. Weights (large, slow-changing, indifferent to code) and code (tiny, fast-changing) were coupled into one artifact and one lifecycle, so every code change paid the weights-transfer cost, and CI’s cache assumptions (sized for normal service images) were simply wrong for this workload.
Fix and lesson. The team moved to Option B (mount a volume pre-populated with weights on each node, refreshed out-of-band from the deploy pipeline) for production, and kept a from-scratch build with a tiny stand-in model (facebook/opt-125m, a few hundred MB) for CI so the Dockerfile/dependency layers were still validated on every PR without moving real weights through the build pipeline at all. Deploy time for a code-only change dropped from ~20 minutes to under a minute; CI build time dropped from 25+ minutes to about 90 seconds. The generalizable lesson: the moment your image is dominated by data rather than code, your CI and registry tooling need a data-vs-code split too — don’t let a shared cache and registry sized for normal service images silently absorb a 40 GB workload; separate the lifecycles explicitly (Mechanism 3’s bake/mount/download decision isn’t just a runtime-architecture choice, it’s a CI/registry-cost decision too).
Saying it out loud. A team baked a 34B fp16 checkpoint into their image because it was simple — about 70 gigabytes of safetensors, roughly a 40 gigabyte layer. The interesting part is that the registry wasn’t even the worst pain: CI ran
docker buildon every PR, the shared runner’s cache was sized for normal service images, so it evicted the 40 GB layer between runs and nearly every PR paid the full copy again. Builds took 25-plus minutes, and shipping a one-line fix meant redistributing the entire checkpoint to every node — a 30-second deploy became 20 minutes. They moved to volume-mounted weights in production and a tinyopt-125mstand-in for CI: build time went from 25 minutes to about 90 seconds, deploy from 20 minutes to under one. The lesson: when your image is dominated by data, your CI and registry assumptions are wrong too.
War story 3 — the “idle” GPU that was actually one bad allocation from falling over
Setup. A dashboard tracked GPU SM/compute utilization per node as the primary GPU health signal, on the reasonable-sounding assumption that “low utilization = healthy, high utilization = busy.”
What happened. A slow KV-cache memory leak, triggered only by a specific long-context request pattern, grew VRAM usage across days while compute utilization stayed unremarkable — the server was mostly waiting on generation, not compute-bound, so nothing on the compute dashboard moved. The first visible symptom was a hard CUDA out-of-memory crash under otherwise normal load, with no warning in the metrics anyone was watching.
Fix and lesson. The team added GPU memory utilization (not just compute) as an explicit, alerted metric via DCGM, with a warning threshold well below 100% so there was runway to intervene before a hard OOM. The generalizable lesson, and the reason it’s paired with Mechanism 6’s monitoring note earlier in this chapter: compute utilization and memory utilization are different signals that fail independently — a GPU container can look “idle” by one measure while one allocation away from crashing by the other, so watch both, not just the one that’s easiest to eyeball on nvidia-smi.
Saying it out loud. A team dashboarded GPU compute utilization as their primary health signal, on the very reasonable assumption that low utilization means healthy. Then a slow KV-cache leak, triggered only by a particular long-context request pattern, grew VRAM usage over days while compute utilization stayed completely flat — because the server was waiting on generation, not compute-bound. The first symptom anyone saw was a hard CUDA out-of-memory crash under otherwise ordinary load, with nothing on the dashboard having moved beforehand. The fix was adding GPU memory utilization as an alerted DCGM metric with a warning threshold well below 100%. The generalizable lesson: compute and memory utilization are different signals that fail independently, and the one that’s easiest to eyeball on
nvidia-smiis not the one that catches this.
Interview mastery
“Explain why GPU images are different from normal Docker images” — in 60 seconds
A normal Docker image is fully self-contained: the base image, the runtime, and the app all travel together, and the host just runs the kernel. A GPU image breaks that isolation on purpose. The container ships CUDA/cuDNN userspace libraries, but the GPU driver is a kernel module that must already be installed on the host — you never bake it into the image — so there’s a version contract: the container’s CUDA version has to be no newer than what the host driver supports, checked with nvidia-smi. Getting the GPU into the container at all requires a host-side component, the NVIDIA Container Toolkit, which injects the driver’s libraries and device nodes at docker run --gpus all time — a plain container gets no GPU access. On top of that, these images are unusually large (multi-GB CUDA runtime, plus optionally multi-GB-to-hundreds-of-GB of model weights), which forces explicit decisions a normal service image never has to make: build vs. runtime base (multi-stage), and whether weights live in the image, a mounted volume, or a startup download. And because the OS + CUDA + Python dependency graph is so much bigger, these images carry a larger CVE surface than a typical microservice, which is why vulnerability scanning and hardened base images get called out specifically for AI workloads rather than being generic Docker hygiene.
Q&A
- How does a container get access to the GPU? Host driver + NVIDIA Container Toolkit +
--gpus. You never install the driver in the image; the toolkit injects host driver libs at runtime. Verify withdocker run --gpus all ... nvidia-smi. runtimevsdevelbase image — which do you ship? Build indevel, ship onruntimevia multi-stage. Shippingdevelis a multi-GB mistake — nvcc, headers, and static libs you never need at inference time.- Where do the weights live and why? Articulate bake vs. mount vs. download (and the emerging OCI-model-artifact option) and the cold-start/size/reproducibility tradeoffs; know that a mounted, persistent HF cache (
HF_HOME) is the usual production answer, with baking reserved for air-gapped/regulated cases. - How do you keep the image small? Multi-stage,
--no-install-recommends, clean apt lists,.dockerignore, don’t bake weights, BuildKit cache mounts. Also know the honest limit: official framework images (e.g.,vllm/vllm-openai) are large (~12 GB) largely by design/maintenance tradeoff, not a bug you can always fix yourself. - How do you handle the CUDA/driver version contract? Pin container CUDA ≤ host-driver-supported; understand minor-version/forward compatibility; recognize the “driver insufficient” error and know it’s fixed on the host or by lowering the image’s CUDA version, never inside the container.
- How do secrets and config get in? Runtime env / orchestrator secrets and BuildKit
--secret, neverENV/COPY .env(persists in layer history forever, even if a later layer deletes the file). - Non-root, healthcheck, signals? Drop to an unprivileged UID, healthcheck with a long
start-periodfor model load (and gate a reverse proxy on that healthcheck), exec-form entrypoint for graceful SIGTERM draining. - Why
--ipc=host/--shm-size? Tensor-parallel / NCCL uses/dev/shm; the 64 MB default causes hangs and bus errors under load. - What is the NVIDIA Container Toolkit actually doing under the hood? It’s a host-side runtime shim that, at container-start, mounts the host’s GPU device nodes and matching driver userspace libraries into the container’s filesystem/namespace, based on
NVIDIA_VISIBLE_DEVICES(legacy) or a CDI spec (current). It does not install or virtualize a driver — the container always uses the exact host driver. - What is CDI and why does it matter? The Container Device Interface is a vendor-neutral spec (Docker/Podman/containerd/Kubernetes all understand it) for describing device injection, replacing NVIDIA’s older proprietary hook mechanism. It matters practically because it’s what makes rootless GPU containers (Podman, rootless Docker) workable — the legacy hook assumed a privileged daemon.
- How would you run a GPU container rootless, and what breaks if you don’t set it up right? Generate a user-space CDI spec (
nvidia-ctk cdi generate --output=~/.config/cdi/nvidia.yaml), reference the device by its CDI name (--device nvidia.com/gpu=all), and ensure the invoking user has device-file permissions (video/rendergroup) and, on SELinux hosts,container_use_devicesenabled — otherwise the container starts but silently can’t see the GPU. - Why might you choose an official/vendor image (vLLM, TGI, NIM) over a hand-rolled Dockerfile? Less Dockerfile/CUDA-version maintenance burden, a tested and (for NIM) per-GPU-SKU-tuned engine, faster time to a working server. Trade-offs: less control over exact image contents/size, and for NIM, potential licensing/entitlement gating.
- How do you defend an image’s security posture in a review? Name the concrete mechanisms: pinned digests, non-root user, no secrets in layers, a CI scanning gate (Docker Scout/Trivy) with a CVE-severity threshold, and — if asked about the current state of the art — hardened base images (e.g., Chainguard) that measurably cut CVE count versus the stock CUDA/PyTorch bases.
- A GPU container works on your dev box but crash-loops on some fleet nodes with a CUDA driver error — how do you debug and fix it, structurally, not just for this incident? Diagnose:
nvidia-smion the failing node to read its driver version, compare against the image’s CUDA version. Fix the immediate incident by aligning driver/CUDA. Fix it structurally by enforcing a minimum-driver-version check at node bootstrap and a preflightnvidia-smicheck in the container entrypoint, so the fleet’s driver floor is an explicit, tested contract instead of “whatever driver the last person’s dev box had.” - Your image is 40+ GB because it bakes in the weights, and CI/registry costs are exploding — what do you change? Split weights out of the image (mount or download), keep a tiny stand-in model for CI/Dockerfile validation, and separate the code-deploy lifecycle from the weights-distribution lifecycle so a one-line code fix doesn’t require redistributing the checkpoint.
- What’s the difference between baking weights into an image layer and packaging them as a separate OCI artifact? An image layer is a compressed tarball; weight files are high-entropy so compression barely helps and costs (de)compression time, and the engine can’t
mmapa compressed layer directly. An OCI model artifact stores the weights uncompressed under a model-specific media type, decoupled from any particular serving engine’s image, so multiple engines can reference the same weights without duplicating them and the engine can load it more directly. - Why do healthcheck
start-periodand reverse-proxydepends_onmatter together? A model load can take minutes; without a longstart-periodthe orchestrator may kill the container mid-load, and without gating the proxy on the healthcheck, requests arrive and get 502s during that window even if the container itself survives. - When would you deliberately choose a larger, less-optimized image over a hand-tuned minimal one? When the maintenance cost of hand-slimming exceeds the storage/pull-time cost — e.g., adopting the official
vllm/vllm-openaiimage rather than fighting its size, because the vLLM maintainers themselves have indicated from-source slimming isn’t a reliably supported path. - What’s the difference between MIG and time-slicing, and when would you reach for each? MIG partitions a GPU in hardware into isolated instances (strong isolation, fixed profile sizes, set up outside the container); time-slicing shares a whole GPU across containers in software with no memory isolation (simpler, weaker guarantee). Reach for MIG when tenants must not be able to starve each other; time-slicing is fine for trusted, non-adversarial sharing.
- Do Docker/Kubernetes resource limits cap GPU usage the way they cap CPU and memory? No —
cpus/memorylimits are enforced via host cgroups and are real; there’s no equivalent cgroups-based cap on GPU compute or VRAM for a container.--gpus/device requests control which GPU(s) a container can see, not how much of a shared one it’s capped at using. MIG or MPS are the actual mechanisms for bounding/sharing GPU resources between containers. - How do you monitor a fleet of GPU containers in production? DCGM exporter (or equivalent) scraped by Prometheus, tracking both compute and memory utilization per GPU/container — not compute alone, since a memory-bound failure can occur with unremarkable compute metrics right up until an OOM.
- A container gets SIGTERM mid-generation — what should happen, and what’s the default risk? Default Docker grace period (10s) is often too short to drain an in-flight streaming response; the fix is a longer grace period (
stop_grace_period/terminationGracePeriodSeconds) plus application-level handling that stops accepting new requests immediately while letting in-flight ones finish within the window. - A build stage that compiles kernels from source suddenly fails on the deploy GPU with
no kernel image is available for execution on the device— what’s wrong?TORCH_CUDA_ARCH_LIST(or equivalent) didn’t target that GPU’s compute capability at build time; the fix is rebuilding for the right architecture list or switching to a prebuilt wheel that already covers it, not anything fixable at runtime. - CI takes 25 minutes to build your image and the cache never seems to hit on a fresh runner — why, and what’s the fix? Local Docker layer caching only helps on the same machine; ephemeral/shared CI runners don’t retain it between jobs. Push/pull the build cache itself through a registry (
docker buildx build --cache-to type=registry,mode=max --cache-from type=registry) so any runner can warm from the last successful build.
System design prompt: “Containerize a multi-GPU model server for production”
A common follow-up prompt: design the containerization and rollout for a model server that needs 4 GPUs per replica (tensor-parallel), running across a fleet, with zero-downtime deploys. A sketch of the answer, mapping back to mechanisms in this chapter:
┌─────────────────────────────┐
│ Load balancer / │
│ reverse proxy (TLS, auth, │
│ rate limit, streaming SSE) │
└───────────────┬───────────────┘
│ routes only to
│ healthy replicas
┌──────────────────────────────────┼──────────────────────────────────┐
│ │ │
┌──────────▼─────────┐ ┌───────────▼──────────┐ ┌──────────▼─────────┐
│ Replica (canary, │ │ Replica (stable, v1) │ │ Replica (stable, v1) │
│ v2, 5% of traffic) │ │ 4x GPU, TP=4 │ │ 4x GPU, TP=4 │
│ 4x GPU, TP=4 │ │ node affinity: pool-A │ │ node affinity: pool-A │
│ node affinity: pool-B│ │ driver >= floor pinned │ │ driver >= floor pinned │
└──────────┬──────────┘ └───────────┬───────────┘ └──────────┬────────────┘
│ │ │
└───────────────┬────────────────────┴───────────────┬───────────────────┘
│ │
┌──────────▼──────────┐ ┌────────▼─────────┐
│ Weights: mounted │ │ Secrets: HF_TOKEN, │
│ volume / warm HF │ │ TLS certs — from │
│ cache per node pool │ │ orchestrator secret │
│ (shared, versioned) │ │ store, never baked │
└──────────────────────┘ └────────────────────┘
CI/CD gate (pre-deploy): docker build → docker scout/trivy scan (fail on
critical/high) → CPU smoke test (import + --help) → nightly/pre-promote GPU
smoke test (tiny model, real /v1/completions call) → sign + push digest →
canary 5% on pool-B → automated rollback if error-rate/latency regress →
promote to 100%.
Talking points an interviewer wants to hear, in roughly this order: (1) the image is built multi-stage (devel → runtime), non-root, healthchecked, exec-form entrypoint; (2) weights are not baked in — mounted or cached per node pool so a code-only rollout doesn’t redistribute the checkpoint (tie this directly to the CI/registry-cost war story); (3) --ipc=host/--shm-size is set because TP=4 needs real shared memory for NCCL; (4) node pools have an enforced minimum driver version, checked at bootstrap and preflighted in the entrypoint, so a mixed-generation fleet can’t silently crash-loop a subset of replicas; (5) the reverse proxy is the only externally exposed surface and only routes to replicas passing healthcheck; (6) rollout is canary-then-promote, gated by a CI pipeline that scans for CVEs and smoke-tests on both CPU (every PR) and GPU (pre-promote); (7) secrets come from the orchestrator’s secret store, never from the image.
Saying it out loud. For a four-GPU tensor-parallel replica with zero-downtime deploys, I’d walk it in this order. The image is multi-stage, non-root, healthchecked, exec-form entrypoint. Weights are not baked in — they’re on a mounted volume or warm cache per node pool, so a code-only rollout doesn’t push the checkpoint again.
--ipc=hostis set because TP=4 needs real shared memory for NCCL, and the 64-megabyte default will hang you. Node pools enforce a minimum driver version at bootstrap and preflight it in the entrypoint, so a mixed fleet can’t silently crash-loop a subset of replicas. A reverse proxy is the only exposed surface and only routes to healthy replicas. And the rollout is canary-then-promote behind a CI gate that scans for CVEs and smoke-tests on CPU every PR, GPU before promotion. Secrets come from the orchestrator, never the image.
Red flags vs. green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Base image choice | Ships devel to production, or python:slim with manual CUDA install | Multi-stage: devel to build, runtime (or a hardened base) to ship |
| Model weights | Baked into the image “because it was simpler” | Explicit bake/mount/download decision tied to a stated tradeoff (air-gap, node pool, autoscaling) |
| Driver/CUDA versioning | “It works on my machine,” no stated minimum driver version | A pinned, fleet-wide minimum driver version enforced at bootstrap + preflighted at container start |
| GPU access | Doesn’t know the difference between --gpus all and NVIDIA_VISIBLE_DEVICES, never heard of CDI | Can explain the toolkit/CDI injection mechanism and when rootless matters |
| Secrets | ENV HF_TOKEN=... or COPY .env in the Dockerfile | Runtime env / orchestrator secrets / BuildKit --secret |
| User | Runs as root, no justification | Non-root UID, with cache/data dirs explicitly chowned |
| Healthcheck | None, or a short start-period that kills the container during model load | Healthcheck with a generous start-period, and downstream proxy gated on it |
/dev/shm | Unaware TP/NCCL needs shared memory; hits mysterious bus errors under load | Sets --ipc=host/--shm-size deliberately, can explain why |
| CI | No image build validation, or claims “GPU tested” from a CPU-only runner | CPU smoke test on every PR, real GPU smoke test with a tiny model pre-promote |
| Security posture | No scanning, no SBOM, “we haven’t gotten to that yet” | CI-gated CVE scanning, SBOM, ideally a hardened base or explicit CVE-budget policy |
| Talking about size | “The image is 40 GB, that’s just how it is” with no plan | Can name the specific driver of size (weights? devel base? apt cache?) and the fix |
Quick reference: commands you’ll actually run
A condensed cheat sheet of the commands from this chapter you’ll reach for most often, in the order you’d typically use them.
# 1. Verify the whole host GPU chain before blaming your image
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
# 2. Build with BuildKit, warming/publishing the cache through a registry (fast CI)
docker buildx build \
--cache-from type=registry,ref=myregistry.example.com/my-llm-server:buildcache \
--cache-to type=registry,ref=myregistry.example.com/my-llm-server:buildcache,mode=max \
-t my-llm-server:1.0.0 .
# 3. Scan before you ship it
docker scout cves my-llm-server:1.0.0 --exit-code --only-severity critical,high
# 4. Run it for real: GPUs, shared memory, persistent weights cache, runtime secrets
docker run --rm --gpus all --ipc=host -p 8000:8000 \
-v $HOME/.cache/hf:/models/hf -e HF_TOKEN=$HF_TOKEN \
my-llm-server:1.0.0 --model meta-llama/Llama-3.1-8B-Instruct
# 5. Or bring up the whole stack (proxy + limits + healthchecks) via compose
docker compose up -d
# 6. Debug from inside a running container — GPU state as *this* container sees it
docker exec -it my-llm-server nvidia-smi
# 7. Rootless / Podman: generate a user-space CDI spec once per host
mkdir -p ~/.config/cdi && nvidia-ctk cdi generate --output=$HOME/.config/cdi/nvidia.yaml
# 8. Shut down cleanly, giving in-flight streaming requests time to drain
docker stop -t 60 my-llm-server
Keep this list next to the Dockerfile and compose file from earlier in this chapter — between the two, they cover build, ship, run, observe, and shut down for a single-node GPU deployment; the Kubernetes chapter picks up from here for multi-node scheduling.
Further reading
Core toolkit and base images
- NVIDIA Container Toolkit — repo, releases (v1.19.0, March 12, 2026): https://github.com/NVIDIA/nvidia-container-toolkit
- NVIDIA Container Toolkit — install guide: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
- NVIDIA Container Toolkit — overview: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/overview.html
nvidia/cudaimage tags (Docker Hub): https://hub.docker.com/r/nvidia/cuda/tags- CUDA container supported tags & flavors: https://gitlab.com/nvidia/container-images/cuda/-/blob/master/doc/supported-tags.md
CDI and rootless GPU containers
- Container Device Interface support (NVIDIA Container Toolkit docs): https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/1.16.2/cdi-support.html
- CDI support in the GPU Operator (25.10): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/25.10/cdi.html
- Running NVIDIA GPU containers with Podman (rootless, CDI walkthrough, 2026-03-18): https://oneuptime.com/blog/post/2026-03-18-run-nvidia-gpu-containers-podman/view
- Using CDI with Podman: https://oneuptime.com/blog/post/2026-03-18-use-cdi-container-device-interface-podman/view
Official framework images
- vLLM — Using Docker: https://docs.vllm.ai/en/latest/deployment/docker/
- vLLM GitHub issue — reducing the official image size (maintainer response on from-source builds): https://github.com/vllm-project/vllm/issues/27154
- vLLM Forums — “Current vLLM docker image size is 12.64Gb, how to reduce it?”: https://discuss.vllm.ai/t/current-vllm-docker-image-size-is-12-64gb-how-to-reduce-it/1204
- Hugging Face TGI — Nvidia GPU install: https://huggingface.co/docs/text-generation-inference/en/installation_nvidia
- Hugging Face TGI — Quick Tour: https://huggingface.co/docs/text-generation-inference/quicktour
- Hugging Face Hub — Understand caching (
HF_HOME): https://huggingface.co/docs/huggingface_hub/en/guides/manage-cache - NVIDIA NIM — overview and developer docs: https://developer.nvidia.com/nim
- NVIDIA NIM — microservices product page: https://www.nvidia.com/en-us/ai-data-science/products/nim-microservices/
Image hardening, size, and weight packaging
- Chainguard — Securing the foundations of AI applications (zero-CVE PyTorch/CUDA image comparison): https://www.chainguard.dev/unchained/securing-the-foundations-of-ai-applications-with-chainguard-images
- Chainguard Containers — overview: https://edu.chainguard.dev/chainguard/chainguard-images/overview/
- Docker — Why OCI Artifacts for AI Model Packaging: https://www.docker.com/blog/oci-artifacts-for-ai-model-packaging/
Supply-chain scanning and security
- Docker Scout —
docker scout cvesreference: https://docs.docker.com/reference/cli/docker/scout/cves/ - Docker Hardened Images — scanning how-to: https://docs.docker.com/dhi/how-to/scan/
- Vulnerability management with Trivy (2025-10-19): https://infrahouse.com/blog/2025-10-19-vulnerability-management-part2-trivy/
Docker mechanics
- Docker — Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker — Building best practices: https://docs.docker.com/build/building/best-practices/
- Docker — Build cache & BuildKit: https://docs.docker.com/build/cache/
- Docker Compose — GPU support: https://docs.docker.com/compose/how-tos/gpu-support/
- NVIDIA GPU Operator — CDI support (25.10): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/25.10/cdi.html
- NVIDIA — Multi-Instance GPU (MIG) user guide: https://docs.nvidia.com/datacenter/tesla/mig-user-guide/index.html
- NVIDIA DCGM — Data Center GPU Manager overview: https://developer.nvidia.com/dcgm
- Docker —
stop/graceful-shutdown grace period reference: https://docs.docker.com/reference/cli/docker/container/stop/ - Docker Buildx — registry cache backend (
--cache-to/--cache-from): https://docs.docker.com/build/cache/backends/registry/ - vLLM GitHub issue — earlier image-size discussion,
python-slimattempt: https://github.com/vllm-project/vllm/issues/13112 - NVIDIA — CUDA compatibility guide (forward compatibility, driver/toolkit matrix): https://docs.nvidia.com/deploy/cuda-compatibility/index.html
Topic 3: Kubernetes Deployment
What You’ll Learn
This topic teaches you how to:
- Deploy LLM serving applications to Kubernetes
- Configure health checks and probes
- Set resource limits and requests
- Use ConfigMaps and Secrets
- Handle rolling updates
- Scale applications
Why We Need This
Business Need
- Cost optimization: Auto-scale down during low traffic, save money
- Reliability: 99.9% uptime SLA requires automatic recovery
- Speed: Deploy updates in seconds, not hours
- Compliance: Resource limits ensure fair resource usage
Technical Need
- Orchestration: Manage hundreds of containers automatically
- Resource isolation: Prevent one service from starving others
- Service discovery: Automatic load balancing across pods
- Self-healing: Restart failed containers automatically
Real-World Impact
Without Kubernetes:
- ❌ Manual scaling (slow, error-prone)
- ❌ Downtime during deployments
- ❌ Resource conflicts between services
- ❌ Difficult to manage at scale (100+ services)
Industry Use Cases
1. Large-Scale ML Platforms
Company: OpenAI, Anthropic, HuggingFace Use Case:
- Serve millions of API requests per day
- Auto-scale based on demand
- Zero-downtime deployments
Example:
# Handles traffic spikes automatically
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 10
maxReplicas: 1000 # Scale to 1000 pods during peak
2. Enterprise ML Infrastructure
Company: Banks, healthcare, finance Use Case:
- Multi-tenant ML serving
- Resource quotas per team
- Compliance and audit trails
Example:
# Resource quotas per namespace
apiVersion: v1
kind: ResourceQuota
metadata:
name: ml-team-quota
spec:
hard:
requests.cpu: "100"
requests.memory: 200Gi
limits.nvidia.com/gpu: "10"
3. Multi-Region Deployment
Company: Global SaaS companies Use Case:
- Deploy same model to multiple regions
- Low latency for global users
- Regional failover
Example:
# Deploy to multiple regions
kubectl apply -f deployment.yaml --context us-east
kubectl apply -f deployment.yaml --context eu-west
kubectl apply -f deployment.yaml --context asia-pacific
4. A/B Testing Infrastructure
Company: Tech companies (Netflix, Spotify) Use Case:
- Run multiple model versions simultaneously
- Split traffic between versions
- Compare performance
Example:
# Deploy model A and B
kubectl apply -f model-a-deployment.yaml
kubectl apply -f model-b-deployment.yaml
# Route 50% traffic to each
5. Development/Staging/Production
Company: All companies Use Case:
- Same deployment config for all environments
- Easy promotion from dev → staging → prod
Example:
# Deploy to dev
kubectl apply -f deployment.yaml -n dev
# Test, then promote to staging
kubectl apply -f deployment.yaml -n staging
# Finally, production
kubectl apply -f deployment.yaml -n production
Industry-Standard Boilerplate Code
Complete Production Deployment (Industry Standard)
# deployment.yaml
# Used by: Production ML serving at scale
apiVersion: v1
kind: Namespace
metadata:
name: llm-serving
---
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
namespace: llm-serving
data:
model_name: "gpt2"
max_length: "100"
temperature: "0.7"
log_level: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
name: llm-secrets
namespace: llm-serving
type: Opaque
stringData:
api_key: "your-api-key-here"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving
namespace: llm-serving
labels:
app: llm-serving
version: v1.0.0
spec:
replicas: 3 # High availability
selector:
matchLabels:
app: llm-serving
template:
metadata:
labels:
app: llm-serving
version: v1.0.0
spec:
containers:
- name: llm-serving
image: your-registry/llm-serving:v1.0.0
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
# From ConfigMap
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
- name: MAX_LENGTH
valueFrom:
configMapKeyRef:
name: llm-config
key: max_length
# From Secret
- name: API_KEY
valueFrom:
secretKeyRef:
name: llm-secrets
key: api_key
resources:
requests:
memory: "4Gi"
cpu: "2000m"
nvidia.com/gpu: 1 # GPU request
limits:
memory: "8Gi"
cpu: "4000m"
nvidia.com/gpu: 1 # GPU limit
# Health checks (critical for production)
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60 # Model loading time
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30 # Allow 5 minutes for startup
periodSeconds: 10
# Node selector for GPU nodes
nodeSelector:
accelerator: nvidia-tesla-v100
# Tolerations for GPU nodes
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
---
apiVersion: v1
kind: Service
metadata:
name: llm-serving
namespace: llm-serving
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: llm-serving
---
# Ingress for external access
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-ingress
namespace: llm-serving
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- llm-api.yourcompany.com
secretName: llm-tls
rules:
- host: llm-api.yourcompany.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-serving
port:
number: 80
Deployment Script (Industry Standard)
#!/bin/bash
# deploy.sh
# Used by: CI/CD pipelines, deployment automation
set -e
NAMESPACE="llm-serving"
IMAGE_TAG="${1:-latest}"
ENVIRONMENT="${2:-staging}"
echo "Deploying to ${ENVIRONMENT} with image tag ${IMAGE_TAG}"
# Create namespace if it doesn't exist
kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
# Update image in deployment
kubectl set image deployment/llm-serving \
llm-serving=your-registry/llm-serving:${IMAGE_TAG} \
-n ${NAMESPACE}
# Wait for rollout
kubectl rollout status deployment/llm-serving -n ${NAMESPACE} --timeout=5m
# Verify deployment
kubectl get pods -n ${NAMESPACE}
kubectl get svc -n ${NAMESPACE}
echo "Deployment complete!"
Rollback Script (Industry Standard)
#!/bin/bash
# rollback.sh
# Used when issues are detected in production
NAMESPACE="llm-serving"
echo "Rolling back deployment..."
kubectl rollout undo deployment/llm-serving -n ${NAMESPACE}
kubectl rollout status deployment/llm-serving -n ${NAMESPACE}
echo "Rollback complete!"
Key Concepts
- Pods: Smallest deployable unit (your container)
- Deployments: Manage pod replicas
- Services: Expose pods to network
- ConfigMaps: Configuration data
- Secrets: Sensitive data
- Ingress: External access
Prerequisites
Local Kubernetes
# Option 1: Minikube
minikube start
# Option 2: Kind
kind create cluster
# Option 3: Docker Desktop (has K8s built-in)
Verify Setup
kubectl cluster-info
kubectl get nodes
Basic Deployment
Deployment Manifest
See deployment.yaml for a complete example.
Key components:
- Deployment: Manages pods
- Service: Exposes pods
- ConfigMap: Configuration
- Resource limits: CPU/memory
Deploy
kubectl apply -f deployment.yaml
Check Status
kubectl get pods
kubectl get services
kubectl logs -f deployment/llm-serving
Health Checks
Liveness Probe
Detects if pod is alive. If fails, pod is restarted.
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
Readiness Probe
Detects if pod is ready to serve traffic.
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 5
Startup Probe
Gives pod time to start (useful for slow-starting apps).
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30
periodSeconds: 10
Resource Management
Requests
Minimum resources guaranteed to pod.
resources:
requests:
memory: "2Gi"
cpu: "1000m"
Limits
Maximum resources pod can use.
resources:
limits:
memory: "4Gi"
cpu: "2000m"
nvidia.com/gpu: 1 # For GPU
Why This Matters
- Requests: Scheduler uses this to place pods
- Limits: Prevents pods from consuming all resources
- GPU: Must specify for GPU workloads
ConfigMaps and Secrets
ConfigMap
Store non-sensitive configuration.
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
data:
model_name: "gpt2"
max_length: "100"
temperature: "0.7"
Secret
Store sensitive data (API keys, tokens).
apiVersion: v1
kind: Secret
metadata:
name: llm-secrets
type: Opaque
data:
api_key: <base64-encoded>
Using in Deployment
env:
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
- name: API_KEY
valueFrom:
secretKeyRef:
name: llm-secrets
key: api_key
Scaling
Manual Scaling
kubectl scale deployment llm-serving --replicas=3
Auto-scaling (HPA)
See 06_autoscaling/ for detailed HPA setup.
Rolling Updates
Update Image
kubectl set image deployment/llm-serving \
llm-serving=llm-serving:v2
Rollback
kubectl rollout undo deployment/llm-serving
Check Rollout Status
kubectl rollout status deployment/llm-serving
GPU Support
Node Labels
# Label nodes with GPU
kubectl label nodes <node-name> accelerator=nvidia-tesla-v100
GPU Resource Request
resources:
limits:
nvidia.com/gpu: 1
Device Plugin
Install NVIDIA device plugin:
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.1/nvidia-device-plugin.yml
Ingress
Expose Service Externally
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-ingress
spec:
rules:
- host: llm.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-serving
port:
number: 8000
Monitoring
View Logs
kubectl logs -f deployment/llm-serving
kubectl logs -f <pod-name>
Describe Resources
kubectl describe pod <pod-name>
kubectl describe deployment llm-serving
Get Events
kubectl get events --sort-by='.lastTimestamp'
Common Issues
Pod Not Starting
- Check logs:
kubectl logs <pod-name> - Describe pod:
kubectl describe pod <pod-name> - Check events:
kubectl get events
Out of Resources
- Check node resources:
kubectl describe node - Adjust resource requests/limits
- Add more nodes
Image Pull Errors
- Check image name and tag
- Verify image registry access
- Check image pull secrets
Health Check Failures
- Verify health endpoint works
- Adjust probe timing
- Check application startup time
Best Practices
- Always set resource limits: Prevent resource exhaustion
- Use health checks: Enable automatic recovery
- Use ConfigMaps: Don’t hardcode configuration
- Tag images: Use semantic versioning
- Test locally: Use minikube/kind before production
- Monitor: Set up logging and metrics
Exercises
- Basic deployment: Deploy app to local K8s
- Health checks: Add liveness/readiness probes
- Resource limits: Set appropriate CPU/memory
- ConfigMap: Move config to ConfigMap
- Scaling: Scale to 3 replicas
- Rolling update: Update to new version
Next Steps
- Topic 4: Load test your K8s deployment
- Topic 6: Set up auto-scaling
- Topic 8: Add monitoring
Further Reading
Kubernetes for LLM Serving
Deploying and operating GPU inference on Kubernetes — the control plane that most production LLM stacks run on.
Why this matters
Once you have a working inference server (vLLM, TGI, Triton, SGLang), the next question is always the same: how do I run twelve of these across a fleet of GPU nodes, upgrade them without dropping traffic, and not set money on fire? Kubernetes is the de-facto answer. It gives you a declarative fleet, health-based traffic gating, rolling upgrades, and a scheduler that can place a pod on exactly the right GPU.
But LLM serving breaks several of Kubernetes’ defaults. Models take minutes to load, so naive probes will kill pods mid-startup in an infinite crash loop. GPUs are not overcommittable, so the usual CPU/memory bin-packing intuition is wrong. Container images and model weights are tens of gigabytes, so image pulls and cold starts dominate. This chapter walks the mechanisms and the sharp edges.
Autoscaling (HPA, KEDA, custom metrics, scale-to-zero) is deep enough to deserve its own chapter — see Autoscaling GPU Inference. Here we reference it but focus on deployment, scheduling, health, weights, and lifecycle.
Saying it out loud. Once you’ve got a working inference server, the question stops being “does it generate text” and becomes “how do I run twelve of these across a GPU fleet, upgrade them without dropping traffic, and not set money on fire.” Kubernetes is the default answer because it gives you a declarative fleet, health-gated routing, rolling upgrades, and a scheduler that can place a pod on exactly the right GPU. But LLM serving breaks three Kubernetes defaults hard: models take minutes to load, so naive probes crash-loop them forever; GPUs can’t be overcommitted, so your CPU bin-packing intuition is simply wrong; and images plus weights are tens of gigabytes, so cold start dominates everything. Most Kubernetes-for-LLM incidents are one of those three defaults biting.
Core intuition
Three mental models carry most of the chapter:
-
A GPU is an indivisible, non-overcommittable device. Unlike CPU (compressible) and memory (overcommittable at your peril), a GPU is handed to exactly one container by the device plugin. You can share it deliberately (time-slicing, MPS, MIG), but the scheduler still treats each advertised unit as an integer resource. There is no “burst above your GPU limit.”
-
Health is a function of time, not just liveness. A pod that has been alive for 40 seconds but hasn’t loaded a 140 GB model is not broken — it’s starting. Kubernetes has a dedicated primitive for exactly this distinction: the startup probe. Get this wrong and you get a crash loop that looks like a hardware failure.
-
The weights are the workload. For classic web services the container image is the app. For LLM serving the image is a runtime and the weights are the payload — often 10x larger than the image. Where the weights live and how they land on the node (baked into the image, PVC,
initContainer, object storage, model cache) determines your cold-start time and your blast radius. -
Voluntary disruption is the one you control. Nodes get drained for upgrades, spot reclamation, and autoscaler scale-down constantly. Kubernetes lets you bound that damage with a PodDisruptionBudget and a graceful-shutdown path. Unlike a crashed process (involuntary), these are scheduled, negotiable evictions — and the difference between a clean rolling drain and a full outage is a few lines of YAML you either wrote or didn’t.
Keep these four in mind and the rest of the chapter is mostly detail: GPUs are exclusive integers, health is time-aware, weights are the real payload, and disruptions are bounded on purpose.
Saying it out loud. Four mental models carry this whole chapter. One: a GPU is an indivisible, non-overcommittable device — unlike CPU which is compressible and memory which you can oversubscribe, a GPU goes to exactly one container as an integer, and there’s no bursting above your limit. Two: health is a function of time, not just liveness — a pod that’s been alive forty seconds without finishing a 140 GB model load isn’t broken, it’s starting, and Kubernetes has a dedicated primitive for that called the startup probe. Three: the weights are the workload — for a normal service the image is the app, here the image is just a runtime and the weights are ten times larger. Four: voluntary disruption is the one you control, and a PodDisruptionBudget is the few lines of YAML between a clean rolling drain and a full outage.
Mechanisms in depth
1. The building blocks: Deployment, Service, Ingress
For a stateless replicated inference server the standard trio is:
- Deployment — declares N replicas of a pod template, handles rolling updates and self-healing.
- Service — a stable virtual IP + DNS name that load-balances across the ready pods (
ClusterIPfor in-cluster,LoadBalancerfor cloud L4). - Ingress (or Gateway API) — L7 routing, TLS termination, path/host rules, into the Service.
A subtlety unique to LLM serving: long request durations and streaming. Token-streaming responses (SSE) can run for tens of seconds to minutes. Make sure your Ingress/proxy timeouts (proxy-read-timeout on the NGINX ingress, backend request timeout on cloud LBs) are raised, and that buffering is disabled so tokens flush as they generate. Default 30–60s timeouts will cut long generations.
Saying it out loud. The standard trio is boring and that’s good: a Deployment declares N replicas and handles rolling updates and self-healing, a Service gives you a stable virtual IP that load-balances across the ready pods, and an Ingress or Gateway does L7 routing and TLS on the way in. The one thing genuinely different about LLM serving is request duration — a token-streaming response can run for minutes, and every proxy in the path defaults to a 30- or 60-second read timeout. So you raise
proxy-read-timeouton the ingress and the backend timeout on any cloud load balancer, and you disable response buffering so tokens flush as they’re generated. Miss that and long generations get truncated mid-sentence, and it’ll look like a model bug rather than a proxy config.
2. GPU scheduling: the NVIDIA device plugin
Kubernetes has no native notion of a GPU. The NVIDIA device plugin is a DaemonSet that runs on every GPU node, discovers the GPUs, and advertises them to the kubelet as an extended resource named nvidia.com/gpu. The scheduler then treats that resource like any countable resource.
You request GPUs under resources:
resources:
limits:
nvidia.com/gpu: 1 # request one whole GPU
Key rules that trip people up:
- Extended resources must be integers, and request must equal limit. Kubernetes requires that for any extended resource, if you set it at all, the request and limit are equal. You cannot request 0.5 of a
nvidia.com/gpuand burst to 1. Practically you only ever specify it underlimits(Kubernetes copies it torequestsfor you). - GPUs are never overcommitted. Two pods cannot each hold
nvidia.com/gpu: 1on a node that advertises one GPU — the second staysPending. This is by design: two processes fighting over one GPU’s memory would OOM each other unpredictably. - Sharing is opt-in and explicit. If you want to pack multiple pods on one GPU you enable time-slicing (the plugin advertises, say, 4 “replicas” of each GPU — but note this is oversubscription with no memory isolation, so proportional compute is not guaranteed), MPS, or hardware MIG partitions. Each mechanism changes what the plugin advertises; the scheduler math stays “integer units.”
The device plugin is often installed as part of the NVIDIA GPU Operator, which additionally manages the driver, the container toolkit, DCGM metrics exporter, Node Feature Discovery, and MIG configuration — so you don’t hand-install drivers on every node.
Saying it out loud. Kubernetes has no built-in concept of a GPU at all. What makes it work is the NVIDIA device plugin — a DaemonSet on every GPU node that discovers the cards and advertises them to the kubelet as an extended resource called
nvidia.com/gpu. From there the scheduler treats it like any countable resource, with two rules people trip on. Extended resources must be whole integers and request must equal limit, so there’s no requesting half a GPU and bursting. And GPUs are never double-booked: two pods each asking for one GPU on a single-GPU node means the second sitsPending, by design, because two processes fighting over one card’s VRAM would OOM each other unpredictably. Sharing exists, but it’s explicit opt-in via MIG, MPS, or time-slicing.
2b. GPU sharing: MIG vs time-slicing vs MPS
One whole GPU per pod is wasteful for small models that use a few GB of a 80 GB card. Three mechanisms let you pack more, each changing what the device plugin advertises:
| Mechanism | Isolation | How it splits | Advertised as | Use when |
|---|---|---|---|---|
| MIG (Multi-Instance GPU) | Hardware — separate memory + compute slices | Physically partitions an A100/H100 into up to 7 instances | nvidia.com/mig-1g.10gb etc. (or relabeled nvidia.com/gpu) | Strong isolation, predictable QoS, multi-tenant |
| Time-slicing | None — processes share memory, take turns on the SMs | Plugin advertises N “replicas” of each GPU; the driver context-switches | nvidia.com/gpu (inflated count) | Bursty/low-QPS dev workloads that tolerate contention |
| MPS (Multi-Process Service) | Soft — shared memory, concurrent kernels with optional compute caps | A daemon runs many clients’ kernels concurrently | nvidia.com/gpu (configured slots) | Higher utilization than time-slicing, some control |
Critical caveat: time-slicing gives no memory isolation — two pods on one time-sliced GPU can OOM each other, and “requesting 2 shared GPUs” does not guarantee 2x the compute. For production multi-tenant serving, MIG is the safe choice; time-slicing/MPS are for dev or trusted, well-characterized co-tenancy. All three are configured via the device plugin / GPU Operator, not by the pod author.
Saying it out loud. Giving a whole 80 GB card to a model that uses six gigabytes is wasteful, so there are three ways to pack more on — and they differ mainly in what guarantee they give you. MIG is hardware partitioning: an A100 or H100 splits into up to seven instances with genuinely separate memory and compute slices, so tenants can’t touch each other. Time-slicing is pure oversubscription: the plugin just advertises four replicas of one card and the driver context-switches, with zero memory isolation. MPS sits in between — concurrent kernels with soft compute caps but still shared memory. The caveat that matters: time-slicing does not give you 4x compute and does not stop two pods from OOM-ing each other, so for anything multi-tenant you use MIG and treat the other two as dev-only.
3. Getting pods onto GPU nodes: selectors, taints, tolerations
You almost never want a random CPU workload landing on an expensive GPU node, and you want GPU pods to land only on GPU nodes. Two complementary mechanisms:
-
Node labels +
nodeSelector/affinity (attraction). GPU nodes carry labels — cloud pools add things likecloud.google.com/gke-accelerator=nvidia-l4, and the GPU Operator / NFD add labels such asnvidia.com/gpu.product=NVIDIA-A100-SXM4-80GB. You pull your pod toward them:nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB -
Taints + tolerations (repulsion). You taint GPU nodes so nothing schedules there unless it explicitly tolerates the taint. Cloud GPU pools often auto-apply a taint like
nvidia.com/gpu=present:NoSchedule. Your inference pod must tolerate it:tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule
Use both: the taint keeps freeloaders off, the selector/affinity ensures your pod picks the right GPU SKU. A GPU node pool is simply a node group with a fixed instance type (all A100, or all L4), its own taint, and often its own cluster-autoscaler settings so you can scale GPU capacity independently of the CPU fleet.
Saying it out loud. You need two complementary things, and people usually remember one. Taints are repulsion: you taint GPU nodes so nothing schedules there unless it explicitly tolerates the taint, which keeps random CPU workloads off your expensive hardware — cloud GPU pools often apply
nvidia.com/gpu=present:NoSchedulefor you. Node selectors and affinity are attraction: they pull your pod toward the right SKU using labels the GPU Operator and Node Feature Discovery add, likenvidia.com/gpu.product. Use both, because they solve different problems. The failure mode from getting it wrong is silent: a pod missing the toleration doesn’t error, it just sitsPendingforever, and the only place that tells you iskubectl describe pod.
4. Probes done right for multi-minute model loads
This is the single most common LLM-on-k8s bug. Kubernetes has three probes:
| Probe | Question it answers | Failure action |
|---|---|---|
| startup | “Has the container finished starting yet?” | Kill & restart the container (crash loop). Disables the other two until it first succeeds. |
| readiness | “Should this pod receive traffic right now?” | Remove pod from Service endpoints (no traffic), do not kill. |
| liveness | “Is this container wedged and needs a restart?” | Kill & restart the container. |
The classic failure: you set a liveness probe with a short initialDelaySeconds, the model takes 4 minutes to load, the liveness probe fails during load, the kubelet kills the container, it restarts, tries to load again, gets killed again — an infinite crash loop that looks like the model is broken.
The fix is the startup probe. While a startup probe is configured and not yet successful, liveness and readiness are suppressed. So you give the startup probe a generous budget:
startupProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 60 # 10s * 60 = up to 600s (10 min) to become healthy
The effective grace window is periodSeconds * failureThreshold. Size it to your worst-case cold load (weights download + load into VRAM + CUDA graph capture / warmup), then add margin. Once the startup probe passes once, the fast liveness and readiness probes take over.
- readiness should reflect “can serve a request” — many servers expose
/health(up) vs a readiness endpoint that only returns 200 once the model is loaded and warmup is done. Gate traffic on the latter. - liveness should be cheap and lenient — it exists to recover a genuinely wedged process (deadlock, CUDA error), not to police slow loads. Use a modest
periodSecondsand afailureThresholdof 3+ so a single slow health check doesn’t kill a healthy pod mid-inference.
Saying it out loud. This is the number-one LLM-on-Kubernetes bug, full stop. There are three probes: startup asks “has it finished starting yet,” readiness asks “should traffic go here right now,” and liveness asks “is this wedged and needs a restart.” The classic disaster is copying a liveness probe from a CPU microservice — thirty seconds of initial delay, three failures allowed — onto a model that takes four minutes to load. The kubelet kills it at sixty seconds, it restarts, loads again, gets killed again, forever. The fix is the startup probe, because while it’s pending it suppresses liveness and readiness entirely; give it
periodSeconds: 10andfailureThreshold: 60for a ten-minute budget sized to your worst-case cold load. Then keep liveness cheap and lenient — it exists to recover a deadlock, not to police a slow load.
5. Resource requests/limits and why GPUs aren’t overcommitted
For CPU and memory you set requests (scheduling guarantee) and limits (cap). For LLM pods:
- CPU: set a request so the scheduler reserves headroom (tokenization, HTTP, scheduling loops are CPU-hungry), but be cautious with CPU limits — throttling the server’s event loop can tank throughput. Many teams set a CPU request and no CPU limit.
- Memory: set request and limit close together and generous. Host RAM is used to stage weights before they hit VRAM; an OOMKill mid-load looks like a probe failure but is actually the kernel.
- GPU:
nvidia.com/gpurequest == limit == integer, always. There is no overcommit. The reason is physical: GPU memory (VRAM) has no swap and no soft limit the scheduler understands. If two pods both assumed they had the whole 80 GB, the second allocation would fail at CUDA-malloc time, not at schedule time — an ugly runtime crash instead of a cleanPending. So Kubernetes refuses to double-book.
Corollary — GPU fragmentation: because allocation is integer and per-node, a cluster with eight 8-GPU nodes and lots of single-GPU pods can end up unable to schedule a pod that needs 4 GPUs on one node, even though 20 GPUs are free cluster-wide. Multi-GPU / tensor-parallel pods need whole nodes or careful bin-packing (topology-aware scheduling, podAffinity, or a scheduler like the NVIDIA/Volcano gang scheduler).
Saying it out loud. For CPU, set a request so the scheduler reserves headroom but be careful with limits, because throttling the event loop that handles tokenization and HTTP will tank your throughput — many teams set a CPU request and no limit at all. For memory, set request and limit close together and generous, because host RAM stages the weights before they land in VRAM, and an OOMKill mid-load looks exactly like a probe failure. For GPU, request equals limit equals an integer, always. The physical reason is that VRAM has no swap and no soft limit the scheduler understands: if two pods both assumed they had the whole 80 GB, the second would fail at CUDA-malloc time — an ugly runtime crash instead of a clean
Pending. The corollary is fragmentation: twenty GPUs free cluster-wide can still mean zero schedulable for a pod that needs four on one node.
6. Serving the model weights
Where do the weights come from at pod start? Four common patterns, with tradeoffs:
| Approach | How | Pros | Cons |
|---|---|---|---|
| Baked into image | COPY weights into the Docker image | Simplest; immutable; no runtime fetch | Enormous images (30–100 GB+), slow pulls, registry bloat, rebuild to change weights |
| initContainer download | An initContainer pulls weights from object storage (S3/GCS) into a shared emptyDir | Small runtime image; weights versioned in bucket | Re-downloads on every cold start unless cached; needs credentials |
| PVC (shared/RWX) | Weights on a persistent volume (e.g. a network filesystem), mounted read-only | Download once, many pods share; fast pod start | Storage class must support RWX or you pre-populate; network FS bandwidth can bottleneck concurrent loads |
| Node-local cache / model cache | Cache weights on local NVMe, or use a model-cache layer (e.g. Run:ai model streamer, KServe modelcar, Fluid) | Fast warm starts; streams weights into VRAM | More moving parts; cache warmup / eviction to manage |
Rules of thumb: bake weights into the image only for small models or when immutability matters more than pull time. For large models, keep the runtime image lean and fetch weights via initContainer or a pre-populated read-only PVC, and cache on the node so replicas 2..N start fast. Beware the thundering herd: ten pods cold-starting simultaneously all pulling 140 GB from the same bucket will saturate egress and each other.
Saying it out loud. Four ways to get weights onto a node, and the choice determines your cold-start time. Baked into the image is simplest and immutable, but you’re pulling a 30-to-100-gigabyte image on every cold node. An initContainer downloading from S3 keeps the image lean but re-downloads on every cold start unless you cache it. A shared read-only PVC means you download once and many pods mount it — usually the right answer for large models — though your network filesystem bandwidth becomes the bottleneck when replicas load concurrently. Node-local NVMe cache is fastest for warm restarts but adds cache-warming and eviction to manage. The failure mode to name is the thundering herd: ten pods cold-starting at once, each pulling 140 GB from the same bucket, saturating egress and slowing each other down.
7. Rolling updates, PodDisruptionBudgets, graceful shutdown
-
Rolling updates: the Deployment’s
RollingUpdatestrategy withmaxUnavailable/maxSurgecontrols how many pods are replaced at once. For GPU pods,maxSurgecosts real extra GPUs — surging by 1 means the autoscaler must find another GPU node. Often you setmaxSurge: 0, maxUnavailable: 1to avoid needing spare GPUs, accepting slightly reduced capacity during the rollout. And remember: each new pod pays the full multi-minute cold-start, so rollouts of GPU fleets are slow. Budget for it. -
PodDisruptionBudget (PDB): protects against voluntary disruptions (node drains, cluster-autoscaler scale-down, upgrades). Without a PDB, a node drain can evict all your replicas at once and take the service down. Set
minAvailable(ormaxUnavailable) so the eviction API refuses to take down too many at once:apiVersion: policy/v1 kind: PodDisruptionBudget spec: minAvailable: 2 selector: matchLabels: { app: llm-inference } -
Graceful shutdown: on SIGTERM, a good inference server should stop accepting new requests, drain in-flight generations, then exit. Kubernetes gives it
terminationGracePeriodSeconds(default 30s) before SIGKILL — raise this well above your longest expected generation (e.g. 120–300s) so streaming requests aren’t cut off. Pair it with apreStophook or a readiness flip so the pod is pulled from Service endpoints before it starts draining, avoiding races where traffic hits a shutting-down pod.
Saying it out loud. Three lifecycle things, and each has a GPU-specific twist. On rolling updates,
maxSurgecosts real extra GPUs — surging by one means the autoscaler has to find another GPU node, which may not exist — so GPU fleets often runmaxSurge: 0, maxUnavailable: 1and accept reduced capacity during the rollout. On PodDisruptionBudgets: without one, a routine node drain or an autoscaler scale-down can evict every replica simultaneously and take the whole service down, so you always ship aminAvailable. On graceful shutdown: the defaultterminationGracePeriodSecondsis thirty seconds, which will SIGKILL a pod in the middle of a two-minute generation, so raise it to a couple hundred and flip readiness first so traffic drains before the pod starts shutting down.
8. Serving frameworks & operators
You don’t have to hand-roll Deployments. Higher-level tools add model-aware features (autoscaling on GPU/queue metrics, scale-to-zero, canary, standardized model formats):
- KServe — a CRD (
InferenceService) on top of Knative/Kubernetes. Handles autoscaling (incl. scale-to-zero), canary rollout, and a standard prediction protocol. Has first-class support for LLM runtimes (vLLM) viaServingRuntime. Good when you want a platform abstraction over raw pods. - NVIDIA NIM / Triton on k8s — NIM packages optimized model microservices as containers; the NIM Operator (and Triton) deploy them, and NIM integrates with KServe for the serving layer. Best when you’re standardized on NVIDIA’s optimized stack and want vendor-supported images.
- KubeAI — an open, k8s-native inference operator focused on OpenAI-compatible serving of LLMs (vLLM/Ollama), with built-in autoscaling and model management, no Istio/Knative dependency. Lighter-weight alternative to KServe.
- Ray Serve (KubeRay) — deploy via the
RayServiceCRD. Shines for multi-model, model-composition, and distributed (multi-node tensor/pipeline-parallel) serving where a request fans across many actors/GPUs. More of a distributed compute framework than a thin serving layer.
Saying it out loud. You don’t have to hand-roll Deployments forever. KServe gives you an
InferenceServiceCRD with autoscaling including scale-to-zero, canary rollouts, and first-class vLLM support — good when you’re a platform team abstracting over many models. KubeAI is a lighter alternative focused on OpenAI-compatible LLM serving without dragging in Istio and Knative. Ray Serve via KubeRay is the one to reach for when a single request has to fan across many GPUs or nodes, or when you’re composing models together — it’s really a distributed compute framework, not a thin serving layer. And NVIDIA NIM plus the NIM Operator is the vendor-supported path if you’re standardized on NVIDIA’s optimized engines. The honest tradeoff: each of these buys you features and costs you a layer of abstraction to debug through.
9. Networking specifics: Gateway API, timeouts, affinity
- Ingress vs Gateway API. The classic
Ingressresource works, but the newer Gateway API (Gateway+HTTPRoute) is the direction the ecosystem is moving and expresses timeouts, traffic splitting, and header routing more cleanly — useful for canarying model versions. - Streaming timeouts. Token-by-token SSE/HTTP responses can run minutes. On the NGINX ingress set
nginx.ingress.kubernetes.io/proxy-read-timeoutandproxy-send-timeoutto several hundred seconds and disable buffering (proxy-buffering: "off") so tokens flush live. Cloud L7 LBs have their own backend timeout you must raise. - Session affinity for KV-cache reuse. With prefix/KV caching, routing a follow-up request to the same replica that holds the cache boosts throughput. Basic
sessionAffinity: ClientIPon the Service helps; smarter setups use a cache-aware router (e.g. the vLLM production stack / router) instead of round-robin. - Headless Services for multi-node. Distributed (tensor/pipeline-parallel across nodes) runtimes often need pod-to-pod addressing; a headless Service (
clusterIP: None) plus a StatefulSet gives stable per-pod DNS.
Saying it out loud. Four networking things bite specifically for LLMs. Gateway API is where the ecosystem is going over classic Ingress, and it expresses timeouts and traffic splitting much more cleanly, which matters when you’re canarying model versions. Streaming timeouts are the practical killer — set
proxy-read-timeoutto several hundred seconds and turn buffering off, or your SSE stream gets cut. Session affinity actually matters here in a way it doesn’t for stateless services, because with prefix caching, routing a follow-up request back to the replica that already holds that KV cache is a real throughput win —sessionAffinity: ClientIPis the crude version, a cache-aware router is the good one. And for multi-node tensor parallelism you need a headless Service plus a StatefulSet for stable pod-to-pod DNS.
10. Observability: know when a GPU pod is unhealthy
Standard pod metrics miss the GPU. Add:
- DCGM exporter (shipped by the GPU Operator) → Prometheus: GPU utilization, memory used, temperature, ECC errors, throttling. Alert on sustained 0% utilization on a “ready” pod (stuck), on VRAM near 100% (OOM risk), and on XID/ECC errors (failing hardware).
- Server-level metrics from the runtime: queue depth, time-to-first-token, tokens/sec, running vs waiting requests. These drive autoscaling (see the autoscaling chapter) and tell you why latency moved.
- Event/probe signals: watch for
Unhealthyprobe events,CrashLoopBackOff, andFailedScheduling(usually taint/selector or capacity).kubectl describe podandkubectl get eventsare your first stop.
A “ready” pod pinned at 0% GPU utilization with a growing request queue is the classic silent failure — the health endpoint returns 200 but inference is wedged. Alert on the metric, not just the probe.
Saying it out loud. Standard pod metrics tell you nothing about the GPU, so you add two layers. DCGM exporter, which ships with the GPU Operator, feeds Prometheus GPU utilization, memory, temperature, ECC errors, and throttling. And server-level metrics from the runtime itself — queue depth, time-to-first-token, tokens per second, running versus waiting requests — which are what actually drive autoscaling and tell you why latency moved. The specific alert worth naming is the silent failure: a pod that passes its readiness probe, sits at 0% GPU utilization, and has a growing request queue. The HTTP endpoint returns 200, so Kubernetes thinks it’s fine, but inference is wedged. Alert on the metric, not just the probe — the probe is exactly the thing that’s lying to you.
11. Spot / preemptible GPUs and cost
GPU nodes are the dominant cost, so many teams run inference on spot/preemptible instances at a large discount — accepting that the cloud can reclaim the node with ~30–120s notice.
- Spread replicas across on-demand and spot with
topologySpreadConstraintsso a spot reclamation storm can’t take the whole service down; keep a baseline of on-demand capacity protected by the PDB. - The preemption signal arrives as a node drain → your graceful shutdown path (SIGTERM, drain,
terminationGracePeriodSeconds) must fit inside the cloud’s notice window, or in-flight requests are lost. - Cold-start time is your enemy here: a reclaimed spot pod must re-download weights and reload the model before serving. Node-local weight caches and pre-pulled images shrink the recovery gap.
- Right-size the GPU: an 8B model on an 80 GB H100 wastes the card — MIG-slice it or pick a smaller SKU (L4/L40S) and let the comparison table of frameworks + autoscaling do the packing.
Saying it out loud. GPU nodes dominate the bill, so running inference on spot or preemptible instances is tempting — big discount, but the cloud can reclaim the node on roughly 30 to 120 seconds notice. Three things make that survivable. Spread replicas across on-demand and spot with topology spread constraints, and keep an on-demand baseline protected by a PDB, so a reclamation storm can’t take the whole service down. Make sure your graceful shutdown path — SIGTERM, drain, terminate — actually fits inside that notice window, because if it doesn’t, every reclamation drops in-flight requests. And cold start is your real enemy: a reclaimed pod has to re-fetch weights and reload the model before it serves anything, so node-local weight caches and pre-pulled images are what shrink the recovery gap.
The 2025–2026 landscape
The mechanisms above are stable, but the tooling around them moved fast between 2025 and 2026. Five developments matter most if you’re standing up a new GPU-serving platform today.
Saying it out loud. The mechanisms are stable but the tooling moved fast. GPU scheduling is heading toward Dynamic Resource Allocation, which went GA in Kubernetes 1.34 and replaces “advertise an integer count” with claim-based allocation that can express things like “two GPUs on the same NVLink island.” KServe grew LLM-native features — autoscaling on vLLM queue metrics via KEDA, declarative multi-node tensor and pipeline parallelism. A standard traffic layer arrived in the Gateway API Inference Extension, so routing to the replica holding the right prefix cache is no longer bespoke per team. And Kueue finally solved quota arbitration between serving and batch. The throughline: GPUs are still exclusive integer units — DRA makes the claims richer, it doesn’t make the scarcity go away.
GPU sharing has matured: MIG, time-slicing, MPS — and DRA is coming for all of them
Through 2025 the NVIDIA GPU Operator (on the 24.9.x/25.x release line) remained the standard way to install the device plugin, driver, container toolkit, DCGM exporter, Node Feature Discovery, and MIG manager as one Helm-installed stack rather than hand-provisioning each piece. Its ClusterPolicy CRD is the single control surface: flip mig.strategy between single and mixed, and the operator relabels nodes and reconfigures the device plugin automatically. Time-slicing configuration is a plain ConfigMap the device plugin reads at startup (shown in the extended example below) — still oversubscription with no memory isolation, exactly as described above.
The structurally bigger change is Dynamic Resource Allocation (DRA), which graduated to General Availability in Kubernetes v1.34 (released September 1, 2025). DRA replaces the device plugin’s “advertise an integer count” model with claim-based allocation: a ResourceClaim lets a pod ask for a class of device with structured parameters (a specific MIG profile, a specific interconnect topology, a set of GPUs that share an NVLink domain) instead of just a count of nvidia.com/gpu. NVIDIA and the major cloud Kubernetes offerings shipped early DRA driver support in 2025–2026 specifically to express MIG profiles and multi-GPU topology (e.g. “give me 2 GPUs on the same NVLink island”) in ways the old device-plugin integer model could never express. DRA does not replace the device plugin overnight — most production clusters in 2026 still run the classic nvidia.com/gpu device plugin — but it is the direction the scheduler is heading, and it is the answer if an interviewer asks “how would Kubernetes GPU scheduling need to evolve to express topology?”
Saying it out loud. The GPU Operator is still how you install the whole host-side stack — device plugin, driver, toolkit, DCGM, MIG manager — as one Helm chart, with
ClusterPolicyas the single control surface. The structural change is DRA, Dynamic Resource Allocation, which went GA in Kubernetes 1.34 in September 2025. Instead of asking for a count ofnvidia.com/gpu, a pod files aResourceClaimfor a class of device with structured parameters — a specific MIG profile, a driver version floor, or two GPUs sharing an NVLink domain. That last one is the killer example, because the old integer model literally cannot express topology, which is exactly why multi-GPU pods end up stranded by fragmentation. Most 2026 clusters still run the classic device plugin, but DRA is the right answer to “how would GPU scheduling need to evolve.”
KServe grew LLM-native primitives
KServe v0.15 (released June 18, 2025) added serving-specific features that a raw Deployment has to hand-roll:
- KEDA-based autoscaling on vLLM metrics — instead of scaling on CPU%, KEDA can scale an
InferenceServiceonvllm:num_requests_runningor queue depth scraped straight from the vLLM Prometheus endpoint, which tracks actual serving pressure far better than CPU ever could. - Multi-node inference — native
pipelineParallelSize/tensorParallelSizefields on theServingRuntimeso a model too big for one node (e.g. Llama 3.1 405B) can be declared, not hand-orchestrated with StatefulSets and headless Services. - Distributed KV cache via LMCache — KV-cache offload and cross-replica cache sharing, cutting time-to-first-token on multi-turn traffic.
- Envoy AI Gateway integration — token-aware rate limiting and model-routing policy at the gateway layer.
- The release also bumped the bundled vLLM backend to 0.8.5, adding support for newer model families and an OpenAI-compatible embeddings API.
Saying it out loud. KServe 0.15, mid-2025, added the things a raw Deployment makes you hand-roll. KEDA-based autoscaling on actual vLLM metrics —
num_requests_running, queue depth, scraped from the vLLM Prometheus endpoint — instead of guessing from CPU percent, which for a GPU workload is close to meaningless. NativetensorParallelSizeandpipelineParallelSizefields, so a model too big for one node is a declaration rather than a hand-orchestrated StatefulSet plus headless Service. Distributed KV cache via LMCache, which offloads and shares cache across replicas to cut time-to-first-token on multi-turn traffic. And Envoy AI Gateway integration for token-aware rate limiting. The pattern worth noticing: every one of those is a serving-specific concern that generic Kubernetes primitives handle badly.
A standard traffic layer arrived: the Gateway API Inference Extension
Before mid-2025, every team invented its own “route to the replica holding the right prefix cache” logic. The Gateway API Inference Extension (introduced June 5, 2025, sigs.k8s.io/gateway-api-inference-extension) standardizes this with two new resources sitting on top of the Gateway API: InferencePool groups model-server pods sharing hardware/model config, and InferenceModel/InferenceObjective declares model identity and request priority (interactive chat vs. batch) for the router to act on. Implementations shipped fast: Istio added support in 2025, as did NGINX Gateway Fabric, and Google’s GKE Inference Gateway is built directly on it — using signals like KV-cache utilization (via GCPBackendPolicy) to route model-aware rather than round-robin. This is the closest thing the ecosystem has to a standard answer for “how do you route LLM traffic on Kubernetes” as of 2026.
Saying it out loud. Before mid-2025, every team wrote their own logic for “route this request to the replica that already has the right prefix cache.” The Gateway API Inference Extension standardizes that with two resources on top of Gateway API: an
InferencePoolgroups model-server pods that share hardware and model config, andInferenceModelorInferenceObjectivedeclares model identity and request priority — so interactive chat and batch traffic can be routed and prioritized differently. Istio, NGINX Gateway Fabric, and Google’s GKE Inference Gateway all shipped support, with GKE routing on signals like KV-cache utilization rather than round-robin. Why it matters: round-robin across LLM replicas actively destroys prefix-cache hit rates, so cache-aware routing is a throughput win you get from the traffic layer, not the model.
Kueue: batch/queue scheduling for GPU jobs
Kueue (kueue.sigs.k8s.io, a Kubernetes SIG project, on the v0.18/v0.19 release line as of early 2026) fills a gap raw Kubernetes scheduling never solved: fair, quota-aware admission of batch and GPU jobs across teams. Where the default scheduler will happily let one team’s fine-tuning job or eval sweep grab every GPU in the cluster, Kueue adds ClusterQueue/LocalQueue objects with quotas, borrowing/lending between cohorts, priority-based FIFO admission, and gang scheduling (all-or-nothing pod admission — no more a distributed training job launching 7 of 8 needed pods and deadlocking). It integrates with the cluster-autoscaler via provisioning requests, so a queued job can trigger new GPU nodes only once it’s actually about to be admitted. MultiKueue extends this across clusters, dispatching an admitted job’s pods to whichever connected cluster has capacity — relevant for fine-tuning/eval batch workloads more than steady-state serving, but increasingly used to arbitrate GPU access between a serving fleet and a training/eval fleet sharing the same pool.
Saying it out loud. Kueue fills a gap the default scheduler never addressed: fair, quota-aware admission of GPU jobs across teams. Out of the box, one team’s eval sweep or fine-tuning run can grab every GPU in the cluster and there’s nothing to stop it. Kueue adds
ClusterQueueandLocalQueueobjects with quotas, borrowing and lending between cohorts, and priority-based FIFO admission. The feature I’d single out is gang scheduling — all-or-nothing admission — which stops a distributed job from launching seven of the eight pods it needs and deadlocking capacity while it waits for the eighth. It also hooks into the cluster autoscaler via provisioning requests, so a queued job only triggers new GPU nodes once it’s actually about to be admitted rather than speculatively.
Multi-cluster and multi-region serving
Two 2025–2026 developments matter here. First, llm-d — a distributed inference stack founded by Red Hat, Google Cloud, IBM Research, CoreWeave, and NVIDIA, which joined the CNCF as a sandbox project in March 2026. It sits above vLLM/SGLang and adds disaggregated prefill/decode (splitting the compute-bound prefill phase from the memory-bandwidth-bound decode phase across different hardware), prefix-cache-aware and predicted-latency routing, and tiered KV-cache offload to CPU/disk — reporting up to 70% higher tokens/sec in disaggregated configurations and roughly 50k output tokens/sec on large clusters as of its v0.5 release (February 2026).
Second, multi-cluster GKE Inference Gateway (announced March 2026) extends the Gateway API Inference Extension across cluster and region boundaries: a dedicated config cluster holds the routing policy while multiple target clusters run the actual model pods, giving you cross-region failover, GPU/TPU capacity pooling (burst into whichever region has free accelerators), and model-aware routing globally instead of per-cluster. The pattern generalizes beyond GKE: the shared idea is separate the routing control plane from the serving data plane so a regional outage or a capacity crunch in one cluster doesn’t take down the whole service — the multi-region equivalent of the PodDisruptionBudget mindset from single-cluster serving.
What this means for the chapter’s core intuitions: GPUs are still exclusive, integer-scheduled units — DRA makes the claims richer, not the underlying scarcity softer. Health is still time-aware — KServe’s KEDA integration scales on real serving pressure instead of guessing from CPU. The weights are still the workload — llm-d’s disaggregation and KV-cache tiering are just more sophisticated answers to “where do the weights/cache live.” And disruption is still bounded on purpose — multi-cluster routing is the PDB idea applied at the scale of a whole region.
Saying it out loud. Two things worth knowing here. llm-d is a distributed inference stack — Red Hat, Google, IBM, CoreWeave, NVIDIA — that joined the CNCF as a sandbox project in March 2026, and it sits above vLLM and adds disaggregated prefill and decode, meaning you run the compute-bound prefill phase and the memory-bandwidth-bound decode phase on different hardware sized for each. They report up to 70% higher tokens per second in disaggregated configurations. Separately, multi-cluster GKE Inference Gateway extends the Inference Extension across regions, with a config cluster holding routing policy and target clusters running the pods. The generalizable idea in both: separate the routing control plane from the serving data plane, which is really the PodDisruptionBudget mindset applied at the scale of a region.
Fully worked example: raw Deployment + Service
A complete, correct manifest for a vLLM server on a single A100, with GPU request, startup/readiness/liveness probes tuned for a slow load, weights fetched from object storage by an initContainer into a shared cache, a PDB, and graceful shutdown.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference
labels: { app: llm-inference }
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # don't demand a spare GPU during rollout
maxUnavailable: 1 # replace one pod at a time
selector:
matchLabels: { app: llm-inference }
template:
metadata:
labels: { app: llm-inference }
spec:
terminationGracePeriodSeconds: 180 # let in-flight generations drain
# --- placement: only land on the right GPU nodes ---
nodeSelector:
nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
# --- weights: download once into a shared emptyDir cache ---
volumes:
- name: model-cache
emptyDir:
sizeLimit: 200Gi
initContainers:
- name: fetch-weights
image: amazon/aws-cli:2.15.0
command:
- sh
- -c
- |
if [ ! -f /models/.done ]; then
aws s3 sync s3://my-models/llama-3.1-70b /models/llama-3.1-70b
touch /models/.done
fi
volumeMounts:
- { name: model-cache, mountPath: /models }
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.3
args:
- --model=/models/llama-3.1-70b
- --served-model-name=llama-3.1-70b
- --port=8000
ports:
- containerPort: 8000
volumeMounts:
- { name: model-cache, mountPath: /models, readOnly: true }
resources:
limits:
nvidia.com/gpu: 1 # one whole GPU, request==limit, integer
memory: 96Gi # host RAM to stage weights
requests:
cpu: "8"
memory: 96Gi
nvidia.com/gpu: 1
# --- probes: startup guards the slow load, then liveness/readiness ---
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 60 # up to 600s to load 70B weights + warmup
readinessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 3 # pull from LB if it goes unhealthy
livenessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 20
failureThreshold: 3 # only restart a genuinely wedged process
lifecycle:
preStop:
exec:
# flip out of rotation, give the LB time to notice before drain
command: ["sh", "-c", "sleep 15"]
---
apiVersion: v1
kind: Service
metadata:
name: llm-inference
spec:
selector: { app: llm-inference }
ports:
- name: http
port: 80
targetPort: 8000
type: ClusterIP
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: llm-inference
spec:
minAvailable: 2 # keep >=2 replicas through drains/upgrades
selector:
matchLabels: { app: llm-inference }
Notes on the choices:
startupProbebudget =10s * 60 = 600s. If your model loads in ~90s, this is generous headroom; shrinkfailureThresholdif you want faster crash detection, but never below your real worst-case load time.- The
initContaineridempotency (.donesentinel) means a restarted pod on a node whoseemptyDirsurvived (it won’t across reschedule) skips the re-download; for true cross-pod caching use a read-only RWX PVC or a node-local hostPath cache instead ofemptyDir. maxSurge: 0trades a little capacity during rollout for not needing an extra GPU.- The
preStopsleep + 180s grace period gives streaming requests time to finish and the load balancer time to stop routing before the process exits.
Saying it out loud. If I had to describe a correct GPU Deployment manifest out loud: one
nvidia.com/gpuunder limits, a toleration for the GPU node taint and a nodeSelector for the right SKU, a startup probe with a budget sized to worst-case cold load, a readiness probe that only passes once the model is actually loaded, a lenient liveness probe that only takes over afterward, an initContainer fetching weights into a shared cache volume, a PodDisruptionBudget withminAvailable, and a termination grace period well above your longest generation. Every single one of those exists because of a specific failure — probes crash-looping a healthy load, drains evicting all replicas, SIGKILL cutting a stream mid-token. It’s not ceremony; it’s a list of incidents somebody already had.
Brief KServe example
The same intent, far less YAML, using KServe’s InferenceService. KServe wires up autoscaling, routing, and the storage fetch for you.
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: llama-31-70b
spec:
predictor:
minReplicas: 1
maxReplicas: 4
model:
modelFormat: { name: vLLM }
storageUri: s3://my-models/llama-3.1-70b # KServe fetches the weights
resources:
limits:
nvidia.com/gpu: "1"
requests:
nvidia.com/gpu: "1"
memory: 96Gi
KServe pulls the weights from storageUri (S3/GCS/PVC/HTTP), applies a ServingRuntime for the vLLM format, and manages the Deployment/Service/autoscaler behind the CRD. You still tune probes and node placement via the ServingRuntime or pod overrides.
Saying it out loud. The KServe version of that same manifest is about fifteen lines: an
InferenceServicewith min and max replicas, a model format of vLLM, astorageUripointing at S3, and a GPU resource limit. KServe fetches the weights, applies aServingRuntimefor the format, and manages the Deployment, Service, and autoscaler behind the CRD for you. The honest tradeoff is that you’ve traded explicit control for less YAML — you still tune probes and node placement, just through theServingRuntimeor pod overrides rather than directly, and when something goes wrong you’re now debugging through an abstraction layer. Which is fine, as long as you understand what the raw manifest would have looked like, because that’s what the CRD is generating underneath.
Build it in practice — extended: MIG, time-slicing & NetworkPolicy
The example above requests one whole GPU. In practice you’ll often want a mix: a small model MIG-sliced for isolation, and a dev/low-QPS pool that’s time-sliced for density. Both are configured at the GPU Operator / device-plugin layer, not in the pod spec — the pod spec just requests whatever resource name the plugin ends up advertising.
1. Enable MIG via the GPU Operator’s ClusterPolicy. The operator’s MIG manager reads a named profile from a ConfigMap and applies it to nodes carrying a matching label:
apiVersion: v1
kind: ConfigMap
metadata:
name: mig-parted-config
namespace: gpu-operator
data:
config.yaml: |
version: v1
mig-configs:
all-1g.10gb:
- devices: all
mig-enabled: true
mig-devices:
"1g.10gb": 7 # slice each A100-80GB into 7 isolated instances
# Label the target node(s) to request that profile; the MIG manager
# cordons/drains GPU workloads on the node, repartitions, then relabels.
kubectl label node gpu-node-1 nvidia.com/mig.config=all-1g.10gb --overwrite
Once applied, the device plugin advertises nvidia.com/mig-1g.10gb on that node instead of (or alongside) nvidia.com/gpu, and a pod requests it exactly like any extended resource:
resources:
limits:
nvidia.com/mig-1g.10gb: 1 # one hardware-isolated 10GB slice
2. Enable time-slicing for a separate, lower-trust dev pool. This is a plain ConfigMap the device plugin consumes, referenced from the node via a device-plugin config label — deliberately not the same nodes running MIG, since the two are different tradeoffs for different tenancy levels:
apiVersion: v1
kind: ConfigMap
metadata:
name: time-slicing-config
namespace: gpu-operator
data:
a100-40gb: |-
version: v1
flags:
migStrategy: none
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 4 # advertise 4x the physical GPU count on this node
kubectl label node gpu-node-dev nvidia.com/device-plugin.config=a100-40gb --overwrite
Pods on gpu-node-dev still request nvidia.com/gpu: 1 — the plugin is simply advertising 4 units per physical card, so 4 pods can land on one GPU with no memory isolation between them. This is the tradeoff called out earlier: fine for a bursty internal eval tool, wrong for a multi-tenant production pool.
3. Lock down the network around the inference pods. A NetworkPolicy limits which namespaces can reach the model server and what the pod itself can reach outbound — worth doing once you have more than one team on the cluster:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: llm-inference-netpol
namespace: inference
spec:
podSelector:
matchLabels: { app: llm-inference }
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: ingress-system } # gateway/ingress controller
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring } # Prometheus scraping /metrics
ports:
- { protocol: TCP, port: 8000 }
egress:
- to: [] # DNS to any endpoint, restricted by port
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to: # object storage for weights, via cloud NAT
- ipBlock: { cidr: 0.0.0.0/0 }
ports:
- { protocol: TCP, port: 443 }
In a real cluster you’d usually scope that last egress rule to your cloud provider’s object-storage IP ranges rather than 0.0.0.0/0; it’s left broad here because most teams reach weights through a NAT gateway whose egress IP isn’t something the pod’s NetworkPolicy can pin down more tightly without also breaking other HTTPS egress (registry pulls, telemetry). The important part is the shape: ingress restricted to the gateway/ingress and monitoring namespaces only, egress restricted to DNS and HTTPS — nothing else in or out.
Saying it out loud. The key thing to understand about MIG and time-slicing on Kubernetes is that neither is configured in the pod spec. They’re set at the GPU Operator and device-plugin layer — a
ClusterPolicyfor MIG, a plain ConfigMap for time-slicing — and the pod just requests whatever resource name the plugin ends up advertising, likenvidia.com/mig-1g.10gbinstead ofnvidia.com/gpu. That separation matters because it means a MIG reconfiguration is a node-level operation that drains and repartitions the card, which is why nodes dip through aPending-inducing window during a MIG change. Add a NetworkPolicy on top so the inference pods only accept traffic from the gateway namespace — on a shared GPU cluster the pods are multi-tenant neighbors, and default-allow networking is not what you want there.
Debugging playbook: Pending and crash-looping GPU pods
Two symptoms cover most incidents. Work them like this:
Pod stuck Pending.
kubectl describe pod <pod> | sed -n '/Events/,$p'
Read the scheduler message:
0/12 nodes are available: 12 Insufficient nvidia.com/gpu→ no free GPUs. Is the cluster-autoscaler adding a GPU node? Is your node pool at max? Is another pod holding the GPU?... node(s) had untolerated taint {nvidia.com/gpu: present}→ you’re missing the toleration.... didn't match Pod's node affinity/selector→ yournodeSelector/label is wrong (check exact label withkubectl get nodes --show-labels).Insufficient cpu/memory→ GPU is free but the node can’t fit your CPU/RAM request.
Pod in CrashLoopBackOff during startup.
kubectl logs <pod> -c vllm --previous # logs from the killed attempt
kubectl get events --field-selector involvedObject.name=<pod>
- Repeated
Liveness probe failedevents at ~the same age → probe is killing the model mid-load; add/extend the startup probe. OOMKilledin the container’slastState→ raise the memory limit (host RAM to stage weights).- initContainer errors (S3 auth, disk full on
emptyDirsizeLimit) → weights fetch is failing; the main container never starts. - CUDA/driver errors in logs → driver/toolkit mismatch (a job for the GPU Operator), or the GPU was already claimed.
Saying it out loud. Two symptoms cover almost every GPU pod incident, and both have a mechanical first move. If a pod is
Pending, runkubectl describe podand read the scheduler’s own message — it names the exact constraint.Insufficient nvidia.com/gpumeans no free GPUs,untolerated taintmeans you’re missing a toleration,didn't match node affinitymeans your selector label is wrong, andInsufficient cpumeans the GPU was free but the node couldn’t fit your CPU or RAM request. If it’sCrashLoopBackOffduring startup, pull the logs from the previous attempt with--previousand check the events. Repeated liveness failures at suspiciously identical ages means a probe is killing the load;OOMKilledinlastStatemeans host RAM, not VRAM. The scheduler is usually telling you the answer already.
Walkthrough: a pod stuck Pending after a MIG config change
A concrete sequence, the way it actually unfolds on-call — this is the “20 free GPUs but nothing schedules” flavor, root-caused end to end:
-
Notice. A new replica from a rollout has sat in
Pendingfor ten minutes.$ kubectl get pods -n inference NAME READY STATUS RESTARTS AGE llm-inference-7d9c9b9f5c-4kxqp 0/1 Pending 0 10m -
Read the scheduler’s reasoning — always the first move, it usually names the exact constraint:
$ kubectl describe pod llm-inference-7d9c9b9f5c-4kxqp -n inference | sed -n '/Events/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 9m52s default-scheduler 0/6 nodes are available: 4 Insufficient nvidia.com/gpu, 2 node(s) had untolerated taint {nvidia.com/gpu: present}. -
Cross-check node capacity vs. allocatable. A MIG config change earlier that day relabeled two nodes, and the device plugin briefly advertised zero GPUs while it reconciled:
$ kubectl get nodes -l nvidia.com/gpu.present=true \ -o custom-columns=NAME:.metadata.name,ALLOC:.status.allocatable."nvidia\.com/gpu",CAP:.status.capacity."nvidia\.com/gpu" NAME ALLOC CAP gpu-node-1 0 8 gpu-node-2 0 8 gpu-node-3 8 8 gpu-node-4 8 8gpu-node-1/2showALLOC=0againstCAP=8— the device plugin pod restarted mid-MIG-reconfiguration and hasn’t re-registered the resource with the kubelet yet. -
Check the device plugin DaemonSet, since it — not the kubelet — owns the extended resource:
$ kubectl get pods -n gpu-operator -l app=nvidia-device-plugin-daemonset -o wide NAME READY STATUS RESTARTS NODE nvidia-device-plugin-daemonset-a1 0/1 CrashLoopBackOff 6 gpu-node-1 nvidia-device-plugin-daemonset-b2 0/1 CrashLoopBackOff 6 gpu-node-2 nvidia-device-plugin-daemonset-c3 1/1 Running 0 gpu-node-3 $ kubectl logs -n gpu-operator nvidia-device-plugin-daemonset-a1 --previous ... error: no MIG devices found matching profile "1g.10gb": mig-parted config "all-1g.10gb" not yet applied -
Root cause. The
mig-parted-configConfigMap was updated to a new profile, the labelnvidia.com/mig.configongpu-node-1/2was flipped by the MIG manager, but the physical repartition (which requires the GPU to have no running processes) hadn’t completed before the device plugin restarted and tried to enumerate MIG devices — a race between the MIG manager’s drain/repartition step and the device plugin’s own restart. -
Fix. Let the MIG manager finish (it cordons and drains the node’s GPU workloads before repartitioning — that’s expected and is why nodes go through a
Pending-inducing dip), or if it’s stuck, restart the MIG manager pod on that node and re-checkkubectl get nodes ... ALLOC. OnceALLOCmatchesCAPagain the pending pod schedules within seconds — no change to the pod spec was ever needed.
Lesson generalized: Insufficient nvidia.com/gpu in the scheduler event is necessary but not sufficient diagnosis — always compare allocatable against capacity per node before assuming “the cluster is full.” A gap between them almost always means the device plugin (or MIG manager, or driver container) is unhealthy on that specific node, not that GPUs are actually unavailable cluster-wide.
Saying it out loud. Here’s the shape of a real one. A pod sits
Pendingten minutes; describe saysInsufficient nvidia.com/gpuon four nodes. The move that cracks it is comparing each node’s allocatable GPUs against its capacity — and two nodes show allocatable zero against capacity eight. That gap is never “the cluster is full,” it’s the device plugin being unhealthy on those specific nodes. Sure enough, the plugin pods are crash-looping because a MIG profile change flipped the node labels but the physical repartition hadn’t finished, and the plugin restarted mid-reconfiguration trying to enumerate MIG devices that didn’t exist yet. Nothing about the pod spec was ever wrong. The generalizable lesson:Insufficient nvidia.com/gpuis a necessary but not sufficient diagnosis — always check allocatable versus capacity per node before believing the cluster is out of GPUs.
A note on cold-start math
Cold-start time for a scaled-up or rescheduled pod is roughly:
[ T_{cold} = T_{provision} + T_{pull} + T_{weights} + T_{load} + T_{warmup} ]
where ( T_{provision} ) is node acquisition (0 if a node is warm, minutes if the cluster-autoscaler must boot a GPU VM), ( T_{pull} ) is image pull, ( T_{weights} ) is fetching weights to the node, ( T_{load} ) is loading them into VRAM, and ( T_{warmup} ) is CUDA-graph capture / first-token warmup. Your startup probe budget must exceed ( T_{weights} + T_{load} + T_{warmup} ) (the init container covers ( T_{weights} ) separately if you split it out), and your autoscaling responsiveness is gated by the whole sum — which is why node-local caches and pre-pulled images matter so much.
Saying it out loud. Cold start for a GPU pod is a sum of five terms, and it’s worth naming all of them: node provisioning if the autoscaler has to boot a VM, image pull, fetching the weights onto the node, loading them into VRAM, and warmup like CUDA graph capture. Two things fall out of that. Your startup probe budget has to exceed the load-plus-warmup portion, or you crash-loop a perfectly healthy pod. And your autoscaling responsiveness is gated by the entire sum — which is why a scale-up decision made in ten seconds can still take eight minutes to deliver capacity. That’s the number that makes node-local weight caches and pre-pulled images worth the operational complexity: they’re the only terms you can actually attack.
Comparison: serving-on-Kubernetes options
| Option | What it is | Autoscaling / scale-to-zero | Best for | Cost |
|---|---|---|---|---|
| Raw Deployment + Service | You write the manifests | HPA only (you wire it); no scale-to-zero out of the box | Full control, simple single-model services, learning | High YAML/ops effort, most flexible |
| KServe (InferenceService) | CRD over Knative/k8s, standard model protocol | Yes, incl. scale-to-zero + canary | Platform teams wanting a model abstraction, many models, standardized rollout | Heavier install (Knative/Istio or raw-deploy mode), more concepts |
| Ray Serve (KubeRay) | Distributed serving on Ray via RayService | Yes, Ray-native autoscaling | Multi-model composition, distributed/multi-node tensor-parallel, complex pipelines | Ray cluster to operate; overkill for one small model |
| Triton / NVIDIA NIM (+ NIM Operator) | NVIDIA-optimized model servers/containers | Via KServe/HPA integration | NVIDIA-standardized stacks, optimized/quantized engines, vendor support | Vendor lock-in to NVIDIA images; excellent perf |
| KubeAI | Lightweight k8s-native LLM operator | Yes, incl. scale-from-zero | OpenAI-compatible serving without Istio/Knative | Younger ecosystem, smaller community |
Rule of thumb: start with a raw Deployment to understand the mechanics; graduate to KServe or KubeAI when you have many models and want autoscaling/canary for free; reach for Ray Serve when a single request must fan across multiple GPUs/nodes or you’re composing models; adopt NIM/Triton when NVIDIA’s optimized engines and support matter.
Saying it out loud. My rule of thumb: start with a raw Deployment so you actually understand the mechanics — GPU requests, probe timing, PDBs, weight fetching. Graduate to KServe or KubeAI once you have many models and want autoscaling and canary rollouts for free rather than hand-wiring an HPA per service. Reach for Ray Serve when a single request has to fan across multiple GPUs or nodes, or when you’re composing several models into a pipeline — that’s genuinely a different problem shape. And adopt NIM or Triton when NVIDIA’s optimized engines and vendor support matter more than portability. The tradeoff running through all of it is control versus ceremony: the raw path is the most flexible and the most YAML, and every abstraction above it is a layer you’ll eventually have to debug through.
Failure modes & pitfalls
- Probes killing pods mid-load. No startup probe (or a liveness probe with a too-short
initialDelaySeconds) turns a 4-minute model load into an infiniteCrashLoopBackOff. Always use a startup probe sized to worst-case load; keep liveness lenient. This is the number-one LLM-on-k8s bug. - GPU fragmentation. Integer, per-node GPU allocation strands capacity: 20 GPUs free cluster-wide but no single node has the 4 your tensor-parallel pod needs. Use topology-aware / gang scheduling and design node pools around your parallelism.
- Image pull of huge images. A 60 GB image (weights baked in) can take many minutes to pull on a cold node, and the cluster-autoscaler’s node-provision + pull time compounds it. Keep runtime images lean, pre-pull images to nodes, or use a node-local weight cache.
- Thundering-herd weight downloads. N pods cold-starting at once each pulling the full model from one bucket saturates egress and slows all of them. Pre-populate a read-only PVC or node cache; stagger scale-ups.
- No PDB → full outage on drain. A routine node upgrade or autoscaler scale-down can evict every replica simultaneously. Always ship a PodDisruptionBudget with
minAvailable. - Missing tolerations / wrong selectors → Pending forever. GPU nodes are tainted; a pod without the matching toleration silently stays
Pending. Conversely, no selector and CPU pods squat on GPU nodes.kubectl describe podshows the scheduling reason. - CPU/memory misconfig masquerading as GPU failure. An OOMKill while staging weights into host RAM, or CPU throttling from a tight CPU limit, looks like a model/probe problem. Give generous memory limits; be careful with CPU limits.
- Ingress/proxy timeouts cutting streams. Default 30–60s proxy timeouts truncate long token streams. Raise read/backend timeouts and disable response buffering.
- Rollouts assuming spare GPUs.
maxSurge > 0on a full GPU pool blocks the rollout waiting for GPUs that don’t exist. UsemaxSurge: 0or ensure headroom. - Ephemeral
emptyDircache re-downloads every reschedule. AnemptyDirdies with the pod, so a rescheduled pod re-fetches the whole model. If cold-start matters, back the cache with a read-only RWX PVC or a node-localhostPath/CSI volume that survives pod churn. - Graceful shutdown too short. Default 30s
terminationGracePeriodSecondsSIGKILLs pods mid-generation. Raise it above your longest generation and flip readiness first. - Time-slicing treated as a free lunch. Advertising 4x replicas of a GPU does not give 4x compute or any memory isolation — two co-scheduled pods can OOM each other. Reach for MIG when tenants don’t fully trust each other.
- No quota between serving and batch/training on a shared cluster. Without Kueue-style quotas, a large eval sweep or fine-tuning job can starve the serving fleet of GPUs with no admission control to stop it.
Saying it out loud. The recurring failures on Kubernetes are pretty consistent. Probes killing pods mid-load — that’s number one, and it’s always a missing startup probe. GPU fragmentation, where twenty GPUs are free cluster-wide but no single node has the four your tensor-parallel pod needs. Huge image pulls compounding with node provisioning time. Thundering-herd weight downloads when N pods cold-start at once. No PDB, so a routine node drain takes the whole service down. Missing tolerations leaving pods
Pendingsilently. AnemptyDircache that dies with the pod so every reschedule re-downloads the model. And a thirty-second grace period SIGKILL-ing pods mid-generation. The pattern: most of them look like something else — hardware failure, network outage, model bug — which is why the diagnosis discipline matters more than memorizing the list.
Production case studies & war stories
Case 1: the 3-minute model load that looked like a hardware failure
Setup. A team migrated a 34B model from a hand-run VM to a Kubernetes Deployment. They copied a probe config from an existing CPU microservice: livenessProbe with initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 3 — no startup probe (this predates the startup-probe-first mindset now standard).
Symptom. Every rollout, every pod restart, every node replacement produced the same pattern: pod Running, then CrashLoopBackOff a few minutes later, forever. Logs showed the model server killed mid-torch.load. The on-call’s first hypothesis was bad GPU hardware — they cordoned two “suspect” nodes before someone actually read the timeline.
Root cause. The model took roughly 3 minutes to load (weights from a network PVC plus CUDA graph capture). The liveness probe’s math: initialDelaySeconds(30) + periodSeconds(10) * failureThreshold(3) = 60s grace window — 60 seconds against a 180-second load. The kubelet killed the container at roughly T+60s, every time, deterministically. It looked random only because different nodes had slightly different PVC read latency, shifting the exact restart timestamp.
Fix. Added a startupProbe with periodSeconds: 10, failureThreshold: 30 (a 300s budget), and left liveness to only take over after startup succeeded — the pattern in the fully worked example above. Rollouts went from “always crash-loops for the first 10 minutes, eventually succeeds by luck when a fast node happens to finish in time” to clean on the first attempt.
Lesson. A crash loop with a suspiciously consistent time-to-first-crash is a probe timing bug, not hardware. Check the arithmetic (initialDelaySeconds + periodSeconds * failureThreshold) against your actual worst-case load time before blaming nodes. This is the single most common LLM-on-Kubernetes incident, and it is entirely self-inflicted — the fix is always a startup probe, never “replace the GPU.”
Saying it out loud. A team moved a 34B model onto Kubernetes and copied a probe config from an existing CPU microservice: liveness with thirty seconds initial delay, ten-second period, three failures. No startup probe. Every rollout produced the same thing — pod Running, then CrashLoopBackOff, forever — and on-call’s first theory was bad GPU hardware, so they cordoned two nodes before anyone did the arithmetic. The math: 30 plus 10 times 3 is a sixty-second grace window against a 180-second load. The kubelet killed it at T+60 every single time, deterministically; it only looked random because PVC read latency varied slightly per node. The fix was a startup probe with a 300-second budget. The lesson worth stealing: a crash loop with a suspiciously consistent time-to-crash is a probe timing bug, never hardware.
Case 2: 20 free GPUs, one pod stuck Pending — fragmentation across nodes
Setup. A cluster with five 8-GPU nodes ran a mix of single-GPU inference pods (many small models) alongside an occasional 4-GPU tensor-parallel deployment for a 70B model.
Symptom. The 4-GPU pod sat Pending for over an hour. kubectl describe reported plain Insufficient nvidia.com/gpu — no taint mismatch, no selector typo. Cluster-wide GPU utilization dashboards showed only 60% of GPUs in use — 20 of 40 GPUs “free.”
Root cause. Single-GPU pods had been scheduled by bin-packing across all five nodes rather than filling nodes one at a time, leaving each node with 3–4 free GPUs but no node with 4 contiguous free GPUs together — because the default scheduler has no built-in notion of “keep this pod’s GPUs on one node for NVLink locality” beyond the basic per-node integer count. The one 4-GPU tensor-parallel pod needed all 4 GPUs on a single node (for NVLink bandwidth between them) and there wasn’t one.
Fix, in order of effort:
- Immediate: manually cordon/drain single-GPU pods off one node to consolidate free capacity, unblocking the pending pod (a manual, one-time bin-pack).
- Short-term: add a
PriorityClass+ preemption so multi-GPU pods can evict lower-priority single-GPU pods to consolidate space, and setpodAntiAffinity/topology spread on the small pods to bias the scheduler toward filling nodes rather than spreading them evenly. - Structural: split the node pool — a pool of nodes reserved (via taint) for multi-GPU tensor-parallel workloads only, sized exactly to the parallelism degree, and a separate pool (optionally MIG- or time-sliced) for single-GPU/small-model traffic. This is the fix that actually scales: don’t let the scheduler discover topology constraints at pending-time, encode them into pool shape up front.
- Longer-term: adopt Kueue with gang scheduling for the multi-GPU workload class, so the 4-GPU pod’s pods are admitted all-or-nothing against a quota that reserves capacity, and/or evaluate DRA once available on the platform, which can express “4 GPUs on the same NVLink domain” as a first-class claim instead of hoping bin-packing works out.
Lesson. “GPUs free cluster-wide” and “GPUs schedulable for this pod” are different numbers whenever a workload needs more than one GPU per node. Multi-GPU workloads need topology-aware placement designed in from the start (dedicated pools, gang scheduling, or DRA), not discovered as an incident.
Saying it out loud. Five 8-GPU nodes, lots of single-GPU pods, and one 4-GPU tensor-parallel deployment that sat
Pendingfor over an hour while dashboards cheerfully showed twenty of forty GPUs free. The cause is bin-packing: the scheduler had spread the small pods evenly across all five nodes, leaving three or four free GPUs on each — and no node with four free together, which is what the tensor-parallel pod needs for NVLink bandwidth between them. The immediate fix is manually consolidating; the structural fix is splitting the node pool so multi-GPU workloads get a tainted pool sized exactly to their parallelism degree. The lesson: “GPUs free cluster-wide” and “GPUs schedulable for this pod” are different numbers the moment a workload needs more than one GPU on one node, and you encode topology into pool shape up front instead of discovering it as an incident.
Case 3: the thundering herd that looked like a network outage
Setup. An autoscaling event scaled a 70B-model service from 2 to 10 replicas in response to a traffic spike. All 8 new pods had cold emptyDir caches and started initContainers pulling the same 140GB from one S3 bucket simultaneously.
Symptom. Network egress on the bucket’s region saturated; all 8 pods’ downloads slowed to a crawl, and the 2 already-healthy pods saw elevated latency as the shared NAT gateway’s connection tracking maxed out. On-call initially chased it as a networking/NAT problem.
Fix and lesson. Pre-populate a read-only RWX PVC (or a node-local cache warmed ahead of the spike) so scale-up pods mount already-present weights instead of re-downloading; if that’s not feasible, stagger initContainer starts (e.g. a prefetch step with concurrency limits, or node-level caching so only the first pod on a given node downloads). The autoscaling chapter covers pre-warming pools for exactly this reason — cold-start stampedes are an autoscaling problem wearing a networking costume.
Saying it out loud. An autoscaler took a 70B service from two replicas to ten during a traffic spike. All eight new pods had cold
emptyDircaches, so all eight initContainers started pulling the same 140 gigabytes from one S3 bucket at the same moment. Egress saturated, every download crawled, and the two already-healthy pods got slower too because the shared NAT gateway’s connection tracking maxed out — so on-call chased it as a networking problem for a while. The fix is to make scale-up pods mount weights that are already there: a pre-populated read-only PVC, or a node-local cache so only the first pod on each node downloads. Failing that, stagger the initContainers with a concurrency limit. The framing I like: cold-start stampedes are an autoscaling problem wearing a networking costume.
Interview mastery
Explain GPU scheduling on Kubernetes in 60 seconds
“Kubernetes has no native GPU concept — the NVIDIA device plugin, a DaemonSet on every GPU node, discovers GPUs and advertises them to the kubelet as the extended resource
nvidia.com/gpu. Extended resources are integer and exclusive: request must equal limit, and the scheduler will never double-book one GPU across two pods, because VRAM has no swap and no safe overcommit. To keep GPU nodes for GPU workloads you taint the nodes and tolerate the taint on your pods, and to pick a specific SKU you add anodeSelectoron a GPU-product label. If you want to pack more than one workload onto a card you do it explicitly — MIG for hardware-isolated partitions, time-slicing or MPS for software-multiplexed sharing with weaker isolation — the device plugin just advertises different resource names or counts depending on which you pick. The scheduler math never changes: whatever unit you’re advertising, it’s still handed out as an exclusive integer per pod.”
Practice saying that out loud in under a minute — it hits device plugin, extended resources, exclusivity/no-overcommit rationale, taints/tolerations, and the sharing mechanisms in the right order.
System design prompt: “run 5 different model sizes on a shared GPU cluster”
Prompt as typically asked: “You need to serve five models — say 1B, 8B, 34B, 70B, and 405B parameters — on one Kubernetes cluster, with mixed traffic (some interactive chat, some high-throughput batch). Sketch the architecture.”
A strong answer separates concerns by parallelism degree and latency class, not just “throw everything in one Deployment”:
+-------------------------------+
| Gateway API + Inference |
| Extension (InferencePool |
| per model, priority via |
| InferenceObjective) |
+---------------+-----------------+
| model-aware / prefix-cache-aware routing
+---------------+--------------+--------------+---------------+
v v v v v
+-----------+ +-----------+ +-----------+ +-----------+ +----------------+
| 1B pool | | 8B pool | | 34B pool | | 70B pool | | 405B pool |
| MIG-sliced| | 1 GPU/pod | | 1 GPU/pod | | TP=4, one | | TP=8/PP=2, |
| (7x/GPU) | | L4/L40S | | A100 80GB | | node, A100| | multi-node, |
| shared A100| | node pool | | node pool | | node pool | | dedicated pool |
| pool | | | | | | (gang | | (gang |
| | | | | | | scheduled)| | scheduled) |
+-----------+ +-----------+ +-----------+ +-----------+ +----------------+
| | | | |
+--- KEDA/HPA on vLLM queue-depth metrics, per pool, independently ---+
|
Kueue ClusterQueues arbitrate GPU quota
between interactive (high priority, low
latency SLO) and batch (best-effort FIFO)
Key decisions to narrate:
- Pool by parallelism, not just by size. The 1B and 8B models fit on fractional/single GPUs — MIG-slice the 1B model (it’s small and latency-sensitive, so hardware isolation beats time-slicing) and give 8B a full GPU on a cheaper SKU. 70B and 405B need tensor/pipeline parallelism across multiple GPUs or nodes — give them dedicated, gang-scheduled pools sized exactly to their parallelism degree (this avoids the fragmentation war story above).
- Route by model identity and priority, using the Gateway API Inference Extension’s
InferencePool/InferenceObjective(or a KV-cache-aware router like llm-d) — interactive chat traffic gets latency-priority routing and reserved capacity; batch/eval traffic runs best-effort and is the first to be preempted or queued. - Autoscale on serving pressure, not CPU — KEDA against each pool’s vLLM
num_requests_running/queue-depth metric, independently per model, so a spike in 8B traffic doesn’t starve 405B capacity or vice versa. - Arbitrate GPU quota with Kueue if teams share the cluster for both serving and batch fine-tuning/eval — quotas prevent one workload class from starving another, and gang scheduling stops a partially-admitted multi-GPU job from deadlocking capacity.
- Bound blast radius per pool — separate PDBs, separate node pools’ taints, so a bad rollout or node drain on the 405B pool can’t touch the 1B pool’s availability.
Interviewers are usually grading whether you separate concerns by parallelism/latency class rather than proposing one Deployment-per-model with no shared reasoning about GPU topology — that’s the signal that differentiates a strong answer.
Saying it out loud. Serving 1B through 405B on one cluster with mixed interactive and batch traffic — the move is to separate concerns by parallelism degree and latency class, not to make one Deployment per model and hope. So: MIG-slice the 1B model since it’s small and latency-sensitive and deserves hardware isolation; put 8B on a full but cheaper GPU like an L4; give 70B a gang-scheduled TP=4 pool sized exactly to its parallelism; give 405B a dedicated multi-node pool. Route by model identity and priority using
InferencePoolandInferenceObjectiveso interactive chat gets reserved capacity and batch runs best-effort. Autoscale each pool independently on vLLM queue depth, not CPU. And arbitrate quota with Kueue so an eval sweep can’t starve the serving fleet. What’s being graded is whether you separate by topology class at all.
Red flags vs. green flags
| Signal | Red flag (weak answer) | Green flag (strong answer) |
|---|---|---|
| GPU overcommit | “You can set a GPU limit higher than request to burst” | “Extended resources require request == limit; GPUs are never overcommitted because VRAM has no swap” |
| Probe design | “Just increase initialDelaySeconds a lot” | “Use a startup probe sized to worst-case load; keep liveness fast and lenient once startup passes” |
| GPU sharing | Treats MIG/time-slicing/MPS as interchangeable | Distinguishes hardware isolation (MIG) from software multiplexing (time-slicing/MPS) and picks based on tenancy trust |
| Fragmentation | “Just add more nodes” | Explains topology-aware pooling, gang scheduling, or DRA as the structural fix |
| Rollouts | Doesn’t mention maxSurge GPU cost | Explains maxSurge: 0/maxUnavailable: 1 tradeoff for GPU-scarce rollouts |
| Disruption | Unaware of PDBs | Explains PDB + graceful shutdown + terminationGracePeriodSeconds together |
| Routing | Proposes plain round-robin for LLM traffic | Mentions prefix/KV-cache-aware routing (session affinity, llm-d, Gateway API Inference Extension) |
| Batch vs. serving | No answer for “how do you share GPUs between training and serving” | Mentions Kueue quotas/ClusterQueues and priority/preemption |
| Multi-region | Treats multi-region as “just another Ingress” | Explains config-cluster/target-cluster split and capacity bursting (or the general pattern even without naming a specific vendor) |
| Cold start | Doesn’t account for weight fetch time in scaling decisions | Breaks cold start into provision + pull + weights + load + warmup and sizes probes/autoscaling around the sum |
Q&A bank (18 questions)
- “A pod loads a 100 GB model in 5 minutes but keeps restarting. Why?” — Missing/short startup probe; liveness kills it mid-load. Fix with a startup probe whose
periodSeconds * failureThresholdexceeds worst-case load, and suppress liveness until then. - “Why can’t you overcommit GPUs like memory?” — VRAM has no swap and the scheduler can’t reason about it; the device plugin advertises integer, exclusive units (request == limit). Sharing requires explicit time-slicing/MPS/MIG.
- “How do you keep GPU pods on GPU nodes and everything else off?” — Taint GPU nodes, add matching tolerations, plus a
nodeSelector/affinity on the GPU SKU label. Explain the attraction-vs-repulsion split. - “Where do the weights come from and how fast is a cold start?” — Articulate baked-image vs initContainer vs read-only PVC vs node cache, the thundering-herd risk, and how you make replicas 2..N start fast.
- “How do you upgrade without an outage?” — RollingUpdate with
maxSurge: 0/maxUnavailable: 1, a PDB withminAvailable, graceful drain viaterminationGracePeriodSeconds+preStop+ readiness flip. - “Readiness vs liveness vs startup — when does each fire and what does failure do?” — Startup gates the others and restarts on failure; readiness gates traffic (no kill); liveness restarts a wedged process. Traffic should ride on a readiness endpoint that only passes after warmup.
- “When would you reach for KServe or Ray Serve over a raw Deployment?” — KServe/KubeAI for many models + autoscaling/canary/scale-to-zero for free; Ray Serve for distributed multi-GPU/multi-node or model composition; raw Deployment for control/simplicity.
- “You have 20 free GPUs but a 4-GPU pod won’t schedule — explain.” — Fragmentation: allocation is per-node and integer. Needs topology-aware/gang scheduling, dedicated pools sized to parallelism, or DRA.
- “What’s the difference between MIG, time-slicing, and MPS, and when do you pick each?” — MIG: hardware-isolated partitions, safe for untrusted multi-tenant workloads, fixed partition sizes. Time-slicing: no isolation, oversubscribed compute, fine for trusted/bursty dev traffic. MPS: shared memory but concurrent kernel execution with some compute control — a middle ground, still not hard-isolated.
- “How would you route requests so follow-up turns hit the replica with the warm KV cache?” — Session affinity as a cheap first step; for real gains, a KV-cache-aware router (vLLM production stack router, llm-d, or the Gateway API Inference Extension’s
InferencePoolwith prefix-cache signals) rather than round-robin. - “How do you share a GPU cluster fairly between serving and batch fine-tuning/eval jobs?” — Kueue
ClusterQueue/LocalQueuewith quotas, borrowing/lending between cohorts, priority-based admission, and gang scheduling so partially-admitted multi-GPU jobs don’t deadlock capacity. - “What is Dynamic Resource Allocation and why does it matter for GPUs?” — DRA (GA in Kubernetes 1.34, September 2025) replaces “advertise an integer count” with claim-based allocation, letting a pod request structured device properties (a MIG profile, GPUs on the same NVLink domain) that the old device-plugin model can’t express.
- “How do you serve a model that doesn’t fit on one node?” — Tensor/pipeline parallelism across nodes (KServe’s
tensorParallelSize/pipelineParallelSize, or Ray Serve/KubeRay’s distributed actors), a headless Service + StatefulSet for stable pod-to-pod addressing, and a dedicated, gang-scheduled node pool sized to the parallelism degree. - “How do you avoid a regional outage taking down your inference service?” — Multi-cluster/multi-region routing (e.g. the multi-cluster Inference Gateway pattern): a config cluster holds routing policy, multiple target clusters run pods across regions, giving automatic failover and cross-region capacity bursting.
- “Your rollout is stuck because
maxSurgewants a GPU that doesn’t exist. What do you do?” — SetmaxSurge: 0, maxUnavailable: 1to roll within existing capacity, or ensure headroom (spare GPU quota) before rolling; explain the tradeoff of reduced capacity during rollout vs. blocking entirely. - “A ‘ready’ pod shows 0% GPU utilization but the request queue is growing. What’s happening and how do you catch it?” — The health endpoint is a shallow check that passed, but inference is wedged (deadlock, stuck CUDA context, dependency hang). Catch it with DCGM-exporter-based alerting on sustained 0% utilization on a ready pod, not just probe status — this is the “silent failure” that probes alone will never see.
- “How would you design probes and autoscaling differently for a model that takes 30 seconds to load vs. one that takes 10 minutes?” — Both need a startup probe sized to worst case, but the 10-minute model changes the autoscaling answer too: scale-up latency of 10 minutes means you want to keep warm standby replicas or pre-provisioned capacity rather than relying on reactive HPA/KEDA scale-up alone.
- “What’s the standard way to express LLM-aware traffic routing on Kubernetes as of 2026?” — The Gateway API Inference Extension (
InferencePool+InferenceModel/InferenceObjective), implemented by multiple gateway controllers and cloud-managed Inference Gateway offerings — model-aware, priority-aware routing standardized on top of the Gateway API instead of bespoke per-team logic.
Appendix: DRA in concrete YAML, and a 2023-vs-2026 cheat sheet
Dynamic Resource Allocation is discussed above at the concept level; here is the shape of it in YAML so it’s recognizable rather than abstract. This is illustrative of the pattern shipping across DRA-enabled clusters as of 2025–2026 (the exact field names can vary slightly by Kubernetes version and vendor driver, so treat this as “what to expect,” not a copy-paste guarantee for your cluster’s exact version):
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
name: gpu-claim-template
spec:
spec:
devices:
requests:
- name: single-gpu
exactly:
deviceClassName: gpu.nvidia.com
allocationMode: ExactCount
count: 1
---
apiVersion: v1
kind: Pod
metadata:
name: gpu-pod
spec:
containers:
- name: app
image: vllm/vllm-openai:v0.6.3
resources:
claims:
- name: single-gpu # ties the container to the claim below
resourceClaims:
- name: single-gpu
resourceClaimTemplateName: gpu-claim-template
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
Compare this to the device-plugin form used throughout this chapter (resources.limits.nvidia.com/gpu: 1). The device-plugin form asks for a count; the DRA form asks for a claim against a device class, and the claim’s spec.spec.devices.requests block is where richer selection criteria (a specific MIG profile, GPUs sharing an NVLink domain, a minimum driver version) get expressed once your cluster’s DRA driver supports them. Until then, the device-plugin model in the rest of this chapter remains the thing you’ll actually operate day to day.
A quick before/after for orientation:
| Concern | Classic (device plugin, still dominant in 2026) | Emerging (DRA, GA since k8s 1.34) |
|---|---|---|
| How a pod asks for a GPU | resources.limits: {nvidia.com/gpu: 1} | resourceClaims referencing a ResourceClaimTemplate/ResourceClaim |
| What’s expressible | An integer count of one resource name | Structured device selection (profile, topology, driver constraints) |
| Who advertises capacity | Device plugin DaemonSet per node | A DRA driver implementing the resource.k8s.io API |
| MIG / topology awareness | Encoded indirectly via distinct resource names (nvidia.com/mig-1g.10gb) | Expressed natively in the claim’s device request |
| Maturity in production (2026) | Default, battle-tested | Early; vendor driver support still rolling out |
Saying it out loud. The concrete difference between the two models is one sentence: the device-plugin form asks for a count, and the DRA form asks for a claim against a device class. In practice that means instead of
resources.limits: nvidia.com/gpu: 1, you reference aResourceClaimTemplatewhose device request is where richer selection criteria live — a specific MIG profile, GPUs sharing an NVLink domain, a minimum driver version. That’s the thing the integer model structurally cannot express, and it’s why fragmentation and topology are so painful today. The honest state of play in 2026, though, is that the device plugin is still the default and battle-tested path, and DRA driver support is still rolling out — so know the shape of it, recognize it in YAML, and keep operating the classic model day to day.
kubectl diagnostic cheat sheet
The commands used throughout the debugging playbook and war stories above, gathered in one place:
| Question | Command |
|---|---|
Why is this pod Pending? | kubectl describe pod <pod> | sed -n '/Events/,$p' |
| Does the node actually have free GPUs right now? | kubectl get nodes -o custom-columns=NAME:.metadata.name,ALLOC:.status.allocatable."nvidia\.com/gpu",CAP:.status.capacity."nvidia\.com/gpu" |
| Is the device plugin healthy on every GPU node? | kubectl get pods -n gpu-operator -l app=nvidia-device-plugin-daemonset -o wide |
| What did a crashed container log before it died? | kubectl logs <pod> -c <container> --previous |
What exact labels does this node carry (for nodeSelector debugging)? | kubectl get nodes --show-labels |
| Is a probe repeatedly failing, and when? | kubectl get events --field-selector involvedObject.name=<pod> |
| Was the container OOMKilled? | kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' |
| Is GPU utilization actually near zero on a “ready” pod? | DCGM exporter metric in Prometheus/Grafana, not kubectl — probes alone can’t see this |
| Did a node drain evict more replicas than the PDB should have allowed? | kubectl get pdb -n <namespace> (check ALLOWED DISRUPTIONS) then kubectl get events -A --field-selector reason=Killing |
Build it in practice — the combined manifest
Putting the MIG resource request, a NetworkPolicy, and the probe/PDB discipline from earlier together in one place, as you’d actually commit it for a small, latency-sensitive model running on a MIG-sliced, multi-tenant GPU pool:
apiVersion: apps/v1
kind: Deployment
metadata:
name: small-model-inference
namespace: inference
labels: { app: small-model-inference }
spec:
replicas: 4
strategy:
rollingUpdate: { maxSurge: 0, maxUnavailable: 1 }
selector:
matchLabels: { app: small-model-inference }
template:
metadata:
labels: { app: small-model-inference }
spec:
terminationGracePeriodSeconds: 60
nodeSelector:
nvidia.com/mig.config: all-1g.10gb # only the MIG-partitioned pool
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.3
args: [--model=/models/small-model, --port=8000]
ports: [{ containerPort: 8000 }]
resources:
limits:
nvidia.com/mig-1g.10gb: 1 # one hardware-isolated MIG slice
memory: 16Gi
requests:
cpu: "2"
memory: 16Gi
nvidia.com/mig-1g.10gb: 1
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 5
failureThreshold: 24 # 120s — small model, fast load
readinessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 20
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: small-model-inference
namespace: inference
spec:
selector: { app: small-model-inference }
ports: [{ name: http, port: 80, targetPort: 8000 }]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: small-model-inference
namespace: inference
spec:
minAvailable: 2
selector:
matchLabels: { app: small-model-inference }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: small-model-inference-netpol
namespace: inference
spec:
podSelector:
matchLabels: { app: small-model-inference }
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: ingress-system }
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring }
ports: [{ protocol: TCP, port: 8000 }]
egress:
- to: []
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
Every piece here traces back to a mechanism explained earlier in the chapter: the MIG resource name (section 2b and the extended build example), maxSurge: 0 for GPU-scarce rollouts (section 7), a startup probe sized in seconds appropriate to a small model’s faster load (section 4 — contrast the 600s budget on the 70B example), a PDB (section 7), and the ingress/egress shape from the NetworkPolicy extended example above. Small models on shared MIG hardware still get the same disciplines as the 70B single-GPU deployment — just with numbers scaled down.
Saying it out loud. The combined manifest is worth reading as a checklist rather than as YAML. It requests a MIG slice instead of a whole card, because this is a small latency-sensitive model on a multi-tenant pool where hardware isolation beats density. It has a startup probe sized to worst-case load, with liveness and readiness taking over only afterward. It has a PodDisruptionBudget so a node drain can’t evict everything at once. It has a termination grace period longer than the longest generation, with readiness flipping first so traffic drains before shutdown begins. And it has a NetworkPolicy restricting ingress to the gateway namespace, because on a shared cluster your neighbors are other people’s workloads. Every line traces to a specific failure mode from earlier in the chapter.
Glossary — quick reference for interviews
| Term | One-line definition |
|---|---|
| Device plugin | DaemonSet that discovers GPUs and advertises them to the kubelet as an extended resource (nvidia.com/gpu) |
| Extended resource | A countable, non-CPU/memory resource type; must be integer, request must equal limit |
| MIG | Hardware partitioning of a GPU into isolated instances with separate memory/compute |
| Time-slicing | Software oversubscription of one GPU across N pods, no memory isolation |
| MPS | Multi-Process Service — concurrent kernel execution with shared memory, soft compute control |
| DRA | Dynamic Resource Allocation — claim-based device allocation (GA in k8s 1.34, Sept 2025), successor direction to the device-plugin model |
| Startup probe | Probe that gates liveness/readiness until it first succeeds; failure restarts the container |
| Readiness probe | Gates traffic (Service endpoints); failure does not kill the pod |
| Liveness probe | Detects a wedged process; failure restarts the container |
| PDB | PodDisruptionBudget — bounds voluntary disruption (drains, scale-down) via minAvailable/maxUnavailable |
| GPU fragmentation | Free GPUs exist cluster-wide but not co-located on one node for a multi-GPU pod |
| Gang scheduling | All-or-nothing pod admission so a multi-pod job never partially starts and deadlocks |
| Kueue | Kubernetes SIG project for quota-aware, gang-scheduled batch/GPU job admission across teams |
| InferencePool / InferenceObjective | Gateway API Inference Extension resources for model-aware, priority-aware LLM traffic routing |
| llm-d | CNCF sandbox (Mar 2026) distributed inference stack: disaggregated prefill/decode, KV-cache-aware routing |
| Thundering herd (weights) | Many pods cold-starting simultaneously saturate shared storage/network pulling the same weights |
Bonus: Kueue quota for sharing GPUs between serving and batch
The landscape section above introduces Kueue conceptually; here is the minimal shape of the objects that implement “arbitrate GPU quota between interactive serving and best-effort batch” from the system-design sketch:
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
name: gpu-a100
spec:
nodeLabels:
nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
name: serving-cluster-queue
spec:
namespaceSelector: {}
resourceGroups:
- coveredResources: ["cpu", "memory", "nvidia.com/gpu"]
flavors:
- name: gpu-a100
resources:
- { name: cpu, nominalQuota: 64 }
- { name: memory, nominalQuota: 512Gi }
- { name: "nvidia.com/gpu", nominalQuota: 16 } # reserved baseline for serving
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
name: batch-eval-cluster-queue
spec:
namespaceSelector: {}
cohort: shared-gpu-cohort # can borrow idle quota from serving
resourceGroups:
- coveredResources: ["nvidia.com/gpu"]
flavors:
- name: gpu-a100
resources:
- { name: "nvidia.com/gpu", nominalQuota: 4, borrowingLimit: 12 }
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
name: eval-jobs
namespace: ml-eval
spec:
clusterQueue: batch-eval-cluster-queue
The serving-cluster-queue guarantees the interactive fleet its 16-GPU baseline; the batch-eval-cluster-queue shares the same cohort and can borrow up to 12 more GPUs when serving isn’t using its full quota, but never starves serving below its nominal reservation. This is the concrete mechanism behind bullet 4 of the system-design answer (“arbitrate GPU quota with Kueue”) and the fix in war-story Case 2’s “longer-term” remediation.
Saying it out loud. The concrete shape of GPU quota arbitration is three objects. A
ResourceFlavorthat identifies the hardware class by node label. AClusterQueuefor serving with a nominal quota — say sixteen GPUs — that’s a guaranteed baseline nobody can take. And a secondClusterQueuefor batch and eval in the same cohort, with a small nominal quota but a borrowing limit, so it can expand into serving’s idle capacity when serving isn’t using it, and gets pushed back out when serving needs it. That’s the whole idea: batch gets to use the expensive idle hardware without ever being able to starve the interactive fleet below its reservation. It’s the mechanism behind “arbitrate quota with Kueue” and the long-term fix for the fragmentation war story.
Further reading
- NVIDIA k8s device plugin (README, resource requests, time-slicing/MPS): https://github.com/NVIDIA/k8s-device-plugin
- NVIDIA GPU Operator (drivers, toolkit, NFD, MIG): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/index.html
- NVIDIA GPU Operator — time-slicing GPUs in Kubernetes: https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html
- Kubernetes — Configure Liveness, Readiness and Startup Probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Kubernetes — Probes concepts: https://kubernetes.io/docs/concepts/workloads/pods/probes/
- Kubernetes — Pod Disruption Budgets: https://kubernetes.io/docs/concepts/workloads/pods/disruptions/
- Kubernetes — Gateway API: https://gateway-api.sigs.k8s.io/
- Kubernetes — Schedule GPUs: https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/
- Kubernetes — Taints and Tolerations: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/
- Kubernetes — Access DRA Device Metadata (task guide): https://kubernetes.io/docs/tasks/configure-pod-container/assign-resources/access-dra-device-metadata/
- Kubernetes blog — v1.34: Dynamic Resource Allocation graduates to GA (Sept 1, 2025): https://kubernetes.io/blog/2025/09/01/kubernetes-v1-34-dra-updates/
- Kubernetes blog — Introducing Gateway API Inference Extension (Jun 5, 2025): https://kubernetes.io/blog/2025/06/05/introducing-gateway-api-inference-extension/
- Gateway API Inference Extension — docs: https://gateway-api-inference-extension.sigs.k8s.io/
- Gateway API Inference Extension — GitHub: https://github.com/kubernetes-sigs/gateway-api-inference-extension
- KServe documentation: https://kserve.github.io/website/
- KServe GitHub: https://github.com/kserve/kserve
- CNCF — Announcing KServe v0.15: Advancing Generative AI Model Serving (Jun 18, 2025): https://www.cncf.io/blog/2025/06/18/announcing-kserve-v0-15-advancing-generative-ai-model-serving/
- Red Hat Developer — How to set up KServe autoscaling for vLLM with KEDA (Sep 23, 2025): https://developers.redhat.com/articles/2025/09/23/how-set-kserve-autoscaling-vllm-keda
- Kueue — Kubernetes-native job queueing: https://kueue.sigs.k8s.io/
- Kueue — features overview: https://kueue.sigs.k8s.io/docs/overview/
- Kueue — ClusterQueue concept docs: https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/
- GKE — Deploy a batch system using Kueue: https://cloud.google.com/kubernetes-engine/docs/tutorials/kueue-intro
- GKE — Allocate existing devices to workloads with DRA: https://docs.cloud.google.com/kubernetes-engine/docs/how-to/deploy-dra-workloads
- llm-d — distributed inference serving stack (CNCF sandbox, Mar 2026): https://github.com/llm-d/llm-d
- Google Cloud blog — Multi-cluster GKE Inference Gateway helps scale AI workloads (Mar 2026): https://cloud.google.com/blog/products/containers-kubernetes/multi-cluster-gke-inference-gateway-helps-scale-ai-workloads
- NVIDIA NIM — Kubernetes deployment & KServe: https://docs.nvidia.com/nim/large-language-models/latest/deployment/kubernetes-deployment/kserve.html
- NVIDIA NIM Operator: https://docs.nvidia.com/nim-operator/latest/index.html
- NVIDIA NIM Operator — Dynamic Resource Allocation support: https://docs.nvidia.com/nim-operator/latest/dra.html
- Ray Serve LLM on Kubernetes (KubeRay): https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayserve-llm-example.html
- KubeAI (k8s-native LLM inference operator): https://github.com/substratusai/kubeai
- EKS — Manage NVIDIA GPU devices: https://docs.aws.amazon.com/eks/latest/userguide/device-management-nvidia.html
Next: autoscaling these deployments — HPA on custom/GPU metrics, KEDA, queue-depth scaling, and scale-to-zero — is covered in Autoscaling GPU Inference.
Topic 4: Load Testing & Latency Measurement
What You’ll Learn
This topic teaches you how to:
- Measure inference latency (P50, P95, P99)
- Test throughput (requests/second)
- Identify performance bottlenecks
- Use Locust for load testing
- Analyze performance metrics
Key Concepts
Latency Metrics
Latency = Time from request sent to response received
Common percentiles:
- P50 (Median): 50% of requests faster than this
- P95: 95% of requests faster than this
- P99: 99% of requests faster than this
Why P95/P99 matter: P50 might be 100ms, but P99 could be 5s. Users notice the slow requests!
Throughput
Throughput = Requests processed per second
- Single request: Measure latency
- Multiple requests: Measure throughput
- Trade-off: Higher throughput often means higher latency
Load Testing Types
- Baseline: Single request, measure latency
- Ramp-up: Gradually increase load
- Sustained: Constant load for extended period
- Spike: Sudden increase in load
- Stress: Keep increasing until system breaks
Tools
Locust
- Python-based load testing
- Write tests in Python
- Web UI for monitoring
- Real-time statistics
Alternatives
- Apache Bench (ab): Simple, command-line
- wrk: High-performance, Lua scripting
- k6: JavaScript-based, modern
Installation
pip install locust requests
Running Load Tests
Option 1: Basic Locust Test
cd 04_load_testing
locust -f locust_test.py --host=http://localhost:8000
Then open http://localhost:8089 in your browser.
Option 2: Headless Mode (No UI)
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 10 \
--spawn-rate 2 \
--run-time 60s
Option 3: Custom Python Script
python measure_latency.py
Understanding Results
Latency Distribution
P50: 150ms (median)
P95: 450ms (95% of requests faster)
P99: 800ms (99% of requests faster)
Max: 2000ms (worst case)
Throughput
Requests/sec: 25.3
Total requests: 1518
Failures: 2 (0.13%)
What to Look For
- High P99: System struggling under load
- Increasing latency: Resource exhaustion
- Failures: System overloaded or errors
- Low throughput: Bottleneck somewhere
Performance Bottlenecks
Common Issues
-
CPU-bound: Model too large for CPU
- Solution: Use GPU, smaller model, or quantization
-
Memory-bound: Out of memory
- Solution: Reduce batch size, use smaller model
-
I/O-bound: Slow tokenization or network
- Solution: Optimize tokenization, use faster network
-
GPU underutilized: Not batching efficiently
- Solution: Use continuous batching (vLLM)
Exercises
- Baseline Test: Measure single-request latency
- Ramp-up Test: Gradually increase from 1 to 50 users
- Compare Models: Test gpt2 vs distilgpt2 performance
- Find Bottleneck: Identify what limits throughput
- Stress Test: Find maximum capacity
Next Steps
- Topic 5: Use vLLM for better performance
- Topic 8: Set up monitoring dashboards
- Topic 6: Configure autoscaling based on load
Load Testing LLM Inference — Measuring Throughput and Latency Correctly Under Load
Why This Matters
You cannot capacity-plan, price, or SLA a serving system you have not load-tested. And LLM inference is unusually easy to benchmark wrong: a single request against an idle server tells you almost nothing about behavior at 200 concurrent users, because the whole point of a modern engine (vLLM, TGI, TensorRT-LLM) is continuous batching — throughput and latency both change as concurrency changes.
The stakes are concrete:
- Capacity planning. “How many GPUs do I need for 500 chat sessions?” is answerable only from a concurrency sweep.
- SLA definition. “p95 time-to-first-token under 500 ms” is meaningless without saying at what load.
- Cost. Output tokens/second/GPU is the number your finance model divides into. A 2× throughput win halves your bill.
- Regression gating. A benchmark you can rerun in CI catches the day someone flips
--enable-chunked-prefilloff.
A wrong benchmark is worse than none: it gives false confidence. Most of this chapter is about the ways benchmarks lie, and how to stop them.
Saying it out loud. You can’t capacity-plan, price, or write an SLA for a system you haven’t load-tested — and LLM inference is unusually easy to benchmark wrong. One request against an idle server tells you essentially nothing about behavior at 200 concurrent users, because the entire point of a modern engine is continuous batching, which means throughput and latency both change as concurrency changes. Concretely: “p95 time-to-first-token under 500 milliseconds” is a meaningless claim unless you say at what load. And output tokens per second per GPU is the number your finance model divides into, so a 2x throughput win literally halves the bill. The framing I’d use: a wrong benchmark is worse than no benchmark, because it buys you false confidence right up until launch day.
Core Intuition
Why average latency lies
Latency distributions in a batched system are heavy-tailed and multi-modal. A request that lands in an empty batch returns fast; an identical request that lands when the batch is full waits for a scheduler slot, then shares GPU compute with 63 neighbors. Same input, wildly different latency.
Average that distribution and you get a number that describes no actual request. Consider ten requests with latencies (ms):
90, 95, 100, 100, 105, 110, 110, 120, 130, 2000
The mean is 296 ms. Nine of ten users saw ≤130 ms; one saw 2 s. The mean reports a latency nobody experienced and hides the 2 s tail that will dominate your support tickets. The p90 is 130 ms and the p100 (max) is 2000 ms — those describe reality. Tail latency is where user pain, timeouts, and retry storms live, so you report percentiles, never just the mean.
Rule: the mean is for throughput accounting; percentiles are for latency SLAs.
Saying it out loud. Latency in a batched system is heavy-tailed and multi-modal — the same request is fast if it lands in an empty batch and slow if it lands behind sixty-three neighbors. So the mean describes no actual request. Take ten samples: nine between 90 and 130 milliseconds, one at two seconds. The mean is 296 milliseconds, which nobody experienced, and it completely buries the two-second tail that’s going to generate your support tickets. The p90 is 130 and the max is 2000 — those describe reality. The rule I’d give: the mean is for throughput accounting, percentiles are for latency SLAs. Tail latency is where timeouts, retries, and retry storms live, and none of them show up in an average.
Why concurrency defines the operating point
There is no single “latency” or “throughput” for a serving system — there is a curve parameterized by load. As you push more concurrent requests:
- Low load: GPU underutilized. Latency is flat and near-minimal. Throughput rises roughly linearly with concurrency.
- The knee: GPU compute (or KV-cache memory) saturates. Throughput flattens — you’ve hit the roofline. Latency starts climbing because requests now queue.
- Overload: Throughput is flat (or falls, from scheduling/paging overhead), but latency climbs without bound as the queue grows.
throughput (tok/s) latency p95 (ms)
| ____________ | /
| / | /
| / <- knee | _____/
| / | ____/
| / |___/
| / |
|/____________________ concurrency |________________ concurrency
The engineering goal is to find the knee and operate just below it: that’s where you get near-peak throughput while latency is still bounded. A benchmark that reports one concurrency level has told you one point on a two-dimensional curve. Always sweep.
Saying it out loud. There is no single latency or throughput number for a serving system — there’s a curve parameterized by load, and it has three regions. At low load the GPU is underutilized, latency is flat, and throughput rises roughly linearly with concurrency. Then you hit the knee, where compute or KV-cache memory saturates: throughput flattens out and latency starts climbing because requests are now queueing. Past that is overload, where throughput is flat or falling and latency climbs without bound. The engineering goal is to find the knee and operate just below it — near-peak throughput while latency is still bounded. Which means a benchmark that reports one concurrency level has told you exactly one point on a two-dimensional curve. Always sweep.
Metrics, Defined Precisely
Let a single streaming request produce output tokens at wall-clock times ( t_1 < t_2 < \dots < t_N ), with the request sent at ( t_0 ). Over a whole test, let ( R ) requests complete in wall-clock window ( T ) seconds, producing ( O ) total output tokens.
Saying it out loud. There are really six numbers and you should be able to define each precisely. Time to first token is when the cursor starts moving — prefill plus any queueing wait. Time per output token is the average gap between tokens after the first, which is your perceived typing speed. End-to-end is the sum of those, and it scales with output length, so it’s only comparable across runs whose output distributions match. Request throughput is completed requests per second; output-token throughput is generated tokens per second across everyone, and that’s the money metric. And every latency number gets reported at p50, p95, and p99, never as a mean. The subtlety worth naming: a “latency regression” is very often just longer outputs, not a slower system.
Time To First Token (TTFT)
[ \text{TTFT} = t_1 - t_0 ]
The latency until the first token appears. Dominated by prefill (processing the prompt) plus any queueing wait for a scheduler slot. This is what a user perceives as “responsiveness” — the cursor starting to move. TTFT is the metric most sensitive to load, because a queued request pays its wait entirely before ( t_1 ).
Micro-example: prompt sent at ( t_0 = 0 ), first token at ( t_1 = 0.18\text{ s} ) → TTFT = 180 ms.
Saying it out loud. TTFT is simply the time from sending the request to the first token arriving, and it’s what a user experiences as responsiveness — the cursor starting to move. Two things go into it: prefill, meaning the model processing the whole prompt in one compute-bound pass, plus however long the request sat in a queue waiting for a scheduler slot. That second term is why TTFT is the metric most sensitive to load — a queued request pays its entire wait before the first token ever appears, so TTFT p95 is usually the first thing to blow up as you approach saturation. Concretely, if you send at time zero and the first token lands at 180 milliseconds, that’s your TTFT, and under load that same request might see 2 seconds without anything about the model changing.
Time Per Output Token (TPOT) / Inter-Token Latency (ITL)
TPOT is the average gap between output tokens after the first, for one request:
[ \text{TPOT} = \frac{t_N - t_1}{N - 1} ]
ITL is the per-gap version — the distribution of individual ( t_{i+1} - t_i ) values. TPOT is the mean of a request’s ITLs. (Tools differ: vLLM reports both TPOT and ITL; some tools call TPOT “inter-token latency.” Know which your tool means.) TPOT is governed by the decode phase; ( 1/\text{TPOT} ) is the per-user tokens/second — the perceived “typing speed.”
Micro-example: a request emits 201 tokens, ( t_1 = 0.18 ), ( t_{201} = 4.18 ). TPOT ( = (4.18 - 0.18)/200 = 20\text{ ms} ) → each user sees ~50 tokens/s.
Saying it out loud. TPOT is the average gap between output tokens after the first — total generation time divided by the number of gaps — and one over TPOT is the per-user tokens per second, the perceived typing speed. ITL is the same thing but as a distribution rather than an average, so TPOT is really the mean of a request’s ITLs. Where TTFT is governed by prefill, TPOT is governed by decode, which is the memory-bandwidth-bound phase. Concretely: a request emitting 201 tokens with a 20-millisecond TPOT gives the user about 50 tokens per second. One practical warning — tools disagree on naming, and some call TPOT “inter-token latency,” so check which your tool means before comparing numbers across tools.
End-to-End (E2E) Request Latency
[ \text{E2E} = t_N - t_0 = \text{TTFT} + (N-1)\cdot\text{TPOT} ]
Total time from send to last token. This is the number that scales with output length, so it is only comparable across runs if output-length distributions match. A “latency regression” is often just longer outputs.
Saying it out loud. End-to-end is just TTFT plus the decode time for the rest of the tokens — send to last token. The critical property is that it scales directly with output length, which makes it the most commonly misread metric in the whole chapter. If your E2E p95 jumps 40% between two runs, the first thing to check isn’t the server, it’s whether the output-length distribution changed, because a “latency regression” is very often just longer answers. That’s why you either hold the output distribution fixed across runs, or you report normalized latency — E2E divided by token count — so length divides out and the runs become comparable.
Normalized latency (per-token E2E)
[ \text{normalized latency} = \frac{\text{E2E}}{N} ]
Divides out output length, making runs with different output distributions comparable. vLLM’s benchmark historically reported this.
Request throughput
[ \lambda_{\text{out}} = \frac{R}{T} \quad [\text{req/s}] ]
Completed requests per second across the whole run. The right top-line number when your unit of work is “a request” (e.g., classification).
Output-token throughput
[ X = \frac{O}{T} \quad [\text{tok/s}] ]
Generated tokens per second across all concurrent requests. This is the money metric for generative workloads and the one that goes up with better batching. Report output tokens (excludes the prompt); also report total token throughput (prompt + output) if prefill cost matters to you. Divide by GPU count for tok/s/GPU.
Micro-example: 64 concurrent requests each streaming at 50 tok/s → aggregate ( X \approx 3200 ) tok/s, even though each user still sees only 50 tok/s. Per-user speed and aggregate throughput are different axes.
Saying it out loud. Output-token throughput is total generated tokens divided by wall-clock time, aggregated across every concurrent request — and for generative workloads it’s the money metric, because it’s what improves when batching improves and it’s what your cost per token divides into. The distinction that trips people up: per-user speed and aggregate throughput are different axes entirely. Sixty-four concurrent requests each streaming at a modest 50 tokens per second is 3,200 tokens per second aggregate, even though no individual user sees anything faster than 50. Report output tokens separately from prompt tokens, since prefill and decode cost differently, and divide by GPU count so you get tokens per second per GPU — that’s the number that actually compares across hardware.
Percentiles
For a metric with sorted samples, the p-th percentile ( P_p ) is the smallest value ( \geq p% ) of samples:
[ P_p = \text{value at rank } \lceil \tfrac{p}{100} \cdot n \rceil \text{ in sorted order} ]
Report p50 (median), p95, p99 for TTFT, TPOT, and E2E. p99 matters more than it looks: if a page makes 10 backend calls, the chance all 10 beat p99 is ( 0.99^{10} \approx 0.90 ) — so ~10% of page loads hit a p99 tail. Tail latency compounds.
A practical note on computing these from a live stream of samples rather than a saved-then-sorted list: naive percentile computation needs the full sorted sample set in memory, which is fine for a single sweep level (thousands of samples) but awkward for a long-running soak test. If you need percentiles over an unbounded stream, use a streaming quantile sketch (e.g. t-digest or HDRHistogram) instead of re-sorting on every report — both are available as Python/Go/JS libraries and are what k6 and Gatling use internally to report percentiles without buffering every sample.
Saying it out loud. Report p50, p95, and p99 for TTFT, TPOT, and end-to-end separately — and p99 matters more than people intuit. Here’s why: if a single page makes ten backend calls, the probability all ten beat p99 is 0.99 to the tenth, about 90% — so roughly one in ten page loads hits a p99 tail even though only one in a hundred requests does. Tail latency compounds with fan-out. One practical note for long soak tests: computing percentiles naively needs the whole sorted sample set in memory, which is fine for a sweep point but awkward over hours, so use a streaming quantile sketch like t-digest or HDRHistogram — that’s what k6 and Gatling do internally.
Open-Loop vs Closed-Loop Load Generation
This is the single most important methodology decision, and the one most benchmarks get wrong.
Saying it out loud. This is the single most important methodology decision, and it’s the one most benchmarks get wrong. Closed-loop means a fixed pool of virtual users, each of which sends a request, waits for the full response, then sends the next — so concurrency is capped by construction. Open-loop means requests fire on an arrival schedule, usually Poisson, completely independent of whether earlier requests have finished, so in-flight concurrency is an emergent property free to grow when the server slows down. Real user traffic is open-loop: people arrive whether or not you’re keeping up. That difference isn’t academic — it’s the entire reason a green pre-launch test can be followed by a red launch day.
Closed-loop
A fixed pool of ( C ) “virtual users.” Each sends a request, waits for the full response, then immediately sends the next. Concurrency is capped at ( C ) by construction. This models a fixed number of clients in a tight loop (e.g., a batch job, or exactly ( C ) synchronous callers).
Open-loop
Requests are launched on an arrival schedule (e.g., Poisson at rate ( \lambda )) independent of whether prior requests have finished. In-flight concurrency is an emergent property, free to grow if the server slows down. This models real traffic: users arrive whether or not your server is keeping up.
Coordinated omission — why closed-loop hides overload
Here is the trap. In a closed-loop test, when the server slows down, each virtual user’s loop stalls waiting for its response — so it stops sending new requests. The offered load automatically backs off exactly when the system is struggling. The load generator “coordinates” with the server’s slowness and omits the requests a real (open) world would have kept sending.
Consequences:
- Your measured request rate silently drops below target, and you may not notice.
- The latency samples you do collect exclude the requests that would have queued behind a slow one, so tail latency is dramatically underreported. The one 2-second stall in a real system would have delayed 40 requests behind it; closed-loop just… didn’t send them.
- You conclude the system is healthy at a load it actually cannot sustain.
The fix in an open-loop tool is to schedule requests on absolute wall-clock times and measure each request’s latency from its intended send time, not from when a freed-up worker got around to it. k6’s constant-arrival-rate/ramping-arrival-rate executors and Gatling’s open model do this; constant-vus/ramping-vus are closed. Corrected closed-loop tools (wrk2, Gatling) reconstruct the omitted samples by back-dating latency to the scheduled time.
Saying it out loud. Here’s the trap, and it’s genuinely subtle. In a closed-loop test, when the server slows down, each virtual user stalls waiting for its response — which means it stops sending. So the offered load automatically backs off at exactly the moment the system is struggling. The generator has “coordinated” with the server’s slowness and omitted the requests a real world would have kept sending. Two consequences: your measured rate silently drops below target, and your latency samples exclude everything that would have queued behind the slow request — so tail latency is dramatically underreported. One two-second stall in production delays forty requests behind it; closed-loop just never sent them. The fix is to schedule on absolute wall-clock times and measure latency from intended send time.
When each is right
- Open-loop / fixed request-rate: validating an SLA against realistic traffic (“can we hold p95 TTFT < 500 ms at 30 req/s?”). This is the honest default for user-facing services.
- Closed-loop / fixed concurrency: finding maximum sustainable throughput and the saturation curve, where a bounded, known concurrency is exactly the independent variable you want to sweep. LLMPerf and GenAI-Perf’s concurrency mode work this way, and that is fine because you are deliberately measuring the throughput ceiling, not pretending to reproduce arrival traffic.
Use both: a concurrency sweep to find the knee, then an open-loop test at your chosen arrival rate to confirm the SLA holds with realistic tail behavior.
Saying it out loud. Both models are legitimate, they just answer different questions. Open-loop at a fixed arrival rate is what you use to validate an SLA against realistic traffic — “can we hold p95 TTFT under 500 milliseconds at 30 requests per second” — and that’s the honest default for anything user-facing. Closed-loop at fixed concurrency is what you use to find maximum sustainable throughput, because there a bounded known concurrency is exactly the independent variable you want to sweep. That’s why LLMPerf and GenAI-Perf’s concurrency mode are fine — they’re deliberately measuring a ceiling, not pretending to reproduce arrival traffic. The practice: sweep closed-loop to find the knee, then run open-loop at your chosen rate to confirm the tail behavior holds.
Little’s Law — Reasoning About Concurrency
Little’s Law relates the three quantities you care about, for any stable system in steady state:
[ L = \lambda , W ]
- ( L ) = average number of requests in the system (concurrency / in-flight).
- ( \lambda ) = average arrival = completion rate (req/s), in steady state.
- ( W ) = average time in system (E2E latency, seconds).
It is an identity — no assumptions about distributions. Uses:
Sanity-check a benchmark. If your open-loop test offers ( \lambda = 20 ) req/s and you measure mean E2E ( W = 4 ) s, then average in-flight concurrency is ( L = 20 \times 4 = 80 ). If your engine’s --max-num-seqs is 64, you are oversubscribed: requests are queuing, latency will keep rising, and the system is not in steady state. The law just told you the offered load exceeds capacity before you stare at a climbing latency graph.
Convert closed-loop to a rate. A closed-loop test with ( C = 100 ) virtual users measuring ( W = 2.5 ) s achieves ( \lambda = L/W = 100/2.5 = 40 ) req/s. That’s how you translate a concurrency-sweep point into “requests per second this config sustains.”
Token form. Apply it to tokens: aggregate output throughput ( X ) (tok/s) with ( n ) requests in flight each of length ( N ) tokens taking ( W ) seconds gives ( X = nN/W ) — decompose regressions into “fewer concurrent” vs “slower per request.”
Caveat: Little’s Law holds in steady state. During warmup, ramp, or overload it does not, which is one more reason to discard warmup and to hold each sweep point long enough to stabilize.
Saying it out loud. Little’s Law says the average number of requests in the system equals arrival rate times average time in system — L equals lambda W — and it’s an identity, no distributional assumptions at all. Two uses make it worth memorizing. Sanity-checking: if you offer 20 requests per second and measure a mean end-to-end of 4 seconds, average in-flight concurrency is 80. If your engine’s
--max-num-seqsis 64, you’re oversubscribed and the system isn’t in steady state — the law told you that before you stared at a climbing graph. And converting: a closed-loop test with 100 virtual users at 2.5 seconds mean latency is sustaining 40 requests per second. The caveat: it only holds in steady state, so discard warmup and hold each sweep point long enough to stabilize.
A Fully Worked Example — Async Open-Loop Client
Below is a self-contained async Python client that drives an OpenAI-compatible streaming endpoint (vLLM, TGI, etc.), generates a Poisson arrival schedule (true open-loop — arrivals do not wait for completions), records TTFT/TPOT/E2E per request from the intended send time (coordinated-omission-safe), discards warmup, and prints percentiles and throughput.
#!/usr/bin/env python3
"""Open-loop load test for an OpenAI-compatible LLM endpoint.
Launches requests on a Poisson schedule at a target rate, independent of
whether prior requests finished, and measures latency from the INTENDED
send time to avoid coordinated omission.
Usage:
python load_test.py --url http://localhost:8000/v1/chat/completions \
--model my-model --rate 20 --duration 60 --warmup 10
"""
import argparse, asyncio, json, random, statistics, time
import aiohttp
PROMPTS = [
"Explain how continuous batching improves LLM throughput.",
"Write a haiku about GPU memory fragmentation.",
"Summarize the tradeoffs of speculative decoding in three sentences.",
"What is time-to-first-token and why does it depend on load?",
]
async def one_request(session, url, model, prompt, max_tokens, sched_t, t0, results):
"""Fire one request. Latency is measured from sched_t (intended send),
NOT from now — this is the coordinated-omission fix."""
# Sleep until this request's scheduled arrival time (open-loop).
delay = sched_t - (time.perf_counter() - t0)
if delay > 0:
await asyncio.sleep(delay)
intended = t0 + sched_t # absolute intended send time
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"stream": True,
"temperature": 0.0,
}
token_times, first_t, err = [], None, None
send = time.perf_counter()
try:
async with session.post(url, json=payload) as resp:
async for raw in resp.content:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"].get("content")
if delta:
now = time.perf_counter()
if first_t is None:
first_t = now
token_times.append(now)
except Exception as e: # noqa: BLE001
err = repr(e)
end = time.perf_counter()
n = len(token_times)
results.append({
"intended": intended,
"ttft": (first_t - send) if first_t else None,
# E2E measured from INTENDED time, not send — captures scheduling debt.
"e2e": end - send, # from actual send time
"e2e_true": end - (t0 + sched_t), # from intended arrival (CO-safe)
"n_tokens": n,
"tpot": ((token_times[-1] - first_t) / (n - 1)) if n > 1 else None,
"error": err,
})
def pct(xs, p):
if not xs:
return float("nan")
xs = sorted(xs)
k = max(0, min(len(xs) - 1, int(round(p / 100 * len(xs) + 0.5)) - 1))
return xs[k]
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--rate", type=float, default=10.0, help="req/s (Poisson mean)")
ap.add_argument("--duration", type=float, default=60.0, help="seconds")
ap.add_argument("--warmup", type=float, default=10.0, help="seconds to discard")
ap.add_argument("--max-tokens", type=int, default=200)
args = ap.parse_args()
# Pre-build a Poisson arrival schedule: gaps ~ Exponential(rate).
schedule, t = [], 0.0
while t < args.duration:
t += random.expovariate(args.rate)
schedule.append(t)
results = []
conn = aiohttp.TCPConnector(limit=0) # no client-side cap!
timeout = aiohttp.ClientTimeout(total=None)
async with aiohttp.ClientSession(connector=conn, timeout=timeout) as session:
t0 = time.perf_counter()
tasks = [
asyncio.create_task(one_request(
session, args.url, args.model,
random.choice(PROMPTS), args.max_tokens, s, t0, results))
for s in schedule
]
await asyncio.gather(*tasks)
wall = time.perf_counter() - t0
# Drop warmup window and errored requests.
ok = [r for r in results if r["error"] is None
and r["intended"] - t0 >= args.warmup]
measured_window = wall - args.warmup
ttfts = [r["ttft"] * 1000 for r in ok if r["ttft"]]
tpots = [r["tpot"] * 1000 for r in ok if r["tpot"]]
e2es = [r["e2e_true"] * 1000 for r in ok]
out_tokens = sum(r["n_tokens"] for r in ok)
print(f"\n=== target rate {args.rate} req/s | wall {wall:.1f}s | "
f"measured window {measured_window:.1f}s ===")
print(f"requests ok: {len(ok)} errors: {sum(1 for r in results if r['error'])}")
print(f"achieved req/s: {len(ok)/measured_window:8.2f}")
print(f"output tok/s: {out_tokens/measured_window:8.1f}")
for name, xs in (("TTFT ms", ttfts), ("TPOT ms", tpots), ("E2E ms", e2es)):
if xs:
print(f"{name:9s} p50 {pct(xs,50):8.1f} p95 {pct(xs,95):8.1f} "
f"p99 {pct(xs,99):8.1f} mean {statistics.mean(xs):8.1f}")
if __name__ == "__main__":
asyncio.run(main())
Two design points that matter:
TCPConnector(limit=0)removes the client’s own connection cap. If you leave aiohttp’s default (100) or run one CPU core hot parsing SSE, the client becomes the bottleneck and you benchmark your laptop, not the server. (See pitfalls.)e2e_truemeasures from the intended arrival time (t0 + sched_t), so a request delayed because the event loop was busy still counts its full latency — coordinated-omission-safe. (Use thee2e_truefield,end - (t0 + sched_t), for reporting; the plaine2efrom actual send time will under-report latency when the event loop falls behind.)
Saying it out loud. A correct load-testing client has five properties, and it’s worth being able to list them. It generates a Poisson arrival schedule so arrivals don’t wait on completions — that’s what makes it genuinely open-loop. It records TTFT, TPOT, and end-to-end per request measured from the intended send time, not from when a free worker got around to it, which is what makes it coordinated-omission-safe. It discards a warmup window, because cold TTFT can be five to fifty times steady-state and a handful of those samples will wreck your p99. It reports percentiles rather than means. And it parses SSE streaming properly, because you cannot measure time-to-first-token from a non-streaming response at all.
Sample results — a concurrency/rate sweep
Run the client at increasing rates against one A100 serving an 8B model, fixed input ≈512 / output ≈200 tokens:
| Target rate (req/s) | Achieved req/s | Output tok/s | TTFT p50 (ms) | TTFT p95 (ms) | TPOT p50 (ms) | E2E p95 (ms) | Avg in-flight (L=\lambda W) |
|---|---|---|---|---|---|---|---|
| 5 | 5.0 | 1000 | 42 | 70 | 15 | 3200 | 16 |
| 10 | 10.0 | 2000 | 55 | 95 | 17 | 3600 | 36 |
| 20 | 20.0 | 4000 | 88 | 210 | 21 | 4400 | 88 |
| 30 | 29.8 | 5900 | 180 | 620 | 28 | 6800 | 200 |
| 40 | 33.1 | 6600 | 540 | 2400 | 41 | 14200 | 470 |
| 50 | 32.9 | 6600 | 1900 | 9000 | 63 | 41000 | 1350 |
Saying it out loud. A sweep table is worth reading out loud once, because the pattern is the whole lesson. On one A100 serving an 8B model: at 5 through 20 requests per second, achieved rate tracks target and output throughput scales roughly linearly from 1,000 to 4,000 tokens per second, with TTFT p95 staying under a quarter second. At 30 you’re at the knee — 5,900 tokens per second, still hitting target, but TTFT p95 has jumped to 620 milliseconds. Then at 40 and 50 the achieved rate flatlines at about 33 per second no matter what you offer, while TTFT p95 goes from 2.4 seconds to 9. Offering 50 doesn’t get you 50; it just grows the queue. That flatline is your true saturation throughput.
Reading the saturation curve
- 5 → 20 req/s: achieved rate tracks target, output tok/s scales ~linearly (1000→4000), TTFT p95 stays modest. Underloaded region — GPU has headroom.
- ~30 req/s is the knee. Output tok/s (5900) is close to the ceiling; achieved rate still ≈ target but TTFT p95 has jumped to 620 ms. This is the operating point you’d target for a latency-sensitive service, maybe backing off to ~25 for headroom.
- 40 → 50 req/s: overload. Achieved rate flatlines at ~33 req/s even as you offer more — that ~33 req/s (≈6600 tok/s) is the true saturation throughput. Meanwhile latency explodes (TTFT p95 2.4 s → 9 s; E2E p95 41 s) and Little’s-Law in-flight ( L ) blows past any sane
max-num-seqs. Offering 50 doesn’t get you 50; it just grows the queue.
The signature of the knee: throughput stops rising while latency starts rising superlinearly. Peak throughput and acceptable latency are different points — publish both, and state which you’re operating at.
Saying it out loud. The signature of the knee is one sentence: throughput stops rising while latency starts rising superlinearly. Below it, achieved rate tracks the target and tokens per second scale roughly with load. At it, throughput is near ceiling but the tail has begun to move. Past it, achieved rate flatlines no matter how much more you offer, and the extra load just grows the queue — Little’s Law will show in-flight concurrency blowing well past any sane
max-num-seqs. The thing to actually say in a review: peak throughput and acceptable latency are different operating points, so publish both numbers and state clearly which one you’re running at. Most teams quote the peak and operate at it, which is exactly how you end up with no headroom for a traffic spike.
A Second Worked Example — Locust (open-model)
Locust is convenient for HTTP services and dashboards. Locust is closed-loop by default (each user loops), but the constant_throughput/constant_pacing shape plus a high user count approximates open arrivals. Here is a locustfile.py that measures streaming TTFT and records it as a custom metric:
# locustfile.py — run: locust -f locustfile.py --host http://localhost:8000
import json, time
from locust import HttpUser, task, constant_throughput
class LLMUser(HttpUser):
# Each user targets 1 req/s; scale arrivals via -u (number of users).
# constant_throughput paces to a rate rather than back-to-back looping,
# which is closer to open-loop than the default.
wait_time = constant_throughput(1.0)
@task
def chat(self):
payload = {
"model": "my-model",
"messages": [{"role": "user", "content": "Explain KV cache paging."}],
"max_tokens": 200, "stream": True, "temperature": 0.0,
}
start = time.perf_counter()
first_t = None
n_tokens = 0
with self.client.post("/v1/chat/completions", json=payload,
stream=True, catch_response=True,
name="chat-stream") as resp:
for raw in resp.iter_lines():
if not raw:
continue
line = raw.decode("utf-8")
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
delta = json.loads(data)["choices"][0]["delta"].get("content")
if delta:
if first_t is None:
first_t = time.perf_counter()
n_tokens += 1
# Report TTFT as a named event so it shows in Locust stats/percentiles.
if first_t is not None:
ttft_ms = (first_t - start) * 1000
self.environment.events.request.fire(
request_type="METRIC", name="TTFT_ms",
response_time=ttft_ms, response_length=n_tokens,
exception=None, context={})
Locust’s own percentile table then gives you p50/p95/p99 for both the full request and the synthetic TTFT_ms event. Drive concurrency with -u <users> and -r <ramp>; use the web UI’s charts to watch the knee live. Caveat: Locust workers are Python and can become the bottleneck — run distributed workers (--worker) and confirm client CPU isn’t saturated before trusting numbers at high load.
Saying it out loud. Locust is worth knowing because it’s convenient — a real dashboard, easy custom flows, arbitrary Python task logic — but there’s a catch you have to state upfront: it’s closed-loop by default, since every user loops. You can approximate open arrivals with
constant_throughputpacing plus a high user count, but that’s an approximation, not an arrival-rate executor. And measuring TTFT requires custom code, because you have to parse the SSE stream yourself and record the first chunk’s timestamp as a custom metric — the built-in timings only know about the whole response. So: Locust for bespoke multi-step traffic where you want a dashboard, k6 when the honesty of the load model is the point.
Tools Comparison
| Tool | Load model | Metrics reported | Endpoints | Best for | Watch out |
|---|---|---|---|---|---|
vLLM benchmark_serving.py / vllm bench serve | Open-loop via --request-rate (inf = burst all --num-prompts at once); Poisson/--burstiness | Request throughput (req/s), Output token throughput (tok/s), Total token throughput, Mean/Median/P99 TTFT, TPOT, ITL, E2E | OpenAI-compatible + native vLLM/TGI backends | Purpose-built LLM serving benchmarks; realistic datasets (--dataset-name sharegpt/random/sonnet) | Ships with vLLM version; --request-rate inf is a burst, not steady rate |
| LLMPerf (Ray) | Closed-loop, --num-concurrent-requests | TTFT, inter-token latency, E2E, output throughput per-request and aggregate | Many providers (OpenAI, Anthropic, Together, Bedrock, Vertex, SageMaker, vLLM) | Cross-provider apples-to-apples; the LLMPerf leaderboard | Concurrency mode = throughput ceiling, not arrival-rate SLA; token counts are provider-tokenized approximations |
| NVIDIA GenAI-Perf (Triton Perf Analyzer) | Both: --concurrency (closed) or --request-rate (open) | TTFT, inter-token latency, output token throughput, request throughput, seq lengths, all with avg/p90/p99 | OpenAI-compatible, Triton (TRT-LLM, vLLM), gRPC/HTTP | Deep NVIDIA/Triton stacks; synthetic + custom datasets; rich exports | Heavier setup; Triton-centric defaults |
| Locust | Closed-loop by default; constant_throughput ≈ open | Whatever you instrument; built-in RPS + percentile table + web UI | Any HTTP (write Python tasks) | Custom flows, quick dashboards, mixed traffic | Python workers can be the bottleneck; streaming/TTFT needs custom code |
| k6 | Open-loop (constant/ramping-arrival-rate) or closed (*-vus) | RPS, latency percentiles, custom Trends | Any HTTP/gRPC (JS scripts) | Honest open-loop SLA tests, CI gating | Go runtime doesn’t tokenize; TTFT needs manual SSE parsing/custom metrics |
| NVIDIA AIPerf (successor to GenAI-Perf, 2026) | Both, plus native session/conversation replay (--conversation-num, --session-concurrency) | Everything GenAI-Perf reports, plus per-session turn/context growth stats | OpenAI-compatible, Triton (TRT-LLM, vLLM) | Multi-turn/agentic benchmarking on an NVIDIA-centric stack | Newer tool; flags and docs are still migrating off the GenAI-Perf names, check the migration guide |
Rule of thumb: benchmark_serving.py/GenAI-Perf when you want LLM-native metrics and datasets out of the box; k6 when you want a rigorous open-loop SLA test; LLMPerf for cross-provider comparisons; Locust for bespoke multi-step traffic with a dashboard; AIPerf when the workload itself is multi-turn or agentic rather than independent requests.
None of these five rows model output length as anything other than a knob you set — a fixed number, a random range. That was a reasonable simplification when most production traffic really was short-and-bounded. It stopped being reasonable once reasoning models and agent frameworks became common, which is exactly the gap the next section covers.
The table above is the 2023–2024 baseline. Tooling has moved fast since — see The 2025–2026 Landscape below for how multi-turn/agentic replay and reasoning-model output-length variability changed what “a good load test” even means.
Saying it out loud. My rule of thumb across the tools: reach for
vllm bench servewhen you want LLM-native metrics and realistic datasets out of the box with no glue code. Reach for k6 when you need a rigorous open-loop SLA test, especially as a CI gate, because its arrival-rate executors don’t suffer coordinated omission. Use LLMPerf for cross-provider comparisons — same client against OpenAI, Anthropic, and your own endpoint — but treat its numbers as a throughput ceiling, not an SLA, because it’s closed-loop by design. Locust for bespoke multi-step flows with a dashboard. And AIPerf when the workload is genuinely multi-turn or agentic. The limitation shared by all of them: none models output length as anything richer than a knob you set, which is precisely where reasoning models break them.
The 2025–2026 Landscape
Everything above is timeless methodology. The tooling that implements it has moved fast, and — more importantly — the workloads people benchmark have changed shape: fewer isolated single-turn chat completions, more multi-turn agent sessions and long, unpredictable reasoning traces. A benchmark built for 2023’s “one prompt in, one answer out” chatbot systematically misleads you about 2026’s agent that calls tools across 40 turns while thinking for 3,000 tokens before answering. This section covers what changed and why it matters for the numbers you report.
Saying it out loud. The methodology in this chapter is timeless; the workloads people benchmark are not. The big shift is that the unit of load moved from the request to the session. A benchmark built for 2023’s one-prompt-in-one-answer-out chatbot systematically misleads you about a 2026 agent that calls tools across forty turns and thinks for three thousand tokens before answering. Two forces drove it: reasoning models, whose output length varies with problem difficulty in ways you can’t know in advance, and agentic workloads, where the vast majority of input tokens are reused across turns via prefix caching. The practical consequence is concrete — a single-turn benchmark against an agentic deployment overstates TTFT and understates achievable concurrency, because it never creates the prefix-reuse conditions real traffic does.
vLLM: from a script to a CLI, plus native multi-turn replay
benchmark_serving.py has grown into a first-class subcommand, vllm bench serve (the standalone script still exists and both are maintained). Its dataset support has broadened well past sharegpt/random/sonnet: as of the current vllm/benchmarks/serve.py, --dataset-name accepts random, random-mm (multi-modal), random-rerank, prefix_repetition (purpose-built to stress prefix-cache reuse), sharegpt, custom, sonnet, hf (any HuggingFace dataset), spec_bench, and timed_trace (replay requests at recorded timestamps — the building block for realistic session replay). Traffic shaping is also richer than a flat Poisson process: --burstiness scales a gamma-distributed arrival process (1.0 = pure Poisson, <1 = burstier, >1 = smoother), and --ramp-up-strategy {linear,exponential} with --ramp-up-start-rps/--ramp-up-end-rps lets a single run sweep from idle to saturation instead of requiring one process per rate — directly automating the sweep this chapter has been doing by hand. (Source: https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/serve.py; docs: https://docs.vllm.ai/en/latest/cli/bench/serve/.)
More significant: vLLM has merged a dedicated multi-turn benchmark, benchmarks/multi_turn/benchmark_serving_multi_turn.py, tracked under RFC #20265 and shipped via a PR from Pliops (pliops-daniels), originally built to benchmark KV-cache offloading under realistic multi-turn conversation replay — i.e., does the engine actually reuse a session’s prior-turn KV cache instead of recomputing it, and what does that do to TTFT as conversations get longer. (PR: https://github.com/vllm-project/vllm/pull/20267; write-up: Pliops, “Setting the Standard: Multi-Turn Benchmarking in vLLM,” https://pliops.com/setting-the-standard-multi-turn-benchmarking-in-vllm/.) The practical shift: a session is now a first-class unit of load, not an afterthought bolted onto a single-turn tool.
Saying it out loud. vLLM’s benchmark grew up. It’s a first-class subcommand now,
vllm bench serve, and the dataset support is much broader — not just ShareGPT and random, butprefix_repetitionbuilt specifically to stress prefix-cache reuse,hffor any Hugging Face dataset, andtimed_traceto replay requests at their recorded timestamps. Traffic shaping got richer too:--burstinessscales a gamma arrival process, where 1.0 is pure Poisson and below 1 is burstier, and--ramp-up-strategylets one run sweep from idle to saturation instead of needing a process per rate — which automates exactly the sweep this chapter does by hand. The bigger deal is the dedicated multi-turn benchmark, built originally to measure whether the engine actually reuses a session’s prior-turn KV cache instead of recomputing it.
NVIDIA GenAI-Perf → AIPerf: sessions and turns as native concepts
NVIDIA’s GenAI-Perf (built on the Triton Perf Analyzer) added explicit multi-turn session modeling: --num-sessions and --session-concurrency control how many independent conversations run and how many run in parallel; --session-turns-mean/--session-turns-stddev control how long each conversation is; --session-turn-delay-mean/--session-turn-delay-stddev (plus --session-delay-ratio to rescale an imported trace) model human “think time” between turns instead of firing the next turn the instant the last one finishes. (Docs: https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/multi_turn.html.)
NVIDIA has since folded this into a successor tool, AIPerf (documented at v0.8.0 as of 2026), which the docs describe as superseding GenAI-Perf, with a migration guide and a “GenAI-Perf vs AIPerf CLI feature comparison matrix” for teams porting scripts. AIPerf’s multi-turn flags are renamed but conceptually identical — --conversation-num, --conversation-turn-mean/--conversation-turn-stddev, --conversation-turn-delay-mean/--conversation-turn-delay-stddev — with the docs explicit that --request-count is for single-turn benchmarking and --conversation-num is for multi-turn. (Docs: https://docs.nvidia.com/aiperf/tutorials/datasets-inputs/multi-turn-conversations and https://docs.nvidia.com/aiperf/getting-started/ai-perf-comprehensive-llm-benchmarking.) If you last touched this tool as “GenAI-Perf,” expect the name (and some flags) to have moved.
Saying it out loud. NVIDIA’s tool made sessions a first-class concept rather than something you bolt on. You control how many independent conversations run and how many run in parallel, how many turns each conversation has as a mean and standard deviation, and — the one people forget — the delay between turns, modeling human think time instead of firing the next turn the instant the last one finishes. That delay matters because it changes whether a session’s KV cache is still resident when the next turn arrives, which is the whole question you’re trying to answer. Practical note: GenAI-Perf has been superseded by AIPerf, and the flags were renamed —
--num-sessionsbecame--conversation-numand so on — so if you last touched this as GenAI-Perf, expect to consult the migration guide.
LLMPerf: still the cross-provider workhorse, still concurrency-based
LLMPerf (Ray) hasn’t fundamentally changed its load model — it remains a closed-loop, --num-concurrent-requests tool, which is exactly why it’s still the default for cross-provider leaderboard-style comparisons (OpenAI vs Anthropic vs a self-hosted vLLM endpoint, apples-to-apples). Its output-length handling is still fixed-max_tokens-per-request rather than a distribution sampled from data, which is the main reason it under-represents reasoning-model workloads well: a reasoning model’s actual output length depends on problem difficulty in a way a flat max_tokens target doesn’t capture (see below). Treat LLMPerf numbers as “throughput ceiling at concurrency C for this fixed output length,” not as an SLA-representative measurement. (https://github.com/ray-project/llmperf)
Saying it out loud. LLMPerf hasn’t fundamentally changed and that’s part of why it’s still useful: it’s closed-loop on a concurrency knob, which makes it a genuinely apples-to-apples way to compare OpenAI against Anthropic against your own vLLM endpoint with one client. But you have to interpret it correctly. It sets a fixed
max_tokensper request rather than sampling from a distribution, which under-represents reasoning-model workloads badly, because a reasoning model’s actual output length depends on problem difficulty in a way a flat cap simply cannot capture. So read LLMPerf numbers as “throughput ceiling at concurrency C for this fixed output length” — never quote them as an SLA-representative latency measurement.
Reasoning models broke the “fixed output length” assumption
Chain-of-thought reasoning models (the o-series-style and DeepSeek-R1-style models that think before answering) don’t just produce longer outputs — they produce output lengths that vary with problem difficulty in a way you cannot know in advance. Li et al.’s 2025 empirical study of reasoning-LLM serving found KV-cache utilization swinging from 3% to 70% within the same batch (versus a steady sub-3% for standard LLMs), and identified a straggler-request problem: a batch containing one hard problem that reasons for thousands of tokens has its entire completion time set by that one request, dragging down every easy request batched alongside it. Their evaluation deliberately moves off idealized fixed-batch benchmarking to a Gamma-distributed open-arrival workload, precisely because batch-shaped synthetic tests hide this behavior. (Li et al., “Reasoning Language Model Inference Serving: An Empirical Study,” arXiv:2510.18672, 2025: https://arxiv.org/abs/2510.18672.)
NVIDIA’s 2026 infrastructure-economics analysis of long chain-of-thought models makes the cost implication concrete: because a reasoning model can burn hundreds to thousands of intermediate tokens before its final answer, cost per token becomes the dominant economic variable, and disaggregated prefill/decode serving (NVIDIA Dynamo) plus speculative decoding (reported tripling throughput to ~30,000 tok/s on some models) are presented as the mitigations. (NVIDIA Perspectives, “Infrastructure Economics: Reasoning Models & Chain-of-Thought,” updated Apr 13, 2026: https://perspectives.nvidia.com/infrastructure-economics-reasoning-models-chain-of-thought.)
What this means for your load test: stop assuming a single max_tokens/mean-output-length number describes your workload. Sample output length from a distribution correlated with a proxy for difficulty (or, at minimum, a heavy-tailed/bimodal distribution — short direct answers mixed with long reasoning traces), track KV-cache occupancy as a time series rather than a single steady-state number, and watch batch-level p99 for straggler contamination, not just per-request percentiles. Section B below gives runnable code for this.
Saying it out loud. Chain-of-thought reasoning models don’t just produce longer outputs, they produce output lengths that vary with problem difficulty in ways you can’t predict — and that breaks a load-testing assumption everyone was quietly relying on. A 2025 empirical study found KV-cache utilization swinging from 3% to 70% within the same batch, versus a steady sub-3% for standard models. That produces a straggler-request problem: a batch containing one hard problem that reasons for thousands of tokens has its whole completion time set by that one request, dragging down every easy request beside it. So stop using a single
max_tokensnumber. Sample output length from a heavy-tailed or bimodal distribution, track KV-cache occupancy as a time series rather than a steady-state value, and look at batch-level p99 for straggler contamination.
Agentic and multi-turn workloads: replay sessions, not requests
Two 2026 data points quantify just how different agentic traffic is from single-turn chat. First, a characterization of ReAct-style agents across ADE-Bench, DABStep, GAIA, SWE-bench Pro, and Terminal-Bench 2.0 found execution is long-tailed in two separate dimensions — some tasks run to hundreds of turns (up to 786 observed), others accumulate huge context (up to 171K tokens) in relatively few turns — yet despite those huge contexts, time is decode-dominated (91–98.6% of LLM time), because 84.6–99.5% of input tokens are reused across turns via prefix caching rather than freshly attended-to. Tool calls (file ops, Bash, web search, etc.) consume 2–29% of wall-clock time depending on domain, and failed tool calls trigger retry loops that inflate both turn count and context length. (Yuan, Nayak, Kundu, Talati, “Agentic AI Workload Characteristics,” arXiv:2605.26297, May 2026: https://arxiv.org/abs/2605.26297.)
Second, vLLM’s own May 2026 write-up on integrating Mooncake Store as a distributed KV cache quantifies what happens when you don’t engineer for this: analyzing 610 real Codex/SWE-bench Pro traces, contexts reach roughly 80K tokens by turn 30 with a 131:1 input-to-output token ratio (i.e., overwhelmingly prefix-dominated), and when load-balanced round-robin across replicas without a shared cache, the achievable cache-hit rate collapses to 1.7% — every re-routed turn recomputes its ~80K-token prefix from scratch. With a distributed cache (Mooncake Store), hit rate recovers to 92.2%, delivering measured 3.8× higher throughput, 46× lower p50 TTFT, and 8.6× lower end-to-end latency on a 12-GPU test bed, scaling near-linearly to 60 GPUs while holding >95% hit rate. (vLLM Blog, “Serving Agentic Workloads at Scale with vLLM x Mooncake,” May 6, 2026: https://vllm.ai/blog/2026-05-06-mooncake-store.)
What this means for your load test: a single-turn benchmark_serving run against an agentic-serving deployment will systematically overstate TTFT and understate achievable concurrency, because it never creates the prefix-reuse conditions real session traffic does — every synthetic request is a cold, independent prefix. If your product is agentic, your load test must (1) replay session-shaped traffic (fixed or sampled turn counts, realistic think-time between turns, growing per-session context) using vLLM’s multi-turn benchmark, GenAI-Perf/AIPerf’s --num-sessions/--conversation-num modes, or a timed_trace replay of real logs; (2) report prefix/KV-cache hit rate as a first-class metric alongside TTFT/TPOT/throughput; and (3) explicitly test cross-instance routing and cache-eviction behavior under concurrent multi-session load, not just single-replica throughput.
Saying it out loud. Agentic traffic looks nothing like chat, and the numbers are striking. Characterizations of ReAct-style agents found tasks running up to 786 turns and contexts up to 171,000 tokens — yet 91 to 98.6% of LLM time is decode, because 84 to 99.5% of input tokens are reused across turns via prefix caching rather than freshly attended to. And vLLM’s own analysis of 610 real coding-agent traces found contexts hitting roughly 80,000 tokens by turn 30 with a 131-to-1 input-to-output ratio. Here’s the punchline: round-robin load balancing across replicas without a shared cache collapses the hit rate to 1.7%, because every re-routed turn recomputes its 80K prefix from scratch. With a distributed cache that recovers to 92%, worth a measured 3.8x throughput and 46x lower p50 TTFT.
What actually changed, in one paragraph
If you take away one thing from this section: the unit of load has shifted from the request to the session. Tooling caught up (multi-turn flags in vLLM’s benchmark, GenAI-Perf/AIPerf’s --session-*/--conversation-* families), and the workloads driving that shift — reasoning models with difficulty-correlated output length, agents with long-tailed turn counts and heavy prefix reuse — mean that a benchmark reporting a single fixed-length, single-turn number is answering a question production traffic no longer asks. Everything else below is about picking the right tool for the shape of session your product actually generates.
Choosing a tool in 2026 — decision matrix
| If you need… | Reach for | Because |
|---|---|---|
| LLM-native metrics + realistic single-turn datasets, fast | vllm bench serve | Built-in TTFT/TPOT/ITL, sharegpt/hf/prefix_repetition datasets, ramp-up sweeps |
| Multi-turn / KV-cache-offload benchmarking on vLLM specifically | benchmarks/multi_turn/benchmark_serving_multi_turn.py | Purpose-built for session replay against vLLM’s own KV-cache paths |
| Cross-provider apples-to-apples (OpenAI vs Anthropic vs self-hosted) | LLMPerf | Same client, many backends; treat as throughput-ceiling only |
| NVIDIA/Triton-centric stack, deep session control | AIPerf (formerly GenAI-Perf) | --conversation-num/--session-* flags, rich exports, active development |
| Rigorous open-loop SLA gate in CI | k6 | True arrival-rate executors, scriptable assertions, no coordinated omission |
| Reasoning-model workload with variable output length | Any of the above, fed a sampled (not fixed) output-length distribution | Fixed max_tokens hides the straggler-request problem entirely |
| Agentic / tool-calling workload | Session/trace replay (vLLM multi-turn, AIPerf conversations, or your own timed_trace) + KV-cache hit-rate metric | Independent-request benchmarks never exercise prefix reuse, so they mismeasure both latency and achievable concurrency |
One caveat applies to every row of that table: this space is moving fast enough that flag names change under you (GenAI-Perf’s own docs point at a CLI migration guide for exactly this reason). Whatever tool you pick, pin its version alongside the model, dataset, and length distribution in your CI gate (Production Checklist, item 8) — a benchmark that silently picks up a new default when the tool auto-updates is just as unreproducible as one that was never pinned at all.
Saying it out loud. The short version of choosing a tool:
vllm bench servefor fast LLM-native single-turn work, vLLM’s multi-turn benchmark if you’re specifically testing KV-cache offload, LLMPerf for cross-provider comparison as a ceiling measurement only, AIPerf for deep session control on an NVIDIA stack, and k6 when you need a rigorous open-loop gate in CI. For a reasoning-model workload, whichever tool you pick, feed it a sampled output-length distribution rather than a fixed cap. For an agentic workload, use session replay and report cache hit rate as a first-class metric. One caveat applies to every row: this space moves fast enough that flag names change under you, so pin the tool version alongside the model and dataset — an auto-updating benchmark is just as unreproducible as an unpinned one.
Failure Modes and Pitfalls
Closed-loop coordinated omission. Covered above — the big one. A closed-loop tool stops sending when the server stalls, so it under-reports tail latency and over-reports sustainable load. Fix: use open-loop arrival-rate executors, or a corrected tool that back-dates latency to the intended send time. If you must go closed-loop, only use it to measure the throughput ceiling, and never quote its latencies as an SLA.
No warmup. The first requests hit cold caches: CUDA graph capture, torch.compile / TRT-LLM engine warmup, cuBLAS autotune, KV-cache allocation, JIT. Cold TTFT can be 5–50× steady-state. Including warmup poisons your percentiles (a handful of huge samples wreck p99). Fix: send a warmup burst and discard the first N seconds/requests (the example’s --warmup). Also warm up the client (DNS, TLS, connection pool).
Unrealistic input/output lengths. A benchmark with 128-in/128-out tokens tells you nothing about a RAG workload with 4000-in/500-out. Prefill cost scales with input length; decode cost and E2E scale with output length; KV-cache pressure scales with both × concurrency. Fixed lengths also hide batching dynamics because every request finishes together. Fix: replay a realistic length distribution (ShareGPT, your own production logs, or --dataset-name random with a mean/std matching production). Report the distribution you used.
Measuring only averages. The mean hides the tail and can be dominated by a few slow requests. Always report p50/p95/p99 (and max) for TTFT, TPOT, and E2E separately. And never average latency across different output lengths without normalizing — longer outputs inflate E2E and masquerade as a regression.
Client-side bottleneck. The most insidious: your load generator, not the server, is the limit. Symptoms: achieved rate plateaus well below the server’s known capacity, client CPU pegged at 100%, or latency that scales with client concurrency. Causes: single-threaded Python parsing SSE, aiohttp/requests connection-pool caps, GIL contention, running the client on the same box as the server, or a network link between client and server that’s the actual bottleneck. Fixes: pin and monitor client CPU, remove connection caps (TCPConnector(limit=0)), use async or distributed workers (k6/Locust workers, multiple client hosts), and sanity-check with Little’s Law — if measured ( L = \lambda W ) can’t reach the server’s max-num-seqs, your client is starving it.
Reusing identical prompts / prefix caching artifacts. If every request sends the same prompt, prefix caching makes prefill nearly free and TTFT looks unrealistically good. Vary prompts (or explicitly test both with- and without-cache) so you measure the case you’ll actually run.
Not holding steady state / too-short runs. Little’s Law and stable percentiles need steady state. A 5-second run at 30 req/s is ~150 samples — too few for a trustworthy p99 (you want ≥1000+ post-warmup samples) and too short to reach queue equilibrium. Run each sweep point long enough that metrics stop drifting.
Saying it out loud. Seven ways benchmarks lie, and I’d name them in this order. Coordinated omission from a closed-loop tool — the big one. No warmup, so cold-cache samples with five-to-fifty-times TTFT poison your p99. Unrealistic input and output lengths, since 128-in-128-out tells you nothing about a 4000-in-500-out RAG workload. Measuring only averages. A client-side bottleneck, where your own load generator is the ceiling — the tell is a plateau while server GPU utilization sits at 40%. Reusing identical prompts, so prefix caching makes prefill nearly free and TTFT looks unrealistically good. And runs too short to reach steady state — you want at least a thousand post-warmup samples before you trust a p99. The unifying habit: cross-check achieved rate, server utilization, and Little’s-Law concurrency against each other.
Build It in Practice — Extended
The async client earlier in this chapter is a correct open-loop tool for one rate. Three things turn it into a practice you can actually run before a launch: (1) a length sampler that replays a realistic input/output distribution instead of a handful of fixed prompts, (2) a sweep driver that finds the saturation knee automatically instead of you eyeballing a table, and (3) a plotting step that turns the sweep into a chart you can put in a review deck.
Saying it out loud. Three things turn a correct one-rate client into something you’d actually run before a launch. A length sampler that replays a real input/output distribution instead of a couple of fixed prompts, because fixed lengths mean every request finishes together and you never see straggler effects or realistic KV-cache pressure. A sweep driver that finds the saturation knee automatically instead of you eyeballing a table, so it can run before every release rather than once a quarter. And a plotting step, because the artifact that actually changes a decision in a design review is a chart with the knee marked on it, not a CSV. That’s the difference between load testing as a one-off exercise and load testing as a gate.
B1. A realistic input/output length sampler
Fixed-length benchmarks hide exactly the dynamics that matter (see Pitfalls, above): requests that finish together, no straggler effects, no realistic KV-cache pressure. The right fix is to sample lengths from real data rather than invent a mean and stddev. The cheapest way to get “real data” without a bespoke dataset is to reuse the ShareGPT-style conversation data that vllm bench serve --dataset-name sharegpt already knows how to load, extract the empirical (input_len, output_len) pairs after tokenization, cache them once, and bootstrap-sample from that empirical pool for every request — this preserves the real joint distribution (including its correlation and heavy tail) instead of fitting a parametric approximation that smooths it away.
#!/usr/bin/env python3
"""length_sampler.py — build and sample from an empirical length distribution.
Run once to build the pool (needs a tokenizer and a ShareGPT-format JSON,
e.g. the file vLLM's own benchmarks download):
python length_sampler.py build \
--dataset ShareGPT_V3_unfiltered_cleaned_split.json \
--tokenizer meta-llama/Llama-3.1-8B-Instruct \
--out lengths.json
Then import build_pool()/sample() from a load-test script to draw
(input_len, output_len) pairs that match a real production-like shape.
"""
import argparse, json, random
def build_pool(dataset_path: str, tokenizer_name: str, limit: int = 20000):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(tokenizer_name)
with open(dataset_path) as f:
convos = json.load(f)
pool = []
for c in convos:
turns = c.get("conversations", [])
# Pair each human turn with the assistant turn that follows it.
for i in range(len(turns) - 1):
if turns[i]["from"] == "human" and turns[i + 1]["from"] == "gpt":
in_len = len(tok(turns[i]["value"]).input_ids)
out_len = len(tok(turns[i + 1]["value"]).input_ids)
if 1 <= in_len <= 8192 and 1 <= out_len <= 4096:
pool.append((in_len, out_len))
if len(pool) >= limit:
break
return pool
def sample(pool, reasoning_mix: float = 0.0, rng: random.Random | None = None):
"""Draw one (input_len, output_len) pair.
reasoning_mix in [0, 1]: fraction of requests that get a synthetic
'hard reasoning' output-length multiplier instead of the empirical
output length, reflecting the finding that reasoning models produce
output length correlated with problem difficulty rather than a fixed
ceiling (Li et al., arXiv:2510.18672). 0.0 reproduces the raw empirical
distribution; try 0.15-0.3 to approximate a mixed reasoning workload.
"""
rng = rng or random
in_len, out_len = rng.choice(pool)
if reasoning_mix > 0 and rng.random() < reasoning_mix:
# Hard problem: reasoning chain multiplies output length heavily,
# with its own long tail (occasionally 10-20x).
out_len = int(out_len * rng.lognormvariate(1.6, 0.7))
out_len = min(out_len, 8192)
return in_len, out_len
if __name__ == "__main__":
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build")
b.add_argument("--dataset", required=True)
b.add_argument("--tokenizer", required=True)
b.add_argument("--out", required=True)
b.add_argument("--limit", type=int, default=20000)
args = ap.parse_args()
if args.cmd == "build":
pool = build_pool(args.dataset, args.tokenizer, args.limit)
with open(args.out, "w") as f:
json.dump(pool, f)
lens_in = sorted(p[0] for p in pool)
lens_out = sorted(p[1] for p in pool)
n = len(pool)
print(f"built pool of {n} pairs")
print(f"input_len p50={lens_in[n//2]} p95={lens_in[int(n*0.95)]}")
print(f"output_len p50={lens_out[n//2]} p95={lens_out[int(n*0.95)]}")
To wire this into the earlier load_test.py, replace the fixed PROMPTS list and --max-tokens flag with a call to sample(pool, reasoning_mix=args.reasoning_mix) per request: pad/truncate a filler prompt to in_len tokens (or, better, reuse the ShareGPT prompt text itself alongside its measured length) and set that request’s max_tokens to the sampled out_len instead of a global constant. The rest of one_request — scheduling from sched_t, measuring from the intended send time — is unchanged; only the per-request length now varies realistically instead of being global.
Why bootstrap from an empirical pool instead of fitting a parametric distribution (lognormal, gamma) to the same data? A fitted distribution smooths over exactly the structure that matters here — the correlation between a conversation’s input and output length, and the heavy tail of occasional very long turns. Sampling (in_len, out_len) pairs together, verbatim, from real conversations preserves both; fitting marginal distributions separately and sampling them independently would silently discard the correlation and could understate how often a long input and a long output co-occur, which is exactly the combination that stresses KV-cache capacity hardest.
Saying it out loud. The right way to get realistic lengths isn’t to invent a mean and standard deviation — it’s to take real conversation data, tokenize it, extract the empirical input-length and output-length pairs, and bootstrap-sample from that pool for every request. The reason to keep them as pairs matters: it preserves the real joint distribution, including the correlation between prompt length and answer length and the heavy tail, both of which a fitted parametric distribution smooths right out. And the heavy tail is precisely the thing that produces straggler requests and realistic KV-cache pressure. Practical detail: tokenize once, cache the pool to disk, and record which tokenizer you used — length distributions aren’t portable across tokenizers, so an unlabeled pool is an unreproducible benchmark.
B2. A concurrency sweep that finds the knee automatically
Manually reading a table (as in the sample-results section above) works for a chapter; it doesn’t scale to “rerun this before every release.” The sweep below is deliberately closed-loop — fixed worker pools per level — because finding the ceiling is precisely the closed-loop use case this chapter argues for: a bounded, known concurrency is the independent variable, and you read off achieved throughput and p95 latency at each level. It doubles concurrency until throughput growth falls below a threshold, then binary-searches between the last two doublings to pinpoint the knee more tightly.
#!/usr/bin/env python3
"""sweep.py — closed-loop concurrency sweep with automatic knee detection.
Usage:
python sweep.py --url http://localhost:8000/v1/chat/completions \
--model my-model --pool lengths.json --out sweep_results.csv
"""
import argparse, asyncio, csv, json, statistics, time
import aiohttp
from length_sampler import sample
async def worker_loop(session, url, model, pool, stop_event, out_tokens_box, latencies):
"""Closed-loop worker: send, await full response, immediately send next."""
while not stop_event.is_set():
in_len, out_len = sample(pool)
payload = {
"model": model,
"messages": [{"role": "user",
"content": "Explain this topic in detail. " * max(1, in_len // 8)}],
"max_tokens": out_len, "stream": True, "temperature": 0.0,
}
t_send = time.perf_counter()
n = 0
try:
async with session.post(url, json=payload) as resp:
async for raw in resp.content:
line = raw.decode("utf-8").strip()
if line.startswith("data:") and line[5:].strip() not in ("", "[DONE]"):
n += 1
except Exception:
continue
if stop_event.is_set():
break # don't count a request that straddles the boundary
latencies.append((time.perf_counter() - t_send) * 1000)
out_tokens_box[0] += n
async def run_level(url, model, pool, concurrency, measure_s, warmup_s):
stop = asyncio.Event()
out_tokens_box, latencies = [0], []
conn = aiohttp.TCPConnector(limit=0)
async with aiohttp.ClientSession(connector=conn,
timeout=aiohttp.ClientTimeout(total=None)) as session:
tasks = [asyncio.create_task(worker_loop(session, url, model, pool, stop,
out_tokens_box, latencies))
for _ in range(concurrency)]
await asyncio.sleep(warmup_s)
latencies.clear(); out_tokens_box[0] = 0 # discard warmup
t0 = time.perf_counter()
await asyncio.sleep(measure_s)
stop.set()
await asyncio.gather(*tasks, return_exceptions=True)
wall = time.perf_counter() - t0
n = len(latencies)
p95 = sorted(latencies)[int(0.95 * (n - 1))] if n else float("nan")
return {
"concurrency": concurrency,
"req_per_s": n / wall if wall else 0.0,
"out_tok_per_s": out_tokens_box[0] / wall if wall else 0.0,
"e2e_p95_ms": p95,
"n_requests": n,
}
def efficiency(level):
"""Throughput per unit of concurrency — falls as you approach the knee."""
return level["out_tok_per_s"] / level["concurrency"]
async def sweep(url, model, pool, measure_s, warmup_s, max_concurrency=256):
levels = []
c = 1
while c <= max_concurrency:
lvl = await run_level(url, model, pool, c, measure_s, warmup_s)
levels.append(lvl)
print(f"concurrency={c:4d} req/s={lvl['req_per_s']:7.2f} "
f"tok/s={lvl['out_tok_per_s']:8.1f} p95={lvl['e2e_p95_ms']:8.1f}ms")
if len(levels) >= 2:
growth = lvl["out_tok_per_s"] / max(levels[-2]["out_tok_per_s"], 1e-9) - 1
latency_blowup = lvl["e2e_p95_ms"] / max(levels[-2]["e2e_p95_ms"], 1e-9)
# Stop doubling once a doubling of concurrency buys <10% more
# throughput, or p95 latency has more than tripled since the
# last level -- either is the overload signature from the
# Core Intuition section above.
if growth < 0.10 or latency_blowup > 3.0:
break
c *= 2
if len(levels) < 2:
return levels, levels[-1]["concurrency"] if levels else 1
# Binary-search refine between the last two doubling points for a
# tighter knee estimate than "somewhere between C and 2C".
lo, hi = levels[-2]["concurrency"], levels[-1]["concurrency"]
lo_lvl = levels[-2]
while hi - lo > max(1, lo // 8): # stop refining once step is <~12% of lo
mid = (lo + hi) // 2
mid_lvl = await run_level(url, model, pool, mid, measure_s, warmup_s)
levels.append(mid_lvl)
print(f" refine concurrency={mid:4d} tok/s={mid_lvl['out_tok_per_s']:8.1f} "
f"p95={mid_lvl['e2e_p95_ms']:8.1f}ms")
growth = mid_lvl["out_tok_per_s"] / max(lo_lvl["out_tok_per_s"], 1e-9) - 1
if growth < 0.10:
hi = mid
else:
lo, lo_lvl = mid, mid_lvl
knee = lo
return sorted(levels, key=lambda l: l["concurrency"]), knee
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--pool", required=True, help="lengths.json from length_sampler.py build")
ap.add_argument("--out", default="sweep_results.csv")
ap.add_argument("--measure-seconds", type=float, default=30.0)
ap.add_argument("--warmup-seconds", type=float, default=10.0)
ap.add_argument("--max-concurrency", type=int, default=256)
args = ap.parse_args()
with open(args.pool) as f:
pool = json.load(f)
levels, knee = await sweep(args.url, args.model, pool, args.measure_seconds,
args.warmup_seconds, args.max_concurrency)
with open(args.out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(levels[0].keys()))
w.writeheader()
for lvl in levels:
w.writerow(lvl)
print(f"\ndetected knee at concurrency ~= {knee} (wrote {args.out})")
if __name__ == "__main__":
asyncio.run(main())
The knee rule implements exactly the “throughput stops rising while latency starts rising superlinearly” signature from the Core Intuition section: growth-per-doubling < 10% catches the throughput flattening, latency_blowup > 3.0 catches the queueing explosion, and either one alone is enough to trigger — some engines saturate on compute (throughput flattens first) while others saturate on scheduler/KV-cache pressure (latency explodes while throughput is still creeping up). The binary-search phase afterward turns “somewhere between 32 and 64” into a specific number worth writing on a slide.
Saying it out loud. The sweep is deliberately closed-loop, and that’s not a contradiction of everything earlier — finding the ceiling is exactly the closed-loop use case, because a bounded known concurrency is the independent variable you want to vary. The algorithm is simple: double concurrency until throughput growth falls below a threshold, then binary-search between the last two doublings to pin down the knee more tightly. Doubling gets you into the right neighborhood in a handful of runs instead of a linear scan, and the binary search buys precision where it matters. The output is a CSV with achieved rate, output tokens per second, and latency percentiles at each level — and the rule stays: use it for the ceiling, then confirm the SLA open-loop at your chosen rate.
B3. Plotting the sweep
#!/usr/bin/env python3
"""plot_results.py — render sweep_results.csv as a throughput/latency chart.
Usage: python plot_results.py sweep_results.csv --knee 40 --out sweep.png
"""
import argparse, csv
def load(path):
with open(path) as f:
rows = [dict(r) for r in csv.DictReader(f)]
rows = [r for r in rows if r["n_requests"] and int(r["n_requests"]) > 0]
rows.sort(key=lambda r: int(r["concurrency"]))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("csv_path")
ap.add_argument("--knee", type=float, default=None)
ap.add_argument("--out", default="sweep.png")
args = ap.parse_args()
rows = load(args.csv_path)
xs = [int(r["concurrency"]) for r in rows]
tok_s = [float(r["out_tok_per_s"]) for r in rows]
p95 = [float(r["e2e_p95_ms"]) for r in rows]
try:
import matplotlib.pyplot as plt
except ImportError:
# No plotting deps available -- fall back to an ASCII sparkline so
# the pipeline still produces *something* reviewable over SSH.
def spark(vals):
bars = " .:-=+*#%@"
lo, hi = min(vals), max(vals)
span = (hi - lo) or 1.0
return "".join(bars[min(9, int((v - lo) / span * 9))] for v in vals)
print("matplotlib not installed; ASCII fallback:")
print("concurrency:", xs)
print("tok/s :", spark(tok_s), f" (min={min(tok_s):.0f}, max={max(tok_s):.0f})")
print("p95 ms :", spark(p95), f" (min={min(p95):.0f}, max={max(p95):.0f})")
return
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(xs, tok_s, marker="o")
ax1.set_xlabel("concurrency"); ax1.set_ylabel("output tok/s")
ax1.set_title("Throughput vs concurrency")
ax2.plot(xs, p95, marker="o", color="firebrick")
ax2.set_xlabel("concurrency"); ax2.set_ylabel("E2E p95 (ms)")
ax2.set_title("Tail latency vs concurrency")
if args.knee is not None:
for ax in (ax1, ax2):
ax.axvline(args.knee, linestyle="--", color="gray")
ax.text(args.knee, ax.get_ylim()[1] * 0.95, f" knee~{args.knee:g}",
va="top", fontsize=8, color="gray")
fig.tight_layout()
fig.savefig(args.out, dpi=150)
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
The matplotlib-missing fallback matters more than it looks: this pipeline is meant to run unattended (a pre-release CI job, a bastion box with no X11 and a minimal Python image), and a plotting script that hard-crashes when a dependency is missing means nobody ever sees the sweep at all. Chained together (length_sampler.py build → sweep.py → plot_results.py), these three scripts are the difference between “we eyeballed some numbers” and “here is the knee, here is the chart, here is the exact concurrency we’re launching at” in a launch-readiness review.
Production Case Studies & War Stories
War story 1 — the load test that lied
Setup. A team preparing to launch a chat feature ran a pre-launch load test with 200 fixed virtual users hammering a vLLM endpoint in a tight closed loop: send, wait for the full response, send again. The report looked clean — median E2E 1.2 s, p99 2.1 s — and the launch was signed off as “validated at 500 req/s equivalent load.”
What actually happened. On launch day, real open-loop traffic arrived on its own schedule, indifferent to server health. An autoscaler scale-up lagged behind a traffic ramp by about ninety seconds, during which the existing pods queued hard. In production, p99 spiked past 40 seconds and client timeouts triggered retries, which added more load on top of an already-saturated system — a classic retry storm. Nothing about this was visible in the pre-launch numbers.
Root cause. The pre-launch test was closed-loop. When the (simulated, in staging) system slowed down, each of the 200 virtual users simply stalled waiting for its in-flight response and stopped sending new requests — the offered load quietly dropped exactly when the system was struggling, and no sample ever recorded the 90-second queueing debt building up behind the stall. This is coordinated omission exactly as described earlier in this chapter, and it is not a hypothetical: it is the single most common reason a “green” load test is followed by a “red” launch.
Diagnosis and fix. Post-incident, the team reran the same nominal load using k6’s ramping-arrival-rate executor (open-loop) and reproduced the p99 spike in staging by chaos-injecting an artificial 90-second slow window on one backend pod. The open-loop test showed the queueing debt immediately — p99 blew out precisely as it had in production, while a control run of the old closed-loop VU-based script, replayed against the same chaos-injected staging environment, again failed to show it. That side-by-side rerun is what convinced the team the tooling, not the server, had been the blind spot.
Lesson. Any load test used as a launch gate for latency SLAs must be open-loop with a realistic arrival distribution, and it must include at least one injected-failure scenario (a slow pod, a delayed autoscale event), not just steady-state load. A closed-loop VU test is still useful — as a throughput-ceiling probe — but it must never be the artifact that says “go.”
Saying it out loud. A team ran a pre-launch test with 200 fixed virtual users hammering vLLM in a tight closed loop. Median 1.2 seconds, p99 2.1 — signed off as validated. On launch day, real open-loop traffic arrived on its own schedule, an autoscaler lagged a ramp by about ninety seconds, p99 spiked past 40 seconds, client timeouts fired retries, and the retries added load to an already-saturated system. Root cause: when the system slowed in staging, all 200 virtual users simply stalled and stopped sending — the offered load quietly dropped exactly when it mattered, and no sample ever recorded the queueing debt. They reproduced it by rerunning open-loop with an injected slow pod. The lesson: any load test used as a launch gate for latency must be open-loop and must include an injected-failure scenario, not just steady state.
War story 2 — the GPU that wasn’t the bottleneck
Setup. An engineer benchmarking a new vLLM engine configuration (a scheduler flag change intended to improve throughput) measured a plateau at roughly 1,800 output tok/s at 32 concurrent requests on an A100 running an 8B model — well under the several-thousand tok/s the same card had hit in a vendor reference benchmark for a comparable model. The natural conclusion: the new configuration regressed something, and the engineer began reverting flags one at a time, burning the better part of a day.
What was actually happening. nvidia-smi showed GPU utilization at roughly 40% throughout the test — not a saturated GPU at all. The load generator was a single Python process using aiohttp with its default connection-pool limit of 100 and synchronous JSON parsing of each SSE chunk on the event loop; at 32 concurrent streaming connections doing that parsing, one CPU core was pegged at 100%. The client, not the server, had run out of capacity.
How it was confirmed. Cross-checking with Little’s Law settled it without more guessing: the achieved request rate and measured mean E2E latency implied an average in-flight concurrency ( L = \lambda W ) far below the server’s configured --max-num-seqs. If the server had genuinely been the bottleneck at 32 concurrent requests, ( L ) should have tracked close to 32; instead it sat well under that, meaning the server had spare scheduling slots the client simply wasn’t filling.
Fix. Removing the aiohttp connector limit (TCPConnector(limit=0)) and splitting the load across four client processes (one per CPU core) with k6’s distributed workers eliminated the client bottleneck. Rerun under the same engine configuration reached roughly 6,100 output tok/s — in line with the vendor reference, and confirming the scheduler-flag change the engineer had been about to revert was fine all along.
Lesson. Before attributing a throughput plateau to the server, check client CPU and connection-pool limits, and cross-validate the achieved in-flight concurrency against Little’s Law and the server’s own max-num-seqs/max-num-batched-tokens settings. The load generator is part of the system under test, and it is very easy for it to quietly become the ceiling.
Saying it out loud. An engineer saw throughput plateau at 1,800 output tokens per second at 32 concurrent requests on an A100 — well under a vendor reference — and started reverting scheduler flags one at a time, losing most of a day. The actual tell was sitting right there:
nvidia-smishowed the GPU at about 40% utilization. The load generator was a single Python process with aiohttp’s default 100-connection pool limit, parsing every SSE chunk synchronously on the event loop, and one CPU core was pegged. Little’s Law confirmed it — the implied in-flight concurrency was far below the server’smax-num-seqs, meaning the server had free scheduling slots the client wasn’t filling. Removing the connector limit and splitting across four processes got 6,100 tokens per second on the same config. The load generator is part of the system under test.
Telling these apart quickly, without waiting for a postmortem
Both stories above are diagnosable in minutes if you check the right signal early, rather than staring at a latency graph and guessing:
| Symptom you observe | Likely cause | Quick check |
|---|---|---|
| Achieved rate tracks target rate, but production still times out under real traffic | Coordinated omission — the test was closed-loop | Rerun the same nominal load open-loop (arrival-rate executor); if the tail latency only appears there, that’s the confirmation |
| Throughput plateaus well below expectation, server GPU utilization is low (well under ~80%) | Client-side bottleneck | Check client CPU, connector/pool limits, and Little’s-Law-implied concurrency against max-num-seqs |
| Throughput plateaus, server GPU utilization is high (~90%+), latency climbing superlinearly | Genuine server-side saturation — you’ve found the real knee | This is the good outcome: sweep further to confirm the plateau holds, then pick an operating point below it |
| Achieved rate silently falls below target rate as you push higher | Either genuine overload (expected past the knee) or a client that can’t keep up with its own schedule | Compare against a known-good baseline concurrency/rate; if the shortfall appears before the expected knee, suspect the client first |
The unifying habit in both incidents: never trust a single graph in isolation. Cross-check achieved-vs-target rate, server-side utilization, and the Little’s-Law-implied concurrency against each other before deciding which side of the client/server boundary the bottleneck is on.
Saying it out loud. You can separate these in minutes with one question: what is the server’s GPU utilization at the plateau? If throughput plateaus and utilization is low, well under 80%, it’s a client bottleneck — check client CPU, connection pool limits, and Little’s-Law-implied concurrency against
max-num-seqs. If throughput plateaus and utilization is high, around 90-plus, with latency climbing superlinearly, that’s genuine server saturation and you’ve found the real knee, which is the good outcome. And if achieved rate tracks target beautifully in test but production still times out, that’s coordinated omission — rerun the identical nominal load open-loop and see whether the tail appears. The habit underneath all three: never trust a single graph in isolation.
Production Checklist — What an Interviewer Probes
- “What do you measure, and why not just average latency?” — Name TTFT, TPOT/ITL, E2E, request- and output-token throughput; report p50/p95/p99, not means, because latency is heavy-tailed and the tail is what users feel. Mean is only for throughput accounting.
- “Open-loop or closed-loop, and what’s coordinated omission?” — Open-loop (arrival-rate) for SLA validation; closed-loop (fixed concurrency) only to find the throughput ceiling. Coordinated omission = closed-loop stops sending when the server stalls, so it hides the tail. Bonus: measure latency from the intended send time.
- “How do you find the right operating point?” — Sweep concurrency/rate, plot throughput and p95 latency vs load, find the knee where throughput flattens and latency turns up; operate just below it with headroom.
- “Apply Little’s Law here.” — ( L = \lambda W ). Given a rate and E2E latency, compute in-flight concurrency and compare to
max-num-seqsto detect oversubscription; convert closed-loop concurrency to sustainable req/s. - “How do you make the workload realistic?” — Match production input/output length distributions (ShareGPT / replayed logs), vary prompts to avoid prefix-cache flattery, and report the distribution used.
- “How do you know the client isn’t the bottleneck?” — Monitor client CPU, remove connection caps, use async/distributed generators, separate client and server hosts, and cross-check achieved ( L ) against server capacity.
- “Warmup and steady state?” — Discard cold-start requests (CUDA graphs/compile/cache allocation); run long enough (≥~1000 post-warmup samples, metrics stable) for trustworthy p99.
- “How do you gate regressions in CI?” — A reproducible benchmark (pinned tool/model/dataset/lengths) run at a fixed rate, asserting on p95 TTFT and output tok/s thresholds, so a config regression fails the build.
Interview Mastery
The checklist above covers the essentials. This section drills deeper: a tight verbal answer to the question interviewers open with, a system-design prompt with a worked sketch, a red-flags/green-flags table for spotting bad methodology fast, and an expanded Q&A bank.
“Explain open-loop vs closed-loop load testing in 60 seconds”
A load test’s load model is either closed-loop or open-loop. Closed-loop means a fixed pool of virtual users, each looping: send, wait for the response, send again — concurrency is capped at the pool size by construction. Open-loop means requests arrive on a schedule — say, Poisson at a target rate — independent of whether earlier requests have finished; concurrency is whatever emerges from that arrival rate hitting server capacity. The difference matters because of coordinated omission: when a closed-loop system slows down, each virtual user stalls waiting for its response and simply stops sending — the offered load backs off exactly when the server is struggling, so the tail latency you’d see in a real, indifferent arrival process never gets sampled. That’s why closed-loop is fine for finding a throughput ceiling — a fixed, known concurrency is exactly the variable you want for that — but open-loop, with latency measured from the intended send time, is the only honest way to validate a latency SLA against realistic traffic.
System design prompt: “Design a load-testing plan before a product launch”
A plausible interview prompt: “We’re launching a customer-facing chat feature backed by a self-hosted 70B model on 8 GPUs in three weeks. Design the load-testing plan.” A strong answer moves through phases, each with a concrete deliverable:
Phase 0 — Sanity check (day 1)
single request, cold and warm -> confirm TTFT/TPOT look reasonable,
no obvious misconfiguration (wrong dtype, tiny max-num-seqs, etc.)
Phase 1 — Concurrency sweep (days 2-4)
closed-loop sweep (Section B2 above) with a *realistic* length sampler
(Section B1), doubling until the knee triggers, then bisecting
-> deliverable: throughput-vs-concurrency and p95-vs-concurrency chart,
a single number ("knee ~= 45 concurrent requests, ~9,000 tok/s")
Phase 2 — Open-loop SLA validation (days 5-8)
k6 (or the async client above) at the *target* production arrival rate
and traffic mix, run long enough for >=1000 post-warmup samples per
metric, with prompts/lengths sampled from real or representative data
-> deliverable: pass/fail against the stated SLA (e.g. "p95 TTFT < 500ms
at 25 req/s"), with margin to the Phase 1 knee stated explicitly
Phase 3 — Failure-mode / chaos injection (days 9-11)
re-run Phase 2 while injecting: a slow/unhealthy pod, an autoscaler
delay, a burst 3x above target rate for 60s, a cold-start (freshly
scaled pod with no warmup)
-> deliverable: a short runbook of "what breaks and how it degrades"
(graceful backpressure vs cascading timeout/retry storm)
Phase 4 — Soak test (days 12-13)
hold the Phase 2 rate for several hours -> catch slow memory/KV-cache
fragmentation leaks that a 10-minute test can't show
Phase 5 — CI regression gate (ongoing, from day 14)
pin tool/model/dataset/lengths; run the Phase 1 sweep (or a cheap
single-concurrency proxy of it) on every config change; assert on
p95 TTFT and output tok/s thresholds so a scheduler-flag regression
fails the build before it reaches production
The parts an interviewer is listening for: naming the knee-finding phase as closed-loop by design, explicitly separating it from the open-loop SLA-validation phase, including a chaos/failure phase (most candidates forget this and only test steady state), and ending with something that survives past launch day — a CI gate, not a one-time report.
Saying it out loud. For a pre-launch plan I’d sequence it in four stages. First, characterize the workload from real or expected data — input and output length distributions, whether traffic is single-turn or session-shaped, and what the actual SLO is, stated as a percentile at a stated load. Second, run a closed-loop concurrency sweep to find the saturation knee and the true throughput ceiling per replica, which gives you your capacity math. Third, run an open-loop test at your target arrival rate to validate the SLA with honest tail behavior — and include at least one injected failure, a slow pod or a delayed scale-up, because that’s where the retry storms come from. Fourth, pin the whole thing — tool version, model, dataset, length distribution — into CI as a regression gate. The signal being graded is whether you use each load model for the question it can actually answer.
Red flags vs green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Latency reporting | Only a mean latency number | p50/p95/p99 (and max) per metric, reported separately for TTFT/TPOT/E2E |
| Load model for an SLA gate | Fixed-VU closed-loop test used to certify a latency SLA | Open-loop, arrival-rate test used for the SLA; closed-loop reserved for finding the throughput ceiling |
| Operating point | A single concurrency number presented as “the” benchmark | A concurrency/rate sweep with the knee explicitly identified and an operating point chosen with headroom below it |
| Workload shape | Same short prompt repeated, fixed max_tokens | Lengths sampled from a real/representative distribution; reasoning/agentic mix modeled if relevant |
| Warmup | Cold-start requests included in the reported percentiles | Warmup window explicitly discarded; client itself warmed up (DNS/TLS/pool) |
| Client health | Client CPU/connection limits never checked | Client CPU monitored, connector caps removed, achieved concurrency cross-checked against Little’s Law |
| Reproducibility | “We ran it once and it looked fine” | Tool/model/dataset/lengths pinned and rerun in CI with pass/fail thresholds |
| Reasoning-model workloads | Fixed max_tokens per request | Output length sampled from a distribution correlated with difficulty; KV-cache occupancy tracked over time, not just at steady state |
| Agentic/multi-turn workloads | Independent single-turn requests only | Session/multi-turn replay with realistic think-time between turns; prefix/KV-cache hit rate reported as a first-class metric |
| Failure handling | Only steady-state load tested | At least one chaos/failure-injection scenario (slow pod, autoscale delay, burst) included before launch |
Expanded Q&A
- What is TTFT dominated by, and why is it the most load-sensitive metric? Prefill cost plus queueing wait for a scheduler slot; queueing wait is paid entirely before the first token, so TTFT reacts to load faster and harder than TPOT does.
- Why report output-token throughput separately from request throughput? They answer different questions — req/s is the right top-line number when the unit of work is “a request” (e.g. classification); output tok/s is the money metric for generative workloads and is what improves when batching gets better, even if req/s stays flat because outputs got longer.
- State Little’s Law and use it to sanity-check a benchmark. ( L = \lambda W ). If a test offers 20 req/s and measures 4 s mean E2E, in-flight concurrency is 80; if
max-num-seqsis 64, the system is oversubscribed and not in steady state, which the latency graph alone won’t tell you until it’s already climbing. - What, concretely, is coordinated omission, and name one tool feature that fixes it. A closed-loop load generator stops sending when the server slows down, because each virtual user is blocked waiting on its own response, so the tail latency a real, indifferent arrival stream would have produced never gets sampled. Fix: an open-loop arrival-rate executor (k6’s
constant-arrival-rate) that measures latency from the scheduled send time rather than the actual send time. - Walk through how you’d automatically find the concurrency knee without eyeballing a chart. Double concurrency each step; stop when a doubling buys less than ~10% more output tok/s or p95 latency more than triples versus the previous level (either condition alone is the overload signature); then binary-search between the last two doubling points to tighten the estimate.
- Your pre-launch load test showed a healthy p99, but production had multi-second stalls. What’s your hypothesis, and how do you confirm it? Hypothesis: the pre-launch test was closed-loop and coordinated omission hid a queueing-debt scenario (e.g. an autoscaler lag). Confirm by rerunning the same nominal load open-loop while chaos-injecting the suspected failure (a delayed/slow pod) in staging, and showing the closed-loop tool fails to reproduce the same spike side-by-side.
- You suspect your load generator, not the server, is the bottleneck. What do you check, in order? Client CPU utilization; connection-pool/connector limits (e.g. aiohttp’s default 100); whether the client is single-threaded/GIL-bound doing synchronous parsing on the event loop; then cross-check achieved in-flight concurrency via Little’s Law against the server’s configured capacity (
max-num-seqs) — if the implied ( L ) is well under server capacity, the client is starving it. - How do you make a synthetic benchmark’s workload realistic? Sample input/output lengths from real data (production logs, or a public conversational dataset like ShareGPT) rather than a fixed pair; vary prompt content so prefix caching doesn’t flatter TTFT unless you’re deliberately testing the cached case; and match the arrival process (Poisson vs bursty vs session-shaped) to how traffic actually behaves.
- How does a reasoning model change what and how you benchmark? Output length becomes correlated with problem difficulty rather than a fixed ceiling, KV-cache utilization swings far more than in standard LLM serving (documented ranges of roughly 3-70% within a batch), and a single hard request in a batch can become a straggler that drags down every other request’s completion time. Practical fix: sample output length from a distribution (ideally bimodal/heavy-tailed) instead of a flat
max_tokens, and track KV-cache occupancy as a time series rather than a single number. - How would you load-test an agentic, multi-turn system differently from a single-turn chatbot? Replay session-shaped traffic — realistic turn counts, think-time between turns, growing per-session context — rather than independent single-turn requests, using a tool with native multi-turn support (vLLM’s multi-turn benchmark, GenAI-Perf/AIPerf’s session/conversation modes, or a timestamped trace replay); and report prefix/KV-cache hit rate as a first-class metric, because agentic sessions are typically decode-dominated with very high input-token reuse across turns, and an independent-request benchmark never creates that reuse condition.
- Compare vLLM’s
benchmark_serving/vllm bench serve, NVIDIA GenAI-Perf/AIPerf, and LLMPerf — when do you reach for each?vllm bench servefor fast, LLM-native metrics and datasets against a vLLM-family or OpenAI-compatible endpoint, including built-in ramp-up sweeps; AIPerf (formerly GenAI-Perf) when you’re deep in the NVIDIA/Triton stack and want first-class session/conversation modeling; LLMPerf when you need the same client hitting multiple different providers for an apples-to-apples comparison, remembering its concurrency mode measures a ceiling, not an SLA, and its fixed-max_tokensmodel under-represents reasoning workloads. - Why does a “regression” in mean E2E latency sometimes not be a regression at all? E2E scales with output length (( \text{E2E} = \text{TTFT} + (N-1)\cdot\text{TPOT} )); if the new run’s requests happened to generate longer outputs (different random seed, different sampled dataset order, a longer reasoning trace), E2E goes up with no change in per-token cost. Always compare either matched length distributions or normalized (per-token) latency.
- What’s the minimum sample size you’d want before trusting a reported p99? On the order of 1,000+ post-warmup samples per metric per level; fewer than that and a single unlucky/lucky request can move the reported p99 by a large margin, and the run may not have reached queueing steady-state for Little’s Law to hold.
- How do you turn a one-off load test into something that gates CI? Pin the tool version, model, dataset, and length distribution so the run is reproducible; run it (or a cheaper proxy of the full sweep) automatically on every serving-config change; assert on concrete thresholds (e.g. p95 TTFT and output tok/s) so a regression fails the build rather than waiting to be noticed in production.
- What is prefix/KV-cache hit rate, and why does it belong in an agentic load-testing report? The fraction of a request’s prompt tokens served from a cached prior computation instead of recomputed; agentic sessions can be 80-99%+ prefix-reuse, so a benchmark that never creates that reuse (independent single-turn requests instead of session replay) will report a TTFT far worse than production actually sees, and a concurrency ceiling far lower than what’s actually achievable once caching is engineered for.
- Why might load-balancing strategy alone cause a large throughput/latency regression in a multi-replica agentic deployment? Naive round-robin routing sends a session’s later turns to a different replica than its earlier turns, forcing that replica to recompute the whole accumulated prefix from scratch; the fix is either session-affinity routing or a distributed KV cache shared across replicas, and the difference between the two shows up as an order-of-magnitude swing in achievable cache-hit rate, not a subtle one.
- A batch of requests to a reasoning model has wildly inconsistent completion times even though every request entered the batch together. What’s going on? A straggler effect: one or two requests in the batch happen to need a much longer reasoning chain (harder problem), and because they share the batch, their completion time sets a floor for how long the whole batch’s compute is tied up, dragging down the easier requests’ effective throughput even though nothing is wrong with the server.
- If you can only run one load test before a launch, which one do you run? The open-loop SLA validation at the target production arrival rate and realistic traffic mix (Phase 2 of the system-design sketch above) — it’s the one that most directly answers “will this hold under real traffic,” and it implicitly exercises most of what a concurrency sweep would show, even though a dedicated sweep gives you a cleaner knee estimate and more margin to reason about.
Further Reading
Core methodology
- vLLM — Benchmark CLI (
vllm bench serve): https://docs.vllm.ai/en/latest/cli/bench/serve/ - vLLM — Benchmarking overview & datasets: https://docs.vllm.ai/en/latest/benchmarking/cli/
- vLLM —
benchmarks/serve.pysource (dataset names,--burstiness,--ramp-up-strategy): https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/serve.py - LLMPerf (Ray) — benchmarking library and
token_benchmark_ray.py: https://github.com/ray-project/llmperf - LLMPerf Leaderboard: https://github.com/ray-project/llmperf-leaderboard
- NVIDIA GenAI-Perf (Triton Perf Analyzer): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html
- NVIDIA AIPerf — Metrics Reference (TTFT/ITL definitions): https://docs.nvidia.com/aiperf/reference/ai-perf-metrics-reference
- k6 — Open vs closed workload models: https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/
- Locust — documentation: https://docs.locust.io/en/stable/
- Gil Tene — “How NOT to Measure Latency” (coordinated omission, source): https://www.infoq.com/presentations/latency-response-time/
- ScyllaDB — On Coordinated Omission: https://www.scylladb.com/2021/04/22/on-coordinated-omission/
- Marc Brooker — Open, Closed, Omission and Collapse: https://brooker.co.za/blog/2023/05/10/open-closed.html
- Little’s Law (overview): https://en.wikipedia.org/wiki/Little%27s_law
- Red Hat AI Inference Server — validating with key metrics (TTFT/TPOT/throughput): https://docs.redhat.com/en/documentation/red_hat_ai_inference_server/3.1/html/getting_started/validating-benefits-with-key-metrics_getting-started
Multi-turn, agentic, and reasoning-model benchmarking (2025-2026)
- vLLM — multi-turn conversation benchmark PR (RFC #20265, KV-cache-offload replay): https://github.com/vllm-project/vllm/pull/20267
- Pliops — “Setting the Standard: Multi-Turn Benchmarking in vLLM”: https://pliops.com/setting-the-standard-multi-turn-benchmarking-in-vllm/
- NVIDIA GenAI-Perf — Multi-Turn Chat benchmarking docs: https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/docs/multi_turn.html
- NVIDIA AIPerf — Multi-Turn Conversations docs: https://docs.nvidia.com/aiperf/tutorials/datasets-inputs/multi-turn-conversations
- NVIDIA AIPerf — Comprehensive LLM Benchmarking (successor to GenAI-Perf): https://docs.nvidia.com/aiperf/getting-started/ai-perf-comprehensive-llm-benchmarking
- Li et al. — “Reasoning Language Model Inference Serving: An Empirical Study” (arXiv:2510.18672, 2025): https://arxiv.org/abs/2510.18672
- NVIDIA Perspectives — “Infrastructure Economics: Reasoning Models & Chain-of-Thought” (2026): https://perspectives.nvidia.com/infrastructure-economics-reasoning-models-chain-of-thought
- Yuan, Nayak, Kundu, Talati — “Agentic AI Workload Characteristics” (arXiv:2605.26297, May 2026): https://arxiv.org/abs/2605.26297
- vLLM Blog — “Serving Agentic Workloads at Scale with vLLM x Mooncake” (May 6, 2026): https://vllm.ai/blog/2026-05-06-mooncake-store
Topic 5: High-Performance Serving with vLLM
What You’ll Learn
This topic teaches you how to use vLLM for high-performance LLM serving:
- What vLLM is and why it’s fast
- Setting up vLLM server
- Understanding continuous batching
- PagedAttention memory optimization
- GPU utilization and throughput optimization
Why We Need This
Business Need
- Cost reduction: 10x higher throughput = 10x lower cost per request
- User experience: Lower latency = better user satisfaction
- Scalability: Handle more users with same infrastructure
- Competitive advantage: Faster responses than competitors
Technical Need
- GPU efficiency: 80-95% utilization vs 20-40% with basic serving
- Memory efficiency: Support longer sequences with same memory
- Throughput: 50-200 req/s vs 1-5 req/s with basic serving
- Production-ready: Used by major companies in production
Real-World Impact
Without vLLM:
- ❌ 10x higher infrastructure costs
- ❌ Poor user experience (slow responses)
- ❌ Can’t scale to handle traffic
- ❌ Wasted GPU resources (expensive!)
Industry Use Cases
1. High-Volume API Services
Company: OpenAI, Anthropic, Cohere Use Case:
- Serve millions of requests per day
- Need maximum GPU utilization
- Cost-sensitive at scale
Example:
# vLLM handles 1000 req/s vs 10 req/s with basic serving
# Cost: $10,000/month vs $100,000/month
2. Real-Time Applications
Company: Chatbots, code completion tools Use Case:
- Sub-second response times required
- Many concurrent users
- Low latency critical
Example:
# GitHub Copilot, ChatGPT use continuous batching
# User types → immediate suggestions
3. Cost-Optimized ML Platforms
Company: ML infrastructure companies Use Case:
- Serve multiple customers on shared infrastructure
- Maximize GPU utilization = lower costs
- Pass savings to customers
Example:
# Shared GPU cluster
# vLLM allows 10x more customers per GPU
4. Long Context Windows
Company: Document processing, code analysis Use Case:
- Process long documents (10K+ tokens)
- PagedAttention enables longer sequences
- Memory-efficient
Example:
# Process entire codebase (100K tokens)
# Traditional: Out of memory
# vLLM: Works efficiently
5. Multi-Tenant Serving
Company: SaaS platforms, ML platforms Use Case:
- Serve multiple models/users simultaneously
- Efficient resource sharing
- Fair resource allocation
Example:
# 100 customers, each with different model
# vLLM manages memory efficiently
Industry-Standard Boilerplate Code
Production vLLM Server (Industry Standard)
"""
Production vLLM server
Used by: OpenAI-compatible APIs, high-throughput serving
"""
from vllm import LLM, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
import os
# Configuration from environment (12-factor app)
MODEL_NAME = os.getenv("MODEL_NAME", "gpt2")
GPU_MEMORY_UTILIZATION = float(os.getenv("GPU_MEMORY_UTILIZATION", "0.9"))
TENSOR_PARALLEL_SIZE = int(os.getenv("TENSOR_PARALLEL_SIZE", "1"))
MAX_MODEL_LEN = int(os.getenv("MAX_MODEL_LEN", "2048"))
app = FastAPI(title="vLLM Serving API")
# Initialize vLLM engine
llm_engine = None
@app.on_event("startup")
async def startup():
global llm_engine
engine_args = AsyncEngineArgs(
model=MODEL_NAME,
tensor_parallel_size=TENSOR_PARALLEL_SIZE,
gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
max_model_len=MAX_MODEL_LEN,
dtype="float16", # FP16 for speed
trust_remote_code=True,
)
llm_engine = AsyncLLMEngine.from_engine_args(engine_args)
class CompletionRequest(BaseModel):
prompt: str
max_tokens: int = 100
temperature: float = 1.0
top_p: float = 1.0
@app.post("/v1/completions")
async def completions(request: CompletionRequest):
"""OpenAI-compatible endpoint"""
from vllm.utils import random_uuid
request_id = random_uuid()
sampling_params = SamplingParams(
temperature=request.temperature,
top_p=request.top_p,
max_tokens=request.max_tokens,
)
llm_engine.add_request(
request_id=request_id,
prompt=request.prompt,
sampling_params=sampling_params,
)
final_output = None
async for request_output in llm_engine.generate(
request_id=request_id,
prompt=request.prompt,
sampling_params=sampling_params,
):
final_output = request_output
return {
"choices": [{
"text": final_output.outputs[0].text,
"finish_reason": final_output.outputs[0].finish_reason
}]
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
vLLM with OpenAI Client (Industry Standard)
"""
Client code using vLLM server
Used by: Applications integrating with LLM APIs
"""
from openai import OpenAI
# vLLM provides OpenAI-compatible API
client = OpenAI(
base_url="http://vllm-server:8000/v1",
api_key="dummy" # vLLM doesn't require real key
)
def generate_text(prompt: str, max_tokens: int = 100) -> str:
"""Generate text using vLLM server"""
response = client.completions.create(
model="gpt2",
prompt=prompt,
max_tokens=max_tokens,
temperature=0.7
)
return response.choices[0].text
# Example: High-throughput batch processing
async def process_batch(prompts: list[str]):
"""Process multiple prompts concurrently"""
import asyncio
tasks = [
generate_text(prompt)
for prompt in prompts
]
return await asyncio.gather(*tasks)
Key Concepts
Continuous Batching
Traditional batching:
Request 1: [████████████████] (waiting)
Request 2: [████████████████] (waiting)
Request 3: [████████████████] (waiting)
→ Process all together
→ Wait for all to finish
vLLM continuous batching:
Request 1: [████████████] (done, remove)
Request 2: [████████████████] (processing)
Request 3: [████████] (processing)
Request 4: [██] (just added)
→ Process together, remove completed, add new
→ GPU always busy
PagedAttention
- Divides KV cache into fixed-size pages
- Allocates pages on-demand
- Reuses freed pages
- Result: Support for longer sequences, less memory waste
Installation
# vLLM requires CUDA (GPU)
pip install vllm
# Or with specific CUDA version
pip install vllm --extra-index-url https://download.pytorch.org/whl/cu118
Running the Examples
Option 1: Using vLLM’s Built-in Server
# Start vLLM server
python -m vllm.entrypoints.openai.api_server \
--model gpt2 \
--port 8000 \
--tensor-parallel-size 1
Option 2: Using Our Custom Server
cd 05_vllm_serving
pip install -r requirements.txt
python vllm_server.py
API Usage
vLLM provides OpenAI-compatible API:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy" # vLLM doesn't require real API key
)
# Chat completion
response = client.chat.completions.create(
model="gpt2",
messages=[
{"role": "user", "content": "Hello, how are you?"}
],
max_tokens=100,
temperature=0.7
)
print(response.choices[0].message.content)
Performance Comparison
Basic Serving (HuggingFace)
- Throughput: ~1-5 requests/second
- Latency: 100-500ms per request
- GPU Utilization: 20-40%
vLLM Serving
- Throughput: ~50-200 requests/second
- Latency: 50-200ms per request
- GPU Utilization: 80-95%
Why the difference?
- Continuous batching keeps GPU busy
- PagedAttention uses memory efficiently
- Optimized CUDA kernels
Configuration Options
Model Loading
from vllm import LLM
llm = LLM(
model="gpt2",
tensor_parallel_size=1, # Number of GPUs
gpu_memory_utilization=0.9, # Use 90% of GPU memory
max_model_len=2048, # Maximum sequence length
dtype="float16", # Use FP16 for speed
)
Generation Parameters
outputs = llm.generate(
prompts=["Hello"],
sampling_params={
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 100,
}
)
Monitoring vLLM
vLLM exposes metrics you can monitor:
- Request queue size
- GPU utilization
- Throughput (tokens/second)
- Latency (P50, P95, P99)
See 08_monitoring/ for Grafana dashboards.
Common Issues
Out of Memory
- Problem: Model too large for GPU
- Solutions:
- Use smaller model
- Reduce
gpu_memory_utilization - Use quantization (INT8, INT4)
- Use tensor parallelism (split across GPUs)
Slow Performance
- Problem: Not using GPU
- Solution: Make sure CUDA is available:
python -c "import torch; print(torch.cuda.is_available())"
Import Errors
- Problem: vLLM not installed correctly
- Solution: Install with correct CUDA version
Exercises
- Compare Performance: Run same model with HuggingFace vs vLLM, measure throughput
- Tune Parameters: Experiment with
gpu_memory_utilization,max_model_len - Test Continuous Batching: Send requests at different rates, observe GPU utilization
- Monitor Metrics: Set up Prometheus to scrape vLLM metrics
Next Steps
- Topic 6: Autoscaling with vLLM
- Topic 8: Monitoring vLLM with Grafana
- Topic 11: Multi-model serving with Triton
Further Reading
vLLM Serving — PagedAttention, Continuous Batching, and Production Tuning
Why this matters
If you serve open-weight LLMs at any real scale, vLLM is very likely the engine underneath — or the baseline everything else is measured against. It became the default high-throughput inference server because it attacked the single biggest bottleneck in LLM serving: memory, specifically the KV cache. Its headline result, from the original paper, is a 2–4× throughput improvement at the same latency versus the prior state of the art (FasterTransformer and Orca).
The insight is almost embarrassingly simple in hindsight: LLM serving was wasting 60–80% of KV-cache memory to fragmentation and over-reservation. vLLM borrowed virtual memory and paging from operating systems, applied it to the KV cache, and turned that wasted memory back into batch capacity. More batch capacity means more requests share each expensive weight-load from GPU memory, which is exactly what raises throughput.
This chapter goes deep on the mechanisms (PagedAttention, continuous batching, prefix caching), the knobs that actually matter in production (gpu-memory-utilization, max-num-seqs, max-num-batched-tokens, chunked prefill), how to scale across GPUs and nodes, and how to trade quality for speed with quantization and speculative decoding. It then covers the 2025–2026 state of the art (V1 engine, disaggregated prefill/decode, EAGLE-family speculation, FP8/INT4-Marlin quantization), a fully worked multi-node tuning walkthrough, real production incidents and their fixes, and an interview-mastery section with 20 Q&A, a system-design prompt, and a red-flag/green-flag table.
Chapter map, since this is long: mechanisms and core tuning knobs come first (PagedAttention -> continuous batching -> chunked prefill -> prefix caching -> engine args -> parallelism -> quantization -> speculative decoding); section (A) is the 2025-2026 state-of-the-art landscape; section (B) is the extended multi-node build-and-tune walkthrough with observability and cost math; section (C) is three real production war stories plus an on-call runbook; section (D) is interview mastery (60-second explanation, a system-design prompt, 20 Q&A, red/green flags); a glossary and further reading close it out.
Saying it out loud. vLLM became the default open-source serving engine because it attacked the actual bottleneck, which is memory — specifically the KV cache — rather than compute. The insight is embarrassingly simple in hindsight: pre-vLLM systems were wasting 60 to 80% of KV-cache memory to fragmentation and over-reservation, because each sequence grabbed a contiguous block sized for the maximum possible length up front. vLLM borrowed paging from operating systems, applied it to the KV cache, and turned that wasted memory back into batch capacity. And more batch capacity means more requests share each expensive weight read from HBM, which is exactly what raises throughput. The headline number from the paper is 2 to 4x throughput at the same latency versus the prior state of the art.
Core intuition: LLM inference is memory-bound, and the KV cache is the problem
Two facts drive everything:
-
Autoregressive decoding is memory-bandwidth-bound, not compute-bound. Generating one token touches the entire model weights but does very little arithmetic per token. The GPU spends most of its time reading weights from HBM, not doing math. The fix is batching: process many sequences at once so a single weight read serves many tokens. Bigger batch → higher throughput, until you run out of memory.
-
What runs you out of memory is the KV cache. For every token in every sequence, attention must remember the key and value vectors of all previous tokens. This “KV cache” grows linearly with sequence length and with the number of concurrent sequences. On a typical setup the weights are fixed, and whatever HBM is left is a fixed budget you spend on KV cache. The more efficiently you pack the KV cache, the bigger your batch, the higher your throughput.
So the game is: fit as many sequences’ KV caches into the leftover HBM as possible, and keep the GPU busy on all of them at once. PagedAttention wins the first half; continuous batching wins the second.
Saying it out loud. Two facts drive everything. First, autoregressive decoding is memory-bandwidth-bound, not compute-bound: generating one token reads the entire model weights but does very little arithmetic, so the GPU spends its time on the memory bus while the tensor cores idle. The fix is batching — process many sequences at once so one weight read serves many tokens. Second, the thing that stops you batching more is the KV cache, which grows linearly with sequence length and with concurrent sequences. So the game is: fit as many sequences’ KV caches into the leftover HBM as possible, and keep the GPU busy on all of them every step. PagedAttention wins the first half, continuous batching wins the second. That’s the whole chapter in two sentences.
How big is the KV cache?
Per token, the KV cache size is:
[ \text{bytes/token} = 2 \times n_\text{layers} \times n_\text{kv_heads} \times d_\text{head} \times \text{dtype_bytes} ]
The leading (2) is for K and V. Note (n_\text{kv_heads}), not the number of query heads — models with grouped-query attention (GQA) share KV heads across query heads, which shrinks the cache dramatically.
Worked numbers (fp16, 2 bytes):
- OPT-13B (40 layers, hidden 5120, full MHA): (2 \times 40 \times 5120 \times 2 = 819{,}200) bytes ≈ 800 KB per token. A single 2048-token sequence needs ~1.6 GB of KV cache — this is the number from the vLLM paper.
- Llama-3-8B (32 layers, 8 KV heads, (d_\text{head}=128), GQA): (2 \times 32 \times 8 \times 128 \times 2 = 131{,}072) bytes = 128 KB per token. GQA makes it ~6× cheaper than a same-size MHA model.
Why this matters more every year: newer model families push KV-per-token even lower via more aggressive GQA/MQA ratios and, in some cases (DeepSeek-V2/V3’s Multi-head Latent Attention) a compressed latent KV representation instead of full per-head K/V — because the whole industry has converged on the same conclusion this chapter opened with: KV cache, not weights, is what limits your batch, so every model architecture decision since ~2023 has been under pressure to shrink it. A rough sense of scale, fp16, per token:
| Model family | Attention scheme | Approx. KV bytes/token |
|---|---|---|
| OPT-13B-class (full MHA) | Full multi-head attention | ~800 KB |
| Llama-3-8B (GQA, 8 KV heads) | Grouped-query attention | ~128 KB |
| Llama-3-70B (GQA, 8 KV heads, 80 layers) | Grouped-query attention | ~320 KB |
| Mixtral-8x7B (GQA, MoE FFN — attention KV unaffected by MoE) | Grouped-query attention | ~128 KB |
| DeepSeek-V2/V3-class (Multi-head Latent Attention) | Compressed latent KV cache | Substantially below GQA at comparable model size — the specific figure is architecture-version-dependent; check the model card |
Always compute the real figure for your model from its config (layers × KV heads × head dim × dtype bytes) rather than trusting a rule of thumb — the whole point of the formula above is that it’s cheap to derive exactly, and it’s the single number every other capacity decision in this chapter depends on.
At 800 KB/token, KV cache is enormous and dynamic — you don’t know a request’s final length in advance. That combination is exactly what classic allocators handle badly.
Saying it out loud. The formula is two — for keys and values — times layers, times KV heads, times head dimension, times bytes per element, per token. The word doing the work there is KV heads rather than query heads, because grouped-query attention shares KV heads across query heads and shrinks the cache dramatically. Concretely, in fp16: an OPT-13B-class model with full multi-head attention is about 800 kilobytes per token, so a single 2,048-token sequence needs 1.6 gigabytes. Llama-3-8B with GQA and 8 KV heads is 128 kilobytes per token — roughly six times cheaper. That’s why every architecture since about 2023 has been under pressure to shrink this number. The habit worth building: derive the real figure from your model’s config rather than trusting a rule of thumb, because every capacity decision downstream depends on it.
PagedAttention in depth
The problem it solves: fragmentation and over-reservation
Pre-vLLM systems (Orca, FasterTransformer) stored each sequence’s KV cache in one contiguous chunk of memory, sized to the maximum possible length. If your model supports 2048 tokens, every sequence reserved space for 2048 tokens the moment it started — even if it only ever generated 30. Three kinds of waste result, and the paper measures them directly:
- Internal fragmentation (13.3%–57.3%): the slot reserved for max length but never filled.
- Reservation waste (part of 25.2%–96.3%): space reserved for future tokens of a still-running sequence — technically “will be used” but idle now, so it can’t serve other requests.
- External fragmentation: gaps between contiguous chunks of different sizes that no new sequence fits into.
Net effect: measured effective KV utilization of only 20.4%–38.2%. Four out of five bytes wasted.
Saying it out loud. Before vLLM, each sequence’s KV cache lived in one contiguous chunk sized to the maximum possible length. So if your model supports 2,048 tokens, every request reserved room for 2,048 the moment it started — even if it generated thirty. That produces three kinds of waste: internal fragmentation from the reserved-but-never-filled slot, reservation waste from space held for a running sequence’s future tokens, and external fragmentation from gaps between differently-sized chunks that nothing fits into. The paper measured the damage directly: effective KV utilization of only 20 to 38 percent. Four out of five bytes wasted — on the exact resource that caps your batch size, which caps your throughput.
The idea: page the KV cache like virtual memory
Operating systems solved this decades ago. A process sees a contiguous virtual address space, but physically it’s scattered across fixed-size pages mapped by a page table. No process reserves all of physical RAM up front; pages are handed out on demand.
PagedAttention does the same for the KV cache:
- The KV cache of a sequence is split into fixed-size KV blocks, each holding the K and V vectors for a fixed number of tokens — the block size, default 16 tokens.
- Blocks live in a global pool of physical GPU memory and need not be contiguous.
- Each sequence has a block table mapping its logical block index → physical block number, exactly like a page table.
- The attention kernel is modified to gather K/V from these scattered blocks using the block table, so attention runs correctly over non-contiguous memory.
Saying it out loud. The fix is borrowed straight from operating systems. A process sees a contiguous virtual address space, but physically its memory is scattered across fixed-size pages tracked by a page table, and no process reserves all of RAM up front. PagedAttention does exactly that for the KV cache: a sequence’s cache is split into fixed-size blocks — sixteen tokens by default — those blocks live in a global pool and don’t need to be contiguous, and each sequence has a block table mapping logical block index to physical block number. The attention kernel is modified to gather K and V through that block table. The payoff: a block is allocated only when the current one fills, so you waste at most one partial block per sequence and zero reservation, taking effective utilization from about 20% up toward 96%.
Diagram-in-words
Logical view (what the sequence "sees"):
Seq A tokens: [ t0 t1 ... t15 | t16 t17 ... t31 | t32 t33 ... ]
logical block 0 logical block 1 logical block 2
Block table for Seq A: [ 0 -> phys #7 ] [ 1 -> phys #3 ] [ 2 -> phys #11 ]
Physical KV block pool (16 tokens each, scattered in HBM):
#0 #1 #2 #3(A1) #4 #5 #6 #7(A0) #8 #9 #10 #11(A2) #12 ...
free free free used free ... free used ... used free
A block is allocated only when the sequence’s current block fills up. A sequence generating 30 tokens uses 2 blocks (32 slots), wasting at most 15 token-slots in its last block — at most one block of internal fragmentation per sequence, and zero reservation waste. External fragmentation vanishes because all blocks are the same size and interchangeable. Effective utilization approaches ~96%.
Sharing and copy-on-write
Because blocks are indirected through a block table, two sequences can point their block tables at the same physical block. This is where paging pays a second dividend:
- Shared prompts. In parallel sampling or beam search, (n) outputs share the same prompt. Instead of (n) copies of the prompt’s KV cache, all (n) block tables point at one shared set of prompt blocks. The paper reports up to 55% memory savings on parallel sampling / beam search.
- Copy-on-write (CoW). When one sharer needs to diverge (e.g., append a different token into a shared block), vLLM copies just that one block, updates that sequence’s block table, and leaves the others untouched — the same trick
fork()uses. Reference counts on each block track sharing.
This block-level sharing is the foundation that automatic prefix caching (below) builds on.
Saying it out loud. Because blocks are indirected through a table, two sequences can just point at the same physical block — and that’s where paging pays a second dividend. In parallel sampling or beam search, n outputs share the same prompt, so instead of n copies of the prompt’s KV cache you have n block tables pointing at one set of blocks; the paper reports up to 55% memory savings there. When one sharer needs to diverge, vLLM copies just that one block and updates only that sequence’s table — literally the same copy-on-write trick
fork()uses, with reference counts per block. This block-level sharing isn’t a side feature; it’s the machinery that automatic prefix caching is built on top of.
Worked memory example
Serve Llama-3-8B in fp16 on one A100-80GB.
- Weights: (8\text{B} \times 2\ \text{bytes} = 16\ \text{GB}).
- Budget:
--gpu-memory-utilization 0.9→ vLLM may use (0.9 \times 80 = 72\ \text{GB}). - Non-KV overhead: CUDA context, activations, CUDA graphs — call it ~2 GB.
- KV cache pool: (72 - 16 - 2 = 54\ \text{GB}).
- Per token: 128 KB (from above). Per block (16 tokens): (16 \times 128\ \text{KB} = 2\ \text{MB}).
- Total blocks: (54\ \text{GB} / 2\ \text{MB} \approx 27{,}600) blocks = ~442,000 tokens of KV capacity.
That single number, ~442k tokens, is your batch budget. It can be 54 sequences at the full 8192-context ((442000/8192)), or ~880 concurrent chatbot turns averaging 500 tokens each, or anything in between. vLLM logs this at startup as # GPU blocks: 27600 and reports “Maximum concurrency for 8192 tokens per request.” Watch that log line — it tells you exactly how much headroom you bought.
Saying it out loud. Walk the arithmetic for Llama-3-8B in fp16 on one 80-gigabyte A100. Weights are 16 gigabytes. At
--gpu-memory-utilization 0.9vLLM may use 72. Subtract about two for CUDA context, activations, and graph capture, and you’re left with 54 gigabytes of KV pool. At 128 kilobytes per token, a sixteen-token block is 2 megabytes, so 54 gigabytes is about 27,600 blocks, or roughly 442,000 tokens of KV capacity. That one number is your entire batch budget — it can be 54 sequences at full 8K context, or about 880 concurrent chat turns averaging 500 tokens, or anything between. vLLM prints it at startup as# GPU blocks, and watching that log line is the fastest way to know whether your config bought you the headroom you thought it did.
Continuous (in-flight) batching in depth
PagedAttention gives you the memory to run a big batch. Continuous batching keeps that batch full.
Static batching (the naive baseline)
Collect (N) requests, run them together, wait for all to finish, return, repeat. The problem: generations have wildly different lengths. If request A emits 20 tokens and request B emits 500, A’s slot sits idle for 480 steps while B finishes, because the batch can’t return or refill until the whole batch is done. GPU utilization craters, and latency for A is dictated by the slowest sibling.
Dynamic batching (a partial fix)
Servers like Triton’s dynamic batcher wait a few milliseconds to form a larger batch before launching, then still run it to completion as a unit. This improves batch size but does not solve the ragged-completion problem — it’s still batch-at-a-time.
Continuous batching (a.k.a. in-flight / iteration-level scheduling)
vLLM schedules at the granularity of a single decode step, not a whole request (the idea comes from Orca’s iteration-level scheduling). At every forward pass:
- Any sequence that emitted its EOS/stop this step is evicted immediately, and its KV blocks are freed.
- Waiting requests are admitted mid-flight to fill the vacated slots.
- The next forward pass runs over the new mix of prefills and decodes.
No sequence waits on a slower sibling. The batch is continuously topped up, so the GPU stays saturated. Anyscale’s widely cited benchmark measured up to 23× throughput from continuous batching plus paging versus naive batching, while also reducing p50 latency — a rare win on both axes, because higher utilization means less queueing.
Why it raises throughput: decoding is memory-bound, so throughput scales with how many sequences you can run per weight-load. Static batching leaves the effective batch shrinking toward 1 as siblings finish; continuous batching holds it near its memory-limited maximum every single step.
Saying it out loud. The key move is that vLLM schedules at the granularity of a single decode step rather than a whole request. Every forward pass, any sequence that just emitted its stop token is evicted immediately and its KV blocks freed, waiting requests are admitted mid-flight to fill the vacated slots, and the next pass runs over the new mix. So no sequence ever waits on a slower sibling — which is exactly the head-of-line blocking that kills static batching, where a request emitting twenty tokens sits idle for 480 steps while its batch-mate finishes. The measured win from Anyscale’s widely cited benchmark is up to 23x throughput versus naive batching while also cutting p50 latency, which is rare — you get both because higher utilization means less queueing.
Prefill vs decode, and chunked prefill
A request has two phases with opposite performance profiles:
- Prefill — process the whole prompt in one big parallel pass. Compute-bound, high FLOPs, fills the pipeline. A 4000-token prompt is one heavy step.
- Decode — generate tokens one at a time. Memory-bound, tiny per-step compute.
Mixing them is awkward. A giant prefill can monopolize a forward pass and stall every decoding sequence, spiking inter-token latency (ITL) for everyone already streaming. This is the classic prefill/decode interference — and, as section (A) below covers, it is the exact motivation for physically separating prefill and decode onto different GPUs in 2025–2026 deployments.
Chunked prefill (--enable-chunked-prefill) splits a large prefill into token-sized chunks and co-schedules a prefill chunk alongside ongoing decodes in the same batch, bounded by max-num-batched-tokens. Benefits:
- Smooths out ITL — decodes no longer freeze behind a monster prompt.
- Improves GPU utilization — decode steps are compute-light, so padding the batch with prefill tokens uses otherwise-idle FLOPs.
- In modern vLLM (V1 engine) chunked prefill is on by default, and prefill/decode are unified in one scheduler.
Tuning: raise max-num-batched-tokens for throughput (bigger chunks, more prefill work per step); lower it to protect decode latency (smaller chunks yield to decodes more often).
Saying it out loud. Prefill and decode have opposite profiles: prefill processes the whole prompt in one heavy compute-bound pass, decode generates one token at a time and is memory-bound with almost no arithmetic. Mixing them naively is awkward, because one giant prefill can monopolize a forward pass and freeze every sequence that’s currently streaming — that’s the classic prefill/decode interference, and it shows up as a spike in inter-token latency for users who did nothing wrong. Chunked prefill splits a big prompt into pieces and co-schedules a chunk alongside ongoing decodes in the same batch. It smooths inter-token latency and it also uses otherwise-idle FLOPs, since decode steps are compute-light. The tuning rule: raise
max-num-batched-tokensfor throughput, lower it to protect decode latency.
Prefix caching (automatic KV reuse)
Many requests share a prefix: the same long system prompt, a shared few-shot preamble, a document everyone asks questions about, a multi-turn conversation where each turn re-sends the history.
Automatic prefix caching (--enable-prefix-caching) hashes KV blocks by their content (and the tokens preceding them). When a new request’s prefix hashes to blocks already in the cache, vLLM skips recomputing that prefill entirely and points the new sequence’s block table at the cached blocks. It’s the block-sharing / CoW machinery from PagedAttention, applied across requests and over time.
- When it wins big: long shared system prompts, RAG with a fixed instruction preamble, multi-turn chat (each turn reuses the whole prior conversation’s KV), agent loops that resend context.
- Cost: cached blocks occupy KV memory that could otherwise hold active batch. Under memory pressure, cached prefix blocks are evicted LRU. It’s a hit-rate bet — near-free when hits are common, mild overhead when they’re not.
- In current vLLM (V1) prefix caching is enabled by default, and the V1 rewrite specifically made it “near-zero performance degradation, even when the cache hit rate is 0%” — so leaving it on is close to a free option (see section A).
The saving is real work avoided: a 2000-token shared system prompt cached across 1000 requests skips ~2,000,000 tokens of prefill compute.
Saying it out loud. Lots of requests share a prefix — the same long system prompt, a fixed few-shot preamble, a document everyone’s asking about, or a multi-turn chat that resends the whole history each turn. Automatic prefix caching hashes KV blocks by content and, when a new request’s prefix hashes to blocks already resident, just points the new sequence’s block table at them and skips recomputing that prefill entirely. It’s literally the copy-on-write block sharing from PagedAttention, applied across requests and across time. The scale of the saving is real work avoided: a 2,000-token shared system prompt cached across a thousand requests skips two million tokens of prefill compute. The cost is that cached blocks occupy memory the active batch could use, so it’s a hit-rate bet — though in V1 it’s engineered to be near-free even at a 0% hit rate.
Memory and the engine args that matter
These are the flags you actually turn in production. Names are the current vllm serve CLI form (dashes); the Python LLM(...) form uses underscores.
| Flag | Default | What it does | How to tune |
|---|---|---|---|
--gpu-memory-utilization | 0.9 | Fraction of each GPU’s HBM vLLM may use (weights + KV + activations). Sets the KV pool size. | Raise toward 0.92–0.95 to grow the batch if you have headroom; lower if you OOM or co-locate other processes. Leave slack for activation spikes. |
--max-num-seqs | 256 (V1; was model-dependent) | Max sequences in a batch (concurrency cap). | Raise for throughput if KV memory allows; lower to cap per-request latency and memory. Often the real batch limit is KV memory, not this. |
--max-num-batched-tokens | auto (e.g. 8192/2048) | Max tokens processed per iteration (prefill chunks + decode tokens). | Raise for throughput, lower to protect ITL. Must be ≥ max-model-len unless chunked prefill is on. |
--max-model-len | from model config | Max context (prompt + output) per request. | Lower it to fit more sequences / avoid OOM when the model’s native context exceeds your needs. Directly bounds worst-case KV per sequence. |
--block-size | 16 | Tokens per KV block. | Rarely changed. Larger blocks = less overhead but more internal fragmentation. |
--enable-prefix-caching / --no-enable-prefix-caching | on (V1) | Reuse KV of shared prefixes across requests. | Keep on for chat/RAG/agents; disable only if prefixes never repeat and you want the memory back. |
--enable-chunked-prefill | on (V1) | Split prefills into chunks, co-schedule with decodes. | Keep on; tune via max-num-batched-tokens. |
--tensor-parallel-size (-tp) | 1 | Shard each layer across N GPUs (intra-node). | Set to fit a model too big for one GPU / to cut latency. Use ≤ GPUs per node with fast NVLink. |
--pipeline-parallel-size (-pp) | 1 | Split layers into stages across GPUs/nodes. | Use to span multiple nodes or when TP alone can’t fit the model. |
--quantization | none | Weight/activation quant scheme (awq, awq_marlin, gptq, gptq_marlin, fp8, bitsandbytes, …). | Use to shrink weights → more KV room / smaller GPU. Costs some quality. |
--kv-cache-dtype | auto | Store KV cache in fp8 etc. | fp8 ~halves KV memory → bigger batch/context; small accuracy cost. |
--swap-space | 4 (GiB/GPU) | CPU RAM for swapping out preempted sequences’ KV. | Raise if you see frequent preemption + recompute; swap can be cheaper than recompute for long sequences. |
--max-num-seqs + --max-num-batched-tokens together | — | The two levers that shape the batch. | Co-tune: token budget caps work/step; seq budget caps concurrency. |
--dtype | auto | Compute dtype (bfloat16, float16). | bfloat16 on Ampere+; matters for numerical stability. |
--speculative-config | none | Speculative decoding config (JSON): draft model, n-gram, or EAGLE/Medusa method. | See below — latency win when acceptance is high. |
Startup log lines to watch: # GPU blocks: (your KV capacity), Maximum concurrency for N tokens, and any Sequence group ... is preempted warnings (you’re memory-starved). vLLM also exposes these as live Prometheus metrics (vllm:gpu_cache_usage_perc, vllm:gpu_prefix_cache_hit_rate, vllm:num_requests_running, vllm:num_requests_waiting) on /metrics — section (B) below shows how to read them while tuning.
Saying it out loud. There are really five flags that matter and the rest is detail.
--gpu-memory-utilizationsets what fraction of HBM vLLM may claim, which determines your KV pool — push it toward 0.94 if you have headroom, back off if you OOM.--max-num-seqscaps concurrency, but here’s the thing people get wrong: KV memory usually binds before that number does, so raising it without memory headroom just causes preemption.--max-num-batched-tokensis your throughput-versus-inter-token-latency dial.--max-model-lendirectly bounds worst-case KV per sequence, so lowering it is often the cheapest way to fit more requests. And--kv-cache-dtype fp8roughly doubles KV capacity for a small accuracy cost. Watch the startup log for# GPU blocksand watch forpreemptedwarnings — those two tell you whether your settings are honest.
Parallelism for big models
When a model (plus its KV cache) doesn’t fit on one GPU, or single-GPU latency is too high, split it.
Saying it out loud. When a model plus its KV cache doesn’t fit on one GPU, you split it, and there are two ways with very different communication profiles. Tensor parallelism shards every layer’s weight matrices across N GPUs and does an all-reduce each layer to combine partial results — bandwidth-hungry, so it wants NVLink and belongs inside one node. Pipeline parallelism assigns contiguous stages of layers to different GPUs, so communication is just a small point-to-point handoff between stages, which tolerates slower links and lets you span nodes. The rule of thumb is: tensor parallel first, up to one node’s worth of GPUs, then pipeline parallel to cross nodes. A very common shape is TP=8 within each node times PP=2 across two nodes for sixteen GPUs total.
Tensor parallelism (TP) — --tensor-parallel-size
Mechanism: shard every layer’s weight matrices across N GPUs; each GPU computes its slice, and an all-reduce combines partial results each layer. The KV cache is also sharded (by heads), so TP grows your KV budget too.
When: the model is too big for one GPU, or you want lower latency on a single request (more GPUs working the same forward pass). Best within one node over NVLink, because the per-layer all-reduce is bandwidth-hungry.
Tradeoff: communication overhead grows with N; going cross-node over slower interconnect (Ethernet/PCIe) tanks efficiency. Keep -tp ≤ GPUs-per-node. TP size must divide the number of attention heads.
Saying it out loud. Tensor parallelism shards each layer’s weight matrices across GPUs — every GPU computes its slice and an all-reduce combines the partial results, once per layer. Two things worth knowing beyond the mechanism. It shards the KV cache too, by heads, so raising TP grows your KV budget as well as fitting bigger weights. And it lowers single-request latency, because more GPUs are working the same forward pass. The tradeoff is that all-reduce traffic grows with the degree, so going cross-node over Ethernet or PCIe rather than NVLink tanks efficiency — keep TP at or below your GPUs-per-node. One hard constraint people forget: the TP size has to divide the number of attention heads.
Pipeline parallelism (PP) — --pipeline-parallel-size
Mechanism: assign contiguous stages of layers to different GPUs; activations flow stage → stage. Communication is a small point-to-point hand-off between stages, tolerant of slower links.
When: to span multiple nodes, or to fit truly huge models where even TP-across-a-node isn’t enough. Common pattern: -tp 8 within each node × -pp 2 across two nodes = 16 GPUs.
Tradeoff: introduces pipeline bubbles (stages idle waiting for the previous stage); throughput-friendly with enough in-flight requests, but adds latency per request. Combine TP (intra-node) + PP (inter-node) for the best of both.
Rule of thumb: TP first, up to one node; PP to cross nodes. vLLM also supports data parallelism / multi-replica behind a router for pure scale-out.
Saying it out loud. Pipeline parallelism cuts the model by layers instead of by weight matrices — GPU zero holds the first block of layers, GPU one the next, and activations flow from stage to stage. The communication is a small point-to-point handoff rather than an all-reduce every layer, which is why it tolerates slower inter-node links and is the right tool for spanning nodes. The cost is pipeline bubbles: a stage sits idle waiting on the one before it, so per-request latency goes up even though throughput holds up fine once you have enough requests in flight to keep every stage fed. That’s the honest framing — pipeline parallelism is throughput-friendly and latency-unfriendly, which is why you use it to cross nodes and tensor parallelism inside them.
Quantization — trade quality for memory and speed
Quantization shrinks weights (and optionally activations/KV) to fewer bits. Smaller weights free HBM for KV cache and can speed up the memory-bound decode. All of it costs some accuracy; how much depends on scheme and model.
| Scheme | Bits | What’s quantized | When to use | Tradeoff |
|---|---|---|---|---|
| AWQ (+ Marlin kernel) | 4-bit | Weights only (activation-aware, protects salient weights) | Serving throughput on Ampere/Ada; strong quality at 4-bit | Needs a pre-quantized AWQ checkpoint; without a fast kernel INT4×FP16 GEMM only wins at batch size 1 (see below) |
| GPTQ (+ Marlin kernel) | 3/4/8-bit | Weights only (2nd-order error minimization) | Broad hardware/checkpoint availability | Quality can degrade at 3-bit; per-model calibration sensitivity |
| FP8 (E4M3) | 8-bit | Weights + activations (and KV via --kv-cache-dtype fp8) | Hopper/H100, Ada with hardware FP8; near-lossless, high throughput | Requires FP8-capable GPUs for full speedup |
| INT8 (SmoothQuant/W8A8) | 8-bit | Weights + activations | Good quality/speed balance where FP8 HW absent | More setup; less dramatic memory savings than 4-bit |
| bitsandbytes | 4/8-bit | Weights, on-the-fly | Quick experiments, no pre-quant step | Slower kernels; not the throughput champion |
| NVFP4 (via NVIDIA Model Optimizer) | 4-bit float | Weights + activations, Blackwell tensor-core native | Newest Blackwell (B200/GB200)-class hardware | Newest path — check current vLLM docs for model/kernel coverage before depending on it in production |
Saying it out loud. Quantization shrinks weights to fewer bits, which frees HBM for KV cache and can speed up memory-bound decode — and it always costs some accuracy, the only question is how much. The practical guidance splits by hardware. On Ampere or Ada, AWQ 4-bit with the Marlin kernel is the workhorse: roughly 4x less weight memory, which is a lot of KV headroom. On Hopper, prefer FP8 — it’s near-lossless and uses native tensor-core FP8 for a real speedup, and you can add
--kv-cache-dtype fp8to roughly double KV capacity on top. The thing to say out loud that separates a good answer: quantization shrinks weights, not the KV cache, so for long-context blowup the relevant levers are KV dtype and--max-model-len, not weight quantization.
Why a 4-bit checkpoint alone doesn’t make inference fast: the Marlin kernel
Quantizing weights to INT4 shrinks memory, but it does not automatically make compute faster. Running INT4 weights against FP16 activations means an unusual mixed-precision GEMM, and naive kernels for it only beat FP16 at batch size 1 — the exact regime where you’re not serving many users. At realistic serving batch sizes (8–32+ concurrent sequences), a poorly-implemented INT4 kernel leaves tensor cores underused and the 4-bit weight advantage evaporates.
Marlin (Frantar et al., “MARLIN: Mixed-Precision Auto-Regressive Parallel Inference on Large Language Models”) is the kernel that fixes this: it achieves close to the ideal 4× speedup for batch sizes up to ~32 tokens, by using tricks like asynchronous global-to-shared memory copies and careful weight layout so tensor cores stay busy across the whole realistic batch range, not just batch 1. vLLM ships Marlin-backed kernels as awq_marlin and gptq_marlin — when you pass --quantization awq or gptq today, vLLM auto-selects the Marlin kernel path where available, which is why a 2024-era checkpoint quantized with plain AutoAWQ still serves fast in current vLLM.
Guidance:
- Memory-constrained, throughput-focused, Ampere/Ada: AWQ 4-bit (Marlin kernel) is the workhorse — cuts weight memory ~4×, freeing large KV headroom, and holds close to ideal speedup up to moderate batch sizes.
- H100 / Hopper: prefer FP8 — near-lossless and uses native tensor-core FP8 for real speedups; add
--kv-cache-dtype fp8to roughly double KV capacity. - Blackwell (B200/GB200): NVFP4 via NVIDIA Model Optimizer is the emerging frontier for 4-bit that’s native to the hardware rather than dequantized on the fly — newer and less battle-tested than AWQ/FP8, validate carefully.
- Quality-sensitive tasks (code, math, long reasoning): measure. 4-bit weight-only can visibly hurt; validate on your eval set, not just perplexity (see the war story in section C).
- Quantization reduces weight memory, not KV — for long-context blowup,
--kv-cache-dtype fp8and--max-model-lenare the relevant levers.
Saying it out loud. This is a genuinely non-obvious point: quantizing weights to INT4 shrinks memory, but it does not automatically make compute faster. Running INT4 weights against FP16 activations is an unusual mixed-precision matmul, and naive kernels for it only beat FP16 at batch size one — which is exactly the regime where you’re not serving anybody. At realistic serving batches of eight to thirty-two concurrent sequences, a bad INT4 kernel leaves the tensor cores underused and the whole 4-bit advantage evaporates. Marlin is the kernel that fixes it, holding close to the ideal 4x speedup out to about batch 32 via asynchronous global-to-shared copies and careful weight layout. vLLM auto-selects the Marlin path when you ask for AWQ or GPTQ, which is why a 2024-era checkpoint still serves fast today.
Speculative decoding — lower latency, not more throughput
Mechanism: a cheap draft proposes several tokens ahead; the big target model verifies them all in one forward pass. Accepted tokens are kept; the first rejection resets to the target’s own token. Because verification is parallel, a good draft yields multiple tokens per target forward pass — output is provably identical in distribution to the target alone (it’s exact, not approximate).
vLLM supports several draft sources, each with a different cost/benefit:
- A small draft model — a separate, much smaller checkpoint from the same family runs ahead of the target. Simple, but needs a compatible small model and its own (tiny) forward-pass cost.
- n-gram / prompt-lookup decoding — instead of a neural draft, propose tokens by matching repeated n-grams already seen in the prompt or generation so far. Free of extra model weights; shines on code, tool-call replays, and RAG where the output echoes the input verbatim.
- EAGLE / EAGLE-3 / EAGLE-3.1 — a lightweight draft head attached to the target model’s own hidden states, trained to predict the next few tokens using the target’s internal representations rather than a fully independent model. Because it reuses target hidden states, EAGLE gets much higher acceptance length than an independent draft model of similar size. EAGLE-3.1 (vLLM blog, 2026-05-26) is a joint release between the EAGLE authors, the vLLM team, and the TorchSpec project that fixes an “attention drift” problem in deep speculation via FC-normalization and post-norm hidden-state feedback, reporting up to 2× longer accepted length than EAGLE-3 on long-context workloads and ~2.03× higher per-user throughput at single concurrency, tapering to ~1.7× at concurrency 4 and ~1.66× at concurrency 16 on the Kimi K2.6 model — a clean illustration that speculative gains shrink as the batch (and GPU compute saturation) grows.
- Medusa — multiple parallel decoding heads trained on top of the target model, each predicting a token at a fixed future offset; verified in the same forward pass as EAGLE-style methods. Simpler training setup than EAGLE, generally slightly lower acceptance.
When to use: latency-sensitive, low-to-moderate batch serving where the GPU has spare compute (decode is memory-bound, so verification is nearly free). Interactive chat, single-user, or bursty low-QPS endpoints, and — per the EAGLE-3.1 numbers above — still worthwhile at moderate concurrency, just with diminishing returns.
Tradeoff: the win depends entirely on acceptance rate. Low acceptance means you paid for drafting and got little back — it can reduce throughput. And under high batch load the GPU is already compute-saturated, so speculation’s “free” parallel verification isn’t free anymore — its benefit shrinks or reverses. Rule: speculate when you’re latency-bound and under-batched; skip it when you’re throughput-bound and saturated. vLLM’s speculators project (v0.3.0, vLLM blog 2025-12-13) now also supports training your own draft/EAGLE heads against a target model, rather than relying only on community-published drafts — useful if your traffic distribution doesn’t match the drafts published for a given base model.
Saying it out loud. The mechanism is: a cheap draft proposes several tokens ahead, the big target model verifies all of them in one forward pass, accepted tokens are kept and the first rejection resets. Because verification is parallel, a good draft yields multiple tokens per target forward pass — and critically the output is provably identical in distribution to the target alone, so it’s exact, not an approximation. But the title is the important part. Speculation helps when you’re latency-bound and under-batched, because decode is memory-bound and the spare compute makes verification nearly free. Under high batch load the GPU is already compute-saturated, so that free lunch disappears — the EAGLE-3.1 numbers show it: roughly 2x at concurrency one, tapering to about 1.66x at concurrency sixteen. Low acceptance rate can make it a net loss.
Fully worked example: serve Llama-3-8B on one A100-80GB, then tune
1. Baseline launch (OpenAI-compatible server)
vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
--host 0.0.0.0 --port 8000 \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
At startup, read the log:
INFO ... # GPU blocks: 27600, # CPU blocks: 2048
INFO ... Maximum concurrency for 8192 tokens per request: 53.9x
That confirms the ~442k-token / ~54-concurrent budget we computed by hand.
Saying it out loud. The baseline launch is genuinely one command —
vllm servewith a model ID, a host and port, and a couple of memory flags — and what you get is an OpenAI-compatible HTTP server, so any existing SDK works by just repointingbase_url. That API compatibility is a bigger deal than it sounds, because it means migrating off a hosted provider is a config change rather than a rewrite. What you should do immediately after launching, before touching any tuning, is read the startup logs: available KV cache memory, GPU KV cache size in tokens, and maximum concurrency. Those three lines convert your abstract flags into the one number that governs throughput — how many tokens of KV you can actually hold.
2. Call it with the OpenAI client
The server speaks the OpenAI API, so existing SDKs work unchanged — just point base_url at vLLM:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=[
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "Explain PagedAttention in two sentences."},
],
max_tokens=128,
temperature=0.2,
stream=True, # tokens stream as they decode
)
for chunk in resp:
delta = chunk.choices[0].delta.content or ""
print(delta, end="", flush=True)
curl sanity check:
curl http://localhost:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"model":"meta-llama/Meta-Llama-3-8B-Instruct","prompt":"Hello","max_tokens":16}'
3. Tuning walkthrough (target: max throughput for a RAG service)
Symptoms to check first with a load test (vllm bench serve or a locust/k6 run): GPU util, tokens/s, p99 ITL, and any preempted warnings.
- Shared 1500-token system+retrieval preamble across requests → keep
--enable-prefix-caching(default on). Hit rate is high; prefill work drops sharply. - GPU shows headroom, no OOM → push memory:
Bigger token budget = fatter prefill chunks and higher throughput; more seqs = more concurrency, backed by the enlarged KV pool.vllm serve meta-llama/Meta-Llama-3-8B-Instruct \ --gpu-memory-utilization 0.94 \ --max-num-seqs 384 \ --max-num-batched-tokens 16384 \ --enable-chunked-prefill \ --max-model-len 8192 - p99 inter-token latency too high (big prompts stalling decodes) → lower
--max-num-batched-tokens(e.g. to 4096). Smaller chunks yield to decodes more often, smoothing ITL at a small throughput cost. Sequence group is preempted by ...warnings → you over-committed KV. Either drop--max-num-seqs, drop--max-model-len, or raise--swap-spaceso preempted sequences swap to CPU instead of recomputing.- Need more KV for longer contexts → add
--kv-cache-dtype fp8(roughly doubles KV capacity) and/or--quantization fp8on an H100 to also shrink weights. - Model too big for one GPU (e.g. Llama-3-70B) →
--tensor-parallel-size 4(or 8) within the node; across nodes add--pipeline-parallel-size 2.
Iterate: change one knob, re-run the load test, compare tokens/s and p99. Stop when you’re memory-limited (preemptions appear) or latency SLO-limited.
Saying it out loud. Tuning is symptom-driven, and the discipline is one knob at a time with a load test between each. If you have a big shared system-and-retrieval preamble, keep prefix caching on — hit rate is high and prefill work drops sharply. If the GPU has headroom and you’re not OOM-ing, push memory utilization toward 0.94 and raise both the sequence and token budgets. If p99 inter-token latency is bad, lower
max-num-batched-tokens, because smaller prefill chunks yield to decodes more often at a small throughput cost. If you seepreemptedwarnings, you’ve over-committed KV — dropmax-num-seqsormax-model-len, or add swap space. And you stop when you hit either preemption or your latency SLO, whichever comes first.
(A) The 2025–2026 landscape
vLLM has moved fast since the original SOSP ’23 paper. If you last read the codebase in 2023–2024, four things have materially changed the picture — an interviewer who works in this space will expect you to know all four, with rough dates.
Saying it out loud. If you last looked at vLLM in 2023, four things have materially changed the picture. The V1 engine is a from-scratch rewrite with a unified scheduler and an isolated engine process, and it made chunked prefill and prefix caching default-on. Disaggregated prefill/decode moved from experimental flag to a real pattern, splitting the two phases onto physically separate GPU pools. Speculative decoding grew from “small draft model” into a family with EAGLE-3 as the acceptance-length leader. And quantization consolidated on Marlin-backed 4-bit for Ampere and FP8 for Hopper. The practical version: if a tutorial tells you to manually pass
--enable-chunked-prefill, that advice is stale, and repeating it in an interview signals your mental model is frozen at the paper.
A.1 The V1 engine rewrite
vLLM V1 (“V1: A Major Upgrade to vLLM’s Core Architecture,” vLLM blog, 2025-01-27) is a from-scratch rewrite of the engine core, not an incremental patch, and it’s the default engine in current releases. The headline changes:
- Unified scheduler. V0 had separate code paths and heuristics for prefill-only steps, decode-only steps, and chunked-prefill mixing. V1 represents every request uniformly as
{request_id: num_tokens}and schedules prefill tokens and decode tokens through one code path — this is what makes chunked prefill, prefix caching, and speculative decoding compose cleanly instead of each needing bespoke scheduler logic. - Isolated
EngineCoreprocess. The scheduler/model-execution loop runs in its own process, decoupled from the API server, communicating over a fast IPC path. Tokenization, de-tokenization, and request bookkeeping — all CPU work — overlap with GPU execution instead of serializing with it. This matters more every year as GPUs get faster and CPU-side overhead becomes the bottleneck it never used to be. - On by default: chunked prefill and prefix caching are default-on in V1, and prefix caching in particular was specifically engineered to add “near-zero performance degradation, even when the cache hit rate is 0%” — so there’s little reason to ever turn it off defensively.
- Measured gains: vLLM’s own numbers show up to 1.7× higher throughput vs V0 on Llama-3.1-8B/70B text serving, with even larger gains on vision-language models (e.g. Qwen2-VL) due to multimodal-specific scheduling improvements.
Practical implication: if you’re reading a pre-2025 vLLM tutorial telling you to manually pass --enable-chunked-prefill or explain why prefix caching costs overhead at 0% hit rate, that advice is stale — verify against the current vllm serve --help and the V1 guide before repeating it in an interview.
Saying it out loud. V1 is a rewrite of the engine core, not a patch, and it’s the default now. Two structural changes matter. The scheduler is unified: V0 had separate code paths for prefill-only steps, decode-only steps, and chunked mixing, whereas V1 represents every request uniformly as a request-ID-to-token-count map and schedules both through one path — which is what lets chunked prefill, prefix caching, and speculation compose cleanly instead of each needing bespoke logic. And the engine core runs in its own process, so tokenization, detokenization, and bookkeeping — all CPU work — overlap with GPU execution instead of serializing with it. That second one matters more every year, because as GPUs get faster the CPU side becomes a bottleneck it never used to be. Measured gain: up to 1.7x throughput over V0.
A.2 Disaggregated prefill/decode serving
Co-locating prefill (compute-bound) and decode (memory-bound) on the same GPUs is simple but creates exactly the interference chunked prefill only partially smooths over: a burst of long prompts still steals cycles from in-flight decodes, spiking ITL.
Disaggregated serving takes this further: prefill and decode run on physically separate GPU pools, with the KV cache computed during prefill transferred to the decode pool (over NVLink/RDMA) rather than recomputed. A connector/proxy layer routes each request through prefill first, then decode, streaming KV in between. This has moved from “experimental” (an early disaggregated-prefill feature flag shipped as far back as v0.7.x) to a first-class pattern with dedicated KV-transfer connectors.
A concrete, dated example: AMD’s MORI-IO KV connector (vLLM blog, 2026-04-07, “Next-Level Inference: Why Your Single-Node vLLM Setup Needs Prefill-Decode Disaggregation”) splits one 8-GPU node into a 4-GPU prefill pool and a 4-GPU decode pool, with an RDMA-based connector transferring KV cache in either read mode (decode pulls KV after prefill finishes) or write mode (prefill streams KV concurrently as it computes). Reported result: ~2.5× higher goodput than standard collocated serving on identical hardware, at the cost of somewhat higher TTFT (the request now hops between two GPU pools) in exchange for much more stable ITL. PyTorch’s own engineering blog (“Disaggregated Inference at Scale with PyTorch & vLLM”) and Ray Serve’s LLM docs (prefill-decode.html) describe the same pattern at larger, multi-node scale.
When it’s worth the complexity: high-scale deployments with long, variable prompts and a strict ITL/streaming-smoothness SLO, where you can afford separate autoscaling groups for prefill vs decode. For most single-node, moderate-QPS deployments, chunked prefill co-location is simpler and sufficient — disaggregation is an optimization you reach for once co-located tuning has plateaued.
Saying it out loud. Chunked prefill smooths prefill/decode interference but doesn’t eliminate it — a burst of long prompts still steals cycles from in-flight decodes. Disaggregation takes it further: prefill and decode run on physically separate GPU pools, and the KV cache computed during prefill is transferred over NVLink or RDMA to the decode pool rather than recomputed. AMD’s MORI-IO connector work splits one 8-GPU node into a 4-GPU prefill pool and a 4-GPU decode pool and reports about 2.5x higher goodput on identical hardware. The tradeoff is explicit and worth naming: TTFT goes up, because the request now hops between two pools, in exchange for much more stable inter-token latency. For a single-node moderate-QPS deployment, co-located chunked prefill is simpler and sufficient.
A.3 Speculative decoding, current state
As detailed in the Speculative Decoding section above, the field has moved from “small draft model” as the default mental model to a family of methods, with EAGLE-3 / EAGLE-3.1 as the current state of the art for acceptance length (see the 2026-05-26 vLLM blog for the joint EAGLE/vLLM/TorchSpec numbers), Medusa as a simpler parallel-head alternative, and n-gram/prompt-lookup as a zero-extra-weights option for code/RAG-style repetition. AMD’s Quark toolchain now also supports training and serving EAGLE-3 drafters on Instinct GPUs (vLLM blog, 2026-07-13), and Red Hat’s developer blog (2026-04-16) documents concrete speedups applying speculative decoding to gpt-oss-style open models — evidence this is now routine production tooling, not a research curiosity.
Saying it out loud. The mental model has moved on from “speculative decoding means a small draft model.” There are three families now. A separate small draft model is the classic, simple but needs a compatible checkpoint. N-gram or prompt-lookup drafting uses no extra weights at all — it just proposes tokens by matching repeated n-grams already in the prompt, which shines on code, tool-call replays, and RAG where output echoes input verbatim. And EAGLE-family methods attach a lightweight draft head to the target model’s own hidden states, which is why they get much higher acceptance length than an independent draft of similar size. EAGLE-3.1 is the current state of the art. Tooling has caught up too — vLLM’s
speculatorsproject can now train your own drafts against your traffic distribution.
A.4 Quantization, current state
awq_marlin and gptq_marlin (the Marlin-kernel-backed paths, see the Quantization section above) are the default fast path for 4-bit weight-only quantization on Ampere/Ada/Hopper today — vLLM auto-selects them when you request awq/gptq if the kernel is available for your hardware. FP8 W8A8 (via LLM Compressor / llmcompressor) is the standard Hopper-class recipe. NVIDIA Model Optimizer integration brings NVFP4 onto vLLM’s roadmap for Blackwell-class (B200/GB200) hardware — check the current docs.vllm.ai/en/latest/features/quantization/ page for exact model/kernel coverage before committing to it, since this is the newest and fastest-moving corner of the quantization stack.
Saying it out loud. The current picture is simple enough to state in one breath. On Ampere, Ada, and Hopper, the Marlin-backed paths —
awq_marlinandgptq_marlin— are the default fast path for 4-bit weight-only, and vLLM auto-selects them when you ask for AWQ or GPTQ if the kernel exists for your hardware. On Hopper-class, FP8 W8A8 via LLM Compressor is the standard recipe and is close to lossless in practice. And NVFP4 through NVIDIA’s Model Optimizer is the emerging Blackwell-native 4-bit path. The caveat to attach every time: this is the fastest-moving corner of the stack, so check the current quantization docs for exact model and kernel coverage before you commit a production deployment to any of it.
A.5 vLLM vs SGLang vs TensorRT-LLM, today
The three serious open-source-adjacent engines have converged on the same core ideas (paged KV, continuous/in-flight batching) and now differentiate on specialization and operational cost:
| Dimension | vLLM | SGLang | TensorRT-LLM |
|---|---|---|---|
| Core differentiator | Broadest model/hardware support, fastest to deploy, strong default performance | RadixAttention — a radix-tree KV cache index built specifically to maximize prefix-sharing across requests | Ahead-of-time compiled kernels/engines for peak NVIDIA-GPU performance |
| Best workload fit | General-purpose serving, fast iteration across many model families | Heavy prefix-sharing workloads: chatbots, RAG, agent loops, few-shot prompting | Fixed, long-lived production model where engine-build cost amortizes |
| Setup / cold start | Low — single pip install / one command; ~1 minute cold start | Low — comparable to vLLM | High — per-model, per-shape engine compilation; can take tens of minutes |
| Hardware | NVIDIA + AMD ROCm + others | Primarily NVIDIA, growing ROCm support | NVIDIA only |
| Quantization | AWQ/GPTQ (Marlin), FP8, INT8, bnb, emerging NVFP4 | AWQ, GPTQ, FP8 | INT4/INT8, FP8 — deeply kernel-optimized |
| Speculative decoding | Draft model, n-gram, EAGLE/EAGLE-3.1, Medusa | EAGLE, other draft methods | Draft model, EAGLE, Medusa |
A representative third-party benchmark (Spheron Blog, “vLLM vs TensorRT-LLM vs SGLang: Which Is Fastest? H100 Benchmarks,” dated 2026-03-23; single H100 SXM5 80GB, Llama-3.3-70B-Instruct at FP8, 50 concurrent requests) reported: TensorRT-LLM ≈ 2,100 tok/s, SGLang ≈ 1,920 tok/s, vLLM ≈ 1,850 tok/s on raw output throughput, with TTFT p50 at 10 requests of 105 ms / 112 ms / 120 ms respectively — but a ~28-minute TensorRT-LLM engine-compilation cold start versus ~1 minute for vLLM and SGLang. Treat any single third-party number like this as a snapshot, not gospel — engines change fast, and you should always benchmark on your own model, hardware, and traffic shape before deciding. The qualitative conclusion that has held up across most 2025–2026 write-ups: TensorRT-LLM wins raw throughput/latency on fixed NVIDIA hardware at the cost of build complexity and vendor lock-in; SGLang wins when prefix-sharing dominates your traffic (its RadixAttention is purpose-built for exactly that); vLLM remains the pragmatic default for broad model support, hardware flexibility, and fast time-to-serve.
Saying it out loud. All three have converged on paged KV and continuous batching, so they now differentiate on specialization and operational cost. TensorRT-LLM wins raw throughput and latency on fixed NVIDIA hardware — a representative H100 benchmark put it around 2,100 tokens per second against SGLang’s 1,920 and vLLM’s 1,850 — but it pays for that with a roughly 28-minute per-model engine compilation versus about a minute for the other two, plus NVIDIA-only lock-in. SGLang’s differentiator is RadixAttention, a radix-tree KV index purpose-built to maximize prefix sharing, so it wins when your traffic is chatbots, RAG, or agent loops. And vLLM stays the pragmatic default for breadth of model and hardware support and time-to-serve. Treat any single benchmark as a snapshot — benchmark your own model and traffic before deciding.
A.6 Kubernetes-native orchestration: llm-d
Running one vllm serve process is straightforward; running a fleet of disaggregated prefill and decode workers, each independently autoscaled, behind smart routing, is a distributed-systems problem in its own right. llm-d (announced 2025-05-20, authored by engineers from Red Hat, Google, and IBM, now a CNCF Sandbox project) exists to standardize that problem: it’s a Kubernetes-native framework that, in its own words, provides “a well-lit path for anyone to serve at scale,” built directly on top of vLLM rather than replacing it.
Concretely, llm-d integrates two pieces with vLLM:
- The Gateway API Inference Extension (IGW) — Kubernetes-native routing that understands LLM-specific signals (KV-cache locality, current queue depth, prefix-cache affinity) instead of routing on generic HTTP load metrics. A request can be routed to the replica most likely to already have its prefix cached, turning prefix-cache hit rate (section B.4) into a cluster-wide property instead of a single-replica one.
- Disaggregated serving as a first-class deployment pattern — llm-d’s “well-lit paths” (its v0.2 release, per the llm-d blog) package the prefill/decode split from section (A.2) as a supported, documented Kubernetes deployment topology — separate prefill and decode
Deployments, independently autoscaled, with the KV-transfer connector wiring already solved — rather than something each team hand-rolls.
When it’s worth adopting: once you’re already running vLLM behind Kubernetes at a scale where you’d otherwise be hand-building the routing and disaggregation plumbing from section (A.2) and (B) yourself. For a single-node or small-fleet deployment, plain vllm serve behind a conventional load balancer (with sticky routing by prefix hash if you want DIY prefix-affinity) remains simpler and is not something to give up prematurely.
Saying it out loud. Running one
vllm serveis easy; running a fleet of independently-autoscaled disaggregated prefill and decode workers behind smart routing is a distributed-systems problem. llm-d exists to standardize that — a Kubernetes-native framework built on top of vLLM rather than replacing it, now a CNCF sandbox project. Two pieces matter. It uses the Gateway API Inference Extension, which routes on LLM-specific signals like prefix-cache affinity and queue depth instead of generic HTTP metrics — that turns prefix-cache hit rate from a per-replica property into a cluster-wide one. And it packages the prefill/decode split as a supported deployment topology with the KV-transfer wiring already solved. Worth adopting once you’d otherwise be hand-building that plumbing; premature otherwise.
A.7 Timeline: how fast this space is moving
Concrete, dated milestones from 2025–2026 worth having ready in an interview — the point isn’t memorizing dates, it’s demonstrating you track a fast-moving space with real sources rather than a static mental model frozen at the 2023 paper:
| Date | Milestone |
|---|---|
| 2025-01-27 | vLLM V1 alpha released — unified scheduler, isolated EngineCore, chunked prefill + prefix caching on by default |
| 2025-05-20 | llm-d announced (Red Hat/Google/IBM) — Kubernetes-native distributed inference built on vLLM |
| 2025-07-01 | Red Hat developer blog on EAGLE-3 speculative decoding speedups in vLLM |
| 2025-12-13 | vLLM speculators v0.3.0 — training support for custom draft/EAGLE heads |
| 2025-12-17 | vLLM large-scale MoE serving results (DeepSeek-class, wide expert-parallelism) |
| 2026-03-23 | Third-party H100 benchmark comparing vLLM, SGLang, and TensorRT-LLM throughput/TTFT/cold-start |
| 2026-04-07 | AMD MORI-IO KV connector blog — disaggregated prefill/decode, ~2.5× goodput on one node |
| 2026-04-16 | Red Hat developer blog on speculative decoding performance for gpt-oss-style models |
| 2026-05-26 | EAGLE 3.1 released jointly by the EAGLE authors, vLLM, and TorchSpec teams |
| 2026-07-13 | EAGLE-3 speculative decoding training/serving support on AMD Instinct via AMD Quark |
Treat this table as a snapshot as of this chapter’s writing, not a permanent record — check vllm.ai/blog and docs.vllm.ai directly for what’s shipped since.
(B) Build it in practice — extended: multi-node 70B+, benchmarking, and reading the tuning signal
The single-GPU 8B walkthrough above teaches the knobs. Real “flagship” deployments — a 70B+ model, multiple nodes, a load test, and a principled read of why you’re moving each dial — is where interviews (and production) actually live. This section builds that end to end.
Saying it out loud. The single-GPU walkthrough teaches you the knobs; the multi-node one is where interviews actually live, because it forces you to reason about topology, measurement, and cost together. The arc is: size the problem from the weight math, launch across nodes with the right TP-times-PP split, load-test it the way clients will actually hit it, read the Prometheus metrics to diagnose rather than guess, turn those metrics into alerts, and finally convert throughput into dollars per million tokens. Each of those steps is a place people skip straight to “we tuned it and it seemed fine” — and the difference between guessing and diagnosing is entirely in step four, reading KV cache usage and prefix hit rate side by side.
B.1 Sizing the problem: Llama-3.1-70B, fp16, across 2 nodes
Weights: (70\text{B} \times 2\ \text{bytes} = 140\ \text{GB}) — bigger than any single 80 GB GPU, and even TP-8 on one 8×80GB node leaves only (80 - 140/8 = 62.5\ \text{GB}) per GPU before KV, activations, and overhead — workable, but tight if you also want a large --max-model-len and high concurrency. We’ll instead spread the model across two 8-GPU nodes (16 GPUs total): -tp 8 inside each node (over NVLink) and -pp 2 across the two nodes (over the slower inter-node fabric), per the TP-first-then-PP rule from the Parallelism section above.
Saying it out loud. Start from the weight math. 70 billion parameters in fp16 is 140 gigabytes, which is bigger than any single 80-gig card. TP=8 on one 8-GPU node would put 17.5 gigabytes of weights on each card, leaving around 62 for KV and overhead — workable, but tight if you also want long context and high concurrency. So you spread across two 8-GPU nodes: TP=8 inside each node over NVLink, and PP=2 across the two nodes over the slower fabric. That’s the tensor-parallel-first-then-pipeline rule applied concretely. And note what drove the decision — not “it doesn’t fit,” because it technically does, but the KV headroom left over after it fits, which is the number that determines your actual concurrency.
B.2 Multi-node launch with Ray
vLLM’s multi-node path is built on Ray: one node starts the Ray head, the other joins as a worker, and vllm serve is launched once, on the head node, with the combined -tp × -pp world size.
On node 0 (head):
# Start the Ray cluster head. Pick a stable port; other nodes connect here.
ray start --head --port=6379 --num-gpus=8
# Confirm the cluster sees both nodes before launching vLLM:
ray status
On node 1 (worker):
# Point at node 0's IP (the Ray head), join with its 8 GPUs.
ray start --address='<NODE0_IP>:6379' --num-gpus=8
Back on node 0, launch the server once Ray reports 16 GPUs total:
vllm serve meta-llama/Llama-3.1-70B-Instruct \
--host 0.0.0.0 --port 8000 \
--dtype bfloat16 \
--tensor-parallel-size 8 \
--pipeline-parallel-size 2 \
--max-model-len 32768 \
--gpu-memory-utilization 0.90 \
--enable-chunked-prefill \
--enable-prefix-caching
vLLM’s launcher detects the existing Ray cluster and places the 16 model-parallel workers across both nodes automatically (8 TP ranks per PP stage, one PP stage per node). Watch the startup log for per-GPU # GPU blocks — with TP-8, each GPU holds (1/8) of the weights and KV heads, so KV capacity is aggregated across all 8 GPUs of a TP group, not duplicated.
Topology, in words:
Node 0 (Ray head, PP stage 0, TP ranks 0-7) Node 1 (Ray worker, PP stage 1, TP ranks 0-7)
+------------------------------------------+ +------------------------------------------+
| GPU0 GPU1 GPU2 GPU3 GPU4 GPU5 GPU6 GPU7 | | GPU0 GPU1 GPU2 GPU3 GPU4 GPU5 GPU6 GPU7 |
| \____/\____/\____/\____/\____/\____/\____/ | | \____/\____/\____/\____/\____/\____/\____/ |
| NVLink all-reduce per layer (TP=8) | | NVLink all-reduce per layer (TP=8) |
| holds layers 1..40 (first half) | | holds layers 41..80 (second half) |
+--------------------+----------------------+ +----------------------+-------------------+
| inter-node link (PP hand-off: activations only, point-to-point) |
+----------------------------------------------------------------->+
Each node’s 8 GPUs form one TP group cooperating over fast NVLink on the same half of the model’s layers; the only traffic crossing the slower inter-node link is the PP hand-off — the activation tensor passed from the last layer of stage 0 to the first layer of stage 1 — which is exactly why PP, not TP, is the parallelism strategy that tolerates crossing nodes. Confusing the two (e.g. setting -tp 16 across both nodes instead of -tp 8 -pp 2) forces the per-layer all-reduce itself over the slow inter-node link, which is a common and expensive multi-node misconfiguration.
Quantized alternative, fewer GPUs, one node: if 16 GPUs aren’t available, an AWQ 4-bit checkpoint drops weights to ~35 GB, fitting comfortably on 2 GPUs with room for KV:
vllm serve casperhansen/llama-3-70b-instruct-awq \
--quantization awq_marlin \
--tensor-parallel-size 2 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
Decision order, restated for a 70B+ model: fit on one node with TP first → quantize to shrink weights and cut GPU count if hardware is scarce → go multi-node with PP only when a single node genuinely cannot hold model + working KV.
Saying it out loud. vLLM’s multi-node path runs on Ray: one node starts the Ray head, the other joins as a worker, and then you launch
vllm serveexactly once, on the head node, with the combined tensor-parallel times pipeline-parallel world size. The thing that surprises people is that it’s a single launch, not one per node — Ray places the workers for you. Two practical gotchas: the inter-node network has to actually be fast enough for the pipeline hand-off, and every node needs the same model weights accessible, which usually means a shared filesystem or a pre-warmed local cache. And your failure domain just got bigger — losing either node takes the whole replica down, which is why the readiness probe and router behavior matter more here than in the single-node case.
Health checks and readiness probes
A vLLM pod behind Kubernetes needs its readiness probe to reflect the real 60–90 second cold start from section C.3, not just “process is up”:
readinessProbe:
httpGet:
path: /health # returns 200 only once the engine has finished loading and is serving
port: 8000
initialDelaySeconds: 20
periodSeconds: 5
failureThreshold: 30 # allow up to ~2.5 minutes for weight load + CUDA graph capture
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120 # only starts checking liveness well after cold start should be done
periodSeconds: 15
failureThreshold: 3
The distinction matters operationally: a readiness failure just removes the pod from the routing pool (correct during cold start); a liveness failure restarts the container (wrong during cold start — it would restart a pod that’s still legitimately loading, resetting its progress and potentially causing the exact oscillation from section C.3). Give liveness a much longer initialDelaySeconds than readiness for precisely this reason.
Saying it out loud. A vLLM pod’s readiness probe has to reflect a real 60-to-90-second cold start, not just “the process is up.” That cold start is dominated by loading weights into GPU memory and then CUDA graph capture, and neither is something you can rush at probe time. So the probe hits an endpoint that only returns healthy once the engine is actually serving, and you give it a startup budget sized to the measured worst case — measured, not guessed. Getting this wrong has two distinct failure modes worth naming separately: too tight and Kubernetes crash-loops a perfectly healthy pod mid-load, too loose and the load balancer routes real traffic at a replica that isn’t ready and every one of those requests times out.
B.3 Benchmark run against the multi-node deployment
Never tune by feel — load-test the deployment exactly as clients will hit it:
vllm bench serve \
--model meta-llama/Llama-3.1-70B-Instruct \
--base-url http://<NODE0_IP>:8000 \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 2000 \
--request-rate 30 \
--max-concurrency 256
This reports, per run: throughput (tokens/s, req/s), TTFT (p50/p90/p99), ITL/TPOT (p50/p90/p99), and total duration. Sweep --request-rate across a few values (e.g. 10, 20, 30, 50) to trace out a throughput-vs-latency curve — the shape you’re looking for is a “knee” where p99 latency starts climbing steeply; that’s your practical capacity ceiling, not whatever number a spec sheet claims.
Saying it out loud. Never tune by feel — load-test the deployment exactly the way clients will hit it.
vllm bench serveagainst a realistic dataset like ShareGPT gives you throughput in tokens and requests per second, plus TTFT and inter-token latency at p50, p90, and p99. The important part isn’t running it once, it’s sweeping--request-rateacross a few values — say 10, 20, 30, 50 — to trace the throughput-versus-latency curve. What you’re looking for is the knee, the point where p99 starts climbing steeply while throughput has stopped rising. That knee is your practical capacity ceiling, and it’s almost always well below whatever number a spec sheet or a vendor benchmark claims.
B.4 Reading GPU memory utilization vs KV cache hit rate while tuning
This is the step people skip, and it’s the difference between guessing and diagnosing. vLLM exposes Prometheus metrics on /metrics; the two to watch side by side are:
vllm:gpu_cache_usage_perc— how full the KV cache pool is right now (0–1). Sustained values near 1.0 mean you are memory-bound and close to triggering preemption.vllm:gpu_prefix_cache_hit_rate(and the block-level hit/query counters it’s derived from) — the fraction of prefill tokens served from the prefix cache instead of recomputed.
Pull them directly while load-testing:
curl -s http://<NODE0_IP>:8000/metrics | grep -E 'gpu_cache_usage_perc|gpu_prefix_cache|num_requests_running|num_requests_waiting'
How to read the combination:
| KV cache usage | Prefix hit rate | Diagnosis | Action |
|---|---|---|---|
| Low (< 0.5) | Low | Under-loaded — you have slack in both memory and reuse | Raise --request-rate in your test, or accept more traffic; consider raising max-num-seqs |
| High (> 0.9), no preemption | High | Healthy: cache is full of useful (reused) blocks doing real work | This is close to the target operating point — leave it, or push gpu-memory-utilization up slightly for more headroom |
| High (> 0.9), preemption warnings appearing | Low | You’re evicting active batch to make room, and it isn’t even prefix cache doing the crowding | Lower max-num-seqs / max-model-len, or add --swap-space; this is memory over-commitment, the failure mode in section C.1 |
| Low–moderate | Dropping over time under load | Cache pressure is evicting prefix blocks before they’re reused | Traffic doesn’t have the reuse you assumed, or memory is too tight to keep both active batch and cached prefixes — reconsider whether prefix caching is earning its keep for this workload |
The general principle: KV cache usage tells you how full the tank is; prefix hit rate tells you how much of that fullness is “free” reused work versus active, paying-for-itself batch. A full tank with a high hit rate is efficient. A full tank with a low hit rate and rising preemption is the KV over-commitment failure mode — tune max-num-seqs/max-model-len/swap-space, not gpu-memory-utilization upward.
Saying it out loud. This is the step people skip and it’s the difference between guessing and diagnosing. Two metrics, read side by side.
gpu_cache_usage_perctells you how full the KV tank is; sustained near 1.0 means you’re memory-bound and about to preempt.gpu_prefix_cache_hit_ratetells you what fraction of prefill tokens came from cache instead of being recomputed. The combination is what diagnoses. High usage with a high hit rate is healthy — the tank is full of reused work doing real good. High usage with a low hit rate and preemption warnings is the over-commitment failure mode, and the fix is loweringmax-num-seqsormax-model-len, not raisinggpu-memory-utilization. Usage tells you how full; hit rate tells you how much of that fullness is free.
B.5 Observability: turning the metrics into alerts
Reading /metrics by hand during a tuning session is step one; the same signals belong in a Grafana dashboard fed by Prometheus scraping vLLM, with alerts that page before an SLO breach rather than after. A minimal, high-signal alert set built directly on the metrics from B.4:
# Prometheus alerting rules (excerpt) for a vLLM deployment
groups:
- name: vllm-capacity
rules:
- alert: VLLMKVCacheNearFull
expr: vllm:gpu_cache_usage_perc > 0.95
for: 2m
labels: {severity: warning}
annotations:
summary: "KV cache >95% full for 2m — preemption risk rising"
- alert: VLLMPreemptionRateHigh
expr: rate(vllm:num_preemptions_total[5m]) > 0
for: 1m
labels: {severity: critical}
annotations:
summary: "Active preemption/recompute thrash — see section C.1 playbook"
- alert: VLLMPrefixCacheHitRateCollapsed
expr: vllm:gpu_prefix_cache_hit_rate < 0.2
for: 10m
labels: {severity: warning}
annotations:
summary: "Prefix cache hit rate collapsed — cache pressure or traffic shape changed"
- alert: VLLMQueueBacklogGrowing
expr: vllm:num_requests_waiting > 20
for: 3m
labels: {severity: warning}
annotations:
summary: "Requests queueing — under-provisioned for current load"
The key discipline: alert on VLLMPreemptionRateHigh directly, not only on downstream p99-latency SLO burn. Section (C.1)’s incident was visible in preemption logs minutes before it became a paging-worthy latency alert — closing that gap is the entire value of this dashboard. Pair it with a panel plotting gpu_cache_usage_perc and gpu_prefix_cache_hit_rate on the same time axis as request rate, so a traffic-shape change (e.g. the C.1 burst of long documents) is visually obvious as “cache usage climbed while hit rate stayed flat” rather than requiring someone to piece it together from logs after the fact.
Saying it out loud. Reading
/metricsby hand during a tuning session is fine; leaving it there is not. The same signals belong on a Grafana dashboard with alerts that page before an SLO breach rather than after. A minimal high-signal set: KV cache usage sustained near full, preemption rate above zero for any meaningful window, queue depth trending up, and prefix cache hit rate dropping. The point that generalizes past vLLM: the metric that would have caught each of this chapter’s incidents earliest is almost never the one on the primary latency-and-error-rate dashboard. Alerting only on SLO burn means you always find out after the users do — alerting on the engine’s own internal pressure signals is what turns postmortems into things you catch in minutes.
B.6 Cost-per-token worked calculation
Tie the tuning knobs back to a dollar figure, since that’s what a “lowest cost per token” system-design answer (section D.2) ultimately needs to produce. Using the multi-node Llama-3.1-70B deployment from B.1–B.2 as the base case, and approximate on-demand H100 pricing of ~$2/GPU-hour (use your actual contracted rate — this varies widely by cloud and commitment):
| Configuration | GPUs | $/hour (fleet) | Throughput (tok/s, from a vllm bench serve sweep) | Approx cost per 1M output tokens |
|---|---|---|---|---|
| fp16, TP8×PP2 (B.2 Option A/C) | 16 | $32/hr | ~2,000 tok/s aggregate | $32 / (2000×3600/1e6) ≈ $4.44 |
| AWQ 4-bit, TP2 (B.2 Option B) | 2 | $4/hr | ~1,400 tok/s (single replica, smaller batch ceiling) | $4 / (1400×3600/1e6) ≈ $0.79 |
| FP8, TP4, one node | 4 | $8/hr | ~1,800 tok/s | $8 / (1800×3600/1e6) ≈ $1.23 |
The arithmetic is simply (\text{cost per 1M tokens} = \dfrac{\text{fleet $/hr}}{\text{tokens/s} \times 3600 / 10^6}). The point isn’t that any one row is “correct” — real throughput numbers must come from your own vllm bench serve sweep against real traffic (section B.3), and real GPU pricing depends on your contract — it’s that this is the calculation an interviewer wants to see you reach for, and that quantization plus right-sizing GPU count can move cost per token by 3–5× for the same model, which is usually a bigger lever than any single scheduler flag.
Saying it out loud. Tie the knobs to dollars, because that’s what a “lowest cost per token” answer needs to produce. The arithmetic is just fleet dollars per hour divided by tokens per second times 3,600, over a million. Using roughly $2 per H100-hour as of this writing — use your own contracted rate, it moves a lot — a 16-GPU fp16 fleet at 2,000 tokens per second is about $4.44 per million output tokens, a 4-GPU FP8 setup at 1,800 tokens per second is about $1.23, and a 2-GPU AWQ 4-bit replica at 1,400 is about $0.79. The point isn’t that any row is right — real throughput has to come from your own sweep. It’s that quantization plus right-sizing GPU count moves cost per token by three to five times, which dwarfs any single scheduler flag.
B.7 Smoke-test checklist before going live
A short, concrete pre-launch checklist that exercises the failure modes this chapter covers, run against the actual deployment before it takes real traffic:
- Startup log sanity — confirm
# GPU blocks,Available KV cache memory, andMaximum concurrency(section “Reading the startup logs”) match hand-calculated expectations from section B.1; a mismatch usually means a flag was set differently than intended. - Max-context request — send one request at exactly
--max-model-lentokens and confirm it succeeds without error (catches the “max-num-batched-tokenstoo low with chunked prefill off” failure mode). - Concurrent burst at target peak QPS — run
vllm bench serveat the traffic rate you actually expect (section B.3), and confirm zeropreemptedlog lines appear at that load; if they do, capacity is under-provisioned for the stated SLO, not just for the benchmark. - Prefix-cache hit-rate check — replay a realistic sample of production-shaped traffic (shared system prompt / multi-turn conversation) and confirm
vllm:gpu_prefix_cache_hit_rateis non-trivial if your workload assumes reuse; a flat 0% means either prefix caching is misconfigured or the traffic doesn’t actually share prefixes the way you assumed. - Cold-start timing — measure actual time from pod scheduling to
/healthreturning 200, and set the readiness probe / autoscaler lead time (section C.3, B.2) from the measured number, not a guess. - Quantization quality gate (if applicable) — run the task-specific pass-rate eval from section C.2 against the quantized checkpoint before it serves any real traffic, not just a perplexity check.
- Failover / restart — kill one replica (or one node, in the multi-node TP×PP case) under load and confirm the router removes it from rotation via the readiness probe without cascading latency elsewhere.
None of these are exotic — each maps directly to one of the failure modes or war stories earlier in this chapter. Running them once, deliberately, before launch is far cheaper than discovering them live.
Saying it out loud. Seven things I’d run against the actual deployment before it sees traffic, and each one maps to a failure mode from earlier in the chapter. Check the startup logs match your hand calculation, because a mismatch means a flag didn’t take. Send one request at exactly
max-model-lento confirm it succeeds. Run a burst at your real expected peak and confirm zero preemption lines — if they appear, you’re under-provisioned for the SLO, not just for the benchmark. Replay production-shaped traffic and confirm prefix hit rate is non-trivial if your design assumed reuse. Measure actual cold start and set the readiness probe from the measurement. Run the task-specific quality eval if you quantized. And kill a replica under load to confirm the router drops it cleanly.
Comparison: vLLM vs TGI vs TensorRT-LLM / Triton
| Dimension | vLLM | TGI (HF Text Generation Inference) | TensorRT-LLM + Triton |
|---|---|---|---|
| Core strength | PagedAttention + continuous batching; best throughput/$ out of the box | Solid production server, tight HF ecosystem fit | Peak NVIDIA-GPU performance via compiled engines |
| Batching | Continuous, iteration-level | Continuous (in-flight) | In-flight batching (Triton backend) |
| KV memory mgmt | PagedAttention (near-zero waste) | Paged KV (adopted vLLM-style ideas) | Paged KV |
| Prefix caching | Automatic, on by default | Supported | Supported |
| Quantization | AWQ, GPTQ, FP8, INT8, bnb | AWQ, GPTQ, EETQ, FP8, bnb | INT4/8, FP8 (compiled, very fast) |
| Speculative decode | Draft model, n-gram, EAGLE/Medusa | Medusa / n-gram | EAGLE, Medusa, draft |
| Setup cost | Low — pip install, one command | Low — Docker image | High — per-model engine build/compile step |
| Hardware | NVIDIA + AMD ROCm + others | NVIDIA + AMD | NVIDIA only |
| API | OpenAI-compatible server | OpenAI-compatible + native | Triton (OpenAI frontend available) |
| Best when | Default choice; open models, fast iteration, high throughput | HF-centric stacks wanting a batteries-included server | Squeezing max perf on fixed NVIDIA hardware, willing to pay build complexity |
Reality check: the three have converged — TGI and TensorRT-LLM adopted paged KV and in-flight batching. TensorRT-LLM often wins raw latency/throughput on NVIDIA thanks to ahead-of-time kernel compilation, at the cost of a per-model engine-build step and NVIDIA lock-in. vLLM wins on flexibility, ease, and hardware breadth, and is the usual default. See section (A.5) above for SGLang’s place in this picture and concrete 2026 benchmark numbers. Always benchmark on your model, hardware, and traffic shape before deciding.
Saying it out loud. The short comparison: vLLM gives you the best throughput per dollar out of the box with the broadest model support and a one-command setup. TGI is a solid production server that fits tightly into the Hugging Face ecosystem and has adopted most of the same ideas. TensorRT-LLM behind Triton gets you peak NVIDIA-GPU performance through ahead-of-time compiled engines, and it genuinely is faster — but it costs a per-model, per-shape compilation step that can run tens of minutes, plus NVIDIA-only lock-in. So the decision rule is about how fixed your deployment is: if the model and shapes are stable and long-lived, the compile cost amortizes and TensorRT wins. If you’re iterating across model families, vLLM’s time-to-serve is worth more than the last 15% of throughput.
Failure modes and pitfalls
- OOM at startup from
gpu-memory-utilizationtoo high. vLLM pre-allocates the KV pool; if you set 0.98 with no slack, activation spikes or CUDA-graph capture push you over and it crashes on load. Back off to ~0.90 and grow gradually. Co-located processes share the same HBM — vLLM only sees the fraction you give it. - Preemption and recompute thrash. When admitted sequences collectively exceed KV capacity, vLLM preempts some — either swapping their KV to CPU (
--swap-space) or discarding and recomputing it later. Frequentpreemptedwarnings mean you over-committedmax-num-seqs/max-model-len; throughput drops as work is redone. Fix by lowering concurrency, shorteningmax-model-len, or adding swap. See the full incident writeup in section (C.1). - Long-context KV blowup. KV grows linearly with context. A handful of 128k-token requests can consume the entire pool and starve everyone else. Bound it with
--max-model-len,--kv-cache-dtype fp8, and admission limits; don’t advertise a context you can’t afford to serve concurrently. - Quantization quality loss. 4-bit weight-only (AWQ/GPTQ) can degrade code/math/reasoning noticeably even when perplexity looks fine. Always validate on a task-specific eval, and prefer FP8 on Hopper where it’s near-lossless. See section (C.2) for a real incident.
- Speculative decoding backfiring. Low draft acceptance, or high batch load, turns speculation into pure overhead. Measure acceptance rate; disable under saturation.
max-num-batched-tokenstoo low with chunked prefill off. If a prompt exceeds the token budget and chunked prefill isn’t enabled, requests fail. Keep chunked prefill on, or set the budget ≥max-model-len.- Assuming
max-num-seqsis the batch limit. Usually KV memory binds first. Raisingmax-num-seqswithout KV headroom just causes preemption. Watch the# GPU blockslog, not just the seq cap. - Prefix cache eviction under pressure. Cached prefixes compete with active KV; under load they’re evicted and hit rate falls — throughput quietly regresses. Size memory for both if prefix reuse is core to your workload.
Saying it out loud. The recurring vLLM failures, in rough order of frequency. OOM at startup from setting
gpu-memory-utilizationtoo high, because vLLM pre-allocates the KV pool and activation spikes push you over — back off to 0.90 and grow. Preemption and recompute thrash, when admitted sequences collectively exceed KV capacity; frequentpreemptedwarnings mean you over-committed. Long-context blowup, where a handful of 128K requests eat the whole pool and starve everyone. Quantization quality loss that perplexity doesn’t catch. Speculative decoding backfiring under high batch load. And the conceptual one: assumingmax-num-seqsis your batch limit when KV memory almost always binds first — raising it without memory headroom just buys you preemption.
(C) Production case studies & war stories
Real incidents read differently from tuning tables — they teach you what the failure feels like from the on-call seat, before you’ve had time to read a metrics dashboard calmly. Two representative ones.
C.1 Preemption/recompute thrashing under bursty long-context traffic
Setup: a RAG assistant serving Llama-3-8B on a single A100-80GB, --max-model-len 8192, --max-num-seqs 256, gpu-memory-utilization 0.90. Sized and load-tested against typical traffic: short questions, ~1200-token average retrieved context, comfortably inside the ~442k-token / ~54-concurrent KV budget from the worked example above.
Incident: a product launch drove a burst of users pasting entire long documents (6,000–8,000 tokens) for summarization, at the same time normal short-query traffic continued. Symptoms, in order of appearance:
- p99 latency alarms fired first — some requests took 10–20× longer than normal, with no corresponding drop in throughput dashboards (which looked almost fine).
- Server logs filled with
Sequence group ... is preempted by PreemptionMode.RECOMPUTEwarnings, dozens per second during the burst. - GPU utilization looked high the whole time — easy to misread as “the GPU is just busy,” when it was actually busy redoing work it had already done.
Root cause: each long-document request consumed far more KV blocks than the average request the system was sized for. Once enough long requests were admitted concurrently, the running batch’s collective KV footprint exceeded the pool. vLLM’s scheduler did exactly what it’s supposed to do — preempted lower-priority sequences to make room, discarding their KV and recomputing it from scratch when they were readmitted (the default preemption mode when --swap-space is small). Every recompute is a full prefill redone; for an 8,000-token document that’s a very expensive redo, and it kept happening because the burst didn’t clear before the preempted sequences got starved again. This is thrash: the system spent its cycles re-deriving state it had already computed, instead of making forward progress — classic memory-over-commitment behavior, just at the KV-cache layer instead of the OS page-cache layer it’s modeled on.
Fix, in order applied:
- Immediate mitigation: capped
--max-model-lendown to what the product actually needed to guarantee (4096), rejecting (with a clear error) documents beyond that instead of admitting them and starving everyone. This is a blunt instrument but stops the bleeding in minutes. - Real fix: raised
--swap-spacefrom the default 4 GiB to 32 GiB per GPU, so that under a burst, preempted sequences’ KV is swapped to CPU RAM and restored rather than discarded and recomputed — much cheaper for long sequences specifically, at the cost of some CPU↔GPU transfer time. Recompute is fine for short sequences (cheap to redo); swap is fine for long ones (expensive to redo, and DMA transfer is comparatively cheap). - Structural fix: split the deployment into two pools behind a router — a small-context, high-concurrency pool for typical short queries, and a separate large-context, lower-concurrency pool with a bigger
--swap-spaceand smaller--max-num-seqsfor document-heavy requests — so one traffic pattern can no longer starve the other. This is a lightweight, single-node precursor to the full disaggregated prefill/decode architecture in section (A.2); the same principle (isolate workloads with different resource profiles) applies at both scales.
Lesson: preemption logs are not noise — they are the single earliest, cheapest signal that your admission control (max-num-seqs, max-model-len) is miscalibrated against your actual traffic distribution, not the average case you load-tested against. Size for your tail request shape, not your median, and alert on preemption rate directly rather than waiting for it to show up as a latency SLO breach.
Saying it out loud. A RAG assistant on one A100, sized and load-tested against typical traffic — short questions, twelve-hundred-token contexts, comfortably inside a 442,000-token KV budget. Then a launch drove users pasting entire six-to-eight-thousand-token documents for summarization, while normal short traffic continued. The KV pool filled, the scheduler started preempting, and preempted sequences got their KV discarded and recomputed later — so the system was redoing prefill work it had already done, which made it slower, which made it preempt more. p99 latency spiked while throughput looked deceptively normal. The lesson: your capacity number is a function of the traffic shape, not just the request rate, and a single long-context traffic class can invalidate a load test that was honest for the workload you had yesterday.
C.2 A quantization choice that quietly hurt output quality
Setup: a coding-assistant service moved a 34B code model from fp16 to AWQ 4-bit weight-only quantization to fit two GPUs instead of four, halving infrastructure cost. Pre-launch validation ran the standard perplexity check on a held-out slice of a general text corpus — the numbers looked fine, within ~1–2% of the fp16 baseline — and the team shipped.
Incident: not a page-you-at-3am outage — worse, in some ways: a slow-burning quality regression that only showed up as a rising rate of user “thumbs down” feedback and support tickets about “the assistant writing subtly broken code” over the following two weeks. Nothing crashed; nothing alerted; the metrics that would have caught it (perplexity, latency, error rate) were all green.
Root cause: perplexity on general text is a weak proxy for quality on narrow, structured tasks like code generation. 4-bit weight-only quantization applies uniform-ish precision loss across all weights; for a general-purpose completion task the aggregate effect is small and perplexity captures it fine. But code correctness depends on getting a small number of high-precision decisions exactly right — matching brackets, correct off-by-one indices, exact API argument order — and those are disproportionately sensitive to the quantization noise that a perplexity average smooths right over. The regression was real but statistically invisible to the metric the team trusted.
Fix:
- Rolled back to fp16 immediately once a task-specific eval (a held-out set of “does the generated code pass its unit tests” checks, not perplexity) was run retroactively and showed a measurable drop in pass rate versus the fp16 baseline — the smoking gun the perplexity check had missed entirely.
- Re-quantized to FP8 instead of AWQ 4-bit (the team’s GPUs were H100s) — FP8 is close to lossless in practice for most models, and the pass-rate eval confirmed no measurable regression versus fp16, while still shrinking weights enough to recover most of the cost savings the team wanted from the original migration.
- Added the task-specific pass-rate eval to the pre-deployment gate for any future quantization or model change — perplexity remained a sanity check, but was no longer the sole quality gate.
Lesson: quantization quality loss is task-dependent, and a generic proxy metric like perplexity can be flat-out blind to regressions that matter enormously to users on structured tasks (code, math, precise extraction). Before shipping any quantization change, validate on an eval set that resembles what your users actually do — and prefer FP8 over 4-bit weight-only when the hardware supports it and the task is precision-sensitive, exactly as the Quantization section above recommends.
Saying it out loud. This is the scariest kind of incident because nothing alerts. A coding-assistant team moved a 34B code model from fp16 to AWQ 4-bit to halve their GPU count, validated with a perplexity check on general text that came back within one or two percent, and shipped. Over the following two weeks, thumbs-down feedback climbed and tickets came in about “subtly broken code.” Nothing crashed; latency, error rate, and perplexity were all green. The root cause is that perplexity averages over everything, while code correctness depends on a small number of high-precision decisions — matching brackets, off-by-one indices, exact argument order — that are disproportionately sensitive to quantization noise. The fix was FP8 instead of 4-bit, plus a task-specific pass-rate eval as a permanent pre-deployment gate.
C.3 Autoscaling cold-start thrashing
Setup: a customer-support chatbot on Llama-3-8B running behind a Kubernetes Horizontal Pod Autoscaler (HPA), scaling vLLM replicas on GPU utilization, targeting 70% average GPU utilization per replica, minimum 2 replicas, maximum 10.
Incident: during a marketing push, traffic ramped from baseline to 4× over about ten minutes. The HPA reacted correctly, in principle — GPU utilization crossed the scale-up threshold and new pods were scheduled. But each new vLLM pod took 60–90 seconds to become ready: pulling the container image (if not already cached on the node), loading a full copy of model weights from remote storage into GPU memory, then running CUDA graph capture before it could serve its first request. During that window, the existing replicas kept absorbing the full ramping load, GPU utilization on them climbed well past the scale-up threshold, and the HPA — seeing utilization still high — kept requesting more new replicas on top of the ones still warming up. When the first batch of new pods finally came online, aggregate capacity briefly overshot demand, utilization dropped, and the HPA started scaling back down — right as the next traffic wave arrived. The result was an oscillating replica count and a period of elevated p99 latency and a handful of request timeouts on the saturated original replicas, even though the cluster had (eventually) more than enough aggregate GPU capacity for the actual load.
Root cause: the HPA’s reaction model implicitly assumes new capacity comes online fast relative to the metric’s response time. vLLM’s model-loading and CUDA-graph-capture startup cost violates that assumption badly compared to, say, a stateless web server pod that’s ready in a second or two — the feedback loop was scaling on a signal (current GPU utilization) that couldn’t reflect capacity already “in flight” but not yet serving.
Fix:
- Immediate mitigation: manually pinned replica count above the oscillation range for the duration of the marketing push, trading elasticity for stability until the traffic pattern was well understood.
- Real fix — scale on a leading indicator, not a lagging one. Switched the HPA’s scaling signal from raw GPU utilization to
vllm:num_requests_waiting(queue depth) with a much lower, earlier-triggering threshold, so scale-up starts before existing replicas are saturated rather than after — giving the 60–90 second warm-up time to actually land before it’s needed. - Cut the warm-up time itself. Pre-baked model weights into the node image / a local NVMe cache instead of pulling from remote object storage on every pod start, and pre-warmed a small pool of “standby” replicas during known high-traffic windows (marketing pushes, product launches) rather than relying purely on reactive autoscaling for predictable bursts.
- Added a scale-down cooldown long enough to ride out a single traffic wave, preventing the “scale up, immediately scale back down” oscillation once new capacity did land.
Lesson: vLLM replicas are not fungible with stateless microservice pods for autoscaling purposes — a 60–90 second cold start (dominated by weight loading and CUDA graph capture) means the autoscaler must react to a leading signal (queue depth, request rate trend) with real lead time, not a lagging one (current utilization), or it will systematically over- and under-shoot during any traffic ramp. This is the same “measure the right signal, not just any green-looking metric” theme as section C.2, applied to the autoscaling layer instead of the quality-eval layer.
Saying it out loud. A chatbot behind an HPA scaling on GPU utilization at a 70% target. Traffic ramped 4x over ten minutes, the HPA correctly scheduled new pods — but each vLLM pod took 60 to 90 seconds to become ready, dominated by loading weights and CUDA graph capture. During that window the existing replicas absorbed the whole ramp, utilization stayed high, and the HPA kept asking for more replicas on top of ones still warming. When the first batch finally landed, capacity overshot, utilization dropped, and it started scaling back down right as the next wave arrived. The fix was scaling on
num_requests_waiting— a leading signal — instead of current utilization, a lagging one, plus pre-baked weights and a scale-down cooldown. vLLM replicas are simply not fungible with stateless pods for autoscaling.
C.4 On-call quick-reference: what these three incidents teach as a single checklist
All three war stories reduce to the same underlying discipline — know which signal actually leads the problem, and alert on that, not on its downstream symptom. As a runbook a new on-call engineer can use directly:
| Symptom you’re paged for | Check this signal first | If it confirms, this is probably… | Playbook |
|---|---|---|---|
| p99 latency spike, throughput looks fine | vllm:num_preemptions_total rate, “preempted” log lines | KV over-commitment / recompute thrash (C.1) | Cap max-model-len for the offending traffic class immediately; raise --swap-space; consider splitting into a separate pool for that traffic shape |
| Slow-building quality complaints, all standard metrics green | Task-specific pass-rate eval (not perplexity) on recent quantization/model changes | Quantization (or any model swap) hurt a narrow, precision-sensitive capability (C.2) | Roll back the change; re-evaluate with a task-specific gate before re-shipping; prefer FP8 over 4-bit weight-only on precision-sensitive tasks |
| Oscillating replica count, intermittent timeouts during traffic ramps | vllm:num_requests_waiting trend vs GPU-utilization trend during the ramp | Autoscaler reacting to a lagging signal against a slow (60–90s) cold start (C.3) | Scale on queue depth / request-rate trend instead of raw utilization; pre-warm for known bursts; add a scale-down cooldown |
| Prefix-cache hit rate silently dropped | vllm:gpu_prefix_cache_hit_rate alongside gpu_cache_usage_perc | Cache pressure evicting reused prefixes, or a genuine traffic-shape change | Confirm which with the B.4 table; add memory or reduce concurrency if it’s pressure, investigate traffic if it’s a shape change |
The common thread across all four rows: the metric that would have caught the problem earliest is almost never the one on the primary latency/error-rate dashboard. Building the section-B.5 alerting rules directly from vllm:* Prometheus metrics — rather than only alerting on downstream SLO burn — is what turns these from postmortems into things you catch in minutes.
Saying it out loud. All three incidents reduce to one discipline: know which signal actually leads the problem, and alert on that rather than its downstream symptom. Paged for a p99 spike while throughput looks fine? Check the preemption counter first — that’s KV over-commitment. Slow-building quality complaints with every standard metric green? That’s a model or quantization change that hurt a narrow capability perplexity can’t see. Oscillating replica count during ramps? That’s an autoscaler on a lagging signal against a slow cold start. The common thread, and the thing worth saying explicitly: the metric that would have caught each of these earliest is almost never the one on your primary latency and error-rate dashboard.
How the attention kernel actually reads paged blocks
It’s worth being precise about why PagedAttention needs a custom kernel, because interviewers probe it. Standard fused attention (FlashAttention) assumes K and V for a sequence live in one contiguous tensor it can stride through. Paged KV breaks that assumption: a sequence’s K/V are scattered across physical blocks in arbitrary order.
The PagedAttention kernel therefore takes the block table as an input. For a query at the current position it:
- Reads the sequence’s block table (logical block → physical block number).
- For each logical block, computes attention scores ( q \cdot k ) against the K vectors in that physical block, iterating block by block.
- Accumulates the softmax-weighted sum of V vectors from the same blocks.
Because the block is the unit of gather, the kernel does a small indirection per block (once per 16 tokens), not per token — cheap relative to the matmul. The block table lives in GPU memory alongside the cache. Modern vLLM builds this on FlashAttention/FlashInfer backends that natively accept paged KV, so you keep FlashAttention’s IO-awareness and paging. This is the crux: paging costs almost nothing at kernel time, yet returns most of the wasted memory as usable batch.
Saying it out loud. Interviewers probe this, so be precise. Standard fused attention like FlashAttention assumes a sequence’s K and V live in one contiguous tensor it can stride through — and paged KV breaks that assumption outright, since the blocks are scattered in arbitrary order. So the PagedAttention kernel takes the block table as an input: for each query it reads the logical-to-physical mapping, computes attention scores against the K vectors in each physical block, and accumulates the softmax-weighted V sum block by block. The key efficiency point is that the block is the unit of gather, so the indirection happens once per sixteen tokens rather than once per token — negligible against the matmul. That’s the crux: paging costs almost nothing at kernel time, and returns most of the wasted memory as usable batch.
The scheduler: waiting, running, swapped
vLLM’s scheduler maintains three queues and reconciles them every step — this is the machinery behind continuous batching, preemption, and swapping.
- Waiting — admitted requests not yet started (need KV blocks allocated for their prefill).
- Running — sequences actively decoding (or being prefilled) this step.
- Swapped — sequences preempted out of GPU KV, their blocks parked in CPU swap space.
Each iteration the scheduler:
- Frees blocks of any sequence that finished last step.
- Tries to admit waiting requests into running, subject to the KV block budget and
max-num-seqs/max-num-batched-tokens. - If running collectively needs more blocks than exist (e.g., all sequences grew a token and a new block boundary was crossed), it preempts the lowest-priority sequences — either swap (copy their KV blocks to CPU, restore later) or recompute (drop KV, re-run prefill when readmitted). Recompute is the default for short sequences; swap wins for long ones where recompute is expensive.
The default policy is FCFS-ish with the newest/lowest-priority preempted first. The practical takeaway: preemption is the pressure-relief valve, and seeing it constantly in logs means your admission settings exceed your true KV capacity. It is correct behavior, not a bug — but it costs throughput, so tune it away. Section (C.1) walks through exactly this failure end to end.
Saying it out loud. The scheduler keeps three queues and reconciles them every single step. Waiting is admitted-but-not-started. Running is actively decoding or prefilling. Swapped is preempted sequences whose KV blocks are parked in CPU memory. Each iteration it frees blocks from anything that finished, tries to promote waiting requests into running subject to the block budget and the sequence and token caps, and if running collectively needs more blocks than exist, it preempts — either swapping KV to CPU or discarding and recomputing it later. Recompute is default for short sequences, swap wins for long ones where recompute is expensive. The takeaway to say out loud: preemption is the pressure-relief valve and it’s correct behavior, not a bug — but seeing it constantly means your admission settings exceed your true KV capacity, and it costs throughput.
Benchmarking vLLM properly
Never tune by feel. vLLM ships a benchmark harness that mirrors real serving:
# Start the server, then in another shell:
vllm bench serve \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 1000 \
--request-rate 20
Metrics that matter, and what they mean:
- Throughput (tokens/s, requests/s) — the number to maximize for batch/offline workloads.
- TTFT (time to first token) — dominated by prefill and queueing; what a chat user feels as “lag before it starts.”
- ITL / TPOT (inter-token latency / time per output token) — decode smoothness; hurt by big un-chunked prefills.
- p50 vs p99 — always look at the tail. High p99 with fine p50 usually means preemption or prefill interference.
Sweep one knob at a time (gpu-memory-utilization, max-num-batched-tokens, max-num-seqs) and plot throughput vs p99 latency. The right operating point is the knee of that curve for your SLO — not the max-throughput point, which usually violates latency targets. Section (B.3)–(B.4) above extends this to a multi-node 70B deployment and shows how to read the Prometheus KV-cache-usage and prefix-hit-rate metrics alongside it.
Saying it out loud. Never tune by feel — vLLM ships a benchmark harness that mirrors real serving, and the discipline is one knob at a time. The four metrics that matter: throughput in tokens and requests per second, which is what you maximize for batch work; TTFT, which is what a chat user feels as lag before anything happens; inter-token latency, which is decode smoothness and is what gets hurt by big un-chunked prefills; and always p50 versus p99, because a fine p50 with a bad p99 almost always means preemption or prefill interference specifically. Then plot throughput against p99 latency and pick the knee of that curve for your SLO — deliberately not the max-throughput point, which will violate your latency target.
V1 engine and disaggregated serving — quick recap
Two architectural notes worth knowing at a glance (see section (A) for full depth and dates):
- The V1 engine (default in current vLLM) rewrote the core for lower CPU overhead and a unified scheduler where prefill and decode are co-scheduled by default. Chunked prefill and prefix caching are on by default there. If you read older tutorials that tell you to manually enable these, that advice is stale.
- Disaggregated prefill/decode separates the compute-bound prefill and memory-bound decode onto different GPU pools, streaming the KV cache between them. Because the two phases have opposite resource profiles, dedicating hardware to each — and scaling them independently — can beat co-locating them, especially at high scale with long prompts. This is an increasingly mainstream pattern (KV transfer over NVLink/RDMA) for large deployments, with concrete production numbers now published (section A.2).
Second worked example: Llama-3-70B across GPUs (single-node summary)
An 8B model fits one card; a 70B does not. In fp16, weights alone are ( 70\text{B} \times 2 = 140\ \text{GB} ) — larger than a single 80 GB GPU. Options, in the order you should consider them (see section B for the full multi-node walkthrough):
Option A — tensor-parallel across 4 GPUs (one node, NVLink):
vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
--tensor-parallel-size 4 \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.92
Weights shard to ( 140/4 = 35\ \text{GB} ) per GPU, leaving each card ~( 0.92 \times 80 - 35 \approx 38\ \text{GB} ) (minus overhead) for its KV shard. KV is also split by heads across the 4 GPUs, so aggregate KV capacity is roughly 4× a single card’s leftover — that is what makes big batches on 70B feasible.
Option B — quantize to AWQ 4-bit, fit on fewer GPUs:
vllm serve casperhansen/llama-3-70b-instruct-awq \
--quantization awq_marlin \
--tensor-parallel-size 2 \
--max-model-len 8192
4-bit weights are ~( 70\text{B} \times 0.5 = 35\ \text{GB} ), fitting two 80 GB cards with room for KV. Fewer GPUs, lower cost, at some quality cost — validate on your eval set (section C.2 is a cautionary tale about skipping this step).
Option C — two nodes, TP×PP: --tensor-parallel-size 8 --pipeline-parallel-size 2 spreads a large model across 16 GPUs, TP within each node over NVLink and PP across the two nodes over the slower inter-node link. Section (B.2) above walks through the full Ray-based multi-node launch for exactly this configuration.
Decision order: fit on one node with TP first; quantize to shrink weights and cut GPU count; go multi-node with PP only when a single node genuinely cannot hold model + working KV.
Saying it out loud. An 8B fits one card; a 70B in fp16 is 140 gigabytes of weights, so it does not. The options in the order you’d consider them: tensor-parallel across four or eight GPUs on one node over NVLink, which is the first thing to try because intra-node all-reduce is cheap. Quantize to 4-bit or FP8, which can bring a 70B down to two or four cards and is often the bigger cost lever. Or go multi-node with pipeline parallelism across nodes on top of tensor parallelism within them. The reasoning to make explicit: you’re not just asking “does it fit,” you’re asking “how much KV headroom is left after it fits,” because that leftover is what determines concurrency and therefore cost per token.
Reading the startup logs (your first diagnostic)
Every launch prints the numbers that tell you whether your config is sane. Learn to read them before touching load tests:
INFO ... Available KV cache memory: 53.7 GiB
INFO ... GPU KV cache size: 442,368 tokens
INFO ... Maximum concurrency for 8192 tokens per request: 54.0x
INFO ... # GPU blocks: 27648, # CPU blocks: 2048
- Available KV cache memory — what’s left after weights + overhead. If this is tiny or negative-adjacent, lower
max-model-len, quantize, or add GPUs. - GPU KV cache size (tokens) — your total batch budget in tokens; divide by average request length to estimate real concurrency.
- Maximum concurrency — full-context sequences you can run at once. If it’s < your expected concurrency, you will preempt under load.
- # CPU blocks — swap capacity, sized by
--swap-space.
If you never look at anything else, look at these four lines. They convert the abstract flags into the one number that governs throughput: how many tokens of KV you can hold.
Saying it out loud. Four log lines at startup tell you whether your config is sane, and reading them takes ten seconds. Available KV cache memory is what’s left after weights and overhead — if it’s tiny, lower
max-model-len, quantize, or add GPUs. GPU KV cache size in tokens is your total batch budget; divide by average request length for realistic concurrency. Maximum concurrency is how many full-context sequences fit at once, and if that’s below your expected load you will preempt under traffic. And CPU blocks is your swap capacity from--swap-space. If you look at nothing else, look at those four, because they convert abstract flags into the single number that governs throughput: how many tokens of KV you can hold.
Appendix: additional operational flags
Beyond the core memory/parallelism/quantization flags covered above, these come up often enough in real deployments to be worth knowing by name:
| Flag | What it does | When you reach for it |
|---|---|---|
--served-model-name | Name the OpenAI-API-facing model string differently from the HF repo id. | Present a stable public model name while swapping checkpoints behind it. |
--api-key | Require a bearer token on the OpenAI-compatible endpoints. | Any deployment reachable outside a trusted network. |
--load-format | Control how weights are loaded (auto, safetensors, pt, bitsandbytes, …). | Speeding up cold start (safetensors mmap is fast) or loading from a non-default checkpoint format. |
--enforce-eager | Disable CUDA graph capture, run in eager PyTorch mode. | Debugging a crash/NaN that only reproduces without graph capture; costs decode throughput. |
--cpu-offload-gb | Offload some weight layers to CPU RAM, streamed to GPU on demand. | Squeezing a model that almost — but doesn’t quite — fit in GPU memory, at a latency cost. |
--num-scheduler-steps | Batch multiple scheduler steps together to cut CPU-side scheduling overhead. | High QPS deployments where CPU scheduling overhead (not GPU compute) is the bottleneck. |
--disable-log-stats | Turn off periodic throughput/latency log lines. | Noisy logs in a low-traffic environment; keep on in production for the diagnostics this chapter relies on. |
--tokenizer | Point at a different tokenizer than the model’s default. | Serving a fine-tune with a custom tokenizer, or a quantized checkpoint that omits tokenizer files. |
--trust-remote-code | Allow executing custom modeling code shipped with a HF repo. | Required for some model architectures; understand the supply-chain implication before enabling in production. |
--disable-sliding-window | Force full attention even for models that support sliding-window attention. | Debugging correctness differences against a reference implementation. |
These rarely need tuning day-to-day, but showing you know they exist — and specifically why each one is reached for — is exactly the kind of breadth an interviewer probing “have you actually operated this in production” is listening for.
Quick-reference config recipes
| Goal | Starting flags |
|---|---|
| Max throughput, batch/offline | --gpu-memory-utilization 0.95 --max-num-batched-tokens 16384 --max-num-seqs 512 |
| Low-latency interactive chat | --max-num-batched-tokens 4096 (protect ITL) --enable-chunked-prefill, consider speculative decoding |
| Long-context serving | --kv-cache-dtype fp8 --max-model-len <needed> --swap-space 16, cap concurrency |
| Memory-tight single GPU | --quantization awq_marlin (or fp8 on Hopper) --gpu-memory-utilization 0.90 |
| RAG with shared preamble | keep --enable-prefix-caching (default), moderate max-num-seqs |
| Model bigger than one GPU | --tensor-parallel-size N (one node) [+ --pipeline-parallel-size M across nodes] |
| Burst-prone long-context traffic | isolate a large-context pool with higher --swap-space, lower --max-num-seqs (see section C.1) |
(D) Interview mastery
D.1 “Explain PagedAttention in 60 seconds”
The question that separates people who’ve read the abstract from people who’ve internalized it. A tight answer, timed:
“LLM serving is memory-bound: decoding one token rereads the whole model’s weights, so the only way to get throughput is to batch many sequences together and amortize that read. What limits batch size is the KV cache — the attention memory of every token in every active sequence — and pre-vLLM systems stored each sequence’s KV cache in one contiguous block sized to the maximum possible length, wasting 60–80% of it on fragmentation and unused reservation. PagedAttention borrows virtual memory from operating systems: it splits the KV cache into small fixed-size blocks — 16 tokens each — that live anywhere in a global pool, indexed per-sequence by a block table, exactly like a page table. Blocks are only allocated as a sequence actually grows, so waste drops to at most one partial block per sequence — effective utilization goes from ~20–38% to ~96%. That reclaimed memory becomes batch capacity, which is why vLLM gets 2–4× the throughput of the prior state of the art at the same latency. And because it’s memory indirection, not physical layout, you get sharing for free — two sequences can point at the same physical block, which is what makes prefix caching and beam-search memory savings possible without extra machinery.”
If you only remember one structural trick to hit every beat: problem (fragmentation/waste) → borrowed idea (OS paging) → mechanism (blocks + block table) → payoff (utilization number) → bonus (sharing enables prefix caching).
D.2 System-design prompt: “Serve a 70B model at the lowest cost per token while hitting a TTFT SLO”
A realistic senior-level prompt. Worked sketch, in the order an interviewer wants to hear it:
1. Clarify the SLO and traffic shape first. What’s the TTFT target (e.g. p99 < 500 ms)? What’s expected QPS, average/tail prompt length, and output length? Is traffic bursty? Is a high prefix-reuse rate expected (chat/RAG) or is every prompt unique? Cost-per-token optimization and TTFT protection pull in different directions, so the answer depends entirely on these numbers — say so explicitly; don’t guess.
2. Pick hardware and precision for cost per token. For a 70B model, the cost-per-token lever with the biggest single effect is usually quantization, because it directly cuts both weight memory (more KV headroom, bigger batches, lower $/token) and, on the right hardware, compute time. On H100/Hopper, FP8 is close to lossless and gets full tensor-core speedup — default choice unless there’s a hard reason to need fp16. On older Ampere/Ada hardware without native FP8, AWQ 4-bit with the Marlin kernel is the next-best cost lever, at some quality risk to validate (section C.2). Quantifying: fewer/cheaper GPUs directly divides your $/GPU-hour by however many requests you can now batch onto each.
3. Pick parallelism to fit the model and hit latency. Decide TP size to fit weights + working KV on a node (TP-first rule); use PP only if you must cross nodes. More TP ranks also lowers single-request latency (more GPUs cooperating on one forward pass), which helps TTFT directly — a real tension with the “fewer GPUs is cheaper” instinct from step 2, and worth naming as a tradeoff explicitly.
4. Batch aggressively for cost, but protect the TTFT SLO with chunked prefill. Cost per token falls as batch size rises (weight-load amortized over more sequences), so push --gpu-memory-utilization and --max-num-seqs up until the KV budget or the TTFT SLO — whichever binds first — says stop. Keep chunked prefill on with a --max-num-batched-tokens sized to protect TTFT: too large and a big prompt from another request can stall a fresh request’s own first token; too small and prefill throughput (hence cost) suffers. This is the direct knob connecting the SLO to the cost target.
5. Turn on prefix caching if traffic has any repeated structure. For chat/RAG/agent workloads, this alone can remove a large fraction of prefill compute — approximately free in the current V1 engine even at a 0% hit rate, so there’s no real reason to leave it off.
6. Decide on disaggregation only after co-located tuning plateaus. If, after all the above, TTFT is still being blown by prefill bursts stealing cycles from decode — and traffic/scale justify the operational cost — split prefill and decode onto separate GPU pools with independent autoscaling (section A.2). This is a scale-dependent decision: don’t reach for it by default, it roughly doubles the operational surface area (two fleets, a KV-transfer connector, a routing proxy).
7. Consider speculative decoding as a final latency lever, with eyes open on batch size. If the SLO is tight and typical concurrency per GPU is low-to-moderate (i.e., you’re not compute-saturated), an EAGLE-family draft head can materially cut TTFT-adjacent per-token latency; at high, cost-optimized batch sizes the benefit shrinks or reverses, so measure before committing it to the cost-optimized fleet specifically.
8. Close the loop with load testing and live metrics. State that the final numbers come from vllm bench serve sweeps against the real traffic shape, read alongside gpu_cache_usage_perc and gpu_prefix_cache_hit_rate (section B.4), not from a spec sheet — and that the operating point chosen is the cost-minimizing point on the throughput/latency curve that still clears the TTFT SLO, not the raw-throughput maximum.
Putting numbers on it (illustrative, using the B.6 cost table’s configurations against a hypothetical TTFT SLO of p99 < 500 ms):
| Candidate config | Cost per 1M tokens (B.6) | Meets TTFT SLO at target QPS? | Decision |
|---|---|---|---|
| AWQ 4-bit, TP2, one node | ~$0.79 | No — smaller batch ceiling and 2 GPUs undersized for peak concurrency, TTFT p99 blows past 500 ms under load | Reject: cheapest per token but violates the SLO, which is a hard constraint, not a soft preference |
| FP8, TP4, one node | ~$1.23 | Yes, with headroom, and validated against a task-specific eval (section C.2) | Likely pick — clears the SLO and is meaningfully cheaper than the fp16 fleet |
| fp16, TP8×PP2, two nodes | ~$4.44 | Yes, with the most headroom | Reject as the default: clears the SLO but at ~3.6× the cost of the FP8 option for no additional SLO benefit — only justified if FP8 fails the quality eval |
This is the shape of answer that closes the loop: the cheapest option that still clears the hard latency constraint, chosen from measured numbers, not the cheapest option in isolation and not the safest-but-most-expensive option by default.
A strong answer names the tension between cost and latency explicitly at each step, rather than treating them as independently optimizable — that’s usually the actual signal the interviewer is listening for.
Saying it out loud. The structure that wins this one: treat the SLO as a hard constraint and cost as the thing you minimize inside it — never the other way round. So first, do the weight and KV math to enumerate feasible configurations: fp16 across two nodes, FP8 on one node, 4-bit on two GPUs. Second, compute cost per million tokens for each from a real
vllm bench servesweep, not a guess. Third, check each against the TTFT SLO under real load, and reject anything that violates it regardless of how cheap it is — the 4-bit config is often cheapest per token and still the wrong answer because its batch ceiling blows the tail latency. Fourth, gate the quantized option on a task-specific quality eval, not perplexity. That sequencing is the signal being graded.
D.3 Red flags vs green flags
| Signal | Red flag (weak answer) | Green flag (strong answer) |
|---|---|---|
| Explaining PagedAttention | “It’s like caching” / can’t name blocks or block table | Names fixed block size (16), block table indirection, and the specific fragmentation numbers it eliminates |
| Batching | Conflates dynamic (Triton-style) batching with continuous/in-flight batching | Explains iteration-level scheduling: eviction + admission every step, not per-batch |
| Sizing KV cache | Guesses a gpu-memory-utilization number with no math | Derives bytes/token from layers × KV heads × head dim × dtype, subtracts weights, computes block count |
| Quantization | “4-bit is basically free, just do it” | Distinguishes AWQ/GPTQ+Marlin vs FP8 vs NVFP4 by hardware, and insists on task-specific eval, not just perplexity |
| Speculative decoding | Assumes it always helps | Ties benefit to acceptance rate and batch saturation; knows it can hurt at high concurrency |
| Parallelism | Picks TP or PP without justifying node topology | States TP-first-within-node, PP-to-cross-nodes, and why (comms cost profile) |
| Debugging a latency spike | Jumps straight to “add more GPUs” | Reads preemption logs / gpu_cache_usage_perc first, diagnoses over-commitment vs genuine capacity limit |
| Comparing engines | “vLLM is just the best one” with no caveat | Names SGLang’s RadixAttention fit for prefix-heavy workloads and TensorRT-LLM’s compile-time/throughput tradeoff, and says “benchmark your own traffic” |
| Talking about 2025–2026 state | Describes only the 2023 paper mechanics | Knows V1 is the current default engine, disaggregation is a live production pattern, and can name EAGLE-3.1 or a dated source |
| Production incident story | Generic “we scaled up” | Has a specific metric (preemption rate, pass-rate eval) that caught the problem and a specific config change that fixed it |
D.4 Question bank (18 Q&A)
-
Why is LLM decode memory-bound, and why does that make batching the key throughput lever? Weights are re-read from HBM every token with comparatively little arithmetic per token; batching amortizes that read across many sequences’ tokens. Throughput scales with batch size until KV memory runs out — which is why KV cache efficiency, not raw compute, is the binding constraint.
-
Explain PagedAttention and what waste it eliminates. Fixed-size KV blocks (default 16 tokens) in a global pool, addressed per-sequence via a block table (like an OS page table); eliminates internal fragmentation, reservation waste, and external fragmentation, raising effective KV utilization from ~20–38% to ~96%.
-
Continuous vs static batching — why does continuous raise throughput and cut latency? Iteration-level scheduling evicts finished sequences and admits new ones every step; no slot idles behind a slow sibling, so GPU utilization stays high and queueing (hence tail latency) drops too — a rare win on both axes at once.
-
Walk me through sizing KV cache and picking
gpu-memory-utilization/max-num-seqs/max-model-lenfor a given GPU. Compute bytes/token from layers × KV heads × head dim × dtype bytes; subtract model weights and overhead from the memory budget to get the KV pool; divide by block size × bytes/token for block count; that determines realistic concurrency at a given context length. Preemption in logs means the chosen seq/context caps exceed this budget. -
When TP vs PP? What are the comms costs? TP shards every layer, needs a per-layer all-reduce — bandwidth-hungry, so keep it intra-node over NVLink. PP splits layers into stages with point-to-point hand-offs — tolerant of slower links, so use it to cross nodes. Rule: TP first up to one node, PP to scale further.
-
Quantization choices and their quality cost — AWQ vs GPTQ vs FP8 vs NVFP4, and when each? AWQ/GPTQ (Marlin-kernel-backed) 4-bit weight-only for memory savings on Ampere/Ada; FP8 near-lossless with native tensor-core support on Hopper; NVFP4 is the emerging Blackwell-native path, less battle-tested. Always validate on a task-specific eval, not just perplexity — quantization damage is task-dependent.
-
When does speculative decoding help vs hurt? Helps at low-to-moderate batch with high draft-acceptance rate, because verification is nearly free on a memory-bound decode step. Hurts under saturation (GPU already compute-bound, verification stops being free) or low acceptance (you paid for a draft pass and got little back).
-
How do you debug an OOM or a latency spike in production? Check
# GPU blocks/Available KV cache memoryat startup and preemption warnings live; lowergpu-memory-utilization/max-model-lenor addswap-spaceif over-committed; tunemax-num-batched-tokensdown for ITL if a big-prompt/decode interference pattern is visible; confirm prefix-cache hit rate hasn’t collapsed under memory pressure. -
What’s the difference between chunked prefill and disaggregated prefill/decode, and when do you reach for the latter? Chunked prefill splits one big prefill into token-sized pieces co-scheduled with decodes on the same GPUs — cheap, on by default, solves most prefill/decode interference. Disaggregation moves prefill and decode to physically separate GPU pools with KV transferred between them over NVLink/RDMA — a bigger operational step reached for at high scale when co-location has been tuned out and ITL stability is still not good enough (e.g. the MORI-IO connector’s ~2.5× goodput gain, at a TTFT cost).
-
What does automatic prefix caching actually reuse, and what does it cost? It reuses whole KV blocks whose content-hash (and preceding-token context) matches a cached block, skipping recomputation of that prefill span entirely — this is PagedAttention’s block-sharing/CoW machinery applied across requests over time. Cost is competing with active batch for KV memory; under pressure cached blocks are evicted LRU and the hit rate — and the savings — fall. In the V1 engine it’s engineered to cost almost nothing even at a 0% hit rate, so it’s normally left on.
-
Why does a 4-bit quantized checkpoint need a special kernel like Marlin to actually be fast? INT4×FP16 mixed-precision GEMM is easy to get “free” at batch size 1 (memory-bound regime) but naive kernels leave tensor cores idle at realistic serving batch sizes, erasing the 4-bit advantage. Marlin is specifically engineered to hold close to the ideal 4× speedup up to ~32-token batches, which is why current vLLM auto-selects Marlin-backed kernels (
awq_marlin/gptq_marlin) for AWQ/GPTQ checkpoints. -
Explain EAGLE-style speculative decoding and how it differs from an independent draft model. EAGLE attaches a lightweight draft head directly to the target model’s own hidden states, rather than running a fully separate small model — reusing the target’s internal representations gives materially higher acceptance length than an independent draft of similar size. EAGLE-3.1 further fixes “attention drift” in deep speculation, roughly doubling accepted length over EAGLE-3 in long-context settings.
-
What causes preemption, and what are the two ways vLLM handles it? Preemption happens when the running batch’s collective KV need exceeds the pool (e.g., growth crosses a new block boundary with no free blocks left). vLLM either swaps the lowest-priority sequences’ KV to CPU RAM (cheap to restore, good for long sequences) or discards and recomputes it later (cheap for short sequences, expensive for long ones). Constant preemption in logs signals admission settings exceed true KV capacity for your traffic’s actual (not average) shape.
-
How would you decide between vLLM, SGLang, and TensorRT-LLM for a new deployment? Default to vLLM for broad model/hardware support and fast iteration. Choose SGLang if the workload is dominated by prefix reuse (chat, RAG, few-shot, agents) — RadixAttention is purpose-built for that. Choose TensorRT-LLM if the model and shapes are fixed and long-lived enough to amortize the compile-time cost, and you need the last few percent of throughput on fixed NVIDIA hardware. Always benchmark your own model/hardware/traffic rather than trusting a spec sheet or a single third-party blog’s numbers.
-
Why does raising
max-num-seqssometimes do nothing? Because KV memory, not the seq cap, is usually the real binding constraint — raising the cap without KV headroom just produces more preemption instead of more useful concurrency. Check# GPU blocks/gpu_cache_usage_percbefore touchingmax-num-seqs. -
What’s the relationship between
gpu-memory-utilization,max-model-len, and the number of GPU blocks logged at startup?gpu-memory-utilizationsets the total HBM budget; subtracting weights and overhead gives the KV pool; dividing by bytes-per-block gives# GPU blocks;max-model-lenbounds the worst-case blocks a single sequence can consume, which (with block count) determines the “maximum concurrency” figure vLLM logs. -
Walk through what happens end-to-end when a request arrives at a V1-engine vLLM server with prefix caching, chunked prefill, and speculative decoding all enabled. Tokenized request enters the unified scheduler as
{request_id: num_tokens}. Prefix cache is checked block-by-block; any matching prefix is pointed at existing physical blocks instead of recomputed. Remaining prefill tokens are chunked and co-scheduled with ongoing decodes up tomax-num-batched-tokens. Each decode step, if speculative decoding is configured, the draft (EAGLE head / n-gram / small model) proposes several tokens, the target verifies them in one forward pass, accepted tokens are kept and the KV cache grows by however many were accepted. The scheduler frees blocks for any sequence completing this step and admits waiting requests into the vacated capacity. -
How do you decide whether disaggregated serving is worth adopting for a given deployment? Only after co-located tuning (chunked prefill, prefix caching, batch sizing) has plateaued and ITL/TTFT SLOs are still not consistently met under real traffic bursts, and only if scale justifies doubling the operational surface (two independently-scaled fleets plus a KV-transfer connector and routing proxy). It’s an optimization reached for at scale, not a default starting architecture — most single-node, moderate-QPS deployments should stop at chunked prefill co-location.
-
What is llm-d, and why isn’t it just “vLLM on Kubernetes with a Helm chart”? llm-d (CNCF Sandbox, backed by Red Hat/Google/IBM engineers) packages the distributed systems problems that show up once vLLM is running as a fleet — KV-cache-aware routing via the Gateway API Inference Extension, and disaggregated prefill/decode as a supported deployment topology with the connector wiring solved — as “well-lit paths,” rather than each team re-solving cache-affinity routing and disaggregation plumbing from scratch. It’s an orchestration layer built on top of vLLM, not a replacement for it.
-
You’re paged for a p99 latency spike but throughput and error-rate dashboards look normal — what do you check first, and why? Preemption signals (
vllm:num_preemptions_totalrate and “preempted” log lines) before anything else — a KV over-commitment/recompute-thrash episode (section C.1) is exactly the failure mode that spikes tail latency while aggregate throughput and error rate still look fine, because the GPU is genuinely busy the whole time, just redoing work instead of making new progress. This is the single highest-value “know which signal leads the symptom” habit from this chapter (section C.4).
Glossary — quick lookup
| Term | One-line definition |
|---|---|
| PagedAttention | Attention mechanism that gathers K/V from non-contiguous, fixed-size GPU memory blocks via a per-sequence block table, eliminating KV-cache fragmentation. |
| KV block | Fixed-size (default 16-token) chunk of a sequence’s key/value cache; the unit of allocation, sharing, and eviction in vLLM. |
| Block table | Per-sequence map from logical block index to physical block number — the “page table” of PagedAttention. |
| Continuous / in-flight batching | Scheduling at the granularity of one decode step: evict finished sequences and admit waiting ones every iteration, instead of running a batch to completion as a unit. |
| Chunked prefill | Splitting a large prefill into token-sized pieces co-scheduled with ongoing decodes in the same iteration, bounded by max-num-batched-tokens. |
| Prefix caching | Reusing already-computed KV blocks across requests whose prompts share a content-hashed prefix, skipping recomputation. |
| Copy-on-write (CoW) | When a shared KV block needs to diverge for one sequence, only that block is copied; other sharers are untouched. |
| TTFT | Time to first token — dominated by prefill compute and queueing; the “lag before it starts” a user feels. |
| ITL / TPOT | Inter-token latency / time per output token — decode-phase smoothness; hurt by large un-chunked prefills stalling decodes. |
| Tensor parallelism (TP) | Sharding each layer’s weight matrices across GPUs, combined via a per-layer all-reduce; best over fast intra-node links (NVLink). |
| Pipeline parallelism (PP) | Splitting the model into sequential stages across GPUs/nodes, with point-to-point activation hand-offs; tolerant of slower inter-node links. |
| AWQ / GPTQ | Post-training weight-only quantization schemes (4-bit typical); need a Marlin-class kernel to realize speedup at realistic batch sizes, not just batch 1. |
| Marlin | GPU kernel achieving near-ideal 4× INT4×FP16 mixed-precision GEMM speedup up to ~32-token batches, underlying awq_marlin/gptq_marlin. |
| FP8 (E4M3) | 8-bit floating-point weight/activation format with native Hopper/Ada tensor-core support; near-lossless in practice for most models. |
| NVFP4 | Emerging 4-bit floating-point format native to Blackwell-class tensor cores, exposed via NVIDIA Model Optimizer integration. |
| Speculative decoding | Cheap draft model/head proposes several tokens; target model verifies them in one parallel forward pass; output distribution is exact, not approximate. |
| EAGLE / EAGLE-3 / EAGLE-3.1 | Draft-head speculative decoding methods that reuse the target model’s own hidden states, yielding higher acceptance length than an independent draft model. |
| Medusa | Multiple parallel decoding heads trained on the target model, each predicting a fixed future offset token, verified alongside EAGLE-style methods. |
| n-gram / prompt-lookup decoding | Speculative drafting by matching repeated n-grams already in the prompt/generation, with no extra model weights. |
| Preemption | vLLM evicting lower-priority sequences from the running batch when KV demand exceeds supply — via swap (to CPU) or recompute (discard and redo). |
| V1 engine | vLLM’s 2025 core rewrite: unified scheduler, isolated EngineCore process, chunked prefill and prefix caching on by default. |
| Disaggregated prefill/decode | Running prefill and decode on physically separate GPU pools, streaming KV cache between them via a connector (e.g. NVLink/RDMA). |
| RadixAttention | SGLang’s radix-tree KV-cache index purpose-built for maximizing prefix-sharing across requests. |
| llm-d | CNCF Sandbox, Kubernetes-native distributed inference framework built on vLLM, adding KV-cache-aware routing and disaggregation as supported deployment topologies. |
| Gateway API Inference Extension (IGW) | Kubernetes routing extension used by llm-d that routes on LLM-specific signals (KV-cache locality, queue depth) instead of generic HTTP load metrics. |
Further reading
- Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention,” SOSP 2023 — the vLLM paper: https://arxiv.org/abs/2309.06180
- ACM DL (SOSP ’23 proceedings): https://dl.acm.org/doi/10.1145/3600006.3613165
- vLLM PagedAttention design doc: https://docs.vllm.ai/en/latest/design/paged_attention/
- vLLM Engine Arguments reference: https://docs.vllm.ai/en/stable/configuration/engine_args/
- vLLM Optimization and Tuning (chunked prefill, batching knobs): https://docs.vllm.ai/en/latest/configuration/optimization/
- vLLM Automatic Prefix Caching: https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html
- vLLM Distributed Inference (TP/PP): https://docs.vllm.ai/en/latest/serving/distributed_serving.html
- vLLM Quantization overview: https://docs.vllm.ai/en/latest/features/quantization/
- vLLM Speculative Decoding: https://docs.vllm.ai/en/latest/features/spec_decode.html
- vLLM OpenAI-compatible server: https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html
- Anyscale, “How continuous batching enables 23x throughput in LLM inference”: https://www.anyscale.com/blog/continuous-batching-llm-inference
- Orca (iteration-level scheduling), OSDI 2022: https://www.usenix.org/conference/osdi22/presentation/yu
- vLLM Blog, “vLLM V1: A Major Upgrade to vLLM’s Core Architecture” (2025-01-27): https://vllm.ai/blog/2025-01-27-v1-alpha-release
- vLLM V1 usage guide: https://docs.vllm.ai/en/stable/usage/v1_guide/
- Red Hat Developer, “vLLM V1 Alpha: A major upgrade to vLLM’s core architecture” (2025-01-28): https://developers.redhat.com/articles/2025/01/28/vllm-v1-a-major-upgrade-vllms-core-architecture
- vLLM Blog, “Next-Level Inference: Why Your Single-Node vLLM Setup Needs Prefill-Decode Disaggregation” (MORI-IO KV connector, 2026-04-07): https://vllm.ai/blog/2026-04-07-moriio-kv-connector
- vLLM disaggregated prefilling docs (experimental, v0.7.1): https://docs.vllm.ai/en/v0.7.1/features/disagg_prefill.html
- PyTorch Blog, “Disaggregated Inference at Scale with PyTorch & vLLM”: https://pytorch.org/blog/disaggregated-inference-at-scale-with-pytorch-vllm/
- Ray Serve LLM, prefill/decode disaggregation guide: https://docs.ray.io/en/latest/serve/llm/user-guides/prefill-decode.html
- vLLM EAGLE Draft Models docs: https://docs.vllm.ai/en/latest/features/speculative_decoding/eagle/
- vLLM Blog, “EAGLE 3.1: Advancing Speculative Decoding Through Collaboration Between the EAGLE Team, vLLM, and TorchSpec” (2026-05-26): https://vllm.ai/blog/2026-05-26-eagle-3-1
- vLLM Blog, “EAGLE-3 Speculative Decoding on AMD Instinct GPUs: Training and Serving with vLLM and AMD Quark” (2026-07-13): https://vllm.ai/blog/2026-07-13-eagle-3-amd-instinct
- vLLM Blog, “Diving into speculative decoding training support for vLLM with Speculators v0.3.0” (2025-12-13): https://vllm.ai/blog/2025-12-13-speculators-v030
- Red Hat Developer, “Fly Eagle(3) fly: Faster inference with vLLM & speculative decoding” (2025-07-01): https://developers.redhat.com/articles/2025/07/01/fly-eagle3-fly-faster-inference-vllm-speculative-decoding
- Red Hat Developer, “Performance improvements with speculative decoding in vLLM for gpt-oss” (2026-04-16): https://developers.redhat.com/articles/2026/04/16/performance-improvements-speculative-decoding-vllm-gpt-oss
- Red Hat Developer, “How Marlin pushes the boundaries of mixed-precision LLM inference” (2024-04-17): https://developers.redhat.com/articles/2024/04/17/how-marlin-pushes-boundaries-mixed-precision-llm-inference
- Frantar et al., “MARLIN: Mixed-Precision Auto-Regressive Parallel Inference on Large Language Models” (paper): https://arxiv.org/pdf/2408.11743
- vLLM AWQ-Marlin quantization API reference: https://docs.vllm.ai/en/stable/api/vllm/model_executor/layers/quantization/awq_marlin/
- vLLM FP8 W8A8 docs: https://docs.vllm.ai/en/v0.8.5/features/quantization/fp8.html
- Zheng et al., “SGLang: Efficient Execution of Structured Language Model Programs” (RadixAttention paper): https://arxiv.org/abs/2312.07104
- Spheron Blog, “vLLM vs TensorRT-LLM vs SGLang: Which Is Fastest? (H100 Benchmarks, 2026)” (2026-03-23): https://www.spheron.network/blog/vllm-vs-tensorrt-llm-vs-sglang-benchmarks/
- AMD ROCm Blog, “vLLM V1 Meets AMD Instinct GPUs: A New Era for LLM Inference Performance”: https://rocm.blogs.amd.com/software-tools-optimization/vllmv1-rocm-llm/README.html
- NVIDIA TensorRT-LLM project: https://github.com/NVIDIA/TensorRT-LLM
- llm-d project, “Announcing the llm-d community!” (2025-05-20): https://llm-d.ai/blog/llm-d-announce
- llm-d GitHub repository: https://github.com/llm-d/llm-d
- llm-d, “llm-d 0.2: Our first well-lit paths”: https://llm-d.ai/blog/llm-d-v0.2-our-first-well-lit-paths
- Red Hat Developer, “llm-d: Kubernetes-native distributed inferencing” (2025-05-20): https://developers.redhat.com/articles/2025/05/20/llm-d-kubernetes-native-distributed-inferencing
- Red Hat Developer, “Introduction to distributed inference with llm-d” (2025-11-21): https://developers.redhat.com/articles/2025/11/21/introduction-distributed-inference-llm-d
- vLLM Blog, “vLLM Large Scale Serving: DeepSeek @ 2.2k tok/s/H200 with Wide-EP” (2025-12-17): https://vllm.ai/blog/2025-12-17-large-scale-serving
- vLLM Distributed inference and serving (Ray, multi-node): https://docs.vllm.ai/en/latest/serving/distributed_serving.html
- Kubernetes Gateway API Inference Extension project: https://github.com/kubernetes-sigs/gateway-api-inference-extension
- vLLM GitHub repository (issues, release notes, source of truth for current flag defaults): https://github.com/vllm-project/vllm
- vLLM Blog index (check here for what’s shipped since this chapter was written): https://vllm.ai/blog
- vLLM Prometheus/Grafana production metrics guide: https://docs.vllm.ai/en/latest/usage/metrics.html
- Kubernetes Horizontal Pod Autoscaler, custom/external metrics docs (for scaling on queue depth, section C.3): https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
- vLLM
vllm benchCLI reference (benchmarking harness used throughout section B): https://docs.vllm.ai/en/latest/cli/bench/
A closing note on staying current: everything in section (A) and the timeline in (A.7) is a snapshot as of this writing. vLLM, SGLang, and TensorRT-LLM all ship fast — engine defaults, kernel selection, and quantization coverage change between minor versions. Before an interview or a production decision, re-check vllm serve --help for your installed version and skim the last few entries at vllm.ai/blog rather than relying on any single source, including this chapter, as permanently current.
Topic 6: Autoscaling
What You’ll Learn
This topic teaches you how to:
- Set up Horizontal Pod Autoscaling (HPA)
- Scale based on CPU/memory metrics
- Scale based on custom metrics (request rate, latency)
- Configure scaling policies
- Handle scale-up and scale-down events
Why Autoscaling?
Benefits
- Cost optimization: Scale down when not needed
- Performance: Scale up under load
- Automation: No manual intervention needed
- Efficiency: Right-size resources
When to Scale
- High CPU/memory: Pods are overloaded
- High request rate: Many incoming requests
- High latency: System struggling
- Queue building: Requests waiting
Types of Autoscaling
1. Horizontal Pod Autoscaler (HPA)
Scales number of pod replicas based on metrics.
2. Vertical Pod Autoscaler (VPA)
Adjusts resource requests/limits (advanced).
3. Cluster Autoscaler
Adds/removes nodes (cloud providers).
HPA Basics
Simple HPA (CPU-based)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-serving-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-serving
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
How It Works
- HPA checks metrics every 15s (default)
- Compares current vs target
- Calculates desired replicas
- Updates deployment
- K8s creates/destroys pods
Custom Metrics
Request Rate Scaling
Scale based on requests per second:
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "10"
Latency-based Scaling
Scale when latency is high:
metrics:
- type: Pods
pods:
metric:
name: request_latency_p95
target:
type: AverageValue
averageValue: "500m" # 500ms
Scaling Behavior
Scale-up Policy
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
- type: Pods
value: 4
periodSeconds: 15
selectPolicy: Max
Scale-down Policy
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
selectPolicy: Min
Metrics Server
HPA needs metrics. Install metrics server:
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
Verify:
kubectl top nodes
kubectl top pods
Prometheus Adapter
For custom metrics, use Prometheus Adapter:
- Install Prometheus
- Install Prometheus Adapter
- Configure custom metrics
- HPA can query Prometheus
See 08_monitoring/ for Prometheus setup.
Complete HPA Example
See hpa.yaml for a complete example with:
- CPU scaling
- Memory scaling
- Custom metrics
- Scaling policies
Testing Autoscaling
Generate Load
# Use Locust or similar
locust -f locust_test.py --host=http://<service-url> \
--users 50 --spawn-rate 5
Watch Scaling
# Watch HPA
kubectl get hpa -w
# Watch pods
kubectl get pods -w
# Watch deployment
kubectl get deployment llm-serving -w
Check Metrics
# CPU/Memory
kubectl top pods
# HPA events
kubectl describe hpa llm-serving-hpa
Best Practices
- Set min replicas: Always have some pods running
- Set max replicas: Prevent runaway scaling
- Use stabilization windows: Avoid flapping
- Monitor scaling events: Track scale-up/down
- Test under load: Verify scaling works
- Consider costs: More pods = more cost
Common Issues
HPA Not Scaling
- Check metrics server is running
- Verify metrics are available
- Check HPA status:
kubectl describe hpa - Ensure deployment has resource requests
Scaling Too Aggressively
- Increase stabilization window
- Adjust scaling policies
- Check for metric spikes
Scaling Too Slowly
- Decrease stabilization window
- Adjust scaling policies
- Check metric collection delay
Exercises
- Basic HPA: Set up CPU-based autoscaling
- Custom metrics: Scale based on request rate
- Scaling policies: Configure scale-up/down behavior
- Load test: Generate load and observe scaling
- Monitor: Track scaling events and metrics
Next Steps
- Topic 7: Canary deployments for safe updates
- Topic 8: Monitor autoscaling with Grafana
- Topic 9: Version management with scaling
Further Reading
Autoscaling LLM Inference
Scaling GPU serving with demand — without wrecking latency or blowing the budget.
Why This Matters
A web service scales cheaply: pods are small, start in seconds, and CPU utilization is a clean proxy for load. LLM inference breaks all three assumptions. A single replica pins one or more GPUs that cost more per hour than an entire fleet of CPU pods. A new replica must pull tens of gigabytes of weights and warm CUDA before it serves a single token, so “add capacity” is a multi-minute operation, not a multi-second one. And traffic is bursty — a Slack integration or a batch job can 10x your request rate in seconds.
Get autoscaling wrong and you fail in one of two expensive directions:
- Under-provision: the queue backs up, time-to-first-token (TTFT) climbs, requests time out, and by the time a new replica is ready the spike is over.
- Over-provision: you pay for idle H100s around the clock to hedge against a spike that comes twice a day.
This chapter is about threading that needle: scaling on the right signals, using the right mechanism (HPA, KEDA, Knative), and mitigating the cold-start tax that makes LLM autoscaling uniquely hard. For the metric definitions and the Prometheus/Grafana stack that feeds these controllers, see the Monitoring chapter.
Saying it out loud. Autoscaling a web service is easy because pods are small, start in seconds, and CPU is a decent proxy for load. LLM inference breaks all three. One replica pins a GPU that costs more per hour than an entire fleet of CPU pods, a new replica has to pull tens of gigabytes of weights and warm CUDA before it serves a single token — so adding capacity is a multi-minute operation — and traffic is bursty enough that a Slack integration can 10x your rate in seconds. Get it wrong in either direction and it’s expensive: under-provision and the queue backs up and requests time out before new capacity lands, over-provision and you’re paying for idle H100s around the clock to hedge against a spike that comes twice a day.
Core Intuition: Why CPU-Based HPA Is Wrong for LLMs
The default Kubernetes HorizontalPodAutoscaler scales on CPU utilization. For a stateless web app that is a reasonable proxy: more requests → more CPU → scale up. For an LLM server it is actively misleading.
Consider what a vLLM or TGI process actually does. The heavy lifting happens on the GPU; the Python/host process spends most of its time waiting on CUDA kernels and shuffling tensors. So:
- A GPU that is 100% saturated — KV cache full, requests queuing — can show modest host CPU. HPA sees “plenty of headroom” and refuses to scale while your p99 latency melts.
- A freshly loaded replica warming its cache can spike CPU while serving nothing, tricking HPA into scaling up when it shouldn’t.
CPU utilization is decoupled from the thing you actually care about: can I admit another request and still hit my latency SLO? For LLMs the honest answer lives in queue depth, in-flight concurrency, KV-cache pressure, GPU utilization, and TTFT — never in host CPU.
The mental model: scale on the length of the line, and on how long people wait in it — not on how busy the cashier’s hands look.
(The GPU-memory pre-allocation and CPU/GPU decoupling claims above are corroborated by field reports on running vLLM under Kubernetes autoscaling — see the DEV Community write-up cited in War Story 1 and the Further Reading list.)
The 60-second interview answer. “CPU-based HPA scales on host CPU utilization, but an LLM server’s real bottleneck is the GPU, not the host. vLLM pre-allocates most of its GPU memory for the KV cache at startup, so GPU memory looks flat whether you’re idle or saturated, and the host process spends its time waiting on CUDA kernels, so CPU stays low even when every request is queuing. That means the metric HPA trusts by default — CPU — is decoupled from the thing that actually determines whether a new request gets served on time. Instead you scale on signals that reflect GPU-side demand directly: queue depth (
num_requests_waiting), in-flight concurrency, KV-cache utilization, and GPU utilization fromdcgm-exporter, with TTFT or p95 latency as a lagging guardrail rather than the primary trigger. The one-line version: scale on the length of the line, not on how busy the cashier’s hands look.”
The scaling control loop
Every mechanism below is the same loop with different parts swapped in. Keep it in your head:
requests ──▶ [ vLLM replicas ] ──▶ metrics (queue depth, GPU util, TTFT)
▲ │
│ ▼
scale up / down [ Prometheus / dcgm-exporter ]
▲ │
│ ▼
[ Deployment ] ◀── HPA / KEDA / KPA ◀── PromQL query vs target
The controller polls a metric, compares it to a target, and nudges the replica count. Everything interesting — which metric, which target, how fast to react, whether zero is allowed — is a knob on that loop.
Saying it out loud. Every autoscaling mechanism in this chapter is the same loop with different pieces swapped in, and it’s worth holding in your head. Requests hit your replicas, the replicas emit metrics, a controller polls one of those metrics, compares it to a target, and nudges the replica count on the Deployment. That’s it. Everything interesting is a knob on that loop: which metric you trust, what target you set, how fast you’re willing to react in each direction, and whether zero replicas is even legal. HPA, KEDA, and Knative’s KPA differ in what plugs into which slot, not in the shape of the loop — which is why picking the mechanism is usually the least important decision you make here.
The Right Scaling Signals
There is no single perfect signal. Each trades responsiveness against noise and against how directly it maps to user-visible latency. Good production setups combine a fast demand signal (queue depth / concurrency) with an SLO guardrail (TTFT or p95 latency) so a breach of either forces a scale-up.
| Signal | Source | Pros | Cons |
|---|---|---|---|
Queue depth (vllm:num_requests_waiting) | vLLM/TGI Prometheus metric | Directly reflects unmet demand; leads latency, so it’s an early signal; cheap to compute | Zero when you’re merely at capacity-but-coping; noisy for spiky traffic without smoothing |
In-flight / concurrency (vllm:num_requests_running) | Engine metric or Knative KPA | Maps cleanly to a per-replica capacity target; stable | Saturates at the batch limit — can’t tell “full” from “overwhelmed” alone |
GPU utilization (DCGM_FI_DEV_GPU_UTIL) | NVIDIA dcgm-exporter | Hardware truth; catches non-vLLM workloads too | Lagging and coarse — 100% util can mean “efficiently batched” or “drowning”; poor sole signal |
KV-cache usage (vllm:gpu_cache_usage_perc) | vLLM metric | Predicts imminent preemption/OOM before latency degrades | vLLM-specific; needs a sensible target (~90%) |
TTFT / p95 latency (vllm:e2e_request_latency_seconds) | Engine histogram → histogram_quantile | Is the SLO — what users actually feel | Lagging: by the time it breaches, users are already hurting. Use as guardrail, not primary |
| Requests per second | Ingress / KPA | Simple, intuitive | Ignores request size; 10 long generations ≠ 10 short ones |
| Batch/token throughput | Engine metrics | Reflects real GPU work | Hard to set a stable target; varies with prompt length |
Rule of thumb: lead with queue depth or concurrency, guard with a latency SLO, and treat GPU util as a sanity cross-check — not as the trigger.
A note on continuous batching. vLLM and similar engines use continuous (iteration-level) batching — new requests join a running batch between decode steps rather than waiting for the current batch to fully finish, and the batch composition changes token by token. This is why per-request metrics like “requests per second” undercount the real picture: two requests generating 20 tokens each and one request generating 2,000 tokens can produce an identical RPS reading while representing wildly different GPU-second costs. Prefer signals that reflect the batching engine’s own internal state (queue depth, in-flight count, KV-cache usage) over signals computed purely from request arrival, since the engine’s internal state is what continuous batching is actually managing.
A note on multi-model and multi-LoRA serving. A replica serving several LoRA adapters (or several small models) behind one endpoint doesn’t have a single, fixed “capacity per replica” — it has one per adapter/model combination currently loaded, and swapping adapters costs time. Queue-depth and concurrency targets derived from a load test of one model in isolation can silently overstate real capacity once several are multiplexed onto the same GPU. If your fleet serves more than one model per replica, re-run the load-test-derived-threshold exercise above against the actual multi-model traffic mix, not a single-model benchmark.
A note on GPU sharing and MIG. NVIDIA’s Multi-Instance GPU (MIG) partitions a single physical GPU into several smaller, hardware-isolated instances, and time-slicing shares a GPU across pods without hardware partitioning. Either changes the unit this entire chapter has been scaling: “a replica” no longer maps 1:1 to “a physical GPU,” so nvidia.com/gpu: 1 in a pod spec might mean a full H100 or a fraction of one depending on the node’s MIG configuration. Before applying any of this chapter’s cost or headroom math to a MIG-partitioned fleet, confirm which of “replica,” “GPU instance,” and “physical GPU” your maxReplicas/quota numbers actually refer to — the three are easy to conflate and the resulting error compounds directly into the warm-pool sizing calculation above.
Saying it out loud. There’s no single perfect signal, so good setups compose two: a fast demand signal as the trigger and a latency SLO as a guardrail, so a breach of either forces a scale-up. Queue depth —
num_requests_waiting— is the best primary because it’s a leading indicator: it moves before users feel anything. In-flight concurrency is the stable alternative. The trap worth naming explicitly is GPU utilization: it reads high whether you’re efficiently batched or drowning, and it can read high while the model is actually memory-bandwidth-stalled, so it’s a sanity cross-check, never a trigger. And latency is the SLO itself, which makes it lagging — by the time it breaches, users are already hurting. Scale on the length of the line, not on how busy the cashier’s hands look.
Signal composition patterns seen in production
Three combinations recur often enough to name:
- Queue depth (primary) + latency guardrail (safety net). The default recommendation throughout this chapter. Queue depth reacts before users feel anything; latency confirms the SLO is actually being met and catches anything queue depth misses.
- Queue depth (primary) + KV-cache usage (early-warning) + latency (guardrail). The three-signal composition War Story 1 argues for — necessary once long-context or highly variable-length requests are common enough that KV-cache pressure can spike faster than the queue does.
- Per-pool signals in disaggregated serving — KV-cache utilization for decode, prefill-queue depth for prefill, scaled and alerted on independently (see the Landscape section’s disaggregated-serving subsection). This is pattern 1 or 2, applied twice, once per pool.
The unifying rule is the same one this chapter opened with: pick signals close to the actual bottleneck, use the fastest one as the trigger, and keep the SLO itself as a guardrail rather than the primary control variable.
Saying it out loud. Three combinations recur enough to be worth naming. The default is queue depth as the trigger plus a latency guardrail — queue depth reacts before anyone feels anything, latency confirms the SLO is genuinely being met. The second adds KV-cache utilization as an early warning in the middle, and you need that once long-context or highly variable-length requests are common, because KV pressure can spike faster than the queue does. The third is per-pool signals in disaggregated serving: KV-cache utilization for the decode pool, prefill-queue depth for the prefill pool, scaled independently. That last one is really just the first pattern applied twice. The unifying rule: pick signals close to the actual bottleneck, trigger on the fastest one, keep the SLO as a guardrail rather than the control variable.
Mechanism 1 — HPA with Custom / External Metrics
Kubernetes’ HPA can scale on more than CPU. Since autoscaling/v2 it supports three metric flavors:
- Resource — CPU/memory (the default; wrong for us).
- Pods — a custom per-pod metric averaged across pods (e.g. queue depth per replica).
- Object / External — a metric attached to another object or pulled from an external system (e.g. a Prometheus query).
To feed HPA a Prometheus metric you install the Prometheus Adapter (prometheus-adapter), which registers the custom.metrics.k8s.io / external.metrics.k8s.io APIs and translates HPA’s metric requests into PromQL. GPU utilization itself comes from NVIDIA’s dcgm-exporter (metric DCGM_FI_DEV_GPU_UTIL), scraped by Prometheus.
Saying it out loud. Kubernetes’ HPA can scale on more than CPU — since
autoscaling/v2it takes resource metrics, per-pod custom metrics, and external metrics. But HPA doesn’t speak PromQL, so you need the Prometheus Adapter in between, which registers the custom-metrics API and translates HPA’s requests into queries. GPU utilization itself comes from NVIDIA’sdcgm-exporter. So the pipeline is: engine emitsvllm:num_requests_waiting, Prometheus scrapes it, the adapter exposes it under a Kubernetes-friendly name, HPA reads that and does its ratio math. Worth knowing because the single most common “HPA won’t scale” incident isn’t the HPA at all — it’s a missing or misnamed metric one layer down in that chain.
Wiring the metric pipeline
HPA does not speak PromQL. The Prometheus Adapter bridges the gap: you give it a rule that maps a Kubernetes metric name to a query. A minimal rule exposing vLLM’s queue depth as a per-pod custom metric looks like:
# prometheus-adapter values.yaml (rules.custom[])
rules:
custom:
- seriesQuery: 'vllm:num_requests_waiting{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "vllm:num_requests_waiting"
as: "vllm_num_requests_waiting" # HPA-friendly name (no colon)
metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)'
Verify the metric is actually served before pointing HPA at it — a huge fraction of “HPA won’t scale” incidents are just a missing or misnamed metric:
kubectl get --raw \
"/apis/custom.metrics.k8s.io/v1beta1/namespaces/inference/pods/*/vllm_num_requests_waiting" | jq .
If that returns no metrics returned from custom metrics API, the problem is the pipeline (labels, series name, scrape) — not the HPA.
Saying it out loud. The adapter config is one rule that maps a Prometheus series to a Kubernetes metric name plus the PromQL to compute it. One gotcha built right into the example: the metric name has a colon in it,
vllm:num_requests_waiting, and HPA metric names can’t contain colons — so you rename it in theas:field. Before you point an HPA at anything, verify the metric is actually being served with akubectl get --rawcall against the custom-metrics API. If that returns “no metrics returned,” your problem is labels, series names, or scrape config — not the autoscaler. Doing that thirty-second check first is the difference between debugging one layer and debugging three.
The HPA algorithm
HPA computes desired replicas with a simple ratio:
[ \text{desiredReplicas} = \left\lceil \text{currentReplicas} \times \frac{\text{currentMetricValue}}{\text{desiredMetricValue}} \right\rceil ]
With multiple metrics, HPA computes a target for each and takes the maximum — the metric demanding the most replicas wins. That is exactly why a fast demand signal plus a latency guardrail composes well: whichever is more stressed drives the decision.
Plugging in numbers. Suppose a deployment currently runs 3 replicas, and the queue-depth metric averages 15 waiting requests per pod against a target of 5:
[ \text{desiredReplicas} = \left\lceil 3 \times \frac{15}{5} \right\rceil = \left\lceil 9 \right\rceil = 9 ]
If, in the same cycle, the GPU-utilization metric only computes a desired count of 6, HPA takes the max of the two — 9 replicas, driven by queue depth — and that’s the number that actually gets applied. This is the concrete mechanism behind “whichever signal is more stressed wins”: it isn’t a qualitative preference, it’s this arithmetic, evaluated per metric, every sync.
Saying it out loud. HPA’s math is a single ratio: desired replicas equals current replicas times current metric value over target metric value, rounded up. So three replicas averaging fifteen waiting requests against a target of five gives you nine. The important behavior with multiple metrics is that HPA computes a recommendation for each and takes the maximum — so whichever signal is most stressed drives the decision. That’s not a qualitative preference, it’s literally this arithmetic evaluated per metric every sync, and it’s exactly why a fast demand signal plus a latency guardrail composes cleanly. It’s also why a stale or misconfigured secondary metric pinned high can silently override a perfectly healthy primary.
Worked HPA manifest — scale on GPU utilization + queue depth
This assumes dcgm-exporter and prometheus-adapter are installed, and the adapter exposes DCGM_FI_DEV_GPU_UTIL as a Pods metric and vllm_num_requests_waiting as an External metric.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicas: 2 # provisioned floor — never cold-start the first request
maxReplicas: 12 # capped by GPU quota (see pitfalls)
metrics:
# Primary demand signal: waiting requests per replica
- type: Pods
pods:
metric:
name: vllm_num_requests_waiting
target:
type: AverageValue
averageValue: "5" # aim to keep <5 queued per pod
# Hardware cross-check: average GPU utilization
- type: Pods
pods:
metric:
name: DCGM_FI_DEV_GPU_UTIL
target:
type: AverageValue
averageValue: "75" # scale up past ~75% average util
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react to spikes immediately
policies:
- type: Pods
value: 4 # add up to 4 pods...
periodSeconds: 60 # ...per minute
- type: Percent
value: 100 # ...or double, whichever is larger
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 min of calm before shrinking
policies:
- type: Pods
value: 1 # remove at most 1 pod...
periodSeconds: 120 # ...every 2 min — GPUs are expensive to churn
selectPolicy: Min
Tuning notes.
scaleUp.stabilizationWindowSeconds: 0— scale up on the freshest reading. Because cold starts are slow, hesitating on scale-up is the costliest mistake you can make.scaleDown.stabilizationWindowSeconds: 300— HPA takes the highest recommendation over the window before shrinking, so a 5-minute window prevents a brief traffic lull from tearing down a replica you’ll re-pay a cold start to rebuild.- Asymmetric policies — aggressive up, gentle down (one pod every two minutes). This is the opposite of a cost-first web-app config, and it’s deliberate: for GPUs, flapping is more expensive than a little idle.
- Target values are per-pod averages —
averageValue: "5"means HPA aims for 5 waiting requests per replica, so the ratio math scales linearly with fleet size.
Saying it out loud. The shape of a correct GPU HPA is deliberately asymmetric, and that’s the thing to say out loud. Scale-up gets a stabilization window of zero — react to the freshest reading, never hesitate, because cold starts already make you slow and hesitating on top of that is the costliest mistake available. Scale-down gets a five-minute window, so a brief lull doesn’t tear down a replica you’ll immediately re-pay a cold start to rebuild. And the step policies match: add up to four pods a minute, remove at most one every two minutes. This is the opposite of a cost-first web-app config and it’s deliberate — for GPUs, flapping costs more than a little idle time does.
Troubleshooting — “my HPA won’t scale”
In rough order of how often each is actually the culprit:
- The custom/external metric isn’t being served at all. Confirm with the
kubectl get --rawcheck earlier in this section before touching the HPA object itself — most “HPA is broken” reports are a missing or misnamed metric one layer down. AverageValuevsValuemismatch. Asum()query paired withtype: Value(expecting a per-pod figure) makes the target drift as the fleet grows; verify which one your PromQL actually returns.- The metric is real but the target is unreachable. If
averageValueis set far below what the workload can ever realistically achieve, HPA will happily recommendmaxReplicasforever — sanity-check the target against a load test, not intuition. minReplicas/maxReplicasboundaries are silently constraining the recommendation —kubectl describe hpashows the computed vs. constrained value; a HPA “stuck” atmaxReplicasis doing its job, the ceiling is just the bottleneck (see the GPU-quota pitfall and War Story 3).- The
behaviorblock’s stabilization window is masking a real signal — during initial rollout, temporarily setscaleDown.stabilizationWindowSecondslow to confirm the underlying metric-to-replica math works at all, then restore the production value. - Multiple metrics disagree and the wrong one is winning — remember HPA takes the max recommendation across metrics; if a stale or misconfigured secondary metric is pinned high, it can override a healthy primary signal.
Saying it out loud. In rough order of how often each is actually the culprit. One: the custom metric isn’t being served at all — check with
kubectl get --rawbefore touching the HPA object, because most “HPA is broken” reports are a missing metric one layer down. Two: anAverageValueversusValuemismatch, where asum()query paired with a per-pod target makes the effective threshold drift as the fleet grows. Three: the target is set below anything the workload can physically achieve, so HPA recommendsmaxReplicasforever. Four: the min or max boundary is silently constraining it —kubectl describe hpashows computed versus constrained. Five: the stabilization window is masking a real signal. Six: multiple metrics disagree and the wrong one is winning the max.
Mechanism 2 — KEDA for Event-Driven & Queue-Based Scaling
HPA + Prometheus Adapter works but is fiddly: you maintain adapter rules, and HPA alone cannot scale to zero. KEDA (Kubernetes Event-Driven Autoscaling) sits on top of HPA and fixes both. It ships 70+ scalers (Prometheus, Kafka, SQS, RabbitMQ, Redis, …) and, crucially, can scale a deployment from 0 → 1 and back to 0.
KEDA introduces two ideas HPA lacks:
activationThreshold— the value that flips a workload from zero to one. This is separate from the scalingthreshold(which governs 1→N). It exists precisely so a single stray request doesn’t wake a cold GPU, and so a trickle doesn’t keep one warm.minReplicaCount: 0— legal in KEDA, impossible in raw HPA.
Saying it out loud. HPA plus the Prometheus Adapter works but is fiddly, and it has one hard limitation: it cannot scale to zero. KEDA sits on top of HPA and fixes both — it ships seventy-plus scalers so you’re not maintaining adapter rules, and it can go from zero to one and back. The concept worth knowing by name is
activationThreshold, which is separate from the scaling threshold: the scaling threshold governs one-to-N, and the activation threshold is what flips you from zero to one. It exists precisely so a single stray request doesn’t wake a cold GPU, and so a trickle of traffic doesn’t keep an expensive replica warm all night for nothing.
ScaledObject vs ScaledJob
Everything above uses KEDA’s ScaledObject, which manages an HPA behind a Deployment — the right shape for a long-lived pool of replicas serving live requests. KEDA also ships ScaledJob, which creates a Kubernetes Job per unit of work instead of managing replica count on a Deployment — the right shape for the batch-summarization system-design prompt later in this chapter: each queued item becomes its own Job, scaled by the same queue-depth-style triggers (SQS/Kafka/Redis length), with no notion of a “replica count” to tune stabilization windows for at all. Picking between them is really a question of workload shape, not a scaling-signal question: ScaledObject for a pool of servers accepting live requests, ScaledJob for a stream of discrete, run-to-completion units of work.
Saying it out loud. KEDA has two primitives and picking between them is a question about workload shape, not about scaling signals. A
ScaledObjectmanages an HPA behind a Deployment — that’s the right shape for a long-lived pool of replicas accepting live requests, which is everything else in this chapter. AScaledJobinstead creates a Kubernetes Job per unit of work, so there’s no replica count to tune stabilization windows for at all — each queued item becomes its own run-to-completion pod. That’s the right shape for batch pipelines: a summarization queue, an eval sweep, an offline scoring job. The rule in one line:ScaledObjectfor a pool of servers,ScaledJobfor a stream of discrete units of work.
Worked KEDA ScaledObject — queue depth + latency guardrail
This mirrors the pattern AWS documents for vLLM on EKS: a primary queue-depth trigger and a p95-latency guardrail, scaling to satisfy whichever demands more replicas.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-scaler
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicaCount: 1 # warm floor; set 0 only if cold starts are acceptable
maxReplicaCount: 12
pollingInterval: 15 # query Prometheus every 15s
cooldownPeriod: 300 # after last trigger, wait 5 min before scaling toward min
advanced:
horizontalPodAutoscalerConfig:
behavior: # KEDA passes this straight through to the HPA it manages
scaleDown:
stabilizationWindowSeconds: 300
scaleUp:
stabilizationWindowSeconds: 0
triggers:
# Primary: queue depth (waiting requests), averaged per replica
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: sum(vllm:num_requests_waiting) or vector(0)
threshold: "5"
activationThreshold: "1" # first waiting request wakes the deployment
# Guardrail: p95 end-to-end latency in seconds
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: |
histogram_quantile(0.95,
sum(rate(vllm:e2e_request_latency_seconds_bucket[1m])) by (le)) or vector(0)
threshold: "5" # scale up if p95 latency exceeds 5s
Tuning notes.
or vector(0)is not decoration — if the query returns no series (e.g. the deployment is scaled to zero and exporting nothing), KEDA would otherwise error.or vector(0)yields a clean0, which is exactly the “no demand” reading you want.metricType: AverageValuedivides the query result by the current replica count so the target is per-pod; useValueonly when your query already returns a per-pod figure.activationThreshold: "1"vsthreshold: "5"— one waiting request is enough to justify the first replica; you only add more replicas once the per-pod queue passes 5.cooldownPeriodgoverns the final ramp towardminReplicaCount(including the drop to zero); the HPAbehaviorblock governs the 1→N steps.- Prefer a dedicated queue metric (
num_requests_waiting) over latency as the primary — latency lags, and by the time p95 breaches, the SLO is already violated. Latency is the seatbelt, not the accelerator.
Saying it out loud. The KEDA version of the same idea is a
ScaledObjectwith two triggers: a Prometheus trigger on queue depth as the primary, and a second Prometheus trigger on p95 latency as the guardrail. KEDA scales to satisfy whichever trigger demands more replicas — same max-of-metrics behavior as HPA, because KEDA is generating an HPA underneath. The additions over raw HPA are the ones that matter operationally:minReplicaCountcan be zero, there’s anactivationThresholdseparate from the scaling threshold, and acooldownPeriodgoverns how long you wait before going back to zero. And you get all of that without maintaining Prometheus Adapter rules by hand.
Mechanism 3 — Scale-to-Zero, Cold Starts, and Serverless GPU
Scale-to-zero is the dream: pay nothing when idle. For LLMs it collides head-on with the cold-start problem.
Saying it out loud. Scale-to-zero is the dream — pay nothing when idle — and for LLMs it collides head-on with cold starts. The honest framing is that scale-to-zero isn’t a mechanism problem, it’s a latency-budget problem: you can absolutely configure it in KEDA or Knative in about five lines, and the question is entirely whether your users can tolerate a multi-minute first response. For anything latency-tolerant — batch jobs, internal tools, dev environments — it’s straightforwardly correct and saves a lot of money. For anything user-facing you keep a warm floor, and then spend your engineering effort on shrinking the cold start rather than on the autoscaler config.
Anatomy of an LLM cold start
When a scaled-to-zero deployment gets a request, the clock runs through:
- Scheduling — Kubernetes finds a node with a free GPU (seconds → minutes if the cluster autoscaler must add a node).
- Image pull — the container image is often 5–15 GB (CUDA, PyTorch, vLLM). Seconds to minutes if not cached on the node.
- Weight load — read tens of GB of weights from disk/network into host RAM, then copy to VRAM. This dominates — often the largest single chunk.
- CUDA / engine warm-up — initialize CUDA context, compile/capture CUDA graphs, allocate the KV cache.
For a mid-size model this is routinely 1–5 minutes, and can be far worse if the cluster autoscaler has to boot a fresh GPU node first. That is an eternity for an interactive request. So true scale-to-zero is only acceptable for latency-tolerant workloads (batch, internal tools, dev). For anything user-facing, you keep a warm floor.
Saying it out loud. Break a cold start into four phases and you know where to spend effort. Scheduling — finding a node with a free GPU, seconds if one’s warm, minutes if the cluster autoscaler has to boot one. Image pull — the container is often five to fifteen gigabytes of CUDA and PyTorch. Weight load — reading tens of gigabytes into host RAM and then into VRAM, and this is the phase that dominates. And CUDA warm-up: context init, graph capture, KV-cache allocation. For a mid-size model that’s routinely one to five minutes end to end, and far worse if a node has to be provisioned first. That’s the number that makes LLM autoscaling structurally different from web-app autoscaling — everything else in this chapter is a response to it.
Cold-start mitigation comparison
| Mitigation | How it works | Cold-start impact | Cost | Best for |
|---|---|---|---|---|
Provisioned min replicas (minReplicas/minReplicaCount ≥ 1) | Never fully scale down; keep N warm | Eliminates it for the first N concurrent requests | Highest — you pay for idle GPUs | Interactive, SLA-bound traffic |
| Warm pool / over-provision headroom | Keep spare ready replicas ahead of demand (e.g. +1 buffer) | New traffic hits an already-warm pod | Medium — pay for the buffer only | Predictable spikes, autoscaling with slack |
| Faster weight loading (Run:ai Model Streamer, tensorizer, safetensors + fast storage) | Stream weights concurrently from object storage straight to GPU; skip slow deserialization | Cuts the dominant load phase (reported up to ~6x) | Low — engineering only | Every setup; stacks with others |
| Node/image pre-pull & DaemonSet cache | Pre-pull the container image and warm node caches | Removes image-pull phase | Low | Large images, node churn |
| Snapshot / checkpoint-restore (NVIDIA Dynamo snapshot, CUDA checkpoint/CRIU) | Snapshot a warmed process (CUDA context + weights in VRAM) and restore it | Can approach near-zero — skips load and warm-up | Medium; newer/less mature | Aggressive scale-to-zero without the latency tax |
| Smaller/quantized model or smaller shards | Fewer bytes to move and initialize | Proportionally shorter load | Free-ish (accuracy tradeoff) | When quality budget allows |
Knative / serverless GPU. Knative Serving offers request-driven autoscaling with native scale-to-zero. Its default KPA (Knative Pod Autoscaler) scales on concurrency or RPS rather than CPU — a much better fit for LLMs than raw HPA. Key pieces:
containerConcurrency/ target concurrency — the per-replica in-flight target KPA scales to maintain.- The activator buffers requests while a scaled-to-zero service spins up, so requests aren’t dropped — they’re held (and pay the cold-start latency).
- Panic mode / target-burst-capacity — when traffic spikes sharply, KPA enters a short “panic” window and scales on a much shorter horizon to react fast, then relaxes.
Knative is elegant for bursty, latency-tolerant serving, but the activator’s request buffering doesn’t erase the cold start — it just prevents dropped requests. You still pay the minutes. Pair scale-to-zero with a snapshot/fast-load strategy, or keep minScale ≥ 1 for interactive paths.
Saying it out loud. Six ways to attack the cold-start tax, roughly by cost. Provisioned minimum replicas eliminates it entirely for the first N requests but is the most expensive — you’re paying for idle GPUs. A warm-pool buffer is the same idea sized to demand growth rather than to peak. Faster weight loading via streaming attacks the dominant phase and costs only engineering effort, which is why it’s closest to a free lunch here. Pre-pulling images removes the pull phase cheaply. Snapshot and checkpoint-restore can approach near-zero by skipping load and warm-up, but it’s the newest and most fragile. And a smaller or quantized model is proportionally faster to load, at an accuracy cost. The practical stance: start with weight streaming, because it stacks with everything else.
Worked Knative Service — concurrency-driven with a warm floor
The same workload as a Knative Service, scaling on concurrency with KPA. Note minScale: 1 — a warm floor that dodges the cold start on the interactive path while still capping cost with maxScale.
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: vllm-llama3-8b
namespace: inference
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/class: "kpa.autoscaling.knative.dev"
autoscaling.knative.dev/metric: "concurrency"
autoscaling.knative.dev/target: "8" # ~8 in-flight requests per replica
autoscaling.knative.dev/target-utilization-percentage: "80"
autoscaling.knative.dev/min-scale: "1" # warm floor (set 0 for scale-to-zero)
autoscaling.knative.dev/max-scale: "12"
autoscaling.knative.dev/scale-down-delay: "5m" # hold before shrinking
autoscaling.knative.dev/target-burst-capacity: "200" # activator buffers bursts
spec:
containerConcurrency: 16 # hard per-replica ceiling
containers:
- image: vllm/vllm-openai:latest
resources:
limits:
nvidia.com/gpu: "1"
Tuning notes.
target: 8vscontainerConcurrency: 16— KPA aims to keep ~8 concurrent requests per replica (the soft target) while 16 is the hard cap. Keeping the soft target well below the hard cap leaves slack for the seconds before a new replica is ready.target-burst-capacitydecides how much spike the activator absorbs (buffering requests) before KPA has scaled out; higher values route more traffic through the activator, trading a little steady-state latency for burst safety.scale-down-delayis Knative’s answer to flapping — the KPA equivalent of HPA’s scale-down stabilization window.- Set
min-scale: 0only when the workload tolerates the cold start; the activator will hold the request but the user still waits out the load.
Saying it out loud. Knative’s KPA scales on concurrency or requests per second rather than CPU, which is a much better fit for LLMs than raw HPA out of the box. Three pieces to know.
containerConcurrencyis the per-replica in-flight target it scales to maintain. The activator buffers requests while a scaled-to-zero service spins up, so they’re held rather than dropped. And panic mode kicks in on a sharp spike, temporarily scaling on a much shorter horizon before relaxing. The honest caveat: the activator prevents dropped requests, it doesn’t erase the cold start — those users still wait the full minutes. Which is why the example setsminScale: 1, a warm floor that dodges the cold start on the interactive path whilemaxScalestill caps cost.
Snapshot and checkpoint-restore, in more depth
The cold-start table above lists snapshot/checkpoint-restore as the mitigation that can approach near-zero cold starts by skipping both the weight-load and warm-up phases, not just one. Worth unpacking why: a “cold” replica pays for weight load (reading tens of GB into VRAM) and warm-up (CUDA context init, graph capture, KV-cache allocation) every single time it starts, even though both produce an identical end state for a given model and configuration. Snapshotting captures that end state once — a already-warmed process, weights resident in VRAM, CUDA context initialized — and restores it directly on subsequent starts, turning “redo the whole boot sequence” into “load a pre-made snapshot.”
Two flavors show up in practice:
- Process/CUDA-context snapshotting (NVIDIA Dynamo Snapshot, CUDA checkpoint combined with CRIU-style process checkpointing) — snapshots the actual running process, GPU memory included, and restores it wholesale. Fastest in principle, since nothing is recomputed; newest and least battle-tested of the mitigations in this chapter, and typically tied to a specific engine/driver version.
- Fast-load without full process snapshotting (Run:ai Model Streamer and similar) — still runs a normal boot sequence, but makes the weight-load phase itself fast by streaming concurrently rather than sequentially. Less fragile than full snapshotting, and the mitigation with the clearest, most reproducible published numbers (the ~6–7.6x figures in the Landscape section above) — which is part of why it has become closer to a default than snapshotting has, as of this writing.
A practical stance: reach for fast weight loading first — it’s lower-risk, stacks with every other mitigation in this chapter, and is now natively supported by vLLM. Reach for full process/CUDA snapshotting only when the SLO genuinely requires sub-few-second cold starts and the engineering cost of maintaining a more fragile, version-pinned snapshot pipeline is worth it — which is exactly the tradeoff the fastest serverless GPU platforms in the Landscape section’s cold-start comparison table have already made on your behalf, if you’d rather not build it yourself.
Saying it out loud. The insight behind snapshotting is that a cold replica pays for weight load and CUDA warm-up every single start, even though both produce an identical end state for a given model and config. So you capture that end state once — warmed process, weights resident in VRAM, CUDA context initialized — and restore it directly. Two flavors: full process and CUDA-context snapshotting, which is fastest in principle since nothing is recomputed but is newest, most fragile, and typically pinned to a specific engine and driver version; and fast-loading like Run:ai Model Streamer, which still boots normally but streams weights concurrently. My stance: reach for fast weight loading first because it’s lower-risk, stacks with everything, and is natively supported in vLLM. Reach for full snapshotting only when the SLO genuinely demands sub-few-second cold starts.
Which mechanism should I pick?
| Situation | Reach for | Why |
|---|---|---|
| Simple custom-metric scaling, floor ≥ 1, existing Prometheus | HPA + Prometheus Adapter | Fewest moving parts; native; no scale-to-zero needed |
| Queue/event-driven, scale-to-zero, many metric sources | KEDA | activationThreshold, 0→1, and 70+ scalers wrap HPA cleanly |
| Concurrency-driven serverless with request buffering | Knative (KPA) | Built-in scale-to-zero + activator; concurrency target fits LLMs |
| Bursty, latency-tolerant, want managed cold-start buffering | Knative | Activator holds requests during spin-up so nothing drops |
| Interactive, strict TTFT SLO | Any + warm floor | The mechanism matters less than never cold-starting the hot path |
These aren’t mutually exclusive: a common production shape is KEDA for the demand-driven scale (including 0→1) plus a provisioned floor for the interactive tier, with all three fed by the same Prometheus pipeline.
Saying it out loud. Short version. If you need simple custom-metric scaling with a floor of at least one and you already run Prometheus, use HPA plus the adapter — fewest moving parts. If you need scale-to-zero or you’re driving off a queue rather than an inference metric, use KEDA, because it wraps HPA cleanly and gives you the zero-to-one transition HPA structurally cannot do. If you want concurrency-driven serverless with built-in request buffering during spin-up, use Knative. And if you have a strict interactive latency SLO — honestly, the mechanism matters far less than never cold-starting the hot path. These aren’t exclusive either: a very common production shape is KEDA for demand-driven scaling plus a provisioned floor for the interactive tier, all fed by one Prometheus pipeline.
The 2025–2026 Landscape
Autoscaling for LLM serving moved from “borrow the web-app playbook” to a purpose-built discipline in 2025–2026. Four threads matter if you’re building or defending a design today.
Saying it out loud. Autoscaling for LLMs went from “borrow the web-app playbook” to a purpose-built discipline over 2025 and 2026, and four threads matter. KEDA’s scaler set kept growing, especially the Cron scaler for genuinely calendar-shaped demand, with predictive forecasting layers appearing on top. Serverless GPU platforms converged on cold starts measured in seconds rather than minutes. Model-weight streaming became close to a default and directly attacks the phase that dominates cold start. And disaggregated serving broke the assumption that “a replica” is the atomic scaling unit, so purpose-built autoscalers now scale prefill and decode pools independently on phase-appropriate metrics. Underneath all of it, the same principle: pick the signal closest to the actual bottleneck.
KEDA’s LLM-relevant scalers keep expanding
- The Prometheus scaler (used throughout this chapter) remains the workhorse; current docs are at KEDA v2.20 (keda.sh/docs/2.20/scalers/prometheus).
- The Cron scaler (keda.sh/docs/2.20/scalers/cron, available since KEDA v1.5) lets you define named
start/endcron windows with adesiredReplicasand IANAtimezone. It does not run on a recurring implicit schedule — it only activates within the explicit windows you give it — which makes it the right tool for genuinely calendar-shaped demand (business hours, known batch windows). AScaledObjectcan carry a Cron trigger and a Prometheus trigger simultaneously; KEDA scales to satisfy whichever trigger currently demands more, the same max-of-metrics idea as HPA’s multi-metric behavior. See the worked example below. - The KEDA community is actively debating going further. GitHub issue kedacore/keda#6934 (opened 2026) proposes an “LLM Scaler” — nicknamed Cognitive Scaling — that would feed unstructured signals (news feeds, social sentiment, support-ticket volume) through an LLM to produce a scaling metric before the effect shows up in queue depth at all, e.g. scaling ahead of a product launch or a viral moment. KEDA maintainer JorTurFer’s counter-proposal — “scaling modifiers” that blend an LLM’s judgment with existing scaler outputs rather than shipping a bespoke scaler — is the more likely direction: augment reactive metrics with synthesized context, don’t replace them. As of this writing it is a proposal under discussion, not a shipped feature — cite it as “where the conversation is going,” not as production-ready.
- Kedify (kedify.io), founded by core KEDA maintainers, layers a proprietary predictive autoscaling feature on top of open-source KEDA: a
MetricPredictorCRD forecasts a metric using Facebook Prophet, retrains on a configurable cadence (e.g. every six hours), validates itself against a held-out window using Mean Absolute Percentage Error (MAPE), and automatically falls back to the raw, non-predicted metric when forecast confidence drops (kedify.io/resources/blog/predictive-autoscaling, published Oct 23 2025). This is the productionized version of “look at yesterday’s shape, not just this second’s queue depth” — it augments, rather than replaces, the reactive Prometheus trigger. - Azure’s own AKS engineering team published a worked example of KEDA driving GPU inference autoscaling for KAITO-hosted models on AKS (blog.aks.azure.com/2026/02/03/autoscale-inference-workloads-with-kaito, Feb 3 2026) — evidence that “KEDA + Prometheus + GPU inference” is now a documented, vendor-supported pattern, not just a DIY recipe.
Saying it out loud. The Prometheus scaler is still the workhorse, but two others matter. The Cron scaler lets you declare named start and end windows with a desired replica count and a timezone — and importantly it only activates inside the windows you give it, which makes it the right tool for genuinely calendar-shaped demand like business hours or a known nightly batch. You can carry a Cron trigger and a Prometheus trigger in the same
ScaledObject, and KEDA satisfies whichever demands more, same max-of-metrics idea as HPA. Beyond that, predictive layers like Kedify’s forecast a metric with Prophet, validate against a held-out window, and fall back to the raw metric when confidence drops. The pattern to notice: these all augment the reactive signal, they don’t replace it.
Serverless GPU platforms have converged on “seconds, not minutes”
An Aug 15 2025 comparison of serverless GPU inference platforms ranked them by cold-start latency (beam.cloud/blog/top-serverless-gpu-providers):
| Platform | Reported cold start | Mechanism note |
|---|---|---|
| Beam | ~2–3 s | Custom beta9 container runtime, not a generic Docker boot path |
| RunPod Serverless | 6–12 s | Snapshot-style “FlashBoot” fast-start path |
| Google Cloud Run (GPU) | 20–30 s | Standard container cold boot, GPU attach |
| Baseten | 16–60 s | Varies by model/deployment configuration |
| Replicate | instant for cached public models; 60+ s for custom deployments | Cache hit vs. cache miss |
The common thread at the fast end is not using a generic Docker-then-CUDA-init boot sequence — Beam’s and RunPod’s fast paths both skip large parts of the standard sequence that a vanilla Kubernetes pod pays for, which is exactly the “snapshot/checkpoint-restore” row in this chapter’s cold-start table. It’s no longer a research curiosity; it’s how the fastest commercial platforms hit single-digit-second cold starts today. If you’re building on raw Kubernetes rather than adopting one of these platforms outright, treat their numbers as the bar your users will implicitly compare you against.
Saying it out loud. The interesting thing about the serverless GPU platform comparisons is that the differentiator is almost entirely cold-start latency, and the winners get there by doing the snapshot and streaming work described earlier on your behalf rather than by any autoscaling cleverness. That reframes the build-versus-buy question usefully: you’re not really choosing an autoscaler, you’re choosing whether to own a fragile, version-pinned snapshot pipeline yourself. If your cold-start SLO is genuinely sub-ten-seconds and you don’t want to maintain that machinery, a managed platform has already paid that engineering cost. If your workload tolerates a warm floor, you’re paying a premium for a problem you don’t have.
Model-weight streaming keeps shrinking the dominant cold-start phase
NVIDIA’s Run:ai Model Streamer — an open-source Python SDK with a multi-threaded C++ backend — concurrently reads weight shards from storage while overlapping the CPU→GPU copy, instead of the default “read fully into host RAM, then copy” path. Its own published benchmarks on a 15 GB Llama 3 8B checkpoint (developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer, Sept 16 2025):
| Source | Baseline loader | Model Streamer | Speedup |
|---|---|---|---|
| IO2 SSD, concurrency 8 | 47 s (Safetensors) | 7.53 s | ~6.2x |
| GP3 SSD, concurrency 16 | — | 14.34 s | — |
| Amazon S3, concurrency 32 | 37.36 s (Tensorizer) | 4.88 s | ~7.6x |
That “up to 6x” figure is the same one cited in Microsoft’s Azure Blob Storage integration write-up (devblogs.microsoft.com/azure-sdk/eliminate-llm-cold-starts-load-models-up-to-6x-faster-with-azure-blob-storage-and-runai-model-streamer), and the mechanism is now wired natively into vLLM (--load-format runai_streamer, docs.vllm.ai/en/stable/models/extensions/runai_model_streamer) and into GKE’s model-loading path (cloud.google.com/blog/products/containers-kubernetes/nvidia-runai-model-streamer-supports-cloud-storage). Azure’s AKS engineering blog (July 13 2026: blog.aks.azure.com/2026/07/13/runai-streamer-vllm) walks through wiring the same streamer to Azure Blob for AKS-hosted vLLM. The practical takeaway for the cold-start table above: weight streaming is close to a default now, not a niche optimization — and it directly attacks the phase (weight load) that dominates the 1–5 minute cold-start estimate.
Saying it out loud. Weight streaming attacks the phase that dominates cold start, and the numbers are good enough to matter. Instead of the default “read the whole checkpoint into host RAM, then copy to GPU,” a streamer reads shards concurrently while overlapping the CPU-to-GPU copy. NVIDIA’s published benchmarks on a 15-gigabyte Llama 3 8B checkpoint show 47 seconds down to 7.5 from local SSD — about 6x — and 37 seconds down to under 5 from S3, about 7.6x. The reason to treat this as close to a default rather than a niche optimization: it’s wired natively into vLLM behind a single
--load-formatflag, it stacks with every other mitigation in this chapter, and it costs you nothing but a config change.
Predictive and scheduled scaling for known traffic patterns
Two complementary tools have matured for demand that isn’t a pure surprise:
- KEDA’s Cron scaler for genuinely scheduled patterns (business hours, batch windows, known regional peaks) — deterministic, no ML required, composable with a Prometheus trigger in the same
ScaledObject(worked example below). - Forecast-based predictive scaling (Kedify’s
MetricPredictor, and the broader pattern described by observability vendors such as Sedai, sedai.io/blog/predictive-autoscaling-in-kubernetes) for patterns that are regular but not calendar-fixed — a shape that correlates with a marketing calendar or a usage cadence that drifts week to week. These layer a forecast on top of, not instead of, the reactive queue-depth signal: the forecast pre-warms capacity, the reactive metric still governs the fine-grained ramp.
Saying it out loud. For demand that isn’t a pure surprise there are two complementary tools, and the distinction between them is worth being precise about. Cron scaling is for genuinely calendar-fixed patterns — business hours, a nightly batch window, a known regional peak — and it’s deterministic with no ML involved. Forecast-based predictive scaling is for patterns that are regular but not calendar-fixed, where the shape correlates with something that drifts week to week. And the important architectural point for both: they layer on top of the reactive queue-depth trigger, never instead of it. The forecast pre-warms capacity ahead of the ramp, the reactive metric still governs the fine-grained response — because a forecast that’s wrong should degrade to “slightly early or late,” not to “no autoscaling.”
Disaggregated serving and purpose-built LLM autoscalers
So far this chapter has treated “a replica” as the atomic scaling unit. Disaggregated serving — splitting the compute-bound prefill phase from the memory-bandwidth-bound decode phase onto separate GPU pools — breaks that assumption, and 2025–2026 tooling has started to bake autoscaling directly into the serving stack rather than leaving it entirely to HPA/KEDA:
-
NVIDIA Dynamo’s Planner makes independent scaling decisions for prefill and decode pools using phase-appropriate metrics: it monitors average KV-cache block utilization across decode GPUs, and separately tracks the depth of a global pending-request queue for prefill, comparing each against its own configurable threshold before shifting GPUs between pools or provisioning new ones from a shared pool (NVIDIA developer blog, May 20 2025: developer.nvidia.com/blog/nvidia-dynamo-adds-gpu-autoscaling-kubernetes-automation-and-networking-optimizations). This is this chapter’s “compose signals, don’t trust one number” principle applied twice — once per pool, each with the metric that actually reflects that pool’s bottleneck. NVIDIA has aligned Dynamo with the community llm-d project for large-scale distributed inference (developer.nvidia.com/blog/nvidia-dynamo-accelerates-llm-d-community-initiatives-for-advancing-large-scale-distributed-inference) and documented the Kubernetes deployment path directly (developer.nvidia.com/blog/deploying-disaggregated-llm-inference-workloads-on-kubernetes; Azure’s AKS walkthrough for multi-node Dynamo on GB200 NVL72, Oct 24 2025: blog.aks.azure.com/2025/10/24/dynamo-on-aks). If you adopt disaggregated serving, the mechanisms in this chapter still apply — you apply them twice, once per pool, with pool-appropriate metrics (KV-cache percentage for decode, queue depth for prefill).
-
AIBrix — originally released by ByteDance engineers and now hosted under the
vllm-projectGitHub organization (github.com/vllm-project/aibrix; announced Feb 21 2025: vllm.ai/blog/2025-02-21-aibrix-release) — ships a purpose-builtPodAutoscalerCRD with three interchangeable algorithms:HPA(native CPU-style),KPA(Knative-style, with a stable window and a shorter panic window for sudden spikes), andAPA— Advanced Pod Autoscaler — which scales like HPA’s ratio-based formula but adds explicitup-fluctuation-tolerance/down-fluctuation-toleranceparameters as a built-in buffer against oscillation, functionally the same job this chapter’s hand-tuned stabilization windows do, just expressed as a percentage tolerance band instead of a time window:
apiVersion: autoscaling.aibrix.ai/v1alpha1
kind: PodAutoscaler
metadata:
name: vllm-llama3-8b-apa
annotations:
autoscaling.aibrix.ai/up-fluctuation-tolerance: "0.1" # 10% headroom before scaling up
autoscaling.aibrix.ai/down-fluctuation-tolerance: "0.2" # 20% headroom before scaling down
spec:
scalingStrategy: APA
minReplicas: 1
maxReplicas: 8
metricsSources:
- metricSourceType: pod
port: "8000"
targetMetric: gpu_cache_usage_perc # same KV-cache signal this chapter recommends as a leading indicator
targetValue: "0.5"
scaleTargetRef:
kind: Deployment
name: model-deployment
Notice the target metric is gpu_cache_usage_perc — the same KV-cache-pressure signal this chapter’s “Right Scaling Signals” table and War Story 1 recommend adding as an earlier-than-queue-depth warning — and the asymmetric-tolerance idea is the asymmetric-stabilization principle expressed through a different knob. The convergence is the point: whether you hand-roll HPA/KEDA behavior blocks or adopt a purpose-built controller like AIBrix’s APA or Dynamo’s Planner, the underlying lessons — lead with a signal close to the real bottleneck, dampen oscillation asymmetrically, and treat prefill/decode (or CPU/GPU) as distinct scaling domains when the architecture actually splits them — hold across all of them.
Saying it out loud. Disaggregated serving splits the compute-bound prefill phase and the memory-bandwidth-bound decode phase onto separate GPU pools, and that breaks the assumption that a replica is the atomic scaling unit. NVIDIA’s Dynamo Planner is the clearest example of what falls out: it makes independent scaling decisions per pool using phase-appropriate metrics — average KV-cache block utilization across the decode GPUs, and separately the depth of a global pending-request queue for prefill — each against its own threshold. That’s exactly this chapter’s “compose signals close to the bottleneck” principle applied twice, once per pool. The practical takeaway if you adopt disaggregation: nothing here stops applying, you just apply all of it twice with different metrics.
Node-level GPU autoscaling closes the gap HPA/KEDA can’t
Everything in this chapter so far scales pods. A new pod still needs a node with a free GPU, and if the cluster autoscaler can’t provision one fast enough (or at all, against quota), maxReplicas is a wish, not a guarantee — exactly the “GPU quota / capacity ceilings” pitfall flagged earlier. AWS’s EKS best-practices guide for AI/ML compute (docs.aws.amazon.com/eks/latest/best-practices/aiml-compute) makes the pairing explicit: Karpenter for just-in-time node-level provisioning, KEDA for pod-level scaling on model performance metrics, working together rather than either alone:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: gpu-inference
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["g"] # GPU instance families
limits:
nvidia.com/gpu: "10" # hard ceiling on GPUs this NodePool will provision
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 60m # don't tear down nodes mid-spike — GPU nodes are as sticky as GPU pods
AWS’s own guidance for this pairing is worth internalizing verbatim: “For real-time workloads, where scaling time is important and workloads take longer than two minutes for the application to be ready to serve traffic, consider optimizing container start-up and ML model loading times” — the node-level Karpenter layer buys you a GPU to schedule onto, but it does nothing about the cold-start phases (image pull, weight load, warm-up) this chapter covers in depth; the two problems are solved by different mechanisms and both need solving. AWS also recommends On-Demand Capacity Reservations (ODCRs) — reserved GPU capacity with no long-term commitment, usable by Karpenter via capacityReservationSelectorTerms — as the fix for “HPA wants 12 replicas but there are no H100s to schedule them on,” turning a Pending-pod incident into a capacity-planning line item instead. The disruption.consolidateAfter: 60m setting is the node-level analogue of this chapter’s scale-down stabilization window, for exactly the same reason: a GPU node is as expensive to re-provision as a GPU pod is to cold-start, so err toward stickiness at both layers.
What this means for the rest of the chapter. None of the above replaces HPA/KEDA/Knative — it sits on top of or beside them. A representative 2026 production stack for a serious LLM product looks like: KEDA (a Prometheus trigger for reactive queue-depth scaling, plus a Cron trigger for known daily patterns, optionally a predictive layer for irregular-but-forecastable demand) driving the HPA that actually moves replica counts, with weight streaming cutting the cold-start tax for whichever replicas still have to boot cold.
Saying it out loud. Everything so far scales pods, and a pod still needs a node with a free GPU. If the cluster autoscaler can’t provision one fast enough — or at all, against quota — then
maxReplicasis a wish rather than a guarantee. So the complete design pairs Karpenter or an equivalent for just-in-time node provisioning with KEDA for pod-level scaling. Two details worth keeping. AWS’s own guidance is that node-level provisioning buys you a GPU to schedule onto and does nothing about image pull, weight load, or warm-up — two different problems, both needing solving. And On-Demand Capacity Reservations turn “HPA wants twelve replicas but there are no H100s available” from a Pending-pod incident into a capacity-planning line item.
Build It in Practice — Extended
The manifests above are correct but abstract. This section tunes them against an actual traffic shape and turns the “headroom sizing” one-liner from the Cost section into a full worked calculation — including the moment the math tells you your maxReplicas cap is wrong.
Saying it out loud. The manifests up to here are correct but abstract, and the thing that makes them real is tuning them against an actual traffic shape rather than a hypothetical rate. That means four exercises: replaying a realistic trace to pick stabilization windows, running the warm-pool sizing math against the observed burst rate rather than a made-up number, deriving your concurrency target from a load-test sweep instead of picking a round number, and then deliberately breaking the config before production does. The most valuable moment in that sequence is when the headroom math tells you your
maxReplicasceiling is wrong — because finding that on a whiteboard is a capacity conversation, and finding it during a spike is an incident.
Worked walkthrough — tuning stabilization windows against a traffic trace
Microsoft’s public Azure LLM Inference Trace 2023 (github.com/Azure/AzurePublicDataset/blob/master/AzureLLMInferenceDataset2023.md) records per-request arrival timestamps, input, and output token counts from production conversational and code-generation LLM services on Nov 11 2023, and was used to characterize workload shape in the Splitwise paper (ISCA 2024). The dataset itself doesn’t ship a pre-aggregated “requests per minute” column, so rather than reprint raw rows, the walkthrough below uses an illustrative 20-minute trace with the qualitative shape practitioners consistently report from it and from similar production traces: a steady diurnal baseline punctuated by short (1–3 minute) bursts several times the baseline rate, plus occasional brief lulls. Treat the specific numbers as a teaching device, not a reproduction of the dataset.
Assume each replica sustainably serves at a per-pod queue-depth target of 5 waiting requests (matching the HPA/KEDA manifests above), so a “raw recommendation” column below is the replica count HPA’s ratio formula would compute from the observed queue depth each minute, before any stabilization is applied:
| Minute | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Raw recommendation | 5 | 5 | 5 | 5 | 6 | 5 | 5 | 6 | 9 | 12 | 12 | 8 | 6 | 5 | 5 | 5 | 2 | 5 | 5 | 5 |
Minutes 8–10 model a real burst (a client retry storm, or a batch job landing); minute 16 models a one-minute lull that isn’t a real trend. Now apply three behavior configurations and read off the actual replica count each minute — remember HPA’s stabilization rule: over the trailing window it picks the minimum recent recommendation for scale-up decisions and the maximum recent recommendation for scale-down decisions, so a single window length changes the two directions asymmetrically only if you configure the two windows independently (as this chapter recommends).
Config 1 — naive, symmetric (scaleUp: 0s, scaleDown: 0s): actual replicas track the raw recommendation exactly. The one-minute lull at minute 16 causes a real scale-down to 2 replicas, which then reverses back up to 5 the very next minute — a wasted cold start for a lull that was never a trend. Minor fluctuations (5→6→5 at minutes 3–5) also churn replicas even though nothing is actually wrong.
Config 2 — recommended asymmetric (scaleUp: 0s, scaleDown: 300s): scale-up still reacts immediately (replicas hit 9 at minute 8 and 12 at minute 9 — no hesitation on real demand). Scale-down looks back 5 minutes and takes the max of that window. At minute 16, the trailing window (minutes 12–16) is [6, 5, 5, 5, 2], so actual replicas hold at 6 — the lull is filtered out. By minute 19 the window [5, 2, 5, 5, 5] maxes at 5, and replicas settle exactly at the true steady state. This is the config used in the manifests above, and the trace shows why: it reacts to the burst immediately and ignores the false-alarm dip.
Config 3 — overly conservative queue-based (scaleUp: 120s, scaleDown: 600s), mirroring the longer windows some queue-based guides recommend: with a 10-minute scale-down window, at minute 16 the trailing window (minutes 7–16) is [6, 9, 12, 12, 8, 6, 5, 5, 5, 2], max = 12 — replicas stay pinned at 12 for the full window even though real demand has been back at baseline (5) since minute 13. That’s three extra minutes of paying for peak capacity nobody needs, on top of the five minutes Config 2 would have already smoothed.
The lesson generalizes into a sizing rule: pick the scale-down window long enough to absorb the noise you actually observe (a single-minute lull, in this trace), but short enough that you’re not bankrolling peak capacity long after a burst has visibly ended. A useful starting point is a scale-down window of roughly ( 3\text{–}5 \times T_{cold} ) — long enough that you won’t immediately need to re-pay a cold start if the burst repeats, short enough that idle GPU-minutes don’t pile up. With ( T_{cold} \approx 180\text{s} ), that lands squarely on the 300s (5-minute) window this chapter recommends by default — the trace above is the empirical argument for that number, not just a rule of thumb.
Saying it out loud. Take a twenty-minute trace with a steady baseline of five replicas’ worth of demand, a real burst to twelve at minutes eight through ten, and a one-minute false-alarm dip at minute sixteen. Now compare configs. Symmetric zero-and-zero tracks the raw recommendation exactly, so the one-minute lull causes a genuine scale-down to two replicas that reverses the very next minute — a wasted cold start for something that was never a trend. The recommended asymmetric config, zero up and 300 seconds down, hits twelve immediately on the real burst but takes the max over the trailing five minutes on the way down, so it holds at six through the dip and settles correctly after. And an overly conservative ten-minute down-window stays pinned at twelve for three extra minutes after demand is clearly back at baseline. The sizing rule that falls out: scale-down window of roughly three to five times your cold-start time.
Worked warm-pool / min-replica sizing calculation
The Cost section’s headroom formula is:
[ \text{buffer replicas} = \left\lceil \frac{\Delta(\text{req/s over } T_{cold})}{\text{capacity per replica}} \right\rceil ]
Run it against the actual burst from the trace above instead of a hypothetical rate. From minute 7 to minute 9 the raw recommendation climbed from 6 replicas to 12 replicas — a demand growth of 6 replicas in 2 minutes, or 3 replicas/minute. Over a cold-start time ( T_{cold} = 180\text{s} = 3\text{ min} ), if you were starting from a cold floor rather than already having 6 replicas warm, you’d need:
[ \text{buffer replicas} = 3 \text{ replicas/min} \times 3 \text{ min} = 9 ]
Added to a steady-state floor of 5, that’s a warm floor of 14 replicas to fully absorb this burst without any request ever waiting on a cold start — which exceeds the maxReplicas: 12 ceiling used in every manifest earlier in this chapter. That’s not a contrived result; it’s the headroom math doing its job. It surfaces, before an incident, that the quota this chapter’s example manifests assumed is actually undersized for the worst burst rate this traffic shape produces. The three honest responses, in order of preference:
- Raise the GPU quota backing
maxReplicas/maxScale, if capacity is available — the cheapest fix when it’s possible. - Accept bounded queueing during the worst 1–2 minutes of a burst like this one, if your SLO has slack — quantify how much queue depth 12 replicas can absorb at the target of 5 per pod (60 requests) and compare against the observed peak.
- Shed or degrade the excess — request queueing with a hard timeout, or routing overflow to a smaller/cheaper model — rather than silently violating the SLO for every request past replica 12.
Saying it out loud. Run the headroom formula against the real burst instead of a hypothetical. Demand climbed from six replicas to twelve over two minutes — three replicas per minute — and with a three-minute cold start you’d need nine buffer replicas to absorb it without anyone waiting. On top of a steady-state floor of five, that’s a warm floor of fourteen — which exceeds the
maxReplicasof twelve used in every manifest earlier. That’s not a contrived result, that’s the math doing its job and surfacing, before an incident, that the assumed quota is undersized for the worst burst this traffic actually produces. Three honest responses, in order: raise the quota, accept bounded queueing during the worst minute or two if the SLO has slack, or explicitly shed and degrade — route overflow to a smaller model rather than silently violating the SLO.
Little’s Law: the theory behind the target values
Every target value picked in this chapter — 5 waiting requests per pod, 8 concurrent requests per replica — is an application of queueing theory’s most useful identity, Little’s Law:
[ L = \lambda \times W ]
where (L) is the average number of requests in the system (waiting plus in flight), (\lambda) is the arrival rate the system is sustaining (throughput), and (W) is the average time each request spends in the system — for a streaming LLM response, roughly TTFT plus generation time.
Rearranged, it tells you directly what a concurrency or queue-depth target should be once you’ve fixed a latency SLO. If a single replica can sustain throughput (\lambda_{replica}) at your target latency (W_{SLO}), the maximum in-flight population that replica can carry without breaching the SLO is
[ L_{replica} = \lambda_{replica} \times W_{SLO} ]
That is precisely what the Knative example’s containerConcurrency/target pair, and the HPA/KEDA averageValue targets, are estimating — a defensible target isn’t a round number, it falls out of (\lambda_{replica} \times W_{SLO}) measured from a load test. Concretely: a replica sustaining 8 requests/second of throughput at roughly a 1-second average time-in-system is, by Little’s Law, carrying an average population of about 8 requests at any instant — which is exactly why target: 8 shows up as this chapter’s Knative concurrency target earlier. The number was never arbitrary; it falls out of the throughput and latency actually measured for the model and hardware in question.
Saying it out loud. Every target in this chapter — five waiting requests per pod, eight concurrent per replica — is Little’s Law applied. The law says the average population in the system equals arrival rate times average time in system, and rearranged it tells you directly what a concurrency target should be once you’ve fixed a latency SLO: max in-flight per replica equals that replica’s sustainable throughput times your latency target. Concretely, a replica sustaining eight requests per second at roughly a one-second time-in-system is carrying about eight requests at any instant — which is exactly where the
target: 8in the Knative example came from. The point to make in an interview: a defensible target isn’t a round number you liked, it falls out of throughput times latency measured on your actual hardware.
Deriving your threshold from a load test, not a guess
Interviewers routinely ask “how did you pick that number?” — here is the derivation, worked. Sweep concurrency per replica in a load test (see the Load Testing chapter for harness details) and record p95 TTFT at each level:
| Concurrency per replica | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 16 |
|---|---|---|---|---|---|---|---|---|
| p95 TTFT (s) | 0.4 | 0.5 | 0.6 | 0.8 | 1.1 | 1.6 | 2.4 | 3.8 |
Against an SLO of p95 TTFT ( \le 1.5 ) s, concurrency 12 already breaches it (1.6 s) while concurrency 10 is still safe (1.1 s) — the curve’s knee sits between the two, as it typically does once KV-cache pressure and batching contention start to dominate. Pick the target below the knee, not at it, to leave slack for the seconds between “queue starts growing” and “new replica is ready”: a target of 8–10 gives roughly 20–35% headroom under the last safe measured point. This is the same derivation this chapter used to justify target: 8 in the Knative manifest, and per Little’s Law above, an 8-request target at roughly 0.8s average time-in-system corresponds to a sustained per-replica throughput of (\lambda_{replica} = L / W \approx 8 / 0.8 = 10) requests/second — a number you can now cross-check independently against the load test’s own throughput measurement at that concurrency level, and treat any large mismatch as a sign the load test or the target needs a second look.
Saying it out loud. “How did you pick that number” is a routine interview question, and here’s the derivation. Sweep concurrency per replica in a load test and record p95 TTFT at each level. Against a p95 TTFT SLO of 1.5 seconds, you’ll typically find something like concurrency 10 still safe at 1.1 seconds and concurrency 12 already breaching at 1.6 — the knee sits between them, as it usually does once KV-cache pressure and batching contention take over. Then you pick your target below the knee, not at it: 8 to 10 gives roughly 20 to 35 percent headroom, which is the slack you need to cover the seconds between the queue growing and a new replica being ready. Setting the target at the knee means every scale-up starts from an already-breaching state.
Combining Cron and Prometheus triggers for a scheduled warm-up
If the diurnal baseline in the trace above repeats on a known daily cycle (e.g., an 8am ramp), pre-warm ahead of it rather than waiting for the reactive trigger to catch up mid-ramp. A single ScaledObject can carry both triggers; KEDA scales to satisfy whichever is higher:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-scaler-scheduled
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicaCount: 1
maxReplicaCount: 12
pollingInterval: 15
cooldownPeriod: 300
triggers:
# Reactive: same queue-depth trigger as before
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: sum(vllm:num_requests_waiting) or vector(0)
threshold: "5"
activationThreshold: "1"
# Scheduled: pre-warm to the known business-hours floor before the 8am ramp arrives
- type: cron
metadata:
timezone: America/New_York
start: "50 7 * * 1-5" # 07:50 weekdays — T_cold (3 min) of margin before 08:00
end: "0 20 * * 1-5" # back to reactive-only floor at 20:00
desiredReplicas: "5" # matches the observed steady-state baseline
Tuning notes.
- The Cron trigger’s
starttime is setT_coldahead of the real ramp, not at the ramp itself — pre-warming only helps if the replicas are actually serving by the time demand arrives. - Outside the
start/endwindow, the Cron trigger contributes nothing and the reactive Prometheus trigger governs alone — including scaling further above the scheduled floor if the day’s actual traffic exceeds the historical baseline. - This is deterministic scheduling, not forecasting — it costs nothing to reason about and is the right first step before reaching for a forecasting layer like Kedify’s
MetricPredictor(see the Landscape section above), which is better suited to patterns that shift week to week rather than a fixed business-hours shape.
Saying it out loud. If your baseline repeats on a known daily cycle — an 8am ramp, say — pre-warm ahead of it rather than letting the reactive trigger catch up mid-ramp, because catching up mid-ramp means every user during the first cold-start window pays for it. A single
ScaledObjectcarries both a Cron trigger with the scheduled floor and the Prometheus trigger for everything the schedule doesn’t predict, and KEDA scales to satisfy whichever is currently higher. The nice property is that this degrades safely in both directions: if the schedule is wrong and traffic comes early, the reactive trigger still catches it, and if traffic doesn’t come at all, you’ve paid for an hour of warm replicas rather than an outage.
Validating the config before it meets production traffic
None of the manifests above should meet real users untested. A short, concrete pre-production checklist:
- Replay a trace-shaped load test, not just a flat ramp — use the traffic-shape methodology from the walkthrough above (steady baseline, a short sharp burst, a brief lull) so you exercise both the scale-up and scale-down paths, not only “more traffic forever.” Tools from the Load Testing chapter apply directly.
- Watch replica count over time during the replay, not just latency — a config that keeps latency fine by scaling up 20 times in 10 minutes has a flapping problem the latency graph alone won’t show you.
- Kill the metrics pipeline mid-test (stop
prometheus-adapter, or block the KEDA-to-Prometheus network path) and confirm the autoscaler fails to a safe state — holds its last known replica count, or a configured safe default — rather than scaling to zero or erroring out. This is the pitfall table’s “metric pipeline is a hidden dependency” row, tested rather than assumed. - Force a scale-up from the actual floor (not from an already-warm fleet) and measure real cold-start time end to end, including scheduling and image pull on a genuinely cold node — synthetic or vendor-published cold-start numbers are a starting estimate, not a substitute for your own measurement on your own image and node type.
- Trigger a simultaneous multi-replica scale-up (the thundering-herd scenario from War Story 2) against your actual weight storage backend, and confirm the staggered
scaleUpstep policy is actually staggering it — a step cap configured but never load-tested is a step cap you’re merely hoping works. - Confirm graceful termination by starting a long generation and triggering a scale-down mid-stream; verify
terminationGracePeriodSecondsis long enough that the request completes rather than getting cut off. - Alert on
PendingGPU pods during the test, deliberately capping node pool size below what the test will demand, to confirm the node-level ceiling from War Story 3 is visible to on-call rather than silent.
A config that has been through all seven steps once, deliberately, before its first real traffic spike is a materially different bet than one that has only been read and reasoned about.
Saying it out loud. Seven things I’d do before this config sees real users. Replay a trace-shaped load test with a baseline, a burst, and a lull, so you exercise scale-down as well as scale-up. Watch replica count over time, not just latency, because a config that keeps latency fine by scaling twenty times in ten minutes has a flapping problem the latency graph won’t show. Deliberately kill the metrics pipeline mid-test and confirm the autoscaler holds its last known count rather than scaling to zero. Force a scale-up from a genuinely cold floor and measure real cold start on your own image. Trigger a simultaneous multi-replica scale-up to confirm your step cap actually staggers it. Verify a long generation survives a scale-down. And cap the node pool deliberately to confirm Pending pods are visible to on-call.
The Cost vs Latency Tradeoff
Every autoscaling decision is a bet on this tradeoff. Warm replicas cost money every second they’re idle; cold replicas cost latency (and lost requests) every time you’re caught short. You can make the tradeoff explicit.
Saying it out loud. Every autoscaling decision is a bet on one tradeoff: warm replicas cost money every second they’re idle, and cold replicas cost latency and dropped requests every time you’re caught short. The useful framing is that the three pieces do three different jobs — the provisioned floor covers your baseline, the headroom buffer covers whatever demand arrives during a cold start, and the autoscaler handles the sustained ramp after that. And you tune the floor and buffer to the cost of a missed SLO, not to a generic utilization target, because “70% GPU utilization” is a number borrowed from a world where capacity arrives in seconds. It doesn’t mean anything when capacity takes three minutes.
A worked calculation
Suppose one H100 replica costs about ( $3.50 ) per hour and serves a steady ( 20 ) requests/second at your latency SLO. Your traffic is ( 20 ) req/s for 8 business hours and near-zero the other 16.
Option A — always-on flat fleet. Provision for peak, run 24/7:
[ \text{Cost}_A = 1 \text{ replica} \times $3.50/\text{hr} \times 24 \text{ hr} = $84 \text{ per day} ]
Option B — autoscale with a warm floor. Keep minReplicas = 1 only during the 8 busy hours, scale to zero otherwise. Assume the 16 idle hours truly cost nothing:
[ \text{Cost}_B = 1 \times $3.50 \times 8 = $28 \text{ per day} ]
That’s a 67% saving — but it buys a cold start on the first request each morning and after any midday lull. If the SLO forbids a multi-minute first response, you instead keep a warm floor 24/7 and land back near Option A, or you spend engineering effort on snapshot/fast-load so scale-to-zero becomes safe.
Headroom sizing. To absorb bursts without waiting on a cold start, provision a buffer. If your scale-up (schedule + pull + load + warm) takes ( T_{cold} = 180 ) s and traffic can climb at ( 2 ) req/s², a replica in flight can’t help for 3 minutes. Size the warm buffer to cover demand growth over ( T_{cold} ):
[ \text{buffer replicas} = \left\lceil \frac{\Delta(\text{req/s over } T_{cold})}{\text{capacity per replica}} \right\rceil ]
The general shape: provisioned floor covers the baseline, headroom buffer covers what arrives during a cold start, and autoscaling handles the sustained ramp. Tune the floor and buffer to the cost of a missed SLO, not to a generic utilization target. See the fully worked version of this calculation, against an actual traffic shape, in “Build It in Practice — Extended” above.
Measuring it, not just modeling it. The worked calculation above assumes you already know your per-replica cost and utilization; in production, that visibility has to come from somewhere. Standard Kubernetes cost tooling reports at the node level, which is close to useless for a GPU fleet where the interesting question is which deployment is paying for idle capacity. OpenCost (opencost.io) has extended pod-level cost allocation to GPUs specifically by collecting dcgm-exporter metrics (nvidia_gpu_utilization, nvidia_gpu_memory_used) and correlating them with pod resource requests and cloud instance pricing, attributing cost down to the pod rather than the node — the tool coverage matches a problem this chapter’s cost math otherwise leaves as an exercise for the reader: real-time inference workloads commonly run at only 20–40% GPU utilization due to sparse, bursty request patterns, while the bill reflects 100% of provisioned capacity regardless. Kubecost, OpenCost’s commercially-supported counterpart, layers dashboards and alerting on the same underlying allocation model. Neither tool changes the autoscaling decision by itself, but both turn “we think our warm floor is costing too much” into a number you can actually put in front of whoever owns the budget — and into a feedback signal for re-running the headroom and warm-pool calculations above against real, current utilization rather than a one-time estimate.
Saying it out loud. Put numbers on it. One H100 replica at roughly $3.50 an hour, serving 20 requests a second at your SLO, with traffic for 8 business hours and near-zero the other 16. Always-on costs 84 dollars a day. Autoscaling with a warm floor only during business hours costs 28 — a 67% saving. But that saving buys you a cold start on the first request each morning and after any midday lull. So if the SLO forbids a multi-minute first response, you either keep the floor 24/7 and land back near the always-on number, or you spend engineering time on snapshotting and fast loading so scale-to-zero becomes safe. Note that GPU hourly pricing moves a lot and varies by cloud and commitment — treat any figure like that as of-a-date and re-derive from your own contract.
Failure Modes & Pitfalls
- Flapping (thrashing). Symmetric or twitchy thresholds scale up and down every few minutes, and each cycle pays a cold start. Fix: long
scaleDown.stabilizationWindowSeconds(300s+), conservative scale-down policies, and a generouscooldownPeriod. For GPUs, err toward stickiness. - Scaling on a lagging metric. If p95 latency or GPU utilization is your primary trigger, you scale after users are already hurting, and because cold starts are slow you stay behind the curve for minutes. Fix: lead with a leading signal (queue depth / concurrency); keep latency as a guardrail only.
- Thundering herd on cold model load. A big spike triggers many replicas at once; they simultaneously hammer the same weights bucket / registry, saturating network and disk, so all of them cold-start slower. Fix: cap
scaleUpstep size (Pods: value/periodSeconds), stagger with fast-load streaming, pre-warm images, and cache weights on nodes. - GPU quota / capacity ceilings.
maxReplicasis a wish; cloud GPU quota and actual availability are the reality. HPA will happily request 12 pods that sitPendingforever because there are no H100s to schedule them on. Fix: setmaxReplicasto your real quota, alert onPendingGPU pods, and combine with cluster-autoscaler node pools sized to quota. - Metric pipeline is a hidden dependency. If Prometheus, the adapter, or
dcgm-exporterhiccups, HPA/KEDA see stale or missing metrics and may freeze or over-react. Fix:or vector(0)guards,ignoreNullValues, alert on the metrics pipeline itself, and a sane default replica count. - Per-pod vs total metric confusion. Using
Valuewhere you meantAverageValue(or a rawsum()without dividing by replicas) makes the target scale wrong as the fleet grows — you either never scale or scale to the moon. Fix: be explicit about per-pod semantics and test with a load generator. - Scale-down mid-generation. Terminating a pod that’s mid-stream kills in-flight long generations. Fix: graceful termination with a drain period longer than your max generation time, and
terminationGracePeriodSecondssized accordingly. - MIG/GPU-sharing capacity confusion. Sizing
maxReplicasor warm-pool buffers against “physical GPUs” when the cluster is actually running MIG-partitioned or time-sliced GPUs (or vice versa) silently over- or under-provisions, since a “replica” no longer maps 1:1 to a full GPU. Fix: confirm what unit your quota numbers andnvidia.com/gpurequests actually refer to before reusing this chapter’s cost/headroom formulas verbatim. - Regional/multi-cluster quota blindness. For a globally-deployed product, GPU quota is granted per region/account, not globally — a
maxReplicasceiling sized against total fleet capacity can still leave one region’s cluster starved ofPendingpods while another region has idle headroom. Fix: sizemaxReplicas/node-pool ceilings and alerting per region, and treat cross-region traffic shifting (if your architecture supports it) as a capacity release valve distinct from autoscaling within a single region.
Saying it out loud. The recurring failures here. Flapping, where twitchy symmetric thresholds scale up and down every few minutes and each cycle pays a cold start — fix with a long scale-down window and err toward stickiness. Scaling on a lagging metric, where p95 latency or GPU utilization is your primary trigger, so you always scale after users are already hurting and then stay behind for minutes. Thundering herds, where a spike triggers many replicas that all pull the same weights simultaneously and slow each other down. A metric pipeline that’s a hidden single dependency and fails silently. And
maxReplicasbecoming aspirational because no node has a free GPU. The pattern: almost every one of these is invisible on a latency dashboard until it’s already an incident.
Wiring Alerts for Autoscaler Health
Every failure mode above has a corresponding alert worth having before it fires in anger, not after. These pair with the Prometheus/Grafana stack from the Monitoring chapter:
# Custom metrics API is not returning data — HPA/KEDA are flying blind
absent(vllm_num_requests_waiting{namespace="inference"})
# Replica churn rate — a proxy for flapping (tune the count/window to your traffic)
sum(changes(kube_deployment_status_replicas{deployment="vllm-llama3-8b"}[15m])) > 6
# GPU pods stuck unschedulable — maxReplicas has become aspirational (War Story 3)
sum(kube_pod_status_phase{phase="Pending", namespace="inference"}) > 0
# KV-cache pressure rising faster than queue depth — the War Story 1 blind spot
rate(vllm:num_preemptions_total[5m]) > 0
# Time-to-ready for a new replica exceeds the cold-start budget assumed by warm-pool sizing
histogram_quantile(0.95, rate(kube_pod_start_time_seconds_bucket[10m])) > 180
Each rule maps directly to a pitfall or war story earlier in this chapter: a missing metric, replica churn, unschedulable pods, silent preemption, and a cold start running longer than the sizing math assumed. Alerting on the autoscaler’s own health, not just the application’s, is what turns “we found out during the incident” into “we found out before it mattered.”
Saying it out loud. Every failure mode deserves an alert that fires before the incident, not during it, and there are five worth having. Absent custom metrics, meaning your autoscaler is flying blind. Replica churn rate over a window, as a direct proxy for flapping. GPU pods stuck Pending, which is the earliest clear evidence that
maxReplicashas become aspirational. Preemption rate above zero, which catches the KV-pressure blind spot that queue depth misses entirely. And time-to-ready exceeding the cold-start budget your warm-pool sizing assumed. The framing that generalizes: alert on the autoscaler’s own health, not just the application’s — that’s what turns “we found out during the incident” into “we found out before it mattered.”
Production Case Studies & War Stories
War story 1 — the queue looked fine while p99 spiked 8x
Symptom. A team scaling vLLM on vllm:num_requests_waiting as the sole trigger saw p99 latency spike roughly 8x during a traffic-mix change (a burst of long-context requests), even though the queue-depth metric driving HPA never crossed its threshold — the autoscaler saw no problem and did nothing.
Root cause. vLLM pre-allocates most of its GPU memory for the KV cache at startup; when that cache fills — which long-context requests do far faster than short ones — the engine doesn’t reject new work, it silently preempts already-in-flight sequences to make room. Preempted requests get re-queued and effectively restart, which is exactly the kind of latency cliff that a short, healthy-looking num_requests_waiting reading can completely miss: the queue is short precisely because the engine is quietly discarding progress on other requests rather than letting them wait. This pattern — and the specific “P99 latency can spike 8x” framing — is documented in a 2026 write-up on why vLLM autoscaling on Kubernetes breaks (dev.to/soniarotglam/why-vllm-autoscaling-on-kubernetes-breaks-and-what-to-use-instead).
Fix. Add vllm:gpu_cache_usage_perc (KV-cache utilization) and vLLM’s preemption-count metric as an earlier warning signal alongside queue depth — KV-cache pressure and preemptions rise before queue depth does when the bottleneck is memory rather than raw arrival rate. The same source also flags a closely related tuning trap: running --gpu-memory-utilization 0.95 leaves no slack and OOMs under concurrent load, while 0.85 provides the headroom that keeps preemption a rare event instead of the default behavior under load.
Lesson. A single “safe-looking” metric is a false sense of security if it isn’t the metric closest to the actual failure mode. Queue depth is an excellent arrival-rate signal; it is not a memory-pressure signal. Compose signals that cover different failure modes rather than trusting one number to mean “everything is fine.”
Saying it out loud. A team scaling purely on
num_requests_waitingwatched p99 latency spike about 8x during a traffic-mix change toward long-context requests — while the queue-depth metric driving HPA never crossed its threshold. The autoscaler saw no problem and did nothing. The reason is subtle and worth knowing: vLLM pre-allocates its KV cache, and when that cache fills, the engine doesn’t reject new work, it silently preempts in-flight sequences to make room. Preempted requests restart. So the queue is short precisely because the engine is discarding progress on other requests rather than letting them wait. The fix was adding KV-cache utilization and the preemption counter as earlier signals. The lesson: queue depth is an excellent arrival-rate signal and is not a memory-pressure signal.
War story 2 — a spike triggered a thundering herd of cold starts
Symptom. A traffic spike caused several replicas to scale up simultaneously. Each one took the better part of five minutes to become ready, so by the time the fleet caught up, the spike had already caused timeouts and dropped requests — the autoscaler technically “worked,” but too slowly to matter.
Root cause, quantified. A concrete breakdown for a Llama 3.1 8B model on L40S GPUs, published by Tensorfuse (tensorfuse.io/docs/blogs/reducing_gpu_cold_start), shows where the time actually goes in a naive cold start:
| Phase | Time (naive) |
|---|---|
| Model download | 61 s |
| Weight loading | 33 s |
| CUDA graph compilation | 42 s |
| CUDA graph capture | 54 s |
| Total | 294 s (4 min 54 s) |
When several replicas cold-start at once, the download phase is the one that gets worse under contention — they’re all pulling the same weights from the same registry or storage bucket at the same time, competing for the same network and disk bandwidth, so the herd’s aggregate cold start is worse than any single one in isolation.
Fix, with results. The same source reports cutting the total to 82 seconds — a 70% reduction — through a combination of: caching the model and compiled artifacts on a persistent volume so repeat cold starts skip the download entirely; restricting CUDA graph capture to the batch sizes actually used in production (e.g. 1,2,4,8,16,24,32,64), which alone cut capture time from 54s to 7s; and leaning on torch.compile’s cross-instance compilation cache. Layer that with two scaling-side mitigations already in this chapter: cap the scale-up step size (policies: [{type: Pods, value: 4, periodSeconds: 60}], as in the HPA manifest above) so the herd is staggered rather than simultaneous, and use weight streaming (Run:ai Model Streamer, see the Landscape section) to shrink the download/load phase itself rather than only caching around it.
Lesson. Thundering-herd cold starts are a shared-resource contention problem as much as a per-replica speed problem. Fixing per-replica cold-start time (caching, graph capture tuning, weight streaming) and fixing the stampede shape (staggered scale-up steps) are complementary — the trace-driven walkthrough above shows a real burst of 6 replicas arriving inside 2 minutes; without a capped scale-up policy, all 6 would hit the weights store at once.
Saying it out loud. A spike caused several replicas to scale up at once, each took most of five minutes to become ready, and by the time the fleet caught up the spike had already caused timeouts. The autoscaler technically worked, just too slowly to matter. A published breakdown for Llama 3.1 8B on L40S puts the naive cold start at 294 seconds — 61 for model download, 33 for weight load, 42 for CUDA graph compilation, 54 for graph capture. And the download phase is the one that gets worse under contention, since every replica is pulling the same weights from the same bucket. They cut it to 82 seconds by caching artifacts on a persistent volume and restricting graph capture to the batch sizes actually used. The lesson: fix per-replica speed and stagger the stampede with a scale-up step cap — they’re complementary.
War story 3 — the pods scaled, the nodes didn’t
Symptom. During a launch-day spike, HPA correctly computed a desired replica count of 10 (up from a floor of 3), and Kubernetes accepted all 10 pods — but 6 of them sat in Pending for the better part of 20 minutes. Users hitting the overflow saw timeouts, while kubectl get hpa showed the autoscaler doing exactly what it was configured to do.
Root cause. maxReplicas describes what the autoscaler is allowed to ask for; it says nothing about whether a node with a free GPU actually exists to run the new pod on. The cluster’s GPU node pool had a static size, and provisioning a new GPU node from the cloud provider — capacity check, instance launch, driver/AMI boot, node join — took longer than the spike itself lasted. This is the scenario AWS’s own EKS best-practices guidance for AI/ML compute is written to prevent: node-level provisioning and pod-level scaling are two different control loops with two different response times, and treating only the pod-level one (HPA/KEDA) as “the autoscaling system” leaves the slower loop as an invisible ceiling (docs.aws.amazon.com/eks/latest/best-practices/aiml-compute).
Fix. Pair pod-level scaling with a node-level autoscaler purpose-built for just-in-time GPU provisioning (Karpenter on AWS, or the equivalent cluster-autoscaler node pool on other clouds), sized with real headroom rather than a static count that only covers steady state. Where the SLO can’t tolerate even a Karpenter-speed node bring-up, use an On-Demand Capacity Reservation so the GPUs are already allocated to the account and Karpenter only has to attach a node to reservation, not queue for scarce on-demand capacity. Alert on Pending GPU pods as a first-class signal — it is the earliest, clearest evidence that maxReplicas has become aspirational rather than real.
Lesson. An autoscaling design isn’t complete at the Deployment/ScaledObject layer. Ask, explicitly, “when HPA/KEDA asks for one more replica than the cluster currently has room for, what happens next, and how long does it take?” — and treat that answer as part of the SLO, not an infrastructure detail to hand-wave past. This is precisely the gap the Node-level GPU autoscaling subsection in the Landscape section above is describing.
Saying it out loud. During a launch spike, HPA correctly computed ten replicas, Kubernetes accepted all ten pods, and six of them sat Pending for twenty minutes. Users hitting the overflow got timeouts while
kubectl get hpashowed the autoscaler doing exactly what it was told. The cause is a distinction that’s easy to gloss over:maxReplicasdescribes what the autoscaler is allowed to ask for, and says nothing about whether a node with a free GPU exists to run the pod on. The node pool was statically sized, and provisioning a new GPU node took longer than the spike lasted. Pod-level and node-level scaling are two control loops with two different response times. The question to ask explicitly in any design: when the autoscaler asks for one more replica than the cluster has room for, what happens, and how long does it take?
Quick reference — defaults to start from
A condensed version of every number this chapter derived, for when you’re sketching a first config under time pressure:
| Knob | Starting default | Why |
|---|---|---|
| Primary signal | Queue depth (num_requests_waiting) or concurrency | Leading indicator, not lagging |
| Guardrail signal | p95/p99 TTFT or latency | Catches what the primary signal misses (see War Story 1) |
scaleUp.stabilizationWindowSeconds | 0 | Never hesitate on real demand — cold starts already make you slow |
scaleDown.stabilizationWindowSeconds | 300 (5 min) | ≈ (3\text{–}5 \times T_{cold}); absorbs noise without hoarding capacity (see trace walkthrough) |
scaleUp step cap | +4 pods / 60s (or similar) | Prevents a thundering herd on shared weight storage |
scaleDown step cap | 1 pod / 120s | GPUs are expensive to churn; err toward stickiness |
KEDA activationThreshold | 1 | One real request is enough to justify the first replica |
minReplicas / warm floor | steady-state baseline, sized from real traffic | Never cold-start the interactive hot path |
| Warm-pool buffer | (\lceil \Delta(\text{req/s over } T_{cold}) / \text{capacity per replica} \rceil) | Covers demand growth during the unavoidable cold-start window |
maxReplicas / node pool ceiling | actual GPU quota, alerted on Pending | A cap you can’t hit is worse than no cap — it fails silently |
| Concurrency/queue-depth target | (\lambda_{replica} \times W_{SLO}) from a load-test sweep, with ~20–35% margin below the SLO knee | Derived, not guessed (Little’s Law) |
| KEDA workload shape | ScaledObject for live-request pools, ScaledJob for run-to-completion batch units | Matches the scaling primitive to the actual unit of work |
| Cost visibility | Pod-level GPU cost allocation (OpenCost/Kubecost) reviewed against actual utilization | Turns “the warm floor feels expensive” into a number, and a feedback loop for re-sizing it |
This table is a starting point for a design discussion, not a substitute for measuring your own workload’s cold-start time, throughput-latency curve, and traffic shape — every number above was derived from a specific worked example earlier in this chapter, and yours will differ.
Use it as a checklist when reviewing someone else’s config, too: for each row, ask “is this value here because it was measured, or because it was left at a default?” — the answer to that question is often the fastest way to find the next incident before it happens.
Saying it out loud. If I’m sketching a first config under time pressure: queue depth as primary, latency as guardrail. Scale-up stabilization zero, scale-down 300 seconds — roughly three to five times cold start. Scale-up capped at four pods a minute to avoid a herd on shared weight storage; scale-down at one pod every two minutes because GPUs are expensive to churn. Activation threshold of one.
minReplicasat the real steady-state baseline.maxReplicasat your actual GPU quota, with an alert on Pending pods, because a cap you can’t hit is worse than no cap — it fails silently. And concurrency target derived from throughput times latency SLO with 20 to 35 percent margin below the load-test knee. The useful way to use that list is as a review checklist: for each row, ask whether the value is there because it was measured or because it was left at a default.
Interview Mastery
Core Q&A (1–8): the mechanics
- “Why not CPU-based HPA for an LLM?” — Host CPU is decoupled from GPU saturation; a full GPU can look idle to HPA. Name the real signals: queue depth, concurrency, KV-cache, GPU util, TTFT. (See the 60-second answer callout earlier in this chapter.)
- “What’s your primary scaling signal and why?” — A leading demand signal (
vllm:num_requests_waiting/ concurrency), with a lagging latency SLO as a guardrail, and the max-of-metrics behavior that composes them. - “How do you handle cold starts?” — Quantify the phases (schedule, pull, load, warm), then name concrete mitigations: warm floor, headroom buffer, fast weight streaming, snapshot/checkpoint-restore, image pre-pull.
- “Scale to zero — yes or no?” — “It depends on the SLO.” Fine for batch/internal; dangerous for interactive unless snapshotting or streaming makes cold starts sub-second-to-low-seconds (see the serverless-GPU cold-start table in the Landscape section). Explain KEDA’s
activationThreshold. - “How do you stop it flapping?” — Asymmetric behavior: aggressive scale-up (0s window), conservative scale-down (300s+ window, one pod at a time), cooldown. Justify the window length against the actual cold-start time, not a default.
- “HPA vs KEDA vs Knative — when each?” — HPA+adapter for simple custom-metric scaling; KEDA for event/queue-driven and scale-to-zero on any metric (plus Cron for scheduled floors); Knative/KPA for concurrency-driven serverless with request buffering.
- “What breaks under a real traffic spike?” — Thundering herd on weight load, GPU quota ceilings leaving pods
Pending, and lagging-metric lock-step. Have a mitigation for each — and be ready to cite the concrete cold-start breakdown (download/load/compile/capture) from the war stories above. - “How do you pick the target value / threshold?” — Derive it from load tests (see the Load Testing chapter): find the per-replica concurrency/queue depth at which TTFT just meets SLO, then set the target below it.
Deeper Q&A (9–18): mechanism internals and judgment calls
- “Explain HPA’s
AverageValuevsValuemetric types — why does the distinction matter for a growing fleet?” —AverageValuedivides the metric by current replica count before comparing to target, so the target stays meaningful as you scale (5 waiting requests per pod, regardless of fleet size).Valuecompares the raw number directly — use it only when the query already returns a per-pod figure, otherwise asum()across a growing fleet will make HPA think demand is exploding (or never scale at all) purely because the denominator changed. - “Walk me through HPA’s desired-replicas formula.” — ( \text{desiredReplicas} = \lceil \text{currentReplicas} \times \frac{\text{currentMetricValue}}{\text{desiredMetricValue}} \rceil ), computed per metric, then HPA takes the max across all configured metrics — whichever metric wants the most replicas wins that cycle.
- “What is KEDA’s
activationThresholdand how does it differ fromthreshold?” —activationThresholdgates the 0→1 transition (waking a scaled-to-zero deployment);thresholdgoverns the 1→N scaling once at least one replica is running. SettingactivationThresholdlow (e.g. “1”) means a single stray request can wake a cold GPU — intentional if you want zero missed requests, costly if bots or health-checks are the “stray” traffic. - “How would you scale to zero safely for an interactive LLM endpoint?” — Generally: don’t, unless a snapshot/checkpoint-restore path gets you to low-single-digit-second restores (per the serverless-GPU comparison in the Landscape section). Otherwise keep
minReplicaCount ≥ 1on the interactive path and reserve true scale-to-zero for batch/internal/dev traffic. - “Explain Knative’s panic mode and
target-burst-capacity.” — Panic mode is a short window where KPA evaluates demand over a much shorter horizon than its normal window, so it reacts to sharp spikes faster than its steady-state smoothing would allow.target-burst-capacitysets how much traffic the activator is willing to buffer (holding requests, not dropping them) while new replicas spin up — higher values trade a little steady-state latency for burst safety. - “How do you avoid a thundering herd when many replicas cold-start at once?” — Cap the scale-up step size (
policies: [{type: Pods, value: N, periodSeconds: 60}]) so replicas come up in waves rather than all at once, pre-pull/cache images and weights on nodes, and adopt weight streaming so each replica’s load phase is short even under shared-storage contention. Reference the concrete before/after numbers (294s → 82s) from the cold-start war story. - “How would you size a warm pool / min-replica floor mathematically, not by gut feel?” — Take the observed worst-case demand growth rate (replicas or req/s per minute) from real traffic data, multiply by your cold-start time ( T_{cold} ) to get the buffer needed to fully absorb a burst with zero cold starts, and add it to your steady-state floor. Then sanity-check the result against your actual
maxReplicas/GPU quota — the worked calculation above shows this can reveal the quota itself is undersized. - “What’s wrong with scaling on GPU utilization alone?” — It’s lagging and coarse: 100% utilization can mean “efficiently batched and totally healthy” or “drowning,” and it doesn’t distinguish the two. It’s a good hardware-truth cross-check, a poor sole trigger.
- “If a
ScaledObjecthas both a Cron trigger and a Prometheus trigger, which one wins?” — Neither “wins” outright — KEDA scales to satisfy whichever trigger currently demands more replicas, same principle as HPA’s multi-metric max. The Cron trigger guarantees a floor during its window; the Prometheus trigger can still scale above that floor if real demand exceeds the scheduled baseline. - “What would you monitor to know your autoscaling configuration itself is broken — not just the app?” — Alert on: HPA/KEDA unable to fetch the custom/external metric (stale or missing readings), GPU pods stuck
PendingagainstmaxReplicas, replica-count churn rate (a proxy for flapping), and time-to-ready per new replica (a proxy for whether your cold-start mitigations are actually working in production, not just in a benchmark). - “How does continuous batching change what a ‘per-replica capacity’ number even means?” — It means capacity isn’t a fixed request count; it’s shaped by the mix of prompt/generation lengths currently in the batch and, if multiple models or LoRA adapters share a replica, by which are currently loaded. A threshold derived from one workload mix can silently overstate capacity under a different mix — re-validate thresholds against your actual production traffic composition, not a single synthetic benchmark.
- “Give me Little’s Law and tell me why it matters here.” — ( L = \lambda \times W ): average in-system population equals arrival rate times average time in system. It matters because it turns “what concurrency/queue-depth target should I set?” from a guess into a calculation — measure your per-replica throughput and target latency from a load test, multiply them, and that product is a defensible target, not a round number.
System design prompt
“Design autoscaling for a bursty, cost-sensitive consumer LLM chat product: usage has a strong daily pattern, occasional viral spikes, and the business has a hard cost ceiling.”
A strong answer sketches a tiered system rather than a single mechanism:
┌────────────────────────────────────────────┐
│ KEDA ScaledObject │
│ ┌───────────────┐ ┌──────────────────┐ │
known daily ───▶│ │ Cron trigger │ │ Prometheus │◀──│─── queue depth,
pattern │ │ (biz-hours │ │ trigger │ │ KV-cache %,
│ │ warm floor) │ │ (reactive scale) │ │ p95 latency
│ └───────┬───────┘ └────────┬─────────┘ │ guardrail
│ └──────────┬──────────┘ │
│ max(...) │
└───────────────────┬──────────────────────────┘
▼
[ HPA: replicas 5↔12, asym. behavior ]
│
┌────────────────┼────────────────────┐
▼ ▼ ▼
warm floor (5) staggered scale-up overflow: request
never cold (+4 pods/min cap, queue + timeout,
starts weight streaming) or route to a
smaller model
Talking points an interviewer wants to hear, in rough priority order:
- Warm floor sized off real data, not a round number — derived from the steady-state baseline plus a headroom buffer covering (T_{cold}) of demand growth (the worked calculation above).
- Cron-scheduled pre-warming ahead of the known daily ramp, layered under a reactive Prometheus trigger for anything the schedule doesn’t predict — including the viral spike case.
- Composite scaling signals: queue depth as the leading trigger, KV-cache usage as an earlier-than-queue warning for memory pressure, p95 latency as the final guardrail — not any single metric alone (see war story 1).
- A capped, staggered scale-up so a viral spike doesn’t create a thundering herd on the weight store (see war story 2), paired with weight streaming to shrink whatever cold start still has to happen.
- A cost ceiling enforced structurally, not hoped for:
maxReplicastied to actual GPU quota (alerted onPendingpods), plus an explicit overflow path — bounded request queueing with a timeout, or degrading to a smaller/cheaper model — so the system fails predictably instead of silently blowing the latency SLO or the budget. - Asymmetric stabilization: fast scale-up, slow scale-down, tuned against the cold-start time as shown in the trace walkthrough — not left at framework defaults.
- Explicitly naming the tradeoff being made — e.g. “we accept N minutes of degraded latency once or twice a year during an unprecedented spike, in exchange for not paying for peak capacity 24/7” — because a design with no acknowledged tradeoff is a design that hasn’t been pressure-tested.
Saying it out loud. For a bursty, cost-sensitive consumer chat product with a daily pattern and occasional viral spikes, the answer is tiered rather than one mechanism. A warm floor sized from real data — steady-state baseline plus a buffer covering demand growth over your cold-start window. A Cron trigger to pre-warm ahead of the known daily ramp, layered under a reactive Prometheus trigger for everything the calendar doesn’t predict, with KEDA taking the max. Asymmetric behavior: instant scale-up with a step cap so the herd is staggered, slow scale-down so a lull doesn’t cost you a cold start. Composite signals — queue depth as trigger, KV-cache pressure as early warning, latency as guardrail. And an explicit overflow story past
maxReplicas: queue with a hard timeout or route to a smaller model, because the hard cost ceiling means the cap is real.
A second system design prompt — the contrasting case
“Now design autoscaling for an internal batch-summarization pipeline: latency-tolerant (minutes are fine), but cost is the dominant concern, and traffic is extremely spiky (idle most of the day, large bursts overnight).”
This is deliberately the mirror image of the consumer-chat prompt above, and a strong candidate notices that the right answer changes almost every knob, not just the numbers:
minReplicas/minScale: 0is now the right default, not a risk to hedge against — there is no interactive user waiting on the first token, so the multi-minute cold start is an acceptable cost of being idle the rest of the time.- KEDA over HPA, specifically for the 0→1 transition HPA cannot do, with
activationThresholdtuned to avoid waking the fleet for a single stray job. - Queue-length-based triggers on the actual job queue (SQS/Kafka/Redis depth via KEDA’s native scalers) rather than an inference-engine metric — the unit of work is a batch job, not a live HTTP request, so the natural signal is upstream of the model server entirely.
- No latency guardrail in the tight sense used for the chat product — replace it with a completion-time SLO (e.g. “the overnight batch finishes by 6am”), which changes the guardrail from “p95 TTFT” to “queue drain rate given current replica count,” a genuinely different metric.
- Aggressive scale-up is still correct, but for a different reason: cost-sensitivity argues for scaling up fast and back down fast, since every minute of an idle replica is pure waste with no offsetting latency benefit the way a warm floor provides for interactive traffic.
- Spot/preemptible capacity becomes attractive here in a way it wasn’t for the interactive product — a batch job that gets preempted can simply be retried, so the cost savings (up to ~90% per AWS’s own guidance cited in the Landscape section) are close to free.
The point of asking this as a follow-up is to check whether a candidate memorized “warm floor + asymmetric stabilization + composite signals” as a single template, or actually understands why each piece was chosen for the first scenario — and can correctly invert the ones that no longer apply.
Saying it out loud. Now invert it: an internal batch-summarization pipeline, latency-tolerant, cost-dominant, idle most of the day with big overnight bursts. Almost every knob flips. Scale to zero is now the right default rather than a risk, because nobody’s waiting on a first token. KEDA over HPA specifically for that zero-to-one transition. The trigger moves upstream entirely — queue length on SQS or Kafka, not an inference-engine metric, because the unit of work is a job, not an HTTP request. The latency guardrail becomes a completion-time SLO like “the batch finishes by 6am,” which is a genuinely different metric: queue drain rate given current replicas. And spot capacity becomes attractive, since a preempted batch job just retries. The point of the follow-up is to check whether you memorized a template or understand why each piece was chosen.
Common mistakes candidates make in this conversation
- Reciting “scale on GPU utilization” as the fix for CPU-based HPA being wrong — it’s a better cross-check, not a better primary signal, for the same lagging-metric reason latency is a guardrail rather than a trigger.
- Proposing scale-to-zero for an interactive product without qualifying it against cold-start time, or without mentioning the fast-load/snapshot mitigations that make it viable.
- Treating
stabilizationWindowSecondsas a single global knob rather than naming the deliberate asymmetry (fast up, slow down) and justifying the specific window against a cold-start time. - Forgetting that
maxReplicasneeds a node-level counterpart — a strong answer proactively raises the Karpenter/cluster-autoscaler layer without being prompted. - Picking round-number thresholds (“let’s just use 70%”) instead of deriving them from a load test’s throughput/latency curve, per Little’s Law.
- Presenting HPA, KEDA, and Knative as competitors rather than describing the common production pattern of layering them (KEDA driving the HPA it manages, Cron plus Prometheus triggers in one
ScaledObject). - Answering “how do you scale to zero” with a flat yes/no instead of naming the SLO-dependent tradeoff and the specific mitigations (fast weight loading, snapshotting) that make an aggressive answer defensible.
- Skipping straight to Kubernetes-native mechanisms without first naming which signal is correct for LLMs — a candidate who jumps to YAML before establishing why CPU is wrong has skipped the part of the answer that actually matters.
Red flags vs. green flags
| Red flag | Green flag |
|---|---|
| Scales on CPU utilization | Scales on queue depth / concurrency, with a latency guardrail |
| Symmetric scale-up/scale-down windows | Aggressive scale-up (near-0s), conservative scale-down (300s+), justified against (T_{cold}) |
| A single scaling signal | Composite signals covering different failure modes (queue depth and KV-cache/preemption and latency) |
maxReplicas picked arbitrarily (“seemed like enough”) | maxReplicas tied to real GPU quota, with alerting on Pending GPU pods |
| Cold-start time unknown or unmeasured | Cold-start phases measured and broken down (schedule/pull/load/warm), with a named mitigation for the dominant phase |
| Scale-to-zero on an interactive SLO with no fast-load or snapshot story | Scale-to-zero reserved for batch/tolerant traffic, or paired with sub-second snapshot/streaming restore |
| Scale-up has no step cap — everything scales at once under a spike | Staggered scale-up (Pods policy with a step + period), avoiding a thundering herd on shared weight storage |
| No visibility into the metrics pipeline itself | Alerts on stale/missing custom or external metrics, with a safe default replica count as a fallback |
| Thresholds set by guesswork | Thresholds derived from load tests: the per-replica concurrency/queue depth at which TTFT just meets SLO |
| GPU-fraction/MIG replicas treated as full GPUs in capacity math | Quota, maxReplicas, and headroom formulas explicitly reference the correct unit (physical GPU vs. MIG slice vs. time-sliced share) |
One global maxReplicas for a multi-region fleet | Per-region quota and Pending-pod alerting, since GPU capacity is granted regionally |
Chapter Summary
- CPU is the wrong signal. GPU saturation and host CPU are decoupled for an LLM server; scale on queue depth, concurrency, KV-cache pressure, and GPU utilization instead, with latency as a guardrail rather than the trigger.
- Cold starts are the defining constraint. A 1–5 minute cold start (or worse) means hesitating on scale-up is the costliest mistake, and it means a warm floor or fast-load strategy is mandatory for any interactive SLO.
- Asymmetric behavior beats symmetric behavior. Fast scale-up, slow scale-down — justified against your measured cold-start time, not a framework default — is the single highest-leverage tuning decision in this chapter.
- Composite signals beat a single signal. Queue depth alone missed the KV-cache-preemption incident in War Story 1; the fix was adding a second, faster-moving signal, not replacing the first.
maxReplicasneeds a node-level counterpart. Pod-level and node-level (Karpenter/cluster-autoscaler) scaling are two different control loops with two different response times — War Story 3 is what happens when only one is designed deliberately.- Targets should be derived, not guessed. Little’s Law ((L = \lambda \times W)) turns a load-test sweep into a defensible concurrency or queue-depth target.
- 2025–2026 tooling is converging on these same lessons, just expressed through new knobs: AIBrix’s fluctuation-tolerance parameters, Dynamo’s per-pool prefill/decode signals, and KEDA’s Cron and predictive layers are all restatements of “lead with a demand signal, dampen oscillation, and plan for known patterns” rather than replacements for understanding why.
- Validate the config before production traffic does it for you — replay a trace-shaped load, kill the metrics pipeline on purpose, and force a real cold start from a real floor, rather than trusting the manifest alone.
Glossary
| Term | Meaning |
|---|---|
| TTFT | Time-to-first-token — latency from request arrival to the first streamed token; the LLM-serving equivalent of “time to first byte” |
| HPA | Horizontal Pod Autoscaler — Kubernetes’ native replica-count controller |
| KEDA | Kubernetes Event-Driven Autoscaling — wraps HPA, adds scale-to-zero and 70+ event-source scalers |
| KPA | Knative Pod Autoscaler — Knative Serving’s default concurrency/RPS-based controller |
ScaledObject | KEDA’s CRD for scaling a Deployment/StatefulSet via one or more triggers |
ScaledJob | KEDA’s CRD for creating a Kubernetes Job per unit of work, for run-to-completion workloads |
activationThreshold | KEDA’s threshold governing the 0→1 transition, distinct from the 1→N scaling threshold |
stabilizationWindowSeconds | The trailing window HPA looks back over before acting, to dampen flapping |
| Cold start | The end-to-end delay (schedule, pull, load, warm-up) before a newly started replica can serve a request |
| Warm floor | A minReplicas/minScale set ≥ 1 specifically to avoid ever paying a cold start on the interactive path |
| Thundering herd | Many replicas cold-starting simultaneously and contending for the same shared resource (weight storage, registry) |
| KV cache | The per-request key/value tensors an LLM engine caches to avoid recomputing attention over prior tokens; the dominant consumer of a replica’s GPU memory |
| Preemption | vLLM evicting an in-flight request’s KV-cache entries to free memory for others, effectively restarting that request |
| Continuous batching | Iteration-level batching where new requests join a running batch between decode steps, rather than waiting for the batch to fully drain |
| Disaggregated serving | Splitting prefill (compute-bound) and decode (memory-bandwidth-bound) onto separate GPU pools, scaled independently |
| Little’s Law | ( L = \lambda \times W ) — ties average in-system population, throughput, and time-in-system together; the theoretical basis for concurrency/queue-depth targets |
| Snapshot/checkpoint-restore | Capturing an already-warmed process (CUDA context, weights resident in VRAM) and restoring it directly, skipping the load and warm-up phases on subsequent starts |
| DCGM | NVIDIA’s Data Center GPU Manager — the source of dcgm-exporter’s Prometheus GPU metrics (utilization, memory) |
| MIG | NVIDIA Multi-Instance GPU — hardware partitioning of one physical GPU into several isolated instances, each schedulable as a separate nvidia.com/gpu unit |
| Panic mode | Knative KPA’s short, fast-reacting evaluation window used when traffic spikes sharply, distinct from its normal steady-state smoothing window |
| MAPE | Mean Absolute Percentage Error — the accuracy check a predictive-scaling forecast (e.g. Kedify’s Prophet-based MetricPredictor) validates itself against before trusting its own prediction |
Further Reading
- Kubernetes — Horizontal Pod Autoscaler (v2, behavior & algorithm)
- Kubernetes — HPA Walkthrough with custom metrics
- KEDA — Prometheus scaler and Scaling Deployments (activation vs threshold, scale-to-zero)
- KEDA — Cron scaler (scheduled scaling windows)
- KEDA — GitHub issue #6934: LLM Scaler / “Cognitive Scaling” proposal
- Kedify — Predictive Autoscaling for Kubernetes (Prophet-based
MetricPredictor) - Kedify — Kubernetes Autoscaling Use Cases: FinOps, AI/GPU, APIs
- AWS — Autoscale AI inference with HPA and KEDA on EKS (vLLM)
- vLLM — Autoscaling with KEDA (production-stack)
- Knative — Autoscaling (KPA, concurrency, scale-to-zero, panic mode)
- Prometheus Adapter — kubernetes-sigs/prometheus-adapter
- NVIDIA — dcgm-exporter (GPU metrics for Prometheus)
- NVIDIA — Dynamo Snapshot: fast startup for inference on Kubernetes
- NVIDIA — Reducing Cold Start Latency for LLM Inference with Run:ai Model Streamer (benchmarks)
- vLLM docs — Loading models with Run:ai Model Streamer
- Microsoft Azure — Eliminate LLM cold starts: load models up to 6x faster with Run:ai Model Streamer
- Azure AKS Engineering Blog — Stream model weights to NVIDIA GPU (vLLM) from Azure Blob Storage using Run:ai Model Streamer
- Azure AKS Engineering Blog — Autoscale KAITO inference workloads on AKS using KEDA
- Google Cloud — Accelerate model downloads on GKE with NVIDIA Run:ai Model Streamer
- Beam — The Top Serverless GPU Providers, Ranked by Cold Start (2025)
- Microsoft / Azure — AzurePublicDataset: AzureLLMInferenceDataset2023 (real production traffic trace)
- Microsoft Research — Splitwise: Efficient Generative LLM Inference Using Phase Splitting (ISCA 2024)
- Tensorfuse — Reducing GPU cold start time when using vLLM (294s → 82s breakdown)
- DEV Community — Why vLLM autoscaling on Kubernetes breaks (and what to use instead)
- OneUptime — Using HPA stabilizationWindowSeconds to prevent scaling thrashing
- KServe — Autoscaler for generative inference
- NVIDIA — Dynamo adds GPU autoscaling, Kubernetes automation, and networking optimizations
- NVIDIA — Dynamo accelerates llm-d community initiatives for large-scale distributed inference
- NVIDIA — Deploying disaggregated LLM inference workloads on Kubernetes
- Azure AKS Engineering Blog — Scaling multi-node LLM inference with NVIDIA Dynamo and GB200 NVL72 GPUs on AKS
- vLLM Blog — Introducing AIBrix: a scalable, cost-effective control plane for vLLM
- AIBrix — Metric-based autoscaling docs (HPA/KPA/APA algorithms, PodAutoscaler CRD)
- AIBrix — GitHub repository
- AWS — EKS Best Practices: AI/ML compute autoscaling (Karpenter + KEDA, capacity reservations)
- OpenCost — Kubernetes cost allocation, including GPU cost attribution
- Kubecost — Cost analyzer for Kubernetes (OpenCost-based, GPU/AI cost tracking)
Related chapters: Load Testing to derive your target thresholds, vLLM Serving for the engine metrics, and Monitoring for the Prometheus/Grafana pipeline that feeds every controller above.
Topic 7: Canary Deployments
What You’ll Learn
This topic teaches you how to:
- Deploy new model versions safely
- Split traffic between versions
- Gradually roll out updates
- Rollback quickly if issues occur
- A/B test different models
- Monitor canary performance
Why Canary Deployments?
Benefits
- Risk reduction: Test new version on small traffic
- Gradual rollout: Increase confidence before full deployment
- Quick rollback: Revert if issues detected
- A/B testing: Compare model versions
- Zero downtime: No service interruption
When to Use
- Model updates: New model version
- Configuration changes: Different parameters
- A/B testing: Compare model performance
- Risk mitigation: Critical production systems
Canary Deployment Strategies
1. Traffic Splitting
Route X% of traffic to new version.
2. User-based
Route specific users to new version.
3. Header-based
Route based on HTTP headers.
4. Gradual Rollout
Start with 5%, increase to 100% over time.
Implementation Options
Option 1: Kubernetes with Service Mesh (Istio)
Most flexible, supports advanced routing.
Option 2: Kubernetes with Multiple Deployments
Simple, uses native K8s features.
Option 3: Application-level Routing
Handle in application code.
Simple Canary with K8s
Step 1: Deploy Stable Version
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving-stable
spec:
replicas: 9
selector:
matchLabels:
app: llm-serving
version: stable
Step 2: Deploy Canary Version
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving-canary
spec:
replicas: 1
selector:
matchLabels:
app: llm-serving
version: canary
Step 3: Service Selector
Service routes to both versions:
apiVersion: v1
kind: Service
metadata:
name: llm-serving
spec:
selector:
app: llm-serving # Matches both stable and canary
Traffic splits 90/10 based on replica count.
Istio Canary (Advanced)
VirtualService
Control traffic routing:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: llm-serving
spec:
hosts:
- llm-serving
http:
- match:
- headers:
canary:
exact: "true"
route:
- destination:
host: llm-serving
subset: canary
weight: 100
- route:
- destination:
host: llm-serving
subset: stable
weight: 90
- destination:
host: llm-serving
subset: canary
weight: 10
DestinationRule
Define subsets:
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: llm-serving
spec:
host: llm-serving
subsets:
- name: stable
labels:
version: stable
- name: canary
labels:
version: canary
Gradual Rollout
Phase 1: 5% Traffic (Day 1)
- Deploy canary with 1 replica
- Monitor for 24 hours
- Check metrics: latency, errors, throughput
Phase 2: 25% Traffic (Day 2)
- Scale canary to 3 replicas
- Continue monitoring
Phase 3: 50% Traffic (Day 3)
- Scale canary to 5 replicas
- Monitor closely
Phase 4: 100% Traffic (Day 4)
- Scale canary to 10 replicas
- Remove stable deployment
- Canary becomes new stable
Monitoring Canary
Key Metrics to Compare
- Latency: P50, P95, P99
- Error rate: Failed requests
- Throughput: Requests per second
- GPU utilization: Resource usage
- Model-specific: Tokens/second, quality metrics
Prometheus Queries
# Compare latency
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="canary"}[5m])
) /
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="stable"}[5m])
)
# Compare error rates
rate(llm_requests_total{version="canary",status="500"}[5m]) /
rate(llm_requests_total{version="canary"}[5m])
Rollback Procedure
Automatic Rollback
Set up alerts that trigger rollback:
- Error rate > threshold
- Latency > threshold
- Custom metrics fail
Manual Rollback
# Scale canary to 0
kubectl scale deployment llm-serving-canary --replicas=0
# Or route all traffic to stable
# Update VirtualService weights
A/B Testing
Compare Models
Deploy two different models:
- Model A: Current production
- Model B: New candidate
Metrics to Track
- Performance: Latency, throughput
- Quality: User feedback, accuracy
- Cost: GPU hours, inference cost
Decision Criteria
- Better performance AND quality → Promote
- Worse performance OR quality → Reject
- Mixed results → Continue testing
Best Practices
- Start small: 5-10% traffic initially
- Monitor closely: Watch metrics during rollout
- Have rollback plan: Know how to revert quickly
- Test thoroughly: Test canary before production
- Document changes: Track what changed
- Communicate: Inform team of canary deployment
Common Issues
Canary Performing Worse
- Check if it’s expected (new model might be slower)
- Compare metrics carefully
- Consider rollback if critical
Traffic Not Splitting Correctly
- Verify service selectors
- Check replica counts
- Verify routing rules (if using Istio)
Canary Not Getting Traffic
- Check service endpoints
- Verify labels match
- Check routing configuration
Exercises
- Basic canary: Deploy canary with 10% traffic
- Gradual rollout: Increase from 10% to 100% over phases
- Monitoring: Set up dashboards to compare versions
- Rollback: Practice rolling back canary
- A/B test: Compare two different models
Next Steps
- Topic 8: Monitor canary with Grafana
- Topic 9: Model versioning system
- Topic 10: Detect drift in canary
Further Reading
Canary Deployments for Model Serving
Safely rolling out new models and versions — when the thing you are canarying can pass every infra gate and still be worse.
Why this matters
Shipping application code and shipping a new model version look identical from thirty thousand feet: build an artifact, put it behind a service, shift traffic, watch dashboards. They are not the same problem.
A web service is mostly right or wrong. It returns a 200 or a 500, it is fast or slow, and your infrastructure metrics — error rate, p95 latency, saturation — catch essentially every regression that matters. A canary that watches those four numbers is a very good canary.
A model is right, wrong, or plausibly-wrong-in-a-way-that-looks-right. A new checkpoint can return HTTP 200 on every request, at lower latency than the old one, while quietly hallucinating more, refusing more legitimate prompts, drifting in tone, or regressing on your hardest 5% of inputs. Every infra gate is green. Your users are unhappy. This is the single most important thing to internalize in this chapter:
Model regressions live in the response body, not in the response envelope. Infra canaries only watch the envelope.
So model serving needs everything a normal progressive-delivery pipeline has — traffic splitting, automated analysis, progressive promotion, automatic rollback — plus a quality gate that reads the body. The rest of this chapter builds that pipeline from the bottom up and then shows you the failure modes that bite teams who forget the italicized sentence above.
Saying it out loud. Shipping code and shipping a model look identical from a distance — build an artifact, put it behind a service, shift traffic, watch dashboards — but they’re not the same problem. A web service is basically right or wrong: it returns a 200 or a 500, it’s fast or slow, and error rate plus latency plus saturation catch essentially every regression that matters. A model is right, wrong, or plausibly wrong in a way that looks right. A new checkpoint can return 200 on everything, faster than the old one, while hallucinating more, refusing more valid prompts, or regressing on your hardest five percent of inputs. The one sentence to remember: model regressions live in the response body, and infra canaries only watch the envelope.
Core intuition: models fail in ways infra canaries don’t catch
Picture two versions of a summarization model behind a gateway. You send 10% of live traffic to v2. Over an hour you observe:
| Signal | v1 (stable) | v2 (canary) | Infra verdict |
|---|---|---|---|
| HTTP 5xx rate | 0.02% | 0.02% | ✅ pass |
| p95 latency | 480 ms | 410 ms | ✅ pass (faster!) |
| Pod restarts / OOMs | 0 | 0 | ✅ pass |
| Throughput | 220 rps | 235 rps | ✅ pass |
| Groundedness / factuality | 0.91 | 0.78 | ❌ regression |
| Refusal rate on valid prompts | 1.2% | 6.5% | ❌ regression |
A classic canary — Flagger or Argo Rollouts watching request-success-rate and request-duration — promotes this rollout. It is faster and just as reliable by every number it knows how to read. The two bottom rows are invisible to it because they require scoring the generated text, which is not a Prometheus counter you get for free.
The mental model:
- Infra metrics answer “did the request complete correctly?” — cheap, real-time, always available.
- Quality metrics answer “was the answer good?” — expensive, often delayed, and specific to your task.
Canary analysis for models = the union of both. If your pipeline only has the first, you have built a very sophisticated way to ship regressions with confidence.
Saying it out loud. Picture ten percent of traffic on a new summarizer. Error rate identical, p95 latency actually 70 milliseconds better, no restarts, throughput up. A standard Flagger or Argo canary promotes that rollout, confidently. Meanwhile groundedness dropped from 0.91 to 0.78 and refusal rate on valid prompts went from 1.2% to 6.5%. Those two rows are invisible to the canary because reading them requires scoring the generated text, which is not a Prometheus counter you get for free. The distinction to hold: infra metrics answer “did the request complete correctly,” and they’re cheap and real-time. Quality metrics answer “was the answer good,” and they’re expensive, often delayed, and specific to your task. Canary analysis for models is the union — with only the first half, you’ve built a very sophisticated way to ship regressions confidently.
A second example, different modality: code completion
The summarization example makes the point with text-quality metrics, but the pattern generalizes to any generative task with its own notion of “correct.” Picture a code-completion model behind an IDE plugin, canaried the same way:
| Signal | v1 (stable) | v2 (canary) | Infra verdict |
|---|---|---|---|
| HTTP 5xx rate | 0.01% | 0.01% | ✅ pass |
| p95 latency | 220 ms | 190 ms | ✅ pass (faster!) |
| Tokens/sec throughput | 340 | 365 | ✅ pass |
| Suggestion compile-rate | 94% | 88% | ❌ regression |
| Suggestion test-pass-rate (on a held-out repo suite) | 71% | 58% | ❌ regression |
Same shape, different domain-specific quality signal: for a summarizer it’s groundedness/refusal; for a code-completion model it’s does-it-compile and does-it-pass-the-tests. The infra gate is blind to both for the same structural reason — neither compile-rate nor test-pass-rate is a property of the HTTP response envelope, they’re properties of running the generated code, which means the quality gate here necessarily looks like a job metric provider (Pattern C from Mechanism 2): spin up a sandboxed compile/test harness against a sample of the canary’s suggestions and gate on its pass rate. The specific metric changes with the task; the requirement that some task-specific quality signal exists in the gate does not.
Saying it out loud. The pattern generalizes to any generative task with its own notion of correct. Take a code-completion model in an IDE: same story — error rate flat, p95 latency down 30 milliseconds, throughput up — while suggestion compile-rate dropped from 94% to 88% and test-pass-rate on a held-out repo suite fell from 71% to 58%. Same structural blindness for the same reason: neither compile-rate nor test-pass-rate is a property of the HTTP envelope, they’re properties of running the generated code. Which tells you what the gate has to look like in practice — a sandboxed compile-and-test harness run against a sample of the canary’s suggestions. The specific metric changes with the task; the requirement that some task-specific quality signal exists in the gate does not.
The four strategies and when each fits model serving
Before mechanisms, get the taxonomy straight. All four move traffic from an old version to a new one; they differ in how much blast radius a bad version gets and how fast you can undo it.
- Rolling update — replace old pods with new ones a few at a time. There is no “old vs new” concept at the traffic layer; once a pod is new, it serves real users. This is the Kubernetes Deployment default.
- Blue-green — stand up the full new version (green) alongside the full old version (blue), test green out-of-band, then flip 100% of traffic at once. Instant cutover, instant rollback (flip back), but you pay for two full fleets during the overlap.
- Canary — run the new version at small scale, send it a slice of real traffic (1% → 5% → 25% → …), analyze, and promote in steps. Small blast radius, gradual confidence.
- Shadow (mirror) — send the new version a copy of real traffic but discard its responses. Users never see canary output. Pure evaluation, zero user risk.
Saying it out loud. Four ways to move traffic from old to new, differing in blast radius and undo speed. Rolling update swaps pods a few at a time with no old-versus-new concept at the traffic layer — that’s the Kubernetes default and it’s the wrong choice here. Blue-green stands up the full new fleet alongside the old and flips 100% at once: instant cutover and instant rollback, but you pay for two full fleets. Canary runs the new version small, sends it a slice of real traffic, analyzes, and promotes in steps. Shadow sends the new version a copy of traffic and throws the responses away — zero user risk, pure evaluation. For models, canary is the workhorse and shadow goes first for anything scary.
Strategy comparison
| Strategy | User blast radius if bad | Rollback speed | Extra cost | Catches quality regressions? | Best fit for models |
|---|---|---|---|---|---|
| Rolling | Grows as pods replace; hard to bound | Slow (roll back = another rollout) | ~none | No — new pods serve users immediately | Low-risk config bumps, sidecar updates |
| Blue-green | 100% at the instant of flip | Instant (flip traffic back) | High (2× fleet during overlap) | Only if you gate the flip on an eval suite | Big/atomic version jumps where partial-mix is unacceptable |
| Canary | Bounded to the canary weight (e.g. 5%) | Fast (set weight → 0) | Moderate (small extra fleet) | Only if analysis includes a quality gate | The default for model rollouts |
| Shadow | Zero (responses discarded) | N/A (no user traffic to roll back) | High (full 2nd inference path, doubled GPU) | Yes, offline — no user exposure | Pre-canary validation of risky checkpoints |
Rules of thumb for model serving:
- Never roll out a new model with a bare rolling update. You lose the old/new traffic distinction exactly when you most need it, and GPU pods are slow to spin up/down so a “quick” rollback isn’t quick.
- Canary is the workhorse. Small weight, automated analysis on infra and quality, progressive promotion.
- Shadow first for scary changes (new architecture, new quantization, new base model). It gives you production-distribution eval data with zero user exposure — then canary the survivors.
- Blue-green when the mix itself is the problem — e.g. a prompt-format or tokenizer change where having v1 and v2 answer the same conversation would be incoherent, so you want an atomic switch gated on a full eval run.
Saying it out loud. My rules of thumb. Never roll out a new model with a bare rolling update — you lose the old-versus-new traffic distinction exactly when you need it most, and GPU pods are slow enough to cycle that a “quick” rollback isn’t quick. Canary is the default: small weight, automated analysis on infra and quality, progressive promotion. Shadow first for genuinely scary changes — new architecture, new quantization, new base model — because it gives you production-distribution eval data with zero user exposure, and then you canary the survivors. And blue-green specifically when the mix is the problem: a tokenizer or prompt-format change where having v1 and v2 answer alternating turns of the same conversation would be incoherent, so you want an atomic switch gated on a full eval run.
The 2025–2026 landscape
The mechanisms in this chapter (Istio/Gateway API weights, Argo Rollouts, Flagger) are stable, years-old primitives. What has changed recently is (1) how much of this you can now do without a service mesh, (2) how the “quality gate” piece — Pattern B/C, below — is being formalized instead of hand-rolled, and (3) a brand-new, LLM-serving-specific routing layer that understands models the way a mesh understands services.
Argo Rollouts keeps shipping steadily as a CNCF project. As of mid-2026 the stable line is v1.9.x: v1.9.0 (March 20, 2026) fixed canary-weight/DestinationRule-update calculations and a blue-green analysis timing bug where success was reported prematurely while the ReplicaSet was still undersaturated; v1.8.4 (Feb 13, 2026) and v1.8.3 (Jun 5, 2025) were patch releases addressing analysis edge cases and an OAuth2 CVE. None of this changes the AnalysisTemplate/Rollout shapes used in this chapter — the API has been stable for years, which is exactly why it’s safe to build a model-serving control plane on top of it. Release notes: https://github.com/argoproj/argo-rollouts/releases.
Flagger has moved decisively toward Gateway API as a first-class, mesh-optional target. Flagger v1.42.0 (Oct 16, 2025) bumped Gateway API support to v1.4.0 and added CORS policy configuration on HTTPRoute, plus a trafficDistribution field (Kubernetes 1.33+) and an unmanagedMetadata option so GitOps controllers and Flagger can co-own the same Service without fighting over labels. Flagger v1.43.0 (Apr 21, 2026) went further on observability and session handling: a Kubernetes External Metrics provider (wired to things like the Datadog Cluster Agent) so canary analysis can pull SLO metrics from outside Prometheus, and a configurable primary-cookie name for session affinity in both the Istio and Gateway API routers. Changelog: https://github.com/fluxcd/flagger/blob/main/CHANGELOG.md.
The bigger shift: mesh-free canaries via Gateway API are now a documented, supported path, not a workaround. Flagger’s own tutorial for this reads almost exactly like the Istio walkthrough later in this chapter, except the traffic-shifting object is a plain HTTPRoute attached to a Gateway, and Flagger edits its backend weights directly — no sidecars, no VirtualService, no mesh control plane to operate: https://docs.flagger.app/tutorials/gatewayapi-progressive-delivery. This matters for model serving specifically because GPU inference pods are already resource-heavy; not running an Envoy sidecar per pod is a real cost and complexity saving when your bottleneck is GPU memory, not network hops. The catch, per Flagger’s own docs, is that you inherit whatever your specific Gateway implementation supports — session affinity needs ResponseHeaderModifier support, mirroring needs RequestMirror support, and not every Gateway controller implements every optional Gateway API feature yet.
The Gateway API project has been formalizing exactly this “mesh-agnostic canary” use case since GEP-1324 (the GAMMA initiative — Gateway API for Mesh Management and Administration), which is explicitly framed around requests like “I want to deploy a canary version of my application that splits traffic based on HTTP properties,” and deliberately stays agnostic to sidecar-vs-sidecar-free mesh data planes. Practically: whether your traffic layer is Istio, Linkerd, a Gateway-API-native controller, or no mesh at all, the same HTTPRoute weight-splitting vocabulary now works, which is why Argo Rollouts, Flagger, and the mesh vendors have all converged on it as the interchange format. GEP: https://gateway-api.sigs.k8s.io/geps/gep-1324/.
Saying it out loud. The mechanisms here are years-old stable primitives; three things changed recently. First, you can now do canaries without a service mesh — Gateway API
HTTPRouteweight-splitting is a documented, supported path in both Argo Rollouts and Flagger, which matters for GPU serving specifically because not running an Envoy sidecar per pod is a real saving when your bottleneck is GPU memory. Second, the quality-gate piece is getting formalized into specs and papers instead of hand-rolled scripts. Third, there’s now a purpose-built LLM routing layer that understands models rather than just replica pools. Net for 2026: pick Argo or Flagger on their merits, route through Gateway API unless you already depend on mesh features, and treat the quality-gate webhook as a first-class metric provider rather than a manual step before or after.
LLM-specific routing: the Gateway API Inference Extension
Everything above splits traffic by replica pool, unaware that the thing behind the pool is an LLM. As of mid-2025 there is a purpose-built layer for exactly that gap: the Gateway API Inference Extension (Kubernetes SIG, announced June 5, 2025, actively developed through early 2026), which adds inference-aware routing on top of Gateway API instead of treating an inference server like any other HTTP backend. The project’s own framing is that generic load balancers don’t understand LLM serving’s actual constraints — long-running, resource-heavy requests, in-memory KV-cache state that makes some backends cheaper to route to than others for a given prefix, and the fact that “the model” isn’t one thing but potentially many LoRA adapters sharing a base model. Announcement: https://kubernetes.io/blog/2025/06/05/introducing-gateway-api-inference-extension/; deep dive: https://www.cncf.io/blog/2025/04/21/deep-dive-into-the-gateway-api-inference-extension/; repo: https://github.com/kubernetes-sigs/gateway-api-inference-extension.
Two new CRDs carry this: an InferencePool groups model-server replicas (the way a Service groups pods, but with KV-cache-aware, queue-depth-aware load balancing done by an “Endpoint Picker” instead of round robin), and an InferenceModel/InferenceObjective sits in front of it to do model-identity routing — which named model or adapter a request actually wants, and at what priority. Istio’s 2025 support for this extension demonstrates the part that matters most for this chapter: weighted canary rollout expressed in terms of model versions, not just replica pools —
apiVersion: inference.networking.x-k8s.io/v1alpha2
kind: InferenceModel
metadata:
name: customer-support-router
spec:
modelName: customer-support
criticality: Critical
poolRef:
name: llama-pool # the InferencePool backing both versions
targetModels:
- name: llama-3-8b-customer-v1
weight: 80
- name: llama-3-8b-customer-v2
weight: 20
(Schema per Istio’s Gateway API Inference Extension integration post — the CRD is young and its exact fields are still evolving, so treat this as illustrative of the pattern rather than a copy-paste-stable spec. Source: https://istio.io/latest/blog/2025/inference-extension-support/.) The weight semantics are the same proportional-split idea as HTTPRoute backend weights, but the split happens inside model-aware routing — the Endpoint Picker can simultaneously balance by KV-cache/queue state and respect the 80/20 version split, something a plain HTTPRoute weight can’t do because it has no concept of cache state at all. The project’s own docs describe the target use cases plainly: “A/B traffic splitting, and safe blue-green base model and model server upgrades” for exactly the canary/progressive-rollout scenarios this chapter covers.
Where this fits relative to everything else in this chapter: it is a routing-layer improvement, not a substitute for the analysis/rollback machinery. You would still put an InferencePool/InferenceModel pair behind an Argo Rollouts or Flagger-style automated analysis loop — the CRDs give you a better dial (model-identity- and cache-aware weighting) to turn, not a different decision process. It is early — expect the API surface to keep changing — but it is the clearest sign yet that “canary a model” is becoming a first-class Kubernetes networking concept, not something you bolt onto generic HTTP traffic splitting and hope for the best.
On the quality-gate side, the “webhook that scores the canary” pattern is getting a name and a spec, not just ad-hoc scripts. A March 2026 paper, Automated Self-Testing as a Quality Gate for LLM Applications (Maiorano, arXiv:2603.15676), formalizes this as a release gate that scores every candidate build against a living “question bank” across five dimensions — task success rate (≥80%), multi-turn context preservation (≥90%), p95 latency (<15s), guardrail/safety pass rate (≥95%), and citation/evidence coverage (≥80%) — and emits one of three deterministic decisions: PROMOTE, HOLD, or ROLLBACK, with a second “70%-of-target” threshold that forces an automatic ROLLBACK for systemic failures rather than a human judgment call. Two findings from that paper are directly relevant to the AnalysisTemplate patterns in this chapter: evidence/citation coverage was the strongest discriminator of severe regressions across their 38 evaluation runs (stronger than latency or routing signals), and automated structural checks plus content-focused LLM judgment caught different failure modes — neither subsumes the other, which is the same “infra gate ≠ quality gate” argument this chapter opened with, now with data behind it. The framework as described is a pre-production gate rather than a live-traffic canary, but its five-dimension scoring and PROMOTE/HOLD/ROLLBACK vocabulary maps directly onto an Argo web metric provider or a Flagger pre-rollout webhook — see the worked example later in this chapter.
Industry write-ups through 2026 echo the same message this chapter leads with. MLflow’s 2026 canary-deployment guide for AI models states it plainly: “A model that looks healthy on error rate and latency dashboards can still be producing logically inferior outputs,” and pushes teams toward hallucination-rate and LLM-as-judge metrics running concurrently with the canary, not just in a pre-launch eval run (https://mlflow.org/articles/what-is-canary-deployment-ai/). None of this changes the mechanisms in this chapter — it validates that infra-only canaries for models are now a widely recognized anti-pattern, not a niche concern.
Net for 2026: pick Argo Rollouts or Flagger on their existing merits (imperative fine-grained steps vs. declarative convention — see the comparison later in this chapter), route traffic through Gateway API HTTPRoute unless you have a mesh-specific reason not to (mTLS, L7 policy you already depend on), watch the Gateway API Inference Extension if your routing layer needs to be model- and cache-aware rather than just replica-aware, and treat the quality-gate webhook/job as a first-class metric provider in your AnalysisTemplate/Canary, not a separate manual step that happens “before” or “after” the automated pipeline.
Saying it out loud. Everything else in this chapter splits traffic by replica pool, completely unaware that the thing behind the pool is an LLM. The Inference Extension adds two CRDs that fix that. An
InferencePoolgroups model-server replicas but load-balances with an Endpoint Picker that’s aware of KV-cache state and queue depth instead of doing round robin. And anInferenceModelsits in front doing model-identity routing — which named model or LoRA adapter a request wants, at what priority — which is where you express an 80/20 canary in terms of model versions rather than pools. The reason that matters: a plainHTTPRouteweight can’t simultaneously respect a version split and route for cache locality, because it has no concept of cache state. But it’s a better dial, not a different decision process — you still wrap it in Argo or Flagger analysis.
Mechanism 1: Traffic splitting
Canary and shadow both need to route a fraction of requests somewhere. There are four common layers to do it, from crudest to most precise.
Saying it out loud. Canary and shadow both need to route a fraction of requests somewhere, and there are four layers to do it at, from crudest to most precise. Replica-ratio splitting through a shared Service — nine stable pods and one canary pod gives you roughly ten percent. Mesh-level weighted routing via an Istio
VirtualService. Vendor-neutral weighting via Gateway APIHTTPRoute. And header or session-based routing when requests aren’t independent. That last distinction is the LLM-specific one: weighted splitting assumes each request stands alone, and a multi-turn chat conversation absolutely does not — if turn three lands on v2 after turns one and two hit v1, the assistant contradicts itself in a different voice.
1a. Kubernetes Service — replica-ratio splitting (crude, avoid)
The oldest trick: two Deployments (model-v1, model-v2) sharing one Service via a common label selector. The Service load-balances across all matching pods, so the traffic split ≈ the replica ratio. Want 10% canary? Run 9 stable pods and 1 canary pod.
apiVersion: v1
kind: Service
metadata:
name: model
spec:
selector:
app: model # matches BOTH v1 and v2 pods
ports:
- port: 80
targetPort: 8000
Why this is bad for models:
- Weight is quantized by replica count. A GPU pod might be an entire A100; you cannot cheaply run “0.5 of one” to get 5%.
- Weight and capacity are coupled — you can’t send 1% of traffic to a canary that has 3 replicas for latency headroom.
- No session affinity, no header-based routing, no clean rollback primitive.
Use it only for a quick-and-dirty test. For anything real, split at the mesh/gateway layer.
Saying it out loud. The oldest trick is two Deployments sharing one Service through a common label selector, so the traffic split is roughly the replica ratio — nine stable pods, one canary, ten percent. It needs no mesh and no extra controller, which is its only virtue. The problems are real, though. Your minimum canary weight is bounded by replica count, so a one-percent canary needs ninety-nine stable pods, which is absurd on GPUs. You can’t decouple weight from capacity, so a small canary is structurally slower per request. And there’s no session stickiness at all. For GPU model serving specifically, that coupling is what makes it wrong — you end up choosing between a meaningful traffic weight and a sane number of expensive replicas.
1b. Istio VirtualService — weighted routing
Istio decouples weight from replica count. A DestinationRule defines subsets by label; a VirtualService assigns weights that sum to 100.
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: model
spec:
host: model
subsets:
- name: v1
labels: { version: v1 }
- name: v2
labels: { version: v2 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: model
spec:
hosts:
- model
http:
- route:
- destination:
host: model
subset: v1
weight: 90
- destination:
host: model
subset: v2
weight: 10
To advance the canary you edit the two weight values (90/10 → 75/25 → 0/100). To roll back, set v2 to 0. This is the primitive Argo Rollouts and Flagger drive for you (below) — they rewrite these weights automatically.
Saying it out loud. Istio’s contribution is decoupling weight from replica count: a
DestinationRuledefines named subsets by pod label, and aVirtualServiceassigns weights across them that sum to 100. So you can send one percent of traffic to a canary that has two replicas — the weight and the capacity are independent knobs, which is exactly what the Service-based approach couldn’t do. That decoupling is what makes real canary ladders possible on GPUs, where you can’t afford ninety-nine stable replicas just to express one percent. The cost is that you’re now operating a mesh control plane and a sidecar per pod, which on GPU nodes is memory and complexity you may not want — hence the move toward Gateway API for teams that don’t otherwise need mTLS or L7 policy.
1c. Gateway API — the vendor-neutral successor
Gateway API (HTTPRoute) does the same weighting in a mesh-agnostic way. Note the docs’ precise wording: weight is a proportional split, not a percentage — the sum of weights in a rule is the denominator.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: model-split
spec:
parentRefs:
- name: model-gateway
rules:
- backendRefs:
- name: model-v1
port: 8000
weight: 90
- name: model-v2
port: 8000
weight: 10
90 + 10 = 100 so v2 gets 10%. If you’d written 9 and 1, v2 still gets 10% — the ratio is what matters. Gateway API is where new tooling is converging; prefer it for greenfield (see The 2025–2026 landscape, above), and note that the Gateway API Inference Extension’s InferencePool/InferenceModel pair is built as a layer on top of this same HTTPRoute vocabulary, not a replacement for it.
Saying it out loud. Gateway API does the same weighting in a mesh-agnostic way through
HTTPRoutebackend refs, and there’s one detail worth getting right because interviewers ask it: weight is a proportional split, not a percentage. The sum of the weights in a rule is the denominator. So weights of 3 and 1 mean 75/25, not 3% and 1% with 96% going nowhere. The reason this vocabulary won is that Argo Rollouts, Flagger, and the mesh vendors all converged on it as an interchange format, so the same weight-splitting expression works whether you’re on Istio, Linkerd, a Gateway-native controller, or no mesh at all. The catch: optional features like mirroring and session affinity depend on what your specific Gateway controller actually implements.
1d. Header / session-based routing (for stateful serving)
Weighted splitting assumes requests are independent. LLM chat sessions are not — a multi-turn conversation must hit the same model version, or turn 3 answers in v2’s voice after turns 1–2 were v1’s. Route by a stable key instead:
http:
- match:
- headers:
x-canary:
exact: "true" # opt-in cohort, internal users, etc.
route:
- destination: { host: model, subset: v2 }
- route: # everyone else
- destination: { host: model, subset: v1 }
More on session pinning in Failure Modes.
Saying it out loud. Weighted splitting quietly assumes requests are independent, and LLM chat sessions are not. If a multi-turn conversation gets split across versions, turn three answers in v2’s voice with v2’s context handling after turns one and two came from v1 — incoherent to the user, and it also corrupts your quality measurement, because now neither version is being evaluated on a clean conversation. So you route by a stable key instead: a session ID header, a consistent hash of the user ID, or an explicit opt-in cohort header for internal users. The rule to state plainly: pin at the conversation level, not the request level, whenever the model’s output depends on prior turns — which is essentially always for chat.
1e. Model-serving-native canary: KServe
Everything above is generic Kubernetes traffic-splitting, bolted onto a model server as if it were any other HTTP backend. If you deploy models via KServe, canarying is a first-class part of the InferenceService spec instead of a separate mesh object. Set canaryTrafficPercent on the component you’re updating and KServe (in its serverless, Knative-backed mode) manages two revisions for you directly:
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: model
spec:
predictor:
canaryTrafficPercent: 10 # 10% of traffic to the new revision
model:
modelFormat:
name: huggingface
storageUri: gs://my-bucket/model/v2
KServe tracks this with two rollout pointers: status.components.predictor.latestRolledoutRevision (the revision serving the other 1-canaryTrafficPercent slice — effectively “stable”) and the newly created revision receiving the canary slice; on rollback it points previousRolledoutRevision back to 100%. Apply a new storageUri with canaryTrafficPercent: 0 first to stage the revision, then ramp the percentage up the same way you’d ramp an Istio weight. The official walkthrough is a good reference: https://kserve.github.io/website/docs/model-serving/predictive-inference/rollout-strategies/canary-example.
Two things worth knowing before you reach for it:
- Serverless-mode only. KServe’s docs are explicit that this canary strategy is only supported when the
InferenceServiceruns in serverless (Knative) deployment mode, not in the raw-Kubernetes-Deployment mode. If you’re on the raw mode for GPU-scheduling reasons, you’re back to Istio/Gateway API weights or Argo/Flagger driving aDeploymentdirectly. - It’s a traffic-splitting primitive, not an analysis engine.
canaryTrafficPercentis the same idea as anHTTPRouteweight, expressed at the ML-serving-platform layer instead of the networking layer — it does not, by itself, run anAnalysisTemplateor check a quality gate. A March 2026 write-up on combining GitOps with KServe canaries pairscanaryTrafficPercentwith Argo Rollouts/Prometheus for the actual promote/rollback decision, and calls out a very on-topic warning for this chapter: “canary windows and traffic percentages should account for warm-up latency” for large models, i.e. the cold-start pitfall from Failure Modes applies here just as much as it does to an Istio-routed canary (https://devopsie.com/2026-03-19/gitops-driven-canary-rollouts-for-ml-models-with-argo-cd-and-kserve.html).
The takeaway: whether the split lives in a VirtualService, an HTTPRoute, an InferenceModel, or canaryTrafficPercent, it’s still just the traffic half of the problem — the analysis/quality-gate half from Mechanism 2 is what actually decides whether to keep ramping.
Saying it out loud. Everything above is generic Kubernetes traffic splitting bolted onto a model server as if it were any HTTP backend. If you deploy through KServe, canarying is a first-class field on the
InferenceService— you setcanaryTrafficPercenton the component you’re updating and KServe manages the two revisions for you, using Knative underneath. The appeal is that the canary concept lives at the same level as the model, so a rollout is one field change rather than coordinated edits across a Deployment, a DestinationRule, and a VirtualService. The tradeoff is the usual one with abstractions: you get less control over the exact ladder and analysis, and you’re debugging through a layer — so it fits teams standardizing on KServe, not teams that need fine-grained step control.
Mechanism 2: Automated canary analysis
Manually staring at Grafana while you bump weights doesn’t scale and doesn’t fire at 3 a.m. Automated analysis makes the promote/rollback decision from metrics. Two dominant tools: Argo Rollouts and Flagger.
The shape of both:
- You declare steps (weights + pauses) and metrics with thresholds.
- The controller shifts traffic to the first step.
- At each step it queries metrics (usually Prometheus) over an interval, a number of times.
- If a metric violates its condition too many times → abort and roll back.
- If all steps pass → promote (canary becomes stable).
Saying it out loud. Staring at Grafana while you bump weights doesn’t scale and definitely doesn’t fire at three in the morning, so you automate the promote-or-rollback decision. Both dominant tools have the same shape: you declare steps — weights and pauses — plus metrics with thresholds; the controller shifts traffic to the first step, queries the metrics over an interval a set number of times, and if a metric violates its condition too often it aborts and rolls back, otherwise it advances. Argo Rollouts is the imperative, fine-grained one where you spell out each step. Flagger is the declarative one where you state a convention and it generates the ladder. The important design property in both: the failure path is fully automatic, and only the success path is allowed to wait on a human.
Argo Rollouts: AnalysisTemplate
An AnalysisTemplate is a reusable metric-check bundle. This one watches success rate and p95 latency from Istio’s Prometheus metrics:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: infra-metrics
spec:
args:
- name: service-name
metrics:
- name: success-rate
interval: 1m
count: 5 # take 5 measurements
successCondition: result[0] >= 0.99
failureLimit: 2 # allow 2 bad reads before aborting
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}",
response_code!~"5.."}[1m]))
/
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}"}[1m]))
- name: p95-latency
interval: 1m
count: 5
successCondition: result[0] <= 500 # milliseconds
failureLimit: 2
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
histogram_quantile(0.95,
sum(irate(istio_request_duration_milliseconds_bucket{
destination_service=~"{{args.service-name}}"}[1m]))
by (le))
Field semantics that trip people up:
interval— how often to run the query.count— how many times total; the analysis runscount × intervalbefore it can succeed.successCondition/failureCondition— a boolean expression overresult(the query’s returned vector). Provide one or the other.failureLimit— how many failed measurements are tolerated before the whole AnalysisRun fails and triggers rollback.failureLimit: 0means one bad read aborts.
Saying it out loud. An
AnalysisTemplateis a reusable bundle of metric checks, and each metric has four numbers that matter. Theintervalis how often you query. Thecountis how many times total. ThefailureConditionis the expression that means bad. AndfailureLimitis how many bad readings you tolerate before aborting — which exists specifically so one noisy measurement doesn’t roll back a healthy deploy. A typical infra template watches success rate and p95 latency from your mesh’s Prometheus metrics. The thing to notice about the design: it’s reusable and referenced by name, so the same quality gate can be shared across every model rollout in the org rather than copy-pasted, which is how a quality bar actually gets enforced rather than suggested.
Wiring it into a Rollout
The Rollout object replaces your Deployment. Its canary steps interleave setWeight, pause, and analysis:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: model
spec:
replicas: 6
selector:
matchLabels: { app: model }
template:
metadata:
labels: { app: model }
spec:
containers:
- name: server
image: registry.example.com/model:v2
ports:
- containerPort: 8000
resources:
limits: { nvidia.com/gpu: 1 }
strategy:
canary:
canaryService: model-canary # Service pointing only at canary pods
stableService: model-stable # Service pointing only at stable pods
trafficRouting:
istio:
virtualService:
name: model
routes: [primary]
steps:
- setWeight: 5
- pause: { duration: 10m }
- analysis:
templates:
- templateName: infra-metrics
args:
- name: service-name
value: model-canary.default.svc.cluster.local
- setWeight: 25
- pause: { duration: 10m }
- analysis:
templates:
- templateName: infra-metrics
args:
- name: service-name
value: model-canary.default.svc.cluster.local
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100
What happens on kubectl apply with a new image:
- Argo creates canary pods, points
model-canaryat them, and sets the VirtualService to 5% canary / 95% stable. - Waits 10m, then runs
infra-metricsfive times over five minutes. - Any check fails past
failureLimit→ weight snapped back to 0, rollout marked Degraded, canary pods torn down. Automatic rollback. - All pass → advance to 25%, repeat, then 50%, then 100%. At 100% the canary ReplicaSet becomes stable.
This is a correct, faster, greener rollout of the bad summarizer from the intuition section — because infra-metrics never looks at output quality. Now we fix that.
Saying it out loud. The
Rolloutobject replaces your Deployment, and its canary strategy is an explicit list of steps you interleave:setWeightto shift traffic,pauseto dwell, andanalysisto run a template. So you literally write out one percent, wait ten minutes, run infra checks, five percent, wait fifteen, run infra plus cheap proxies, and so on. The virtue of that verbosity is that the ladder is legible in one place and reviewable in a pull request — someone can see exactly how much user exposure each gate is protecting. The other capability worth knowing:spec.strategy.canary.analysisruns background analysis continuously across the whole rollout, which is how you get a circuit breaker on error rate that doesn’t have to wait for the next step boundary.
Adding a model-quality gate
The quality gate is just another metric in the AnalysisTemplate — the trick is where the number comes from. Three patterns, cheapest to strongest:
Pattern A — proxy signals already in Prometheus. Some quality signals are cheap counters if your server emits them: refusal rate, empty-completion rate, average output token count (a proxy for truncation/degeneration), guardrail-filter trigger rate, mean logprob. Gate on those directly:
- name: refusal-rate
interval: 2m
count: 5
failureCondition: result[0] > 0.03 # >3% refusals on valid prompts = bad
failureLimit: 1
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
sum(irate(model_refusals_total{version="canary"}[2m]))
/
sum(irate(model_requests_total{version="canary"}[2m]))
Pattern B — an online judge/eval job that writes a gauge. Run an evaluator (LLM-as-judge, a reward model, or a reference-based scorer on prompts that have known-good answers) against a sample of canary responses, and have it push a score to Prometheus (Pushgateway) or an HTTP metrics endpoint. Then:
- name: quality-score
interval: 5m
count: 4
successCondition: result[0] >= 0.85 # judge score, 0..1
failureLimit: 1
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: avg_over_time(canary_quality_score[5m])
Pattern C — a web/job provider that runs an eval suite synchronously. Argo Rollouts also supports web and job metric providers. A job provider spins up a Kubernetes Job that runs your offline eval harness against the canary endpoint and exits non-zero on regression; a web provider hits an eval service that returns JSON you assert on. Use these when the eval is heavy (a full benchmark set) and you want it as a hard gate before promoting past, say, 25%.
- name: eval-suite
provider:
job:
spec:
template:
spec:
containers:
- name: eval
image: registry.example.com/eval-harness:latest
args: ["--endpoint", "http://model-canary:8000", "--suite", "regression-v3"]
restartPolicy: Never
backoffLimit: 0
Reference this template alongside infra-metrics in a later canary step so quality is a blocking condition, not an afterthought. This closes the loop: the fast/green/worse summarizer now fails quality-score at 5% and rolls back automatically.
Tie-in to evaluation: these gates are only as good as the eval behind them. Everything from your offline eval chapter — golden datasets, LLM-as-judge calibration, reference-based metrics, statistical significance on small samples — is exactly what feeds Pattern B and C. A canary quality gate is your offline eval, run online, on a traffic sample, wired to a rollback switch.
Saying it out loud. Here’s the key reframe: the quality gate is just another metric in the AnalysisTemplate. The whole trick is where the number comes from, and there are three patterns from cheapest to strongest. Pattern A is proxy signals already in Prometheus if your server emits them — refusal rate, empty-completion rate, mean output length as a truncation proxy, guardrail trigger rate, mean logprob. Those cost nothing and catch gross failures. Pattern B is a
webprovider calling an eval service that scores a window of recent canary responses. Pattern C is ajobprovider running a blocking eval suite. Start with A because it’s free and instrument it today; graduate to B and C as the stakes rise. What you must not do is stop at latency and error rate.
Flagger: the same idea, declared on one object
Flagger folds steps + metrics + webhooks into a single Canary resource and drives the mesh for you. Equivalent rollout:
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: model
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: model
service:
port: 8000
analysis:
interval: 1m # analyze every minute
threshold: 5 # roll back after 5 failed checks
maxWeight: 50 # cap canary at 50% before promote-to-100
stepWeight: 10 # +10% each successful interval
metrics:
- name: request-success-rate
thresholdRange: { min: 99 }
interval: 1m
- name: request-duration
thresholdRange: { max: 500 } # ms
interval: 1m
- name: quality-score # custom Prometheus MetricTemplate
thresholdRange: { min: 0.85 }
interval: 5m
webhooks:
- name: eval-suite
type: pre-rollout # must pass before any traffic shifts
url: http://eval-harness.default/run
timeout: 5m
metadata:
endpoint: http://model-canary.default:8000
suite: regression-v3
- name: load-test
type: rollout
url: http://flagger-loadtester.default/
metadata:
cmd: "hey -z 1m -q 20 http://model-canary.default:8000/generate"
Flagger’s control loop: every interval it nudges weight up by stepWeight, checks all metrics, and runs rollout-phase webhooks. Built-in request-success-rate and request-duration come from your mesh’s Prometheus; custom quality metrics are MetricTemplate objects (arbitrary PromQL) referenced by name. A pre-rollout webhook is a hard gate that runs before the first traffic shift — the right place for an expensive full eval suite. Cross threshold failed checks → automatic rollback to primary.
Argo vs Flagger, briefly: Argo Rollouts is imperative-steps + first-class AnalysisTemplate/Experiment (great when you want fine-grained control and blue-green and canary in one tool); Flagger is declarative and convention-driven with batteries-included webhooks (great when you want less YAML and a strong load-test/conformance story). Both roll back automatically on metric breach. Pick one; don’t run both on the same workload.
Saying it out loud. Flagger folds the steps, metrics, and webhooks into a single
Canaryresource and drives the mesh for you — you declare a step weight, a max weight, an interval, and a failure threshold, and Flagger generates the ladder rather than you enumerating it. The real philosophical difference from Argo is declarative convention versus imperative control: Flagger is less YAML for the common case and less flexible when you want a non-uniform ladder, like dwelling much longer at 25% than at 5%. The other thing to know is that Flagger treats every configured metric and every rollout-phase webhook as a hard AND — a single failure halts and rolls back. Which is what you want for a quality gate, and worth confirming rather than assuming.
Build it in practice — extended
The AnalysisTemplate/Flagger snippets above showed infra metrics and quality metrics as separate examples. In production you gate on both at once — a single analysis step where a canary must clear a Prometheus-based infra threshold and a webhook-based quality score before the next weight bump fires. Below is a complete, corrected worked example: the eval-service contract, the Argo Rollouts side, and the Flagger equivalent.
Saying it out loud. In real pipelines you gate on infra and quality at once — a single analysis step where the canary must clear a Prometheus threshold and a webhook-based quality score before the next weight bump fires. That means three pieces: an eval service you own that scores recent canary responses, the Argo or Flagger wiring that calls it, and — the piece people get wrong — a clean distinction between “failed the gate” and “not enough data yet.” Those two are completely different outcomes, and conflating them is precisely how you get flaky rollbacks that erode trust in the pipeline until someone turns the gate off. Which is worse than never having built it.
The eval-service contract both tools call into
Whatever provider (web, job, or a webhook) you use, the actual scoring logic lives in a small service you own. It doesn’t need to be elaborate — it needs to (a) pull a window of recently-logged canary responses, (b) score them (LLM-as-judge, reference match, or whatever your offline-eval chapter calibrated), and (c) return enough information for the gate to check both the score and the sample size. A minimal sketch:
# quality-eval-service — called by Argo's `web` provider or Flagger's `rollout` webhook
from fastapi import FastAPI, Response
import time
app = FastAPI()
@app.post("/score")
def score(req: dict):
window_s = req.get("window_minutes", 5) * 60
since = time.time() - window_s
samples = fetch_canary_responses( # your logging/store lookup
endpoint=req["endpoint"], since_ts=since
)
if len(samples) < req.get("min_samples", 100):
# Not enough data yet — this is NOT the same as "failed the gate".
# Argo: return low n_samples, successCondition's n_samples check catches it.
# Flagger: return 202 (not yet ready) rather than a hard 4xx/5xx.
return Response(status_code=202,
content='{"quality_score": null, "n_samples": %d}' % len(samples))
score = judge_score(samples) # LLM-as-judge / reference-based scorer
passed = score >= 0.85
body = {"quality_score": score, "n_samples": len(samples)}
# Argo's `web` provider just wants the JSON body (see jsonPath below).
# Flagger's `rollout` webhook wants the pass/fail encoded as the HTTP status.
return Response(status_code=200 if passed else 500, content=str(body).replace("'", '"'))
The 202 branch matters: “not enough samples yet” and “the model failed” are different outcomes, and conflating them is how you get the flaky-rollback pitfall from Mechanism 3. Argo’s JSON-based successCondition can distinguish the two explicitly (below); a pure-status-code webhook (Flagger) has to be more careful about what it returns while still warming up.
Saying it out loud. The scoring service is small and you own it: pull a window of recently-logged canary responses, score them however your offline eval work calibrated, and return enough for the gate to check both the score and the sample size. That second return value is the whole point. If you’ve only got twelve judged samples so far, that is not a failure — it’s not-yet-ready, and it should return a 202 rather than a 500. Argo can distinguish them explicitly because its
successConditionreads named JSON fields, so you can require both a score threshold and a minimum sample count. A pure status-code webhook like Flagger’s has to be more careful, because a 500 while warming up looks identical to a genuine regression.
Argo Rollouts: one AnalysisTemplate, two independent metrics, both must pass
Argo Rollouts semantics: a Rollout’s analysis step references one or more AnalysisTemplates, and — per Argo’s own docs — “if multiple templates are referenced, then the controller will merge the templates together,” so an AnalysisRun only reaches Successful when every metric in every referenced template passes its condition. There is no “OR” — a web provider failing rolls the run back exactly like a prometheus provider failing. That is precisely the “gated on both” behavior we want.
Argo’s web metric provider calls the eval service directly; when jsonPath resolves to a JSON object rather than a scalar, successCondition can reference its fields by name — this is documented behavior, not a hack:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: infra-and-quality-gate
spec:
args:
- name: service-name
metrics:
# --- infra metric #1: success rate, from Prometheus ---
- name: success-rate
interval: 1m
count: 5
successCondition: result[0] >= 0.99
failureLimit: 2
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}",
response_code!~"5.."}[1m]))
/
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}"}[1m]))
# --- infra metric #2: p95 latency, from Prometheus ---
- name: p95-latency
interval: 1m
count: 5
successCondition: result[0] <= 500
failureLimit: 2
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
histogram_quantile(0.95,
sum(irate(istio_request_duration_milliseconds_bucket{
destination_service=~"{{args.service-name}}"}[1m]))
by (le))
# --- quality metric: webhook-scored model output, must ALSO pass ---
- name: model-quality
interval: 5m
count: 3
failureLimit: 0 # zero tolerance: any bad read aborts
successCondition: "result.quality_score >= 0.85 && result.n_samples >= 100"
provider:
web:
url: "http://quality-eval-service.default.svc.cluster.local/score"
method: POST
timeoutSeconds: 30
jsonBody:
endpoint: "http://model-canary.default.svc.cluster.local:8000"
window_minutes: 5
min_samples: 100
jsonPath: "{$}" # whole response body as `result`
Notes on the parts that are easy to get wrong:
jsonPath: "{$}"returns the whole JSON body asresult, which is what letssuccessConditionreferenceresult.quality_scoreandresult.n_samplestogether — this is how you enforce a minimum sample size (protecting against the “flaky rollback on 12 samples” pitfall) in the same condition as the score itself, without needing a second metric.failureLimit: 0on the quality metric is a deliberate asymmetry versusfailureLimit: 2on the infra metrics: infra metrics tolerate a couple of noisy scrapes, but a bad quality read after clearing the sample-size bar is treated as real signal, not noise, and aborts immediately.interval: 5m/count: 3on the quality metric versusinterval: 1m/count: 5on infra: quality scoring is expensive (it may itself call an LLM judge) and needs more wall-clock time to accumulate 100+ samples; don’t run it on the same cadence as a cheap Prometheus scrape.- All three metrics live in one
AnalysisTemplate, referenced once from theRollout’sanalysisstep — Argo evaluates them concurrently and the step only succeeds when all three do.
Wire it into the same canary ladder as before, just swapping the template name:
steps:
- setWeight: 5
- pause: { duration: 10m }
- setWeight: 25
- pause: { duration: 5m }
- analysis:
templates:
- templateName: infra-and-quality-gate
args:
- name: service-name
value: model-canary.default.svc.cluster.local
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100
Quality gating is deliberately deferred to the 25% step rather than 5% — at 5% traffic the eval service can’t reliably gather 100 samples in a 5-minute window, so gating it there would violate the sample-size principle from Mechanism 3, below. At 25% of, say, 300 rps, 100 samples arrive in well under a minute.
Saying it out loud. Argo’s semantics here are worth stating precisely because they’re what makes the composite gate work. When a rollout step references multiple
AnalysisTemplates, the controller merges them, and theAnalysisRunonly reaches Successful when every metric in every referenced template passes. There is no OR — awebprovider failing rolls back exactly like aprometheusprovider failing. That’s precisely the gated-on-both behavior you want, and it means you can keep infra checks and quality checks in separate reusable templates without inventing any combination logic. The other documented detail that makes it practical: when awebprovider’sjsonPathresolves to an object rather than a scalar,successConditioncan reference its fields by name — so score and sample count can be checked in one condition.
Flagger: the same “both must pass” gate via a metric + a pre-rollout webhook
Flagger expresses the same idea with a built-in request-success-rate/request-duration metric pair plus a synchronous webhook that must return HTTP 200 before Flagger advances the weight. Flagger treats every configured metric and every rollout-phase webhook as a hard AND — any single failure halts and rolls back:
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: model
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: model
service:
port: 8000
analysis:
interval: 1m
threshold: 5
maxWeight: 50
stepWeight: 10
metrics:
- name: request-success-rate
thresholdRange: { min: 99 }
interval: 1m
- name: request-duration
thresholdRange: { max: 500 }
interval: 1m
webhooks:
- name: model-quality-gate
type: rollout # runs at every weight step, not just once
url: http://quality-eval-service.default/score
timeout: 30s
metadata:
endpoint: "http://model-canary.default:8000"
window_minutes: "5"
min_samples: "100"
# Flagger treats any non-2xx response from this webhook as a failed check,
# counted against `threshold` exactly like a failed metric. Our eval-service
# sketch returns 202 (not yet enough samples) rather than 5xx while warming
# up, so Flagger's retry-on-next-interval semantics don't burn `threshold`
# budget on "still collecting data" the way a hard failure would.
The eval service here must return a 2xx only when it judges the canary healthy (e.g. quality_score >= 0.85 internally, with the same n_samples >= 100 guard) and a non-2xx otherwise — Flagger doesn’t parse a JSON body from rollout webhooks the way Argo’s web provider does, so the scoring logic (and the sample-size guard) has to live inside the webhook handler rather than in the CRD. This is the main practical difference between the two tools’ quality-gate ergonomics: Argo lets the CRD assert on the JSON response; Flagger wants a boolean expressed as an HTTP status code.
Saying it out loud. Flagger expresses the same composite gate differently: its built-in success-rate and duration metrics, plus a synchronous webhook that has to return HTTP 200 before Flagger advances the weight. And Flagger treats every metric and every rollout-phase webhook as a hard AND, so any single failure halts and rolls back — same semantics as Argo, different surface. The practical difference is that Flagger’s gate signal is an HTTP status code rather than a JSON body, which is exactly why the not-enough-samples case needs care: you can’t encode “score is 0.87 but n is only 12” in a status code without deciding what that means. Returning 202 for warming-up is the convention, but it’s a convention you have to implement deliberately.
Mechanism 3: Progressive promotion and automatic rollback
The promotion ladder is the heart of canarying. A sane model ladder:
weight dwell gate
------ ----- ----
1% 10 min infra only (smoke: is it even up?)
5% 15 min infra + cheap quality proxies (refusal, empty, logprob)
25% 30 min infra + online judge sample (quality-score)
50% 60 min infra + full eval-suite job (blocking)
100% — promote; keep old fleet for N minutes before scale-down
Design principles:
- Dwell long enough to see the signal. Quality metrics are noisy on small samples. At 1% traffic you may not accumulate enough judged responses in 10 minutes for a stable estimate — either lengthen the dwell, widen the sample, or don’t gate quality until a higher weight. Gating quality at 1% on 12 samples is how you get flaky rollbacks.
- Rollback must be cheaper than roll-forward. With Argo/Flagger, rollback = set canary weight to 0 and keep serving stable. It’s instantaneous at the traffic layer because you never tore down stable. This is why you keep the old fleet warm until promotion fully completes.
- Automatic beats manual. The controller aborts the moment
failureLimit/thresholdis crossed. Humans add an optionalpause: {}(indefinite) step for a manual approval gate before 100% on high-stakes rollouts — but the failure path should never require a human. - Analysis can also run for the whole rollout, not just per-step. Argo’s
spec.strategy.canary.analysis(background analysis) runs continuously and can abort at any weight the instant a metric breaches — useful for a “circuit breaker” on error rate that shouldn’t wait for the next step boundary.
Saying it out loud. A sane model ladder looks like: 1% for ten minutes with infra-only smoke checks, 5% for fifteen with cheap quality proxies, 25% for thirty with an online judge sample, 50% for an hour with a blocking full eval suite, then promote — and keep the old fleet warm for a bake period. Three design principles hold it together. Dwell long enough to actually see the signal, because gating a quality metric at 1% traffic on twelve samples is how you get flaky rollbacks. Rollback must be cheaper than roll-forward, which it is only because you never tore down stable — rollback is setting the canary weight to zero, instantaneous at the traffic layer. And automatic beats manual: humans can add an approval pause before 100%, but the failure path should never wait for a person.
Statistical rigor: the peeking problem
There’s a subtler version of the “sample size” pitfall worth naming explicitly, because it’s the kind of thing that separates a good answer from a great one in an interview. count/interval/failureLimit checks a metric repeatedly over the dwell window — which means you are, whether you call it that or not, running a sequential statistical test. Classic fixed-sample significance testing (the kind behind a simple “is 0.78 significantly worse than 0.91?” calculation) assumes you look at the data once. An AnalysisRun that queries a quality score every 5 minutes for an hour and aborts the instant one reading crosses a threshold is looking at the data repeatedly — this is the “peeking problem,” and it inflates your false-rollback rate beyond what a naive confidence interval suggests, because with enough repeated looks, pure noise will eventually cross almost any fixed threshold by chance.
Practical mitigations, roughly in order of how much rigor they buy you:
- Require repeated breaches, not one bad read (
failureLimit > 0, or Flagger’sthreshold) — this is already standard practice in this chapter’s examples, and it’s a crude but effective defense against a single noisy measurement triggering rollback. - Widen the confidence margin as a function of how many looks you’ll take. A quality gate checked 12 times over an hour needs a stricter per-check threshold than one checked once, if you want the same overall false-rollback rate — a Bonferroni-style correction is a blunt but defensible way to reason about this out loud in an interview.
- Prefer group-sequential or always-valid testing methods if you’re building this properly. These are designed exactly for “check repeatedly, stop as soon as you’re confident” scenarios (this is standard territory in A/B-testing platforms and clinical-trial-style sequential analysis) and control the false-positive rate under repeated looks, unlike a naive fixed threshold checked on a loop.
- Don’t conflate “the gate uses a threshold” with “the gate is statistically rigorous.” A candidate who says “we check the score five times and require two breaches” has a reasonable engineering answer; a candidate who can also say “and we’re aware that’s a repeated-testing problem, so we’ve widened the threshold / used a sequential test to compensate” is showing they understand why the pitfall in Mechanism 3 (flaky rollbacks) happens at a statistical level, not just that it happens.
None of this changes the YAML in this chapter — failureLimit/threshold already exist specifically to blunt this problem — but understanding why they exist, rather than treating them as an arbitrary knob, is exactly the depth a senior interviewer is probing for when they ask “how do you know your threshold is right?”
Saying it out loud. Here’s the subtlety that separates a good answer from a great one. When you configure a metric with an interval, a count, and a failure limit, you are running a sequential statistical test whether you call it that or not. Classic significance testing assumes you look at the data once. An analysis run that queries a quality score every five minutes for an hour and aborts the instant a reading crosses a threshold is looking twelve times — that’s the peeking problem, and it inflates your false-rollback rate, because with enough looks pure noise will eventually cross almost any fixed threshold. The mitigations: require repeated breaches rather than one bad read, widen the per-check threshold as a function of how many looks you’ll take, or use a properly sequential test.
failureLimitexists specifically to blunt this — knowing why is the depth being probed.
Shadow / mirror deployments
Shadowing (a.k.a. mirroring, dark launch) sends the new version a copy of real requests and throws away its responses. Users are served entirely by stable; the canary sees production-distribution traffic with zero user risk. This is the safest possible way to evaluate a scary model change.
Saying it out loud. Shadowing sends the new version a copy of real requests and throws its responses away. Users are served entirely by stable; the canary sees production-distribution traffic — real prompt lengths, real adversarial inputs, real burstiness — with literally zero user risk. That makes it the safest possible way to evaluate a scary model change, and the strongest quality comparison available, because you can diff v1 and v2 outputs on identical live inputs rather than on statistically similar samples. The cost is that you’re running a full second inference path, so you’re doubling GPU spend for the duration. The mature pattern is shadow, then canary, then promote: shadow catches gross regressions at zero user risk, and only the survivors graduate to real users.
Istio mirroring
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: model
spec:
hosts:
- model
http:
- route:
- destination:
host: model
subset: v1 # 100% of user-visible traffic → stable
weight: 100
mirror:
host: model
subset: v2 # a copy also goes to canary
mirrorPercentage:
value: 100.0 # mirror 100% of it (or dial down for GPU cost)
Semantics that matter:
- Mirrored requests are fire-and-forget — Envoy does not wait for and discards the canary’s response. Canary latency and errors cannot hurt users.
- Istio appends
-shadowto theHost/Authorityheader of mirrored requests, so the canary (and any downstream) can tell it’s shadow traffic. mirrorPercentage.valuecontrols what fraction is copied. GPU inference is expensive; mirror 100% only if you can afford a second full inference path, else sample (e.g.10.0).
Argo Rollouts expresses the same idea with a setMirrorRoute step (mirror by percentage/match), letting you shadow before you canary within one Rollout.
Saying it out loud. Istio expresses mirroring as a
mirrordestination on the route plus amirrorPercentage, and the important semantics are that the mirrored request is fire-and-forget — the response is discarded and the mirror’s latency doesn’t affect the user’s request at all. Two details that matter operationally. The mirror percentage lets you shadow ten or twenty percent rather than the full hundred, which for paired-diff quality comparison is usually plenty and costs a fraction of the GPU. And Istio adds a suffix to the mirrored request’s Host header, which is your hook for making downstream services shadow-aware — because a mirrored request that reaches a real tool call or a real database write is a production incident, not a test.
What shadow buys you, and what it can’t
Shadow gives you real prompts against the new model with no user exposure — perfect for:
- Comparing v1 vs v2 outputs on identical live inputs (paired diffing → the strongest quality comparison you can get).
- Load/soak testing on real traffic shape (bursty, long-context, adversarial) that synthetic tests miss.
- Warming caches and JIT/compilation before real traffic arrives (see cache warmup below).
What it cannot do:
- Anything with side effects. If the model call writes to a DB, calls a tool, sends an email, or bills a token budget, the shadow will do it too unless downstream services are shadow-aware (check that
-shadowheader and no-op). A mirrored request that triggers a real tool call is a production incident. Make write paths shadow-aware or don’t mirror them. - Measure user-facing outcomes. Shadow responses are discarded, so you get model-quality signals but not click-through, thumbs-up, or downstream conversion. Those need a real canary.
The mature pattern: shadow → canary → promote. Shadow the risky checkpoint to catch gross regressions with zero risk; the survivors graduate to a small canary with real users and outcome metrics; the ones that pass promote.
Saying it out loud. Shadow is great for three things: paired diffing of v1 versus v2 on identical live inputs, which is the strongest quality comparison you can get; soak testing on real traffic shape that synthetic tests never reproduce; and warming caches before real traffic arrives. Two hard limits. It cannot do anything with side effects — if the model call writes to a database, invokes a tool, or bills a token budget, the shadow does it too unless downstream services check the shadow header and no-op. A mirrored request triggering a real tool call is an incident, not a test. And it cannot measure user-facing outcomes at all: responses are discarded, so you get quality signals but never click-through, thumbs-up, or conversion. Those need a real canary with real users.
Sizing the canary: a back-of-envelope cost model
Every extra GPU-hour a canary or shadow deployment burns is a real line item, so it’s worth being able to reason about the cost out loud rather than just asserting “small canary, cheap.” A simple model: if your stable fleet is (N) replicas of a GPU class costing (c) per hour, a canary at replica count (k) held for (h) hours costs (k \cdot c \cdot h) — independent of its traffic weight, because the weight controls how much traffic it gets, not how many GPUs it occupies. This is precisely why pitfall 4 (cold-start skew from too few canary replicas) and cost containment pull in opposite directions: a 1-replica canary is cheap but structurally slower per-request than a 12-replica stable fleet, which can look like a latency regression that isn’t one; a canary sized to match stable’s per-replica load costs proportionally more.
A worked example: stable is 12 replicas of an 8×H100 node-class instance at, say, (c) = $28/hour effective cost, running continuously. A canary at 2 replicas (roughly matching per-replica load if canary traffic is capped around 15–20% during the early steps) for a 3-hour ladder (1%→5%→25%→50%→100% with the dwell times from Mechanism 3) costs (2 \times 28 \times 3 = $168) — a small, bounded, one-time cost per rollout. Shadowing the same checkpoint first, mirroring 100% of traffic for a 1-hour soak at the same 2-replica canary size, adds another (2 \times 28 \times 1 = $56). Compare that to the cost of not canarying: the “regressed on the 5%” case study above ran for four days at full production traffic before anyone noticed, on a fleet sized for 100% of load — orders of magnitude more expensive in both direct GPU cost and reputational cost than the canary that would have caught it. The point isn’t the specific dollar figures — it’s that the canary/shadow tax is a small, bounded, up-front cost, and the failure-to-canary tax is unbounded and paid in production.
Two knobs matter most for keeping the bounded side small: dwell time (don’t let steps sit longer than needed to accumulate a statistically sound sample — see the peeking-problem discussion in Mechanism 3) and mirror percentage for shadow (mirroring 10–20% of traffic instead of 100% is usually enough for paired-diff quality comparison, at a fraction of the GPU cost of a full mirror).
Saying it out loud. The cost model is simple and the key insight is counterintuitive: a canary’s cost depends on its replica count, not its traffic weight — weight controls how much traffic it gets, not how many GPUs it occupies. So a one-percent canary on two replicas costs exactly the same as a fifty-percent canary on two replicas. Concretely, two replicas of an 8xH100 node-class instance at roughly $28 an hour effective, held for a three-hour ladder, is about $168 per rollout, plus another $56 for a one-hour shadow soak — those are of-a-date figures and GPU pricing moves. Compare that to the alternative: the incident later in this chapter ran a regression at full production traffic for four days. The canary tax is small, bounded, and paid up front; the failure-to-canary tax is unbounded and paid in production.
Failure modes and pitfalls
1. Quality regression sails through infra gates. The headline failure, restated because it is the whole point: latency/error/saturation are all green while factuality, refusal rate, format-adherence, or tone regress. Mitigation: a quality gate (Patterns A–C) is non-negotiable in any model canary. If you only remember one thing, remember this.
2. Sample size / noise → flaky rollbacks (and flaky promotions). Quality metrics on a 1% slice over 10 minutes are statistically thin. Too tight a threshold → you roll back good models on noise; too loose → you promote bad ones. Mitigation: size the dwell/weight so each analysis has enough judged samples for a stable estimate; use count/failureLimit to require repeated breaches, not one bad read; gate quality at higher weights where volume is sufficient.
3. Session / stateful pinning broken by weighted splitting. Weighted routing assigns each request independently. A multi-turn chat then flips between v1 and v2 mid-conversation — incoherent, and it also corrupts your per-version quality attribution. Mitigation: route by a stable key (session id / user id via consistent-hash or header match) so a conversation stays on one version for its lifetime; only split new sessions by weight.
4. KV-cache / prefix-cache warmup and cold-start cliffs. A freshly started model pod has a cold KV cache, cold prefix cache, cold CUDA graphs / compiled kernels, and possibly a cold model load from disk/network. Its first minutes show inflated latency and lower throughput that have nothing to do with the model’s steady-state quality. A canary that measures latency in that window rolls back a perfectly good model. Mitigation: add a warmup/pause before the first analysis; use a readiness probe that only passes post-warmup; pre-load and pre-compile (shadow traffic is great for this); exclude the warmup window from analysis.
5. Cost of running two model copies. Canary and shadow both mean a second inference path on scarce, expensive accelerators. Shadow at 100% mirror = 2× GPU for the whole shadow period; blue-green = 2× fleet during overlap. Mitigation: right-size the canary (small replica count is fine at 5% weight — but watch pitfall #4, too few replicas + cold cache skews latency); sample shadow traffic (mirrorPercentage) instead of mirroring everything; keep overlap windows tight; scale the old fleet down promptly after promotion is confirmed (but not before — you need it for instant rollback).
6. Metric attribution bleed. If canary and stable share a Service/Prometheus label, your “canary success rate” query silently averages both and hides the regression. Mitigation: distinct version labels and separate canaryService/stableService; always scope analysis PromQL to the canary subset.
7. Rollback that isn’t actually fast. Teams assume rollback is instant, then discover the old fleet was already scaled to zero, so “rollback” means cold-starting GPUs for minutes under a live incident. Mitigation: keep stable fully warm until promotion completes; make weight→0 the rollback, never a redeploy.
8. Shadow side effects. Covered above — mirrored traffic hitting real write paths. Mitigation: shadow-aware downstreams keyed on the -shadow header; never mirror non-idempotent paths blindly.
9. The peeking problem — repeated statistical looks inflating false rollbacks. Checking a noisy quality metric many times over a dwell window is a sequential test, not a single fixed-sample test, and naive fixed thresholds checked on a loop roll back good models more often than the raw confidence interval suggests. Mitigation: require repeated breaches (failureLimit/threshold > 0), widen per-check thresholds as a function of how many looks you’ll take, or use a group-sequential/always-valid testing approach if you’re building this at scale. See Mechanism 3 for the full discussion.
10. Sampling proportional to traffic, not proportional to risk. An online judge or eval-suite sample drawn in proportion to live traffic mix inherits the traffic distribution’s blind spots — a rare-but-important query category (a 5% long-tail intent, a long-context conversation, an edge-case language) gets a proportionally thin slice of the analysis sample, so a regression confined to that category hides behind a healthy aggregate score. Mitigation: stratify the quality sample with a guaranteed minimum count per category that matters, not a fair share of the aggregate — see the “regressed on the 5% no one canaries” case study, next.
11. Engine-level state that doesn’t carry across versions. Modern serving engines (vLLM, SGLang, TensorRT-LLM) keep substantial engine-level state — paged KV-cache blocks, prefix/radix caches, continuous-batching scheduler queues — that is specific to a running engine instance and is not portable across a version bump, even a “minor” one. A new engine version with a different attention kernel, a different default block size, or a changed scheduler policy starts every canary pod with none of that warm state, which compounds Failure Mode 4: it’s not just the model weights that are cold, the serving engine itself hasn’t built up the request-shape-specific scheduling behavior (batch-size heuristics, cache eviction patterns) that steady-state stable has accumulated. Mitigation: when the canary is an engine/runtime upgrade rather than a pure checkpoint swap, budget extra warmup time proportional to how different the new engine’s caching/batching behavior is, and consider shadowing specifically to pre-populate prefix caches with your production prompt-prefix distribution before any user traffic hits the canary.
Saying it out loud. The recurring ways model canaries go wrong. Quality regressions sailing through green infra gates — the headline one. Gating quality too early on too few samples, producing flaky rollbacks that teach people to ignore the gate. Cold-start and topology skew, where a one-replica canary against a twelve-replica stable fleet looks slower for structural reasons that have nothing to do with the model. Session splitting, where a multi-turn conversation gets served by two versions. Sampling that inherits your traffic distribution’s blind spots. Decommissioning the old fleet immediately on promotion, which throws away your cheap rollback path. And the eval service becoming a single point of failure whose outage reads as a model regression. The pattern: most of these make the canary wrong, not the model.
Production case studies & war stories
Case: the “regressed on the 5% no one canaries” incident
The following is a composite, but it is an extremely common shape — variations of it are the concrete reason the MLflow and arXiv sources cited in The 2025–2026 landscape exist, and most engineers who have run model canaries for more than a year have lived some version of it.
Setup. A customer-support chat assistant serves three broad query shapes: general product questions (~70% of traffic), billing/account questions (~25%), and a long tail of multi-step troubleshooting conversations (~5%) that involve several turns of the user providing diagnostic details before the model proposes a fix. The team ships v7, a new base-model checkpoint, mainly to cut p50 latency and cost. It goes through the canary ladder from Mechanism 3:
| Step | Weight | Gate | Result |
|---|---|---|---|
| 1 | 1% | infra only (smoke) | pass — up, responding |
| 2 | 5% | infra + cheap proxies (refusal rate, empty-completion rate) | pass — both flat vs v6 |
| 3 | 25% | infra + online judge sample (general Q&A prompts only) | pass — judge score 0.89 vs 0.87 baseline, v7 looks better |
| 4 | 50% | infra + full eval-suite job | pass — the eval suite’s fixed prompt set is dominated by general-Q&A-style prompts, mirroring the 70% traffic mix |
| 5 | 100% | promote | promoted |
Every gate in this chapter’s own AnalysisTemplate examples was green. p95 latency actually improved by 80ms. The online-judge sample at step 3 sampled proportionally to traffic, so it drew mostly general-Q&A turns — exactly the class v7 was good at — and almost no multi-step troubleshooting conversations, because 25% of traffic times a further sampling rate leaves very few multi-turn troubleshooting sessions in any given analysis window.
What actually happened. v7 had a subtle regression specific to multi-step troubleshooting: it was slightly more prone to losing track of which diagnostic step the user was on after 4+ turns, occasionally re-asking a question the user had already answered two turns earlier. This is exactly the “context preservation” dimension the arXiv quality-gate paper calls out as a distinct axis from task success — a model can nail single-turn task success while regressing on multi-turn coherence, and a judge sample skewed toward single-turn prompts will never see it.
It surfaced four days after full promotion, via a spike in support-escalation tickets tagged “assistant repeated itself” — a lagging, human-reported signal, not a canary metric. By then v6 had been fully decommissioned per the “scale down promptly after promotion” guidance, so the fix required re-deploying v6 from the image registry (fast) and re-running the canary ladder for a patched v7.1 (slow) rather than an instant weight-based rollback.
Root causes, and the fix for each:
- Stratified sampling was missing from the judge/eval step. The fix: the eval-suite job and the online-judge sampler were changed to sample a fixed minimum count per query category (general, billing, multi-step-troubleshooting), not proportional-to-traffic — mirroring the “four-tier stratification” idea from the arXiv paper (functional / orchestration / edge-case / adversarial), so a 5%-of-traffic category still gets meaningfully evaluated instead of being drowned out.
- The eval-suite’s fixed prompt set didn’t include multi-turn conversations at all — it had grown organically from single-turn Q&A pairs. The fix: every retro after this incident, a new failing conversation gets added to the permanent eval suite as a regression test, the same “living question bank fed by post-mortems” pattern the quality-gate paper recommends.
- Decommissioning
v6immediately on promotion removed the cheap rollback path. The fix: keep the previous stable version’s fleet at a small warm standby (not zero) for a defined bake period (e.g. 72 hours) after full promotion, specifically to cover “regression surfaces after promotion, before decommission” — cheap insurance against exactly this failure. - No metric existed for “context preservation” at all — only task-level judge scores. The fix: added a specific multi-turn-coherence check (a judge prompt that scores whether the assistant references only correctly-carried context across a conversation) as its own AnalysisTemplate metric, independent from the general quality score, per the “different metrics catch different failure modes” finding above.
The lesson, restated for interviews: a quality gate that samples proportionally to traffic inherits your traffic distribution’s blind spots. Rare-but-important query classes need guaranteed representation in the eval sample, not a fair share of it — and “the eval suite passed” is only meaningful to the extent the eval suite actually contains the failure mode you’re worried about. This is a sharper, more specific version of the chapter’s opening claim: it’s not merely “infra gates miss quality regressions,” it’s “even a quality gate misses regressions the eval data doesn’t represent.”
Saying it out loud. A support assistant serving 70% general questions, 25% billing, and a 5% tail of multi-step troubleshooting. A new checkpoint went up the full ladder — 1%, 5%, 25% with a judge sample, 50% with the full eval suite — and passed every gate; p95 latency even improved 80 milliseconds. But the judge sampled proportionally to traffic, so it drew almost entirely general Q&A, the exact class the new model was good at. The regression was in multi-turn context preservation: after four-plus turns it started re-asking questions the user had already answered. It surfaced four days later through support tickets, and because the old fleet had been decommissioned on promotion, there was no instant rollback. The sharpened lesson: it’s not just that infra gates miss quality regressions — even a quality gate misses regressions the eval data doesn’t represent. Rare classes need guaranteed representation, not a fair share.
Shorter incident notes worth knowing
- Cold-start latency masquerading as a real regression. A team’s canary at 5% weight failed the p95-latency gate every time, on every version, canary or not — because the canary ReplicaSet only had 1 replica against 12 stable replicas, so its per-pod queueing depth was structurally worse regardless of model quality, and its KV-cache was permanently cold (never warm enough between analysis windows to amortize). Not a model problem — a topology problem. Fix: size canary replica count so per-replica load is comparable to stable, and pre-warm before the first analysis window (see Failure Mode 4, above).
- The webhook quality gate became a single point of failure. A
quality-eval-serviceoutage (unrelated to the model) caused every canary’sweb/webhook metric to fail, which correctly aborted rollouts — but on-call initially assumed the model was regressing, and wasted an hour bisecting a bad checkpoint. Fix: alert on and dashboard the eval service’s own health distinctly from “canary failed,” so a dependency outage reads as a dependency outage, not a false model regression. - A quantized canary that “passed” on aggregate but not on long context. A team shipped an INT4-quantized version of an existing checkpoint purely for cost savings, canaried it on latency/error/refusal-rate — all fine — and promoted. Weeks later, an analysis of downstream conversion showed a small but real drop confined to conversations over ~6k tokens of context, where quantization error compounded enough to nudge factual answers wrong more often. The team’s eval sample, like most, skewed toward shorter interactions. Fix: when the change under canary is specifically a compression/quantization technique, add a context-length-stratified quality check (short/medium/long) rather than relying on an aggregate score, since compression artifacts are exactly the kind of regression that scales with sequence length.
Saying it out loud. Three shorter ones worth internalizing. A canary that failed the p95 latency gate on every version because it had one replica against twelve stable ones — structurally worse queueing and a permanently cold KV cache, so it was a topology problem masquerading as a model problem; the fix is sizing canary replicas so per-replica load is comparable, and pre-warming before the first analysis window. An eval-service outage that correctly aborted every rollout, while on-call spent an hour bisecting a checkpoint that was fine — so dashboard the eval service’s health separately from “canary failed.” And an INT4-quantized canary that passed on aggregate but had a real regression confined to conversations over about six thousand tokens, because quantization error compounds with sequence length and the eval sample skewed short.
Production checklist — what an interviewer probes
- “Your canary is green on latency and error rate — how do you know the new model is actually good?” Answer must name a quality gate: cheap proxies in Prometheus (refusal/empty/logprob), an online judge/eval-run writing a metric, or a blocking eval-suite job/webhook. If your answer stops at latency+errors, you’ve failed the question.
- Traffic-split mechanism and why. Can you go beyond replica-ratio Service splitting to mesh/Gateway weights? Do you know weight is proportional, not percentage, in Gateway API? Do you handle session pinning for multi-turn?
- Automatic rollback path. What exact condition fires it (
failureLimit/threshold), how fast is it, and why is it fast (old fleet stays warm; rollback = weight→0, not redeploy)? - Shadow vs canary tradeoff. When do you shadow first? What can shadow not tell you (user outcomes), and what’s the side-effect hazard?
- Statistical soundness of the gate. How do you avoid flaky rollbacks from thin quality samples? Dwell time, sample size, repeated-breach thresholds.
- Cold-start / cache warmup handling. How do you keep KV/prefix-cache and kernel-compile cold starts from skewing the first analysis window?
- Cost. How much extra GPU does your canary/shadow burn, and how do you bound it (mirror sampling, tight overlap, prompt post-promotion scale-down)?
- Blue-green vs canary decision. When is an atomic flip (tokenizer/prompt-format change) actually the right call over a mixed canary?
Interview mastery
“Explain why model canaries need quality gates, not just latency, in 60 seconds”
A tight answer, timed:
“A normal canary watches error rate, latency, and saturation — the response envelope. That’s necessary but not sufficient for models, because a new checkpoint can return HTTP 200, at lower latency, on every request, while the response body quietly gets worse — more hallucination, more wrongful refusals, worse multi-turn coherence, tone drift. None of that shows up as a 5xx or a slow request. So a model canary needs a second class of gate that actually scores the generated output — cheap proxy signals like refusal rate or empty-completion rate as a first line, and an LLM-judge or reference-based eval running on a traffic sample as the real gate — wired into the same automated promote/rollback loop as the infra metrics, with enough dwell time and sample size that the quality signal isn’t noise. Skip that, and you’ve built a very fast, very reliable way to ship regressions with high confidence.”
That’s the whole chapter in one breath: envelope vs. body, cheap proxies vs. real judge, wired into automation, sized for statistical validity.
Q&A bank (18 questions, roughly ordered easy → hard)
- What’s the difference between a rolling update, blue-green, canary, and shadow deployment? Blast radius and cost tradeoffs — rolling has unbounded/growing exposure and slow rollback; blue-green is atomic with 2x cost during overlap; canary bounds exposure to a weight and rolls back by re-zeroing that weight; shadow has zero user exposure but doubles inference cost and can’t measure real user outcomes.
- Why is a bare Kubernetes Service + replica-ratio a bad way to canary a model? Weight is quantized by replica count (can’t cheaply do 1% with expensive GPU pods), weight is coupled to capacity, and there’s no clean single-object rollback primitive — use Istio/Gateway API weighted routing instead.
- In Gateway API, is
weight: 10a percentage? No — it’s a proportion.weight: 90/weight: 10gives 10%, but so would9/1. The sum of weights in the rule is the denominator. - Why must multi-turn chat sessions be pinned to one model version? Weighted routing splits per-request, not per-conversation; a session that flips versions mid-conversation is incoherent to the user and corrupts per-version quality attribution. Route by a stable session/user key instead of pure weight for anything stateful.
- What’s the core failure mode this whole topic exists to prevent? A new model version that passes every infra signal (error rate, latency, saturation) while regressing on output quality — hallucination, refusal, tone, multi-turn coherence — because infra metrics read the response envelope, not the body.
- Name three ways to get a quality signal into an automated canary analysis, cheapest to most expensive. (A) proxy counters already emittable to Prometheus — refusal rate, empty-completion rate, mean output length/logprob; (B) an online judge/eval job scoring a sample of canary responses and pushing a gauge; (C) a synchronous webhook/job that runs a full eval suite and gates on a JSON/HTTP-status result before promoting.
- How do Argo Rollouts and Flagger differ architecturally? Argo replaces your Deployment with a
RolloutCRD holding two ReplicaSets and drives imperativesteps(setWeight/pause/analysis) with first-classAnalysisTemplates; Flagger leaves your Deployment untouched and creates a shadow “primary” deployment alongside it, driven by a declarativeCanaryCRD with metrics + webhooks. Argo defaults to more manual control; Flagger defaults to more automation out of the box. - What exactly triggers an automatic rollback in Argo Rollouts? A metric’s
count/intervalmeasurements are taken; each measurement is checked againstsuccessCondition/failureCondition; if the number of failing measurements exceedsfailureLimit, the wholeAnalysisRunfails, Argo sets canary weight back to 0, and marks the RolloutDegraded. In Flagger, it’s the built-inthresholdcounter across all metrics/webhooks — cross it and Flagger scales the canary to zero and routes 100% back to primary. - Why must rollback be “reset a weight,” not “redeploy”? Because the fast part of rollback is not creating anything new — the stable fleet is already running and warm, so flipping traffic back to it is near-instant. If you’d already scaled stable down, “rollback” means cold-starting GPU pods during a live incident, which is exactly what you were trying to avoid.
- How do you avoid flaky rollbacks from noisy quality metrics? Size the dwell time and traffic weight so each analysis window accumulates enough judged samples for a stable estimate; require repeated breaches (
failureLimit/threshold> 0) rather than one bad read; consider a combinedsuccessConditionthat asserts both the score and a minimum sample count (see theresult.n_samples >= 100example earlier) so a thin-sample bad read can’t fail the gate on its own. - What can shadow/mirror traffic not tell you, and why mirror at all if the response is thrown away? It can’t measure real user-facing outcomes (clicks, thumbs-up, conversions) since users never see canary responses — but it gives you paired real-traffic comparisons and load/soak testing with zero risk, and it’s the safest way to pre-screen a scary architecture/checkpoint change before it ever gets a canary weight.
- What’s the side-effect hazard with shadow traffic, specifically? If serving the request has side effects — a tool call, a DB write, sending an email, decrementing a billing quota — the mirrored copy will trigger them too unless the downstream is shadow-aware (e.g. checks Istio’s appended
-shadowHost/Authority header and no-ops). A mirrored request that actually calls a payment API is a production incident, not a test. - When would you choose blue-green over canary for a model rollout? When a mixed population of v1/v2 responses is actually incoherent or unsafe — e.g. a tokenizer or prompt-format change where any user seeing a blend of formats mid-session is worse than a clean atomic cutover gated on a full offline eval pass, even though blue-green costs a full second fleet during the overlap.
- What’s the “cold start” pitfall and how do you defend against it? A freshly started canary pod has a cold KV/prefix cache and possibly uncompiled kernels, so its first minutes show inflated latency/lower throughput unrelated to model quality — a naive analysis window right after
setWeightrolls back a fine model. Defend with a warmup pause before the first analysis, readiness probes that gate on post-warmup state, and pre-warming via shadow traffic. - How would you decide where in the canary ladder to first apply a quality gate (1%? 5%? 25%?) Based on how many judged/scored samples that weight and dwell time will actually produce — gate quality once the analysis window can gather enough samples for a stable estimate (often not until 25%+ of traffic for a rare traffic pattern), and use cheaper proxy signals at the earliest, thinnest-traffic steps.
- Why does sampling proportional to traffic mix create a blind spot for quality gates? Rare-but-important query categories (a 5% long-tail intent, or long-context conversations under a quantization change) get a proportionally thin slice of the analysis sample, so a regression confined to that category can hide behind a healthy aggregate score. Fix with stratified/guaranteed-minimum sampling per category rather than pure proportional sampling — see the war stories above.
- What’s the difference between Argo’s
webmetric provider and Flagger’srollout-type webhook, practically? Argo’swebprovider letssuccessConditionassert directly on fields in the JSON response (result.quality_score,result.n_samples), so sample-size and score logic can both live in the CRD; Flagger’s webhook contract is a bare HTTP status code (2xx = pass), so the scoring and sample-size logic must live inside the webhook handler itself. - If your quality-eval service itself goes down, what happens to your canary — and is that the right behavior? Its metric/webhook check fails, which (correctly, conservatively) aborts the rollout — a missing quality signal should not default to “assume it’s fine.” The operational gotcha is distinguishing “eval service is down” from “model regressed” quickly, which means alerting on the eval service’s own health separately from canary-failure alerts.
System-design prompt: “Design the rollout process for a new model version at a company with strict SLAs”
A sketch of a strong answer, roughly in the order an interviewer wants to hear it. Start with the shape of the pipeline, then narrate each box:
┌───────────────────────────┐
│ Offline eval + shadow │
│ (stratified prompt suite, │
│ mirrored live traffic, │
│ zero user exposure) │
└─────────────┬─────────────┘
│ survivors only
▼
┌────────────────────────────────────────────────────────┐
│ Weighted traffic split (HTTPRoute / │
│ VirtualService / InferenceModel) │
│ │
│ 1% ──10m──▶ 5% ──15m──▶ 25% ──30m──▶ 50% ──60m──▶ 100%│
│ │ │ │ │ │
│ smoke infra + infra + infra + │
│ (infra cheap judge blocking │
│ only) proxies sample eval-suite │
└───────┬──────────┬───────────┬────────────┬─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────────────────────────────────┐
│ AnalysisTemplate / Canary CRD: │
│ Prometheus (success rate, p95/p99 lat.) │
│ AND quality-eval webhook (score + n) │
│ -> any breach = automatic rollback │
│ (weight -> 0, stable never scaled down)│
└────────────────────────────────────────────┘
│ all steps pass
▼
┌───────────────────────────┐
│ Promote + warm-standby │
│ bake period for old │
│ version (hours-days) │
│ before full decommission │
└─────────────┬─────────────┘
│
▼
┌───────────────────────────┐
│ Any prod incident feeds │
│ back into the eval suite │
│ as a new regression test │
└───────────────────────────┘
1. Clarify the SLA and blast-radius constraints first. What’s the latency SLA (p95/p99, in ms)? What’s the error-budget? Is this a multi-turn conversational product (session pinning required) or stateless request/response? Is a mixed-version user experience acceptable at all, or does this specific change (tokenizer, prompt format) require atomicity?
2. Pre-production: shadow before anything touches a real user. Mirror a sample of live traffic to the new version, discard its responses, diff v1 vs v2 outputs on identical inputs, and run it against the full offline eval suite (stratified across query categories, including the rare-but-important ones — see the war stories above). This is the cheapest place to catch a gross regression, at zero user risk.
3. Progressive canary via a weighted traffic split (Gateway API HTTPRoute or a mesh VirtualService, or a model-aware InferenceModel split if you’ve adopted the Gateway API Inference Extension; Argo Rollouts or Flagger driving it), with:
- A ladder of weights and dwell times sized so each step accumulates enough samples for the thinnest traffic category you care about, not just the aggregate.
- Two classes of gate at every step past the smoke-test step: infra (success rate, p95/p99 latency, saturation) from Prometheus, and quality (cheap proxies early, judge/eval-webhook score once volume supports it) — both required to pass, neither sufficient alone.
- Session/user-key pinning so a conversation never straddles versions.
- A pre-warm step (readiness gate or shadow-fed warmup) before the first analysis window, to avoid cold-cache false rollbacks.
4. Automatic rollback as the default failure path, with rollback = re-zero the weight against a fleet that was never scaled down — not a redeploy. A human-approval pause before the final 100% step is reasonable for high-stakes changes; the failure path must never wait on a human.
5. Post-promotion bake, not instant decommission. Keep the previous stable version warm at reduced replica count for a defined window (hours to a few days depending on how rare your riskiest query category is) so a regression that surfaces late still has a fast rollback path, before fully scaling the old fleet to zero.
6. Feed every incident back into the eval suite. Any regression caught in production — by a user report, an escalation, anything — becomes a permanent regression test in the offline suite and, if it’s a new category of failure, a new stratified sample bucket in the online quality gate. The eval suite is a living artifact, not a fixed asset.
What a strong candidate says that a weak one doesn’t: naming the sample-size/stratification problem unprompted, insisting quality gates are “and” not “or” with infra gates, and explaining why rollback is fast (stable never scales down until confirmed) rather than just asserting it is.
Saying it out loud. I’d narrate it as a funnel. Offline eval against a stratified prompt suite first, then a shadow soak on mirrored live traffic — zero user exposure, paired diffing against stable, and only survivors move on. Then a weighted canary ladder through Gateway API or a mesh: 1%, 5%, 25%, 50%, with dwell times long enough to accumulate a real sample at each. Every step gated on infra and a task-specific quality signal, both must pass, with automatic rollback that’s just setting the weight to zero. Session-pinned routing so multi-turn conversations don’t split across versions. And after promotion, keep the old fleet on warm standby for a defined bake period — 72 hours — because the regression that surfaces on day three needs an instant rollback, not a redeploy.
Red flags vs. green flags
| Signal in a candidate’s (or a team’s) design | Red flag | Green flag |
|---|---|---|
| Canary gate composition | Only latency/error-rate/saturation | Infra metrics and a model-quality signal, both blocking |
| Quality sampling | Proportional to live traffic mix | Stratified with a guaranteed minimum per query category |
| Rollback mechanism | “Redeploy the old version” | “Re-zero a weight against a fleet that’s still warm” |
| Session handling | Pure weighted routing for chat | Stable key (session/user) pinning per conversation |
| Cold start | Analysis starts the instant weight shifts | Warmup/pre-load window before the first analysis |
| Shadow traffic and side effects | Mirrors write paths unconditionally | Downstream is shadow-aware (checks -shadow header) or write paths are excluded from mirroring |
| Sample-size discipline | Gates on a raw score alone | Gates on score and a minimum sample count |
| Decommission timing | Old fleet scaled to zero immediately on promotion | Bake period at reduced replica count before full decommission |
| Eval suite evolution | Fixed prompt set, never updated | Living suite fed by every production incident/post-mortem |
| Eval-service failure handling | Eval outage silently treated as “pass” or conflated with a model regression | Eval-service health monitored/alerted separately from canary-failure alerts |
| Tooling choice justification | “We use Argo Rollouts” (no why) | Explains the Argo-vs-Flagger tradeoff (imperative/manual vs declarative/automated) relative to their team’s needs |
Quick reference: cheap quality proxies to instrument today
Before you can gate on an expensive judge score (Pattern B/C), you need Pattern A’s cheap proxies actually emitting metrics your server can scrape. This is the fastest, lowest-effort improvement most teams can make to an infra-only canary — none of these require a judge, a reward model, or an eval job, just counters in your inference server.
| Proxy metric | What it approximates | How to emit it | A reasonable starting threshold |
|---|---|---|---|
| Refusal rate | Model wrongly declining valid requests | Classify output against a refusal-phrase list or a small classifier at generation time; increment a counter | fail if > 3% above stable’s baseline |
| Empty / truncated completion rate | Degeneration, hitting max-tokens on non-trivial prompts, decoding failures | Counter on len(output) == 0 or finish_reason == "length" on prompts that shouldn’t need it | fail if > 1–2% |
| Mean output token count | Truncation or verbosity drift vs. baseline | Histogram of completion token counts | fail if it moves > 20% either direction vs. stable |
| Mean logprob / perplexity of the generated sequence | Gross fluency or confidence collapse | Sum/average token logprobs the server already computes during generation | fail on a large negative shift vs. stable’s rolling baseline |
| Guardrail / safety-filter trigger rate | Safety regressions, not just refusals | Counter on your existing content-filter or moderation layer firing | fail if > 2× stable’s baseline rate |
| Repetition / degenerate-loop rate | n-gram repetition, a classic quality failure mode independent of refusal | Simple n-gram-repeat detector over the output, incremented as a counter | fail if > 1% |
| Tool-call malformation rate (agentic serving) | Broken function-calling / tool-use output that infra metrics never see (a 200 with unparseable JSON is still a 200) | Counter on JSON-schema/tool-call parse failures | fail if > 0.5% |
None of these require you to change or slow down the response path — they’re counters incremented next to logging, scraped by the same Prometheus that already backs your infra AnalysisTemplate. Wire two or three of these into the earliest canary steps (1–5% weight) as Pattern A, and reserve the expensive judge/eval-suite gate (Pattern B/C) for the steps with enough traffic volume to support it — this is exactly the ladder from Mechanism 3.
Saying it out loud. If you take one action from this chapter, it’s this: instrument the cheap quality proxies before you build anything sophisticated. Refusal rate on valid prompts. Empty or near-empty completion rate. Mean and p95 output token count, as a proxy for truncation and degeneration. Guardrail-filter trigger rate. Mean token logprob, as a crude confidence signal. Every one of those is a Prometheus counter your server can emit today with no judge model, no eval service, and no extra GPU. They won’t catch a subtle factuality regression — but they would have caught the 6.5% refusal-rate jump from the opening example, and a gate that catches gross regressions today is worth more than a perfect gate you’re still designing next quarter.
Glossary: fields and terms used in this chapter
A field-by-field lookup for the YAML above — useful when you’re skimming back through this chapter mid-incident and need the exact knob, not the prose around it.
| Term | Tool / layer | Meaning |
|---|---|---|
weight | Istio VirtualService, Gateway API HTTPRoute | Proportional traffic share; the sum of weights in a rule is the denominator, not 100 by convention |
mirror / mirrorPercentage | Istio VirtualService | Sends a copy of traffic to a subset and discards the response; percentage of requests copied |
setWeight | Argo Rollouts Rollout step | Imperative step that sets the canary traffic weight to a specific value |
pause | Argo Rollouts Rollout step | Holds at the current weight for a fixed duration, or indefinitely (manual gate) if empty |
analysis (step) | Argo Rollouts Rollout | Runs one or more AnalysisTemplates at the current weight before advancing |
AnalysisTemplate / AnalysisRun | Argo Rollouts | Reusable metric-check bundle (template) and its live execution instance (run) |
interval | Argo Rollouts metric | How often a metric is queried during an AnalysisRun |
count | Argo Rollouts metric | Total number of measurements to take; analysis runs for count × interval |
successCondition / failureCondition | Argo Rollouts metric | Boolean expression evaluated against result from the provider |
failureLimit | Argo Rollouts metric | Number of failed measurements tolerated before the whole AnalysisRun fails |
provider: prometheus | Argo Rollouts metric | Metric source is a PromQL query against a given Prometheus address |
provider: web | Argo Rollouts metric | Metric source is an HTTP call; jsonPath extracts the value(s) bound to result |
provider: job | Argo Rollouts metric | Metric source is a Kubernetes Job’s exit code / output, for heavyweight eval suites |
jsonPath | Argo Rollouts web provider | JSONPath expression selecting a scalar or object from the HTTP response body |
canaryService / stableService | Argo Rollouts Rollout | Services scoped to only the canary or only the stable ReplicaSet, for unambiguous metric attribution |
stepWeight | Flagger Canary | Fixed traffic-percentage increment applied each successful interval |
maxWeight | Flagger Canary | Ceiling on canary traffic before Flagger promotes to 100% |
threshold | Flagger Canary | Number of failed checks (metrics or webhooks combined) tolerated before automatic rollback |
thresholdRange | Flagger metric | {min, max} acceptable range for a built-in or custom metric |
MetricTemplate | Flagger | Custom PromQL metric definition referenced by name from a Canary’s metrics list |
webhooks[].type: pre-rollout | Flagger | Hard gate that must pass before any traffic shifts at all |
webhooks[].type: rollout | Flagger | Gate re-checked at every weight step during the rollout |
canaryTrafficPercent | KServe InferenceService | Native traffic-split field on a predictor component; serverless-mode only |
latestRolledoutRevision / previousRolledoutRevision | KServe InferenceService status | Pointers KServe uses to track which revision is “stable” and which to roll back to |
InferencePool | Gateway API Inference Extension | Groups model-server replicas with cache/queue-aware load balancing, analogous to a Service |
InferenceModel / InferenceObjective | Gateway API Inference Extension | Model-identity routing in front of an InferencePool; carries targetModels/weight for canarying by model version |
-shadow header suffix | Istio mirroring | Appended to Host/Authority on mirrored requests so shadow-aware downstreams can detect and no-op |
Degraded (Rollout status) | Argo Rollouts | State a Rollout enters when an AnalysisRun fails, signaling automatic rollback has occurred |
backoffLimit | Kubernetes Job (used by Argo’s job metric provider) | Number of retries for a failed eval-suite Job before it’s treated as a hard failure |
Further reading
- Argo Rollouts — Analysis, AnalysisTemplate & metrics: https://argo-rollouts.readthedocs.io/en/stable/features/analysis/
- Argo Rollouts — Web metric provider spec (jsonPath, successCondition on object fields): https://argo-rollouts.readthedocs.io/en/stable/analysis/web/
- Argo Rollouts — Canary strategy & steps: https://argo-rollouts.readthedocs.io/en/stable/features/canary/
- Argo Rollouts — Istio traffic management: https://argo-rollouts.readthedocs.io/en/stable/features/traffic-management/istio/
- Argo Rollouts — GitHub releases (v1.9.0, Mar 20 2026; v1.8.4, Feb 13 2026; v1.8.3, Jun 5 2025): https://github.com/argoproj/argo-rollouts/releases
- Flagger — How it works (Canary spec, promotion/rollback): https://docs.flagger.app/usage/how-it-works
- Flagger — Metrics analysis (MetricTemplate, thresholds): https://docs.flagger.app/usage/metrics
- Flagger — Webhooks (pre-rollout gates, load testing): https://docs.flagger.app/usage/webhooks
- Flagger — Deployment strategies (canary, blue-green, mirroring): https://docs.flagger.app/usage/deployment-strategies
- Flagger — Gateway API canary tutorial (mesh-free progressive delivery): https://docs.flagger.app/tutorials/gatewayapi-progressive-delivery
- Flagger — CHANGELOG (v1.42.0, Oct 16 2025: Gateway API v1.4.0, CORS, trafficDistribution; v1.43.0, Apr 21 2026: External Metrics provider, session-cookie naming): https://github.com/fluxcd/flagger/blob/main/CHANGELOG.md
- Istio — Traffic shifting (weighted VirtualService): https://istio.io/latest/docs/tasks/traffic-management/traffic-shifting/
- Istio — Traffic mirroring (shadow): https://istio.io/latest/docs/tasks/traffic-management/mirroring/
- Istio — Bringing AI-aware traffic management to Istio (Gateway API Inference Extension support, InferenceModel weighted canary): https://istio.io/latest/blog/2025/inference-extension-support/
- Kubernetes blog — Introducing the Gateway API Inference Extension (Jun 5, 2025): https://kubernetes.io/blog/2025/06/05/introducing-gateway-api-inference-extension/
- CNCF — Deep dive into the Gateway API Inference Extension: https://www.cncf.io/blog/2025/04/21/deep-dive-into-the-gateway-api-inference-extension/
- Gateway API Inference Extension — source repo (InferencePool, InferenceModel/InferenceObjective): https://github.com/kubernetes-sigs/gateway-api-inference-extension
- Gateway API — Traffic splitting (HTTPRoute weights): https://gateway-api.sigs.k8s.io/guides/traffic-splitting/
- Gateway API — GEP-1324, service mesh support / GAMMA initiative: https://gateway-api.sigs.k8s.io/geps/gep-1324/
- Kubernetes — Deployment rolling updates: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
- Maiorano, “Automated Self-Testing as a Quality Gate for LLM Applications” (arXiv:2603.15676, Mar 2026): https://arxiv.org/html/2603.15676v1
- MLflow — “Canary Deployment for AI Models: A 2026 Guide”: https://mlflow.org/articles/what-is-canary-deployment-ai/
- Buoyant — “Flagger vs Argo Rollouts vs Service Meshes: A Guide to Progressive Delivery in Kubernetes”: https://www.buoyant.io/blog/flagger-vs-argo-rollouts-for-progressive-delivery-on-linkerd
Topic 8: Monitoring & Observability with Grafana
What You’ll Learn
This topic teaches you how to:
- Set up Prometheus for metrics collection
- Create Grafana dashboards for visualization
- Monitor LLM serving performance
- Track GPU utilization
- Set up alerts for anomalies
Why We Need This
Business Need
- SLA compliance: Meet 99.9% uptime SLAs
- Cost optimization: Identify expensive operations
- User experience: Detect issues before users complain
- Compliance: Audit trails for regulated industries
Technical Need
- Debugging: Understand why system is slow/failing
- Capacity planning: Know when to scale
- Performance optimization: Identify bottlenecks
- Incident response: Quick detection and resolution
Real-World Impact
Without monitoring:
- ❌ Issues discovered by users (too late!)
- ❌ Can’t debug production problems
- ❌ Don’t know when to scale
- ❌ No visibility into system health
Industry Use Cases
1. Production ML Platforms
Company: OpenAI, Anthropic, HuggingFace Use Case:
- Monitor API performance 24/7
- Alert on latency spikes, errors
- Track cost per request
Example:
# Alert if P95 latency > 1s
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
2. Enterprise ML Infrastructure
Company: Banks, healthcare, finance Use Case:
- Compliance monitoring
- Audit trails
- Performance SLAs
Example:
# Track all requests for audit
count(llm_requests_total)
3. Cost Management
Company: All companies with ML infrastructure Use Case:
- Track GPU costs
- Identify expensive operations
- Optimize resource usage
Example:
# Cost per request
gpu_cost_per_hour / rate(llm_requests_total[1h])
4. Incident Response
Company: All production systems Use Case:
- Detect issues immediately
- Alert on-call engineers
- Quick root cause analysis
Example:
# Alert on high error rate
alert: HighErrorRate
expr: rate(llm_requests_total{status="500"}[5m]) > 0.05
5. Performance Optimization
Company: All companies Use Case:
- Identify slow endpoints
- Find bottlenecks
- Optimize based on data
Example:
# Find slowest endpoints
topk(10, histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])))
Industry-Standard Boilerplate Code
Complete Monitoring Setup (Industry Standard)
"""
Production monitoring setup
Used by: All production ML systems
"""
from prometheus_client import Counter, Histogram, Gauge, generate_latest
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
import time
# Metrics (industry standard names)
REQUEST_COUNT = Counter(
'llm_requests_total',
'Total requests',
['method', 'endpoint', 'status', 'model_version']
)
REQUEST_DURATION = Histogram(
'llm_request_duration_seconds',
'Request duration',
['method', 'endpoint'],
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
ACTIVE_REQUESTS = Gauge(
'llm_active_requests',
'Active requests',
['endpoint']
)
TOKENS_GENERATED = Counter(
'llm_tokens_generated_total',
'Total tokens generated',
['model_name']
)
GPU_UTILIZATION = Gauge(
'llm_gpu_utilization_percent',
'GPU utilization',
['gpu_id']
)
app = FastAPI()
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
"""Collect metrics for every request"""
endpoint = request.url.path
method = request.method
# Skip metrics endpoint
if endpoint == "/metrics":
return await call_next(request)
# Track active requests
ACTIVE_REQUESTS.labels(endpoint=endpoint).inc()
# Measure duration
start = time.time()
status_code = 200
try:
response = await call_next(request)
status_code = response.status_code
return response
except Exception:
status_code = 500
raise
finally:
duration = time.time() - start
# Record metrics
REQUEST_DURATION.labels(
method=method,
endpoint=endpoint
).observe(duration)
REQUEST_COUNT.labels(
method=method,
endpoint=endpoint,
status=str(status_code),
model_version="v1.0.0"
).inc()
ACTIVE_REQUESTS.labels(endpoint=endpoint).dec()
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint"""
return Response(
content=generate_latest(),
media_type="text/plain"
)
Prometheus Alerts (Industry Standard)
# alerts.yml
# Used by: Production monitoring systems
groups:
- name: llm_serving_alerts
interval: 30s
rules:
# High latency alert
- alert: HighLatency
expr: histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "P95 latency > 1s"
description: "Latency is {{ $value }}s"
# High error rate
- alert: HighErrorRate
expr: rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "Error rate > 5%"
# GPU high utilization
- alert: GPUHighUtilization
expr: llm_gpu_utilization_percent > 95
for: 10m
labels:
severity: warning
annotations:
summary: "GPU utilization very high"
# Service down
- alert: ServiceDown
expr: up{job="llm-serving"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "LLM serving is down"
Grafana Dashboard JSON (Industry Standard)
{
"dashboard": {
"title": "LLM Serving Dashboard",
"panels": [
{
"title": "Request Rate",
"targets": [{
"expr": "rate(llm_requests_total[5m])",
"legendFormat": "{{endpoint}}"
}]
},
{
"title": "Latency (P95)",
"targets": [{
"expr": "histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))",
"legendFormat": "P95"
}]
},
{
"title": "Error Rate",
"targets": [{
"expr": "rate(llm_requests_total{status=\"500\"}[5m]) / rate(llm_requests_total[5m])",
"legendFormat": "Error Rate"
}]
},
{
"title": "GPU Utilization",
"targets": [{
"expr": "llm_gpu_utilization_percent",
"legendFormat": "GPU {{gpu_id}}"
}]
}
]
}
}
Key Concepts
Monitoring Stack
- Prometheus: Metrics collection and storage
- Grafana: Visualization and dashboards
- Exporters: Collect metrics from applications
- Alertmanager: Handle alerts
Key Metrics to Monitor
Application Metrics
- Request rate: Requests per second
- Latency: P50, P95, P99 response times
- Error rate: Failed requests percentage
- Queue size: Pending requests
System Metrics
- GPU utilization: How much GPU is being used
- GPU memory: Memory usage
- CPU usage: CPU utilization
- Memory usage: RAM usage
Model Metrics
- Tokens generated: Tokens per second
- Batch size: Current batch size
- Model version: Which model is running
Architecture
[LLM Server] → [Prometheus Exporter] → [Prometheus] → [Grafana]
↓
[GPU Metrics] → [Node Exporter] → [Prometheus]
Setup
1. Install Prometheus
# Download Prometheus
wget https://github.com/prometheus/prometheus/releases/download/v2.45.0/prometheus-2.45.0.linux-amd64.tar.gz
tar xvfz prometheus-*.tar.gz
cd prometheus-*
# Or use Docker
docker run -d -p 9090:9090 prom/prometheus
2. Install Grafana
# Docker
docker run -d -p 3000:3000 grafana/grafana
# Or install locally
# See: https://grafana.com/docs/grafana/latest/setup-grafana/installation/
3. Set Up Metrics Export
Add Prometheus metrics to your serving application (see prometheus_exporter.py).
Running the Stack
Option 1: Docker Compose
cd 08_monitoring
docker-compose up -d
This starts:
- Prometheus on http://localhost:9090
- Grafana on http://localhost:3000
- Node Exporter (system metrics)
Option 2: Manual Setup
- Start Prometheus:
./prometheus --config.file=prometheus.yml - Start Grafana:
./grafana-server - Configure data source in Grafana
Grafana Dashboards
Pre-built Dashboards
- LLM Serving Overview: Request rate, latency, errors
- GPU Monitoring: GPU utilization, memory, temperature
- System Metrics: CPU, memory, disk, network
- Model Performance: Throughput, tokens/second
Creating Custom Dashboards
- Open Grafana (http://localhost:3000)
- Login (default: admin/admin)
- Add Prometheus data source
- Create new dashboard
- Add panels for metrics you care about
Key Metrics Queries
Request Rate
rate(http_requests_total[5m])
Latency (P95)
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
Error Rate
rate(http_requests_total{status="error"}[5m]) / rate(http_requests_total[5m])
GPU Utilization
nvidia_gpu_utilization_gpu
GPU Memory
nvidia_gpu_memory_used_bytes / nvidia_gpu_memory_total_bytes
Alerts
Example Alert Rules
groups:
- name: llm_serving
rules:
- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
for: 5m
annotations:
summary: "High latency detected"
- alert: HighErrorRate
expr: rate(http_requests_total{status="error"}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
annotations:
summary: "High error rate"
- alert: GPUHighUtilization
expr: nvidia_gpu_utilization_gpu > 95
for: 10m
annotations:
summary: "GPU utilization very high"
Exercises
- Set up monitoring: Deploy Prometheus and Grafana
- Create dashboard: Build a dashboard for your metrics
- Set up alerts: Configure alerts for high latency
- Monitor GPU: Add GPU metrics to dashboard
- Compare performance: Monitor before/after optimization
Next Steps
- Topic 6: Use metrics for autoscaling
- Topic 10: Set up drift detection alerts
- Topic 9: Monitor model version performance
Further Reading
Monitoring LLM Serving — Observability for GPU Inference in Production
Why this matters. A classic web service is healthy when latency and error rate look good. An LLM server can pass both of those checks and still be quietly on fire: the KV cache is 98% full, requests are piling up in a queue you never graphed, and your p50 looks fine only because the p99 users already gave up and disconnected. Serving LLMs introduces metrics that ordinary dashboards don’t have — token-level latency, cache pressure, batch dynamics — and if you don’t measure them you cannot run the system. This chapter is about the metrics that actually matter for GPU inference, where they come from, and how to wire Prometheus, Grafana, DCGM, and tracing together into an observability stack you can put on-call against.
Core intuition: LLM serving has latency your web stack never had
A REST endpoint has essentially one latency: request in, response out. An LLM endpoint has three latencies, and users feel all of them differently.
-
Time To First Token (TTFT) — how long until the first token appears. This is the prefill cost: the model processes the whole prompt before it can emit anything. Long prompts, cold caches, and queue waiting all inflate TTFT. For a chat UI this is the “is it thinking?” delay and it dominates perceived responsiveness.
-
Time Per Output Token (TPOT), a.k.a. inter-token latency (ITL) — the gap between subsequent tokens during decode. This sets the “typing speed” of the stream. A user reads at maybe 5–10 tokens/sec; if TPOT is 100 ms (10 tok/s) the stream feels fluid, at 300 ms it feels painful.
-
End-to-end latency — total wall-clock for the whole response. This is roughly ( \text{TTFT} + (N_{\text{output}} - 1) \times \text{TPOT} ), so a long answer amplifies a small per-token regression into a large total.
The second thing that’s different: the bottleneck is a fixed pool of GPU memory, not CPU or connection count. Modern engines (vLLM, TGI) batch many sequences together and store each sequence’s attention state in a KV cache carved out of GPU HBM. When the cache fills, the scheduler stops admitting new sequences — they wait in a queue — or it preempts running ones and recomputes them later. So the health signals that predict a latency cliff are queue depth and KV-cache utilization, not GPU-percent-busy. A GPU can read 100% utilized while the real problem is that it’s thrashing the cache.
Keep two mental models side by side:
- RED (Rate, Errors, Duration) — the request-centric view. Good for the API surface users touch.
- USE (Utilization, Saturation, Errors) — the resource-centric view. Good for the GPU and the KV cache. Saturation — the queue and the cache — is where LLM serving lives or dies, and it’s the box most teams forget.
Saying it out loud. So the short version is that an LLM endpoint doesn’t have one latency, it has three, and users feel each one differently. There’s time to first token — how long you stare at a blank screen while the model reads your prompt — then time per output token, which is basically the typing speed of the stream, and then total wall clock, which is just the first one plus the length of the answer times the second one. The other thing that surprises people coming from web services is that the resource you run out of isn’t CPU or connections, it’s a fixed pool of GPU memory called the KV cache, which holds the attention state for every in-flight request. So the signals that predict trouble are queue depth and how full that cache is — GPU utilization can read 100% while the card is memory-bandwidth-bound and doing almost no useful math.
Metrics catalog — what to measure and why
| Metric | What it means | Why it matters | Healthy range / notes |
|---|---|---|---|
| TTFT p50/p95/p99 | Time until first token | Perceived responsiveness; captures prefill + queue wait | Interactive chat: p95 < 1–2 s. Rising p95 with flat p50 = queue building |
| TPOT / inter-token latency | Steady-state gap between output tokens | Stream “typing speed”; regressions multiply over long outputs | 10–50 ms/token typical; > 100 ms feels slow |
| E2E latency p50/p95/p99 | Full request wall-clock | The SLO users actually sign; skewed by output length | Always report percentiles, never the mean |
| Throughput — requests/s | Completed requests per second | Capacity planning, autoscaling signal | Compare against offered load; gap = queue growth |
| Throughput — tokens/s | Generated tokens per second (decode) | The real work rate of the GPU; the currency of cost | Track output tok/s separately from prompt tok/s |
| Queue depth / waiting seqs | Requests admitted-but-waiting | Leading indicator of a latency cliff | Should hover near 0; sustained > 0 = under-provisioned |
| Running sequences | Sequences decoding right now | Effective batch size; drives GPU efficiency | Low + full queue = memory-bound, not compute-bound |
| GPU utilization | % time GPU had work scheduled | Coarse “is the GPU busy” signal | High util ≠ efficient; can be high while thrashing |
| GPU memory used / free | HBM in use (framebuffer) | OOM risk; headroom for larger batches/KV | Leave headroom; OOM crashes the whole replica |
| KV-cache utilization | Fraction of paged KV blocks in use | The saturation signal for LLM serving | > 90% sustained → preemption, TTFT spikes |
| Batch size | Sequences processed per step | Throughput vs latency tradeoff knob | Larger = more throughput, higher TPOT |
| Preemptions | Sequences evicted & recomputed | Direct evidence of cache pressure | Any sustained rate is a red flag |
| Prefix-cache hit rate | Fraction of prompt tokens served from reused KV blocks | Reused context is free prefill; regressions inflate TTFT with no traffic change | vLLM V1 replaced the raw hit-rate gauge with cache_query_hit / cache_query_total counters — derive the ratio yourself |
| Spec-decode acceptance rate | Fraction of speculatively drafted tokens the target model accepts | Tells you whether speculative decoding is actually buying throughput | < 50% acceptance usually means the draft model or config is miscalibrated for current traffic |
| Prefill vs. decode time | Split of per-request time between prompt processing and generation | Separates “the prompt got longer” from “steady-state decode got slower” | Exposed directly as separate histograms in newer engine versions |
| Error rate | Failed / total requests | Availability SLI; 5xx, OOM, timeouts, truncations | Alert on rate, not raw count |
| Cost per 1k tokens | $ per 1000 tokens served | Turns efficiency into money; the exec-facing number | Derived: GPU $/hr ÷ (tokens/s × 3.6) |
The rule of thumb: latency metrics are the SLIs; queue, KV-cache, and preemptions are the leading indicators; GPU/memory are the resource ceiling; cost is the business translation. The newer rows above — prefix-cache hit rate, spec-decode acceptance, and the prefill/decode split — are recent additions to the major engines’ metric surfaces; see “The 2025–2026 landscape” below for exactly what changed, when, and why it matters for your dashboards.
Saying it out loud. If someone asks what I’d measure, I’d give four buckets rather than reciting a list. Latency percentiles are the SLIs — the thing you actually promise users. Queue depth, KV-cache utilization, and preemptions are the leading indicators, the stuff that moves before the SLI does. GPU and HBM are the ceiling you’re pushing against, and dollars per thousand tokens is the translation for whoever pays the bill. The one people forget is preemptions — any sustained rate of sequences being evicted and recomputed means you’re already past the cliff, you just haven’t seen it in latency yet.
The 2025–2026 landscape
Two things changed in the last eighteen months or so: (1) OpenTelemetry started standardizing GenAI metric names — not just traces — which matters for serving infrastructure specifically, not only application code; and (2) the engines and GPU exporters you already scrape grew new fields for prefix caching, speculative decoding, and profiling. This section is a dated snapshot of where things stand as of mid-2026 so you know which names are stable enough to build alerts on and which are still moving.
Saying it out loud. The honest framing here is that the metric names you build dashboards on are still moving, so the useful skill is knowing which ones are safe to alert on. Two things changed recently: OpenTelemetry started standardizing GenAI metric names, not just trace attributes, and the engines themselves grew new fields for prefix caching and speculative decoding. My rule is that I alert on engine-native names like the vLLM time-to-first-token histogram, because those are what actually get populated today, and I treat the OpenTelemetry gen_ai names as the cross-vendor join key I’ll migrate to once they settle. As of mid-2026 nothing in that dedicated GenAI conventions repo is marked Stable and no major engine emits those names natively, so putting a page on them would be building on sand.
OpenTelemetry GenAI semantic conventions reach the serving layer
OpenTelemetry has had GenAI span conventions (gen_ai.request.model,
gen_ai.usage.input_tokens, …) for a while, but the metrics side is newer
and, importantly, defines two distinct families
(GenAI metrics reference):
gen_ai.server.*— serving-layer metrics, meant to be emitted by the inference server itself:gen_ai.server.time_to_first_token,gen_ai.server.time_per_output_token, andgen_ai.server.request.duration(all histograms, unit seconds). This is the vendor-neutral overlay for exactly the TTFT/TPOT/E2E triad this chapter has been building dashboards around.gen_ai.client.*— application-layer metrics, meant to be emitted by whatever code calls a model API:gen_ai.client.token.usage,gen_ai.client.operation.duration, and (for streaming)gen_ai.client.operation.time_to_first_chunk/gen_ai.client.operation.time_per_output_chunk.
The practical read: server.* is what you’d expect an inference engine or
gateway to export next to vllm:time_to_first_token_seconds; client.* is
what a RAG service or agent framework exports about its own calls out to that
engine. They answer different questions — “is the model server healthy” vs.
“is my application’s use of the model server healthy” — and conflating them is
a common dashboard-design mistake once teams start adopting both.
The conventions are still moving fast and are not yet Stable. Version history worth knowing (state of OTel GenAI semconv, July 2026):
- v1.37.0 (Aug 2025) —
gen_ai.systemrenamed togen_ai.provider.name. - v1.40.0 (Feb 2026) — agent- and RAG-telemetry additions.
- v1.41.0 (Apr 2026) — client/internal agent span splitting.
- v1.42.0 (Jun 2026) — the GenAI conventions were fully deprecated out of
the core
open-telemetry/semantic-conventionsrepo and migrated to a dedicated project,open-telemetry/semantic-conventions-genai(migration notice).
As of July 2026 no GenAI-specific metric or attribute in that dedicated repo is
marked Stable, and the repo has no versioned releases of its own yet
(state of OTel GenAI semconv, July 2026).
In practice this means: no major serving engine has switched its native
Prometheus metrics over to gen_ai.server.* names, so you still scrape
vllm:/tgi_ metrics day to day, and you treat the OTel GenAI names as the
emerging cross-vendor join key to watch, not yet something to alert on
directly.
Saying it out loud. The distinction that matters is server versus client. The gen_ai.server metrics are emitted by the inference server itself — is the model server healthy, what’s its time to first token, its time per output token. The gen_ai.client metrics are emitted by whatever application calls a model API — is my RAG service’s use of that server healthy, how many tokens did it burn. Teams blend them onto one dashboard and then can’t tell whether the model server is slow or their own code is, which is a genuinely painful thirty minutes during an incident. And one line of history is worth knowing: the GenAI conventions were moved out of the core semantic-conventions repo into their own project in mid-2026 and still have no stable release, so treat them as direction, not dependency.
vLLM’s V1 metrics — what’s new since the V0 engine
vLLM’s rewritten V1 engine kept the core metric names this chapter already covers, but the current design adds several fields that didn’t exist in the older API-server-only metrics endpoint (vLLM metrics design, current, vLLM engine metrics reference):
vllm:cpu_cache_usage_perc— the CPU-side counterpart ofgpu_cache_usage_perc, for deployments that swap KV blocks to host memory under pressure instead of only GPU HBM.vllm:cache_config_info— an Info metric (labels only, no useful value) that pins the exact cache configuration (block size, GPU/CPU block counts) a given process is running with, useful for correlating a dashboard change with a config change.- Prefix-cache counters replace the hit-rate gauge. The old
vllm:gpu_prefix_cache_hit_rate/vllm:cpu_prefix_cache_hit_rategauges are deprecated in favor ofcache_query_hit/cache_query_totalcounters — compute the ratio yourself withrate(cache_query_hit[5m]) / rate(cache_query_total[5m]), which behaves correctly across restarts and Prometheus aggregation (a rate of two counters composes; averaging a pre-computed gauge across replicas does not). - Speculative decoding is now implemented, not just planned.
vllm:spec_decode_draft_acceptance_rate,vllm:spec_decode_efficiency, and the countersvllm:spec_decode_num_accepted_tokens_total/_num_draft_tokens_total/_num_emitted_tokens_totallet you watch whether a speculative-decoding config is actually paying for itself in practice. - Prefill and decode time are split, as separate histograms
(
vllm:request_prefill_time_seconds,vllm:request_decode_time_seconds), which is what makes the “Prefill vs. decode time” catalog row above possible without guessing from TTFT and TPOT alone. vllm:lora_requests_info— a gauge for multi-LoRA deployments, so you can see adapter-level request mix on a shared base model.- Three older metrics are deprecated/removed and worth knowing so you don’t
chase ghosts in old dashboards:
vllm:num_requests_swapped,vllm:time_in_queue_requests(duplicatedrequest_queue_time_seconds), and an unimplementedvllm:tokens_total.
TGI
TGI’s metric surface (tgi_request_duration, tgi_queue_size, and friends,
covered in Mechanism 1 below) hasn’t grown a comparable set of new fields in
this window; the ecosystem instead grew around it — there’s now a community
Grafana dashboard purpose-built for TGI on Kubernetes
(TGI dashboard, Grafana Labs)
that you can import rather than hand-build panels for tgi_ metric names.
Saying it out loud. The V1 change I’d actually bring up in an interview is the prefix-cache one, because it shows you understand Prometheus, not just vLLM. The old gauge handed you a hit rate directly; V1 replaced it with two counters — cache hits and cache queries — and you compute the ratio yourself. That’s strictly better, because a rate of two counters composes correctly when you sum across replicas and it survives process restarts, whereas averaging a pre-computed hit-rate gauge across five replicas is arithmetically meaningless. The other additions worth naming are speculative-decoding acceptance rate, where anything under about 50% usually means the draft model is miscalibrated for your traffic, and the split of prefill time from decode time, which lets you tell “the prompts got longer” apart from “decode got slower” without guessing.
DCGM exporter’s current metric set — and a real gotcha
The default DCGM exporter field set, as documented by NVIDIA
(DCGM exporter docs),
groups into: clocks (DCGM_FI_DEV_SM_CLOCK, _MEM_CLOCK), thermals
(_GPU_TEMP, _MEMORY_TEMP), power (_POWER_USAGE,
_TOTAL_ENERGY_CONSUMPTION), utilization (_GPU_UTIL, _MEM_COPY_UTIL,
_ENC_UTIL, _DEC_UTIL), framebuffer memory (_FB_USED, _FB_FREE,
_FB_RESERVED), reliability (_XID_ERRORS,
_UNCORRECTABLE_REMAPPED_ROWS, _CORRECTABLE_REMAPPED_ROWS,
_ROW_REMAP_FAILURE), NVLink bandwidth, and the DCP profiling group
(DCGM_FI_PROF_GR_ENGINE_ACTIVE, _PIPE_TENSOR_ACTIVE, _DRAM_ACTIVE,
_PCIE_TX_BYTES, _PCIE_RX_BYTES) that this chapter leans on to see past a
misleadingly-high GPU_UTIL during decode.
The gotcha: the DCP profiling metrics are not guaranteed on by default the way they were in dcgm-exporter 3.x. Two concrete, dated reports:
- Running GPU Operator v25.3.0 with dcgm-exporter v4.1.1-2, operators have hit
DCGM_FI_PROF_GR_ENGINE_ACTIVE: metric not enabled(NVIDIA/gpu-operator#1397) — the profiling module has to be explicitly available/enabled on the driver and exporter side; it doesn’t just show up because you upgraded. - Metrics available by default in 3.x, like
DCGM_FI_PROF_PCIE_TX_BYTES, have been reported missing after upgrading to dcgm-exporter 4.x (NVIDIA/dcgm-exporter#513).
The operational takeaway: after any dcgm-exporter or GPU Operator version
bump, re-verify with curl -s http://<node>:9400/metrics | grep PROF before
trusting a dashboard panel that depends on PIPE_TENSOR_ACTIVE or
DRAM_ACTIVE — a silently-missing profiling metric reads as “no data,” not as
an error, and a panel that quietly goes blank is easy to miss until the exact
moment you need it during an incident.
Saying it out loud. DCGM is NVIDIA’s GPU telemetry daemon; the exporter turns it into Prometheus metrics on port 9400, and it’s how you see temperature, power, HBM usage, and ECC errors. The gotcha worth knowing is that the profiling group — the DCGM_FI_PROF fields like tensor-pipe-active — is not guaranteed to be on; it depends on the DCGM profiling module being enabled, and there are dated public reports of those fields disappearing after a routine dcgm-exporter or GPU Operator upgrade. That failure mode is nasty because a missing metric renders as “no data,” not as an error, so the panel just goes quietly blank and you discover it during exactly the incident where you needed it. So after any exporter bump, curl the metrics endpoint and grep for PROF before you trust the panel.
Building unified dashboards across the serving stack
The pattern that’s emerged for tying engine metrics and GPU metrics into one
view is: one Prometheus with multiple scrape jobs (engine + DCGM + gateway/
router), one Grafana dashboard templated with $model/$engine/$gpu
variables, and — once the OTel GenAI conventions stabilize — those names as a
long-term cross-vendor join key.
vLLM’s own reference deployment,
vllm-project/production-stack
(launched January 2025; latest release vllm-stack-0.1.11, May 2026), ships
exactly this: a router in front of multiple vLLM engines, plus a Grafana
dashboard whose panels mix vLLM-specific series (available instance count,
E2E/TTFT latency distributions, active and pending requests) with GPU-facing
series (KV-cache utilization, KV-cache/prefix-cache hit rate) — all fed by one
Prometheus scraping both the router and the engines. A companion dashboard
covers LMCache (vLLM’s disaggregated KV-cache backend) separately. If you don’t
want to hand-roll the JSON in the next section, this repo — or the community
kubeai-project/kubeai vLLM Grafana dashboard —
is a reasonable starting point to fork.
Saying it out loud. The pattern that’s settled out is boring, and that’s the point: one Prometheus with several scrape jobs — the engines, the DCGM exporters, the router — feeding one Grafana dashboard templated on model, engine, and GPU, so a single dashboard serves every replica. You don’t have to hand-roll it either; vLLM’s own production-stack repo ships a router plus a reference dashboard that already mixes engine series like TTFT distributions with GPU-facing series like KV-cache utilization. If I were asked to design this I’d say fork that and spend the saved time on alert tiering instead. The thing to avoid is a dashboard per team — that’s how you end up with five different definitions of p95 in one org.
Mechanism 1 — Scraping engine metrics
Both major open-source engines expose Prometheus metrics natively. You don’t instrument the model; you scrape the server.
vLLM
vLLM publishes a /metrics endpoint on its OpenAI-compatible API server (same
port as the API, default 8000). Every metric is prefixed vllm:. The ones
that matter, by type
(vLLM production metrics,
metrics design):
Histograms (latency — these give you percentiles):
vllm:time_to_first_token_seconds— TTFTvllm:time_per_output_token_seconds— TPOT / inter-token latencyvllm:e2e_request_latency_seconds— full request latencyvllm:request_queue_time_seconds— time spent waiting to be scheduledvllm:request_prefill_time_seconds— prefill portion of inference timevllm:request_decode_time_seconds— decode portion of inference timevllm:request_prompt_tokens— prompt length distributionvllm:request_generation_tokens— output length distributionvllm:iteration_tokens_total— tokens processed per scheduler step
Gauges (instantaneous system state):
vllm:num_requests_running— sequences currently decodingvllm:num_requests_waiting— sequences queued (the saturation signal)vllm:gpu_cache_usage_perc— KV-cache utilization (a fraction 0–1, so multiply by 100 to get a percent — the name is misleading)vllm:cpu_cache_usage_perc— CPU-side KV-cache utilization, for deployments that swap blocks to host memoryvllm:lora_requests_info— active adapter mix, for multi-LoRA servingvllm:spec_decode_draft_acceptance_rate/vllm:spec_decode_efficiency— speculative-decoding health, if enabled
Counters (cumulative — take rate() of these):
vllm:prompt_tokens_total— prompt tokens processedvllm:generation_tokens_total— output tokens generated (throughput source)vllm:request_success_total— successful requests (has afinished_reasonlabel so you can separatestopvslengthvsabort)vllm:num_preemptions_total— cache-pressure evictionsvllm:cache_query_hit/vllm:cache_query_total— prefix-cache hits vs. lookups; the current, correct way to compute prefix-cache hit rate (the oldergpu_prefix_cache_hit_rategauge is deprecated — see “The 2025–2026 landscape” above for why a rate-of-counters beats an averaged gauge here)vllm:spec_decode_num_accepted_tokens_total/_num_draft_tokens_total/_num_emitted_tokens_total— speculative-decoding token accounting
Histograms are exposed as three series each: _bucket (cumulative, labelled by
le), _sum, and _count. You compute percentiles from _bucket and averages
from _sum / _count.
A handful of older metric names are deprecated or were never implemented —
vllm:num_requests_swapped, vllm:time_in_queue_requests, and
vllm:tokens_total — so if you inherit a dashboard built against an older
vLLM version, expect a few blank panels until you re-map them onto the current
names above.
TGI (Text Generation Inference)
Hugging Face TGI exposes /metrics with a tgi_ prefix
(TGI metrics reference):
tgi_request_duration— end-to-end latency (histogram)tgi_request_inference_duration— inference time excluding queue (histogram)tgi_request_queue_duration— time spent waiting in queue (histogram)tgi_request_mean_time_per_token_duration— inter-token latency (histogram)tgi_batch_current_size— current batch size (gauge)tgi_batch_current_max_tokens— token budget of current batch (gauge)tgi_queue_size— requests waiting (gauge)tgi_request_count/tgi_request_success— request counters
Note the naming gap: TGI does not ship a single metric literally named “TTFT.”
You approximate it as tgi_request_queue_duration + the prefill portion, or you
capture first-token timing at the client / gateway. This is a common source of
dashboard confusion — always confirm which engine you’re scraping and map its
names onto your canonical SLIs. If you don’t want to hand-build a TGI
dashboard from scratch, the community-maintained
TGI Grafana dashboard (ID 20246)
is a ready import for a Kubernetes deployment.
Saying it out loud. The key point is that you don’t instrument the model, you scrape the server — both vLLM and TGI already expose a Prometheus endpoint, so this is configuration, not code. What bites people is that the two engines don’t agree on names. vLLM gives you a real time-to-first-token histogram; TGI ships no metric literally called TTFT, so you either approximate it from queue duration plus the prefill portion, or you capture first-token timing at the gateway. So step one on any new stack is a mapping table from engine-native names onto your canonical SLIs — otherwise p95 quietly means something different depending on which engine served the request.
Mechanism 2 — GPU metrics with DCGM
Engine metrics tell you about requests. They don’t tell you the GPU is at 90 °C, throttling its clocks, or that another process is stealing HBM. For that you run NVIDIA’s DCGM exporter, which reads the Data Center GPU Manager and exposes Prometheus metrics on port 9400 (NVIDIA/dcgm-exporter, DCGM exporter docs).
Key fields (all prefixed DCGM_FI_):
| Metric | Meaning |
|---|---|
DCGM_FI_DEV_GPU_UTIL | GPU utilization (% of time a kernel was resident) |
DCGM_FI_DEV_FB_USED | Framebuffer (HBM) memory used, MiB |
DCGM_FI_DEV_FB_FREE | Framebuffer memory free, MiB |
DCGM_FI_DEV_FB_RESERVED | Framebuffer memory reserved by the driver, MiB |
DCGM_FI_DEV_POWER_USAGE | Board power draw, watts |
DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION | Cumulative energy draw, useful for a $/token energy view |
DCGM_FI_DEV_GPU_TEMP | GPU die temperature, °C |
DCGM_FI_DEV_SM_CLOCK | SM clock, MHz (watch for throttling) |
DCGM_FI_DEV_MEM_COPY_UTIL | Memory-copy engine utilization |
DCGM_FI_DEV_ENC_UTIL / _DEC_UTIL | Video encode/decode engine utilization (irrelevant for text LLMs, relevant for multimodal) |
DCGM_FI_DEV_XID_ERRORS | Hardware/driver XID error codes — a leading indicator of a dying GPU |
DCGM_FI_DEV_UNCORRECTABLE_REMAPPED_ROWS | HBM rows remapped after uncorrectable ECC errors — rising count means the GPU is degrading |
DCGM_FI_PROF_GR_ENGINE_ACTIVE | Graphics/compute engine active ratio |
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE | Tensor-core pipe active ratio (real compute intensity) |
DCGM_FI_PROF_DRAM_ACTIVE | Memory-bandwidth active ratio |
DCGM_FI_PROF_PCIE_TX_BYTES / _RX_BYTES | Host-device PCIe transfer rate |
Three subtleties worth internalizing:
DCGM_FI_DEV_GPU_UTILis a liar for LLM decode. It reports “a kernel was scheduled,” which is nearly always true during autoregressive decode even when the GPU is memory-bandwidth-bound and compute-idle. UseDCGM_FI_PROF_PIPE_TENSOR_ACTIVEandDCGM_FI_PROF_DRAM_ACTIVEto see whether you’re compute-bound or bandwidth-bound.- Every DCGM series carries a
gpu(index) and usuallyUUID/modelNamelabel, so on a multi-GPU node you aggregate or break down per device. - The
DCGM_FI_PROF_*(DCP) group is not guaranteed to be present. As covered in “The 2025–2026 landscape,” profiling metrics depend on the DCGM profiling module being available and enabled, and reports of these fields silently missing after a dcgm-exporter upgrade are common enough to be worth a post-upgradecurl | grep PROFcheck rather than an assumption.
Mechanism 3 — Prometheus + Grafana wiring
Prometheus pulls metrics on an interval from targets you list; Grafana queries Prometheus with PromQL to draw panels. For LLM serving you point Prometheus at three kinds of targets: the inference engines, the DCGM exporters, and (optionally) your gateway/load balancer.
A worked, correct scrape config (prometheus.yml):
global:
scrape_interval: 15s # pull every 15s
evaluation_interval: 15s # evaluate alert rules every 15s
rule_files:
- "alerts/llm_serving.yml" # alert rules loaded below
scrape_configs:
# vLLM / TGI inference servers (engine metrics)
- job_name: "vllm"
metrics_path: /metrics
static_configs:
- targets:
- "vllm-0.inference.svc:8000"
- "vllm-1.inference.svc:8000"
labels:
engine: vllm
model: "llama-3-8b-instruct"
# DCGM exporter (one per GPU node), port 9400
- job_name: "dcgm"
static_configs:
- targets:
- "gpu-node-0:9400"
- "gpu-node-1:9400"
# In Kubernetes you'd usually replace static_configs with
# kubernetes_sd_configs + relabeling, or annotate pods with
# prometheus.io/scrape and let the k8s SD discover them.
Shell tip: to sanity-check a target before wiring it up, just
curl -s http://vllm-0:8000/metrics | grep vllm:— the$you see in a prompt is literal.
Saying it out loud. Prometheus pulls, it doesn’t receive: you hand it a list of targets and a scrape interval and it fetches slash-metrics on a schedule. For LLM serving that’s three kinds of target — the inference engines on their API port, the DCGM exporter on 9400 on every GPU node, and your gateway or router. In Kubernetes you’d swap the static target list for service discovery so new replicas get scraped automatically instead of someone editing YAML at 2am. And keep the scrape interval in your head as a real limit: at 15 seconds, anything that spikes and resolves inside 15 seconds is invisible to you, which is why a KV-cache threshold needs headroom rather than sitting at 100%.
PromQL: the queries that earn their keep
These are copy-pasteable against the metric names above. Histogram percentiles
use histogram_quantile over the _bucket series, summed by the le label.
TTFT p95 over the last 5 minutes:
histogram_quantile(
0.95,
sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))
)
Inter-token latency (TPOT) p99:
histogram_quantile(
0.99,
sum by (le) (rate(vllm:time_per_output_token_seconds_bucket[5m]))
)
Output-token throughput (tokens/s), the real work rate:
sum(rate(vllm:generation_tokens_total[1m]))
Request throughput (req/s), broken down by outcome:
sum by (finished_reason) (rate(vllm:request_success_total[1m]))
KV-cache utilization as a percent (remember it’s a 0–1 fraction):
avg(vllm:gpu_cache_usage_perc) * 100
Queue depth (waiting sequences) — your saturation early warning:
sum(vllm:num_requests_waiting)
GPU utilization vs. real tensor activity, per device:
avg by (gpu) (DCGM_FI_DEV_GPU_UTIL)
avg by (gpu) (DCGM_FI_PROF_PIPE_TENSOR_ACTIVE) * 100
GPU memory used percent:
100 * DCGM_FI_DEV_FB_USED
/ (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE)
Cost per 1k tokens — combine a static price with live throughput. With a recording rule holding the GPU hourly price, cost per 1k output tokens is:
[ \text{cost}{1k} = \frac{\text{price}{$/\text{hr}}}{\text{tokens/s} \times 3.6} ]
# price_per_gpu_hour is a constant series you set (e.g. via a recording rule)
(sum(price_per_gpu_hour))
/ (sum(rate(vllm:generation_tokens_total[5m])) * 3.6)
(The 3.6 converts tokens/second into thousands-of-tokens/hour:
( \text{tok/s} \times 3600,\text{s/hr} \div 1000 = \text{tok/s} \times 3.6 ).)
Saying it out loud. The one piece of PromQL I’d want to be able to write on a whiteboard is the percentile: histogram_quantile at 0.95 over a sum-by-le of the rate of the bucket series. And I’d explain every piece — rate because the buckets are counters, sum by le because that’s how you merge histograms from every replica into one distribution, and histogram_quantile applied once at the very end. The mistake that shows up constantly is computing p95 per host and then averaging those p95s. That’s arithmetically invalid — percentiles don’t average — and it usually understates the tail, which is the exact number you’re being paged about.
Grafana
Build one dashboard per concern and template it with a $model /
$engine / $gpu variable so a single dashboard serves every replica:
- Latency row — TTFT p50/p95/p99, TPOT p95, E2E p95/p99 (time-series).
- Throughput row — req/s and tokens/s, with offered-vs-served overlaid.
- Saturation row — waiting sequences, running sequences, KV-cache %, preemption rate. This row is what tells you why latency moved.
- Resource row — GPU util, tensor-active, HBM used %, power, temp, SM clock from DCGM.
- Cost row — $/1k tokens and $/hr per replica.
Always plot percentiles as separate series; never a single “avg latency” line. The next section, “Build it in practice — extended,” turns this row list into an actual importable dashboard sketch and a full alert runbook.
Mechanism 4 — SLIs, SLOs, and alerting
An SLI is a measured signal; an SLO is the target you promise; an alert fires when you’re at risk of missing it. For interactive LLM serving a reasonable starting SLO set:
| SLI | Example SLO |
|---|---|
| TTFT p95 | < 1.5 s over rolling 5 min |
| TPOT p95 | < 80 ms/token |
| E2E availability (non-error rate) | ≥ 99.9% over 30 days |
| Error rate | < 0.1% of requests |
Alert on symptoms users feel (SLO burn) and on leading indicators
(saturation), not on raw resource gauges. A full example rule file
(alerts/llm_serving.yml):
groups:
- name: llm_serving
rules:
# ---- Symptom: TTFT SLO breach ----
- alert: TTFTHighP95
expr: |
histogram_quantile(
0.95,
sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))
) > 1.5
for: 10m
labels:
severity: page
annotations:
summary: "TTFT p95 above 1.5s SLO"
description: "p95 first-token latency is {{ $value | humanizeDuration }} on {{ $labels.model }}."
# ---- Leading indicator: queue building ----
- alert: RequestQueueBuilding
expr: sum(vllm:num_requests_waiting) > 20
for: 5m
labels:
severity: warning
annotations:
summary: "Requests queueing at the engine"
description: "{{ $value }} sequences waiting — scale out or shed load before TTFT breaches."
# ---- Leading indicator: KV cache saturation ----
- alert: KVCacheSaturated
expr: avg(vllm:gpu_cache_usage_perc) * 100 > 90
for: 5m
labels:
severity: warning
annotations:
summary: "KV cache > 90%"
description: "Cache pressure imminent; expect preemptions and TTFT spikes."
# ---- Resource: GPU memory near OOM ----
- alert: GPUMemoryHigh
expr: |
100 * DCGM_FI_DEV_FB_USED
/ (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE) > 95
for: 5m
labels:
severity: warning
annotations:
summary: "GPU {{ $labels.gpu }} HBM > 95%"
# ---- Symptom: error budget burn ----
- alert: HighErrorRate
expr: |
sum(rate(vllm:request_success_total{finished_reason="abort"}[5m]))
/ sum(rate(vllm:request_success_total[5m])) > 0.01
for: 5m
labels:
severity: page
annotations:
summary: "Request abort rate > 1%"
The for: clause suppresses flapping — the condition must hold continuously
before it pages. Pair symptom pages (wake someone up) with leading-indicator
warnings (fix it before it pages). Advanced teams add multi-window
multi-burn-rate error-budget alerts so a fast burn pages immediately and a
slow burn opens a ticket.
Saying it out loud. The framing I’d use is: an SLI is what you measure, an SLO is what you promised, and an alert should fire when the promise is at risk — not when a number merely looks big. So I split alerts into two tiers. Symptom alerts, like TTFT p95 over the SLO or error rate climbing, page a human, because a user is feeling that right now. Leading indicators — queue depth building, KV cache above 90% — open a ticket, because they say a cliff is coming and buy you time to scale out. And everything gets a for-clause so the condition has to hold for several minutes; without that you’re paging on one noisy scrape, which is how you train your on-call to ignore you.
Build it in practice — extended
Mechanism 3 gave you the row layout; this section turns it into something you can actually import and page against: a full panel list with the PromQL wired in, a dashboard JSON sketch, and a runbook for the one alert that most directly encodes the “LLM serving is different” lesson from this chapter — KV cache saturation.
The golden-signals dashboard — full panel list
| # | Row | Panel | Type | Query |
|---|---|---|---|---|
| 1 | Latency | TTFT p50/p95/p99 | Time series | histogram_quantile(0.50/0.95/0.99, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))) |
| 2 | Latency | TPOT p95 | Time series | histogram_quantile(0.95, sum by (le) (rate(vllm:time_per_output_token_seconds_bucket[5m]))) |
| 3 | Latency | E2E p50/p95/p99 | Time series | histogram_quantile(0.50/0.95/0.99, sum by (le) (rate(vllm:e2e_request_latency_seconds_bucket[5m]))) |
| 4 | Throughput | Requests/s by outcome | Stacked time series | sum by (finished_reason) (rate(vllm:request_success_total[1m])) |
| 5 | Throughput | Output tokens/s | Time series | sum(rate(vllm:generation_tokens_total[1m])) |
| 6 | Saturation | Waiting sequences | Time series + threshold line at 20 | sum(vllm:num_requests_waiting) |
| 7 | Saturation | Running sequences | Time series | sum(vllm:num_requests_running) |
| 8 | Saturation | KV-cache utilization % | Time series/gauge + threshold at 90 | avg(vllm:gpu_cache_usage_perc) * 100 |
| 9 | Saturation | Preemptions/s | Time series | sum(rate(vllm:num_preemptions_total[5m])) |
| 10 | Saturation | Prefix-cache hit rate | Time series | sum(rate(vllm:cache_query_hit[5m])) / sum(rate(vllm:cache_query_total[5m])) |
| 11 | Resource | GPU util vs. tensor-active, per GPU | Time series, two series overlaid | avg by (gpu) (DCGM_FI_DEV_GPU_UTIL) and avg by (gpu) (DCGM_FI_PROF_PIPE_TENSOR_ACTIVE) * 100 |
| 12 | Resource | HBM used % | Time series + threshold at 95 | 100 * DCGM_FI_DEV_FB_USED / (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE) |
| 13 | Resource | GPU temp / power | Time series | DCGM_FI_DEV_GPU_TEMP, DCGM_FI_DEV_POWER_USAGE |
| 14 | Cost | $ per 1k tokens | Stat panel | sum(price_per_gpu_hour) / (sum(rate(vllm:generation_tokens_total[5m])) * 3.6) |
Rows 1–5 are RED; rows 6–10 are the LLM-specific USE-saturation row that most generic dashboards omit; rows 11–13 are USE-utilization/errors from DCGM; row 14 is the business translation. That ordering — latency, then throughput, then saturation, then resource, then cost — mirrors how an on-call engineer should actually read the dashboard during an incident: symptom first, cause last.
Dashboard JSON sketch
A trimmed but structurally real Grafana dashboard JSON — enough to see the
templating variables and how a panel’s targets wire to the PromQL above. In
practice you’d have 14 panels (per the table); this sketch shows the pattern
for one panel per row so you can extend it mechanically:
{
"title": "LLM Serving — Golden Signals",
"schemaVersion": 39,
"tags": ["llm", "vllm", "dcgm"],
"templating": {
"list": [
{ "name": "model", "type": "query", "datasource": "Prometheus",
"query": "label_values(vllm:request_success_total, model)" },
{ "name": "engine", "type": "query", "datasource": "Prometheus",
"query": "label_values(vllm:request_success_total, engine)" },
{ "name": "gpu", "type": "query", "datasource": "Prometheus",
"query": "label_values(DCGM_FI_DEV_GPU_UTIL, gpu)" }
]
},
"panels": [
{
"id": 1, "title": "TTFT p50/p95/p99",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{ "expr": "histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket{model=\"$model\"}[5m])))",
"legendFormat": "p95" },
{ "expr": "histogram_quantile(0.50, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket{model=\"$model\"}[5m])))",
"legendFormat": "p50" }
]
},
{
"id": 8, "title": "KV-cache utilization %",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "thresholds": {
"steps": [ { "value": null, "color": "green" },
{ "value": 90, "color": "red" } ] } } },
"targets": [
{ "expr": "avg(vllm:gpu_cache_usage_perc{model=\"$model\"}) * 100",
"legendFormat": "KV cache %" }
]
},
{
"id": 11, "title": "GPU util vs. tensor-active",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"targets": [
{ "expr": "avg by (gpu) (DCGM_FI_DEV_GPU_UTIL{gpu=~\"$gpu\"})",
"legendFormat": "util (gpu {{gpu}})" },
{ "expr": "avg by (gpu) (DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{gpu=~\"$gpu\"}) * 100",
"legendFormat": "tensor-active (gpu {{gpu}})" }
]
},
{
"id": 14, "title": "$/1k tokens",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 8 },
"targets": [
{ "expr": "sum(price_per_gpu_hour) / (sum(rate(vllm:generation_tokens_total{model=\"$model\"}[5m])) * 3.6)" }
]
}
]
}
Remember this is Grafana JSON, not MathJax input — the $model/$engine/
$gpu here are Grafana template-variable interpolations, evaluated by Grafana
before the query ever reaches Prometheus.
Runbook alert: KVCacheSaturated
This is the alert most worth having a written runbook for, because it is the leading indicator specific to LLM serving that generic on-call runbooks don’t cover.
Alert (from Mechanism 4):
avg(vllm:gpu_cache_usage_perc) * 100 > 90
for: 5m
Why 90%, not 100% or 75%? vLLM’s scheduler starts preempting running sequences (or refusing to admit new ones) once it cannot allocate KV blocks for the next scheduling step — it doesn’t wait for the cache to be literally full, because a burst of a few large requests can consume the remaining headroom before the next scrape interval even lands. 90% leaves roughly 10% of blocks — typically enough for one or two more average-sized sequences — as shock absorber against normal traffic variance between your 15-second scrape interval and the 5-minute alerting window. Set the threshold lower (75–80%) if your traffic has high variance in prompt/output length; set it higher only if you’ve measured that your specific workload never bursts past a gap that small.
First three diagnostic steps when this pages:
- Check
num_requests_waitingandrate(num_preemptions_total[5m])in the same window. If both are also rising, this is genuine, ongoing cache pressure — go to remediation. If KV usage is high but queue and preemptions are flat, it may be a temporary hold from a burst that already passed; confirm before paging further. - Check whether
request_prompt_tokens/request_generation_tokensdistributions shifted recently. A new customer, a changed prompt template, or a longer defaultmax_tokensinflates KV usage per request with no change in request count — this is a traffic-mix problem, not a capacity regression, and the fix is different (right-sizemax_model_lenormax_num_seqs, not just “add replicas”). - Check the prefix-cache hit ratio
(
cache_query_hit/cache_query_total). A drop here — often caused by a routing change that stopped sending same-prefix traffic to the same replica — means requests that used to reuse cached KV blocks are now recomputing them from scratch, inflating effective cache usage without any real increase in offered load.
Remediation, roughly in order of speed: shed or defer low-priority batch
traffic first (it’s the cheapest lever); then scale out replicas if the router
supports fast rebalancing; then, if it’s a traffic-mix issue, adjust
max_num_seqs / max_model_len or fix prefix-cache-aware routing rather than
just adding hardware against a problem hardware won’t solve.
Saying it out loud. If a KV-cache alert wakes me up I’m not immediately scaling out — I want to know which of three things it is. First I check whether queue depth and the preemption rate are rising too; if the cache is full but nothing’s queueing or getting evicted, it was a burst that already passed. Second I check whether the prompt and output length distributions moved, because one new customer with much longer prompts inflates cache usage with zero change in request count — that’s a traffic-mix problem and adding replicas is the wrong fix. Third I check the prefix-cache hit ratio, since a routing change that stops sending same-prefix traffic to the same replica makes you recompute KV blocks you used to get for free. And the reason the threshold is 90 rather than 100 is that the scheduler starts preempting the moment it can’t allocate blocks for the next step, so you need a couple of average sequences’ worth of headroom as a shock absorber.
RED and USE, applied to inference
RED — instrument the request surface:
- Rate —
rate(vllm:request_success_total[1m])(req/s). - Errors — the abort / failure ratio shown above.
- Duration — TTFT, TPOT, and E2E histograms. LLM serving splits “Duration” into three because users feel three.
USE — instrument the constrained resource (the GPU and its cache):
- Utilization —
DCGM_FI_DEV_GPU_UTIL, and more honestlyDCGM_FI_PROF_PIPE_TENSOR_ACTIVE/DCGM_FI_PROF_DRAM_ACTIVE; HBM used %. - Saturation —
vllm:num_requests_waiting(queue) andvllm:gpu_cache_usage_perc(KV cache). This is the LLM-specific box. Classic USE saturation is CPU run-queue; here it’s sequences waiting for cache blocks. - Errors — OOM kills, CUDA errors, preemption-driven recompute
(
vllm:num_preemptions_total).
Run both: RED catches the user-facing symptom, USE tells you which resource caused it. The link between them is almost always the saturation row — queue and cache — which is exactly the pair that generic dashboards omit.
Saying it out loud. RED and USE are just two lenses and you want both on the wall. RED — rate, errors, duration — is the request’s point of view, which is what users actually experience. USE — utilization, saturation, errors — is the resource’s point of view, which tells you why. The LLM-specific twist lives in the saturation box: in a classic web service that’s the CPU run queue, but here it’s sequences waiting for KV-cache blocks. That’s exactly the box generic dashboards leave empty, and it’s the one that connects “p95 got worse” to “because the cache filled up.”
Distributed tracing with OpenTelemetry
Metrics tell you that p99 TTFT is bad; a trace tells you where the time
went for one slow request as it crossed gateway → queue → prefill → decode.
vLLM ships OpenTelemetry support: start it with
--otlp-traces-endpoint <collector:4317> and it emits spans with attributes for
queue time, TTFT, and per-request token counts, exported over OTLP to a
collector (Jaeger, Tempo, etc.)
(vLLM OpenTelemetry example).
Propagate a traceparent header from your gateway through to the engine so the
model’s spans nest under the user request. The payoff: for a single tail-latency
request you can see whether the 4 seconds was 3.8 s of queue wait (scale out),
prefill on an 8k-token prompt (input-length problem), or slow decode (batch /
memory-bandwidth problem). Metrics aggregate; traces let you debug one victim.
In practice you sample traces (e.g. 1–5%, plus always-sample on error) to
keep cost and cardinality sane.
Where OTel GenAI semantic conventions fit in. As covered in “The
2025–2026 landscape,” the emerging gen_ai.server.* metric names
(time_to_first_token, time_per_output_token, request.duration) are
designed to be the vendor-neutral equivalent of exactly these vLLM span
attributes — the idea being that a trace exported by vLLM, a trace exported by
a different engine, and a trace exported by a hosted API could all carry the
same attribute names, so one Grafana/Tempo/Jaeger view works regardless of
which engine served the request. Since the conventions are still at
Development stability with no engine having adopted the gen_ai.server.*
names as its native metric names, treat this as the direction things are
heading rather than something to depend on for today’s alerting — keep
alerting on the engine-native names (vllm:..., tgi_...) and treat
gen_ai.* attributes on your trace spans as a bonus cross-vendor label for
now.
Saying it out loud. Metrics tell you that p99 is bad; a trace tells you where the time went for one specific slow request. So for a four-second request, a trace splits it three ways: 3.8 seconds of queue wait means scale out, a huge prefill means somebody sent an eight-thousand-token prompt, and slow decode means a batching or memory-bandwidth problem. Those are three completely different fixes and aggregate metrics can’t tell them apart. The practical requirements are propagating the traceparent header from your gateway into the engine so the engine’s spans nest under the user’s request, and sampling — one to five percent plus always-sample-on-error — because full-rate tracing at scale becomes its own cost and cardinality problem.
Production case studies & war stories
These are composite scenarios — the pattern shows up across enough real on-call postmortems that they’re worth walking through in detail, without attaching them to a specific company. Both are directly traceable to the concepts above.
War story 1 — alert fatigue swallowed the real page
Setup. A team stood up an LLM-serving cluster by cloning their existing web-service alerting pack: disk I/O latency, node network error rate, CPU steal time, per-pod restart count, and about a dozen more — roughly 40 alert rules total, most inherited wholesale and never re-tuned for GPU nodes. Two of them — a disk-latency alert tuned for spinning-disk-era thresholds and a node-network-error alert overly sensitive to normal NIC counter resets — fired several times a week with no real incident behind them.
Timeline.
| Time | Event |
|---|---|
| T+0:00 | A new customer’s traffic mix shifts to much longer prompts; gpu_cache_usage_perc crosses 90% and stays there |
| T+0:02 | KVCacheSaturated and RequestQueueBuilding both fire (correctly) |
| T+0:03 | The same on-call channel also receives the familiar disk-latency and network-error false alarms, as it does most nights |
| T+0:04 | On-call, trained by weeks of noise, acks the whole notification group without reading each one individually and goes back to sleep |
| T+0:45 | User complaints escalate through support; someone finally opens the dashboard and sees TTFT p95 at 9 seconds |
| T+0:52 | Mitigated by scaling out and shedding batch traffic |
Lesson. The KV-cache and queue alerts did their job — they fired within
minutes of the real onset. The failure was organizational: 40 minutes of
degraded service happened after a correct page, purely because the signal
was buried in noise. The fix wasn’t a better KV-cache alert; it was deleting
or fixing the two chronically-noisy generic-infra alerts, splitting severities
so only true symptom-of-SLO-burn alerts page (leading-indicator alerts like
RequestQueueBuilding can reasonably be a ticket, not a page, if a
higher-severity symptom alert also exists), and reviewing “alerts that fired
in the last 30 days with no action taken” on a regular cadence. Alert volume
is itself a metric worth graphing.
Saying it out loud. The lesson here isn’t a missing metric — the KV-cache and queue alerts fired correctly, within two minutes of onset. The failure was that they landed in a channel that also got two chronically noisy inherited alerts most nights, so the on-call acked the whole group without reading it and went back to sleep. Forty minutes of degraded service happened after a correct page. So the fix wasn’t a better alert, it was deleting the noisy ones, splitting severities so only symptom alerts page, and reviewing “alerts that fired in the last thirty days with no action taken” on a regular cadence. Alert volume is itself a metric worth graphing.
War story 2 — the metric that was already there, just not on a dashboard
Setup. A team ran vLLM in production with a Grafana dashboard built early
in the project, before the KV-cache row existed. It had latency percentiles
and DCGM_FI_DEV_GPU_UTIL, which — per this chapter’s warning about that
metric — read a steady ~60% and looked comfortably “healthy.” Nobody had gone
back to add vllm:gpu_cache_usage_perc or vllm:num_requests_waiting once
those became available; the engine was already emitting them, they just
weren’t graphed or alerted on.
Timeline.
| Time | Event |
|---|---|
| T-3:00 | A product change increases average prompt length roughly 3x for a subset of traffic |
| T-3:00 | gpu_cache_usage_perc (ungraphed) climbs past 90% and starts triggering silent preemptions |
| T-2:50 → T-0:10 | TTFT p95 creeps from ~900 ms to ~6 s over roughly three hours, visible only if someone happened to look at the latency panel |
| T-0:10 | Support tickets about “slow responses” reach a volume that triggers a manual investigation |
| T-0:05 | An engineer, following this chapter’s advice, checks gpu_cache_usage_perc for the first time in the incident and finds it pinned at 97% |
| T+0:00 | Root cause identified: cache pressure from the longer prompts, not a GPU or infra problem; mitigated by reducing max_num_seqs and scaling out |
Lesson. This wasn’t a missing-instrumentation problem — vLLM had exported
the KV-cache gauge the entire time. It was a dashboarding-and-alerting
discipline problem: the leading indicator existed but nobody looked at it
until after three hours of silent degradation, because GPU_UTIL “looked
fine” and nobody had wired an alert to the metric that actually predicts an
LLM-serving latency cliff. The postmortem action item was almost embarrassingly
simple — add the KV-cache and queue-depth rows from Mechanism 3/“Build it in
practice” above and wire the KVCacheSaturated alert — which is exactly why
this chapter treats those two metrics as non-optional rather than nice-to-have.
Saying it out loud. This one is the opposite failure and it’s more common than people admit: the metric existed the whole time, the engine was exporting it, nobody had put it on a dashboard. GPU utilization read a comfortable sixty percent so everything looked fine, while KV cache sat at 97% and TTFT crept from about 900 milliseconds to six seconds over three hours. Nobody noticed until support tickets piled up. The remediation was embarrassing in its simplicity — add the KV-cache and queue-depth panels, wire the saturation alert — which is exactly why I treat those two as non-optional rather than nice-to-have.
Failure modes and pitfalls
-
Alerting on averages. A mean latency of 400 ms can hide a p99 of 12 s. Averages hide the tail, and the tail is who churns. Always alert on percentiles from
_bucketseries, and compute them withhistogram_quantileover arate(), never over a raw counter. -
No queue or KV-cache metrics. The single most common LLM-monitoring gap. Without
num_requests_waitingandgpu_cache_usage_percyou get no warning before the latency cliff — GPU util reads high and everything “looks fine” right up until TTFT triples. These are your leading indicators; graph and alert on them. (See “War story 2” above for exactly how expensive this gap gets in practice.) -
Trusting
DCGM_FI_DEV_GPU_UTIL. It says “a kernel ran,” not “the GPU did useful work.” During decode it sits near 100% while the device is memory-bandwidth-bound and compute-starved. Cross-check withPIPE_TENSOR_ACTIVE/DRAM_ACTIVEbefore concluding you’re compute-bound. -
Assuming DCGM profiling metrics are always present. As covered in “The 2025–2026 landscape,”
DCGM_FI_PROF_*fields depend on the DCGM profiling module and have been reported missing after routine dcgm-exporter/GPU Operator upgrades. A panel built onPIPE_TENSOR_ACTIVEthat goes quietly blank after an unrelated infra upgrade is a trap during exactly the incident where you need it — verify with acurl | grep PROFafter any such upgrade. -
Cardinality explosions. Labelling metrics with unbounded values —
request_id, raw prompt text, user IDs, full model paths — multiplies time series until Prometheus OOMs. Keep labels low-cardinality (model,engine,gpu,finished_reason). Push per-request detail into traces/logs, not metric labels. -
Misreading
gpu_cache_usage_perc. It’s a 0–1 fraction despite the_percsuffix. Forgetting the* 100silently makes a “90% full” alert fire at 9000% or never fire at all. -
Percentiles over the wrong window.
histogram_quantileon a[5m]rate is a 5-minute view; too short and it’s noisy, too long and it lags an incident. Match the window to the SLO evaluation period. -
Averaging pre-computed percentiles across replicas. You cannot average p95s. Sum the
_bucketseries across replicas first, then take the quantile. Aggregating already-quantiled numbers gives a wrong answer. -
No cost visibility. If nobody graphs $/1k tokens, efficiency regressions (a bad batch-size change, an underutilized replica) go unnoticed until the cloud bill arrives. Cost is the metric leadership reads; derive it from tokens/s and GPU price.
-
Blind spot between gateway and engine. If you only scrape the engine you miss load-balancer queuing and network time. Measure TTFT at the edge too, and reconcile the two.
-
Alert-pack inheritance without re-tuning. Cloning a generic web-service alert pack onto GPU-serving infrastructure, unchanged, produces exactly the alert-fatigue failure in “War story 1” above — every alert rule you carry over should be re-justified against this workload, not assumed correct because it worked for a REST API.
Saying it out loud. If I had to name the three pitfalls that bite hardest: alerting on averages, having no saturation metrics at all, and trusting GPU utilization. A mean of 400 milliseconds can hide a p99 of twelve seconds, and the tail is who churns. No queue or KV-cache metric means zero warning before the latency cliff — everything looks fine right up until TTFT triples. And GPU util says “a kernel was scheduled,” not “the GPU did useful work”; during decode it sits near 100% while the card is memory-bandwidth-starved. There’s a purely arithmetic one too: you cannot average p95s across replicas, you have to merge the bucket series first and take the quantile once.
Production checklist & interview mastery — what an interviewer probes
Explain the golden signals for LLM serving in 60 seconds
“A REST service has one latency; an LLM server has three: time-to-first-token, which is your prefill and queue cost and drives ‘is it thinking’; inter-token latency, which is your decode cost and drives streaming feel; and end-to-end, which is roughly TTFT plus output-length times inter-token latency. On top of that, the resource that actually constrains an LLM server isn’t CPU or connections, it’s a fixed pool of GPU memory holding the KV cache. So the two signals that predict a latency cliff before users feel it are queue depth — requests waiting to be admitted — and KV-cache utilization — how full that memory pool is. GPU utilization alone is misleading, because during decode it reads high even when the GPU is memory-bandwidth-bound, not compute-bound. So: RED for the request surface — rate, errors, and the three durations — and USE for the resource, where saturation is queue-plus-cache, not CPU run-queue. Alert on SLO burn for the symptoms, and on queue/cache for the leading indicators, so you get paged before the cliff, not after.”
Q&A
- “Which latency metrics for an LLM, and why not just one?” — Name TTFT, TPOT/ITL, and E2E; explain prefill vs decode and that E2E ≈ TTFT + (N−1)·TPOT.
- “How do you know before users do?” — Point to queue depth
(
num_requests_waiting) and KV-cache utilization as leading indicators, not GPU util. - “Show me the PromQL for TTFT p95.” —
histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))), and know why you sum_bucketbylefirst. - “GPU util is 100% — are you compute-bound?” — Not necessarily; decode is
often memory-bandwidth-bound. Cross-check
PIPE_TENSOR_ACTIVE/DRAM_ACTIVE. - “What do you alert on?” — Symptoms (SLO burn on TTFT/error rate) plus
leading indicators (queue, KV cache), with
for:to debounce; ideally multi-burn-rate error budgets. - “How do you avoid a Prometheus cardinality blowup?” — Low-cardinality labels only; per-request detail goes to traces/logs.
- “How do you debug one slow request?” — OpenTelemetry tracing with
propagated
traceparent, sampled, to split queue vs prefill vs decode time. - “What’s your cost metric?” — $/1k tokens derived from GPU $/hr and
rate(generation_tokens_total), tracked per model/replica. - “vLLM’s V1 engine changed some metric names — what, and why should I
care?” — The prefix-cache hit-rate gauge was replaced by
cache_query_hit/cache_query_totalcounters (rate-of-counters composes correctly across replicas and restarts; an averaged gauge doesn’t); speculative-decoding metrics and a prefill/decode time split were added. Caring about this signals you keep dashboards current as engines evolve instead of alerting on names that quietly stopped being populated. - “What’s the difference between
gen_ai.server.*andgen_ai.client.*in the OpenTelemetry GenAI conventions?” —server.*is emitted by the inference server itself (serving-layer TTFT/TPOT/duration);client.*is emitted by the application code calling a model API (token usage, operation duration). Conflating the two produces a dashboard that can’t tell you whether the model server or your service’s use of it is slow. - “Are those conventions something you’d build alerts on today?” — No; as of mid-2026 they’re still Development-stability with no versioned release and no major engine has adopted them as native metric names. Track them as the emerging cross-vendor join key, keep alerting on engine-native names.
- “How would you build one dashboard across vLLM and GPU metrics?” — One
Prometheus scraping both the engine
/metricsand DCGM exporter on 9400, one Grafana dashboard templated on$model/$engine/$gpu, rows ordered latency → throughput → saturation → resource → cost. Bonus: cite thatvllm-project/production-stackships exactly this as a reference. - “Tell me about a monitoring incident and what you changed.” — Use the alert-fatigue or ungraphed-KV-cache story above: a correct alert or a correct metric existed, but noise or dashboard neglect delayed the response; the fix was alert hygiene / dashboard discipline, not new instrumentation.
- “Why might a DCGM profiling metric like
PIPE_TENSOR_ACTIVEbe missing on a node you just upgraded?” — The DCP profiling group depends on the DCGM profiling module being enabled; it isn’t guaranteed on by default the way it was in older exporter versions, and there are dated public reports of exactly this after dcgm-exporter/GPU Operator upgrades. Verify with a/metrics | grep PROFcheck post-upgrade rather than assuming. - “What would you NOT alert on, and why?” — Raw resource gauges without
context (e.g., bare GPU util), anything without a
for:debounce, and anything inherited from a generic web-service pack that hasn’t been re-justified for GPU-serving traffic patterns. - “How do percentiles fail you if you’re not careful?” — Averaging
already-computed p95s across replicas is mathematically wrong; you must
sum
_bucketseries first, then take the quantile. Also: window size trades off noise against incident-detection lag.
System design prompt: “Design the monitoring stack for a new inference platform”
A common senior-level prompt: “We’re launching a new inference platform serving several open-weight models across a few hundred GPUs, multi-tenant, mixing interactive chat and best-effort batch traffic. Design the monitoring stack.” A strong answer walks through layers, not just tool names:
┌───────────────────────────┐
users/apps ───▶ │ Gateway / router │──▶ traces (OTLP, sampled)
│ (traceparent propagate) │
└─────────────┬─────────────┘
│ scraped
┌────────────────────┼─────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ vLLM / TGI │ │ DCGM exporter │ │ Gateway metrics │
│ engines (:8000) │ │ (:9400 / node) │ │ (req/s, 4xx/5xx) │
└────────┬────────┘ └─────────┬─────────┘ └─────────┬────────┘
└──────────────┬──────┴──────────────────────┘
▼
┌──────────────────┐
│ Prometheus │── alert rules (SLO burn + leading indicators)
│ (multi scrape job)│
└────────┬─────────┘
▼
┌──────────────────┐ ┌────────────────────┐
│ Grafana │ │ Alertmanager │
│ $model/$engine/ │ │ severity routing: │
│ $gpu templated │ │ page vs ticket │
└──────────────────┘ └────────────────────┘
Points a strong candidate hits, roughly in order:
- Separate SLIs for interactive vs. batch traffic — they have different
SLOs (batch tolerates queueing; chat doesn’t), so route them to different
alert thresholds or even different metric label values (
priority=interactivevspriority=batch) rather than one blended TTFT p95. - Multi-tenant labeling without cardinality blowup — tenant/customer as a label is tempting and dangerous; either keep it low-cardinality (a handful of tier buckets) or push per-tenant detail to logs/traces instead of Prometheus labels.
- Scrape topology — one Prometheus (or a federated pair for scale) hitting
engine
/metrics, DCGM:9400per GPU node, and the gateway; Kubernetes service discovery over static targets at this scale. - Alert tiering — SLO-burn symptom alerts page; queue/cache/HBM leading-indicator alerts open a ticket unless a symptom alert is also firing, which avoids the alert-fatigue failure mode above.
- Tracing sampling strategy — low fixed-rate sampling plus always-sample-on
-error, propagated
traceparentend to end, because at a few hundred GPUs 100%-sampled traces are a cost and cardinality problem of their own. - Cost as a first-class row, not an afterthought — $/1k tokens per model, visible to more than just the on-call engineer.
- A plan for engine-version drift — new engine versions rename/deprecate metrics (see the vLLM V1 changes above); dashboards need an owner who updates them when that happens, not a “set it and forget it” assumption.
Saying it out loud. For a prompt like this I’d answer in layers rather than naming tools. The gateway emits traces and its own request metrics; engines and DCGM exporters get scraped by one Prometheus, or a federated pair at a few hundred GPUs; Grafana templated on model, engine and GPU; Alertmanager doing severity routing. Then I’d hit the two things that make it a senior answer. Interactive and batch traffic need separate SLIs, because batch tolerates queueing and chat doesn’t, so a blended TTFT p95 tells you nothing. And multi-tenant labeling is a cardinality trap — tenant identity goes into logs and traces, not into a Prometheus label, unless it’s bucketed down to a handful of tiers. I’d also name an owner for metric-name drift, because engines rename metrics between versions and an unowned dashboard quietly rots.
Red flags vs. green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Latency dashboard | Single “avg latency” line | Separate TTFT/TPOT/E2E percentile series |
| Primary GPU signal | Alerts on GPU_UTIL alone | Cross-checks PIPE_TENSOR_ACTIVE/DRAM_ACTIVE before concluding compute-bound |
| Saturation | No queue or KV-cache metric graphed at all | Queue depth and KV-cache % both graphed and alerted |
| Alert design | Every alert pages, no for: debounce | Symptom alerts page, leading indicators ticket, for: on everything |
| Alert provenance | Inherited wholesale from a generic web-service pack | Every rule re-justified for GPU-serving traffic patterns |
| Percentile math | Averages pre-computed p95s across replicas | Sums _bucket series first, then takes the quantile |
| Cost visibility | No $/1k tokens metric anywhere | Cost row on the same dashboard as latency |
| Engine-version hygiene | Dashboard built once, never revisited across engine upgrades | Someone owns re-mapping deprecated/renamed metrics (e.g. vLLM V1 changes) after upgrades |
| Tracing | No propagated traceparent; traces (if any) don’t nest under the user request | traceparent propagated gateway → engine; sampled + always-sample-on-error |
| GenAI semantic conventions | Alerting depends on gen_ai.server.* names today | Treated as an emerging cross-vendor join key, not yet load-bearing |
Further reading
- vLLM — Production Metrics: https://docs.vllm.ai/en/v0.6.1/serving/metrics.html
- vLLM — Metrics design (current): https://docs.vllm.ai/en/latest/design/metrics/
- vLLM — Engine metrics API reference: https://docs.vllm.ai/en/v0.9.2/api/vllm/engine/metrics.html
- vLLM — OpenTelemetry example: https://docs.vllm.ai/en/v0.9.0/examples/online_serving/opentelemetry.html
- vLLM production stack (reference deployment + Grafana dashboards): https://github.com/vllm-project/production-stack
- kubeai — example vLLM Grafana dashboard JSON: https://github.com/kubeai-project/kubeai/blob/main/examples/observability/vllm-grafana-dashboard.json
- TGI — Metrics reference: https://huggingface.co/docs/text-generation-inference/main/en/reference/metrics
- TGI — Community Grafana dashboard (ID 20246): https://grafana.com/grafana/dashboards/20246-text-generation-inference/
- NVIDIA DCGM exporter (GitHub): https://github.com/NVIDIA/dcgm-exporter
- NVIDIA DCGM exporter docs: https://docs.nvidia.com/datacenter/cloud-native/gpu-telemetry/latest/dcgm-exporter.html
- NVIDIA GPU Operator — DCGM profiling metric not enabled (real-world gotcha): https://github.com/NVIDIA/gpu-operator/issues/1397
- NVIDIA dcgm-exporter — 4.x vs. 3.x default metric changes (real-world gotcha): https://github.com/NVIDIA/dcgm-exporter/issues/513
- OpenTelemetry GenAI semantic conventions (dedicated repo): https://github.com/open-telemetry/semantic-conventions-genai
- OpenTelemetry GenAI metrics reference (
gen_ai.server.*/gen_ai.client.*): https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md - OpenTelemetry blog — “Inside the LLM Call: GenAI Observability with OpenTelemetry” (2026): https://opentelemetry.io/blog/2026/genai-observability/
- OpenTelemetry — GenAI semantic conventions migration notice: https://opentelemetry.io/docs/specs/semconv/gen-ai/
- The state of the OpenTelemetry GenAI semantic conventions, July 2026 (version timeline): https://john-hodge.com/blog/opentelemetry-genai-semantic-conventions/
- Prometheus — Querying /
histogram_quantile: https://prometheus.io/docs/prometheus/latest/querying/functions/#histogram_quantile - Prometheus — Alerting rules: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/
- Grafana — The RED Method (Tom Wilkie): https://grafana.com/blog/the-red-method-how-to-instrument-your-services/
- Brendan Gregg — The USE Method: https://www.brendangregg.com/usemethod.html
- Google SRE Workbook — Alerting on SLOs: https://sre.google/workbook/alerting-on-slos/
- OpenTelemetry — Documentation: https://opentelemetry.io/docs/
Topic 9: Model Versioning
What You’ll Learn
This topic teaches you how to:
- Manage multiple model versions
- Implement model registry
- Version models semantically
- Track model metadata
- Enable quick rollbacks
- Support A/B testing
Why Model Versioning?
Benefits
- Reproducibility: Know exactly which model is running
- Rollback: Quickly revert to previous version
- A/B testing: Compare model versions
- Audit trail: Track model changes
- Compliance: Meet regulatory requirements
Challenges
- Storage: Multiple versions take space
- Complexity: Managing versions adds overhead
- Testing: Need to test each version
- Deployment: Coordinate version updates
Versioning Strategies
1. Semantic Versioning
v1.0.0 # Major.Minor.Patch
v1.1.0 # Minor update
v2.0.0 # Major update
2. Git-based
Use Git tags/commits for versions.
3. Timestamp-based
model-2024-01-15-10-30-00
4. Hash-based
Use model hash as version identifier.
Model Registry
What to Store
- Model files: Weights, tokenizer, config
- Metadata: Training date, metrics, dataset
- Code: Training script, preprocessing
- Environment: Dependencies, requirements
Storage Options
- S3/GCS: Object storage
- HuggingFace Hub: Model hosting
- MLflow: Model registry
- Local filesystem: For development
Implementation
Simple File-based Registry
models/
v1.0.0/
model.bin
tokenizer.json
config.json
metadata.json
v1.1.0/
model.bin
tokenizer.json
config.json
metadata.json
Metadata Schema
{
"version": "v1.0.0",
"created_at": "2024-01-15T10:30:00Z",
"model_name": "gpt2",
"training_date": "2024-01-10",
"metrics": {
"accuracy": 0.95,
"latency_ms": 150
},
"dataset": "dataset-v1",
"git_commit": "abc123"
}
Version Management API
List Versions
GET /api/v1/models/versions
Get Version Info
GET /api/v1/models/versions/v1.0.0
Deploy Version
POST /api/v1/models/versions/v1.0.0/deploy
Rollback
POST /api/v1/models/rollback
{
"target_version": "v1.0.0"
}
Integration with Serving
Environment Variable
env:
- name: MODEL_VERSION
value: "v1.0.0"
ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: model-config
data:
model_version: "v1.0.0"
model_path: "/models/v1.0.0"
Dynamic Loading
Load model version at runtime based on config.
Rollback Procedure
Quick Rollback
- Identify current version
- Identify target version
- Update deployment
- Verify health
- Monitor metrics
Automated Rollback
Set up alerts that trigger rollback:
- Error rate spike
- Latency increase
- Quality degradation
A/B Testing Support
Deploy Multiple Versions
# Version A
deployment-a:
model_version: "v1.0.0"
# Version B
deployment-b:
model_version: "v1.1.0"
Compare Metrics
Track metrics per version:
- Performance (latency, throughput)
- Quality (accuracy, user feedback)
- Cost (GPU hours)
Best Practices
- Version everything: Models, code, configs
- Tag releases: Use semantic versioning
- Store metadata: Track training info
- Test before deploy: Validate new versions
- Document changes: Changelog for each version
- Automate: Use CI/CD for versioning
Exercises
- Create registry: Set up model version storage
- Version model: Tag and store model version
- Deploy version: Deploy specific version
- Rollback: Practice rolling back
- A/B test: Compare two versions
Next Steps
- Topic 7: Use versions in canary deployments
- Topic 10: Detect drift per version
- Topic 8: Monitor version performance
Further Reading
Model Versioning & Registry — A Deep Dive
Tracking, promoting, and rolling back model versions in production serving.
Why This Matters
A colleague pings you: “Prod is giving different answers than last week, but nobody
deployed a new model.” You check the model name in the config. It reads
chatbot-llm. Same as always. You are certain nothing changed.
Something changed. Maybe a teammate re-ran the fine-tune and pushed to the same
Hugging Face repo. Maybe the base image bumped transformers from 4.44 to 4.46 and
the tokenizer now splits emoji differently. Maybe latest on your object store now
points at a different set of weights. Maybe the vLLM version changed and the sampling
RNG behaves differently. Each of these silently mutates behavior while the name you
pinned stays identical.
An un-pinned “same” model is not the same model. Reproducibility in LLM serving is not a nice-to-have — it is the difference between “we can explain and roll back this regression in five minutes” and “we have no idea what we are running.” This chapter is about making a served model version an exact, immutable, auditable thing: what it comprises, how a registry tracks and promotes it, how the server resolves “current prod,” and how you roll back when it goes wrong.
Saying it out loud. The line I’d lead with is: an unpinned “same” model is not the same model. Somebody re-runs the fine-tune and pushes to the same repo, or the base image bumps transformers and the tokenizer splits emoji differently, or latest starts pointing at different bytes — and the name in your config never changed, so nothing looks like a deploy. The cost isn’t the regression itself, it’s that you can’t explain it or undo it, because you don’t actually know what you’re running. So the whole goal of versioning is to make a served model an exact, immutable, auditable thing, and the test of whether you’ve got it is simple: can you say, for a request three weeks ago, precisely which bytes answered it.
Core Intuition
Think of a model version the way you think of a container image digest, not a tag.
- A tag (
myapp:latest,chatbot-llm) is a mutable pointer. It can be repointed at any time. It tells you a name, not an identity. - A digest (
sha256:9f86d0...) is content-addressed. It names the bytes. If the bytes change, the digest changes. Two people who pull the same digest get the same thing, forever.
Good model versioning gives you both layers, and keeps them separate:
- Immutable identity — a content hash or an append-only version number that never moves. This is what you record in logs, evals, and audit trails.
- Mutable pointers (aliases/stages) — human-friendly names like
@champion,@production,stagingthat point at an immutable version and can be repointed during a promotion or rollback.
The server should resolve a mutable pointer once, at load time, and then pin the
resolved immutable identity for the life of the process — logging it on every request.
Rollback then becomes “repoint the alias and reload,” and provenance becomes “which
exact version answered request X.”
Saying it out loud. The mental model I use is container image tags versus digests. A tag like latest is a mutable pointer — it’s a name, not an identity, and anyone can repoint it. A digest is content-addressed: it names the bytes, so if the bytes change the digest changes, and two people pulling the same digest get the same thing forever. Good model versioning keeps both layers but keeps them separate — an immutable version id you log and evaluate against, plus human-friendly aliases like @champion that point at one immutable version and can be moved. And the discipline that makes it work is that the server resolves the alias once, at load time, then pins and logs the resolved id for the life of the process — otherwise a promotion splits traffic mid-flight and your logs can’t tell you which version answered.
What Must Be Versioned Together
The single most common LLM-serving mistake is versioning only the weights. A served model is a bundle. Change any component and outputs can move. Pin all of it or you have not pinned anything.
| Component | Why it changes outputs | Failure if unpinned |
|---|---|---|
| Weights (safetensors/GGUF/etc.) | The model itself | Different answers; the obvious one |
Model config (config.json, arch, rope/context, dtype) | Defines how weights are interpreted | Wrong context length, silent truncation |
| Tokenizer (vocab, merges, special tokens, chat template) | Maps text ↔ tokens | Prompt/formatting drift, off-by-one special tokens |
| Generation config (temp, top_p, stop, max_new_tokens defaults) | Shapes sampling | “Same prompt, different vibe” |
| Serving/adapter code (pre/post-processing, prompt template, LoRA merge) | Wraps the model | Prompt template drift is a top silent regression |
| Inference engine + version (vLLM, TGI, TensorRT-LLM, llama.cpp) | Kernels, sampling RNG, quant handling, batching | Numeric drift, different quant results |
| Quantization recipe (AWQ/GPTQ/FP8 params, calibration set) | Alters the effective weights | Quality cliff that “weights hash” alone won’t catch |
Runtime deps (torch, CUDA, transformers, flash-attn) | Kernel/numeric behavior | Reproducibility gaps across hosts |
| Hardware/precision assumptions (GPU arch, bf16 vs fp16) | Numeric results differ | Cross-host non-determinism |
Reproducibility checklist — a version is not reproducible unless you can answer all of:
- Exact weights identified by content hash (not a moving tag)
-
config.json+tokenizer.*+ chat template captured with the weights -
generation_config.json/ default sampling params captured - Inference engine name and exact version recorded
- Quantization recipe + calibration data recorded (if quantized)
- Serving-code commit SHA recorded
- Runtime deps pinned (lockfile / image digest)
- Training/lineage: source run, data snapshot, base model revision
- Eval results linked to this version id
Practical shortcut: bake weights + tokenizer + config + engine into a container image referenced by digest, and register that digest as the version’s artifact. The image digest content-addresses most of the bundle in one shot.
Agentic serving note: once an LLM is wrapped in an agent, the bundle grows again — the system prompt, the tool/function schemas, and the engine version all shape behavior as much as the weights do, and each now has its own release cadence. See The 2025–2026 Landscape below for how modern registries version prompts and tool schemas alongside the model itself.
Saying it out loud. The most common mistake is versioning only the weights. A served model is a bundle: weights, config, tokenizer and chat template, generation defaults, the serving code that wraps it, the inference engine and its version, the quantization recipe, and the runtime dependencies. Change any one of those and the outputs move. The one that catches people most often is the tokenizer and chat template, because it drifts through a dependency bump nobody associates with the model at all. My shortcut is to bake weights plus tokenizer plus config plus engine into a container image and register that image digest as the version’s artifact — one digest content-addresses most of the bundle in a single shot.
Immutable, Content-Addressed Artifacts
An artifact is content-addressed when its identifier is a cryptographic hash of its bytes. Two properties fall out for free:
- Integrity — re-download and re-hash; if it matches, the bytes are intact.
- Deduplication & identity — identical artifacts share an id; different bytes get different ids. You cannot accidentally overwrite version 5 with new content and keep the id.
You can express a version identity as the hash over the ordered set of component digests:
[ \text{version_id} = H\big( H(\text{weights}) ,|, H(\text{tokenizer}) ,|, H(\text{config}) ,|, \text{engine_ver} ,|, \text{code_sha} \big) ]
where ( H ) is a strong hash (SHA-256) and ( | ) is concatenation. If any input byte changes, ( \text{version_id} ) changes. This is exactly how Docker image digests, Git commit SHAs, and safetensors integrity checks work.
Semantic version vs content hash — use both, for different jobs.
- A semantic/registry version (
v3,2.1.0, or MLflow’s auto-incremented integer) is human-ordered: it tells you “newer than v2,” carries release intent, and is what people talk about. It does not guarantee the bytes are unique or unchanged. - A content hash is machine-truth: it guarantees identity but is unordered and unreadable. It is what you log and verify against.
Best practice: assign a monotonic registry version for humans, and record the content hash(es) as immutable metadata/tags on that version. Never reuse a version number for different bytes.
Saying it out loud. Content-addressed just means the identifier is a hash of the bytes, and two useful things fall out for free. Integrity: re-download, re-hash, and if it matches, nothing was corrupted or swapped. And identity: you physically cannot overwrite version five with new content and keep the same id, because different bytes give a different hash. What I’d add is that you want both a content hash and a human version number, because they do different jobs — the integer tells a person “newer than v2” and carries release intent, the hash is machine truth. The rule that ties them together is: never reuse a version number for different bytes.
Artifact Storage
LLM artifacts are big — a 70B model in bf16 is ~140 GB; even a 7B is ~14 GB. Storage choices are shaped by size:
- Object storage (S3, GCS, Azure Blob) is the default backing store. It is cheap, durable, versioned, and content-addressable if you key objects by hash. Registries (MLflow, SageMaker, Vertex) all store metadata in a database and artifacts in object storage.
- Enable object-versioning / immutability (S3 Object Lock, GCS object versioning) so a bucket write cannot silently mutate an existing version’s bytes.
- Deduplicate with content-addressed keys:
s3://models/by-hash/<sha256>; the registry version just references the hash. Identical LoRA adapters, tokenizers, and base weights are stored once. - Mind egress and cold-start. Pulling 140 GB per pod on autoscale is slow and expensive. Common mitigations: node-local caches, a shared read-only volume (EFS/Filestore), pre-warmed images, or a peer-to-peer distributor. The version id must stay stable regardless of where it is cached.
- Git LFS underpins the Hugging Face Hub: each repo is a Git repo, large files go to LFS, and every commit is a content-addressed revision (this is why HF pinning works — more below). Newer chunk-based, content-addressed backends (Hugging Face’s Xet, OCI artifact registries) push the same idea further — see The 2025–2026 Landscape.
Saying it out loud. The thing that shapes storage here is just size — a 70B model in bf16 is roughly 140 gigabytes, and even a 7B is around 14. So object storage is the default backing store, with object versioning or object lock turned on so a write can’t silently mutate an existing version’s bytes, and keys derived from the content hash so identical tokenizers and base weights get stored once. The operational pain isn’t storage cost, it’s cold start: pulling 140 gigabytes onto every new pod during an autoscale event is slow enough to break your scaling behavior. So you add node-local caches, a shared read-only volume, or pre-warmed images — but the version id has to stay identical no matter where the bytes were cached from.
Registries & the Promotion Workflow
A model registry is the source of truth that maps human-facing pointers to immutable versions, records lineage/metadata, and gates promotion. Two pointer models exist; modern registries favor the second:
Stages vs Aliases
- Stages (classic): a version lives in one of
None → Staging → Production → Archived. Exactly one stage per version; transitions move a version between buckets. Simple, but coarse — you get one “Production” slot and rigid semantics. MLflow has deprecated stages in favor of aliases + tags. - Aliases (modern): named, repointable pointers (
@champion,@challenger,@production,@canary) that each point at exactly one version. A version can carry many aliases; you can have@championand@shadowsimultaneously. Aliases decouple “what code loads” from “which version is behind it.” MLflow, Vertex, and (effectively) HF branches all use this model.
Saying it out loud. Stages were the classic model: a version sits in exactly one of None, Staging, Production, or Archived. It’s simple but coarse — you get one production slot. Aliases are the modern answer: named pointers like @champion, @challenger, @shadow, each pointing at exactly one version, and one version can carry several. That matters because in LLM serving the normal case is having a champion, a canary, and a shadow all live at once, which the single-slot model just can’t express. MLflow deprecated stages in favor of aliases for exactly this reason, and Vertex and Hugging Face branches are effectively the same shape.
Gated Promotion Workflow (dev → staging → prod)
A promotion is a pointer move guarded by evidence, not a rebuild:
register (immutable version N, content-hashed)
│
▼
[dev] ──► automated evals + smoke tests ──► set alias @staging → N
│ (metrics logged and LINKED to version N)
▼
[staging] ──► shadow / offline evals / human approval (gate)
│ approver signs off; validation_status=approved
▼
[prod] ──► set alias @champion → N (server reloads / picks up)
│
▼
rollback ──► set alias @champion → N-1 (previous version still intact)
The critical properties:
- Promotion never mutates bytes. It only moves a pointer to an already-registered, immutable version. This is what makes rollback trivial and instant.
- Gates are enforced, not advisory. A version should be blocked from
@championunless eval metrics on that version id pass thresholds and (for prod) an approver signed off. Encode gates in CI/CD, not tribal knowledge. - Approvals are recorded on the version (who, when, against which eval run).
- The previous prod version stays registered and warm-able, so rollback is a pointer move back, not a rebuild-and-redeploy.
Saying it out loud. The one-sentence version is that a promotion is a pointer move guarded by evidence — never a rebuild. You register an immutable, content-hashed version once; you run evals and attach the results to that exact version id; then a gate checks those results and moves the alias. Two properties follow. Rollback is instant, because the previous version was never destroyed and you’re just moving the pointer back. And the gate has to be enforced in CI rather than being tribal knowledge — including failing closed when there are no eval results at all, because a gate that treats “missing data” as “pass” will eventually promote something nobody evaluated.
Fully Worked Example: MLflow Registry Workflow
This is a real, correct MLflow (3.x) workflow: log + register a transformers model with
its tokenizer, link eval metrics, use aliases as gates, promote to @champion, and load
by alias in the server. It also shows how the server resolves “current prod version,”
walks through a full dev→staging→prod pipeline gated by an eval threshold, and ends
with a rollback drill that simulates a bad promotion and recovers from it.
1. Log the full bundle and register a version
import mlflow
from mlflow import MlflowClient
from transformers import AutoModelForCausalLM, AutoTokenizer
mlflow.set_tracking_uri("http://mlflow:5000")
mlflow.set_experiment("chatbot-llm")
MODEL_NAME = "chatbot-llm" # registered model (the "name")
BASE = "meta-llama/Llama-3.1-8B-Instruct"
BASE_REVISION = "0e9e39f" # pin the base model commit (see HF section)
model = AutoModelForCausalLM.from_pretrained(BASE, revision=BASE_REVISION)
tokenizer = AutoTokenizer.from_pretrained(BASE, revision=BASE_REVISION)
with mlflow.start_run() as run:
# Log weights + tokenizer TOGETHER so they can never drift apart,
# and register a new immutable version in one call.
info = mlflow.transformers.log_model(
transformers_model={"model": model, "tokenizer": tokenizer},
name="model",
registered_model_name=MODEL_NAME, # -> creates/append version
# Pin the runtime so the bundle is reproducible:
pip_requirements=[
"transformers==4.44.2",
"torch==2.4.0",
"accelerate==0.33.0",
],
)
# Capture lineage + the exact engine/code we intend to serve with.
mlflow.set_tag("git_sha", "a1b2c3d")
mlflow.set_tag("base_model_revision", BASE_REVISION)
mlflow.set_tag("serving_engine", "vllm==0.6.2")
version = info.registered_model_version # e.g. "7" — immutable, monotonic
print("registered", MODEL_NAME, "version", version)
2. Link eval results to this version, and gate with an alias
client = MlflowClient()
# Run your eval harness against THIS version id, then record results
# ON the version so promotion decisions are auditable.
eval_exact_match = 0.712
eval_toxicity = 0.004
client.set_model_version_tag(MODEL_NAME, version, "eval_exact_match", str(eval_exact_match))
client.set_model_version_tag(MODEL_NAME, version, "eval_toxicity", str(eval_toxicity))
client.set_model_version_tag(MODEL_NAME, version, "validation_status", "pending")
# First gate: expose as challenger for staging/shadow traffic.
client.set_registered_model_alias(MODEL_NAME, "challenger", version)
3. Gated promotion to production
def promote_to_champion(name: str, version: str,
min_em: float = 0.68, max_tox: float = 0.01) -> None:
mv = client.get_model_version(name, version)
em = float(mv.tags.get("eval_exact_match", "0"))
tox = float(mv.tags.get("eval_toxicity", "1"))
if em < min_em or tox > max_tox:
raise RuntimeError(f"gate failed: em={em} tox={tox}")
# (human approval would be checked here too, e.g. an approved tag)
client.set_model_version_tag(name, version, "validation_status", "approved")
# Atomically repoint the production pointer at the new version.
client.set_registered_model_alias(name, "champion", version)
print(f"{name} @champion -> v{version}")
promote_to_champion(MODEL_NAME, version)
4. The server loads BY ALIAS and pins the resolved version
# --- serving process, at startup ---
import mlflow
from mlflow import MlflowClient
MODEL_NAME = "chatbot-llm"
ALIAS = "champion"
client = MlflowClient()
# Resolve the alias ONCE to an immutable version + source, and pin it.
mv = client.get_model_version_by_alias(MODEL_NAME, ALIAS)
RESOLVED_VERSION = mv.version # e.g. "7"
RESOLVED_SOURCE = mv.source # artifact URI / storage location
RESOLVED_RUN = mv.run_id
print(f"serving {MODEL_NAME} @{ALIAS} = v{RESOLVED_VERSION} (run {RESOLVED_RUN})")
# Load the exact bundle (weights + tokenizer). Loading by @alias is convenient,
# but we resolved+logged the concrete version above so every request is auditable.
pipeline = mlflow.transformers.load_model(f"models:/{MODEL_NAME}@{ALIAS}")
def handle(request_text: str) -> dict:
out = pipeline(request_text)
# Stamp the immutable version on every response/log line.
return {"model": MODEL_NAME, "version": RESOLVED_VERSION, "output": out}
How the server resolves “current prod version”: it asks the registry for the version
behind the @champion alias (get_model_version_by_alias) at load time, records the
returned immutable version id, and serves that. It does not re-resolve per request —
otherwise a mid-flight promotion would split traffic across versions unpredictably.
Instead, a promotion signals a controlled reload (rolling restart, or a
watch-and-drain), and until then the process keeps serving its pinned version and logs
it on every request.
5. Rollback is a pointer move
# Something regressed in v7. Point production back at the known-good v6.
client.set_registered_model_alias(MODEL_NAME, "champion", "6")
# Trigger the servers to reload (rolling restart). v7 stays registered for forensics.
Load-by-version equivalent (fully pinned, no alias indirection):
mlflow.transformers.load_model("models:/chatbot-llm/7"). Use aliases for operability; use explicit versions when you want the config file itself to be the pin.
Saying it out loud. Rolling back is the part people get wrong in interviews, so I’d be concrete: it’s one API call that repoints the alias to the last known-good version, plus whatever makes the servers actually reload — usually a rolling restart. The bad version stays registered, which you want, because you need it for forensics. And the pointer move is the easy half; the SLO you should quote is repoint plus drain plus serving the previous version in a few minutes with zero rebuild. If reverting requires re-running training, re-quantizing, or rebuilding an image, you don’t have rollback — you have a second forward deploy, and it’ll take hours.
6. Build it in practice, extended — a full promotion pipeline
The three-step gate above (challenger → check tags → champion) is correct but
minimal. A real pipeline runs unattended in CI, evaluates every candidate the same
way, keeps an explicit record of “what was champion before,” and refuses to promote on
missing or stale data. Here is a complete, runnable gate function that drives a
candidate through dev → staging → prod, each hop guarded by a score threshold read
from the version’s own tags — never from a human’s memory:
from dataclasses import dataclass
from mlflow import MlflowClient
from mlflow.exceptions import MlflowException
client = MlflowClient()
MODEL_NAME = "chatbot-llm"
@dataclass
class Gate:
alias: str # alias this stage promotes TO on success
min_em: float # exact-match threshold
max_tox: float # toxicity ceiling
require_approval: bool = False # prod requires a human tag, staging doesn't
PIPELINE = [
Gate(alias="staging", min_em=0.60, max_tox=0.02, require_approval=False),
Gate(alias="champion", min_em=0.68, max_tox=0.01, require_approval=True),
]
def run_eval_harness(version: str) -> dict:
"""Stub: run your real eval suite against THIS registered version's
artifact (never against a local checkpoint that might differ) and
return metrics. In production this loads models:/{name}/{version}."""
... # pretend this returns freshly computed numbers
return {"exact_match": 0.712, "toxicity": 0.004}
def record_eval(name: str, version: str, metrics: dict) -> None:
for k, v in metrics.items():
client.set_model_version_tag(name, version, f"eval_{k}", str(v))
client.set_model_version_tag(name, version, "eval_ts", str(mlflow.utils.time.get_current_time_millis()))
def gate_passes(name: str, version: str, gate: Gate) -> tuple[bool, str]:
mv = client.get_model_version(name, version)
tags = mv.tags
if "eval_exact_match" not in tags or "eval_toxicity" not in tags:
return False, "no eval recorded on this version — refusing to promote blind"
em = float(tags["eval_exact_match"])
tox = float(tags["eval_toxicity"])
if em < gate.min_em:
return False, f"exact_match {em:.3f} < required {gate.min_em}"
if tox > gate.max_tox:
return False, f"toxicity {tox:.3f} > allowed {gate.max_tox}"
if gate.require_approval and tags.get("approved_by") is None:
return False, "prod promotion requires a human 'approved_by' tag"
return True, "ok"
def promote_through_pipeline(name: str, version: str) -> None:
"""Runs a candidate through every gate in order. Before EACH promotion
to an alias, snapshot the alias's CURRENT target as 'last_known_good'
so a rollback never has to guess what was there before."""
for gate in PIPELINE:
ok, reason = gate_passes(name, version, gate)
if not ok:
raise RuntimeError(f"blocked at @{gate.alias}: {reason}")
# Snapshot the outgoing version for this alias, if one exists,
# so rollback is a single lookup instead of an archaeology dig.
try:
previous = client.get_model_version_by_alias(name, gate.alias)
client.set_model_version_tag(
name, version, f"previous_{gate.alias}", previous.version
)
except MlflowException:
pass # first-ever promotion to this alias — nothing to snapshot
client.set_registered_model_alias(name, gate.alias, version)
print(f"{name} @{gate.alias} -> v{version} ({reason})")
# --- usage ---
metrics = run_eval_harness(version)
record_eval(MODEL_NAME, version, metrics)
client.set_model_version_tag(MODEL_NAME, version, "approved_by", "ml-lead@company.com")
promote_through_pipeline(MODEL_NAME, version)
Two details do the real work here:
gate_passesrefuses to promote a version with no eval tags at all, rather than treating “missing” as “pass.” A pipeline that fails open on missing data will eventually promote something nobody ever evaluated.previous_<alias>is written on the incoming version, at promotion time, not read from history after the fact. That single tag is what turns the rollback drill below from “grep the audit log” into “read one tag.”
Saying it out loud. The detail worth stealing from this pipeline is writing a previous_champion tag onto the incoming version at promotion time, rather than reconstructing history afterwards. That turns rollback from “grep the audit log and hope” into “read one tag,” which matters at three in the morning when the person on call didn’t do the promotion. The other half is the gate refusing to promote a version that has no eval tags at all. Failing open on missing data is the quiet killer — the pipeline looks like it’s protecting you right up until the eval job silently didn’t run.
7. Rollback drill — simulate a bad promotion and recover by alias
Run this as an actual drill (quarterly, or after onboarding a new on-call) so the first time your team executes a rollback is not during a real incident.
# --- STEP 0: baseline. v6 is a known-good champion. ---
client.set_registered_model_alias(MODEL_NAME, "champion", "6")
# --- STEP 1: a bad promotion. v7 passed the automated gate (its eval
# harness ran on a stale eval set that didn't catch a regression) and
# got promoted. This is the failure mode gates alone don't fully close —
# which is why 'previous_champion' bookkeeping and monitoring both matter.
promote_through_pipeline(MODEL_NAME, "7") # @champion now -> v7
assert client.get_model_version_by_alias(MODEL_NAME, "champion").version == "7"
# --- STEP 2: detection. Online monitoring (thumbs-down rate, error rate,
# a canary eval re-run against live traffic) keyed by the version_id
# stamped on each request shows v7's quality is worse than v6's.
# This is the payoff of "stamp the version on every response" from
# Step 4 above: the alert can say "v7 regressed" instead of "prod is bad."
# --- STEP 3: rollback. Read the snapshot this SAME promotion wrote,
# don't rely on memory of what was running an hour ago.
def rollback(name: str, alias: str, bad_version: str) -> str:
mv = client.get_model_version(name, bad_version)
previous = mv.tags.get(f"previous_{alias}")
if previous is None:
raise RuntimeError(
f"no previous_{alias} tag on v{bad_version} — cannot auto-rollback; "
"fall back to the registry's version history for this model"
)
client.set_registered_model_alias(name, alias, previous)
client.set_model_version_tag(name, bad_version, "validation_status", "rolled_back")
return previous
restored = rollback(MODEL_NAME, "champion", "7")
assert restored == "6"
assert client.get_model_version_by_alias(MODEL_NAME, "champion").version == "6"
print(f"rolled back: @champion -> v{restored} (v7 kept registered for forensics)")
# --- STEP 4: verify the resolved artifact, not just the pointer. ---
mv = client.get_model_version_by_alias(MODEL_NAME, "champion")
print(f"confirmed serving v{mv.version} from {mv.source}")
# In a real drill, also re-hash the artifact at mv.source and compare it
# against the content hash recorded when v6 was first registered — a
# pointer move is only a real rollback if the bytes behind it are the
# bytes you think they are.
What the drill is meant to prove, and what to time when you run it for real:
- Time-to-detect — how long between the bad promotion and monitoring flagging
v7by version id (not “prod feels off”). - Time-to-rollback — how long from “roll back” decided to
@championpointing atv6again and servers actually serving it (this is the pointer move plus the reload, not just the API call). - No rebuild anywhere in the path. If any step in your real rollback requires re-running training, re-quantizing, or rebuilding an image, the drill has found a gap — close it before you need it under pressure.
Saying it out loud. The reason I’d run this as a real drill, quarterly, is that a rollback path you’ve never executed is a hypothesis. There are three numbers to time: time to detect — how long until monitoring names the bad version by id, not just “prod feels off”; time to roll back — from decision to servers actually serving the old version, which is the pointer move plus the reload; and whether any step in the path requires a rebuild. That last one is pass/fail. And the drill only counts if you verify the bytes behind the restored pointer, because a pointer move is only a rollback if what’s behind it is what you think it is.
Registry Comparison
| Feature | MLflow Model Registry | SageMaker Model Registry | Vertex AI Model Registry | Hugging Face Hub |
|---|---|---|---|---|
| Version unit | Registered model + integer version | Model Package Group + Model Package | Model resource + version id | Git repo + commit (revision) |
| Pointer mechanism | Aliases + tags (stages deprecated) | Approval status (Pending/Approved/Rejected) | Version aliases (incl. default) | Branches / tags / commit SHA |
| Immutable id | Version number + logged artifact hash | Model Package ARN | Version id (immutable) | Commit hash (content-addressed via Git/LFS) |
| Gated promotion | Alias move + tag gates in CI | Approval status flip (EventBridge-triggerable) | Alias reassignment | PR/branch merge; manual convention |
| Artifact store | Pluggable (S3/GCS/Azure/local) | S3 (+ ECR image) | Google Cloud Storage | Git LFS on the Hub |
| Lineage/metadata | Runs, params, metrics, tags | Metrics, data lineage, source pipeline | Metadata, eval, dataset links | Model card (README.md + YAML) |
| Approval/audit | Tags + external gate | Native approval workflow + audit | IAM + Cloud Audit Logs | Repo history / commits |
| Best when | Open-source, self-hosted MLOps | Deep AWS + Pipelines/EventBridge | Deep GCP + Vertex Endpoints | Public/OSS models, git-native pinning |
Key nuances:
- MLflow deprecated the
None/Staging/Production/Archivedstages in favor of named aliases + tags — repoint an alias to promote or roll back. Load withmodels:/<name>@<alias>ormodels:/<name>/<version>. - SageMaker organizes versions under a Model Package Group; promotion is a flip
of approval status (
PendingManualApproval → Approved), which can trigger downstream deploys via EventBridge. TheModelPackageArnis the immutable handle. - Vertex AI keeps versions under one model resource; aliases (e.g. the built-in
default) point at versions. Referencemodel@defaultor a version id; update the alias target to promote without touching client code. - Hugging Face Hub is git-native: every push is a commit, and
revision=onfrom_pretrainedpins a commit hash, branch, or tag. The commit hash is a true content address; a baremainis a mutable pointer — never rely on it in prod.
Saying it out loud. If someone asks me to pick a registry I’d say the mechanics are the same everywhere and the choice follows your cloud. MLflow gives you aliases plus tags with a pluggable artifact store — the right default if you’re self-hosting. SageMaker models it as approval status on a model package, which is nice because flipping to Approved can trigger a downstream deploy through EventBridge. Vertex uses version aliases including a built-in default. And Hugging Face is git-native, where the commit hash is a genuine content address. The trap is the same across all four: whatever the tool calls its mutable pointer — main, latest, default — never let production resolve it at runtime.
The 2025–2026 Landscape
The mechanics above (content hash + alias + gate) are not just theory — they are exactly what production registries converged on through 2025 and into 2026. Four concrete developments are worth knowing cold, with real, checkable sources.
Hugging Face Hub: revision is the reproducibility contract
Every Hugging Face Hub repo is a Git repo; every push is a commit, and every commit has
a hash. AutoModel.from_pretrained(repo_id, revision=...) accepts that hash, a tag, or
a branch name — but only the commit hash is a true content address. Passing main
(the default when revision is omitted) resolves to whatever main currently points
at, which is exactly the mutable-tag failure mode this chapter opened with. The
Transformers maintainers spell this out directly on the forum thread discussing
commit_hash in from_pretrained: the resolved commit hash is threaded through the
loading code specifically so a cached load and a fresh load agree on which files they
mean, even if main has since moved (Hugging Face Forums, “Purpose of commit_hash in
PreTrainedModel.from_pretrained”). Baseten’s engineering blog makes the operational case
explicit: pin revision to an exact commit whenever you use trust_remote_code (which
executes arbitrary code from the repo) or need reproducible eval numbers, because an
upstream maintainer can push backwards-incompatible or malicious changes to main
without you doing anything at all (“Pinning ML model revisions for compatibility and
security,” baseten.co, 2024–2025).
Underneath this, Hugging Face has been replacing plain Git LFS with Xet, a
content-defined-chunking (CDC) storage backend: files are split into ~64 KB
variable-length chunks by a rolling hash (so an edit only invalidates the chunks that
actually changed, unlike LFS’s whole-file versioning), and chunks are stored in a
content-addressed store (CAS) keyed by their own hash. The result is deduplication
across repositories, not just across commits of one repo, and a stronger
content-addressing guarantee at the chunk level, not just the file level (Hugging Face,
“Xet Chunk-Level Deduplication Specification” and the “From Chunks to Blocks” engineering
blog post). For versioning purposes the practical upshot is the same lesson, reinforced
at finer grain: identity is a hash of bytes, and main/latest is not that.
Saying it out loud. For Hugging Face specifically the one thing to know is the revision argument. from_pretrained takes a commit hash, a tag, or a branch, and only the commit hash is a real content address — omit it and you get main, which resolves to whatever main points at right now. That’s the mutable-pointer failure mode, but with an extra edge: if you’re using trust_remote_code you’re executing arbitrary code from that repo, so an unpinned revision is a supply-chain exposure, not just a reproducibility one. Underneath, Hugging Face has been moving from Git LFS to Xet, which chunks files with a rolling hash so an edit only invalidates the chunks that changed and dedup works across repos, not just across commits. Same lesson at finer grain: identity is a hash of bytes.
MLflow’s Prompt Registry: versioning the other half of an agent
As of MLflow 3.x (the docs tree covers versions up to 3.15.0, released July 31, 2026), MLflow ships a dedicated Prompt Registry alongside the Model Registry, because in an agentic system the prompt is a first-class artifact that changes on its own schedule. It uses the same mental model this chapter has been building for models: prompt versions are immutable once created (Git-inspired, commit-message-per-version), and named aliases move between them for promotion and rollback:
import mlflow
# Register a new, immutable prompt version with a commit message.
mlflow.genai.register_prompt(
name="agent-system-prompt",
template="You are a support agent for {{product}}. Be concise...",
commit_message="Tighten tone, add refund-policy clause",
)
# Promote it the same way you promote a model: repoint an alias.
mlflow.genai.set_prompt_alias("agent-system-prompt", alias="production", version=5)
# The server resolves the alias once, same discipline as @champion above.
prompt = mlflow.genai.load_prompt("prompts:/agent-system-prompt@production")
A reserved @latest alias always resolves to the newest version for convenience in
dev, but production code should pin @production (or an explicit version) for the same
reason it should never load a model by latest (MLflow docs, “Prompt Registry” and
“Manage Prompt Lifecycles with Aliases,” mlflow.org/docs/latest/genai/prompt-registry/).
MLflow additionally lets you log the generation parameters (model name, temperature,
max_tokens) alongside the prompt version, so a prompt version and the sampling config it
was tuned against travel together — closing exactly the “generation config” gap flagged
in the versioning-together table earlier in this chapter.
Saying it out loud. The point of a prompt registry is that in an agentic system the prompt is a first-class artifact that changes on its own schedule — usually faster than the weights do. MLflow 3 ships one, and it deliberately uses the same mental model as the model registry: each prompt version is immutable once created, with a commit message, and named aliases move between versions for promotion and rollback. There’s a reserved @latest alias for convenience in dev, and you should no more ship that to production than you’d ship a model by latest. The nice extra is that MLflow lets you log the generation parameters next to the prompt version, so a prompt and the temperature it was tuned against travel together.
Content-addressed, OCI-packaged models
A second, independent trend treats a model bundle as an OCI artifact — the same
content-addressed, layered, digest-referenced format container images use — rather than
inventing a bespoke model format. The CNCF’s ModelPack project
(github.com/modelpack/model-spec) defines an open standard for packaging weights,
tokenizer, and config as OCI layers so a model can be pulled, cached, and run with the
same tooling (registries, signing, Kubernetes volume sources) already built for
containers; modctl (github.com/modelpack/modctl) is its reference CLI for
building and pushing these artifacts. The CNCF’s own August 2025 write-up frames the
motivation plainly: “Models can be versioned, distributed, and tracked like container
images,” gaining OCI’s existing digest-based integrity guarantees and sigstore-based
signing for free instead of re-deriving them per-vendor (“How OCI Artifacts will drive
future AI use cases,” cncf.io, August 27, 2025). VMware’s Broadcom team has since
documented running Harbor — an existing OCI-compliant container registry — as an AI
model registry on top of this, i.e., production teams are already reusing container
infrastructure for model versioning rather than standing up a separate system
(“Using Harbor as an AI Model Registry,” blogs.vmware.com, March 2026).
Saying it out loud. The bet here is that instead of inventing a bespoke model format, you package a model bundle as an OCI artifact — the same content-addressed, layered, digest-referenced format container images already use. You inherit the whole ecosystem for free: digest-based integrity, sigstore signing, existing registries, Kubernetes volume sources, multi-arch manifests. CNCF’s ModelPack spec and its modctl CLI are the standardization effort, and teams are already running Harbor as a model registry on top of it. The tradeoff to name is that you give up ML-specific metadata — run linkage, eval results, lineage graphs — that a purpose-built registry like MLflow hands you natively, unless you layer it back on with tags.
Versioning the whole agentic bundle
Put the three developments together and a pattern falls out for agentic serving: an agent’s observable behavior is now a function of (at least) the model version, the prompt version, the tool/function-schema version, and the engine version — each independently versioned in 2025–2026 tooling, each capable of drifting on its own. The practical fix is the same content-hash-of-components idea from earlier in this chapter, applied one level up — an explicit bundle manifest that a promotion pipeline treats as a single unit to gate and roll back together:
# agent_bundle_manifest.yaml — versions the WHOLE agent, not just the model.
# Hash this file's canonical form and register THAT as the agent's version id.
agent_bundle_version: "support-agent-2026.07.2"
model:
registry: mlflow
name: chatbot-llm
version: 12
content_hash: "sha256:9f86d0..."
prompt:
registry: mlflow-prompts
name: agent-system-prompt
version: 5
alias: production
tools:
schema_hash: "sha256:1a2b3c..." # hash over the tool/function-calling schema set
count: 7
engine:
name: vllm
version: "0.11.2"
runtime:
image_digest: "sha256:7c4a8d..."
Register this manifest itself as a version (an MLflow run’s params/tags, or a dedicated “agent” entry in whatever registry you use), gate promotion on the manifest as a whole, and roll back by repointing the manifest’s alias — not by repointing the model, prompt, and tool schema independently and hoping they land on compatible versions at the same time.
Saying it out loud. Once you wrap a model in an agent, behavior is a function of at least four independently versioned things: the weights, the system prompt, the tool schemas, and the engine. Each one has its own release cadence and each can drift alone. So the fix is the same content-hash-of-components idea, applied one level up — write an explicit bundle manifest that names the model version, the prompt version, the tool-schema hash, the engine version and the runtime image digest, then hash that manifest and register it as the agent’s version. You gate and roll back the manifest as one unit. The failure to avoid is repointing the model alias and the prompt alias in two separate uncoordinated steps and landing on a combination nobody ever evaluated.
Reproducibility & Rollback Mechanics
Reproducibility means: given a version id, you can reconstruct the exact serving behavior on a fresh host. Mechanically:
- Resolve the version id → immutable artifact reference (hash/ARN/commit).
- Fetch bytes from object storage; re-hash and verify against the recorded digest.
- Load with the pinned engine + pinned runtime (image digest or lockfile).
- Apply the captured generation/serving config (not the engine defaults).
- Re-run the version’s eval suite; confirm metrics match the recorded numbers within tolerance. If they don’t, something in the bundle was not actually pinned.
Rollback works because the previous version was never destroyed and the pointer is cheap to move:
- Registry-level: repoint
@champion(MLflow), flip approval / redeploy previousModelPackageArn(SageMaker), reassigndefaultalias (Vertex), or pin the prior commit (HF). O(1) metadata operation. - Serving-level: the server must actually reload. Options: rolling restart of pods, a sidecar that watches the alias and drains+reloads, or blue/green where the old version’s replicas are kept warm until the new one is confirmed. Keeping N-1 warm turns rollback into a traffic shift measured in seconds.
- Forensics: because every request logged its immutable version id, you can bound the blast radius exactly — “requests between 14:03 and 14:31 hit v7” — and attach that to the incident.
Rollback SLO worth stating out loud: repoint + drain + serve previous version in under a few minutes, with zero rebuild. If your rollback requires re-running a training or build pipeline, you do not have rollback; you have a second forward deploy.
Saying it out loud. Reproducibility here means: give me a version id and I can reconstruct the exact serving behavior on a fresh host. Mechanically that’s resolve the id to an immutable artifact reference, fetch the bytes and re-hash them against the recorded digest, load with the pinned engine and pinned runtime, apply the captured generation config rather than engine defaults, then re-run that version’s eval suite and confirm the numbers match. That last step is the real test — if the evals come out different, something in the bundle wasn’t actually pinned, and you’ve just found which layer you’re missing. Rollback is the same machinery run backwards, and it’s cheap only because the old version was never destroyed.
A/B and Shadow of Versions
Aliases make multi-version serving natural because several pointers can coexist:
- A/B (canary): split live traffic across
@championand@challenger. Users see responses from both; you compare online metrics (latency, thumbs-up, task success) by the version id stamped on each request. Promote the winner by repointing@champion. (See the Canary Deployments chapter for traffic-splitting mechanics.) - Shadow (mirror): send a copy of production traffic to
@shadow(the candidate) but do not return its output to the user. You capture the candidate’s responses and latency for offline comparison with zero user risk — ideal for validating an engine upgrade or a re-quantized version before it ever touches a user.
Both require the same discipline: the response/log record must carry the exact version that produced it, or the comparison is meaningless. Shadow is the safest way to catch tokenizer/engine drift before promotion, because you diff the candidate against prod on identical inputs.
Saying it out loud. A/B and shadow are answering different questions and I’d keep them separate. A/B, or canary, splits real traffic between champion and challenger, so users see both and you compare online metrics — latency, thumbs-up rate, task success — sliced by the version id stamped on each request. Shadow mirrors a copy of production traffic to the candidate but never returns its output, so you get real-input comparison at zero user risk. Shadow is what I’d reach for before an engine upgrade or a re-quantization, because those break in ways offline evals miss. Both are worthless without one discipline: every log record carries the exact version that produced it, or the comparison means nothing.
Model Cards & Lineage
A model card is the human-readable documentation of a version: intended use, training
data, eval results, limitations, biases, and license (Mitchell et al., 2019, Model
Cards for Model Reporting). On the Hugging Face Hub the card is the repo’s README.md
with a YAML metadata header; it renders on the model page and is machine-parsable.
Lineage is the machine-readable provenance graph: this version came from this training run, on this data snapshot, from this base-model revision, built by this pipeline commit. Registries capture lineage as run links (MLflow), source pipeline references (SageMaker), or metadata edges (Vertex).
Why both matter for serving: when an incident, a compliance request, or a “which-data-did-this-see” question lands, the card answers what and why, and lineage answers from what. A version with neither is unauditable — you cannot prove what it is or where it came from, which is exactly the state the opening anecdote describes.
Saying it out loud. A model card is the human-readable half — intended use, training data, evals, limitations, license — and lineage is the machine-readable half: this version came from this training run, on this data snapshot, from this base-model revision. You need both, and you need them for a boring reason: when an incident or a compliance request lands, the card answers what and why, and lineage answers from what. A version with neither is unauditable — you can’t prove what it is or where it came from, which is exactly the situation this chapter opened with.
Production Case Studies & War Stories
The failure modes in this chapter are not hypothetical. Here are three, in increasing order of “the version number was technically correct the whole time.”
War story 1 — a tokenizer auto-upgrade silently shifted a special token id
In April 2026, users of Kimi K2.5’s multimodal inference in vLLM hit a hard crash:
AssertionError: Failed to apply prompt replacement for mm_items['vision_chunk'][0]
(vLLM issue #39261). Nothing about the registered model version had changed. The root
cause: the model’s config.json had been written assuming a slow, TikToken-style
tokenizer, with a hardcoded media_placeholder_token_id = 163605. When transformers
v5 loaded the same repo, it auto-converted the tokenizer to its faster backend — and
that backend compacts gaps in the special-token id space, which shifted the actual
<|media_pad|> token from id 163605 to id 163602. Token 163605 now decoded to
[UNK]. vLLM went looking for a token that, at runtime, no longer existed at that id.
The model weights were unchanged. The registered version number was unchanged. What changed was a loader-level auto-migration one layer below the version the team thought they had pinned. The lesson generalizes directly from this chapter’s bundle table: pinning “the tokenizer files” is not the same as pinning “tokenizer behavior.” A startup self-check that encodes a fixed probe string (including every special token) and compares the resulting ids against a recorded golden fingerprint would have caught this in a health check, not in production traffic.
Saying it out loud. This is my favorite example because nothing anyone would call “the model” changed. A newer transformers auto-converted the tokenizer to its fast backend, that backend compacts gaps in the special-token id space, and a token the config had hardcoded by numeric id moved by three. The engine went looking for a token that no longer existed at that id and hard-crashed. Weights unchanged, registered version unchanged; a loader-level auto-migration one layer below what the team thought they’d pinned. The generalizable lesson: pinning the tokenizer files is not the same as pinning tokenizer behavior — so encode a fixed probe string, record the resulting token ids as a golden fingerprint, and check it at startup.
War story 2 — an unrelated dependency bump broke chat formatting
In December 2025, a team deploying DeepSeek-V3.2 on vLLM 0.11.2 hit a ValueError at
request time, not at startup: "As of transformers v4.44, default chat template is no longer allowed, so you must provide a chat template if the tokenizer does not define one" (vLLM issue #29849). The server had started cleanly; the model loaded; the crash
only surfaced the moment a real chat-formatted request arrived, because the model had
been relying on an implicit default chat template that a transformers version bump
in the base image simply removed. Nothing in the registered model version changed —
the break was purely in an unpinned runtime dependency, exactly the “Runtime deps” row
this chapter’s versioning-together table calls out. The practical guard this incident
argues for: a deploy-time canary that sends one real, chat-formatted request through
the new pod before it takes production traffic, so a broken template fails the
rollout gate instead of a live user’s request.
Saying it out loud. The nasty part of this one is the timing: the server started cleanly and the model loaded fine, and the failure only appeared when the first real chat-formatted request arrived. A transformers bump in the base image removed an implicit default chat template the model had been relying on. Nothing in the registered version changed — it was purely an unpinned runtime dependency, which is why the versioning-together table has a row for runtime deps. The guard it argues for is a deploy-time canary: push one real, fully formatted request through the new pod before it takes production traffic, so a broken template fails the rollout gate instead of a live user’s request.
War story 3 — the mutable latest tag (a composite, illustrative pattern)
This one is presented deliberately as a pattern, not a single sourced incident,
because it is the single most commonly reported shape of this failure across teams and
is worth naming precisely: a serving config points at chatbot-llm:latest (or an S3
prefix like s3://models/chatbot-llm/latest/, or an HF main branch with no
revision=). A teammate — in a different repo, a different team, a different
timezone — pushes a retrain, a quick fix, or even just a README.md metadata update
that happens to also touch the pointer. latest now resolves to different bytes. No
deploy fired. No CI ran. The next pod restart (an autoscale event, a node drain, a
routine rolling update — not even the same team’s action) picks up the new bytes and
starts serving different behavior under a config line that has not changed in months.
The only way to notice is behavioral: a metric drifts, a user complains, or an eval
canary re-run against a fixed prompt set produces a different fingerprint than
yesterday. This is precisely why this chapter insists a served pointer be resolved
once, at load, to an immutable id that gets logged — with that discipline, this
class of incident becomes “check the resolved version id in the logs,” not “trace
through everyone’s recent commits.”
Common thread across all three: in every case the thing a human would call “the model” — the name, the registered version, the config line — stayed put. The behavior changed because something the bundle table lists as a separate row moved underneath it. That is the whole argument for versioning the bundle, not the weights.
Saying it out loud. This one is a pattern rather than a single incident, and it’s the most common shape of the whole failure class. Your config points at latest, or an S3 latest prefix, or the HF main branch with no revision pinned. Someone in a different team and a different timezone pushes something — a retrain, or honestly just a README update that touches the pointer — and now latest resolves to different bytes. No deploy fired, no CI ran. The next pod restart, which could be a routine autoscale event nobody initiated, starts serving different behavior under a config line that hasn’t changed in months. With a resolved-once-and-logged version id, this becomes “check the version in the logs”; without it, it’s archaeology across everyone’s commit history.
Failure Modes & Pitfalls
- Mutable
latest/mainin prod. Servingchatbot-llm:latestor HFmainmeans behavior can change under you with no deploy and no diff. Pin a digest, alias→version, or commit hash.latestis for dev laptops, never production. (War story 3, above.) - Weights not content-addressed. If a bucket write can overwrite version 5’s bytes and keep the id, your “immutable” version is a lie. Key by hash and enable object immutability/versioning.
- Tokenizer/engine drift. Weights pinned, but the tokenizer or chat template comes
from a different revision, or the base image bumped
transformers/vLLM. Same weights, different tokens, different outputs. Version the whole bundle and record the engine version; catch it with shadow. This is not hypothetical — see War stories 1 and 2 above, both real, dated incidents where the registered version never changed at all. - Prompt/serving-code drift. The prompt template or post-processing lives in app code that deploys on its own cadence, decoupled from the model version. Pin the serving-code SHA into the version — or, for agentic systems, version the prompt itself in a prompt registry and gate it the same way you gate the model.
- No link between served version and eval results. Metrics logged against a run but not the version id, or evals run on a different bundle than what ships. Promotion gates then guard nothing. Attach eval numbers to the version, run them on the exact artifact.
- Alias re-resolved per request. Resolving
@championon every request makes a promotion split traffic mid-flight and makes logs ambiguous. Resolve once at load, pin, and reload on change. - Semantic version reused for different bytes. Re-tagging
v3after a hotfix silently changes identity. Versions are append-only; new bytes get a new number. - Rollback that rebuilds. If reverting requires re-running the build/train pipeline, incidents last hours. Keep N-1 registered and warm-able.
- Config drift between registry and serving. The registry says v7 but the pod mounted a stale cached artifact. Verify the loaded hash against the registry at startup and fail closed on mismatch.
- A gate that fails open on missing data. An eval-gated promotion pipeline that treats “no eval tags recorded yet” as “pass” will eventually promote something nobody evaluated. Fail closed: no eval on the version means no promotion.
Saying it out loud. If I’m naming the pitfalls that actually cause incidents: mutable pointers in production, bytes that aren’t content-addressed so a bucket write can overwrite a version in place, tokenizer or engine drift where the weights are pinned but the layer underneath isn’t, and eval results attached to a run instead of to the version id — which means your promotion gate is guarding nothing. Two more that are less obvious. Re-resolving an alias per request instead of once at load, which splits traffic mid-promotion and makes logs ambiguous. And a gate that fails open on missing eval data, which will eventually promote something nobody evaluated. Rollback that requires a rebuild belongs on the list too: that turns a five-minute incident into a multi-hour one.
Interview Mastery
This section is the one to over-prepare. Interviewers use model versioning as a proxy for “does this person actually understand production ML systems, or just training runs” — the questions below go from fundamentals to a full system-design prompt.
-
“What exactly is a model version to you?” — Expect the full bundle: weights + config + tokenizer + generation config + serving code + engine version + runtime, all pinned. Naming only the weights is a red flag.
-
“How does the server know which version is prod, right now?” — Alias/approval resolved once at load, pinned, and stamped on every request; not
latest, not per-request re-resolution. -
“Walk me through promoting dev → staging → prod.” — Immutable register, evals linked to the version, enforced gates, approval recorded, pointer move — never a rebuild.
-
“A regression is in prod. Roll it back.” — Repoint alias / redeploy prior ARN / pin prior commit; drain + reload; previous version still registered and warm; target minutes, no rebuild. Bonus points for mentioning a
previous_<alias>-style snapshot written at promotion time, so rollback is a lookup, not a memory test. -
“How do you guarantee the model is byte-for-byte what you think?” — Content hash / commit digest, re-verified on load; object-store immutability; fail closed on mismatch.
-
“Same weights but outputs changed — how did that happen and how do you prevent it?” — Tokenizer/engine/config/prompt drift; version the whole bundle, record engine version, catch with shadow. Cite a concrete mechanism if you can — e.g. a tokenizer-backend auto-conversion silently remapping special-token ids (War story 1).
-
“How do you compare two versions safely in production?” — Shadow for zero-risk diffing, A/B/canary for online metrics, with the version id stamped on every record.
-
“How would you audit which model answered a given request three weeks ago?” — Immutable version id logged per request + model card + lineage back to run/data.
-
“Explain, in about 60 seconds, why ‘the same model’ can silently change in production.” — A strong answer names specific mechanisms, fast, without rambling: (a) a mutable pointer —
latest,main, an S3latest/prefix — gets repointed by someone else’s unrelated push; (b) a base-image/runtime-dependency bump (transformers, vLLM, CUDA) changes tokenizer behavior, chat-template defaults, or sampling kernels without anyone touching the model config; (c) a tokenizer loader auto-migrates formats (slow→fast) and silently remaps special-token ids; (d) a quantization or LoRA-merge step gets re-run with a different calibration set under the same artifact name; (e) prompt template or post-processing code deploys on its own cadence, decoupled from the model version. The unifying point to land on: the human-facing name is not the identity — only a content hash of the full bundle is, which is why every mechanism above is invisible until you check bytes, not names. -
System design: “Design the model registry + promotion workflow for a company running 5 models in prod.” A strong answer covers, roughly in this order:
- Requirements first: how many teams own models, what’s the rollback SLO, is there a compliance/audit requirement, do models share infra (GPUs, base images)?
- One registry, namespaced per model — not five bespoke systems. MLflow (or SageMaker/Vertex if already on that cloud) with a Postgres metadata store and S3/GCS artifact backend, content-addressed by hash.
- Aliases per model, not global:
chatbot-llm@champion,ranker@champion, etc. — each model gets its own@champion/@challenger/@shadow, so a promotion on one model can’t accidentally touch another. - Promotion pipeline as code, shared across all 5 models — one CI job template parameterized by model name, so gates (eval thresholds, approval requirement) are consistent and reviewable, not five diverging tribal processes.
- Serving layer: each model’s servers resolve their own alias once at startup,
log the resolved version id on every request, and expose a
/versionendpoint for on-call sanity checks. - Rollback SLO: keep N-1 warm per model (accept the GPU-hours cost for the 5 models that matter) so rollback is a pointer move + drain, not a cold rebuild.
- Monitoring tied to version id: per-model dashboards sliced by the version stamped in logs, so a regression alert names a version, not just “the service.”
- Ownership: one on-call rotation per model team, but a single shared registry pattern and runbook, so a new hire only has to learn the pattern once.
Sketch:
┌─────────────────────────────────────────────────────────────┐ │ Model Registry (MLflow) │ │ metadata: Postgres artifacts: S3 (content-addressed) │ │ │ │ chatbot-llm @champion→v12 @challenger→v13 @shadow→v14 │ │ ranker @champion→v4 @challenger→v5 │ │ summarizer @champion→v9 │ │ classifier @champion→v2 @challenger→v3 │ │ embedder @champion→v6 │ └───────────────┬────────────────────────────────────────────────┘ │ resolve alias ONCE at load; log version_id/req ┌───────────┼───────────┬───────────┬───────────┐ ▼ ▼ ▼ ▼ ▼ chatbot pods ranker pods summ. pods class. pods embed pods │ └── shared CI promotion pipeline (eval gate → alias move), one template, parameterized by model name -
“How would you version an agentic system where the prompt, tools, and engine all change independently of the weights?” — Version each independently (a prompt registry with its own aliases, a hash over the tool/function schema set, the engine version pinned per-image), then define an explicit bundle manifest that references all of them and gate/promote/roll back the manifest as one unit — see the agentic-bundle section above. The failure to avoid: repointing the model’s alias and the prompt’s alias in two separate, uncoordinated steps.
-
“What’s the difference between a semantic version and a content hash, and why do you need both?” — Semver/registry integer is human-ordered and communicates intent (“newer than v2”) but doesn’t guarantee uniqueness of bytes; a content hash guarantees identity but is unordered and meaningless to a human. Use an auto-incrementing registry version for people, and record the content hash as immutable metadata on it — never let a version number get reused for different bytes.
-
“When would you choose OCI artifacts / a container registry over something like MLflow for models?” — When you want to reuse existing container infra (registry, signing via sigstore, Kubernetes volume sources, multi-arch manifests) rather than stand up a separate model-specific system — the CNCF ModelPack effort and Harbor-as-model-registry deployments are exactly this bet. Trade-off: you give up some ML-specific metadata (runs, eval linkage, lineage graphs) that a purpose-built registry like MLflow gives you natively, unless you layer it back on with tags.
-
“How do you detect version drift automatically, before a human notices bad outputs?” — Startup self-check: re-hash the loaded artifact against the recorded digest and fail closed on mismatch; a fixed probe-string tokenizer fingerprint checked against a golden value (this would have caught War story 1 at boot, not in live traffic); a deploy-time canary request through the full serving stack before taking traffic (this would have caught War story 2 at rollout, not at the first real user request); continuous shadow evaluation comparing
@shadowagainst@championon identical inputs. -
“What’s the cost/latency tradeoff of keeping N-1 warm for instant rollback, and how do you decide how many versions to keep warm?” — Warm N-1 costs roughly double the steady-state GPU footprint for that model during any promotion window; justify it by rollback SLO (if “minutes” is the requirement, cold-start-from-object- storage for a 70B model won’t hit it) and by blast radius (a model with 5 dependent downstream services justifies the spend more than an internal experiment). Most teams keep exactly N-1 warm and rely on object storage + a fast loader for anything older, since rollback more than one hop back is rare and can tolerate a slower path.
-
“Stages vs aliases — when, if ever, would you still want classic stages?” — Stages are simpler when you genuinely have one linear lifecycle and only ever need one “current production” slot with no concurrent challenger/shadow traffic; they get in the way the moment you want two things pointing at production-adjacent versions simultaneously (canary + shadow + champion), which is the normal case for LLM serving — hence MLflow’s move to aliases.
Saying it out loud. For a design prompt like five models in production, the shape of a good answer is: one registry, namespaced per model, not five bespoke systems — metadata in Postgres, artifacts content-addressed in object storage. Each model gets its own aliases, so promoting the ranker can’t accidentally touch the chatbot. One promotion pipeline as code, parameterized by model name, so the eval thresholds and approval requirements are consistent and reviewable rather than five diverging tribal processes. Each server resolves its own alias once at startup, logs the resolved version id on every request, and exposes a version endpoint for on-call. And I’d state the rollback SLO explicitly and pay for it — keeping N-1 warm roughly doubles that model’s steady-state GPU footprint during a promotion window, and that’s the honest tradeoff: you’re buying minutes-not-hours rollback with GPU-hours.
Red flags vs. green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Naming a version | “the weights” only | Full bundle: weights + tokenizer + config + engine + serving code + runtime deps |
| Pointer semantics in prod | latest / main / an S3 latest/ prefix | A pinned alias or commit, resolved once at load and logged |
| Promotion mechanism | Manual file copy, ad hoc rebuild | Pointer move over an already-registered, already-hashed version |
| Rollback time | “We’d redeploy / retrain” | Alias repoint + rolling restart; N-1 kept warm; minutes, not hours |
| Eval linkage | Metrics live in a spreadsheet or a run, untied to a version id | Eval results stored as tags/params on the exact served version |
| Drift detection | “We’d notice from user complaints” | Hash re-verification at load + tokenizer fingerprint + shadow diffing pre-promotion |
| Dependency pinning | “requirements.txt, roughly” | Lockfile or image digest pinned per version, engine version explicitly recorded |
| Auditability | “Check the deploy Slack channel” | Immutable version id on every request log + model card + lineage graph |
| Scaling to many models | Ad hoc process per team, reinvented each time | One registry pattern, namespaced aliases, one promotion pipeline as code |
| Agentic bundles | Prompt/tools versioned informally in app code | Prompt registry + tool-schema hash + engine version, gated as one manifest |
Further Reading
- MLflow — Model Registry (aliases, tags, workflow): https://mlflow.org/docs/latest/ml/model-registry/workflow/
- MLflow — Registry concepts (stages deprecated in favor of aliases): https://mlflow.org/docs/latest/model-registry/
- MLflow — Load a registered model (
models:/name@alias,models:/name/version): https://mlflow.org/docs/latest/getting-started/registering-first-model/step3-load-model/ - MLflow — Prompt Registry overview: https://mlflow.org/docs/latest/genai/prompt-registry/
- MLflow — Manage prompt lifecycles with aliases: https://mlflow.org/docs/latest/genai/prompt-registry/manage-prompt-lifecycles-with-aliases/
- MLflow — release notes (3.15.0, July 31, 2026, and history): https://mlflow.org/releases/
- Amazon SageMaker — Register a model & Model Package Groups: https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry.html
- Amazon SageMaker — Update model approval status: https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry-approve.html
- Vertex AI — Model Registry introduction: https://cloud.google.com/vertex-ai/docs/model-registry/introduction
- Vertex AI — Model version aliases: https://docs.cloud.google.com/gemini-enterprise-agent-platform/machine-learning/model-registry/model-alias
- Hugging Face — Sharing & the
revisionargument (pin a commit): https://huggingface.co/docs/transformers/model_sharing - Hugging Face — Model Cards (format & metadata): https://huggingface.co/docs/hub/en/model-cards
- Hugging Face Forums — Purpose of
commit_hashinPreTrainedModel.from_pretrained: https://discuss.huggingface.co/t/purpose-of-commit-hash-in-pretrainedmodel-from-pretrained/174304 - Hugging Face — Xet chunk-level deduplication specification: https://huggingface.co/docs/hub/en/xet/deduplication
- Hugging Face — “From Chunks to Blocks” (Xet storage engineering): https://huggingface.co/blog/from-chunks-to-blocks
- Baseten — Pinning ML model revisions for compatibility and security: https://www.baseten.co/blog/pinning-ml-model-revisions-for-compatibility-and-security/
- CNCF — How OCI Artifacts will drive future AI use cases (Aug 27, 2025): https://www.cncf.io/blog/2025/08/27/how-oci-artifacts-will-drive-future-ai-use-cases/
- CNCF ModelPack — open model packaging spec: https://github.com/modelpack/model-spec
- CNCF ModelPack —
modctlreference CLI: https://github.com/modelpack/modctl - VMware/Broadcom — Using Harbor as an AI Model Registry (Mar 2026): https://blogs.vmware.com/cloud-foundation/2026/03/03/using-harbor-as-an-ai-model-registry/
- vLLM issue #39261 — Kimi K2.5 tokenizer-backend token-id mismatch (Apr 2026): https://github.com/vllm-project/vllm/issues/39261
- vLLM issue #29849 — DeepSeek-V3.2 chat-template break from a
transformersbump (Dec 2025): https://github.com/vllm-project/vllm/issues/29849 - Mitchell et al., 2019 — Model Cards for Model Reporting: https://arxiv.org/abs/1810.03993
- Docker — image digests vs tags (the mental model): https://docs.docker.com/dhi/core-concepts/digests/
Topic 10: Drift Detection
What You’ll Learn
This topic teaches you how to:
- Detect data drift (input distribution changes)
- Detect concept drift (model performance degrades)
- Set up monitoring with Evidently AI
- Create alerts for anomalies
- Analyze drift patterns
- Take corrective action
Why Drift Detection?
The Problem
Models degrade over time because:
- Data drift: Input data distribution changes
- Concept drift: Relationship between input/output changes
- Model decay: Model becomes outdated
Impact
- Reduced accuracy: Model performs worse
- Business impact: Wrong predictions cost money
- User experience: Poor quality outputs
- Compliance: May violate regulations
Types of Drift
1. Data Drift
Input data distribution changes.
Example:
- Training: 80% English, 20% Spanish
- Production: 60% English, 40% Spanish
Detection: Compare input distributions.
2. Concept Drift
Relationship between input and output changes.
Example:
- Training: “hot” = positive sentiment
- Production: “hot” = negative sentiment (context changed)
Detection: Monitor prediction accuracy.
3. Prediction Drift
Model predictions distribution changes.
Example: Model starts predicting more positive labels.
Detection: Compare prediction distributions.
Evidently AI
What is Evidently?
Open-source tool for ML monitoring and drift detection.
Features
- Data drift detection: Statistical tests
- Model performance: Accuracy monitoring
- Data quality: Missing values, outliers
- Dashboards: Visual reports
Setup
Installation
pip install evidently
Basic Usage
from evidently import ColumnMapping
from evidently.report import Report
from evidently.metrics import DataDriftTable
# Compare reference (training) vs current (production)
report = Report(metrics=[DataDriftTable()])
report.run(
reference_data=train_data,
current_data=production_data
)
report.show()
Monitoring Pipeline
1. Collect Data
Store production inputs and predictions.
2. Compute Metrics
Calculate drift metrics periodically.
3. Compare to Baseline
Compare against training/reference data.
4. Alert on Drift
Send alerts when drift detected.
5. Take Action
Retrain model or investigate cause.
Implementation
Data Collection
# Store production data
def log_prediction(input_data, prediction, model_version):
store.append({
"timestamp": datetime.now(),
"input": input_data,
"prediction": prediction,
"model_version": model_version
})
Drift Detection
# Run drift detection daily
def detect_drift():
reference = load_reference_data()
current = load_recent_production_data()
report = Report(metrics=[
DataDriftTable(),
DatasetDriftMetric(),
PredictionDriftMetric()
])
report.run(reference_data=reference, current_data=current)
return report
Alerting
# Check if drift detected
if report.get_metric(DataDriftTable()).drift_detected:
send_alert("Data drift detected!")
Metrics to Monitor
Data Drift Metrics
- PSI (Population Stability Index): Distribution similarity
- Kolmogorov-Smirnov test: Distribution differences
- Chi-square test: Categorical distribution
Model Performance
- Accuracy: Overall correctness
- Precision/Recall: Per-class metrics
- F1 score: Balanced metric
Prediction Drift
- Prediction distribution: How predictions change
- Prediction by segment: Per-group analysis
Dashboards
Evidently Dashboard
from evidently.ui.dashboards import Dashboard
dashboard = Dashboard("Drift Monitoring")
dashboard.add_report(drift_report)
dashboard.show()
Integration with Grafana
Export metrics to Prometheus, visualize in Grafana.
Alerting Rules
Data Drift Alert
alert: DataDriftDetected
expr: drift_score > 0.2
for: 1h
annotations:
summary: "Data drift detected"
Performance Degradation
alert: ModelPerformanceDegraded
expr: accuracy < 0.8
for: 2h
annotations:
summary: "Model accuracy below threshold"
Best Practices
- Establish baseline: Use training data as reference
- Monitor continuously: Check drift regularly
- Set thresholds: Define what’s “drift”
- Investigate causes: Understand why drift occurs
- Document actions: Track responses to drift
- Automate responses: Auto-retrain or alert
Common Scenarios
Gradual Drift
Slow change over time → Retrain periodically
Sudden Drift
Rapid change → Investigate cause immediately
Seasonal Drift
Predictable patterns → Account for seasonality
Exercises
- Set up Evidently: Install and configure
- Detect data drift: Compare training vs production
- Create dashboard: Visualize drift metrics
- Set up alerts: Alert on drift detection
- Investigate drift: Analyze why drift occurred
Next Steps
- Topic 8: Integrate drift detection with monitoring
- Topic 9: Track drift per model version
- Topic 7: Use drift detection in canary deployments
Further Reading
Drift Detection for Deployed LLMs — A Practical Guide
Noticing when the inputs your model sees, or the outputs it produces, have quietly stopped looking like what you tested against.
Why this matters
You shipped a model. Evals were green, latency was fine, the demo delighted the VP. Then three weeks later support tickets spike, a red-team screenshot lands in Slack, and someone asks the question you cannot answer: did the model change, or did the world change?
An LLM endpoint is a static function deployed into a non-stationary environment. The weights are frozen at a checkpoint. But the prompts arriving at 3pm on a Tuesday in month four are drawn from a different distribution than the ones you curated for your eval set. Users discover new use cases. A marketing launch sends a new persona your way. An upstream service starts truncating context. A competitor publishes a jailbreak. None of these touch your weights, and none of them show up in a unit test — but all of them change what your system does in production.
Drift detection is the monitoring discipline that makes this observable. It answers three operational questions:
- Are inputs shifting? (Are we being asked things we were not built for?)
- Are outputs shifting? (Is quality, length, refusal rate, or latency decaying?)
- Is the input→output relationship shifting? (Concept drift — the right answer changed even though the question looks the same.)
Get this right and you catch regressions before your users file them. Get it wrong and you either miss real degradation or drown in false alarms until the on-call engineer mutes the channel. Both failure modes are common, and both are avoidable with a small amount of statistics applied with judgment.
This chapter is deliberately intuition-first: every method gets a plain-English picture before a formula, then a worked micro-example with numbers you can reproduce, then the honest caveat about where it breaks.
Saying it out loud. The question drift monitoring exists to answer is: did the model change, or did the world change? Because an LLM endpoint is a static function dropped into a non-stationary environment — the weights are frozen at a checkpoint, but the prompts arriving in month four are drawn from a different distribution than the eval set you curated at launch. Users find new use cases, a marketing launch sends a new persona your way, an upstream service starts truncating context. None of that touches your weights and none of it shows up in a unit test. And the honest framing is that both failure modes here are common: miss real degradation, or drown people in false alarms until they mute the channel.
Core intuition: the model is static, the world is not
Hold one picture in your head for the whole chapter.
At training/eval time you sampled a reference distribution ( P_{\text{ref}} ) — the prompts, embeddings, and outputs you validated against. In production you observe a stream that, windowed, gives you a live distribution ( P_{\text{live}} ). Drift is simply:
[ P_{\text{live}} \ne P_{\text{ref}} ]
Every technique in this chapter is a way to measure a distance between these two distributions from finite samples, and then decide whether that distance is large enough to act on. That is the whole game:
- Pick what you measure (a feature: prompt length, embedding, refusal flag, latency).
- Pick a distance / test (PSI, KS, MMD, embedding distance, a classifier).
- Pick a window and a threshold.
- Decide what happens when the threshold trips.
The subtlety — and where most production systems fail — is not the math. It is choosing a reference window that means something, choosing a live window that is neither too jittery nor too laggy, and resisting the urge to page a human every time noise crosses a line. A drift monitor that fires ten times a week is worse than no monitor, because it trains everyone to ignore it.
One more framing that matters for LLMs specifically: you usually have no labels. In classic ML monitoring you eventually learn the ground truth (the loan defaulted, the click happened) and can measure real performance. For a chat endpoint, “was this answer good?” often never arrives, or arrives weeks later as a thumbs-down on 0.3% of turns. So drift detection on the inputs and the observable outputs is frequently the only early-warning signal you get. That raises its stakes — and it means you must be honest that an input-drift alarm is smoke, not a diagnosis.
Saying it out loud. Mechanically all of this is one idea: you have a reference distribution from eval time and a live distribution from a recent window, and every technique is just a way to measure a distance between them from finite samples and decide if it’s big enough to act on. So you pick what you measure, pick a distance, pick a window, pick a threshold, and decide what happens when it trips. The hard part isn’t the math — it’s choosing a reference that means something and resisting the urge to page a human every time noise crosses a line. And the thing that makes LLM serving special is that you usually have no labels: “was that answer good?” often never arrives, so input drift is frequently your only early warning, which means you have to be honest that it’s smoke, not a diagnosis.
A drift taxonomy
Four kinds of drift, in the order you typically detect them:
| Type | What shifts | LLM example | Typically detected via |
|---|---|---|---|
| Input / prompt drift | The distribution of raw inputs ( P(x) ) | Prompts get longer; a new language appears; topic mix changes | PSI / KS on scalar features (length, token count, language ID); topic classifiers |
| Embedding / semantic drift | The distribution of inputs (or outputs) in vector space ( P(\phi(x)) ) | Users start asking about a product feature that did not exist at launch | MMD, domain classifier, centroid / cosine distance on embeddings |
| Output / quality drift | The distribution of outputs ( P(y) ) or a quality proxy | Answers get shorter, refusals rise, latency creeps up, tone changes | PSI/KS on output length, refusal rate, latency; LLM-judge scores |
| Concept drift | The conditional ( P(y \mid x) ) — the correct mapping | “Best model” now points to a newer model; a policy changed so the right answer flipped | Requires labels or re-eval; input/output drift can be flat while this moves |
Two things worth internalizing:
- Input drift and output drift can move independently. Inputs can look identical while outputs decay (e.g., a silent upstream change to your system prompt, or a provider swapping the model behind an alias). Outputs can look identical while inputs shift (the model gracefully handles new topics — good!). Monitor both, and monitor them separately so you can tell which one moved.
- Concept drift is the dangerous one and the hardest to see. ( P(x) ) can be perfectly stable while ( P(y \mid x) ) rots underneath you. Detecting it genuinely requires ground truth or periodic re-evaluation — no unsupervised distance on inputs will find it. Say this out loud in an interview; it separates people who have run monitoring from people who have read about it.
A useful mental decomposition: the joint ( P(x,y) = P(x),P(y\mid x) ). Input/embedding drift is a change in ( P(x) ); concept drift is a change in ( P(y\mid x) ). Output drift is a change in the marginal ( P(y) ), which can be caused by either — which is exactly why an output-drift alarm alone cannot tell you whether users changed or the model rotted. You have to look at inputs and outputs together.
Saying it out loud. There are four kinds and I’d name them in the order you detect them. Input drift is the distribution of prompts changing. Embedding or semantic drift is that shift in vector space — same length, different meaning. Output drift is your responses changing: shorter, more refusals, higher latency. And concept drift is the conditional changing — the question looks identical but the correct answer is now different. Two things worth saying out loud. Input and output drift move independently, so you monitor them separately or you can’t tell which one moved. And concept drift is the dangerous one, because no unsupervised distance on inputs will ever find it — you need labels or periodic re-evaluation, full stop.
Detection methods in depth
For each method: the intuition, the precise formula, and a small worked example with real numbers.
1. Population Stability Index (PSI)
Intuition. Bin a feature. Compare the share of traffic in each bin now vs. at reference time. If mass sloshed from one bin to another, PSI grows. It is a symmetric, binned relative-entropy-flavored score, and it is the workhorse of tabular drift monitoring because it produces a single interpretable number with battle-tested thresholds.
Formula. With ( B ) bins, reference proportion ( r_b ) and live proportion ( l_b ) in bin ( b ):
[ \text{PSI} = \sum_{b=1}^{B} \left( l_b - r_b \right), \ln!\frac{l_b}{r_b} ]
Each term is ( \ge 0 ) (a bin that moves in either direction adds positively), so PSI is a non-negative divergence. It is the symmetrized KL contribution per bin: ( (l_b - r_b)\ln(l_b/r_b) = \text{KL term}{l|r} + \text{KL term}{r|l} ) collapsed into one expression. Because it sums per-bin contributions, PSI also localizes drift — you can read off which bin is driving the score, which KS cannot do. Empty bins blow up the log, so clamp proportions to a small ( \epsilon ) (e.g. ( 10^{-6} )) or add a pseudo-count.
Standard thresholds (from credit-risk practice, widely reused):
- ( \text{PSI} < 0.1 ): no meaningful shift.
- ( 0.1 \le \text{PSI} < 0.2 ): moderate shift — investigate.
- ( \text{PSI} \ge 0.2 ): significant shift — act. (Some shops use 0.25 as the “major” line.)
Worked micro-example. Four equal reference bins, so ( r_b = 0.25 ) each. Live proportions drift toward the top bin: ( l = (0.10,, 0.20,, 0.30,, 0.40) ).
| Bin | ( r_b ) | ( l_b ) | ( l_b - r_b ) | ( \ln(l_b/r_b) ) | term |
|---|---|---|---|---|---|
| 1 | 0.25 | 0.10 | (-0.15) | (-0.916) | 0.1375 |
| 2 | 0.25 | 0.20 | (-0.05) | (-0.223) | 0.0112 |
| 3 | 0.25 | 0.30 | (+0.05) | (+0.182) | 0.0091 |
| 4 | 0.25 | 0.40 | (+0.15) | (+0.470) | 0.0705 |
[ \text{PSI} = 0.1375 + 0.0112 + 0.0091 + 0.0705 = 0.2282 ]
Above 0.2 — a significant shift. Notice the top bin dominates the score: PSI is most sensitive where a large relative change lands, which is exactly why quantile bins (equal mass at reference) behave better than equal-width bins for skewed features like token counts. With equal-width bins on a heavy-tailed feature, the tail bins are nearly empty at reference, so a handful of new samples there produce a huge ( \ln(l_b/r_b) ) and a jumpy, unreliable score.
Data type: scalar / categorical features. Not for raw high-dimensional embeddings — you would have to bin per-dimension and lose all cross-dimensional structure.
Seen in the wild: this is the exact scoring NannyML’s univariate drift detector and Evidently’s DataDriftTable metric compute per column by default — if you’ve called either of those libraries on tabular or scalar LLM features, you were already running this formula.
Saying it out loud. PSI is the workhorse. You bin a feature, compare the share of traffic in each bin now versus at reference time, and sum a symmetrized divergence term across bins. One number, and — unlike KS — it localizes, so you can read off which bin is driving the score. The thresholds people quote are 0.1 for a moderate shift and 0.2 or 0.25 for a significant one, and I’d be careful to call those what they are: a rule of thumb inherited from credit-risk scorecards, not a law. Recalibrate them per feature against your own known-good history. The other practical detail is using quantile bins frozen from the reference, because with equal-width bins on a heavy-tailed feature like token count the tail bins are nearly empty and a handful of samples produces a wild, jumpy score.
2. Kolmogorov–Smirnov (KS) two-sample test
Intuition. Forget bins. Compare the two empirical cumulative distribution functions directly, and take the single point of maximum vertical gap between them. Big gap ⇒ the distributions differ. KS is non-parametric (assumes nothing about shape) and needs no binning choice, which makes it a clean default for continuous features.
Formula. For empirical CDFs ( F_{\text{ref}} ) and ( F_{\text{live}} ):
[ D = \sup_{x} \bigl| F_{\text{live}}(x) - F_{\text{ref}}(x) \bigr| ]
( D \in [0,1] ). The p-value comes from the Kolmogorov distribution; for sample sizes ( n, m ) you reject “same distribution” at level ( \alpha ) when
[ D > c(\alpha),\sqrt{\frac{n+m}{n,m}}, \qquad c(0.05) \approx 1.36 . ]
Worked micro-example. Reference sample ( {1,2,3,4} ), live sample ( {2,3,4,5} ) (each shifted up by 1). Step through the pooled sorted values and read both CDFs:
| ( x ) | ( F_{\text{ref}} ) | ( F_{\text{live}} ) | gap |
|---|---|---|---|
| 1 | 0.25 | 0.00 | 0.25 |
| 2 | 0.50 | 0.25 | 0.25 |
| 3 | 0.75 | 0.50 | 0.25 |
| 4 | 1.00 | 0.75 | 0.25 |
| 5 | 1.00 | 1.00 | 0.00 |
( D = 0.25 ). With ( n=m=4 ) that is nowhere near significant (the critical value is enormous for four points) — a reminder that KS on tiny windows is uninformative, and that the statistic and its significance are different things. On the realistic 5000-vs-1500 example below, ( D = 0.26 ) with a p-value around ( 10^{-67} ): same statistic magnitude, wildly different verdict, because sample size collapses the noise band.
Caveats. KS is most sensitive near the center of the distribution and comparatively blind in the tails. With very large windows it becomes hypersensitive — trivial, operationally irrelevant differences produce ( p < 0.001 ). That is why you pair the p-value with an effect-size threshold on ( D ) itself (say, alert only if ( D > 0.1 ) and ( p < 0.01 )). For categorical features KS does not apply — use a chi-square test of the count table instead.
Seen in the wild: SciPy’s ks_2samp (used throughout this chapter’s worked examples) is the reference implementation; Evidently and NannyML both call it internally for their per-column drift tests on numeric features, wrapping it with the same effect-size-plus-significance framing recommended above.
Saying it out loud. KS skips binning entirely: you compare the two empirical CDFs and take the single biggest vertical gap between them. That’s the D statistic, it lives between zero and one, and it comes with a real p-value. The catch is that the statistic and its significance are two different things, and window size is what separates them — D of 0.25 on four samples means nothing, while D of 0.26 on five thousand versus fifteen hundred gives you a p-value around ten to the minus sixty-seven. Which is why on huge windows KS becomes hypersensitive and flags differences nobody cares about. So you gate on both: alert only when D is above roughly 0.1 and the p-value is below 0.01. And KS is center-sensitive and comparatively blind in the tails, which is worth knowing before you rely on it for tail behavior.
3. Maximum Mean Discrepancy (MMD)
Intuition. The right tool when your feature is a vector (an embedding), not a scalar. Map every sample through a kernel into a high-dimensional space, take the mean of each set there, and measure the distance between those means. If the distributions are identical, the mean embeddings coincide and MMD is zero. Unlike PSI/KS it is inherently multivariate — no binning, no per-dimension decomposition — which is why it shows up in embedding-drift toolkits.
Formula. With kernel ( k ) (commonly RBF, ( k(a,b)=\exp(-\gamma\lVert a-b\rVert^2) )), the (biased) empirical squared MMD between reference ( X={x_i}{i=1}^m ) and live ( Y={y_j}{j=1}^n ):
[ \widehat{\text{MMD}}^2 = \frac{1}{m^2}\sum_{i,i’} k(x_i,x_{i’}) + \frac{1}{n^2}\sum_{j,j’} k(y_j,y_{j’}) - \frac{2}{mn}\sum_{i,j} k(x_i,y_j) ]
Read it as (within-reference similarity) + (within-live similarity) − 2·(cross similarity). When the two clouds overlap, the cross term matches the within terms and everything cancels toward zero. Significance comes from a permutation test: shuffle the pooled labels many times, recompute MMD each time to build the null distribution, and see where your observed value falls in that null.
Worked micro-example. 1-D, RBF with ( \gamma = 0.5 ). Reference ( X={0,1,2} ), live ( Y={3,4,5} ). Computing the three kernel-matrix means:
- within-reference mean ( = 0.633 )
- within-live mean ( = 0.633 ) (same spacing, so same self-similarity)
- cross mean ( = 0.101 ) (clouds are far apart, so kernel values are small)
[ \widehat{\text{MMD}}^2 = 0.633 + 0.633 - 2(0.101) = 1.0635, \qquad \widehat{\text{MMD}} = 1.031 ]
The large value reflects two clearly separated clouds. Pitfall: MMD’s scale is meaningless in the abstract — a value of 1.03 is only “large” relative to the permutation null for your data and your ( \gamma ). The kernel bandwidth ( \gamma ) matters a lot; a common heuristic sets it from the median pairwise distance of the pooled sample. Always calibrate the threshold empirically; never hard-code an MMD number. Cost is ( O((m+n)^2) ) per window, so subsample for large windows.
Seen in the wild: Evidently AI lists MMD as one of its five embedding-drift methods (see the 2025–2026 landscape section below), and it is the test underlying most “kernel two-sample test” drift-detection literature, including the original Gretton et al. formulation cited in Further reading.
Saying it out loud. MMD is what you reach for when the feature is a vector rather than a scalar. Intuitively: push every sample through a kernel, take the mean of each set in that space, and measure the distance between the two means. Identical distributions give you zero. It’s genuinely multivariate — no binning, no per-dimension decomposition — which is why it shows up in embedding-drift toolkits. The pitfall to name is that MMD’s scale is meaningless in the abstract. A value of 1.03 tells you nothing until you’ve built a null by permuting the pooled labels and seeing where your observed value falls. Never hard-code an MMD threshold from a paper, and remember it costs order n-squared per window, so subsample.
4. Embedding distance & clustering
Intuition. The cheapest embedding-drift signals. Summarize each set of embeddings by its centroid (mean vector) and measure how far the centroids moved, either by Euclidean distance or by cosine of the angle. Fast, streaming-friendly, and interpretable — but coarse.
Formulas. Centroids ( \bar\phi_{\text{ref}} = \frac1m\sum_i \phi(x_i) ) and ( \bar\phi_{\text{live}} ):
[ d_{\text{euclid}} = \lVert \bar\phi_{\text{ref}} - \bar\phi_{\text{live}} \rVert_2, \qquad d_{\cos} = 1 - \frac{\bar\phi_{\text{ref}} \cdot \bar\phi_{\text{live}}}{\lVert \bar\phi_{\text{ref}}\rVert,\lVert \bar\phi_{\text{live}}\rVert} ]
Worked micro-example. Two 2-D centroids, normalized: ( \bar\phi_{\text{ref}} = (0.8,0.6) ) and ( \bar\phi_{\text{live}} = (0.6,0.8) ) (both already unit-norm).
[ \cos = 0.8(0.6) + 0.6(0.8) = 0.96 \Rightarrow d_{\cos} = 0.04, \qquad d_{\text{euclid}} = \lVert(0.2,-0.2)\rVert = 0.2828 ]
The failure mode you must know: centroid distance is blind to variance and multimodal shifts. If half your traffic moves far left and half moves far right, the centroid can sit exactly where it started while the distribution has torn in two. This is why serious embedding-drift setups prefer a domain classifier (train a binary model to tell reference from live; if it achieves ROC-AUC meaningfully above 0.5, the sets are distinguishable ⇒ drift, and the classifier’s important features tell you why) or MMD, both of which see distributional shape, not just the mean. Centroid distance is a good first alarm, never the only one.
Seen in the wild: this is the cheapest of Evidently’s five embedding-drift methods and the first one most teams wire up, precisely because it needs no training and no permutation test — and precisely why the multimodal-blindness caveat above is the single most common way an embedding-drift monitor gives false reassurance.
Saying it out loud. Centroid distance is the cheapest embedding signal — take the mean vector of each set and measure how far the means moved, by Euclidean distance or cosine. It’s fast, streaming-friendly, and needs no training, which is why it’s the first thing most teams wire up. It also has a failure mode you must be able to name in an interview: it’s blind to variance and to multimodal shifts. If half your traffic moves left and half moves right, the centroid can sit exactly where it started while the distribution has torn in two. So centroid distance is a fine first tripwire and a terrible only signal — pair it with a domain classifier or MMD, which see distributional shape rather than just the mean.
5. Wasserstein distance & chi-square (honorable mentions)
Two more you should be able to name:
- Wasserstein (earth-mover’s) distance on a scalar feature: the minimum “work” to reshape one distribution into the other, ( W_1 = \int |F_{\text{ref}}(x) - F_{\text{live}}(x)|,dx ). Unlike KS (a single sup gap) it integrates the whole difference and is reported in the feature’s real units (tokens, milliseconds), which makes thresholds interpretable. Evidently uses it per-dimension in one of its embedding-drift methods.
- Chi-square test for categorical features (topic labels, language, refusal/no-refusal): compares observed vs. expected counts, ( \chi^2 = \sum_b (O_b - E_b)^2 / E_b ). This is the categorical analogue of KS — reach for it whenever the feature is a label rather than a number.
Seen in the wild: Evidently’s embedding-drift guide uses per-dimension Wasserstein distance as one of its five methods (alongside the domain classifier and MMD covered above), aggregating the per-dimension distances into a single “share of drifted components” score — a good middle ground between a single opaque MMD number and a full per-dimension report.
A fully worked example: PSI + KS on a live window
A drop-in monitor over one scalar feature — here prompt length in tokens. The reference is captured at deploy time; the live window is the last ( N ) requests. The numbers in the comments are the actual output of this code (seed fixed), so you can run it and reproduce them exactly.
import numpy as np
from scipy import stats
# ----- Two windows of a real feature: prompt length (tokens) -----
# Reference: captured at deploy/eval time.
# Live: users are now pasting more context -> longer prompts.
rng = np.random.default_rng(42)
ref = rng.gamma(shape=4.0, scale=30.0, size=5000) # mean ~120 tokens
live = rng.gamma(shape=4.0, scale=42.0, size=1500) # mean ~168 tokens
def psi(ref, live, bins=10, eps=1e-6):
"""PSI with quantile bins fixed by the REFERENCE distribution.
Quantile (equal-mass) bins are the right default for skewed
features like token counts: equal-width bins would leave the
long tail nearly empty and make the score unstable.
"""
# Bin edges = reference deciles; outer edges pushed to +/- inf
edges = np.quantile(ref, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
r_counts, _ = np.histogram(ref, bins=edges)
l_counts, _ = np.histogram(live, bins=edges)
# Clip to avoid log(0) / divide-by-zero on empty live bins
r_prop = np.clip(r_counts / r_counts.sum(), eps, None)
l_prop = np.clip(l_counts / l_counts.sum(), eps, None)
return float(np.sum((l_prop - r_prop) * np.log(l_prop / r_prop)))
# ----- Compute both signals -----
psi_val = psi(ref, live)
ks = stats.ks_2samp(ref, live) # returns (statistic D, p-value)
print(f"PSI = {psi_val:.4f}") # PSI = 0.4345
print(f"KS statistic D = {ks.statistic:.4f}") # KS statistic D = 0.2567
print(f"KS p-value = {ks.pvalue:.2e}") # KS p-value = 2.51e-67
# ----- Turn signals into an alert -----
PSI_ALERT = 0.20 # significant-shift threshold
KS_D_ALERT = 0.10 # minimum effect size we care about
KS_P_ALERT = 0.01 # significance level
psi_fires = psi_val >= PSI_ALERT
ks_fires = (ks.statistic >= KS_D_ALERT) and (ks.pvalue < KS_P_ALERT)
if psi_fires and ks_fires:
print("ALERT: prompt-length drift (PSI + KS agree). "
"Inputs are longer than at deploy time; "
"check truncation, context limits, and eval coverage.")
elif psi_fires or ks_fires:
print("WATCH: one signal tripped; monitor next windows before paging.")
else:
print("OK: no meaningful prompt-length drift.")
Output:
PSI = 0.4345
KS statistic D = 0.2567
KS p-value = 2.51e-67
ALERT: prompt-length drift (PSI + KS agree). ...
Both signals agree — PSI 0.43 is well past the 0.2 line, and KS gives ( D=0.26 ) with an astronomically small p-value. The AND of an effect-size gate and a significance gate is the pattern that keeps this from crying wolf: on a huge window KS alone would flag a 2-token difference; requiring ( D \ge 0.10 ) suppresses that. PSI alone, on a tiny window, would be jumpy; requiring both cross-checks it. Two cheap, independent tests on the same feature is a good default posture.
Note the two design choices that make this production-safe: (1) bin edges are frozen from the reference — if you re-derive quantile edges from the live window each time, both histograms are uniform by construction and PSI collapses to zero, hiding the drift; (2) the alert message is actionable — it names the likely causes and next checks, not just “drift detected.”
Saying it out loud. What this example is really demonstrating is the AND of an effect-size gate and a significance gate. On a window of thousands of requests, KS alone will flag a two-token difference as significant; requiring the D statistic above 0.1 suppresses that. PSI alone on a small window is jumpy; requiring KS to agree cross-checks it. Two cheap independent tests on the same feature is a good default posture. And there’s a subtle bug worth memorizing: freeze the bin edges from the reference. If you re-derive quantile edges from each live window, both histograms are uniform by construction and PSI collapses to exactly zero — your monitor silently stops working and looks perfectly healthy while doing it.
Extending to embeddings: MMD + a domain classifier
Scalars are the easy case. The moment you want semantic drift — “are users asking about different things?” — the feature is an embedding vector and you need a multivariate method. Here is a compact, correct monitor that runs both MMD (with a median-heuristic bandwidth and a permutation p-value) and a domain classifier, on a synthetic 16-dim embedding stream where four dimensions have shifted.
import numpy as np
from scipy.spatial.distance import pdist
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
rng = np.random.default_rng(0)
d = 16
ref = rng.normal(0.0, 1.0, size=(2000, d))
live = rng.normal(0.0, 1.0, size=(600, d))
live[:, :4] += 0.6 # 4 of 16 dims drift (a new topic cluster)
def median_gamma(Z):
"""RBF bandwidth from the median pairwise distance (standard heuristic)."""
med = np.median(pdist(Z))
return 1.0 / (2.0 * med ** 2)
def rbf_mmd2(X, Y, gamma):
"""Biased empirical squared MMD with an RBF kernel."""
XX = np.exp(-gamma * ((X[:, None, :] - X[None, :, :]) ** 2).sum(-1))
YY = np.exp(-gamma * ((Y[:, None, :] - Y[None, :, :]) ** 2).sum(-1))
XY = np.exp(-gamma * ((X[:, None, :] - Y[None, :, :]) ** 2).sum(-1))
return XX.mean() + YY.mean() - 2.0 * XY.mean()
def mmd_permutation_test(X, Y, n_perm=200, sub=300, seed=0):
"""MMD^2 plus a permutation p-value. Subsample first — MMD is O(n^2)."""
r = np.random.default_rng(seed)
X = X[r.choice(len(X), min(sub, len(X)), replace=False)]
Y = Y[r.choice(len(Y), min(sub, len(Y)), replace=False)]
Z = np.vstack([X, Y])
gamma = median_gamma(Z)
n = len(X)
obs = rbf_mmd2(X, Y, gamma)
null = np.empty(n_perm)
for i in range(n_perm):
idx = r.permutation(len(Z)) # shuffle labels under H0
null[i] = rbf_mmd2(Z[idx[:n]], Z[idx[n:]], gamma)
pval = (1 + (null >= obs).sum()) / (1 + n_perm)
return obs, pval
def domain_classifier_auc(ref, live):
"""Train ref-vs-live; AUC ~0.5 => indistinguishable, ~1.0 => strong drift."""
X = np.vstack([ref, live])
y = np.r_[np.zeros(len(ref)), np.ones(len(live))]
clf = LogisticRegression(max_iter=1000)
return cross_val_score(clf, X, y, cv=5, scoring="roc_auc").mean()
mmd2, mmd_p = mmd_permutation_test(ref, live)
auc = domain_classifier_auc(ref, live)
# Representative run:
# MMD^2 = 0.037, permutation p = 0.005
# domain classifier AUC = 0.80
print(f"MMD^2 = {mmd2:.3f} perm p = {mmd_p:.3f}")
print(f"domain classifier AUC = {auc:.2f}")
AUC_ALERT = 0.65 # AUC this far above 0.5 => sets are clearly separable
if mmd_p < 0.01 and auc >= AUC_ALERT:
print("ALERT: embedding drift (MMD + classifier agree). "
"Cluster the live embeddings to find the new topic(s).")
Two takeaways. First, the domain-classifier AUC is the most interpretable embedding-drift number you can report — 0.80 means a simple model tells reference from live 80% of the time, which is unambiguous drift, and the classifier’s coefficients point at which dimensions moved. Second, MMD’s raw value (0.037) is meaningless without the permutation p-value — the same shift under a different bandwidth produces a different MMD magnitude, so always report significance, never the bare statistic. When these two disagree, trust the classifier for “is there drift?” and use MMD as a cheaper continuous tripwire between retrains.
Saying it out loud. For embeddings I’d default to the domain classifier, because it gives the most interpretable number you can put in front of a stakeholder: train a small model to tell reference from live, and if it hits an ROC-AUC of 0.80, it’s distinguishing them 80% of the time, which is unambiguous drift — and the classifier’s coefficients point at which dimensions moved, so you get a lead on why. MMD I’d run as the cheaper continuous tripwire between retrains, but always with a permutation p-value attached, never the raw statistic. If the two disagree, trust the classifier for “is there drift” and treat MMD as an early warning. The threshold I’d quote as a starting point is AUC 0.65, recalibrated per embedding model.
Build it in practice — extended: rolling embedding drift + a combined multi-signal alert
The two worked examples above each run in isolation: PSI+KS on one scalar, MMD+classifier on one static pair of windows. Production monitors need two more things the isolated examples skip over: (1) embedding drift computed continuously, over a rolling window, from real text rather than a pre-made array; and (2) an alert that only fires when independent signals agree, so a single noisy metric cannot page anyone by itself. This section builds both, end to end, and then runs a realistic scenario — three quiet weeks, one seasonal false-positive week, and four weeks of genuine gradual drift — to show the fusion rule working exactly as intended.
Real embeddings, swapped for a deterministic stand-in. In production you would embed every prompt with a sentence-embedding model, e.g.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2") # 384-dim, unit-normalized
embeddings = model.encode(texts, normalize_embeddings=True)
To keep this example runnable offline with no model download and no GPU, the code below swaps in a deterministic hashing-embedding with the identical interface (list[str] -> (n, dim) unit vectors). Only the embed() function would change in production; the monitor, the rolling window, and the fusion logic are exactly what you would deploy.
import numpy as np
from collections import deque
from scipy import stats
rng = np.random.default_rng(7)
EMBED_DIM = 64 # small for a fast demo; a real MiniLM embedding is 384-dim
def embed(texts, dim=EMBED_DIM):
"""Stand-in for model.encode(texts, normalize_embeddings=True).
Deterministic and offline: hashes each token to a fixed random
direction and sums, then L2-normalizes. Same input/output contract
as a real sentence-transformer call.
"""
out = np.zeros((len(texts), dim))
for i, t in enumerate(texts):
for tok in t.lower().split():
h = abs(hash(tok)) % (2**32)
out[i] += np.random.default_rng(h).normal(size=dim)
norms = np.linalg.norm(out, axis=1, keepdims=True)
return out / np.clip(norms, 1e-8, None)
class RollingEmbeddingDriftMonitor:
"""
Tracks centroid + cosine drift of a live embedding stream against a
frozen reference centroid, over a rolling window of recent requests.
"""
def __init__(self, reference_embeddings, window_size=300,
cosine_alert=0.02, euclid_alert=0.20):
self.ref_centroid = reference_embeddings.mean(axis=0)
self.ref_centroid /= np.linalg.norm(self.ref_centroid)
self.window = deque(maxlen=window_size) # oldest requests fall off
self.cosine_alert = cosine_alert
self.euclid_alert = euclid_alert
def update(self, new_embeddings):
"""Feed a batch of new (already-normalized) embeddings, return the score."""
for e in new_embeddings:
self.window.append(e)
return self.score()
def score(self):
if len(self.window) < 10:
return None # not enough data to trust a centroid yet
live_centroid = np.mean(self.window, axis=0)
live_centroid /= np.linalg.norm(live_centroid)
cos_dist = 1.0 - float(np.dot(self.ref_centroid, live_centroid))
euclid_dist = float(np.linalg.norm(self.ref_centroid - live_centroid))
return {
"cosine_dist": cos_dist,
"euclid_dist": euclid_dist,
"fires": cos_dist >= self.cosine_alert or euclid_dist >= self.euclid_alert,
}
def psi_len(ref, live, bins=10, eps=1e-6):
"""Same frozen-reference-edges PSI as the worked example above."""
edges = np.quantile(ref, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
r_counts, _ = np.histogram(ref, bins=edges)
l_counts, _ = np.histogram(live, bins=edges)
r_prop = np.clip(r_counts / r_counts.sum(), eps, None)
l_prop = np.clip(l_counts / l_counts.sum(), eps, None)
return float(np.sum((l_prop - r_prop) * np.log(l_prop / r_prop)))
def combined_alert(ref_lengths, live_texts, live_lengths, monitor):
"""Fire ONLY when >=2 of {PSI, KS, embedding-drift} agree."""
psi_val = psi_len(ref_lengths, live_lengths)
ks = stats.ks_2samp(ref_lengths, live_lengths)
emb_result = monitor.update(embed(live_texts))
psi_fires = psi_val >= 0.20
ks_fires = (ks.statistic >= 0.10) and (ks.pvalue < 0.01)
emb_fires = bool(emb_result and emb_result["fires"])
votes = int(psi_fires) + int(ks_fires) + int(emb_fires)
return {
"psi": round(psi_val, 3),
"ks_D": round(float(ks.statistic), 3),
"emb_cos_dist": round(emb_result["cosine_dist"], 3) if emb_result else None,
"votes": votes,
"ALERT": votes >= 2, # require agreement -- this is the whole point
}
Simulating a realistic month. A support-chat reference of billing/support/how-to questions; then three stable weeks; then one week where a billing surge (a seasonal, calendar-driven spike, not a product change) shifts the topic mix hard; then four weeks where a genuinely new topic — a just-shipped product feature — grows from 10% to 45% of traffic.
def make_batch(n, topic_mix, rng):
topics, probs = list(topic_mix), list(topic_mix.values())
chosen = rng.choice(topics, size=n, p=probs)
texts = [f"{t} question about {t} details number {rng.integers(0, 999)}" for t in chosen]
# prompts about the new feature run longer (users paste config/logs) --
# length scales with how much of the traffic is the new topic
lengths = rng.gamma(shape=4.0, scale=30.0, size=n) + 40.0 * topic_mix.get("new_feature", 0.0)
return texts, lengths
ref_texts, ref_lengths = make_batch(2000, {"billing": 0.4, "support": 0.4, "howto": 0.2}, rng)
monitor = RollingEmbeddingDriftMonitor(embed(ref_texts), window_size=300)
for week in range(1, 4): # stable weeks
texts, lengths = make_batch(300, {"billing": 0.4, "support": 0.4, "howto": 0.2}, rng)
print(f"week {week} (stable): ", combined_alert(ref_lengths, texts, lengths, monitor))
texts, lengths = make_batch(300, {"billing": 0.75, "support": 0.20, "howto": 0.05}, rng)
print("week 4 (seasonal spike):", combined_alert(ref_lengths, texts, lengths, monitor))
for week, share in zip(range(5, 9), [0.10, 0.20, 0.30, 0.45]): # real, growing drift
s = 1.0 - share
mix = {"billing": 0.4*s, "support": 0.4*s, "howto": 0.2*s, "new_feature": share}
texts, lengths = make_batch(300, mix, rng)
print(f"week {week} (real drift {share:.0%}):", combined_alert(ref_lengths, texts, lengths, monitor))
Actual output of this code:
week PSI KS D cos_dist votes ALERT
week 1 (stable) 0.017 0.038 0.001 0 False
week 2 (stable) 0.02 0.034 0.001 0 False
week 3 (stable) 0.038 0.051 0.0 0 False
week 4 (seasonal spike) 0.009 0.036 0.071 1 False
week 5 (real drift 10%) 0.034 0.078 0.005 0 False
week 6 (real drift 20%) 0.067 0.11 0.027 2 True
week 7 (real drift 30%) 0.158 0.148 0.054 2 True
week 8 (real drift 45%) 0.262 0.19 0.14 3 True
Read this table as the whole point of the section. Week 4 is a large, real shift in topic mix (billing jumps from 40% to 75% of traffic in a single week) — a naive single-metric monitor watching topic share with PSI/chi-square would page on-call immediately. But prompt length barely moves (billing questions are not systematically longer or shorter than support questions), so PSI and KS both stay quiet; only the embedding-centroid signal fires, casting 1 of 3 votes — below the 2-vote bar, so the combined alert correctly stays silent on what is, in fact, ordinary seasonal or promotional traffic. Weeks 6 through 8 tell the opposite story: a genuinely new topic keeps growing, dragging prompt length up with it (users paste extra config for the new feature), so the embedding signal and the length-based tests climb together — by week 6 two of three signals agree and the alert fires, and by week 8 all three agree, which is the correct place to escalate from “investigate” to “page.” The system did exactly the job description from earlier in this chapter: the AND-of-independent-signals gate suppressed the false positive and still caught the real drift, with increasing signal count doubling as a built-in severity ladder.
Two structural notes for reuse: the RollingEmbeddingDriftMonitor’s window is a deque(maxlen=...), so it is O(1) to update and always reflects only the most recent window_size requests — old traffic ages out automatically, which is what makes this safe to run continuously rather than as a batch job. And the reference centroid, like the PSI bin edges earlier, is computed once, from the frozen reference, never recomputed from the live window — the same bug that silently zeroes out PSI (re-deriving bins from live data) would silently zero out this monitor too if you recomputed the reference centroid from the window it’s supposed to be compared against.
Saying it out loud. The lesson from running this on a realistic timeline is that the agreement rule does the heavy lifting. In the simulation, week four is a big genuine shift in topic mix — billing jumps from 40% to 75% of traffic — and a naive single-metric monitor would have paged. But prompt length barely moves, so only the embedding signal fires: one vote out of three, below the bar, no page. That’s the seasonal false positive correctly suppressed. Then from week six onward a genuinely new topic keeps growing and drags length up with it, so two signals agree and it fires, and by week eight all three do. The number of agreeing signals doubles as a built-in severity ladder, which is the cheapest false-positive lever you have.
Calibrating embedding-drift thresholds from history, not from a blog post
Earlier sections warn repeatedly that MMD and centroid/cosine-distance thresholds have no intrinsic scale — a value that’s alarming for one embedding model, dataset, and window size is noise for another. The discipline this demands in practice: replay the monitor over many historical known-good windows (weeks with no reported incident) and set the alert threshold from a high percentile of the resulting score distribution, rather than importing a threshold from this or any other chapter.
def calibrate_threshold_from_history(monitor_factory, historical_windows, percentile=99):
"""
monitor_factory: () -> a fresh RollingEmbeddingDriftMonitor built from
the SAME frozen reference centroid every time, so each replay
starts from identical state.
historical_windows: list of embedding batches, each a KNOWN-GOOD window
(e.g. the last 12 months of weeks with no reported incident).
Returns a calibrated cosine_dist threshold at the given percentile.
"""
scores = []
for window in historical_windows:
m = monitor_factory()
result = m.update(window)
if result is not None:
scores.append(result["cosine_dist"])
return float(np.percentile(scores, percentile))
# Replay 52 known-good weekly windows (same generator as the simulation above,
# stable topic mix throughout -- no incident in any of them).
historical_windows = []
for _ in range(52):
texts, _ = make_batch(300, {"billing": 0.4, "support": 0.4, "howto": 0.2}, rng)
historical_windows.append(embed(texts))
threshold = calibrate_threshold_from_history(
lambda: RollingEmbeddingDriftMonitor(embed(ref_texts), window_size=300),
historical_windows, percentile=99)
print(f"empirically calibrated cosine_dist threshold (P99 of 52 known-good weeks) = {threshold:.4f}")
# empirically calibrated cosine_dist threshold (P99 of 52 known-good weeks) = 0.0021
That calibrated figure — 0.0021 — is roughly 10x tighter than the illustrative cosine_alert=0.02 used in the worked example above. Neither number is “correct” in the abstract; the calibrated one is correct for this reference population and this embedding function, which is the entire point. Recalibrate whenever the reference window, the embedding model, or the window size changes — all three change the natural spread of the score, and a threshold calibrated under one regime will silently over- or under-fire under another.
Saying it out loud. This is where I’d push back on any threshold quoted from a blog post, including this chapter’s. Embedding distances have no intrinsic scale, so the right procedure is to replay your monitor over a year of known-good windows — weeks with no reported incident — and set the alert at a high percentile of that score distribution. In this example the illustrative threshold was 0.02 and the empirically calibrated P99 came out at 0.0021, roughly ten times tighter. Neither is correct in the abstract; the calibrated one is correct for that reference population and that embedding function. And recalibrate whenever the reference, the embedding model, or the window size changes, because all three move the natural spread of the score.
A minimal judge / anchor-set attribution monitor
The 2025–2026 landscape section above cites a sharp finding: if your only quality signal is an LLM judge’s score, a routine judge-model version bump or prompt edit produces an alarm that is indistinguishable from real product decay, and naive rolling-average monitors reportedly false-alarm on the large majority of judge-only changes. The fix is small enough to implement directly: keep a fixed, human-labeled anchor set, re-score it with whatever judge is currently deployed at every check, and use the change in the judge’s own bias on those anchors to separate “the judge moved” from “the system moved.” Below is a minimal, runnable version — the judge itself is mocked (a deterministic function of a hidden “true quality” plus a judge-specific bias and noise) so the four possible worlds — nothing changed, only the product decayed, only the judge changed, and both — can all be demonstrated with real numbers.
import numpy as np
rng = np.random.default_rng(11)
# 40 fixed anchor items with a stable, human-labeled quality in [0, 1].
N_ANCHOR = 40
anchor_human_scores = rng.uniform(0.6, 0.95, size=N_ANCHOR)
def make_judge(judge_shift=0.0, noise=0.03, seed=0):
"""A mock judge: true quality + a fixed judge-specific bias + noise.
judge_shift models a version bump / prompt edit that recalibrates the
judge's scoring -- it moves EVERY score, anchors included."""
r = np.random.default_rng(seed)
def score_fn(item_id, true_quality):
return true_quality + judge_shift + r.normal(0, noise)
return score_fn
class JudgeAnchorAttributor:
"""Separates system drift from judge drift via a frozen anchor set."""
def __init__(self, anchor_human_scores, z_alert=2.5, system_drop_alert=0.10):
self.anchor_human_scores = np.asarray(anchor_human_scores, dtype=float)
self.z_alert = z_alert
self.system_drop_alert = system_drop_alert
self.baseline_gap_mean = None
self.baseline_gap_std = None
def calibrate(self, judge_score_fn):
"""Call once, right after deploying the current judge version."""
scores = np.array([judge_score_fn(i, q) for i, q in
enumerate(self.anchor_human_scores)])
gap = scores - self.anchor_human_scores # judge bias on anchors
self.baseline_gap_mean = gap.mean()
self.baseline_gap_std = gap.std() + 1e-6
def check(self, judge_score_fn, live_true_quality):
# Re-score the SAME frozen anchors with whatever judge is live now.
anchor_scores = np.array([judge_score_fn(i, q) for i, q in
enumerate(self.anchor_human_scores)])
gap = anchor_scores - self.anchor_human_scores
se = self.baseline_gap_std / np.sqrt(len(self.anchor_human_scores))
judge_drift_z = float((gap.mean() - self.baseline_gap_mean) / se)
judge_drifted = abs(judge_drift_z) >= self.z_alert
# Score live traffic, then correct for the judge bias just measured
# so a judge shift alone can't masquerade as a system quality drop.
live_scores = np.array([judge_score_fn(1000 + i, q) for i, q in
enumerate(live_true_quality)])
corrected = live_scores - gap.mean()
system_quality = float(corrected.mean())
baseline_quality = float(self.anchor_human_scores.mean())
system_dropped = (baseline_quality - system_quality) >= self.system_drop_alert
if judge_drifted and system_dropped:
verdict = "system+judge"
elif judge_drifted:
verdict = "judge"
elif system_dropped:
verdict = "system"
else:
verdict = "none"
return {"judge_drift_z": round(judge_drift_z, 2),
"system_quality": round(system_quality, 3),
"verdict": verdict}
attributor = JudgeAnchorAttributor(anchor_human_scores)
judge_v1 = make_judge(judge_shift=0.0, seed=1)
attributor.calibrate(judge_v1)
live_true_quality_stable = rng.uniform(0.60, 0.95, size=200)
live_true_quality_decayed = rng.uniform(0.35, 0.70, size=200) # real regression
judge_v2 = make_judge(judge_shift=-0.18, seed=2) # a version bump
print("week 1 (nothing changed): ", attributor.check(judge_v1, live_true_quality_stable))
print("week 2 (real product decay): ", attributor.check(judge_v1, live_true_quality_decayed))
print("week 3 (judge version bump only):", attributor.check(judge_v2, live_true_quality_stable))
print("week 4 (judge bump + real decay):", attributor.check(judge_v2, live_true_quality_decayed))
Actual output:
week 1 (nothing changed): {'judge_drift_z': -1.01, 'system_quality': 0.764, 'verdict': 'none'}
week 2 (real product decay): {'judge_drift_z': -0.19, 'system_quality': 0.523, 'verdict': 'system'}
week 3 (judge version bump only): {'judge_drift_z': -40.8, 'system_quality': 0.764, 'verdict': 'judge'}
week 4 (judge bump + real decay): {'judge_drift_z': -42.57, 'system_quality': 0.527, 'verdict': 'system+judge'}
Read the four rows as the whole point: week 2’s real decay is caught (system_quality drops to 0.523, well past the 0.10 alert margin) while the anchor-derived judge_drift_z stays near zero — nothing about the judge moved, so the verdict is cleanly system. Week 3 is the case naive monitors get wrong: the live traffic’s true quality never changed, but a judge-shift of (-0.18) still shows up as a massive judge_drift_z because the anchors — which have fixed, known-correct human scores — expose the judge’s new bias immediately; the monitor correctly reports judge, not a phantom product regression, and critically the bias-corrected system_quality (0.764) still reads as healthy because the attribution logic subtracted out the measured judge bias before judging the system. Week 4 shows both moving at once and reports system+judge — the one case where you should page a human for two separate reasons rather than one, and go fix the judge and the product independently rather than assuming a single root cause. This is the concrete mechanism behind the abstract claim in the landscape section above: the anchor set is what lets the number “quality dropped” actually mean “the product got worse,” instead of silently meaning “our ruler moved.”
Saying it out loud. The trap with LLM-as-judge monitoring is that your ruler is itself a versioned model. Bump the judge’s version or edit its prompt by one line and the score moves exactly the way a real product regression would — one 2026 paper reports a naive rolling z-test monitor false-alarming on 75% of streams where only the judge had changed. The fix is small: keep a fixed, human-labeled anchor set that never changes, and re-score it with whatever judge is currently deployed at every check. Because the anchors are frozen, any movement in their scores can only be the judge drifting, so you can attribute an alarm to none, system, or judge, and subtract the measured judge bias out before you decide the product got worse. And the lesson generalizes: any detector that is itself learned or versioned — a domain classifier, an embedding model, a judge — needs its own frozen-anchor check.
Methods comparison
| Method | Data type | Output | Sensitivity | Pros | Cons |
|---|---|---|---|---|---|
| PSI | Scalar / categorical | Unbounded score, standard thresholds (0.1 / 0.2) | Sensitive where relative mass changes; binning-dependent | One interpretable number; no p-value plumbing; localizes to bins; industry-standard cutoffs | Needs binning choice; unstable with sparse bins; not for raw vectors |
| KS test | Continuous scalar | ( D\in[0,1] ) + p-value | Strong at distribution center, weak in tails | Non-parametric, no binning, principled p-value | Hypersensitive on huge windows; univariate only; tail-blind; not for categoricals |
| MMD | Vectors / embeddings | ( \ge 0 ), null via permutation | Detects general distributional shape shifts | Truly multivariate; kernel-flexible; theoretically grounded | ( O(n^2) ) cost; threshold not intuitive; bandwidth ( \gamma ) tuning matters |
| Domain classifier | Vectors / embeddings | ROC-AUC ( \in [0.5,1] ) | Sees any separable shift, incl. multimodal | Interpretable (AUC + feature importance), robust across embedding types, a strong default | Needs training per window; can overfit small windows |
| Centroid / cosine dist | Vectors / embeddings | Distance ( \ge 0 ) | Only mean shift | Cheap, streaming, interpretable | Blind to variance & multimodal splits; threshold hand-tuned |
| Wasserstein (per-dim) | Scalar / per-embedding-dim | ( \ge 0 ), in feature units | Sensitive to any shift incl. tails | Metric with real units; tail-aware | Univariate per dim; needs aggregation across dims |
| Chi-square | Categorical | ( \chi^2 ) + p-value | Any count-table change | Right tool for labels; simple | Needs adequate expected counts per cell |
Rule of thumb: scalars ⇒ PSI or KS; categoricals ⇒ chi-square or PSI; embeddings ⇒ domain classifier (default) or MMD; want a cheap tripwire ⇒ centroid distance.
Saying it out loud. If I had to compress the method choice into one line: scalars use PSI or KS, categoricals use chi-square, embeddings use a domain classifier by default or MMD, and centroid distance is your cheap tripwire. The reasoning behind the defaults is about what each one can see. PSI gives you one interpretable number and tells you which bin moved, but it needs a binning choice and can’t touch raw vectors. KS is principled and binning-free but univariate and hypersensitive on large windows. The domain classifier wins for embeddings because it sees any separable shift including multimodal ones and it’s interpretable — the cost is that you retrain it per window, and on a small window it can overfit and tell you there’s drift when there isn’t.
LLM-specific drift signals
Generic distribution tests get you far, but LLM serving has signals you should monitor by name. In every case the feature is LLM-specific; the test is the same PSI/KS/MMD/chi-square machinery.
- Topic / intent shift. Run a lightweight topic or intent classifier (or cluster embeddings) and watch the category mix with PSI/chi-square over the topic histogram. A launch, a season, or an outage upstream shows up here first.
- Rising refusals. Track the fraction of outputs that are refusals or safety deflections (“I can’t help with that”). A climbing refusal rate on stable-looking inputs often means a system-prompt or provider-side model change, not user behavior. Alert on the rate, and segment by topic — a global rise and a single-topic rise mean very different things.
- Jailbreak / adversarial probing. A rising share of prompts matching known jailbreak patterns, or a spike in prompt-injection markers, is an attack signal masquerading as input drift. Monitor it as its own series so it doesn’t get averaged away in overall input drift.
- Quality decay. With no labels, use proxies: an LLM-as-judge score on a sampled slice, self-consistency across samples, retrieval-hit rate for RAG, or user thumbs-down rate. Treat judge scores as a drifting feature themselves and run PSI/KS on them window over window.
- Output length shift. Sudden shortening often signals truncation, a max-tokens change, or the model bailing early; sudden lengthening can signal rambling or a prompt-template regression. Cheap to compute, high signal.
- Latency / cost shift. p50/p95 latency and tokens-per-request are drift signals too. A provider swapping the model behind an alias frequently shows up as a latency and length change before anyone notices quality — sometimes it is your earliest signal of a silent model change.
- Language / encoding shift. A new language appearing, or a jump in non-ASCII / emoji ratio, changes tokenization and can silently degrade a model tuned for English.
- Format / schema conformance. For structured-output endpoints, monitor the rate of JSON-parse failures or schema-validation errors. A creeping failure rate is quality drift you can measure without labels.
Saying it out loud. Generic tests get you most of the way, but there are LLM-specific features worth naming — the test is the same machinery, only the feature changes. Topic and intent mix with chi-square. Refusal rate, which climbing on stable-looking inputs usually means a system prompt or provider-side model change, not user behavior. Jailbreak pattern matches, monitored as their own series so an attack doesn’t get averaged into general input drift. Output length, where sudden shortening often means truncation or a max-tokens change. Format conformance — JSON parse failure rate — which is the rare quality signal you can measure with no labels at all. And latency plus tokens-per-request, because when a provider silently swaps the model behind an alias, latency and length usually move before anyone notices quality.
The 2025–2026 landscape
As of 2026, drift monitoring for LLM systems splits into two camps that are gradually converging in practice: adapt classic distributional-drift tooling to embeddings, or lean on an LLM judge for the semantic understanding that statistics can’t see. Here is who is doing what, and how the debate is actually being resolved.
Camp 1 — adapt classic ML monitoring to embeddings:
- Evidently AI (Olga Filippova and Elena Samuylova, 5 methods to detect drift in ML embeddings; first published May 17, 2023, last updated July 16, 2025 — https://www.evidentlyai.com/blog/embedding-drift-detection) documents five methods, the same ones covered earlier in this chapter: Euclidean centroid distance, cosine distance, a domain classifier scored by ROC-AUC, “share of drifted components” (treat each embedding dimension as a numeric feature and run PSI/KS per dimension, then report what fraction of dimensions drifted), and MMD. The guide frames these explicitly for “retrieval pipelines or high-volume user input analysis” — i.e., RAG.
- NannyML’s multivariate drift detector (https://nannyml.readthedocs.io/en/stable/how_it_works/multivariate_drift.html) takes a related but distinct approach: fit PCA on the reference set, reconstruct live data through that PCA basis, and alert on rising reconstruction error — a single scalar capturing “the live data no longer looks like anything the reference basis can explain,” with no domain classifier required.
- WhyLabs’ LangKit (https://github.com/whylabs/langkit, docs at https://docs.whylabs.ai/docs/large-language-model-monitoring/) takes a third angle: rather than one drift test, it extracts text-quality, text-relevance, security (jailbreak/injection pattern matches), and sentiment/toxicity signals per request via
whylogs, then relies on the resulting profile’s drift over time inside the WhyLabs platform. The underlying math is the same PSI-style profile comparison used throughout this chapter, applied to LLM-specific extracted features instead of raw tabular columns.
Camp 2 — LLM-native, judge-based monitoring:
- A March 2026 market survey (Galileo, 9 Best LLM Drift Monitoring Platforms in 2026, https://galileo.ai/blog/best-llm-output-drift-monitoring-platforms) reviews nine commercial platforms — Galileo, Arize AI, LangSmith, Langfuse, Arthur AI, WhyLabs, Weights & Biases (W&B Weave), Aporia, and Helicone — and stakes out a position squarely against Camp 1: “traditional ML monitoring relies on statistical tests like KL divergence or Population Stability Index over numerical distributions,” but these “often fail to capture semantic shift.” By the survey’s count, only three of the nine platforms offer purpose-built semantic-drift algorithms rather than requiring custom implementation.
- The pitch for judge-based detection is real and worth stating plainly: PSI on token length cannot tell you that the model started giving confidently wrong answers about a new product feature while staying exactly the same length.
The debate is being resolved, in practice, as “both, layered, weighted”:
- A representative synthesis (dev.to, aiwithmohit, Your LLM Is Lying to You Silently: 4 Statistical Signals That Catch Drift Before Users Do, March 29, 2026 — https://dev.to/aiwithmohit/your-llm-is-lying-to-you-silently-4-statistical-signals-that-catch-drift-before-users-do-4cg2) proposes four signals used together rather than competing:
- KL divergence on token-length distributions, alert at ( \ge 0.15 );
- embedding cosine drift on the centroid, alert when similarity drops below 0.82;
- LLM-as-judge scoring — slower (5–8 days to flag decay) but semantically aware;
- refusal-rate fingerprinting — fastest (3–5 days), catches safety-filter recalibration.
- The author’s own advice is explicitly tiered and cost-aware: “start with KL divergence… add embedding drift next week… layer in LLM-as-judge when you have budget,” the four combined via weighted voting (roughly KL 0.25, embedding 0.30, judge 0.30, refusal 0.15) to reach a reported ~0.93 AUC on labeled drift incidents. That is precisely the “require agreement across independent signals” pattern built out earlier in this chapter — just with a judge score added as one more vote, and a price tag attached to it.
Judges rot too — and that changes the design of the monitor:
- The sharpest 2026 argument against treating an LLM judge as a stable oracle for drift comes from Yitao Li’s Who Drifted: the System or the Judge? Anytime-Valid Attribution in LLM Evaluation Pipelines (arXiv:2606.15474, June 2026 — https://arxiv.org/abs/2606.15474). The problem: if your drift monitor is “ask a judge model to score outputs and watch the trend,” a routine judge-model version bump or a one-line prompt edit to the judge itself produces a drift alarm indistinguishable from real product decay — the paper reports that a naive rolling z-test monitor false-alarmed on 75% of streams where nothing about the product had changed, only the judge had.
- The proposed fix, implemented in runnable form earlier in this chapter: keep a small, fixed, human-labeled anchor set that never changes, and re-score it with the current judge at every check. Because the anchors are frozen, any movement in their scores can only be the judge drifting, letting you attribute an alarm to one of
{none, system, judge}instead of shrugging at “something drifted.” On real judge-version-bump and judge-prompt-edit datasets this attributed correctly on 60 of 60 and 110 of 120 runs respectively. - The lesson generalizes past LLM judges specifically: any drift detector that is itself a learned or versioned component — a domain classifier, an embedding model, a judge — needs its own frozen-anchor check, or you cannot tell “the world changed” from “your ruler changed.”
RAG and agentic systems get their own drift vocabulary:
- For retrieval-augmented systems, the 2025 RAGOps: Operating and Managing Retrieval-Augmented Generation Pipelines paper (Xu, Weytjens, Zhang, Lu, Weber, and Zhu — CSIRO’s Data61, TU Munich, UNSW, and Fraunhofer — arXiv:2506.03401 — https://arxiv.org/abs/2506.03401) proposes treating retrieval coverage itself as a drift signal: continuously compare live query embeddings against a held-out test-query set, and alert when fewer than 85% of live queries achieve an adequate similarity match to anything in that test set. This is a direct, RAG-specific instance of the embedding-drift machinery in this chapter, aimed at “the index or corpus moved out from under the retriever” rather than at the language model itself.
- For multi-agent and long-running agentic systems, two 2026 threads stand out. Agent Drift: Quantifying Behavioral Degradation in Multi-Agent LLM Systems Over Extended Interactions (Abhishek Rath, arXiv:2601.04170, January 2026 — https://arxiv.org/abs/2601.04170) proposes an “Agent Stability Index” scored across twelve dimensions (response consistency, tool-usage patterns, reasoning-pathway stability, inter-agent agreement, among others) to decompose agent decay into semantic drift (deviation from the original objective), coordination drift (breakdown in multi-agent consensus), and behavioral drift (emergence of undesirable strategies), with mitigations including episodic-memory consolidation and drift-aware routing.
- A practitioner write-up on monitoring LangGraph agents in production (Vadim Nicolai, June 15, 2026 — https://vadim.blog/agent-defect-drift-detection-production/) implements six concrete runtime signals: tool-entropy collapse (the ratio of distinct tool calls to total calls dropping below 0.4, i.e. the agent is looping on the same few tools), role drift and execution-gap (both judge-scored: did the agent abandon its assigned framing, did it claim to have done something it did not), illegal state transitions and excessive loops (both cheap, deterministic graph checks), and dead-ends. It deliberately runs the cheap deterministic checks first, reserving a single fenced judge call only for genuinely ambiguous cases, with hard violations routed to human review.
- That “cheap deterministic gate before an expensive judge call” ordering is the same cost-aware pattern the field is converging on everywhere in 2025–2026: statistical and embedding signals as the always-on tripwire, judge-based signals as the higher-cost confirmation layer — and, per the anchor-set lesson above, the judge itself kept under its own drift watch, not trusted as ground truth.
Saying it out loud. The field split into two camps and is converging on “both, layered.” One camp adapts classic ML monitoring to embeddings — Evidently’s five embedding-drift methods, NannyML’s PCA reconstruction-error approach, WhyLabs extracting LLM-specific features and drifting the profile. The other camp argues statistics can’t see semantics: PSI on token length will never tell you the model started giving confidently wrong answers about a new product feature at exactly the same length. What’s actually settling out is a weighted vote across cheap statistical signals plus a judge, with the judge as a higher-cost confirmation layer rather than an always-on one. And the 2026 twist that changes the design is that the judge rots too, so the judge gets its own frozen-anchor drift watch instead of being treated as an oracle.
Windowing & thresholds
The math is easy; the windowing is where judgment lives.
- Reference window. Prefer a fixed, curated reference (your eval-time distribution or a known-good production period), not a rolling one — a rolling reference lets slow drift redefine “normal” and hides exactly the gradual decay you care about. Refresh it deliberately, versioned, when you re-eval or retrain, and keep the old one around so you can diff.
- Live window. Big enough to be stable, small enough to be timely. Two common shapes: a tumbling window (disjoint hourly/daily batches — clean, but bursty) and a sliding window (smoother, but overlapping samples correlate, so consecutive readings are not independent). Size it to your traffic: a KS test wants hundreds-to-thousands of points to be meaningful; below ~50 the statistic is mostly noise.
- Thresholds. Start from priors (PSI 0.2, KS ( D>0.1 ) with ( p<0.01 )), then calibrate against your own history: replay past known-good weeks, look at the natural spread of each metric, and set thresholds a few standard deviations above that baseline. For embedding methods with no intrinsic scale (MMD, centroid distance), thresholds must be empirical (permutation null or historical quantiles) — a literature number is meaningless for your data.
- Require agreement / persistence. Two independent signals agreeing, or one signal tripping for ( k ) consecutive windows, cuts false alarms dramatically versus a single-window single-metric trigger. This is the cheapest reliability lever you have.
- Segment before you aggregate. A global metric averages away a severe shift confined to one client, region, or topic. Compute drift per meaningful segment; alert on the worst segment, not the mean.
Saying it out loud. The math is easy; the windowing is where the judgment lives. Use a fixed, curated reference — your eval-time distribution or a known-good production period — never a rolling one, because a rolling reference lets slow decay redefine “normal” and hides exactly the gradual rot you’re trying to catch. The live window has to be big enough to be stable and small enough to be timely; below about fifty points a KS statistic is mostly noise. Start from priors on thresholds, then calibrate against your own history by replaying known-good weeks. And two rules do most of the false-positive work: require agreement across independent signals or persistence across consecutive windows, and segment before you aggregate, because a global metric will average away a severe shift confined to one customer or one region.
Response playbook: what to do on drift
An alert is a question, not a verdict. Work the ladder:
- Confirm it’s real. Is it persistent across windows or a single-window blip? Do independent signals agree? Rule out a data-pipeline bug (a logging change, a tokenizer upgrade, a new client version that reformats prompts looks exactly like drift) before anything else. This is the single most common “drift” root cause.
- Localize it. Which feature, which segment, which topic, which customer, which region? Slice by metadata. “Overall PSI up” is useless; “prompt-length drift confined to the mobile client after the 4.2 release” is actionable.
- Classify it. Input drift, output drift, or (suspected) concept drift? Benign (new-but-handled use case) or harmful (quality decay)? Input drift with stable outputs may need nothing but a note and an eval-coverage ticket.
- Re-evaluate. Run your eval suite against a fresh sample of current traffic, not last quarter’s fixtures. This is the only way to convert “the distribution moved” into “quality actually dropped.” If you have any labels or can label a slice, do it now — even 100 hand-labeled current examples beats zero.
- Mitigate, cheapest first:
- Prompt / template fix if a system-prompt or template regression is the cause (fastest, most common).
- Roll back the model, provider alias, or config change if the drift lines up with a deploy.
- Guardrail / route — add a filter or route the drifted segment to a fallback or a stronger model.
- Expand evals to cover the new distribution so it stops being a blind spot next time.
- Retrain / fine-tune / update RAG index — the heaviest, slowest lever; reserve it for genuine, persistent concept drift, not a one-week anomaly.
- Close the loop. Update the reference window (versioned) once you have accepted the new normal, and write down what the alert meant so the next on-call doesn’t re-derive it at 2am. A drift runbook with past incidents is worth more than any single dashboard.
Saying it out loud. The framing I’d lead with is that a drift alert is a question, not a verdict — it’s a leading indicator that warrants investigation, not an incident. So the ladder is: confirm it’s real, localize it, classify it, re-evaluate, then mitigate cheapest-first. Confirm comes first because the single most common root cause of “drift” is a data-pipeline bug — a logging change, a tokenizer upgrade, a new client version reformatting prompts — and all of those look exactly like drift. Localize matters because “overall PSI is up” is useless while “prompt-length drift confined to the mobile client after the 4.2 release” is actionable. And on mitigation, order matters: a prompt or template fix is fastest and most often the real cause, rollback next, retraining last — that’s the heaviest lever and it’s rarely the right answer to a one-week anomaly.
Production case studies & war stories
Two composite incidents, each assembled from patterns that recur across postmortems for deployed LLM products. Names and specifics are illustrative; the mechanisms and the lessons are exactly the failure modes this chapter exists to prevent.
War story 1: the silent upstream change that took five weeks to notice
The setup. A support-chat assistant had been live for four months, evals green, dashboards calm. The product team shipped an unrelated change: a redesigned onboarding flow that, as a side effect, linked a new class of trial users straight into the chat widget from a “getting started” tooltip — a change that never touched the model, the prompt template, or any file the ML team owned.
What actually happened:
- The new users asked simpler, more repetitive setup questions than the assistant’s tuned-for-power-users training mix.
- The model, never having seen this exact style, quietly gave shallow, sometimes-wrong answers — but it never refused, and it never got noticeably shorter, the two signals the team already watched.
- Refusal rate stayed flat. Latency stayed flat. Nobody was running a topic-mix or embedding-centroid monitor, because the input side had “always looked fine” for four months and nobody expected an onboarding-flow decision to change what the model needed to handle.
How it surfaced. Five weeks later, as a slow bleed of support escalations and a dip in a lagging NPS survey — not from any monitor.
The retroactive reconstruction:
- Pulling week-by-week embeddings from logs showed the topic centroid had drifted steadily and measurably starting the day the onboarding change shipped.
- A domain-classifier AUC comparing week-1 to week-5 traffic came back at 0.89 — obvious, in hindsight.
- Re-running the eval suite against a sample of the new traffic showed accuracy on setup-style questions had been roughly 20 points below the tuned baseline the entire time.
The fix that shipped:
- An always-on topic-mix PSI plus embedding-centroid monitor, with a standing alert routed to the same channel as the product team’s release notes.
- A lightweight process change: any change that could alter who reaches the assistant, or how, gets flagged for a one-week close eval — not just changes to the model or the prompt itself.
The lesson. Input drift and output drift are genuinely decoupled, and it is entirely possible for a completely healthy-looking output dashboard to sit on top of five weeks of degrading quality. Monitoring only refusal rate and latency builds an alarm that structurally cannot ring for this failure mode — which is why this chapter insists on watching inputs and outputs separately, and why quality decay concentrated in a new user population needs periodic re-evaluation on fresh traffic, not just a static dashboard.
Saying it out loud. The reason this one took five weeks is structural, not sloppy. A product team shipped an onboarding change that routed a new class of trial users into the chat widget — nothing touching the model, the prompt, or any file the ML team owned. Those users asked simpler questions the assistant had never been tuned for, and it answered them shallowly, but it never refused and never got shorter, which were the only two signals anyone watched. Refusal rate flat, latency flat, quality quietly twenty points below baseline for five weeks. In hindsight the topic centroid had moved from the day the change shipped, and a domain classifier separating week one from week five came back at 0.89 AUC. The lesson is that monitoring only outputs builds an alarm that structurally cannot ring for this — and that any change to who reaches your model, or how, deserves a close eval.
War story 2: the false alarm that paged an on-call engineer on a holiday
The setup. A different team’s drift monitor was better instrumented than the one above — PSI on prompt length, KS on the same, and a chi-square test on topic labels, all wired to page whoever was on call on any single signal, not on agreement.
What actually happened:
- On the last weekend of a fiscal quarter, billing-related questions spiked hard as customers rushed to check invoices and usage before month-end.
- The topic-mix chi-square test and the length PSI both crossed threshold within the same hour, and the monitor paged the on-call engineer at 2am on a holiday weekend.
- Compounding the bad luck, a routine model-provider version bump had gone out three days earlier. Seeing two red metrics and a recent deploy in the timeline, the on-call engineer made the reasonable-looking call to roll the provider version back — disrupting an unrelated improvement that had nothing to do with the spike.
How the real cause surfaced. The next morning, by accident: someone pulled up the same week from the prior fiscal quarter and found an almost identical billing-topic spike, on almost the same calendar day. The “drift” was calendar-driven and had recurred every quarter for at least two years — nobody had ever compared it against the matching period a year prior, because the monitor’s only frame of reference was “the last N requests” versus a single static reference captured outside of any high-variance period.
The fix that shipped:
- The alert rule changed from “any one signal fires” to “at least two of three signals agree” — directly the fusion rule built out in the extended worked example earlier in this chapter, which alone would have kept the alarm from firing, since neither length nor topic mix was outside its normal quarter-end range once compared like-for-like.
- A known high-variance calendar (quarter-end, major holidays, product-launch weeks) was added; for those windows, the monitor compares against the matching period from the prior cycle rather than last week’s traffic, exactly as the “Windowing & thresholds” section recommends.
- The provider-version rollback was reverted once the real cause was clear, at the cost of a wasted on-call page, a needless regression for a few hours, and a chunk of trust in the monitor that took months to rebuild.
The lesson. The “confirm it’s real” step at the top of the response playbook is not optional busywork — it is the single check that would have prevented this incident, and it was skipped under 2am pressure precisely because the alert message gave no indication that the pattern might be routine. An alert that cannot distinguish “this happens every quarter” from “this has never happened before” will eventually cost you either a false remediation or a muted channel, and often both, in that order.
Saying it out loud. This is the other side of the same coin. A better-instrumented team wired three signals to page on any single one of them, and on the last weekend of a fiscal quarter billing questions spiked, two metrics went red at 2am on a holiday, and the on-call — seeing red metrics plus a recent provider version bump in the timeline — rolled back something unrelated. The real cause turned out to be a calendar effect that had recurred every quarter for two years; nobody had ever compared against the matching period a year prior. Two fixes shipped: require two of three signals to agree, and maintain a known-high-variance calendar so quarter-end and holidays get compared against the equivalent prior period. The cost of getting this wrong isn’t just the wasted page — it’s the trust in the monitor, which took months to rebuild.
Failure modes & pitfalls
- Seasonality masquerading as drift. Traffic looks different at 3am, on weekends, on the 1st of the month. A reference captured Tuesday-midday will “drift” every Saturday. Compare like-for-like windows (same weekday/hour), or model the seasonality out; otherwise you train the team to ignore alerts.
- Wrong / stale reference window. Rolling references silently absorb slow decay. Too-short references are noisy. A reference from a broken period bakes the breakage into “normal” and you will never see the fault.
- Big-window hypersensitivity. On millions of requests, KS and chi-square reject everything — every trivial difference is “significant.” Pair p-values with effect-size gates, always.
- High-dimensional embedding drift is subtle. Centroid distance can read zero while the distribution splits in two; per-dimension tests miss cross-dimensional structure; and in high dimensions everything is far from everything (distance concentration), so raw Euclidean thresholds are treacherous. Prefer domain-classifier or MMD, and reduce dimensionality thoughtfully — drift can hide in the components you discarded, so don’t PCA blindly.
- No ground-truth labels. You can prove the inputs moved; you cannot prove quality dropped without evaluation. Unsupervised drift is a smoke alarm, not a diagnosis — never auto-remediate off it alone.
- Alerting on everything. One metric per feature per window with a tight threshold = a muted channel within a week. Aggregate, require persistence/agreement, and route by severity.
- Multiple comparisons. Running KS on 200 features guarantees ~10 “significant” hits at ( \alpha=0.05 ) by pure chance. Correct for it (Bonferroni / Benjamini–Hochberg FDR) or you will chase ghosts daily.
- Confusing statistic with significance. A tiny p-value on a ( D=0.02 ) shift is real but irrelevant; a large ( D ) on 8 samples is irrelevant noise. Report and gate on both the effect size and the p-value.
- Reference/live binning mismatch. Re-deriving quantile bin edges from each window makes every histogram uniform and PSI identically zero. Freeze edges from the reference — a subtle bug that silently disables the whole monitor.
- Silently swapping the embedding model. Upgrading from one sentence-embedding model to another (even a same-vendor version bump) changes the vector space itself — a reference centroid computed under the old model is meaningless compared against live embeddings from the new one. Any embedding-model change requires re-snapshotting the reference, exactly like a model deploy does for the scalar reference.
- Assuming retrieval coverage is fixed forever, in a RAG system. A stable generator sitting behind a corpus or index that has silently gone stale (new documents never ingested, old ones expired) will show flat generator-side metrics while answer quality quietly degrades. Monitor retrieval coverage (e.g., the fraction of live queries with an adequate similarity match against a held-out test-query set) as its own signal, not folded into overall input drift.
- Scoring agent trajectories only at the turn level. Per-turn scalar and embedding checks can miss behavior that only shows up across a whole trajectory — looping on the same tool, abandoning the assigned role over many turns, or claiming actions never taken. Add trajectory-level, deterministic checks (tool-call diversity, state-transition legality, loop counts) alongside per-turn ones.
- Trusting the judge as ground truth. A judge-based quality signal is itself a versioned model or prompt; a judge upgrade or a prompt tweak can move the score exactly like a real regression would. Without a fixed, human-labeled anchor set re-scored every check, you cannot tell “the product decayed” from “the judge changed” — see the anchor-set discussion in the 2025–2026 landscape section above.
Saying it out loud. If I’m naming the pitfalls that actually bite: seasonality masquerading as drift, because a reference captured Tuesday midday will “drift” every Saturday. Stale or rolling reference windows that absorb the decay you’re hunting. Big-window hypersensitivity, where on millions of requests every test rejects everything. Multiple comparisons — run KS on 200 features at the 5% level and you get about ten significant hits from pure chance, so correct for it or you chase ghosts daily. And two subtle ones: re-deriving bin edges from the live window silently zeroes PSI, and swapping the embedding model changes the vector space itself, so a reference centroid computed under the old model is meaningless. Above all: unsupervised drift is a smoke alarm, not a diagnosis — never auto-remediate off it alone.
Production checklist / what an interviewer probes
- “What exactly do you monitor, and why those features?” — Expect a named list: prompt length/token count, topic mix, refusal rate, output length, latency, format-conformance, and at least one embedding-based signal. Bonus for explaining input vs. output vs. concept coverage.
- “PSI vs. KS vs. MMD — when each?” — Scalars → PSI/KS; categoricals → chi-square; embeddings → MMD or domain classifier. Know PSI’s 0.1/0.2 thresholds and that KS is a supremum-of-CDF-gap statistic.
- “How do you pick the reference window?” — Fixed, curated, versioned; not rolling; refreshed deliberately. Red flag if they roll it automatically.
- “How do you avoid false alarms?” — Effect-size + significance gates, persistence across windows, multi-signal agreement, seasonality handling, per-segment analysis, multiple-comparison correction.
- “You have no labels — how do you know quality actually dropped?” — Must acknowledge unsupervised drift ≠ quality drop; re-eval on current traffic, LLM-judge on a slice, thumbs-down rate, canary/labeled sample.
- “Drift fires — walk me through the response.” — Confirm (rule out pipeline bug) → localize → classify → re-eval → mitigate cheapest-first (prompt fix / rollback before retrain) → update reference.
- “How would you detect embedding drift, and what breaks the naive approach?” — Domain classifier / MMD; centroid distance is blind to variance and multimodal splits; distance concentration in high dimensions.
- “Concept drift with stable inputs — how do you catch it?” — Honest answer: unsupervised input monitoring won’t; you need labels, periodic re-eval, or downstream outcome tracking.
Saying it out loud. The thing that distinguishes someone who has actually run this from someone who has read about it is that they lead with false positives rather than detection power. Anyone can recite PSI and KS. The operator brings up seasonality, reference-window staleness, alert fatigue, multiple-comparisons correction — and the fact that most so-called drift incidents turn out to be a pipeline bug, a calendar effect, or a judge that changed, not the product decaying. The other answer that scores is the honest one about concept drift: if inputs and outputs both look normal but the right answer changed, no unsupervised statistic will find it, and the fix is a faster re-eval cadence or labeled samples, not a better drift score.
Interview mastery
Explain PSI vs. KS in 60 seconds
If you only get one minute, say this: “Both compare a reference sample to a live sample and give you one number. PSI bins the feature, computes what fraction of traffic falls in each bin at reference time versus now, and sums ( (l_b - r_b)\ln(l_b/r_b) ) across bins — it’s a symmetrized divergence, gives you an unbounded score with industry-standard cutoffs at 0.1 and 0.2, and its big advantage is that it localizes: you can read off exactly which bin moved. KS skips binning entirely and compares the two empirical CDFs directly, taking the single largest vertical gap between them, ( D = \sup_x |F_{\text{live}}(x)-F_{\text{ref}}(x)| ) — it comes with a principled p-value, but it’s most sensitive near the center of the distribution and comparatively blind in the tails, and on very large windows it gets hypersensitive, flagging trivial differences as significant. In practice: PSI for a quick, interpretable, binning-tolerant read, especially on skewed features like token counts with quantile bins; KS when you want a formal significance test and don’t want to pick bin edges. Run both — they’re cheap, and requiring both to agree before alerting is the single best false-positive-reduction trick available.” That’s the whole answer; if pressed further, add that neither applies to embeddings — for those you need MMD or a domain classifier instead.
Extended Q&A bank (continued)
- “Two teams disagree: one wants a rolling reference window, one wants a fixed one. Who’s right?” — Fixed, almost always, for the reason in this chapter: a rolling reference lets slow, real decay quietly become “the new normal,” which is exactly the failure you’re trying to catch. The only case for a rolling reference is a genuinely non-stationary benign baseline (e.g., traffic volume itself, which naturally trends) — and even then, prefer a fixed reference refreshed on a deliberate, versioned schedule over a window that silently redefines itself every day.
- “Your embedding-drift monitor and your judge-based quality monitor disagree — embeddings say stable, judge says quality dropped. What do you do?” — Don’t average them away. This is exactly the input-vs-concept-drift split: stable embeddings with a falling judge score is the textbook signature of concept drift — the questions look the same, but the right answer (or the model’s ability to give it) changed. Escalate straight to re-evaluation rather than waiting for input drift to confirm it, because input drift may never come.
- “How would you detect that your LLM-judge itself has drifted, not your product?” — Keep a small, fixed, human-labeled anchor set that never changes and re-score it with whatever judge is currently in use at every check. Since the anchors are frozen by construction, any movement in their scores can only be judge drift (a version bump, a prompt edit), letting you attribute an alert to
{none, system, judge}instead of shrugging at “something moved.” This is the core idea behind the 2026 “Who Drifted: the System or the Judge?” line of work — naive judge-score monitors reportedly false-alarm on the large majority of judge-only changes if this check is skipped. - “Design drift monitoring for a RAG system specifically — what’s different from a plain chat endpoint?” — Add a retrieval-coverage check: embed live queries and measure what fraction achieve an adequate similarity match against a held-out set of test/known-good queries (a documented approach uses an 85% coverage floor); a drop means the corpus or the query distribution has moved out from under the retriever, which is a distinct failure mode from the generator drifting. Monitor retrieval-hit-rate and citation/grounding rate as their own time series, not folded into a single “quality” number, since retrieval failures and generation failures need different fixes.
- “Design drift monitoring for a multi-agent or long-running agent — what’s different?” — Single-turn signals (prompt length, refusal rate) under-cover an agent because the failure mode is often behavioral, accumulating over a trajectory rather than a single turn: repetitive tool use, abandoning the assigned role, claiming actions it didn’t take, or looping. Add deterministic, cheap-to-compute trajectory signals first (tool-call diversity, state-transition legality, loop counts) and reserve an LLM-judge call for the ambiguous cases those checks can’t resolve — running the judge on every turn of every trajectory is usually not affordable, and it’s also the component most likely to need its own drift watch per the anchor-set point above.
- “How do you keep a multi-signal monitor from crying wolf, concretely?” — Require agreement (at least two of three independent signals firing) rather than any-single-signal alerting; gate each signal on both effect size and significance, not p-value alone; compare like-for-like calendar windows so routine seasonality doesn’t trip every metric at once; and correct for multiple comparisons if you’re running many tests per window, since 200 independent KS tests at ( \alpha=0.05 ) will produce roughly 10 “significant” hits by chance alone.
- “What’s the actual cost profile of running all this in production, and how do you keep it cheap?” — Scalar tests (PSI, KS, chi-square) are near-free — histograms and a CDF walk. Embedding-based tests scale with window size: centroid/cosine distance is O(n); MMD is O(n²), so subsample before computing it; a domain classifier needs retraining per window but on modest data is fast. Judge-based scoring is the expensive tier — per-call LLM cost — so sample a slice rather than scoring every request, and only escalate to the judge when the cheap deterministic/statistical layer has already flagged something ambiguous, mirroring the “deterministic gate before judge call” pattern used in production agent-monitoring writeups.
- “A stakeholder asks why the drift dashboard didn’t catch a known quality regression last quarter. How do you answer without being defensive?” — Walk the taxonomy: was it input drift (should have shown on PSI/KS/embedding metrics — if it didn’t, the reference or thresholds need recalibration), output drift (should show on length/refusal/latency — same fix), or concept drift (inputs and outputs both looked normal, but the mapping changed — this is the one unsupervised monitoring cannot catch by design, and the fix is a faster periodic re-eval cadence or labeled-sample tracking, not a better drift statistic).
- “When would you not build a statistical drift monitor and just rely on periodic re-evaluation instead?” — When request volume is too low for any test to be meaningful (KS and PSI both need hundreds-to-thousands of points per window to avoid pure noise), or when the traffic is highly non-repetitive by nature (e.g., a coding agent handling bespoke one-off tasks) such that “the distribution” is not a stable, well-defined object to test against week over week. In both cases, scheduled re-evaluation against a curated eval set, plus close tracking of a handful of scalar proxies (latency, error rate, refusal rate), is more honest than a drift score that’s mostly measuring noise.
- “What’s the single biggest sign that someone has actually run drift monitoring in production, versus just read about it?” — They immediately bring up false positives, not detection power. Anyone can describe PSI and KS from a textbook; the people who’ve operated this in production lead with seasonality, reference-window staleness, alert fatigue, multiple-comparisons correction, and the fact that most “drift incidents” turn out to be a pipeline bug, a calendar effect, or a judge that changed — not the product decaying.
System design prompt: “Design drift monitoring for a deployed agent”
A concrete sketch, the shape an interviewer wants to see on a whiteboard:
PRODUCTION AGENT (hot path -- never touched)
|
async, sampled logging
v
+---------------------------------------------------------------+
| LOG STORE (append-only) |
| per turn: prompt, response, tool calls, latency, embeddings |
| per trajectory: full transcript, tool sequence, final state |
+---------------------------------------------------------------+
|
scheduled batch job (hourly/daily)
v
+---------------------------------------------------------------+
| TIER 1 -- cheap, always-on |
| scalar: PSI/KS on prompt+response length, latency, tool- |
| call count |
| categ.: chi-square on topic label, tool-name distribution |
| embed.: rolling centroid/cosine drift (this chapter's |
| RollingEmbeddingDriftMonitor) on prompt embeddings |
| agent-specific (deterministic): tool-entropy collapse, |
| illegal state transitions, excessive-loop count |
+---------------------------------------------------------------+
|
>= 2 of N signals agree, or persistent
v
+---------------------------------------------------------------+
| TIER 2 -- judge, sampled + fenced |
| sample the flagged window; one fenced judge call per |
| trajectory scores: role adherence, execution-gap (claimed |
| vs. actual tool use), goal-completion |
| JUDGE ITSELF is watched via a frozen human-labeled anchor |
| set re-scored every run -> attributes {none, system, judge} |
+---------------------------------------------------------------+
|
severity routing
v
+---------------------------------------------------------------+
| LOW: dashboard note | HIGH: page on-call |
| (1 signal, or judge=none)| (>=2 signals + judge agrees, |
| | or hard deterministic |
| | violation e.g. illegal |
| | transition) |
+---------------------------------------------------------------+
|
response playbook (this chapter)
confirm -> localize -> classify -> re-eval -> mitigate
Talking points to narrate while drawing this: (1) the hot path is untouched — monitoring is asynchronous, best-effort, and a monitor outage must never affect serving; (2) Tier 1 is cheap and catches most drift on its own via the multi-signal-agreement rule; (3) Tier 2 exists specifically because agent failures are often behavioral and accumulate over a trajectory, not visible in single-turn scalars, but an LLM judge is too expensive to run on every turn, so it’s gated behind Tier 1 and sampled; (4) the judge is explicitly a monitored component, not an oracle, via the anchor set; (5) severity routing determines whether a human sees a dashboard note tomorrow or gets paged tonight, and that routing is exactly where false-positive discipline (agreement, persistence, seasonality-awareness) has to live.
Saying it out loud. For a design prompt I’d draw three tiers and one rule. The hot path is never touched — logging is asynchronous and best-effort, and a monitor outage must never affect serving. Tier one is cheap and always on: PSI and KS on scalars, chi-square on topic and tool-name labels, rolling embedding-centroid drift, plus deterministic agent checks like tool-entropy collapse and illegal state transitions. Tier two is a sampled LLM-judge call, gated behind two-of-N tier-one agreement, because running a judge on every turn of every trajectory isn’t affordable. And the judge itself sits under a frozen anchor set so an alarm resolves to none, system, or judge. Then severity routing decides dashboard note versus page — which is exactly where the false-positive discipline has to live.
Red flags vs. green flags
| Signal in a candidate’s answer | Red flag | Green flag |
|---|---|---|
| Reference window | “We just compare to last week’s traffic” | “Fixed, curated reference from deploy/eval time, versioned, refreshed deliberately” |
| Alerting policy | “Any metric over threshold pages on-call” | “Requires ( \ge 2 ) independent signals to agree, or persistence across windows” |
| Embeddings | “We use cosine distance on the centroid” and stops there | Names cosine/centroid as the cheap first signal, then names its blind spot (multimodal shifts) and reaches for a domain classifier or MMD |
| Statistical rigor | Reports a bare KS ( D ) or MMD value with no p-value or threshold context | Distinguishes effect size from significance; calibrates embedding thresholds empirically, never from a textbook number |
| Labels | Claims a drift score “proves” quality dropped | States plainly that unsupervised drift is a smoke alarm, not a diagnosis, and describes a concrete re-eval / labeling step |
| Judge-based monitoring | Treats an LLM judge’s score as ground truth | Flags that the judge itself is a versioned component and describes a frozen-anchor check to attribute drift to system vs. judge |
| Seasonality | Never mentions it, or “we just watch for spikes” | Explicitly compares like-for-like calendar windows and can describe a false-alarm incident it caused |
| Multiple comparisons | Runs dozens of per-feature tests with no correction and is unaware that’s a problem | Names Bonferroni/Benjamini–Hochberg unprompted when discussing many-feature monitoring |
| Response to an alert | Jumps straight to retraining or rollback | Works the ladder: confirm it’s real (rule out a pipeline bug first) → localize → classify → re-eval → cheapest mitigation first |
| RAG/agent specifics | Treats a RAG or agentic system identically to a plain chat endpoint | Names retrieval-coverage drift for RAG, and trajectory-level signals (tool-entropy, role drift, illegal transitions) for agents |
Quick-reference thresholds cheat sheet
Defaults used throughout this chapter, worth being able to recite from memory — with the standing caveat that every embedding- and judge-based row must be recalibrated on your own reference population, never taken as a universal constant:
| Signal | Default starting threshold | Notes |
|---|---|---|
| PSI | 0.1 moderate / 0.2 significant | Industry-standard, from credit-risk practice |
| KS | ( D \ge 0.10 ) and ( p < 0.01 ) | Effect size and significance together, never either alone |
| Chi-square | ( p < 0.01 ) on the count table | Categorical analogue of KS |
| MMD | permutation ( p < 0.01 ) | The raw MMD value is meaningless without this |
| Domain classifier | ROC-AUC ( \ge 0.65 ) | 0.5 = indistinguishable; recalibrate per embedding model |
| Centroid / cosine distance | calibrate empirically (e.g. P99 of known-good history) | No intrinsic scale — this chapter’s illustrative demo used 0.02; a real calibration on the same data gave 0.0021 |
| Combined alert (fusion rule) | ( \ge 2 ) of 3 independent signals agree | The single biggest false-positive-reduction lever in this chapter |
| Judge anchor-drift | ( |z| \ge 2.5 ) on anchor bias | Attributes an alert to {none, system, judge} |
| RAG retrieval coverage | below 85% of live queries matched | Per the RAGOps coverage-check approach cited above |
| Agent tool-entropy collapse | distinct-tool-call ratio ( < 0.4 ) | Deterministic and cheap; run before any judge call |
Where this sits in the serving stack
A drift monitor is a small, boring pipeline that runs beside the hot path, never in it:
- Log at the edge. For every request/response, emit cheap features — token counts, latency, refusal flag, topic label, format-valid flag — plus a sampled subset of embeddings. Sample embeddings (they are expensive to store); log scalars in full.
- Snapshot a reference. At each deploy/eval, freeze a reference profile: bin edges per scalar feature, a held-out embedding sample, and the baseline rates. Version it alongside the model.
- Window & score offline. On a schedule (hourly/daily), pull the latest window, run PSI/KS/chi-square on scalars and MMD/classifier on embeddings, per segment.
- Gate & alert. Apply effect-size + significance gates, require persistence or multi-signal agreement, then route by severity to a dashboard (low) or a page (high).
- Feed the runbook. Every alert links to the playbook above and appends to an incident log so patterns become institutional knowledge.
The key architectural property: the monitor is asynchronous and best-effort. It must never add latency to inference, and a monitor outage must never take down serving. Compute drift from logs, not inline.
Saying it out loud. The architectural property that matters most here is that the monitor is asynchronous and best-effort. It reads from logs, never from the request path; it must add zero latency to inference, and if the monitor falls over, serving keeps going. Practically that’s five steps: log cheap features at the edge for every request plus a sampled subset of embeddings, freeze a versioned reference snapshot at each deploy, window and score on a schedule, gate on effect size plus agreement, then route by severity. And step five is the one people skip — every alert links to the runbook and appends to an incident log, so the next on-call reads what the last alert meant instead of re-deriving it at 2am.
Monitoring maturity by team size
Not every team needs every tier from day one. A rough progression, useful for scoping what to build first:
| Stage | What to run | Typical tooling |
|---|---|---|
| Pre-launch / solo builder | Manual spot-checks on a sample of traffic; PSI/KS on 2–3 scalars (prompt length, latency) in a notebook, run weekly by hand | scipy.stats, a spreadsheet, or Evidently run locally, ad hoc |
| Early production (1–3 person ML team) | Tier 1 scalars (PSI/KS/chi-square) plus a rolling embedding-centroid signal; scheduled batch job; alerts to a Slack channel | Evidently or NannyML, self-hosted, on a cron job |
| Scaling (dedicated MLOps function) | Full Tier 1 + Tier 1b (domain classifier / MMD); sampled judge scoring gated behind Tier 1 agreement, with a frozen anchor set; on-call rotation and severity routing | WhyLabs, Arize, Galileo, or an in-house monitor built on this chapter’s patterns |
| Large-scale, multi-team, multi-product | Per-team and per-segment monitors; judge-drift attribution treated as standard practice, not an afterthought; RAG retrieval-coverage and agent-trajectory monitoring as first-class, independently owned systems; a “monitor of monitors” tracking alert volume and false-positive rate over time | Commercial platform(s) plus custom in-house layers for product-specific signals |
The mistake to avoid at every stage is skipping straight to the tooling for a later stage before the earlier one is solid — a large-scale judge-attribution pipeline bolted onto a system with no fixed reference window or agreement-based alerting will just produce expensive, unreliable noise instead of cheap, unreliable noise.
Saying it out loud. Not every team needs every tier on day one, and the mistake I’d warn about is jumping to later-stage tooling before the earlier stage is solid. A solo builder running PSI and KS on two or three scalars in a notebook once a week is doing something real. A small team adds a scheduled batch job and a rolling embedding signal into a Slack channel. A dedicated MLOps function adds the domain classifier and sampled judge scoring with a frozen anchor set and an on-call rotation. Bolting a judge-attribution pipeline onto a system that has no fixed reference window and no agreement-based alerting just gets you expensive unreliable noise instead of cheap unreliable noise.
Mapping this chapter onto the drift_detector.py in this folder
The companion module in this same folder, drift_detector.py, is a compact, real implementation of the Tier 1 statistical layer described above, built on Evidently AI (see requirements.txt: evidently==0.4.14). Reading it alongside this chapter should make each piece legible:
DriftDetector.__init__takes areference_dataDataFrame — this is the frozen reference window this chapter insists on: captured once, versioned, never silently re-derived from live traffic.detect_data_drift()builds an EvidentlyReportfromDataDriftTable()andDatasetDriftMetric()— under the hood these run exactly the per-column PSI/KS-style tests worked through by hand earlier in this chapter, just across every column in the DataFrame at once, and roll them up into a singledataset_driftboolean plus adrift_score(the fraction of columns that drifted). Thethreshold: float = 0.2parameter is literally this chapter’s PSI significant-shift threshold, passed straight through.detect_prediction_drift()runsPredictionDriftMetric()andColumnDriftMetric()against aprediction_column— this is the output-drift half of the taxonomy from the top of this chapter, applied to whatever scalar or categorical prediction/output feature you log (label, output length, a judge score you’ve written back into the DataFrame, etc.).generate_report()is the offline, scheduled scoring step from “Where this sits in the serving stack” above, materialized as an HTML artifact for a human to review rather than a bare pass/fail.
What it does not do — and where this chapter’s extended sections plug in — is embeddings, multi-signal fusion, or judge-based checks: it has no rolling embedding-centroid monitor, no MMD/domain-classifier pair, no combined-alert vote-counting, and no judge/anchor-set attribution. Extending DriftDetector with the RollingEmbeddingDriftMonitor and combined_alert() logic from “Build it in practice — extended,” and gating any judge call behind its output the way the system-design sketch in “Interview mastery” does, turns this module from a Tier-1-only drift table into the full layered monitor this chapter argues for.
Cost and latency by monitoring tier
Tying the tiers used throughout this chapter (and in the system-design sketch above) to what they actually cost to run:
| Tier | Example checks | Per-window cost | Latency to detect | What it catches | What it misses |
|---|---|---|---|---|---|
| 0 — logging | request/response metadata, cheap scalars | near-zero (already logged) | n/a (raw data, not a check) | nothing by itself | — |
| 1 — statistical | PSI, KS, chi-square on scalars | ( O(n) ) per feature, negligible | minutes to hours (batch schedule) | length/latency/topic-label shifts | semantic shifts with stable scalars |
| 1 — embedding (cheap) | rolling centroid / cosine drift | ( O(n) ) per window | minutes to hours | topic/semantic shifts even with stable scalars | multimodal splits (centroid-only is blind to these; pair with a classifier or MMD) |
| 1b — embedding (heavier) | MMD, domain classifier | ( O(n^2) ) (subsample) / ( O(n) ) to train | minutes to hours | any separable distributional shape shift | still no ground truth on quality |
| 2 — judge (sampled) | LLM-as-judge score on a slice | one LLM call per sampled item | hours to days (sampling cadence + judge latency) | quality/semantic-correctness proxies | the judge’s own drift (needs the anchor-set check) |
| 2b — judge (anchor-corrected) | frozen human-labeled anchors re-scored every check | one LLM call per anchor item, a small fixed set | same as above | attributes an alarm to {none, system, judge} | still a proxy, not ground truth |
| 3 — human / ground truth | labeled current-traffic sample, full re-eval suite | most expensive, largely manual | days to weeks | actual quality, concept drift | too slow to be a first-detection early-warning system on its own |
Rule of thumb for the design-prompt interview answer above: run tier 0/1 on every window, unconditionally — it’s nearly free; gate tier 2 behind tier-1 agreement to control judge spend; reserve tier 3 for confirming and quantifying what tiers 1–2 already flagged, not for first detection. This is the same cost curve reflected in the 2025–2026 landscape section’s “start with KL divergence, add embedding drift, layer in LLM-as-judge when you have budget” advice, and in the deterministic-checks-before-judge-call pattern used in production agent monitoring.
Saying it out loud. The cost curve is the part worth memorizing because it drives the design. Scalar tests are essentially free — histograms and a CDF walk, linear in the window. Cheap embedding signals like centroid distance are also linear. MMD is quadratic, so subsample. The judge is the expensive tier — one LLM call per sampled item — and it’s also the slowest to detect, on the order of days rather than minutes. Human labeling and full re-eval is the most expensive and slowest of all, days to weeks. So the rule of thumb is: run tiers zero and one on every window unconditionally because they’re nearly free, gate the judge behind tier-one agreement to control spend, and reserve human ground truth for confirming and quantifying what the cheap tiers already flagged — never for first detection.
Further reading
- Fiddler AI — Measuring Data Drift with the Population Stability Index (PSI): https://www.fiddler.ai/blog/measuring-data-drift-population-stability-index
- GeeksforGeeks — Population Stability Index (PSI): https://www.geeksforgeeks.org/data-science/population-stability-index-psi/
- Gretton et al. — A Kernel Two-Sample Test (JMLR 2012, the MMD paper): https://www.jmlr.org/papers/volume13/gretton12a/gretton12a.pdf
- TorchDrift — Intuition for the Maximum Mean Discrepancy two-sample test: https://torchdrift.org/notebooks/note_on_mmd.html
- Evidently AI — 5 methods to detect drift in ML embeddings (Filippova & Samuylova; published May 17, 2023, updated July 16, 2025): https://www.evidentlyai.com/blog/embedding-drift-detection
- Evidently AI — Data drift algorithm (how thresholds/tests are chosen): https://docs-old.evidentlyai.com/reference/data-drift-algorithm
- Evidently AI — Monitoring embeddings drift (open-source course module): https://learn.evidentlyai.com/ml-observability-course/module-3-ml-monitoring-for-unstructured-data/monitoring-embeddings-drift
- NannyML — Multivariate Drift Detection (PCA reconstruction error): https://nannyml.readthedocs.io/en/stable/how_it_works/multivariate_drift.html
- NannyML — Monitoring data drift: univariate & multivariate methods: https://www.nannyml.com/blog/monitoring-data-drift
- SciPy —
ks_2sampreference: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ks_2samp.html - Real Statistics — Two-sample Kolmogorov–Smirnov test: https://real-statistics.com/non-parametric-tests/goodness-of-fit-tests/two-sample-kolmogorov-smirnov-test/
- WhyLabs — whylogs (data logging & drift profiling, open source): https://github.com/whylabs/whylogs
- WhyLabs — LangKit (open-source LLM text-quality, relevance, security & sentiment signal extraction for observability): https://github.com/whylabs/langkit
- WhyLabs — Large Language Model (LLM) Monitoring docs: https://docs.whylabs.ai/docs/large-language-model-monitoring/
- Galileo — 9 Best LLM Drift Monitoring Platforms in 2026 (market survey of Galileo, Arize, LangSmith, Langfuse, Arthur AI, WhyLabs, W&B Weave, Aporia, Helicone; March 24, 2026): https://galileo.ai/blog/best-llm-output-drift-monitoring-platforms
- dev.to (aiwithmohit) — Your LLM Is Lying to You Silently: 4 Statistical Signals That Catch Drift Before Users Do (March 29, 2026): https://dev.to/aiwithmohit/your-llm-is-lying-to-you-silently-4-statistical-signals-that-catch-drift-before-users-do-4cg2
- Vadim Nicolai — Detecting Agent Defects & Drift in Production (LangGraph runtime signals; June 15, 2026): https://vadim.blog/agent-defect-drift-detection-production/
- Xu, Weytjens, Zhang, Lu, Weber, Zhu — RAGOps: Operating and Managing Retrieval-Augmented Generation Pipelines (arXiv:2506.03401, 2025): https://arxiv.org/abs/2506.03401
- Rath — Agent Drift: Quantifying Behavioral Degradation in Multi-Agent LLM Systems Over Extended Interactions (arXiv:2601.04170, January 2026): https://arxiv.org/abs/2601.04170
- Li — Who Drifted: the System or the Judge? Anytime-Valid Attribution in LLM Evaluation Pipelines (arXiv:2606.15474, June 2026): https://arxiv.org/abs/2606.15474
- Yildiz (Forbes) — The 1% Catastrophe: Why AI Agent Drift Is The Boardroom’s Real Problem (May 7, 2026): https://www.forbes.com/sites/guneyyildiz/2026/05/07/the-1-catastrophe-why-ai-agent-drift-is-the-boardrooms-real-problem/
- Elixir Data — AI Agent Drift Detection: Monitoring Model & Decision Drift: https://www.elixirdata.co/blog/ai-agent-drift-detection
Topic 12: Multi-Model Serving with Triton
What You’ll Learn
This topic teaches you how to:
- Deploy models with NVIDIA Triton Inference Server
- Serve multiple models simultaneously
- Use dynamic batching
- Optimize for different frameworks (PyTorch, TensorFlow, ONNX)
- Create model ensembles
- Monitor Triton performance
Why Triton?
Benefits
- Multi-framework: PyTorch, TensorFlow, ONNX, TensorRT
- Dynamic batching: Automatic request batching
- Model ensembles: Chain multiple models
- High performance: Optimized inference
- Production-ready: Used by major companies
Use Cases
- Multiple models: Serve different models on same server
- Model pipelines: Chain models together
- A/B testing: Easy model switching
- Resource efficiency: Share GPU across models
Triton Architecture
Components
- Triton Server: Main inference server
- Model Repository: Storage for models
- Model Config: Configuration per model
- Scheduler: Request scheduling and batching
Request Flow
Client → Triton Server → Model Backend → GPU → Response
Installation
Docker (Recommended)
docker pull nvcr.io/nvidia/tritonserver:23.10-py3
Local Installation
# See Triton documentation for your platform
Model Repository Structure
model_repository/
model1/
config.pbtxt
1/
model.pt
model2/
config.pbtxt
1/
model.onnx
Model Configuration
Basic Config
name: "gpt2"
platform: "pytorch_libtorch"
max_batch_size: 8
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ -1 ]
}
]
output [
{
name: "output"
data_type: TYPE_FP32
dims: [ -1, 50257 ]
}
]
Dynamic Batching
dynamic_batching {
max_queue_delay_microseconds: 100
preferred_batch_size: [ 4, 8 ]
max_batch_size: 16
}
Starting Triton Server
Basic
tritonserver --model-repository=/path/to/models
With GPU
docker run --gpus all \
-v /path/to/models:/models \
nvcr.io/nvidia/tritonserver:23.10-py3 \
tritonserver --model-repository=/models
Client API
Python Client
import tritonclient.http as httpclient
client = httpclient.InferenceServerClient("localhost:8000")
# Prepare input
inputs = [httpclient.InferInput("input_ids", [1, 10], "INT64")]
inputs[0].set_data_from_numpy(input_ids)
# Infer
result = client.infer("gpt2", inputs)
output = result.as_numpy("output")
REST API
curl -X POST http://localhost:8000/v2/models/gpt2/infer \
-H "Content-Type: application/json" \
-d '{
"inputs": [{
"name": "input_ids",
"shape": [1, 10],
"datatype": "INT64",
"data": [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]
}]
}'
Dynamic Batching
How It Works
- Requests arrive at Triton
- Triton queues requests
- When batch ready (size or timeout), process together
- Return responses
Configuration
dynamic_batching {
max_queue_delay_microseconds: 100000 # 100ms
preferred_batch_size: [ 4, 8, 16 ]
max_batch_size: 32
}
Model Ensembles
Chain Models
name: "pipeline"
platform: "ensemble"
input [
{ name: "input", data_type: TYPE_STRING }
]
output [
{ name: "output", data_type: TYPE_STRING }
]
ensemble_scheduling {
step [
{
model_name: "tokenizer"
model_version: -1
input_map { key: "input" value: "input" }
output_map { key: "output" value: "tokens" }
},
{
model_name: "llm"
model_version: -1
input_map { key: "tokens" value: "input_ids" }
output_map { key: "output" value: "generated" }
}
]
}
Monitoring
Metrics Endpoint
curl http://localhost:8000/metrics
Key Metrics
- Request count
- Inference latency
- Queue size
- GPU utilization
- Batch size
Best Practices
- Organize models: Clear repository structure
- Optimize configs: Tune batching parameters
- Monitor performance: Track metrics
- Version models: Use versioning in repository
- Test thoroughly: Validate before production
Common Issues
Model Not Loading
- Check model repository path
- Verify model format
- Check config.pbtxt syntax
Low Throughput
- Increase batch size
- Tune dynamic batching
- Check GPU utilization
Out of Memory
- Reduce batch size
- Use smaller models
- Add more GPUs
Exercises
- Deploy model: Set up Triton with one model
- Multiple models: Serve multiple models
- Dynamic batching: Configure and test batching
- Model ensemble: Chain models together
- Monitor: Set up monitoring
Next Steps
- Topic 8: Monitor Triton with Grafana
- Topic 6: Autoscale Triton deployments
- Topic 9: Version models in Triton
Further Reading
NVIDIA Triton Inference Server: Production Multi-Model Serving
Why this matters
Most inference tutorials show you how to serve one model with one framework. Production is rarely that tidy. You have a PyTorch embedding model, an ONNX classifier, a TensorRT vision model, and — increasingly — a large language model, and they all need to sit behind stable HTTP/gRPC endpoints, share expensive GPUs, batch requests to keep those GPUs busy, expose Prometheus metrics, and version cleanly.
NVIDIA Triton Inference Server is the piece that does all of that in one process. It is a serving runtime, not a model format: you point it at a directory of models, each with a small config file, and it loads them across whatever backends they need, batches incoming requests, runs multiple copies concurrently, and serves them on standardized endpoints. For LLMs specifically, Triton pairs with the TensorRT-LLM backend to deliver in-flight (continuous) batching and paged KV cache — the same class of technique that makes vLLM fast — with NVIDIA’s most aggressive kernel optimizations underneath.
If you have exactly one LLM and nothing else, vLLM or TGI is often simpler. If you have a fleet of heterogeneous models, or you want NVIDIA’s fastest LLM path with unified ops tooling, Triton is the standard answer. This chapter explains what it is, how the model repository and config.pbtxt work, the backend landscape, the two flavors of batching, how to build a real multi-model ensemble, how to benchmark it correctly, and how it stacks up against vLLM, SGLang, and TGI in 2026 — plus the production incidents that teach the lessons faster than any diagram.
Saying it out loud. The problem Triton solves is that production is rarely one model with one framework. You’ve got a PyTorch embedder, an ONNX classifier, maybe a TensorRT vision model, and an LLM, and all of them need stable endpoints, shared GPUs, batching, metrics, and clean versioning. Triton is a serving runtime rather than a model format — you point it at a directory of models with small config files and it loads each one on whatever backend it needs, all in one process behind one API. The honest caveat I’d give up front is that it’s NVIDIA-only, and if you have exactly one LLM and nothing else, vLLM or SGLang gets you most of the throughput with a fraction of the setup. Triton earns its keep when you have a fleet.
Core intuition
Hold one sentence in your head:
Triton is a serving runtime that hosts many models across many backends, with request batching and per-model concurrency built in.
Everything else is detail hanging off that sentence:
- Many models — a model repository (a directory) holds every model. Add a folder, Triton serves it. Triton can hot-load, hot-unload, and version them.
- Many backends — each model declares a
backend(orplatform). TensorRT-LLM, vLLM, Python, ONNX Runtime, PyTorch (LibTorch), TensorRT, and more all run inside the same server process behind the same API. - Batching built in — Triton groups small requests into larger batches to feed the GPU efficiently. For non-LLM models this is dynamic batching; for LLMs it is in-flight batching.
- Concurrency built in — instance groups let you run N copies of a model on one or more GPUs so requests overlap instead of queueing.
The payoff of the runtime abstraction: your infra team learns one server, one metrics format, one deployment story — and every model, whatever framework trained it, fits into it.
Saying it out loud. If I had to compress it to one sentence: Triton is a serving runtime that hosts many models across many backends, with request batching and per-model concurrency built in. Everything else hangs off that. Many models means a model repository — add a directory, Triton serves it, and it can hot-load and hot-unload without a restart. Many backends means each model declares which engine runs it: TensorRT-LLM, vLLM, Python, ONNX Runtime, LibTorch, TensorRT. Batching means Triton groups small requests to feed the GPU efficiently — dynamic batching for fixed-shape models, in-flight batching for LLMs. And concurrency means instance groups, which control how many copies of a model run at once. The payoff of the abstraction is that your infra team learns one server, one metrics format, one deployment story.
Architecture and the model repository
The big picture
A single tritonserver process contains:
- Frontends — HTTP/REST on port 8000, gRPC on port 8001, and a Prometheus metrics endpoint on port 8002.
- The core — request routing, the scheduler (which does batching), model management (load/unload/versioning), and the shared-memory / pinned-memory machinery for zero-copy tensor passing.
- Backends — shared libraries that actually execute a model. Each backend adapts one framework to Triton’s C API. Multiple backends coexist in one server.
Requests arrive at a frontend, get routed to the named model, land in that model’s scheduler queue, are (optionally) batched, dispatched to a model instance (a loaded copy on a specific device), and the response flows back out.
Saying it out loud. Architecturally it’s one process with three layers. Frontends: HTTP on 8000, gRPC on 8001, Prometheus metrics on 8002 — worth memorizing, those three ports come up constantly. The core does request routing, the scheduler that does the batching, model management for load, unload and versioning, and the shared-memory machinery that passes tensors between models without copying. And backends are shared libraries that actually execute a model, each adapting one framework to Triton’s C API, all coexisting in the same process. A request lands on a frontend, gets routed to a named model, sits in that model’s scheduler queue, gets batched, and dispatches to an instance on a specific device.
The model repository
Triton is started with one or more repositories:
tritonserver --model-repository=/models
The layout is strict and load-bearing — Triton discovers models by walking this tree:
/models/
├── text_classifier/
│ ├── config.pbtxt
│ ├── 1/
│ │ └── model.onnx
│ └── 2/
│ └── model.onnx
├── image_embedder/
│ ├── config.pbtxt
│ └── 1/
│ └── model.pt
└── llama3_trtllm/
├── config.pbtxt
└── 1/
└── ...engine files...
Rules that trip people up:
- The top-level directory name is the model name clients use in requests (
text_classifier, not the file inside). - Version subdirectories are integers (
1/,2/). Non-integer or0directories are ignored. By default Triton serves the highest numbered version; aversion_policyin the config changes that (latest N, all, or specific). - The model file name is fixed per backend —
model.onnxfor ONNX Runtime,model.ptfor PyTorch/LibTorch,model.planfor TensorRT,model.pyfor the Python backend, etc. - Repositories can live on local disk, S3, GCS, or Azure Blob (
--model-repository=s3://bucket/models).
config.pbtxt — the model configuration
Every model gets a config.pbtxt (protobuf text format) that declares its backend, tensor shapes, batching, and concurrency. For many framework backends Triton can auto-generate the config (--strict-model-config=false), but in production you write it explicitly so nothing is a surprise — see the “silent batching cap” war story below for what happens when you don’t.
A minimal ONNX classifier config:
name: "text_classifier"
backend: "onnxruntime"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ 128 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ 5 ]
}
]
Key fields:
| Field | Meaning |
|---|---|
name | Must match the directory name (optional if it does). |
backend / platform | Which backend executes the model (onnxruntime, python, pytorch/platform: "pytorch_libtorch", tensorrt/platform: "tensorrt_plan", vllm, tensorrtllm). |
max_batch_size | Largest batch Triton will assemble. 0 means the model does not support Triton’s batching (first dim is not a batch dim). |
input / output | Tensor name, data_type (TYPE_FP32, TYPE_INT64, TYPE_STRING, TYPE_BF16, …), and dims. When max_batch_size > 0, the batch dimension is implicit — you list only the per-sample shape. Use -1 for dynamic dims. |
instance_group | How many copies, on what devices (see below). |
dynamic_batching | Enables server-side batching (see below). |
version_policy | { latest: { num_versions: 1 } }, { all: {} }, or { specific: { versions: [1,3] } }. |
Saying it out loud. The repository layout is strict and load-bearing, and it’s where beginners lose an hour. The top-level directory name is the model name clients use — not the filename inside. Version subdirectories have to be integers; a folder called “v2” or “0” is silently ignored, and by default Triton serves the highest-numbered one. The model filename is fixed per backend: model.onnx, model.pt, model.plan, model.py. And then config.pbtxt declares the backend, tensor shapes, batching, and concurrency. Triton can auto-generate that config, which is fine on a laptop and dangerous in CI — the war story later in this chapter is exactly a regenerated config silently dropping the batching block and capping throughput at one request at a time.
Backends: pick the right engine
A backend is the plug-in that runs a model. Choosing the wrong one for LLMs is the single most common Triton mistake, so internalize this table:
| Backend | backend/platform | Best for | Batching model | Notes |
|---|---|---|---|---|
| TensorRT-LLM | tensorrtllm | Production LLM inference on NVIDIA GPUs | In-flight (continuous) | Fastest LLM path. As of TensorRT-LLM 1.x, the PyTorch execution backend (the “LLM API”) is the default and can serve HF checkpoints directly with no separate engine-compile step; the older trtllm-build engine-compile workflow still exists but is the legacy path. Paged KV cache, tensor/pipeline/expert parallel. |
| vLLM | vllm | LLMs you want to run with minimal conversion | Continuous (vLLM’s own) | Wraps vLLM’s AsyncLLMEngine; PagedAttention; no engine build step. NVIDIA’s own benchmarking puts the Triton vLLM backend within a couple of percent of standalone vLLM throughput/latency. |
| Python | python | Pre/post-processing, tokenization, glue, custom logic, BLS | Dynamic (if you enable it) | You write model.py with TritonPythonModel. The universal escape hatch; also hosts Business Logic Scripting. |
| ONNX Runtime | onnxruntime | Classifiers, embedders, small/medium models exported to ONNX | Dynamic | Portable, CPU or GPU, good default for non-LLM models. |
| PyTorch (LibTorch) | pytorch / pytorch_libtorch | TorchScript / traced models | Dynamic | Serve model.pt directly without re-exporting. |
| TensorRT | tensorrt / tensorrt_plan | Vision/CNN/transformer engines compiled to a .plan | Dynamic | Extremely fast for non-generative models; needs a TensorRT build. |
The one rule to remember: do not try to serve an LLM’s token-by-token generation loop through a plain ONNX/PyTorch backend with dynamic_batching. Autoregressive decoding has variable-length outputs and per-request state; naive dynamic batching stalls the whole batch on the slowest sequence. LLMs need in-flight batching, which means the TensorRT-LLM or vLLM backend.
Saying it out loud. Picking the wrong backend for an LLM is the single most common Triton mistake, so the rule I’d state is: never serve an autoregressive generation loop through the plain ONNX or PyTorch backend with dynamic batching. Those backends assume every request does the same amount of work, and decoding doesn’t — one request emits five tokens and another emits five hundred, so the whole batch stalls on the slowest sequence. LLMs need the TensorRT-LLM or vLLM backend, which do in-flight batching with a paged KV cache. Everything else maps cleanly: ONNX Runtime for classifiers and embedders, LibTorch for TorchScript, TensorRT for compiled vision engines, and the Python backend as the universal escape hatch for tokenization and glue.
Dynamic batching vs in-flight batching
Batching is how you keep a GPU — which loves large parallel matmuls — busy when requests trickle in one at a time. Triton has two mechanisms, and the distinction is the heart of LLM serving.
Dynamic batching (for fixed-shape models)
For a classifier or embedder, every request does the same amount of work and produces a fixed-shape output. Triton’s dynamic batcher waits a tiny, bounded window, collects whatever requests arrived, forms one batch, runs it once, and splits the results back out.
dynamic_batching {
preferred_batch_size: [ 8, 16 ]
max_queue_delay_microseconds: 1000
}
preferred_batch_size— batch sizes the scheduler prefers to form (often a power of two the engine is tuned for).max_queue_delay_microseconds— the most time a request will wait to be batched. This is the core latency/throughput knob: bigger delay leads to fuller batches and more throughput but more tail latency.1000microseconds is 1 millisecond.- Optional
preserve_ordering, andpriority_levelsfor QoS.
The mental model: one batch in, one batch out, everyone waits for the slowest member. That is fine when all members do equal work. It is a disaster for generation, where one request might emit 5 tokens and another 500.
Saying it out loud. Dynamic batching is the classic mechanism and the mental model is: one batch in, one batch out, everyone waits for the slowest member. Triton waits a short bounded window, collects whatever requests arrived, runs them as one batch, and splits the results back out. There are two knobs. Preferred batch size, which should match what the engine was actually tuned for or you waste time on padding. And max queue delay in microseconds, which is the real latency-versus-throughput dial — longer delay means fuller batches, more throughput, and a worse tail. A thousand microseconds is one millisecond, and that’s a reasonable place to start. This works beautifully when every member does equal work, and it’s a disaster for generation.
In-flight (continuous) batching (for LLMs)
LLM decoding is iterative: each forward pass produces one token per active sequence, then loops. In-flight batching (a.k.a. continuous or iteration-level batching) exploits this. Instead of freezing a batch for its whole lifetime, the scheduler operates per decoding iteration:
- Finished sequences leave the batch immediately and return to the client.
- Newly arrived requests join the running batch at the next iteration, filling the freed slots.
- The batch composition changes every step — the GPU is never idle waiting for the slowest sequence.
Paired with a paged KV cache (the attention key/value cache stored in fixed-size blocks, like OS virtual memory pages), this eliminates the memory fragmentation and rigid padding that kill naive LLM batching. This is exactly the vLLM PagedAttention idea; the TensorRT-LLM backend implements the same class of technique with NVIDIA-optimized kernels.
In the TensorRT-LLM backend you turn it on in the tensorrt_llm model’s config:
parameters: { key: "gpt_model_type" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "batching_strategy" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "kv_cache_free_gpu_mem_fraction" value: { string_value: "0.9" } }
The engine itself must be built (or, on the newer PyTorch execution path, configured) with paged KV cache enabled. Get this wrong — build a static-batch engine, or leave batching_strategy as v1 — and you have thrown away the entire point of using TensorRT-LLM.
Saying it out loud. In-flight batching — also called continuous or iteration-level batching — is the thing that makes LLM serving work, and the insight is that decoding is iterative. Instead of freezing a batch for its whole lifetime, the scheduler makes decisions per decode iteration: finished sequences leave the batch immediately and return to the client, new arrivals join at the next iteration and fill the freed slots. The batch composition changes every single step, so the GPU is never idle waiting on the longest sequence. Pair that with a paged KV cache — attention state in fixed-size blocks, like OS virtual memory pages — and you kill the fragmentation and padding that ruin naive batching. The failure mode to name: if you leave the batching strategy at v1, or build a static-batch engine, you paid the whole conversion cost and got none of the benefit.
Instance groups and concurrent execution
Batching fills a single GPU pass. Instance groups decide how many independent passes can be in flight at once, and where.
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
count— number of loaded copies (instances) of the model.kind—KIND_GPU,KIND_CPU, orKIND_MODEL(let the backend decide device placement — used by the vLLM and TensorRT-LLM backends).gpus— which physical GPUs to place instances on.
Two instances on one GPU means Triton can execute two requests concurrently on that GPU (overlapping compute and memory transfer via CUDA streams), improving utilization when a single request under-fills the device. Instances across multiple GPUs give you data-parallel scale-out of the same model.
Combining knobs:
- Dynamic batching + multiple instances — each instance has its own batch scheduler; requests spread across instances, each forms batches. Great for high-throughput fixed-shape models.
- For LLMs, you usually do not stack many small instances. One instance owns the GPU (or several GPUs via
tensor_parallel_size/world_size) and in-flight batching handles concurrency internally. Multiple TensorRT-LLM instances only make sense across separate GPU sets.
# Spread three instances across two GPUs
instance_group [
{ count: 1 kind: KIND_GPU gpus: [ 0 ] },
{ count: 2 kind: KIND_GPU gpus: [ 1 ] }
]
Saying it out loud. Batching fills a single GPU pass; instance groups decide how many passes are in flight at once and where. Two instances on one GPU lets Triton overlap two requests on separate CUDA streams, which helps when one request doesn’t fill the device. Instances spread across GPUs give you data-parallel scale-out of the same model. But here’s the distinction that matters: for LLMs you generally do not stack many small instances. One instance owns the GPU, or several GPUs through tensor parallelism, and in-flight batching handles concurrency internally — adding instances just fragments your KV cache into smaller pools. Multiple LLM instances only make sense across genuinely separate GPU sets.
Ensembles and Business Logic Scripting (BLS)
Real inference is a pipeline: tokenize → run model → detokenize; or embed → search → rerank. Triton gives you two ways to compose models server-side so the client makes one call.
Ensembles (declarative DAG)
An ensemble is a model with platform: "ensemble" and no code — just a config describing how tensors flow between other models. Triton executes the graph internally, passing tensors in GPU/shared memory without extra network hops.
name: "ensemble"
platform: "ensemble"
max_batch_size: 8
input [ { name: "text_input" data_type: TYPE_STRING dims: [ 1 ] } ]
output [ { name: "text_output" data_type: TYPE_STRING dims: [ 1 ] } ]
ensemble_scheduling {
step [
{
model_name: "preprocessing"
model_version: -1
input_map { key: "QUERY" value: "text_input" }
output_map { key: "input_ids" value: "ids" }
},
{
model_name: "tensorrt_llm"
model_version: -1
input_map { key: "input_ids" value: "ids" }
output_map { key: "output_ids" value: "gen_ids" }
},
{
model_name: "postprocessing"
model_version: -1
input_map { key: "output_ids" value: "gen_ids" }
output_map { key: "OUTPUT" value: "text_output" }
}
]
}
This is exactly the canonical TensorRT-LLM layout: a preprocessing Python model (string to input_ids), the tensorrt_llm engine model, and a postprocessing Python model (output_ids to string), stitched by an ensemble. Section (B) below walks through the full, runnable version of this pipeline with real model.py code.
Ensembles are static graphs. They cannot express loops or data-dependent branching.
Saying it out loud. Real inference is a pipeline — tokenize, run the model, detokenize, or embed, search, rerank — and an ensemble lets you express that server-side so the client makes one call. An ensemble is a model with no code at all: just a config describing how tensors flow between other models, and Triton executes the graph internally, passing tensors through shared or GPU memory with no network hops between steps. That’s the whole value proposition — one client call, three internal model hops, zero round trips. The constraint to know is that an ensemble is a static DAG, so it can’t branch on runtime data or call an external service mid-graph.
Business Logic Scripting (BLS)
When you need conditionals, loops, or calling model B based on model A’s output, use BLS: a Python-backend model that issues inference requests to other Triton models from inside its execute():
import triton_python_backend_utils as pb_utils
class TritonPythonModel:
def execute(self, requests):
responses = []
for request in requests:
prompt = pb_utils.get_input_tensor_by_name(request, "text_input")
# Call the tokenizer model
tok = pb_utils.InferenceRequest(
model_name="preprocessing",
requested_output_names=["input_ids"],
inputs=[prompt],
)
ids = tok.exec().output_tensors()[0]
# ... branch on content, loop, call the LLM, etc.
responses.append(pb_utils.InferenceResponse(output_tensors=[...]))
return responses
For LLMs, the TensorRT-LLM backend ships a tensorrt_llm_bls model as an alternative to the ensemble — same pipeline, but expressed in Python so you can add guardrails, retries, or multi-model routing. Rule of thumb: ensemble for a fixed DAG, BLS when logic depends on runtime data.
Saying it out loud. BLS is the escape hatch for when the pipeline isn’t a static graph. It’s a Python-backend model that issues inference requests to other Triton models from inside its execute function, so you can branch on model A’s output before deciding whether to call model B, loop, add guardrails, or call out to an external service over HTTP. The rule of thumb I’d give is: ensemble for a fixed DAG, BLS when the logic depends on runtime data. The tradeoff is real — BLS is arbitrary Python running in the serving process, so it’s slower and easier to get wrong than a declarative config, and reaching for it on every pipeline is a red flag.
HTTP/gRPC endpoints and metrics
Endpoints (KServe v2 / “predict” protocol)
- HTTP/REST on
:8000, gRPC on:8001. - Inference:
POST /v2/models/{model}/infer(and/versions/{v}/infer). - Health/readiness:
GET /v2/health/ready,/v2/health/live. - Metadata:
GET /v2/models/{model}, and repository/config introspection. - LLM convenience: the generate endpoint
POST /v2/models/{model}/generate(and/generate_streamfor token streaming with decoupled models).
A generic infer call:
curl -s localhost:8000/v2/models/text_classifier/infer -d '{
"inputs": [
{ "name": "input_ids", "shape": [1, 128], "datatype": "INT64",
"data": [ 101, 2054, 2003, ... ] }
]
}'
An LLM generate call (vLLM or TensorRT-LLM ensemble):
curl -s -X POST localhost:8000/v2/models/vllm_model/generate -d '{
"text_input": "What is Triton Inference Server?",
"parameters": { "stream": false, "temperature": 0, "max_tokens": 128 }
}'
Streaming token-by-token needs a decoupled model — one that returns many responses per request — declared with model_transaction_policy { decoupled: true }, and is consumed over gRPC streaming or /generate_stream.
Request cancellation and priority
Two operational details that matter once real users are behind an LLM endpoint:
- Cancellation. If a client disconnects (closes the browser tab, hits a client-side timeout) mid-generation, a decoupled streaming request can be cancelled so the GPU stops spending cycles on a response nobody will read — gRPC’s native call cancellation propagates into Triton’s core and, for the TensorRT-LLM and vLLM backends, into the in-flight batching scheduler, freeing that sequence’s KV-cache blocks immediately rather than waiting for it to run to completion. Skipping this is a common source of wasted GPU-seconds under bursty, abandon-prone traffic (chat UIs where users retype a prompt mid-stream).
- Priority.
dynamic_batching { priority_levels: N ... }lets you declare multiple priority queues for fixed-shape models. For LLM in-flight batching, priority is typically handled one layer up — at the request-routing/gateway layer, or via backend-specific scheduling parameters — rather than through Triton’s classic dynamic-batching priority mechanism, since the scheduling unit for an LLM is a decode iteration, not a whole batch.
Saying it out loud. Triton speaks the KServe v2 protocol, which is worth knowing by name because it’s the same API surface across every model type — HTTP on 8000, gRPC on 8001, a standard infer endpoint, plus health and readiness endpoints you wire straight to Kubernetes probes. For LLMs there’s a convenience generate endpoint and a streaming variant. The detail that trips people is streaming: token-by-token output requires a decoupled model, meaning one that returns many responses per request, and you have to declare that explicitly in the config. Leave it off and you get one buffered blob at the end instead of tokens — and it matters even for non-streaming pipelines, because any BLS stage producing variable numbers of responses needs it too.
Metrics
Triton exposes Prometheus metrics at :8002/metrics (curl localhost:8002/metrics). Core series:
| Metric | Meaning |
|---|---|
nv_inference_request_success / _failure | Request counts. |
nv_inference_count | Inferences performed (includes batching effects). |
nv_inference_queue_duration_us | Time requests spend queued — your batching-pressure signal. |
nv_inference_compute_infer_duration_us | Actual model compute time. |
nv_inference_compute_input_duration_us / _output_ | Tensor marshalling time. |
nv_gpu_utilization, nv_gpu_memory_used_bytes | Per-GPU device metrics. |
nv_inference_first_response_histogram_ms | Time-to-first-response histogram, exposed for coupled and decoupled (streaming) models in recent Triton releases — your TTFT signal straight from Prometheus. |
The TensorRT-LLM and vLLM backends add custom metrics for KV cache block usage and in-flight batching (active/scheduled request counts) via Triton’s custom-metrics API. Watch queue duration and KV-cache utilization together: rising queue time with KV cache near 100% means you are memory-bound and should raise kv_cache_free_gpu_mem_fraction, shorten max sequence length, or scale out.
For LLM-aware benchmarking, use GenAI-Perf (part of Perf Analyzer), which reports LLM-specific numbers: time to first token (TTFT), inter-token latency (ITL), output tokens/sec, and request throughput — the metrics that actually matter for chat workloads. Section (B) below has a full walkthrough.
Saying it out loud. Two operational details that only matter once real users are behind the endpoint. Cancellation: when someone closes the tab or retypes their prompt mid-stream, gRPC call cancellation should propagate down into the in-flight batching scheduler so the sequence is evicted and its KV-cache blocks are freed immediately, rather than the GPU spending the next twenty seconds generating tokens nobody will read. On a chat UI with abandon-prone traffic that’s real money, and it’s the kind of thing that stays unhandled until a cost review surfaces it. Priority is the other one: Triton’s classic priority levels attach to dynamic batching, so for LLMs you generally handle priority a layer up at the router, because the scheduling unit is a decode iteration, not a whole batch.
Runtime model control — load/unload without a server restart
Triton’s model management API lets you add, update, or remove models from a running server without a restart — essential for CI/CD pipelines that redeploy models frequently and for the version-rollout workflow described earlier in this chapter.
# Explicit model control mode must be enabled at startup:
# tritonserver --model-repository=/models --model-control-mode=explicit
# Load a model (or a new version of one) after adding files to the repository:
curl -X POST localhost:8000/v2/repository/models/sentiment/load
# Unload a model to free its GPU memory:
curl -X POST localhost:8000/v2/repository/models/sentiment/unload
# Ask what the server currently thinks is in the repository (useful after
# adding/removing a version directory out from under a running server):
curl -X POST localhost:8000/v2/repository/index
Three control modes exist: none (load everything at startup, no runtime changes), poll (periodically re-scan the repository directory and hot-reload changes — convenient, but easy to trigger an unintended reload by touching the wrong file), and explicit (nothing loads or unloads except via the API above — the recommended mode for production, since it makes every model change an auditable, deliberate action rather than a side effect of a filesystem write).
Saying it out loud. The two Triton metrics I’d watch first are queue duration and compute duration, per model, because together they tell you what kind of trouble you’re in. Rising queue time with a full KV cache means you’re memory-bound — raise the cache fraction, shorten max sequence length, or scale out. Rising queue time with an idle GPU means your batching window is under-tuned. Recent releases also expose a first-response histogram, which gives you TTFT straight out of Prometheus rather than having to measure it at the client. And the LLM backends add custom metrics for KV-cache block usage and in-flight batch composition, which is what turns “the service feels slow” into a specific diagnosis.
Build it in practice
Part 1 — an ONNX classifier with batching + concurrency
This is a complete, runnable non-LLM deployment.
Repository layout
/models/
└── sentiment/
├── config.pbtxt
└── 1/
└── model.onnx
config.pbtxt
name: "sentiment"
backend: "onnxruntime"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ 128 ]
},
{
name: "attention_mask"
data_type: TYPE_INT64
dims: [ 128 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ 2 ]
}
]
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 2000
}
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
version_policy { latest { num_versions: 1 } }
This serves the ONNX model with up-to-32 dynamic batches (waiting at most 2 ms to fill one) and two concurrent GPU instances.
Launch the server (Docker)
docker run --gpus all --rm -it \
-p 8000:8000 -p 8001:8001 -p 8002:8002 \
--shm-size=1G --ulimit memlock=-1 --ulimit stack=67108864 \
-v /models:/models \
nvcr.io/nvidia/tritonserver:25.10-py3 \
tritonserver --model-repository=/models --strict-model-config=true
You should see sentiment reported READY in the startup table, and GET localhost:8000/v2/health/ready returns 200.
Call it
curl -s localhost:8000/v2/models/sentiment/infer -d '{
"inputs": [
{ "name": "input_ids", "shape": [1,128], "datatype": "INT64", "data": [101, 2023, 2003, 6659, 999, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"attention_mask": [] }
]
}'
Saying it out loud. Runtime model control is what makes Triton usable in a CI/CD pipeline: you can add, update, or remove models from a running server with no restart. There are three control modes and the choice is a real one. None loads everything at startup and never changes. Poll re-scans the repository directory periodically, which is convenient and also means touching the wrong file triggers an unintended reload of a production model. Explicit is the one I’d run in production, because nothing loads or unloads except through an API call — every model change becomes a deliberate, auditable action instead of a side effect of a filesystem write. That’s also the mode that makes a zero-downtime version swap possible: load the new version alongside the old, shift traffic, then unload the old.
Part 2 — the full LLM ensemble: tokenizer to TensorRT-LLM to detokenizer
This is the piece most tutorials skip: the entire runnable pipeline, with real Python-backend code, not just the ensemble_scheduling skeleton. It mirrors the canonical layout used by NVIDIA’s tensorrtllm_backend (now vendored inside the TensorRT-LLM repo under triton_backend/).
Repository layout
/models/
├── preprocessing/
│ ├── config.pbtxt
│ └── 1/
│ └── model.py
├── tensorrt_llm/
│ ├── config.pbtxt
│ └── 1/
│ └── (engine files or PyTorch-backend checkpoint dir)
├── postprocessing/
│ ├── config.pbtxt
│ └── 1/
│ └── model.py
└── ensemble/
├── config.pbtxt
└── 1/ # empty — ensembles have no model files
preprocessing/config.pbtxt — tokenizer as a Python backend model
name: "preprocessing"
backend: "python"
max_batch_size: 8
input [
{ name: "QUERY" data_type: TYPE_STRING dims: [ 1 ] },
{ name: "REQUEST_OUTPUT_LEN" data_type: TYPE_INT32 dims: [ 1 ] }
]
output [
{ name: "input_ids" data_type: TYPE_INT32 dims: [ -1 ] },
{ name: "request_input_len" data_type: TYPE_INT32 dims: [ 1 ] }
]
parameters { key: "tokenizer_dir" value: { string_value: "/models/preprocessing/1/tokenizer" } }
parameters { key: "add_special_tokens" value: { string_value: "True" } }
instance_group [ { count: 1 kind: KIND_CPU } ]
preprocessing/1/model.py:
import json
import numpy as np
import triton_python_backend_utils as pb_utils
from transformers import AutoTokenizer
class TritonPythonModel:
def initialize(self, args):
model_config = json.loads(args["model_config"])
tokenizer_dir = model_config["parameters"]["tokenizer_dir"]["string_value"]
self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
self.tokenizer.pad_token = self.tokenizer.pad_token or self.tokenizer.eos_token
def execute(self, requests):
responses = []
for request in requests:
query = pb_utils.get_input_tensor_by_name(request, "QUERY")
text = query.as_numpy()[0][0].decode("utf-8")
ids = self.tokenizer.encode(text, add_special_tokens=True)
input_ids = np.array([ids], dtype=np.int32)
input_len = np.array([[len(ids)]], dtype=np.int32)
responses.append(
pb_utils.InferenceResponse(
output_tensors=[
pb_utils.Tensor("input_ids", input_ids),
pb_utils.Tensor("request_input_len", input_len),
]
)
)
return responses
tensorrt_llm/config.pbtxt — the engine model
name: "tensorrt_llm"
backend: "tensorrtllm"
max_batch_size: 64
input [
{ name: "input_ids" data_type: TYPE_INT32 dims: [ -1 ] },
{ name: "request_input_len" data_type: TYPE_INT32 dims: [ 1 ] },
{ name: "request_output_len" data_type: TYPE_INT32 dims: [ 1 ] }
]
output [
{ name: "output_ids" data_type: TYPE_INT32 dims: [ -1, -1 ] }
]
model_transaction_policy { decoupled: true }
parameters: { key: "gpt_model_type" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "batching_strategy" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "gpt_model_path" value: { string_value: "/models/tensorrt_llm/1" } }
parameters: { key: "kv_cache_free_gpu_mem_fraction" value: { string_value: "0.9" } }
parameters: { key: "enable_chunked_context" value: { string_value: "True" } }
instance_group [ { count: 1 kind: KIND_MODEL } ]
decoupled: true is what allows this model to stream partial (per-token) responses back through the ensemble instead of buffering the whole generation.
postprocessing/config.pbtxt — detokenizer
name: "postprocessing"
backend: "python"
max_batch_size: 8
input [ { name: "output_ids" data_type: TYPE_INT32 dims: [ -1, -1 ] } ]
output [ { name: "OUTPUT" data_type: TYPE_STRING dims: [ 1 ] } ]
parameters { key: "tokenizer_dir" value: { string_value: "/models/preprocessing/1/tokenizer" } }
instance_group [ { count: 1 kind: KIND_CPU } ]
postprocessing/1/model.py:
import json
import numpy as np
import triton_python_backend_utils as pb_utils
from transformers import AutoTokenizer
class TritonPythonModel:
def initialize(self, args):
model_config = json.loads(args["model_config"])
tokenizer_dir = model_config["parameters"]["tokenizer_dir"]["string_value"]
self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
def execute(self, requests):
responses = []
for request in requests:
output_ids = pb_utils.get_input_tensor_by_name(request, "output_ids").as_numpy()
text = self.tokenizer.decode(output_ids[0][0], skip_special_tokens=True)
out = np.array([[text.encode("utf-8")]], dtype=object)
responses.append(
pb_utils.InferenceResponse(output_tensors=[pb_utils.Tensor("OUTPUT", out)])
)
return responses
ensemble/config.pbtxt — the glue
name: "ensemble"
platform: "ensemble"
max_batch_size: 8
input [
{ name: "text_input" data_type: TYPE_STRING dims: [ 1 ] },
{ name: "max_tokens" data_type: TYPE_INT32 dims: [ 1 ] }
]
output [
{ name: "text_output" data_type: TYPE_STRING dims: [ 1 ] }
]
ensemble_scheduling {
step [
{
model_name: "preprocessing"
model_version: -1
input_map { key: "QUERY" value: "text_input" }
input_map { key: "REQUEST_OUTPUT_LEN" value: "max_tokens" }
output_map { key: "input_ids" value: "_input_ids" }
output_map { key: "request_input_len" value: "_request_input_len" }
},
{
model_name: "tensorrt_llm"
model_version: -1
input_map { key: "input_ids" value: "_input_ids" }
input_map { key: "request_input_len" value: "_request_input_len" }
input_map { key: "request_output_len" value: "max_tokens" }
output_map { key: "output_ids" value: "_output_ids" }
},
{
model_name: "postprocessing"
model_version: -1
input_map { key: "output_ids" value: "_output_ids" }
output_map { key: "OUTPUT" value: "text_output" }
}
]
}
Launch and call the pipeline
docker run --gpus all --rm -it \
-p 8000:8000 -p 8001:8001 -p 8002:8002 \
--shm-size=2G --ulimit memlock=-1 --ulimit stack=67108864 \
-v /models:/models \
nvcr.io/nvidia/tritonserver:25.10-trtllm-python-py3 \
tritonserver --model-repository=/models
curl -s -X POST localhost:8000/v2/models/ensemble/generate -d '{
"text_input": "Explain in one sentence why paged KV cache matters.",
"max_tokens": 64
}'
One client call, three internal model hops, zero network round trips between them — that is the entire value proposition of ensembles in one example.
Saying it out loud. The non-LLM case is the easy one and worth doing first because it makes the abstractions concrete. A config file declaring the ONNX backend, input and output tensor shapes, a max batch size, a dynamic batching block with preferred batch sizes and a two-millisecond queue delay, and an instance group with two GPU copies. That’s a complete production deployment. The one shape gotcha to remember: when max batch size is greater than zero, the batch dimension is implicit, so you list only the per-sample shape. Listing the batch dim explicitly double-counts it and breaks shape validation in a way whose error message won’t obviously tell you that.
Part 2b — multi-GPU launch: tensor parallel and pipeline parallel
A single GPU cannot hold every model at every precision — a large dense model in FP16/BF16, or any model at long context with a large KV cache, needs to be split across GPUs. TensorRT-LLM (and the vLLM backend) support two orthogonal ways to split it:
- Tensor parallelism (TP) — shard each layer’s weight matrices across GPUs, with an all-reduce after each sharded op. Reduces per-GPU memory and lets a bigger model fit, at the cost of inter-GPU communication on every layer — wants NVLink, not just PCIe, between the GPUs involved.
- Pipeline parallelism (PP) — assign different layers to different GPUs, streaming activations forward through the pipeline. Lower communication overhead per step than TP, but only helps throughput if you keep the pipeline full with enough concurrent requests, and adds bubble latency for the first request through an empty pipeline.
Real deployments often combine both (tensor_parallel_size * pipeline_parallel_size = world_size, the total GPU count for one model replica). This is set at build/config time and reflected in the launch command, which uses MPI to start one Triton/TensorRT-LLM process per GPU that all rendezvous into a single logical model:
# 2-way tensor parallel, 2-way pipeline parallel = 4 GPUs for one model replica
python3 /app/scripts/launch_triton_server.py \
--world_size=4 \
--tensorrt_llm_model_repository_path=/models \
--tensorrt_llm_model_name=tensorrt_llm
Under the hood this is an mpirun-launched set of ranks, one per GPU, each running its shard of the model; Triton’s frontend still presents a single model endpoint to clients — the parallelism is entirely invisible from the API. The config’s gpt_model_path/checkpoint must have been built (or, on the PyTorch backend, configured) for that same world_size; you cannot load a 4-GPU checkpoint with --world_size=2 or vice versa. If instance_group { kind: KIND_MODEL } is set (as in the example ensemble above), Triton delegates device placement entirely to the backend’s own multi-GPU orchestration rather than trying to manage it itself — this is the correct setting whenever the backend is doing TP/PP internally.
Saying it out loud. When one GPU can’t hold the model, there are two orthogonal ways to split it and they have different costs. Tensor parallelism shards each layer’s weight matrices across GPUs with an all-reduce after every sharded op — it keeps single-request latency low, but it wants NVLink-class bandwidth between those GPUs, not PCIe. Pipeline parallelism assigns whole layers to different GPUs and streams activations forward — much less communication per step, but it only helps throughput if you keep the pipeline full, and the first request through an empty pipeline eats bubble latency. Big deployments combine both, where tensor size times pipeline size equals world size. And the operational rule: a checkpoint built for a four-GPU world size cannot be loaded with two — those numbers are part of the artifact.
Part 3 — benchmarking it correctly with GenAI-Perf
Curl calls tell you the pipeline works. They tell you nothing about throughput, tail latency, or whether your batching configuration is actually helping. GenAI-Perf (a Perf Analyzer subcommand purpose-built for generative workloads) is the tool for that.
Install
pip install genai-perf
# or, with zero local setup, use the Triton SDK container:
docker run --gpus all --rm -it --net=host \
nvcr.io/nvidia/tritonserver:25.10-py3-sdk bash
Benchmark the TensorRT-LLM ensemble over gRPC (KServe protocol)
genai-perf profile \
-m ensemble \
--backend tensorrtllm \
--url localhost:8001 \
--endpoint-type kserve \
--streaming \
--concurrency 16 \
--synthetic-input-tokens-mean 200 --synthetic-input-tokens-stddev 20 \
--output-tokens-mean 128 --output-tokens-stddev 10 \
--measurement-interval 10000 \
--profile-export-file trtllm_c16.json
Benchmark the vLLM backend over its OpenAI-compatible frontend
genai-perf profile \
-m my_vllm_model \
--service-kind openai --endpoint-type chat \
--url localhost:8000 \
--streaming \
--concurrency 16 \
--synthetic-input-tokens-mean 200 \
--output-tokens-mean 128
Reading the output
GenAI-Perf prints (and writes to CSV/JSON) a table with, at minimum:
| Metric | What it tells you |
|---|---|
| Time to first token (TTFT) | Perceived “did it start responding” latency — dominated by prefill + queueing. |
| Inter-token latency (ITL) | Steady-state per-token decode speed — dominated by batch composition and KV-cache pressure. |
| Output token throughput (tokens/s) | Aggregate decode throughput across all concurrent requests — the number that answers “how many users can this GPU serve.” |
| Request throughput (req/s) | End-to-end completions per second — sensitive to max_tokens distribution. |
| p50/p90/p99 latency | Tail behavior; the number that catches SLO violations averages hide. |
The workflow that actually matters in production: run GenAI-Perf at a sweep of concurrencies (1, 4, 8, 16, 32, 64…) before and after any config change — a new kv_cache_free_gpu_mem_fraction, a different instance_group, a Triton or TensorRT-LLM version bump — and diff the curves. A config that looks fine at concurrency 1 can fall off a cliff at 32 because the KV cache runs out of room; only the sweep shows you that. Treat a GenAI-Perf regression sweep as a release gate the same way you would a latency/throughput dashboard for a database migration.
Saying it out loud. Curl proves the pipeline works; it tells you nothing about throughput or tail latency. GenAI-Perf is the benchmarking tool that reports the numbers that actually matter for generation — time to first token, inter-token latency, output tokens per second, and percentile latencies — rather than generic requests per second. The workflow I’d insist on is a concurrency sweep: run it at 1, 4, 8, 16, 32, 64 before and after any config change, and diff the curves. A configuration that looks perfectly healthy at concurrency 1 can fall off a cliff at 32 because the KV cache runs out of room, and only the sweep shows you that. Treat a GenAI-Perf regression sweep as a release gate, the same way you’d gate a database migration.
Triton + TensorRT-LLM vs vLLM vs TGI
| Dimension | Triton + TensorRT-LLM | vLLM (standalone) | TGI (Text Generation Inference) |
|---|---|---|---|
| Primary goal | Multi-model serving fleet + fastest NVIDIA LLM path | Fast, simple LLM serving | HuggingFace-native LLM serving |
| Continuous batching | Yes (in-flight, fused) | Yes (native) | Yes (native) |
| KV cache | Paged | PagedAttention (originator) | Paged |
| Setup effort | High (classic engine path) to medium (new PyTorch-backend / LLM API path) | Low — pip install, point at HF model | Low–medium — Docker + model id |
| Peak throughput on NVIDIA | Highest (tuned kernels, esp. with FP8/FP4 quantization) | Very high | High |
| Hardware | NVIDIA only | NVIDIA-first (some others) | NVIDIA-first (some others) |
| Non-LLM models | Yes — same server hosts ONNX/PyTorch/TensorRT | No | No |
| Ops surface | One server, unified metrics, ensembles/BLS | Simple, LLM-scoped | Simple, LLM-scoped |
| Streaming | Decoupled + /generate_stream | OpenAI-compatible SSE | SSE, OpenAI-compatible |
| Best when | You run many models and/or want max NVIDIA LLM perf with unified ops | You want the least-effort fast LLM server | You are all-in on the HF stack |
Honest summary: for a single LLM, vLLM or TGI is faster to stand up and gets you most of the throughput with a fraction of the effort. Triton + TensorRT-LLM wins when you need a heterogeneous model fleet under one runtime, or when you have squeezed everything else and need the last increment of GPU efficiency and are willing to pay the setup tax. Note also that Triton can host the vLLM backend, giving you vLLM’s ergonomics inside Triton’s ops framework — a common middle ground, and increasingly the default recommendation for teams that want Triton’s multi-model story without the TensorRT-LLM engine-build tax.
Saying it out loud. The honest summary is that for a single LLM, vLLM or SGLang is faster to stand up and gets you most of the throughput for a fraction of the effort. Triton wins on two specific axes: a heterogeneous model fleet under one runtime with one metrics format and one deployment story, or the last increment of NVIDIA-hardware efficiency through TensorRT-LLM’s tuned kernels and FP8 or FP4 quantization. What people miss is that it isn’t a binary — Triton can host the vLLM backend, and NVIDIA’s own numbers put that within about two percent of standalone vLLM. So the real question isn’t Triton versus vLLM, it’s whether you need a multi-model runtime, and if so, which engine you put inside it.
(A) The 2025–2026 landscape
Triton and TensorRT-LLM have both changed shape since the “build an engine, wire an ensemble” era described above was the only way to do this. If you are interviewing or designing a new deployment in 2026, know the current picture — not just the mechanics.
The TensorRT-LLM backend has moved, and PyTorch became the default execution path
The Triton-specific glue code that used to live in its own triton-inference-server/tensorrtllm_backend repository has been relocated into the TensorRT-LLM repository itself, under a triton_backend/ directory — the two projects now ship and version together rather than as loosely-coupled siblings (github.com/triton-inference-server/tensorrtllm_backend, and github.com/NVIDIA/TensorRT-LLM). More significantly, TensorRT-LLM’s own execution backend changed: what used to be the only path — compile a model with trtllm-build into a .engine/.plan, then load that plan — is now the legacy path. The PyTorch execution backend (often called the “LLM API”) is the default: you point it at a HuggingFace checkpoint and it builds and manages the runtime graph itself, no separate offline compile step required. By TensorRT-LLM’s 1.2 release line, the PyTorch backend became effectively the sole supported execution backend, with the classic TensorRT engine-compile workflow deprecated (nvidia.github.io/TensorRT-LLM/release-notes.html; github.com/NVIDIA/TensorRT-LLM/releases). Practically, this means:
- New deployments should default to the PyTorch/LLM-API backend inside Triton (container tags like
*-trtllm-python-py3) unless you have a specific reason to hand-tune a compiled engine. - The mental model in this chapter — engine build, then ensemble, then launch — still describes how the request path works (in-flight batching, paged KV cache, pre/post-processing via Python models); what changed is how the model artifact itself gets produced, not the serving architecture around it.
- If you inherited a repo that still does
trtllm-buildby hand, it will keep working, but plan a migration; NVIDIA’s own examples now lead with the PyTorch path.
Saying it out loud. The thing to know if you’re interviewing in 2026 is that the setup story changed. The classic path was compile a model with trtllm-build into an engine file, then load that plan — and that’s now the legacy path. The PyTorch execution backend, the LLM API, is the default: you point it at a Hugging Face checkpoint and it manages the runtime graph itself with no offline compile step. The Triton glue code also moved into the TensorRT-LLM repository, so the two version together rather than as loosely-coupled siblings — part of a broader pattern where Triton has been folded into NVIDIA’s wider inference stack rather than standing alone. What matters practically is that this removes the biggest setup-cost objection to Triton; the serving architecture around it is unchanged.
Quantization and precision: FP4/NVFP4 arrive alongside FP8
TensorRT-LLM’s quantization matrix has grown substantially on Blackwell-class GPUs (nvidia.github.io/TensorRT-LLM/latest/features/quantization.html):
- FP8 — per-tensor, block-scaling, and rowwise variants — is broadly supported from Ada/Hopper through Blackwell, including an FP8 KV cache to shrink the biggest LLM-serving memory line item.
- FP4 / NVFP4 (4-bit floating point, plus an MXFP4 variant) is now supported on Blackwell (sm100/sm103) for both weights and, on the newest GPUs, KV cache — roughly halving memory footprint again versus FP8 with NVIDIA reporting minimal accuracy loss on validated model families.
- Weight-only integer quantization (W4A16/W4A8 AWQ and GPTQ) remains available across generations for teams on Ampere/Ada hardware without FP8/FP4 tensor cores.
The practical takeaway for an interview or a design doc: quantization choice is now a GPU-generation decision as much as an accuracy decision. On Blackwell, FP4/NVFP4 is usually the throughput-per-dollar winner if the model has been validated at that precision; on Hopper, FP8 is the mainstream default; older GPUs fall back to AWQ/GPTQ weight-only quantization.
In practice, quantizing a checkpoint for the PyTorch/LLM-API backend is a config-driven step rather than a bespoke script — you point the build/serve tooling at the checkpoint and declare the target precision, and it handles calibration for the formats that need it (AWQ/GPTQ still require a calibration pass over representative data; FP8 and FP4/NVFP4 on validated model families can often run with vendor-provided or activation-aware scaling with a lighter calibration step). The engineering discipline that doesn’t change: always re-run your task-specific eval suite (not just perplexity) after quantizing, since generative quality degradation from aggressive quantization is uneven across tasks — code and math are typically more sensitive than open-ended chat.
Saying it out loud. Quantization is now as much a GPU-generation decision as an accuracy decision, and that framing is what scores. On Blackwell, FP4 or NVFP4 roughly halves memory again versus FP8 and is usually the throughput-per-dollar winner if your model family has been validated at that precision. On Hopper and Ada, FP8 is the mainstream default, and an FP8 KV cache shrinks your biggest memory line item. Older cards without those tensor cores fall back to weight-only INT4 through AWQ or GPTQ. The discipline that doesn’t change with any of it: re-run your task-specific eval suite after quantizing, not just perplexity, because degradation is uneven — code and math break well before open-ended chat does. And these figures are as of 2026 hardware; check the current support matrix before quoting them.
Disaggregated serving and speculative decoding are now first-class
Two techniques that used to be research topics are now supported serving patterns:
- Disaggregated (prefill/decode-split) serving — running prefill and decode on separate GPU pools and transferring the KV cache between them (over NVLink or a network fabric) so that the compute-bound prefill phase and the memory-bandwidth-bound decode phase don’t contend for the same hardware. TensorRT-LLM added a KV Cache Connector API specifically to make this state-transfer pluggable, and Triton’s own reference architectures describe 1.2–2.5x throughput gains from splitting the two phases at scale.
- Speculative decoding — draft-and-verify schemes (n-gram drafting, multi-layer EAGLE-3, and external draft models) are now integrated with in-flight batching and guided/structured decoding, so you can turn on speculation without giving up continuous batching.
Neither is “day one” complexity — start with a single-pool in-flight-batched deployment — but both are now the answer to “we’ve maxed out a single-pool deployment, what next,” and are worth naming in a systems-design interview even if you have not implemented them yourself.
Saying it out loud. Two things that were research topics are now supported serving patterns, and both are good “what would you do next” answers. Disaggregated serving splits prefill and decode onto separate GPU pools and ships the KV cache between them, because prefill is compute-bound and decode is memory-bandwidth-bound and they contend badly for the same hardware — NVIDIA’s reference numbers put the gain somewhere in the 1.2 to 2.5x range at scale. Speculative decoding drafts several tokens cheaply and verifies them in one pass, and it’s now integrated with in-flight batching so you don’t have to give one up for the other. Neither is day-one complexity. Reach for them once a single-pool deployment is saturated and profiling shows prefill and decode fighting each other.
Where Triton sits next to vLLM and SGLang today
vLLM and SGLang have both matured into serious standalone production servers with their own continuous batching, quantization, disaggregated-serving, and OpenAI-compatible APIs. That narrows — but does not eliminate — Triton’s differentiation:
- NVIDIA’s own positioning (see the vLLM x Triton materials NVIDIA has published) is that Triton is not competing with vLLM’s engine — it wraps it. The Triton vLLM backend measures within roughly 2% of standalone vLLM’s throughput and latency, while adding Triton’s scheduling, multi-model hosting, Prometheus metrics, and an OpenAI-compatible FastAPI front door on top. In other words: you can get vLLM’s engine and Triton’s ops surface at the same time.
- SGLang has pulled ahead on some structured-generation and multi-turn/prefix-heavy workloads (its RadixAttention prefix cache), and is a legitimate default for teams whose workload is dominated by long shared prefixes (agents, few-shot prompting, RAG with repeated system prompts).
- Choose Triton when: you are serving a fleet of heterogeneous models (LLM + embedding + reranker + a classic ONNX/PyTorch model) behind one runtime; you need enterprise support and a stable KServe v2 API across model types; or you specifically need TensorRT-LLM’s peak NVIDIA-hardware throughput with FP4/FP8 and are willing to operate the extra moving part.
- Choose vLLM or SGLang directly when: you have exactly one (or a small number of) LLMs, want the fastest path to a working OpenAI-compatible endpoint, and don’t need a shared multi-framework serving runtime. Many teams now run vLLM/SGLang standalone for the LLM and only reach for Triton once a second or third non-LLM model shows up that needs to share the same ops story.
- The pragmatic middle ground more teams land on in 2026: Triton hosting the vLLM backend rather than TensorRT-LLM — you get Triton’s multi-model repository, metrics, and ensembles, without paying the TensorRT-LLM engine/PyTorch-backend conversion tax, and you upgrade to the TensorRT-LLM backend later only if profiling shows you actually need the extra throughput.
Saying it out loud. The competitive picture narrowed but didn’t close. vLLM and SGLang are both serious standalone production servers now with their own continuous batching and OpenAI-compatible APIs, and SGLang in particular has pulled ahead on prefix-heavy workloads through its radix prefix cache — agents, few-shot prompting, RAG with a repeated system prompt. So I’d choose Triton when I’m serving a genuinely heterogeneous fleet, when I need a stable KServe v2 API across model types, or when I specifically need TensorRT-LLM’s peak throughput and can operate the extra moving part. The middle ground more teams actually land on is Triton hosting the vLLM backend — multi-model repository, metrics, ensembles, without the engine-build tax — and upgrading the engine later only if profiling says you need to.
Failure modes and pitfalls
- Wrong backend for LLMs. Serving generation through ONNX/PyTorch +
dynamic_batchingproduces terrible throughput and head-of-line blocking. LLMs require the TensorRT-LLM or vLLM backend with in-flight batching. This is the number-one mistake. - Static-batch TensorRT-LLM engine. Building the engine without paged KV cache, or leaving
batching_strategy/gpt_model_typeatv1, silently disables continuous batching. You paid the conversion cost and got none of the benefit. - Misconfigured dynamic batching.
max_queue_delay_microsecondstoo high causes latency spikes; too low causes tiny batches and an idle GPU.preferred_batch_sizemismatched to what the engine was tuned for wastes time on padding. Tune against GenAI-Perf, not by guessing. max_batch_sizevs shape confusion. Withmax_batch_size > 0the batch dim is implicit — listing it explicitly indimsdouble-counts it and breaks shape checks. Setmax_batch_size: 0only for models whose first dim is not a batch dimension.- KV-cache OOM.
kv_cache_free_gpu_mem_fractiontoo aggressive (or too many concurrent LLM instances) OOMs under load; too conservative wastes capacity. Watch KV-cache utilization metrics. - Version / container mismatches. The TensorRT-LLM engine (or PyTorch-backend checkpoint), the backend build, and the Triton container are a matched set. Loading an artifact built against one TensorRT-LLM release inside a mismatched Triton image fails to load or crashes. Pin versions together, and re-validate after every upgrade — see the war story below.
- Model name / directory mismatch.
nameinconfig.pbtxtdisagreeing with the directory, or a non-integer version folder, makes the model silently not load. Read the startup READY/UNAVAILABLE table. - Conversion complexity underestimated. Even on the newer PyTorch/LLM-API path, wiring the ensemble (pre/post-processing, tokenizer directories, decoupled streaming) is genuinely involved and model-specific. Budget for it; do not promise a one-day LLM deploy.
- Forgetting
--shm-size/ decoupled streaming. Python-backend ensembles need adequate shared memory; streaming needsdecoupled: trueor you get one blob at the end instead of tokens.
Saying it out loud. The pitfalls cluster into one theme: things that load fine and run badly. Wrong backend for an LLM is number one — generation through ONNX plus dynamic batching gets you head-of-line blocking and terrible throughput. A static-batch engine, or leaving the batching strategy at v1, silently disables continuous batching after you paid the whole conversion cost. Version mismatches between the artifact, the backend build, and the container tag can load and then quietly run on a degraded path. A model name that disagrees with its directory just doesn’t load, and you’ll only see it in the startup READY table. The unifying lesson is that Triton’s health checks tell you a model loaded, not that it’s fast — which is why a benchmark sweep has to be part of the deploy, not an afterthought.
(C) Production case studies & war stories
War story 1 — the config regeneration that silently capped throughput at 1x
Setup: A team ran an ONNX sentiment classifier in Triton with a hand-tuned config.pbtxt: dynamic_batching { preferred_batch_size: [8, 16, 32] max_queue_delay_microseconds: 2000 } and two GPU instances. Throughput was healthy for months.
The incident: A routine model refresh redeployed the model directory from a CI pipeline that regenerated config.pbtxt from a template — and the template had been written against --strict-model-config=false defaults, before anyone had added the dynamic_batching block. The new config still loaded fine (Triton auto-generated a valid config from the ONNX graph), the model still went READY, health checks still passed — but the auto-generated config had no dynamic_batching block at all, meaning every request executed one at a time. GPU utilization on the dashboard quietly dropped from ~70% to under 10%, and p99 latency crept up as the request queue backed up during traffic peaks — but nothing failed, so no alert fired.
How it was caught: A GenAI-Perf-style concurrency sweep run before the next capacity-planning review showed throughput flatlining at concurrency 4 instead of scaling to concurrency 32 the way the same model had six months earlier. Diffing config.pbtxt between the running container and the last known-good version showed the missing dynamic_batching stanza immediately.
Lesson: Auto-generated config (--strict-model-config=false) is fine for local experimentation and dangerous in a CI/CD pipeline that doesn’t diff the result. Run production with --strict-model-config=true so a missing or malformed batching config is a hard failure at load time, not a silent throughput regression discovered a quarter later. Treat config.pbtxt as reviewed, versioned infrastructure code, not a generated artifact.
A cheap guardrail that would have caught war story 1 before it shipped — a CI check run against every config.pbtxt change:
#!/usr/bin/env bash
# ci_check_batching_config.sh — fail the pipeline if a model that should
# batch doesn't declare a batching strategy.
set -euo pipefail
for cfg in models/*/config.pbtxt; do
name=$(dirname "$cfg")
if grep -q 'backend: "tensorrtllm"' "$cfg"; then
grep -q 'batching_strategy' "$cfg" || { echo "FAIL: $name missing batching_strategy"; exit 1; }
elif grep -qE 'backend: "(onnxruntime|pytorch)"' "$cfg" && grep -q 'max_batch_size: [1-9]' "$cfg"; then
grep -q 'dynamic_batching' "$cfg" || { echo "FAIL: $name has max_batch_size > 0 but no dynamic_batching block"; exit 1; }
fi
done
echo "All configs OK"
It is a blunt instrument — it checks for the presence of a block, not that the values are well-tuned — but “present vs silently absent” is exactly the failure mode that bit this team, and a five-line grep script in CI is cheaper than a quarter of degraded throughput.
Saying it out loud. This is the one I’d tell if asked about a silent regression. A CI pipeline regenerated config.pbtxt from a template that predated the hand-tuned dynamic batching block. The model loaded, went READY, health checks passed — and every request executed one at a time, because the auto-generated config had no batching block at all. GPU utilization dropped from about 70% to under 10% and nothing failed, so nothing alerted. It took a concurrency sweep before a capacity review, a quarter later, to notice throughput flatlining at concurrency 4. Two fixes: run production with strict model config so a missing batching block is a hard failure at load time, and treat config.pbtxt as reviewed, versioned infrastructure code rather than a generated artifact.
War story 2 — an upgrade that loaded fine and ran 3x slower
Setup: A team running a TensorRT-LLM ensemble upgraded their Triton container to pick up a security patch, without rebuilding the model artifact, on the assumption that “the model directory didn’t change, so nothing needs rebuilding.”
The incident: The new container loaded the existing engine/checkpoint without erroring — but the backend version bundled with the new image did not match the one the artifact had been produced against closely enough to run the requested batching_strategy at full performance; it silently fell back to a degraded execution path. Nothing crashed. Nothing appeared in the error logs. The service was “working.” Token throughput per GPU, measured only informally by an on-call engineer noticing users complaining that responses “felt slower,” turned out to be down roughly 3x versus the pre-upgrade baseline.
How it was caught: Because there was no automated before/after GenAI-Perf comparison gating the rollout, this took days to notice and diagnose — the eventual fix was rebuilding the artifact against the new backend version and re-running the same GenAI-Perf concurrency sweep to confirm parity before calling the upgrade complete.
Lesson: Pin the TensorRT-LLM/backend version, the model artifact, and the Triton container tag together as one versioned unit, and treat any change to any one of the three as a change to all three — rebuild and re-benchmark, don’t assume compatibility because it loads. Bake a GenAI-Perf regression sweep into the deployment pipeline as an automated gate (fail the rollout if p50 tokens/s at a fixed concurrency drops more than some threshold, e.g. 10%, versus the current production baseline) rather than relying on a human noticing a “feels slower” complaint.
Saying it out loud. Same shape of failure, different trigger. A team upgraded the Triton container for a security patch without rebuilding the model artifact, on the reasonable-sounding logic that the model directory hadn’t changed. The new container loaded the old engine without erroring, but the bundled backend version didn’t match closely enough to run the requested batching strategy at full speed, so it silently fell back to a degraded path. Nothing crashed, nothing logged an error, and throughput per GPU was down roughly 3x — discovered days later because users said it “felt slower.” The rule: the artifact, the backend build, and the container tag are one versioned unit, and a change to any one is a change to all three. Gate the rollout on an automated benchmark diff, not on whether it loads.
War story 3 — KV cache sized for the demo, not for peak concurrency
Setup: kv_cache_free_gpu_mem_fraction was set to 0.9 during initial rollout, validated against a demo workload of a handful of short prompts.
The incident: Under real traffic — many concurrent long-context conversations — the KV cache filled up, and new requests began queueing behind an in-flight batch that could not make room for them, producing a sawtooth pattern of throughput collapsing to zero and recovering, visible in nv_inference_queue_duration_us spiking in lockstep with KV-cache utilization hitting 100%.
Lesson: Load-test with a realistic input/output token-length distribution and concurrency — not a demo prompt — before shipping a kv_cache_free_gpu_mem_fraction value, and alert on KV-cache utilization directly rather than waiting for the downstream symptom (queue duration) to show up.
Saying it out loud. The last one is the simplest and the most common. The KV cache memory fraction was set at 0.9 and validated against a demo workload of a few short prompts. Under real traffic — many concurrent long-context conversations — the cache filled, new requests queued behind an in-flight batch that couldn’t make room for them, and throughput collapsed and recovered in a sawtooth, with queue duration spiking in lockstep with cache utilization hitting 100%. The lesson is that you cannot size a KV cache from a demo prompt; you load test with a realistic distribution of input and output lengths at realistic concurrency. And alert on KV-cache utilization directly rather than waiting for queue duration, which is the downstream symptom.
Operating Triton at scale: Kubernetes, security, and multi-tenancy
Kubernetes deployment shape
Triton itself doesn’t manage a fleet of replicas or autoscale — that’s Kubernetes’ (or KServe’s) job, with Triton as the container image running inside each pod. The common shape:
- Deployment/StatefulSet running the
tritonservercontainer, GPU requested vianvidia.com/gpu: 1(or more, for a multi-GPU TP/PP model replica — in which case the pod typically also needs multiple GPUs scheduled onto the same node, or a multi-node MPI job for very large models). - Readiness/liveness probes wired to
GET /v2/health/readyand/v2/health/live— a model still loading (e.g., a large LLM checkpoint) should fail readiness, not liveness, so Kubernetes doesn’t kill a pod that’s merely slow to start. - Horizontal Pod Autoscaler driven by a custom metric — GPU utilization alone is a poor autoscaling signal for LLM serving because a GPU running in-flight batching near KV-cache capacity can show high utilization while still queueing;
nv_inference_queue_duration_usor a GenAI-Perf-derived tokens/s-per-replica target is a better trigger. - KServe’s
InferenceServiceCRD wraps this pattern with a standard interface across ONNX, PyTorch, and Triton-hosted models, and is a common choice when a platform team wants one custom resource across many serving runtimes rather than hand-rolled Deployments per model.
Saying it out loud. Triton doesn’t manage replicas or autoscale — that’s Kubernetes’ job, with Triton as the container inside each pod. Three details make or break it. Wire readiness to the ready endpoint and liveness to the live endpoint separately, because a large LLM checkpoint that’s still loading should fail readiness, not liveness — get that backwards and Kubernetes kills pods for the crime of being slow to start. Autoscale on queue duration or a tokens-per-second target rather than GPU utilization, since a GPU running in-flight batching near KV-cache capacity reads as fully utilized while it’s queueing. And KServe’s InferenceService wraps this pattern if a platform team wants one custom resource across many serving runtimes.
Security
Triton’s core does not implement authentication or authorization — treat the raw HTTP/gRPC/metrics ports as internal-network-only and put a gateway in front for anything internet-facing:
- API keys or mTLS at the gateway/ingress, not at Triton itself — Envoy, an API gateway, or a service mesh sidecar is the right layer for this.
- The model-repository control API (
/v2/repository/models/{name}/load/unload) is an administrative capability — anyone who can reach it can load an arbitrary model from the repository storage or unload a production model. Restrict it to an internal admin network or a separate management port/network policy; do not expose it on the same public path as inference traffic. - The metrics endpoint (
:8002) can leak operational detail (model names, request volumes) — scope its exposure to your monitoring stack’s network, not the public internet.
Multi-tenancy
Triton’s model repository and instance groups give you the primitives for multi-tenant isolation, but the isolation policy is yours to build:
- Resource isolation — pin different tenants’ models to different
instance_group { gpus: [...] }sets (or different node pools in Kubernetes) if noisy-neighbor GPU contention between tenants is a concern; Triton does not enforce per-tenant fairness within a shared GPU on its own. - Namespacing — prefixing model names by tenant (
tenantA_sentiment,tenantB_sentiment) in a shared repository is simple but couples tenants to one repository’s blast radius (a bad--strict-model-configchange or a repository-wide restart affects everyone); separate model repositories per tenant, each behind its own Triton deployment, trade operational simplicity for stronger isolation. - Quota/rate limiting happens above Triton — at the gateway — since Triton’s own priority levels and batching config are per-model scheduling knobs, not per-tenant quota enforcement.
Saying it out loud. The security answer is short and it’s mostly about what Triton doesn’t do: there’s no authentication or authorization in the core, so you treat the HTTP, gRPC, and metrics ports as internal-network-only and put a gateway in front — API keys or mTLS at Envoy or a mesh sidecar, not at Triton. The one people forget is that the model repository control API is an administrative capability: anyone who can reach the load endpoint can load an arbitrary model from repository storage or unload a production one, so it belongs on a management network, not the same public path as inference. Same for the metrics port, which leaks model names and request volumes.
(D) Interview mastery
Explain when you’d choose Triton over vLLM in 60 seconds
“Default to vLLM (or SGLang) if I have one LLM and want the fastest path to an OpenAI-compatible endpoint — it’s less setup, and it gets most of the throughput of any alternative. I reach for Triton when I have more than just an LLM: an embedding model, a reranker, maybe a classic ONNX classifier, all needing to share GPUs, expose one metrics format, and version consistently — Triton is a serving runtime that hosts all of them, LLM included, behind one API. If I specifically need NVIDIA’s peak-throughput LLM path, I’d use the TensorRT-LLM backend inside Triton, accepting the extra setup for FP8/FP4 kernels and in-flight batching tuned at the kernel level. But a very common middle ground today is Triton hosting the vLLM backend — you get vLLM’s engine, which NVIDIA’s own numbers put within a couple percent of standalone vLLM, plus Triton’s multi-model hosting, ensembles, and metrics, without paying the TensorRT-LLM engine-build tax. So it’s not really ‘Triton vs vLLM’ — it’s ‘do I need a multi-model runtime, and if so, which engine do I put inside it.’”
System design prompt: serve an LLM, an embedding model, and a reranker behind one inference server
Prompt as asked in interviews: “Design a system that serves three model types — a generative LLM, an embedding model, and a cross-encoder reranker — for a RAG application, behind a single inference server.”
Sketch:
┌─────────────────────────────┐
│ Triton Inference Server │
│ (single process, 1+ GPUs) │
│ │
client ── gRPC/HTTP┼──▶ rag_pipeline (ensemble) │
│ │ │
│ ├─▶ embedder (ONNX / PyTorch backend)
│ │ dynamic_batching, 2 instances, KIND_GPU
│ │
│ ├─▶ [external vector search — outside Triton,
│ │ called from a BLS step or by the client]
│ │
│ ├─▶ reranker (ONNX / PyTorch backend,
│ │ cross-encoder) dynamic_batching, small
│ │ max_batch_size, 1 instance
│ │
│ └─▶ ensemble/BLS: preprocessing → tensorrt_llm/vllm
│ → postprocessing (in-flight batching,
│ decoupled streaming)
└─────────────────────────────┘
Repository sketch:
/models/
├── embedder/ # backend: onnxruntime or pytorch, dynamic_batching
├── reranker/ # backend: onnxruntime or pytorch, dynamic_batching, small batches
├── preprocessing/ # backend: python (tokenizer for the LLM)
├── tensorrt_llm/ # or "vllm" — the generative model, in-flight batching
├── postprocessing/ # backend: python (detokenizer)
└── rag_pipeline/ # platform: ensemble or BLS, ties everything together
Key design points to say out loud:
- Different batching per model type. The embedder and reranker are fixed-shape, equal-work models —
dynamic_batchingis correct and sufficient. The LLM is autoregressive and variable-length — it needs in-flight batching via the TensorRT-LLM or vLLM backend. Using the same batching strategy for all three is the interview red flag to avoid. - Instance groups sized to workload shape. The embedder is usually the highest-QPS, cheapest-per-call model — give it more instances or a dedicated GPU. The reranker runs on a much smaller candidate set per request (rerank top-50, not every document) — it needs less concurrency. The LLM usually owns its own GPU(s) outright because in-flight batching handles its concurrency internally.
- Vector search is not a Triton model. Whether to put retrieval inside a BLS step (calling out to an external vector DB from Python) or keep it as a separate service the client/orchestrator calls between the embed step and the rerank+generate step is a real design decision — BLS keeps it inside one server call at the cost of coupling Triton to the vector DB’s availability; keeping it external keeps Triton stateless but adds a network hop and moves orchestration logic to the client.
- One ensemble vs multiple client calls. If retrieval must happen between embedding and reranking, and it’s an external service, you likely cannot express the whole RAG flow as a single static
ensemble(no external I/O mid-DAG) — you’d either do it as a BLS model that calls out over HTTP from Python, or split it into two client-visible calls:embed_and_searchhandled by the orchestrator, thenrerank_and_generateas one ensemble. - Metrics and scaling story. All three model types show up in the same Prometheus
/metricsendpoint with per-model queue duration and compute duration — call this out as the payoff of the unified-runtime choice versus running three separate servers.
Saying it out loud. For the RAG design prompt, the answer that scores is one server, one repository, different batching per model type. The embedder and reranker are fixed-shape equal-work models, so dynamic batching is correct. The LLM is autoregressive and variable-length, so it needs in-flight batching on the TensorRT-LLM or vLLM backend. Using the same batching strategy for all three is the red flag. Then size instance groups to workload shape — the embedder is highest-QPS and cheapest per call so it gets more instances, the reranker only sees the top fifty candidates so it needs little concurrency, and the LLM owns its GPU outright because in-flight batching manages concurrency internally. And I’d flag explicitly that vector search is not a Triton model: putting it in a BLS step keeps it to one client call but couples Triton’s availability to the vector DB’s.
Red flags vs green flags
| Signal | Red flag | Green flag |
|---|---|---|
| Batching choice for an LLM | “I’d use dynamic_batching for the LLM too, for consistency.” | “LLM decoding is variable-length and iterative, so it needs in-flight/continuous batching, not dynamic_batching.” |
| Config management | “Let auto-generated config handle it, it’s simpler.” | “Production runs --strict-model-config=true; batching config is reviewed, versioned infra.” |
| Upgrades | “If the container starts and the model loads, the upgrade is safe.” | “Loading isn’t validating. Re-run a GenAI-Perf sweep and diff against the pre-upgrade baseline before calling it done.” |
| KV cache sizing | “Set kv_cache_free_gpu_mem_fraction once and move on.” | “Load test with realistic concurrency and context length distributions, and alert on KV-cache utilization directly.” |
| Tool choice | “Triton is always better/always worse than vLLM.” | “Depends on whether there’s a multi-model fleet; single-LLM workloads often don’t need Triton at all.” |
| Ensembles vs BLS | Reaches for BLS (arbitrary Python) for every pipeline, even static DAGs. | Uses a plain ensemble for static DAGs; reserves BLS for branching/looping logic or calls to external services. |
| Debugging a “silent” throughput regression | Assumes the problem is the GPU or the model. | First checks whether config.pbtxt still contains the expected dynamic_batching/instance_group/batching_strategy block after the last deploy. |
Q&A
- “You have five models in four frameworks sharing two GPUs. Design it.” One Triton server, one model repository, per-model
backendandinstance_group, dynamic batching on the fixed-shape models, an LLM on the TensorRT-LLM/vLLM backend. Tests whether you understand Triton’s core value proposition. - “Dynamic vs in-flight batching — when each, and why?” Fixed-shape/equal-work models use dynamic batching; autoregressive LLMs use in-flight/continuous batching, because variable output length makes static batches stall on the slowest sequence. Bonus points for mentioning paged KV cache.
- “Walk me through deploying an LLM on Triton today.” Either point the TensorRT-LLM PyTorch/LLM-API backend at a HF checkpoint directly (the current default path, no offline engine compile), or fall back to the classic
trtllm-buildengine-compile path if you need it; wire pre/post-processing plus the model into an ensemble or BLS; launch withworld_size/tensor-parallel settings matching the GPU count; call/generateor/generate_stream. Being explicit that the PyTorch-backend path is now the default, and being honest that ensemble wiring is still real engineering effort, both score points. - “How do you tune the latency/throughput tradeoff?”
max_queue_delay_microsecondsandpreferred_batch_sizefor dynamic batching; instance count for concurrency;kv_cache_free_gpu_mem_fractionand max sequence length for LLMs — all validated with a GenAI-Perf concurrency sweep (TTFT, ITL, tokens/s), not guessed. - “What do you monitor, and what does a bad number mean?”
nv_inference_queue_duration_us(batching pressure), compute duration,nv_gpu_utilization, KV-cache utilization,nv_inference_first_response_histogram_msfor TTFT. Rising queue time with a full KV cache means memory-bound — scale out or trim context; rising queue time with an idle GPU means an under-tuned batching window. - “Ensemble vs BLS?” Ensemble for a static DAG (tokenize→infer→detokenize); BLS when the pipeline branches, loops, or needs to call an external service based on runtime data.
- “When would you NOT use Triton?” A single LLM where vLLM/SGLang is dramatically simpler and gets you most of the throughput; no heterogeneous model fleet; a team without NVIDIA-stack depth. Knowing when the simpler tool wins signals seniority.
- “How do you roll out a new model version safely?” Version subdirectories plus
version_policy, load the new version alongside the old, shift traffic, hot-unload the old one — no server restart. For an LLM engine/checkpoint change specifically, also re-run the GenAI-Perf benchmark before promoting. - “What changed in the TensorRT-LLM + Triton integration recently, and why does it matter?” The Triton-facing backend code moved into the TensorRT-LLM repo itself (
triton_backend/), and the PyTorch execution backend (the “LLM API”) replaced the classictrtllm-build-then-load-a-plan workflow as the default, letting you serve a HF checkpoint directly. It matters because it lowers the setup cost that used to be Triton’s biggest disadvantage versus vLLM. - “How would you decide between FP8 and FP4/NVFP4 quantization for an LLM deployment?” It’s largely a GPU-generation decision: FP4/NVFP4 needs Blackwell-class tensor cores and roughly halves memory again versus FP8, so it’s the throughput-per-dollar default there if the model family has been validated at that precision; FP8 is the mainstream choice on Hopper/Ada; older GPUs fall back to weight-only INT4 (AWQ/GPTQ). Always validate accuracy on your own eval set before shipping a lower precision.
- “What is disaggregated serving and when would you reach for it?” Splitting prefill (compute-bound, benefits from batching many prompts) and decode (memory-bandwidth-bound, benefits from many concurrent small steps) onto separate GPU pools, transferring the KV cache between them. Reach for it once a single-pool in-flight-batched deployment is GPU-saturated and profiling shows prefill and decode are contending for the same hardware — not as a first deployment.
- “A model is
READYin the startup table but throughput is terrible. What do you check first?” Whetherconfig.pbtxtactually contains the batching block you expect (dynamic_batching for fixed-shape, correctbatching_strategyfor TensorRT-LLM) — auto-generated or regenerated configs silently omitting it is the single most common cause of a model that “loads fine” but runs at a fraction of expected throughput. - “Why would decoupled mode matter even if you don’t need streaming to the end user?” Any model that internally produces a variable number of responses per request — including intermediate steps inside a BLS pipeline — needs
model_transaction_policy { decoupled: true }, or Triton will only deliver the final buffered response, breaking any pipeline stage that expects to see partial output. - “How do you avoid a repeat of a ‘looked fine after the upgrade, was actually 3x slower’ incident?” Pin the model artifact, backend build, and container tag together as one versioned unit; gate every upgrade behind an automated GenAI-Perf sweep compared against the current production baseline, not a manual smoke test that only checks the model loads.
- “How do you hot-swap a model version with zero downtime?” Set
--model-control-mode=explicit, drop the new version directory into the repository,POST /v2/repository/models/{name}/loadto bring it up alongside the still-serving old version, shift client traffic (or updateversion_policyto prefer the new version), thenunloadthe old one — no server restart, no dropped requests during the transition. - “What happens if a client abandons a streaming LLM request halfway through?” Without cancellation handling, the in-flight batching scheduler keeps generating tokens nobody will read, wasting GPU-seconds and holding KV-cache blocks. gRPC call cancellation should propagate down into the backend so the sequence is evicted and its KV-cache blocks freed as soon as the client disconnects — worth calling out explicitly, since it’s an easy thing to leave unhandled until a cost review surfaces it.
- “Tensor parallel vs pipeline parallel — when would you pick one over the other?” Tensor parallelism shards every layer’s weights across GPUs with an all-reduce per layer — needs NVLink-class bandwidth, but keeps latency for a single request low. Pipeline parallelism assigns whole layers to different GPUs with lower per-step communication, but only pays off with enough concurrent requests to keep the pipeline full, and adds bubble latency to the first request through an empty pipeline. Many large-model deployments combine both.
Further reading
- Triton model configuration (config.pbtxt reference): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/user_guide/model_configuration.html
- Triton model repository layout: https://github.com/triton-inference-server/server/blob/main/docs/user_guide/model_repository.md
- Dynamic batching & concurrent model execution (conceptual guide): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tutorials/Conceptual_Guide/Part_2-improving_resource_utilization/README.html
- TensorRT-LLM backend (now vendored inside the TensorRT-LLM repo,
triton_backend/): https://github.com/triton-inference-server/tensorrtllm_backend - TensorRT-LLM backend docs: https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tensorrtllm_backend/README.html
- TensorRT-LLM main repository (PyTorch/LLM-API execution backend, release notes): https://github.com/NVIDIA/TensorRT-LLM
- TensorRT-LLM release notes (PyTorch backend, disaggregated serving, speculative decoding): https://nvidia.github.io/TensorRT-LLM/release-notes.html
- TensorRT-LLM quantization reference (FP8, FP4/NVFP4, AWQ/GPTQ, KV-cache quantization): https://nvidia.github.io/TensorRT-LLM/latest/features/quantization.html
- vLLM backend for Triton: https://github.com/triton-inference-server/vllm_backend
- Deploying a vLLM model in Triton (tutorial): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tutorials/Quick_Deploy/vLLM/README.html
- NVIDIA vLLM x Triton positioning (integration, benchmarks, disaggregated serving): https://developer.download.nvidia.com/triton/vLLM-x-Triton-meetup-External.pdf
- Python backend (custom logic + BLS): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/python_backend/README.html
- Metrics reference: https://github.com/triton-inference-server/server/blob/main/docs/user_guide/metrics.md
- GenAI-Perf (LLM benchmarking): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html
- Triton Inference Server release notes index (container tags, backend versions bundled per release): https://docs.nvidia.com/deeplearning/triton-inference-server/release-notes/index.html
Topic 12: The API Layer
Every topic before this one has been about the model server.
How to get tokens out of a GPU quickly, how to batch them, how to shard a model that does not fit, how to put the whole thing on Kubernetes and let it scale itself. That work is real and it is hard and it is not your product.
Your product is an HTTP endpoint.
Between the person typing a question and the vLLM process that answers it sits a service that nobody drew on the architecture diagram, and it is the service that decides whether your system survives a Tuesday. It authenticates the caller. It checks whether that caller is allowed to spend what this request will cost. It looks to see whether somebody already asked this exact question four minutes ago. It decides, when the fleet is saturated, who waits and who gets told no. And it writes down what happened, in enough detail that you can bill for it and debug it.
That service is the subject of this topic.
Why it gets skipped
Because it is boring until it isn’t.
The first version of every LLM product is a thin proxy: take the request, forward it to the model, stream the answer back. It works beautifully for weeks. Then one of four things happens.
A customer writes a retry loop with no backoff, and their bug becomes everyone’s outage. Someone posts your demo somewhere popular, and the GPU fleet — which autoscales, which you tested, which is fine — cannot scale in the ninety seconds you have before every request times out. Finance asks what a customer costs to serve and you cannot answer, because you never metered anything at the granularity of a customer. Or you notice that the top forty questions account for a third of your traffic and you have been paying full price to answer each of them from scratch, thousands of times a day.
None of those are model problems. All four are solved in the same place, by the same service, and it is the one you did not build.
What this topic covers
Rate limiting, done properly. The five algorithms — fixed window, sliding window log, sliding window counter, token bucket, and the leaky-bucket/GCRA family — with what each one actually costs to store and when you would pick it. Then the part generic articles get wrong: for an LLM API the request is the wrong unit of measurement, because one request can be two hundred tokens or two hundred thousand. You will meter tokens, concurrency, and dollars, not requests.
Distributed enforcement. Why an in-process counter is silently wrong the moment you run two replicas, how to do it in Redis without the race condition that everyone ships first, and when approximate local limiting is the right call anyway.
Queuing and admission control. What to do at capacity when rejecting is the wrong answer — bounded queues, backpressure, priority classes, load shedding — and why an unbounded queue is not a safety valve but a slower way to fail.
Caching, in layers. Exact-match, semantic (nearest-neighbour lookup on an embedded query, with the false-positive risk explained honestly rather than glossed over), embedding caches, and provider-side prompt caching — which is a genuinely different mechanism from your own cache, and composes with it rather than replacing it. Plus the operational half: key design, TTLs, invalidation, cache stampede, and what a hit rate is telling you.
Gateway patterns. Whether this logic belongs in your application, a sidecar, or a dedicated gateway like Envoy or Kong — with the tradeoffs stated plainly — along with key management, logging, and multi-provider failover.
What you will be able to do
Explain, at a whiteboard, why a fixed-window limiter lets through twice its stated limit and exactly when that matters.
Write a token-metered limiter that charges an estimate up front and reconciles against the real usage when generation finishes, because you cannot know a request’s cost until after you have served it.
Build a two-tier cache that tries exact match, falls back to semantic similarity, refuses a near-miss that would have returned a confidently wrong answer, and reports its hit rate.
Choose a similarity threshold on evidence rather than vibes — and say out loud what your false-positive budget is.
Answer two of the four questions that come up in nearly every AI-engineering system design interview: how would you handle rate limits for millions of users? and how would you cache LLM responses efficiently?
How this fits with the rest of the guide
This topic sits above the serving layer and below the application.
When capacity is the real constraint, the answer is in this guide’s own autoscaling chapters, and the signals you need to see any of this are in its monitoring chapters — this topic points there rather than reprinting them.
For the layer above — an agent loop that decides on its own how many model calls to make, and how you keep that from costing forty dollars a run — see the learn-production-agent book’s treatment of budgets and cost control.
That book charges a budget before spending it; this topic is where the same idea is enforced against a stranger on the internet who does not share your interests.
Read the deep dive with a terminal open. The worked example is dependency-free Python and the output in the chapter is real output from running it.
The API layer: rate limiting, quotas, and caching
There is a service in your architecture that nobody drew.
You have a diagram with a load balancer, a Kubernetes cluster, some GPU nodes running vLLM, a monitoring stack off to the side. Between the arrow labelled “user” and the box labelled “inference” there is a gap, and in production something has to live in that gap.
That something is your API layer, and it is the actual front door of your product.
The model server is not the front door. vLLM does not know who your customers are, has no opinion about whether this particular caller has spent their monthly allowance, will not notice that the same question arrived eleven times in the last minute, and — critically — will happily accept every request you hand it right up to the point where its queue depth makes the ninety-ninth percentile latency worse than a timeout. It is a very good engine. It is not a car.
The API layer authenticates, authorizes, meters, caches, queues, and degrades. Six verbs. This chapter is about all six, with the most time spent on the two that come up in every design review and every interview: metering, which people call rate limiting, and caching.
Saying it out loud. The framing I’d open with is that there’s a service in most architecture diagrams that nobody actually drew. You’ve got a load balancer, a cluster, some GPU nodes running vLLM — and between the arrow labelled “user” and the box labelled “inference” there’s a gap that something has to fill in production. The model server is not the front door. vLLM doesn’t know who your customers are, has no opinion about whether this caller has spent their monthly allowance, won’t notice the same question arrived eleven times in a minute, and will happily accept requests right up to the point where queue depth makes p99 worse than a timeout. It’s a very good engine; it isn’t a car. The API layer authenticates, authorizes, meters, caches, queues, and degrades.
What breaks when this layer is missing
Three failures, and you will see all three if you run long enough.
One user starves everyone. A customer ships an integration with a retry loop, no backoff, and a bug — a malformed request that gets a 400, retried immediately, forever. On a normal REST API that is a nuisance in the logs. On an LLM API it is an outage, because their requests occupy GPU slots that are genuinely scarce, and your other customers experience it as everything getting slow at once. Without per-caller metering, one bad script is indistinguishable from organic growth until you read the access logs by hand.
A viral moment melts the fleet. Autoscaling works, and for GPU workloads it is slow: a node, a container image measured in gigabytes, then model weights measured in more gigabytes. Two to ten minutes of cold start is normal, and this guide’s autoscaling chapters cover shaving that down. None of it helps in the ninety seconds between someone popular linking to your demo and your request pool being exhausted. Something has to absorb that spike or shed it deliberately.
You answer the same question at full price, forever. Every consumer-facing LLM product has a fat head in its query distribution — “what can you do”, “how do I cancel”, the same onboarding prompt from every new user, the same document summarized by six people on the same team. Each is a fresh forward pass through a very large model, paying for a computation whose answer you already have from four minutes ago.
All three have the same shape: a decision that must be made before the request reaches the model, using state the model server does not have.
Saying it out loud. Three things break without this layer and you’ll hit all three eventually. One customer with a retry loop and no backoff starves everyone, because on an LLM API their requests occupy genuinely scarce GPU slots and your other customers just experience everything getting slow. A viral moment melts the fleet, because GPU autoscaling takes two to ten minutes — a node, a multi-gigabyte image, then multi-gigabyte weights — and none of that helps in the ninety seconds between someone popular linking your demo and your pool being exhausted. And you answer the same question at full price forever, because every consumer LLM product has a fat head in its query distribution. All three have the same shape: a decision that has to be made before the request reaches the model, using state the model server doesn’t have.
Rate limiting
Rate limiting is the practice of capping how much of a shared resource any one caller can consume in a window of time.
The word limiting undersells it. What you are really doing is deciding, continuously and automatically, how a finite resource gets divided among callers who each want all of it. That is an allocation policy, and the algorithm you pick determines the shape of the traffic your backend sees.
There are five algorithms worth knowing. They differ in exactly two ways that matter: how much state they cost you per caller, and what burst behaviour they permit.
Fixed window counter
Keep one integer per caller per time window. Round the current time down to the window — say, the current minute — and use that as part of the key. Increment on each request; if the counter exceeds the limit, reject. When the clock ticks over to the next minute, the key changes and the count starts at zero.
This is the simplest thing that works, and its cost is one small integer per active caller, which is essentially free.
It has one flaw, and it is a real one: the boundary burst. A caller limited to 100 requests per minute can send 100 at 11:59:59 and another 100 at 12:00:00 — two hundred requests in a two-second span, both windows individually legal, and your backend seeing double the rate you configured.
Whether that matters depends on what you are protecting. For a limit that exists mostly to catch runaway scripts, a 2× overshoot for one second is nothing. For a GPU fleet sized with 20% headroom, it is an incident. Assume LLM serving is the second case unless you have measured otherwise.
Saying it out loud. Fixed window is the simplest thing that works: one integer per caller per minute, increment, reject over the limit, and the key changes when the clock ticks. Essentially free in state. It has exactly one flaw and it’s real — the boundary burst. A caller limited to a hundred a minute sends a hundred at 11:59:59 and another hundred at 12:00:00: two hundred requests in two seconds, both windows individually legal, and your backend seeing double the configured rate. Whether that matters depends entirely on what you’re protecting. For catching runaway scripts, a 2x overshoot for one second is nothing. For a GPU fleet sized with twenty percent headroom, it’s an incident — and you should assume LLM serving is the second case unless you’ve measured otherwise.
Sliding window log
Store a timestamp for every request the caller makes. On each new request, drop the timestamps older than the window, count what remains, and admit if the count is under the limit.
This is exactly correct — no boundary artefact, because there is no boundary. The window slides continuously with the current time.
The cost is one entry per request per caller for the length of the window.
A caller allowed 10,000 requests per minute needs up to 10,000 timestamps held for a minute, and you pay that for every active caller simultaneously.
In Redis this is a sorted set, pruned with ZREMRANGEBYSCORE and counted with ZCARD.
Use it when limits are small and precision matters — expensive operations, per-account write limits, anything where “approximately 100” is not acceptable. Do not use it as the front-line limiter for high-volume traffic.
Saying it out loud. The sliding window log is the exactly-correct one: store a timestamp per request, drop the ones older than the window, count what’s left. No boundary artifact because there’s no boundary — the window slides continuously. The cost is what rules it out at scale: one entry per request per caller for the whole window, so a caller allowed ten thousand a minute needs ten thousand timestamps held for a minute, simultaneously, for every active caller. So I’d reach for it when limits are small and precision genuinely matters — expensive operations, per-account write limits, anywhere “approximately a hundred” isn’t acceptable — and never as the front-line limiter for high-volume traffic.
Sliding window counter
The compromise, and in practice the most common choice.
Keep two fixed-window counters, current and previous, and estimate the sliding rate as a weighted blend:
\( \text{estimate} = c_{\text{cur}} + c_{\text{prev}} \times (1 - f) \)
where \( f \) is the fraction of the current window elapsed. A quarter of the way into the current minute you count this minute’s requests in full plus 75% of last minute’s.
The estimate assumes the previous window’s requests were spread evenly through it, which is not true, so it is approximate. Cloudflare measured this across 400 million requests from 270,000 sources and found 0.003% of requests wrongly allowed or limited — approximate, but not by much. Two integers per caller, no boundary burst, and for most general-purpose API rate limiting this is the right default.
Saying it out loud. The sliding window counter is the practical compromise and the most common general-purpose choice. You keep two fixed-window counters, current and previous, and estimate the sliding rate by blending them by how far into the current window you are — a quarter of the way in, you count this minute’s requests in full plus seventy-five percent of last minute’s. It’s approximate, because it assumes the previous window’s traffic was spread evenly, which it wasn’t. But Cloudflare measured this across 400 million requests and found about three-thousandths of a percent wrongly allowed or limited. Two integers per caller, no boundary burst — that’s a very good trade for general API limiting.
Token bucket
Now we get to the one you will actually implement.
Picture a bucket that holds up to \( B \) tokens and refills at \( r \) tokens per second. Each request removes tokens equal to its cost. If the bucket does not have enough, the request is rejected.
Two parameters, two meanings, and keeping them straight is most of the skill:
- \( r \) is your sustained rate. Over the long run, no caller gets more than \( r \) per second.
- \( B \) is your burst allowance. A caller who has been idle can spend the full bucket at once.
That second property is the whole reason to choose token bucket. Real clients are bursty and their burstiness is usually legitimate — a user opens the app and five parallel requests fire to populate the screen. A limiter that smooths those into a queue makes the product feel slow to protect a backend that could have handled them. Token bucket says: go ahead, you have been quiet, spend your savings.
You do not need a background process to refill, which is the mistake in most naive implementations. Store the token count and the timestamp of the last update; on each request add \( \Delta t \times r \) tokens, capped at \( B \). Lazy refill, exact, two numbers per caller.
Token bucket is the default for LLM APIs, and the reason is in the next section: it is the only one of these five that naturally handles requests with different costs.
Saying it out loud. Token bucket is the one you’ll actually implement for an LLM API. A bucket holds up to B tokens and refills at r per second; each request removes tokens equal to its cost, and if there aren’t enough, you reject. The reason to pick it is that r and B are separate knobs meaning different things — r is your sustained rate, B is your burst allowance, so a caller who’s been idle can spend the whole bucket at once. That matters because real clients are bursty and their burstiness is usually legitimate: a user opens the app and five parallel requests fire. And the implementation detail that trips people up: you don’t need a background refill process. Store the token count and the last-update timestamp, and add elapsed time times rate on the next request, capped at B.
Leaky bucket and GCRA
Leaky bucket inverts the picture.
Requests pour into a bucket, the bucket drains at a fixed rate, and if it overflows requests are rejected.
The output rate is constant regardless of the input, which makes it a shaper rather than merely a limiter — useful when your backend needs smooth input.
NGINX’s limit_req is a leaky bucket; its burst parameter sets the queue depth and nodelay forwards queued requests immediately rather than spacing them.
The elegant version is GCRA, the Generic Cell Rate Algorithm, borrowed from ATM networking. Instead of a token count it tracks one timestamp: the theoretical arrival time (TAT), the earliest moment at which the next request would be perfectly conforming.
Two constants — the emission interval \( T \), the ideal gap between requests, which is the window divided by the quota; and the delay variation tolerance \( \tau \), which is burst capacity expressed as time. A request at time \( t \) is admitted when
\( t \ge \text{TAT} - (\tau + T) \)
and on admission you set \( \text{TAT} \leftarrow \max(t, \text{TAT}) + T \).
One timestamp per caller, no drip process, no counter to reset. As Brandur Leach puts it in the canonical write-up, removing the drip removes a whole category of failure, because an offline or overloaded drip worker limits incorrectly rather than merely limiting late.
GCRA and token bucket are close cousins — a token bucket with a fractional count carries the same information as a TAT. Pick GCRA for minimum state and exact pacing; pick token bucket when costs vary per request, which for LLM traffic they emphatically do.
Saying it out loud. Leaky bucket inverts the picture — requests pour in, the bucket drains at a fixed rate, and overflow gets rejected, so the output rate is constant regardless of input. That makes it a shaper rather than just a limiter, which is what NGINX’s limit_req is doing. The elegant version is GCRA, borrowed from ATM networking, which tracks one timestamp — the theoretical arrival time, the earliest moment the next request would be perfectly conforming — instead of a token count. One timestamp per caller, no drip process, and that last part is the real argument: an offline or overloaded drip worker limits incorrectly rather than merely late. GCRA and token bucket are close cousins; pick GCRA for minimum state and exact pacing, token bucket when per-request costs vary, which for LLM traffic they emphatically do.
The comparison, condensed
| Algorithm | State per caller | Boundary burst | Variable cost | Good for |
|---|---|---|---|---|
| Fixed window | 1 integer | Yes, up to 2× | Awkward | Cheap coarse protection |
| Sliding window log | 1 entry per request | No | Yes | Small, expensive limits |
| Sliding window counter | 2 integers | No | Awkward | General-purpose default |
| Token bucket | 2 numbers | Controlled by \( B \) | Natural | LLM APIs |
| GCRA / leaky bucket | 1 timestamp | Controlled by \( \tau \) | Possible, less natural | Smooth pacing, minimum state |
The LLM twist: requests are the wrong unit
Here is where the generic rate-limiting article stops being useful.
Every one of those algorithms, as usually presented, counts requests. For an LLM API, counting requests is close to meaningless, because the variance in what a request costs is enormous.
One request is “hi” and produces twelve tokens. Another is a 180,000-token document with “summarize this” on the end, which costs roughly four orders of magnitude more compute, occupies a KV-cache allocation the size of a small dataset, and holds a slot on a GPU for thirty seconds. Both are one request.
Limit on requests and you have built something that either rejects the trivial user unnecessarily or lets the expensive one consume the entire fleet. So: meter what is scarce.
Tokens. This is the primary axis. Limits look like “60,000 input tokens per minute, 20,000 output tokens per minute,” which is exactly how the commercial providers express theirs — OpenAI, Anthropic and the rest publish TPM (tokens per minute) alongside RPM (requests per minute), and it is TPM you hit first.
Concurrent requests. The second axis, and the one people forget. Tokens per minute does not bound how many of a caller’s requests are simultaneously occupying GPU slots. A caller running fifty parallel long generations may be within their token budget over a minute while pinning your batch scheduler right now. Cap in-flight requests per caller separately.
Dollars. The third axis, for anything with per-seat pricing or a free tier. Tokens are a proxy for cost, but the exchange rate differs by model: a thousand tokens on your largest model may cost fifteen times a thousand tokens on the small one. If you route between models, meter money, or callers will discover that routing to the expensive model is free.
Saying it out loud. This is where the generic rate-limiting article stops being useful. Every one of those algorithms, as usually presented, counts requests — and for an LLM API that’s close to meaningless, because one request is “hi” producing twelve tokens and another is a 180,000-token document with “summarize this” on the end, roughly four orders of magnitude more compute, holding a GPU slot for thirty seconds. Both are one request. So you meter what’s actually scarce, on three axes. Tokens is the primary one, which is why commercial providers publish tokens-per-minute alongside requests-per-minute — TPM is what you hit first. Concurrent in-flight requests is the axis people forget, because a caller running fifty parallel long generations can be inside their token budget while pinning your batch scheduler right now. And dollars, if you route across models with different prices, or callers will discover that routing to the expensive model is free.
Charging before you know the price
There is a real difficulty here and it is worth naming clearly.
You cannot know a request’s token cost until it has finished. Input tokens you can count before dispatch — that is just tokenization. Output tokens are unknown until generation stops.
The standard resolution is a two-phase charge:
- Reserve on admission, using input tokens plus an estimate of output. The estimate can be the caller’s
max_tokens(conservative, and it will make heavy users feel over-limited), a rolling percentile of that caller’s historical output length (better), or a per-endpoint constant (fine to start). - Reconcile on completion. Charge the difference if you underestimated; refund it if you overestimated.
Reserving at max_tokens and never reconciling is the naive version, and it is why some providers feel far stingier than their published numbers suggest.
Reconciliation is maybe fifteen lines of code and it materially changes how the product feels.
The worked example below implements exactly this.
Saying it out loud. There’s a genuine difficulty worth naming: you can’t know a request’s cost until it’s finished. Input tokens you can count before dispatch — that’s just tokenization — but output tokens are unknown until generation stops. The standard resolution is a two-phase charge. Reserve on admission using input tokens plus an estimate of output, where the estimate is either the caller’s max_tokens, which is conservative and will make heavy users feel over-limited, or better, a rolling percentile of that caller’s own history. Then reconcile on completion: charge the difference if you underestimated, refund if you overestimated. Reserving at max_tokens and never reconciling is the naive version, and it’s why some providers feel far stingier than their published numbers suggest. Reconciliation is about fifteen lines of code and it materially changes how the product feels.
Whose limit is it
A single limit keyed on one identity is not enough, because you have several failure modes to prevent and they have different scopes.
Per API key catches the runaway script. One customer, three integrations, three keys; when one integration misbehaves you want the other two unaffected. This is your isolation boundary.
Per user matters in a consumer product where identity is a person, not a key, and it is what stops one enthusiastic user from consuming a shared team allowance.
Per organization or account is where the commercial limit lives — what the customer bought. It caps the sum across all their keys and users.
Global is the one people leave out and then wish they hadn’t. A ceiling on total admitted load, expressed in whatever units your fleet is actually sized in, that exists to protect the fleet from the aggregate of everyone behaving legitimately at once.
Evaluate them cheapest-first and reject on the first failure, so a request that is going to be denied for concurrency does not first burn a Redis round trip against the token budget.
Tiers map onto this straightforwardly. Free, pro, enterprise get different \( r \) and different \( B \), and it is worth understanding that they are separate knobs: raising \( B \) alone gives a tier a better feel — snappier bursts, no perceived throttling on normal use — without giving away any additional sustained throughput. That is often the cheapest upgrade you can ship.
Give new accounts a lower ceiling that grows with account age and payment history. Abuse is disproportionately from new accounts, and this costs nothing to implement.
Saying it out loud. One limit on one identity isn’t enough, because you’re preventing several different failure modes with different scopes. Per API key is your isolation boundary — one customer with three integrations gets three keys, so when one misbehaves the other two are unaffected. Per user matters in consumer products where identity is a person. Per organization is where the commercial limit lives, capping the sum across all their keys. And global is the one people leave out and then wish they hadn’t: a ceiling on total admitted load that protects the fleet from everyone behaving legitimately at once. Evaluate them cheapest-first so a request that’s going to be denied on concurrency doesn’t first burn a Redis round trip against the token budget. And tiers are just different r and B — raising the burst allowance alone makes a tier feel snappier without giving away any sustained throughput, which is often the cheapest upgrade you can ship.
What to return
When you reject, be useful about it.
Status 429 Too Many Requests. Not 503, which says your server is broken; not 403, which says the caller may never do this. 429 says: this is a valid request, you are over quota, try again.
Include Retry-After, in seconds, and compute it honestly from the limiter state.
You know exactly when the bucket will hold enough tokens — it is \( (\text{cost} - \text{tokens}) / r \).
Returning a real number instead of a hardcoded 60 is the single highest-leverage thing you can do for client behaviour, because well-written clients will honour it and stop hammering you.
For the limit state itself there is now a standards-track answer.
The IETF HTTPAPI working group’s draft RateLimit header fields for HTTP (draft-11 as of May 2026) defines two Structured Field response headers:
RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400
RateLimit: "default";r=50;t=30
q is the quota, w the window in seconds, r the remaining quota, t the seconds until reset.
Crucially for us, there is a qu parameter for the quota unit — so qu="tokens" says plainly that this is a token budget, not a request budget.
That is the first standard that expresses what an LLM API actually needs.
The older convention — X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset — is what most clients still parse.
Emit both for now.
The draft is still a draft; it is also clearly where this is going.
Return the same headers on successful responses too. A client that can see it has 400 tokens left out of 60,000 can slow down before it gets rejected, which is better for both of you than discovering the limit by hitting it.
Two more things worth doing:
Never reject a streaming response mid-stream because of reconciliation. If the generation overshot its estimate, charge it and let the next request pay the price. Truncating an answer the user is watching arrive is a terrible experience in exchange for a rounding error.
Distinguish “you are over quota” from “we are over capacity.” They look the same to the client and require different responses — the first is the caller’s problem and backing off fixes it, the second is yours and the caller backing off merely spreads the pain. Different error codes in the body, even if both are 429.
Saying it out loud. When you reject, be useful about it. It’s a 429 — not a 503, which claims your server is broken, and not a 403, which says the caller may never do this. And include a Retry-After computed honestly from limiter state, because you know exactly when the bucket will hold enough tokens. Returning a real number instead of a hardcoded sixty is the single highest-leverage thing you can do for client behavior, since well-written clients honor it and stop hammering you. Two more that get missed: emit the limit headers on successful responses too, so a client can see it has 400 tokens left of 60,000 and slow down before being rejected. And distinguish “you are over quota” from “we are over capacity” in the body — they look identical to the client and require opposite responses, since backing off fixes the first and merely spreads the pain in the second.
Distributed rate limiting
Everything above assumed one process holding the state.
The moment you run two replicas, an in-process limiter is wrong, and it is wrong in a specific and predictable direction: with \( N \) replicas behind a round-robin balancer, each enforcing the full limit locally, a caller can consume up to \( N \times \) the limit.
You cannot fix this by dividing the limit by \( N \). Traffic is not evenly distributed across replicas — connection reuse and keep-alive see to that — so a caller whose connections happen to land on one replica gets \( 1/N \) of what you promised them, and you have turned an over-limit bug into an under-limit bug that generates support tickets.
The state has to be shared.
Saying it out loud. The moment you run two replicas, an in-process limiter is wrong, and it’s wrong in a predictable direction: with N replicas each enforcing the full limit locally, a caller can consume N times the limit. And you cannot fix that by dividing by N, because traffic isn’t evenly distributed — connection reuse and keep-alive see to that — so a caller whose connections land on one replica gets a fraction of what you promised them. You’ve turned an over-limit bug into an under-limit bug that generates support tickets, which is arguably worse because now it’s visible to customers. The state has to be shared, full stop.
Doing it in Redis, correctly
Redis is the standard answer: fast, single-threaded, and with a data model that fits.
The trap is that every one of these algorithms is a read-modify-write. Read the current tokens, decide, write the new tokens. Issued as separate commands, two concurrent requests can both read a bucket holding one token, both conclude they are allowed, and both write back zero.
Two requests admitted, one token spent. Under load this is not rare; it is the common case, and it scales with your concurrency, which means the limiter fails hardest exactly when it matters most.
INCR is atomic and EXPIRE is atomic, but INCR then EXPIRE is not, and the failure there is subtle and worse: if the process dies between them you have a counter with no TTL, which never resets, and that caller is permanently limited.
The fix is to make the whole read-decide-write sequence one atomic unit.
In Redis that means a Lua script via EVAL (or EVALSHA after loading it once), which runs to completion without interleaving.
Redis’s own rate-limiting tutorial is explicit that every one of its five limiter recipes uses Lua for precisely this reason.
Three practical notes.
Keep the script small and pure. It blocks the entire server while it runs. Do arithmetic, not iteration over large collections.
Pass the timestamp in as an argument, not from TIME inside the script. Scripts that call TIME are non-deterministic, which historically complicated replication; passing the caller’s clock also lets you write tests with a fake clock, which you want.
Declare your keys in KEYS, not ARGV. Redis Cluster routes by key, and a script that touches an undeclared key will break the day you shard. When one logical limit spans multiple keys, use a hash tag — {user:42}:tokens and {user:42}:reqs — to force them onto the same slot.
Saying it out loud. The trap with Redis is that every one of these algorithms is a read-modify-write. Issued as separate commands, two concurrent requests both read a bucket holding one token, both conclude they’re allowed, and both write back zero — two admitted, one spent. Under load that isn’t rare, it’s the common case, and it scales with concurrency, so the limiter fails hardest exactly when it matters most. INCR is atomic and EXPIRE is atomic, but INCR-then-EXPIRE isn’t, and that failure is worse: die in between and you have a counter with no TTL that never resets, permanently limiting that caller. The fix is one Lua script via EVAL so the whole read-decide-write runs without interleaving. Keep it small since it blocks the server, pass the timestamp in as an argument rather than calling TIME inside, and declare every key in KEYS or you’ll break the day you shard.
The accuracy-versus-latency tradeoff
A Redis round trip is somewhere between 0.2 and 2 milliseconds.
For an LLM request that will take three seconds, that is free — genuinely, do not think about it. For the local rate limiter in front of a gateway handling a hundred thousand requests per second of mixed traffic, it is not free, and it is a hard dependency on a service that can be down.
The standard answer is two tiers, and it is what Envoy does by design.
A local limiter in each process — an in-memory token bucket, no network — set generously, whose job is to absorb obvious floods and shed them at zero cost. Behind it, a global limiter in Redis that enforces the real quota. Envoy’s documentation makes this explicit: local rate limiting is used alongside global rate limiting to reduce load on the global service.
You accept some slop. A caller can exceed the global limit by roughly the sum of local burst allowances before the global limiter catches them, which is bounded and small if you configure it so.
The other technique worth knowing is distributed token leasing: each replica leases a block of quota from Redis — say 500 tokens — and spends it locally, returning for another block when it runs low. One round trip per 500 tokens instead of one per request. The cost is that unreturned leases held by a crashed replica are lost until they expire, so the effective limit is slightly under the configured one.
And decide, explicitly and in advance, what happens when Redis is unreachable.
Fail open admits everything, and your rate limiter’s outage does not become your API’s outage. This is usually right for a limiter protecting against accidents.
Fail closed rejects everything, which turns a Redis blip into a total outage but prevents an attacker from disabling your limits by taking down one dependency. Right for a limiter that is a security control.
Most LLM APIs should fail open on the abuse limiter and fail closed on the billing limiter. Whatever you choose, choose it deliberately and put it in a config flag, because you will want to flip it during an incident.
Saying it out loud. A Redis round trip is somewhere between two-tenths of a millisecond and two milliseconds. Against an LLM request that takes three seconds, that is genuinely free — don’t think about it. The reason to build two tiers anyway is that it’s a hard dependency on a service that can be down. So you put a generous in-memory token bucket in each process to absorb obvious floods at zero cost, and a global limiter in Redis behind it enforcing the real quota — which is exactly what Envoy does by design. You accept some slop: a caller can exceed the global limit by roughly the sum of local burst allowances, which is bounded and small if you configure it so. And decide explicitly, in a config flag, what happens when Redis is unreachable. Most LLM APIs should fail open on the abuse limiter and fail closed on the billing limiter.
Queuing and admission control
Rate limiting answers “is this caller allowed to spend this?” Admission control answers a different question: “do we have the capacity to serve this right now?”
They are independent. A caller can be well within quota while the fleet is saturated, and rejecting them with a 429 tells them to slow down when the honest answer is “everyone should slow down, briefly.”
The queue
The instinct is to queue: hold the request until a slot frees up.
For LLM serving this is more attractive than for most workloads, because your requests are already long — a user who waits three seconds will not notice four. Queueing converts a hard rejection into slightly worse latency, which is a very good trade up to a point that arrives faster than you expect.
An unbounded queue is not a safety valve. It is a mechanism for converting a fast failure into a slow one.
Requests arrive at a rate you do not control and are served at a rate fixed by your GPU count. When arrival exceeds service the queue grows without limit, and every request in it still gets served — eventually, by which time the client has timed out and retried. So you burn GPU on answers nobody will read, for clients that have already asked again. Memory grows, percentiles go vertical, and the system does not recover on its own after the surge passes, because it is working through a backlog while new traffic keeps arriving.
Three rules.
Bound the queue. Set a maximum depth and reject with 429 or 503 when it is full. Size it from Little’s Law: with a target wait \( W \) and a service rate \( \lambda \), the depth is \( L = \lambda W \). If you serve 20 requests per second and will tolerate 2 seconds of queueing, your queue holds 40. Not 10,000.
Bound the wait. Stamp each request on arrival and drop it when it has been waiting longer than the client’s timeout. Serving a request whose caller has given up is pure waste, and under load it is the waste that keeps you from recovering.
Watch depth, not just latency. Queue depth is a leading indicator; latency is a lagging one. By the time p99 moves, the queue has been growing for a while. This is the signal to wire into the autoscaler, and the guide’s monitoring chapters cover getting it out of the serving layer.
Saying it out loud. Rate limiting asks “is this caller allowed to spend this?” Admission control asks something independent: “do we have capacity right now?” A caller can be well inside quota while the fleet is saturated. Queueing is more attractive here than for most workloads, because requests are already long — someone waiting three seconds won’t notice four. But an unbounded queue is not a safety valve, it’s a mechanism for converting a fast failure into a slow one: arrivals exceed service, the queue grows, everything still gets served eventually — by which time the client timed out and retried, so you’re burning GPU on answers nobody will read for clients who already asked again. Three rules: bound the depth using Little’s Law, drop requests that have waited past the client’s timeout, and watch queue depth rather than latency, because depth leads and latency lags.
Priority classes
Not all requests deserve the same treatment, and under pressure you should say so.
A reasonable default set: interactive (a human is watching a cursor blink), background (a batch job, a nightly summarization), and best-effort (speculative prefetch, cache warming, evaluation runs).
Under load, interactive traffic goes to the front. Background work waits, and that is correct — nobody is watching. Best-effort is dropped entirely, and if you have built it properly, nothing breaks.
The failure mode to design against is starvation: a class that never gets served because a higher class is always full. Weighted fair queueing rather than strict priority — background gets 20% of slots even when interactive is saturated — avoids it, and the cost is a slightly worse tail for interactive traffic in exactly the conditions where the tail is already bad.
Saying it out loud. Under pressure, say out loud that not all requests deserve the same treatment. A reasonable default set is interactive, where a human is watching a cursor blink; background, like a nightly summarization job; and best-effort, like speculative prefetch or cache warming. Interactive goes to the front, background waits — which is correct, nobody’s watching — and best-effort gets dropped entirely, and if you built it properly nothing breaks. The failure mode to design against is starvation, where a lower class never gets served because a higher one is always full. Weighted fair queueing rather than strict priority fixes that — background gets twenty percent of slots even when interactive is saturated — and the cost is a slightly worse interactive tail in exactly the conditions where the tail is already bad.
Load shedding
Shedding is deciding, deliberately, to drop work you could technically accept, because accepting it makes everything else worse.
Shed by cost first. A 100,000-token summarization occupies a GPU for thirty seconds; ten short chats fit in the same window. When you are saturated, rejecting the one expensive request preserves service for ten users instead of one. This feels unfair and it is the right call, and you should document the policy so support can explain it.
Shed by tier second — free before paid, best-effort before interactive.
And shed early, at the edge, before you have spent anything on the request. A request rejected after tokenization, embedding, and a cache lookup has already cost you real work.
Saying it out loud. Shedding is deliberately dropping work you could technically accept, because accepting it makes everything else worse. Shed by cost first: a hundred-thousand-token summarization occupies a GPU for thirty seconds, and ten short chats fit in that same window, so rejecting the one expensive request preserves service for ten users instead of one. That feels unfair, it’s the right call, and you should write the policy down so support can explain it. Shed by tier second — free before paid, best-effort before interactive. And shed early, at the edge, before you’ve spent anything: a request rejected after tokenization, embedding, and a cache lookup has already cost you real work.
Degradation
The most under-used tool here.
Instead of rejecting, serve something cheaper.
Route to a smaller model — the guide’s chapters on multi-model serving cover keeping a Haiku-class model warm alongside your large one, and under load, routing overflow traffic to it is a far better experience than a 429.
Drop optional stages: skip the reranker, skip the second retrieval pass, cut max_tokens.
Serve a stale cache entry rather than nothing.
Fall back to a non-generative path where one exists — a search results page instead of a synthesized answer.
Degradation has to be designed in. You cannot add it during an incident. Decide the ladder now, put each rung behind a flag, and test that the flags work.
Saying it out loud. Degradation is the most under-used tool at this layer, and the idea is simply: instead of rejecting, serve something cheaper. Route overflow to a smaller model you keep warm alongside the large one — that’s a far better experience than a 429. Drop optional stages: skip the reranker, skip the second retrieval pass, cut max_tokens. Serve a stale cache entry rather than nothing. Fall back to a non-generative path where one exists, like search results instead of a synthesized answer. The catch is that degradation has to be designed in — you cannot add it during an incident. So decide the ladder now, put each rung behind a flag, and actually test that the flags work.
Caching
Caching is where the API layer stops being a tax and starts being the reason your unit economics work.
There are four distinct things people call “the cache” and they operate at different layers with different semantics. Conflating them is the source of most confused conversations about this topic, so let us separate them first.
The four layers
1. Exact-match response cache. You hash the request and store the response. Same request, same answer, no model call. Yours, in your infrastructure.
2. Semantic cache. You embed the query and look for a similar previous query. Different text, similar meaning, reuse the answer. Yours.
3. Embedding cache. You cache the embedding vectors themselves, because computing them is a model call too. Yours, and the least glamorous of the four while frequently having the best return.
4. Provider-side prompt cache. The model provider caches the internal computation for a repeated prompt prefix. Theirs, not yours, and it is not a response cache at all.
Saying it out loud. There are four distinct things people call “the cache” and conflating them causes most of the confused conversations about this topic. An exact-match response cache hashes the request and stores the response — yours, in your infrastructure. A semantic cache embeds the query and looks for a similar previous one — also yours, and much riskier. An embedding cache stores the vectors themselves, because computing them is a model call too — the least glamorous of the four and frequently the best return. And provider-side prompt caching caches the model’s internal computation for a repeated prompt prefix — theirs, not yours, and not a response cache at all. Different layers, different semantics, and the last one composes with rather than competes with the first three.
Exact-match
The boring one, and the one to build first. Normalize the request, hash it, look it up; on a miss, call the model and store the result.
It handles more traffic than you expect, because a great deal of LLM traffic is genuinely identical — the same system prompt over the same document, the same button in your UI firing the same templated request, the same FAQ question typed the same way by different people.
And it is exact, so it cannot be wrong. If the key matches, the request matched. That property is worth more than it sounds, and everything below trades some of it away.
The subtlety is normalization: what counts as “the same request.”
Lowercase and strip whitespace, certainly.
But temperature must be in the key — a request at temperature 1.2 is asking for variety, and serving it a cached answer defeats the point, which is a good argument for not caching non-zero-temperature requests at all unless you have thought about it.
So must the model name, max_tokens, the system prompt, the tool definitions, and anything else that changes the output distribution.
Saying it out loud. Exact match is the boring one and the one to build first: normalize, hash, look up, and on a miss call the model and store it. It handles more traffic than people expect, because a lot of LLM traffic is genuinely identical — the same system prompt over the same document, the same UI button firing the same templated request. And the killer property is that it cannot be wrong: if the key matched, the request matched. Everything below trades some of that away. The subtlety is normalization — what counts as the same request. Lowercase and strip whitespace, sure, but temperature has to be in the key, because a request at temperature 1.2 is explicitly asking for variety and serving it a cached answer defeats the point. Same for model name, max_tokens, system prompt, and tool definitions.
Semantic caching
Here is where it gets interesting, and where it gets dangerous.
“How do I reset my password?” and “I forgot my password, what do I do?” are the same question. An exact-match cache sees two unrelated strings. A semantic cache embeds both into vectors, notices they are close, and serves the cached answer for the second.
The mechanism: embed the incoming query, do a nearest-neighbour search against the embeddings of cached queries, and if the closest one exceeds a similarity threshold, return its cached response. GPTCache, the reference open-source implementation, is built on exactly this pipeline — an embedding function, a vector store, a similarity evaluator, and an eviction policy.
The appeal is obvious. Hit rates that exact matching cannot touch, on the head of your query distribution where it matters most.
Now the honest part.
A semantic cache can return the wrong answer — not a stale answer, a wrong one, to a question that was never asked.
The threshold is doing all the work, and the assumption underneath it is that embedding similarity tracks answer equivalence. It approximately does, not reliably, and the failures are not random — they cluster on exactly the short, near-identical, high-stakes queries a cache sees most. Consider:
- “What is the refund window for annual plans?” versus “for monthly plans.” Nearly identical vectors. Completely different answers. Both are policy statements a customer will act on.
- “Should I take this medication with food?” versus “Should I take this medication without food?” Negation is close to invisible to a bag-of-meaning embedding.
- “What’s my account balance?” asked by two different users. Identical vectors, and if your key does not scope by user, you have just built a data leak with excellent latency.
That last one is not a quality problem, it is a security incident, and it is covered properly in the key design section below.
Research has caught up with this. The vCache work (arXiv 2502.03771) analyses static thresholds directly and finds that the similarity distributions of correct and incorrect cache hits overlap substantially — meaning no single global threshold cleanly separates them, and the “right” threshold varies per query and per embedding model. Their proposed alternative learns a per-embedding threshold online against a user-specified error bound, reporting substantially higher hit rates at much lower error rates than static thresholds. There is also adversarial work on deliberately crafting queries that collide with cached entries under a given threshold.
The practical guidance:
Set the threshold from labelled data, not intuition. Take a few hundred real query pairs, label whether the answers should be the same, compute similarities, and look at where the distributions overlap. That plot is the most useful thing you can produce about your cache.
Scope the cache narrowly. Semantic caching is safe on a documentation FAQ bot and reckless on anything giving personalized, financial, medical, or legal answers. It is a per-surface decision, not a global switch.
Never cache semantically across tenants. Partition the vector index by tenant. Cheaper to enforce than to explain afterwards.
Log every semantic hit with its similarity score. When you get a complaint about a wrong answer, that log is how you find out the cache did it. Without it you will spend a week blaming the model.
Consider a cheap verification step. A small fast model asked “do these two questions have the same answer?” costs a fraction of the large generation you are avoiding, and converts an unbounded risk into a bounded cost. This is a real pattern and it is under-used.
Have a false-positive budget and measure against it. If you cannot say what rate of wrong answers you will accept, you are not ready to run a semantic cache.
Saying it out loud. Semantic caching is where this gets interesting and where it gets dangerous. “How do I reset my password” and “I forgot my password, what do I do” are the same question, and an exact cache sees two unrelated strings. So you embed, nearest-neighbor search the cached queries, and serve the answer if similarity clears a threshold. Now the honest part: a semantic cache can return a confidently wrong answer to a question nobody asked. The example I’d give is refund window for annual plans versus monthly plans — nearly identical vectors, completely different answers, both things a customer will act on. Negation is nearly invisible to embeddings too. And the research backs this up: the vCache work found the similarity distributions of correct and incorrect hits overlap substantially, so no single static threshold cleanly separates them. Set it from labelled pairs, log every hit’s score, and have a stated false-positive budget.
Embedding cache
Embeddings are model calls. For a semantic cache, every incoming query needs one — you cannot look up without embedding first. So the thing that makes your cache fast is itself a per-request model call, and if you do not cache it, your semantic cache has a fixed floor on both latency and cost.
Cache embeddings by content hash. They are deterministic for a given model and input, so this is exact-match caching with none of the semantic risk, and the hit rate on a repetitive workload is very high.
Include the embedding model name and version in the key. Vectors from different models are not comparable, and mixing them silently produces a similarity metric that means nothing. When you upgrade the embedding model you must rebuild the whole index — treat it as a migration, not a config change.
Saying it out loud. The embedding cache is the unglamorous one with the best return. Every incoming query to a semantic cache needs an embedding before you can look anything up — so the thing that makes your cache fast is itself a per-request model call, and if you don’t cache it, your semantic cache has a hard floor on both latency and cost. Cache by content hash: embeddings are deterministic for a given model and input, so this is exact-match caching with none of the semantic risk, and hit rates on repetitive workloads are very high. One rule that will save you: put the embedding model name and version in the key, because vectors from different models aren’t comparable, and mixing them silently produces a similarity metric that means nothing. An embedding model upgrade is an index rebuild — treat it as a migration, not a config change.
Provider-side prompt caching
This is a different mechanism, and the confusion between it and your response cache is worth clearing up carefully.
Your response cache stores outputs. Provider prompt caching stores intermediate state — the key-value attention tensors computed while processing a prompt prefix. It does not skip generation; it skips re-processing the part of the input the model has already seen.
Four consequences follow.
It is prefix-based. The cache matches on an exact prefix, so static content must come first and variable content last. Anthropic’s hierarchy is tools, then system, then messages, and placing a breakpoint after something that changes per request means it never hits.
It has minimum sizes. Anthropic requires 512 tokens for Opus 5-class models and 1,024 or more for others; OpenAI requires 1,024. Below that nothing is cached and no error is returned — a silent no-op worth checking for in your usage metrics.
It has explicit pricing. On Anthropic a 5-minute cache write costs 1.25× base input, a 1-hour write 2×, and a read 0.1× — so it pays for itself on the second hit. OpenAI’s newer models similarly charge 1.25× for writes with a large read discount and a minimum 30-minute retention.
It is controlled differently per provider. Anthropic is opt-in via cache_control markers (up to four explicit breakpoints, or automatic placement); OpenAI’s is automatic for eligible prompts with an optional prompt_cache_key to improve routing; Gemini offers both implicit and explicit context caching.
And it composes with your cache rather than competing:
- Your exact cache hits — no provider call at all. Cheapest.
- Your semantic cache hits — no provider call. Cheap, with the risk described above.
- Miss, so you call the provider — and the provider’s prompt cache makes that call cheaper by reusing your long system prompt and retrieved documents.
Layer 3 helps every request that reaches it, including all your cache misses, and it requires no infrastructure from you. Structure your prompts prefix-stable and it costs you a code review.
One more thing worth knowing: KV cache inside your own vLLM deployment is the same idea, applied locally. vLLM’s automatic prefix caching reuses attention state across requests that share a prefix. If you self-host, that is your version of provider prompt caching, and this guide’s vLLM chapters cover how to enable and size it.
Saying it out loud. Provider prompt caching is a different mechanism from your response cache and the confusion is worth clearing up. Yours stores outputs; theirs stores intermediate state — the attention key-value tensors from processing a prompt prefix. It doesn’t skip generation, it skips re-processing input the model already saw. Four consequences. It’s prefix-based, so static content goes first and variable content last, and a breakpoint after something that changes per request never hits. It has minimum sizes, typically several hundred to a thousand-plus tokens, below which nothing caches and no error is raised — a silent no-op worth checking in your usage metrics. It has explicit pricing, roughly 1.25x base input for a write and about a tenth for a read, so it pays off on the second hit. And it composes with your cache rather than competing: it makes every request that misses your cache cheaper. If you self-host, vLLM’s automatic prefix caching is your version of exactly this.
Cache key design
The most consequential fifteen lines in the whole system.
The key must include everything that changes the answer:
- the normalized prompt (lowercased, whitespace-collapsed, with your specific normalizations)
- the model identifier including version — a new model version is a new cache namespace
- the sampling parameters — temperature, top_p, max_tokens, stop sequences
- the system prompt or its hash
- the tool definitions if any
- the prompt/template version, so a prompt change invalidates the cache automatically rather than serving answers generated under the old instructions
And then the one that matters most:
Include the tenant, and include the user whenever the response is personalized.
If your responses depend on who is asking — retrieved documents scoped by permission, account data, anything from a per-user context — then a key without the user identity means user B can receive user A’s answer.
This is not a subtle bug. It is a cross-user data leak, it will be found, and it will be found by a customer. It is also, in my experience, the single most common serious defect in hand-rolled LLM caches, because it does not show up in testing (one test user), it does not show up at low traffic (few collisions), and it produces no error when it fires.
The rule that makes it hard to get wrong: derive the cache key from the same context object that produced the prompt.
If the retrieval was scoped by user_id, then user_id is part of the input, and it belongs in the key mechanically rather than by remembering.
Where responses genuinely are not personalized — a public documentation bot — sharing across users is exactly the point, and you get much better hit rates. Make that a deliberate, per-surface, written-down decision.
Saying it out loud. This is the most consequential fifteen lines in the whole system. The key has to include everything that changes the answer — normalized prompt, model identifier including version, sampling parameters, system prompt, tool definitions, and the prompt template version so a prompt change invalidates the cache automatically. And then the one that matters most: include the tenant, and include the user whenever the response is personalized. If retrieval was scoped by permissions or the answer reads account data, a key without user identity means user B can receive user A’s answer. That isn’t a caching bug, it’s a cross-user data leak, and it’s the single most common serious defect in hand-rolled LLM caches, because it doesn’t show up in testing with one test user, doesn’t show up at low traffic, and raises no error when it fires. The rule that makes it hard to get wrong: derive the key from the same context object that produced the prompt.
TTL policies
TTL is a bet about how long an answer stays correct.
Set it from the volatility of the underlying data, not from a habit:
- Static knowledge — explanations, definitions, code examples: hours to days.
- Documentation-grounded answers: tie the TTL to your documentation deploy cycle, or better, invalidate on deploy.
- Data-grounded answers — anything reading a live database: minutes at most, and consider whether caching is appropriate at all.
- Personalized answers: short, and scoped per user.
- Anything with a timestamp in the answer: do not cache, or strip the timestamp.
Two refinements.
Jitter the TTLs. Entries written together expire together, and a synchronized expiry is a self-inflicted stampede. Add ±10% randomness.
Consider adaptive TTLs. Extend on hit — a popular entry stays warm — and expire cold entries fast. This approximates LFU eviction with much simpler bookkeeping.
Saying it out loud. A TTL is a bet about how long an answer stays correct, so set it from the volatility of the underlying data rather than habit. Static knowledge — explanations, definitions, code examples — can live hours to days. Documentation-grounded answers should be tied to your docs deploy cycle, or better, invalidated on deploy. Anything reading a live database gets minutes at most, and you should ask whether it should be cached at all. Anything with a timestamp in the answer: don’t cache it, or strip the timestamp. Two refinements worth naming. Jitter every TTL by about ten percent, because entries written together expire together and a synchronized expiry is a self-inflicted stampede. And consider extending TTL on hit, which approximates least-frequently-used eviction with far simpler bookkeeping.
Invalidation
TTL is invalidation by giving up. It is fine, and it is what you should do by default, but sometimes you need to actually invalidate.
Version prefixes are the pattern that works. Put a version in the key namespace — cache:v7:... — and to invalidate everything, bump to v8. Old entries are orphaned and expire on their own TTL. No scanning, no deletes, instant, and trivially reversible if you were wrong.
Tag-based invalidation for finer control. Record which source documents contributed to each cached answer; when a document changes, invalidate the entries tagged with it. This requires you to track provenance, which you probably want for citations anyway.
Event-driven invalidation for data-grounded answers: your CMS publishes a change event, the cache layer drops the affected keys.
Do not scan for keys to delete. KEYS blocks Redis, SCAN is a slow-motion version of the same problem. Design the namespace so that invalidation is a namespace change, not a search.
Saying it out loud. TTL is invalidation by giving up, and that’s fine as a default — but sometimes you need to actually invalidate. The pattern that works is a version prefix in the key namespace: to invalidate everything, bump the version, and old entries are orphaned and expire on their own. Instant, no scanning, no deletes, and trivially reversible if you were wrong. For finer control, tag entries with the source documents that contributed to them and invalidate by tag when a document changes — which requires provenance tracking you probably want for citations anyway. And the anti-pattern to name explicitly: do not scan for keys to delete. KEYS blocks Redis and SCAN is a slow-motion version of the same problem. Design the namespace so invalidation is a namespace change, not a search.
Cache stampede
Here is the failure that turns a cache from a protection into an amplifier.
A popular entry expires. A hundred concurrent requests for it all miss simultaneously. All hundred call the model. All hundred compute the same answer. All hundred write it back.
You have just sent 100× your steady-state load at the GPU fleet, at the exact moment the cache stopped helping. This is a cache stampede, also called a thundering herd or a dogpile, and for expensive backends it is the most important cache failure mode to design against.
Four mitigations, roughly in order of how often you should reach for them.
Single-flight (request coalescing). The first request to miss takes a lock and computes; the others wait on the same in-flight computation and share its result. One model call instead of a hundred. This is the primary defence and it is the one to implement first. Go’s singleflight package is the canonical implementation of the idea; the pattern is a map from key to a pending future.
Probabilistic early expiration (XFetch). Rather than expiring at a hard boundary, each request nearing the TTL has a small and growing probability of recomputing early, so one lucky request refreshes the entry while everyone else is still being served the cached value. The classic formulation recomputes when
\( t_{\text{now}} - \delta \beta \ln(\text{rand}()) \ge t_{\text{expiry}} \)
where \( \delta \) is how long the recomputation takes and \( \beta \) tunes eagerness. It is elegant, it is a handful of lines, and it eliminates the synchronized-expiry class of stampede entirely.
Stale-while-revalidate. Keep serving the expired value while one background task refreshes it. Everyone gets a fast response; one of them is a few seconds stale. Whether that is acceptable is a product decision, and for most LLM answers it very much is.
Pre-warming. For entries you know will be hot — a launch, a scheduled campaign, the FAQ — populate the cache before traffic arrives.
Locks need care. A lock holder that dies must not block everyone forever, so set a TTL on the lock; a waiter must not wait longer than the client’s timeout. Bound both.
Saying it out loud. This is the failure that turns a cache from a protection into an amplifier. A popular entry expires, a hundred concurrent requests all miss at once, all hundred call the model, all hundred compute the same answer. You’ve just sent a hundred times your steady-state load at the GPU fleet at the exact moment the cache stopped helping. The primary defence is single-flight coalescing: the first request to miss takes a lock and computes, the rest wait on that same in-flight computation and share the result — one model call instead of a hundred. Then probabilistic early expiration, where requests near the TTL have a small growing chance of refreshing early so one lucky request warms the entry while everyone else still gets served. Then stale-while-revalidate, and pre-warming for known-hot keys. And put a TTL on the lock, or one dead lock holder blocks everyone forever.
Measuring it
A cache you do not measure is a cache you do not have.
Hit rate, split by tier. Exact and semantic hit rates are different numbers with different meanings, and a single blended figure hides the one you need to watch.
What is good? It depends entirely on the workload, and anyone quoting a universal number is selling something. Some calibration:
- A general-purpose assistant with an open-ended query distribution: 5–15% is realistic. The tail is genuinely long. Do not be disappointed.
- A documentation or support bot with a fat head of common questions: 30–60% is achievable, mostly on the top few hundred queries.
- An internal tool with templated prompts over a bounded document set: 60%+ and it should be higher if it isn’t.
The diagnostic signals matter more than the absolute number:
- Hit rate near zero means your key is over-specific. Something varying is in it that should not be — a timestamp, a request ID, a session token. Log a few keys and look.
- Hit rate suspiciously high on personalized content means your key is under-specific, and you should check for the cross-user leak today.
- A sudden drop means something changed the key shape: a model version bump, a prompt template edit, a new field in the context object. This is one of the most useful alerts you can configure.
Also track cost avoided — the token cost of the requests you served from cache, which is the number that justifies the system in a budget conversation — and latency by path, because cache hits should be single-digit milliseconds and if they are not, something is wrong with your store.
And track the semantic cache’s similarity distribution. Watching where hits cluster relative to the threshold tells you whether you have headroom or are living on the edge of it.
Saying it out loud. A cache you don’t measure is a cache you don’t have, and the number people want — the hit rate — should be split by tier, because exact and semantic hits mean different things. As for what’s good, refuse the universal number: an open-ended assistant realistically gets five to fifteen percent because the tail is genuinely long, a support bot with a fat head of common questions can hit thirty to sixty, and an internal tool over templated prompts should be above sixty. The diagnostics matter more than the absolute figure. Near-zero means an over-specific key with something varying in it — a timestamp, a request ID. Suspiciously high on personalized content means an under-specific key, and you should go check for the cross-user leak today. And a sudden drop means something changed the key shape, which is one of the most useful alerts you can configure.
Gateway patterns
Where does this logic live?
In your application — middleware in FastAPI or Express, same process as your business logic. Simplest to start, full access to application context so a limit can depend on anything you know about the user, easy to test. The cost is duplication: every service that needs limiting implements it and they drift, and your process spends CPU on requests it is about to reject. Right when you have one service, or when policy genuinely needs application knowledge.
In a sidecar — a proxy container beside each application container, sharing a network namespace. Envoy in a service mesh is the canonical shape. Policy is centrally managed and applied uniformly, in any language, without touching application code, and rejections happen before your app sees them. The cost is operational: another container per pod, another config surface, another thing in the request path to debug at 3am. Right when you have a mesh already.
In a gateway — one tier of proxies in front of everything: Envoy, Kong, APISIX, NGINX, or a cloud gateway. One place for auth, limiting, routing and logging; rejection at the edge before any application resource is spent; and a battle-tested implementation you configure rather than write. The cost is expressiveness. Gateway limiting works on request attributes — headers, path, source IP, a JWT claim. It does not know that this request will generate 8,000 tokens, and that is precisely what an LLM API needs to limit on.
So the honest recommendation is a split:
Coarse limits at the gateway. Requests per second per key, connection limits, IP-based abuse controls, body size caps. Cheap, fast, catches the obvious. Envoy’s local rate limit filter is a token bucket per listener; its global filter calls out to a rate limit service over gRPC, with Envoy’s own Go reference implementation backed by Redis. Kong and APISIX offer equivalents, with Kong’s advanced plugin supporting sliding-window counters over a Redis strategy.
Token-aware limits in your service. Because only your service can tokenize the request, estimate the output, know which model it will route to, and reconcile after generation.
Do not contort a gateway into doing token accounting. Do not skip the gateway because it cannot.
Three related concerns live at this layer too.
Authentication and key management. Store a hash of each API key, never the key — you should be unable to display it after creation. Prefix keys with an environment tag (sk_live_, sk_test_) so a leak is greppable and mistakes are visible. Support multiple active keys per account so rotation does not require downtime, and record last-used timestamps so you can find keys that are safe to revoke. Scope keys to permissions.
Request and response logging. You need it for debugging, billing, abuse investigation, and building evaluation sets from real traffic. You also have to reckon with the fact that prompts and completions frequently contain personal data. Log metadata always — token counts, latency, model, cache outcome, limiter decision. Log content selectively, with retention limits, redaction, and a per-customer opt-out. Sampling gives you most of the debugging value at a fraction of the exposure.
Multi-provider routing and failover. Once you have a layer between your app and the model, you can route: cheap models for easy requests, a different provider when your primary is degraded, a self-hosted vLLM for the bulk of traffic with a commercial API as overflow. Circuit breakers so a failing provider is removed quickly rather than eating your timeout budget on every request. This is also where you notice that the layer is doing quite a lot and should probably be its own service.
Saying it out loud. The question of where this logic lives has a real answer and it’s a split. In your application is simplest to start and gives you full context, but every service reimplements it and they drift. A sidecar centralizes policy without touching application code, at the cost of another container and another thing to debug at 3am. A gateway gives you one place for auth, limiting, routing and logging, with rejection at the edge before any application resource is spent — and a battle-tested implementation you configure rather than write. But a gateway works on request attributes: headers, path, source IP, a JWT claim. It does not know this request will generate eight thousand tokens, and that is precisely what an LLM API needs to limit on. So: coarse limits at the gateway — requests per second, connections, body size, IP — and token-aware limits in your service, because only your service can tokenize, estimate, and reconcile. Don’t contort the gateway into token accounting, and don’t skip it because it can’t.
A worked example
Everything above, in dependency-free Python, small enough to read in one sitting.
Two pieces. A token-metered limiter that charges on tokens rather than requests, handles concurrency, and reconciles estimates against actuals — with the Redis Lua script it mirrors shown alongside, so you can see that the in-memory version is the same arithmetic without the network. And a two-tier cache that tries exact match, falls back to semantic similarity, scopes by tenant, and reports its hit rate.
The mock embedder is a normalized bag of words.
It is not a real encoder and it is not pretending to be — its only job is to be deterministic, dependency-free, and to have the shape of a similarity function, so the threshold behaviour it demonstrates is real behaviour.
Swap in sentence-transformers and the surrounding code does not change.
"""API layer demo: token-metered rate limiting + two-tier cache. Stdlib only."""
import hashlib
import math
import re
import time
from collections import OrderedDict
# ---------------------------------------------------------------- rate limiting
REFILL_LUA = """
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- tokens refilled per second
local capacity = tonumber(ARGV[2]) -- bucket size (burst allowance)
local now = tonumber(ARGV[3]) -- caller-supplied clock, seconds
local cost = tonumber(ARGV[4]) -- tokens this request wants
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then tokens = capacity; ts = now end
tokens = math.min(capacity, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= cost then
allowed = 1
tokens = tokens - cost
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
local deficit = 0
if allowed == 0 then deficit = cost - tokens end
return {allowed, math.floor(tokens), deficit / rate}
"""
class TokenBucket:
"""In-memory mirror of REFILL_LUA. Same arithmetic, no network."""
def __init__(self, rate_per_sec, capacity, clock=time.monotonic):
self.rate = float(rate_per_sec)
self.capacity = float(capacity)
self.clock = clock
self._state = {}
def check(self, key, cost):
now = self.clock()
tokens, ts = self._state.get(key, (self.capacity, now))
tokens = min(self.capacity, tokens + (now - ts) * self.rate)
allowed = tokens >= cost
if allowed:
tokens -= cost
self._state[key] = (tokens, now)
retry_after = 0.0 if allowed else (cost - tokens) / self.rate
return allowed, int(tokens), retry_after
class Limiter:
"""Three buckets per identity: tokens/min, requests/min, concurrency."""
def __init__(self, tpm, rpm, max_concurrent, clock=time.monotonic):
self.tokens = TokenBucket(tpm / 60.0, tpm, clock)
self.requests = TokenBucket(rpm / 60.0, rpm, clock)
self.max_concurrent = max_concurrent
self.inflight = {}
def admit(self, key, estimated_tokens):
if self.inflight.get(key, 0) >= self.max_concurrent:
return Decision(False, "concurrency", 1.0, 0)
ok, left, retry = self.requests.check(key, 1)
if not ok:
return Decision(False, "requests", retry, left)
ok, left, retry = self.tokens.check(key, estimated_tokens)
if not ok:
return Decision(False, "tokens", retry, left)
self.inflight[key] = self.inflight.get(key, 0) + 1
return Decision(True, None, 0.0, left)
def settle(self, key, estimated_tokens, actual_tokens):
"""Reconcile the estimate against reality once generation finishes."""
self.inflight[key] = max(0, self.inflight.get(key, 1) - 1)
delta = actual_tokens - estimated_tokens
if delta > 0:
self.tokens.check(key, delta) # overspend: charge it
elif delta < 0: # underspend: refund
t, ts = self.tokens._state[key]
self.tokens._state[key] = (min(self.tokens.capacity, t - delta), ts)
class Decision:
def __init__(self, allowed, reason, retry_after, remaining):
self.allowed = allowed
self.reason = reason
self.retry_after = retry_after
self.remaining = remaining
def headers(self, policy_name, quota, window):
h = {"RateLimit-Policy": f'"{policy_name}";q={quota};qu="tokens";w={window}',
"RateLimit": f'"{policy_name}";r={max(0, self.remaining)};t={window}'}
if not self.allowed:
h["Retry-After"] = str(max(1, math.ceil(self.retry_after)))
return h
# ---------------------------------------------------------------------- caching
_WORD = re.compile(r"[a-z0-9']+")
_STOP = {"the", "a", "an", "is", "are", "do", "does", "i", "to", "of", "my", "in"}
def embed(text):
"""Deterministic bag-of-words unit vector. Stands in for a real encoder."""
vec = {}
for w in _WORD.findall(text.lower()):
if w in _STOP:
continue
vec[w] = vec.get(w, 0.0) + 1.0
norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0
return {k: v / norm for k, v in vec.items()}
def cosine(a, b):
small, large = (a, b) if len(a) <= len(b) else (b, a)
return sum(v * large.get(k, 0.0) for k, v in small.items())
class TwoTierCache:
def __init__(self, threshold=0.90, ttl=300.0, capacity=512, clock=time.monotonic):
self.threshold = threshold
self.ttl = ttl
self.capacity = capacity
self.clock = clock
self.exact = OrderedDict() # key -> (expires, response)
self.semantic = [] # [(key, vector, expires, response)]
self.stats = {"exact_hit": 0, "semantic_hit": 0, "miss": 0, "rejected": 0}
def key(self, tenant, model, params, prompt):
h = hashlib.sha256()
for part in (tenant, model, repr(sorted(params.items())), prompt.strip().lower()):
h.update(part.encode()); h.update(b"\x00")
return h.hexdigest()[:16]
def _expire(self):
now = self.clock()
for k in [k for k, (exp, _) in self.exact.items() if exp <= now]:
del self.exact[k]
self.semantic = [e for e in self.semantic if e[2] > now]
def get(self, tenant, model, params, prompt):
self._expire()
k = self.key(tenant, model, params, prompt)
if k in self.exact:
self.exact.move_to_end(k)
self.stats["exact_hit"] += 1
return self.exact[k][1], "exact", 1.0
scope = f"{tenant}|{model}"
vec = embed(prompt)
best, best_sim = None, 0.0
for ckey, cvec, _exp, resp in self.semantic:
if not ckey.startswith(scope):
continue # never cross tenants
sim = cosine(vec, cvec)
if sim > best_sim:
best, best_sim = resp, sim
if best is not None and best_sim >= self.threshold:
self.stats["semantic_hit"] += 1
return best, "semantic", best_sim
if best is not None:
self.stats["rejected"] += 1
self.stats["miss"] += 1
return None, "miss", best_sim
def put(self, tenant, model, params, prompt, response):
exp = self.clock() + self.ttl
k = self.key(tenant, model, params, prompt)
self.exact[k] = (exp, response)
self.exact.move_to_end(k)
while len(self.exact) > self.capacity:
self.exact.popitem(last=False)
self.semantic.append((f"{tenant}|{model}|{k}", embed(prompt), exp, response))
def hit_rate(self):
s = self.stats
served = s["exact_hit"] + s["semantic_hit"]
total = served + s["miss"]
return served / total if total else 0.0
The driver runs both against a fake clock, so the output is deterministic. It admits five jobs for two tenants, advances the clock thirty seconds to show refill, settles one request whose real cost was ten times its estimate, and then puts six queries through the cache:
lim = Limiter(tpm=6000, rpm=60, max_concurrent=2, clock=tick)
for tenant, est in [("acme", 500), ("acme", 500), ("acme", 4000),
("acme", 2000), ("solo", 2000)]:
d = lim.admit(tenant, est)
if d.allowed:
lim.settle(tenant, est, actual_tokens=est)
cache = TwoTierCache(threshold=0.85, ttl=60.0, clock=tick)
answer("acme", "How do I reset my password?")
answer("acme", "how do I RESET my password?") # exact, normalised
answer("acme", "How can I reset my password?") # true paraphrase
answer("acme", "What is the refund window for annual plans?")
answer("acme", "What is the refund window for monthly plans?") # near miss
answer("globex", "How do I reset my password?") # other tenant
Running it, with the print statements restored:
====================================================================
1. TOKEN-METERED RATE LIMITING
====================================================================
acme est= 500 tok -> ALLOW remaining=5500
acme est= 500 tok -> ALLOW remaining=5000
acme est= 4000 tok -> ALLOW remaining=1000
acme est= 2000 tok -> DENY (429, reason=tokens) remaining=1000
RateLimit-Policy: "tokens-per-min";q=6000;qu="tokens";w=60
RateLimit: "tokens-per-min";r=1000;t=60
Retry-After: 10
solo est= 2000 tok -> ALLOW remaining=4000
acme after 30s of refill -> ALLOW remaining=2000
reconciling an underestimate:
admitted on a 500-token estimate, remaining=5500
generation actually used 5000; next request remaining=500 allowed=True
====================================================================
2. TWO-TIER CACHE (exact -> semantic)
====================================================================
[miss sim=0.000] acme: 'How do I reset my password?' -> upstream
[exact sim=1.000] acme: 'how do I RESET my password?'
[semantic sim=0.866] acme: 'How can I reset my password?'
[miss sim=0.000] acme: 'What is the refund window for annual plans?' -> upstream
[miss sim=0.833] acme: 'What is the refund window for monthly plans?' -> upstream
[miss sim=0.000] globex: 'How do I reset my password?' -> upstream
exact=1 semantic=1 miss=4 threshold-rejected=1
hit rate = 33.3% upstream calls = 4/6
====================================================================
3. WHERE THE THRESHOLD ACTUALLY SITS
====================================================================
same question, reworded -> 0.866 (want a HIT)
one word changes the answer -> 0.833 (want a MISS)
usable margin between them -> 0.033
threshold 0.60: paraphrase=hit annual/monthly=SERVES THE WRONG ANSWER
threshold 0.75: paraphrase=hit annual/monthly=SERVES THE WRONG ANSWER
threshold 0.85: paraphrase=hit annual/monthly=correctly rejected
threshold 0.95: paraphrase=miss annual/monthly=correctly rejected
====================================================================
4. TTL EXPIRY
====================================================================
after 61s with ttl=60s -> miss (entry gone: True)
Read section 1 carefully.
The third request costs 4,000 tokens and is admitted, leaving 1,000.
The fourth wants 2,000 and is denied — not because the caller made too many requests (four is nothing against a limit of sixty) but because they asked for too much work.
Retry-After: 10 is computed, not guessed: the bucket needs 1,000 more tokens and refills at 100 per second.
The solo tenant is unaffected, which is the whole point.
Then the reconciliation. A request admitted on a 500-token estimate that actually generates 5,000 does not get truncated — it completes, and the overspend is charged afterwards, so the next request sees a bucket at 500 instead of 5,500. The caller pays for what they used, one request late.
Section 3 is the important one, and it is the reason this example exists.
Two pairs of questions. One is a genuine paraphrase and should hit: 0.866. One changes a single word in a way that completely changes the correct answer and must miss: 0.833.
The gap is 0.033.
At a threshold of 0.75 — which sounds cautious, and which you will find recommended in blog posts — the cache confidently tells a customer the wrong refund policy. At 0.95 it stops working at all. The window where it does the right thing on both is narrow, and it is narrow here, on a toy embedder, on two hand-picked pairs.
On real traffic the distributions overlap, which is exactly the vCache finding: there may be no threshold that gets every case right. Which is why the guidance above is to derive the number from labelled data, log every hit with its score, and know your false-positive budget.
Anyone who tells you semantic caching is free has not measured the third section.
Saying it out loud. The thing this worked example actually proves is in its third section, and it’s the argument against casual semantic caching. Two pairs of questions run through a toy embedder: a genuine paraphrase that should hit scores 0.866, and a one-word change that flips the correct answer scores 0.833. The usable margin between “serve this” and “absolutely do not” is 0.033. At a threshold of 0.75 — which sounds cautious and which you’ll find recommended in blog posts — the cache confidently tells a customer the wrong refund policy. At 0.95 it stops working at all. And that’s on a toy embedder with two hand-picked pairs; on real traffic the distributions overlap, so there may be no threshold that gets every case right. Anyone who tells you semantic caching is free hasn’t measured that gap.
Production checklist
Rate limiting
- Meter on tokens, not requests — input and output counted separately
- Separate cap on concurrent in-flight requests per caller
- Dollar-based limits if you route across models with different prices
- Two-phase charge: reserve an estimate on admission, reconcile on completion
- Limits at key, user, org, and global scope, evaluated cheapest-first
- Burst allowance (\( B \)) tuned separately from sustained rate (\( r \))
- New accounts start lower and graduate with age
- 429 with a computed
Retry-After, never a hardcoded one -
RateLimit/RateLimit-Policyheaders on success and failure,X-RateLimit-*alongside for compatibility - Quota exhaustion and capacity exhaustion are distinguishable in the response body
- Shared state in Redis with the read-decide-write in a single Lua script
- Timestamps passed in as arguments; all keys declared in
KEYS; hash tags for multi-key limits - Fail-open vs fail-closed decided per limiter and controlled by a flag
- Local pre-filter in front of the global limiter to shed floods for free
Queuing
- Queue is bounded, with depth derived from Little’s Law and your latency target
- Requests dropped when their queue age exceeds the client timeout
- Priority classes with a starvation guarantee for lower classes
- Load shedding by cost and by tier, applied at the edge
- A written degradation ladder — smaller model, fewer stages, stale cache — behind flags that have been tested
- Queue depth alerted on and exported to the autoscaler
Caching
- Exact-match tier before any semantic tier
- Cache key includes model+version, all sampling params, system prompt, tool defs, and prompt template version
- Cache key includes tenant, and includes user whenever the response is personalized
- Key derived from the same context object that built the prompt, not assembled by hand
- Semantic threshold chosen from labelled pairs, with the overlap plot saved somewhere
- Semantic cache disabled on personalized, financial, medical, and legal surfaces
- Vector index partitioned by tenant
- Every semantic hit logged with its similarity score
- A stated false-positive budget, measured against
- Embeddings cached by content hash, keyed by embedding model version
- Prompts structured prefix-stable so provider prompt caching engages; cache-read and cache-write token counts monitored
- TTLs set from data volatility, jittered ±10%
- Invalidation by version prefix, never by key scanning
- Single-flight coalescing on misses
- Stampede protection beyond that — probabilistic early expiry or stale-while-revalidate
- Hit rate tracked per tier, with alerts on sudden drops
- Cost avoided tracked in currency, for the budget conversation
What an interviewer will probe
Two of the four canonical AI-engineering system design questions are this chapter. These come up in some form nearly every time.
“How would you rate limit an LLM API for millions of users?”
Lead with the unit. Requests are the wrong unit because request cost varies by four orders of magnitude, so you meter tokens, concurrency, and dollars. Token bucket, because it handles variable cost naturally and gives you burst tolerance as a separate knob. State in Redis with the whole read-decide-write in one Lua script. Local pre-filter in each replica to shed obvious floods without a network hop. Limits at key, user, org, and global scope. 429 with a computed Retry-After and limit headers on every response.
The follow-up is always the two-phase charge, so get there before they ask.
“You can’t know the token cost until after you serve the request. So what do you charge?”
Reserve an estimate on admission — max_tokens, or better, a rolling p90 of that caller’s history. Reconcile on completion: charge the overspend, refund the underspend. Never truncate a stream to enforce a limit; let the overshoot land and make the next request pay. Say out loud that reserving at max_tokens without reconciling is why some APIs feel much stingier than their published numbers.
“Why isn’t a fixed window good enough?”
The boundary burst: 100 at 11:59:59 and 100 at 12:00:00 is 200 in two seconds, both windows legal. Then say when you would accept it anyway — a coarse abuse filter in front of something with real headroom — and when you would not, which is anything in front of a GPU fleet sized at 20% margin.
“You have twenty replicas. Where does the limiter state live?”
Not in-process, because twenty replicas each enforcing the limit means 20× the limit gets through, and dividing by twenty is worse because traffic is not evenly distributed. Shared store, atomic update. Then the tradeoff: a Redis round trip is nothing against a three-second generation, so accuracy is cheap here — but a two-tier local/global setup still buys you flood protection at zero cost and reduces load on the limiter itself.
“What breaks if you use INCR then EXPIRE?”
Each is atomic; the pair is not. Crash between them and you have a counter with no TTL that never resets, permanently limiting that caller. More generally, any read-decide-write split across commands has a TOCTOU race, and under load the race is the common case. One Lua script.
“How would you cache LLM responses efficiently?”
Layers. Exact match first, keyed on normalized prompt plus model version plus sampling params plus system prompt plus template version plus tenant. Semantic second, with the risk stated. Embedding cache underneath both because embeddings are model calls too. And provider-side prompt caching, which is a different mechanism entirely — it caches KV state for a repeated prefix, not responses — and helps every request that misses your cache.
“What’s the risk with semantic caching?”
That it returns a confidently wrong answer to a question nobody asked. Give the concrete example: annual versus monthly refund policy, near-identical embeddings, opposite answers. Cite that the similarity distributions of correct and incorrect hits overlap, so a single static threshold cannot cleanly separate them. Then the mitigations: threshold from labelled data, tenant partitioning, per-surface enablement, logging every hit’s score, a cheap verification model on borderline hits, and a stated false-positive budget.
“What must be in the cache key?”
Everything that changes the answer — and then the one that gets missed: the user or tenant, whenever responses are personalized. Name it as a cross-user data leak rather than a caching bug, and note why it survives testing: one test user, low traffic, no error raised. The structural fix is to derive the key from the same context object that produced the prompt.
“A hot cache entry expires and a hundred requests miss at once. What happens?”
Cache stampede. A hundred identical model calls at the moment the cache stopped protecting you, so the cache has amplified load rather than reduced it. Single-flight coalescing is the primary fix. Then probabilistic early expiration so one request refreshes early while others still get the cached value, stale-while-revalidate if slightly stale is acceptable, jittered TTLs so entries do not expire in lockstep, and pre-warming for known-hot keys.
“You’re at capacity. Queue or reject?”
Both, with a boundary. Queue briefly, bounded by Little’s Law against your latency target, and drop anything that has waited past the client timeout. An unbounded queue is a slower failure: clients time out and retry, so you burn GPU on answers nobody reads. Then priority classes, shedding by cost first because one long request costs ten short ones, and degradation — a smaller model or a stale cache entry beats a 429.
“What hit rate should we expect?”
Refuse the universal number. 5–15% for an open-ended assistant, 30–60% for a support bot with a fat head, higher for internal templated workloads. Then pivot to the diagnostics, which is what they are really testing: near-zero means an over-specific key with something varying in it; suspiciously high on personalized content means an under-specific key and you should go check for a leak today; a sudden drop means a model version, prompt template, or context field changed the key shape.
“Gateway or application?”
Both, split by what each can know. Coarse limits at the gateway — requests per second, connections, body size, IP — because rejecting at the edge is cheapest and Envoy or Kong have already solved it. Token-aware limits in your service, because only your service can tokenize the input, estimate the output, and reconcile afterwards. Then say plainly that you would not contort a gateway into doing token accounting, and you would not skip the gateway because it cannot.
Further reading
Rate limiting algorithms
- Brandur Leach, Rate Limiting, Cells, and GCRA — the canonical GCRA explanation, including why removing the drip process removes a class of failure. https://brandur.org/rate-limiting
- Wikipedia, Generic cell rate algorithm — the formal definition from ATM networking. https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm
- Redis, Build 5 Rate Limiters with Redis — fixed window, sliding log, sliding counter, token bucket, with the Lua scripts and an explicit treatment of why atomicity is required. https://redis.io/tutorials/howtos/ratelimiting/
- Cloudflare, How we built rate limiting capable of scaling to millions of domains — the sliding window counter approximation at scale. https://blog.cloudflare.com/counting-things-a-lot-of-different-things/
Standards and headers
- IETF HTTPAPI WG, RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers-11, May 2026) —
RateLimitandRateLimit-Policy, including theququota-unit parameter. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ - The working group’s repository, with examples and discussion. https://github.com/ietf-wg-httpapi/ratelimit-headers
Gateways
- Envoy, Global rate limiting — the rate limit service architecture and how local and global limiting compose. https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/other_features/global_rate_limiting
- Envoy, Local rate limit filter — the in-process token bucket. https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/local_rate_limit_filter
- NGINX, Rate Limiting with NGINX — leaky bucket, and what
burstandnodelayactually do. https://blog.nginx.org/blog/rate-limiting-nginx
Semantic caching
- Zilliz, GPTCache — the reference open-source semantic cache: embedder, vector store, similarity evaluator, eviction. https://github.com/zilliztech/GPTCache
- GPTCache: An Open-Source Semantic Cache for LLM Applications (paper). https://openreview.net/pdf?id=ivwM8NwM4Z
- vCache: Verified Semantic Prompt Caching (arXiv 2502.03771) — why static thresholds fail, with the overlapping-distribution analysis and a per-embedding adaptive alternative. https://arxiv.org/abs/2502.03771
Provider prompt caching
- Anthropic, Prompt caching —
cache_control, breakpoints, minimum token counts, 5m and 1h TTLs, and the 1.25×/2×/0.1× pricing multipliers. https://platform.claude.com/docs/en/build-with-claude/prompt-caching - OpenAI, Prompt caching — automatic caching above 1,024 tokens,
prompt_cache_key, and prefix structuring. https://developers.openai.com/api/docs/guides/prompt-caching - Amazon Bedrock, Prompt caching — the same idea across Bedrock-hosted models. https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
Stampede
- Vattani, Chierichetti & Lowenstein, Optimal Probabilistic Cache Stampede Prevention (VLDB 2015) — the XFetch algorithm. https://cseweb.ucsd.edu/~avattani/papers/cache_stampede.pdf
- Go’s
singleflightpackage — the canonical request-coalescing implementation. https://pkg.go.dev/golang.org/x/sync/singleflight
Sibling repositories
llm-serving-inference-guide— this guide’s own autoscaling chapters for what happens when capacity is the real constraint, and its monitoring chapters for the queue-depth and cache-hit signals this topic assumes you can see.learn-production-agent— the agent-layer view of the same economics: budgets charged before spending, model routing, and cost per successful task rather than per call. That book governs an agent you control; this topic enforces limits against a caller you do not.