2.3 分位数与聚合陷阱:histogramquantile 不是银弹 第 2.1 节我们把指标命名与标签设计讲透了,第 2.2 节把 vLLM / TGI 的 真正接进了 Prometheus,并顺手给了几条派生 PromQL。到这一步,理论上你已经能在 Grafana 里画出 TTFT 分位、吞吐、排队压力。但恰恰是「画分位」这一步,藏着大模型推理监控里最多、也最隐蔽的坑——很多人画的 P99 根本不是真实的 P99,只是看起来像。 读者读完这一节,应该能说出一句话:分位数永远要基于聚合后的原始直方图桶去算,绝不能对分位数再做平均;任何「先算分位、再跨实例求平均」的写法,都会系统性地骗你。 2.3.1 先厘清:分位数到底存在哪 很多人直觉里以为,Prometheus 把「P99 = 2.
第 2.1 节我们把指标命名与标签设计讲透了,第 2.2 节把 vLLM / TGI 的 /metrics 真正接进了 Prometheus,并顺手给了几条派生 PromQL。到这一步,理论上你已经能在 Grafana 里画出 TTFT 分位、吞吐、排队压力。但恰恰是「画分位」这一步,藏着大模型推理监控里最多、也最隐蔽的坑——很多人画的 P99 根本不是真实的 P99,只是看起来像。
读者读完这一节,应该能说出一句话:分位数永远要基于聚合后的原始直方图桶去算,绝不能对分位数再做平均;任何「先算分位、再跨实例求平均」的写法,都会系统性地骗你。
很多人直觉里以为,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% 的观察值都小于等于这个值」。也就是说,分位数是在查询那一瞬间,基于桶的分布现算的。
这个事实直接决定了后面所有正确写法。记住一句铁律:你能在查询里控制「在哪个聚合粒度上算分位」,但绝不能先算分位再二次聚合。
上图点破根因:TSDB 里只有桶,分位是算出来的;一旦你先把分位算出来、再去平均分位,就丢失了桶的分布信息,得到的数字在数学上不成立。
这是生产里出现频率最高、也最像「没问题」的错误。设想你的推理服务部署了 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。
一个量化直觉:当各副本分布差异越大(你线上大概率如此——有的副本刚被长请求拖住,有的空着),「avg 分位」和「合并分位」的差距就越大,而这种差距恰恰发生在你最该警惕的尾部。换句话说,你越需要 P99 来发现问题,avg 分位的误导就越严重。
histogram_quantile 的第二个参数必须是一个「按 le 分组的桶向量」。任何聚合操作只要把 le 从 by 列表里弄丢,查询就会报错或返回空;反之,如果在 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 暴涨但短桶平稳,混在一起你只看到一条温和的曲线。
一句话原则:by 里 = le + 你想看的下钻维度;by 里绝不含 instance、不应含任何你想「合并掉」的高基数维度。
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 窗口里既不会被完全平均掉,也不会让面板天天抽风。
histogram 的桶是 counter(只增不减)。Prometheus 的 rate() 会自动处理 counter 重置(进程重启导致计数归零),这是它比手算增量的优势。但有两个例外会让 rate 算错、分位失真:
rate 能识别「下降=重置」并修正;但如果重启极频繁(比如每隔几分钟 OOM 一次),rate 的修正会引入误差,分位曲线会出现周期性「塌方」。这不是 PromQL 的锅,而是服务本身在抖——监控诚实地把它暴露出来了,别去掩盖。metric_relabel_configs 做别名归一。如果你在归一时不小心把本该稳定的标签(如 model)也改写了,或者 instance 标签因 Pod 重建而频繁变化,原本连续的一条序列会被当成「旧序列结束 + 新序列开始」,触发 rate 重置修正,分位曲线出现不该有的跳变。应对:对 instance 这类会变的标签,若要在面板里保持稳定,用 honor_labels 或记录规则(recording rule)把不稳定维度固化;对 model 这类语义稳定的标签,确保 relabel 的 replace 是幂等的(同名进同名出)。
即使写法全对,histogram_quantile 返回的也只是「插值估算值」,不是精确分位。它的算法是:找到「累计比例刚跨过目标分位」的那个桶,然后用桶的上下界做线性插值。结果是——真实值落在桶内的哪个位置,是被线性假设「猜」出来的。
这意味着两件事:
le="1" 和 le="+Inf" 两档,P99 会被估算成「1 秒到无穷大之间随便插一个值」,毫无意义。这就是为什么 2.1 强调桶要覆盖「告警阈值附近的敏感区」。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 正好是边界之一。
当你发现「全局 P99」「按路由 P99」「按模型 P99」在很多面板都要用,每次都写一长串 histogram_quantile(sum by (le,...) (rate(...))) 既难维护又容易各写各的(前面讲的陷阱就会在团队里遍地开花)。最佳实践是用 Recording Rule 把「聚合后的桶」或「算好的分位」固化成一条新指标。
推荐两步固化:
record: job:llm_ttft_seconds_bucket:rate5m expr: sum by (le, route, model) (rate(llm_ttft_seconds_bucket[5m]))
histogram_quantile(0.99, job:llm_ttft_seconds_bucket:rate5m),所有面板共用同一份聚合,写法统一、性能也更好(recording rule 后台算好,查询不必每次重算)。这样还顺带解决了「团队里有人写 avg 分位、有人写合并分位」的分裂——约定只用 recording rule 产出的指标,错误写法根本没有入口。
把这一节的坑压缩成一张表,贴进你的监控规范文档:
| 反模式 | 表现 | 正确做法 |
|---|---|---|
| 对分位求平均 | 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 章)我们上升到「进阶原理」:当监控体系自己成为性能瓶颈时,如何在指标精度与系统开销之间做权衡。