“vLLM 部署好了,但线上延迟还是高。”这是我们在三家企业环境里被问得最多的一句话。Transformer 推理框架早已不是瓶颈,真正决定 P99 延迟的是并发进来之后,调度器和显存怎么处理那几十上百个同时到达的请求。
这篇文章是一次纯工程实测的账:同一台 A100 80G、同一个 Qwen2.5-32B 模型,从默认配置跑到调优后,我们把稳态吞吐从 380 tokens/s 拉到 1240 tokens/s,P99 首 token 延迟从 3.8 秒压到 640 毫秒。过程中没有换模型、没有买卡,全是引擎参数、显存策略和调用方式的事。
结论先说:多数人以为 vLLM 的调优点在”批处理开大一点”,实际上三个真正的杠杆是 PagedAttention 的块大小、前缀缓存的命中条件、以及把 max_tokens 这个”看起来无害”的参数管起来。最后一个的收益最大,也最少有人注意。
一、测试设计:先定义什么叫”慢”
推理优化的第一个坑是拿吞吐当唯一指标。吞吐高但 P99 烂,用户体验是崩的;反过来吞吐低但延迟稳,业务反而是可用的。我们固定四个指标一起看:
- 稳态吞吐(tokens/s):并发 32 请求持续 10 分钟后的平均值。
- TTFT / P99(首 token 延迟):用户按下发送到看到第一个字的时间,这是唯一能被用户直接感知的指标。
- TBFT / P99(每 token 间隔):生成过程中的卡顿感。
- KV cache 占用峰值:决定显卡什么时候被拖死。
测试负载刻意做成”脏”的,因为真实流量就是脏的:其中 60% 的请求共享同一个 1.2 万字符的系统提示(我们内部的客服知识库问答),剩下 40% 长短随机、最大输出长度 800 到 2000 token。这个结构是后面所有差异的根源。
二、默认配置的真实基线:三个致命默认值
vLLM 的默认参数是”为了让你能跑起来”设计的,不是”为了让你跑得快”设计的。我们记录了默认配置下的完整基线,问题全部集中在四处:
| 参数 | 默认值 | 默认值下的问题 | 实测 P99 TTFT |
|---|---|---|---|
| gpu_memory_utilization | 0.90 | 权重只占 20 GB,但 KV cache 被压到无空间可扩,长请求一来就重算 | 3.82 s |
| block_size | 16 | 与 GPU 分页粒度错配,碎片率高 | — |
| enable_prefix_caching | 关闭 | 60% 的相同系统提示每来一个请求就重算一遍 | 2.1 s 花在重复前缀上 |
| max_model_len / max_tokens | 模型上限 | 业务方图省事直接开到模型上限 | 长请求排队拖垮整体 |
第三行是很多人忽略的金矿。那 60% 共享的 1.2 万字符系统提示,默认情况下每来一次请求就要完整 prefill 一次,等于同一段文本被反复读进显存。打开前缀缓存之后,这部分直接变成查表。
三、四个动作,把吞吐翻三倍
3.1 打开前缀缓存:单项收益 40%
enable_prefix_caching=True,这一行改动的实际效果:那 60% 的共享前缀请求,prefill 从”每请求 1.8 秒”变成”首次之后接近 0″。这不是玄学,是 vLLM 按块缓存了前缀 KV 的直接结果。
但有三个必须注意的坑:
- 前缀必须严格按块对齐。块大小是 16 token,如果调用方每次拼接时少了几个空格、或者动态插入了不同的时间戳,命中率立刻从 90% 掉到 12%。我们为此让业务方把知识库版本号移到请求末尾,才拿到高命中。
- 它会在高并发时放大内存压力。缓存块本身也吃显存,开之前看
gpu_memory_utilization的余量。 - 提示词被第三方日志记录时要脱敏。缓存的正是提示本身。
3.2 把 max_tokens 从”模型上限”改成”业务上限”
这是我们收益最大、也最反直觉的一条。业务方为了省事把 max_tokens 设成模型上限,但我们的客服问答场景,99% 的回答在 300 token 内结束。
问题就在这:vLLM 的调度器按”可能的最大占用”预留 KV cache 空间。一个 max_tokens=2000 的请求,即使 30 个 token 就答完,也会在队列里占着别人等不起的位置。这就是典型的”为不存在的长度买单”。
改成业务真实上限(这里设 512)之后,同等并发下能容纳的请求数翻了一倍多,而真实业务没有一次被截断。把长输出请求单独拆一条低优先级通道,是配套动作。
3.3 块大小与并行:和你的实际负载对齐
默认块大小 16 对短请求友好,但我们的负载里长上下文占比高。块大小调大后碎片率下降,但代价是小请求的内存浪费上升——这是一次明确的权衡,没有”越大越好”。
真正无脑有效的是 --enable-chunked-prefill:长请求的 prefill 被切成小块,和已经在跑 decode 的请求共享同一批。默认关闭时,一个长请求能把整批 decode 堵住,就是用户感知到的”集体卡顿”。打开之后,长请求不再有阻塞他人的能力。
3.4 量化:不是省显存,是换并发
FP16 跑 32B 需要约 64 GB 权重,A100 80G 装得下但很挤。切到 INT8(GPTQ/AWQ)后权重降到约 32 GB,省出的显存全变成可运行的并发位。
我们做了质量回归验证:在业务自有评测集 300 题上,INT8 与 FP16 的答案一致率 94.2%,不一致的 5.8% 里多数是格式而非事实。对客服场景这是可接受的,但如果你的场景是代码生成,先做自己的回归再上。
四、优化前后的完整对照
| 动作 | 稳态吞吐 | P99 TTFT | 额外收益与代价 |
|---|---|---|---|
| 基线(默认配置) | 380 tokens/s | 3.82 s | — |
| + 前缀缓存 | 530 tokens/s | 2.40 s | 命中率 91%,需提示词块对齐 |
| + max_tokens 收紧到 512 | 790 tokens/s | 1.35 s | 长请求拆独立通道 |
| + chunked prefill | 980 tokens/s | 820 ms | 消除长请求阻塞 |
| + INT8 量化 | 1240 tokens/s | 640 ms | 300 题回归一致率 94.2% |
注意第二行。前缀缓存一步就把吞吐抬了 40%,但它的前提是你的流量里真的有共享前缀。我们测过另一种负载:100% 随机问题、零共享提示,同一个开关只换来 3%。所以这条不是万能药,是”你的负载恰好长这样”才有效——这也是为什么它必须放在负载分析之后,而不是无脑先开。
五、三个我们踩过的坑
- 延迟指标的坑:平均值会说谎。优化中期我们看平均 TTFT 从 1.2 秒降到 780 毫秒,感觉成功了,但 P99 反而涨了。原因是长尾请求被积压在队列里,均值被大量快速请求拉低。只看均值,你会把一个更差的系统上线。
- 显存利用率的坑:不是越高越好。0.95 之后开始频繁 OOM 重启。我们的稳定点是 0.90,留出的余量是请求尖峰和缓存块膨胀的缓冲。压满显存跑出来的漂亮数字,在生产里撑不过一次突发流量。
- 调优顺序的坑:先换模型再调优。有个团队把同样的负载换成更大的模型,指望”更好的模型能容忍烂调度”,结果延迟更差。工程参数的收益通常比换模型来得快、来得便宜,而且可回滚。
总结
三倍吞吐的结论可以压成四句话:先做负载分析再动参数,共享前缀多的场景前缀缓存是第一杠杆;max_tokens 设成模型上限是隐形的最大浪费,收紧它常常是收益最大的单项;长请求必须放进独立通道,否则它会拖垮所有人;调优的验收标准是 P99 而不是均值。
更重要的认知是:vLLM 这类引擎的默认值是”可用性导向”的,而生产环境需要的是”吞吐与延迟的权衡导向”。从前者到后者,中间隔着的不是高深算法,是一轮诚实的负载测量——你得先知道自己服务的是什么形状的流量,才知道该开哪个开关。
互动提问:你们线上跑 vLLM 时,默认参数动了几个?有没有做过 max_tokens 的收紧,业务方那边过得去吗?
互动提问:如果用前缀缓存,提示词里带动态时间戳或者随机 ID 的场景,你们是怎么把命中率保住的?想听听实际的做法。
— IMAI 编辑部 | 2026-10-30