Administrator
发布于 2026-08-24 / 1722 阅读
47

大模型推理加速:缓存、量化与批处理

八月的 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显存占用
14819%410ms68%
415234%780ms72%
1623843%3,100ms79%
3224144%7,800ms81%

两个关键发现:

一是吞吐在 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,080ms241 tok/s
固定时间戳41.2%2,140ms352 tok/s
稳定工具顺序78.6%1,320ms598 tok/s
前缀感知路由92.4%1,180ms674 tok/s

这里最反直觉的一点:开关本身几乎没用,请求构造的优化才是全部收益来源。我看到不少团队开了 --enable-prefix-caching 就以为完事了,其实要看命中率指标。vLLM 的 metrics 里有 vllm:gpu_prefix_cache_hit_rate,我们的 Grafana 面板上这个是排在第一位的。

第二刀:量化,但要挑场景

量化是收益和风险都很大的一步,我们做得很谨慎。

测试了三种方案,用我们的 640 条问答集 + 180 条工具调用集做评测:

方案显存占用吞吐问答准确率工具调用正确率P99 延迟
FP16(基线)64GB674 tok/s89.4%94.1%1,180ms
FP834GB891 tok/s89.2%93.8%1,040ms
AWQ INT419GB1,240 tok/s87.1%88.6%920ms
GPTQ INT419GB1,196 tok/s86.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 首 tokenGPU 利用率显存 OOM 次数
128812 tok/s940ms58%0
256(默认)1,012 tok/s1,180ms66%0
3841,148 tok/s1,740ms71%2
5121,190 tok/s2,860ms73%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/s1,180 tok/s+392%
GPU 利用率31%68%+37pp
P99 首 token3,100ms900ms-71%
P99 端到端8,400ms3,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% 差远了。

就写到这。如果哪天你也被《大模型推理加速:缓存、量化与批处理》里同一个坑绊住,回来翻这篇,能省半小时。

参考