L5.4 可观测性与 SRE
三维坐标
layer: L5(MLOps/LLMOps)|level: Senior|pillar: 训推框架本文聚焦「把一个跑起来的 AI 服务,变成一个看得见、管得住、算得清的工业系统」。我们沿着 SRE 的四条主线展开:全栈监控(看得见硬件)、分布式链路追踪(看得清长尾)、SLO 与告警(管得住质量)、成本可观测(算得清经济账)。这是从「能跑」到「能稳定营收」的关键一跃。
学习目标
- 前置知识:理解 L4 推理层的核心服务指标(尤其 TTFT / TPOT / P99 与吞吐-延迟权衡);读过 L5.1–L5.3(实验管理、模型注册与 CI/CD、部署与回滚),知道一个模型「从训练产物到线上服务」是怎么落地的;会用基础 Docker / Python,听说过 Prometheus 即可,无需 SRE 实战经验。
- 学完产出:① 能画出一条指标的完整生命周期(GPU 硬件计数器 → Exporter → Prometheus Pull → Grafana/Alertmanager → 值班),并说清 Pull 模型为何能把「目标失联」本身变成可监控信号;② 能列出 AI Infra 区别于普通 Web 服务的核心指标(MFU、KV Cache 利用率、显存碎片率、NVLink/NCCL 通信占比、TTFT/TPOT),并解释「GPU 利用率 95% 却 MFU 28%」这类假象的成因;③ 能把 SLI → SLO → Error Budget → Burn Rate → 多窗口多燃烧率告警这条逻辑链讲通,并说清错误预算如何反向约束「发不发版」;④ 能用 OpenTelemetry 的 TraceID + Span 思路设计一套长尾归因方案,理解为什么大集群必须用 Tracing 而非平均值定位 Tail Latency;⑤ 亲手用 Docker Compose 起一套 Prometheus + Grafana,采集 mock 推理服务指标并触发一条真实可见的 SLO 告警闭环。
- 阅读姿势:盯住一条主线——「可观测性不是为了好看的看板,而是为了把『服务质量』和『花的钱』都变成可量化、可告警、可追责的工程信号」。无论是看得见硬件的 Metrics、看得清长尾的 Tracing,还是管得住质量的 SLO 与算得清经济账的 FinOps,本质都在回答同一个问题:当系统出问题或在烧钱时,你能不能在用户和老板发现之前先看见它。
背景与现状
传统 Web 服务的可观测性,三大支柱是 Metrics(指标)/ Tracing(链路)/ Logging(日志)。但大模型基础设施有它独有的可观测性盲区——这些盲区恰恰是普通 APM(应用性能监控)工具看不到的:
- 显存碎片(Memory Fragmentation):
nvidia-smi报 60% 显存空闲,但新请求却 OOM——因为 KV Cache 把显存切成了碎片。这是 GPU 服务独有的「假空闲」陷阱。 - NVLink / NCCL 通信延迟:多卡训练里 90% 的「GPU 利用率」可能是在等 All-Reduce。普通监控只看 SM 占用率,看不到「卡在通信上」。
- MFU(Model FLOPs Utilization,模型算力利用率):GPU 利用率 100% ≠ 算力用满。MFU 才是衡量「这块卡的钱有没有花在刀刃上」的黄金指标,业界训练大模型常年在 30%–50% 徘徊。
从产业演进看,这条线索可以概括为三个阶段:
- 2020 前:infra 监控 = 主机监控(CPU / 内存 / 磁盘),GPU 只看一个利用率数字。
- 2020–2023:随着千卡集群普及,DCGM-Exporter 把 GPU 内部指标(SM 时钟、HBM 带宽、ECC 错误、NVLink 流量)暴露给 Prometheus,可观测性下沉到硬件。
- 2023 至今:推理成本压倒训练,SLO + FinOps 成为主线——不再问「服务挂没挂」,而是问「P99 是否守住、每个 token 花了多少钱、这笔账记到哪个业务头上」。
业界信号:Prometheus + Grafana 已是 Kubernetes 生态事实标准;NVIDIA DCGM-Exporter、OpenTelemetry 成为 GPU 集群可观测性的默认拼图。可观测性已从「运维选配」变成「SLA 合同的技术底座」。
原理与架构
2.1 监控采集链路:从硬件寄存器到告警
理解可观测性,最有效的方式是沿着一条指标的完整生命周期——从 GPU 硬件计数器出发,一路走到值班手机的告警。
沿链路读这张图:硬件计数器(GPU 内部寄存器)→ Exporter 翻译成 Prometheus 文本格式暴露 /metrics → Prometheus 周期性 Pull(拉取) 并存入时序数据库 → 一路给 Grafana 出图、一路按 告警规则(alert rule) 评估 → 触发后交给 Alertmanager 做分组去重 → 推到值班。
值得注意的是:Prometheus 是 Pull 模型(自己去抓),不是 Push。这意味着「目标失联」本身就是一个可监控信号(
up == 0),而 Push 模型里「不上报」和「服务死了」无法区分。这是选型时常被忽略的设计哲学。
2.2 AI Infra 必须监控的核心指标
| 维度 | 关键指标 | 为什么 AI 服务特别在意 |
|---|---|---|
| 显存 | KV Cache 利用率、显存碎片率、HBM 带宽利用率 | 显存是推理的硬约束,碎片导致「假 OOM」 |
| 通信 | NVLink 带宽利用率、NCCL All-Reduce 耗时、通信/计算占比 | 多卡场景下通信常是真正瓶颈 |
| 算力 | MFU、SM 占用率、Tensor Core 利用率 | GPU 利用率会骗人,MFU 才反映真实性价比 |
| 服务质量 | QPS、TTFT(首 token 延迟)、TPOT(token 间延迟)、P99 | LLM 流式输出有独特的「首 token / 后续 token」两段延迟 |
LLM 特有:传统服务只看「请求总延迟」,而 LLM 必须拆成 TTFT(Time To First Token) 和 TPOT(Time Per Output Token)。前者决定用户「等多久看到第一个字」,后者决定「打字速度」——两者的 SLO 完全不同。
2.3 SLI / SLO / Error Budget 与告警的关系
逻辑链:你先选出能代表用户体验的 SLI(如 P99 延迟) → 定一个可量化的 SLO(99.9% 的请求 P99 < 200ms) → SLO 的「反面」就是 Error Budget(错误预算,0.1%) → 监控预算的 燃烧率(Burn Rate) → 用「快慢双窗口」组合告警(快窗抓突发故障,慢窗抓慢性恶化)→ 触发后做热重排 / 扩容,或冻结发布以保住剩余预算。
MFU 观测的工程意义:训练侧把 MFU 当作核心 SLI。MFU 突然下跌往往意味着发生了热点偏移(某些专家/分片负载倾斜)或通信退化,此时触发 热重排(rebalancing)——动态调整数据/专家分布把负载摊平,是大规模训练里最典型的 SRE 自愈动作。
2.4 分布式链路追踪:定位长尾延迟
单机 P99 看着正常,但端到端 P99 爆炸——问题往往藏在跨节点的某一跳。这就是 OpenTelemetry(OTel) 分布式链路追踪的战场:通过 TraceID 把一个请求穿过网关 → 路由 → 调度器 → 多个 GPU worker 的全过程串成一条 Span(跨度)链,让你一眼看出「这 300ms 里,280ms 卡在了 worker-7 的排队上」。
长尾的本质:在 N 个并行子任务里,端到端延迟由最慢的那一个决定。GPU 越多,「至少一个慢」的概率越高——这就是为什么大集群必须用 Tracing 而非平均值来定位长尾(Tail Latency)。
动手实践:极简代码实操
实验目标:用 Docker Compose 起一套 Prometheus + Grafana,采集一个用 Python 写的 mock 推理服务 暴露的指标(QPS / P99 / KV 利用率),并配置一条 SLO 告警规则(P99 超阈值即告警)。纯 CPU 可跑,无需 GPU。产出物:Grafana 看板 + 一条能被触发的告警。
3.1 目录结构
mkdir -p obs-lab && cd obs-lab
# 最终结构:
# obs-lab/
# ├── mock_infer.py # 暴露 /metrics 的 mock 推理服务
# ├── prometheus.yml # Prometheus 抓取配置
# ├── alert_rules.yml # SLO 告警规则
# └── docker-compose.yml # 一键起 prometheus + grafana
3.2 mock 推理服务(暴露 Prometheus /metrics)
# mock_infer.py —— 用 prometheus_client 暴露推理指标,纯 CPU
# pip install prometheus_client
import random
import time
import threading
from prometheus_client import start_http_server, Counter, Histogram, Gauge
# 请求总数(用于算 QPS:rate(infer_requests_total[1m]))
REQUESTS = Counter("infer_requests_total", "推理请求总数", ["status"])
# 请求延迟直方图(用于算 P99:histogram_quantile)
LATENCY = Histogram(
"infer_request_latency_seconds", "推理请求延迟(秒)",
buckets=(0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0),
)
# KV Cache 利用率(模拟显存压力)
KV_UTIL = Gauge("infer_kv_cache_utilization", "KV Cache 利用率 0-1")
def serve_one():
"""模拟一次推理:随机延迟,偶发慢请求触发长尾。"""
base = random.uniform(0.03, 0.12)
# 10% 概率制造长尾,用来触发 SLO 告警
tail = random.uniform(0.4, 1.5) if random.random() < 0.10 else 0.0
latency = base + tail
time.sleep(latency)
LATENCY.observe(latency)
REQUESTS.labels(status="ok").inc()
KV_UTIL.set(min(0.95, random.gauss(0.6, 0.15)))
def workload():
while True:
serve_one()
if __name__ == "__main__":
start_http_server(8000) # /metrics 暴露在 :8000
print("[mock-infer] metrics on http://0.0.0.0:8000/metrics")
# 起多个并发「请求」制造流量
for _ in range(4):
threading.Thread(target=workload, daemon=True).start()
while True:
time.sleep(3600)
3.3 Prometheus 抓取配置
# prometheus.yml
global:
scrape_interval: 5s # 每 5s 拉一次
evaluation_interval: 5s # 每 5s 评估一次告警规则
rule_files:
- /etc/prometheus/alert_rules.yml
scrape_configs:
- job_name: "mock-infer"
static_configs:
# host.docker.internal 让容器访问宿主机上的 python 服务
- targets: ["host.docker.internal:8000"]
3.4 SLO 告警规则(P99 超阈值)
# alert_rules.yml —— SLO: 99% 请求 P99 延迟应 < 200ms
groups:
- name: slo-latency
rules:
# 先用录制规则算出近 1 分钟的 P99(单位:秒)
- record: job:infer_latency:p99_1m
expr: histogram_quantile(0.99, sum(rate(infer_request_latency_seconds_bucket[1m])) by (le))
- alert: InferP99SLOBreach
expr: job:infer_latency:p99_1m > 0.2 # 阈值 200ms
for: 30s # 持续 30s 才告警,避免抖动
labels:
severity: page
annotations:
summary: "推理 P99 延迟突破 SLO"
description: "近 1 分钟 P99 = {{ $value }}s,已超过 200ms 阈值,正在消耗 Error Budget。"
3.5 一键编排
# docker-compose.yml
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- ./alert_rules.yml:/etc/prometheus/alert_rules.yml
extra_hosts:
- "host.docker.internal:host-gateway" # Linux 下让容器能回访宿主机
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
3.6 运行与观察
# 终端 1:起 mock 推理服务(宿主机直接跑)
python3 -m venv .venv && source .venv/bin/activate
pip install prometheus_client
python mock_infer.py
# 终端 2:起监控栈
docker compose up -d
# 验证:
# 1) 指标已暴露
curl -s localhost:8000/metrics | grep infer_request_latency
# 2) Prometheus 已抓到目标 -> 浏览器 http://localhost:9090/targets 应为 UP
# 3) 看 P99 -> Prometheus 查询框输入:
# histogram_quantile(0.99, sum(rate(infer_request_latency_seconds_bucket[1m])) by (le))
# 4) 告警 -> http://localhost:9090/alerts 约 1 分钟后 InferP99SLOBreach 会进入 PENDING/FIRING
# 5) Grafana -> http://localhost:3000 (admin/admin) 添加 Prometheus 数据源(http://prometheus:9090)出图
由于 mock 服务有 10% 概率制造 0.4–1.5s 的长尾,P99 几乎必然超过 200ms,因此 1 分钟左右你就能在 /alerts 页面看到告警从 PENDING 变为 FIRING——这就是一条完整的、可被触发的 SLO 告警闭环。
本机实测(docker compose):
Prometheus Server is Ready.
Grafana health: {"database":"ok","version":"13.1.0",...}
[alerts] count=1
[ok] observability stack smoke passed
一键复现:bash run_obs_stack.sh。
踩坑预警 (Gotchas)
host.docker.internal在 Linux 默认不存在:必须在 compose 里显式加extra_hosts: ["host.docker.internal:host-gateway"],否则 Prometheus targets 一直DOWN。或把 mock 服务也容器化、用服务名互访。- P99 不能对预聚合值再求平均:必须用
histogram_quantile配合_bucket原始桶计算。直接avg(p99)跨实例求平均在数学上无意义,是最常见的 SLI 计算错误。 rate()时间窗口要 ≥ 4× scrape_interval:本例 scrape 5s,用[1m]窗口稳妥。窗口太短会因采样点不足出现锯齿甚至NaN。- Histogram 桶设错 = P99 不准:分 位数精度受桶边界限制。若你的延迟集中在 50–200ms,却没在该区间设细桶,算出的 P99 会被「拍平」到最近的桶边界。
- 告警
for太短会风暴:去掉for: 30s会让瞬时抖动也告警。生产环境推荐「多窗口多燃烧率」而非单一阈值。
深入思考
下面三题每题先给题干,再用
<details>折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。
思考题 1:假空闲与真瓶颈
线上推理服务 nvidia-smi 显示 GPU 利用率常年 95%,但 MFU 经测算只有 28%。结合 2.2 节的核心指标矩阵,解释这两个数字为什么能同时成立?你会监控哪些额外指标(提示:通信/计算占比、KV Cache 命中、batch 充满度)来定位「GPU 忙但没干正事」的真因?
展开参考答案(含「忙 vs 有效」分解图 + 算一遍)
结论:nvidia-smi 的利用率只回答「过去一段采样窗口里 SM 上有没有指令在跑」,它把「等通信」「等访存」「跑了一堆没填满的小 kernel」全都算成「忙」;而 MFU 衡量的是「这块卡的理论算力里有多少真正花 在了有效矩阵乘上」。所以「忙」与「没干正事」可以同时为真——GPU 在忙着搬数据、等 All-Reduce、跑半空的 batch。
用具体数字算一遍(理解 MFU 口径,数量级估算):
- 设单卡稠密 BF16 峰值算力约 990 TFLOPS(H100 量级),实测有效算力 = 模型每秒真正完成的浮点运算。
- 若 MFU = 28%,意味着有效算力 ≈ 990 × 0.28 ≈ 277 TFLOPS——剩下的 ~72% 的「卡时」被通信、访存、空泡(pipeline bubble)、半空 batch 吃掉了。
- 同一时刻
nvidia-smi仍可能报 95%:因为它统计的是「采样间隔内是否至少有一个 warp 在某个 SM 上活跃」,等 All-Reduce 时 SM 上挂着通信 kernel 也算「活跃」。
该补监控的额外指标:
| 怀疑方向 | 该看的指标 | 判据 |
|---|---|---|
| 通信吃掉算力 | NCCL All-Reduce 耗时占比、NVLink 带宽利用率、通信/计算时间比 | 通信占比 > 30% → 通信受限 |
| 访存受限 | HBM 带宽利用率、KV Cache 命中率、Tensor Core 实际利用率 | 带宽打满但 TC 利用率低 → 访存墙 |
| batch 没填满 | 平均 batch size、batch 充满度、padding 比例 | 充满度低 → 调大 batch / 连续批处理 |
定位口诀:nvidia-smi 看「忙不忙」,MFU 看「值不值」,中间这层差距靠通信占比 + 访存 + batch 充满度三类指标拆解。
思考题 2:长尾归因
端到端 P99 = 800ms,但每个 GPU worker 单独看 P99 都 < 100ms。结合 2.4 节的分布式链路追踪,请用 OpenTelemetry 的 TraceID + Span 思路设计一套追踪方案,定位这 700ms 去了哪里——是网关排队、跨节点 RPC、还是某个 worker 的 KV Cache 抢占?并说明为什么「看平均值」在这里会彻底误导你。
展开参考答案(含一条请求的 Span 链分解图)
结论:单机 P99 正常而端到端 P99 爆炸,说明那 700ms 不在任何单个 worker 的计算里,而藏在「请求穿越系统时跨过的某一跳」——网关排队、调度路由、跨节点 RPC、或某 worker 的 KV Cache 被抢占而排队。只有把同一个 TraceID 串起的所有 Span 按时间轴铺开,才能看出哪一段 Span 吃掉了 700ms;平均值会把这条慢链稀释进成千上万条快链里,让问题彻底隐身。
方案设计(OTel 落地步骤):
- 入口生成 TraceID:网关为每个请求生成全局唯一 TraceID,通过 HTTP header(
traceparent)向下游透传。 - 逐跳埋 Span:网关、路由器、每个 worker、KV Cache 管理器各开一个 Span,记录 start/end 时间戳与关键属性(worker_id、queue_depth、kv_evicted 等)。
- 跨进程传播 context:跨节点 RPC 时把 TraceID + SpanID 注入请求,下游用它作为父 Span,串成一棵完整调用树。
- 按 P99 采样而非随机采样:用 tail-based sampling(尾部采样),优先保留慢请求的完整 trace——否则正好把那条 800ms 的链采丢了。
怎么读出 700ms 去哪了:把这条 trace 的所有 Span 按时间轴铺 开,看哪段最宽。若 RPC Span 占 650ms → 跨节点网络问题;若 worker 计算 Span 正常但前面有个 700ms 的 kv_wait 子 Span → KV Cache 被高优先级请求抢占而排队。
「看平均值」为何误导:长尾的本质(呼应 2.4 节)是「端到端延迟由最慢的那一跳决定」。
- 设 99% 请求 50ms、1% 请求 800ms,则平均 ≈ 50 × 0.99 + 800 × 0.01 ≈ 57.5ms——平均值看着很健康,完全掩盖了那 1% 的灾难。
- 而用户体验由 P99 = 800ms 定义。平均值把信号稀释成噪声,分位数 + 单条 trace 才能精确归因。
思考题 3:成本 chargeback 设计
公司有 3 个业务线共享同一个 100 卡推理集群。CFO 要求按实际用量把 GPU-hour 成本分摊(chargeback) 到各业务线。结合 2.2 节的指标体系与背景中的 FinOps 主线,设计一套可观测性方案:如何打标签、采集哪些指标(GPU-hour、token 数、请求数),来既公平又可审计地把「每 1000 token 的成本」算到对应业务头上?FinOps 异常检测又该盯哪个指标的突变?
展开参考答案(含成本归集链路图 + 单价对比表)
结论:chargeback 的本质是「把一笔共享的 GPU-hour 总成本,按一个可审计的用量口径切给各业务线」。可观测性层面要做三件事:给每条请求/每个 Pod 打上 tenant(业务线)标签、把标签贯穿到 token 级用量指标、再用「成本 ÷ 用量」算出可对账的单价(如每 1000 token 成本);FinOps 异常检测则盯「单位成本」这一比率指标的突变,而非绝对开销。
采集口径(既要公平又要可审计):
- 打标签:在请求入口注入
tenant标签,向下贯穿到 Pod label、Prometheus 指标 label({tenant="A"});Kubernetes 侧用 namespace/label 把 Pod 归属到业务线。 - GPU-hour:
卡数 × 占用时长,乘以该型号 GPU 的折算单价(含采购摊销 + 电费 + 机房)。共享卡需按时间片或显存份额拆分。 - token / 请求:
infer_tokens_total{tenant}(区分 prompt / completion token)与infer_requests_total{tenant},作为分摊的「用量分母」。
为什么用「每 1000 token 成本」做主口径:GPU-hour 是供给侧成本,token 是需求侧产出。直接按 GPU-hour 分摊对「请求小但常驻」的业务不公平;按 token 折算把成本和真实业务产出对齐,最贴近「这笔钱产生了多少价值」。
单价对比表(示例,便于对账):
| 业务线 | 月度 GPU-hour | 月度 token 数 | 分摊成本 | 每 1000 token 成本 |
|---|---|---|---|---|
| A(对话) | 30000 | 90 亿 | 90000 美元 | 0.010 美元 |
| B(批量摘要) | 50000 | 400 亿 | 150000 美元 | 0.00375 美元 |
| C(实时翻译) | 20000 | 30 亿 | 60000 美元 | 0.020 美元 |
读表:C 业务每 1000 token 成本是 B 的 5 倍多——多半因为实时翻译追求低延迟、batch 充满度低,GPU 没跑满。这正是 chargeback 的价值:把「谁在低效烧钱」量化到可追责。
FinOps 异常检测该盯什么:盯**「每 1000 token 成本」这个比率指标的突变**,而非绝对开销。绝对成本随业务量自然起伏,会误报;而单价突然抬高(如某业务线从 0.010 跳到 0.025 美元)几乎一定意味着出了效率问题——batch 充满度掉了、长尾变多导致重试、或路由把流量打到了贵卡上。比率指标才是 FinOps 告警的正确锚点。
延伸阅读
专题深入:Prometheus 指标与 SLO 告警本章已覆盖;OpenTelemetry 链路追踪的完整动手实验(RAG Span 树、GenAI 语义属性、OTLP 导出)见 L5.6 OpenTelemetry 追踪。事故响应与 postmortem 流程见 L5.7 事故响应与复盘。
1. 核心 Paper / 规范
- Google SRE Book —— SLI/SLO/Error Budget、多窗口多燃烧率告警、postmortem 文化的权威源头(章节 "Service Level Objectives" / "Being On-Call")。
- Dapper, a Large-Scale Distributed Systems Tracing Infrastructure(Google,2010)—— 分布式链路追踪的鼻祖,OpenTelemetry 的思想原点。
2. 相关高 Star 仓库与源码必读路径
prometheus/prometheus—— 看promql/理解histogram_quantile/rate的实现,scrape/理解 Pull 模型。NVIDIA/dcgm-exporter—— 看它把哪些 GPU 硬件计数器(显存碎片、NVLink、SM 时钟、ECC)映射成 Prometheus 指标。open-telemetry/opentelemetry-collector—— 看 receiver / processor / exporter 的 pipeline 设计,是统一 Metrics/Traces/Logs 的事实标准。
3. 优质博客 / 视频
- Grafana Labs 官方博客「How to monitor GPU workloads」系列与 DCGM 集成实践。
- Google Cloud「SRE Workbook」中的 Alerting on SLOs 与 Postmortem 模板,直接可落地到团队流程。
下一篇 → L5.5 工程化方法论:Benchmark 可复现性与职业成长:从可观测性的「看见」走向「治理」,讲清大模型工程的研发流程、规范与质量门禁。