
目录
摘要
本文以 7B 参数大语言模型在线推理服务为例,构建一套覆盖性能指标、异常检测与日志记录的监控告警体系。 生产标准设定为 P99 首 token 延迟低于 500ms、单卡生成吞吐高于 800 tokens/s、错误率低于 1%。 异常检测以 3 个标准差作为统计告警阈值,将告警误报率控制在 5% 以内。
1. 模型监控概述
大语言模型推理服务与传统 Web 服务在可观测性上存在本质差异。传统服务的请求是无状态、可重放的,响应要么成功要么失败,错误码可以直接暴露故障。而 LLM 推理是状态性的:同一请求被拆分为 KV Cache 写入显存、逐 token 自回归生成,期间任意一步的显存紧张、调度排队或权重损坏都不会产生标准错误码,只表现为输出变慢、截断或语义退化。这种"无声失败"意味着服务可用不代表服务正确,监控必须同时覆盖资源、性能、质量和成本四个维度。
监控的落地形态是三层数据通道。第一层是指标(Metrics):以数值序列描述延迟、吞吐、利用率等可聚合量,由 Prometheus 每 15 秒拉取一次。第二层是日志(Logs):记录每个请求的结构化明细,供单请求回溯与聚合分析。第三层是调用链(Traces):追踪一次请求在网关、推理引擎、向量库之间的流转耗时。三层数据在异常判定阶段汇合,三层都正常的请求才判定为健康。
#mermaid-svg-ThESkP0byS8E2ILJ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ThESkP0byS8E2ILJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ThESkP0byS8E2ILJ .error-icon{fill:#552222;}#mermaid-svg-ThESkP0byS8E2ILJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ThESkP0byS8E2ILJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ThESkP0byS8E2ILJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ThESkP0byS8E2ILJ .marker.cross{stroke:#333333;}#mermaid-svg-ThESkP0byS8E2ILJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ThESkP0byS8E2ILJ p{margin:0;}#mermaid-svg-ThESkP0byS8E2ILJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ThESkP0byS8E2ILJ .cluster-label text{fill:#333;}#mermaid-svg-ThESkP0byS8E2ILJ .cluster-label span{color:#333;}#mermaid-svg-ThESkP0byS8E2ILJ .cluster-label span p{background-color:transparent;}#mermaid-svg-ThESkP0byS8E2ILJ .label text,#mermaid-svg-ThESkP0byS8E2ILJ span{fill:#333;color:#333;}#mermaid-svg-ThESkP0byS8E2ILJ .node rect,#mermaid-svg-ThESkP0byS8E2ILJ .node circle,#mermaid-svg-ThESkP0byS8E2ILJ .node ellipse,#mermaid-svg-ThESkP0byS8E2ILJ .node polygon,#mermaid-svg-ThESkP0byS8E2ILJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ThESkP0byS8E2ILJ .rough-node .label text,#mermaid-svg-ThESkP0byS8E2ILJ .node .label text,#mermaid-svg-ThESkP0byS8E2ILJ .image-shape .label,#mermaid-svg-ThESkP0byS8E2ILJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-ThESkP0byS8E2ILJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ThESkP0byS8E2ILJ .rough-node .label,#mermaid-svg-ThESkP0byS8E2ILJ .node .label,#mermaid-svg-ThESkP0byS8E2ILJ .image-shape .label,#mermaid-svg-ThESkP0byS8E2ILJ .icon-shape .label{text-align:center;}#mermaid-svg-ThESkP0byS8E2ILJ .node.clickable{cursor:pointer;}#mermaid-svg-ThESkP0byS8E2ILJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ThESkP0byS8E2ILJ .arrowheadPath{fill:#333333;}#mermaid-svg-ThESkP0byS8E2ILJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ThESkP0byS8E2ILJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ThESkP0byS8E2ILJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ThESkP0byS8E2ILJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ThESkP0byS8E2ILJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ThESkP0byS8E2ILJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ThESkP0byS8E2ILJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ThESkP0byS8E2ILJ .cluster text{fill:#333;}#mermaid-svg-ThESkP0byS8E2ILJ .cluster span{color:#333;}#mermaid-svg-ThESkP0byS8E2ILJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ThESkP0byS8E2ILJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ThESkP0byS8E2ILJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-ThESkP0byS8E2ILJ .icon-shape,#mermaid-svg-ThESkP0byS8E2ILJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ThESkP0byS8E2ILJ .icon-shape p,#mermaid-svg-ThESkP0byS8E2ILJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ThESkP0byS8E2ILJ .icon-shape .label rect,#mermaid-svg-ThESkP0byS8E2ILJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ThESkP0byS8E2ILJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ThESkP0byS8E2ILJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ThESkP0byS8E2ILJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-ThESkP0byS8E2ILJ .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-ThESkP0byS8E2ILJ .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-ThESkP0byS8E2ILJ .default tspan{fill:#000000!important;}
观测采集层
在线服务层
动态批处理回环
未越界
越界
处理结论写回
客户端请求
API 网关
推理引擎
返回响应
指标探针
Prometheus
日志采集器
Elasticsearch
调用链采样
追踪存储
综合判定
指标归档
告警系统
值班人员
1.1 监控的必要性
推理服务的故障面比常规服务宽得多,且多数故障在错误率指标上看不出来。以一个 7B 模型单卡部署为例:权重 FP16 约 14GB,单请求上下文 4096 token 时 KV Cache 约占用 0.5GB,A100 80GB 显存上并发 16 时显存余量不到 30GB。一旦并发模型或提示词长度分布变化,显存水位会在几分钟内越过 95% 的告警线,触发 OOM 或抢占式批处理的被动逐出,表现为 P99 延迟从 500ms 攀升到 3s,而平均延迟只从 380ms 涨到 520ms。只看平均值会漏掉这类故障。
另一个被低估的监控对象是模型版本。模型文件损坏、量化误差累积、或加载了错误的 checkpoint 时,推理服务完全正常,但生成质量下降。这类质量回归只有通过输出侧采样评估才能发现:每 1000 个请求抽取 1 个,用固定评估集计算困惑度变化,偏差超过 0.15 时回滚版本。监控的必要性还体现在成本上:一个 7B 模型的单次生成约消耗 7 GFLOPs 量级的计算,空闲 GPU 每小时的电费与摊销成本按 1.2 美元估算,监控是发现闲置与错配的唯一途径。
监控的核心价值是缩短平均发现时间(MTTD)与平均恢复时间(MTTR)。接入逐层埋点后,一次显存告警的发现时间可以从人工巡检的 30 分钟压缩到探针上报的 15 秒以内。量化收益可以这样估算:每月一次 P1 级故障,故障持续 2 小时,每 10 分钟损失约 40 次请求的 SLA 违约,监控到位后违约次数可减少 80%。
监控缺失的成本还可以从另一个角度量化。一次未被发现的模型质量回归,用户侧会以投诉、流失和差评的形式体现,一次事故的赔偿与口碑损失通常超过监控体系一年的运行成本。以一台 A100 每小时 3.5 美元的租赁成本计算,24 小时无人值守的空转损失为 84 美元;而一套覆盖 10 台 GPU 的监控栈,Prometheus 与 Grafana 均为开源软件,每月的人力维护成本折合约 1 人天。投入产出比决定了监控不是可选优化,而是推理服务上线的强制前置条件。
观测数据的三类形态各有覆盖盲区,监控必要性也体现在"盲区互补"上。指标覆盖资源与性能,日志覆盖请求级明细,调用链覆盖跨服务流转,三者单独使用都会漏掉故障:指标看不出单个请求的失败原因,日志看不出全局趋势,调用链在单机部署时毫无价值。只有三类数据在同一故障时间轴上对齐,才能回答"是什么、为什么、影响多大"三个问题,这也是后续章节分层设计的依据。
监控的必要性最终落在组织能力上:没有监控就没有指标,没有指标就没有容量决策与成本核算。GPU 集群的扩缩容、模型量化方案的选择、并发参数的调优,每一项都依赖监控数据做回归验证。推理服务上线监控,等价于为每一项技术决策安装测量仪器,这层测量能力一旦缺失,后续所有优化都退化为凭感觉行事。
# 来源:prometheus_client 0.20.0 / health_probe.py
from prometheus_client import Counter, Gauge, Histogram, start_http_server
import random
import time
# 三个核心观测量:请求计数、延迟直方图、模型版本
REQUEST_TOTAL = Counter("llm_requests_total", "累计请求数", ["model", "outcome"])
LATENCY = Histogram(
"llm_latency_seconds",
"请求端到端延迟",
buckets=(0.1, 0.3, 0.5, 1.0, 2.0, 5.0, float("inf")),
)
VERSION = Gauge("llm_model_version", "当前加载的模型版本号")
def main() –> None:
"""暴露 /metrics 端口,每 1 秒产生一条模拟观测样本。"""
start_http_server(9101)
VERSION.set(37)
while True:
outcome = "ok" if random.random() < 0.99 else "error"
REQUEST_TOTAL.labels(model="qwen-7b", outcome=outcome).inc()
# 模拟延迟分布:均值 0.4s,P99 落在 0.5-1.0s 桶内
LATENCY.observe(max(0.0, random.gauss(0.4, 0.1)))
time.sleep(1)
if __name__ == "__main__":
main()
上述探针把观测从"人工巡检"变为"机器采集"。指标在进程内完成分桶聚合,单进程开销小于 1% CPU,避免了为每个请求落一条日志的高昂成本。探针端口由 Prometheus 抓取后,延迟分位数、错误率、版本号立即进入时序库,后续所有章节的异常检测与告警都建立在这一层观测数据之上。
1.2 监控挑战
LLM 服务给监控系统本身带来了四个可量化的挑战。第一是标签基数爆炸:请求按模型版本、量化方式、GPU 编号、地区、prompt 前缀等多维打标,标签笛卡尔积可达 10 万级。Prometheus 时序库对每条时间序列都要保留内存索引,序列数从 1000 涨到 10 万时,单节点内存占用从约 50MB 涨到 5GB,查询延迟从 100ms 级退化到秒级。
第二是高吞吐下的写入压力。100 QPS 的服务若对每个请求记录 6 个指标样本,每 15 秒抓取周期内要写入约 6 万条样本;若延迟直方图分 7 个桶,实际写入量乘以 7 倍,约 42 万样本。Prometheus TSDB 单节点每秒可吸收约 50 万样本,但叠加查询负载后会形成写放大,需要按指标类型分流到不同 job。
第三是指标间相关性。GPU 利用率、KV Cache 占用、队列长度三个指标单独看都可能在正常区间,联合起来却指向调度失衡:利用率 60%、缓存占用 90%、队列 40 个,说明请求堆积在排队而非计算。单指标阈值对此类场景完全失效,需要多元异常检测或预设的联合规则。
第四是告警疲劳。规则数量与误报率正相关,30 条规则、每条误报率 10% 时,每天会产生约 72 条无意义告警,值班人员对告警的响应率会从 95% 跌到 40%。挑战的边界在于:指标采集越细,越能捕捉细微退化,但存储与告警成本越高。生产上通常在 1% 的采样率与全量关键路径指标之间取平衡,非关键指标降采样到每 60 秒一个点。
# 来源:自实现 / cardinality_guard.py
import re
# 指标名只允许小写字母、数字、下划线,段之间用单下划线
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:_[a-z0-9]+)*$")
class LabelPolicy:
"""在埋点注册阶段拦截高基数与不规范标签,把问题挡在写入之前。"""
def __init__(self, max_labels=8, max_values=20):
self.max_labels = max_labels
self.max_values = max_values
self.seen = {}
def register(self, metric, labels):
if not _NAME_RE.fullmatch(metric):
raise ValueError(f"非法指标名: {metric}")
if len(labels) > self.max_labels:
raise ValueError(f"标签数量超限: {len(labels)} 超过 {self.max_labels}")
for name, value in labels.items():
if name.endswith("_id") or name == "request_id":
continue # 请求级 ID 属于日志域,不进指标域
bucket = self.seen.setdefault((metric, name), set())
bucket.add(value)
if len(bucket) > self.max_values:
raise RuntimeError(
f"{metric}[{name}] 基数超限: {len(bucket)} 个唯一值"
)
return True
if __name__ == "__main__":
policy = LabelPolicy(max_values=20)
policy.register("llm_latency_seconds", {"model": "qwen-7b", "gpu": "gpu0"})
policy.register("llm_latency_seconds", {"model": "qwen-13b", "gpu": "gpu0"})
try:
policy.register("llm_requests_total", {"instance": "host-%d" % 1})
for i in range(25):
policy.register("llm_requests_total", {"instance": "host-%d" % i})
except RuntimeError as exc:
print("拦截到高基数:", exc)
标签策略与写放大是同一枚硬币的两面。把请求 ID、时间戳等高基数字段挡在指标域之外、只进日志域,既控制了时序库的序列数量,又保留了逐请求的可回溯能力。运行时再叠加基数守卫,埋点代码即使写错,也会在注册阶段被拒绝,而不是污染线上时序库。
1.3 监控目标
监控目标必须先用服务等级目标(SLO)锚定,再反向推导采集与告警需求。三者的边界需要明确:SLI 是可测量的指标值,SLO 是为该指标设定的目标区间,SLA 是与客户约定的违约条款。以 99.9% 可用性目标为例,30 天错误预算为 43.2 分钟,一旦累计不可用时间超过预算的一半,就必须进入止损流程,而不是等到月底才发现违约。
生产环境的监控目标按四层分解。第一层是可用性:99.9% 的请求成功返回,错误预算 43.2 分钟/月。第二层是性能:P99 首 token 延迟低于 500ms,P99 端到端延迟低于 2s,P99 每 token 生成延迟低于 60ms。第三层是效率:单卡生成吞吐不低于 800 tokens/s,GPU 计算利用率不低于 70%,KV Cache 水位不超过 90%。第四层是质量:黄金评估集准确率周环比下降不超过 2%,输入分布 PSI 偏移不超过 0.2。
目标与采集之间存在数量关系。P99 延迟要可信,至少需要 500 个样本才能让 99% 分位数的置信区间收窄到 10% 以内;按 100 QPS 计算,500 个样本只需 5 秒。而 P99.99 分位数需要 1 万个样本,同一流量下需要 100 秒,这意味着极长尾指标要么用更大的时间窗口,要么接受更大的不确定性。目标的设定还要与容量规划联动:GPU 利用率目标 70% 为峰值留出 30% 的余量,否则一次流量毛刺就会把利用率顶到 95% 并触发告警。
# 来源:自实现 / error_budget.py
class ErrorBudget:
"""基于 99.9% SLO 的月度错误预算计算器。"""
def __init__(self, slo_nines=3, period_days=30):
# 一个月按 30 天换算成分钟,违约分钟数由 1 减可用性得出
# 99.9% 可用性对应 0.1% 违约时间,30 天共 43.2 分钟
self.limit = period_days * 24 * 60 * (10 ** (–slo_nines))
self.burned = 0.0
def record(self, minutes):
self.burned += minutes
return self.remaining()
def remaining(self):
return self.limit – self.burned
def status(self):
ratio = self.burned / self.limit
if ratio > 0.5:
return "已消耗超过一半预算,进入止损流程"
if ratio > 0.2:
return "预算消耗偏快,检查近期变更"
return "预算充足"
if __name__ == "__main__":
budget = ErrorBudget(slo_nines=3, period_days=30)
# 模拟一次 25 分钟故障与两次 8 分钟抖动
for minutes in (25, 8, 8):
budget.record(minutes)
print(f"预算上限 {budget.limit:.1f} 分钟,剩余 {budget.remaining():.1f} 分钟")
print(budget.status())
错误预算把监控从"事后查看"变成"事前阈值"。当预算消耗比例超过 50% 时自动进入止损,这与后续章节的告警阈值互为补充:前者管业务可用性,后者管技术指标。目标设定完毕后,所有采集项都必须能对接到某一层目标,无法对接到目标的埋点就是无效埋点,应当在评审时删除。
2. 性能指标
性能指标分在线与离线两类,二者回答的问题不同。在线指标回答"服务现在跑得如何",采集自运行时探针,粒度是秒级,覆盖延迟、吞吐、并发、资源利用率;离线指标回答"模型质量是否退化",采集自批量评估任务,粒度是天级,覆盖困惑度、分步指标、分布偏移。在线指标无法发现语义质量下降,离线指标无法发现延迟毛刺,一套完整的监控体系必须两者并行。
在线指标内部还要区分"服务于用户"的指标与"描述资源"的指标。前者包括首 token 延迟、生成延迟、端到端延迟,直接决定用户体验;后者包括 GPU 利用率、显存占用、KV Cache 大小、批处理规模,间接影响前者的稳定性。资源指标是延迟指标的根因层:80% 的延迟劣化案例最终可以归因到显存、批处理或队列的变化,因此采集时要把两者在时间轴上对齐,才能做根因关联。
#mermaid-svg-2YsEF2z7x8Qe234f{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2YsEF2z7x8Qe234f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2YsEF2z7x8Qe234f .error-icon{fill:#552222;}#mermaid-svg-2YsEF2z7x8Qe234f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2YsEF2z7x8Qe234f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2YsEF2z7x8Qe234f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2YsEF2z7x8Qe234f .marker.cross{stroke:#333333;}#mermaid-svg-2YsEF2z7x8Qe234f svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2YsEF2z7x8Qe234f p{margin:0;}#mermaid-svg-2YsEF2z7x8Qe234f .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-2YsEF2z7x8Qe234f .cluster-label text{fill:#333;}#mermaid-svg-2YsEF2z7x8Qe234f .cluster-label span{color:#333;}#mermaid-svg-2YsEF2z7x8Qe234f .cluster-label span p{background-color:transparent;}#mermaid-svg-2YsEF2z7x8Qe234f .label text,#mermaid-svg-2YsEF2z7x8Qe234f span{fill:#333;color:#333;}#mermaid-svg-2YsEF2z7x8Qe234f .node rect,#mermaid-svg-2YsEF2z7x8Qe234f .node circle,#mermaid-svg-2YsEF2z7x8Qe234f .node ellipse,#mermaid-svg-2YsEF2z7x8Qe234f .node polygon,#mermaid-svg-2YsEF2z7x8Qe234f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2YsEF2z7x8Qe234f .rough-node .label text,#mermaid-svg-2YsEF2z7x8Qe234f .node .label text,#mermaid-svg-2YsEF2z7x8Qe234f .image-shape .label,#mermaid-svg-2YsEF2z7x8Qe234f .icon-shape .label{text-anchor:middle;}#mermaid-svg-2YsEF2z7x8Qe234f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2YsEF2z7x8Qe234f .rough-node .label,#mermaid-svg-2YsEF2z7x8Qe234f .node .label,#mermaid-svg-2YsEF2z7x8Qe234f .image-shape .label,#mermaid-svg-2YsEF2z7x8Qe234f .icon-shape .label{text-align:center;}#mermaid-svg-2YsEF2z7x8Qe234f .node.clickable{cursor:pointer;}#mermaid-svg-2YsEF2z7x8Qe234f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2YsEF2z7x8Qe234f .arrowheadPath{fill:#333333;}#mermaid-svg-2YsEF2z7x8Qe234f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2YsEF2z7x8Qe234f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2YsEF2z7x8Qe234f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2YsEF2z7x8Qe234f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2YsEF2z7x8Qe234f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2YsEF2z7x8Qe234f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2YsEF2z7x8Qe234f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2YsEF2z7x8Qe234f .cluster text{fill:#333;}#mermaid-svg-2YsEF2z7x8Qe234f .cluster span{color:#333;}#mermaid-svg-2YsEF2z7x8Qe234f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2YsEF2z7x8Qe234f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2YsEF2z7x8Qe234f rect.text{fill:none;stroke-width:0;}#mermaid-svg-2YsEF2z7x8Qe234f .icon-shape,#mermaid-svg-2YsEF2z7x8Qe234f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2YsEF2z7x8Qe234f .icon-shape p,#mermaid-svg-2YsEF2z7x8Qe234f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2YsEF2z7x8Qe234f .icon-shape .label rect,#mermaid-svg-2YsEF2z7x8Qe234f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2YsEF2z7x8Qe234f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2YsEF2z7x8Qe234f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2YsEF2z7x8Qe234f :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-2YsEF2z7x8Qe234f .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-2YsEF2z7x8Qe234f .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-2YsEF2z7x8Qe234f .default tspan{fill:#000000!important;}
离线指标
在线指标
否
是
否
是
次日对比
触发告警
TTFT 首 token 延迟
TPOT 每 token 延迟
P99 端到端延迟
吞吐 tokens/s
困惑度
PSI 分布偏移
黄金集准确率
延迟直方图分桶
速率与饱和度分析
每日批量评估
延迟是否越界
质量是否回归
基线存储
进入异常检测
告警系统
2.1 在线指标
在线指标的核心是延迟分解。一次 LLM 请求的端到端延迟由四段相加:网络传输、调度排队、首 token 计算、逐 token 生成。首 token 延迟(TTFT)是调度与预填充阶段的总和,生成延迟(TPOT)是单个 token 的自回归计算时间,两者量级差异悬殊:TTFT 通常在 200-600ms,而 TPOT 在 40ms 左右。如果端到端延迟劣化,必须先判断劣化段落在哪一段,才能决定是扩容、调批处理还是换量化。
延迟采集必须用直方图而不是均值。均值对长尾分布没有意义:一个 7B 模型在动态批处理下的延迟呈明显长尾,P50 可能只有 300ms,P99 却到 800ms,P99.9 到 2s。用均值做告警,长尾劣化会被高并发请求的平均效应淹没。直方图在客户端按桶聚合,再在查询端用 histogram_quantile 还原分位数,既降低传输量,又保留分布信息。
生产目标的量化基线如下表,均以单卡 A100、7B 模型、输入输出各 512 token 为前提测得。表中 P99 是告警的主指标,P50 用于日常趋势观察,P99.9 用于长尾治理。
| TTFT 首 token 延迟 | 请求到达至首个 token 输出 | 300ms | 500ms | P99 大于 500ms 持续 5 分钟 |
| TPOT 生成延迟 | 单个 token 的自回归耗时 | 40ms | 60ms | P99 大于 80ms 持续 10 分钟 |
| 端到端延迟 | 完整请求耗时 | 1.2s | 2.0s | P99 大于 2s 持续 5 分钟 |
| 生成吞吐 | 每卡每秒生成 token 数 | 800 | 1200 | 低于 600 持续 10 分钟 |
| 错误率 | 5xx 与非 200 请求占比 | 0.1% | 0.5% | 大于 1% 持续 10 分钟 |
长尾延迟的根因通常是排队而非计算。当并发请求数超过动态批处理容量,请求进入调度队列,等待时间随队列长度线性增长:队列 20 个请求、单请求平均服务时间 1.5s 时,队尾请求的排队延迟约 30s。因此在线指标必须同时采集队列长度与并发数,队列长度超过 10 且持续上升时,即使延迟尚未越界,也应触发扩容告警。
# 来源:prometheus_client 0.20.0 / latency_tracker.py
from prometheus_client import Histogram, generate_latest
import random
# 延迟直方图按业务阈值切桶,保证分位数还原精度
TTFT = Histogram(
"llm_ttft_seconds", "首 token 延迟", buckets=(0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 2.0)
)
TPOT = Histogram(
"llm_tpot_seconds", "每 token 生成延迟", buckets=(0.01, 0.02, 0.04, 0.08, 0.16)
)
def serve(prompt_tokens, output_tokens):
"""记录一次推理的耗时观测:预填充与自回归两段。
生产环境在真实请求路径上调用,这里用随机样本模拟测量。"""
ttft = max(0.0, random.gauss(0.35, 0.05)) # 预填充与调度耗时
tpot = max(0.0, random.gauss(0.04, 0.008)) # 单 token 生成耗时
TTFT.observe(ttft)
TPOT.observe(tpot)
return ttft + tpot * output_tokens
if __name__ == "__main__":
for _ in range(2000):
serve(random.randint(32, 512), random.randint(16, 256))
for line in generate_latest().splitlines():
text = line.decode()
if text.startswith("llm_ttft_seconds_bucket") or text.startswith(
"llm_ttft_seconds_count"
):
print(text)
2.2 离线指标
离线指标回答在线指标回答不了的问题:模型输出质量是否退化。一个文本生成模型可能延迟、吞吐、错误率全部正常,但生成结果从"正确摘要"退化为"幻觉式摘要",这种退化只能通过离线批量评估捕捉。离线评估的基本单元是黄金评估集:2000 条带标准答案的提示词,每天或每版本跑一轮,对比困惑度、ROUGE、准确率等指标的变化。
分布偏移是离线监控的另一类核心对象。线上输入分布会随业务演进漂移:新的 prompt 模板、新的语言混合、用户行为的季节性变化,都会让输入分布偏离训练分布。量化偏移的常用指标是群体稳定性指数(PSI),以 10 个分箱计算新旧分布的差异,PSI 超过 0.2 即判定为明显偏移,需要触发重训练或特征工程调整。嵌入向量的余弦距离分布也可作为分布监控的补充,采集成本高于 PSI,但能捕捉语义级漂移。
离线评估有明确的调度与成本约束。2000 条提示词、单条最长 2048 token,在一张 A100 上完整评估约耗时 40 分钟,产生约 12 万条推理日志。评估任务通常安排在流量低谷的凌晨 2 点执行,结果写入独立的评估时序库,与在线指标分开存储,避免评估负载污染在线监控数据。评估结果的比对对象是最近 7 天的基线:单日下降超过 5% 需要人工复核,超过 10% 自动冻结模型发布。
离线与在线的边界在于时间尺度:离线指标是分钟到小时级延迟的"慢信号",无法用于实时告警,但能发现在线指标永远看不见的质量退化。两者配合的典型动作链是:PSI 超过 0.2 触发离线复评,复评确认准确率下降 8%,随后触发模型回滚与重训练排期。这一链路把监控从"救火"升级为"预防"。
离线评估的覆盖范围要与在线流量结构对齐。若线上 70% 请求来自客服摘要场景、20% 来自文档问答,黄金评估集就应该按 7:2:1 的比例分配场景,否则评估结果会被高频场景稀释,低频场景的质量回归被平均数掩盖。评估集场景比例每季度核对一次,与线上流量采样对比,偏差超过 10% 就重新配比。评估输出还要分层:困惑度这类全局指标只能给出"是否有问题"的结论,分场景的 ROUGE 与准确率才能定位"问题在哪类输入上",后者才是回滚决策的依据。
# 来源:自实现 / psi_drift.py
import math
import random
def psi(expected, actual, eps=1e-6):
"""群体稳定性指数,大于 0.2 判定为明显分布偏移。"""
if len(expected) != len(actual):
raise ValueError("两组分布的分箱数不一致")
score = 0.0
for e, a in zip(expected, actual):
e = max(e, eps)
a = max(a, eps)
score += (a – e) * math.log(a / e)
return score
def quantize(values, low, high, bins=10):
"""把连续值映射到 [low, high] 区间的 10 个等宽分箱。"""
width = (high – low) / bins
counts = [0] * bins
for v in values:
idx = min(int((v – low) / width), bins – 1)
counts[idx] += 1
total = sum(counts) or 1
return [c / total for c in counts]
if __name__ == "__main__":
# 基线输入长度与偏移后的输入长度
baseline = [random.uniform(32, 512) for _ in range(5000)]
drifted = [random.uniform(32, 768) for _ in range(5000)]
p = psi(quantize(baseline, 32, 768), quantize(drifted, 32, 768))
verdict = "触发重训练" if p > 0.2 else "分布稳定"
print(f"PSI={p:.3f},{verdict}")
2.3 指标采集
指标采集的通道选择决定数据完整性与系统复杂度。Pull 模型(Prometheus 每 15 秒主动抓取)适合基础设施与长生命周期进程,服务方只需暴露 /metrics 端点,抓取节奏由监控端统一控制,天然支持服务发现。Push 模型(StatsD 客户端主动上报)适合短生命周期任务和需要逐请求计数的场景,如批量评估作业、批处理容器,任务结束即上报完毕。
两类通道在 LLM 场景的选型边界很清晰:常驻推理进程用 Pull,探针以 15 秒间隔抓取,每个进程暴露约 50 条时间序列;离线评估、数据管线等批任务用 Push,任务结束时上报汇总值。混合部署时必须在指标命名上区分来源,例如 llm_infra_ 前缀来自 Pull、llm_batch_ 前缀来自 Push,否则聚合时会把两个语义不同的序列混在一起。
采集成本需要量化。每条时间序列以 15 秒间隔采样,每天产生 5760 个样本,TSDB 压缩后约 1.5 字节每样本,即每天约 8.6KB。3000 条活跃序列对应每天约 26MB、保留 15 天约 390MB,Prometheus 单节点完全承受得起。真正的大头是直方图:7 个桶的直方图会产生 8 条序列(7 桶加 count 与 sum 共 9 条),3000 个直方图序列对应的实际磁盘占用是普通序列的 4 倍以上,因此只对延迟这类关键指标启用直方图,其余指标用 Gauge。
采集配置的另一个关键参数是抓取间隔与评估间隔。15 秒的默认值对分钟级告警足够,但对秒级毛刺无能为力:一次持续 20 秒的延迟毛刺会被 15 秒采样完全错过。需要捕获秒级毛刺的指标(如 OOM 前的显存水位)应缩短到 5 秒抓取,代价是写入量乘以 3。实践中按指标重要性分两档:关键指标 5 秒、常规指标 15 秒。
采集失败的处理机制同样要定义清楚。抓取超时或返回非 200 时应重试并记录失败计数,连续 3 次失败触发采集源告警,防止"监控静默失效"。采集端与 TSDB 之间的背压策略应明确:TSDB 写入拥塞时优先丢弃低优先级序列样本,保留关键指标,避免整条写入链路被拖垮。采集配置纳入版本管理,变更走评审,配置错误导致的指标缺口应可回溯到具体变更。
采集的合规与审计要求:涉及用户数据的指标(如请求内容长度、脱敏后的 token 数)应记录采集字段清单与留存周期,超过保留期的数据定期清理。指标采集器的访问权限最小化,写权限只授予监控组件账号,人工查询走只读通道,采集链路变更记录审计日志。
采集与告警的联动边界:采集只负责把数据送进 TSDB,判定与告警在规则层完成,两者解耦后采集升级或规则调整互不影响。采集配置变更前用影子采集验证兼容性,避免升级导致指标断流。
# 来源:statsd 4.0.1 / 客户端用法,main 为自实现
import random
import time
import statsd
from prometheus_client import Counter, Gauge, start_http_server
# Push 通道:StatsD 客户端,前缀 llm,上报到本机 8125 端口
stats = statsd.StatsClient("127.0.0.1", 8125, prefix="llm")
# Pull 通道:Prometheus 探针,供抓取
TOKENS = Counter("llm_tokens_generated_total", "累计生成 token 数")
BATCH = Gauge("llm_current_batch_size", "当前动态批大小")
def emit(batch_size, gen_tokens, latency_ms):
"""同一份观测同时写两条通道,演示双通道协同。"""
BATCH.set(batch_size)
TOKENS.inc(gen_tokens)
stats.gauge("batch_size", batch_size)
stats.timing("tpot_ms", latency_ms)
if __name__ == "__main__":
start_http_server(9102)
for _ in range(30):
emit(random.randint(1, 64), random.randint(10, 256), random.randint(20, 120))
time.sleep(1)
print("已向 StatsD 上报 30 组样本,并向 Prometheus 暴露 /metrics")
双通道协同的价值在故障场景中最明显:Pull 侧持续记录稳态指标,Push 侧记录批任务的一次性结果。当一次批量评估的 PSI 值通过 Push 上报后超过 0.2,采集层再结合 Pull 侧的在线延迟序列判断影响面,两种通道的数据在同一时序库中对齐,异常检测才能同时拿到资源侧与质量侧的证据。
3. 异常检测
异常检测的目标是把"指标偏离"翻译成"故障信号",再决定是否告警。它位于指标采集与告警体系之间,承担两层过滤:第一层是把正常波动与真实异常分开,第二层是把真实异常从偶发噪声中稳定地确认下来。异常检测方法分统计方法与机器学习方法两大阵营,前者可解释、开销小,适合单指标;后者能捕捉多指标联合异常,但需要训练数据与再校准周期。
异常检测必须明确自己的失败模式。统计方法对平稳分布的假设在流量周期性波动下经常失效——白天 800 QPS、夜间 80 QPS 的负载差异会让固定阈值在夜间把正常流量误判为异常。机器学习方法则受训练数据分布约束,上线后遇到训练期没见过的负载形态会产生失控的虚警。因此生产环境几乎都采用"多方法投票"结构:统计方法与机器学习方法并行打分,至少两票一致才判定异常,把单一方法的偏差稀释掉。
#mermaid-svg-FXarhr3ErTLVhrWn{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FXarhr3ErTLVhrWn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FXarhr3ErTLVhrWn .error-icon{fill:#552222;}#mermaid-svg-FXarhr3ErTLVhrWn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FXarhr3ErTLVhrWn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FXarhr3ErTLVhrWn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FXarhr3ErTLVhrWn .marker.cross{stroke:#333333;}#mermaid-svg-FXarhr3ErTLVhrWn svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FXarhr3ErTLVhrWn p{margin:0;}#mermaid-svg-FXarhr3ErTLVhrWn .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-FXarhr3ErTLVhrWn .cluster-label text{fill:#333;}#mermaid-svg-FXarhr3ErTLVhrWn .cluster-label span{color:#333;}#mermaid-svg-FXarhr3ErTLVhrWn .cluster-label span p{background-color:transparent;}#mermaid-svg-FXarhr3ErTLVhrWn .label text,#mermaid-svg-FXarhr3ErTLVhrWn span{fill:#333;color:#333;}#mermaid-svg-FXarhr3ErTLVhrWn .node rect,#mermaid-svg-FXarhr3ErTLVhrWn .node circle,#mermaid-svg-FXarhr3ErTLVhrWn .node ellipse,#mermaid-svg-FXarhr3ErTLVhrWn .node polygon,#mermaid-svg-FXarhr3ErTLVhrWn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FXarhr3ErTLVhrWn .rough-node .label text,#mermaid-svg-FXarhr3ErTLVhrWn .node .label text,#mermaid-svg-FXarhr3ErTLVhrWn .image-shape .label,#mermaid-svg-FXarhr3ErTLVhrWn .icon-shape .label{text-anchor:middle;}#mermaid-svg-FXarhr3ErTLVhrWn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FXarhr3ErTLVhrWn .rough-node .label,#mermaid-svg-FXarhr3ErTLVhrWn .node .label,#mermaid-svg-FXarhr3ErTLVhrWn .image-shape .label,#mermaid-svg-FXarhr3ErTLVhrWn .icon-shape .label{text-align:center;}#mermaid-svg-FXarhr3ErTLVhrWn .node.clickable{cursor:pointer;}#mermaid-svg-FXarhr3ErTLVhrWn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FXarhr3ErTLVhrWn .arrowheadPath{fill:#333333;}#mermaid-svg-FXarhr3ErTLVhrWn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FXarhr3ErTLVhrWn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FXarhr3ErTLVhrWn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FXarhr3ErTLVhrWn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FXarhr3ErTLVhrWn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FXarhr3ErTLVhrWn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FXarhr3ErTLVhrWn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FXarhr3ErTLVhrWn .cluster text{fill:#333;}#mermaid-svg-FXarhr3ErTLVhrWn .cluster span{color:#333;}#mermaid-svg-FXarhr3ErTLVhrWn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FXarhr3ErTLVhrWn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FXarhr3ErTLVhrWn rect.text{fill:none;stroke-width:0;}#mermaid-svg-FXarhr3ErTLVhrWn .icon-shape,#mermaid-svg-FXarhr3ErTLVhrWn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FXarhr3ErTLVhrWn .icon-shape p,#mermaid-svg-FXarhr3ErTLVhrWn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FXarhr3ErTLVhrWn .icon-shape .label rect,#mermaid-svg-FXarhr3ErTLVhrWn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FXarhr3ErTLVhrWn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FXarhr3ErTLVhrWn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FXarhr3ErTLVhrWn :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-FXarhr3ErTLVhrWn .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-FXarhr3ErTLVhrWn .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-FXarhr3ErTLVhrWn .default tspan{fill:#000000!important;}
机器学习方法
统计方法
至少两票一致
单票命中
持续 3 个窗口
值班确认后反馈
Z 分数 3 个标准差
IQR 1.5 倍四分位距
CUSUM 累积和
孤立森林
自编码器重构误差
时序预测残差
指标序列
滑动窗口 30 分钟
多方法投票
判定异常
进入观察列表
写入告警
3.1 统计方法
统计方法以分布假设为基础,把偏离中心趋势到特定距离的观测判为异常。Z 分数是最直接的度量:用最近 30 分钟窗口的均值与标准差归一化当前值,|Z| 大于 3 即超过 3 个标准差,判为异常。对正态分布而言,3 个标准差之外的样本占比约 0.27%,即每 1000 个样本最多 3 个,误报率天然被压在 1% 以内。代价是对非正态分布(延迟、请求量这类右偏分布)会产生系统性误判,需要先做对数变换或分位数变换。
IQR 方法对分布形状更稳健。以 P25 与 P75 之差作为离散度基准,超过 Q3 + 1.5 倍 IQR 或低于 Q1 – 1.5 倍 IQR 判为异常。对延迟这类重尾数据,IQR 方法比 Z 分数更抗极端值污染,因为它用分位数而非均值与方差。实际部署中两类方法同时运行:Z 分数捕获均值漂移,IQR 捕获尾部突变,任一命中都记为统计方法一票。
累积和(CUSUM)解决的是"持续小幅偏移"问题。3 个标准差的阈值在均值漂移 0.3 个标准差时,需要数小时才能累积出足够证据,CUSUM 通过累积正偏差与负偏差,把 0.3 个标准差的持续偏移在 20-30 个样本内(约 5-8 分钟)检测出来。指数加权移动平均(EWMA)与此互补:以 0.3 的衰减系数平滑序列,对近期样本加权更高,适合捕捉趋势变化。四类方法的检测延迟与误报率对比如下。
| Z 分数 | 3 个标准差 | 1 个窗口 | 约 0.3% | 平稳指标均值漂移 |
| IQR | 1.5 倍四分位距 | 1 个窗口 | 对重尾更稳 | 延迟、时延类指标 |
| EWMA | 衰减系数 0.3 | 2-3 个窗口 | 约 1% | 趋势平滑与趋势变化 |
| CUSUM | 漂移 0.5 倍标准差 | 20-30 个样本 | 约 2% | 小幅持续偏移 |
统计方法的边界在非平稳序列面前暴露得最彻底。业务流量有明显的日内周期时,固定窗口的均值本身就在变化,Z 分数会误报。解法是周期化窗口:按"过去 7 天同一小时"的样本构建参考分布,再计算当前值的偏离。这类周期对齐把 30 分钟窗口扩展到 7 天采样,计算量增长约 336 倍,但误报率可以降到 0.1% 以下。
# 来源:自实现 / ewma_cusum.py
import random
class CusumDetector:
"""表格法 CUSUM:累积正负偏差,超过阈值判为漂移,全程使用原始量纲。"""
def __init__(self, mean, std, drift=0.5, threshold=5.0):
self.base = mean
self.drift = drift * std # 允许的无漂移区间 k,取 0.5 倍标准差
self.h = threshold * std # 判定阈值 h,取 5 倍标准差
self.cum_hi = 0.0
self.cum_lo = 0.0
def update(self, x):
self.cum_hi = max(0.0, self.cum_hi + (x – self.base – self.drift))
self.cum_lo = max(0.0, self.cum_lo + (self.base – x – self.drift))
return self.cum_hi > self.h or self.cum_lo > self.h
class Ewma:
"""指数加权移动平均,alpha 越大对近期样本越敏感。"""
def __init__(self, alpha=0.3):
self.alpha = alpha
self.value = None
def update(self, x):
self.value = x if self.value is None else self.alpha * x + (1 – self.alpha) * self.value
return self.value
if __name__ == "__main__":
detector = CusumDetector(mean=0.5, std=0.1, drift=0.5, threshold=5.0)
hits = 0
for i in range(200):
# 前 150 点服从原分布,之后叠加 0.12 的均值偏移
x = random.gauss(0.5, 0.1) + (0.12 if i >= 150 else 0)
if detector.update(x):
hits += 1
print(f"CUSUM 在 200 个样本中触发 {hits} 次,偏移量 0.12 约等于 1.2 个标准差")
3.2 机器学习方法
机器学习方法处理统计方法无法覆盖的多指标联合异常。单个指标正常、组合异常的场景在 LLM 服务中很常见:GPU 利用率 60%、KV Cache 占用 92%、队列长度 40,单独看都不越界,联合看却指向"请求堆在排队、显存被缓存占满、计算空闲"的调度失衡。多指标方法把 8 维特征向量当作整体判断,这类联合异常只有多维建模才能发现。
孤立森林(Isolation Forest)是生产环境最常用的选择,因为它不假设分布形状、训练开销小。以 8 维特征(GPU 利用率、显存、队列、P99 延迟、错误率、批大小、吞吐、KV Cache)训练 200 棵树,3000 条正常样本训练耗时约 2 秒,打分单样本亚毫秒级。异常分数来自路径长度:样本被随机切分得越早,说明它越容易被"孤立",越可能是异常。contamination 参数设为 0.01,即预设 1% 的异常率,对应隔离树路径长度的判定阈值。
自编码器走另一条路线:用正常数据训练重构网络,正常样本重构误差小、异常样本重构误差大,以重构误差的 P99 作为阈值。它的优势是能学到正常模式的非线性结构,劣势是需要调网络结构、训练成本高(30 天数据约 10 分钟训练),且对训练数据的代表性敏感。时序预测残差法则用 Prophet 或 ARIMA 预测下一时刻值,实际值与预测值的残差超过 3 倍历史残差标准差即告警,擅长捕捉趋势突变更能容忍季节波动。
三类方法的成本与适用边界需要量化权衡,如下表。生产实践中以隔离森林为主检测器、以时序残差法为慢检测器,自编码器仅在联合异常频发的场景启用。
| 孤立森林 | 3000 样本约 2s | 亚毫秒 | 无需分布假设、快 | 对点异常敏感,对序列上下文弱 |
| 自编码器 | 30 天数据约 10 分钟 | 毫秒级 | 捕捉非线性正常模式 | 调参复杂、依赖数据代表性 |
| 时序残差法 | 需 30 天以上历史 | 毫秒级 | 兼顾季节与趋势 | 对突变响应慢一个预测周期 |
机器学习方法的边界是概念漂移:训练数据来自某个负载形态,负载结构变化后模型输出会失真。隔离森林训练 30 天、之后每月重训是常见节奏;上线初期没有历史数据时,先用统计方法顶住,累积两周数据后再切换机器学习方法,避免冷启动期的高误报。
# 来源:scikit-learn 1.3.2 / sklearn.ensemble.IsolationForest
import numpy as np
from sklearn.ensemble import IsolationForest
FEATURES = [
"gpu_util", "vram_gb", "queue_len", "p99_latency_ms",
"error_rate", "batch_size", "tokens_per_s", "kv_cache_gb",
]
def train(history):
"""用正常历史数据训练隔离森林,预设 1% 异常率。"""
model = IsolationForest(n_estimators=200, contamination=0.01, random_state=42)
model.fit(history)
return model
def score(model, sample):
"""返回异常分数与判定结果,得分低于 -0.5 判为异常。"""
raw = model.score_samples([sample])[0]
return raw, raw < –0.5
if __name__ == "__main__":
rng = np.random.default_rng(7)
# 构造 3000 条正常样本:8 个特征各自围绕典型值分布
normal = rng.normal(
[80, 20, 5, 400, 0.5, 32, 800, 12],
[5, 2, 1, 40, 0.1, 8, 60, 1],
size=(3000, 8),
)
model = train(normal)
# 构造联合异常:显存与队列同时飙高、吞吐骤降
joint_anomaly = [95, 40, 60, 1500, 4.0, 4, 120, 30]
raw, is_anomaly = score(model, joint_anomaly)
print(f"异常分数 {raw:.3f},判定 {'异常' if is_anomaly else '正常'}")
3.3 告警阈值
告警阈值是异常检测与告警体系的接口,设定质量直接决定告警疲劳与漏报的平衡。阈值来源分两类:统计阈值来自分布计算,如 3 个标准差、1.5 倍 IQR,优点是自动适配数据;业务阈值来自 SLO 与容量规划,如 P99 延迟 500ms、错误率 1%,优点是可直接对账到业务目标。生产实践中两者结合:统计阈值做预筛,业务阈值做最终裁定。
阈值必须配套持续条件,否则单点毛刺就会刷屏。常见的持续条件是"N 分钟内 M 分钟越界":P99 延迟大于 500ms 且持续 5 分钟才告警,一次持续 20 秒的毛刺(通常由批处理波动引起)被抑制。窗口越长误报越低但发现越慢:5 分钟窗口的漏报风险约 3%,1 分钟窗口的误报率会高到 30%。对资源类指标还可以加迟滞(hysteresis):进入告警需超过 95%,恢复需回落到 90% 以下,避免在阈值边缘反复横跳。
具体到 LLM 服务的典型规则集如下表。注意每条规则都标注了严重级别与预期的误报率,规则评审时以误报率为门槛,超过 5% 的规则必须重调。
| 延迟劣化 | P99 大于 500ms 持续 5 分钟 | P1 | 2% |
| 错误率上升 | 错误率大于 1% 持续 10 分钟 | P1 | 1% |
| 显存水位 | 显存占用大于 95% 持续 3 分钟 | P2 | 3% |
| 吞吐骤降 | 吞吐低于 600 tokens/s 持续 10 分钟 | P2 | 4% |
| 质量回归 | PSI 大于 0.2 或准确率下降 8% | P1 | 5% |
阈值设定的边界案例集中在流量波动期。发布新版本后的 1 小时、促销活动的高峰期,指标整体上移,固定阈值会系统性误报。解法是阈值分级:把一天分成高峰(10-22 点)与低谷两档,各自维护一套统计阈值;或者在发布流程中自动挂静默,发布窗口内只记录不告警。统计阈值本身也有自校准周期:每周重算一次参考分布,避免阈值随数据漂移失效。
# 来源:自实现 / rule_engine.py
from collections import deque
class SlidingRule:
"""滑动窗口规则:窗口内越界比例达到阈值才触发,抑制单点毛刺。"""
def __init__(self, rule_id, kind, threshold, window=5, hysteresis=0.1, min_ratio=0.6):
self.rule_id = rule_id
self.kind = kind
self.threshold = threshold
self.hysteresis = hysteresis
self.min_ratio = min_ratio
self.history = deque(maxlen=window)
def feed(self, value):
self.history.append(value)
if len(self.history) < self.history.maxlen:
return False, 0.0
over = sum(1 for v in self.history if self._breach(v))
ratio = over / self.history.maxlen
return ratio >= self.min_ratio, ratio
def _breach(self, v):
# 迟滞:越界时需超过 threshold + hysteresis
if self.kind == "high":
return v > self.threshold + self.hysteresis
return v < self.threshold – self.hysteresis
if __name__ == "__main__":
rule = SlidingRule("p99_latency", "high", threshold=500, window=5, hysteresis=20, min_ratio=0.6)
samples = [400, 420, 560, 620, 590, 610, 540, 480]
for i, v in enumerate(samples):
fired, ratio = rule.feed(v)
mark = "触发告警" if fired else ""
print(f"第 {i + 1} 个样本: 延迟 {v}ms,越界比例 {ratio:.0%} {mark}")
4. 日志记录
日志记录补全指标与调用的盲区:指标回答"发生了什么",日志回答"具体是哪个请求、哪个模型版本、哪块 GPU"。一次 P1 延迟告警触发后,定位根因必须回到日志:是某个特定 prompt 触发了超长预填充,还是某个 GPU 的 KV Cache 碎片化,只有逐请求的结构化日志能给出答案。日志因此承担着可追溯性与取证性,其价值在故障复盘时才会完全显现。
日志体系的三层设计对应采集、存储与消费。采集层把散落在各进程的标准输出汇总为统一事件流;存储层以索引化方式保存,支撑秒级检索;消费层完成两类分析——按请求 ID 的全链路还原,与按维度的聚合统计。三层之间用缓冲解耦:采集层突发写入不直接压垮存储层,存储层故障时日志在缓冲中等待重试,而不是直接丢弃。
#mermaid-svg-o8CPnjrP3HNAjhzY{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o8CPnjrP3HNAjhzY .error-icon{fill:#552222;}#mermaid-svg-o8CPnjrP3HNAjhzY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o8CPnjrP3HNAjhzY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o8CPnjrP3HNAjhzY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o8CPnjrP3HNAjhzY .marker.cross{stroke:#333333;}#mermaid-svg-o8CPnjrP3HNAjhzY svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o8CPnjrP3HNAjhzY p{margin:0;}#mermaid-svg-o8CPnjrP3HNAjhzY .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster-label text{fill:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster-label span{color:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster-label span p{background-color:transparent;}#mermaid-svg-o8CPnjrP3HNAjhzY .label text,#mermaid-svg-o8CPnjrP3HNAjhzY span{fill:#333;color:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY .node rect,#mermaid-svg-o8CPnjrP3HNAjhzY .node circle,#mermaid-svg-o8CPnjrP3HNAjhzY .node ellipse,#mermaid-svg-o8CPnjrP3HNAjhzY .node polygon,#mermaid-svg-o8CPnjrP3HNAjhzY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o8CPnjrP3HNAjhzY .rough-node .label text,#mermaid-svg-o8CPnjrP3HNAjhzY .node .label text,#mermaid-svg-o8CPnjrP3HNAjhzY .image-shape .label,#mermaid-svg-o8CPnjrP3HNAjhzY .icon-shape .label{text-anchor:middle;}#mermaid-svg-o8CPnjrP3HNAjhzY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-o8CPnjrP3HNAjhzY .rough-node .label,#mermaid-svg-o8CPnjrP3HNAjhzY .node .label,#mermaid-svg-o8CPnjrP3HNAjhzY .image-shape .label,#mermaid-svg-o8CPnjrP3HNAjhzY .icon-shape .label{text-align:center;}#mermaid-svg-o8CPnjrP3HNAjhzY .node.clickable{cursor:pointer;}#mermaid-svg-o8CPnjrP3HNAjhzY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-o8CPnjrP3HNAjhzY .arrowheadPath{fill:#333333;}#mermaid-svg-o8CPnjrP3HNAjhzY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-o8CPnjrP3HNAjhzY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-o8CPnjrP3HNAjhzY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o8CPnjrP3HNAjhzY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o8CPnjrP3HNAjhzY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o8CPnjrP3HNAjhzY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster text{fill:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY .cluster span{color:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-o8CPnjrP3HNAjhzY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-o8CPnjrP3HNAjhzY rect.text{fill:none;stroke-width:0;}#mermaid-svg-o8CPnjrP3HNAjhzY .icon-shape,#mermaid-svg-o8CPnjrP3HNAjhzY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o8CPnjrP3HNAjhzY .icon-shape p,#mermaid-svg-o8CPnjrP3HNAjhzY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-o8CPnjrP3HNAjhzY .icon-shape .label rect,#mermaid-svg-o8CPnjrP3HNAjhzY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o8CPnjrP3HNAjhzY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-o8CPnjrP3HNAjhzY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-o8CPnjrP3HNAjhzY :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-o8CPnjrP3HNAjhzY .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-o8CPnjrP3HNAjhzY .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-o8CPnjrP3HNAjhzY .default tspan{fill:#000000!important;}
采集管道
日志产生
按 request_id 检索
按模型版本聚合
结论回写
消费积压超过阈值
推理服务
结构化 JSON 日志
PII 脱敏
Filebeat
Logstash 解析
Kafka 缓冲
Elasticsearch 索引
是否需要排查
单请求全链路还原
错误率与延迟聚合
根因分析
丢弃低优先级日志
4.1 日志规范
日志规范的核心是结构统一与字段齐全。每条请求日志必须是单行 JSON,字段分为标识域、上下文域与度量域。标识域包含 request_id、trace_id、model_version,是跨系统关联的主键。上下文域包含 prompt_hash、num_input_tokens、num_output_tokens、gpu_id、region,是聚合分析的维度。度量域包含 latency_ms、ttft_ms、tpot_ms、cache_hit、error_code,是性能还原的数据源。字段在埋点规范文档中统一定义,新增字段必须走评审。
日志等级划分必须与经济性挂钩。INFO 记录每次请求的完成信息,用于性能与业务分析;WARN 记录可恢复的降级(重试成功、排队超时),数量级约为 INFO 的 5%;ERROR 记录失败请求与内部异常,必须带 error_code 与堆栈摘要。DEBUG 默认关闭,仅在按 request_id 定位问题时临时开启,避免写入量爆炸。日志量与成本的关系是硬约束:单条请求日志约 600 字节,100 QPS 时每天产生约 5.2GB,保留 90 天约 470GB,这是选型 Elasticsearch 集群规模的直接输入。
敏感数据治理是日志规范不可跳过的一环。prompt 与响应可能包含用户隐私,脱敏在日志产生进程内完成:邮箱、手机号用正则掩码,token 序列只保留前 64 与后 64 个,用户 ID 以盐值哈希存储。脱敏必须发生在序列化之前,而不是存储之后,否则明文会先落到磁盘再进入备份,补救成本成倍上升。灰度期的经验数据是:未脱敏日志泄露一次,合规整改的人力成本约 2 人周。
# 来源:Python 3.11 logging + 自实现 JSONFormatter
import hashlib
import json
import logging
import re
import sys
import time
_PII = re.compile(r"[\\w.+-]+@[\\w-]+\\.[\\w.]+|\\b1[3-9]\\d{9}\\b")
def mask(text):
"""把邮箱与手机号统一掩码为等长星号。"""
return _PII.sub(lambda m: "*" * len(m.group()), text or "")
class JsonFormatter(logging.Formatter):
"""把 logging 记录序列化为单行 JSON,并附加统一时间戳。"""
def format(self, record):
payload = {
"ts": round(time.time() * 1000),
"level": record.levelname,
"logger": record.name,
"msg": mask(record.getMessage()),
}
for key in ("request_id", "model_version", "prompt_hash", "latency_ms", "tokens"):
if hasattr(record, key):
payload[key] = getattr(record, key)
return json.dumps(payload, ensure_ascii=False)
logger = logging.getLogger("llm.access")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout) # 输出到标准输出,便于管道采集
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
if __name__ == "__main__":
logger.info("request finished", extra={
"request_id": "req-0001",
"model_version": "qwen-7b-v37",
"prompt_hash": hashlib.sha256(b"user prompt").hexdigest()[:12],
"latency_ms": 486,
"tokens": 96,
})
4.2 日志采集
日志采集的管道设计要解决三个问题:不丢、不堵、可回溯。Filebeat 作为采集器常驻各节点,以 tail 模式读取 JSON 日志文件,按 4096 条一批或 5MB 触发一次批量推送,避免每条日志一次网络请求。采集器本地维护读取偏移,进程重启后从断点继续,保证至少一次语义。Logstash 或 Vector 在中间层完成字段解析与标准化,把自由文本转成统一 schema。
缓冲层解决生产与消费的速度差。推理服务在流量毛刺时日志产生速率可达稳态的 5 倍,直接写入 Elasticsearch 会造成写队列堆积与拒绝。Kafka 以 3 副本、保留 24 小时作为缓冲,采集器只面对 Kafka,消费端按自身能力拉取。积压监控是管道健康的关键:Kafka 消费 lag 超过 500 万条时,按优先级丢弃——低优先级日志(INFO 以下)先丢,ERROR 日志最后丢,保证故障现场的高价值日志不丢失。
管道故障的边界行为必须预演。Elasticsearch 写入失败时,消费端退避重试,重试 3 次仍失败则把日志转存冷路径(对象存储),而不是无限重试拖垮消费进程。Filebeat 自身的日志量与磁盘配额也要监控:单节点日志目录 20GB 上限,超过上限触发轮转,轮转文件保留 7 天。采集管道的监控指标(采集延迟、消费 lag、丢弃量)本身也要接入监控体系,否则日志管道故障会成为盲区。
# 来源:Elastic Beats Filebeat 8.11 / filebeat.yml
filebeat.inputs:
– type: filestream
id: llm–access–logs
paths:
– /var/log/llm/access.jsonl
parsers:
– ndjson:
target: ""
filebeat.autodiscover:
providers:
– type: kubernetes
output.elasticsearch:
hosts: ["es-master:9200"]
bulk_max_size: 4096
flush_bytes: 5242880
backoff.init: 1s
backoff.max: 60s
# 来源:confluent-kafka 2.3.0 / 消费端检查脚本,main 为自实现
from confluent_kafka import Consumer
def lag_check(broker, topic, group):
"""返回指定消费组的积压条数,超过 500 万视为管道告警。"""
c = Consumer({"bootstrap.servers": broker, "group.id": group})
try:
high = c.get_watermark_offsets(topic, 0)[1]
committed = c.committed([c.list_topics(topic).topics[topic].partitions[0]])
pos = committed[0].offset if committed and committed[0].offset else 0
return max(0, high – pos)
finally:
c.close()
if __name__ == "__main__":
lag = lag_check("kafka-1:9092", "llm-access-logs", "es-indexer")
level = "ERROR" if lag > 5_000_000 else "INFO"
print(f"{level} 消费积压 {lag} 条")
4.3 日志分析
日志分析的两种主路径覆盖故障处理与趋势洞察。故障处理路径按 request_id 全链路还原:输入日志、输出日志、调度日志、GPU 日志以 request_id 为 join 键合并,还原出该请求的完整生命周期。一次延迟告警的定位时间可以从 30 分钟压到 2 分钟:先看 ttft_ms 与 tpot_ms 的拆分,若 ttft 异常则检查预填充长度与队列,若 tpot 异常则检查 GPU 邻居请求的并发影响。
趋势洞察路径按维度聚合。模型版本是最重要的聚合维度:新版本上线后,对比新旧版本的错误率与延迟分布,差异超过 2% 即触发版本回滚评估。GPU 维度聚合能发现单卡劣化:同一批次 8 卡中某一卡的错误率显著高于其余 7 卡,指向显存 ECC 纠错或散热问题。这类聚合分析用 Elasticsearch 的 terms 聚合与日期直方图实现,查询耗时在 3 节点、2 亿条文档规模下可以控制在 1 秒以内。
日志检索的存储策略由索引生命周期管理(ILM)执行:热阶段 7 天,保存在 SSD,支持毫秒级检索;暖阶段 7-30 天,迁移到 HDD;冷阶段 30-90 天,以 searchable snapshot 存在对象存储,检索变慢但成本降低约 80%;超过 90 天删除。容量规划按 4.1 节的 5.2GB/天推算:三副本存储 90 天约 1.4TB,对象存储冷数据按 0.02 美元每 GB 每月计算,月存储成本约 9 美元,这是可以接受的日志治理成本。
日志分析的边界在于噪音与覆盖率。抽样率不足时聚合结论失真:只采样 1% 请求计算错误率,样本量 100 时 95% 置信区间宽达正负 2%,无法区分 1% 与 3% 的差异。因此错误率这类关键聚合必须全量采集,而性能分布分析可以抽样。日志与指标的校准也值得注意:日志统计的延迟通常比指标略高,因为指标在进程内聚合、日志在序列化后统计,两者偏差约 2-5% 属于正常范围。
# 来源:elasticsearch-py 8.12 / 自实现分析脚本
from elasticsearch import Elasticsearch
INDEX = "llm-access-*"
def error_rate_by_version(es, minutes=60):
"""按模型版本聚合过去一小时各版本的错误率。"""
body = {
"size": 0,
"query": {"range": {"ts": {"gte": f"now-{minutes}m"}}},
"aggs": {
"by_version": {
"terms": {"field": "model_version", "size": 10},
"aggs": {
"total": {"value_count": {"field": "ts"}},
"errors": {"filter": {"term": {"level": "ERROR"}}},
},
}
},
}
resp = es.search(index=INDEX, body=body)
out = []
for bucket in resp["aggregations"]["by_version"]["buckets"]:
total = bucket["total"]["value"]
errors = bucket["errors"]["doc_count"]
out.append((bucket["key"], errors / total if total else 0.0))
return out
if __name__ == "__main__":
es = Elasticsearch("http://127.0.0.1:9200", request_timeout=10)
for version, rate in error_rate_by_version(es, minutes=60):
flag = "需回滚评估" if rate > 0.02 else ""
print(f"{version}: 错误率 {rate:.2%} {flag}")
5. 告警体系
告警体系是把异常检测结论转化为值班行动的最后一道工序,其设计目标不是"发出告警",而是"让值班人员在最短时间内采取正确动作"。告警体系由三块组成:分级定义告警的严重性与响应时限,路由把告警送到正确的团队与通道,去重把相同或相关的告警收敛成一条可执行的任务。三者共同决定告警的精准度:理想状态下每条告警都对应一次真实故障,且附带定位信息。
告警体系的失效模式表现为两级:漏报(该告没告)与噪音(不该告的刷屏)。漏报由阈值与检测方法决定,属于异常检测的范畴;噪音由分级、路由、去重不当导致,一条故障产生 20 条重复告警就是典型的路由收敛失败。生产实践的经验数据是:去重与分组可以把告警数量压缩 70%,告警精确率(告警对应真实故障的比例)从 30% 提升到 85% 以上。
#mermaid-svg-XJ8iOnbxiHyDeIZK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XJ8iOnbxiHyDeIZK .error-icon{fill:#552222;}#mermaid-svg-XJ8iOnbxiHyDeIZK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XJ8iOnbxiHyDeIZK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .marker.cross{stroke:#333333;}#mermaid-svg-XJ8iOnbxiHyDeIZK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XJ8iOnbxiHyDeIZK p{margin:0;}#mermaid-svg-XJ8iOnbxiHyDeIZK .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster-label text{fill:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster-label span{color:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster-label span p{background-color:transparent;}#mermaid-svg-XJ8iOnbxiHyDeIZK .label text,#mermaid-svg-XJ8iOnbxiHyDeIZK span{fill:#333;color:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .node rect,#mermaid-svg-XJ8iOnbxiHyDeIZK .node circle,#mermaid-svg-XJ8iOnbxiHyDeIZK .node ellipse,#mermaid-svg-XJ8iOnbxiHyDeIZK .node polygon,#mermaid-svg-XJ8iOnbxiHyDeIZK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .rough-node .label text,#mermaid-svg-XJ8iOnbxiHyDeIZK .node .label text,#mermaid-svg-XJ8iOnbxiHyDeIZK .image-shape .label,#mermaid-svg-XJ8iOnbxiHyDeIZK .icon-shape .label{text-anchor:middle;}#mermaid-svg-XJ8iOnbxiHyDeIZK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .rough-node .label,#mermaid-svg-XJ8iOnbxiHyDeIZK .node .label,#mermaid-svg-XJ8iOnbxiHyDeIZK .image-shape .label,#mermaid-svg-XJ8iOnbxiHyDeIZK .icon-shape .label{text-align:center;}#mermaid-svg-XJ8iOnbxiHyDeIZK .node.clickable{cursor:pointer;}#mermaid-svg-XJ8iOnbxiHyDeIZK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .arrowheadPath{fill:#333333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XJ8iOnbxiHyDeIZK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XJ8iOnbxiHyDeIZK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XJ8iOnbxiHyDeIZK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster text{fill:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK .cluster span{color:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XJ8iOnbxiHyDeIZK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XJ8iOnbxiHyDeIZK rect.text{fill:none;stroke-width:0;}#mermaid-svg-XJ8iOnbxiHyDeIZK .icon-shape,#mermaid-svg-XJ8iOnbxiHyDeIZK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XJ8iOnbxiHyDeIZK .icon-shape p,#mermaid-svg-XJ8iOnbxiHyDeIZK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XJ8iOnbxiHyDeIZK .icon-shape .label rect,#mermaid-svg-XJ8iOnbxiHyDeIZK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XJ8iOnbxiHyDeIZK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XJ8iOnbxiHyDeIZK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XJ8iOnbxiHyDeIZK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-XJ8iOnbxiHyDeIZK .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-XJ8iOnbxiHyDeIZK .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-XJ8iOnbxiHyDeIZK .default tspan{fill:#000000!important;}
路由与收敛
告警分级
P0
P1 以下
未确认
重试
P0 服务不可用
15 分钟响应
P1 延迟越界
30 分钟响应
P2 资源水位
4 小时响应
告警实例
按告警名与集群分组
去重指纹
严重级别
电话与值班
即时消息
抑制低级别告警
静默维护窗口
通知通道
升级到下一级别
5.1 告警分级
告警分级把故障按影响面与紧迫度排序,核心产出是响应时限与服务承诺。分级依据三个维度:影响范围(全局或局部)、用户可感知程度(可用或降级)、止损动作复杂度(回滚或扩容)。LLM 服务的典型分级如下表,每一级都绑定明确的响应时限与升级路径,值班职责表据此排班。
| P0 | 服务不可用、推理结果损坏 | 15 分钟 | 电话 + 值班 | 立即回滚或切换容灾 |
| P1 | P99 延迟大于 2s、错误率大于 5% | 30 分钟 | 即时消息 + 电话 | 回滚模型版本 |
| P2 | 显存大于 90%、吞吐下降 | 4 小时 | 即时消息 | 观察并准备扩容 |
| P3 | 缓存命中率下降、日志积压 | 次日 | 工单 | 排期处理 |
分级不能拍脑袋定级,必须与 SLO 对账。P0 对应错误预算中的灾难性事件,单次故障消耗预算超过 10% 即升级为 P0;P1 对应预算消耗率超过告警线的持续劣化;P2、P3 是潜在风险与优化项。定级的另一条准则是"能否被止损动作消解":可回滚的故障定 P1,不可回滚的定 P0,只有软恢复手段的定 P2。这一准则让分级天然与运维能力对齐,避免全员 P0 的滥用。
分级还决定值班人力与排班成本。P0 需要 7 天 24 小时电话值班,一个 6 人团队排 3 班、每班 8 小时,每人每周约 28 小时值班负担;P1 只需工作日 9-21 点即时消息值班。分级越严格,值班成本越高,因此分级评审每月一次,把长期无 P0 的规则降级到 P1,把频繁 P2 的规则升级到 P1,保持分级与真实风险匹配。
# 来源:自实现 / severity_policy.py
import time
from dataclasses import dataclass
# 各级别响应时限(分钟),超过即升级通知
SEVERITY = {"P0": 15, "P1": 30, "P2": 240, "P3": 1440}
@dataclass
class Alert:
name: str
severity: str
started: float
class Escalator:
"""跟踪未确认告警,超时未响应则触发升级。"""
def __init__(self):
self.active = {}
def fire(self, name, severity):
self.active[name] = Alert(name, severity, time.time())
def overdue(self, now=None):
now = now or time.time()
result = []
for alert in self.active.values():
limit = SEVERITY[alert.severity] * 60
if now – alert.started > limit:
result.append(alert)
return result
if __name__ == "__main__":
esc = Escalator()
esc.fire("p99_latency_2s", "P1")
esc.fire("service_down", "P0")
# 模拟 31 分钟后仍未确认,检查升级清单
fake_now = time.time() + 31 * 60
for alert in esc.overdue(fake_now):
print(f"{alert.name} 超过 {SEVERITY[alert.severity]} 分钟时限,升级通知")
5.2 告警路由
告警路由决定"告警发给谁",是告警体系避免全员轰炸的关键。路由规则以告警标签为输入:服务、团队、严重级别、集群,通过路由树逐层匹配。Prometheus Alertmanager 是事实上的路由标准,其路由树支持 matcher 匹配、嵌套路由与 receiver 绑定,一条告警从根路由出发,按匹配规则下沉到叶子节点对应的接收方。
路由配置的三个核心参数是分组、等待与重复。分组把相同告警名与集群的告警合并为一条通知,避免 50 台 GPU 同时告警刷 50 条消息。group_wait 控制在分组建立后等待多久再发送,默认 30 秒,用于等待同组告警聚齐。group_interval 控制同一分组两次通知的最小间隔,默认 5 分钟。repeat_interval 控制未恢复告警的重复通知周期,默认 4 小时。这三个参数决定通知量:50 条 GPU 告警经分组收敛为 1 条,再经 repeat_interval 把 4 小时内的重复通知从 200 次压到 1 次。
路由树的边界在于跨团队与跨系统的告警归属。模型质量告警归算法团队、基础设施告警归平台团队、业务可用性告警归 SRE,一旦一条告警同时涉及多个团队,必须有明确的"主接收方 + 抄送"约定,否则会出现三头不接的真空。路由配置需要版本化管理并纳入评审:路由错误是告警漏发的首要原因,其影响比阈值错误更隐蔽——阈值错误产生误报,路由错误直接吞掉告警。
# 来源:Prometheus Alertmanager 0.26.0 / alertmanager.yml
route:
group_by: ["alertname", "cluster"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: platform–default
routes:
– matchers: ["severity=P0"]
receiver: oncall–phone
continue: false
– matchers: ["team=ml"]
receiver: ml–team–im
inhibit_rules:
– source_matchers: ["severity=P0"]
target_matchers: ["severity=^(P1|P2)$"]
equal: ["alertname", "cluster"]
receivers:
– name: oncall–phone
webhook_configs:
– url: http://pagerduty.example.com/events/v2/enqueue
– name: ml–team–im
slack_configs:
– channel: "#ml-ops"
title: "{{ .CommonAnnotations.summary }}"
# 来源:PyYAML 6.0 / alert_routes_checker.py
import yaml
# 演示用最小配置;生产环境读取版本化的 alertmanager.yml
_EXAMPLE = """
route:
group_by: ["alertname", "cluster"]
group_wait: 30s
receiver: platform-default
routes:
– matchers: ["severity=P0"]
receiver: oncall-phone
"""
def walk(route, depth=0, parents=()):
"""遍历路由树,返回每条叶子路由的接收方与层级。"""
routes = route.get("routes") or []
if routes:
for r in routes:
yield from walk(r, depth + 1, parents + (route.get("receiver"),))
else:
yield route.get("receiver"), depth, parents
if __name__ == "__main__":
try:
with open("alertmanager.yml", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
except FileNotFoundError:
cfg = yaml.safe_load(_EXAMPLE)
root = cfg["route"]
print("根路由分组字段:", root.get("group_by"))
for receiver, depth, parents in walk(root):
print(f"叶子路由 接收方={receiver} 层级={depth} 父接收方={parents}")
5.3 告警去重
告警去重解决"一条故障多条告警"的通知放大问题,机制分三层。第一层是分组:同告警名、同集群、同实例的告警在 Alertmanager 内合并,只发一条通知。第二层是指纹去重:每条告警按告警名、实例、严重级别生成指纹,同一指纹在去重窗口内只通知一次,窗口默认 1 小时。第三层是抑制:高优先级告警抑制同源的低优先级告警,P0 触发时同集群的 P1、P2 一律静默,因为根因已被 P0 覆盖。
抑制规则要小心的边界是"误抑制"。P0 抑制 P1 的前提是二者同源:inhibit_rules 用 equal 字段约束告警名与集群相同,跨集群或跨告警名的 P0 不抑制 P1。另一个去重维度的补充是抖动检测:告警在触发与恢复间反复横跳(flapping),1 小时内状态翻转 4 次以上即判定抖动,抖动告警进入观察列表而不是重复通知,待稳定后再决定是否升级。
去重的量化效果可以验证。一次 GPU 显存告警,8 卡集群会产生 8 条同告警名告警,经分组收敛为 1 条;若同时触发延迟、错误率、吞吐 3 条关联告警,经抑制只保留延迟一条。实测一个 30 条规则的监控体系,去重前日均通知 40 条,去重后 12 条,压缩率 70%,值班人员对告警的响应率从 40% 回升到 90%。去重失效的最常见原因是标签不一致:同一指标在不同 exporter 打了不同实例标签,指纹永远不同,去重完全失效,这又回到标签规范的治理问题。
# 来源:自实现 / dedup.py
import hashlib
import time
from collections import defaultdict, deque
class Deduper:
"""基于告警指纹的去重器,窗口内同指纹只通知一次。"""
def __init__(self, window=3600):
self.window = window
self.fired = {}
def fingerprint(self, alert):
raw = f"{alert['name']}|{alert['instance']}|{alert['severity']}"
return hashlib.sha1(raw.encode()).hexdigest()
def should_send(self, alert):
key = self.fingerprint(alert)
now = time.time()
if key in self.fired and now – self.fired[key] < self.window:
return False
self.fired[key] = now
return True
class FlapDetector:
"""统计 1 小时内状态翻转次数,超过阈值判定为抖动。"""
def __init__(self, limit=4, span=3600):
self.limit = limit
self.span = span
self.spans = defaultdict(lambda: deque())
def observe(self, key, state):
queue = self.spans[key]
now = time.time()
queue.append((now, state))
while queue and now – queue[0][0] > self.span:
queue.popleft()
flips = sum(1 for i in range(1, len(queue)) if queue[i][1] != queue[i – 1][1])
return flips >= self.limit, flips
if __name__ == "__main__":
d = Deduper(window=3600)
sample = {"name": "p99_latency_2s", "instance": "serving-1:9101", "severity": "P1"}
print("首次发送:", d.should_send(sample))
print("窗口内重复被拦截:", not d.should_send(sample))
fd = FlapDetector(limit=3, span=60)
flips = 0
for state in (True, False, True, False):
_, flips = fd.observe("node-x", state)
print(f"1 分钟内翻转 {flips} 次,判定为 {'抖动' if flips >= 3 else '稳定'}")
6. 监控平台
监控平台是前三章的承载实体:指标、异常检测、告警、日志都要在统一的平台上落地运行,平台架构决定了整套监控体系的可扩展性与可用性。平台选型的分水岭是团队规模与数据规模:3 人以内的小团队用 Prometheus 加 Grafana 加 Elasticsearch 三件套即可覆盖全部需求;数据量超过千万样本每天、或需要跨团队共享时,才需要考虑 Thanos、VictoriaMetrics 与集中式日志平台的引入。
平台的部署形态要避免两个极端。过度堆叠的极端是"监控系统的监控":每一层都引入高可用组件,Prometheus 主备、Alertmanager 三副本、ES 三节点,总资源开销可达监控流量的 3 倍,复杂度反过来吞噬了监控的价值。过度精简的极端是单点:Prometheus 单节点、无持久化,一次重启丢光 15 天历史。生产上采用"两节点 Prometheus 加远端对象存储备份、Alertmanager 双副本、ES 三节点"的折中,既扛得住单节点故障,又不至于为监控养一支运维团队。
#mermaid-svg-o93WxEckQEVtfPdy{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o93WxEckQEVtfPdy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o93WxEckQEVtfPdy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o93WxEckQEVtfPdy .error-icon{fill:#552222;}#mermaid-svg-o93WxEckQEVtfPdy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o93WxEckQEVtfPdy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o93WxEckQEVtfPdy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o93WxEckQEVtfPdy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o93WxEckQEVtfPdy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o93WxEckQEVtfPdy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o93WxEckQEVtfPdy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o93WxEckQEVtfPdy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o93WxEckQEVtfPdy .marker.cross{stroke:#333333;}#mermaid-svg-o93WxEckQEVtfPdy svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o93WxEckQEVtfPdy p{margin:0;}#mermaid-svg-o93WxEckQEVtfPdy .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-o93WxEckQEVtfPdy .cluster-label text{fill:#333;}#mermaid-svg-o93WxEckQEVtfPdy .cluster-label span{color:#333;}#mermaid-svg-o93WxEckQEVtfPdy .cluster-label span p{background-color:transparent;}#mermaid-svg-o93WxEckQEVtfPdy .label text,#mermaid-svg-o93WxEckQEVtfPdy span{fill:#333;color:#333;}#mermaid-svg-o93WxEckQEVtfPdy .node rect,#mermaid-svg-o93WxEckQEVtfPdy .node circle,#mermaid-svg-o93WxEckQEVtfPdy .node ellipse,#mermaid-svg-o93WxEckQEVtfPdy .node polygon,#mermaid-svg-o93WxEckQEVtfPdy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o93WxEckQEVtfPdy .rough-node .label text,#mermaid-svg-o93WxEckQEVtfPdy .node .label text,#mermaid-svg-o93WxEckQEVtfPdy .image-shape .label,#mermaid-svg-o93WxEckQEVtfPdy .icon-shape .label{text-anchor:middle;}#mermaid-svg-o93WxEckQEVtfPdy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-o93WxEckQEVtfPdy .rough-node .label,#mermaid-svg-o93WxEckQEVtfPdy .node .label,#mermaid-svg-o93WxEckQEVtfPdy .image-shape .label,#mermaid-svg-o93WxEckQEVtfPdy .icon-shape .label{text-align:center;}#mermaid-svg-o93WxEckQEVtfPdy .node.clickable{cursor:pointer;}#mermaid-svg-o93WxEckQEVtfPdy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-o93WxEckQEVtfPdy .arrowheadPath{fill:#333333;}#mermaid-svg-o93WxEckQEVtfPdy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-o93WxEckQEVtfPdy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-o93WxEckQEVtfPdy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o93WxEckQEVtfPdy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o93WxEckQEVtfPdy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o93WxEckQEVtfPdy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-o93WxEckQEVtfPdy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-o93WxEckQEVtfPdy .cluster text{fill:#333;}#mermaid-svg-o93WxEckQEVtfPdy .cluster span{color:#333;}#mermaid-svg-o93WxEckQEVtfPdy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-o93WxEckQEVtfPdy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-o93WxEckQEVtfPdy rect.text{fill:none;stroke-width:0;}#mermaid-svg-o93WxEckQEVtfPdy .icon-shape,#mermaid-svg-o93WxEckQEVtfPdy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o93WxEckQEVtfPdy .icon-shape p,#mermaid-svg-o93WxEckQEVtfPdy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-o93WxEckQEVtfPdy .icon-shape .label rect,#mermaid-svg-o93WxEckQEVtfPdy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o93WxEckQEVtfPdy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-o93WxEckQEVtfPdy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-o93WxEckQEVtfPdy :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-o93WxEckQEVtfPdy .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-o93WxEckQEVtfPdy .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-o93WxEckQEVtfPdy .default tspan{fill:#000000!important;}
应用层
存储层
采集层
是
否
周期巡检
进程 Exporter
节点 Exporter
GPU Exporter
自定义指标
Prometheus 主
Prometheus 备
Elasticsearch 三节点
Alertmanager
Grafana
回调通道
SLO 是否健康
继续观察
6.1 平台架构
平台的采集层按指标来源划分 exporter。进程 exporter 暴露推理引擎自身的指标(队列长度、批大小、KV Cache 使用),节点 exporter 暴露主机指标(CPU、内存、网络),GPU exporter 暴露显存与计算利用率,自定义 exporter 暴露业务指标(SLO 达成率、PSI 偏移)。exporter 遵循一个出口原则:每个进程只暴露一个 /metrics 端点,全部指标在同一端口聚合,Prometheus 的 scrape 配置随之简化。
存储层的可用性设计决定故障恢复能力。Prometheus 以两节点并行抓取相同目标,主节点故障时备节点接管查询,两节点间通过远端写入与对象存储归档共享历史。Prometheus 时序库的默认保留期设为 15 天,超过 15 天的数据通过 thanos 或 victoriametrics 侧写到对象存储,保留 180 天用于月度趋势分析。ES 三节点按主从分片承载日志,主节点故障时从分片提升,写入不停。
告警链路的高可用是平台架构里最容易被忽视的一环。Alertmanager 双副本以 gossip 协议共享告警状态,单副本故障不丢告警;Prometheus 的 rule_files 里加载告警规则,评估间隔与抓取间隔对齐为 15 秒。链路的每一跳都要有自身的存活指标:Prometheus 的上游抓取失败率、Alertmanager 的通知失败率、ES 的写入延迟,这些元指标在独立的 dashboards 上呈现,否则监控平台自身会成为最大的盲区。
平台架构的另一个维度是安全与访问控制。Grafana 与 Kibana 的查看权限按团队划分:SRE 拥有全部看板编辑权,算法团队只有本模型相关看板的只读权限,值班人员有告警确认权但无阈值修改权。Prometheus 的查询 API 与 ES 的读取都要走统一网关,审计日志记录每一次数据访问,配合 4.1 节的 PII 脱敏策略,满足合规审计要求。权限的最小化不是流程冗余,而是防止值班人员在故障现场误改阈值导致漏报的兜底手段。
# 来源:Prometheus 2.51 / prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
– /etc/prometheus/rules/*.yml
scrape_configs:
– job_name: "llm-serving"
static_configs:
– targets: ["serving-1:9101", "serving-2:9101"]
– job_name: "gpu-exporter"
static_configs:
– targets: ["gpu-1:9400", "gpu-2:9400", "gpu-3:9400"]
– job_name: "alertmanager"
static_configs:
– targets: ["alertmanager-1:9093", "alertmanager-2:9093"]
# 来源:PyYAML 6.0 / scrape_validator.py
import yaml
# 演示用最小配置;生产环境读取版本化的 prometheus.yml
_EXAMPLE = """
global:
scrape_interval: 15s
scrape_configs:
– job_name: llm-serving
static_configs:
– targets: ["serving-1:9101"]
"""
def validate(path):
"""校验抓取配置:检查全局间隔与各 job 是否有 target。"""
try:
with open(path, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
except FileNotFoundError:
cfg = yaml.safe_load(_EXAMPLE)
interval = cfg["global"]["scrape_interval"]
missing = []
for job in cfg["scrape_configs"]:
if not job.get("static_configs"):
missing.append(job["job_name"])
return interval, missing
if __name__ == "__main__":
interval, missing = validate("prometheus.yml")
print(f"全局抓取间隔 {interval},缺少 target 的 job: {missing or '无'}")
6.2 看板设计
看板设计的原则是"一屏回答一个问题"。顶层看板回答"SLO 是否健康":请求成功率、错误预算消耗、P99 延迟三块大图并排,红绿阈值一目了然。第二层看板回答"服务跑得如何":延迟热力图、吞吐、并发、队列长度四块,用于值班巡检。第三层看板回答"资源是否充足":GPU 利用率、显存、KV Cache、批大小四块,用于容量规划。三层看板之间的跳转靠 URL 变量贯通:顶层点击实例进入第二层,第二层点击实例进入第三层。
延迟看板必须用直方图热力图而非单条折线。单条 P99 折线无法回答"长尾分布的形状如何",热力图按时间与延迟桶两个维度着色,能直接看出毛刺是集中在某个桶还是整体右移。PromQL 的实现是 histogram_quantile 配合 sum(rate(bucket)) 聚合,查询间隔用 Grafana 的 $__rate_interval 变量对齐抓取周期,避免速率窗口与抓取间隔不匹配导致的锯齿。
看板要处理多模型版本并存的可比性问题。线上同时运行 v36 与 v37 两个版本做灰度时,看板必须按版本分面板,才能对比两版本的延迟与错误率差异。PromQL 的 by (model_version) 聚合加上 Grafana 的 legend 分组即可实现。看板自身的治理也重要:面板数量超过 100 时查询并发会拖慢加载,超期未使用的面板归档,命名带负责人标签,看板变更走 Git 评审而不是在界面上直接改。
# 来源:Grafana 10.2 面板 JSON / latency_panel.py
PANEL = {
"title": "P99 延迟",
"type": "timeseries",
"targets": [
{
"expr": (
"histogram_quantile(0.99, "
"sum(rate(llm_ttft_seconds_bucket[$__rate_interval])) by (le))"
)
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"steps": [
{"value": 0, "color": "green"},
{"value": 0.5, "color": "red"},
]
},
}
},
}
def build_promql(metric, quantiles=(0.5, 0.9, 0.99)):
"""为给定直方图指标生成多条分位线查询。"""
exprs = []
for q in quantiles:
exprs.append(
f"histogram_quantile({q}, "
f"sum(rate({metric}_bucket[$__rate_interval])) by (le))"
)
return exprs
if __name__ == "__main__":
print("默认面板查询:", PANEL["targets"][0]["expr"])
print("扩展分位线:")
for expr in build_promql("llm_ttft_seconds"):
print(" ", expr)
6.3 数据存储
数据存储的分工是:时序数据进时序库,日志数据进全文索引库,两种引擎的选型依据是访问模式。时序库的访问模式是"按时间范围聚合分位数",Prometheus TSDB 的分块压缩与倒排索引为此优化;日志库的访问模式是"按字段过滤检索原文",Elasticsearch 的倒排索引为此优化。把日志塞进时序库或把指标塞进日志库都会在查询性能与存储成本上同时吃亏。
时序存储的成本模型可以精确估算。单条序列以 15 秒采样,TSDB 压缩后约 1.5 字节每样本,每天 8.6KB;3000 条活跃序列每天约 26MB,15 天保留期约 390MB,Prometheus 单节点绰绰有余。但直方图指标放大 9 倍(7 桶加 count 加 sum),且多副本告警等都会让序列数膨胀。序列数超过 10 万时,内存索引从 100MB 量级涨到 5GB,查询从毫秒级退化到秒级,此时必须引入 VictoriaMetrics 的下采样或 Thanos 的垂直分区。
日志存储的容量按 4.1 节的推算执行:100 QPS、单条 600 字节,每天 5.2GB,90 天三副本约 1.4TB。ES 索引生命周期管理分四阶段:热 7 天 SSD、暖 8-30 天 HDD、冷 31-90 天快照到对象存储、90 天删除。冷阶段的检索从毫秒退化到秒级,但成本降到原来的 20%。时序与日志的保留期差异要写进运维手册:分析师查 60 天前的延迟要用日志冷数据,查 60 天前的时序分位数则直接失效,因为时序只保留 15 天。
{
"policy": "llm-log-policy",
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": { "max_size": "50gb", "max_age": "1d" }
}
},
"warm": {
"min_age": "7d",
"actions": {
"forcemerge": { "max_num_segments": 1 }
}
},
"cold": {
"min_age": "30d",
"actions": {
"searchable_snapshot": { "snapshot_repository": "backup" }
}
},
"delete": {
"min_age": "90d",
"actions": { "delete": {} }
}
}
}
# 来源:自实现 / storage_budget.py
def storage_budget(req_per_s, bytes_per_log, retention_days=90, redundancy=1.1):
"""按请求量与单条日志大小推算存储成本。"""
per_day = req_per_s * bytes_per_log * 86400 / (1024 ** 3)
total = per_day * retention_days * redundancy
return per_day, total
def series_budget(series_count, scrape_interval_s=15, bytes_per_sample=1.5, retention_days=15):
"""按时序序列数与采样间隔推算磁盘占用。"""
samples = series_count * (86400 / scrape_interval_s)
per_day = samples * bytes_per_sample / (1024 ** 2)
return per_day, per_day * retention_days
if __name__ == "__main__":
log_day, log_total = storage_budget(req_per_s=100, bytes_per_log=600)
metric_day, metric_total = series_budget(series_count=3000)
print(f"日志: 每天 {log_day:.1f} GB,90 天三副本约 {log_total:.1f} GB")
print(f"时序: 每天 {metric_day:.1f} MB,15 天约 {metric_total:.1f} MB")
7. 实践建议与最佳实践
监控体系的成败不在工具选型,而在流程与治理。工具只解决"能不能采到、能不能看到",流程解决"采到什么算数、告警由谁响应、坏了谁修",治理解决"指标不膨胀、告警不失控、数据不出格"。本章把前六章沉淀为一套可执行的落地方法:先定义上线流程,再建立治理规则,最后用持续改进闭环让监控体系自身保持健康。
落地的顺序也有讲究。正确的顺序是先定 SLO 再埋点,先建看板再配告警,先配路由再调阈值。反过来的顺序(先配告警再补 SLO)会在一个月后产生大量无法对账到目标的告警规则,届时删除规则比新增规则难得多。最佳实践的检验标准是:任意一条告警,值班人员都能在 5 分钟内回答三个问题——触发条件是什么、影响范围多大、止损动作在哪份文档里。
#mermaid-svg-6JKV9qv5LG0fQbjY{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6JKV9qv5LG0fQbjY .error-icon{fill:#552222;}#mermaid-svg-6JKV9qv5LG0fQbjY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6JKV9qv5LG0fQbjY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6JKV9qv5LG0fQbjY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6JKV9qv5LG0fQbjY .marker.cross{stroke:#333333;}#mermaid-svg-6JKV9qv5LG0fQbjY svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6JKV9qv5LG0fQbjY p{margin:0;}#mermaid-svg-6JKV9qv5LG0fQbjY .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster-label text{fill:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster-label span{color:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster-label span p{background-color:transparent;}#mermaid-svg-6JKV9qv5LG0fQbjY .label text,#mermaid-svg-6JKV9qv5LG0fQbjY span{fill:#333;color:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY .node rect,#mermaid-svg-6JKV9qv5LG0fQbjY .node circle,#mermaid-svg-6JKV9qv5LG0fQbjY .node ellipse,#mermaid-svg-6JKV9qv5LG0fQbjY .node polygon,#mermaid-svg-6JKV9qv5LG0fQbjY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6JKV9qv5LG0fQbjY .rough-node .label text,#mermaid-svg-6JKV9qv5LG0fQbjY .node .label text,#mermaid-svg-6JKV9qv5LG0fQbjY .image-shape .label,#mermaid-svg-6JKV9qv5LG0fQbjY .icon-shape .label{text-anchor:middle;}#mermaid-svg-6JKV9qv5LG0fQbjY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6JKV9qv5LG0fQbjY .rough-node .label,#mermaid-svg-6JKV9qv5LG0fQbjY .node .label,#mermaid-svg-6JKV9qv5LG0fQbjY .image-shape .label,#mermaid-svg-6JKV9qv5LG0fQbjY .icon-shape .label{text-align:center;}#mermaid-svg-6JKV9qv5LG0fQbjY .node.clickable{cursor:pointer;}#mermaid-svg-6JKV9qv5LG0fQbjY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6JKV9qv5LG0fQbjY .arrowheadPath{fill:#333333;}#mermaid-svg-6JKV9qv5LG0fQbjY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6JKV9qv5LG0fQbjY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6JKV9qv5LG0fQbjY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6JKV9qv5LG0fQbjY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6JKV9qv5LG0fQbjY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6JKV9qv5LG0fQbjY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster text{fill:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY .cluster span{color:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6JKV9qv5LG0fQbjY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6JKV9qv5LG0fQbjY rect.text{fill:none;stroke-width:0;}#mermaid-svg-6JKV9qv5LG0fQbjY .icon-shape,#mermaid-svg-6JKV9qv5LG0fQbjY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6JKV9qv5LG0fQbjY .icon-shape p,#mermaid-svg-6JKV9qv5LG0fQbjY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6JKV9qv5LG0fQbjY .icon-shape .label rect,#mermaid-svg-6JKV9qv5LG0fQbjY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6JKV9qv5LG0fQbjY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6JKV9qv5LG0fQbjY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6JKV9qv5LG0fQbjY :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-6JKV9qv5LG0fQbjY .default>*{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-6JKV9qv5LG0fQbjY .default span{fill:#faf9f5!important;stroke:#ffffff!important;color:#000000!important;stroke-width:0px!important;}#mermaid-svg-6JKV9qv5LG0fQbjY .default tspan{fill:#000000!important;}
持续改进
运行中
上线前
是
否
定义 SLO
埋点与指标命名
搭建看板与告警规则
例行巡检
告警是否命中
执行 Runbook
记录观察
复盘与优化
月度告警质量评审
阈值重校准
评估集更新
7.1 监控流程
监控流程以服务上线为起点,分四步验收。第一步定义 SLO:与业务方确认可用性与延迟承诺,产出 SLI 清单与错误预算。第二步埋点:按指标命名规范完成探针接入,验证 /metrics 端点输出与直方图桶位。第三步看板与告警:三层看板就位,告警规则按严重级别分档,路由配到接收方。第四步演练:用混沌手段注入一次 GPU OOM、一次延迟毛刺、一次模型版本回滚,验证告警能在 5 分钟内到达值班人员、Runbook 能在 30 分钟内执行完毕。
Runbook 是流程中比告警更重要的资产。每条告警规则必须关联一份 Runbook,写明触发条件、影响面判断、止损步骤、升级联系人。以 P99 延迟告警为例,Runbook 的排查顺序固定为:确认告警实例与模型版本、检查 GPU 利用率与队列长度、回滚到上一稳定版本、验证 P99 回落到 500ms 以下。Runbook 的维护与代码同等对待:每次故障复盘发现 Runbook 缺步骤就补,改完走评审合并,而不是口头传达。
流程的有效性靠演练与复盘检验。每两周一次故障演练,交替注入基础设施故障与模型层故障,演练通过率低于 80% 就要回炉培训;每次真实故障后 48 小时内完成复盘,输出 MTTD、MTTR、误判环节三项指标。持续改进的目标是可量化的:MTTD 从 30 分钟降到 5 分钟以内,MTTR 从 2 小时降到 30 分钟以内,这是流程设计而非工具选型带来的收益。
流程与变更管理是耦合的。一次模型版本发布既是变更也是监控的输入:发布前确认新版本的指标基线、发布中开启发布窗口的告警静默(只记录不打扰)、发布后 30 分钟对比新旧版本的延迟与错误率曲线。若新版本 P99 延迟高出基线 10% 以上,发布流程自动触发回滚,而不是等监控体系在事后发现。变更门禁把监控指标从"被动展示"提升为"主动决策输入",这是监控流程与 CI/CD 流水线衔接的关键一环。
# 来源:自实现 / runbook.py
from dataclasses import dataclass
@dataclass
class Step:
order: int
action: str
owner: str
timeout_min: int
RUNBOOKS = {
"p99_latency_2s": [
Step(1, "确认告警来源实例与模型版本", "oncall", 5),
Step(2, "检查 GPU 利用率和队列长度", "oncall", 5),
Step(3, "回滚模型版本到上一稳定版本", "ml-engineer", 15),
Step(4, "验证 P99 延迟回落至 500ms 以下", "oncall", 10),
],
"vram_90pct": [
Step(1, "确认显存占用最高的实例", "oncall", 5),
Step(2, "降低并发或迁移部分流量", "oncall", 10),
Step(3, "检查 KV Cache 是否可回收", "ml-engineer", 15),
],
}
def run(runbook_name):
steps = RUNBOOKS.get(runbook_name)
if not steps:
return "未找到对应 Runbook,转人工"
return " -> ".join(
f"{s.order}.{s.action}({s.owner},{s.timeout_min}分钟)" for s in steps
)
if __name__ == "__main__":
print(run("p99_latency_2s"))
7.2 监控治理
监控治理管三件事:命名、基数与责任。命名规范是治理的第一道闸门,指标名遵循 namespace_subsystem_name 三段式,例如 llm_latency_seconds 中 llm 是命名空间、latency 是子系统、seconds 是单位。单位后缀必须出现在指标名里,seconds、bytes、total、ratio 四类后缀是硬性要求,否则看板与告警表达式里无法凭名判断量纲,跨团队协作时必然出错。
标签基数是治理的第二道闸门。每个标签的取值数量上限设为 20,请求级 ID 不进指标域,高基数维度一律走日志域。基数的控制分两层:埋点规范在代码评审时检查,运行时用基数守卫拦截越界写入。超过基数的指标一旦进入时序库,删除成本远高于阻止成本,因此守卫必须配置在写入路径上而不是事后清理脚本上。
责任归属是治理的第三道闸门。每条告警规则必须绑定负责人与团队标签,负责人承担规则的准确性责任:阈值误报率超过 5% 由负责人调优,连续 3 个月零触发的规则由负责人给出保留或删除结论。数据治理与告警治理并列:日志保留期、PII 脱敏策略、审计访问记录,由合规角色每季度复核一次。治理的目标是让监控体系在人员流动的情况下依然可维护,负责人离职时规则不变成孤儿。
治理的落地要依托可执行的制度而非倡议。指标评审随代码评审一起执行,新增指标未过命名与基数校验不合并;告警规则以代码仓库管理,改阈值必须提交变更记录,注明原因与预期效果。值守排班与规则负责人对齐:同一团队的规则由本团队值班人员处理,跨团队的规则以对接人清单为准,避免告警发出后无人认领的真空期。
治理的边界还涉及监控资源本身的预算。每个服务分配的监控资源(时序序列数、日志配额)纳入容量管理,超预算的监控需求走申请评审,而不是无限增长。一个 3000 条序列、每天 5.2GB 日志的团队预算上限写进季度规划,超过上限由平台团队裁定取舍:优先保留 SLO 相关指标,删除长期无人查看的看板与规则。资源预算把监控治理从口号变成可审计的工程约束。
治理检查清单在服务上线评审时逐项打勾,缺少任何一项都不得进入生产:
- 指标命名通过 lint 校验,单位后缀齐全,无请求级 ID 进入指标域。
- 每个标签取值数不超过 20,运行时基数守卫已部署在写入路径。
- 每条告警规则绑定负责人与团队标签,关联的 Runbook 已评审合并。
- 日志保留期与 PII 脱敏策略已确认,敏感字段在序列化前完成掩码。
- 看板按三层划分且负责人明确,无人维护的看板标记为待归档。
- 监控资源预算在配额内,新增序列数与日志量已计入容量规划。
以上检查项对应本章正文的命名、基数、责任与资源四个治理闸门,任何一项未落实都会在故障时以"告警没人看、指标看不懂、数据没留够"的形式反噬。
# 来源:自实现 / metric_linter.py
import re
NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(_[a-z0-9]+)*$")
UNIT_SUFFIX = {"seconds", "bytes", "total", "ratio", "tokens"}
def lint(metric):
"""按命名规范校验指标名,返回违规清单。"""
problems = []
if not NAME_RE.fullmatch(metric):
problems.append("命名不规范,应为小写字母数字下划线")
parts = metric.split("_")
if len(parts) < 3:
problems.append("缺少 namespace 或 subsystem 段")
if parts[–1] not in UNIT_SUFFIX:
problems.append(f"缺少单位后缀,合法后缀: {sorted(UNIT_SUFFIX)}")
return problems
if __name__ == "__main__":
for name in ("llm_latency_seconds", "p99_latency", "GPU_util"):
issues = lint(name)
print(f"{name}: {'合规' if not issues else ';'.join(issues)}")
7.3 持续改进
持续改进是监控体系的元循环:监控自身的健康度也要被监控。月度评审的输入是告警质量数据:本月告警总数、误报数、漏报数、MTTD、MTTR,输出是三类调整——阈值重校准、规则增删、评估集更新。评审的量化目标设定为:告警精确率不低于 90%(每条告警对应真实故障),召回率不低于 95%(真实故障不漏报),误报率低于 5%,三者冲突时优先保召回。
阈值重校准是改进中最频繁的动作。统计阈值随数据漂移,每周用最近 30 天数据重算参考分布;业务阈值随 SLO 变更,SLO 每季度评审时同步更新。校准必须留下审计轨迹:阈值从 3 个标准差调到 2.5 个标准差,要记录原因与效果(误报从 8% 降到 4%),否则三个月后没人记得阈值为何如此设定,调整会退化为盲改。
评估集更新支撑离线监控的长期有效性。黄金评估集每月补充新业务场景的提示词,剔除已失效场景,保证评估集与线上分布对齐;PSI 的参考分布每月滚动更新一次,避免基线本身漂移。改进循环的最后一环是把优化写回流程:阈值调整、Runbook 补充、规则增删都走评审合并,形成"观测、决策、执行、沉淀"的闭环,让监控体系随业务演进而不是僵化成一套静态配置。
# 来源:自实现 / alert_quality.py
def evaluate(alerts, incidents, tp_window_min=15):
"""用告警与真实故障对照计算精确率与召回率。
命中判定:告警时间落在故障发生前 15 分钟窗口内,且不晚于故障发生。"""
window = tp_window_min * 60
hit_alerts = 0
for alert in alerts:
if any(0 <= inc["start"] – alert["time"] <= window for inc in incidents):
hit_alerts += 1
fp = len(alerts) – hit_alerts
# 召回率按"被至少一条告警命中的故障数"计算,防止多条告警重叠高估
detected = set()
for idx, inc in enumerate(incidents):
for alert in alerts:
if 0 <= inc["start"] – alert["time"] <= window:
detected.add(idx)
fn = len(incidents) – len(detected)
precision = hit_alerts / len(alerts) if alerts else 0.0
recall = len(detected) / len(incidents) if incidents else 0.0
return {"tp": hit_alerts, "fp": fp, "fn": fn, "precision": precision, "recall": recall}
if __name__ == "__main__":
# 模拟一个月:8 条告警对应 3 起真实故障,3 条告警为误报
alerts = [{"time": t} for t in (10, 20, 30, 40, 50, 60, 200, 240)]
incidents = [{"start": 10}, {"start": 30}, {"start": 50}]
result = evaluate(alerts, incidents)
print(
f"精确率 {result['precision']:.0%},召回率 {result['recall']:.0%},"
f"误报 {result['fp']} 条,漏报 {result['fn']} 起"
)
总结
模型监控与告警的落地路径可以概括为一条主线:以 99.9% 可用性 SLO 为锚,把 P99 首 token 延迟 500ms、错误率 1% 这类具体目标逐层转化为指标、异常检测、日志与告警四套子系统。指标层用直方图与双通道采集覆盖在线延迟与离线质量,异常检测以 3 个标准差加多方法投票控制误报率在 5% 以内,日志层以结构化 JSON 支撑全链路还原,告警层以分级、路由与去重把日均通知从 40 条压到 12 条。平台承载全部数据,流程与治理保证体系可维护,月度评审持续校准阈值与评估集。监控的最终检验标准只有一条:任何一次故障,从发生到被识别、到止损动作执行完毕,全部环节都有数据支撑,且过程可复盘、结果可改进。
外部引用
- Prometheus 官方文档:指标模型与 TSDB 存储:https://prometheus.io/docs/introduction/overview/
- prometheus_client Python 客户端:https://github.com/prometheus/client_python
- Prometheus Alertmanager 配置文档:https://prometheus.io/docs/alerting/latest/alertmanager/
- Grafana 官方文档:https://grafana.com/docs/
- Elasticsearch 索引生命周期管理(ILM):https://www.elastic.co/guide/en/elasticsearch/reference/current/index-lifecycle-management.html
- StatsD 标准协议:https://github.com/statsd/statsd
- scikit-learn IsolationForest 文档:https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.IsolationForest.html
- Google SRE Workbook(错误预算与 SLO):https://sre.google/workbook/
网硕互联帮助中心





评论前必须登录!
注册