大概三个月前,我负责的一个OCR识别服务经历了一次让人后背发凉的升级。新版本的模型在离线评测集上准确率提升了将近三个点,压测的吞吐也过得去,于是我选在一个业务低峰期的周四下午做了灰度发布。结果半小时后,业务方在群里甩过来一个录屏:客户端转圈八秒钟才返回结果,用户已经开始投诉了。我的第一反应是查日志、看报错,结果服务一切正常,模型也正常返回,只是"变慢了"——准确率提高了,单次推理耗时却从原来的600ms涨到了2.8秒。更尴尬的是,当时整个项目居然没有一个现成的监控面板能告诉我这2.8秒到底消耗在哪一个环节。就是从那一刻起,我开始系统性做这套AI模型推理延迟监控方案。这篇文章把我踩过的坑、验证过的做法、以及最终沉淀下来的监控体系完整梳理一遍,给同样在折腾AI应用、大模型服务、ML基础设施的朋友们一条可以直接参考的落地路径。
1. 为什么监控延迟比监控准确率更难:一次事故复盘
1.1 那次让业务喊停的模型升级
事故之后我做了复盘,发现最核心的问题不是"新模型变慢了"这件事本身,而是我们对这个慢根本没有感知能力。准确率可以用离线评测集来验证,跑一遍脚本就出数字;吞吐可以用压测工具来摸底,平均QPS、平均耗时一拉就有。但推理延迟是一个在真实流量下才会暴露的动态指标,它与输入的分布、并发的高低、当前机器的资源状态强相关。
离线评测和压测都存在同一个盲区:它们使用的输入数据太"干净"了。评测集的图片分辨率、文本长度、图像内容都是固定的,压测时并发可控、请求均匀、不存在突刺,所以算出来的"平均延迟"掩盖了长尾问题。真实生产流量完全不是这样:同一个模型,用户传来的图片有的300x300,有的4000x3000,有的文本十几个字,有的上下文几万token,最终的单次推理耗时可能相差几十倍。
当时我们看到的数字很典型:压测阶段服务端平均耗时650ms,P99大概1.2s,看起来完全可接受。但灰度之后生产流量的P99直接飙到8秒以上,整条链路的瓶颈根本不在模型本身,而在于请求排队、预处理时间和上下文组装时间。这些环节在压测脚本里几乎是零开销,到了生产环境却变成了延迟的主要贡献者。所以后来我特别强调一个原则:评估一个推理服务能不能上线,不能只看准确率和吞吐,必须把延迟的P50、P95、P99作为硬性发布门槛。
1.2 延迟指标远不止"快慢":TTFT、TPOT与端到端
很多第一次接触推理延迟监控的朋友会问:不就是一个请求从发出到返回的时间吗?把它记下来,求个平均不就行了?实际上,对于不同形态的AI应用,需要观测的延迟指标差异非常大。
对于传统的小模型推理服务(OCR、目标检测、向量编码),通常看两个数字就够了:服务端端到端时延和模型前向推理耗时。但对于大语言模型、多模态生成这种流式服务,必须拆分得更细,至少包含四个维度:
- TTFT(Time To First Token):从请求发出到返回第一个输出token的时间。这个指标直接决定用户"点完按钮后白屏多久",是对话式体验最敏感的指标。
- TPOT(Time Per Output Token):生成模式下每个token的平均产出时间,相当于模型的"吐字速度"。对话流畅度主要看它。
- 端到端时延:从客户端发起到完整响应落地的总时长。
- 排队时延:请求到达服务后、进入实际推理前的等待时间。这个时间往往被忽略,但在高并发下恰恰是P99爆炸的主要来源。
如果你做的应用还串联了RAG检索、多轮对话历史处理,那么检索耗时、Prompt组装耗时也要单独埋点。道理很简单:用户报障说的是"整个功能变慢了",但工程上必须知道"慢在哪一段",否则排查起来就是大海捞针。我在事故之后做的第一件事,就是拉着后端把请求生命周期从头到尾理了一遍,然后在每个边界都打上时间戳。这一步做完,监控面板才终于有了可以依托的骨架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 延迟到底卡在哪:推理链路的逐段拆解
2.1 从客户端到GPU:一个请求的完整生命周期
我习惯把一次推理请求从发出到返回拆成七个阶段,每个阶段都有独立的监控埋点。你可以对照自己项目的架构做裁剪,但拆解思路是一样的。
| 链路阶段 | 做什么 | 典型耗时量级 | 关键监控指标 |
|---|---|---|---|
| 1. 客户端网络传输 | 用户到网关的网络往返 | 10ms~500ms | 客户端测速、网络请求耗时 |
| 2. 网关接入 | 鉴权、路由、限流 | 1ms~30ms | 网关处理时延、拒绝次数 |
| 3. 服务端排队 | 请求进入线程池/事件循环等待 | 0ms~数秒 | 队列长度、排队等待时延 |
| 4. 预处理 | tokenizer、图像解码、Prompt组装、检索 | 5ms~500ms | 预处理耗时、输入长度分布 |
| 5. 模型推理 | Prefill、Decode、单次前向传播 | 10ms~数秒 | 推理耗时、GPU利用率、显存 |
| 6. 后处理 | 采样、JSON解析、输出过滤 | 1ms~100ms | 后处理耗时 |
| 7. 响应传输 | 结果回传客户端 | 10ms~500ms | 响应大小、序列化耗时 |
这个表格不是让你照搬,而是提醒你:延迟是累加的,任何一个环节都可能成为瓶颈。我见过最隐蔽的一个问题出在第4步——某个Python服务每次请求都会重新加载一个外部词典文件,平时只花几十毫秒,一旦磁盘IO抖动就飙到几百毫秒,直接拉高了P99。这种问题如果不把预处理阶段单独埋点,根本查不出来。
2.2 最容易成为隐形瓶颈的三个环节
根据我实测过的经验,绝大多数推理服务的延迟劣化都出在三个"隐性瓶颈"上。
第一个是服务端排队。很多工程团队把注意力放在模型推理耗时上,却忽略了服务的并发模型。如果你用的是同步的线程池,每个线程在同一时刻只能处理一个请求,线程数只有20,那么当同时到达的请求超过20个时,多余的请求就只能排队。等得越久,用户感知越慢,但模型本身的推理耗时并没有变。这时候如果你只监控"模型推理时长",数字永远是漂亮的,可用户体感已经崩了。
第二个是Prefill和Decode的资源竞争。大模型场景最常见的麻烦:生成流式输出时,一个新来的请求需要做Prefill,而Prefill需要大量的GPU算力,结果正在进行的Decode节奏被打乱,所有并发请求的TPOT集体变慢。这个问题在单机部署、大批量并发的场景下尤其明显。引用NVIDIA和vLLM社区的一些实践结论:Token生成速度和当前Batch大小、KV Cache命中率强相关,单纯看GPU利用率是解释不了"为什么每个token都变慢了"这种问题的,需要结合每秒生成的Token数来综合判断。
第三个是后处理和IO抖动。Python服务里GIL锁竞争、数据库连接池打满、Redis超时重试、响应体序列化时间过长——这些和模型推理完全无关的环节,最后都会算进"接口总耗时"里。我见过一个项目,模型推理只用了300ms,但接口总耗时超过2秒,排查到最后发现是服务在把模型输出转换成前端需要的JSON结构时,跑了一个嵌套很深的循环遍历,时间复杂度O(n^2),数据一长就爆炸。所以监控不只是盯着GPU,也要把业务代码的CPU时间纳入视野。
3. 从埋点到看板:延迟监控的工程落地细节
3.1 指标与追踪的选型:为什么我用OpenTelemetry配合Prometheus
监控方案的选型,我最终确定的是OpenTelemetry负责链路追踪和数据采集,Prometheus负责指标存储,Grafana负责可视化。这个组合有几个好处:Prometheus生态足够成熟,绝大多数公司已经在用;OpenTelemetry有统一的埋点API,以后即使换了监控后端,埋点代码也不用重写。
有朋友问我为什么不用自研方案,我的回答是:延迟监控这个领域,自研的成本远比你想象的高。你需要处理指标存储、分位数计算、时间窗口对齐、告警规则引擎、可视化面板,每一个模块都有成熟的现成方案。自研这些属于典型的"重复造轮子",而且造出来的轮子往往还不如开源的稳定。当然,如果你的团队已经有统一的监控平台,优先接入现有平台,不要图新鲜另起炉灶。
部署形态上,服务内通过OpenTelemetry SDK采集指标,用OTLP Exporter推送到Collector,再由Collector转给Prometheus。监控进程以独立部署的方式放一台轻量机器上,采集周期设为15秒。注意一点:采集间隔不要设得太短,否则你的监控系统本身就会变成性能瓶颈。
3.2 埋点代码怎么写:Histogram的bucket选择是门学问
指标埋点我推荐直接用Prometheus客户端的Histogram类型,也就是直方图。它比Summary更灵活,能够在服务端动态计算P50、P90、P95、P99。下面是一段我在Python推理服务里实际用过的埋点示例:
python复制from prometheus_client import Histogram, Counter, Gauge
# 请求耗时直方图,按模型名、版本、端点分开
REQUEST_DURATION = Histogram(
'inference_request_duration_seconds',
'Inference request duration in seconds',
['model_name', 'model_version', 'endpoint'],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
)
# 请求总数
REQUEST_COUNT = Counter(
'inference_request_total',
'Total inference requests',
['model_name', 'model_version', 'endpoint', 'status']
)
# 当前排队中的请求数
QUEUE_SIZE = Gauge(
'inference_queue_size',
'Current number of requests waiting in queue',
['model_name']
)
这里最容易被忽略的是bucket的设计。如果bucket范围选得太窄,比如最大只到2.0,那么一旦出现5秒的慢请求,它就全部落进最后一个桶,分位数直接就失真了。我建议bucket的跨度覆盖你预期延迟范围的1到2个数量级,宁可多设几个高区间的桶。按我的经验,像0.005到10.0这个区间,基本能覆盖大多数推理服务的延迟分布。
再说说为什么不用Summary。Summary是在客户端算好分位数再上报的,问题是你没法在后续调整分位数阈值;而且多个实例的分位数不能做聚合,没法算出全局P99。Histogram则可以在Prometheus端通过histogram_quantile函数随时计算任意分位数,灵活得多。
3.3 业务指标与系统指标的对齐
光埋点还不够,延迟的问题最终要落到系统资源上。我通常会把三类指标放进同一个看板:业务指标、系统指标、调度指标。业务指标就是上面埋的请求耗时、QPS、成功率;系统指标包括GPU利用率、显存占用、CPU使用率、磁盘IO;调度指标包括队列长度、并发数、Batch Size。
解释一下为什么要对齐看。假如你发现P99升高,第一反应应该去看GPU利用率。如果GPU利用率本身不高,说明瓶颈不在推理算力,而在排队或者预处理;如果GPU利用率已经接近100%,说明模型推理本身是瓶颈,可能需要优化模型或者扩容。这组联动关系是定位问题的关键,我在下一章会用一个真实案例说明具体怎么看。
系统指标采集方面,GPU部分我推荐用DCGM Exporter,它可以直接导出NVIDIA GPU的利用率、温度、显存、功耗等细粒度指标;普通机器指标用node_exporter就够。最后在Prometheus里通过label把业务指标和系统指标关联起来,比如按model_name和instance对齐。这样每个模型服务都可以看到它对应的那台GPU机器的实时状态,联动分析时非常顺手。
4. 慢请求定位的真实链路:一次P99飙高排障全记录
4.1 告警触发后的第一轮操作
有一次周五下午,告警规则触发了:某个对话式模型的P99延迟在10分钟内从1.2秒涨到3.5秒。当时我做监控已有几个月的积累,所以排查链路相对清晰。第一步不是看日志,而是打开Grafana全局面板,先看三个东西:哪个模型的P99涨了、QPS有没有变化、成功率有没有掉。
看一眼数据,QPS稳定在40左右,成功率100%,只有P99飙高——这说明不是流量突刺引起的雪崩,而是服务内部某个环节变慢了。接着我看请求耗时分布,发现P50只有400ms,但P99到了3.5秒,这是一个典型的"长尾劣化"信号:大多数请求是快的,有一小撮请求非常慢。
这一步就把问题范围缩小了很多:不是整体资源不够,而是存在某种局部阻塞,只在特定条件下触发。带着这个假设,我点开服务的队列长度面板,发现queue_size在告警时段出现了规律性的锯齿波动,每次堆积到200左右才被消化。于是怀疑点从"模型变慢"转移到了"排队机制异常"。
4.2 把问题收敛到"队列"还是"GPU"的关键判断
判断排队问题还是推理问题的核心在于看三个指标的组合:GPU利用率、请求P50、队列长度。我总结了一张判断表,在排障时非常实用:
| 现象组合 | 根因方向 |
|---|---|
| GPU利用率高 + P50升高 + 队列变长 | 推理能力不足,需要扩容或优化模型 |
| GPU利用率正常 + P50正常 + 队列变长 | 请求调度或并发模型问题 |
| GPU利用率正常 + P50正常 + 队列正常 + P99变高 | 长尾慢请求,可能是锁竞争、IO抖动 |
| GPU利用率忽高忽低 + P50抖动 | Batch策略不稳定或热点请求 |
那次的情况属于第二类:GPU利用率只有55%,P50完全正常,但队列在堆积。说明算力本身是够的,问题出在"请求进入推理之前"的调度环节。再往下查,发现服务在线程池的设计上有一个隐藏缺陷:固定线程数是16,但其中4个线程被一个定时后台任务占用,导致实际可用于处理推理请求的线程只剩12个。高峰期一下来了30个并发,16个线程里12个在忙,4个在跑后台任务,剩余请求就只能排队。
这个问题的隐蔽之处在于:线程池总容量没变,代码没改,只是后台任务的执行频率在高峰期恰好和请求高峰撞在一起,导致有效容量突然下降。如果不是监控把"队列长度"和"线程池活跃线程数"做成了一张联合面板,靠人眼看日志几乎不可能定位到这一层。
4.3 算清并发和排队的关系,才能确定根因
定位到线程池后,还需要从理论上验证这个判断是否成立。排队论里有一个很基础但非常好用的公式——Little's Law:L = λ × W,L是系统里的平均请求数,λ是到达率,W是每个请求的平均处理时长。
回到当时的数据:QPS约40,P50约0.4秒,那么系统里平均停留的请求数约为40×0.4=16个。而线程池实际可用线程只有12个,意味着平均有4个请求在排队等待。高峰期QPS冲到60时,系统内请求数变成60×0.4=24个,超出可用线程数12个,排队请求变成12个,堆得越来越快,P99自然就爆了。
后来我们把四线程后台任务挪到独立进程,同时给线程池加了一个动态扩容策略,P99迅速回落到1.4秒左右。这次排障让我深刻体会到:延迟监控不能只记录现象,必须把业务指标和系统指标放到同一个视角下去关联,才能做到可解释、可归因、可复现。
5. 告警阈值设置:从"狼来了"到"一告一个准"
5.1 静态阈值为什么会让人麻痹
很多团队的告警规则都是这么写的:P99超过2秒就发告警。看起来简单直接,但实际上极容易误报。为什么?因为推理服务的延迟天然波动,白天高峰和凌晨低峰的P99可以差一倍以上。如果固定阈值设得低,高峰时段每天弹告警,大家看多了就麻木了,等到真正出大事的时候反而没人响应。如果阈值设得高,又会在低峰期漏掉真正的劣化。
说白了,静态阈值的核心问题是没有"上下文"意识。它不知道当前这个模型平时P99是多少、也不知道是否处于高峰时段、更不知道这个延迟水平和昨天同时段相比是变好了还是变差了。没有基线参照,告警就没有说服力。
所以我在项目里全面改成了动态基线策略。核心思路是:用"历史同时段数据"作为基线,当当前指标明显偏离基线时才告警。
5.2 用分位数和滑动基线替代固定阈值
具体做法是先用Prometheus里的histogram_quantile算出P99实时值,再和过去7天同一时间窗口的P99做对比。PromQL写出来长这样:
promql复制# 当前5分钟的P99
histogram_quantile(0.99,
sum(rate(inference_request_duration_seconds_bucket[5m])) by (le, model_name)
)
# 高于过去7天同时段P99的1.5倍时触发告警
> 1.5 * (
histogram_quantile(0.99,
sum(rate(inference_request_duration_seconds_bucket[5m] offset 1d)) by (le, model_name)
)
)
这条规则的含义是:昨天这个时刻P99如果只要1秒,今天超过1.5秒就告警;如果昨天这个时刻P99是3秒,今天3.5秒就不会误报,因为它本身就在正常波动范围内。建议同时叠加一个最低阈值,比如P99必须超过1秒才触发告警,避免在基线非常低的时候出现"翻倍了但仍然无所谓"的噪音。
5.3 按业务影响分级,别把生产告警和优化提示混在一起
告警分级的本质是把"必须立刻处理"和"可以稍后看看"分开,否则所有告警都往群里发,最后所有人都会选择性忽略。
我最终把推理延迟告警分了三级。P0级对应的是可用性受损或SLO被突破,比如端到端成功率持续下降、P99超过SLO硬性要求连续5分钟以上,此时直接电话+IM双重通知;P1级对应明显性能劣化,比如P99动态基线偏离超过1.5倍且持续10分钟、或者GPU利用率异常打满,这会触发IM通知,由当值负责人确认;P2级则是优化建议类,比如某模型P50持续走高、但P99还在合理范围,这类只在告警面板里展示,不主动推送,等有精力时再优化。
另外要特别注意告警频率控制。同一个问题如果持续一小时,中间不要重复发同级别的告警,否则疲劳轰炸反而会掩盖问题。我通常设置一个聚合窗口:同一个告警至少间隔30分钟才允许再次触发,并且每轮告警都要带上当前值、基线值、持续时间、涉及模型和实例信息,让收到告警的人不用打开Grafana就能有个初步判断。
6. 降延迟不只是监控的事:回流验证与持续迭代
6.1 优化前后对比的正确打开方式
监控做到位之后,紧接着的问题就是怎么用它来做回归验证。总不能每次优化完模型、调完参数之后,还是靠压测脚本拍脑袋猜效果。我建议在监控体系里增加一个固定的"对比视图":横轴是时间,纵轴是P50/P95/P99,用不同的颜色标识优化前和优化后两个时间窗口。
做对比时有一个必须严格把控的前提——控制变量。优化前后必须保证测试流量、输入数据分布、并发数基本一致,否则对比没有意义。我之前踩过一个坑:把压测从一个8核机器挪到一台16核机器上跑,误以为是模型优化带来的延迟下降,实际上纯粹是机器变强了。正确做法是固定同一批测试数据集,并记录输入长度分布、Batch Size等变量,只改动你真正想验证的那个参数。
6.2 我在这一轮监控建设里踩过的坑
分享几个我遇到过并且花了不少时间才走出来的具体问题。
第一个坑是采样精度不够。监控刚上线时我把采样率设成100%,结果请求量大时Prometheus存储压力巨大,查询变慢,Grafana面板经常转圈。后来改成固定采样10%,又把Histogram的bucket优化了一版,问题才缓解。但这里有个经验要记住:分位数对采样率很敏感,如果你的P99总是跳来跳去,先检查采样率是不是太低了,低于1%时P99基本不可信。
第二个坑是业务口径不统一。前端同学说的"响应慢"指的是从点击到渲染完成,后端同学说的"处理耗时"是网关到网关,两边统计的口径不一样,开会时对不上。后来我们干脆定了一个标准:所有延迟讨论都以网关日志的时间戳为基准,前后端各层额外埋点只做辅助分析,不再作为对外承诺的SLO口径。
第三个坑是忘了给Trace加采样策略。OpenTelemetry的Trace数据如果全量采集,在大模型场景下一个请求可能产生几百个Span,存储成本非常惊人。我给所有Traces加了基于请求ID的哈希采样(默认10%,慢请求100%),既保证了排障时能拿到Tracing数据,又控制了成本。
第四个坑是陷阱式的告警依赖。刚开始监控建设阶段,我把所有指标都设了告警,结果每天几百条信息,团队直接免疫了。后来痛定思痛,删掉了三分之二的告警规则,只保留能代表用户体感和可用性的核心指标,告警准确率才真正提上来。
6.3 从延迟监控到成本监控的延伸
延迟监控建设完成之后,我逐渐发现一个规律:延迟与成本本质上是同一枚硬币的两面。GPU利用率太低但P99很高,说明你白买了算力,存在调度问题;GPU利用率又高P99又好,说明利用率不错,但可能马上要扩容;如果GPU利用率低P99也低,那大概率可以缩容降本。
所以我把延迟监控的指标和GPU利用率、GPU显存、QPS放在同一张成本分析图里,每周做一次资源水位回顾。比如某个模型单GPU的算力利用率长期只有20%,但P99仍然健康,我就会考虑把它和另一个模型做混部,把算力榨干。反过来,如果某个模型的P99长期贴着SLO上限,我会评估是否应该扩容一个副本来降低延迟。
这套联动分析后来又演进出了依赖关系监控。现在的AI应用很少是单模型单点,通常前面有网关,中间有向量数据库或外部知识库,后面还连着对象存储。任何一个下游环节抖动,最终都会反映在推理接口的延迟上。我把下游依赖的耗时也加进了监控面板,用一条时间轴把"当前请求的延迟构成"可视化出来。排查问题再也不用靠猜了,直接点开那段时间窗口,看是检索慢了还是模型推理慢了,一目了然。延迟监控永远不会是一锤子买卖,它会随着系统架构的演进不断长出新的触角。
