
老规矩接着上一篇继续写。今天聊的是大模型推理加速系列里最硬核的一块KV Cache 和 vLLM。前几篇我们聊了量化、算子融合、批处理调度那些优化通常能带来 20%、50% 的提升但 KV Cache 和 vLLM 带来的往往是数量级上的差距尤其在并发场景下从“动不动就 OOM”到“能稳定撑着几十路请求”基本靠这一套东西。先别被名字唬住KV Cache 的本质并不复杂。Transformer 生成回答时是一个 token 一个 token 往外蹦的可如果你不做什么特殊处理每蹦一个 token 都得把之前生成过的所有内容重新算一遍。三四十层网络叠下来这个浪费会被放大到非常离谱。KV Cache 就是把这个“重复计算”砍掉的关键而 vLLM 则是把它做到极致的工程化框架。下面我就从最底层的计算过程开始拆顺便把部署时那些容易踩的坑也一并讲清楚。1. KV Cache先搞懂一个 token 到底是怎么算出来的1.1 预填充与逐 token 生成两个阶段的不对称大模型服务端的常规生成流程可以用两个阶段来概括。第一个阶段叫预填充也就是 prefill。用户把 prompt 发过来之后服务端会把这个 prompt 里的所有 token 一次性喂进网络所有位置并行做一次完整的前向计算然后生成第一个输出 token。这里的关键词是“并行”整段 prompt 在这一个阶段里只算一遍。第二个阶段叫 decode也就是逐 token 生成阶段。从第一个 token 开始每生成一个新 token都要把新位置对应的向量作为 query去和所有历史 token 的 key、value 做注意力计算。这个阶段无法并行一次只能推一个位置所以整个生成过程的时间基本都被 decode 占满。这两个阶段的不对称是 KV Cache 存在的根本原因。prefill 阶段虽然计算量大但并行度高一次性就完成了。decode 阶段看着单次计算量小但它是串行的要跑几百次甚至上千次而且每一次都要去重新“翻阅”历史信息。如果不能高效地保存和复用历史信息decode 就会成为整个生成流程的无底洞。1.2 没有 KV Cache 时重复计算有多夸张我最早刚接触大模型推理时以为生成过程就是每一轮把“完整的前文 最新 token”重新送进网络其实朴素实现还真是这样。每生成一个新 token就把之前生成的所有 token 连同新 token 一起从第一层 Transformer 开始重新跑一遍前向计算。听起来好像没什么但仔细一想就会发现问题假设你已经生成了 1000 个 token现在要生成第 1001 个那你得把前 1000 个 token 的 embedding 全部重新送入网络重新计算它们的 key、value 和中间激活值。也就是说生成第 i 个 token 时重复计算量正比于 i而整个序列生成下来的总计算量就是 1 2 3 ... N也就是 N 的平方级别。举个直观的数字假设一个模型单步前向计算需要 0.5 毫秒生成 1000 个 token朴素做法需要的总时间大概是所有历史位置重新计算的时间之和——按照平方级别估算会达到几十秒甚至分钟级。这还没算并发真实服务端根本不可能这么干。所以 KV Cache 的思路非常直接在 prefill 阶段既然已经把每个 token 的 key 和 value 算出来了那就不丢存起来。decode 阶段每生成一个新 token只需要计算当前新位置的 key 和 value然后 append 到缓存里同时也只需要把当前新 token 的 query 和缓存里的所有 key、value 做注意力计算。重复计算被彻底消除decode 的单步开销不再随序列长度线性增长。1.3 KV Cache 的工作方式与显存计算KV Cache 的具体实现本质上就是在每一层 Transformer 里维护两个大的缓存张量一个存 key一个存 value。每生成一个新 token先将新位置的 key 和 value 计算出来然后拼接到对应的缓存矩阵末尾再用拼接后的 key、value 去计算当前的 attention 输出。我画过一张很粗糙的伪代码来理解这件事# 伪代码只展示结构不是真实实现 class DecoderLayerWithKV: def __init__(self): self.k_cache None # key 缓存 self.v_cache None # value 缓存 def forward(self, new_query, new_key, new_value, mask): # 新 token只算当前位置的 k, v if self.k_cache is None: self.k_cache new_key self.v_cache new_value else: self.k_cache torch.cat([self.k_cache, new_key], dimseq) self.v_cache torch.cat([self.v_cache, new_value], dimseq) # 注意力query 与拼接后的全部 key 做计算 scores torch.matmul(new_query, self.k_cache.transpose(-1, -2)) scores scores / scale mask weights softmax(scores) output torch.matmul(weights, self.v_cache) return output说到 KV Cache很多人第一反应是“既然能加速那就把缓存开大点”。但缓存不是免费的它消耗的是显存。KV Cache 的占用有一个很简单的公式[ \text{KV Cache 显存} \text{层数} \times \text{序列长度} \times \text{隐藏层维度} \times 2 \times 2 \times \text{batch 数} ]最后的两个 2一个代表 key 和 value 两份缓存另一个代表用 FP16 存储时每个元素占 2 字节。以我常用的一个 7B 规模模型为例假设是 40 层 Transformer隐藏维度 4096batch 为 8上下文长度为 2048。每生成一个 tokenKV Cache 就要新增40 层 × 2 份 × 4096 维 × 2 字节也就是约 0.65MB。2048 个 token 算下来单条请求约 1.34GB8 条并发请求就是 10.7GB。注意这还没把模型权重和中间激活值算进去。换句话说只要并发数和上下文长度一上来KV Cache 就是当之无愧的显存第一杀手。2. 显存管理是 KV Cache 能不能落地的那道坎2.1 缓存碎片化连续分配带来的浪费看到 KV Cache 能省下大量重复计算之后很多人会想那我就把所有请求的 KV Cache 都存在显存里不就行了。但问题没有那么简单因为 KV Cache 是动态增长的——每生成一个 token就需要重新分配一小块显存来存放新增的 key 和 value。如果按照朴素思路给每个序列分配一整块连续显存随着序列不断生成这块区域需要不断拓展。多个长短不一的请求并发时就会产生非常典型的外部碎片显存整体剩余空间可能还很多但每一块都不连续新请求或者已有请求的新增 token 找不到足够大的连续空闲区域。这跟搬家具时遇到的情况一模一样房间明明还有很多零散空间但想放下一面墙的柜子愣是找不到完整的角落。小碎块累积到一定程度最终往往会触发“CUDA out of memory”即使你查看显存占用时发现总利用率并没有满。很多早期推理框架性能上不去很大一部分原因不是算力不够而是显存碎片把并发数卡死了。2.2 预分配策略要么浪费要么爆显存既然动态分配容易碎片化那就换一种思路预先给每个请求分配最大长度的 KV Cache。比如服务端约定最大上下文 2048那我就在请求进来的时候直接为它预留 2048 个 token 的缓存空间。这种做法在工程上实现很简单显存分配是连续的不至于碎片化但代价同样致命大多数真实请求根本用不到 2048 的长度。还是上面那个例子每 token 的 KV Cache 约 0.65MB10 个并发请求全按 2048 上下文预留要占 13.4GB。如果这 10 个请求实际平均只用了 512 token真正用掉的只有大约 3.3GB剩余的 10GB 全被空占。这就是内部碎片明明分配了却始终没用上。预分配策略导致两个直接后果。一是能扛的并发数量被极大压缩因为每个请求都要按最大长度记账二是如果你想支持更长的上下文比如 8K 甚至 32K预留的显存会成倍增加很快就把整张卡吃光了。到头来你会发现KV Cache 能不能真正提升推理速度已经不取决于缓存算法本身而取决于显存管理策略够不够聪明。2.3 为什么说吞吐量瓶颈在 KV Cache 而不在算力还有个常见的误解推理慢是不是 GPU 算力不够其实在高并发、长上下文场景下真正的瓶颈往往不是浮点计算能力而是显存容量和显存带宽。每做一次 decode模型要从显存里把历史 token 对应的 KV 矩阵搬出来做注意力计算。序列越长、并发越高KV 矩阵搬运的数据量就越大GPU 的计算单元反而大量时间在等数据。这也是为什么 KV Cache 的显存利用率直接决定服务端能同时撑起多少路请求。显存利用率上不去算力再强也只能干瞪眼。所以接下来要讲的 vLLM本质上是在回答一个问题能不能像操作系统管理内存那样把 KV Cache 的显存也管得又紧凑又高效。3. vLLM 核心原理拆解PagedAttention 与 Continuous Batching3.1 PagedAttention把操作系统内存管理搬到 GPUvLLM 最有代表性的设计是 PagedAttention。它借鉴的是操作系统里虚拟内存的分页思想。我们平时看教程喜欢用“把显存当内存管理”这样的类比我觉得更准确的说法是它把 KV Cache 切成了固定大小的物理块让“逻辑上连续”的缓存可以存储在“物理上零散”的显存块里。具体做法是设定一个固定大小的 KV block默认通常是 16 个 token 一个块。当请求进入时vLLM 不按最大长度一次性分配完整缓存而是按需分配 block。每个序列维护一张块表记录它当前的逻辑块和实际显存物理块的映射关系。新 token 生成时如果当前物理块满了再申请一个新的物理块追加到块表末尾。这个设计一下子就解决了我上面说的外部碎片问题。因为 block 是固定大小、可以分散存储的任何请求的新增缓存只需要找任意一个空闲块即可不需要一整块连续区域。在 vLLM 的实测数据里KV Cache 的显存利用率能到 90% 以上而朴素预分配方案往往是 30% 到 50%差距非常明显。3.2 块表映射逻辑连续物理零散理解 PagedAttention核心就是抓住“逻辑”和“物理”两个概念。从模型的角度看每个序列的 KV Cache 永远是连续的第 0 到第 15 个 token 在第 0 个逻辑块第 16 到第 31 个 token 在第 1 个逻辑块依此类推。但实际上第 0 个逻辑块可能落在显存物理地址 A第 1 个逻辑块可能落在物理地址 C甚至中途还会插入别的请求的块。block table 就是用来记录这种映射关系的一张表相当于操作系统的页表。每次做注意力计算时模型通过块表把逻辑块索引翻译成实际的物理块地址跳过那些不属于当前序列的显存区域只读取当前请求需要的块。这就保证了显存分配可以非常自由缓存扩容时不再需要搬移大量数据。我在自己的部署环境里测试过8 并发、2K 上下文的模型未优化前经常在运行十几分钟后出现显存分配失败。改成 PagedAttention 之后同样的请求量下显存峰值明显更平缓也不再出现“明明有显存却分配不出来”的情况。3.3 前缀共享与 Copy-on-Write 的妙用PagedAttention 还有一个不那么起眼但很实用的优化前缀共享。现在很多应用都会在 prompt 前面拼一段很长的 system prompt比如角色设定、工具说明、上下文注入。如果 10 个并发请求共享同一个 system prompt那么这段长长的前缀 KV Cache 其实是完全相同的。在朴素 KV Cache 实现里每个请求都要重新计算一遍并单独存一份。vLLM 的做法是让这些请求的块表直接指向同一组物理块前缀部分只存一份所有请求共用。只有当前缀后面开始出现不同内容时才各自分配新的物理块并利用 copy-on-write 机制处理需要修改的场景。这个特性在多轮对话和 RAG 场景里收益特别大。我自己做过一个简单的对比给同一段 1500 token 的 system prompt 发 8 个请求开启前缀共享后预填充阶段的计算时间几乎只和 1 个请求相当KV Cache 占用的显存也明显下降。当然前提是服务端能正确识别并匹配公共前缀实际使用时要留意 vLLM 对 prompt 前缀的缓存命中效果。3.4 Continuous Batching在 token 粒度上做动态调度PagedAttention 解决的是“缓存放不放得下”的问题而 Continuous Batching 解决的是“GPU 忙不忙得起来”的问题。传统的批处理方式是静态批处理等一批请求凑够了一起送进 GPU等这一整批全部生成完毕再释放资源接下一批。这里有个很尴尬的时间差一个 batch 里有的请求可能 30 个 token 就结束了有的要生成 600 个 token但静态批处理必须等最慢的那个完事其余早已完成的请求只能干等着占坑。Continuous Batching 的思路则是在每个 decode 步骤做调度每一步前检查当前 batch 里哪些请求已经结束把它们立即移出同时把等待队列里新到的请求加进来。这样 GPU 每一轮都在为“仍然活跃”的请求做计算不会出现大量空闲等待。配合 PagedAttention新请求加进来时可以马上分配物理块已结束请求占用的块立刻释放整个流水线一直是满的。在流量波动明显的生产环境里这带来的吞吐提升非常直观。我见过一个模型服务静态批处理下吞吐量大概在几百 tokens/s切到 Continuous Batching 之后直接跳了一个数量级而且响应延迟分布更均匀不再出现“前十个请求快后面排队等半天”的现象。vLLM 之外还有投机采样这类进阶优化比如用小模型先草拟多个 token再用大模型一次验证但这需要额外部署一个草稿模型配置复杂一些。我个人的建议是先把 PagedAttention 和 Continuous Batching 吃透这两件事带来的收益最稳、最直接。4. 实操把 vLLM 部署起来并调好关键参数4.1 环境与版本选型vLLM 的部署本身不难难的是环境和版本配对。它依赖特定版本的 CUDA、PyTorch 和 GPU 驱动一般建议直接用官方提供的容器镜像或者照着文档的版本对照表安装不要闭着眼睛装一个最新版 Python 就开干。GPU 硬件方面如果你是 Ampere 架构及以上也就是 30 系、40 系、A100、A800 这类可以完整享受 FlashAttention 带来的算子加速老一点的卡也能跑但 attention 算子可能会回退到慢速路径性能差别明显。我的建议是生产环境至少保证有 FP16 或者 BF16 支持并给足显存别用一张 8GB 的卡硬扛 13B 模型。4.2 关键启动参数逐个解释vLLM 启动时的参数看着多但决定 KV Cache 和并发行为的主要就这么几个--gpu-memory-utilizationKV Cache 最多能用多少比例的显存。默认值通常是 0.9意思是显存的 90% 可以用于缓存剩下留一部分给模型权重、激活值和 CUDA context。这个值不能设成 1.0否则会直接 OOM。--max-model-len最大上下文长度包括 prompt 和生成 token 的总和。这个值会直接影响 KV Cache 的预算设得越大能支撑的并发数就越少。--max-num-seqs最大并发序列数。它和gpu-memory-utilization、max-model-len共同决定了 KV Cache 够不够用。--block-size每个物理块装多少个 token默认 16。这个参数通常不需要改但如果你的长序列特别多可以试试 32减少块表的大小反过来短请求多可以调小一点减少内部碎片。--swap-space设置多少 CPU 内存用于缓存换出。这个参数很关键设得太大容易导致 KV Cache 被换到 CPU生成速度断崖式下降。--enforce-eager默认情况下 vLLM 会用 CUDA graph 优化如果你在做调试或者遇到奇怪的内存报错可以加上这个参数禁用方便定位问题。4.3 一个可以直接抄的启动配置下面是一套我实际部署时用过的参数跑的是一个 7B 量级的模型单卡 80GB 显存vllm serve /path/to/model \ --gpu-memory-utilization 0.90 \ --max-model-len 8192 \ --max-num-seqs 16 \ --block-size 16 \ --swap-space 4 \ --dtype bfloat16为什么这样设置我简单算一笔账。7B 模型 bf16 权重大概占 14GB。80GB 显存留 90% 给 KV Cache 和运行时也就是 72GB扣掉权重后还有约 58GB 可用。按照前面算的 0.65MB/token8K 上下文的一条请求需要约 5.2GB 缓存理论并发 11 个左右。我设置成 16 意味着请求可能被排队但不会轻易 OOM同时留了一些余量给激活值和其他中间张量。如果你用的是 40GB 左右的卡建议把max-num-seqs降到 4 到 8max-model-len降到 4096否则会非常紧张。这个预算公式比任何建议都管用照着算就行。4.4 代码接入从几行脚本到异步服务vLLM 除了命令行服务也提供 Python 接口。如果只是做离线批量推理可以直接用LLM类from vllm import LLM, SamplingParams llm LLM(model/path/to/model, gpu_memory_utilization0.9, max_model_len8192) params SamplingParams( temperature0.7, top_p0.9, max_tokens1024, ) outputs llm.generate([请介绍一下KV Cache的工作原理], params) print(outputs[0].outputs[0].text)如果要做在线服务我更推荐直接使用它提供的 OpenAI 兼容接口因为 vLLM 的动态批处理和 KV Cache 管理已经内置好了你不需要关心后端调度。调用方式也简单就是把地址指向 vLLM 启动的端口然后用标准的 chat completion 格式请求。有一点需要注意Python 接口和 serving 模式在某些版本里的参数行为不完全一样比如有些参数只在 serve 命令行里生效。上线前务必看清楚你用的版本支持哪些配置别在代码里写了一个实际上被忽略的参数排查起来很费劲。5. 效果评估从哪些指标看加速到底有没有生效5.1 三个必须盯住的指标TTFT、TPOT、吞吐很多刚接触推理加速的人只关心“每秒生成多少个 token”但实际评估一个服务端推理方案至少要看三个维度TTFT、TPOT 和端到端吞吐。TTFT 指首 token 时间也就是用户发出请求到看到第一个输出 token 的延迟。这个指标主要由 prefill 阶段决定。KV Cache 真正减少的是生成过程中的重复计算但对首 token 的加速更多来自算子优化和并行计算。TPOT 是每个生成 token 的平均时间这个和 decode 阶段紧密相关。KV Cache 的作用在这里体现得最充分没有缓存时 TPOT 会随序列长度变大有缓存后基本能保持平稳。吞吐量则是服务端整体每秒能输出的 token 数。vLLM 的 PagedAttention 和 Continuous Batching 主要是在拉高这个指标。前两个指标是单用户体验吞吐量则决定一个 GPU 能同时服务多少用户、单位成本能生产多少内容。5.2 不同方案的实测数据对比我整理过一张自己测试时的趋势对照表数字不是绝对值但能反映优化方向。测试条件是单卡 80GB7B 模型8 并发平均 2K 上下文方案单请求 TPOT相对8 并发吞吐量相对KV Cache 显存利用率朴素重复计算100基准10无缓存朴素预分配 KV Cache3545约 30%vLLM PagedAttention Continuous Batching30100约 90%可以看到单请求速度提升虽然有限但并发吞吐量是数量级差异这完全符合我前面说的KV Cache 的优化核心是解决多请求场景下的显存和调度瓶颈而不是单点推理时延。5.3 性能调优的顺序先 cache 优化再算子优化在实际调优模型服务时我建议按这个顺序来先解决 KV Cache 显存管理再考虑算子优化、量化、投机采样这些进阶手段。原因是显存管理决定你能不能让 GPU 跑起来算子优化决定跑得快不快。一个连并发都撑不住的框架算子再快也只能干瞪眼。具体操作时先用默认参数跑一个基准记录 TTFT、TPOT、吞吐、显存峰值。然后逐步增加并发数观察 KV Cache 占用率。如果显存利用率很高但吞吐没有随之提升再考虑 FlashAttention 这类算子优化如果显存很快就满了优先检查是不是max-num-seqs或者max-model-len设置不合理。这样一步步来比盲目调参要清晰得多。6. 常见问题与排查技巧实录6.1 显存 OOM常见原因与排查顺序vLLM 用久了最常见的还是 CUDA OOM但它出现的原因往往不在你第一眼看到的地方。我踩过的坑按出现频率排序大概是这样的第一gpu-memory-utilization设太高。有些教程推荐 0.95但如果你同时开了 CUDA graph 或者模型非常大0.95 很容易触发 OOM。解决方案是降到 0.85 到 0.9 之间。第二max-num-seqs超过 KV Cache 预算。尤其是在长上下文场景里并发数每提高一点KV Cache 占用就成倍上升。出现 OOM 时先把并发数砍半看是否缓解。第三max-model-len设置过长。很多人为了支持长文本把上下文设到 32K但 KV Cache 预算是按这个值计算的显存不够就会崩。如果你不需要那么长的输出把它控制在真实需求的 1.2 倍左右最稳妥。排查顺序我推荐反过来先看max-model-len再算 KV Cache 预算最后调max-num-seqs。这样不会盲目改参数。6.2 推理变慢但显存没满可能被 swap 了有一个很隐蔽的问题模型跑着跑着突然变慢但不是 GPU 算力不够而是 KV Cache 被换到 CPU 内存了。vLLM 的swap-space参数允许在显存不足时把部分 block 换出到 CPU但 CPU 到 GPU 之间的数据传输非常慢一旦频繁换入换出整个吞吐会断崖式下降。判断方法很简单观察日志里的 swap 相关字段如果出现大量 swap in/swap out就说明显存预算已经不够了系统在拿 CPU 内存应急。解决方式是降低max-num-seqs或max-model-len让 KV Cache 全部留在 GPU 里或者直接把swap-space设为 0 关闭换出迫使系统在并发上做更严格的限制。我在一次压测里遇到过这个问题8 并发时一切正常改成 16 并发后明明显存还有几个 G但总吞吐反而下降了一半查日志才发现 swap 成了瓶颈。把并发降回 12 后吞吐立刻恢复。6.3 长序列偶发崩溃block 管理配置需调整另一个值得注意的问题是长序列偶发崩溃报错通常和 block 分配有关。如果一条请求生成非常长的内容比如 8K 甚至 16K tokenPagedAttention 的块表会变得非常大长序列还容易占满整个 KV Cache导致新请求无法分配 block。遇到这种情况先确认max-model-len有没有真正覆盖需要的长度然后可以尝试调整block-size。我测试过对长序列为主的流量把 block-size 从 16 调到 32块表变小长序列的分配开销会低一些但代价是短请求更容易产生内部碎片所以要看你的流量特点来定。还有一点不要让max-num-seqs过大。长序列本身就吃掉大量 block如果允许的并发数太多后续请求会一直排队表现出来就是长序列正常、短请求卡住不动。把并发上限压到让每个序列都能拿到足够 block 的程度是这类问题最直接的解法。6.4 日志指标怎么看一张速查表vLLM 的日志里有很多字段刚开始很容易看花眼。我整理了一份自己常用到的速查表日志字段含义关注点GPU KV cache usageKV 缓存当前占用/最大可用接近 1 时说明显存预算紧张Throughput端到端吞吐量压测时最重要的总量指标Avg generation throughput生成阶段平均吞吐主要反映 decode 速度Swap in/out换入/换出 block 数量大于 0 时需要警惕性能下降Requests running/waiting正在执行/排队请求数连续长时间等待说明并发超预算我的习惯是压测时固定打点隔 10 秒记录一次这几个字段。如果 GPU KV cache usage 一直顶着上限但吞吐没有增长说明显存是瓶颈如果 waiting 数持续走高说明并发预算满了如果 swap 频繁出现得马上降并发。这套方法帮我快速定位了好几次线上抖动比看面板猜原因高效得多。最后说点个人体会。KV Cache 和 vLLM 这套东西本质是把“空间换时间”的思路和操作系统里的内存分页思想搬到了 GPU 推理场景工程上很成熟但细节确实多。我自己在第一次部署时以为调好几个参数就完事了结果压测一跑全是问题包括并发开太大 OOM、swap 拖慢速度、长序列排不上队都是一步步踩出来才摸清门路。所以建议别一上来就追投机采样、量化这些花活先把 KV Cache 的显存预算和 vLLM 的调度参数吃透。把这个基础打牢再往上加其他优化手段你会觉得一切都顺理成章。