八月的 GPU 账单让我坐不住了
我们自建的推理集群,8 台 A100 80G,7 月的账单是 ¥41.6 万。同时监控告诉我另一个数字:GPU 利用率平均 31%。
花了钱,卡在空转。这两件事放一起,不优化说不过去。
这篇是过去五周做的事。缓存、量化、批处理三块,全会上线后的结果是:单卡吞吐从 240 tok/s 提到 1,180 tok/s,单位成本降 71%。但中间有几个坑,以及一块我到现在还没搞定的事。
先测清楚:瓶颈到底在哪
动手之前我坚持先做压测,因为"GPU 利用率低"是个症状,不是病因。
压测结果(单卡,Qwen2.5-32B,输入 3,200 token,输出 400 token):
| 并发数 | 吞吐(tok/s) | GPU 利用率 | P99 首 token | 显存占用 |
|---|---|---|---|---|
| 1 | 48 | 19% | 410ms | 68% |
| 4 | 152 | 34% | 780ms | 72% |
| 16 | 238 | 43% | 3,100ms | 79% |
| 32 | 241 | 44% | 7,800ms | 81% |
两个关键发现:
一是吞吐在 16 并发就饱和了。再往上加并发,吞吐不涨、延迟翻倍。这说明 batch 没组起来,或者组起来了但被别的东西卡住。
二是显存占用高但利用率低。81% 的显存被占着,GPU 却在等。查了一下,其中 KV Cache 池预分配了 24GB,但实际利用率只有 30% 左右。
再看请求特征,这才是根因:
SELECT
round(avg(system_tok)) AS sys_tok,
round(avg(shared_prefix_tok)) AS prefix_tok, -- 与系统提示重合的部分
round(avg(input_tok)) AS in_tok,
round(avg(output_tok)) AS out_tok,
count(*) FILTER (WHERE shared_prefix_tok > 1000) * 1.0 / count(*) AS high_prefix_ratio
FROM inference_log
WHERE created_at > now() - interval '7 days';
sys_tok | prefix_tok | in_tok | out_tok | high_prefix_ratio
---------+------------+--------+---------+-------------------
1,840 | 1,712 | 3,200 | 412 | 0.847
84.7% 的请求有超过 1,000 token 的公共前缀(系统提示词 + 工具定义)。这部分每次都在重算,纯浪费。
第一刀:前缀缓存
思路是把公共前缀的 KV Cache 缓存下来,后面的请求直接复用。vLLM 类引擎开一个开关就行:
python -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-32B-Instruct \
--enable-prefix-caching \
--gpu-memory-utilization 0.90 \
--max-model-len 32768
但开关开了不等于生效。命中率才是唯一有意义的指标,而这个取决于你的请求怎么构造。
我们做的第一件事是把系统提示词和工具定义固定下来。之前有个地方在提示词里插了当前时间戳(精确到秒),导致每个请求的前缀都不一样,命中率 0.3%。改成分级时间戳(只到分钟)之后:
// 改前:每次都不同,前缀缓存全废
"当前时间:2026-08-14T15:22:41.337Z"
// 改后:同一分钟内相同,缓存可复用
"当前时间:2026-08-14 15:22(精确到分钟)"
第二件事是工具定义的顺序要稳定。我们之前用 HashSet 存工具,序列化顺序随机,前缀每次都变。改成 LinkedHashSet 且按名称排序。
第三件事是网关侧做前缀感知的路由。同一个业务的请求尽量打到同一张卡上,否则每张卡都要缓存一份前缀,浪费显存:
public String route(String tenantId, String scene) {
// 按 (租户, 场景) 做一致性哈希,同前缀请求聚到同一副本
int idx = Hashing.consistentHash(
Hashing.murmur3_128().hashString(tenantId + "#" + scene, UTF_8),
replicas.size());
return replicas.get(idx);
}
三步做完,命中率的变化:
| 阶段 | 前缀缓存命中率 | P99 首 token | 吞吐 |
|---|---|---|---|
| 开启开关(未优化请求) | 0.3% | 3,080ms | 241 tok/s |
| 固定时间戳 | 41.2% | 2,140ms | 352 tok/s |
| 稳定工具顺序 | 78.6% | 1,320ms | 598 tok/s |
| 前缀感知路由 | 92.4% | 1,180ms | 674 tok/s |
这里最反直觉的一点:开关本身几乎没用,请求构造的优化才是全部收益来源。我看到不少团队开了 --enable-prefix-caching 就以为完事了,其实要看命中率指标。vLLM 的 metrics 里有 vllm:gpu_prefix_cache_hit_rate,我们的 Grafana 面板上这个是排在第一位的。
第二刀:量化,但要挑场景
量化是收益和风险都很大的一步,我们做得很谨慎。
测试了三种方案,用我们的 640 条问答集 + 180 条工具调用集做评测:
| 方案 | 显存占用 | 吞吐 | 问答准确率 | 工具调用正确率 | P99 延迟 |
|---|---|---|---|---|---|
| FP16(基线) | 64GB | 674 tok/s | 89.4% | 94.1% | 1,180ms |
| FP8 | 34GB | 891 tok/s | 89.2% | 93.8% | 1,040ms |
| AWQ INT4 | 19GB | 1,240 tok/s | 87.1% | 88.6% | 920ms |
| GPTQ INT4 | 19GB | 1,196 tok/s | 86.4% | 87.2% | 948ms |
结论很清楚,而且和很多宣传口径不一样:INT4 的代价主要在工具调用,不在问答。
问答准确率掉 2.3 个百分点,我们能接受;但工具调用正确率掉 5.5 个百分点是不能接受的——因为工具调用错了会引发真实的副作用(上一篇写的重复退款就是这个领域的)。我们专门看了 INT4 下的错误类型,主要是参数格式错误:数字精度丢失、JSON 结构多一个少一个逗号、枚举值大小写错。
// FP16 输出(正确)
{"orderNo":"SO20260628009","amount":12900}
// AWQ INT4 输出(错误示例,出现过 11 次)
{"orderNo":"SO20260628009","amount":12900.0000001}
{"orderno":"SO20260628009","amount":12900} // 字段名大小写错
最终方案是分场景部署:
- 纯问答场景(客服、知识库):AWQ INT4,占我们流量的 61%;
- 工具编排场景:FP8,准确率损失只有 0.3 个百分点,显存省一半;
- 极少数高精度要求的场景(合同审查):保留 FP16。
这个拆分让集群的整体显存占用降了 42%,同时保住了关键场景的准确率。代价是运维复杂度上升——要维护三套模型权重,路由逻辑也复杂了。我认为值。
第三刀:连续批处理的参数调优
连续批处理(continuous batching)引擎默认就开了,但默认参数不是最优的。这是我们调了最久的一块。
关键参数有四个,我逐个试:
--max-num-seqs 256 # 最大并发序列数
--max-num-batched-tokens 8192 # 单个 batch 的最大 token 数
--enable-chunked-prefill # 长输入的 prefill 切块
--scheduler-policy fcfs # 或 priority
max-num-seqs 从默认 256 往上调,实测到 512 时吞吐还能涨,但 P99 延迟开始恶化:
| max-num-seqs | 吞吐 | P99 首 token | GPU 利用率 | 显存 OOM 次数 |
|---|---|---|---|---|
| 128 | 812 tok/s | 940ms | 58% | 0 |
| 256(默认) | 1,012 tok/s | 1,180ms | 66% | 0 |
| 384 | 1,148 tok/s | 1,740ms | 71% | 2 |
| 512 | 1,190 tok/s | 2,860ms | 73% | 7 |
我们选了 320,是吞吐和延迟的折中点,实测 P99 首 token 1,420ms、吞吐 1,090 tok/s、零 OOM。这个数不是算出来的,是压出来的。
enable-chunked-prefill 这个参数值得单独说。开启后,长输入(我们的 RAG 场景输入经常 8,000+ token)的 prefill 会被切成小块,和其他请求的 decode 交替执行。收益是长请求不会独占 GPU 太久:
# 关闭 chunked prefill
长请求(输入 12,000 token):prefill 独占 GPU 2.4s,期间其他请求全部排队
P99 首 token(全体):4,120ms
# 开启
长请求:prefill 切 8 块,与其他请求交错
P99 首 token(全体):1,610ms,长请求自身首 token 从 2,480ms 变 2,610ms
长请求自己慢了 5%,全体 P99 快了 61%。这个交换在在线服务里永远值得做。
三者叠加的结果
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 单卡吞吐 | 240 tok/s | 1,180 tok/s | +392% |
| GPU 利用率 | 31% | 68% | +37pp |
| P99 首 token | 3,100ms | 900ms | -71% |
| P99 端到端 | 8,400ms | 3,600ms | -57% |
| 支撑相同负载所需卡数 | 8 张 | 3 张 | -62.5% |
| 月度 GPU 成本 | ¥41.6 万 | ¥15.6 万 | -62.5% |
| 问答准确率 | 89.4% | 88.5%(加权) | -0.9pp |
最后一行是代价。88.5% 是加权平均(61% 的流量跑 INT4、其余 FP8/FP16)。业务方接受了这个损失,因为省下的钱够再招两个人。但我心里清楚,这 0.9 个百分点在某些场景是实打实的用户体验下降,我们和用户增长团队约定了:如果投诉率上升超过 0.5 个百分点,就把 INT4 的比例降下来。
踩的坑
三个坑,都不是配置问题。
坑一:缓存击穿导致显存抖动。前缀缓存生效后,我们有一次批量发版(改了系统提示词),所有缓存同时失效,8 张卡的 KV Cache 池瞬间被打满,OOM 了 3 次。修复是做了灰度:新提示词的流量从 5% 开始涨,让缓存逐步重建。另外给 KV Cache 池留了 20% 的余量,不按满配算。
坑二:多租户之间的缓存串扰。我们一开始按租户路由,但有些大租户的请求量占了单卡的 80%,把前缀缓存池全占了,小租户的命中率只有 12%。当前的方案是按场景路由(不看租户),并在缓存池层面做了权重限制。这个问题解决得不彻底,还在看。
坑三:量化模型的输出格式校验失效。我们有个后处理逻辑用正则校验模型输出的 JSON,INT4 上线后这个校验的失败率从 0.04% 涨到 0.9%。不是量化本身的问题,是校验规则写得太严(比如要求 amount 必须是整数形式)。放宽校验 + 加一层容错解析之后降到 0.11%。
还没搞定的
最想做的是 KV Cache 的跨实例共享和卸载。现在每个实例各自缓存,8 台机器(现在是 3 台)之间不共享。理论上把 KV Cache 卸载到 Redis 或共享存储,命中率还能再提,而且能支持实例的弹性伸缩——现在扩容一个实例要重新预热缓存,冷启动那 5 分钟命中率是 0。
我们试了 LMCache 的方案,实测 Redis 方案的传输开销太大:3,200 token 的前缀 KV 大约 480MB(32B 模型、FP16),走网络传输要 340ms,比重新算(210ms)还慢。除非用 RDMA 或者本地 NVMe 卸载,否则不划算。这块我还在测 NVMe 方案,没结论。
另一个没解决的是多轮对话的 KV 复用。前缀缓存只在"完全相同的前缀"时生效,但多轮对话里第二轮的前缀是"系统提示 + 第一轮问答",如果用户换个说法,前缀就变了。对话层面的复用命中率只有 34%,比单轮的 92% 差远了。
就写到这。如果哪天你也被《大模型推理加速:缓存、量化与批处理》里同一个坑绊住,回来翻这篇,能省半小时。