vLLM 推理节点运维:容量怎么算、健康检查怎么做、故障长什么样原创
# vLLM 推理节点运维:容量怎么算、健康检查怎么做、故障长什么样
一台 8×A100-80GB 的推理节点,此刻同时成立三件事:
/health在 0.8 毫秒内返回200;每张卡的显存占用是 76.4 GB / 81.9 GB(93%);nvidia-smi报告的 GPU 利用率是 0%。这台机器是健康的、是过载的、还是在空烧钱? 三条信号各自都"正常",合在一起却什么都没说明。这篇讲清楚推理节点该看哪些信号、 容量按什么单位算、健康检查为什么不能只打
/health,以及三类最常见的误判。文中的量化数据来自一台真实在跑的推理节点(vLLM 0.19.1,TP 部署于 8 卡 A100-80GB, 服务一个 30B 级 Coder 模型),观测窗口为该实例最近一次启动后的 3.3 小时、1352 次请求。 主机名、地址、密钥均已脱敏,只保留结构与量级。
# 1. 三条互相矛盾的信号,谁在说谎
先把开头那三条拆开:
/health 返回 200 —— vLLM 的 /health 是存活探针(liveness),它检查的是引擎进程还在、
事件循环没死。它不检查队列有多长、KV 缓存还剩多少、请求是不是正在被无限期排队。
一个已经排了 200 个请求、新请求要等 90 秒才轮到的实例,/health 照样 200。
显存 93% 被占 —— 这不是泄漏,是预分配。vLLM 启动时按 gpu_memory_utilization
(默认 0.9)一次性把显存吃下来切成 KV cache block。实测这台机器的配置:
gpu_memory_utilization = 0.9
num_gpu_blocks = 25730
block_size = 1056
cache_dtype = fp8
enable_prefix_caching = True
2
3
4
5
显存曲线在启动后就是一条平线,它不随负载变化。拿显存占用率做容量告警, 你会得到一条永远在报警、或者永远不报警的曲线——取决于你把阈值定在 0.9 的哪一侧。
GPU 利用率 0% —— nvidia-smi 的 utilization.gpu 是一个采样瞬时值,
含义是"过去一个采样周期内有 kernel 在跑的时间占比"。推理服务是突发型负载:
请求到达时瞬间打满,请求之间完全空闲。在没有请求的间隙采一次,就是 0%。
把它当作"这台机器闲着,可以下线/合并"的依据,是最常见的误杀理由。
结论:这三条信号一条都不能单独用于容量或健康判断。 真正能用的信号在 /metrics 里。
# 2. 该看的七个信号,以及它们的真实读数
vLLM 暴露 Prometheus 格式的 /metrics。指标有上百条,日常运维只需要盯七个。
下面每条都给出这台节点在 3.3 小时窗口内的实测值和读法。
# 2.1 排队深度:num_requests_running / num_requests_waiting
curl -s http://<vllm-host>:8000/metrics | grep -E "num_requests_(running|waiting)"
vllm:num_requests_running 0.0
vllm:num_requests_waiting 0.0
2
这是唯一真正的过载信号。 waiting 持续 > 0 意味着请求已经排不进这一批了。
running 反映的是当前批次里的并发请求数,它的上限由 max_num_seqs 和 KV 容量共同决定。
告警应该建在 waiting 上,而不是显存或 GPU 利用率上:
# 排队请求数持续 2 分钟高于 0 —— 容量不足的第一手证据
avg_over_time(vllm:num_requests_waiting[2m]) > 0
2
# 2.2 首 token 延迟 TTFT:用户感知的"卡不卡"
vllm:time_to_first_token_seconds_count 1352
vllm:time_to_first_token_seconds_sum 1276.97
2
平均值 0.944 秒。但平均值在这里几乎没有意义,把 bucket 拉出来看分布:
| 分位(由 bucket 累积计数换算) | TTFT |
|---|---|
| ~p60 | ≤ 0.5 s |
| ~p78 | ≤ 1.0 s |
| ~p88 | ≤ 2.5 s |
| ~p97 | ≤ 5.0 s |
| ~p99 | ≤ 7.5 s |
平均 0.94 秒、p99 却是 7.5 秒,长尾差了 8 倍。原因是 TTFT 由 prefill 决定, 而 prefill 耗时与 prompt 长度近似线性——长上下文请求把尾巴拉长了。 只报平均 TTFT 的看板,会让你在用户已经开始抱怨时仍然看到一条平稳的绿线。
# 2.3 单 token 生成耗时 TPOT:吐字快不快
vllm:request_time_per_output_token_seconds_sum 12.23
vllm:request_time_per_output_token_seconds_count 1352
2
12.23 / 1352 ≈ 9.0 毫秒/token,约合 110 tokens/s 的单请求吐字速度。 TPOT 由 decode 阶段决定,它对并发敏感:并发上来后单请求的 TPOT 会线性劣化, 这正是"人多了大家都变慢"的量化形式。TPOT 是做 SLO 的合适对象,TTFT 是做告警的合适对象。
# 2.4 队列等待时间:区分"慢"和"挤"
vllm:request_queue_time_seconds_sum 79.66
vllm:request_queue_time_seconds_count 1352
2
平均 59 毫秒,占端到端延迟的 1%。这条指标的价值在于归因: 端到端变慢时,如果 queue_time 同步上涨,是容量问题(该加卡/加实例); 如果 queue_time 平稳而 TTFT 上涨,是请求形态变了(prompt 变长),加机器解决不了。
# 2.5 前缀缓存命中率:Agent 类负载的第一优化项
vllm:prefix_cache_queries_total 54,577,837
vllm:prefix_cache_hits_total 39,773,184
2
命中率 72.9%。从 token 口径看同一件事:
vllm:prompt_tokens_total 49,418,894
vllm:prompt_tokens_cached_total 35,911,392 (72.7%)
vllm:prompt_tokens_by_source_total{source="local_compute"} 13,507,502
2
3
也就是说,4940 万个输入 token 里只有 1350 万真正跑了 prefill 计算,其余 72.7% 直接命中缓存。
这是 Agent / 长系统提示词类负载独有的红利:每轮对话的前缀(系统提示、工具定义、
历史消息)高度重复,enable_prefix_caching 打开后,重复前缀不重算。
对应的运维动作有两条:一是任何会打乱前缀稳定性的改动都要评估(比如把时间戳、 随机 ID 塞进系统提示词的开头,会让命中率断崖式下跌);二是命中率本身要监控, 它掉下来通常意味着上游改了 prompt 模板,而不是引擎出了问题。
# 2.6 抢占次数:容量红线的真正标志
vllm:num_preemptions_total 0.0
当 KV cache 不够用时,vLLM 会抢占正在运行的请求——把它踢回等待队列, 之前算好的 KV 全部作废,之后重算。这个动作对用户表现为"生成到一半卡住然后重来", 对集群表现为算力凭空蒸发。
num_preemptions_total 只要开始持续增长,就是容量已经越线的确凿证据,
比任何显存或利用率指标都直接。这台节点在窗口内是 0,说明当前负载离容量上限还很远。
# 2.7 结束原因分布:质量与配置的体检
vllm:request_success_total{finished_reason="stop"} 1311
vllm:request_success_total{finished_reason="length"} 40
vllm:request_success_total{finished_reason="error"} 0
2
3
length 占 3.0%——即 3% 的请求是被 max_tokens 截断的,不是自然收尾。
这个比例是上游配置的体检项:持续偏高说明客户端的 max_tokens 给小了,
用户看到的是"回答被砍掉半句",而服务端一切正常、零错误。
# 3. 容量按 token 算,不按请求数算
这台节点的 KV cache 容量可以直接算出来:
num_gpu_blocks × block_size = 25,730 × 1,056 ≈ 27,170,000 tokens
2717 万 token 的 KV 槽位,这才是这台机器真正的容量单位。它能同时服务多少请求, 完全取决于请求形态:
| 请求形态 | 单请求 KV 占用 | 理论并发 |
|---|---|---|
| 短问答(1K 上下文) | ~1K token | ~27,000 |
| 常规对话(8K 上下文) | ~8K token | ~3,400 |
| Agent 长上下文(64K) | ~64K token | ~420 |
| 满上下文(128K) | ~128K token | ~210 |
同一台机器,"能扛多少并发"的答案在 210 到 27000 之间浮动 128 倍。 所以"这台机器能扛多少 QPS"是个无法回答的问题,必须先问平均上下文长度。
还有一个更反直觉的比例。这个窗口内:
prompt_tokens_total 49,418,894
generation_tokens_total 752,517
2
输入输出比 65.7 : 1。传统的"LLM 服务 = 生成密集型"直觉在 Agent 负载上完全不成立—— 它是极度 prefill 密集的。这个比例决定了两件事:
- 优化的着力点在 prefill 侧(前缀缓存、chunked prefill、上下文裁剪), 而不是常被讨论的 decode 侧吞吐。
- 成本结构也在输入侧。按典型的商用计价口径(输入 $0.588/M token、 输出 $2.353/M token)折算,这 3.3 小时的用量约合 $29.1 输入 + $1.8 输出 ≈ $31, 其中 94% 的成本在输入。想省钱先砍上下文,砍生成长度基本没用。
# 4. 健康检查:三个能让故障放大的坑
# 坑 1:把存活探针当就绪探针
症状:网关一直把流量往一个实例上送,用户端超时,而所有健康检查都是绿的。
原因:/health 只回答"进程还在吗"。一个 KV 打满、队列积压、抢占频发的实例,
/health 依然 0.8 毫秒返回 200。
解药:存活和就绪分开。存活用 /health;就绪用 /metrics 派生的复合条件,
放在一个轻量探针里:
#!/usr/bin/env bash
# readiness probe: 队列深度 + KV 用量双门槛
m=$(curl -s -m 3 "http://${VLLM_HOST}:8000/metrics") || exit 1
waiting=$(grep -oP 'vllm:num_requests_waiting\{[^}]*\}\s+\K[0-9.]+' <<<"$m")
kv=$(grep -oP 'vllm:kv_cache_usage_perc\{[^}]*\}\s+\K[0-9.]+' <<<"$m")
awk -v w="$waiting" -v k="$kv" 'BEGIN { exit !(w < 10 && k < 0.92) }' \
&& echo "READY" || { echo "NOT_READY waiting=$waiting kv=$kv"; exit 1; }
2
3
4
5
6
7
8
网关拿这个脚本的出口码摘流量,而不是拿 /health 的 200。
# 坑 2:网关侧健康检查默认是关的
这是一个在网关配置里极易被忽略的默认值。以 Kong 为例,upstream 的健康检查
默认全部关闭,查询任意一个 upstream 的健康状态会看到:
curl -s http://localhost:8001/upstreams/<name>/health
{"data":[{"health":"HEALTHCHECKS_OFF",
"addresses":[{"health":"HEALTHCHECKS_OFF","port":8000}]}]}
2
HEALTHCHECKS_OFF 的含义是:网关永远认为这个后端是好的,哪怕它已经拒绝连接。
在一次真实的配置盘点中,一套 14 个 upstream 的网关里,只有 1 个开了主动健康检查——
其余 13 个包括推理服务在内,全部是 HEALTHCHECKS_OFF。这类配置不会报错、不会告警,
只在后端真的挂掉那天才第一次显形。
给推理后端补上主动健康检查:
curl -X PATCH http://localhost:8001/upstreams/<name> \
--data 'healthchecks.active.type=http' \
--data 'healthchecks.active.http_path=/health' \
--data 'healthchecks.active.healthy.interval=5' \
--data 'healthchecks.active.healthy.successes=2' \
--data 'healthchecks.active.unhealthy.interval=5' \
--data 'healthchecks.active.unhealthy.http_failures=3' \
--data 'healthchecks.active.unhealthy.timeouts=3'
2
3
4
5
6
7
8
# 坑 3:默认重试会把一次故障放大 5 倍
症状:后端刚开始不稳定,压力反而瞬间翻数倍,故障从"部分失败"迅速变成"全线雪崩"。
原因:Kong 的 service.retries 默认值是 5。一个失败请求会被重投 5 次,
后端瞬时压力放大到 5 倍。对普通的毫秒级 HTTP 服务这不算什么,
但推理请求单次要占住 GPU 几秒钟,重试成本高得多,而且重试的请求会重新排队,
把队列越推越长。
解药:推理类 service 的 retries 调到 1,并把超时按真实延迟分布设置——
读超时至少要覆盖 p99 的端到端延迟(这台节点 p95 ≈ 10 秒,60 秒的默认读超时是合理的,
但如果你把它按普通 API 的习惯调到 5 秒,会把 15% 的正常长请求判成失败):
curl -X PATCH http://localhost:8001/services/<vllm-service> \
--data 'retries=1' --data 'read_timeout=120000'
2
# 5. 一套可以直接抄的告警规则
把上面的信号落成规则,只留真信号:
groups:
- name: vllm
rules:
# 真过载:请求排不进批次
- alert: VLLMQueueBacklog
expr: avg_over_time(vllm:num_requests_waiting[2m]) > 0
for: 2m
# 容量越线:开始抢占,算力在浪费
- alert: VLLMPreemption
expr: increase(vllm:num_preemptions_total[5m]) > 0
for: 0m
# 用户感知劣化:TTFT 尾部
- alert: VLLMSlowTTFT
expr: histogram_quantile(0.95,
rate(vllm:time_to_first_token_seconds_bucket[5m])) > 5
for: 5m
# 上游 prompt 模板被改坏:前缀缓存命中率塌陷
- alert: VLLMPrefixCacheDrop
expr: rate(vllm:prefix_cache_hits_total[15m])
/ rate(vllm:prefix_cache_queries_total[15m]) < 0.4
for: 15m
# 引擎失联(存活层,仍然要保留)
- alert: VLLMDown
expr: up{job="vllm"} == 0
for: 1m
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
刻意没有建的告警:GPU 显存使用率、GPU 利用率。前者是启动即固定的常数, 后者是会在空闲瞬间读到 0% 的采样值——两条都只会产生噪声。
# 6. 一个需要权衡的选择:就绪判定放在哪一层
补全健康检查有两条路,我选了第二条:
方案 A:网关主动健康检查打 /health。 改动最小,一条 PATCH 就完事,
零新增组件。代价是它只能发现进程级死亡——引擎卡死、队列积压、KV 打满这些
"活着但不可用"的状态,它一概看不见,而这些恰恰是推理服务最常见的劣化形态。
方案 B:网关健康检查指向一个基于 /metrics 的复合就绪端点。 探针按第 4 节的
双门槛(num_requests_waiting < 10 且 kv_cache_usage_perc < 0.92)判定,
网关据此摘流量。代价是多了一个必须自己维护的探针进程,而且门槛值需要按负载形态调——
上下文长的场景 KV 阈值要更早触发。
选 B,理由是故障形态决定的:推理服务真正的故障模式不是"进程没了", 而是"进程还在但已经不该再接流量了"。方案 A 覆盖不到这一类,等于把最常见的故障漏掉。 承担一个探针进程的维护成本,换来的是摘流量的时机从"已经挂了"提前到"快撑不住了"。
保留的回退路径:探针进程自身异常时,网关退回方案 A 的 /health 判定,
即探针不可用不能导致后端全被摘掉——探针故障必须 fail-open。
# 7. 可复用要点
推理节点的容量单位是 token,不是 QPS。
num_gpu_blocks × block_size就是 KV 总容量;能扛多少并发完全由平均上下文长度决定, 同一台机器在 1K 和 128K 上下文下的并发能力可以差两个数量级。谈 QPS 之前先问上下文长度。显存和 GPU 利用率都不是健康信号。 显存是启动即固定的预分配,利用率是突发负载下 会读到 0% 的采样值。真信号是
num_requests_waiting(过载)和num_preemptions_total(越线),两条都在/metrics里。/health是存活不是就绪。 一个队列积压、KV 打满的实例照样毫秒级返回 200。 就绪判定必须建在/metrics的复合条件上,并且探针本身要 fail-open。检查网关侧的默认值,它们是为毫秒级 API 设计的。 健康检查默认关闭 (
HEALTHCHECKS_OFF不报错、不告警,只在后端真挂那天显形)、重试默认 5 次 (把一次故障放大 5 倍并让请求重新排队)。推理后端要显式改成"开检查、少重试、长超时"。Agent 类负载是 prefill 密集的,实测输入输出比可达 65:1。 优化和成本都在输入侧: 前缀缓存命中率(实测 72.9%)是第一优化项,且它掉下来通常是上游改了 prompt 模板, 不是引擎故障——所以它值得单独一条告警。
🤖 Agent 可直接解析的元数据块(点击展开)
{
"_meta": {
"doc_version": "2026-08-24",
"topic": "vllm-inference-node-operations",
"audience": "SRE / platform engineer running self-hosted LLM inference"
},
"environment": {
"gpu": "8x A100-80GB (SXM4)",
"engine": "vLLM 0.19.1",
"python": "3.10.18",
"model_class": "30B-class coder model",
"gateway": "Kong 3.9.1"
},
"engine_config": {
"gpu_memory_utilization": 0.9,
"num_gpu_blocks": 25730,
"block_size": 1056,
"cache_dtype": "fp8",
"enable_prefix_caching": true,
"kv_capacity_tokens": 27170880
},
"observed_window": {
"duration_hours": 3.3,
"requests": 1352,
"ttft_avg_s": 0.944,
"ttft_p95_s_approx": 5.0,
"ttft_p99_s_approx": 7.5,
"e2e_avg_s": 5.5,
"tpot_avg_ms": 9.0,
"queue_time_avg_ms": 59,
"prompt_tokens": 49418894,
"generation_tokens": 752517,
"prefill_to_decode_ratio": 65.7,
"prefix_cache_hit_rate": 0.729,
"preemptions": 0,
"finished_reason_length_pct": 3.0
},
"key_metrics_to_watch": [
"vllm:num_requests_waiting",
"vllm:num_preemptions_total",
"vllm:time_to_first_token_seconds_bucket",
"vllm:request_time_per_output_token_seconds",
"vllm:request_queue_time_seconds",
"vllm:prefix_cache_hits_total / vllm:prefix_cache_queries_total",
"vllm:request_success_total{finished_reason}"
],
"anti_signals": [
"gpu memory utilization (pre-allocated, constant)",
"nvidia-smi utilization.gpu (instantaneous sample, 0% while idle between bursts)",
"/health status code (liveness only, not readiness)"
],
"gateway_defaults_to_override": {
"kong_upstream_healthchecks": "OFF by default -> enable active http check on /health",
"kong_service_retries": "5 by default -> set to 1 for inference backends",
"kong_read_timeout_ms": "must cover p99 e2e latency"
},
"q9_decision": {
"choice": "方案 B:基于 /metrics 的复合就绪探针",
"reason": "推理服务的主流故障形态是『活着但不可用』,/health 覆盖不到",
"trade_off": "多一个自维护探针进程,门槛值需按上下文长度调",
"safety_rule": "探针故障必须 fail-open,回退到 /health 判定"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63