1. 为什么AI模型推理会卡在“多线程”上
先说个真实场景。上个月我们团队要上一个大模型推理服务,模型已经用TensorRT优化过,单张卡上看延迟能压到几十毫秒。可一旦开放给内部十几个业务方同时调用,整个服务就像被堵住一样,请求排队,RT从几十毫秒直接飙到好几秒,最后还时不时超时报错。当时大家都懵了:模型明明很快,GPU利用率也不高,怎么整体表现这么拉胯?
这个问题的根子,恰恰就出在“多线程”上。AI推理服务从来不是一个单独在干活的东西。你从客户端发请求,服务端要接收HTTP连接、解析请求体、做数据预处理,然后把请求送到推理引擎去跑模型,跑完后还得做后处理、序列化返回。这一整条链路里,涉及服务端框架的线程池、推理框架内部的调度线程、GPU执行任务的队列,甚至还有客户端发起请求时自己的线程模型。任何一个环节的并发处理能力跟不上,都会把整条链路拖垮。
很多人以为性能测试就是拿工具怼流量,跑到服务报错就完事。但实际上,AI推理场景下的多线程性能测试,真正要回答的是三个问题:
- 这个服务当前架构能撑住多大的并发压力,也就是最大吞吐量在哪?
- 并发上来之后,延迟的稳定性如何?P99会不会冲上天?
- GPU、CPU、内存、线程池这些资源,谁先到瓶颈?是不是存在大量线程在那里空转或者互相等待?
只有把这些问题的答案量化出来,你才知道接下来该优化谁。是加GPU卡,是调线程池大小,还是改推理框架的batch策略。否则就是盲人摸象,今天调个这,明天调个那,钱花了效果没出来。
这篇文章就围绕这个主题展开。我会完整拆解一次AI推理服务多线程性能测试的思路和实操过程,包括用什么工具压、请求场景怎么设计、出来的数据怎么看、线程池和推理参数怎么调,以及我在实际测试中踩过的一堆坑。无论你是算法工程师想把模型部署做得更稳,还是后端开发在排查接口突然变慢的问题,或者测试同学正在研究怎么对AI服务做压测,这篇内容应该都能给你一些直接能用的参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试思路与场景设计:先想清楚再动手
2.1 用什么工具去压AI推理服务
目前用来压AI服务的主流工具其实就那几类:JMeter、wrk、k6,以及自己写Python并发脚本。我在这次项目中主力用的是JMeter,原因很简单:团队里其他同学也要参与测试,JMeter有图形界面,配置线程数、请求体、断言、聚合报告都比较直观,而且新增一个HTTP采样器就能直接打到推理服务的REST接口上,不需要额外写代码。
如果你对JMeter足够熟,整个过程其实就三件事:配置线程组、配置HTTP请求采样器、配置监听器。线程组里设置并发线程数和循环次数,HTTP请求里填服务地址、请求方法、Body数据,聚合报告里看吞吐量、平均响应时间、错误率。
如果追求更高的压测上限,或者需要复杂的请求间依赖关系,我建议用Python写脚本配合concurrent.futures里的ThreadPoolExecutor来压。这种方式灵活度更高,可以自定义请求内容生成策略,也方便把结果直接写成CSV做后续分析。我后面对比过,单个JMeter压测机在高并发下可能会因为自身线程调度开销影响结果,但Python脚本手动控制线程模型会更直接一些。对一般场景来说,JMeter已经够用了。
工具层面上还有一个值得提的点:压测机本身一定要和服务端分开部署。我自己就犯过这个错,一开始直接在开发机上跑JMeter,CPU被压测客户端吃满,结果服务端还没到瓶颈,压测机自己先卡死了,测出来的数据毫无参考价值。
2.2 JMeter测AI服务的配置步骤与参数说明
以下是我实际用JMeter压测一个基于HTTP的AI推理服务的配置方式,可以直接参考。
先说线程组。我通常会建三个不同场景的线程组:
- 冒烟测试线程组:1个线程,循环1次。目的是确认请求通路正常,模型不会因为输入格式问题报错。
- 阶梯加压线程组:线程数从5、10、20、50逐步递增,每个梯度持续30秒。目的是摸清服务在不同并发下的表现变化,找到延迟拐点。
- 峰值稳定性线程组:固定一个目标并发数,持续压5到10分钟。目的是看服务在持续压力下有没有内存泄露、连接池耗尽、响应时间逐渐恶化的问题。
线程组的核心参数是线程数、Ramp-Up Period和循环次数。比如你想模拟30个并发用户同时打请求,就设置线程数为30,Ramp-Up设为10秒,意思是30个线程在10秒内陆续启动完毕,而不是一开机就瞬间并发30个请求,后者会造成初始冲击,把服务瞬时报垮,结果并不真实。
接着是HTTP请求采样器的配置。AI推理服务一般是一个POST接口,Body里放JSON格式的输入数据。比如一个文本分类模型:
json复制{
"text": "这家餐厅的菜味道不错,环境也很干净",
"max_length": 64
}
在JMeter里添加一个HTTP请求采样器,配置服务器地址和端口,Method选POST,Path填/predict,Body Data里粘贴上述JSON。注意Content-Type要设置为application/json,否则服务端解析Body会失败。
还有几个容易被忽略的配置项:
- 连接超时和响应超时。我给的是连接超时3000ms,响应超时60000ms。AI推理本身可能比较慢,尤其在并发高的时候排队会拉长响应时间,超时设太短会造成大量误报错误。
- 请求头里加上
Connection: keep-alive。我默认开启HTTP Keep-Alive,实测下来能让TPS提升20%到30%,因为减少了TCP握手的开销。如果你用的是短连接,每次请求都要重新建连,压测结果会严重低于服务的真实能力。
2.3 场景设计顺序:从单线程冒烟到持续性压测
多线程性能测试最忌讳一上来就拉高并发,那样你根本不知道问题是出在模型推理速度、服务框架排队,还是资源不足。我的习惯是从低到高分阶段进行。
第一阶段是单线程冒烟测试。发几个请求确认接口通、输出正确、模型没有因为输入shape不同而报错。这个阶段我还会顺手记录一个数据:单请求的延迟。比如单请求延迟150ms,那就意味着一个线程最多也就每秒处理6到7个请求,如果服务端不做批处理,你想达到100 QPS,至少需要15到20个线程同时打。这个初始计算能帮你快速判断后续压测的并发量级。
第二阶段是阶梯加压测试。我常用5、10、20、50、100这个序列,每档跑2到3分钟。跑的过程中重点观察两件事:一是TPS是否随并发数线性增长,二是平均RT和P99 RT的变化趋势。如果并发从50涨到100,TPS几乎没涨,但RT翻了一倍,说明系统已经进入饱和区,要么是线程池满了在排队,要么是GPU计算资源被占满。
第三阶段是稳定性测试。拿阶梯测试中系统还能保持稳定表现的并发数作为基线,再乘一个0.7到0.8的安全系数,持续压10到15分钟。这样做的目的是验证系统在长时间高负载下的表现,看线程池有没有任务堆积,内存有没有持续增长。
2.4 必须先说明白的一个概念:线程数不等于并发数
这里有个新手特别容易绕晕的点。JMeter线程组里面配置的“线程数”是JMeter模拟的用户数,而不是实际打到服务端的瞬时请求并发数。如果你的脚本每个线程是串行发请求的,那一个线程在没有思考时间的情况下大概每隔“响应时间”才能发一个请求。所以线程数100,服务端每秒可能只收到几十个请求,取决于单请求RT有多长。
所以压测的时候,不要死盯线程数,要看聚合报告里的“吞吐量”这个指标。比如你设置了100个线程,单请求RT是200ms,那理论上每秒最多发出100/0.2=500个请求。你想往上压更大的流量,要么加线程数,要么缩短单请求的处理时间,没有别的办法。这个逻辑在后续调优环节特别重要,很多人加了一堆线程结果TPS没变化,其实就是线程数早就超过服务端的处理能力,多出来的请求全部在排队。
3. 核心指标与数据解读:压测结果到底怎么看
3.1 一组把服务看透的基础指标
压测跑完,JMeter聚合报告会给你一堆数字。但真正需要关注的没那么多,我每次必看的就是下面这组:
| 指标 | 含义 | 判断参考 |
|---|---|---|
| TPS/QPS | 每秒处理完的请求数 | 系统吞吐能力的直接体现 |
| Average RT | 平均响应时间 | 整体快慢的粗略指标 |
| P95/P99 RT | 95%/99%请求的响应时间 | 反映尾部延迟,用户体验关键 |
| Error% | 错误请求占比 | 必须为0或接近0 |
| GPU利用率 | 显卡计算资源占用 | 判断推理侧是否打满 |
| CPU利用率 | 服务所在机器的处理器占用 | 判断前后处理和框架开销 |
有一类指标特别容易掩盖问题,那就是平均响应时间。假设你测了1000个请求,990个是200ms,10个是5秒,平均值算出来约248ms,看着貌似还不错,但实际这10个请求已经慢到让调用方超时了。所以看延迟一定要看P99,最好把P95、P99、Max三个值都拉出来。P99稳,说明系统在绝大多数情况下的表现都是可控的。
3.2 怎么判断服务已经被压垮了
很多时候我们测着测着一看TPS在涨就觉得没问题,其实系统早就进入不健康状态了。我个人的经验是,出现下面几个信号中的任何一个,就意味着当前这个并发量已经超过系统的承受能力了。
第一,TPS不再随并发数增长,甚至开始下降。这说明系统已经满负荷运行,新增的请求不光没有被处理,反而因为线程切换、排队占用内存等因素进一步拖慢了整体效率。
第二,响应时间出现明显的“拐点式增长”。正常状态下,RT应该在小范围内波动。但当系统进入饱和状态后,RT曲线会突然变得陡峭,可能从300ms级别直接跳到2秒级别。这个拐点对应的并发数,就是你要找的关键阈值。
第三,错误率开始上升,而且错误类型从连接超时逐步过渡到500报错或服务端拒绝连接。这说明线程池已经拒绝接收新任务了。
第四,GPU利用率出现剧烈波动甚至掉到0。出现这个情况往往意味着问题不在模型计算,而是上游请求根本送不过来,比如线程池满了、队列爆了、网络连接数达到上限。GPU空着没事干,请求却在排队等待,这是一种极其典型的资源错配。
3.3 一个实测数据对照:为什么线程数翻倍不代表性能翻倍
拿我们自己的服务来举例。我们的模型在单张A10显卡上推理,单请求延迟约120ms,服务端用的是一个基于Java的HTTP服务框架,线程池核心线程数配置为50。以下是压测得到的部分数据:
| 并发线程数 | 平均TPS | 平均RT(ms) | P99 RT(ms) | 错误率 |
|---|---|---|---|---|
| 2 | 16 | 123 | 140 | 0% |
| 5 | 40 | 128 | 160 | 0% |
| 10 | 78 | 130 | 175 | 0% |
| 20 | 150 | 135 | 190 | 0% |
| 50 | 290 | 175 | 260 | 0% |
| 100 | 300 | 340 | 780 | 0.5% |
| 200 | 280 | 710 | 2100 | 4% |
从2线程到50线程,TPS接近线性增长,说明系统资源还没有用满。但从100线程开始,TPS几乎不再增长,RT却迅速恶化,错误率也开始冒头。这就说明,服务的并发处理能力的极限大概在300 QPS上下,而这个极限对应的可不是100线程这个客户端数字。
这个数据的价值在于:它告诉我们继续盲目加客户端线程数已经没有意义了,真正要去优化的是服务端能同时处理的请求数量上限。要么扩容实例,要么在推理框架层做动态批处理提升单次GPU调用的吞吐,要么重构服务端线程模型,把HTTP处理和推理解耦成异步模式。这些都是后话,但如果没有这组压测数据做基线,后续改动的收益根本无法衡量。
4. 调优线程池与推理框架参数的实际操作
4.1 客户端压测参数的选取经验
先讲客户端这边怎么调,因为很多人第一步就卡在这里。压测时的线程数不是拍脑袋定的,要根据你想要模拟的目标吞吐量和单请求RT来反推。
有一个简单的估算公式可以参考:如果你希望测出服务端的极限TPS是300,而单请求正常RT大约150ms,那客户端至少需要保持300×0.15=45个在途请求,也就是至少需要45个并发线程。考虑到线程调度和网络开销,一般我会在这个理论值的基础上加30%的余量,也就是设到60左右。
但这里要注意,客户端线程也不是越多越好。JMeter线程数开得过大时,JMeter自身会产生大量日志和结果数据,本机CPU可能先被打满。我试过一次开了500个线程,结果JMeter所在机器CPU直接飙到95%以上,压测数据波动剧烈。后面我改成分布式压测,用两台压测机同时打,数据才稳定下来。
所以一个基本建议是:如果预估目标TPS超过1000,优先考虑用两台以上压测机做分布式压测,而不是单机堆线程。单机压测时也要监控压测机CPU,如果CPU超过85%,说明客户端本身已经成了瓶颈,测出来的服务端数据失真。
4.2 服务端线程池到底该怎么调
服务端线程池的调优,是所有优化里见效最明显、也最容易出问题的环节。核心问题只有一个:线程池开多大才能既不浪费资源,又不让请求排队时间太长。
理论上有经验公式可以参考,比如CPU密集型任务建议线程数为CPU核数加1,IO密集型任务建议线程数为CPU核数乘以2再加1。但AI推理服务恰恰是个混合体:接收请求、解析JSON、后处理是CPU和IO操作,真正跑模型却是GPU操作。这就导致传统公式很难直接套用。
我调优时用的是另一套思路。先把QPS目标和接受的最大RT定好,然后按照队理论里的Little's Law反推。比如业务要求峰值支持300 QPS,单请求在正常负载下耗时150ms,那同时被处理的请求数就是300×0.15=45。线程池的“核心线程数”至少要为这个数,否则请求一多就要排队。我一般会把核心线程数设为这个估算值的1.5倍左右作为缓冲,比如设成64,最大线程数则可以适当放大到100到200,配合有界队列使用。
但这里有个特别容易踩的坑:线程池不是越大越好。我见过有同事把最大线程数直接设成2000,说这样请求再多也不怕。实际上当线程数过多时,CPU大部分时间都耗在线程上下文切换上,服务端的实际处理能力反而下降,RT还会变长。经验值是,如果你的服务是Java技术栈,线程池最大线程数最好不要超过CPU核数的10到20倍,超过了就要转而考虑异步化或队列削峰。
4.3 推理框架层的并发与批处理设置
这一层是AI推理和多线程测试中最有“AI特色”的部分。GPU推理和CPU计算不一样,它的优势在于大规模并行,单次推理请求如果只算一条数据,GPU的算力利用率其实很低。正确做法是在推理框架层面把多个并发请求攒成一批,一次forward处理多条数据,这就是所谓的动态批处理。
拿我用的推理框架举例,它支持动态批处理参数。比如设置最大Batch Size为8,意思是同一个GPU执行周期内最多聚合8个请求同时推理。这样100个并发请求进来后,不是100次独立的GPU推理,而是被拆成十几批,每次用一批算完再算下一批。GPU利用率从30%直接拉到90%以上,整体吞吐量提升非常明显。
动态批处理对延迟的影响也要讲清楚。batch从1调到8,单请求的延迟通常会增加,但单位时间处理的总请求数会大幅提升。实际测试中,我们的单请求P50延迟从120ms涨到190ms,但TPS从300涨到接近1200,翻了4倍。对很多业务来说,用少量延迟换数倍吞吐是非常划算的。
调batch参数时有一个测试技巧:从batch=1开始,逐步增大,每档压测一次,记录TPS和延迟的变化曲线。重点观察batch从哪个值开始TPS增长趋于平缓。那个点就是这个模型在当前硬件上的最佳吞吐平衡点。继续堆batch只会让延迟恶化,而吞吐不再提升。
4.4 一次从“接口超时”到“稳定运行”的调优案例复盘
这里复盘一个我们优化线程池相关参数的完整过程,从问题暴露到参数落地,你可以参考这个思路。
第一版配置是这样的:服务端核心线程数为10,最大线程数200,队列容量为10000,推理框架的动态批处理没开。结果是只要并发一上来,平均RT就飙升到2秒以上,大量请求排队超过超时阈值。
排查后发现:核心线程数只有10,意味着正常情况下同时只有10个请求在被处理。并发一旦超过10,多出来的请求全部进入队列等待。而最大线程数虽然写了200,但队列满之前线程池根本不会扩容到最大值,10000的队列容量又太大,导致大量请求积压。这批积压的请求最终成为系统的负担,表现为RT从几百毫秒直接跳到几秒。
调整动作分三步。第一步,把核心线程数从10提高到64,因为根据QPS和RT估算,至少需要45个并发处理才能满足300 QPS的目标,64留有余量。第二步,把队列容量从10000降到500,这样当系统过载时,新请求能更快触发拒绝策略而不是无限积压。第三步,把推理框架的动态批处理开关打开,设置最大batch值为4。
改完后重新压测,同样的300 QPS压力下,P99 RT从原来的800ms以上降到260ms左右。GPU利用率从40%提升到85%,错误率降到了0。这个案例其实说明一个道理:很多时候服务慢不是硬件不够,而是并发处理的配置设计不合理,请求在软件层面排队堵死了。
5. 常见问题与排查技巧实录
5.1 压测过程中最常见的几类故障
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 并发升高后大量请求超时 | 线程池队列积压严重 | 查看服务端活跃线程数、队列深度 |
| 错误率飙升,报连接拒绝 | 服务端连接数达上限或线程池已满 | 查网络连接数、线程池活跃线程与最大值 |
| 错误率上升,报500 | 请求处理中超时或资源争抢异常 | 查服务端日志,看有没有锁等待、内存溢出 |
| GPU利用率低但服务RT很高 | 请求无法及时送达到推理引擎 | 查前置解析、线程池排队、网络瓶颈 |
| 长时间压测后RT逐渐变长 | 可能存在内存泄露或连接池逐步耗尽 | 监控内存曲线、连接池使用情况 |
| TPS上不去但不报错 | 压测客户端自身线程不足 | 看压测机CPU和线程数,必要时加压测机 |
5.2 用系统命令快速定位瓶颈在谁身上
压测出问题时,别急着改代码,先用系统监控工具判断瓶颈所在方位。这一步能帮你快速缩小排查范围。
如果怀疑GPU有问题,我用的是:
bash复制nvidia-smi dmon -s pucvmet -d 1
这个命令每秒输出一次GPU利用率、显存使用、温度、功耗等信息。如果利用率长期低于50%,说明GPU没吃饱,瓶颈大概率在GPU上游的数据传输、解析或者排队环节。
如果怀疑CPU线程有问题,用:
bash复制top -Hp <服务进程PID>
这个命令能看到进程内部每个线程的CPU占用率。如果发现大量线程处于等待状态,CPU占用很低,但请求又处理不过来,基本可以断定是锁竞争或者队列阻塞问题。
排查线程池队列积压时,我通常会临时在服务端通过JMX或者Actuator接口把线程池的活跃线程数、队列大小、任务拒绝次数打出来。比如Java后端可以看ThreadPoolExecutor的几个关键指标,队列堆积数持续上涨且拒绝次数不为0,说明线程池已经撑不住了。
5.3 几个需要特别留意的经验坑
第一,模型预热的问题。GPU模型在服务刚启动时是“冷”的,显存里还没有完成kernel的初始化,头几个请求的延迟可能异常高。如果没做预热就直接开始压测,会把前几个请求算进结果里,拉高P99。我现在的做法是冒烟测试阶段先发几十个请求,等模型响应时间稳定了再开始正式计时。
第二,JMeter的聚合报告默认会包含所有请求的结果,包括压测启动阶段和结束阶段的不稳定数据。为了得到干净的数据,我会把监听器里的“仅统计成功请求”开启,并且在分析时跳过测试前10秒和后10秒的数据。这些边缘数据如果不剔除,会干扰对服务真实性能的判断。
第三,并发测试时一定要开启HTTP Keep-Alive,这一点前面提过但值得再说一次。我遇到过有人测AI服务时长连接没开,测出来的TPS比真实能力低了一半,排查了整整一天,最后发现只是压测脚本漏配了一个请求头。这种低级错误在团队协作里特别容易出现。
6. 多线程性能测试的扩展思路:从单机到平台化
单次压测做到上面这些步骤,其实已经能帮你解决很多问题了。但如果你的服务会长期迭代,模型版本经常换、推理参数频繁调,我建议把性能测试从一次性“临时任务”升级成可持续运行的“日常动作”。
我在后来做的平台化方案里,把压测脚本固定化,每次模型上线前自动跑一轮基准测试,包含单线程冒烟、阶梯加压、稳定性测试三段场景,把TPS、P99、GPU利用率这些关键指标自动写入文档与监控面板。这样模型版本升级后性能是变好了还是恶化了,一眼就能看出来,不用每次重新摸索测试方案。
一个值得参考的做法是:把测试结果做成简单的“性能快照”,每次模型仓库里发布新版本时自动更新快照。遇到性能回退的版本,直接对比快照数据就会发现是哪次改动引起的。相比传统功能测试,性能指标对代码改动的敏感度其实更高,一个小小的线程池参数调整可能就让延迟翻倍,这种问题靠人工review代码往往很难发现,但一次标准化的压测跑下来就能暴露无遗。
此外,如果公司内部有多套推理硬件,比如有A10也有L20,甚至还有CPU推理节点,那同样一套多线程压测方案跑在不同硬件上,得到的数据还能用来做硬件选型参考。我自己就整理过一个简单的对比表,把不同GPU在相同测试脚本下能支撑的并发渠道及延迟表现都列出来,后面申请资源时直接拿数据说话,比口头解释模型要多大显存有力得多。
这个方向上的做法可以不断扩展,但核心还是那几点:把压测脚本沉淀成资产、把指标固化到监控体系、把结果纳入版本发布流程。这样多线程性能测试就不再只是某个阶段的一次性苦力活,而是服务生命周期里随时能调用的评估工具。
我在实际项目中最大的体会是:性能测试的价值不在于“测”本身,而在于它逼着你去系统性地理解整个服务的链路。当你把线程池、队列、GPU利用率、批处理策略这些变量都搞清楚之后,很多线上问题的定位速度会快得多。下一次你遇到服务变慢,脑海中自然会浮现出那根RT曲线在哪个并发数开始拐头,GPU利用率是不是在某个时间点掉下来了,而不是一头扎进代码里瞎猜。这个判断力和直觉,才是反复做多线程压测真正沉淀下来的东西。
