Prometheus + Grafana 监控实战:让 LLM 服务可见
LLM 服务挂了而没人知道,多半是只盯着 CPU。Prometheus + Grafana 能看见请求,但默认的 node 面板看不见 token、TTFT,也看不见 model 标签把时序基数打爆。
下面按抓取 → 查询 → 规则 → 面板写。正文里的 llm_* 名字是示例指标名,不是 Prometheus 或某个网关的标准导出。接入前对照实际 /metrics。
先把请求、错误、分位延迟看成普通 HTTP 服务,再叠加 token 和 TTFT。CPU 只解释饱和,不解释质量。
下面的 YAML 和 PromQL 是可改的示意,指标名按实际 /metrics 替换。没有 histogram 就先补埋点;Summary 不能跨实例聚合 p95,分位只在查询侧计算即可。
选 / 不选
| 选 | 不选 |
|---|---|
直方图 + histogram_quantile 看 p95 / p99 |
只用 _sum / _count 当「平均延迟」交差 |
model 当作有界枚举标签 |
user_id / prompt_id / 请求正文当标签 |
recording rule 喂仪表盘;alert 带 for: |
每个面板现场算一遍巨型 PromQL |
| RED 第一行,token / TTFT 第二行,CPU 垫底 | 一张盘全是 node_cpu,LLM 当装饰 |
scrape 之后 labeldrop 掉高基数标签 |
靠「以后再清」让 TSDB 先胀死 |
抓取和 relabel
官方 scrape_config 里,relabel_configs 改的是抓取前的目标标签;metric_relabel_configs 改的是抓取后的样本标签。高基数标签只能在后一层丢掉,抓进来再后悔会写盘。
示意 prometheus.yml(路径、端口按部署改;未在本机跑过这一份):
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: llm-gateway
metrics_path: /metrics
scrape_interval: 15s
scrape_timeout: 10s
static_configs:
- targets:
- 127.0.0.1:8000
labels:
service: llm-gateway
relabel_configs:
- source_labels: [__address__]
regex: "([^:]+):\\d+"
target_label: instance
replacement: "${1}"
metric_relabel_configs:
- regex: "user_id|prompt_id|request_id|session_id"
action: labeldropjob 保持稳定。instance 从 __address__ 剥端口,避免每次滚动发布换端口就换一条时序。labeldrop 的名单按实际泄漏的标签改;库如果把 prompt 文本写进标签,那条样本没有保留价值。
scrape_interval 15s 对 LLM 网关够用。TTFT 和 token 是累计值,不需要 1s 刮一次。刮太勤,histogram bucket 时序会先把 Prometheus 自己打满。scrape_timeout 必须小于 interval;目标卡住时,超时的那一轮应记成 scrape 失败,而不是把半截样本写进去。
Kubernetes 服务发现用官方的 kubernetes_sd_configs + __meta_kubernetes_* relabel,不要手写一份永远过期的 static_configs 当生产。单机或 compose 用上面的 static 即可。多个网关副本必须能从标签区分,否则 sum 会把不同模型路由搅成一条线。
指标从哪来:手写 client 库,或 OpenTelemetry 再远程写入 / 导出成 Prometheus 文本。OTel 指数直方图进 Prometheus 会变成 native histogram;经典直方图可以按文档转成 NHCB。查询形状取决于导出格式,下面按仍占多数的经典 _bucket 来写。桶边界一旦写进经典直方图,中途改边界等于换一套时序,旧盘会空。开工前把 SLO 附近的边界留出来,或直接上 native histogram。
RED 不够:延迟用 quantile
RED 仍是第一层:Rate、Errors、Duration。LLM 网关同样是 HTTP(或 gRPC)服务,先把这三件钉住,再加 token。没有 Rate 和 Errors,Duration 的分位无法解释:流量掉光时 p95 会假绿,错误变成超时之后 code 仍可能是 200。
下面 llm_http_requests_total、llm_request_duration_seconds_* 均为示例名。Duration 必须是 histogram(或 native histogram),不要用 Summary 还指望跨实例聚合。
# Rate:每秒请求
sum by (job, model, code) (
rate(llm_http_requests_total[5m])
)
# Errors:非 2xx 占比。code 标签要在导出时就有
sum by (job, model) (
rate(llm_http_requests_total{code!~"2.."}[5m])
)
/
sum by (job, model) (
rate(llm_http_requests_total[5m])
)平均延迟会说谎。一条 30s 的超时被一百条 200ms 的成功稀释之后,_sum / _count 看起来仍「正常」。histogram_quantile 从 bucket 估计分位;经典直方图必须把 le 留在聚合里:
# 不要只写这个交差
rate(llm_request_duration_seconds_sum[5m])
/
rate(llm_request_duration_seconds_count[5m])
# p95:先 rate bucket,再 sum by (le, …),再 quantile
histogram_quantile(
0.95,
sum by (le, job, model) (
rate(llm_request_duration_seconds_bucket[5m])
)
)histogram_quantile 是估计值,误差受 bucket 边界限制。SLO 落在 300ms 而 bucket 只有 0.1 / 1 / +Inf,p95 会漂在错误的桶里。native histogram 按分辨率插值,少一次「提前猜边界」。查询端改 φ 和窗口,不必重做埋点——这是直方图相对 Summary 的理由。
rate() 窗口要盖住至少两到三个 scrape。5m 对 15s 抓取是稳妥默认。窗口短于一个 scrape,quantile 会抖成噪声,看起来像故障。跨实例聚合必须先 rate 再 sum;先 sum 再 rate 会把不同实例的计数器重置搅在一起。
Grafana 时间序列面板里,区间用 $__rate_interval,不要写死 15s 又把看板最小间隔改成 1m。Prometheus 查询编辑器 对 quantile 的示例就是这个形状。
LLM 指标:token、TTFT、model 基数
进程 CPU 上不去,模型已经在换、首字已经在拖、补全已经被 max_tokens 截断。这三件事不在 node_cpu_seconds_total 里。token 是钱和上下文窗口;TTFT 是体感;总时长是排队加生成。三件拆开才能定位。
仍是示例名:
llm_tokens_total:counter。标签direction="prompt|completion"(或拆成两条 counter)。单位是 token 个数,不是美元。llm_ttft_seconds:histogram。Time to first token,流式接口才有意义;非流式可以不导,不要用总时长冒充 TTFT。llm_request_duration_seconds:histogram。从接到请求到收完最后一个 token(或非流式的完整响应)。model:有界枚举。gpt-4.1、claude-sonnet这种闭集可以。用户自定义模型名、lora 文件名、每次请求的 prompt hash 不行。
# 示例:prompt / completion token 速率
sum by (job, model, direction) (
rate(llm_tokens_total[5m])
)
# 示例:每次请求的 completion token(粗平均,不是分位)
sum by (job, model) (rate(llm_tokens_total{direction="completion"}[5m]))
/
sum by (job, model) (rate(llm_http_requests_total[5m]))
# 示例:TTFT p95
histogram_quantile(
0.95,
sum by (le, job, model) (
rate(llm_ttft_seconds_bucket[5m])
)
)命名与标签 写得很直:每个唯一标签组合都是一条新时序。model 三个值 × code 五个值 × instance 两个值,还扛得住。user_id 一万、prompt_id 无界,TSDB 先死,查询后死。le 是直方图自带的;不要再叠 quantile="0.95" 这种 Summary 标签到同一套 histogram 上。
质量分数(人工评测、LLM-as-judge、幻觉抽样)不是 scrape 出来的标准指标。有离线任务写成 gauge 可以另开一块;没有就不要在盘上画一条假装存在的 llm_hallucination_ratio。评测批次应带 eval_run 这种有界标签,不要把每条 prompt 当标签。
Recording rules 和告警草图
看板每 10s 刷一次 histogram_quantile(sum by (le, …)(rate(…))),实例一多就会卡。recording rules 把热查询预计算成新时序。命名用 level:metric:ops 这种约定,避免和原始指标撞名。规则文件用 SIGHUP 或热加载;语法先走 promtool。一组规则算不完会被跳过,p95 盘上会出现空洞,看起来像延迟掉零。
示意 /etc/prometheus/rules/llm.yml:
groups:
- name: llm.recording
interval: 30s
rules:
- record: job_model:llm_http_requests:rate5m
expr: sum by (job, model, code) (rate(llm_http_requests_total[5m]))
- record: job_model:llm_request_duration_seconds:p95
expr: |
histogram_quantile(
0.95,
sum by (le, job, model) (
rate(llm_request_duration_seconds_bucket[5m])
)
)
- record: job_model:llm_ttft_seconds:p95
expr: |
histogram_quantile(
0.95,
sum by (le, job, model) (
rate(llm_ttft_seconds_bucket[5m])
)
)
- record: job_model:llm_tokens:rate5m
expr: sum by (job, model, direction) (rate(llm_tokens_total[5m]))
- name: llm.alerts
rules:
- alert: LLMHighP95Latency
expr: job_model:llm_request_duration_seconds:p95 > 8
for: 10m
labels:
severity: page
annotations:
summary: "LLM p95 latency high ({{ $labels.job }} {{ $labels.model }})"
description: "placeholder threshold 8s; set from the SLO, not from this file"
- alert: LLMHighErrorRatio
expr: |
(
sum by (job, model) (job_model:llm_http_requests:rate5m{code!~"2.."})
/
sum by (job, model) (job_model:llm_http_requests:rate5m)
) > 0.05
for: 5m
labels:
severity: page
- alert: LLMTTFTRegressed
expr: job_model:llm_ttft_seconds:p95 > 2
for: 15m
labels:
severity: ticket
annotations:
summary: "TTFT p95 high; CPU-only dashboards will still look idle"> 8、> 0.05、> 2 是占位阈值,不是本站测过的 SLO。改数字之前先看一周的分位,再写 for:,避免瞬时尖峰翻页。Alerting rules 负责判定;通知、静默、聚合交给 Alertmanager。
promtool check rules 能在加载前验语法。规则组没在下一个 evaluation_interval 前算完会被跳过,recording 时序会出现空洞——复杂 quantile 更该预计算,而不是塞进每个 Grafana 面板。
Grafana 面板怎么分层
Dashboard 按阅读顺序排,不要一张盘四十个 node_exporter 图再在角落塞 token。
- 变量:
job、model、instance。默认model可多选。变量值从label_values(llm_http_requests_total, model)来,不要手打。 - 第一行 RED:QPS(stat + timeseries)、错误率、p50 / p95 / p99。p 分位走 recording rule,或走带
$__rate_interval的histogram_quantile。 - 第二行 LLM:token / s(按
direction分色)、每次请求 completion token、TTFT p95。流式服务 TTFT 和总时长并排,才能看出是排队还是生成慢。 - 第三行分布:时长 heatmap(查询 format 设 Heatmap,用
_bucket)。平均线上看不见的双峰(短成功 + 长超时)在热力图上是两块。 - 第四行饱和度:CPU、内存、队列深度、GPU 利用率(有就导)。这是解释层,不是标题层。CPU 低不能用来结案。目标存活用单独的 stat 看 up;采集断了时所有 LLM 图都会变成没数据,看起来像质量事故,其实是 scrape 停了。单位在 Grafana 里设成 seconds / reqps,不要把秒画成毫秒再口头除一千。
面板少而稳。同一查询不要在六个图里复制六遍:recording rule 算一次,盘上引用。告警阈值和盘上阈值用同一条表达式,避免「盘是绿的、pager 在响」。
只看 CPU 时,质量在静默下降
典型假绿:
rate(node_cpu_seconds_total{mode!="idle"}[5m])平坦,机器「不忙」。rate(llm_http_requests_total[5m])没掉,HTTPcode="200"仍接近 100%。- 同时:
llm_ttft_seconds的 p95 抬升(上游排队或冷启动);每次请求的 completion token 下降(max_tokens被砍、模型提前停);prompt token 上升(上下文越堆越长);model出现一个新值(路由切到更便宜或更慢的模型)。
这四件事任意一件都可以在 CPU 不变、错误率为 0 时发生。调用方收到的是变慢的首字、变短的答案、或换了脾气的模型,进程却很闲。GPU 利用率同样可能不动:等待上游、等待限流、等待 tokenizer,都不烧 GPU。能导出的替代信号是空响应、JSON 解析失败、被策略拦截——仍要在代码里变成 counter,示例名可以是带 outcome 标签的 llm_responses_total,不是标准名。没有这些 outcome 计数时,只能从 token 和 TTFT 侧面推断,不要把 CPU 当质量代理。幻觉、拒答、格式崩溃如果不做离线抽样,Prometheus 里不会出现;把它们画成没有 scrape 的曲线,比不画更糟。
先把 RED 和 TTFT / token 放到 CPU 上面。扩容决定看饱和度;质量回退看分位和 token,不看 idle 百分比。扩容之前先确认 p95 上升是因为排队还是因为模型本身变慢:队列深度和 TTFT 一起涨,才是容量问题;只有总时长涨、TTFT 不动,更像生成侧或 max_tokens。