【Bug已解决】[Bug]: vllm0.19.1 with llmcache with qwen3.6-27B-FP8 failed 解决方案 【Bug已解决】[Bug] vllm0.19.1 with llmcache with qwen3.6-27B-FP8 failed 解决方案一、现象长什么样在 vLLM 0.19.1 上挂载 LLMCache外接 KV 缓存层把前缀 KV 缓存从显存搬到本地磁盘 / 共享内存 / 远端存储去服务Qwen3.6-27B-FP8时进程在启动后第一次真正跑推理就报错典型的三类报错是RuntimeError: LLMCache connector: kv tensor dtype float16 mismatches engine kv_cache_dtype fp8_e5m2或者更隐晦一点的ValueError: cache block shape (num_heads40, head_dim128) does not match stored block (40, 128) but dtype stride differs再或者进程根本起不来卡在 connector 初始化阶段AttributeError: CacheEngine object has no attribute get_kv_cache_layout最让人困惑的是单独用Qwen3.6-27B-FP8不带 LLMCache 完全正常单独用 LLMCache 配一个 bf16 模型也完全正常。两者一组合就炸而且报错信息指向的是「缓存」不是「模型权重」很容易让人误以为是磁盘或网络问题来回换存储后端浪费大量时间。二、背景LLMCache 这类外接缓存的核心思路是把一个请求的 KV 缓存块block从 vLLM 的CacheEngine里取出来序列化后写到外部存储下次遇到相同前缀同一段 system prompt、同一段长文档就直接从外部存储读回、绕过重算。它工作在两个层面取数通过 connector hook 拿到CacheEngine里某个 block 的 KV 张量。落地把张量to(cpu)后做序列化通常.contiguous()numpy或torch.save再交给后端存储。而Qwen3.6-27B-FP8是一个 FP8 权重量化模型。它有一个关键特征它的 KV 缓存数据类型默认被引擎设成了 fp8具体是fp8_e5m2目的是让长上下文下的 KV 缓存占用减半配合 FP8 权重一起把显存压到单卡能跑。问题就出在「取数」这一步。LLMCache 的早期 connector 实现里写死了一个假设引擎里的 KV 张量一定是 fp16 或 bf16torch.float16/torch.bfloat16于是在「取数」时直接.to(torch.float16)做降精度搬运。当真实 dtype 是 fp8_e5m2 时这个假设被打破错误在「落地 / 读回」的不对称点爆发。三、根因根因可以拆成两条但主因是第一条。主因缓存层对 KV dtype 做了硬编码假设。LLMCache 的 connector 在put时把 KV 张量无条件 cast 成 fp16在get时又按 fp16 的形状去解析外部存储里的字节。当引擎实际 KV dtype 是 fp8 时put阶段fp8 张量被 cast 成 fp16信息被「先放大再保存」但重要的是——此时外部存储里记录下来的 dtype 元数据是 fp16。get阶段从外部存储读回的是 fp16 字节但引擎期望喂回去的是 fp8 张量。connector 又把 fp16 字节to(fp8_e5m2)强行塞回引擎dtype 看似对齐了但fp8 的相对缩放因子scale在 fp16→fp8 这一步已经丢失更糟的是某些实现根本没做回 cast直接把 fp16 张量按 fp8 的 stride 去view于是出现shape matches but stride differs的诡异报错。次因版本 API 漂移。vLLM 0.19.1 把CacheEngine.get_kv_cache_layout()重构成了get_cache_block_size() 新的 layout 描述结构。LLMCache 0.2.x 还调用老接口于是初始化阶段报AttributeError。这条不影响已经跑起来的 bf16 模型因为那条路径没走到 layout 反射但 FP8 路径会触发更深的 connector 逻辑从而撞上这个老接口。一句话总结根因缓存层把「KV 张量的 dtype」当成了一个固定常量fp16而 FP8 模型让这个常量变成了变量缓存层既没读取引擎真实 dtype也没在存取两侧保持 dtype 对称。四、最小可运行复现下面这段纯 CPU 代码不依赖 vLLM但完整复现了「缓存层硬编码 fp16、遇到 fp8 张量就坏」的核心逻辑。你可以直接python repro.py跑不需要 GPUimport torch import struct class NaiveCacheConnector: 复现 LLMCache 早期 connector写死 fp16 假设。 STORE {} # 模拟外部存储 def put(self, block_id: int, kv: torch.Tensor) - None: # 错误点无条件 cast 成 fp16并记录 fp16 元数据 cpu kv.to(torch.float16).contiguous() self.STORE[block_id] { data: cpu.numpy().tobytes(), dtype: float16, shape: tuple(cpu.shape), } def get(self, block_id: int, expect_dtype: torch.dtype) - torch.Tensor: rec self.STORE[block_id] buf torch.frombuffer( bytearray(rec[data]), dtypetorch.float16 ).reshape(rec[shape]) # 错误点读回时按声明 dtype 强转但 fp8 的 scale 已丢 return buf.to(expect_dtype) def make_fp8_kv(num_heads4, head_dim8, seq16): # 用 fp8_e5m2 模拟一段真实 KV 缓存 t torch.randn(num_heads, head_dim, seq, dtypetorch.float32) return t.to(torch.float8_e5m2) def main(): conn NaiveCacheConnector() real_kv make_fp8_kv() conn.put(1, real_kv) # 引擎期望 fp8但 connector 返回的是「fp16 字节强转 fp8」 back conn.get(1, torch.float8_e5m2) print(readback dtype:, back.dtype) print(readback[0,0,0]:, float(back[0, 0, 0])) # 与真实值对比会发现数值严重失真且若引擎后续做 stride view 会直接崩 print(expected[0,0,0]:, float(real_kv[0, 0, 0])) diff (back.float() - real_kv.float()).abs().mean().item() print(mean abs diff (应接近 0, 实际很大):, diff) if __name__ __main__: main()跑出来你会看到mean abs diff非常大fp8 本身有量化误差但这里还叠加了 fp16↔fp8 的二次损失并且如果引擎对读回张量做as_strided之类依赖 stride 的操作就会抛出开头那种stride differs报错。五、解决方案第一层最小直接修复最省事、风险最低的直接修复让缓存层不要改 dtype原样存取引擎的 KV 张量并把真实 dtype 写进存储元数据。import torch class AlignedCacheConnector: 第一层修复原样存取记录真实 dtype。 STORE {} def put(self, block_id: int, kv: torch.Tensor) - None: cpu kv.detach().to(cpu).contiguous() self.STORE[block_id] { data: cpu.numpy().tobytes(), dtype: str(kv.dtype), # 关键记录引擎真实 dtype shape: tuple(cpu.shape), } def get(self, block_id: int, _expect_dtypeNone) - torch.Tensor: rec self.STORE[block_id] dt getattr(torch, rec[dtype].split(.)[-1]) buf torch.frombuffer( bytearray(rec[data]), dtypedt ).reshape(rec[shape]) return buf # 原样返回dtype 与引擎一致在 vLLM 侧同时显式声明kv_cache_dtype不要让它「自动」。给 LLMCache 的 connector 配置加一行# 启动 vLLM 服务时 llm LLM( modelQwen3.6-27B-FP8, kv_cache_dtypefp8_e5m2, # 显式固定避免 connector 误判 # ... 其余参数 )如果磁盘空间允许、且你更在意精度稳定性而非显存还可以直接把 KV 缓存退回 fp16llm LLM( modelQwen3.6-27B-FP8, kv_cache_dtypeauto, # 用 fp16/bf16 存 KV和老 connector 假设一致 )这样即使 connector 还是老逻辑「假设 fp16」也恰好对了能立刻跑起来。这是线上救火时最常用的一招。六、解决方案第二层结构性改进第一层是「绕过」第二层是「让 connector 自己学会适配」。核心改造connector 在初始化时主动读取引擎的 KV dtype 和 block 形状作为单一事实来源single source of truth存取两侧都基于它。import torch from dataclasses import dataclass, field from typing import Dict, Tuple dataclass class KVCacheProfile: dtype: torch.dtype num_heads: int head_dim: int block_size: int property def block_shape(self) - Tuple[int, int, int]: # (num_heads, head_dim, block_size) return (self.num_heads, self.head_dim, self.block_size) # 模型 - 推荐 KV 配置来自实测避免 connector 瞎猜 RECOMMENDED: Dict[str, KVCacheProfile] { qwen3.6-27b-fp8: KVCacheProfile( torch.float8_e5m2, num_heads40, head_dim128, block_size16 ), } class AdaptiveCacheConnector: 第二层修复以引擎 profile 为唯一事实来源。 def __init__(self, model_tag: str, engine_profile: KVCacheProfile | None None): self.profile engine_profile or RECOMMENDED.get(model_tag) if self.profile is None: raise ValueError(f未知的模型 {model_tag}请提供 engine_profile) self.STORE: Dict[int, bytes] {} def put(self, block_id: int, kv: torch.Tensor) - None: # 断言进来的张量必须和引擎 profile 完全一致 assert kv.dtype self.profile.dtype, ( fKV dtype {kv.dtype} ! engine {self.profile.dtype} ) assert tuple(kv.shape) self.profile.block_shape, ( fKV shape {tuple(kv.shape)} ! {self.profile.block_shape} ) cpu kv.detach().to(cpu).contiguous() # 把 profile 一起序列化保证 get 侧对称 header f{kv.dtype};{,.join(map(str, kv.shape))}\n.encode() self.STORE[block_id] header cpu.numpy().tobytes() def get(self, block_id: int) - torch.Tensor: raw self.STORE[block_id] header, _, body raw.partition(b\n) dtype_str, _, shape_str header.decode().partition(;) dt getattr(torch, dtype_str.split(.)[-1]) shape tuple(int(x) for x in shape_str.split(,)) arr torch.frombuffer(bytearray(body), dtypedt).reshape(shape) # 二次断言读回的 dtype/shape 必须仍和 profile 一致 assert arr.dtype self.profile.dtype assert tuple(arr.shape) self.profile.block_shape return arr同时补上版本兼容connector 初始化时检测 vLLM 版本走不同 layout 接口消掉AttributeErrorimport importlib.metadata as md def resolve_layout_caller(): ver md.version(vllm) major, minor, _ (int(x) for x in ver.split(.)[:3]) if (major, minor) (0, 19): # 0.19.1 起用新接口 from vllm.v1.core.kv_cache_manager import get_cache_block_size return get_cache_block_size else: # 老版本回退 from vllm.core.cache_engine import CacheEngine return lambda *a, **k: CacheEngine.get_kv_cache_layout(*a, **k)这样 connector 既不会因为 dtype 错配坏数据也不会因为 API 漂移起不来。七、解决方案第三层断言 / CI 守护把「dtype 对称」和「版本兼容」固化成测试避免以后有人回退 connector 又踩坑import torch import pytest def test_put_get_preserves_fp8_dtype(): prof KVCacheProfile(torch.float8_e5m2, num_heads4, head_dim8, block_size16) conn AdaptiveCacheConnector(x, prof) kv torch.randn(4, 8, 16, dtypetorch.float32).to(torch.float8_e5m2) conn.put(1, kv) back conn.get(1) assert back.dtype torch.float8_e5m2 assert back.shape kv.shape # 原样存取量化误差应极小仅 fp8 自身一次量化 assert (back.float() - kv.float()).abs().mean() 0.05 def test_put_rejects_wrong_dtype(): prof KVCacheProfile(torch.float8_e5m2, num_heads4, head_dim8, block_size16) conn AdaptiveCacheConnector(x, prof) bad torch.randn(4, 8, 16, dtypetorch.float16) with pytest.raises(AssertionError): conn.put(99, bad) def test_layout_resolver_returns_callable(): fn resolve_layout_caller() assert callable(fn) def test_unknown_model_raises(): with pytest.raises(ValueError): AdaptiveCacheConnector(no-such-model)再补一个回归测试专门守住「FP8 模型 LLMCache 不再静默失真」def test_llmcache_fp8_roundtrip_no_silent_corruption(): prof RECOMMENDED[qwen3.6-27b-fp8] conn AdaptiveCacheConnector(qwen3.6-27b-fp8, prof) kv torch.randn(*prof.block_shape, dtypetorch.float32).to(prof.dtype) conn.put(7, kv) back conn.get(7) # 关键断言dtype 与引擎一致且未经过 fp16 中转 assert back.dtype torch.float8_e5m2 assert back.numel() kv.numel()把这些放进 CI任何改 connector 的 PR 都必须过「fp8 往返」用例问题就不可能再悄悄复发。八、排查清单先看报错指向谁是connector/LLMCache还是model/weight前者大概率是本问题。打印引擎真实kv_cache_dtypeengine.cache_config.kv_cache_dtype确认是不是 fp8。检查 LLMCache 版本与 vLLM 版本是否匹配看 release notes 的兼容矩阵。临时救火把kv_cache_dtype设成auto退回 fp16验证能否跑通能跑通就坐实是 dtype 错配。升级 LLMCache 到修了 fp8 支持的版本或按第六节的AdaptiveCacheConnector自研适配层。若报AttributeError: get_kv_cache_layout确认 vLLM 0.19.1 时 connector 是否走了新 layout 接口。落地存储后端磁盘 / 共享内存 / 远端本身与 dtype 无关别在存储层瞎折腾先修 connector。九、小结这个 bug 的本质不是「FP8 模型有问题」也不是「LLMCache 有问题」而是两层组件对「KV 张量的 dtype 是什么」这件事没有达成约定vLLM 侧按模型需求把 KV 设成 fp8缓存层却还在用 fp16 的老习惯。修复思路分三层——第一层最省事显式kv_cache_dtype或退回 fp16 让假设重新成立第二层把引擎 KV profile 提升为唯一事实来源存取两侧对称第三层用 pytest 把「fp8 往返不失真」固化成回归测试。改动量不大但抓住「dtype 对称」这一条主线就能从根上避免同类问题在别的量化模型上重演。