上下文压缩与 Memory 管理:窗口不够用怎么办
结论先行
上下文窗口是 Agent 的第一资源约束,它有三个物理属性:有上限(token 数量封顶)、不便宜(注意力是 O(n²),每一轮还要重发全部历史)、会稀释(窗口越长,模型越抓不住重点,即「lost in the middle」)。
工程上应对它,不是「换更大的窗口」,而是三件事:压缩(上下文快满时总结,腾出空间)、外置记忆(把长期内容写进窗口之外的存储,需要时再检索回来)、分层索引(索引只存指针不存内容,防止索引自己膨胀成第二个「大窗口」)。
但记忆管理的真正难点不在「存哪里」,而在**「什么时候写、写成什么粒度」。一个成熟系统的记忆写入分布在四个精确时机上:实时写元数据、每轮写全文、压缩前 flush、会话结束交接——每个时机负责一段记忆的写入或恢复,最后合起来就是一条可观测、可回放**的记忆管线。这篇文章先讲窗口为什么不够用,再讲三类解法,接着拆这条管线的写入时机,最后落到 Claude Code 与 Codex 已经落地的几套记忆机制(CLAUDE.md、memory、auto memory、compaction,以及 Codex 的 compact 与两阶段记忆管线)上,看看这些通用原则是怎么被真实产品验证的。
一、上下文窗口的三个物理上限
先摆事实,避免一个常见误区:「窗口不够用」不是一个 bug,而是一组物理约束。 你在工程里遇到的所有记忆问题,都源自这三条。
1.1 Token 上限:窗口是封顶的
模型能「同时看到」的内容是有限的,以 token 计。窗口大小在持续增长——从早期的 4K、8K,到现在的 128K、200K,甚至更长——但「更长」不等于「无限」。一个真实项目动辄几十个文件、几万行代码,加上工具调用返回的日志、JSON、错误堆栈,窗口几分钟内就会被塞满。窗口是有限的资源,而 Agent 的任务是无限的,这是矛盾的根本来源。
更要命的是,Agent 的工作方式注定了它会迅速耗尽窗口。回顾这个系列第二篇讲过的 Tool Loop:每一轮都要把从第一条消息到当前的全部历史重新发给模型。模型没有「增量记忆」,它靠重读历史维持状态。单轮平均 N token、跑 M 轮,总消耗就是 O(M × N) 的线性膨胀——一个只查一次天气的任务,实际账单可能是普通单轮对话的 5-10 倍。
1.2 费用:注意力是 O(n²),且每轮重发
上下文长的代价不是线性的。Transformer 的自注意力机制复杂度是 O(n²)——上下文翻一倍,注意力计算量翻四倍。所以「换更大的窗口」不是免费的:它带来的是真金白银的计算成本,而且这个成本会随着历史重发在每一轮循环里反复支付。
工程上有个更隐蔽的浪费点:工具返回的大块内容会被反复重发。工具读了一个 2MB 的文件、查了一次 5000 行的日志,这段内容会在之后的每一轮都被原样重发一遍。所以「结果裁剪」(observe 阶段就截断)的性价比,往往比「加大窗口」高得多。记忆管理的第一原则和它一脉相承:别让没用的东西进窗口,进了就别再让它反复进来。
1.3 注意力稀释:窗口越大,模型越抓不住重点
即使钱不是问题,窗口也不是越大越好。一个被反复验证的现象是「lost in the middle」:模型对开头和结尾的内容记得牢,对中间的内容记性差。上下文塞得越满、越长,真正关键的信息被淹没的概率越高,模型越容易「跑偏」或遗忘约束。
这就是为什么「把 CLAUDE.md 写满、把所有工具的说明书全塞进上下文」是个坏主意:信息越多,信号越弱。 好的记忆管理不是「存得更多」,而是「在该出现的时候,只出现该出现的那一条」。这一点在第五节「分层索引」里会具体展开。
一句话概括这一节:窗口有上限、不便宜、还会稀释注意力。这决定了记忆管理不是「扩容」,而是「在窗口之外建一套外存系统,按需换入换出」——就像操作系统的虚拟内存:物理内存(窗口)有限,但可以通过磁盘(外置存储)+ 页表(索引)假装无限。
二、三类解法总览
对应上面三条物理约束,工程上有三类解法,各解决一个维度:
| 解法 | 解决什么 | 核心思路 | 典型落地 |
|---|---|---|---|
| 压缩 | 窗口装不下 | 把旧历史总结成摘要,用更少的 token 保留语义 | /compact、压缩前 flush |
| 外置记忆 | 窗口之外还要记得 | 把内容写到外部存储,需要时检索回来 | memory 文件、RAG 向量检索 |
| 分层索引 | 索引本身别膨胀 | 索引只存指针不存内容,按需加载 | 薄路由、只存指针的索引文件 |
三者不是三选一,而是一体两面、配合使用:压缩解决「当前窗口里的东西怎么腾挪」,外置记忆解决「窗口外的东西怎么长期保存」,分层索引解决「该加载哪个窗口外的东西」。下面逐个拆。
三、解法一:压缩——在窗口快满时总结
压缩的本质是用「摘要」换「原文」:把一大段历史浓缩成一小段「到目前为止发生了什么、结论是什么、还有什么没做」,丢弃掉过程细节,腾出空间继续跑。
什么时候压缩? 最自然的时机是「上下文快满时」。Claude Code 的 /compact 就是这个:手动或自动地把当前对话压成摘要,作为新对话的起点。但在一个长期运行的 Agent 系统里,压缩不能是「用户想起来才按一下」,而应该挂在生命周期的固定节点上——也就是「压缩前」这个时机。
这里有一个压缩动作里最容易写错的顺序问题:先持久化,再压缩。 因为压缩是有损的——一旦把原文总结成摘要,被丢弃的细节就再也找不回来了。所以在「丢弃」之前,必须先把原始内容持久化到一个安全的地方,将来要复盘、要回溯、要审计,都能翻回来。很多人直接 summary = llm.summarize(history) 然后就把 history 扔了——结果就是「模型记住了摘要,你永远失去了原文」。压缩必须在有副作用的损失发生前,先完成无损失的落盘。
💭 思考:为什么压缩一定要「先落盘、再总结」,这个顺序不能反?——从「压缩丢掉的东西还能不能找回来」反推:压缩是有损的,把原文总结成摘要之后,被丢弃的细节就永久消失了,将来要复盘、要审计、要回溯,现场都没了。而「重不重要」这件事,模型此刻未必判断得准——它可能把「第 3 步遗留的 bug」当成噪声丢掉。所以正确顺序不是「总结 → 丢弃」,而是「原文先逃到安全区 → 再放心做有损操作」。如果不这样做,代价不是「多花点 token」,而是用一次性的空间换走了永久的事实——审计、复盘、回放三条路全断了。
压缩的另一个细节是压缩的粒度。压缩不是「把整个历史压成一段」,而是分层的:能保留结构的保留结构(比如「已完成事项 / 未决事项 / 关键决策」三段式),而不是让模型自由发挥写一段散文。因为压缩产物是要继续喂给模型当上下文的,它本身就是一条 prompt——结构化摘要比散文摘要更利于模型在下一轮精准定位。
这套思路在 Codex 的压缩实现里能看到更工程化的细节(codex-rs/core/src/compact.rs)。压缩不是把全部历史一股脑丢给模型总结,而是有一条硬预算:重建压缩后历史时,保留的 user 消息总计不超过 COMPACT_USER_MESSAGE_MAX_TOKENS = 20000 token。这个预算在 build_compacted_history_with_limit 里被严格执行——从最新一条 user 消息往回挑,用 approx_token_count 估算每条消息的 token 数,装得下就保留,装不下就用 truncate_text(..., TruncationPolicy::Tokens(remaining)) 把那条截断到剩余额度、然后停手,更老的消息直接丢弃。compact.rs 里还定义了一个 content_items_to_text 工具函数,把结构化的内容条目(InputText / OutputText)摊平成纯文本,InputImage、InputAudio 这类无法被总结的非文本项直接跳过。压缩这一整轮还有 hook 边界:run_compact_task_inner 在总结前先跑 run_pre_compact_hooks、总结后再跑 run_post_compact_hooks——这正是第六节「压缩前 flush」在源码里的落点。最终用 replace_compacted_history 把整段原文替换成「摘要 + 预算内精选的历史尾部」,摘要以 SUMMARY_PREFIX 打头、作为新历史的最后一条。也就是说,压缩的产物不是一段孤零零的摘要,而是「一段摘要 + 一个 token 预算内保留的精选尾部」——两者合起来,才是下一轮的新起点。
四、解法二:外置记忆——把长期内容写出去,需要时检索回来
压缩解决的是「窗口内」,但窗口外的东西怎么办?答案是外置记忆:把内容写到上下文窗口之外的存储里,会话之间、任务之间都能持久保留,需要时再检索回窗口。外置记忆有两种典型形态。
4.1 Memory 文件:人机同读的长期记忆
最简单、也最被低估的形态是一个 Markdown 文件。把「这个项目的关键约定、踩过的坑、每次的重要结论」写进去,会话启动时自动读回窗口。它便宜、可 git 管理、人机同读,是长期记忆的最小可用实现。Claude Code 的 CLAUDE.md 就是这个形态的标杆——一个纯文本文件,既是「给模型的记忆」,也是「给人看的文档」。
但 Memory 文件有一个致命陷阱:它会无限膨胀。如果每次会话都往里面追加内容,几个月后它会变成几万行,会话启动时全部读回窗口——等于你亲手造出了第二个「大窗口」,把 1.3 节的注意力稀释问题又请了回来。所以 Memory 文件的正确用法是只存指针,不存内容(详见第五节),正文内容落到别的存储里。
4.2 RAG 向量检索:语义召回
当记忆量超过「一两个文件能装下」的量级,就需要检索了。RAG(Retrieval-Augmented Generation)的思路是:把历史内容向量化存进向量库,每次会话/任务开始时,用当前 query 做语义检索,只把最相关的几条召回窗口——而不是全量读回。
这里有一个容易被忽略的工程选择:检索不只有向量这一条路。 向量检索擅长「语义相似」(你说「内存不够」能召回「OOM」相关的内容),但它的短板是精确匹配和关键词——搜一个函数名、一个精确的错误码,向量检索常常不如全文索引准。所以成熟的记忆系统通常是双通道的:
- 全文索引(倒排索引 + 分词,
MATCH查询)——负责精确关键词、短语、函数名、错误码的检索; - 向量库——负责「意思相近但措辞不同」的语义召回。
两者互补:要找「这个函数上次是怎么改的」,用全文索引;要找「之前有没有聊过类似的内存优化」,用向量检索。 单纯押注向量库、放弃全文索引,是 RAG 落地里最常见的过度设计——很多系统的「记忆」实际是「只记得大概意思、记不准原文」,恰恰是因为它只有向量这一条通道。
4.3 模型即检索器:让模型从记忆的「索引卡片」里挑
还有第三条路,比全文索引更省事、比向量库更轻——Claude Code 的记忆目录就押注在它身上:不建索引,让模型自己选。 每个记忆文件头部带一段 frontmatter(name、description、type 四选一:user / feedback / project / reference)。召回分两步:scanMemoryFiles(src/memdir/memoryScan.ts)只读每个 .md 文件的前 FRONTMATTER_MAX_LINES = 30 行来抠出头部,最新优先排序后最多取 MAX_MEMORY_FILES = 200 个;formatMemoryManifest 再把这些头部拼成一张「一行一个」的清单——- [type] 文件名 (ISO 时间戳): description。接着交给一个旁路的模型调用去挑「对当前 query 真正有用的那几个」:findRelevantMemories(src/memdir/findRelevantMemories.ts)走 sideQuery、限定 max_tokens = 256、要求按 JSON schema 输出 selected_memories 数组,明确最多挑 5 个,并要求「不确定就不选」,宁缺毋滥;挑中后再把对应文件读回窗口。
它的妙处在于,把「相关性判断」这个最难的部分外包给了模型——而模型天然擅长「这段描述跟这个 query 相不相关」。代价是每次召回都要多烧一次模型调用,所以它只适合「记忆文件不多(最多扫最新 200 个)、但要精挑」的场景。几十万条记忆做全量向量召回,与几百条记忆做「模型精选」,是两种量级、两种选择——别在只有几十个文件时急着上向量库。
五、解法三:分层索引——索引只存指针,防索引膨胀
这是三类解法里最「反直觉」、也最体现工程判断力的一条:记忆系统的索引本身不能膨胀。
回到那个问题:为什么路由文件(比如 CLAUDE.md)里的意图速查表每个格子只写「触发词 → 工具名」,而不写工具的执行细节?因为上下文窗口是钱。如果启动时把所有工具的完整说明书全读进窗口,每次会话都要烧掉大量 token,而且模型被信息淹没,注意力稀释。正确做法是:启动时只加载「地图」,走到哪一步再加载「那一页的说明书」。
这就是「分层索引」的核心:索引和内容分离,索引薄、内容厚,按需加载。
L1(路由文件) —— 只存「意图 → 工具名」的指针,一张表
L2(工具说明书) —— 命中后才 Read 的完整方法论,一份说明书
同一个原则用在 Memory 文件上,就是那条反模式铁律:索引只存指针,不存内容。 Memory 文件不写「某次分析得出的完整结论」,只写「关于 X 的分析结论,见 xxx」这样的指针。这样索引文件永远薄,会话启动读它几乎不花 token;真正的内容躺在索引之后,需要时再顺着指针取。
💭 思考:为什么索引要「只存指针、不存内容」,而不是把结论直接写进索引?——从「索引的职责是什么」反推:索引唯一的任务就是「告诉模型去哪找」,不是「代替正文」。一旦它开始存内容,体积会随内容一起涨,启动加载越来越贵,等于亲手造出第二个「大窗口」,把注意力稀释问题又请了回来。更隐蔽的是「过期」:内容会变,指针也得跟着变,但索引里的正文不会自动更新,几个月后就是一条「稳定」的谎言。所以「薄索引」不是抠门,而是把「会变的东西」挡在常驻层之外——常驻层只放不变的地图,会变的正文留在按需加载的深处。
这个原则在 Claude Code 的记忆目录里有一条硬编码的阈值兜底(src/memdir/memdir.ts):入口索引常量 ENTRYPOINT_NAME = 'MEMORY.md' 被明确定义为「index,不是 memory」——每个条目只能是一行、约 150 字符的 - [标题](文件.md) — 一句话钩子 指针,正文必须写进单独的话题文件。索引本身还压着双重上限:MAX_ENTRYPOINT_LINES = 200、MAX_ENTRYPOINT_BYTES = 25_000(约 25KB),truncateEntrypointContent 超了就截断并在末尾追加警告,倒逼你把细节挪进正文文件,而不是放任索引膨胀成第二个「大窗口」。这不靠自觉,靠截断——用硬性阈值把「索引薄」从一条建议变成一条物理约束。
这条原则的收益是双重的:一是省钱(索引薄,启动加载少);二是防过期——内容会变,指针要跟着变。配套的另一条铁律是「记忆条目要标注它描述的是哪个时间点的代码」:代码会演进,一条不标版本的记忆,几个月后就是一条「稳定」的谎言,模型会基于一个已经不存在的实现去做判断。索引不只存指针,还要存「这个指针指向的内容是何时有效的」。
这条铁律在 Claude Code 里不是口号,而是一段自动注入的逻辑(src/memdir/memoryAge.ts)。memoryAgeDays 先把修改时间按 86 400 000 毫秒取整成天数,memoryAge 再换算成人类可读的「today / yesterday / N days ago」——函数注释明说「模型不擅长日期运算,一个原始 ISO 时间戳不会像『47 days ago』那样触发对时效性的推理」。更关键的是,记忆一旦超过一天,memoryFreshnessText 就会自动附上一条时效警告:「这条记忆是 N 天前的,记忆是时间点观察、不是实时状态,关于代码行为或 file:line 引用的断言可能已过时,下结论前先核对当前代码。」 时效不进模型大脑,而是作为元数据随记忆一起被召回——这正是「索引存时间」的落地形态。
分层还有一个维度:作用域。 Claude Code 的记忆目录把记忆分成 private(个人,仅对你可见)和 team(团队共享)两个 scope。团队记忆在 src/memdir/teamMemPaths.ts 里是 auto-memory 目录下的 team/ 子目录(getTeamMemPath()),入口是 team/MEMORY.md(getTeamMemEntrypoint()),每次会话开始时同步,并明确禁止把 API key、凭证这类敏感数据写进共享记忆。个人记忆和团队记忆各维护各的索引、各按各的作用域加载——这也是「分层」,不是按目录层级分,而是按「谁能看、谁该加载」分。
六、记忆的「写入时机」工程
前面三节都在讲「怎么存、存哪里」,这一节讲记忆管理里真正难、也真正值钱的部分:什么时候写。
直觉上,「记忆」像是一件「事后做的事」——会话结束了,把内容存一下。但如果只挂在会话结束时写,你会丢三样东西:过程中的元数据(每一圈循环做了什么)、每轮问答的全文(不是最终结果,而是中间的推理和决策)、以及压缩发生前的原始内容(压缩一丢就没了)。
所以记忆的写入不能只有一个时机,而要分布在生命周期的多个节点上。把这个思路抽象成通用机制,就是四个时机:
| 时机 | 触发点 | 写什么 | 解决什么 |
|---|---|---|---|
| 实时写元数据 | 每次工具调用后 | 追加一条元数据(时间、工具、参数、是否成功) | 过程可回放 |
| 每轮写全文 | 每轮 Agent 回复结束 | 完整的问答全文,建索引 | 结论可检索 |
| 压缩前 flush | 上下文压缩前 | 把原始内容先落盘 | 有损前抢救 |
| 会话结束交接 | 会话退出 | 最终落盘 + 交接摘要 | 跨会话可续 |
逐个拆开讲它背后的工程意图:
实时写元数据:每次工具调用后,append 一条元数据记录。它是 append-only 的日志流,记录的是**「循环的每一圈」。这解决的是 Agent 工程最痛的调试问题:循环过程是黑盒。Agent 跑完 20 圈,你只看到最终答案,中间哪一圈绕了路、哪个工具把方向带偏了,无从追溯。有了这条逐条追加的记录,就能回放整个 Tool Loop**——这也正是 Claude Code 暴露 PostToolUse 这类 hook 事件的用意:让开发者能在「每次工具调用后」这个时间点上挂自己的逻辑。
每轮写全文:每轮 Agent 回复结束(模型给出了最终答案、不再调工具)时,把完整的问答全文落库并建索引。它记录的是**「循环的终点」**——不是元数据,而是可检索的全文。元数据日志和全文存档一前一后:一个偏元数据、实时、append-only;一个偏全文、落库、可检索。两者合起来,才构成对一次 Agent 运行的完整可观测。
压缩前 flush:第三节讲过,压缩有损,所以在压缩发生前先 flush 原始内容。这个时机的价值在于「在丢失发生前抢救」——它是记忆管线里唯一一个「防丢失」而不是「记录」的时机。对应到 Claude Code,就是 PreCompact 这个 hook 事件:它允许开发者在「上下文压缩即将发生」时执行自己的持久化逻辑。
会话结束交接:会话退出时最终 flush,把该落盘的都落盘。它保证「下次会话能接上」——一个任务跨多个会话时,上一步的结论、未决的决策、产生的中间产物,都在这一刻交接给下一次。Claude Code 的 SessionEnd hook 事件就是这个时机。
把这四个时机串起来看,记忆管线的设计哲学就清楚了:
记忆不是「会话结束后存一次」,而是「在每个会丢失信息、或需要恢复信息的节点上,各做一件精确的事」。
- 要回放过程 → 实时写元数据;
- 要检索结论 → 每轮落全文;
- 要防止压缩丢失 → 压缩前 flush;
- 要跨会话续上 → 会话结束交接;
- 要在新会话恢复 → 会话启动读回。
每个时机解决一个具体的「会丢」或「要恢复」的点,合起来才是一条可观测、可回放的记忆管线。
💭 思考:为什么记忆写入要拆成四个时机,而不是「会话结束时存一次」就完了?——从「什么时候会丢信息」反推:只在会话结束写,等于默认「会话过程中不会丢任何东西」。但事实上有四个「会丢或要恢复」的节点:工具每调一次,中间过程就过去一次(会丢元数据);每轮回复结束,中间推理就过去一次(会丢全文);压缩发生,原文就永久消失(会丢原始内容);会话退出,上下文就清空(要交接)。每个节点对应一种「丢法」,所以每个节点要配一个「写动作」。如果不拆,你就是用「会话结束」这一张兜网去接四个不同位置的漏洞——网再大,漏洞不在网里。
这套「分时机写入」的思路,在 Codex 的记忆管线里被做成了一个后台批处理(codex-rs/memories/,运行时编排在 codex-rs/core/src/memories/):根会话启动时异步跑两阶段。Phase 1 从状态 DB 按启动认领规则 claim 一批 rollout 任务,并行(带并发上限)地把每条发去提取结构化输出——raw_memory、rollout_summary、可选的 rollout_slug——并对生成字段做脱敏,再写回 DB。Phase 2 拿一把全局锁,把最新的 stage-1 记录按 usage_count、再按最近的 last_usage / generated_at 排序去重,同步成磁盘上的 raw_memories.md 和 rollout_summaries/,并写一份 phase2_workspace_diff.md 交给一个无网络、只本地写的 consolidation 子 agent。也就是说,「记忆写入」本身也被从主循环里剥离出来,变成一个带并发上限、带租约、带水位线的独立任务——不占用主会话的窗口,也不打断主会话的节奏。
七、Claude Code 的公开记忆机制
前面讲的都是通用原则,这一节落到真实产品上:Claude Code 已经把「压缩、外置记忆、分层索引、写入时机」这四条分别落成了几个落地特性(Codex 的实现细节见第三、六节的对照)。理解它们,等于看到这套工程原则的工业级实现。
7.1 CLAUDE.md:分层的外置记忆
CLAUDE.md 是 Claude Code 的「项目记忆文件」,会话启动时自动加载进上下文。它的设计里有几个点,正好印证了前面的原则:
它是分层的。 Claude Code 支持多级 CLAUDE.md——用户级(全局记忆,对所有项目生效)、项目级(项目根目录,对本项目生效)、子目录级(嵌套在子目录里,只在处理该目录时加载)。这对应的就是「分层索引」的思想:不是把全部记忆一次性塞进窗口,而是按作用域按需加载——你走进哪个目录,才加载哪个目录的记忆。
它支持导入而非内联。 CLAUDE.md 里可以用 @path/to/file.md 引入外部文件,让记忆文件本身保持薄,正文内容散落在被导入的文件里。这正是「索引只存指针、不存内容」的落地——CLAUDE.md 是那张薄薄的路由表,指向真正厚的说明书。
它有本地私用版本。 CLAUDE.local.md 用于个人笔记(通常被 gitignore),把「团队共享的规范」和「个人的临时记忆」分开。这解决了外置记忆里「哪些该进版本库、哪些不该」的边界问题。
7.2 memory:用 # 导入的长期记忆
除了 CLAUDE.md,Claude Code 还提供了更轻的记忆机制:在对话里输入 #,可以把一个文件或目录添加为 memory。被添加为 memory 的内容,会在之后每次会话启动时自动加载。配套的 /memory 命令则打开一个记忆编辑器,让你直接维护这些记忆。
它的设计动机和第四节「memory 文件」完全一致:把「经常要用、又不想每次手动粘贴」的内容变成自动注入的长期记忆。 但也要注意 4.1 的陷阱——# 添加的东西会被自动加载,所以不能无节制地加。加得越多,每次会话的固定开销越大,注意力稀释越严重。正确的用法是只加「高频、稳定、短小」的内容(比如关键约定、常用命令),而不是把整个知识库都塞进 memory。
7.3 compaction:自动化的上下文压缩
/compact 是 Claude Code 的压缩命令,它把当前对话历史总结成一段摘要,丢弃过程细节,作为新对话的起点。除了手动触发,Claude Code 还会在上下文接近窗口上限时自动触发 compaction——先总结历史、再继续,避免硬性截断。
对照第三节,这里有两个点值得注意:
一是压缩是「摘要替换」,不是「清空重来」。 compaction 会尽量保留关键决策、未完成事项、用户偏好这类「语义」,只丢弃「过程性的原文」。这就是第三节说的「用摘要换原文」——用更少的 token 保住语义。
二是压缩时机被做成了可扩展的 hook。 Claude Code 的 PreCompact 事件允许开发者在「压缩发生前」插入自定义逻辑;Codex 的 codex-rs/core/src/compact.rs 里同样显式地先 run_pre_compact_hooks、总结后再 run_post_compact_hooks。这正是第六节「压缩前 flush」这个时机在真实产品里的位置:任何依赖原文的东西(日志、审计、复盘),都应该在 compaction 有损压缩之前,先把自己的那份原始内容抢救出来。 如果你不做这件事,compaction 一过,中间的推理过程和工具调用细节就永久消失了——模型记住的是摘要,而你失去的是现场。
7.4 auto memory(记忆目录):外置记忆 + 分层索引 + 时效的完整落地
前三套机制是「点」,Claude Code 的记忆目录(auto memory)把它们串成了一条「线」:一个按项目隔离的持久目录,装着带 frontmatter 的记忆文件和一张 MEMORY.md 索引。它同时落地了本文的三条原则——
- 外置记忆 + 相关性检索:记忆写进目录里的独立文件,会话启动只加载
MEMORY.md索引;需要具体记忆时,findRelevantMemories用一个旁路的模型调用,从scanMemoryFiles扫出的最新 200 个记忆头部里挑出最多 5 个最相关的,再读回正文。 - 分层索引:
MEMORY.md被明确定义为「index,不是 memory」,一行一条指针、上限MAX_ENTRYPOINT_LINES = 200行 /MAX_ENTRYPOINT_BYTES = 25_000(约 25KB),正文永远不进索引。 - 时效:每条记忆召回时都带
memoryAge换算出的年龄,超过一天由memoryFreshnessText自动附上「这是时间点观察、不是实时状态」的警告。 - 团队记忆:private 与 team 两个作用域分目录存储,团队记忆每次会话开始同步,共享目录禁止写敏感数据。
它和 7.2 的 # 记忆的区别在于:# 是「每次会话都全量注入」的常驻记忆(所以要防膨胀),而记忆目录是「索引常驻、正文按需召回」——前者靠人肉控制数量,后者靠「检索 + 索引 + 时效」三层机制自动控制。
把这几套机制放回本文框架,就能看清它们分别落在哪:
CLAUDE.md→ 外置记忆 + 分层索引(分层加载、指针导入、内容分离);memory(#//memory)→ 外置记忆(自动注入的长期记忆,但要防膨胀);auto memory(记忆目录)→ 外置记忆 + 分层索引 + 时效(findRelevantMemories相关性召回、MEMORY.md薄索引、memoryAge时效、团队记忆);compaction(/compact+ 自动 compact +PreCompact)→ 压缩 + 压缩前 flush 时机(Codex 的 compact.rs 同款:20000 token 预算 +content_items_to_text文本化)。
Claude Code 和 Codex 都没有发明什么新魔法,它们只是把「压缩、外置记忆、分层索引、写入时机」这四条原则,做成了一组开箱即用、且可被 hook 扩展的机制——上面这些源码细节就是证据。
总结
把整篇压成一段话:
上下文窗口有上限、不便宜、会稀释,所以 Agent 的记忆管理不是「换更大的窗口」,而是「在窗口之外建一套外存系统」——用压缩腾挪窗口内的空间,用外置记忆保存窗口外的内容,用分层索引决定该把哪一条内容换回窗口。而真正决定记忆系统质量的,不是「存哪里」,而是「什么时候写」:实时写元数据、每轮写全文、压缩前 flush、会话结束交接,四个时机各做一件精确的事,拼成一条可观测、可回放、可交接的记忆管线。Claude Code 的 CLAUDE.md、memory、auto memory、compaction,以及 Codex 的 compact 与两阶段记忆管线,正是这四条原则的工业级实现。
理解了这个,再看市面上的 RAG 框架、向量数据库、Memory 插件,本质上都是在回答同一个问题的不同侧面:怎么让一个上下文有限的模型,假装自己什么都记得。 窗口是物理的,记忆是工程造出来的。
章末提问
追问 1:上下文窗口不够用了,你会怎么解决?
结论先行:不是换更大的窗口,而是「压缩 + 外置记忆 + 分层索引」三件事一起做。 因为:窗口有上限、不便宜(注意力 O(n²) 且每轮重发全部历史)、会稀释(Lost in the Middle),单纯扩容只解决「有上限」这一个维度,还放大后两个。压缩把旧历史换成摘要腾空间,外置记忆把长期内容写到窗口外,分层索引决定该把哪一条换回窗口——三者分别解决「窗口内怎么腾」「窗口外怎么存」「该加载哪一条」。
追问 2:压缩是有损的,你凭什么敢丢原文?顺序上有什么讲究?
结论先行:先持久化、再压缩,落盘必须发生在有损操作之前。 因为:压缩把原文总结成摘要后,被丢弃的细节永久消失,将来复盘、审计、回溯都没了现场。所以顺序不能反——先把原始内容无损失地写到安全的地方,再放心做「摘要换原文」的有损压缩。如果不做,你只是用「省 token」换走了「永久的事实」。
追问 3:为什么外置记忆的索引要「只存指针、不存内容」?
结论先行:防止索引自己膨胀成第二个「大窗口」,也防止内容过期。 因为:索引的职责是「告诉模型去哪找」,不是「代替正文」。一旦索引开始存内容,体积随内容一起涨,启动加载越来越贵,等于把注意力稀释问题又请回来;而且内容会变,索引里的正文不会自动更新,几个月后就是一条「稳定」的谎言。所以常驻层只放「不变的地图」,会变的正文按需加载。
追问 4:检索记忆时,向量检索、全文检索、「模型挑记忆」怎么选?
结论先行:语义召回用向量、精确匹配用全文、记忆文件少时用「模型精选」,别一上来就上向量库。 因为:向量擅长「意思相近但措辞不同」,但搜函数名、错误码这类精确匹配不如全文索引准;而只有几十个记忆文件时,向量库是过度设计——让模型从最新 200 个头部里挑最多 5 个更省事、更准。三种是不同量级、不同需求下的选择,不是越「高级」越好。
追问 5:记忆应该在什么时候写?为什么不能只在会话结束写一次?
结论先行:拆成四个时机——实时写元数据、每轮写全文、压缩前 flush、会话结束交接。 因为:只在会话结束写,等于默认过程中不丢东西,但事实上有四个「会丢或要恢复」的节点:工具每调一次丢元数据、每轮回复结束丢中间推理、压缩发生丢原文、会话退出要交接。每个节点对应一种「丢法」,所以要各配一个写动作,最后才拼成一条可观测、可回放、可交接的记忆管线。