2.3 分位数与聚合陷阱:histogram_quantile 不是银弹


文档摘要

2.3 分位数与聚合陷阱:histogramquantile 不是银弹 第 2.1 节我们把指标命名与标签设计讲透了,第 2.2 节把 vLLM / TGI 的 真正接进了 Prometheus,并顺手给了几条派生 PromQL。到这一步,理论上你已经能在 Grafana 里画出 TTFT 分位、吞吐、排队压力。但恰恰是「画分位」这一步,藏着大模型推理监控里最多、也最隐蔽的坑——很多人画的 P99 根本不是真实的 P99,只是看起来像。 读者读完这一节,应该能说出一句话:分位数永远要基于聚合后的原始直方图桶去算,绝不能对分位数再做平均;任何「先算分位、再跨实例求平均」的写法,都会系统性地骗你。 2.3.1 先厘清:分位数到底存在哪 很多人直觉里以为,Prometheus 把「P99 = 2.

2.3 分位数与聚合陷阱:histogram_quantile 不是银弹

第 2.1 节我们把指标命名与标签设计讲透了,第 2.2 节把 vLLM / TGI 的 /metrics 真正接进了 Prometheus,并顺手给了几条派生 PromQL。到这一步,理论上你已经能在 Grafana 里画出 TTFT 分位、吞吐、排队压力。但恰恰是「画分位」这一步,藏着大模型推理监控里最多、也最隐蔽的坑——很多人画的 P99 根本不是真实的 P99,只是看起来像。

读者读完这一节,应该能说出一句话:分位数永远要基于聚合后的原始直方图桶去算,绝不能对分位数再做平均;任何「先算分位、再跨实例求平均」的写法,都会系统性地骗你。

2.3.1 先厘清:分位数到底存在哪

很多人直觉里以为,Prometheus 把「P99 = 2.1 秒」这么一个数存在某个指标里,查询时直接 llm_ttft_p99 取出来就行。这是最大的误解。正如 2.1 提过的,分位数不是存在指标里的,是查询时算出来的

存进 TSDB 的只有三样东西(以 histogram 为例):

  • xxx_bucket{le="0.1"}:观察值落在「≤0.1 秒」这个桶里的累计计数(counter);
  • xxx_bucket{le="+Inf"}:总数(等于 _count);
  • xxx_sum:所有观察值之和;
  • xxx_count:总次数。

histogram_quantile(0.99, <bucket 向量>) 做的是:拿到一组带 le 标签的桶计数,用线性插值法估算出「99% 的观察值都小于等于这个值」。也就是说,分位数是在查询那一瞬间,基于桶的分布现算的

这个事实直接决定了后面所有正确写法。记住一句铁律:你能在查询里控制「在哪个聚合粒度上算分位」,但绝不能先算分位再二次聚合。

```mermaid flowchart LR O[原始观察值] --> B[落入各 le 桶 累计计数] B --> S[(TSDB 只存 bucket/sum/count)] S -->|查询时现算| Q[histogram_quantile] Q --> R[P99 估算值] Q -.错误做法.-> W[对 P99 再求平均] W --> X[无意义数字] ```

上图点破根因:TSDB 里只有桶,分位是算出来的;一旦你先把分位算出来、再去平均分位,就丢失了桶的分布信息,得到的数字在数学上不成立。

2.3.2 陷阱一:对分位数求平均(最经典、最致命)

这是生产里出现频率最高、也最像「没问题」的错误。设想你的推理服务部署了 3 个副本(instance=a/b/c),你想看「全局 TTFT 的 P99」。新手会这么写:

avg by (route) ( histogram_quantile(0.99, sum by (le, model) (rate(vllm:time_to_first_token_seconds_bucket[5m])) ) )

注意看:它先在每个 instance 内部算了 P99,再对这几个 P99 取 avg这是错的,而且错得很隐蔽。

为什么错?因为分位数不具备可加性。假设副本 a 的 TTFT 分布在 50ms300ms、P99=290ms;副本 b 因为混了一批长上下文请求,分布是 50ms8s、P99=7.9s;副本 c 类似 b。三个 P99 各自是真实的,但「(290+7900+7900)/3 ≈ 5363ms」这个平均,既不是 a 的真实 P99,也不是全局合并后的 P99——全局合并后真正的 P99 应该接近 8s(因为 b、c 的长尾尾巴里 1% 的请求确实花了 ~8s),而平均值把它「稀释」到了 5.3s,系统性低估了最坏用户体验。

正确的写法是:先把所有副本的桶计数加总,再算一次分位。sum by (le, ...) 跨实例聚合,histogram_quantile 只调用一次:

histogram_quantile(0.99, sum by (le, route, model) ( rate(vllm:time_to_first_token_seconds_bucket[5m]) ) )

差别只在一点:sum by (le, ...) 在 quantile 外面、且把 instance 排除在 by 之外。这样 Prometheus 先把 a/b/c 三副本的桶「叠」成一个大桶(真正合并了分布),再算一次分位,得到的才是全局真实 P99。

```mermaid graph TD A[错误: 先 quantile 再 avg] --> A1[每实例算 P99] A1 --> A2[对 P99 求平均] A2 --> A3[稀释长尾 低估风险] B[正确: 先 sum 桶 再 quantile] --> B1[跨实例 sum by le] B1 --> B2[合并分布成大桶] B2 --> B3[算一次 P99 = 真实长尾] A3 -.真实应≈8s.-> R[显示成5.3s 骗你] B3 -.真实.-> R2[≈8s 暴露风险] ```

一个量化直觉:当各副本分布差异越大(你线上大概率如此——有的副本刚被长请求拖住,有的空着),「avg 分位」和「合并分位」的差距就越大,而这种差距恰恰发生在你最该警惕的尾部。换句话说,你越需要 P99 来发现问题,avg 分位的误导就越严重。

2.3.3 陷阱二:sum by 里丢了 le,或多了不该有的标签

histogram_quantile 的第二个参数必须是一个「按 le 分组的桶向量」。任何聚合操作只要把 leby 列表里弄丢,查询就会报错或返回空;反之,如果在 by 里多加了无关标签,又会把本该合并的桶拆碎,算出的分位变成「每个标签组合各自一个」,丢失了全局视野。

典型错误:

# 错:sum by (model) 丢了 le → histogram_quantile 无法工作 histogram_quantile(0.99, sum by (model) (rate(..._bucket[5m])))

正确写法必须保留 le,并只保留你「想要的下钻维度」:

histogram_quantile(0.99, sum by (le, model) (rate(..._bucket[5m])) )

这里有个 LLM 场景特有的讲究:你想按「输出长度分桶」看分位(呼应 1.1 的分位×长度拆解),就必须把那个长度桶标签保留在 by。比如你的引擎上报了 llm_ttft_seconds{output_length_bucket="short|mid|long", model},那么:

histogram_quantile(0.99, sum by (le, output_length_bucket, model) ( rate(llm_ttft_seconds_bucket[5m]) ) )

这样你会得到三条曲线——短/中/长桶各自的 P99 并排。如果你把 output_length_bucket 也丢了,三条就混成一条,1.1 苦口婆心讲的「长尾藏在均值里」就在这里重演:长桶 P99 暴涨但短桶平稳,混在一起你只看到一条温和的曲线。

```mermaid graph LR K[by 子句设计] --> L[必须含 le: 否则 quantile 失败] K --> D[保留你想下钻的维度: model/route/长度桶] K --> X[剔除 instance 等聚合维度: 否则分位被拆碎] L --> R1[分位可算] D --> R2[得到有意义的拆分曲线] X --> R3[全局合并分位] ```

一句话原则:by 里 = le + 你想看的下钻维度;by 里绝不含 instance、不应含任何你想「合并掉」的高基数维度。

2.3.4 陷阱三:rate / irate / increase 用错,分位跟着抖

histogram_quantile 的第一个参数要用「速率类」向量喂桶,常见写法是 rate(..._bucket[5m])。但 rate 的窗口选择和 irate 的误用,会让分位要么过于平滑、要么噪声爆表。

  • rate(range):对 counter 取区间平均速率,平滑。适合画趋势、看分位。窗口建议 5m~15m,窗口太短(如 1m)会放大抖动,太长(如 1h)会抹掉近期劣化。
  • irate(range):只用区间最后两个点算瞬时速率,灵敏但噪声大。绝不要把它喂给 histogram_quantile——irate 会破坏桶之间的累计一致性,分位结果会乱跳。
  • increase(range):取区间累计增量,等价于 rate × 窗口秒数」。在算吞吐(sum(increase(tokens_total[1m]))`)时有用,但不适合直接喂分位(分位需要归一化的桶比例,rate 更合适)。

针对 LLM 推理我给一个明确主张:分位一律用 rate(..._bucket[5m]),不要用 irate;吞吐用 sum(rate(tokens_total[1m]))increase(...[1m]) 均可。窗口选 5m 是在「灵敏度」与「稳定性」之间的甜点——秒级毛刺(1.1 讲过的排队抖动、抢占)在 5m 窗口里既不会被完全平均掉,也不会让面板天天抽风。

```mermaid graph TD Q[分位计算喂桶] --> R[rate bucket 5m 平滑稳定 推荐] Q --> I[irate bucket 噪声大 禁用] Q --> X[increase 不归一 不推荐] T[吞吐计算] --> T1[sum rate tokens 1m] T --> T2[increase tokens 1m] ```

2.3.5 陷阱四:counter 重置与 relabel 造成的「断层」

histogram 的桶是 counter(只增不减)。Prometheus 的 rate() 会自动处理 counter 重置(进程重启导致计数归零),这是它比手算增量的优势。但有两个例外会让 rate 算错、分位失真:

  1. 进程重启后桶不连续:若你的推理引擎重启,counter 从 0 重新开始,rate 能识别「下降=重置」并修正;但如果重启极频繁(比如每隔几分钟 OOM 一次),rate 的修正会引入误差,分位曲线会出现周期性「塌方」。这不是 PromQL 的锅,而是服务本身在抖——监控诚实地把它暴露出来了,别去掩盖。
  2. relabel 改了标签导致序列「换身份」:2.2 讲过用 metric_relabel_configs 做别名归一。如果你在归一时不小心把本该稳定的标签(如 model)也改写了,或者 instance 标签因 Pod 重建而频繁变化,原本连续的一条序列会被当成「旧序列结束 + 新序列开始」,触发 rate 重置修正,分位曲线出现不该有的跳变。

应对:对 instance 这类会变的标签,若要在面板里保持稳定,用 honor_labels 或记录规则(recording rule)把不稳定维度固化;对 model 这类语义稳定的标签,确保 relabel 的 replace 是幂等的(同名进同名出)。

2.3.6 陷阱五:分位估算本身的误差,与桶边界设计

即使写法全对,histogram_quantile 返回的也只是「插值估算值」,不是精确分位。它的算法是:找到「累计比例刚跨过目标分位」的那个桶,然后用桶的上下界做线性插值。结果是——真实值落在桶内的哪个位置,是被线性假设「猜」出来的

这意味着两件事:

  • 桶越粗,估算误差越大。若你的 TTFT 桶只有 le="1"le="+Inf" 两档,P99 会被估算成「1 秒到无穷大之间随便插一个值」,毫无意义。这就是为什么 2.1 强调桶要覆盖「告警阈值附近的敏感区」。
  • 跨桶边界处误差最明显。比如 P99 恰好落在 le="2" 桶内,估算值会偏向 2 秒一侧;若你真正关心的阈值就是 2 秒(SLO 写 P99≤2s),桶边界设在 2s 会让「是否达标」的判断出现 ±一个桶宽度的模糊。

实操建议:把你最关键的 SLO 阈值(如 TTFT P99=2s、TPOT P99=50ms)直接设成一个桶边界,这样「分位是否越过阈值」的判定最干净;同时在阈值两侧各留一两档,让插值有参照。桶边界示例(TTFT):0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10(秒)——2s 正好是边界之一。

```mermaid graph LR B[桶设计] --> S[阈值设成桶边界: 判定最干净] B --> N[阈值两侧各留档: 插值有参照] B --> W[桶太粗: P99 误差大 无意义] S --> R1[分位越界一目了然] N --> R2[估算有锚点] W --> R3[误导决策] ```

2.3.7 进阶:用 Recording Rule 固化聚合,避免重复计算与歧义

当你发现「全局 P99」「按路由 P99」「按模型 P99」在很多面板都要用,每次都写一长串 histogram_quantile(sum by (le,...) (rate(...))) 既难维护又容易各写各的(前面讲的陷阱就会在团队里遍地开花)。最佳实践是用 Recording Rule 把「聚合后的桶」或「算好的分位」固化成一条新指标

推荐两步固化:

  1. 先把跨实例的桶合并成一个稳定指标(保留 le 与下钻维度):
record: job:llm_ttft_seconds_bucket:rate5m expr: sum by (le, route, model) (rate(llm_ttft_seconds_bucket[5m]))
  1. 在面板上直接 histogram_quantile(0.99, job:llm_ttft_seconds_bucket:rate5m),所有面板共用同一份聚合,写法统一、性能也更好(recording rule 后台算好,查询不必每次重算)。

这样还顺带解决了「团队里有人写 avg 分位、有人写合并分位」的分裂——约定只用 recording rule 产出的指标,错误写法根本没有入口。

```mermaid flowchart TD R[原始桶 rate 计算] --> RR[Recording Rule: job:..._bucket:rate5m] RR --> P1[面板1 P99] RR --> P2[面板2 P95] RR --> P3[告警规则] P1 --> S[统一口径 无歧义] P2 --> S P3 --> S ```

2.3.8 一张「反模式速查表」收尾

把这一节的坑压缩成一张表,贴进你的监控规范文档:

反模式 表现 正确做法
对分位求平均 avg(histogram_quantile(...)) 跨实例 sum by (le,...) 桶,再算一次分位
sum by 丢 le quantile 报错/空 by 必含 le
by 里含 instance 分位被拆成每实例一条 合并掉 instance,只留下钻维度
用 irate 喂分位 分位乱跳 rate(..._bucket[5m])
桶太粗 P99 估算无意义 桶覆盖阈值敏感区,阈值设成边界
relabel 改稳定标签 序列换身份、分位断层 归一保持幂等,固化不稳定维度
每次手写长表达式 团队写法分裂 用 Recording Rule 固化聚合

小结

这一节把「画分位」这件看起来简单的事拆成了五个真实陷阱:分位数不可平均(必须先合并桶再算一次)、by 必须含 le 且不含 instance、分位喂桶用 rate 而非 irate、counter 重置与 relabel 会造成断层、桶边界设计决定估算误差。它们在大模型推理场景尤其致命——因为你的副本分布天然不均、长尾天然肥厚,任何一处写错都会把「最该被看到的尾部风险」悄悄抹平。记住一句话总结:分位是算出来的,不是存出来的;算分位之前,永远先把原始桶在正确的粒度上合并好。 带着这套查询纪律,下一章(第 3 章)我们上升到「进阶原理」:当监控体系自己成为性能瓶颈时,如何在指标精度与系统开销之间做权衡。


发布者: 作者: 分词分到自闭的小龙虾 转发
评论区 (0)
U