AI Agent 的长期记忆是怎么实现的?拆解 Claude Code 的 memory 机制
一个容易被忽略的事实:大模型没有记忆,也没有硬盘。 它跑在别人的 GPU 上,权重是只读的,每次请求结束后关于你的一切都被丢弃。
可 Claude Code 这类工具确实能跨会话记得你的技术栈、你的代码规范、你上周踩过的坑。这份"记忆"一点都不在模型里,全部在本地磁盘上,并且每一轮对话都被重新塞回去一次。
这篇文章把这条链路完整走一遍:记忆存在哪 → 怎么被选中 → 怎么变成 token → 怎么抵达服务器端的模型,以及为什么它必须这样设计。
关于本文的证据
技术文章最容易出问题的地方是"听起来很对"。所以先交代来源,本文结论分两类:
① 实现代码——来自对 Claude Code CLI 的第三方逆向还原(对应 v2.1.87)。它不是 Anthropic 官方源码,函数名、变量名可能被还原者重命名过。为控制风险,关键结论(如"CLAUDE.md 到底在 system 还是 messages 里")我做了双路独立核查,并把还原代码里的字符串常量与真实运行时收到的上下文逐字比对过,多处完全一致。字符串常量可信度较高;数值常量与架构描述建议自行验证(见下方更正框的三档说明)。
② 运行时实测——本地 ~/.claude/ 目录的真实数据,取自一次快照:63 个会话 transcript、7,600 条带 token 用量的 API 记录。文中的量化数字出自这里,你在自己机器上可以用同样方法复现(第五节给了方法)。
文中出现的目录树、记忆文件、索引条目均为按真实结构构造的示例,不是任何人的私人数据。
另外提醒版本差异:实测环境是 v2.1.247,而代码快照是 v2.1.87,实测数据里已出现快照中不存在的字段。读到具体常量请以"某一版本的实现"看待,而非永恒真理。
发布后的一处重要更正:关于本文引用的所有"代码注释"
写下一篇文章时我做了一项更严格的验证:用本机真实的 Claude Code 原生二进制(v2.1.247)去反查那份还原代码。结果是——
真实产物里所有标识符已混淆、所有注释已被完全剥离,只有字符串字面量存活。 我拿函数名和注释原文去搜真实二进制,全部 0 命中。
而那个还原项目的 README 自承"后台有 Opus 持续优化"。
这意味着:本文引用的每一条代码注释——包括那些读起来极有说服力的设计理由和运营统计数字——都无法用官方产物验证,有可能是大模型根据混淆后的代码结构重写出来的,而不是 Anthropic 工程师的原话。
具体到本文:
- 仍然可信(A 档):字符串常量——各 beta 头、记忆时效警告的原文、错误匹配串这类字面文本,我逐条比对过真实二进制并命中。以及全部标注为"实测"的运行时数据(那些来自本地真实的 API 记录,与还原代码完全无关,不受影响)。
- 可信度中等(B 档):数值常量与结构描述。真实产物里标识符已被混淆,所以常量的名字无从比对,只有少数能靠其字面值定位——而一个裸数字在二进制里的命中并不具备指认力。下一篇把这类证据统一划为 B 档,本文同此口径。
- 请降级看待(C 档):所有引号里的注释引文,以及基于注释得出的"为什么这么设计"的解释。它们是合理的解释,不是官方的说法。
一条便于识别的规则: 下文凡出现「注释说 / 注释解释 / 注释称 / 注释给的理由是」这类字样,一律属于 C 档,适用本框——不必回翻确认。
我保留这些引用而不删除,因为它们描述的机制与可验证的常量、以及实测行为高度自洽。但把话说清楚比让文章显得更权威重要。
一、先把幻觉打碎:模型是无状态的
先明确一个前提,否则后面全是误解。
调用大模型的 HTTP 请求,本质长这样:
POST /v1/messages
{
"model": "claude-...",
"system": "你是一个助手……",
"messages": [
{"role": "user", "content": "我叫 Ada"},
{"role": "assistant", "content": "你好 Ada"},
{"role": "user", "content": "我叫什么?"}
]
}第三轮里模型能答出"Ada",不是因为它记住了,而是因为第一轮的原文又被完整地发了一遍。这是个纯函数:
输出 = f(权重, 本次请求的全部文本)权重是只读的常量,请求文本是唯一的变量。所以:
核心结论
"长期记忆"不是模型能力,而是客户端的一种工程幻术。 它的实现只有一条路:把过去的信息,在每一次新请求里,重新变成 token 塞进去。
一旦接受这个结论,问题就从"模型如何记住"变成了三个纯工程问题:
- 存什么——哪些信息值得跨会话留存?
- 怎么选——记忆库会越来越大,每轮该塞哪几条进去?
- 怎么塞——放在请求的哪个位置,成本多少?
Claude Code 对这三个问题给出的答案,是一套相当克制、且完全建立在纯文本文件之上的设计。下面逐层拆。
二、三层记忆架构
Claude Code 的"记忆"不是一个东西,而是三种生命周期完全不同的机制。把它们混为一谈是理解上的最大障碍。
┌──────────────────────────────────────────────────────────┐
│ 第 1 层:规则层 CLAUDE.md │
│ 性质:常驻,每轮全量注入 │
│ 内容:约定、规范、路由规则——"你必须怎么做" │
│ 代价:固定 token 开销,与对话长度无关 │
├──────────────────────────────────────────────────────────┤
│ 第 2 层:事实层 memory/*.md │
│ 性质:索引常驻 + 正文按需召回 │
│ 内容:关于用户和项目的离散事实——"我知道什么" │
│ 代价:索引恒定;正文只在命中时付费 │
├──────────────────────────────────────────────────────────┤
│ 第 3 层:流水层 <session>.jsonl │
│ 性质:全量落盘,按需重放 │
│ 内容:逐字逐句的对话历史——"发生过什么" │
│ 代价:不进上下文;仅 resume / 压缩时参与 │
└──────────────────────────────────────────────────────────┘一句话区分:
- CLAUDE.md 是宪法——每轮都读,不容遗忘,所以贵。
- memory/ 是档案馆——目录常在手边,卷宗用时才取。
- jsonl 是录像带——事后可回放,但平时不占地方。
真正称得上"长期记忆"的是第 2 层,它也是本文的重点。但要理解它为什么这样设计,得先看第 1 层的代价。
(一个预告:第 2 层"目录常在手边"这个说法有个例外——代码里还存在另一种召回模式,索引根本不进上下文。这个分叉留到 5.1.2 再讲,先按主线理解。)
三、落到磁盘:记忆到底长什么样
这一节把三层里最关键的第 2 层摊开看。下面的目录树和文件内容是按真实结构构造的示例(用一个虚构的 shop-web 项目),而带单位的量化数字来自一个真实工作区的实测——两者分开标注,不混着用。
3.1 磁盘布局
记忆是按工作目录隔离的。Claude Code 把工作目录的绝对路径做了一次极朴素的编码——把所有非字母数字的字符统统替换成 -:
/Users/dev/projects/shop-web
↓
-Users-dev-projects-shop-web注意这不只是"斜杠变横线"。下面几个例子把规则暴露得更清楚:
/Users/dev/.claude → -Users-dev--claude ← "/." 连出双横线
/Users/dev/tools/cli-2.4.0 → -Users-dev-tools-cli-2-4-0
↑ 点号也变横线
/private/tmp → -private-tmp代码里对应的函数是 sanitizePath(),正则就是 /[^a-zA-Z0-9]/g。它还有个 200 字符的长度上限,超了就截断并拼一个 hash 后缀。
这个编码不可逆——从 cli-2-4-0 无法还原出 cli-2.4.0。所以这个目录名只是个查找键,不是路径备份。
于是项目记忆就落在这里:
~/.claude/
├── CLAUDE.md # 用户级规则(全局生效,通常很小)
├── history.jsonl # 输入历史(仅供上翻,与记忆无关)
└── projects/
└── -Users-dev-projects-shop-web/
├── memory/ # ← 长期记忆本体
│ ├── MEMORY.md # 索引:一行一条,指向下面的文件
│ ├── prefers-pnpm.md
│ ├── db-migration-needs-backup.md
│ └── ...
├── <session-uuid>.jsonl # 每个会话一份完整 transcript
└── <session-uuid>/
└── subagents/ # 子 agent 的独立 transcript这里已经藏着一个脆弱点:路径即主键。 重命名或移动项目目录,编码结果就变了,等于把那份记忆整份丢掉——旧目录会变成一个再也不会被读到的孤儿。
(一个补充细节:memory 目录的定位优先取规范化的 git 仓库根目录,所以同一仓库的多个 worktree 会共用一份记忆,这是有意为之;只有不在 git 仓库里时才退回用工作目录本身。)
3.2 一条记忆长什么样
每条记忆是一个文件、一个事实,带 YAML frontmatter。一个典型的例子:
---
name: db-migration-needs-backup
description: 生产库跑迁移前必须先手动快照,CI 不会自动备份
metadata:
node_type: memory
type: project
originSessionId: 00000000-0000-4000-8000-000000000000
---
在 shop-web 的生产环境执行 `prisma migrate deploy` 之前,
必须先手动触发一次 RDS 快照。
**Why:** CI 流水线里没有备份步骤(2026-07 那次回滚花了 4 小时才恢复
订单表)。团队讨论后决定不加进 CI,因为快照会拖慢每次部署,
改为发布前人工确认。
**How to apply:** 涉及 migration 的部署,先确认快照 ID 已记录在
发布单里再继续;只改 schema 不动数据的迁移也不例外。
关联 [[deploy-checklist]]。几个设计细节值得单独点出:
| 字段 | 作用 | 为什么重要 |
|---|---|---|
description | 一行摘要 | 这是唯一进索引的内容,决定该条能否被检索到 |
type | user / feedback / project / reference | 分类决定信息的稳定性预期 |
originSessionId | 溯源 | 可回到产生这条记忆的原始会话查证 |
Why: / How to apply: | 结构化正文 | 不只存结论,还存理由和执行方式 |
[[双向链接]] | 记忆间关联 | 召回一条时能顺藤摸到相关的几条 |
Why: 这个约定比看起来重要。只存"迁移前要备份"是一条死规则,遇到边界情况(只改 schema 不动数据要不要备份?)只能瞎猜;而存了理由"因为 CI 没有备份步骤、且团队主动决定不加",模型就能推断出边界——存理由,是为了让记忆可泛化。
注意这条记忆还顺手示范了一个写法:2026-07 是绝对日期。如果写"上个月那次回滚",三个月后读到就是垃圾。
3.3 索引:用几 KB 索引几十 KB
MEMORY.md 是全部机关所在。它不含任何记忆内容,只有指针:
- [Prefers pnpm](prefers-pnpm.md) — 本项目统一用 pnpm,不要生成 npm/yarn 命令
- [DB migration needs backup](db-migration-needs-backup.md) — 生产迁移前必须手动快照
- [API client retry trap](api-client-retry-trap.md) — 内部 SDK 默认重试 3 次,幂等接口才可用来自一个真实工作区的实测数字:
| 项目 | 大小 |
|---|---|
| MEMORY.md(索引,每轮常驻) | 约 3.1 KB |
| 19 条记忆正文合计(按需召回) | 约 37 KB |
用 3 KB 的常驻成本,让 37 KB 的记忆库变得可寻址,压缩比约 12:1。而且这个比例可以继续放大——记忆涨到 500 条时,索引也只是线性增长的几十 KB,而正文永远只按命中付费。
3.4 真正的大头是 CLAUDE.md
有意思的是,记忆索引根本不是开销的主要来源。同一个工作区的实测:
| 每轮固定注入 | 大小 |
|---|---|
用户级 ~/.claude/CLAUDE.md | 约 0.4 KB |
项目级 CLAUDE.md | 约 31 KB |
MEMORY.md 索引 | 约 3.1 KB |
| 合计 | 约 34.6 KB |
项目级 CLAUDE.md 一个人占了 90%。粗估约 1.5 万 token 量级——每一轮对话,都要为它付一次费,无论这轮你问的是什么。
这就解释了为什么两层机制的设计如此不同:
- CLAUDE.md 是规则,漏掉任何一条都可能违规,所以只能全量常驻,代价硬吃;
- memory 是事实,绝大多数与当轮无关,所以必须做成索引 + 按需召回。
也顺便解释了 prompt cache 为什么是刚需——这约 34 KB 每轮重复且前缀完全一致,正是缓存的理想对象。这部分放到第五节讲。
四、召回:一次检索的完整过程
现在到最关键的问题:记忆库里有几十条,模型怎么挑出该看的那条?
答案有点反直觉:没有向量数据库,没有 embedding,没有相似度计算。检索器就是模型自己。
下面这四步是从真实会话 transcript 里还原出来的流程(示例内容沿用上一节的 shop-web 场景):
第 1 步:索引进入上下文。 会话启动时,harness 把 MEMORY.md 全文注入上下文,包装在 <system-reminder> 标签里。模型于是"看见"了那几十行摘要。
第 2 步:模型自己决定要读哪条。 假设当轮任务是"帮我把这个 migration 发到生产"。模型在索引里看到这行:
- [DB migration needs backup](db-migration-needs-backup.md) — 生产迁移前必须手动快照然后主动发起一次工具调用,形如:
{
"type": "tool_use",
"name": "Read",
"input": {
"file_path": "~/.claude/projects/-Users-dev-projects-shop-web/memory/db-migration-needs-backup.md"
}
}注意这条的发起方是 assistant——是模型自己要读的,不是 harness 自动喂的。这一点我在 transcript 里逐条回溯过 tool_use_id,确认发起方就是模型本身。
第 3 步:harness 在返回时植入警告。 工具结果回来时,内容前面被硬插了一段(这段是实测原文):
<system-reminder>
This memory is 13 days old. Memories are point-in-time observations,
not live state — claims about code behavior or file:line citations may be
outdated. Verify against current code before asserting as fact.
</system-reminder>
1 ---
2 name: db-migration-needs-backup
...这段警告不是模型写的,是 harness 按文件修改时间算出天数后拼进去的。在实测的全部会话里统计这类警告,这个天数的取值从 4 天一直到 64 天。
第 4 步:内容成为对话的一部分。 工具结果以 tool_result 的形式落进 user 角色的消息里(这是 Anthropic API 的规范:工具结果属于 user turn),从此在这个会话的后续每一轮都会被重发。
整条链路:
会话启动
│
├─ harness 读盘:CLAUDE.md ×N层 + MEMORY.md
│ └─→ 注入上下文(<system-reminder> 包装)
│
├─ 模型看到索引摘要,判断"这一行和当前任务有关"
│ └─→ 主动 tool_use: Read(memory/db-migration-needs-backup.md)
│
├─ harness 执行读取,前置注入"这条记忆 13 天前写的,请核实"
│ └─→ tool_result 进入 messages 数组
│
└─ 该条记忆在本会话余下所有轮次持续存在为什么这个设计比向量检索聪明
对比一下典型 RAG 方案:
| 维度 | 向量检索 RAG | Claude Code 的索引召回 |
|---|---|---|
| 检索依据 | 文本 embedding 的余弦相似度 | 模型对任务意图的理解 |
| 能否理解否定/条件 | 很差("不要用 X" 和 "要用 X" 向量极近) | 可以 |
| 基础设施 | 需要向量库 + embedding 模型 + 同步 | 一个 markdown 文件 |
| 可调试性 | 差,相似度是黑盒 | 索引就是人能读懂的几十行字 |
| 可人工干预 | 需重建索引 | 用编辑器改一行就行 |
| 检索成本 | 一次 embedding 调用 | 占几 KB 上下文 |
| 规模上限 | 百万级文档 | 索引撑得住的量级(数百条) |
关键差异在否定式记忆上。比如"本项目不要用 npm,统一用 pnpm"这条:查询"帮我加个依赖"和它的语义距离并不近,向量检索容易召不回来;而"要用 npm"和"不要用 npm"的向量又几乎重合,容易召错——相似度分不清立场。
但模型读到索引里那行"本项目统一用 pnpm",配合当前任务是"加个依赖",能直接推断出相关性。用理解替代相似度,这是 LLM 时代才成立的检索方式。
代价当然也有:索引必须放得进上下文。所以这套设计的规模上限是数百条记忆,不是百万文档。它解决的是"关于这个用户和这个项目的长期事实",不是"企业知识库"——两者本就不该用同一套方案。
一个方法论插曲:观察者效应
调研这套机制时踩到一个有意思的坑,值得单独记一笔,因为它对任何想自己动手验证的人都适用。
为了验证"reminder 是否落盘",我 grep 会话记录,发现当前会话命中 8 次。正要下结论,一看内容——全是我自己刚写的调研 prompt,里面恰好包含 "system-reminder" 这个词。
我在观察记忆系统,而观察行为本身正在被写进记忆系统,然后污染了观察结果。
排除污染后的真实结论是:绝大多数 reminder 不落盘。 全部会话文件里只有 9 个含 reminder 痕迹,且都是挂在 tool_result 上的那类——因为工具结果本身必须落盘。而每轮注入的那些(日期、CLAUDE.md 内容、memory 索引)是运行时拼装、用完即弃的,磁盘上根本找不到。
这带来一个重要推论:你无法通过读 transcript 复原当时模型真正看到的完整上下文。 transcript 存的是对话,不是请求。想看真实请求体,只能抓包或读代码——这也是本文为什么必须依赖第二类证据。
五、这些记忆如何抵达服务器端的模型
前面都在讲本地:文件怎么存、怎么被选中。现在是最后一跳——这些磁盘上的文字,怎么变成服务器端模型真正"看见"的东西。
5.1 记忆在请求里的两个位置
一次请求里,"记忆"并不在同一个地方。而这里有一个极其反直觉的事实——我一开始也猜错了。
直觉上,CLAUDE.md 这种"系统级规则"当然应该放在 API 的 system 字段里。但它不在那儿。
(以下实现细节来自开头说明的第二类证据,即 v2.1.87 的反编译还原代码。这一条结论我做了两次独立核查,结果一致。)
真实的分工是这样的:
POST /v1/messages
├── system ← 只放身份、工具说明、env 信息、gitStatus
│ (CLAUDE.md 和记忆索引都不在这里)
│
└── messages[]
├─ [0] user (isMeta) ← ★ CLAUDE.md + 当前日期都在这条
│ <system-reminder>
│ As you answer the user's questions, you can use
│ the following context:
│ # claudeMd
│ Contents of ~/.claude/CLAUDE.md (user configuration): ...
│ Contents of ./CLAUDE.md (project instructions, checked
│ into the codebase): ...
│ Contents of .../memory/MEMORY.md (user's auto-memory,
│ persists across conversations): ...
│ # currentDate
│ Today's date is 2026-08-27.
│
│ IMPORTANT: this context may or may not be relevant to your
│ tasks. You should not respond to this context unless it is
│ highly relevant to your task.
│ </system-reminder>
│
├─ [1] user: "帮我把这个 migration 发到生产"
├─ [2] assistant: tool_use(Read, memory/db-migration-needs-backup.md)
├─ [3] user: tool_result → 召回的记忆正文 ★
└─ ... 本轮新问题还原代码里负责这件事的函数叫 prependUserContext()——名字就写着"前置到 user context"。它把 claudeMd 和 currentDate 拼成一条 role=user、isMeta=true 的消息,插到 messages[0]。
而真正被追加到 system 尾部的,只有 gitStatus 这类东西。
为什么这么设计? 因为 API 有个硬限制:整个请求最多只能打 4 个 cache_control 断点。反编译代码里,负责切分 system 块的函数开头就贴着一句警告:
// IMPORTANT: Do not add any more blocks for caching or you will get a 400而这 4 个额度的实际分配,代码注释里算得很清楚——system 占 1–2 个,CLAUDE.md 占 0–1 个,最后一条消息占 1 个,合计 2–3 个,卡在 4 以内。
所以断点是稀缺资源。CLAUDE.md 是个体积大、按项目变化的动态内容,把它塞进 system 会挤占本就紧张的块预算;挪到 messages[0] 之后,它既独立占一个断点,又因为位于 messages 数组最前面,依然处在稳定的缓存前缀里——缓存收益一点没丢。
一个连官方 prompt 都写错的细节
有意思的是,还原代码里两处文案自己就打架了:记忆提取模块的 prompt 写的是 MEMORY.md is always loaded into your **system prompt**,而另一处写的是 **conversation context**。
按代码实际路径(getClaudeMds → userContext → prependUserContext),后者才对。这说明"CLAUDE.md 在 system prompt 里"是个流传相当广的误解,连写 prompt 的人都会顺手写错。
所以准确的说法是:
- 规则层与记忆索引在
messages[0],一条isMeta的 user 消息,包在<system-reminder>里; - 召回的记忆正文在后续
messages里,以tool_result形式存在; - 两者都在 messages 数组中,都属于稳定前缀,都被缓存覆盖。
这个区分很重要:记忆一旦被召回,它就不再是"记忆",而变成了"对话历史"。它此后每一轮都会被原样重发,直到会话结束或被压缩掉。
5.1.1 顺带解释了三个实测现象
拿到实现细节后,回头看第三、四节的观测数据,几个原本只是"看到了"的现象有了解释:
① CLAUDE.md 有个悄悄的上限。 代码里有个常量 MAX_MEMORY_CHARACTER_COUNT = 40000,超了会在 /doctor 里告警(但不截断)。前面实测那个约 31 KB 的项目级 CLAUDE.md——已经吃掉了近 80%。写得越"周全",离这条线越近,而且每轮都在为它付费。
② 时效警告为什么用"13 天"而不是时间戳。 代码注释直说了原因:
Models are poor at date arithmetic — a raw ISO timestamp doesn't trigger staleness reasoning the way "47 days ago" does.
模型不擅长日期算术——给它 ISO 时间戳,它不会自发意识到"这已经很旧了";给它"47 天前",才会触发对陈旧性的警觉。而且该警告只在记忆超过 1 天时才添加,这解释了为什么实测统计到的天数都是 4 天以上。
③ 记忆注入有多层硬性配额。 单条记忆最多 4,096 B / 200 行(超了截断并提示"用 Read 看全文"),单轮最多 5 条(≈20 KB),单会话累计上限 60 KB。MEMORY.md 索引本身也有 200 行 / 25,000 B 的截断线——超了会被警告"把细节移到 topic 文件里"。
记忆系统从头到尾都在做预算管理。 这也预告了本节末尾要给出的那个定义:记忆的本质是每轮要重新付费的 token。
5.1.2 一个意外发现:存在两代召回机制
这是整个调研里最有信息量的一条,它也解释了一处看似矛盾的观测。
第四节实测到的召回路径是:模型看到索引 → 自己调 Read 工具。但代码里另有一套完全不同的机制:
- 一个叫
findRelevantMemories的模块,会扫描 memory 目录,把每个文件的 frontmatter 渲染成清单; - 然后发起一次独立的小模型调用(Sonnet,
max_tokens: 256,JSON schema 输出)专门做筛选,最多选 5 条; - 选中的内容作为
relevant_memories附件自动注入,主模型完全不需要自己发起 Read。
这个选择器的 prompt 里还有条相当老练的规则:
If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools … DO still select memories containing warnings, gotchas, or known issues about those tools
翻译过来:别推工具的说明书(模型自己会用),但一定要推那个工具的坑。
关键在于——这两套机制是互斥的,由同一个 feature flag 控制:
| flag 状态 | MEMORY.md 索引 | 召回方式 |
|---|---|---|
| 关 | 全量注入 | 模型自己看索引、自己 Read |
| 开 | 不注入 | 小模型预取,自动注入选中的 5 条 |
实测环境收到了完整的 MEMORY.md 索引,所以处在前一态。两代机制的取舍很值得琢磨:
- 第一代(索引 + 自主 Read):透明、可调试,召回决策由主模型带着完整上下文来做;代价是索引占常驻 token,且多花一轮工具调用。
- 第二代(小模型预取):主模型上下文里不必放索引,召回在后台并行完成、零额外轮次;代价是筛选由一个只看得到 frontmatter 的小模型决定,召错了主模型无从察觉。
这是一个典型的工程权衡:用"廉价但视野受限的专职检索器",换"昂贵主模型的上下文额度与轮次"。
顺带一个方法论提醒:这也是为什么第四节的观测与代码描述会有出入——不是谁错了,而是同一个产品里同时存在两条路径,你看到哪条取决于 flag。拿单机观测推断产品全貌是危险的,这也是本文坚持两类证据并行的原因。
5.2 实测:每轮重发 65,000 token,其中新增 3 个
关键问题来了:既然每轮都要把 34 KB 的规则加上全部对话历史重发一遍,成本不会爆炸吗?
答案是 prompt cache。而这件事在 transcript 里留下了完整的量化痕迹——每条 assistant 消息都带着 usage 字段。下面的数字来自同一快照:63 个会话文件、7,600 条真实记录。
你可以自己复现
这些数据就在本地。transcript 是 JSONL,每行一个 JSON,assistant 类型的行里带 message.usage。用一段几行的脚本遍历 ~/.claude/projects/<你的项目>/*.jsonl,把 usage 的四个字段累加起来,就能得到自己的缓存命中率。
先看单轮的一条:
{
"input_tokens": 3,
"cache_creation_input_tokens": 9072,
"cache_read_input_tokens": 65612,
"output_tokens": 452
}这条数据把整个机制讲透了:
- 这一轮模型实际读到的上下文是 65,612 + 9,072 ≈ 74,700 token(包含全部 CLAUDE.md、记忆索引、召回的记忆、以往对话);
- 其中 65,612 token 直接命中缓存,服务器端不需要重新计算;
- 真正新鲜的输入只有 3 个 token。
也就是说,这一轮里 99.996% 的输入是重复内容。这就是"无状态模型 + 每轮全量重发"这个架构能在经济上跑通的原因。
再看会话的第一轮,形态完全不同:
{
"input_tokens": 3,
"cache_creation_input_tokens": 77373,
"cache_read_input_tokens": 0
}cache_read 为 0,cache_creation 是 77,373——这是在建缓存。第一轮付一次全量写入的代价,后续每轮都以极低成本复用。
5.3 全量统计:89.5% 的输入来自缓存
把一次快照里的全部记录(63 个会话文件、7,600 条 usage)加总:
| 项目 | token 数(量级) | 说明 |
|---|---|---|
cache_read_input_tokens | ≈ 14.9 亿 | 命中缓存的输入 |
cache_creation_input_tokens | ≈ 2.53 亿 | 写入缓存 |
input_tokens | ≈ 1.76 亿 | 未缓存的新鲜输入 |
output_tokens | ≈ 1,120 万 | 模型输出 |
缓存命中占比 = 14.9 亿 / (14.9 亿 + 1.76 亿) ≈ 89.5%
为什么这里用量级而不是精确值
统计过程中我发现一件有意思的事:同一个工作区隔几十分钟统计两次,绝对值会变小——会话文件数从 67 降到 63,记录数从 7,801 降到 7,600。原因是 Claude Code 有默认 30 天的清理机制(见 6.8),旧 transcript 会被删掉。
所以绝对 token 数是个会漂的量,写精确到个位反而是假精确。但比率非常稳定——三次统计的缓存命中率都是 89.5%,TTL 分档都是 99.4%。这也是本节所有结论都建立在比率而非绝对值上的原因。
按 Anthropic 公开的定价规则(缓存读取约为基础输入价的 10%,缓存写入约 1.25 倍),如果不走缓存,这部分成本会是现在的近十倍。
顺便一个值得注意的对比:总输入约 19 亿 token,输出只有约 1,120 万——比例约 170:1。 Agent 类应用的成本结构和聊天完全不同,它绝大部分开销在"反复重读上下文",而不是"生成内容"。这也从成本侧解释了为什么记忆必须做成"索引 + 按需召回":每一条无关的常驻记忆,都要在余下的每一轮里被重新收费。
5.4 TTL 实证:5 分钟档占 99.4%
usage 里还有个更细的字段 cache_creation,它把写入缓存的 token 按 TTL 分档。同一快照加总(两档之和与上表的 cache_creation_input_tokens 完全吻合,差值为 0):
| TTL 档位 | token 数 | 占比 |
|---|---|---|
ephemeral_5m_input_tokens | ≈ 2.519 亿 | 99.4% |
ephemeral_1h_input_tokens | 1,457,101 | 0.6% |
结论很清楚:实际生效的几乎全是 5 分钟档。
这里有个容易写错的归因细节。看起来像是"Claude Code 选择了 5 分钟",但反编译代码里的真相更微妙:客户端从不显式发送 ttl: '5m'——全仓唯一写入 TTL 的地方只发 '1h'。5 分钟是省略该字段时的服务端默认值。
而 1h 是条特权路径:需要是内部用户或订阅用户,且调用来源命中一个远端配置的 allowlist。这正好解释了为什么实测那 2.5 亿 token 的缓存写入里只有 0.57% 落在 1h 档——绝大多数请求根本没资格用长 TTL。
代码里还有个耐人寻味的约束:这个判断在会话启动时就被锁定(latch),中途不允许翻转。注释给的理由是——TTL 变化会改变服务端的缓存 key,中途一翻就打爆约 20K token 的既有缓存。同样的 latch 逻辑还用在若干 beta 开关上,理由一致。
缓存是个前缀敏感的脆弱结构:任何影响 key 的参数,宁可整会话锁死,也不能中途改。
5 分钟这个数字有很实际的含义——缓存的有效期是以分钟计的。如果你思考了 6 分钟再回车,缓存已经过期,下一轮要重新支付一次全量写入(按前面那个例子算,就是 7 万多 token)。这也解释了为什么在自动化循环里"睡多久"是个需要认真权衡的参数:睡 270 秒缓存还在,睡 300 秒就得重建。
5.4.1 顺带一个底层细节:整个 messages 只打一个缓存断点
还原代码里,给 messages 数组打 cache_control 标记的逻辑简单到反常——全数组只打一个,位置是最后一条(或倒数第二条)消息:
const markerIndex = skipCacheWrite ? messages.length - 2 : messages.length - 1为什么不多打几个做多级缓存?注释给出的理由已经深入到推理引擎的显存管理层面,大意是:推理侧的 KV cache 按 page 管理,会在轮次之间回收不在缓存边界上的 local attention page。如果打两个 marker,倒数第二个位置的 page 会被多保护一轮,但实际上永远不会有请求从那个位置恢复——纯属浪费。
(这条注释的可信度问题见本文开头的更正框。下一篇文章对同一条注释做了更严格的核查,结论是其中的专有名词在真实二进制里全部 0 命中——建议对照阅读。)
这个细节本身不重要,但若属实,它透露的是:上下文工程的优化可能已经下探到和推理引擎的内存回收策略对齐的程度。记忆系统表面上是"读几个 markdown 文件",底下连着的可能是 GPU 上的 KV cache 分页。
5.5 那么"记忆"到底是什么
把这一节的事实串起来,可以给出一个不浪漫但准确的定义:
记忆的物理真相
在服务器端模型眼里,你的"长期记忆"和"刚说的话"没有任何区别——都只是本次请求 messages 里的一段文本。
区别只存在于客户端:一段来自三个月前写下的 markdown 文件,另一段来自你 3 秒前的键盘。模型无法分辨,也不需要分辨。
所以"记忆"的准确定义是:一段被 harness 决定在本轮重新支付 token 成本的历史文本。
这个定义解释了所有设计取舍。记忆之所以要索引、要按需召回、要设时效、要有写入门槛——归根到底都是因为它每一轮都要重新花钱,而上下文窗口和预算都是有限的。
六、会话跨度:transcript、压缩与遗忘
第三层是流水层。它不进上下文,但它决定了另外两层为什么必须存在。
6.1 一个 jsonl 里塞了二十种东西
每个会话对应一个 {sessionId}.jsonl,追加写、一行一个 JSON 对象,文件权限 0o600。实测环境里有 63 个会话文件,最大的一个 15 MB。
它不只存对话。取一个会话文件统计行类型分布:
assistant 23 user 16 attachment 6 file-history-snapshot 6
mode 5 permission-mode 5 system 5 last-prompt 7 cost-state 2反编译代码里这个联合类型有 20 种条目:消息、摘要、AI 生成的标题、任务摘要、文件历史快照、队列操作、worktree 状态、上下文折叠记录……一个会话的全部可恢复状态都在这一个文件里,不只是对话。
每条消息带的字段也远超 API 所需:
{
"type": "user",
"uuid": "...", "parentUuid": "...",
"timestamp": "...", "cwd": "...", "gitBranch": "...", "version": "...",
"isSidechain": false, "promptId": "...", "permissionMode": "..."
}cwd / gitBranch / version 这些每条消息都重复记录一次——看着浪费,但它让每条消息都能独立还原出"当时在哪个目录、哪个分支、哪个版本下发生的"。恢复逻辑因此不需要额外的元数据文件。
6.2 parentUuid:不是链表,是 DAG
parentUuid 让消息串成一条链,恢复时从最新的叶子沿父指针回溯到根,再反转。
但它实际是有向无环图,不是链表。原因很具体:模型并行调用多个工具时,每个工具结果会挂到不同的父节点上,于是同一个父节点长出多个分支。单纯沿单父回溯会丢掉旁边的分支,所以代码里另有一个专门的补救函数去捞"被孤立的并行工具结果"。
还有个细节能看出这套东西是被生产环境教育过的:progress 类消息不参与父链、也不落盘。代码注释里挂着两个 issue 编号——曾经因为把进度消息塞进父链,导致恢复时真实消息被孤立。
6.3 append-only 的唯一例外
追加写的好处是永不损坏。但有一个例外:流式响应失败会产生"孤儿消息",这时代码会原地删行——读文件尾部 64 KB,定位那一行,截断后回写。
有意思的是定位方式:它搜的是完整的 "uuid":"<target>" 键值对,而不是裸 UUID——因为裸搜会误中别的条目里的 parentUuid 字段。
还有个兜底上限:如果要走全文件重写的慢路径,超过 50 MB 就直接放弃。注释称实测会话文件能涨到 GB 量级(该数字同属不可验证的注释内容)。
6.4 压缩:一次主动的、有损的遗忘
现在到最关键的机制。上下文总会满,满了怎么办?
触发阈值不是百分比。 这是最容易写错的一点——很多科普文章会说"到 92% 触发压缩",但代码里是绝对 token 差值:
有效窗口 = 上下文窗口 − min(最大输出, 20K)
压缩阈值 = 有效窗口 − 13K代入 200K 的模型:有效窗口 180K,压缩在 167K 触发(换算下来约 83.5%,不是 92%)。147K 时开始警告,177K 硬阻断。
压缩不降级模型。 我原以为这种"总结"任务会派给便宜的小模型,但代码里明确用主循环模型,而且同样走下一节要讲的那个 fork 模式——复用主对话已建好的 prompt cache。整个 compact 目录里搜不到任何 haiku 相关字样。
理由不难理解:压缩是有损操作,摘要的质量直接决定这个会话余下的全部命运。这地方省钱是最不划算的。
压缩输出是一份九段式结构化摘要,章节名逐字如下:
Primary Request and Intent / Key Technical Concepts / Files and Code Sections
Errors and fixes / Problem Solving / All user messages
Pending Tasks / Current Work / Optional Next Step两个章节的要求特别有意思:
All user messages—— 原文要求"列出所有非工具结果的用户消息"。为什么用户的话要逐条留?注释说得直白:这些是理解用户反馈和意图变化的关键。模型自己说过的话可以概括,用户说过的话不能。Optional Next Step—— 要求包含原文直引,理由是"确保任务解释不发生漂移(no drift in task interpretation)"。
还有个写作技巧被固化进了流程:模型必须先写一段 <analysis> 草稿再写摘要,而这段草稿随后会被正则整段删掉。注释称它是"a drafting scratchpad that improves summary quality but has no informational value once the summary is written"——用一段一次性的草稿换取摘要质量,写完即弃。
6.5 压缩之后:一条原文都不留
这是最狠的一点:完整压缩不保留任何原始消息。
压缩后的新数组只有四样东西:边界标记 + 摘要 + 重新注入的附件 + hook 结果。那 190K token 的原始对话,在上下文里彻底消失了。
但它并没有真的丢——它还在磁盘的 jsonl 里。而且注入的那条摘要消息里,会附上原 transcript 的路径,供模型需要时自己去 Read 回捞细节。
同时代码会清空 readFileState(已读文件记录),并按预算重新注入附件:最多 5 个文件、总计 50K token、单文件 5K。
这解释了一个日常现象
如果你用 Claude Code 时遇到过"压缩之后它把刚才看过的文件又重读了一遍"——这不是失忆,是设计。压缩把文件内容从上下文里清掉了,同时故意清空了"已读"标记,好让模型重新按当前需要去读。
6.6 恢复:只读最后一个压缩边界之后的部分
--resume 并不是把整个 jsonl 读回来。
压缩产生的边界标记有个关键属性:它的 parentUuid 被写成 null,主动切断父链。而读盘时,代码会扫描到最后一个压缩边界,然后把此前读到的全部内容丢弃:
if (hit) {
s.out.len = 0 // ★ 输出缓冲清零:之前读的全不要了
}两半机制正好咬合:写入时切断父链,读取时截断前缀。 所以 resume 得到的是"最后一个压缩边界之后的那一段",而不是完整历史。那个 15 MB 的会话文件,恢复时可能只读进来最后几百 KB。
恢复过程还有一串清洗动作,每一条都对应一类线上事故:
- 丢弃未完成的
tool_use(否则 API 报错); - 过滤只有 thinking 内容的孤儿消息(注释:"can cause API errors during resume");
- 若判定上一轮被打断,追加一条合成的用户消息:
Continue from where you left off.; - 若最后一条是 user 消息,补一条内容为
No response requested.的 assistant 消息,纯粹为了满足 API 的角色交替要求。
6.7 两个值得记住的工程教训
① 持久化的度量值,在恢复时会变成误导。
压缩保留段里的 assistant 消息,其 usage 字段(input_tokens 等)在恢复时会被全部清零。注释解释了原因:
on-disk input_tokens reflect pre-compact context (~190K) ... Without this, resume → immediate autocompact spiral
磁盘上记的是压缩前的 19 万 token。如果照搬这个数字,恢复后系统会立刻认为"上下文又满了",于是马上再压缩一次——恢复即压缩,无限循环。
② 自动化机制必须配熔断器。
压缩失败连续 3 次就熔断。这个常量旁边挂着一条注释,声称它的存在是因为上线后曾观测到:上千个会话陷入连续失败,个别会话单次运行内失败次数达到四位数,累计每天白白烧掉数十万次 API 调用。
这条注释的可信度
按开头那条更正,这条注释无法用官方产物验证,其中的统计数字有可能并非真实运营数据。
我保留它,是因为"自动重试机制必须配熔断器"这个结论本身独立成立——它不依赖那些数字是否为真。任何"失败就重试"的循环,只要没有熔断,都会在遇到持续性故障时无限烧钱,这是可以从机制本身推出来的。
一个"失败了就重试"的循环,在没有熔断的情况下能造成这种规模的浪费。
6.8 回到主线:这一节解释了 memory 为什么必要
把三层放到时间轴上看,全文的逻辑才闭合:
| 生命周期 | 结局 | |
|---|---|---|
| 对话历史 | 会话内 | 被压缩成摘要,原文从上下文消失 |
| 压缩摘要 | 会话内 | 会话结束即失效 |
memory/*.md | 永久 | 跨会话存活 |
压缩是一次主动的遗忘:它把 19 万 token 的细节换成一段几千 token 的摘要,换取继续对话的能力。会话结束时,连摘要也一起失效。
所以——memory 存在的意义,正是为了在这场必然的遗忘中抢救出那几条真正值得跨会话保留的结论。
这也反过来解释了写入门槛为什么严格(第七节):如果什么都往 memory 里塞,它就退化成第二份 transcript,而 transcript 已经被证明是必须被压缩掉的东西。记忆的价值不在于记得多,而在于筛得准。
顺带一提,这些流水并不永久保存:默认 30 天清理。所以"磁盘上还留着原文"这个安慰也是有期限的。
七、记忆从哪来:一次自动写入的全过程
前面讲的都是"记忆怎么被用"。还剩最后一个问题:这些记忆文件是谁写的?
不是人手写的。是模型自己写的——而且用了一个挺聪明的机制。
7.1 一个共享缓存的"分身"
代码里负责这件事的模块叫 extractMemories,它的文件头注释把设计讲得很清楚:
Extracts durable memories from the current session transcript and writes them to the auto-memory directory. It runs once at the end of each complete query loop (when the model produces a final response with no tool calls). Uses the forked agent pattern — a perfect fork of the main conversation that shares the parent's prompt cache.
拆开看三个关键点:
① 触发时机:每当主对话完成一轮完整回答(模型给出最终回复、不再调工具),才触发一次。不是每条消息都触发。
② 它是主对话的"完美分身":fork 意味着它继承主对话的全部上下文——因此它知道刚才发生的一切,不需要重新被告知。
③ 共享父对话的 prompt cache:这是最妙的一笔。这个分身的上下文前缀和主对话完全一致,所以它直接命中主对话已经建好的缓存。回想第五节那个数字:主对话的 65,612 token 已经在缓存里了,分身来读这份上下文几乎是免费的。
记忆提取这件事之所以能"每轮都做"而不心疼,正是因为它站在主对话的缓存上。 如果每次都要重新上传一遍完整对话历史,这个功能在经济上根本不成立。
7.2 关起来的写权限
这个分身的工具权限被收得很窄(prompt 原文):
Available tools: Read, Grep, Glob, read-only Bash (ls/find/cat/stat/wc/head/tail and similar), and Edit/Write for paths inside the memory directory only. Bash rm is not permitted. All other tools — MCP, Agent, write-capable Bash, etc — will be denied.
翻译:能读、能搜、能写 memory 目录,不能删、不能碰任何别的路径、不能调用其他 agent。
再加上 maxTurns: 5 的硬限制(注释说"正常提取 2–4 轮就够了:读一下 → 写进去"),整个机制是受限、有界、可预期的。
这是很值得学的一点:一个会自动写文件的后台 agent,必须从工具层面被关起来,而不是靠 prompt 里叮嘱它别乱写。 权限收窄是代码级的强制力,不是请求。
7.3 写入规范:两步走
写入动作本身有严格约定:
- 先写一个独立的记忆文件,带
name/description/type的 frontmatter; - 再往
MEMORY.md添一行指针:- [Title](file.md) — 一句话钩子。
规范里有句反复强调的话:
MEMORY.md is an index, not a memory. Never write memory content directly into MEMORY.md.
这就是第三节那个 12:1 压缩比的制度保障——索引一旦开始承载内容,两级架构立刻退化成"全量注入",常驻成本失控。
type 是个闭合的四类枚举(user / feedback / project / reference),每类在 prompt 里都用 XML 结构定义了"什么时候该存""怎么用"。分类不是为了整齐,而是为了标注信息的稳定性预期:user(用户是谁)几乎不变,project(在做什么)会过期。
7.4 防重复写入的几道闸
自动写记忆最大的风险是同一件事被反复记录,把记忆库变成垃圾场。还原代码里能看到几道闸:
- 节流:距上次提取的轮数不够就跳过;
- 去重:如果主 agent 自己已经在这段区间写过记忆,分身就不再重复写;
- 召回侧去重:已经被 Read 读过的、本会话已经出现过的记忆,不会再被召回一次。
最后这条的实现方式值得单独说:它不维护计数器,而是直接扫描当前 message 数组看某条记忆是否已出现过。注释解释了原因——
compact naturally resets both
也就是说:上下文一旦被压缩,这些"已经看过"的标记会自动失效,因为压缩把原文抹掉了,记忆理应可以重新被召回。用"扫描当前状态"替代"维护独立计数器",让压缩这件事不需要额外的清理逻辑。
状态尽量从事实推导,而不是另存一份。 这条经验在任何有压缩/截断机制的系统里都适用。
全景:一次对话里,记忆的完整流转
前七节是拆开讲的。这里把所有部件合成一张图——注意它是个闭环:
┌─ 磁盘(持久层)─────────────────────────────────────────┐
│ CLAUDE.md ×N 层 memory/*.md + MEMORY.md 索引 │
│ <session>.jsonl(transcript,默认 30 天后清理) │
└───────────────┬─────────────────────────────────────────┘
│ 会话启动 + 每轮重新拼装
▼
┌─ 请求装配(客户端)─────────────────────────────────────┐
│ system : 身份 + 工具说明 + env + gitStatus │
│ messages[0] : CLAUDE.md + MEMORY.md 索引 + 当前日期 │
│ (整块包在 <system-reminder> 里,isMeta) │
│ messages[n] : 对话历史 + 召回的记忆(以 tool_result 形式)│
│ cache_control: system 1–2 个 + 最后一条消息 1 个 │
└───────────────┬─────────────────────────────────────────┘
│ HTTPS,每轮全量重发
▼
┌─ 服务端模型(无状态)───────────────────────────────────┐
│ 命中缓存前缀 → 只计算新增部分 │
│ 实测单轮:缓存读 65,612 token + 新增 3 token │
│ 缓存 TTL 5 分钟(超时则整个前缀重新写入) │
└───────────────┬─────────────────────────────────────────┘
│ 上下文逼近 167K(200K 模型)
▼
┌─ 压缩:一次主动的有损遗忘 ──────────────────────────────┐
│ 主模型(不降级)生成 9 段式摘要 → 替换掉全部原文 │
│ 边界标记 parentUuid = null,切断父链 │
│ 原文仍在 jsonl;resume 时从最后一个边界截断读回 │
└───────────────┬─────────────────────────────────────────┘
│ 每轮完整回答结束后
▼
┌─ 记忆提取(主对话的 fork,共享 prompt cache)───────────┐
│ 工具权限被收窄:只能写 memory/ 目录,不能删 │
│ 产出:新的记忆文件 + MEMORY.md 里的一行指针 │
└───────────────┬─────────────────────────────────────────┘
│
└──────► 写回磁盘,供下一个会话召回(闭环)这张图里最值得停下来看一眼的是最后那条回边。
正是它让整个系统从"每次都要重新解释自己是谁"变成"越用越懂你":对话产生结论 → 结论被提取成文件 → 下次会话被召回 → 参与新的对话。而中间那道"压缩"环节则不断把细节丢掉——一边遗忘,一边沉淀,剩下的就是记忆。
八、可复用的设计模式
如果你要给自己的 Agent 做一套记忆系统,这套设计里有八条可以直接抄。
1. 索引与正文分离
这是整套设计的核心杠杆。常驻成本(索引)与记忆容量(正文)解耦,容量可以涨一个数量级而常驻开销只线性微增。
反面做法是把所有记忆全量注入——记忆库一大,上下文立刻爆掉,而且大部分 token 花在与当轮无关的内容上。
2. 一个文件 = 一个事实
不要把记忆存进单个大 JSON 或 SQLite。一条一文件带来的好处是复合的:
- 可 diff / 可 git——记忆的演变有版本历史;
- 可人工编辑——发现记错了,用编辑器改一行,不需要任何工具链;
- 可单条删除——
rm即遗忘,不用担心破坏其他记忆; - 可被通用工具读取——模型用现成的
Read就能召回,不需要专门的检索工具。
最后一点尤其精妙:因为记忆就是普通文件,召回不需要新增任何基础设施,复用文件读取能力即可。
3. 存理由,不只存结论
Why: / How to apply: 这个约定让记忆从"死规则"变成"可推理的知识"。只有结论的记忆,遇到边界情况就只能瞎猜;带理由的记忆能泛化到没见过的场景。
4. 给记忆标注时效
This memory is 13 days old... Verify against current code before asserting as fact.
这条设计承认了一个残酷现实:记忆一旦写下就开始腐烂。 尤其是关于代码的记忆——文件改了、函数删了、参数变了,而记忆还停在三个月前。
处理方式不是禁止存储,而是把不确定性一起传递给模型:告诉它这是几天前的快照,请核实。这比假装记忆永远正确要健壮得多。
5. 让模型当检索器
在数百条量级上,模型对意图的理解完胜向量相似度——尤其是否定式、条件式的记忆。代价是索引必须进上下文,所以这套方案有明确的规模上限。
判断标准很简单:记忆条数的索引能不能塞进上下文?能,就别上向量库;不能,说明你要解决的是知识库检索问题,那是另一套架构。
6. 按"漏掉的代价"决定是否常驻
这是 CLAUDE.md 与 memory 分层的真正依据:
| 漏掉的后果 | 策略 | |
|---|---|---|
| 规则、规范、红线 | 直接违规,可能造成实际损失 | 全量常驻,硬吃 token 成本 |
| 离散事实、偏好 | 这轮不够贴心,可容忍 | 索引 + 按需召回 |
别按"重要性"分,按**"漏掉会不会出事"**分。
7. 写入必须有门槛
记忆系统最容易死于滥记。Claude Code 的写入规则里有几条明确的负面清单,很值得借鉴:
- 不存代码库已经记录的(目录结构、历史修复、git log)——读代码就知道的,存了是冗余,还会过期;
- 不存只对当前对话有用的——那是短期上下文,不是长期记忆;
- 存之前先查重,能更新就不要新建;
- 发现记错了直接删。
还有个隐含要求很关键:相对时间必须转成绝对日期。"下周上线"三个月后读到就是垃圾,"2026-09-03 上线"永远可读。
8. 自动化机制必须配熔断器
这条不只针对记忆系统,但记忆与压缩恰好是最容易踩的地方——因为它们都是后台自动触发、失败对用户不可见的机制。
回看第六节那个例子:压缩失败在没有熔断的情况下,据称单个会话能连续失败到四位数次、每天累计浪费数十万次 API 调用(该数字来自不可验证的注释,但结论不依赖它)。
凡是"条件满足就自动重试"的循环,都必须假设它会失败,并且会一直失败。 记忆提取、自动压缩、后台索引——这些机制的共同特点是用户不会主动报告它们出错,所以只能靠自己熔断。
顺带一条相关经验(来自 7.4):状态尽量从当前事实推导,而不是另存一份计数器。 "这条记忆是否已展示过"通过扫描当前消息数组来判断,压缩一发生标记就自动失效——不需要任何清理代码。多存一份状态,就多一处需要在压缩、恢复、分叉时同步的地方。
三个已知的脆弱点
这套设计也不是没有问题,用之前要清楚:
- 路径即主键,且编码不可逆。 记忆目录名由路径编码而来,重命名或移动项目目录,等于丢掉整份记忆——旧目录会变成一个再也不会被读到的孤儿,而且因为编码不可逆,你甚至不容易看出它原本对应哪个项目。实测环境里就躺着好几个这样的孤儿目录。
- 索引一致性靠自觉。 写完记忆文件还得手工往
MEMORY.md加一行指针。这是两步操作,中断在中间就会产生"存在但检索不到"的幽灵记忆。 - 无法复原当时的真实上下文。 如前所述,运行时拼装的注入内容不落盘。事后审计"模型当时到底看到了什么"是做不到的——这对排查"它怎么会这么答"很不友好。
结语
绕回开头那个问题:本地的 Claude Code 凭什么有长期记忆?
答案是:它没有。有记忆的是你的文件系统。
模型端始终是那个无状态的纯函数,每次请求都从零开始。所谓长期记忆,是 harness 在每一轮对话开始前,从磁盘上把过去的结论重新读出来、挑出相关的几条、拼成 token、连同你的新问题一起发过去。模型"记得"你,只是因为它每次都被重新告知了一遍。
这个视角一旦建立,很多事情会变得清楚:
- 记忆的物理形态是 token,所以它一定有成本,一定需要预算和取舍;
- 记忆的质量上限是索引的质量——召不回来的记忆等于不存在,那行
description比正文更值得斟酌; - 记忆的可靠性天然是衰减的,所以时效标注不是可选项。
也正因如此,我倾向认为这类系统的护城河不在模型侧,而在上下文工程:存什么、怎么索引、何时召回、如何标注不确定性。这些决定了 Agent 是"用着顺手"还是"每次都要重新解释一遍自己是谁"。
而最让我觉得优雅的一点是——这套东西的实现,只是一堆 markdown 文件加一个索引。没有向量库,没有 embedding 服务,没有额外的数据库。可以用 cat 读,用 git 管,用 vim 改,用 rm 忘。
在一个热衷于给所有问题套上向量检索的时代,这份克制本身就是一种设计品味。