TL;DR

  • 虚拟内存思想:用有限上下文窗口支撑无限知识,操作系统几十年前就解决了这个问题
  • 工作集机制:只把当前需要的内容加载到”物理内存”(上下文窗口),其他留在”磁盘”(向量库)
  • 混合架构:长上下文窗口 + RAG 分页 = 既享 RAM 速度,又有磁盘容量
  • 智能调度:按需加载(page fault 触发检索) + 局部性预取 + LRU/K-means 置换 这不是未来愿景——而是今天可实现的工程方案。

引言:128K上下文是个陷阱

OpenAI说GPT-4支持128K上下文,Claude说200K。

但你知道实际使用时会发生什么吗?

延迟飙升: 长上下文的首次 token 时间(TTFT)随上下文长度近似线性放大;业界通常观察到数倍乃至一个数量级的差异,受模型实现与硬件影响 成本爆炸: API 费用一般与上下文长度近似成正比(部分模型按”读”与”写”分别计费) 注意力稀释: 关键信息淹没在海量噪声中,模型容易出现 Lost in the Middle 现象——Liu 等人对长上下文位置敏感性的实证研究常被引用作为参考

这就像给电脑装了128GB内存,然后试图把所有数据都塞进RAM——愚蠢且昂贵。

操作系统早就解决了这个问题:虚拟内存(Virtual Memory)

虚拟内存的核心思想

物理内存(RAM)是有限的、昂贵的、快速的。 磁盘空间是无限的、便宜的、慢的。

解决方案: 只把当前需要的数据放在RAM,其他的留在磁盘,按需换入(page in)。

关键洞察:程序不需要同时访问所有数据

分页机制的工作原理

局部性原理(Locality):

  • 时间局部性:刚访问的数据很可能再次访问
  • 空间局部性:相邻数据很可能被一起访问

这就是为什么虚拟内存可行——程序实际的行为是”局部”的。

LLM的”页错误”时刻

当前架构的问题

现在的RAG系统像什么?

这相当于:每次访问都重新加载整个工作集

💡 Key Insight

这相当于:每次访问都重新加载整个工作集。

没有页表、没有缓存、没有预取——纯粹的暴力检索。

什么情况下会触发”页错误”

场景1:多轮对话

问题:第二轮应该”记住”第一轮的内容,而不是重新检索。

场景2:长文档处理

问题:人类读长文档是跳着读的,LLM却试图一次性加载全部。

场景3:工具调用链

问题:早期步骤的结果被后期淹没,形成”上下文债务”。

RAG作为分页机制的设计

RAG作为分页机制的设计

核心组件映射

操作系统 LLM/RAG系统
物理内存(RAM) 上下文窗口(Context Window)
磁盘/SSD 向量数据库(Vector Store)
页表(Page Table) 上下文映射表(Context Map)
页错误(Page Fault) 检索触发(Retrieval Trigger)
页面置换算法(LRU/LFU) 上下文淘汰策略
工作集(Working Set) 活跃上下文(Active Context)

💡 Key Insight

操作系统和LLM/RAG系统的组件映射不是修辞手法,而是字面意义上的结构类比——页表、页错误、工作集每个概念都有直接对应的工程实现。

上下文页表的设计

Context Map 是分页RAG的核心数据结构,负责追踪每一页的元信息。每一页条目包含以下字段:

  • page_id:页的唯一标识符(由6.1节的页ID方案生成)
  • vector:该页的向量嵌入,用于相似度检索
  • recency:上次访问时间戳,决定页的冷热程度
  • access_count:访问计数器,衡量页的活跃度
  • dirty_flag:布尔值,标记页在上下文窗口中是否被修改过

检索触发(Retrieval Trigger)时,Context Map被查询以确定需要的页是否已在上下文中。如果页不在(发生”页错误”),则从向量库加载,更新recency和access_count,并将该页标记为clean。如果页已存在但被修改过(dirty_flag=true),则触发写回流程。

生成内容时,新页加入上下文窗口,Context Map写入新条目。如果上下文窗口满,则根据LRU/K-means混合策略选出最冷的一页换出:先将该页的向量更新到向量库(如果dirty),再从上下文中移除。

Context Map本身可以存储在向量库旁的关系数据库(如SQLite)中,也可以直接以键值对形式放在内存中,由于它只存储元信息(不含原始文本),体积很小,查询速度极快。

分页粒度:多大算一页

太细(句子级):

  • 页表爆炸,管理开销大
  • 失去段落/章节的语义连贯性

太粗(文档级):

  • 每次加载太多无关信息
  • 失去精细控制

Sweet Spot(段落级,~500 tokens):

  • 保持语义连贯
  • 控制粒度适中
  • 符合大多数文档的自然结构

预取

操作系统会预取相邻页,因为空间局部性。

LLM场景:

  • 用户在读第3章,预取第4章
  • 对话中提到”之前说的API问题”,预取相关对话
  • 代码生成的下一步,预取相关函数定义

写回与写穿

写回(Lazy):

  • 修改只发生在上下文
  • 定期/按需写回向量库
  • 性能好,但有数据丢失风险

写穿(Eager):

  • 每次修改同步更新向量库
  • 数据安全,但性能差

LLM场景的选择:

  • 对话历史:写穿(重要,不能丢)
  • 临时推理:写回(可重建)
  • 用户编辑的内容:写穿

工作集窗口

操作系统跟踪每个进程的工作集——最近Δ时间内访问的页集合。

LLM应用:

  • 跟踪最近N轮对话中引用的文档/知识
  • 这些应该常驻上下文(”钉住”在内存中)
  • 其他的可以换出

混合内存管理:长上下文模型 + RAG

混合内存管理:长上下文模型 + RAG

为什么不是二选一

纯长上下文:

  • 优点:简单,无需检索逻辑
  • 缺点:贵、慢、注意力稀释

纯RAG:

  • 优点:便宜、可扩展
  • 缺点:检索质量决定一切,丢失连贯性

混合架构:

  • 长上下文作为”物理内存”(工作集)
  • RAG作为”虚拟内存”(按需加载)
  • 获得两者的优点

实际架构示例

考虑一个技术文档问答系统的实际场景:文档库包含1000篇Markdown,总计约500万token。

上下文窗口(”物理内存”):配置为16K tokens上限,存放当前对话的工作集。

向量库(”磁盘”):对每篇文档按段落分块(每块约500 tokens),生成向量索引。

Context Map(”页表”):记录每个段落的page_id、向量、recency、dirty_flag。

运行流程:

用户问:”第三章的API调用示例是什么?”

  1. 系统查询Context Map,发现相关段落(page_id含”第三章”)不在上下文窗口中——触发检索触发。
  2. 向量库检索找到top-5相关段落,将文本加载进上下文窗口,更新Context Map。
  3. 上下文窗口现有内容:当前对话历史(约4K)+ 5个段落文本(约5K)= 9K tokens,距离上限还有空间。
  4. 上下文窗口接近上限时,Context Map根据recency+access_count选出最冷的页,将其写回向量库并从窗口移除,为新页腾出空间。

整个过程中,长上下文模型负责推理和生成,RAG负责按需加载和换出,分页逻辑负责协调——三者各司其职。

性能对比

上表为工程示意区间,准确率数字会随领域、查询分布与评测协议差异显著。业界公开评测(如 RAGAS 框架BEIR benchmark)显示,纯 RAG 与混合架构的差距取决于检索质量、文档分块策略与基线模型能力,不应作为统一基准。

方案 平均延迟 成本 准确率(示意区间)
纯长上下文(128K) 数秒级,受上下文长度近似线性放大 高(与上下文长度成正比) 业界观察区间通常 60-80%,关键信息位置敏感
纯RAG(top-10) 检索阶段 + 推理阶段 业界观察区间通常 50-70%,受检索质量制约
分页RAG(混合) 接近纯RAG,工作集常驻加快热查询 工程经验上常优于纯方案,但缺乏统一基准

分页RAG的优势:

  • 常用信息常驻内存(快)
  • 非常用信息按需加载(省)
  • 工作集跟踪保持连贯性(准)

💡 Key Insight

分页RAG的优势:常用信息常驻内存(快)、非常用信息按需加载(省)、工作集跟踪保持连贯性(准)。

实现中的细节

页ID设计

如何唯一标识一页?页ID是Context Map的主键,也是向量库检索的粒度依据。三种主流方案各有优劣:

方案1:文档路径 + 段落序号

使用源文档的路径加段落序号作为页ID,例如 /docs/api-reference.md#p42。这种方案的最大优势是可读性和可导航性——给定一个页ID,开发者可以直接定位到原始文档的原始位置,也方便在用户界面展示”这条知识来自哪里”。缺点是如果文档被修改或重构,路径会失效,需要额外的重定向机制。

方案2:内容哈希

对页的原始文本内容计算SHA-256或BM25哈希,生成固定长度的字符串作为页ID,例如 sha256:a3f5c9d2e1b4...。内容哈希的核心价值是去重和版本控制——当同一段内容出现在多个文档中时,哈希能识别出它们是重复的,不必在向量库中存储多份。当文档被修改时,哈希值立即变化,自动触发向量库中的增量更新,而不需要手动追踪”哪些段落变了”。

方案3:语义ID

利用 embedding 模型的输出向量,对所有页向量做 K-means 或层次聚类,用聚类中心的 ID 标记每页。这种方案的出发点是语义相关的页拥有相似的ID,从而在检索时可以先在语义空间做粗筛。但语义ID不具可读性,也不能反映原文的来源信息,一般只作为辅助索引。

💡 Key Insight

推荐:方案1 + 方案2混合——路径用于导航,哈希用于去重。

实际工程中,路径用于UI展示和人工定位,哈希用于向量库的增量更新和去重,两者拼接(如 page_id = "/docs/api-reference.md#p42" + "_v2sha256:a3f5...")作为完整唯一键,兼顾可读性和精确性。

脏页检测

在分页RAG系统中,”脏页”指的是在上下文窗口中被修改过、但尚未同步回向量库的那一页。检测脏页是实现写回(Write-back)策略的前提——如果不知道页被改过,就无法决定要不要写回。

显式标记是最直接的方案:用户对LLM输出说”这个不对,应该是…“,系统在Context Map中手动将该页的dirty_flag设为true。这种方式准确度高,但依赖用户的主动反馈,无法覆盖LLM自行修改了上下文中检索结果的情况。

版本号是隐式方案:每一页在向量库中都有一个版本号(整数,自增)。当页从向量库加载到上下文时,Context Map记录”加载时的版本号”。当页在上下文中发生变化(无论是用户编辑还是LLM推理修改),版本号不改变。下次写回时,系统比对”Context Map中的版本号”与”向量库中的版本号”——如果不同,说明向量库在这期间被其他会话更新过,存在冲突,需要人工介入或自动合并。

哈希冲突检测介于显式和隐式之间:每次页被加载到上下文时,计算并缓存其内容哈希。当上下文窗口中的页发生变化时,重新计算哈希,与缓存值对比,不同则标记dirty。这种方案无需修改向量库schema,完全在客户端侧完成,适合向量库不支持版本控制的场景。缺点是每次内容变化都要重新算哈希,有一定计算开销。

实际系统通常组合使用:dirty_flag用哈希检测做粗筛,冲突解决依赖版本号,显式纠正由用户触发,三者共同保证”写回不错漏、冲突可追溯”。

缺页率监控

操作系统监控缺页率(Page Fault Rate)来调整工作集大小。

LLM应用:

  • 高缺页率 → 增加上下文窗口或改进预取策略
  • 低缺页率 → 可以减小上下文窗口以节省成本

缺页率监控

结尾

长上下文模型是硬件限制(我们造不出无限大的芯片),不是架构最优

虚拟内存架构是计算机科学最伟大的工程成就之一——它让我们能够用有限的物理资源,支撑无限的逻辑空间。

LLM应用应该学习这一点:

  • 不要试图记住一切(贵且慢)
  • 只记住现在需要的(工作集)
  • 其他的按需加载(RAG分页)
  • 智能预取和置换(局部性原理)

这才是可扩展、经济、高效的Agent架构。


深度阅读时间:约 12 分钟

延伸阅读:

  • Denning, P.J. (1968). “The Working Set Model for Program Behavior”
  • Tannenbaum, A.S. “Modern Operating Systems” (Chapter 3: Memory Management)
  • Liu, N.F., et al. (2023). “Lost in the Middle: How Language Models Use Long Contexts”

标签: #RAG #上下文窗口 #内存管理 #LLM优化 #系统架构 #Agent设计