“KV cache 是不是就是内存管理?上下文管理是不是就是 KV cache 的 GC 策略?”——这个直觉值得认真对待,因为它一半比你想的更对:vLLM 的 PagedAttention 论文发表在操作系统顶会 SOSP 上,它不是”像”虚拟内存,它就是把虚拟内存机制原样搬进了显存;前缀缓存里的
ref_cnt字段,就是教科书里的引用计数算法。但另一半需要换一个名词:上下文管理对应的不是 GC,而是 OS 工具箱里的另一件东西——页面置换,外加一件 OS 工具箱里根本没有的新东西。这篇把整个映射逐层对齐,最后给你一个三问判据,以后看到任何”回收机制”都能自己归类。
一句话主线:回收机制的形态由一个问题决定——“这块东西死了吗”能不能被证明。能证明死亡的,用精确回收(引用计数/GC);死不死只能预测的,用置换(LRU/重要性);死了但内容可以近似重建的,用有损压缩——推理栈从下到上恰好把三格填满,而第三格是 LLM 栈独有的发明。
0. 站内已有的拼图,和这篇补的那块
这个话题本站已经埋了三块拼图:《KV Cache 的显存账》算了”为什么装不下”;《vLLM 不是把外部模型包一层》和《vLLM 的请求是怎么跑完的》讲了 KVCacheManager 在架构里的位置;《Agent 的内存管理》在 agent 层点过 GC 根集的类比。这篇补的是缺的那块:把 OS 内存管理的完整概念栈——malloc、分页、页表、碎片、写时复制、引用计数、页面缓存、LRU、swap、GC——逐个对到推理栈上,并且精确标出哪里是同一个东西、哪里只是形似、哪里类比彻底失效。
先上全景对照表,后文逐行展开:
| 操作系统概念 | 推理栈对应物 | 对应程度 |
|---|---|---|
| 物理内存 / 页帧 | 显存里的 KV block(vLLM 默认 16 token 一块) | 同构搬运 |
| 虚拟地址连续、物理不连续 | 请求看到连续序列,块散落在显存各处 | 同构搬运 |
| 页表 | block table(逻辑块号 → 物理块号) | 同构搬运 |
| 内外碎片 | 预分配连续 KV 张量的浪费(PagedAttention 的动机) | 同构搬运 |
| fork + 写时复制 | 并行采样/beam search 共享前缀,分叉时按块复制 | 同构搬运 |
| 引用计数 GC | 前缀缓存块的 ref_cnt | 字面同一 |
| free 掉的内存被留作 page cache | freed 块带着哈希留在空闲队列里等复用 | 字面同一 |
| LRU 页面置换 | 空闲队列头部弹出 / RadixAttention 的 LRU 淘汰 | 同构搬运 |
| swap 换出到磁盘 | 被抢占请求的 KV 换出到 CPU 内存(或重算) | 同构搬运 |
| 追踪式 GC(标记-清除) | ——(没有对应物,原因见 §3) | 类比失效 |
| 页面置换 + 工作集 | 上下文淘汰(丢弃旧消息/工具输出) | 同构 |
| 内存压缩(zram,无损) | 上下文压缩(LLM 摘要,有损) | OS 没有的新格子 |
1. KV cache 层:不是类比,是搬运
1.1 问题同源:碎片
2023 年之前的推理系统怎么管 KV cache?为每个请求按最大长度预分配一段连续显存。这和早期操作系统按最大需求给进程划连续内存段是同一个设计,死法也一样:请求实际长度千差万别,预留的空间大量闲置(内部碎片);不同请求的预留段之间的空隙塞不进新请求(外部碎片)。PagedAttention 论文(SOSP ‘23)的出发点就是这笔账:KV cache 显存被碎片和冗余复制大量浪费,直接压死了 batch size——而 decode 是带宽受限的,batch size 上不去,吞吐就上不去。
1.2 解法同款:分页 + 页表
OS 的答案在 1960 年代就有了:把内存切成固定大小的页,进程看到的地址连续(虚拟),物理页随便散落,中间靠页表翻译。PagedAttention 原样照搬:KV cache 切成固定大小的 block(vLLM 默认 16 个 token 一块),请求看到的 token 序列逻辑连续,物理块在显存里随便放,中间靠 block table 翻译。论文自己说得毫不含糊:“inspired by the classical virtual memory and paging techniques in operating systems”。效果:近零浪费,吞吐相对 FasterTransformer/Orca 提升 2–4 倍。
所以”KV cache 对应内存管理”这半句判断,正确等级是最高档——推理引擎作者就是照着 OS 教科书写的,而且发在 OS 顶会上让 OS 研究者审的稿。
1.3 连 fork 都搬了:写时复制
OS 里 fork 一个进程,不真的复制内存,父子共享物理页,谁先写谁再复制(copy-on-write)。推理里的并行采样(一个 prompt 出 N 个候选)和 beam search 是同一个结构:多个分支共享同一段前缀的 KV,分叉点之后才各写各的。PagedAttention 在块粒度上实现了同款 CoW:共享块只读引用,某分支要写共享块时先复制出私有副本。一个 prompt 采样 8 个候选,prompt 部分的 KV 只存一份——这是”flexible sharing of KV cache within and across requests”在论文摘要里占一整句的原因。
1.4 swap 也搬了:抢占与换出
显存真的满了怎么办?OS 把不活跃的页换出到磁盘。vLLM 的调度器抢占低优先级请求时,同样有两条恢复路线:把它的 KV 块整体换出到 CPU 内存,或者干脆丢掉将来重算(论文的调度与抢占一节)。注意这个选择题本身就很”OS”:换出赌的是”恢复时 IO 比重算快”,重算赌的反过来——和 OS 里 swap vs 按需重读文件页的权衡一模一样。
2. 真正的”GC”在哪一层?——引擎层,而且是字面意义的
你问题的后半句是”上下文管理是不是对应 GC”。先说一个更有趣的事实:推理栈里确实有一层字面意义的 GC,但它不在上下文层,在引擎的前缀缓存层。
看 vLLM V1 前缀缓存的官方设计文档,核心数据结构 KVCacheBlock 长这样:
KVCacheBlock:
block_id # 物理块号,不变
block_hash # 块满时赋值(父块哈希 + 块内 token 链式哈希)
ref_cnt # 引用计数:有多少个在跑的请求正在用这块
prev_free_block / next_free_block # 空闲队列的双向链表指针
机制拆开,每一条都能在 OS/运行时教科书里找到原型:
ref_cnt就是引用计数——CPython 用它管对象、OS 内核用它管共享页。多个请求命中同一段前缀,同一物理块被共同引用,计数加一;请求结束,计数减一;归零才进空闲队列。这不是”像”引用计数,这就是引用计数。- freed ≠ dead:free 队列就是 page cache。设计文档里最有味道的细节:块被释放后带着 block_hash 进空闲队列,哈希表里的映射不删。下一个请求如果命中同样前缀,直接从空闲队列里把它捞回来复活(缓存命中,省掉整段 prefill)。只有当它被推到队列头、被弹出去装新数据时,哈希才被重置——那一刻才真正”死亡”。这和 Linux 把 free 掉的文件页留作 page cache、内存紧张才真正回收,是同一个设计:空闲内存不是浪费,是免费的缓存。
- LRU 淘汰:文档原话,释放的块按逆序加到队列尾部,“从队列头弹出的就是 LRU 块”。SGLang 的 RadixAttention(NeurIPS 2024)把这套做得更激进:所有请求的 KV 组织成一棵 radix tree,多级前缀共享 + LRU 淘汰 + 缓存感知调度,吞吐最高提升 6.4 倍——论文自己的定位就是”把 KV cache 当作传统 cache 来管理”。
但注意这一层”GC”的一个关键性质,它是下一节论证的铺垫:被淘汰的 KV 块可以无损重算(token 还在,重新 prefill 一遍就有了)。所以严格说,前缀缓存层管理的不是”堆”而是”缓存”——丢了不损失数据,只损失时间。引用计数在这里保证的是”正在被用的块绝不能被动”(正确性),LRU 决定的是”闲置的块谁先让位”(性能)。一层机制,两种约束,分工清清楚楚。
3. 上下文管理是 GC 吗?——结构同构,但灵魂不同
现在到你问题的核心。上下文管理(compaction、消息淘汰、工具输出掩码)看起来非常像 GC:有根集(系统提示词、用户意图——上下文淘汰那篇已经建立了这个框架),有回收策略,有固定预算。结构上的同构是真的。但有一条界线,把它和 GC 永远分在两边:
GC 的灵魂是”死亡可证明”。 垃圾回收器回收的是不可达对象——从根集出发沿引用图走,走不到的对象,数学上保证程序永远无法再访问它。所以 GC 有一条铁律:绝不回收还活着的对象,宁可漏收(内存泄漏)不可错收(悬垂指针、程序崩溃)。GC 是一个正确性机制,它的判断是二值的、可证明的。
上下文的”死亡”不可证明,只能预测。 一条 300 轮之前的消息,未来还会被注意力用到吗?没有任何引用图可以回答这个问题——模型的”引用”发生在未来的注意力计算里,而未来的查询还不存在。所以上下文管理者做的不是”证明这条消息死了”,而是”赌这条消息未来用不到”。赌错了的后果也不是崩溃,而是模型变笨一点(丢了个关键约束,答偏了)。判断是概率的、有损的、后果是软的。
这个描述在 OS 教科书里有精确的名字,但不是 GC——是页面置换(page replacement)和工作集(working set)理论:物理内存装不下所有页,靠 LRU/clock 等启发式预测哪些页近期不会被访问,把它们换出去;赌错了不崩溃,只是缺页中断变多、变慢。把两边并排,严丝合缝:
| 追踪式 GC | 页面置换 | 上下文淘汰 | |
|---|---|---|---|
| 判断依据 | 可达性(可证明) | 访问模式(预测) | 未来注意力(预测) |
| 错误后果 | 崩溃/悬垂指针(硬) | 缺页变慢(软) | 模型变笨(软) |
| 允许有损? | 绝不 | 无损(换出的页在磁盘上) | 可以有损 |
| 机制性格 | 保守、精确 | 激进、启发式 | 激进、启发式、还带压缩 |
所以你的类比修正后是:上下文管理 = KV cache(以及消息历史)的页面置换策略,而不是 GC 策略。 真正的 GC(引用计数)在引擎层,管的是”哪些物理块正被活跃请求引用着,绝对不能动”——那才是有硬正确性约束的地方。
3.1 三个动词的 OS 对应,和那个 OS 里没有的格子
上下文淘汰那篇总结过 agent 层的三个动词,现在可以给它们精确的 OS 坐标了:
- 丢弃(Evict) = 页面置换。Claude Code 的 microcompact 掩码旧工具输出,赌的是”文件还在磁盘上,可重算”——注意这恰好和 §2 里 KV 块淘汰的性质相同(可无损重建),所以它是三个动词里最安全的,学术对照实验(The Complexity Trap)也证明它常常不输昂贵的摘要。
- 外置(Externalize) = swap out。把内容写到窗口外的文件,留个指针,要用再读回来。MemGPT 论文标题就叫 Towards LLMs as Operating Systems,明说这是分页思想。
- 压缩(Compress) = ——这里类比断了,而且断得很有信息量。OS 也压缩内存(Linux 的 zram、macOS 的内存压缩),但那是无损压缩:换回来的页和换出去的一比特不差,因为程序状态不容许近似。而 LLM 摘要是有损压缩:600 token 的调试过程变成”修复了 auth 模块的空指针”,细节永久蒸发。OS 的工具箱里没有这个格子,因为只有当内存的消费者是一个能容忍近似、能从残缺信息中重建语义的模型时,“有损回收”才是合法操作。 这是 LLM 栈对内存管理谱系真正的新贡献——不是分页,不是 LRU,而是”把回收和压缩合并成一个有损操作”。
4. 类比的尽头:两个反转
灰度处理,类比用到这里已经很好用了,但要知道它在哪里翻车,以及哪里被反过来用。
反转一:既然是分页,为什么不直接用硬件的分页? vAttention(ASPLOS 2025,微软)对 PagedAttention 发起了一次很”OS 原教旨”的批评:你在用户态软件里重新发明了分页——attention kernel 都得改写成认识 block table 的版本,编程和性能开销都不小;可是 GPU 硬件本来就有虚拟内存。vAttention 用 CUDA VMM 底层 API 把虚拟/物理内存分配解耦:KV cache 在虚拟地址上保持连续(kernel 完全不用改),物理页按需映射,还能用 64KB 小页(默认 2MB)压碎片,对比 PagedAttention 版 kernel 吞吐最高提升 1.23 倍。这个反转的教训很普适:当你发现自己在应用层重新实现 OS 机制时,先问一句——OS/硬件是不是本来就提供? 类比不仅能帮你理解设计,还能帮你发现设计的冗余。
反转二:换一种表示,让”管理”问题直接消失。 本站前两天的 KDA 深读讲的线性注意力,可以放进本文的框架重读:KV cache 之所以需要分页、置换、压缩,根源是它随上下文线性增长;而 KDA 把记忆表示换成固定大小的矩阵状态——堆不再增长,malloc、页表、置换策略整套问题在表示层被消灭了(代价是记忆有损,所以还得混 1/4 的全注意力层,那部分的 KV 依然要走本文这套管理)。内存管理的终极优化,是让内存不需要管理。
5. 带走的判断模型:三问定机制
回到你的原始问题,把答案压成可复用的形状。看到任何系统里的”回收”,问三个问题:
- 对象的死亡可证明吗?(存在引用图/可达性这类硬判据)→ 用精确回收:引用计数、追踪式 GC。推理栈里对应:
ref_cnt保护活跃请求的 KV 块。这一层错一次就是 crash,所以机制必须保守。 - 死活只能预测,但内容可无损重建? → 用缓存置换:LRU、重要性打分。推理栈里对应:前缀缓存淘汰、RadixAttention、上下文里”可重算”的工具输出掩码。赌错了赔时间,不赔数据,所以机制可以激进。
- 不可无损重建,但消费者能容忍近似? → 用有损压缩:LLM 摘要。这是 OS 谱系里不存在、LLM 栈新开的第三格。赌错了赔语义,所以要配根集(绝不压缩的部分)和外置(把不容许有损的写到磁盘)兜底。
你的两句直觉,修正后落位:“KV cache 对应内存管理”——对,而且是字面意义的对,从分页页表到 CoW 到 swap 全套搬运;“上下文管理对应 GC”——结构对了,名词错了:真正的 GC(引用计数)在引擎层守正确性,上下文管理是页面置换(守性能)加有损压缩(守语义),一共三层,各管一段。 三十年 OS 的内存管理智慧,推理栈用两年全部重新发明了一遍,然后加了一格 OS 从来不敢加的——因为它的”用户”第一次能容忍遗忘。
参考来源
arXiv 论文
- Efficient Memory Management for Large Language Model Serving with PagedAttention(arXiv:2309.06180,SOSP ‘23) — 分页/页表/CoW/抢占换出的出处
- SGLang: Efficient Execution of Structured Language Model Programs(arXiv:2312.07104,NeurIPS 2024) — RadixAttention 的 radix tree + LRU
- vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention(arXiv:2405.04437,ASPLOS 2025) — 用 GPU 硬件虚拟内存替代软件分页
- MemGPT: Towards LLMs as Operating Systems(arXiv:2310.08560) — 上下文层的分页/swap 思想奠基
工程文档与源码
- Automatic Prefix Caching — vLLM 官方设计文档 —
KVCacheBlock/ref_cnt/空闲队列 LRU 的一手描述 - vllm-project/vllm(GitHub) / microsoft/vattention(GitHub)
本站相关(前置与姊妹篇)
- KV Cache 的显存账 · Decode 为什么是带宽受限的
- vLLM 不是把外部模型包一层 · vLLM 的请求是怎么跑完的
- Agent 的内存管理:Claude Code 与 Codex 如何决定”忘掉什么” — 上下文层三动词与根集框架
- 源码级深读 Kimi K3 的心脏 KDA — “换表示消灭管理问题”的路线