Skip to main content

记忆系统:只记偏好,不记代码

Claude Code 的 auto memory 可能是你用了之后觉得最惊艳的功能之一。但看了源码才知道,「记住什么」和「不记什么」之间的权衡,比表面看起来讲究得多。

物理实现:纯文件系统

本节源码:src/memdir/memdir.ts

记忆系统的实现极其简单。没有数据库、没有向量存储、没有复杂的索引,就是文件系统上的一个目录:

~/.claude/projects/<memory>/
├── MEMORY.md # 索引文件,每次会话自动加载
├── user_role.md # 用户身份信息
├── feedback_testing.md # 行为反馈
├── project_status.md # 项目状态
└── reference_docs.md # 外部资源指针

核心入口是 MEMORY.md。源码里硬编码了三个常数:

export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

200 行、25 KB——超过任一限制直接截断,并追加一条警告。

为什么要这么严格?因为 MEMORY.md 每次对话都会被完整注入到 system prompt。如果不限制大小,记忆会不断膨胀,最终吃掉大量上下文窗口。200 行 × ~125 字符/行 ≈ 25 KB,是 Anthropic 实测后认为「够用但不浪费」的平衡点。

四类记忆

本节源码:src/memdir/memoryTypes.ts

memoryTypes.ts 定义了四种记忆类型,每种有不同的用途和存储策略:

类型记什么变化速度例子
user用户的身份、角色、偏好很慢「用户是高级工程师,喜欢 TypeScript」
feedback用户对 AI 行为的反馈中等「不要在测试中 mock 数据库」
project项目的状态、目标、deadline较快「API 重构目标是 4 月 15 日完成」
reference外部系统的位置和用途较慢「bug 追踪在 Linear 的 INGEST 项目」

这个分类不是拍脑袋定的。每种类型对应不同的召回策略——userfeedback 几乎在每次对话中都有用(它们影响 AI 怎么做事),project 只在相关上下文中有用,reference 只在用户提到外部系统时才需要。

核心设计决策:只记偏好,不记代码

本节源码:src/memdir/memdir.ts

源码里有一段很明确的「不记什么」清单:

## What NOT to save in memory

> 本节源码:`src/memdir/memdir.ts`
- Code patterns, conventions, architecture, file paths, or project structure
- Git history, recent changes, or who-changed-what
- Debugging solutions or fix recipes
- Anything already documented in CLAUDE.md files
- Ephemeral task details

为什么不记代码相关的信息?一个例子就清楚了。

假设 AI 记住了「验证函数 validateUsersrc/utils/auth.ts 的第 42 行」。下次对话时你重构了代码,这个函数被移到了 src/services/auth/validation.ts 的第 15 行。AI 的记忆现在是错误的,而且它不知道这条记忆已经过时

更糟的是,AI 可能基于这条错误记忆直接开始改错的文件。记忆不是搜索——搜索永远给你最新结果,记忆给你的是「上次记住的东西」。

所以 Claude Code 的设计原则是:代码相关的事实永远实时读取,不记忆。记忆只存人的偏好和判断,这些东西变化慢,不会因为代码重构而失效。

持久化应该存「变化慢的东西」。模型的能力(搜索、读文件)可以实时获取「变化快的东西」。把变化快的东西放进记忆,就是在制造定时炸弹。

记忆提取管线

本节源码:src/services/extractMemories/extractMemories.ts

记忆提取不是每条消息都触发。源码的限流逻辑保证只在 AI 完成一轮回答后才考虑触发。

为什么用独立的 fork agent?

提取工作由一个独立的 fork agent 完成。这个子 AI 继承了主对话的上下文(所以它知道刚才聊了什么),但权限被严格限制:只能读文件和写入记忆目录,连 Bash 都不能跑

为什么不让主 AI 顺便做?两个工程理由:

  • 上下文隔离。提取过程会产生大量中间推理——分析对话、判断什么值得记、格式化记忆文件。如果在主对话中做,这些推理会占用主对话的上下文窗口。Fork agent 有自己的上下文,不污染主对话。
  • 安全隔离。记忆文件是持久化的、跨会话生效的。如果主 AI 可以自由写记忆,恶意 prompt 注入可能通过记忆系统实现持久化——一次对话写入恶意记忆,未来所有对话都中招。把记忆写入权限限制到一个最小权限的 agent,缩小了攻击面

Cache 共享设计

这里有个工程难题:fork agent 必须和主对话共享完全相同的 system prompt、工具列表和模型,两者才能复用同一份 prompt cache。如果 fork 改了其中任何一项,cache key 就不同了,fork 需要重新处理整个 prompt,速度会慢 10 倍以上

所以 fork agent 的权限控制不是通过修改工具列表实现(那样会破坏 cache),而是通过一个 canUseTool 钩子函数。工具列表一样,但钩子在运行时拦截不允许的操作

这个设计把「cache 效率」和「安全隔离」两个看似矛盾的需求都满足了——看似多余的间接层恰恰是关键工程决断。

双向互斥

主 agent 和提取 fork 之间是互斥关系:

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
// 主 agent 已经写了,跳过后台提取
return
}

如果主 agent 凭自己的判断直接保存了记忆,后台提取就跳过这一轮。避免两个 agent 同时改同一个文件造成的竞态。

重叠请求的 Stash 模式

如果用户连续快速发消息,可能在上一轮提取还没完成时就触发了新一轮。源码用 stash-and-continue 模式:不启动新的提取,而是把新请求暂存。等当前一轮完成后,再处理暂存的请求。这避免了提取任务的堆积。

MEMORY.md 的索引设计

本节源码:src/memdir/memdir.ts

MEMORY.md 不是记忆本体,而是一个索引。每条记忆的详细内容存在独立文件里(如 user_role.md),MEMORY.md 只列一行指针:

- [用户身份](user_role.md) — 高级工程师,偏好 TypeScript 和简洁风格
type: user

这个设计解决了 200 行上限的约束:索引可以指向任意数量的记忆文件,每个文件可以有任意长度。MEMORY.md 的限制只约束「同时可见的记忆条目数量」,而不是「总记忆容量」。

description 字段的双重作用

每个记忆文件有标准 frontmatter:

---
name: 用户角色
description: 高级工程师,偏好 TypeScript 和简洁风格
type: user
---
# 记忆内容...

description 字段不只是给人看的注释——它会被主动用于记忆召回。新会话开始时,系统用一个独立的 Sonnet 侧查询(sideQuery),把所有记忆文件的 description 和当前对话上下文做匹配,选出最相关的几条加载到上下文中

description 写得太模糊(如「一些偏好」),匹配不到具体场景。写得太窄(如「关于 React 组件的测试」),其他场景下用不上。好的 description 应该具体到足够匹配,又抽象到足够泛化

记忆的年龄感知

本节源码:src/memdir/memoryAge.ts

源码里有一个精细的年龄感知机制

export function memoryFreshnessText(mtimeMs: number): string {
const d = memoryAgeDays(mtimeMs)
if (d <= 1) return ''
return `This memory is ${d} 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.`
}

1 天内的记忆不加警告。超过 1 天的会明确提示:

这是 N 天前的观察,不是当前状态。涉及代码行为或文件位置的声称可能已过时,请先验证再当真

这个设计防止了一个微妙的问题:AI 可能基于 3 天前的记忆自信地说「这个函数在第 42 行」,但代码已经重构了。年龄警告让 AI 在使用旧记忆时保持怀疑,而不是盲目信任

autoDream:睡眠中整理记忆

本节源码:src/services/autoDream/autoDream.ts

还有一个 autoDream 功能——名字很有意味,像人在睡觉时大脑整理白天的记忆。autoDream 在后台静默运行,用户完全不知道,是纯反思性的、非破坏性的整理。

触发要过三重门,全部满足才运行:

检查方式阈值
时间门文件 mtime距上次巩固 ≥ 24 小时
会话门会话计数积累了 ≥ 5 个新会话
锁门文件锁无其他进程正在巩固

另有 10 分钟的扫描节流防止频繁检查。运行失败的话系统会回滚锁的 mtime,让下轮重试。

整理过程分四个阶段:

  1. Orient(定向) —— 审阅现有记忆文件,了解当前全貌
  2. Gather Signal(收集信号) —— 从最近会话日志里提取值得记忆的信息
  3. Consolidate(整合) —— 把新信息写入持久记忆文件。相对日期(如「下周四」)必须转成绝对日期,否则未来无法解读
  4. Prune / Index(修剪/索引) —— 删除过时记忆,确保 MEMORY.md 保持在 200 行 / 25 KB 以内

autoDream 只使用只读的 Bash 访问,纯反思性、不破坏现有数据。

记忆的信任问题

本节源码:src/memdir/memdir.ts

源码里有一段很关键的指令:

Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources.

记忆的价值是提供「时间点上的真相」。在使用任何记忆之前,模型应当先验证它是否仍然成立——读当前文件、查当前状态。代码会变、依赖会变、项目状态会变,但记忆不会主动更新

给 AI 产品开发者的教训

本节源码:src/memdir/memdir.ts

最简单的存储方案往往是对的

纯文本文件、Markdown 格式、文件系统目录结构——没有数据库、没有 API、没有复杂的查询语言。这个方案的维护成本几乎为零,而且用户可以直接用文本编辑器查看和修改自己的记忆文件

分类存储,分级信任

不同类型的记忆有不同的变化速率和使用频率。用户偏好几乎不变,每次都应该考虑。项目状态变化快,只在相关时使用。一刀切的「全部记住」或「全不记」都不如分类管理

给记忆系统设上限

200 行、25 KB。没有上限的记忆系统会膨胀到无法管理。上限迫使系统做取舍,只留真正有价值的记忆,丢弃琐碎的细节

用独立 agent 做记忆提取

安全隔离 + 上下文隔离 + cache 共享。这三个需求看似矛盾,但通过 canUseTool 钩子函数实现了优雅的统一。

记录成功,不只记录失败

Feedback 类记忆有个反直觉要求——不只记录用户的纠正(「不要这样做」),也要记录用户的验证(「是的,就这样」)。如果只记录失败,AI 会变得过于保守,什么都不敢做。记录成功让 AI 知道哪些做法是被认可的,在类似场景下可以继续沿用。

时间规范化是必须的

用户说「下周四」,AI 必须在记忆中存为 2026-04-10。否则两周后回看这条记忆,「下周四」指的是哪个周四?这个规则看起来琐碎,但它解决了跨时间召回记忆时的歧义问题

即使用户要求记,也要追问

源码有一条有趣的规则:当用户说「帮我记住这周的 PR 列表」时,AI 不应该直接记 PR 列表(那是临时信息),而应该追问「其中什么是意外的或非显而易见的?」那个才值得记。这防止了记忆系统被当成笔记本用