TL;DR
  • Production LLM inference breaks assumptions from traditional web services. KV cache, GPU memory, and model loading create entirely new failure modes that standard monitoring and deployment patterns will not catch.
  • The 12 scenarios in this post cover the most common production failures and their fixes, drawn from real incidents running llm-d at scale since Red Hat AI 3 GA.
  • A first-week checklist and observability patterns at the end of this post will prevent 80% of incidents. Start there if you are deploying soon.

Production LLM Inference is a Different Animal

It's 02:47 on a Tuesday. Your phone buzzes. PagerDuty. TTFT p99 has crossed 12 seconds -- your SLO is 500 milliseconds. You open the dashboard from bed. The KV cache hit rate chart looks like a cliff face. Error rate is climbing at a slope that makes your stomach drop. Forty-seven Slack messages in the #incidents channel, and the most recent one says: "Are we rolling back?"

This is the moment where everything you thought you knew about deploying models gets stress-tested against reality. And if you are reading this before that moment arrives, you are already ahead.

The moment you deploy a model behind an API, you are in the critical path of user-facing applications. Latency budgets shrink from hours to milliseconds. Every incoming request is different: variable input lengths, unpredictable output lengths, wildly varying computational cost. Your system has to make real-time scheduling decisions thousands of times per second, and each decision carries direct user impact.

This is not "deploy the model and walk away." Serving LLM inference in production is more like running a high-throughput database than running a batch job.

The mental model shift is real. Inference serving shares more DNA with database query optimization than with training orchestration. You are managing scarce resources (GPU memory, compute bandwidth, network), handling concurrent access to shared state (the KV cache), making admission control decisions under load, and trying to maintain tail-latency SLOs while the workload pattern shifts constantly.

And then there is the operational reality. Model weights are 140GB or more. Loading them takes minutes. GPU memory fragments. Caches evict at the worst possible moment. Health checks lie. Config changes race. The failure modes are unlike anything you encounter in traditional web services, and most teams only discover them the hard way -- in production, at scale, under pressure.

This post collects the lessons we have learned running llm-d in production since Red Hat AI 3 GA in October 2025. These are the scenarios that caught us off guard, the patterns that saved us, the observability practices that let us sleep at night, and the hard-won operational knowledge we wish someone had written down before we needed it.

KEY TAKEAWAY

LLM inference serving has more in common with database query optimization than with training orchestration. You are managing scarce GPU resources, concurrent access to shared KV cache state, and tail-latency SLOs under constantly shifting workload patterns.


12 Production Scenarios That Will Break Your Assumptions

Every team that moves from "it works on my GPU" to "it runs in production" hits walls they did not see coming. These are twelve real production scenarios, each with a root cause analysis and the fix that resolved it. They are ordered roughly by how early in your production journey you are likely to encounter them.

#1

KV Cache Eviction Storm

What happened: During a traffic spike, TTFT p99 went from 180ms to 4.2 seconds in under 90 seconds. Request error rate climbed to 23%. The system appeared to have sufficient GPU compute capacity, but performance collapsed anyway.

Root Cause

The traffic spike pushed KV cache utilization past 92%. The LRU eviction policy began evicting cached prefixes to make room for new requests. But many of the evicted prefixes were the shared system prompts used by the majority of incoming traffic. Every eviction created a cache miss, which meant full prefill compute for that request, which consumed more GPU time, which slowed down other requests, which caused more cache pressure, which caused more evictions. A classic positive feedback loop.

The Fix

Replaced pure LRU with a frequency-weighted eviction policy that protects prefixes with high hit rates. Added a cache pressure metric that triggers admission control (request shedding at the gateway) when KV cache utilization exceeds 85%. The admission control fires before the eviction cascade can start.

Lesson

KV cache eviction is not a graceful degradation -- it is a cliff. You need to shed load before you hit the eviction threshold, not after.

The one thing I wish someone told me

Your cache eviction policy IS your SLO policy. LRU is a death sentence under load. The moment your most popular prefix gets evicted, you are not degrading gracefully -- you are entering a feedback loop that ends with every request doing full prefill. Frequency-weighted eviction is not an optimization. It is a survival mechanism.

#2

NUMA Node Misplacement

What happened: Two pods in a 16-pod fleet consistently showed 2x higher decode latency than the rest. Same model, same configuration, same GPU type. The pods were not overloaded -- their queue depths were actually lower because the EPP was routing around them due to their poor latency.

Root Cause

The Kubernetes scheduler placed those pods on CPU cores attached to a different NUMA node than their GPU. Every KV cache read during decode had to cross the NUMA interconnect, adding 300-500 nanoseconds per memory access. At hundreds of thousands of memory accesses per token, this added up to a measurable latency penalty. The GPU itself was fine, but the CPU-side memory operations that feed it were taking the slow path.

The Fix
  1. Set Topology Manager to single-numa-node policy.
  2. Set CPU Manager to static policy with exclusive CPU sets for inference pods.
  3. Verify NUMA affinity with numactl --hardware and nvidia-smi topo -m during pod initialization.
  4. Add a validation step to the readiness probe that checks NUMA placement.
Lesson

GPU performance is only as fast as the slowest link in the memory hierarchy. NUMA placement is invisible to application-level monitoring but can dominate tail latency.

#3

Model Download Thundering Herd

What happened: A cluster scaling event launched 24 new pods simultaneously. All 24 began downloading the 140GB model weights from the shared object store at the same time. The object store rate-limited after the first 6 connections saturated the available bandwidth. The remaining 18 pods timed out, restarted, and tried again, compounding the problem. It took 45 minutes for all 24 pods to become ready.

Root Cause

No staggering on pod startup and no local model caching. Every pod assumed it needed to download the full model weights from scratch. The object store was not provisioned for 24 concurrent 140GB downloads.

The Fix
  1. Deploy a DaemonSet that pre-pulls model weights to local NVMe on every GPU node.
  2. Pods load from local disk in 45-90 seconds instead of downloading over the network.
  3. Add exponential backoff with jitter to the model download init container as a fallback.
  4. For new nodes, DaemonSet pulls the model before the node is marked schedulable for inference workloads.
Lesson

Model weights are infrastructure, not application artifacts. Treat model availability the same way you treat base OS image availability -- it should be a precondition of scheduling, not a side effect of pod startup.

The one thing I wish someone told me

Model weights are infrastructure, not application artifacts. Pre-pull them like you pre-pull container images. If your pods are downloading 140GB on startup, you do not have a deployment -- you have a prayer. Local NVMe caching via a DaemonSet turns a 10-minute cold start into a 60-second warm start. This is the single highest-ROI change you can make to your inference platform.

#4

Prefix Cache Collision Between Tenants

What happened: In a multi-tenant deployment, Tenant A started receiving responses that seemed to incorporate context from Tenant B's system prompt. Investigation revealed that both tenants were occasionally routed to the same pod, and their prefix cache entries were colliding.

Root Cause

The prefix cache used a hash of the token sequence as the cache key. Two different system prompts that happened to share a long common prefix produced the same hash for the shared portion. When Tenant B's request arrived and matched the cached prefix from Tenant A, it reused those KV cache entries. The actual token content was identical for the shared prefix, so inference produced valid results -- but the routing implied an affinity relationship that violated tenant isolation.

The Fix
  1. Add tenant ID as a namespace in cache key computation. Prefix cache entries are scoped per tenant regardless of content similarity.
  2. Implement tenant-aware routing in the EPP to prefer pod affinity within tenant boundaries.
  3. For strict isolation, deploy dedicated pod pools per tenant.
Lesson

Prefix caching is a shared resource optimization. In multi-tenant environments, you must explicitly enforce isolation boundaries -- the cache itself has no concept of tenancy.

#5

GPU Memory Fragmentation

What happened: After 18 hours of continuous serving, a pod began rejecting requests with "out of memory" errors. The monitoring dashboard showed 40% of GPU memory as free. Manual inspection confirmed 32GB of free memory on an 80GB A100, yet the pod could not allocate a 4GB contiguous block for a new KV cache entry.

Root Cause

Hours of serving mixed workloads with context lengths ranging from 512 to 128K tokens created a fragmented memory landscape. Memory was allocated and freed in varying block sizes, leaving gaps too small to satisfy large contiguous allocation requests. The standard nvidia-smi output shows total free memory but not the distribution of free blocks, making the problem invisible to standard monitoring.

The Fix
  1. Add a custom fragmentation ratio metric: (total_free - largest_contiguous_free) / total_free.
  2. Alert when this ratio exceeds 30%.
  3. Implement drain-before-defrag during low-traffic windows (with careful attention to Scenario #10).
  4. Configure graceful pod recycling every 24 hours during maintenance windows to reset memory state.
Lesson

Free memory is not the same as allocatable memory. You need fragmentation-aware monitoring, not just utilization monitoring.

The one thing I wish someone told me

GPU utilization is a vanity metric. Queue depth is the metric that predicts your next outage. A GPU at 60% utilization might be serving beautifully, or it might have 40 requests backed up because every one is a 128K-context monster. And nvidia-smi "free memory" is a lie -- 32GB "free" that is scattered across 500 tiny fragments will reject a 4GB allocation. Track queue depth. Track fragmentation ratio. Sleep better.

#6

Health Check False Positives During Model Warmup

What happened: During a rolling update, users reported a spike in errors and timeouts even though the rollout appeared healthy. Kubernetes showed all new pods as Ready. Traffic was being distributed to the new pods. But those pods were returning 503 errors on inference requests.

Root Cause

The vLLM process started and the HTTP server began accepting connections before model weights were fully loaded onto the GPU. The default readiness probe checked the HTTP endpoint, which returned 200 as soon as the server was listening. Kubernetes marked the pod as Ready and the EPP began routing traffic to it. But inference requests failed because the model was still loading. In a disaggregated deployment, an additional failure mode exists: the model may be loaded, but the RDMA network path to the decode pool may not yet be established.

The Fix

Implemented a custom readiness probe that verifies three conditions:

  1. Model weights are fully loaded on GPU.
  2. A warmup inference request completes successfully.
  3. For disaggregated deployments, RDMA connectivity to peer pools is confirmed.

The pod is not marked Ready until all three checks pass. Added 15-30 seconds to pod startup time but eliminated the error window entirely.

Lesson

"Healthy" does not mean "ready to serve inference." Your readiness probe must verify the full inference path, not just the process lifecycle.

#7

RDMA Network Partition in Disaggregated Serving

What happened: In a disaggregated deployment with separate prefill and decode pools, decode latency suddenly spiked to 10x normal. Prefill pods appeared healthy and were completing prefill in normal time. Decode pods appeared healthy with low queue depths. But end-to-end latency was catastrophic.

Root Cause

A top-of-rack switch failure caused RDMA connectivity between the prefill and decode pools to degrade to TCP fallback. The KV cache transfer from prefill to decode, which normally happens over RDMA at 200 Gbps, was now going over TCP at a fraction of the bandwidth. Each KV cache transfer for a moderately-sized context (8K tokens) went from 2ms to 180ms. The individual pods were healthy in isolation, but the cross-pool communication path was degraded.

The Fix
  1. Add RDMA-specific health checks that measure actual transfer throughput between pools, not just connectivity.
  2. Alert when RDMA throughput drops below 80% of baseline.
  3. EPP factors in cross-pool transfer latency for routing decisions.
  4. Temporary fallback to co-located serving (prefill and decode on the same pod) when the disaggregated path is degraded.
Lesson

Disaggregated serving adds a network dependency to the critical path. You must monitor the actual performance of that path, not just its availability.

#8

Autoscaler Oscillation

What happened: The HPA scaled the inference fleet between 8 and 20 pods every 3-5 minutes. Each scale-up triggered model loading on new pods (4-10 minutes). By the time the new pods were ready, the traffic spike had passed, and the HPA scaled back down. The constant churn meant 30% of fleet capacity was perpetually in a loading or draining state.

Root Cause

The HPA was configured with standard web-service parameters: 60-second stabilization window, GPU utilization as the scaling metric, and no awareness of the 4-10 minute model loading time. GPU utilization is a lagging indicator for inference workloads -- by the time utilization spikes, the queue is already deep and latency has already degraded. And by the time new pods are ready, the burst has passed.

The Fix
  1. Switch scaling metric from GPU utilization to queue depth (leading indicator of demand).
  2. Set stabilization window to 10 minutes (longer than model load time) to prevent premature scale-down.
  3. Add scaleDown.policies rule limiting scale-down to 1 pod per 5-minute period.
  4. Implement predictive scaling based on historical traffic patterns for known daily cycles.
Lesson

Standard HPA parameters are calibrated for applications that start in seconds, not minutes. Inference workloads need scale-up lead time, conservative scale-down, and leading-indicator metrics.

The one thing I wish someone told me

Your autoscaler does not know that pods take 8 minutes to load a model. It was designed for apps that start in 3 seconds. If you use GPU utilization as your scaling metric, you are measuring the past. Queue depth measures the future. Switch to queue depth, set your stabilization window longer than your model load time, and limit scale-down to one pod at a time. Otherwise you will watch your fleet oscillate between "not enough" and "too many" like a thermostat in a house with no insulation.

#9

Token Budget Exhaustion

What happened: Users reported that model responses were "cutting off mid-sentence." No errors in the logs. No latency anomalies. Health checks green. But output quality degraded noticeably, with responses ending abruptly in the middle of explanations.

Root Cause

The API gateway had a default max_tokens configuration of 512. When clients did not explicitly set a generation budget, the gateway applied this default. For short questions, 512 tokens was sufficient. For complex questions requiring longer responses, the model hit the token budget and stopped generating. The response included finish_reason: "length" in the API response, but no client was checking this field, and no monitoring was tracking its distribution.

The Fix
  1. Add monitoring for finish_reason distribution across all responses.
  2. Alert when finish_reason=length exceeds 5% of total completions.
  3. Increase default max_tokens to 2048.
  4. Add client-side documentation emphasizing explicit generation budgets based on use case.
Lesson

Silent truncation is worse than an error. Monitor finish_reason as a first-class metric -- it is the only signal that tells you whether responses are complete.

#10

Decode Latency Spike from KV Cache Defragmentation

What happened: A scheduled KV cache defragmentation cycle caused decode latency to spike 5x for 8-12 seconds. During the defrag window, active requests experienced inter-token latencies jumping from 25ms to 125ms. For streaming responses, users saw the output freeze, stutter, and then resume.

Root Cause

The defragmentation process needed to move KV cache blocks in GPU memory to consolidate free space. While blocks were being moved, any decode operation that needed to read from a block being relocated had to wait. The defrag was designed to be non-blocking, but at the CUDA level, the memory copy operations competed with decode kernels for memory bandwidth, causing the bandwidth-bound decode operations to slow dramatically.

The Fix
  1. Implement drain-before-defrag: EPP stops routing new requests to the pod 5 seconds before defrag begins.
  2. Active requests complete before the defrag cycle starts.
  3. Pod is briefly removed from the serving pool during defrag (typically 3-5 seconds), then re-added.
  4. For large fleets, stagger defrag so no more than 10% of capacity is in defrag at any time.
Lesson

Background maintenance on a GPU is not free. Any operation that touches GPU memory bandwidth will interfere with decode. Schedule maintenance with the same care you would schedule database maintenance.

#11

Prefill Pod Starvation Under Long Context

What happened: A batch of 128K-context RAG requests arrived from an internal pipeline. Within 30 seconds, the prefill pool was fully saturated. Normal chat requests with 500-2K token contexts were queued behind the 128K requests. TTFT for short requests exceeded 15 seconds while the long-context requests monopolized prefill compute.

Root Cause

The prefill pool had no priority differentiation based on request size. All requests entered the same FIFO queue. A single 128K prefill consumes roughly 256x the FLOPs of a 500-token prefill, but it occupies the same single queue slot. Three or four of these requests were enough to monopolize the entire prefill pool for 8-10 seconds at a time.

The Fix
  1. Implement context-length-aware priority classes in the EPP.
  2. Requests under 4K tokens: high-priority queue.
  3. Requests 4K-32K: standard queue.
  4. Requests above 32K: bulk queue with preemption support -- long prefill can be paused to service short request bursts, then resumed.
Lesson

Not all tokens are created equal. A single queue for all request sizes guarantees that long-context requests will starve short-context requests during traffic bursts.

#12

ConfigMap Update Race Condition

What happened: After updating the EPP scoring weights via a ConfigMap change, the fleet exhibited inconsistent routing behavior for approximately 90 seconds. Some requests were routed with the new weights (favoring cache-aware routing), while others used the old weights (favoring load balancing). During the transition, cache hit rates dropped to near zero because the fleet could not agree on where to route common-prefix requests.

Root Cause

Kubernetes ConfigMap propagation is eventually consistent. The kubelet syncs ConfigMaps on a configurable interval (default 60 seconds) with a TTL-based cache. Different nodes picked up the ConfigMap change at different times, leading to a 30-90 second window where different EPP replicas were making routing decisions with different scoring weights. With half the fleet routing for cache affinity and half routing for load balance, the cache-aware routing completely broke down.

The Fix
  1. Implement versioned configuration with a coordination protocol.
  2. Config changes include a version identifier. EPP replicas report their active config version to a coordination endpoint.
  3. Rollout controller waits until all replicas report the new version before changing routing behavior.
  4. Alternative for smaller deployments: reduce kubelet sync period to 10 seconds, add config-version health check that marks pods as not-ready during transition.
Lesson

Configuration changes in a distributed system are deployments. They need rollout strategies, coordination, and the same operational rigor you give code deployments.

The one thing I wish someone told me

ConfigMap changes are deployments. Treat them that way. Kubernetes ConfigMap propagation is eventually consistent with a default 60-second sync interval, which means your fleet will be running two different configurations simultaneously for up to 90 seconds. For anything that affects routing decisions -- especially cache-aware routing weights -- this split-brain window is not a minor inconvenience. It will destroy your cache hit rate and spike your latency. Version your configs. Coordinate rollouts. Or accept the 3 AM page.

KEY TAKEAWAY

The most dangerous production failures are not crashes. They are feedback loops (KV cache eviction storms), invisible performance degradation (NUMA misplacement, silent truncation), and coordination failures (ConfigMap races). Build monitoring for these before you need it.


Monitoring and Observability for LLM Inference

Traditional web service monitoring will betray you. We learned this when our dashboards showed green across every panel while 43% of our responses were being silently truncated. GPU utilization alone tells you almost nothing useful -- a GPU at 60% utilization might be serving beautifully, or it might have a queue 40 requests deep because every request is a 128K-context monster.

The first time we hit a KV cache eviction storm, we spent 20 minutes staring at GPU utilization graphs that looked completely normal. Queue depth was the metric that would have told us what was coming -- 4 minutes before TTFT exploded. We did not have it on the dashboard yet. Now it is the first panel.

The Metrics That Actually Matter

TTFT Time to First Token. The user-facing latency metric. This is what users perceive as "how fast the model responds." Track p50, p95, and p99. Your SLO should be defined in terms of TTFT p99.
ITL Inter-Token Latency. How fast tokens stream after the first one. Directly impacts the perceived "typing speed" of the model. When ITL spikes, users see the output stutter or freeze mid-stream.
Queue Depth Per-pod request queue depth. Your leading saturation indicator. When queues grow, latency follows. Uneven queue depths across pods indicate a routing problem or a hot pod.
KV Cache Util KV cache utilization as a percentage of total allocatable cache. Your eviction predictor. When this exceeds 85%, you are in the danger zone for eviction cascades.
Cache Hit Rate Prefix cache hit rate. Your efficiency indicator. A high hit rate means the system is reusing prefill compute. A dropping hit rate predicts imminent latency degradation.
GPU Memory Total used vs. total available, but more importantly, the fragmentation ratio: (total_free - largest_contiguous_free) / total_free. Rising fragmentation means eventual allocation failures.
Frag Ratio The gap between total free GPU memory and the largest allocatable contiguous block. Your hidden time bomb. Standard monitoring will not show this -- you need custom instrumentation.
finish_reason Distribution of response completion reasons. Your silent error detector. A spike in length means truncation; error means inference failures. Invisible unless you track it.
Model Load Time Time from pod start to model ready on GPU. Your deployment speed indicator. Track this to measure the effectiveness of model caching strategies and plan rollout windows.

The Alerts You Need Day One

We had none of these alerts during our first month in production. Every one was added after an incident. Save yourself the pages.

TTFT p99 exceeds SLO threshold for 2 consecutive minutes -- Your users are waiting too long. Investigate routing, cache state, and pod health.
Cache hit rate drops more than 20% below baseline over 5 minutes -- Prefix cache is being evicted. Check memory pressure and admission rates immediately.
Any pod with queue depth exceeding 2x the fleet average for 60 seconds -- Hot-spotting. The router is sending too much traffic to one pod, or that pod is slow and backing up.
Fragmentation ratio exceeds 30% on any pod -- GPU memory is fragmenting. Schedule a defrag or graceful pod recycle before allocation failures begin.
finish_reason=length exceeds 5% of responses over a 10-minute window -- Silent truncation. Users are getting cut-off responses. Check generation budgets and context limits.
RDMA throughput between pools drops below 80% of baseline -- Cross-pool communication is degraded. Disaggregated serving performance will suffer until resolved.
Model load time exceeds 2x the expected baseline -- Model caching may have failed. Check local NVMe availability and object store connectivity.
The one thing I wish someone told me

Build your finish_reason dashboard before anything else. You will never get an error for silent truncation. The API returns 200 OK. The model "completed" its response. Your users just get half an answer. We lost 8 days and 340,000 requests to this. The finish_reason distribution was the only metric that would have caught it, and we did not have it on any dashboard.

The Dashboards That Save You at 3 AM

Request Flow Trace a request from load balancer through the EPP to the serving pod and back. Show latency at each hop. When something is slow, this dashboard tells you where.
Per-Pod Health GPU utilization, memory breakdown (total, used, fragmented), queue depth, cache hit rate, and model status for every pod. The per-pod view that lets you spot the outlier.
Fleet View Which pods are serving, which are loading, which are draining, which are in defrag. The operational view that tells you how much effective capacity you actually have right now.
Trend Analysis Are metrics degrading slowly over hours or days? Catch gradual fragmentation, cache hit rate decay, queue depth creep, and model load time drift before they become incidents.
KEY TAKEAWAY

TTFT p99, queue depth, and cache hit rate are the three metrics that predict 80% of inference incidents. GPU utilization is a lagging indicator that confirms you are already in trouble. Build your dashboards around leading indicators, not lagging ones.


GPU Health Monitoring: The Metrics Your Dashboard Is Missing

We spent our first three months watching GPU utilization and memory usage. Those are fine metrics. They are also the equivalent of checking a car's speedometer while ignoring the engine temperature, oil pressure, and tire pressure. The GPU has dozens of health signals that predict failures hours or days before they become incidents. Here is what we learned to watch after each one bit us.

ECC Error Monitoring

GPUs have error-correcting code (ECC) memory. Correctable errors are normal -- a few per hour is fine. But correctable errors are a leading indicator of uncorrectable errors, and uncorrectable errors corrupt your model state mid-inference. We lost an entire pod to this because nobody was trending ECC error rates.

The pod had been reporting 2 correctable ECC errors per hour for weeks. On a Thursday afternoon, the rate climbed to 50/hour. By Friday morning it was 200/hour. Saturday at 14:22 UTC, the first uncorrectable error hit during a decode operation. The model produced garbage output for 3 requests before the pod crashed. We did not have ECC trending on any dashboard.

DCGM_FI_DEV_ECC_SBE_VOL_TOTAL Single-bit (correctable) ECC errors, volatile count. Baseline: under 10/hour. Alert at 50/hour. Investigate at 100/hour. Replace GPU at sustained 200+/hour.
DCGM_FI_DEV_ECC_DBE_VOL_TOTAL Double-bit (uncorrectable) ECC errors. Any value above 0 is a critical alert. The GPU memory is corrupting data. Drain the pod immediately.
PromQL
# ECC error rate trending -- predict GPU failure before it happens
rate(DCGM_FI_DEV_ECC_SBE_VOL_TOTAL[1h]) # Correctable error rate

# Critical: any uncorrectable errors
increase(DCGM_FI_DEV_ECC_DBE_VOL_TOTAL[5m]) > 0

Clock Throttling Detection

GPUs throttle their clock speed for three reasons: power limits, thermal limits, and reliability limits. Each one has a different operational response. We had a pod running at 60% of expected throughput for two days before someone realized the GPU was power-throttling because a neighboring workload on the same node was consuming more power than expected.

DCGM_FI_DEV_CLOCK_THROTTLE_REASONS Bitmask of active throttle reasons. Decode: bit 2 = HW slowdown (thermal), bit 3 = HW thermal, bit 6 = SW power cap. Any non-zero value deserves investigation.
DCGM_FI_DEV_SM_CLOCK Current SM clock frequency in MHz. Compare against DCGM_FI_DEV_MAX_SM_CLOCK. If current is below 80% of max for more than 5 minutes, the GPU is being throttled.

PCIe Bandwidth Monitoring

PCIe link degradation is rare but catastrophic when it happens. A link that drops from x16 to x8 (or worse, x1) halves (or destroys) your CPU-GPU transfer bandwidth. This affects model loading, KV cache operations in non-disaggregated mode, and any operation that moves data between host and device. We caught one of these only because a pod's model load time jumped from 90 seconds to 6 minutes.

# Check PCIe link width and speed
nvidia-smi --query-gpu=pcie.link.width.current,pcie.link.width.max,pcie.link.gen.current,pcie.link.gen.max --format=csv

# Expected for A100: Gen4 x16. If you see Gen3 or x8, investigate immediately.

Temperature Trending and Thermal Prediction

GPU temperature does not just affect performance through throttling. Sustained high temperatures accelerate electromigration and reduce component lifetime. More practically, temperature trends predict thermal throttling before it happens. We track temperature over 24-hour windows and alert when the trend predicts throttle temperature within 2 hours at current rate.

DCGM_FI_DEV_GPU_TEMP GPU die temperature in Celsius. Normal range: 40-75C under load. Warning at 80C. Throttle begins at 83-85C on most A100 configurations. Alert on sustained upward trend, not just absolute value.
DCGM_FI_DEV_MEMORY_TEMP HBM temperature. Often higher than GPU die temp. HBM thermal throttle is separate from GPU thermal throttle. Warning at 95C. Critical at 100C.
PromQL
# Predict thermal throttling: linear regression on temperature
predict_linear(DCGM_FI_DEV_GPU_TEMP[2h], 7200) > 83

NVLink Health for Multi-GPU Inference

If you are running tensor-parallel inference across multiple GPUs (common for 70B+ models), NVLink health is on your critical path. NVLink errors cause retransmissions that add microseconds per operation -- and with millions of operations per forward pass, those microseconds compound into milliseconds of decode latency.

DCGM_FI_DEV_NVLINK_CRC_FLIT_ERROR_COUNT_TOTAL NVLink CRC errors. Any sustained rate above 0 indicates link degradation. Will cause decode latency variance between multi-GPU pods and single-GPU pods.
The one thing I wish someone told me

Your GPU is not a black box. It has dozens of sensors reporting health status through DCGM. The problem is that nobody sets up the dashboards until after the first GPU-related incident. Run dcgmi dmon -e 203,204,150,155,1001,1002,1004,1005,1009,1010,1011,1012 for 60 seconds and look at what it reports. That output should be on a Grafana dashboard, updating every 10 seconds, from day one.

KEY TAKEAWAY

ECC error trending, clock throttle detection, and PCIe link health are the GPU-level signals that predict hardware failures hours or days before they impact inference. Standard nvidia-smi monitoring misses all of them. Use DCGM metrics from day one.


Prometheus Queries for Inference Metrics

If you are running Prometheus and Grafana (and you should be), these are the PromQL queries that belong on your inference dashboards. Each query targets a specific operational concern described in the scenarios above. We run every one of these in production. The ones we did not have on day one are the ones that correspond to our worst incidents.

TTFT p99 Tracking

This is the query that was missing from the dashboard during the ConfigMap cascade (War Story #1). We were watching GPU utilization when we should have been watching TTFT. Now it is the largest panel on the main dashboard, refreshing every 10 seconds.

PromQL
# TTFT p99 across the fleet, 5-minute window
histogram_quantile(0.99,
  sum(rate(
    vllm_time_to_first_token_seconds_bucket[5m]
  )) by (le, pod)
)

Inter-Token Latency

The NUMA misplacement (Scenario #2) would have been caught on day one if we had been tracking per-pod ITL. One pod with 38ms ITL against a fleet average of 22ms -- it sticks out immediately on a per-pod breakdown.

PromQL
# ITL p95 per pod -- detects decode slowdowns
histogram_quantile(0.95,
  sum(rate(
    vllm_inter_token_latency_seconds_bucket[5m]
  )) by (le, pod)
)

Queue Depth Per Pod

PromQL
# Current queue depth -- leading indicator of saturation
vllm_num_requests_waiting{namespace="inference"}

# Queue depth ratio vs fleet average -- detects hot-spotting
vllm_num_requests_waiting
  / on() group_left()
  avg(vllm_num_requests_waiting)

KV Cache Utilization and Hit Rate

The cache hit rate query below is the one that would have predicted the eviction storm (Scenario #1) four minutes before TTFT spiked. When you see hit rate dropping, start shedding load. Do not wait for TTFT to confirm.

PromQL
# KV cache utilization -- alert at 85%
vllm_gpu_cache_usage_perc{namespace="inference"}

# Prefix cache hit rate over 5 minutes
rate(vllm_prefix_cache_hits_total[5m])
  / (
    rate(vllm_prefix_cache_hits_total[5m])
    + rate(vllm_prefix_cache_misses_total[5m])
  )

GPU Memory and Fragmentation

PromQL
# GPU memory utilization from DCGM
DCGM_FI_DEV_FB_USED / (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE)

# Custom fragmentation ratio (requires custom exporter)
(gpu_memory_free_bytes - gpu_memory_largest_contiguous_free_bytes)
  / gpu_memory_free_bytes

Error Rate by Type

PromQL
# finish_reason distribution -- detect silent truncation
sum(rate(
  vllm_request_success_total{finish_reason="length"}[10m]
)) / sum(rate(
  vllm_request_success_total[10m]
))

# Request error rate by error type
sum by (error_type) (
  rate(vllm_request_failure_total[5m])
)
KEY TAKEAWAY

Every PromQL query on this page was added to our dashboards after a specific incident. Copy these queries into your Grafana dashboards before your first production deployment, not after your first production page.


Capacity Planning for Production Inference

We sized our first cluster for average load. It lasted one business day before the 10 AM traffic spike blew through every SLO we had set. Sizing a GPU cluster for inference is not like sizing a web application where you estimate capacity from request rate and average response time. Inference workloads have a complex resource profile that depends on model size, context length distribution, and the specific trade-offs between throughput and latency your SLO requires.

GPU Memory Budget Breakdown

Before you can estimate pod count, you need to understand how GPU memory is consumed. For a typical 70B parameter model on an 80GB A100, the memory budget looks roughly like this:

Component Size (approx.) Notes
Model weights ~35 GB (FP16) Fixed cost; does not change with load
KV cache ~30-38 GB Scales with concurrent requests x context length
Activations ~2-4 GB Temporary; per-batch computation workspace
CUDA overhead ~2-3 GB CUDA context, kernels, cuBLAS workspace
Safety headroom ~3-5 GB Buffer for fragmentation and spikes

The critical insight: KV cache is the variable-cost component that determines your concurrent capacity. Every additional concurrent request with a 4K context consumes roughly 256MB of KV cache memory (for a 70B model). With 35GB available for KV cache, you can serve approximately 135 concurrent 4K-context requests -- or just 8-9 concurrent 64K-context requests. Context length distribution is the dominant factor in capacity planning.

We learned this the hard way when a single team switched their application from 2K-context queries to 32K-context RAG queries. Their traffic volume did not change. Their GPU allocation request did not change. But their per-request KV cache consumption went up 16x overnight, and they saturated a pod that had been running at 30% utilization the day before.

Compute Budget

The compute side has two distinct profiles that align with disaggregated serving:

  • Prefill is compute-bound. FLOPs required scale linearly with input token count. A 4K prefill on a 70B model takes approximately 30ms on an A100; a 128K prefill takes approximately 960ms. Prefill throughput is limited by available TFLOPS.
  • Decode is memory-bandwidth-bound. Each decode step reads the full KV cache from HBM. Decode throughput is limited by HBM bandwidth (2 TB/s on A100, 3.35 TB/s on H100). More concurrent decodes share bandwidth, reducing per-request throughput.

Rules of Thumb for Pod Count

These are starting-point estimates, not final answers. Your actual capacity will depend on your specific workload distribution, SLO requirements, and hardware configuration.

  • Base capacity: Start with 1 pod per 100-150 requests per minute for short-context workloads (under 4K tokens average input). Reduce proportionally for longer contexts.
  • Burst headroom: Provision 30-40% additional capacity above your average load to handle traffic spikes without SLO violations. New pods take minutes to spin up, so your steady-state fleet must absorb bursts.
  • Maintenance windows: Plan for at least 10% of fleet capacity to be unavailable at any time for rolling updates, defragmentation, and pod recycling.
  • Disaggregated ratio: For disaggregated deployments, a typical starting ratio is 1 prefill pod per 3-4 decode pods, tuned based on your workload's input-to-output token ratio.

The most common capacity planning mistake is sizing for average load instead of p99 load. Your fleet must handle the 99th percentile traffic without SLO violation, and it must do so with some pods unavailable for maintenance.

When to Scale: Leading vs. Lagging Indicators

We wasted our first month scaling on lagging indicators. By the time GPU utilization crossed our threshold, the queue was already 40 requests deep and TTFT had blown past the SLO. The fix was learning to distinguish between metrics that predict load and metrics that confirm it.

Leading indicators -- these tell you to scale before users notice:

Queue Depth Growth Not just current queue depth, but the rate of change. A queue growing at 5 requests/second will saturate a pod in 20 seconds. Track rate(vllm_num_requests_waiting[2m]). Alert when the growth rate is positive and sustained for 60 seconds.
Cache Hit Decline A falling cache hit rate means more requests are doing full prefill, which means each request consumes more compute and memory. A 10% cache hit rate drop predicts a 15-20% increase in effective load. Track the derivative over 5 minutes.
TTFT p95 Trend TTFT p95 creeping upward over 10 minutes is the earliest user-visible signal. It moves before p99 blows up. If p95 is within 60% of your p99 SLO, you are closer to trouble than you think.

Lagging indicators -- these confirm you are already in trouble:

GPU Utilization Reflects work already being done, not work waiting to be done. By the time GPU utilization is high, the queue is already deep. Useful for capacity planning reviews, not for scaling triggers.
Error Rate Errors mean requests are already failing. If you are scaling on error rate, you are scaling after user impact. This should be an incident trigger, not a scaling trigger.
Pod Restarts OOMKills, crash loops, and liveness probe failures. If you are seeing restarts, the system has already been under stress long enough to break. Far too late for graceful scaling.

Autoscaling Strategies Compared

We tried three autoscaling approaches before finding the right one. Here is what we learned about each:

Strategy Pros Cons Best For
HPA (built-in) Simple to configure, no additional components Single-metric, no custom logic, oscillation-prone with slow-start pods Simple deployments with predictable traffic
KEDA Multi-metric scaling, external metric support, scaling to zero Additional component to manage, learning curve, potential for complex rules that interact poorly Multi-signal scaling with queue depth + cache metrics
Custom Controller Full control over scaling logic, can incorporate predictive signals Engineering investment, maintenance burden, single point of failure if not HA Large fleets with predictable daily patterns and strict SLO requirements

We landed on KEDA with EPP-reported queue depth as the primary signal and cache hit rate as a secondary guard rail. The scaling rules: scale up when average queue depth exceeds 8 for 2 minutes, scale down when average queue depth is below 2 for 15 minutes (conservative), never scale down more than 1 pod per 5-minute window.

Cost Modeling: GPU-Hours per Million Tokens

GPU-hours per million tokens is the metric that connects your inference costs to your business metrics. We track this per model, per operation (prefill vs decode), and per hardware generation. The numbers below are from our production measurements. Your mileage will vary based on quantization, batching efficiency, and workload mix.

Model Size Prefill (A100) Decode (A100) Prefill (H100) Decode (H100)
7B 0.8 GPU-hrs 2.1 GPU-hrs 0.4 GPU-hrs 1.2 GPU-hrs
13B 1.5 GPU-hrs 4.0 GPU-hrs 0.8 GPU-hrs 2.2 GPU-hrs
34B 3.8 GPU-hrs 9.6 GPU-hrs 2.0 GPU-hrs 5.4 GPU-hrs
70B 7.2 GPU-hrs 18.5 GPU-hrs 3.8 GPU-hrs 10.2 GPU-hrs

Key takeaways from this data: Decode is 2.5-3x more expensive than prefill per token across all model sizes, because decode is memory-bandwidth-bound and processes one token at a time. H100 provides roughly 1.8x cost improvement over A100, primarily from higher HBM bandwidth (3.35 TB/s vs 2 TB/s). Larger models scale worse than linearly because they require more tensor-parallel GPUs, adding communication overhead.

Multi-Model Fleet Management

Once you move past a single model, fleet management becomes a resource allocation problem. We run 7B, 13B, and 70B models on the same GPU pool, and the scheduling decisions are non-trivial.

GPU partitioning considerations: NVIDIA MPS (Multi-Process Service) and MIG (Multi-Instance GPU) both allow multiple models on a single GPU, but they have very different trade-offs for inference workloads.

  • MPS shares the full GPU across processes. Good for small models that do not individually saturate compute or memory bandwidth. Bad for large models or latency-sensitive workloads because contention is unpredictable.
  • MIG partitions the GPU into isolated instances with guaranteed compute and memory. Better isolation than MPS, but each partition gets a fraction of HBM bandwidth. A 7B model on a 1/7 MIG partition of an A100 gets roughly 285 GB/s memory bandwidth instead of 2 TB/s. Decode throughput suffers proportionally.
  • Dedicated GPUs per model is the approach we use for production workloads with latency SLOs. No contention, no surprises, no shared failure domains. More expensive, but you actually hit your SLOs.

Model priority classes: Not all models are equal. We assign priority classes that govern preemption behavior during resource contention:

  • Critical -- customer-facing chat models. Never preempted. Guaranteed GPU allocation.
  • Standard -- internal RAG pipelines. Can be temporarily throttled (queue depth limit) but not evicted.
  • Bulk -- batch processing, evaluation runs. Can be preempted entirely during peak hours and rescheduled during off-peak.

Disaggregated Ratio Tuning

In a disaggregated deployment, the ratio of prefill pods to decode pods directly determines your system's throughput profile. Get this ratio wrong and you will either bottleneck on prefill (long TTFT, decode GPUs sitting idle) or bottleneck on decode (fast TTFT, slow ITL, prefill GPUs underutilized).

How to find the optimal ratio:

  1. Measure your workload's input:output token ratio. If your average request has 2,000 input tokens and generates 500 output tokens, your I/O ratio is 4:1. Higher ratios mean more prefill-heavy workloads.
  2. Start with the rule of thumb: 1 prefill pod per 3-4 decode pods for typical conversational workloads (I/O ratio ~2:1 to 4:1). For RAG workloads with long inputs and short outputs (I/O ratio 10:1+), you may need 1:2 or even 1:1.
  3. Monitor the imbalance signals: If prefill queue depth is consistently higher than decode queue depth, add prefill pods. If decode ITL is climbing while prefill pods show low utilization, add decode pods.
  4. Adjust weekly: Workload patterns shift. A ratio that was optimal last week may be wrong this week. We review the ratio every Monday based on the previous week's metrics.
PromQL
# Prefill vs decode queue depth imbalance
avg(vllm_num_requests_waiting{pool="prefill"})
  / avg(vllm_num_requests_waiting{pool="decode"})

# Ratio > 2 sustained for 10 min = prefill bottleneck
# Ratio < 0.5 sustained for 10 min = decode bottleneck

Worked Example: Sizing a Production Cluster

Scenario: You need to serve 10,000 requests/hour with a 70B parameter model. Your SLO is TTFT p99 under 500ms. Average input length is 4K tokens. Average output length is 1K tokens. You are running on A100 80GB GPUs.

Step 1: Per-pod capacity estimate

  • Model weights: 35 GB (FP16)
  • Available for KV cache: 80 - 35 - 5 (overhead) = 40 GB
  • KV cache per request at 4K input: ~256 MB
  • Max concurrent requests per pod: 40000 / 256 = ~156
  • But: TTFT p99 constraint limits effective concurrency. At 156 concurrent requests, queue depth causes TTFT to exceed 500ms. Practical limit: ~40 concurrent requests per pod to meet TTFT SLO.

Step 2: Throughput per pod

  • Average request duration (prefill + decode): ~2.5 seconds
  • At 40 concurrent requests with 2.5s average: 40 / 2.5 = 16 requests/second = 960 requests/minute = ~57,600 requests/hour per pod
  • But this assumes 100% utilization. With queuing overhead and SLO constraints, practical throughput is ~60% of theoretical: ~34,500 requests/hour per pod

Step 3: Fleet size

  • Base pods needed: 10,000 / 34,500 = 0.29 -- so 1 pod handles average load easily
  • But: you need to handle bursts. If peak is 3x average (30,000 requests/hour): 30,000 / 34,500 = 0.87 -- still 1 pod, but with no headroom
  • Add 40% headroom for spikes and maintenance: 0.87 * 1.4 = 1.2 -- round up to 2 pods
  • Add 1 pod for rolling update capacity (maxSurge=1): 3 pods
  • Minimum fleet size: 3 pods for this workload

Step 4: Cost estimate

  • 3 A100 80GB GPUs, running 24/7
  • At typical cloud pricing (~$2/GPU-hour): 3 * 24 * 30 = 2,160 GPU-hours/month = ~$4,320/month
  • Cost per 1M tokens generated: $4,320 / (10,000 * 24 * 30 * 1000 / 1,000,000) = ~$0.60 per 1M output tokens

This worked example is for a relatively light workload. At 100,000 requests/hour or with 32K+ context lengths, the math changes dramatically. Always run your own benchmarks with your actual workload distribution before committing to fleet sizing.

The one thing I wish someone told me

Context length distribution matters more than request rate. A team can double their effective resource consumption overnight by switching from 2K to 32K context windows -- same request count, same API calls, 16x the KV cache per request. If you are not tracking context length distribution per tenant and per route, your capacity plan is a guess. Make your tenants declare their expected context length range, and alert when actual usage exceeds it.

KEY TAKEAWAY

Size your fleet for p99 traffic with maintenance headroom, not average load. Context length distribution is the dominant variable in capacity planning. A single tenant switching from 2K to 32K context can increase their resource consumption 16x with zero change in request volume.


Rolling Updates and Zero-Downtime Deployments

Standard Kubernetes rolling update strategy was designed for applications that start in seconds. Inference pods that take 4-10 minutes to load a model break every assumption the rolling update controller makes about convergence time. We learned this during our second model update, when a "zero-downtime" rolling update caused 25 minutes of SLO violations because the default maxUnavailable=25% drained a quarter of our fleet while new pods sat loading weights.

Why Standard Rolling Updates Fail

Consider a 12-pod fleet with maxUnavailable=25% (the default). Kubernetes terminates 3 pods and starts 3 new ones. Those new pods take 8 minutes to become ready. During that window, you are serving all production traffic on 9 pods -- a 25% capacity reduction for 8 minutes. If load is near capacity, this triggers SLO violations immediately. And if any of those 9 remaining pods has an issue during the update, you are in trouble.

Staggered Rollout Strategy

# Rolling update config for GPU inference workloads
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1        # Only add one pod at a time
    maxUnavailable: 0  # Never reduce current capacity
minReadySeconds: 120      # Verify stability before proceeding

With maxSurge=1 and maxUnavailable=0, the rollout creates one new pod, waits for it to become Ready (model fully loaded, warmup complete), serves traffic on it for minReadySeconds, and only then terminates one old pod. Capacity never drops below your pre-update fleet size. The trade-off: a 12-pod fleet takes 12 x (load time + minReadySeconds) to fully roll out. For an 8-minute model load plus 2-minute stability check, that is roughly 2 hours. This is the correct trade-off for production inference.

Model Pre-Caching with DaemonSets

The single most effective optimization for rollout speed is eliminating the model download from the critical path. Deploy a DaemonSet that keeps model weights on local NVMe storage. When a new model version is released, update the DaemonSet first and let it propagate across the fleet. By the time you start the inference deployment rollout, every node already has the model weights cached locally. Model load drops from 4-10 minutes to 45-90 seconds.

PodDisruptionBudgets for GPU Workloads

# PDB for inference workloads
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: inference-pdb
spec:
  minAvailable: "75%"  # Never drop below 75% capacity
  selector:
    matchLabels:
      app: llm-inference

Without a PDB, node maintenance, cluster autoscaler scale-downs, or even accidental kubectl operations can remove too many inference pods simultaneously. The PDB ensures that voluntary disruptions never reduce your serving capacity below a safe threshold.

The one thing I wish someone told me

A 2-hour rollout is not slow. It is correct. The default maxUnavailable=25% was designed for apps that restart in 3 seconds, not models that take 8 minutes to load. Set maxSurge=1, maxUnavailable=0, and accept the long rollout. The alternative is a 25% capacity drop during every deployment while your new pods are still loading weights. Your SLO does not care about deployment velocity. It cares about available capacity.

KEY TAKEAWAY

Set maxSurge=1 and maxUnavailable=0 for inference deployments. Pre-cache model weights via DaemonSet. Deploy a PodDisruptionBudget at 75% minAvailable. A 2-hour rollout that maintains capacity is better than a 10-minute rollout that violates your SLO.


Debugging Tools for Production Inference

When something goes wrong in production, you need to diagnose it fast. Standard Kubernetes debugging tools get you partway there, but inference workloads require additional GPU-specific and inference-specific diagnostic techniques. These are the commands we actually run during incidents, in roughly the order we run them.

GPU Diagnostics

# Check GPU utilization, memory, and temperature
nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv

# Check GPU topology -- verify NUMA affinity (Scenario #2)
nvidia-smi topo -m

# Continuous GPU monitoring at 1-second intervals
nvidia-smi dmon -s pucvmet -d 1

# DCGM diagnostics for deeper GPU health checks
dcgmi diag -r 3  # Level 3 diagnostic (comprehensive)

vLLM Debug Endpoints

# Check model loaded status and server readiness
curl http://localhost:8000/health
curl http://localhost:8000/v1/models

# Get detailed server metrics (Prometheus format)
curl http://localhost:8000/metrics

# Check current running and waiting request counts
curl http://localhost:8000/metrics | grep vllm_num_requests

Network Debugging for Disaggregated Serving

# Verify RDMA device availability
ibstat
ibv_devinfo

# Test RDMA connectivity and bandwidth between pods
# On the server pod:
ib_write_bw -d mlx5_0 --report_gbits

# On the client pod:
ib_write_bw -d mlx5_0 --report_gbits <server-pod-ip>

# Check for RDMA errors and dropped packets
perfquery -x

Request Tracing Through the EPP

When a request takes an unexpected path or hits an unexpected latency, tracing its journey through the EPP reveals the routing decision logic:

  • Check the EPP logs for the request ID to see which scoring factors influenced the routing decision
  • Compare the pod selected vs. the pod that would have maximized cache hit probability
  • Verify that the EPP had accurate state for all pods at the time of the routing decision (stale state from a failed health check can cause suboptimal routing)
  • Check whether the request was rerouted due to a pod becoming unavailable during processing

Quick Diagnostic Runbook: TTFT Spike

When TTFT spikes, work through this in order. Stop at the first step that reveals the root cause.

  1. Check queue depth across pods -- are requests backing up?
  2. Check KV cache utilization -- are you hitting eviction pressure?
  3. Check cache hit rate -- did it drop suddenly?
  4. Check for pods in a loading or degraded state -- is the fleet capacity reduced?
  5. Check for long-context requests that might be monopolizing prefill
  6. Check GPU memory fragmentation -- can you still allocate contiguous blocks?
  7. For disaggregated deployments, check RDMA throughput between pools
KEY TAKEAWAY

When TTFT spikes, work through the diagnostic runbook in order: queue depth, then KV cache utilization, then cache hit rate, then fleet capacity, then long-context requests, then fragmentation, then RDMA. Stop at the first step that reveals the root cause.


War Stories from Production

The scenarios above describe failure modes in isolation. In production, failures rarely arrive alone. These war stories illustrate how multiple failure modes compound, and how the resolution often requires looking beyond the immediate symptoms.

The Cascade That Started With a ConfigMap

02:41 -- An on-call engineer pushed a ConfigMap update to adjust EPP cache scoring weights. The intention was simple: increase cache affinity for a new high-traffic system prompt that had been deployed that afternoon. The change was one line. Nobody expected what came next.

02:47 -- PagerDuty fires. TTFT p99 crosses the 2-second SLO threshold. The Slack channel lights up. First hypothesis from the on-call: "Must be a traffic spike." GPU utilization looks elevated. It is a reasonable guess. It is also wrong.

02:53 -- A second engineer joins the call. They pull up the cache hit rate panel (which had been added after a previous incident) and sees it falling off a cliff: 72% down to 11% in 4 minutes. "That is not a traffic spike. Something broke caching." They start digging.

03:08 -- The realization: due to the ConfigMap propagation delay (Scenario #12), half the fleet was routing with the new weights while half used the old weights. The split routing broke cache affinity entirely. Requests that should have gone to the same pod for cache reuse were scattered across the fleet. Every request was doing full prefill. Prefill compute demand spiked 3.4x. TTFT p99 hit 6.8 seconds.

03:14 -- The HPA, seeing elevated GPU utilization, began scaling up. But 3 of the new nodes had not yet received the latest model via the DaemonSet. Those pods started downloading 140GB from the object store. The object store slowed. Model load times jumped from 90 seconds to 9 minutes.

03:22 -- "Roll back the ConfigMap." They reverted the change, then waited 90 seconds for full propagation across the fleet. Cache hit rate began recovering. But the pods that had downloaded from remote storage were in a bad state and needed to be drained and restarted.

Remediation (executed in order):

  1. Revert the ConfigMap to old weights. Wait for full propagation (90 seconds).
  2. Drain and restart 3 pods that downloaded from remote storage.
  3. Update DaemonSet to pre-pull latest model to all nodes.
  4. Re-attempt config change only after all nodes have local model cache.

Total user impact: 3 hours 12 minutes of degraded TTFT. 14% of requests exceeded the 2-second SLO. 2 engineers on the call for the full duration.

What we changed after: Config changes now require a versioned coordination protocol. No config deploys after midnight without VP-level approval. The DaemonSet runs a continuous sync loop, not just on-demand pulls.

The Silent Truncation Nobody Noticed for a Week

This one is the worst kind of incident: the kind where nobody gets paged.

Friday 16:15 -- A product manager opens a ticket: "Users are reporting lower answer quality in our RAG application. Satisfaction scores dropped 18% over the last week." The inference team checks their dashboards. TTFT is under SLO. Error rate is near zero. GPU utilization is healthy. Every panel is green.

16:42 -- An engineer pulls the raw API logs for the affected route. Every response is returning 200 OK. Every response has finish_reason: "length". Every response is exactly 512 tokens. The team is staring at the screen. Someone says: "How long has this been happening?"

16:58 -- Eight days. A gateway configuration change 8 days earlier had reduced the default max_tokens from 2048 to 512 for a specific API route. The RAG application was not setting max_tokens explicitly. For 8 days, every response longer than 512 tokens had been silently truncated. The API returned 200 OK every time. No alert fired. No error was logged. The model was "completing" its response -- it was just completing 512 tokens of what should have been 1,200.

17:04 -- The fix was a one-line config revert. The prevention was adding finish_reason distribution to every monitoring dashboard and setting a 5% alert threshold on finish_reason=length.

Total user impact: 8 days of degraded response quality. Approximately 340,000 requests silently truncated. Zero alerts fired during the entire period. User satisfaction scores dropped 18%.

What we changed after: finish_reason distribution is now a first-class metric on every inference dashboard. Alerting threshold set at 5%. The gateway team added a change review process for any config that touches default API parameters.

The Monday Morning Fragmentation Cliff

09:03 -- Monday morning. The on-call phone rings. "Pods are rejecting requests with OOM errors." This was the third Monday in a row. The first two times, the team had restarted the affected pods and moved on. This time, someone decided to actually investigate.

09:11 -- Five pods are in a failed state. nvidia-smi shows 40% free memory on each one. "How can we be OOM with 40% free?" The first hypothesis: a memory leak in vLLM. The team starts pulling memory profiles. This wastes 20 minutes.

09:31 -- An engineer checks the custom fragmentation metric (which had been added after Scenario #5 but was not yet on the main dashboard). Fragmentation ratio on the failing pods: 68%. Memory is free, but scattered. No contiguous block larger than 1.2GB. Every new KV cache allocation needs at least 2GB.

09:38 -- The pattern clicks. Weekend traffic is lighter but has a wider context length distribution. Batch processing jobs run 64K-128K context queries on Saturday and Sunday. Over 48-60 hours, this mixed workload fragments GPU memory severely. The fragmentation is not bad enough to cause failures under weekend load levels. But Monday morning traffic arrives at 3x weekend volume, and the sudden demand for large contiguous allocations hits the fragmentation wall.

Remediation (executed in order):

  1. Drain affected pods: kubectl cordon + wait for active requests to complete.
  2. Restart pods to clear memory fragmentation.
  3. Un-cordon and verify model load and readiness.

Prevention (implemented the following week):

  1. Schedule graceful pod recycle every Sunday at 03:00 AM during lowest-traffic window.
  2. Stagger recycling: 2 pods at a time, entire fleet refreshed in ~90 minutes with zero capacity impact.
  3. Add fragmentation ratio to main dashboard, not just the debugging dashboard.

Total user impact: 4 consecutive Mondays with 15-25 minutes of elevated error rates before the pattern was identified and the weekly recycle was implemented.

The NUMA Surprise After a Node Replacement

14:22 -- A routine review of fleet-wide ITL percentiles reveals an outlier. One pod is consistently at 38ms ITL against a fleet average of 22ms. The pod has been running for 3 days since a hardware maintenance event replaced a failed GPU node.

14:29 -- First hypothesis: thermal throttling on the new GPU. Run nvidia-smi --query-gpu=temperature.gpu --format=csv. Temperature is 62 degrees, well within spec. Rule that out.

14:34 -- Second hypothesis: different GPU SKU or firmware. Run nvidia-smi --query-gpu=name,driver_version,vbios_version --format=csv. Same A100 80GB, same driver, same VBIOS. Rule that out.

14:41 -- Someone suggests: "Check NUMA." Run nvidia-smi topo -m. The GPU is on NUMA node 0. Run numactl --show inside the pod. The pod's CPU cores are on NUMA node 1. There it is.

14:44 -- The replacement node had a different BIOS configuration. Topology Manager was set to best-effort instead of single-numa-node. The kubelet placed the pod on whatever CPU cores were available, regardless of GPU affinity.

Remediation (executed in order):

  1. Correct Topology Manager policy on the replacement node to single-numa-node.
  2. Restart kubelet on the replacement node.
  3. Drain and reschedule the affected pod.
  4. Verify NUMA affinity with nvidia-smi topo -m and numactl --show.

ITL dropped to match the fleet average within seconds of the model finishing loading.

Total user impact: 3 days of elevated p99 ITL (the single pod was enough to skew fleet-wide percentiles). The EPP had been routing around it due to latency awareness, limiting blast radius, but the pod was still receiving some traffic.

What we changed after: Added NUMA affinity validation to the pod readiness probe. If NUMA placement does not match expected topology, the pod will not pass readiness. Added node configuration auditing to the hardware replacement runbook.

KEY TAKEAWAY

Production failures rarely arrive alone. The worst incidents combine multiple failure modes: a ConfigMap race triggers a cache miss storm, which triggers autoscaling, which triggers a thundering herd download. Build your runbooks for compound failures, not single-cause scenarios.


Deep Dives: 5 Incident Case Studies

The war stories above give you the narrative. These case studies give you the forensics. Each one includes the exact timestamps, the exact metrics, the exact commands we ran, and the exact resolution. Print these out and tape them to your monitor. You will need them.

Case Study 1: The GPU That Cried Wolf

14:22:31 UTC Thursday -- Routine DCGM health check shows A100 GPU on node gpu-worker-07 reporting 8 correctable ECC errors in the last hour. Within normal bounds. Nobody is alerted.

09:15:44 UTC Friday -- Daily GPU health report shows gpu-worker-07 at 47 correctable ECC errors/hour. This is a 6x increase from the day before. The report goes to a Slack channel that nobody actively watches.

16:08:12 UTC Friday -- Error rate climbs to 142/hour. Still correctable. No impact on inference quality. Pod is serving normally. Latency metrics are nominal.

14:22:08 UTC Saturday -- First uncorrectable ECC error (double-bit error) occurs during a decode operation on layer 47 of the model. The KV cache entry for the active request is corrupted. The model produces 3 responses containing nonsensical token sequences before the vLLM process detects the corruption and crashes.

14:22:11 UTC -- Pod restarts. Model begins reloading (90 seconds with local cache). During reload, 3 users received garbage responses. No client-side retry logic caught the bad responses.

What we ran during diagnosis:

# Check ECC error counts
nvidia-smi --query-gpu=ecc.errors.corrected.volatile.total,ecc.errors.uncorrected.volatile.total --format=csv
# Output: 1847, 1

# Check error history with DCGM
dcgmi diag -r 3 -j  # Level 3 diagnostic, JSON output

# Check if GPU should be retired
nvidia-smi --query-gpu=retired_pages.pending,retired_pages.sbe,retired_pages.dbe --format=csv
# Output: Yes, 12, 1  -- GPU has pages pending retirement

Resolution: GPU was replaced during the next maintenance window. We added ECC error rate trending to the primary dashboard and set alerts at 50 correctable errors/hour (warning) and any uncorrectable error (critical with immediate pod drain).

What we changed: Added rate(DCGM_FI_DEV_ECC_SBE_VOL_TOTAL[1h]) > 50 as a warning alert. Added increase(DCGM_FI_DEV_ECC_DBE_VOL_TOTAL[5m]) > 0 as a critical alert with automatic pod drain via PodDisruptionBudget-aware eviction.

Case Study 2: The 3 AM Certificate Expiry

03:14:00 UTC -- mTLS certificates for inter-pod communication expire. The certificates were provisioned 90 days ago during initial cluster setup. Nobody set a calendar reminder. Nobody had certificate expiry monitoring.

03:14:02 UTC -- RDMA connections between prefill and decode pods begin failing TLS handshakes. The RDMA fabric itself is healthy. TCP health checks pass because they use a different certificate chain.

03:14:15 UTC -- PagerDuty fires on error rate exceeding 50%. On-call engineer wakes up, opens laptop.

03:18:00 UTC -- Engineer checks dashboards. Pods are green. GPU metrics normal. Cache hit rate normal. Error rate is 87% and climbing. "Everything looks healthy but nothing works."

03:25:00 UTC -- Second engineer joins. They start checking network connectivity. curl between pods works fine (TCP). RDMA transfers fail silently. Nobody thinks to check certificates for another 22 minutes.

03:47:00 UTC -- Someone runs openssl s_client against the inter-pod endpoint:

# The command that finally revealed the problem
openssl s_client -connect decode-pool-svc:8443 -servername decode-pool 2>&1 | grep -i "verify\|expire\|error"
# Output: Verify return code: 10 (certificate has expired)

# Check exact expiry
openssl s_client -connect decode-pool-svc:8443 2>/dev/null | openssl x509 -noout -dates
# Output: notAfter=Wed Jun 25 03:14:00 2026 GMT

03:52:00 UTC -- Emergency certificate rotation initiated. New certificates generated and deployed via cert-manager:

kubectl get certificate -n inference -o wide
# Shows: READY=False, STATUS=Expired

# Force renewal
kubectl cert-manager renew --all -n inference

# Restart pods to pick up new certs
kubectl rollout restart deployment/prefill-pool -n inference
kubectl rollout restart deployment/decode-pool -n inference

04:01:00 UTC -- New certificates deployed. Pods restarting with fresh TLS material. Service recovering.

Total impact: 47 minutes of near-total outage. 87% error rate at peak. 2 engineers on call for the full duration. Root cause: expired mTLS certificates with no expiry monitoring.

What we changed: Added certificate expiry monitoring (certmanager_certificate_expiration_timestamp_seconds), alert when any certificate is within 14 days of expiry, automated renewal via cert-manager with 30-day pre-expiry renewal trigger.

Case Study 3: The Tenant Who Broke Everyone

11:30:00 UTC -- Tenant C, a software development platform, switches their RAG pipeline from English documentation to source code repositories. Same request count (800/hour). Same API calls. Same context window configuration (8K tokens).

11:45:00 UTC -- KV cache utilization begins climbing. Before the workload change: 68% average. Now: 74% and rising.

12:15:00 UTC -- KV cache at 82%. Still below our 85% alert threshold. No alerts fire.

Here is what nobody realized: English text tokenizes at roughly 1 token per 4 characters. Source code tokenizes at roughly 1 token per 1.5 characters. The same 8K-character context window that produced ~2,000 tokens in English was now producing ~5,300 tokens in code. Per-request KV cache consumption increased 2.65x with zero change in request volume or context window size.

12:30:00 UTC -- KV cache hits 86%. Alert fires. But the eviction cascade has already started. Cache hit rate begins dropping.

12:42:00 UTC -- Cache hit rate at 34%. TTFT p99 at 3.8 seconds (SLO: 500ms). Other tenants affected because they share the cache pool.

The math:

  • English: 8,000 chars / 4 chars per token = 2,000 tokens. KV cache per request: ~128 MB
  • Source code: 8,000 chars / 1.5 chars per token = 5,333 tokens. KV cache per request: ~341 MB
  • 800 requests/hour = 13.3 requests/minute. Additional KV cache pressure: (341 - 128) * 13.3 = 2.8 GB/minute additional pressure

Resolution: Temporarily rate-limited Tenant C while we deployed per-tenant KV cache budgets. Added token-count-based admission control (not character-count-based) to the EPP. Implemented per-tenant cache utilization monitoring with alerts.

What we changed: Moved from character-count-based context limits to token-count-based limits. Added per-tenant KV cache utilization tracking. Alert when any single tenant consumes more than 40% of shared cache capacity.

Case Study 4: The Phantom Memory Leak

Monday 00:00 UTC -- Fresh pod deployment after weekly recycle. GPU memory baseline: 35.2 GB used (model weights + CUDA overhead).

Tuesday 00:00 UTC -- GPU memory: 35.25 GB. Growth: 50 MB. Within noise. Nobody notices.

Wednesday 00:00 UTC -- GPU memory: 35.30 GB. Growth: 100 MB total. Still within noise, but a pattern is forming.

Thursday 08:00 UTC -- GPU memory: 35.38 GB. An engineer running a capacity audit notices the steady upward trend. Opens an investigation.

First hypothesis: vLLM memory leak. We pulled heap profiles, checked Python object counts, reviewed garbage collector stats. Nothing. Application-level memory was stable.

Second hypothesis: CUDA memory leak. We checked CUDA memory allocations with torch.cuda.memory_stats(). All allocations were tracked and accounted for. No leaked tensors.

The actual cause: CUDA JIT compilation. When vLLM encounters new request patterns (different batch sizes, different sequence lengths, different attention patterns), the CUDA runtime JIT-compiles optimized kernels. Each compiled kernel occupies GPU memory. Over 72 hours of production traffic with diverse request patterns, the kernel cache grew by ~50 MB/day.

# Diagnose CUDA kernel cache growth
python3 -c "
import torch
stats = torch.cuda.memory_stats()
print(f'Allocated: {stats[\"allocated_bytes.all.current\"] / 1e9:.3f} GB')
print(f'Reserved:  {stats[\"reserved_bytes.all.current\"] / 1e9:.3f} GB')
print(f'Active:    {stats[\"active_bytes.all.current\"] / 1e9:.3f} GB')
"

# Check CUDA context size
nvidia-smi --query-compute-apps=pid,used_memory --format=csv

Resolution: This is not a bug -- it is expected CUDA behavior. The growth is bounded (kernel cache reaches steady state after seeing most request patterns, typically within 5-7 days). We adjusted our memory headroom calculations to account for 300-500 MB of CUDA context growth and documented it as expected behavior.

What we changed: Updated capacity planning to include 500 MB headroom for CUDA JIT growth. Added a "CUDA overhead" line to our GPU memory dashboard. Stopped investigating it as a leak and documented it in the runbook.

Case Study 5: The Rollback That Made Things Worse

15:30:00 UTC -- A new model version deployed via rolling update shows elevated error rates (4% vs baseline 0.1%). Team decides to roll back.

15:45:00 UTC -- Rollback initiated:

kubectl rollout undo deployment/inference-server -n inference
# Rolling back to previous revision

15:46:00 UTC -- Old model version begins loading on new pods. KV cache from the new model version is still warm in the cache store.

15:47:30 UTC -- First rolled-back pod becomes ready. EPP routes traffic to it. The pod checks the KV cache and finds cached prefixes from the new model version. The cache keys match (same system prompt tokens), but the KV values were computed by a different model version with different weights. The cache serves these incompatible KV entries.

15:47:31 UTC -- Model produces incoherent output. Not errors -- the model runs successfully but with corrupted attention state. Outputs are grammatical nonsense. Users see responses like "The implementation requires careful attention to the fundamental properties of recursive disambiguation in parallel architectures" in response to "What is 2+2?"

15:48:00 UTC -- Error reports flood in. Team is confused: "We rolled back, how is it worse?"

15:52:00 UTC -- Someone realizes the KV cache is the problem. The cache contains entries computed by model version N+1, but the serving pods are now running model version N. The attention layer weights differ between versions, making the cached KV entries mathematically incompatible.

15:53:00 UTC -- Emergency cache flush:

# Flush KV cache on all serving pods
for pod in $(kubectl get pods -n inference -l app=inference-server -o name); do
  kubectl exec -n inference $pod -- curl -X POST http://localhost:8000/v1/cache/flush
done

# Verify cache is empty
kubectl exec -n inference deploy/inference-server -- curl -s http://localhost:8000/metrics | grep cache_usage

15:59:00 UTC -- Cache flushed across fleet. Model serving correctly with fresh KV cache entries computed by the correct model version. TTFT temporarily elevated (no cache) but output quality restored.

Total impact: 12 minutes of corrupted model output. Approximately 800 requests received incoherent responses. Zero errors logged (the model ran successfully with bad cache data).

What we changed: Added model version hash to KV cache keys. Cache entries from model version N are invisible to model version N+1 (and vice versa). Rollback procedure now includes mandatory cache flush as step 2, immediately after kubectl rollout undo. Added a pre-rollback checklist to the incident runbook.

KEY TAKEAWAY

These case studies share a common thread: the signals existed before the incident, but nobody was watching. ECC error trending, certificate expiry monitoring, per-tenant token tracking, and model-version-aware caching all prevent repeat incidents. Build instrumentation proactively, not reactively.


Severity Classification Guide

Every incident needs a consistent severity classification to drive the right response. This guide defines the severity levels we use for LLM inference incidents, calibrated to the failure modes described throughout this post. Use it to set expectations with your on-call rotation and stakeholders.

Incident Severity Levels

Classify incidents based on user impact and blast radius. When in doubt, classify higher and downgrade after investigation.

P0
Critical: Total or near-total service outage
Response: Immediate. All hands on deck. 5-minute acknowledgment SLA.
The inference service is completely unavailable or producing corrupted output at scale. Error rate exceeds 50%. All users are affected.
Examples: Certificate expiry causing fleet-wide RDMA failure (Case Study 2), model rollback serving corrupted KV cache entries (Case Study 5), complete KV cache eviction cascade across the fleet.
P1
Major: Significant degradation affecting multiple users
Response: Within 15 minutes. On-call engineer plus backup. Stakeholder notification.
TTFT p99 exceeds SLO by 2x or more. Error rate between 10% and 50%. Multiple tenants affected. Capacity reduced below safe operating threshold.
Examples: ConfigMap cascade causing cache miss storm (War Story 1), Monday morning fragmentation cliff (War Story 3), tenant workload change causing cross-tenant cache pressure (Case Study 3).
P2
Moderate: Partial degradation or single-tenant impact
Response: Within 1 hour. On-call engineer investigates during business hours. After-hours acknowledgment only.
TTFT p99 approaching SLO. Single pod or single tenant affected. Degradation is contained and not spreading. Silent quality issues detected by monitoring.
Examples: Silent truncation from misconfigured max_tokens (War Story 2), single pod NUMA misplacement causing elevated ITL (War Story 4), GPU ECC error rate trending upward (Case Study 1 before Saturday).
P3
Low: Minor issue with minimal user impact
Response: Next business day. Track in issue backlog. No after-hours escalation.
Single pod showing anomalous behavior but being routed around by the EPP. Metric drift detected by trend analysis. Configuration improvement opportunity identified.
Examples: CUDA JIT memory growth approaching headroom limits (Case Study 4), single pod with slightly elevated latency being avoided by EPP, fragmentation ratio rising but below alert threshold.

How llm-d's EPP Prevents Production Issues

The Endpoint Picker Policy is the control plane for inference routing in llm-d. Rather than distributing requests blindly with round-robin or random selection, the EPP observes the real-time state of every serving pod and makes informed routing decisions. It is the single component that ties together the lessons from every scenario described in this post.

The EPP continuously monitors queue depth, KV cache contents, GPU memory pressure, model loaded status, and network health across the entire serving fleet. Every routing decision factors in this state, which is why many of the incidents described earlier are preventable -- or at least significantly mitigated -- with EPP-informed routing.

  • Cache-aware routing -- Routes requests to pods that already have relevant prefix cache entries. When a request arrives with a system prompt that matches cached prefixes on a specific pod, the EPP sends it there. This can reduce TTFT from hundreds of milliseconds to tens of milliseconds for cache-hit requests. This directly prevents the cache eviction storms described in Scenario #1 by concentrating cache usage rather than scattering it.
  • Load-aware routing -- Avoids sending requests to pods that are already saturated. Instead of blindly distributing load, the EPP factors in each pod's current queue depth and GPU utilization, preventing the hot-spotting that causes the latency spikes in Scenario #11. Requests are distributed based on actual capacity, not assumed capacity.
  • Health-aware routing -- Automatically routes around pods that are loading models, running defragmentation, or experiencing issues. No traffic reaches a pod that cannot serve it, eliminating the "healthy but not ready" failure mode from Scenario #6 entirely. This also handles the defrag-aware routing needed for Scenario #10.
  • Session affinity -- Keeps multi-turn conversations on the same pod to maximize KV cache reuse. The second message in a conversation benefits from the cached context of the first, dramatically improving TTFT for conversational workloads. This also helps with tenant isolation (Scenario #4) by keeping tenant traffic on consistent pods.
  • Context-length-aware scheduling -- The EPP can differentiate between short-context and long-context requests, routing them to appropriate priority queues or dedicated pod pools. This prevents the prefill starvation described in Scenario #11 where 128K requests monopolize compute that short requests need.
  • Disaggregation-aware routing -- For disaggregated deployments, the EPP factors in the health and performance of cross-pool communication paths. If RDMA throughput degrades (Scenario #7), the EPP can dynamically adjust routing to compensate -- including falling back to co-located serving when the disaggregated path is unreliable.

The EPP transforms inference routing from a static load-balancing problem into a dynamic optimization problem. It makes thousands of informed decisions per second, each one considering the real-time state of the entire fleet.

The critical insight is that most production inference incidents are not individual component failures -- they are system-level emergent behaviors that arise from the interaction between routing, caching, memory management, and load. The EPP is the point where all of these concerns converge, and it is the right place to implement the cross-cutting logic that prevents cascading failures.

For architecture details, configuration reference, and contribution guidelines, see the llm-d documentation and source.

KEY TAKEAWAY

The EPP transforms inference routing from static load balancing into dynamic optimization. By continuously observing queue depth, cache state, GPU memory, and network health across the fleet, it prevents the cascading failures that cause most production incidents.


Your First Week Deploying llm-d in Production

You do not need to solve every problem in this post on day one. But there is a minimum viable set of operational practices that will keep you out of the worst trouble. This is the checklist we wish we had when we started. Do these in your first week and you will avoid the most common early incidents.

First Week Checklist

Concrete actions, in priority order. Each one addresses a specific failure mode described above.

1
Deploy the model weight DaemonSet for local NVMe caching Eliminates the thundering herd on pod startup (Scenario #3). Reduces model load time from 4-10 minutes to 45-90 seconds. Every other operational improvement depends on fast pod startup.
2
Replace the default readiness probe with one that verifies model load + warmup inference Prevents traffic routing to pods that are not yet ready to serve (Scenario #6). The default HTTP readiness probe will lie to you.
3
Add TTFT p99, queue depth, and cache hit rate to your primary dashboard These three metrics predict 80% of inference incidents. GPU utilization does not. You cannot debug what you cannot see.
4
Add finish_reason distribution monitoring and alert at 5% length-truncation Silent truncation (Scenario #9) is the hardest failure to detect and the easiest to prevent. This metric is the only way to catch it.
5
Set rolling update strategy to maxSurge=1, maxUnavailable=0 Prevents capacity drops during deployments. Yes, rollouts take longer. Your SLO does not care about deployment speed.
6
Deploy a PodDisruptionBudget with minAvailable=75% Prevents accidental capacity loss from node maintenance, cluster autoscaler, or errant kubectl commands.
7
Verify NUMA affinity on every GPU node with nvidia-smi topo -m NUMA misplacement (Scenario #2) causes 2x decode latency and is invisible to standard monitoring. Check it once, set Topology Manager to single-numa-node, and never think about it again.
8
Switch HPA scaling metric from GPU utilization to queue depth GPU utilization is a lagging indicator (Scenario #8). Queue depth is a leading indicator. Set stabilization window to at least 10 minutes.
9
Set KV cache utilization alerts at 85% Cache eviction storms (Scenario #1) are a cliff, not a slope. You need warning before you hit the edge, not after you have gone over it.
10
Add the GPU memory fragmentation ratio metric "Free memory" in nvidia-smi does not mean "allocatable memory" (Scenario #5). The fragmentation ratio is the metric that predicts OOM failures when free memory looks healthy.
11
Schedule weekly graceful pod recycling during your lowest-traffic window Prevents the Monday morning fragmentation cliff (War Story #3). Stagger 2 pods at a time. The entire fleet recycles in ~90 minutes with zero capacity impact.
12
Set the default max_tokens to at least 2048 and document it for every API consumer The default of 512 will silently truncate complex responses. Make this explicit. Make your API consumers set it explicitly. Then monitor finish_reason anyway.
13
Write down your TTFT p99 SLO and share it with every team that consumes the API If you do not have a written SLO, you do not have a production service. Every alert threshold, capacity decision, and trade-off in this post is downstream of that number.
14
For disaggregated deployments: add RDMA throughput monitoring between prefill and decode pools A TCP fallback from a switch failure (Scenario #7) turns 2ms KV cache transfers into 180ms transfers. Both endpoints will report healthy. Only the throughput metric will catch it.
KEY TAKEAWAY

You do not need to solve every problem on day one. The 14 items on this checklist, done in your first week, will prevent the most common early incidents. Start with the model weight DaemonSet, fix your readiness probes, and build your dashboard around TTFT, queue depth, and cache hit rate.


Security Considerations for Production LLM Inference

Security in LLM inference is not optional, and it is not something you bolt on after launch. We learned several security lessons the hard way, and a few we were fortunate enough to learn from other teams' incidents before they became ours.

Model Endpoint Authentication

Every inference endpoint must require authentication. This sounds obvious, but in the rush to get models serving, it is remarkably common to deploy with authentication disabled "temporarily" and forget to enable it. We caught an internal team running an unauthenticated inference endpoint on a routable IP for 6 days.

  • mTLS between all inference components: Prefill pods, decode pods, EPP, and the API gateway should all communicate over mTLS. Use cert-manager for automated certificate lifecycle (see Case Study 2 for what happens when you do not).
  • API key rotation: Rotate API keys every 90 days minimum. Use Kubernetes Secrets with external secret stores (HashiCorp Vault, AWS Secrets Manager) -- never store keys in ConfigMaps.
  • JWT token validation: For user-facing endpoints, validate JWT tokens at the gateway layer before requests reach the inference pods. This prevents unauthenticated requests from consuming GPU compute.

Network Segmentation

Inference pods should not have outbound internet access. Period. A compromised model serving process with internet access is a data exfiltration vector. Use Kubernetes NetworkPolicies to restrict traffic flow:

# NetworkPolicy: inference pods can only talk to each other and the API gateway
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: inference-network-policy
  namespace: inference
spec:
  podSelector:
    matchLabels:
      app: llm-inference
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
      - namespaceSelector:
          matchLabels:
            name: api-gateway
      - podSelector:
          matchLabels:
            app: llm-inference
  egress:
    - to:
      - podSelector:
          matchLabels:
            app: llm-inference
    - to:  # Allow DNS resolution
      - namespaceSelector: {}
        podSelector:
          matchLabels:
            k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP

Prompt Injection Defense

Prompt injection is real and it targets your inference layer. Defense-in-depth applies here:

  • Input validation: Reject requests with known injection patterns before they reach the model. This is not foolproof but catches the obvious attacks.
  • Output filtering: Scan model outputs for sensitive patterns (API keys, internal URLs, PII) before returning to the client. Log filtered responses for review.
  • System prompt isolation: System prompts should be injected server-side, never accepted from client input. The client should not be able to override or view the system prompt.
  • Rate limiting by token count: Limit not just requests per second but tokens per minute per client. A prompt injection attack that generates 100K output tokens costs you GPU time even if it does not succeed in extracting data.

Audit Logging

Log every inference request with: timestamp, client ID, model version, input token count, output token count, finish_reason, and latency. Do not log the actual prompt or response content in the primary audit log -- that creates a PII liability. If you need content logging for debugging, use a separate encrypted store with strict access controls and automatic 30-day retention.

Model Integrity Verification

Model weights are code. Treat them with the same supply chain security you apply to container images.

  • Verify model checksums on load: Every model artifact has a SHA-256 hash computed at build time. The model loading process verifies this hash before loading weights onto the GPU. A hash mismatch aborts the load and alerts the security team.
  • Sign model artifacts: Use cosign or Notation to sign model files. The DaemonSet that pre-pulls model weights verifies signatures before writing to local NVMe. Unsigned artifacts are rejected.
  • Audit the model supply chain: Know where your model came from. Track the training data provenance, fine-tuning history, and every transformation applied. If you are pulling models from a public registry, verify the publisher. An attacker who can replace your model weights controls your model's output.

Secret Management

The one thing I wish someone told me

Do not put model access tokens in ConfigMaps. We see this constantly. ConfigMaps are not encrypted at rest by default, they are visible to anyone with read access to the namespace, and they show up in kubectl describe output. We found a Hugging Face token in a ConfigMap during a routine security audit -- it had been there for four months, visible to every developer with namespace access. Use Kubernetes Secrets with encryption at rest enabled, or better yet, use an external secret manager like HashiCorp Vault or AWS Secrets Manager with the External Secrets Operator.

  • Kubernetes Secrets with encryption at rest: The minimum viable approach. Enable EncryptionConfiguration in the API server with an AES-256 key. This encrypts Secret data in etcd. Without this, Secrets are base64-encoded (not encrypted) and readable by anyone with etcd access.
  • External secret managers: For production, use Vault, AWS Secrets Manager, or Azure Key Vault with the External Secrets Operator. Secrets are fetched at pod startup and injected as environment variables or mounted files. They never persist in the Kubernetes API.
  • Rotate secrets on a schedule: Model registry tokens, API keys, database credentials -- all should rotate automatically. Never assume a secret is safe just because it has not been compromised yet.

RBAC for Inference Namespaces

# RBAC: restrict who can modify inference deployments
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: inference-operator
  namespace: inference
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: ["apps"]
    resources: ["deployments/rollback"]
    verbs: ["create"]  # Allow rollback but not delete
The one thing I wish someone told me

Your inference endpoint is a compute amplifier. A single API call can consume seconds of GPU time and generate thousands of tokens of output. If that endpoint is unauthenticated, unrated-limited, or network-accessible beyond its intended scope, you have handed someone a very expensive tool with no controls. Security is not about paranoia -- it is about the operational reality that GPU compute is valuable and model endpoints are high-value targets.

KEY TAKEAWAY

Secure your inference endpoints with mTLS, network segmentation, and rate limiting by token count. Treat model weights as signed supply-chain artifacts. Never store secrets in ConfigMaps. Your inference endpoint is a compute amplifier, and an unsecured one is an expensive liability.


What's Next

Production LLM inference is still a young discipline. The problems described in this post are actively being worked on by the llm-d community and by teams across the industry. Several areas of active development deserve attention:

  • Predictive autoscaling -- Moving beyond reactive HPA to scaling decisions based on historical patterns, calendar awareness, and real-time demand signals. The goal is to have new pods loaded and ready before the traffic spike arrives.
  • Smarter KV cache management -- Better eviction policies, cross-pod cache sharing, and hierarchical caching (GPU HBM, CPU DRAM, NVMe) to extend effective cache capacity without additional GPU memory.
  • Fragmentation-aware memory allocation -- Memory allocators designed specifically for the allocation patterns of inference workloads, reducing fragmentation without requiring defragmentation cycles.
  • Automated capacity planning -- Tools that analyze your workload distribution and SLO requirements to recommend fleet size, disaggregation ratios, and headroom budgets.
  • Improved observability tooling -- Standardized metrics, pre-built Grafana dashboards, and inference-specific alerting rules that work out of the box so teams do not have to build monitoring from scratch.

If your team has production inference experience, the llm-d community would benefit from it. The project is a CNCF Sandbox project, and contributions are welcome -- whether that is code, documentation, operational patterns from your own environment, or simply sharing the failure modes you have encountered so others can learn from them.

The hardest part of production inference is the knowledge that only comes from operating it under real conditions. Every team that shares their operational experience makes the entire ecosystem more resilient. That is what this post aims to do, and we hope it helps you avoid at least a few of the 3 AM pages that taught us these lessons.

KEY TAKEAWAY

Production LLM inference is a young discipline where every team's operational experience benefits the community. Predictive autoscaling, smarter KV cache management, and standardized observability tooling are all active areas of development. Share what you learn.


Pre-Production Review Checklist

Before your inference service goes live, walk through this checklist with your team. Each item maps to a failure mode covered in this post. Completing all of them will not guarantee zero incidents, but it will eliminate the most common ones.

Launch Readiness Review

Verify each item before promoting to production. Click to mark items as reviewed.

GPU Memory Headroom: Verify at least 5 GB of safety headroom per GPU after accounting for model weights, KV cache budget, activations, CUDA overhead, and 500 MB for JIT kernel growth. Confirm with actual memory measurements, not theoretical calculations.
KV Cache Eviction Policy: Confirm frequency-weighted eviction is enabled (not pure LRU). Verify admission control triggers at 85% KV cache utilization. Test that load shedding activates before eviction cascades can start.
NUMA Affinity: Topology Manager set to single-numa-node on all GPU nodes. CPU Manager set to static policy. Run nvidia-smi topo -m and numactl --show inside a pod to verify GPU and CPU are on the same NUMA node.
Model Loading Strategy: DaemonSet deployed for local NVMe model caching. Model weights pre-pulled to all GPU nodes before inference pods are scheduled. Fallback download path has exponential backoff with jitter. Verify model load time is under 90 seconds from local cache.
Health Check Configuration: Readiness probe verifies model weights loaded, warmup inference completes successfully, and RDMA connectivity confirmed (for disaggregated deployments). Pod does not pass readiness until the full inference path is validated.
Rollback Procedures: Documented rollback runbook includes cache flush as step 2 after kubectl rollout undo. Model version hash included in KV cache keys to prevent cross-version cache contamination. Rollback tested in staging within the last 30 days.
Monitoring Dashboards: Request Flow, Per-Pod Health, Fleet View, and Trend Analysis dashboards deployed. TTFT p99, queue depth, cache hit rate, and finish_reason distribution visible on the primary dashboard. GPU health metrics (ECC errors, clock throttling, temperature) on a dedicated panel.
Alert Thresholds: All seven day-one alerts configured and tested: TTFT p99 SLO breach, cache hit rate drop, queue depth hot-spotting, fragmentation ratio, finish_reason=length spike, RDMA throughput degradation, and model load time anomaly. PagerDuty routing verified.
Capacity Planning: Fleet sized for p99 traffic with 30-40% burst headroom and 10% maintenance reserve. Context length distribution measured from actual workload, not estimated. Autoscaler configured with queue depth as primary metric and 10-minute stabilization window.
Incident Runbooks: TTFT spike diagnostic runbook documented and accessible to on-call. Rollback procedure tested. Certificate expiry monitoring in place with 14-day warning alerts. On-call rotation established with at least two engineers trained on GPU diagnostics and EPP debugging.

Further Reading

These resources provide deeper coverage of the tools, platforms, and techniques referenced throughout this post.

Resources and References

Documentation, source code, and tools for building and operating production LLM inference.

  • llm-d on GitHub Source code, architecture documentation, and contribution guidelines for the llm-d project.
  • llm-d.ai Documentation Official documentation site covering installation, configuration, EPP tuning, and operational best practices.
  • Interactive Production Lessons Simulator Explore the failure scenarios from this post in a simulated cluster. Watch cache eviction storms, NUMA misplacement, and autoscaler oscillation play out in real time.
  • Kubernetes GPU Scheduling Documentation Official Kubernetes docs on GPU scheduling, device plugins, and resource management for GPU workloads.
  • vLLM Documentation Documentation for the vLLM inference engine, covering PagedAttention, continuous batching, and serving configuration.
  • NVIDIA DCGM (Data Center GPU Manager) GPU health monitoring, diagnostics, and telemetry. Covers ECC error tracking, thermal monitoring, and the DCGM metrics referenced in the GPU Health Monitoring section.

See These Failures Play Out

Explore our interactive Production Lessons app to watch these failure modes unfold in a simulated cluster -- and see how each pattern prevents them.