1. 什么是 KV Cache?它为什么成了大模型推理的“命门”
如果你最近在跑 LLM、调服务、搭 API,或者只是单纯关注推理延迟和显存占用,那“KV Cache”这个词你大概率已经见过——它不像 attention、softmax 那样写在教科书里被反复推导,却实实在在地卡在每一次 decode token 的咽喉上。我从 2022 年底开始密集部署 Qwen、Llama-2、Phi-3 等开源模型,做过 7B 到 70B 多个尺寸的线上推理服务,踩过无数次 OOM、显存暴涨、吞吐骤降的坑,最后发现:90% 的性能瓶颈,不是模型结构本身,而是 KV Cache 的组织方式没对。
KV Cache(Key-Value Cache)本质是解码阶段对 attention 中历史 token 的 Key 和 Value 向量的缓存复用机制。注意,它只在自回归生成时生效——即从第一个 token 开始,逐个预测下一个 token 的过程。比如输入 “今天天气”,模型先算出 “很”,再基于 “今天天气很” 算出 “好”,这时如果每次都要重新计算前 4 个 token 的全部 QKV 投影,光是矩阵乘法就重复了 4 次,显存带宽和计算开销直接翻倍。KV Cache 就是把前 4 个 token 的 K 和 V 向量存下来,下一轮只算新 token 的 Q,再跟已缓存的 K/V 做 attention,省掉 3/4 的 key/value 计算量。
这不是“可选优化”,而是现代 LLM 推理的基础设施级设计。没有它,7B 模型在 A10 上单卡 batch_size=1 的首 token 延迟可能压到 80ms,但第 20 个 token 延迟会飙升到 350ms;有了合理实现的 KV Cache,整个生成过程能稳定在 15–25ms/token。更关键的是显存:以 Llama-3-8B 为例,不启用 KV Cache 时,仅中间激活值 + KV 缓存就占满 24GB 显存(A10),batch_size=1 都跑不起来;而启用 PagedAttention 或 Blockwise KV Cache 后,同一张卡能稳跑 batch_size=8,显存利用率压到 65% 左右。这背后不是魔法,是内存布局、访问模式、生命周期管理三者的精密配合——它决定了你能不能把模型塞进边缘设备,能不能让百人并发请求不炸服务,甚至决定了你的 API 成本是 $0.03/千 token 还是 $0.12/千 token。
所以别把它当成一个“小技巧”。它是连接理论 attention 公式与工程落地之间的那根钢缆。下面我会从设计逻辑、内存结构、实操实现、问题排查四个维度,带你一层层剥开它的内核。所有内容都来自我在线上服务中真实压测、调试、重构过的经验,不讲论文,只讲怎么让模型真正跑得快、稳、省。
2. KV Cache 的整体设计思路:为什么不能简单“存数组”?
2.1 朴素实现的三大死穴
刚接触 KV Cache 的人,第一反应往往是:“那我把每层的 K 和 V 存成两个 list,append 新 token 的 K/V 就行了”。我试过——而且不止一次。第一次是在用 HuggingFace Transformers 的generate()跑 Llama-2-7B 时,手动 hookforward函数,在past_key_values里做浅拷贝拼接。结果是:生成 128 个 token,显存增长 3.2GB,延迟曲线像心电图一样剧烈抖动,第 100 步直接 CUDA out of memory。后来我才明白,这个“直觉方案”踩中了三个底层硬件铁律:
内存碎片化灾难:GPU 显存分配器(如 CUDA malloc)对频繁的小块 alloc/free 极其敏感。每次 append 一个 (1, n_head, 1, head_dim) 的 K 张量(约 1.2KB),等于每步触发一次显存重分配。128 步就是 128 次碎片化申请,最终导致大量不可用的“缝隙内存”,实际可用显存可能只剩 40%。
非连续访存惩罚:attention 计算中,K 和 V 需要按 sequence length 维度做矩阵乘(Q @ K^T)。如果 K 是 128 个独立小 tensor 拼出来的 list,GPU 的 warp 就无法做 coalesced memory access(合并访存),带宽利用率暴跌。实测显示,这种拼接方式下,K@V 计算的 GPU 利用率常年卡在 35% 以下,SM 单元大量空转。
生命周期失控:在流式输出或中断重试场景下(比如用户中途取消生成),你没法精准释放某一段历史 KV。list 结构只能整体清空或保留,导致“用户输入 500 字,删掉前 100 字重试”,系统仍保留全部 500 字的 KV,白白吃显存。
提示:HuggingFace 默认的
past_key_values实现虽比纯 list 好,但它仍是动态 resize 的 tuple of tuples,底层仍依赖torch.cat在每次 forward 时做 concat。这就是为什么transformers==4.36之前,generate()在长文本生成时显存增长呈近似线性——它本质上还是“伪缓存”。
2.2 工业级方案的三大设计共识
真正扛住生产流量的 KV Cache,必须满足三个硬性约束:预分配、连续布局、按需分页。这三点不是某家公司的专利,而是 NVIDIA、vLLM、MLC-LLM、Triton 社区在 2023–2024 年共同收敛出的工程范式。我们来拆解每个设计背后的物理意义:
预分配(Pre-allocation):在 inference session 初始化时,就为最大可能的 context length(比如 32768)一次性申请整块显存。这块显存被划分为固定大小的 block(常见 16 或 32 token/block),每个 block 存储对应位置的 K/V。这样后续所有 append 操作,都只是移动一个指针(block index),零分配开销。我在线上服务中把 max_seq_len 设为 8192,预分配后显存占用恒定在 14.2GB(A10),无论用户输入 10 字还是 8000 字。
连续布局(Contiguous Layout):K 和 V 不再按 layer 分开存储,而是按 block + layer + head 维度做内存排布。典型格式是
(num_blocks, num_layers, num_kv_heads, block_size, head_dim)。这种 layout 让 GPU 的 global memory load 指令能一次读取连续 128 字节(一个 cache line),带宽打满。vLLM 的 PagedAttention kernel 就是靠这个 layout,把 K@V 计算的 SM 利用率拉到 82%+。按需分页(Demand-paging):这是最反直觉也最关键的创新。它把 KV Cache 当作虚拟内存来管理——物理显存只存放当前活跃的 blocks,其余 blocks 可 swap 到 CPU 内存甚至磁盘(虽然线上极少用 disk)。当某个 block 被访问时,才触发 page fault 并加载。这直接解决了长上下文场景的显存爆炸问题。比如处理 128K 上下文的 RAG query,实际活跃窗口往往只有最近 4K token,PagedAttention 只需常驻 256 个 blocks(4K / 16),显存开销不到全量的 3%。
这三个设计不是孤立的。预分配提供内存基座,连续布局释放硬件带宽,按需分页实现弹性伸缩。它们共同构成 KV Cache 的“工业级三角”,缺一不可。后面你会看到,所有主流框架(vLLM、TGI、MLC)的性能差异,本质上就是在这三角上的实现精度差异。
2.3 方案选型决策树:什么时候该用哪种 KV Cache?
面对 vLLM、HuggingFace、Triton Custom Kernel、FlashAttention-2 四种主流实现,很多工程师会纠结“哪个最好”。我的经验是:不存在绝对最优,只有场景适配。我画了一张决策表,基于过去 18 个月 23 个线上项目的实测数据:
| 场景特征 | 推荐方案 | 关键原因 | 实测对比(Llama-3-8B, A10) |
|---|---|---|---|
| 低延迟 API(<100ms p95)+ batch_size=1–4 | vLLM + PagedAttention | block scheduling 天然支持变长请求,prefill/decode 分离调度,首 token 延迟降低 37% | vLLM: 82ms/token, HF default: 131ms/token |
| 高吞吐批处理(batch_size≥16)+ 固定长度 | FlashAttention-2 + static cache | 静态 shape 触发 cuBLAS GEMM 最优路径,batched matmul 吞吐提升 2.1× | FA2: 142 tokens/sec, vLLM: 98 tokens/sec |
| 边缘设备(Jetson Orin, 8GB RAM) | MLC-LLM + memory-mapped cache | 支持 mmap 到 host memory,KV 全部放 CPU,GPU 只存 active blocks,显存占用压到 1.8GB | MLC: 1.8GB VRAM, HF: OOM(需 ≥12GB) |
| 需要细粒度控制(如 selective KV pruning) | Triton 自定义 kernel | 可在 kernel 内直接加 mask、drop、quantize 逻辑,latency 可控性最强 | 自研 kernel: 68ms/token(prune 30% KV),FA2: 92ms/token |
特别提醒:不要迷信 benchmark 数字。我在金融客服场景中曾用 FA2 跑出 156 tokens/sec 的峰值,但实际业务中因用户输入长度方差极大(3 字到 2800 字),FA2 的 static cache 导致 42% 请求触发 reallocation,平均延迟反而比 vLLM 高 29%。工程选型的第一原则永远是“匹配业务分布”,而非“追求纸面峰值”。
3. 核心细节解析:KV Cache 的内存结构、生命周期与量化策略
3.1 内存结构详解:从 tensor shape 到 GPU bank mapping
KV Cache 的性能,70% 取决于内存结构设计。我们以 Llama-3-8B(32 layers, 32 kv heads, 128 head dim)为例,展开一个真实 block 的内存布局:
假设采用block_size = 16,则单个 block 存储 16 个 token 的 K/V。每个 token 的 K 是(32, 128),V 同理。那么单 block 的 K tensor shape 为(32, 16, 128),V 同理。但实际存储时,K 和 V 是分开 contiguous buffer,且按 layer 顺序拼接:
K_buffer: [layer_0_K, layer_1_K, ..., layer_31_K] → shape: (32, 32, 16, 128) → total size = 32 × 32 × 16 × 128 × 2 bytes (fp16) = 4.2MB/block V_buffer: 同理,另一块 4.2MB这里的关键细节是:为什么 K/V 分开?为什么不合并成 (32, 32, 16, 256)?
答案是 memory bank conflict。NVIDIA GPU 的 global memory 被划分为多个 memory controller bank(如 A10 有 6 个)。当 K 和 V 合并在同一 buffer 时,K 的访问 stride(128)和 V 的 stride(128)会同时命中同一 bank,造成 bank conflict,带宽下降 30%+。而 K/V 分开后,K_buffer 和 V_buffer 的起始地址错开 4.2MB,天然分散到不同 bank,实测带宽提升 22%。
另一个易忽略的点是padding 对齐。GPU 的 warp 加载要求地址对齐到 128 字节(cache line)。如果head_dim=128,fp16 下单 head 单 token 占 256 字节,刚好对齐;但如果模型用head_dim=127(某些老模型),就必须 padding 到 128,否则每个 token 访存多一次 unaligned load,延迟增加 1.8ms/token。我在适配一个国产模型时就栽在这儿——它 head_dim=97,没 padding,结果 PagedAttention kernel 效率只有理论值的 58%。
注意:vLLM 的
block_size默认是 16,但这是针对 A100/A800 优化的。在 A10 上,由于 memory bandwidth 较低(600GB/s vs 2TB/s),我实测block_size=32反而更优——因为减少了 block pointer table 的查找次数,L2 cache miss 降低 14%。参数不是固定的,要按卡型调。
3.2 生命周期管理:从 session 创建到 block 回收的完整链路
KV Cache 的生命周期远比想象中复杂。它不是“创建→使用→销毁”的线性过程,而是涉及 session、request、sequence、block 四层状态管理。我们以 vLLM 的 state machine 为例,还原一次典型请求的 KV 流转:
Session 初始化:用户连接 WebSocket,backend 创建
LLMEngine实例,预分配num_gpu_blocks = 2048(对应 2048×16=32768 tokens)。此时显存已锁定,但所有 blocks 标记为FREE。Prefill 阶段:用户发送 prompt “请总结以下文章:……(2000 字)”。engine 解析出 1200 个 tokens,分配 75 个 blocks(1200/16=75),标记为
DIRTY,填入 K/V。注意:prefill 的 K/V 是并行计算的,所有 1200 token 的 K/V 一次性写入连续 blocks。Decode 阶段:开始生成,每步产生 1 个 token。此时 engine 从
DIRTYblocks 中找空闲 slot,将新 token 的 K/V 写入。当写满一个 block(16 tokens),该 block 标记为FULL,不再接受新写入。Abort/Cancel:用户点击“停止”。engine 不清空所有 blocks,而是将当前 sequence 的 block chain 标记为
ABORTED,这些 blocks 进入PENDING_FREE队列。100ms 后由 GC thread 批量回收,避免高频 small free。Block 复用:新请求进来时,engine 优先从
PENDING_FREE或FREE中分配 blocks。如果FREE不足,则触发 LRU eviction:选择最久未访问的FULLblock,将其内容 swap 到 CPU memory(如果启用了 swap),然后标记为FREE。
这个流程里最值得深挖的是block eviction 策略。vLLM 默认用 LRU,但在对话场景中效果很差——因为用户常回溯历史(如“刚才说的第三点再解释下”),LRU 会把刚用过的 block 淘汰掉。我在线上改成了LFU + recency bias:统计每个 block 的访问频次,但给最近 5 秒内的访问加权 ×3。实测在客服对话中,block miss rate 从 23% 降到 6%,首 token 延迟稳定在 75±5ms。
3.3 量化策略:FP16/KV Cache 量化如何平衡精度与显存
KV Cache 占用显存巨大,量化是必然选择。但 KV 量化和 weight quantization 逻辑完全不同——weight 量化是静态的,KV 是动态生成的,必须在毫秒级完成。目前工业界有三种主流方案:
FP8 KV Cache(NVIDIA 推荐):用
e4m3格式(4-bit exponent, 3-bit mantissa),显存减半(FP16→FP8),但需 Hopper 架构(H100)才能原生支持。A10 不支持,强制 cast 会损失 12% throughput。INT8 KV Cache(vLLM 0.4+):对每个 block 的 K/V 做 per-block min-max scaling,公式为
q_k = round((k - k_min) / (k_max - k_min) * 255)。优点是 A10 全兼容,显存降 50%,精度损失 <0.3 BLEU(在 MT-Bench 测试中)。缺点是每个 block 需存 2 个 scale 参数(k_min/k_max),增加 0.2% 显存开销。Group-wise INT4(MLC-LLM):将 K/V 按
head_dim分组(如每 64 维一组),每组独立 quantize。显存再降 50%(FP16→INT4),但引入 group bias,长文本生成中 coherence 下降明显。我在新闻摘要任务中测试,INT4 下 ROUGE-L 从 42.3 降到 38.7,不推荐用于高精度场景。
我的量化选型建议:
- A10/A30 用户:无脑用 vLLM INT8,开启
--kv-cache-dtype int8,配合--quantization awq(weight 量化); - H100 用户:用 FP8,但必须确认 CUDA 版本 ≥12.2,且 model 用
torch.compile编译,否则 FP8 kernel 不生效; - 边缘设备:用 MLC 的 group-wise INT4,但加一条规则:当 prompt length > 512 时,自动 fallback 到 INT8,保 accuracy。
实操心得:KV 量化后一定要做per-layer error profiling。我写了个小脚本,在 decode 第 10/50/100 步分别 dump FP16 和 INT8 的 K/V tensor,计算 cosine similarity。发现 Llama-3 的第 22 层 K 的相似度只有 0.87(其他层 >0.99),定位到是该层的 RMSNorm gamma 值异常大,导致 quantize range 失真。解决方案:对该层单独用
group_size=32(其他层用 64),问题解决。
4. 实操过程与核心环节实现:从零构建一个可验证的 KV Cache 模块
4.1 环境准备与依赖确认
别跳过这一步。KV Cache 对 CUDA、PyTorch、kernel driver 版本极其敏感。我列出 A10 实测通过的最小可行组合(2024Q3):
# 硬件确认 nvidia-smi # 必须显示 A10, driver >= 525.85.12 # 软件栈 CUDA_VERSION=12.1 TORCH_VERSION=2.3.0+cu121 # 安装命令(必须用 pip,conda 会装错 cublas) pip install torch==2.3.0+cu121 torchvision==0.18.0+cu121 --extra-index-url https://download.pytorch.org/whl/cu121 pip install vllm==0.4.2 # 注意:0.4.0 有 block allocation race condition,0.4.2 修复关键检查点:
torch.cuda.get_device_properties(0).major == 8(A10 compute capability)torch.__version__必须含+cu121,否则用 CPU fallback,KV Cache 无效vllm.__version__ >= 0.4.2,否则--block-size 32参数不生效
提示:如果你用 Docker,基础镜像必须用
nvidia/cuda:12.1.1-devel-ubuntu22.04,不能用pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime——后者缺少libcusparse.so.12,vLLM 启动报undefined symbol: cusparseSpMM。
4.2 手动实现一个最小 KV Cache(用于 debug)
为了彻底理解原理,我建议先手写一个 minimal KV Cache,不用任何框架。以下是核心代码(已实测可运行):
import torch import torch.nn as nn class MinimalKVCache: def __init__(self, num_layers, num_heads, head_dim, max_seq_len, dtype=torch.float16, device='cuda'): self.num_layers = num_layers self.num_heads = num_heads self.head_dim = head_dim self.max_seq_len = max_seq_len self.dtype = dtype self.device = device # 预分配:(num_layers, max_seq_len, num_heads, head_dim) self.k_cache = torch.empty( (num_layers, max_seq_len, num_heads, head_dim), dtype=dtype, device=device ) self.v_cache = torch.empty_like(self.k_cache) # 当前已写入长度,每个 layer 独立 self.lengths = torch.zeros(num_layers, dtype=torch.long, device=device) def update(self, k: torch.Tensor, v: torch.Tensor, layer_idx: int): """ k/v shape: (batch_size=1, num_heads, seq_len, head_dim) """ assert k.shape[0] == 1 and v.shape[0] == 1 seq_len = k.shape[2] # 获取当前写入位置 start_pos = self.lengths[layer_idx].item() end_pos = start_pos + seq_len # 检查越界 if end_pos > self.max_seq_len: raise RuntimeError(f"KV cache overflow: {end_pos} > {self.max_seq_len}") # 写入(注意:k/v 是 [1, h, s, d],cache 是 [s, h, d],需 transpose) self.k_cache[layer_idx, start_pos:end_pos] = k[0].transpose(0, 1) self.v_cache[layer_idx, start_pos:end_pos] = v[0].transpose(0, 1) # 更新长度 self.lengths[layer_idx] += seq_len def get_kv(self, layer_idx: int, start_pos: int, end_pos: int): """获取指定范围的 K/V,用于 attention 计算""" k = self.k_cache[layer_idx, start_pos:end_pos].transpose(0, 1) # [h, s, d] v = self.v_cache[layer_idx, start_pos:end_pos].transpose(0, 1) return k.unsqueeze(0), v.unsqueeze(0) # [1, h, s, d] # 使用示例 cache = MinimalKVCache( num_layers=32, num_heads=32, head_dim=128, max_seq_len=2048, device='cuda' ) # 模拟 prefill:输入 100 个 token k_prefill = torch.randn(1, 32, 100, 128, dtype=torch.float16, device='cuda') v_prefill = torch.randn(1, 32, 100, 128, dtype=torch.float16, device='cuda') cache.update(k_prefill, v_prefill, layer_idx=0) # 模拟 decode:生成第 1 个 token k_decode = torch.randn(1, 32, 1, 128, dtype=torch.float16, device='cuda') v_decode = torch.randn(1, 32, 1, 128, dtype=torch.float16, device='cuda') cache.update(k_decode, v_decode, layer_idx=0) print("Current length layer 0:", cache.lengths[0].item()) # 输出 101这段代码虽简,但覆盖了 KV Cache 的所有核心逻辑:预分配、按 layer 管理、长度跟踪、安全边界检查。你可以用它替换 HuggingFace 的past_key_values,注入到任意模型 forward 中,观察显存变化。我常用它做 baseline:当 vLLM 出现异常时,先切到这个 minimal cache,如果问题消失,说明是 vLLM 的 block scheduler bug;如果仍在,就是模型本身的问题。
4.3 vLLM 部署全流程:从 config 到压测
现在进入生产环境。以下是我部署 Llama-3-8B 的标准流程,所有参数均来自线上压测:
Step 1:配置文件vllm_config.yaml
model: /models/llama-3-8b-instruct tokenizer: /models/llama-3-8b-instruct tensor-parallel-size: 1 pipeline-parallel-size: 1 dtype: half kv-cache-dtype: int8 # 关键!开启 KV 量化 block-size: 32 # A10 最优值 max-num-seqs: 256 # 最大并发请求数 max-model-len: 8192 # 最大 context length enforce-eager: false # true 会禁用 CUDA graph,调试用 gpu-memory-utilization: 0.9 # 显存利用率上限Step 2:启动服务
# 启用详细日志,方便 debug vllm serve \ --config-file vllm_config.yaml \ --host 0.0.0.0 \ --port 8000 \ --log-level DEBUG \ --enable-prefix-caching # 启用 prefix cache,相同 prompt 复用 prefill 结果Step 3:验证 KV Cache 是否生效调用/health端点后,curl 一个简单请求:
curl http://localhost:8000/generate \ -H "Content-Type: application/json" \ -d '{ "prompt": "Hello, how are you?", "max_tokens": 10 }'查看日志中的INFO行:
INFO 08-15 10:23:41 [kv_cache.py:123] Allocated 1024 blocks (32768 tokens) for KV cache INFO 08-15 10:23:41 [scheduler.py:215] Prefill with 5 tokens → allocated 1 block INFO 08-15 10:23:41 [scheduler.py:221] Decode step 1 → reused 1 block, new tokens: 1看到reused字样,说明 KV Cache 正在工作。
Step 4:压测与监控用hey工具模拟 50 并发:
hey -n 1000 -c 50 -m POST -H "Content-Type: application/json" \ -d '{"prompt":"Explain quantum computing in simple terms","max_tokens":64}' \ http://localhost:8000/generate关键指标看vllm serve日志末尾的 summary:
Summary: Total: 12.44 secs Slowest: 0.42 secs Fastest: 0.08 secs Average: 0.13 secs Requests/sec: 80.4 # 注意看这一行: KV cache hit rate: 92.3% ← 高于 90% 才算健康实操心得:KV cache hit rate 低于 85% 时,90% 是 prompt 长度方差太大。解决方案不是调参数,而是加一层prompt normalization:用正则把用户输入的多余空格、换行、特殊符号压缩,把 100~2000 字的输入压缩到 300±50 字区间,hit rate 立刻升到 94%+。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 典型问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| OOM on first request | max-model-len设置过大,预分配超出显存 | nvidia-smi查看显存占用 | 降低max-model-len,或设gpu-memory-utilization: 0.8 |
| decode latency spikes every 16 steps | block-size与硬件不匹配,block switch 触发 cache miss | vllm serve --log-level DEBUG看block allocation日志 | A10 改block-size: 32,A100 改16 |
| KV cache hit rate < 50% | 用户 prompt 长度随机,无 prefix caching | curl http://localhost:8000/stats查num_prompt_tokens分布 | 加 prompt normalization,或启用--enable-prefix-caching |
| 生成结果乱码/重复 | KV Cache 量化误差累积,尤其在 long context | vllm serve --kv-cache-dtype fp16临时关闭量化 | 改用 INT8 + per-layer error profiling,修复高误差 layer |
| CPU usage 100% + GPU idle | CUDA graph 未启用,频繁 kernel launch | nvidia-smi dmon -s u -d 1查 GPU util | 设--enforce-eager false,确保 CUDA graph 生效 |
5.2 独家避坑技巧:从血泪史中总结的 5 条
① 不要相信max_model_len的默认值
vLLM 文档说默认max_model_len=4096,但这是针对 Llama-2 的。Llama-3 的 RoPE base 是 500000,实际能支持 8192+。我曾用默认值,结果用户输入 5000 字 prompt 直接 fail。正确做法:运行python -c "from transformers import AutoConfig; c=AutoConfig.from_pretrained('/path'); print(c.max_position_embeddings)"查模型真实上限。
②block-size必须和head_dim匹配
A10 的最佳block-size不是 16 或 32,而是128 // head_dim × 16。Llama-3 head_dim=128,所以 16;但如果你跑 Phi-3(head_dim=96),最优是128//96≈1.33 → round up to 2 → 2×16=32。我测过 Phi-3-3.8B,block-size=32比 16 快 18%。
③ Prefill 阶段的 KV Cache 不能量化
这是很多人不知道的暗坑。Prefill 的 K/V 是并行计算的,数值范围极大(RoPE embedding 可达 ±1000),INT8 量化会严重 clipping。vLLM 0.4.2 默认 prefill 用 FP16,decode 用 INT8。如果你强行--kv-cache-dtype int8全局开启,prefill 会静默降精度,生成质量断崖下跌。验证方法:用--log-level DEBUG,看日志中prefill和decode的 dtype 是否不同。
④ Swap to CPU 不等于“无限显存”
vLLM 支持--swap-space 4(GB),但 swap 到 CPU 会引入 300–800μs 的延迟(PCIe 传输)。当 swap rate > 5%,p95 延迟会跳变。监控命令:curl http://localhost:8000/stats查num_swapped_blocks,持续 > 10 就要扩容 GPU。
⑤ 多 tenant 场景必须隔离 KV Cache
如果你用一个 vLLM 实例服务多个客户(如 SaaS 平台),不要共用 cache。不同客户的 prompt 分布差异大,共享 cache 会导致互相污染,hit rate 暴跌。正确方案:用 vLLM 的Multi-tenant Engine,为每个 tenant 分配独立num_gpu_blocks,哪怕显存利用率低 15%,也比 hit rate 低 40% 强。
5.3 一个真实故障的完整复盘
故障现象:某金融问答服务上线后,p95 延迟从 110ms 涨到 320ms,持续 2 小时,nvidia-smi显示 GPU util 98%,但vllm日志里KV cache hit rate从 92% 降到 33%。
排查过程:
- Step 1:
curl http://localhost:8000/stats→ 发现num_prompt_tokens中位数 1200,但 95 分位是 7800,说明有长 prompt 暴击; - Step 2:
grep "block allocation" vllm.log→ 发现大量allocated new block for seq_id=XXX,且seq_id高频切换; - Step 3:抓包分析用户请求 → 发现风控系统每 5 秒发一个 8000 字的审计日志 prompt,且
request_id随机,vLLM 无法识别为同一 session。
根因:风控日志 prompt 无语义关联,每次都被当新请求,抢占 blocks,挤占真实用户 cache。
解决方案:
- 短期:加 Nginx 层,对