AI 模型推理中的延迟分析与测试把模型训练出来只是第一步真正让它跑起来、跑得快、跑得稳才是工程上最磨人的那个阶段。我过去大半年一直在折腾推理服务从单卡跑通到对外提供接口中间踩得最深的一个坑就是延迟——不是模型不收敛是模型上线之后响应慢得让人心慌。用户那边一压测P99 直接飙到两秒多模型本身看起来没问题显卡利用率也不高但就是慢。后来把整个链路拆开来看才发现延迟这个指标根本不是一个数字而是一堆时间片拼起来的结果。这篇就结合我这段时间的实操把 AI 推理中的延迟分析和测试好好捋一遍从怎么测、怎么拆、怎么定位再到怎么优化尽量说透。1. 拆开来看一次推理请求的时间都去哪了做延迟分析最忌讳的就是把“推理延迟”当成一个黑盒。你看到的响应时间是一个总数但背后起码有四五段不同的时间消耗叠在一起。如果不拆开你只会看到一个笼统的“慢”完全不知道慢在哪。我习惯把一次完整的推理请求按时间顺序拆成六个阶段网络传输客户端到服务端的请求字节流传输时间局域网内很小公网或跨机房就不可忽略。请求排队请求进入了服务端队列等待被某个 worker 或推理进程取走的等待时间。并发一高这一段的占比会迅速膨胀。输入预处理文本要 tokenize图像要 resize、归一化、转张量格式这些操作在 CPU 上完成偶尔很慢。模型计算张量在 GPU或 CPU上的实际前向传播时间也就是大家最常说的“推理时间”。这里面又可以进一步拆成 prefill 阶段和 decode 阶段LLM 场景尤其明显。输出后处理从张量到最终结果比如去 vocab 映射、采样、beam search 的收尾有时还有 JSON 序列化。返回传输结果数据回传对长输出文本来说这部分往往被忽略但实际不小。这里我要强调一点第 4 步的模型计算时间不一定是延迟的大头。很多情况下排队时间和预处理时间合计起来比 GPU 计算时间还长。我见过最夸张的一个案例一个文本分类接口模型本身跑一次只要 8 毫秒但整个接口的耗时却到了 180 毫秒——最后发现光是 JSON 序列化和日志打印就占了 70 多毫秒。所以做延迟分析的第一步不是优化模型而是在服务的入口和出口把各阶段的时间戳埋好。你在入口记录请求到达时间 t0在预处理结束时记录 t1在模型推理前后分别记录 t2、t3在后处理结束时记录 t4在出口准备返回时记录 t5。这样任何一次请求的耗时分布都清清楚楚。埋点的方式可以去改服务代码也可以借助成熟的 tracing 系统来打 span。我建议两种都做代码里加简单的时间戳日志最适合快速排查而正式的 tracing 系统比如基于 OpenTelemetry 的链路追踪适合长期监控和对比。2. 延迟测试的正确打开方式并发、百分位数与口径陷阱延迟测试不是一个“发几个请求看平均响应时间”这么简单的事。这里面的门道主要在三个方面并发模型怎么设计、指标怎么选平均还是百分位、测试口径怎么统一。任何一个环节偷懒都可能得出误导性的结论。2.1 平均延迟会骗人尾延迟才是真相先说明一个我自己的观点做推理服务性能评估平均延迟Avg基本可以不用看。原因很简单推理服务的延迟分布通常严重右偏——绝大多数请求很快但少量请求会特别慢。平均值会被拖得没什么代表性而且它掩盖了最影响用户体验的那部分问题。真正要盯的是百分位数P50、P90、P95、P99甚至 P999。P99 的意思是 99% 的请求延迟都低于这个值也就意味着 1% 的请求比这个值更慢——对于高并发服务来说这 1% 的用户体验往往决定了口碑。我在实际测试中就碰到过这种情况平均延迟只有 120ms看起来挺健康结果 P99 是 980ms几乎要破一秒大关。这要是不看百分位根本发现不了问题的严重性。所以我的建议是每一次压测报告里至少要给出四列数据P50、P90、P95、P99有条件的话把 P999 也带上并且附上最大延迟值。对照着看就能快速判断延迟瓶颈是存在于整体所有百分位都高还是尾部只有高百分位异常。2.2 并发模型固定并发才是压测的第一课很多人在压测时习惯用“逐渐增加 QPS”的模式从一个很低的速率慢慢往上加看系统最多能顶多少 QPS。这个模式适合去看吞吐量上限但不太适合严格评估延迟。因为它掩盖了一个关键事实当系统过载时新请求不是立刻得到响应而是在队列里堆积延迟会呈现“雪崩式”增长速率模型下这些堆积请求会无限拉长响应时间数据一塌糊涂。我更推荐用固定并发数的压测模型即同时有 N 个请求在途in-flight一个请求完成后立刻发出下一个。这个模型下系统的排队状态保持稳定测出来的延迟才是这个并发水平下真实的延迟表现。具体做法可以用压测工具直接把并发数固定比如 wrk、k6、ghz 或者 Locust 都能配置。举一个我常用的压测矩阵设计思路适合服务上线前做一轮摸底并发数持续时间关注指标12 分钟基准延迟无排队干扰看模型本身的极限85 分钟一般线上水平关注 P50/P95325 分钟中等压力开始观察排队时间占比645 分钟高压场景看 P99 是否恶化、是否抛错1285 分钟过载测试找服务能承受的并发上限这个矩阵的关键是先拿到单并发基准值再往上叠加并发压力这样每一层测试都能算出“排队带来的额外延迟”是多少。如果从 8 并发到 32 并发P99 从 200ms 涨到 600ms而 P50 只从 80ms 涨到 100ms那就说明排队开始吃人了。2.3 测试口径冷启动和热启动必须分开算延迟测试里还有一个大坑冷启动 vs 热启动的混淆。模型服务第一次接收到请求时往往需要加载模型、初始化 CUDA context、分配显存、做 kernel warm-up 等操作这些开销和正常运行时的开销完全是两码事。如果你开压测之后立刻开始计时前面的数据大概率会严重虚高带偏整体统计。解决方法是正式压测前先跑几轮“预热请求”让服务完成初始化同时也让 GPU 的 kernel 编译缓存生效很多框架第一次跑某个 shape 时需要即时编译之后就快了。我一般预热 50 到 100 个请求然后再开始正式计时。另一个相关口径是“稳态 vs 瞬时”。如果压测只跑 30 秒可能只覆盖了系统表现最好的阶段持续 5 到 10 分钟才能暴露显存碎片、内存泄漏、连接数堆积这类慢速恶化的隐患。所以延迟测试建议至少跑满 3 分钟正式评估跑 5 分钟以上。提示压测脚本里最好加一个“忽略预热期数据”的逻辑或者把预热请求和正式请求分批统计否则最后看报告时根本不知道哪些数据有效。3. 定位瓶颈从指标到根因的系统排查链路测试数据出来之后下一步就是定位瓶颈在哪一段。我有一套固定的排查链路基本能覆盖 90% 的问题场景。3.1 先看 GPU 利用率再决定往哪查这是最快的一刀切分法。如果压测时 GPU 利用率利用 nvidia-smi 或 DCGM 查看一直很低比如不到 30%那说明瓶颈根本不在计算而在于数据喂不进去——要么是预处理太慢要么是排队太严重要么是网络传输卡住了。如果 GPU 利用率已经顶到 90% 以上那就是模型计算本身的瓶颈要考虑的是怎么把单次推理时间降下来。举一个实际例子我做一个中文情感分类模型的推理服务压测发现 GPU 利用率才 15%QPS 只有 20 多但单请求模型推理只要 3 毫秒。怎么排查的呢先看服务日志发现请求在大批量进入时排队时间占了总共耗时的 40%再往下追发现排队是因为服务开的 worker 进程太少默认只开了 2 个进程每个进程同时只能处理一个请求。把 worker 数调到 8 之后GPU 利用率立刻上到 60% 以上QPS 翻了近三倍。这个案例说明了一个道理模型快不等于服务快瓶颈往往在你意想不到的调度层。3.2 利用阶段耗时占比锁定拖后腿的环节这一招是我日常最常用的。拿到埋点数据后直接计算每个阶段的耗时占比按占比排行来分配精力。占比最大的那一段就是优化优先级最高的那一段这个逻辑听起来像废话但很多人实际操作时还是习惯性地先去改模型结构而不是先看数据。我梳理过几次延迟优化项目最后结论差不多都是下面这种分布阶段通常耗时占比文本模型典型情况优先优化建议网络传输1%-5%少见瓶颈通常无需处理请求排队5%-40% 不等并发高时飙升增加 worker、调并发数、做动态批处理预处理5%-20%缓存 tokenize 结果、批量预处理模型推理30%-70%量化、蒸馏、kernel 优化后处理5%-20%简化 JSON、避免不必要的采样逻辑返回传输1%-5%长输出时压缩或流式返回这个表格只是典型参考实际占比会因模型类型、数据规模而大不相同。图像类模型的预处理往往更重解码、resizeLLM 的 decode 阶段则是真正的耗时大头。一定要基于自己的实测数据来排优先级。3.3 用压测工具做“逐步排除法”的精确定位除了埋点数据我还会用压测工具配合“排除法”做交叉验证。具体做法是用一个 mock 服务替代真实的推理服务接口返回同样的响应体但内部不做模型推理然后跑同样的压测——如果 mock 服务的延迟和真实服务差异不明显说明瓶颈基本不在模型如果差异巨大那就集中精力优化模型层。这个验证在服务改造前做一次改造后再做一次能非常直观地判断改动是否有效。排除法还可以继续往下钻。比如你把预处理阶段直接砍掉输入固定的 token 序列看延迟变化再把后处理砍掉直接返回原始张量看延迟变化。每一次“手术”都对应一次压测数据的对比会清清楚楚地告诉你每一段代码的真实成本。这套链路走下来大概花半天时间就能把瓶颈锁得很死。之后动手优化时每一步改动都可以用同一套测试口径复测避免“感觉变快了”这种模糊判断。4. 常用推理延迟测试工具与参数解读工具不用贪多关键是会用、会读数据。我把常用工具按使用场景分成三类每类介绍几个我实际验证过的并说明怎么解读它们输出的参数。4.1 轻量级命令行工具wrk 与 ghzwrk 是我做 HTTP 服务压测的首选它用 C 写压测性能极高单机就能打出很高的并发。常用参数就几个-t线程数一般设成 CPU 核数-c连接数即并发数-d持续时间建议 30s 或以上-s脚本文件可以自定义请求体适合 post JSON启动命令大致长这样wrk -t4 -c64 -d30s -s post.lua http://127.0.0.1:8000/predictpost.lua 里定义请求方法和 bodywrk.method POST wrk.headers[Content-Type] application/json wrk.body {text: 今天天气不错适合出门走走}wrk 输出的核心数据包括Requests/secQPS、Transfer/sec带宽、Latency 分布Avg、Stdev、Max、正负一个标准差内的请求占比。注意它默认打印的是平均值和标准差不会直接给出 P99需要加--latency参数才会打印延迟分布直方图。如果压测的是 gRPC 接口wrk 就不合适了我用的是 ghz。ghz 是专门给 gRPC 做的压测工具参数风格和 wrk 很像核心选项ghz -c 32 -n 10000 -z 30s \ --proto ./service.proto \ --call your.package.PredictService/Predict \ -d {text: 测试文本} \ 127.0.0.1:50051ghz 的输出非常友好会把 50th、90th、95th、99th、99.9th 都列出来而且自带错误率和耗时分布直方图是我目前见过最省事的 gRPC 压测工具。4.2 分布式压测Locust如果目标是模拟更接近真实用户的行为比如思考时间、不同接口的混合调用或者需要多台机器同时加压Locust 会是更好的选择。它是 Python 写的通过写脚本定义用户行为非常灵活。下面是一个非常精简的 Locust 压测脚本from locust import HttpUser, task, between class PredictUser(HttpUser): wait_time between(1, 3) task def predict(self): self.client.post(/predict, json{text: 测试文本用于模拟请求。})启动时指定并发数和生成速率locust -f locustfile.py --host http://127.0.0.1:8000 --headless -u 32 -r 4 -t 5m-u 32表示模拟 32 个并发用户-r 4表示每秒增加 4 个用户-t 5m表示运行 5 分钟。Locust 在 headless 模式下也会输出统计摘要包含各类百分位数不过它更适合跑长稳测试短期高吞吐压测它的性能不如 wrk。4.3 监控侧别只盯着压测工具的输出压测工具的量测都是从客户端视角出发的“端到端延迟”但这个数据不足以支撑根因分析还需要配合服务端监控。GPU 侧我用 DCGM 采集利用率、显存、温度、功耗CPU 侧看服务进程的 CPU 使用率和内存占用系统侧看网络连接数、句柄数、上下文切换。这些数据可以用 Prometheus Grafana 部署一套现成的监控不用自己造轮子。我强烈建议压测期间实时开着监控面板。很多诡异的问题比如“延迟周期性波动”你只看压测工具的输出根本找不到原因但一看监控面板就会发现 GPU 利用率周期性跌落原因可能是别的大任务在抢占显存或者服务内的缓存周期性清理导致 CPU 暴增。5. 从延迟数据到优化动作我踩过的坑和验证方法有了数据、定位了瓶颈之后优化手段其实是有固定套路的。我把常用的优化方案按“见效速度”排个序同时提醒几个容易踩的坑。5.1 最优先动态批处理与并发配置如果你用的是 PyTorch 或 TensorFlow Serving 这类服务框架且模型推理是瓶颈那么第一个优化动作就是动态批处理dynamic batching。它能让你把并发请求攒起来在一次前向传播里同时处理多个输入从而摊薄 kernel 启动开销大幅提升吞吐。但这里必须提醒一个大坑动态批处理提升的是吞吐不是单请求延迟。攒批会引入等待时间——后面的请求要等前面的请求到齐才能一起发车。如果一个用户单独发一个请求他反而可能更慢因为服务在“等更多的人上车”。所以做动态批处理时一定要设置合理的最大批处理等待时间比如 5ms/10ms/20ms在吞吐和延迟之间做权衡。我做过一个对比测试在推荐模型服务上关闭动态批处理时单请求延迟 30msQPS 只有 120开启后最大等待设 10msP50 延迟涨到 40ms但 QPS 提升到了 400 多。对大多数线上场景来说这个取舍非常划算前提是用户能容忍这 10ms 的额外等待。与之配套的还有并发参数调优进程数、线程数、gRPC 最大并发流数这些都要根据压测矩阵结果逐档调整。通常的做法是“两倍两倍地往上加加到性能不升反降为止再回退一档”。5.2 模型侧量化与 kernel 优化的真实收益模型推理本身占大头时优先做量化。以 Float16 换 Int8 为例通常能把模型体积压缩到一半左右推理速度提升 1.5 到 3 倍取决于算子和硬件支持程度。但量化不是无损的精度会掉必须在测试报告里附上精度对比数据。我在一个 CV 分类模型上做过量化前后的对比原始 FP16 模型推理延迟 15msInt8 量化后降低到 6ms精度从 92.4% 掉到 91.8%降幅可接受上线后观察一段时间也没有异常。所以只要你评估过精度损失在业务容忍范围内这个优化就很值得上。除了量化还有一个经常被忽略的优化固定输入 shape。动态 shape 会导致框架反复做算子选择甚至动态编译固定 shape 后许多框架能提前生成最优 kernel延迟能降低 10%-20%。我处理的几个服务都有这个改动收益稳定且零风险。5.3 容易被忽略的隐形杀手预热、缓存与日志这个部分想专门说说那些“不在模型里却拖垮延迟”的隐形杀手。我踩过的坑有以下几类第一缺少预热导致首请求延迟爆炸。服务刚启动时第一个请求要完成 CUDA context 初始化、模型权重加载、kernel 编译等耗时可能高达几十秒。所以服务脚本里一定要写预热逻辑——启动后自动发几个假请求把该初始化的都初始化完再对外宣称“就绪”。第二重复 tokenize 和重复解码。如果你的服务会反复处理相近的输入可以考虑在预处理层做缓存。比如用户短文本分类场景相同或相似的文本反复出现时直接用缓存结果能削掉很大一部分重复计算。缓存方案的 TTL过期时间要设计好避免占内存过多。当然要谨慎使用适合查重率高的场景不适合开放性生成类任务。第三日志和序列化的开销超出你的想象。我曾在一个接口里发现把完整请求体和响应体打进日志占了总耗时的将近 20%。日志该打但别打全量 body用摘要、长度、耗时这些关键信息就够了。JSON 序列化也要留意大响应体的序列化时间不短可以考虑精简字段、减少嵌套。5.4 复测方法论每次改动的效果必须跟同一把尺子比优化做完不是结束验证才是关键。我自己的习惯是每次改动只动一个小模块然后立即用同一套压测矩阵复测对比数据和改动前的基线。如果一次改了多个模块哪个改动带来了提升根本说不清楚。复测时我会做这样一份对比记录改动项并发数P50 (ms)P90 (ms)P99 (ms)QPS备注基线无改动3285160420180排队较高调整 worker 数3260110210320排队显著下降动态批处理3270125260500单请求延迟微升这份记录同时体现了延迟和吞吐的变化两列数据一起看避免只优化延迟却牺牲吞吐或者反过来。对照清晰的数据上线与否的决策就有了依据。6. 测试报告的呈现方式与汇报要点最后一个环节也是容易被低估的环节怎么把延迟测试的结果有效地呈现出来。数据是一回事能不能让团队理解和信任你的结论是另一回事。一份能说服人的延迟测试报告至少要有三块测试环境描述包括硬件型号GPU/CPU、框架版本、模型版本、服务配置、压测工具及参数。没有这些所有数据都无法复现也无法横向比较。我见过太多报告只写“P99 800ms”却不写是什么 GPU、什么批大小、什么并发等于白测。多维度指标对比至少包含 QPS、P50/P90/P95/P99、错误率这几个核心项并配一张延迟分布直方图。分布形状能直观反映是否存在异常抖动。如果在各并发档位下分别测试还要把不同档位的表格放全。结论和风险说明。结论用来明确某一个配置是否满足既定 SLA风险说明用来提示已知问题比如“当前显存只够支撑 64 并发再高会 OOM”或“量化后精度下降了 0.6 个百分点需要业务侧确认”这类信息。汇报的时候还有一个小技巧把“无法满足 SLA”的档位和满足 SLA 的档位之间的分界点标出来。这能直接回答“我们能承受多大的并发”这个问题是团队最关心的核心结论。我在文档里还会额外加一个“复现步骤”区块把完整的压测命令、预热脚本、监控采集方式都写进去。原因很简单延迟问题往往在环境变动后复发比如换了 GPU 驱动、升级了框架、调整了批大小到时候拿着旧报告却想起当时用的什么参数会让人非常头疼。有了一键可执行的复现步骤后续做性能回归就省事得多。关于延迟分析我最想对后来者说的一点体会是永远不要在没有拆解阶段耗时的情况下直接优化模型。很多时候你觉得是模型慢其实根本是排队和序列化的问题反过来也有你以为服务配置不够结果模型量化一发入魂的情况。数据永远先于直觉。现在我自己接手任何一个推理服务第一件事就是埋点、建压测矩阵、跑基线、拆耗时这一套流程走下来至少能避免 80% 的无头苍蝇式优化。希望对正在折腾推理服务性能的你也有帮助。