两个月前我接手了一个自建的大模型 API 服务:7B 模型跑在一台单卡 GPU 服务器上,vLLM 起的服务,前面挂一层 Nginx 做入口,平时业务量不大,跑得挺稳。后来运营把小程序的活动推上去了,QPS 从 2 涨到 15。
我的第一反应很朴素:加并发。于是把 --max-num-seqs 从 8 调到 32,Nginx 那边也把 upstream keepalive 和连接数一起放开。结果是吞吐几乎没动,平均首字延迟从 1.2 秒飙到 7 秒多,P95 到了 11 秒,客户端开始大面积超时。服务日志里反复刷着一行 GPU KV cache is full,后面跟着一串 preemption 警告。
最让人想不通的是,nvidia-smi 一看,显存还剩着 30 多 G 空闲,显卡利用率也到了 95%。明明没爆显存,为什么请求全在排队?这篇就把这个坑从头捋一遍。
如果你也在自建大模型推理服务、需要一台显存给得足的 GPU 机器先把环境跑起来,这类 AI 基础设施可以先看看:aaitr(AI云基础设施),选卡的时候显存是第一位的,别光看价格。

先把这笔账算清楚。GPU 显存里装的东西大致分四块:模型权重(7B 模型 fp16 大约 14G,永远占着)、KV Cache、激活值和临时缓冲、以及碎片。前面两次排查我盯的全是权重,因为它是静态的;实际上把服务打慢的是 KV Cache。
KV Cache 是什么?模型自回归生成时,每生成一个 token,都要把已经生成的那段内容的 Key 和 Value 缓存下来,下次接着算就不用从头再算一遍。这个缓存是按请求、按 token 数线性增长的:请求越长、并发越高,占的显存就越多。请求进来到生成结束,缓存一直挂着不释放,别人就用不了这块显存。
关键的一句话:并发数 × 单请求序列长度 = KV Cache 的总占用。7B 模型 fp16 下,每个 token 的 KV 大约 512KB(2 份 K/V × 32 层 × 32 头 × 128 维 × 2 字节)。算一笔账:
512KB × 2048 token ≈ 1GB / 请求1GB × 32 并发 ≈ 32GB 显存
所以我把并发开到 32 的时候,光 KV Cache 就要吃掉 30G 以上的显存,加上权重和激活,留给引擎的余量被压到很薄。引擎发现可用块不够,就会把正在跑的请求踢掉重算(preemption + recompute),被踢掉的请求从头再来一遍这就是延迟翻 6 倍的直接原因:不是算力不够,是显存不够导致算力在反复做无用功。
我这台机器当时就误判过一次,分享三个我实际在用的检查点。
第一,看 vLLM 启动日志。服务起来的时候日志里会打印一行 GPU KV cache size: N tokens,这个 N 就是引擎算出来的 KV 缓存容量(以 token 计)。N 除以你的平均请求长度,就大致等于能同时跑多少个请求。日志里还有 Maximum concurrency 这个提示,它把上限直接告诉你了。
第二,看 /metrics 暴露的指标。重点盯三个:num_requests_running(正在跑)、num_requests_waiting(在排队)、gpu_cache_usage_perc(KV 缓存占用率)。经验判断是这样的:
cache 使用率长期 > 95% + waiting 队列持续 > 0 → 显存/KV Cache 瓶颈cache 使用率只有 40% 但 running 数上不去 → 算力或调度瓶颈preemption 计数持续上涨 → 显存已经在赶人了
我那次的现场就是:cache 使用率 98%,waiting 队列常年挂着十几个,preemption 计数每小时涨几千。结论很清楚,不是 CPU 不够,也不是 Nginx 没调好。
第三,别只看 nvidia-smi 的空闲显存。空闲显存不等于可用显存引擎有预留水位,碎片也会占着地方。真正该看的是引擎自己算出来的 KV cache size 和 cache 使用率。

1)--gpu-memory-utilization:默认 0.9,可以推到 0.92~0.95。这个值告诉引擎"允许你用多少比例的显存"。卡上没别的负载时,调高它能把更多显存让给 KV Cache。但别贪到 0.98,留一点给 CUDA context 和显存碎片,不然容易在长跑之后 OOM。
2)--max-model-len:这是最容易漏的一个。很多教程直接照抄模型上限,比如 8192 或 32768。可你的业务真的需要这么长的上下文吗?KV Cache 的占用跟这个值直接相关,设成 4096 而不是 32768,等于给并发腾出了 8 倍的空间。我把 max-model-len 从 8192 降到 4096,再把 max-num-seqs 从 32 调回 16,延迟立刻回到 1.3 秒,吞吐反而比之前高。
3)--max-num-seqs:并发上限要压测着定,不要拍脑袋。我现在的做法是从 4 开始阶梯往上加,8、16、32 各跑一轮 10 分钟,记录吞吐和 P95 延迟,找到那个"吞吐还在涨、延迟已经开始抬头"的拐点,就停在拐点前一个档位。
4)量化:显存不够时最有效的一招。7B 模型从 fp16 换到 AWQ 或 GPTQ 的 4bit 量化,权重能从 14G 压到 5G 上下,省出来的七八 G 全部变成 KV Cache 空间,并发能力直接翻倍。代价是精度损失一点点,对聊天、摘要、分类这类任务基本无感,对严格的代码生成要自己评测一下。
5)--swap-space:只用来救急性子。它把放不下的 KV Cache 溢写到内存(CPU swap),服务不会崩,但请求要跨 PCIe 搬数据,延迟会非常难看。它是保险丝,不是性能手段。

与其撞墙再调,不如先算。单请求的 KV 占用公式:
2(K 和 V)× 层数 × KV 头数 × head_dim × 精度字节 × token 数
拿 7B 模型举例:2 × 32 层 × 32 头 × 128 维 × 2 字节 = 512KB/token。假设你把 max-model-len 设为 4096,一个满长请求的 KV 大约 2GB。若卡上扣掉 14G 权重、2G 激活、1G 碎片之后还剩 24G 给 KV Cache,那理论上最多同时放 12 个满长请求。实际还要留 20% 余量,8~10 个并发是比较稳的数字。
我一开始就是跳过了这一步,直接把并发调到 32,等于让 32 个满长请求(64G)去挤 24G 的空间,必然抢占。这一步算清楚了,后面的参数怎么调都有底。
preemption 的机制值得单独说,因为它是"加并发变慢"最容易被误判成别的问题的一环。引擎发现 KV 块不够时,会挑一个正在生成的请求把它踢出去,回收它的缓存块给新请求用;被踢的请求恢复时,缓存没了,要把已经生成的那一段全部重新算一遍。
这意味着 GPU 有一大段时间在做重复计算,算力利用率看着很高,产出却很低。表现出来就是:QPS 上不去、平均延迟飙升、GPU 利用率 95% 以上但业务毫无进展。日志里会有 Sequence group ... is preempted by PreemptionMode.RECOMPUTE 之类的警告行。
排查时我会直接盯这个计数:只要 preemption 计数在涨,不管当前延迟如何,都说明并发设置已经越界了,先降下来再说。
我做压测时会记三个数:吞吐(输出 token/s)、首字延迟 TTFT、每个输出 token 的时间 TPOT。只看 QPS 很容易骗自己QPS 是并发除以延迟,并发和延迟一起涨,QPS 可能纹丝不动。
正确的做法是固定并发阶梯,画吞吐-延迟曲线,找拐点。还可以做一次"输入长度扫描":把请求的输入 token 数从 500 扫到 4000,看看吞吐在哪一段开始塌。这条曲线基本就是你的 KV Cache 容量边界。

这次排查最大的教训是:大模型推理服务的瓶颈几乎总是显存,而不是算力。判断一台 GPU 服务器能不能扛住你的业务,先看三件事显存多大、是什么卡(显存带宽决定单请求速度)、要不要多卡(多卡 tensor parallel 会带来额外的通信开销,单机能解决就别上多机)。
参数上我的最终配置是:7B 模型 AWQ 量化、max-model-len 4096、max-num-seqs 12、gpu-memory-utilization 0.92,压测下来 P95 稳定在 1.9 秒,比之前"并发 32 延迟 11 秒"的状态好了一个数量级。
最后提醒一句:大模型服务上线前一定留一个能立刻回滚的版本。改参数看着是小事,改错了服务就起不来。配置进版本管理,模型权重和量化文件放快照里,出问题重置比现场调参快得多。
如果你也在折腾自建推理,记得先把"每请求 KV 占用"这一笔账算出来,再决定并发数。这一步能省你后面大半的加班时间。