When to use this
You run vLLM in production. GPU utilization looks high, and that tells you very little: a GPU can sit at 100% while users are either served well or waiting in a long queue. vLLM's own metrics show what users experience. This runbook covers what to scrape, which queries to use and how to read them.
Step 1: check the metrics endpoint
vLLM's server exposes Prometheus metrics at /metrics by default:
curl -s http://vllm:8000/metrics | grep -E '^vllm:' | cut -d'{' -f1 | sort -u
Warning
Metric names change between vLLM releases. For example, GPU KV cache usage has appeared as both
vllm:gpu_cache_usage_percandvllm:kv_cache_usage_perc. Run the command above, and use the names that your version shows. Pin the version, and recheck dashboards after an upgrade.
Key metrics:
| Metric | Type | Meaning |
|---|---|---|
vllm:num_requests_running | Gauge | Requests being processed now |
vllm:num_requests_waiting | Gauge | Requests queued, not yet scheduled |
vllm:gpu_cache_usage_perc or vllm:kv_cache_usage_perc | Gauge | KV cache in use (1 = 100%) |
vllm:time_to_first_token_seconds | Histogram | Time to first token (TTFT) |
vllm:e2e_request_latency_seconds | Histogram | Whole request time |
vllm:request_queue_time_seconds | Histogram | Time spent waiting |
vllm:prompt_tokens_total | Counter | Input tokens processed |
vllm:generation_tokens_total | Counter | Output tokens generated |
vllm:request_success_total | Counter | Finished requests, by finish reason |
Step 2: scrape it
Plain Prometheus:
scrape_configs:
- job_name: vllm
scrape_interval: 15s
metrics_path: /metrics
static_configs:
- targets: ["vllm-0:8000", "vllm-1:8000"]
With the Prometheus Operator on Kubernetes:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: vllm
namespace: inference
spec:
selector:
matchLabels:
app: vllm
endpoints:
- port: http
path: /metrics
interval: 15s
The Service's port must be named http for this to match. If vLLM requires an API key, check whether /metrics is exposed without auth, and restrict it at the network level. Also scrape GPU hardware metrics with DCGM exporter.
Step 3: core PromQL
Queue and load:
# Waiting and running, by instance
sum by (instance) (vllm:num_requests_waiting)
sum by (instance) (vllm:num_requests_running)
Latency:
# P95 time to first token
histogram_quantile(0.95,
sum by (le, model_name) (rate(vllm:time_to_first_token_seconds_bucket[5m])))
# P95 queue time
histogram_quantile(0.95,
sum by (le) (rate(vllm:request_queue_time_seconds_bucket[5m])))
# P95 end-to-end latency
histogram_quantile(0.95,
sum by (le) (rate(vllm:e2e_request_latency_seconds_bucket[5m])))
Throughput:
# Output tokens per second
sum(rate(vllm:generation_tokens_total[5m]))
# Input tokens per second
sum(rate(vllm:prompt_tokens_total[5m]))
# Completed requests per second, by finish reason
sum by (finished_reason) (rate(vllm:request_success_total[5m]))
KV cache:
max by (instance) (vllm:kv_cache_usage_perc)
Output truncation, as a share of finished requests:
sum(rate(vllm:request_success_total{finished_reason="length"}[15m]))
/ sum(rate(vllm:request_success_total[15m]))
A rising length share means answers are being cut off. See output truncated.
Label names can also vary by version. Check them in the raw /metrics output.
Step 4: alert rules
Use these as a starting point, and set the thresholds from your own baseline and SLOs.
groups:
- name: vllm
rules:
- alert: VllmDown
expr: up{job="vllm"} == 0
for: 2m
labels: { severity: critical }
annotations:
summary: "vLLM {{ $labels.instance }} is not responding to scrapes"
- alert: VllmQueueBuilding
expr: sum by (instance) (vllm:num_requests_waiting) > 10
for: 5m
labels: { severity: warning }
annotations:
summary: "Requests are queuing on {{ $labels.instance }}"
- alert: VllmTtftHigh
expr: |
histogram_quantile(0.95,
sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))) > 3
for: 10m
labels: { severity: warning }
annotations:
summary: "P95 TTFT is above 3 seconds"
- alert: VllmKvCacheNearFull
expr: max by (instance) (vllm:kv_cache_usage_perc) > 0.9
for: 10m
labels: { severity: warning }
annotations:
summary: "KV cache above 90% on {{ $labels.instance }}"
- alert: VllmNoThroughputWithQueue
expr: |
sum(vllm:num_requests_waiting) > 0
and sum(rate(vllm:generation_tokens_total[5m])) == 0
for: 3m
labels: { severity: critical }
annotations:
summary: "Requests are waiting, and no tokens are being generated"
The last alert catches a hung engine that still answers health checks. It's one of the most useful alerts here, because up stays at 1.
Tip
Keep the alert count low. Alert on what users feel, such as queue time and TTFT, and leave GPU temperature and utilization to dashboards unless hardware limits are near.
Step 5: read the signals together
| Waiting | KV cache | TTFT | Throughput | Likely cause | Action |
|---|---|---|---|---|---|
| High | High | High | Flat at max | At capacity | Add replicas, or reduce max_tokens |
| High | Low | High | Low | Limited by a request cap or scheduler settings | Review --max-num-seqs |
| Low | Low | High | Normal | Long prompts, or cold prefix cache | Check prompt sizes and prefix caching |
| Low | Low | Normal, but E2E is high | Normal | Long outputs | Check output length; it may be fine |
| Rising | High | Spiky | Falling | Preemption from memory pressure | See KV cache tuning |
| Above 0 | Any | Rising | Zero | Hung engine | Capture logs, then restart the replica |
| Zero | Zero | No data | Zero | No traffic reaches it | Check the gateway and service routes |
Separate queue time from processing time in any TTFT investigation:
histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m])))
-
histogram_quantile(0.95, sum by (le) (rate(vllm:request_queue_time_seconds_bucket[5m])))
Subtracting percentiles gives only a rough figure, but it's enough to show direction. If most TTFT is queue time, you need capacity. If queue time is small, look at prompt length, prefix caching and GPU speed. See TTFT latency spikes.
Check average prompt and output size, because a traffic-shape change often looks like a capacity problem:
sum(rate(vllm:prompt_tokens_total[15m])) / sum(rate(vllm:request_success_total[15m]))
sum(rate(vllm:generation_tokens_total[15m])) / sum(rate(vllm:request_success_total[15m]))
Step 6: incident steps
- Open the dashboard, and find the matching row in the table above.
- Check the changes: new model version, prompt template, config or traffic source.
- Compare instances. If one replica is bad, remove it from rotation, and don't scale everything.
- Collect the evidence before restarting:
kubectl logs -n inference deploy/vllm --since=15m | tail -200
kubectl exec -n inference deploy/vllm -- nvidia-smi
curl -s http://vllm:8000/metrics > vllm-metrics-$(date +%s).txt
- Mitigate: limit traffic at the gateway, reduce
max_tokens, or fail over to a backup.
Caution
Restarting a replica drops all its in-flight requests and its cache, and reloading large model weights can take minutes. Restart one at a time, and only after confirming there's enough capacity elsewhere. See the crash loop runbook.
Scale on the right signal
GPU utilization makes a poor autoscaling metric, because it's already high at moderate load. Scale on vllm:num_requests_waiting, or on queue time. See KEDA and queue depth.
Prevent it next time
- Load-test each model to find its max sustainable tokens per second, and set thresholds from it. See load testing.
- Pin vLLM's version, and keep dashboards in Git.
- Add a synthetic request every minute, which catches failures that metrics can't.
- Related: throughput tuning and end-to-end latency.