AI 推理延迟分桶:P50 好看不代表 P99 没问题,长尾才是用户真正感知的 AI 推理延迟分桶P50 好看不代表 P99 没问题长尾才是用户真正感知的一、平均延迟 80ms 的服务投诉率却居高不下运维面板上推理服务的平均延迟显示 80msP50 是 75ms。从监控数据看服务表现堪称完美。但用户投诉量持续走高反馈有时候卡一下要等 2 秒。一查 P99 延迟——1200ms。再看 P99.9——3800ms。这 15 倍于 P50 的长尾延迟恰恰是用户真正感知到的卡。基础设施不需要漂亮话平均值在延迟分布上是个彻头彻尾的骗子。AI 推理服务的延迟分布天然就是长尾的batch size 波动、GPU 显存碎片、KV Cache 命中率变化、模型服务的热加载——每一个因素都能让极少数请求的延迟飙升数倍。而这些长尾请求的数量虽少对用户体验的杀伤力却是致命的。延迟分桶监控不是把请求按 100ms、100-200ms、200-500ms 分别计数就完事了。真正有效的延迟观测需要三样东西高精度分位数P50/P90/P99/P99.9、延迟分布热力图Heatmap、按调用链维度分拆哪个模型、哪个 batch size、哪个 GPU。三者缺一延迟问题就只能靠猜。二、延迟分桶的统计模型与可视化Prometheus 的 Histogram 类型天然支持分桶统计。原理是将观测值落入预定义的区间bucket中计数查询时用histogram_quantile做线性插值估算分位数graph LR subgraph 采集层 A1[推理请求1br/延迟 45ms] -- B[Histogram Buckets] A2[推理请求2br/延迟 210ms] -- B A3[推理请求3br/延迟 1800ms] -- B end subgraph Bucket 分布 B -- C1[≤50ms: 1] B -- C2[≤100ms: 1] B -- C3[≤200ms: 2] B -- C4[≤500ms: 2] B -- C5[≤1000ms: 2] B -- C6[≤2000ms: 3] B -- C7[≤Inf: 3] end subgraph 分位数计算 C1 C2 C3 C4 C5 C6 C7 -- D[histogram_quantilebr/线性插值] D -- E1[P50 ≈ 155ms] D -- E2[P99 ≈ 1960ms] end subgraph 问题诊断 E1 -- F1{与 SLO 对比} E2 -- F2{长尾根因分析} F2 -- F3[Batch Size 不均?] F2 -- F4[GPU 显存碎片?] F2 -- F5[KV Cache Miss?] endBucket 的边界设计是分桶监控中最容易出错的一环。假设 SLO 是 200ms但 bucket 只有{50ms, 100ms, 500ms, 1000ms}那 P99 的估算误差可以高达数百毫秒。正确做法是在 SLO 阈值两侧加密 bucket# 错误示例bucket 跨度太大 Buckets: [10, 50, 100, 500, 1000, 5000] # 正确示例SLO200ms 时在 100-300ms 区间加密 Buckets: [10, 25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 500, 1000, 2000, 5000]加密 bucket 的代价是时间序列数量增加。每个 bucket 就是一个独立的时间序列。20 个 bucket × 3 个标签维度model × version × gpu可能产生数千条时间序列。需要在精度和成本之间找到平衡——建议 SLO 阈值 ±50% 范围内加密到 25ms 间隔远离 SLO 的区域用对数间隔。三、生产级延迟分桶监控实现以下代码在 Go 推理网关上实现精确的延迟分桶统计配合 Prometheus Histogram Heatmappackage observability import ( context net/http time github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promauto github.com/prometheus/client_golang/prometheus/promhttp go.opentelemetry.io/otel go.opentelemetry.io/otel/attribute go.opentelemetry.io/otel/metric ) // InferenceLatencyMetrics 推理延迟分桶指标体系 type InferenceLatencyMetrics struct { // Prometheus Histogram按模型版本GPU 维度分桶 // 使用 exponential bucket 在 SLO 附近加密 requestDuration *prometheus.HistogramVec // 长尾请求计数器超过 SLO 阈值 ×2 的请求单独计数 longTailCounter *prometheus.CounterVec // Heatmap 分桶用于 Grafana Heatmap 面板 // 每个 bucket 独立计数方便构建延迟分布热力图 latencyBuckets *prometheus.CounterVec // OpenTelemetry Histogram用于分布式链路追踪的延迟上报 otelHistogram metric.Float64Histogram } // NewInferenceLatencyMetrics 创建推理延迟指标集 // sloMs: SLO 阈值毫秒bucket 加密策略以此为中心 func NewInferenceLatencyMetrics(sloMs float64) (*InferenceLatencyMetrics, error) { // 构建动态 bucket 列表SLO 前加密到 10ms 间隔SLO 后对数分布 buckets : buildBuckets(sloMs) m : InferenceLatencyMetrics{ requestDuration: promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: inference_request_duration_seconds, Help: AI 推理请求延迟分布 (Histogram), Buckets: buckets, }, []string{model_name, model_version, gpu_uuid}, ), longTailCounter: promauto.NewCounterVec( prometheus.CounterOpts{ Name: inference_long_tail_requests_total, Help: 超过 SLO×2 的长尾请求计数, }, []string{model_name, model_version}, ), latencyBuckets: promauto.NewCounterVec( prometheus.CounterOpts{ Name: inference_latency_distribution, Help: 推理延迟分布按延迟区间分桶, }, []string{model_name, latency_range}, // latency_range: 50ms, 50-100ms, ... ), } // 初始化 OpenTelemetry Histogram meter : otel.Meter(inference-gateway) otelHistogram, err : meter.Float64Histogram( inference.request.duration, metric.WithDescription(Inference request duration in seconds), metric.WithUnit(s), ) if err ! nil { return nil, err } m.otelHistogram otelHistogram return m, nil } // buildBuckets 构造自适应 bucket 数组 // 策略SLO 前 50% 范围以 10ms 间隔加密SLO 后使用指数增长 func buildBuckets(sloMs float64) []float64 { var buckets []float64 // 阶段一50ms 以下5ms 间隔 for ms : 5.0; ms 50; ms 5 { buckets append(buckets, ms/1000.0) } // 阶段二SLO 前 50% 到 SLO50%10ms 间隔 startMS : sloMs * 0.5 endMS : sloMs * 1.5 for ms : startMS; ms endMS; ms 10 { buckets append(buckets, ms/1000.0) } // 阶段三SLO 以上对数间隔乘数 2.0 for ms : endMS 50; ms 10000; ms * 2.0 { buckets append(buckets, ms/1000.0) } return buckets } // RecordRequest 记录一次推理请求的延迟数据 // 必须在请求处理完毕后调用 func (m *InferenceLatencyMetrics) RecordRequest( ctx context.Context, modelName, modelVersion, gpuUUID string, duration time.Duration, ) { durSeconds : duration.Seconds() // 1. Prometheus Histogram 观测 m.requestDuration.WithLabelValues(modelName, modelVersion, gpuUUID).Observe(durSeconds) // 2. 长尾检测超过 SLO×10 的极端请求打标 // 注意sloMs 在初始化时已确定这里用 2 秒作为硬编码长尾阈值 if durSeconds 2.0 { m.longTailCounter.WithLabelValues(modelName, modelVersion).Inc() } // 3. 延迟区间分布计数用于热力图 latencyRange : classifyLatency(duration) m.latencyBuckets.WithLabelValues(modelName, latencyRange).Inc() // 4. OpenTelemetry Histogram 上报 m.otelHistogram.Record(ctx, durSeconds, attribute.String(model.name, modelName), attribute.String(model.version, modelVersion), attribute.String(gpu.uuid, gpuUUID), ) } // classifyLatency 将延迟归类到区间标签 func classifyLatency(d time.Duration) string { ms : d.Milliseconds() switch { case ms 50: return 50ms case ms 100: return 50-100ms case ms 200: return 100-200ms case ms 500: return 200-500ms case ms 1000: return 500-1000ms case ms 2000: return 1000-2000ms default: return 2000ms } } // Middleware 返回 HTTP 中间件自动记录所有推理请求的延迟 func (m *InferenceLatencyMetrics) Middleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start : time.Now() // 使用 ResponseWriter wrapper 捕获状态码 wrapped : responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(wrapped, r) duration : time.Since(start) // 从 context 或 header 提取模型信息 modelName : r.Header.Get(X-Model-Name) if modelName { modelName unknown } modelVersion : r.Header.Get(X-Model-Version) if modelVersion { modelVersion unknown } gpuUUID : r.Header.Get(X-GPU-UUID) if gpuUUID { gpuUUID unknown } m.RecordRequest(r.Context(), modelName, modelVersion, gpuUUID, duration) }) } } type responseWriter struct { http.ResponseWriter statusCode int } func (rw *responseWriter) WriteHeader(code int) { rw.statusCode code rw.ResponseWriter.WriteHeader(code) }对应的 Grafana 延迟热力图 PromQL# P50/P90/P99/P99.9 分位数趋势 histogram_quantile(0.50, sum(rate(inference_request_duration_seconds_bucket[1m])) by (le, model_name)) histogram_quantile(0.99, sum(rate(inference_request_duration_seconds_bucket[1m])) by (le, model_name)) histogram_quantile(0.999, sum(rate(inference_request_duration_seconds_bucket[1m])) by (le, model_name)) # 长尾请求占比 rate(inference_long_tail_requests_total[5m]) / rate(inference_requests_total[5m]) # 延迟分布热力图按延迟区间 sum(rate(inference_latency_distribution[1m])) by (latency_range)四、分桶精度的工程权衡Bucket 数量与时间序列成本每个唯一的标签组合 × bucket 数量 一条时间序列。如果按model_name × model_version × gpu_uuid三个标签维度每种组合 20 个 bucket100 个模型版本 × 10 张 GPU 20000 条时间序列。加上_bucket、_sum、_count三个基础序列总共约 60000 条。在千卡集群中这个量级对 Prometheus 是可控的但必须配合 Recording Rules 做预聚合避免 Grafana 面板在查询时计算histogram_quantile。P99.9 的采样不足问题P99.9 意味着每 1000 个请求只取 1 个样本。如果服务每分钟处理 1000 个请求P99.9 每分钟只有一个有效样本统计上极其不稳定。在一分钟内 P99.9 可能从 800ms 跳到 3500ms 再跳回来这不代表系统出了问题而纯粹是样本量太小导致的抖动。对此有两种应对策略加长评估窗口从 1m 改为 5m 或 15m让更多样本参与分位数计算或者改用 P99 作为核心告警指标P99.9 仅做长期趋势观察。长尾的根因追踪知道了 P99 高没有用得知道为什么高。延迟分桶要和请求追踪链路打通——当 P99 超过阈值时自动采样那 1% 的长尾请求的完整 Trace通过 Trace 中的 span 耗时分布定位瓶颈在模型推理、GPU 调度还是网络传输。Prometheus 只告诉你有问题Jaeger/Tempo 才告诉你问题在哪。Histogram vs SummaryPrometheus 同时支持 Histogram 和 Summary 两种分位数指标。Summary 在客户端计算分位数精度更高但不能跨实例聚合。Histogram 在服务端用histogram_quantile计算可以跨实例聚合精度受 bucket 设计影响。推荐使用 Histogram因为在 Kubernetes 环境下实例是动态变化的跨实例聚合是刚需。五、总结AI 推理服务的延迟监控必须从平均值和 P50 升级到多维分桶P50/P90/P99/P99.9 四位一体去掉任何一个都是盲区。P99 是告警主力P99.9 做趋势观察平均值只用来算总吞吐。Bucket 要在 SLO 阈值周围加密SLO ±50% 范围内用 10-25ms 间隔的细粒度 bucket远离 SLO 的区间用对数间隔平衡精度与成本。长尾请求必须独立追踪超过 SLO×2 的请求单独打标计数关联分布式 Trace 做根因定位。计数告诉你比例Trace 告诉你根因。配合 Recording Rules 做预聚合直接查询原始 Histogram 的histogram_quantile成本极高预聚合后 P50/P99 面板查询时间从秒级降到毫秒级。P50 好看只能说明大部分请求正常P99 才是用户看到的产品质量。