ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KV Cache显存优化:FP8量化、Offload、淘汰与质量回归

KV Cache显存优化:FP8量化、Offload、淘汰与质量回归

部署大模型时,很多人先计算权重能不能放进显存:参数量乘以数据类型字节数,再加一点运行空间,结果看起来绰绰有余。服务上线后却发现,模型空闲时正常,只要并发增加、上下文变长,显存水位就迅速爬满。为了止住 OOM,把最大上下文砍半后服务暂时稳定,但长文档问答和多轮对话又开始截断。

被漏掉的主角就是 KV Cache。自回归生成每产生一个 Token,都要让当前 Query 关注此前 Token 的 Key 和 Value。如果每一步都重新计算完整历史,代价非常高,因此推理引擎会保存每层历史 K/V。它用显存换计算,带来了高效解码,也让显存占用随上下文、并发和层数持续增长。

优化 KV Cache 不能等同于“把精度调低”。FP8 量化、CPU Offload、淘汰、滑动窗口、前缀复用和分页分配解决的是不同问题,也引入不同的延迟与质量风险。本文建立一条从容量核算到策略选择、从实装到任务级回归的完整路径。重点不是给出一个万能参数,而是让每一字节节省都能解释,每一次质量变化都能被发现。

是

否

否

是

采集模型结构与长度分布

计算KV理论预算

分页缓存基线

容量是否满足SLO

保持高精度并持续观测

FP8量化与校准

任务级质量回归

质量与延迟是否过门禁

按需评估Offload

极端场景再评估淘汰

灰度、监控与回滚

1. KV Cache为什么会成为并发上限

在标准多头注意力中,每层会为历史 Token 保存 Key 和 Value。粗略容量可以写成:

KV字节 ≈ 2 × 层数 × KV头数 × 头维度 × 已缓存Token数 × 元素字节数

其中2代表 K 与 V。若模型采用 GQA 或 MQA,KV 头数少于 Query 头数,缓存会明显小于传统 MHA;因此不能只看隐藏维度猜容量。服务端还会按块管理缓存,存在块对齐、元数据、临时张量和碎片,公式只适合预算,真实值要从推理引擎指标和显存分析工具读取。

“已缓存 Token 数”也不是配置文件中的最大上下文乘以请求数那么简单。持续批处理下,不同请求长度各异,已结束请求的块可以回收,新请求会不断进入;前缀缓存可能让物理块被多个逻辑请求引用;Beam Search 还可能扩大缓存分支。真正决定 OOM 的是峰值时刻活跃块加运行工作区,而不是单请求演示。

权重量化也不会自动量化 KV Cache。权重是离线或加载时确定的数据,KV 是每个请求运行时产生的激活。一个 INT4 权重模型完全可能继续使用 BF16 KV Cache。部署参数、校准方式和内核支持必须分别确认,不能看到“4 bit 模型”就假设缓存也是 4 bit。

2. 先做一个不会混淆单位的预算器

下列脚本只依赖 Python 标准库,用模型结构计算单序列理论 KV 容量,再估算给定预算能承载多少等长序列。kv_heads必须读取模型配置;bytes_per_element对 BF16/FP16 取 2,对 FP8 取 1。结果没有包含块碎片和工作区,因此代码明确要求预留安全系数。

from__future__importannotationsfromdataclassesimportdataclassfrommathimportfloor@dataclass(frozen=True)classKVShape:layers:intkv_heads:inthead_dim:intbytes_per_element:floatdef__post_init__(self)->None:values=(self.layers,self.kv_heads,self.head_dim,self.bytes_per_element)ifany(value<=0forvalueinvalues):raiseValueError("KV结构参数必须全部大于0")defkv_bytes(shape:KVShape,tokens:int,sequences:int=1)->int:iftokens<0orsequences<0:raiseValueError("Token数与序列数不能为负")returnint(2*shape.layers*shape.kv_heads*shape.head_dim*shape.bytes_per_element*tokens*sequences)defmax_sequences(budget_gib:float,shape:KVShape,tokens_per_sequence:int,safety_ratio:float=0.85,)->int:ifbudget_gib<=0ornot0<safety_ratio<1:raiseValueError("预算必须为正,安全系数必须位于0和1之间")usable=budget_gib*(1024**3)*safety_ratio per_sequence=kv_bytes(shape,tokens_per_sequence)returnfloor(usable/per_sequence)ifper_sequenceelse0def_self_check()->None:tiny=KVShape(layers=2,kv_heads=2,head_dim=4,bytes_per_element=2)assertkv_bytes(tiny,tokens=10)==640assertmax_sequences(1,tiny,tokens_per_sequence=10)>0if__name__=="__main__":_self_check()

这个工具最有价值的用法是做相对比较。例如把元素从 2 字节改成 1 字节,理论缓存主体约减半,但可用并发不会保证正好翻倍,因为权重、CUDA 图、激活峰值和碎片没有同步减半。若实测差距很大,先检查运行时是否真正启用了目标 dtype、是否加载了缩放因子,以及显存预算是否被其他组件占用。

容量规划要按长度桶计算。若 90% 请求只有 2K 上下文,10% 请求可达 64K,用全局均值会低估长尾峰值,用最大值乘全部并发又会严重浪费。更实际的做法是为短交互、长文档和离线任务设独立并发上限,入口按 Token 数准入,必要时分池。

3. PagedAttention先解决分配问题,不改变数值精度

早期实现常为每个请求预留一段连续的最大长度缓存。实际输出远小于上限时,大量显存被内部浪费;不同请求不断创建和结束,还会出现外部碎片。vLLM 的 PagedAttention 借鉴操作系统分页思想,把 KV Cache 划为固定大小块,逻辑序列通过块表映射到不连续的物理块。请求按需增长,结束后归还块,前缀也可以通过引用共享。

分页解决的是“怎样分配和复用”,不是“每个元素占几字节”。它通常应该先于激进压缩考虑,因为不会主动丢失数值信息。但块大小也有权衡:块过大,序列尾部浪费更明显;块过小,块表和调度元数据增加,内核访问也更复杂。不要只凭单请求 Token/s 调块大小,应观察生产长度分布、块利用率、前缀命中和调度开销。

前缀缓存建立在分页之上时,要格外注意缓存键。System Prompt、Tokenizer、模型版本、LoRA 适配器、RoPE 配置和租户权限任一变化,都可能让“同样的字符串”不再代表同一计算。命中错误前缀不会总是崩溃,它可能产生语法正确却语义错误的答案,因此版本和安全域必须进入缓存身份。

4. FP8 KV Cache:省显存不等于零损失

FP8 使用 8 位浮点数保存 K/V,常见格式包括 E4M3 和 E5M2。E4M3 用更多尾数位换精度、指数范围较小;E5M2 指数范围更大、有效精度更低。实际支持名称和硬件路径由推理框架决定,不能仅凭格式理论选择。vLLM 官方文档支持配置 FP8 KV Cache,并在较新版本中区分 per-tensor 与特定后端支持的 per-attention-head 缩放路径。

量化通常把浮点值映射到有限表示范围,缩放因子决定动态范围。若 scale 过大,小值分辨率浪费;scale 过小,离群值饱和。直接使用默认 scale 可能让接口跑通,却不代表模型质量稳定。对支持的框架,应使用代表性校准数据生成缩放因子,并把 scale 文件与模型摘要、引擎版本一起管理。

KV 量化与权重量化风险不同。权重误差对所有请求相对稳定,KV 误差来自每个请求的动态激活,并在后续注意力中持续参与计算。长上下文、精确复制、数字推理、代码引用和“在文档某处找到一个细节”通常比普通闲聊更敏感。只跑短句常识问答,很容易得出虚假的“无损”结论。

KIVI 的原始研究进一步分析了 K 与 V 的分布差异,提出 Key 按通道、Value 按 Token 的非对称低比特量化。这个结论说明量化粒度不是实现细节;但论文中的 2 bit 方法、Hugging Face 的 QuantizedCache 和 vLLM 的 FP8 实现并非同一个算法。写配置时必须说清“框架的哪条实现路径”,不能把论文结果直接移植成生产承诺。

5. 一个稳妥的FP8上线步骤

第一步固定基线。模型权重、Tokenizer、采样参数、推理引擎、并行配置和输入集合全部不变,只让 KV dtype 成为实验变量。若同时更新框架、改权重量化并启用 FP8,出现回归后无法归因。

第二步验证硬件与内核路径。确认目标 GPU、注意力后端和当前框架版本真正支持所选格式;启动日志应显示 KV dtype 和 scale 来源。某些路径可能支持存储为 FP8,却在计算前反量化;另一些路径可在量化域执行注意力,性能特征不同。不要仅看 CLI 接受参数就断言已获得吞吐收益。

第三步用真实数据校准。样本应覆盖长短上下文、不同语言、代码、表格、数字、工具调用和 RAG 证据。校准集不能来自测试答案本身,也不能只含最常见的简单请求。生成 scale 后,把它作为模型工件做校验和,禁止节点各自临时生成。

第四步测容量与质量。容量侧记录最大活跃 Token、KV 块使用、抢占、OOM、吞吐和 TTFT/TPOT;质量侧执行后文的成对回归。若缓存容量增加却 TPOT 变差,要检查量化/反量化内核和批大小变化,而不是只庆祝“能塞更多请求”。

第五步小流量灰度并保留 BF16 回切池。遇到长上下文任务错误率、工具参数合法率或引用准确率越界时,路由可以按任务回切,而不是全局硬扛。回切必须连同 KV Cache 重建,不能把 FP8 产生的块直接交给期望 BF16 的 worker。

6. Offload:用总线带宽换显存

当显存紧张且延迟要求允许时,可以把 KV Cache 移到 CPU 内存,在需要某层计算前预取回 GPU。Hugging Face Transformers 的缓存策略支持 offloaded dynamic/static cache:通常只把当前层保留在 GPU,并异步预取下一层。官方文档同时提醒,它会以数据搬运换取显存,生成吞吐可能下降。

Offload 是否有效取决于工作集和互联。PCIe 带宽、NUMA 位置、Pinned Memory、CPU 内存容量以及同时 Offload 的请求数都可能成为瓶颈。若 CPU 内存跨 NUMA 节点分配,GPU 访问路径可能多绕一跳;若同一总线还承担网络或存储流量,压力会叠加。监控必须包括主机内存和传输吞吐,不能只看 GPU 显存下降。

对每层都 Offload 并不总是合理。滑动窗口层的缓存本来就有上限,搬出去节省有限却仍要付传输成本。Transformers 提供是否只 Offload 非滑动层的选择,就是为了保留这种优化空间。模型含有混合注意力结构时,先画出每类层的缓存增长行为,再决定策略。

服务端还可把不活跃会话的块换出,恢复时再换入。这与每次前向按层 Offload 是不同机制:前者按请求冷热管理,后者按层流式搬运。会话级换出适合“用户思考时间长、活跃会话多”的场景,但恢复首 Token 会变慢。路由器如果把恢复请求发到没有对应主机缓存的节点,就会失去收益。

7. 淘汰策略:真正的问题是保留哪些历史

量化和 Offload 尽量保留完整历史,只改变表示或位置;淘汰会直接删除一部分 K/V,因此可能改变模型可见信息。最简单的滑动窗口只保留最近 Token,适合模型本身按窗口训练或任务主要依赖近期上下文。若模型使用全局注意力而业务要引用开头条款,机械删除旧 Token 会让它“忘记”关键条件。

H2O 研究观察到少量 Heavy Hitter Token 对注意力贡献较大,提出同时保留近期 Token 和高贡献 Token。其他方法会按注意力、聚类或任务结构选择缓存。论文结果可以帮助理解设计空间,但生产选择仍要验证模型、任务和内核实现是否匹配。尤其不能在没有适配实现的情况下,仅根据论文概念在应用层删除提示词;输入截断和内部 KV 淘汰不是同一操作。

一个更保守的优先级通常是:先限制无意义的超长输入和输出;再使用模型原生滑动窗口或分页复用;然后评估 FP8;显存仍不足时考虑 Offload;只有在质量回归体系成熟后,才尝试信息选择型淘汰。这个顺序不是绝对标准,但能把不可逆的信息损失放到最后。

淘汰还会影响可复现性。相同提示在不同并发下,如果缓存压力触发了不同淘汰路径,输出可能发生变化。策略必须与请求绑定并可观测,日志至少记录策略版本、保留 Token 数和是否发生淘汰,避免质量事故只能靠猜。

8. 质量回归要测“使用缓存的生成过程”

只计算一次全序列前向的困惑度可能绕过真实生成缓存路径。验证 KV 优化必须实际执行 Prefill 和逐 Token Decode,并覆盖足够长的序列。测试集建议包含:长文针查找、跨段多跳、开头约束回忆、数字与代码精确复制、JSON/工具调用、RAG 引用、多轮对话以及业务拒答规则。

下面的标准库脚本读取 BF16 基线和优化版本的 JSONL 结果,检查样本是否一一对应,并统计精确匹配率、包含预期关键片段的比例与平均字符相似度。它不替代语义评测,而是提供一个能在 CI 中稳定运行的最低门槛。输入每行应包含id、output,基线还应包含可选的must_contain列表。

from__future__importannotationsimportjsonfromdifflibimportSequenceMatcherfrompathlibimportPathfromtypingimportAnydefload_jsonl(path:Path)->dict[str,dict[str,Any]]:rows:dict[str,dict[str,Any]]={}withpath.open("r",encoding="utf-8")ashandle:forline_number,lineinenumerate(handle,start=1):ifnotline.strip():continuerow=json.loads(line)sample_id=str(row["id"])ifsample_idinrows:raiseValueError(f"{path}第{line_number}行ID重复:{sample_id}")rows[sample_id]=rowreturnrowsdefcompare_runs(baseline:dict[str,dict[str,Any]],candidate:dict[str,dict[str,Any]],)->dict[str,float]:ifbaseline.keys()!=candidate.keys():missing=sorted(baseline.keys()-candidate.keys())extra=sorted(candidate.keys()-baseline.keys())raiseValueError(f"样本集合不同 missing={missing[:3]}extra={extra[:3]}")exact=0constraints_ok=0similarity_sum=0.0forsample_id,expectedinbaseline.items():left=str(expected.get("output","")).strip()right=str(candidate[sample_id].get("output","")).strip()exact+=left==right must_contain=[str(x)forxinexpected.get("must_contain",[])]constraints_ok+=all(fragmentinrightforfragmentinmust_contain)similarity_sum+=SequenceMatcher(None,left,right).ratio()count=len(baseline)ifcount==0:raiseValueError("没有可比较样本")return{"exact_match":exact/count,"constraint_pass":constraints_ok/count,"mean_similarity":similarity_sum/count,}def_self_check()->None:base={"a":{"output":"答案42","must_contain":["42"]}}cand={"a":{"output":"答案42"}}assertcompare_runs(base,cand)["constraint_pass"]==1.0if__name__=="__main__":_self_check()

精确匹配对确定性结构化任务很有用,对开放式回答则过严。应再加入任务指标:JSON Schema 合法率、工具名与参数正确率、检索证据覆盖率、代码测试通过率、数学答案准确率、拒答一致性。对长上下文可以按长度桶画回归曲线,寻找从何处开始劣化;单个全局平均会把长文本退化藏在大量短样本中。

采样要尽量确定:固定随机种子、温度和后处理。但 GPU 浮点与批次顺序仍可能导致差异,所以先重复运行基线,测出自身波动,再判断候选是否超出噪声。每次评测要保存模型摘要、框架版本、KV dtype、scale 摘要、注意力后端、最大上下文、并发和策略版本,否则分数无法复现。

9. 性能验收不能只有“省了多少显存”

对每个方案至少记录四组数据。容量组包括每 GPU 可用 KV 字节、最大活跃 Token、可承载并发、块利用率和抢占次数;延迟组包括按输入/输出长度分桶的 TTFT、TPOT 与端到端分位数;吞吐组包括成功请求数和有效输入/输出 Token;质量组包括任务指标及错误类型。

FP8 可能让缓存容纳更多并发,但如果把新增空间全部填满,批次变大、每步读取更多历史,单请求 TPOT 不一定改善。Offload 可能消除 OOM,却因 PCIe 搬运让流式输出抖动。淘汰可能提升吞吐,却让开头信息召回率下降。只有把四组数据放在一起,才能判断优化是否符合目标。

压测流量要保持真实到达节奏。一次性把请求全部发出只测到了极端排队,不等于线上行为;固定长度也会掩盖碎片和冷热差异。最好从脱敏生产日志抽取长度、到达间隔和会话停顿,使用合成内容重放,既保持形态又不泄露原文。

回归门禁要按业务定义,而不是追求所有文本完全一致。工具调用业务可把参数正确率设为硬门槛,闲聊允许措辞变化;RAG 则应优先守住引用和事实。任何门槛都要在实验前确定,避免看到结果后临时改变标准。

10. 常见故障与定位顺序

配置了FP8但显存几乎没降:确认量化的是 KV Cache 而非仅权重,检查启动日志的实际 dtype、scale 加载和注意力后端;再区分权重占用、KV 预算和工作区。单请求短上下文时 KV 占比本来就小,也看不出明显变化。

短任务正常,长文档开始答非所问:按长度桶比较 BF16 与 FP8,检查 scale 是否用代表性长文本校准;如果启用了淘汰,分别关闭量化和淘汰做消融。不要一次把所有优化关掉,那样只能知道“组合有问题”。

Offload后GPU显存下降但TPOT尖峰:观察 CPU-GPU 拷贝、Pinned Memory、NUMA 命中和主机内存压力。若多个 GPU 共用同一 PCIe 根复杂度,单卡测试无法代表多卡并发。尝试只 Offload 非滑动层,或把高优先级短请求留在纯 GPU 池。

前缀命中高却偶发错误:核对缓存键是否包含模型、Tokenizer、适配器、系统提示、租户和内容版本;确认引用计数与写时复制。命中率不是越高越好,跨安全域命中属于事故。

没有OOM却频繁抢占重算:总显存看似足够,不代表可分配 KV 块足够。检查引擎为权重和执行图预留后的块数、请求最大输出、块碎片和准入策略。重算会把显存问题转化为延迟问题,不能被“成功率100%”掩盖。

11. 安全、隐私与资源滥用

KV Cache 来源于用户输入,应按敏感数据管理。CPU Offload 会扩大数据驻留面:原本只在 GPU 的中间状态进入主机内存,可能出现在崩溃转储、调试工具或越权进程可见范围。生产环境要限制进程权限、关闭不必要的 core dump、隔离租户与节点,并明确缓存生命周期。

攻击者还可以用超长输入和超大max_tokens消耗缓存,造成其他请求被抢占。入口必须在可信侧重新 Tokenize,按身份限制上下文、输出和并发,并在预算不足时尽早拒绝。客户端声明的字符数不能作为资源计量,因为不同 Tokenizer 的 Token 数差异很大。

前缀缓存不能因为哈希相同就跨租户共享。哈希只证明内容键一致,不证明授权一致;并且需要防止低成本构造大量前缀把热门块挤出。配额和淘汰应按租户考虑,监控异常命中与大量一次性前缀。

12. 如何选择:一张按问题而不是按潮流的路线图

若问题是分配碎片和预留浪费,先使用成熟的分页缓存与连续批处理。若瓶颈是完整 KV 元素太大,并且硬件/框架支持,评估 FP8 与可靠 scale。若显存确实不足且能接受传输延迟,使用 CPU Offload 或冷热换出。若任务只依赖有限近期上下文,优先采用模型原生窗口;只有在充分质量评测后,才引入信息选择型淘汰。

这些技术也可以组合,但每增加一项都要做消融。分页加 FP8 是常见组合;FP8 再 Offload 可能进一步节省显存,也可能让量化转换与传输叠加;淘汰再叠加量化则同时改变信息集合和数值表示,最难定位。工程上应保留一个可随时切换的高精度基线池。

没有哪种策略能代替准入控制。缓存再省,资源仍是有限的;无限接收长请求只会把 OOM 推迟。优化的目标是用明确质量和延迟代价承载更多有效工作,而不是制造“上下文长度写得很大”的配置截图。

13. 配置与缓存工件也需要版本治理

KV 优化常被当作几行启动参数,实际却已经构成一组必须一致发布的模型工件。至少要把模型权重摘要、Tokenizer 摘要、推理引擎版本、注意力后端、KV dtype、scale 文件摘要、块大小、最大上下文、滑动窗口与淘汰策略写进部署清单。任何节点缺少字段都不应加入同一服务池。

Scale 文件尤其容易被忽略。它不是普通的性能建议,而是量化数值解释的一部分。错误 scale 可能不会触发启动失败,只会让特定层或长上下文质量下降。制品仓库应把 scale 与目标模型绑定,下载后校验摘要;禁止用“最新”标签让节点在不同时间拿到不同内容。若 scale 由校准流水线生成,还应保存校准代码版本、数据类别摘要和生成参数,但不要把敏感原始样本放进公开工件。

滚动发布时,新旧节点不能共享不兼容的前缀缓存。最简单可靠的做法是让缓存命名空间包含完整兼容版本,新池预热完成后再切流,新旧缓存自然隔离。试图跨 BF16 与 FP8、跨块布局或跨模型版本复用缓存,节省的预热时间远小于潜在正确性风险。回滚时也要回到旧命名空间,不能继续读取新版本生成的块。

配置校验要发生在接流量之前。启动探针不仅检查端口,还要执行一组短 Prefill/Decode、自报实际 KV dtype 与可用块,并验证 scale 已加载。就绪探针通过后,控制面再把节点加入路由。仅凭进程存活会把仍在模型加载、图捕获或缓存初始化的节点暴露给用户,制造冷启动超时。

发布清单还要声明兼容矩阵。例如某注意力后端支持 FP8 存储,但特定 GPU 架构没有高效内核;某模型含滑动窗口层,QuantizedCache 路径却不支持;某版本 Offload 与编译模式冲突。把这些条件写成机器可校验的规则,比依赖值班人员记忆可靠。未知组合默认拒绝,而不是先上线观察。

回滚策略要覆盖“缓存已经创建”的情况。参数回滚并不会自动把现存块恢复成高精度;应停止新请求进入候选池,排空或取消活跃请求,释放该池缓存,再用旧配置重新就绪。对长任务可以设置最大排空时间并向客户端返回可重试状态,不能让一次回滚无限等待。

最后,版本治理也服务于质量分析。评测结果必须关联唯一配置指纹,线上异常样本才能与离线门禁对应。若同一个服务名背后混有多套 scale 和策略,再精细的统计也无法回答哪个变更导致回归。缓存优化越深入,越应该把它当作模型发布的一部分,而不是运维人员临时调整的显存旋钮。

灰度比例不应只按请求数计算。长请求数量少,却占据大部分 KV Token;若随机放入百分之一请求,候选池可能长期遇不到真正的长上下文压力。更合理的是同时控制请求比例与活跃 Token 预算,并刻意覆盖各长度桶。候选池达到预定缓存水位后再比较 TPOT、抢占和质量,低负载下“没有 OOM”不能证明优化有效。

配置审计还要记录是谁在何时改变了哪一项,以及变更前后的验证报告。紧急调低最大上下文或开启 Offload 可以是止损手段,但必须带过期时间和恢复条件,防止临时配置永久存在。发生事故时,优先回到完整配置指纹对应的已知版本,而不是在现场继续叠加未知参数;可重复的回滚比临时调参更能保护数据与服务质量。

14. 总结

KV Cache 是大模型服务把历史计算换成解码速度的关键状态,也是长上下文和高并发下最容易被低估的显存消费者。PagedAttention改善分配,FP8改变表示精度,Offload改变存放位置,淘汰改变保留的信息;它们不应被混为一个“缓存优化”开关。

可靠实施需要从模型结构和长度分布计算预算,用运行时指标验证真实块容量;每次只改变一个变量,用包含长上下文、精确复制、工具调用和引用的任务集回归;同时测显存、尾延迟、吞吐和质量。看到 OOM 消失只是第一步,用户在长对话中还能正确回忆开头约束,才说明优化真正成立。

参考资料

  1. vLLM:Quantized KV Cache
  2. vLLM:Cache配置参考
  3. Hugging Face Transformers:Cache strategies
  4. Hugging Face Transformers:Caching原理
  5. PagedAttention:Efficient Memory Management for Large Language Model Serving
  6. vLLM官方论文与项目
  7. KIVI:A Tuning-Free Asymmetric 2bit Quantization for KV Cache
  8. KIVI官方实现
  9. H2O:Heavy-Hitter Oracle for Efficient Generative Inference
  10. SnapKV:LLM Knows What You are Looking for Before Generation
  11. KVQuant:Towards 10 Million Context Length LLM Inference
  12. OCP:8-bit Floating Point Specification
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进