目录

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: labeldrop

job 保持稳定。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_totalllm_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 会抖成噪声,看起来像故障。跨实例聚合必须先 ratesum;先 sumrate 会把不同实例的计数器重置搅在一起。

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.1claude-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。

  1. 变量jobmodelinstance。默认 model 可多选。变量值从 label_values(llm_http_requests_total, model) 来,不要手打。
  2. 第一行 RED:QPS(stat + timeseries)、错误率、p50 / p95 / p99。p 分位走 recording rule,或走带 $__rate_intervalhistogram_quantile
  3. 第二行 LLM:token / s(按 direction 分色)、每次请求 completion token、TTFT p95。流式服务 TTFT 和总时长并排,才能看出是排队还是生成慢。
  4. 第三行分布:时长 heatmap(查询 format 设 Heatmap,用 _bucket)。平均线上看不见的双峰(短成功 + 长超时)在热力图上是两块。
  5. 第四行饱和度: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]) 没掉,HTTP code="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。