1. 为什么要做容器化推理性能测试
1.1 从一次线上事故说起
上个月我帮一个团队排查模型服务响应变慢的问题,现象很奇怪:同样的模型、同样的GPU卡,容器里跑就是比裸机跑慢30%左右,而且并发一高就报错。排查到最后发现,问题出在Docker的共享内存默认只有64MB,模型推理时的Token流式输出和KV Cache写入把shm打满了。这让我意识到一个问题:很多团队在用容器化部署AI模型时,根本没有一套完整的性能基准测试方案,上线全靠"感觉没问题"。
说实话,AI模型推理容器化已经不是新话题了,绝大多数团队现在都会把FastAPI + vLLM这类推理服务用Docker打包,再扔到K8s里管理。但"能不能跑起来"和"能跑多快、能扛多大并发"完全是两码事。容器带来的资源隔离、共享内存限制、GPU驱动映射、网络端口映射,每一层都可能变成性能瓶颈。而且模型推理和其他Web服务有个本质区别:它不是一次性返回结果,而是逐Token流式输出,这导致它的性能衡量维度完全不同,压测方式也完全不同。
1.2 谁能从这篇内容里获得价值
如果你是下面这几类人,这篇文章值得看完:
- 算法工程师:模型训练完了要交付给工程团队,但不懂推理服务怎么压测,别人问"模型并发能力怎么样"答不上来。
- 后端/运维工程师:要用容器化方式部署推理服务,但搞不清楚要给容器多少资源、shm-size设多大、并发线程怎么配。
- AI平台开发:正在搭内部的推理服务平台,需要一套可复现的压测方案来做容量评估和弹性伸缩依据。
- 正在纠结裸机还是容器部署的小团队:可以拿这篇文章的测法,先把两种部署方式的性能数字跑出来,再决定走哪条路。
这篇文章会从测试方案设计、工具选型、执行过程、指标解读到避坑指南完整讲一遍。我不讲虚的,全部是我实际测过、踩过坑之后沉淀下来的经验。我的测试环境是这样:2张NVIDIA A10 24GB卡,使用Docker并开启GPU模式,推理框架用vLLM,模型选Qwen2-7B-Instruct。 你不需要同样的配置,整体思路完全可复用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试目标与指标体系设计
2.1 先明确"性能"到底指什么
提到性能测试,很多人第一反应是"用JMeter压一下接口"。但AI模型推理和传统Web接口有一个关键差异:输出是流式的,不是一个完整响应。传统接口的指标是"每秒处理多少请求"(QPS)和"平均响应延迟",但模型推理按照生成Token的方式工作,所以必须引入一套完全不同的指标。
我推荐一套在业界公认的模型推理核心指标组合:
| 指标名称 | 缩写 | 含义 | 类比 |
|---|---|---|---|
| 首Token延迟 | TTFT | 从发送请求到收到第一个Token的时间 | 你点了一个菜到第一个菜上桌的时间 |
| 单Token生成耗时 | TPOT | 生成每个Token的平均耗时 | 两道菜之间的上菜间隔 |
| 吞吐量 | Throughput | 单位时间生成的Token总数 | 餐厅单位时间能出多少道菜 |
| 并发数 | Concurrency | 同时处理的请求数量 | 餐厅里同时能坐下多少桌客人 |
| 请求完成率 | Success Rate | 成功完成请求的比例 | 有多少桌客人能吃完走人而不是被等走 |
对于对话、生成类任务,用户体感最差的是TTFT太长——输入一堆问题后转圈不出字。而吞吐量直接决定GPU的利用率,影响成本。所以压测时必须两个维度一起看,不能只盯着一个。
2.2 压测场景怎么设计才科学
我见过很多团队做压测就是"拿脚本猛怼接口,看服务会不会挂",这种方式能暴露极限问题,但拿不到调优所需的量化数据。合理的做法是按真实使用模式拆成多个场景。
我习惯把场景分成三档:
- 低并发场景(并发数1~4):模拟日常开发调试、少量用户接入的情况,主要看单请求质量,TTFT和TPOT要足够低。
- 中并发场景(并发数8~16):模拟一个小团队或中等业务量,看吞吐量是否线性增长,有没有出现资源和锁竞争。
- 高并发场景(并发数32~64):模拟大流量或峰值情况,看服务能不能扛得住,错误率和延迟的尾部行为(P95/P99)如何。
同时要注意输入输出长度的影响。很多团队压测只用256个Token的短输入,结果上线后真实业务是2000字的长文档,性能直接崩掉。因为输入越长,预填充阶段(Prefill)的显存和计算开销就越大。我一般至少覆盖三档:短文本(输入256, 输出256)、中长文本(输入1024, 输出512)、长文本(输入2048, 输出1024)。
2.3 预热、持续时长和稳定性
压测过程中还有一个必须处理的东西:冷启动 vs 热状态。模型首次推理时,权重需要被加载到GPU显存,CUDA内核也会执行一遍初始化,所以第一批请求通常会异常慢。如果直接拿第一批请求的数据作为结果,会严重失真。
我的标准做法是:正式压测前先发5~10个请求做预热,确保权重和缓存都已经就绪,然后再开始正式记录数据。持续时间上,我至少跑5分钟,有些场景会跑到15分钟。原因很简单:1分钟的数据可能掩盖显存碎片化和内存溢出的渐进性问题。我实际遇到过不少案例,压测前3分钟一切正常,第5分钟开始请求失败率飙升,就是因为KV Cache管理或显存泄漏导致的。这种问题短时间压测根本测不出来。
3. 测试环境搭建与工具选型
3.1 容器环境:从镜像到启动参数
先把Docker镜像和启动参数这块说清楚。推理服务的基础镜像有很多选择,但我建议直接用官方维护的,因为CUDA驱动版本和PyTorch版本的兼容性非常容易出问题。我用的镜像是基于 nvidia/cuda:12.4.0-base-ubuntu22.04 构建的,Python 3.10 + vLLM 0.6.0。
一个我的独家经验:不要用 nvidia/cuda:12.4.0-runtime-ubuntu22.04 这种runtime镜像来做推理服务,base就够用。推理场景不需要编译,runtime镜像里那些多余的库只让镜像体积变大,构建时间变长,对运行没有任何帮助。
启动命令这里有个关键点,特别值得展开说:
bash复制docker run -d \
--name qwen2-inference \
--gpus all \
--shm-size=8g \
-p 8000:8000 \
-e CUDA_VISIBLE_DEVICES=0,1 \
-e VLLM_WORKER_MULTIPROC_METHOD=spawn \
-v /data/models:/models \
qwen2-inference:v1
--shm-size=8g 是无数团队踩过坑的地方。Docker默认共享内存只有64MB,而vLLM在张量并行和数据处理时会使用共享内存做IPC通信,如果没调大,并发稍微一高就会出现 Bus error 或者 CUDA error when copying to host memory。这个参数我第一次也是漏了,排查了一天才定位到。小建议:8G是一个比较稳妥的起步值,如果你的并发脚本输出很长或者批量推理量很大,可以改到16G。
VLLM_WORKER_MULTIPROC_METHOD=spawn 这个环境变量在vLLM做多卡张量并行时是必须的,不然在容器里经常报 default value of multiprocess method is 'fork' 相关的错误。
3.2 压测工具:自研脚本还是现成框架
工具选型是个很实际的问题。JMeter在Web压测领域是标配,但它对SSE流式响应处理并不友好,因为JMeter默认拿到一个完整Response才算请求结束,而模型推理是持续推送Token片段,如果用JMeter压,你需要额外引入WebSocket或SSE的插件。我试过,配置麻烦,而且对Token级别延迟的数据处理能力很差。
我最终选的是Python自研压测脚本 + httpx 客户端 + asyncio 并发控制,配合输出指标计算。选择原因有三个:
- 我们能直接读取SSE流,精确计算TTFT和TPOT。
- asyncio提供了方便的并发控制方式,想模拟多少并发就开多少任务。
- 数据采集后直接用pandas统计,P50/P95/P99随口算。
但这不代表JMeter没用。如果你只是验证接口是否连通、基本功能是否正常,JMeter还是不错的。JMeter 压非流式场景,自研脚本压真实推理场景,两者不冲突。
还有一个值得提的工具是 vllm-bench,如果你用的是vLLM,官方自带了一套压测脚本,能直接压出吞吐和延迟数据。但它的场景覆盖比较有限,不太方便模拟真实的业务输入分布,所以我选择了自研。建议你以自研为主,官方脚本做交叉验证。
3.3 监控工具组合
压测过程中如果只看压测工具输出的数字,出了问题很难定位到底是谁的锅。我的习惯是同时开三路监控:
- GPU层:
nvidia-smi dmon每秒采样一次,记录GPU利用率、显存占用、温度、功耗。这个能发现GPU有没有吃满,是否存在显存瓶颈。 - 容器层:
docker stats记录CPU、内存、网络I/O。这个能发现是否CPU核数分配不足,或网络吞吐有瓶颈。 - 推理服务内部:vLLM自带Prometheus指标暴露(
/metrics接口),里面有vllm:num_requests_running、vllm:gpu_cache_usage_perc这些黄金指标。尤其要注意gpu_cache_usage_perc,这个值如果接近1.0,说明KV Cache快满了,服务开始做Prefix Caching或者淘汰,延迟会显著飙升。
这三路数据最后按时间戳对齐,压测结束后我才能说清楚"这个延迟瓶颈到底是在GPU算力、显存带宽还是CPU调度"。
4. 压测脚本和完整执行流程
4.1 核心压测脚本拆解
下面这个脚本是我根据多次实验迭代后保留的精简版,它具备并发控制、流式响应处理和指标数据统计能力。你可以直接复制去用,按需调整模型名称和接口路径。
python复制import asyncio
import json
import time
import statistics
import httpx
# 配置区域
BASE_URL = "http://127.0.0.1:8000/v1/chat/completions"
MODEL = "qwen2-7b-instruct"
INPUT_PROMPT = "请用详细的步骤解释什么是量子计算,并给出至少5个实际应用场景。" * 20 # 约2000字输入
MAX_TOKENS = 512
CONCURRENCY = 16 # 并发数,从1测到32
TOTAL_REQUESTS = 100
# 用于存储结果
ttft_list = []
tpot_list = []
total_latency_list = []
error_count = 0
async def single_request(client, semaphore, request_id):
"""发送单个推理请求,计算TTFT和TPOT"""
global error_count
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": INPUT_PROMPT}],
"max_tokens": MAX_TOKENS,
"temperature": 0,
"stream": True,
}
start_time = time.time()
ttft = None
prev_token_time = None
generated_tokens = 0
try:
async with semaphore:
async with client.stream("POST", BASE_URL, json=payload, timeout=300) as response:
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
# 首Token计时
if ttft is None:
ttft = time.time() - start_time
ttft_list.append(ttft)
# 记录每个Token的时间间隔
now = time.time()
if prev_token_time is not None:
tpot_list.append(now - prev_token_time)
prev_token_time = now
generated_tokens += 1
total_latency = time.time() - start_time
total_latency_list.append(total_latency)
except Exception as e:
error_count += 1
print(f"请求 {request_id} 失败: {str(e)[:200]}")
async def run_pressure_test():
semaphore = asyncio.Semaphore(CONCURRENCY)
# 使用支持流式响应的transport
limits = httpx.Limits(max_connections=100, max_keepalive_connections=50)
async with httpx.AsyncClient(limits=limits, timeout=300) as client:
# 预热阶段:提前发几个请求让服务进入热状态
for i in range(3):
await single_request(client, asyncio.Semaphore(1), f"warmup_{i}")
print(f"预热请求 {i+1}/3 完成")
print("预热完成,开始正式压测...")
tasks = [asyncio.create_task(single_request(client, semaphore, i)) for i in range(TOTAL_REQUESTS)]
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(run_pressure_test())
# 输出统计
if ttft_list:
print(f"\n======= 压测结果 (并发数={CONCURRENCY}) =======")
print(f"成功请求: {TOTAL_REQUESTS - error_count}/{TOTAL_REQUESTS}, 错误率: {error_count/TOTAL_REQUESTS*100:.2f}%")
print(f"TTFT (首Token延迟): 平均值={statistics.mean(ttft_list)*1000:.1f}ms, P50={statistics.median(ttft_list)*1000:.1f}ms, P95={sorted(ttft_list)[int(len(ttft_list)*0.95)]*1000:.1f}ms")
if tpot_list:
avg_tpot = statistics.mean(tpot_list)
print(f"TPOT (单Token耗时): 平均值={avg_tpot*1000:.2f}ms, 等效生成速度={1/avg_tpot:.1f} tokens/s")
print(f"总延迟: 平均值={statistics.mean(total_latency_list)*1000:.1f}ms")
print(f"吞吐量: {sum(len(INPUT_PROMPT) for _ in range(TOTAL_REQUESTS - error_count)) / (sum(total_latency_list)/CONCURRENCY):.2f} tokens/s")
这个脚本里有个细节值得说明:我使用 client.stream 来发流式请求,而不是普通 client.post,因为只有用流式接口才能准确感知每个Token到达的时间点。另外我在正式压测之前先用 Semaphore(1) 串行发了3个预热请求,把模型权重和缓存预热好,这样后面的数据才是真实稳定状态。
4.2 分档测试的执行矩阵
单个脚本固定一个并发数,但我们需要的是多个并发数下的对比曲线。所以我是用外层Shell循环来跑这组测试的:
bash复制for concurrency in 1 2 4 8 16 32; do
echo "======== 并发数: $concurrency ========"
sed -i "s/^CONCURRENCY = .*/CONCURRENCY = $concurrency/" pressure_test.py
python pressure_test.py | tee result_${concurrency}.log
sleep 30 # 每个场景之间休息,让服务状态复位
done
为什么要每个场景之间休息30秒?这是踩过坑才懂的。前一个高并发场景结束后,服务内部可能有大量未释放的连接、缓存或GPU上残留的KV Cache。如果不休息直接跑下一轮,结果会受干扰。30秒是我试出来的平衡点,既能稳住服务状态,又不至于拖慢整个压测节奏。
至于输入长度的组合,我一般会单独跑三轮,每轮修改 INPUT_PROMPT 里的参数。如果觉得麻烦,可以给脚本加个 --input-length 参数,这个是脚本优化的小问题,不影响方法论。
4.3 裸机对照实验怎么做
既然要验证"容器化"带来的性能影响,光测容器内数据不够,必须做一组裸机(直接在宿主机上跑同一套推理服务)对照实验。很多团队忽略这一步,最后只能拿出"容器内吞吐是多少",却没法回答"这个数字有没有比裸机低"。
裸机对照实验的关键是控制变量:
- 同一个模型文件,放在宿主机同一个路径下(不要再做一份拷贝到容器,如果可能尽量用同一个磁盘路径)。
- 同样的推理框架版本、同样的启动参数(除了容器相关参数)。
- 同样的压测脚本、同样的并发场景。
我实际测下来的数据有代表性:同一台机器,裸机和容器在低并发(1~4)下几乎没有差距,但在并发数达到16以上时,容器版本吞吐下降约8%~12%,TPOT也有轻微攀升。这是正常的,因为容器多了NAT网络、cgroup限制、共享内存在跨进程通信时的拷贝开销。但这个差距不是不能接受,如果你需要的是弹性扩缩容和部署灵活性,10%左右的性能代价通常是可以权衡的。
有一种例外:如果你的推理服务内部频繁使用多进程IPC(比如vLLM张量并行),容器里共享内存和进程通信的开销会被放大,高并发下性能差距会扩大到20%以上。这时候就要考虑通过调 --shm-size、使用 host 网络模式或调大并发队列来缓解。这些在后面章节展开。
5. 结果分析与容器参数调优
5.1 怎么解读一组完整的压测数据
一组完整的数据,真正要读懂的不只是平均值。下面这张表是我某次实测的数据(短文本场景,输出256 tokens,vLLM 0.6.0,单卡A10):
| 并发数 | 吞吐量 (tokens/s) | TTFT P50 (ms) | TTFT P95 (ms) | TPOT P50 (ms) | 错误率 |
|---|---|---|---|---|---|
| 1 | 320 | 186 | 220 | 12.4 | 0% |
| 4 | 1024 | 312 | 388 | 13.1 | 0% |
| 8 | 1480 | 452 | 590 | 15.8 | 0% |
| 16 | 1740 | 720 | 1180 | 22.3 | 0% |
| 32 | 1690 | 1250 | 3100 | 35.6 | 2% |
这组数据里面藏着两个关键结论:
第一个结论:吞吐量并不是一直随并发数上升的。从16增加到32,吞吐量反而掉了50 tokens/s。这说明系统的极限吞吐大约在1700~1750之间,再多并发只是增加排队,而且因为资源竞争导致吞吐不再上升。做容量规划时,建议把目标并发数设在极限并发数的60%~70%,留出缓冲。
第二个结论:P95和P50的差距在并发升高后急剧拉大。8并发时P95/P50大约1.3倍,32并发时变成2.5倍。这意味着一部分请求被严重阻塞,可能是因为GPU显存中的KV Cache队列已经排满,也可能是vLLM的Continuous Batching调度在高并发下跟不上。遇到这种情况,就得考虑调优了。
5.2 容器参数调优的四个优先方向
如果数据不对劲,不要急着改代码,先按下面的优先级调容器和框架参数。
第一步:调 --shm-size 和 --ulimit。共享内存不足是最隐蔽的容器性能杀手。如果你在容器里执行 df -h /dev/shm 看到only 64M,但压测时并发高、输出长,那就直接改大。另外建议把文件句柄数和线程栈调大:
bash复制--ulimit nofile=1048576:1048576
--ulimit memlock=-1:-1
--ulimit stack=1073741824:1073741824
memlock=-1 这个参数对使用CUDA pinned memory的框架特别重要,如果不解锁内存锁限制,部分框架会退回到pageable memory,显存拷贝会明显变慢。
第二步:调整vLLM的服务端参数。vLLM有 --max-num-seqs 参数,控制同时处理的序列数。如果这个值小于并发数,多余的请求会排队;如果太大,又会把显存分给过多队列导致每个请求的单Token生成变慢。我实测下来,单卡A10上 --max-num-seqs=8 到16之间性价比最好,超过16延迟明显恶化。另外 --max-model-len 也很关键,设太短会导致输入长的请求直接报错。
第三步:判断是否要开 host 网络模式。容器默认用的是bridge网络,数据包多一次NAT转换。对于内部压测或者单机多卡场景,可以改成 --network=host,延迟大约能下降5%~8%。但要注意,host模式会跳过Docker的端口映射和网络隔离,在K8s里一般不这么配,需要结合你的平台能力来权衡。
第四步:检查 --gpus 和显存分配策略。如果是多卡机器,让Docker禁用没用的GPU,可以减少不必要的上下文切换。例如 --gpus '"device=0,1"' 而不是 --gpus all。
5.3 一个典型的调优前后对比
拿我调优过的一个案例来说——之前用默认参数跑,并发16时错误率有3%,TPOT达到38ms。通过以下操作:
--shm-size从默认64MB调整到8G,错误率直接降到0%;--network=host后TTFT P95降了6%;- vLLM的
--max-num-seqs从默认值256调到16,TPOT从38ms降到22ms; --ulimit memlock=-1保障pinned memory后,整体吞吐再涨了4%。
最终结果:并发16时吞吐从1400涨到1750,TTFT P95从1350ms降到1180ms。这里可以看到,问题不是"容器比裸机慢",而是"容器参数没配好"。正确配置之后,性能差距可以缩小到5%以内。
6. 常见问题与排查技巧实录
6.1 GPU在容器里识别不到或性能异常低
这个问题出现的频率出奇地高。现象是容器里能跑 nvidia-smi 但PyTorch报 CUDA error: no kernel image is available,或者GPU利用率上不去。
排查顺序我建议是:
nvidia-smi确认宿主机的GPU驱动版本。docker run ... nvidia-smi确认容器里看到的驱动版本。- 确认镜像里的CUDA runtime版本与宿主机驱动的兼容性。这里我举一个具体的坑:宿主机驱动550.x,支持CUDA 12.4,但你镜像里装了PyTorch 2.0对应的CUDA 11.8,那么绝大多数情况下也能跑,但如果模型用了某些新特性就编译不过。解决方案是统一标准,建议宿主机驱动更新到最新稳定版,镜像里CUDA版本和PyTorch都选配套的。
- 确认是否使用了正确的runtime。Docker + NVIDIA GPU 现在需要
--gpus参数配合nvidia-container-toolkit才能暴露GPU。如果裸跑docker run不带--gpus,容器里只有CPU。
还有一个容易忽略的点:如果宿主机启用了MIG(Multi-Instance GPU),每个GPU实例的显存和计算单元是被切分的,此时 nvidia-smi 看到的是小容量显存,性能指标也会跟整卡有较大出入。遇到这种情况,你要明确任务到底是要MIG还是整卡。
6.2 高并发下 Bus error 或进程崩溃
这基本上就是我开头说的那个翻车事故的典型症状。如果你在压测日志里看到 Bus error (core dumped)、Failed to allocate shared memory、RuntimeError: CUDA error when copying to host memory,九成是共享内存不够。
排查和解决步骤:
bash复制# 进容器看当前共享内存大小
docker exec -it qwen2-inference df -h /dev/shm
# 如果显示 64M,重新启动容器并设置更大的shm
docker stop qwen2-inference
docker rm qwen2-inference
docker run ... --shm-size=8g ...
还有一种隐蔽情况是:容器启动时确实设了 --shm-size=8g,但你在docker-compose里没写 shm_size 字段,导致启动后还是默认值。如果使用docker-compose,务必检查配置里有没有 shm_size: 8gb 这个键,否则你命令行工具里设置的参数不会生效。
6.3 压测吞吐很高但TTFT严重超标
这是一种"看起来很美"的数据。吞吐量很高说明系统整体算力没有闲着,但TTFT高说明部分请求在排队等待。可以这么理解:如果把GPU比作一个繁忙的厨师,每个请求是一桌客人点菜,吞吐量是厨房单位时间能出的菜总数,TTFT则是每桌前菜的等待时间。厨师很忙不代表每桌菜都上得快。
遇到这种情况我的排查路径:
- 看vLLM
/metrics里的vllm:num_requests_running是不是一直等于--max-num-seqs上限。如果是,说明队列塞满了,需要调大--max-num-seqs来提速TTFT。 - 看GPU利用率是否始终在90%以上。如果是,说明算力已经饱和,调大并发不会让总吞吐明显提高,但会增加排队。应当降低目标并发或者扩容。
- 看Prompt输入长度占比。如果大量请求都是超长输入,Prefill阶段会占用大量计算资源。可以考虑换一个Serving框架(比如SGLang),它有更精细的调度策略能让短请求插入执行,降低TTFT。
这里有个心态问题想强调:不是所有性能问题都适合用调参解决,有时候结论就是"该扩容了"。不要指望一个A10能扛出H100的吞吐。
6.4 压测脚本本身成了瓶颈
有时候服务性能一切正常,反而是压测脚本所在的机器性能不够,导致压出来的数字偏低。我自己在压一个高吞吐模型时,压测脚本由于并发数太大,先把客户端机器的CPU跑满了,结果TTFT和TPOT全部失真,真实的服务性能被严重低估。
判断方法很简单:压测过程中看压测脚本所在机器的CPU使用率。如果CPU已经超过80%,就需要提升压测脚本的执行效率或者换一台更强的压测机器。具体优化手段:
- 把压测客户端部署到更好的CPU机器上。
- 减少不必要的日志打印,把数据攒到内存里最后再统一落盘。
- 用
uvloop替代asyncio默认的事件循环,高并发时能省不少CPU。
还有一个细节:如果你在K8s集群里跑压测,压测Pod和推理Pod尽量不要在同一个Node上,否则压测Pod本身会抢推理Pod的CPU和内存资源,导致测试结果偏低。我当时就因为这个原因测出来比裸机差20%,分开部署后数据才恢复正常。
6.5 快速排查速查表
下面这个表格是我结合多次实战总结出来的,日常遇到问题直接对照排查:
| 现象 | 可能原因 | 排查命令/方法 | 解决手段 |
|---|---|---|---|
| 容器内GPU不可见 | 漏加 --gpus 参数 |
docker run ... nvidia-smi |
加 --gpus all,装nvidia-container-toolkit |
Bus error |
共享内存不足 | df -h /dev/shm |
设置 --shm-size=8g 以上 |
| CUDA版本不兼容 | PyTorch与驱动不一致 | python -c "import torch; print(torch.version.cuda)" |
重建镜像,统一驱动与CUDA版本 |
| GPU利用率低但吞吐不高 | CPU预处理瓶颈 | 看容器CPU使用率 | 增加CPU核数、优化数据加载、减少CPU到GPU拷贝 |
| 高并发时错误率升高 | 队列溢出或超时 | 看错误类型是connet reset还是timeout | 调整 --max-num-seqs、增加超时时间、优化并行策略 |
| KV Cache满导致延迟飙高 | 并发超过显存容量 | 看 gpu_cache_usage_perc |
降低并发、缩短最大序列长度、优化显存分配 |
7. 如何把压测结果用于容量规划
折腾完压测拿到了数据,最终目标还是指导实际部署。这里分享一个我常用的容量估算逻辑。
先说结论:申请GPU资源时,不看模型参数规模,只看你要服务的业务并发量和延迟要求。7B模型和70B模型对资源的需求差异巨大,如果你连压测都不做直接上线,要么资源浪费要么服务崩溃。
我一般按如下步骤做容量规划:
- 确定业务的目标并发数。比如来自网关日志统计,高峰期同时在线请求大约20个。
- 在压测矩阵里找到满足延迟目标的并发数档位。比如业务要求TTFT P95 < 1000ms,那我们就把16并发作为目标档位,实测吞吐约1700 tokens/s。
- 估算单Token的业务消耗。1个用户提问约200字,回复约500字,那么一次完整对话大概消耗700个Token。1700 tokens/s 的吞吐换算下来,每秒能完成约2.4次完整对话。
- 倒推总量。如果高峰期1分钟有100次对话,那平均每秒约1.7次。此时目标容量1.7次/秒远低于测出的2.4次/秒,理论上1张A10就够了,再留30%~50%冗余应对突发峰值,可选2张卡或1张卡但限制并发到10。
这套逻辑比拍脑袋决定"7B模型至少要几块H100"要靠谱得多。当然,70B模型如果做量化,或者业务里长文本请求很多,单块卡能支撑的并发就更有限,具体数字只能靠压测来量化。
还有一个实操经验是用这些数据来配置K8s的HPA(自动扩容)策略。vLLM提供了 vllm:num_requests_running 这个指标,你可以结合自己压测得出的"该指标超过某个值后TPOT开始恶化"来设置弹性伸缩阈值。比如8并发时TPOT表现很好,16并发时明显恶化,那把HPA的目标值设为8,当运行请求数接近8时自动扩容新副本,就是一套很实用的自动伸缩策略。
8. 最后分享几个我自己养成的习惯
做过的压测轮次越多,越发现测试方法和习惯比测试工具本身更重要。最后分享三个我坚持了很久的小习惯。
习惯一:压测时每次都完整保存原始日志和监控数据,而不是只保存最终统计数字。统计数字会掩盖很多细节,但原始数据不会。当我发现一次压测结果异常时,能回看每一秒的GPU利用率和请求延迟曲线图,定位出问题出在哪个时间窗口。没有原始数据,排查起来只能靠猜。
习惯二:每次修改一个变量。新手最容易犯的错是同时改了 --shm-size、--max-num-seqs、网络模式,然后发现性能提升了,但搞不清楚是哪一项起了作用。调优时一定要一次只改一个参数,重新压测,记录结果,再改下一个。这样可以建立起参数和性能的因果关系,而不是停留在玄学层面。
习惯三:关注P95、P99而不是只盯着平均值。平均值对异常波动不敏感,但真实用户感知到的是糟糕的尾延迟。一个完全不能忍受的服务可能是"平均延迟100ms但P99要3秒",这种问题如果不关注百分比,上线后一定被用户骂。所以我的压测脚本里一定会输出P50/P95/P99三个值,并且以P95作为主要优化目标。
容器化AI推理的性能测试,本质上是一个系统工程问题。它不只要会写压测脚本,还要理解推理框架的调度逻辑、容器运行时的资源限制、GPU硬件的特性差异。这篇文章里提到的坑和调优手段,都是我实际跑了一遍又一遍才总结出来的。把这个流程跑通一次,面对新的模型、新的业务场景时,你的评估速度会快很多。
最后再提一句:不同的推理框架(vLLM、Triton、SGLang、TensorRT-LLM)在容器里的表现差异很大,如果你在换框架,建议用同一套压测流程重新测一遍,千万不要直接沿用上一个框架的并发配置和性能预估。我自己曾经被这个坑过,换了框架后吞吐从1400涨到2000,但因为用的还是旧的并发预估配置,导致白买了一堆GPU——这算是压测带来的另一个价值:尽早暴露问题,或者尽早省下钱。
