1. 核心认知:不是一个内存系统,而是六个

Claude Code 代码库里 memory 这个词被用于多个相互独立的子系统。最容易混淆的就是这一点——很多人以为有一个统一的"记忆模块",实际上它由六个职责不同、目录不同、生命周期不同的子系统组成。

先把全貌理清:

子系统 目录 生命周期 写入者 召回方式
Auto memory (memdir) ~/.claude/projects/<slug>/memory/ 跨会话 主 agent + 后台 extractMemories fork MEMORY.md 始终在系统提示里;findRelevantMemories 选 ≤5 条注入
Agent memory ~/.claude/agent-memory/<agentType>/ 跨会话,按 agent 类型隔离 被派生的 subagent 自己 loadAgentMemoryPrompt 注入到该 agent 的系统提示
Team memory <autoMemPath>/team/ 跨会话,团队共享 同 auto memory + 服务器同步 同 auto memory,用 buildCombinedMemoryPrompt
Session memory ~/.claude/session-memory/ 单会话,为压缩续命 extractSessionMemory post-sampling hook 压缩时加载,不跨会话召回
CLAUDE.md 指令文件 User/Project/Local/Managed 路径 跨会话 用户手动编辑 getMemoryFiles 加载进系统提示
AgentSummary 内存里的 AgentProgress 短暂 UI 状态 30s 定时器 fork 仅 UI,不进任何内存库

⚠️ 最常见的混淆

/memory 命令编辑的是旧的 CLAUDE.md 指令文件(User/Project/Local/Managed),跟 auto-memory memdir 是两套东西。前者是用户手写的全局指令,后者是 agent 自动提炼的跨会话记忆。


2. 数据模型:四类封闭分类法与索引机制

2.1 四种内存类型

src/memdir/memoryTypes.ts 定义了一个封闭枚举,只允许四种类型,刻意排除可从代码或 git 推导的内容:

// memoryTypes.ts:14-19
export const MEMORY_TYPES = ['user', 'feedback', 'project', 'reference'] as const
  • user:用户的角色、目标、偏好
  • feedback:用户对你工作方式的纠正或确认(“record from failure AND success”)
  • project:进行中的工作/目标/事故,不可从代码或 git 推导;相对日期必须转绝对日期
  • reference:指向外部系统(Linear、Grafana、Slack)的指针

WHAT_NOT_TO_SAVE_SECTION 明确排除:代码模式、架构、git 历史、debug 方案、CLAUDE.md 已有内容、临时任务状态——即使用户明确要求保存也适用。这是一个硬规则,不是建议。

2.2 单条内存的格式

每条内存是一个带 YAML frontmatter 的 .md 文件:

---
name: <memory name>
description: <一句话描述 - 用于在未来对话中判断相关性,要具体>
type: <user | feedback | project | reference>
---
<memory 内容;feedback/project 类型需写成:规则/事实 + **Why:** + **How to apply:**>

description 字段是召回时的相关性信号(详见第 5 节),所以提示词反复强调 “be specific”。一个模糊的 description 意味着这条记忆永远不会被召回选中。

2.3 MEMORY.md 索引文件

// memdir.ts:34-38
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

MEMORY.md 是索引,不是内存本身。每条一行、<150 字符:- [Title](file.md) - one-line hook。无 frontmatter。提示词明确:“Never write memory content directly into MEMORY.md”。

保存是两步流程(buildMemoryLines):先写主题文件,再在 MEMORY.md 加一行指针。这两步合在一起才构成一次完整的"记忆写入"。


3. 存储与路径解析

3.1 是否启用

isAutoMemoryEnabled()paths.ts:30-55)按优先级链判断(先定义者胜):

  1. CLAUDE_CODE_DISABLE_AUTO_MEMORY 环境变量
  2. CLAUDE_CODE_SIMPLE/--bare → 关
  3. 远程模式无 CLAUDE_CODE_REMOTE_MEMORY_DIR → 关
  4. settings.json 的 autoMemoryEnabled(支持项目级 opt-out)
  5. 默认:开

3.2 主目录解析

getAutoMemPath()paths.ts:223-235,memoized)解析逻辑:

  1. CLAUDE_COWORK_MEMORY_PATH_OVERRIDE 环境变量
  2. settings.json 的 autoMemoryDirectory(只信 trusted 源:policy/local/user,projectSettings 被排除——防恶意 repo 把内存目录指向 ~/.ssh
  3. <memoryBase>/projects/<sanitized-git-root>/memory/,其中 memoryBase = ~/.claudeCLAUDE_CODE_REMOTE_MEMORY_DIR

getAutoMemBase()findCanonicalGitRoot,让同一仓库的所有 worktree 共享一个内存目录。

3.3 路径安全校验

validateMemoryPathpaths.ts:109-150)拒绝:相对路径、根/近根路径、Windows 盘根(C:)、UNC 路径、null 字节。isAutoMemPath 用 normalize 防遍历。这是防止恶意项目配置把记忆目录指向敏感路径的安全防线。


4. 三大核心内存:写入与召回

在六个子系统中,Auto memoryAgent memorySession memory 是三个最核心的、与日常对话直接相关的内存。下面按"何时记 / 怎么记 / 何时召回 / 怎么召回"四个维度逐一拆解。

  • 🟦 Auto Memory:跨会话的"长期记忆"。主会话自动提炼,每轮积极召回,下次会话直接生效。
  • 🟪 Agent Memory:子 agent 专属的长期记忆。只在子 agent 派生时加载,按 agent 类型隔离。
  • 🟩 Session Memory:本会话的草稿本。为 autocompact 续命,对话结束就作废,永不跨会话。

4.1 Auto Memory — 主会话长期记忆

目录: ~/.claude/projects/<项目>/memory/

何时记

两个写手,互斥:

  • 写手 A — 主 agent 自己。 系统提示里始终带着保存指令(howToSave),任何时候它判断某事值得记,就直接写。用户明确说"记住 X"时,指令要求立即存。
  • 写手 B — 后台抽取(extractMemories)。 在每轮查询循环结束(模型给出最终回复、不再调工具)时,由 handleStopHooks 触发。有门控:功能开关打开、auto memory 开着、非远程模式、只主线程(子 agent 跳过)、节流。

互斥机制: hasMemoryWritesSince 检查主 agent 这轮是否已往记忆目录写过文件;写过就跳过抽取、只推进游标。两者每轮二选一,防重复。

// extractMemories.ts:348-360 — 互斥检查
if (hasMemoryWritesSince(lastExtractCursor)) {
  // 主 agent 这轮已经写过内存了,fork 跳过,只推进游标
  return
}
怎么记
  • 都用 Write/Edit 工具写目录下的 .md 文件,一文件一记忆
  • 两步流程:写 .md + 在 MEMORY.md 加一行指针
  • 后台抽取的具体方式:分叉(fork)主对话(共享提示词缓存),先把现有记忆扫成清单预塞进去(省一轮查目录),5 轮预算
  • 权限沙箱 createAutoMemCanUseTool:只读工具放开,Edit/Write 只能写在记忆目录内
何时召回

两条路:

  • 路径 A — 常驻。 会话启动构建系统提示时,loadMemoryPrompt 把 MEMORY.md 拼进系统提示,全程在场。
  • 路径 B — 按请求。 每次用户发消息(且超过一个词),触发 findRelevantMemories 做动态召回。
怎么召回
  • 路径 A:同步读 MEMORY.md,截断到 200 行/25KB,拼进系统提示
  • 路径 B:扫最新 200 个文件头 → 拼成清单 → 调一次小模型(Sonnet)按文件名+描述选 ≤5 条 → 读全文注入

4.2 Agent Memory — 子 agent 专属长期记忆

目录: 按 scope 分三种

Scope 路径 VCS 提示词引导
user ~/.claude/agent-memory/<type>/ 不进 VCS “keep learnings general, apply across all projects”
project <cwd>/.claude/agent-memory/<type>/ 建议进 VCS,团队共享 “tailor to this project”
local <cwd>/.claude/agent-memory-local/<type>/ gitignore “tailor to this project and machine”
何时记
  • 只有子 agent 自己跑时写,它判断值得记就写
  • 没有后台抽取extractMemories 通过 agentId 检查跳过子 agent
  • 快照机制:加载 agent 定义时,若有快照且本地记忆为空,initializeFromSnapshot 把快照文件拷进本地;若快照比本地新,标记 pendingSnapshotUpdate,主线程 agent 启动时弹窗让用户选是否合并
怎么记
  • 子 agent 用 Write/Edit 写自己类型的目录
  • 同样的前置元数据格式、一文件一记忆,复用 buildMemoryPrompt
  • Write/Edit/Read 被强制注入子 agent 的工具表;写权限通过 isAgentMemoryPath 开窗放行
// loadAgentsDir.ts:726-730 — 注入到子 agent 系统提示
getSystemPrompt: () => {
  if (isAutoMemoryEnabled() && memory) {
    const memoryPrompt = loadAgentMemoryPrompt(agentType, memory)
    return systemPrompt + '\n\n' + memoryPrompt
  }
  return systemPrompt
}
何时召回 & 怎么召回
  • 只在子 agent 被派生(或用 SendMessage 恢复)时,loadAgentMemoryPrompt 构建子 agent 的系统提示
  • 每次派生/恢复都从磁盘重读,没有跨调用的缓存
  • 和主会话的 auto memory 完全隔离,不同 agent 类型互不共享

4.3 Session Memory — 本会话压缩续命的草稿本

目录: ~/.claude/session-memory/

何时记
  • 每次模型输出后触发 extractSessionMemory(post-sampling hook),但门槛控制
  • 初始化:对话累计 ≥ 10k token(一次性)
  • 更新:自上次更新增长 ≥ 5k token(每次都要)
  • 且 ≥ 3 次工具调用,或上一轮 assistant 没工具调用(自然断点)
  • 只主线程(querySource === 'repl_main_thread'),跳过子 agent

关键区别

Session memory 的 hook 每轮模型输出都触发一次(门槛控制),auto memory 的 extractMemories 只在整轮 query loop 结束时触发。两者频率不同、目的不同。

怎么记
  • 分叉主对话,但沙箱极严:createMemoryFileCanUseTool 只允许 Edit 工具编辑那一个会话笔记文件,其他全拒
  • 更新单文件,分节:标题 / 当前状态 / 文件和函数 / 工作日志 / 错误与纠正
  • 上限:每节 2000 token、总 12000 token
何时召回 & 怎么召回
  • 不跨会话召回
  • 只在自动压缩时:压缩前 waitForSessionMemoryExtraction 等 ≤15 秒让在途抽取完成,然后会话笔记作为上下文喂给压缩
  • 不像 auto 那样每轮当附件注入,它只在压缩这个节点出场

5. 召回机制的设计哲学:没有向量检索

这是整个 Agent Memory 设计中最反直觉、也最有意思的决策:没有向量数据库、没有 embedding、没有 token 重叠排序

动态召回管线(findRelevantMemories)的工作方式是:

  1. 扫描scanMemoryFiles 递归 readdir,过滤 .md(排除 MEMORY.md),对每个文件只读前 30 行(frontmatter),按 mtime 降序,上限 200 个文件
  2. 拼清单- [type] filename (ISO时间): description
  3. LLM 选择:调 sideQuery,用 getDefaultSonnetModel()(比主循环更小更快的模型),系统提示要求 “up to 5”、“不确定就不选”,输出 JSON schema
  4. 读 + 注入:选中后读全文(带截断),作为 { type: 'relevant_memories' } 附件,渲染成 <system-reminder>
// findRelevantMemories.ts:77-141 — LLM 选择器
// 系统提示要求 "up to 5", "不确定就不选"
// 输出 JSON schema: { selected_memories: string[] }
// max_tokens: 256
// recentTools 一并传给选择器,避免给正在用的工具再推参考文档
// 结果过滤到 validFilenames(防幻觉文件名)

设计意图

排序完全委托给 Sonnet 的 sideQuery,唯一信号是 filename + description + type + 时间戳。这正是 description 字段为何被反复强调要具体——模糊的 description = 永远不会被召回的记忆

新鲜度信号

memoryAge.ts 提供 memoryAge()(如 “47 days ago”)。注释说得很直白:“模型不擅长日期算术,原始 ISO 时间戳触发不了陈旧推理”。memoryFreshnessNote() 包成 <system-reminder>,且 header 在附件创建时预算好,避免渲染时调 Date.now() 导致 prompt cache 失效。

七层防爆

层级 机制 限制
1 索引截断 MEMORY.md ≤ 200 行 / 25KB
2 扫描上限 最多扫 200 个文件
3 选择上限 Sonnet 最多选 5 条
4 单条截断 每条 ≤ 200 行 / 4KB
5 单轮上限 每轮注入 ≤ 20KB
6 会话上限 累计 ≤ 60KB
7 去重 已注入过的、模型已读过的不再注入;压缩重置计数

6. Query Loop 中的出场时机

src/query.ts:241queryLoop 是一个异步生成器(async function*),核心是个 while (true) 循环。一次迭代 = 一次模型调用 + 这次调用的工具执行。一个用户请求 = 一个 query loop,可能跑很多轮迭代。

下面把一轮迭代从头到尾走一遍,标出三个 memory 各在哪一步出场:

进入循环前(每个用户请求只做一次)

// query.ts:296-304
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(state.messages, state.toolUseContext)

Auto 召回 在 while 循环之前发起一次,后台跑 findRelevantMemories。注释说得很清楚:“Fired once per user turn - the prompt is invariant across loop iterations, so per-iteration firing would ask sideQuery the same question N times.”(提示词在各轮迭代间不变,每轮都问就是重复问同一个问题)。它不阻塞模型流式输出,结果在流式过程中被消费。

while 每轮迭代(按代码顺序)

步骤 代码位置 动作 Memory 出场
365 准备消息(取压缩边界之后)
379 控制工具结果体积
400 裁剪旧历史
413 微压缩(折叠旧工具结果)
440 上下文折叠
453 自动压缩 Session 读
628 阻塞上限检查
654-863 调模型(流式)+ 流式工具执行
999-1009 post-sampling hooks Session 写
1015 中断处理
1062+ 判断是否继续(needsFollowUp)
1267 handleStopHooks Auto 抽取
1357 返回 completed

关键代码段

步骤 ⑨ — Session memory 写入(每轮模型输出后触发):

// query.ts:999-1009
if (assistantMessages.length > 0) {
  void executePostSamplingHooks([...messagesForQuery, ...assistantMessages], ...)
}
// extractSessionMemory 注册在这里面。注意是 void(发了就不管,异步不等)。
// 但 session memory 内部有门槛:初始化要 10k token、增长要 5k token——不达标就什么都不做。

步骤 ⑫ — Auto memory 抽取(整轮结束时触发):

// stopHooks.ts:141-153
if (!isBareMode()) {
  void executePromptSuggestion(stopHookContext)
  if (feature('EXTRACT_MEMORIES') && !toolUseContext.agentId && isExtractModeActive()) {
    void extractMemoriesModule!.executeExtractMemories(stopHookContext, ...)
  }
  void executeAutoDream(stopHookContext, ...)
}
// 注意条件:!toolUseContext.agentId(只主线程,子 agent 跳过)

Agent memory 怎么和 query loop 挂上

Agent memory 不在主 query loop 里直接出场。它的接入是间接的:

主 query loop 某轮迭代里,模型调了 Agent 工具(步骤 ⑧)→ 这个工具作为一次工具调用执行 → 工具内部启动一个子 agent → 子 agent 跑它自己的 query loop(递归)→ 在子 query loop 开始构建系统提示时,loadAgentMemoryPrompt 把该类型的 MEMORY.md 注入子 agent 的系统提示。

嵌套关系

主循环某轮迭代里发起子 agent,子 agent 跑完自己的整个 query loop 返回结果,主循环拿着结果继续下一轮。子 agent 的 query loop 也会走 ⑨ 和 ⑫,但 agentId 检查会让 session memory 写入和 auto memory 抽取都跳过——子 agent 的 query loop 不写主会话的 auto/session 记忆,只写自己的 agent memory


7. 场景实战:多轮对话完整时间线

用一个具体场景把三个内存在多轮对话里的行为讲清楚。

场景设定

用户在新会话里说:“帮我看看 login.tsx 里登录按钮点了没反应。另外按我们之前定的——测试别 mock 数据库。”

这个场景能同时触发三条线:召回旧 feedback(测试别 mock)、记录新项目上下文、派生子 agent 查 auth 子系统。

会话启动:构建系统提示(用户还没发消息)

  • Auto memoryloadMemoryPrompt() 跑一次:读 MEMORY.md,截断到 200 行/25KB,拼进系统提示。同时 ensureMemoryDirExists 建好目录。"测试别 mock 数据库"那条 feedback 已经在系统提示里了(假设之前存过)。
  • Agent memory:什么都不做。子 agent 还没派生,它的内存目录不会被读。
  • Session memory:什么都不做。新会话刚开始没有笔记文件;上一次会话的 session-memory 不会被加载。

第 1 轮:用户发消息 → 模型回复

  • Auto 召回(模型调用前)findRelevantMemories 扫所有 .md 的 frontmatter,拼成 manifest,让 Sonnet sideQuery 挑 ≤5 条相关的,注入成 <system-reminder> 附件。"测试别 mock"很可能被选中注入。去重:已注入过的、模型已读过的不再注入。
  • Session 写(模型回复后)extractSessionMemory hook 被触发,但 shouldExtractMemory 检查阈值:初始化要 ≥10k tokens。这轮还太早,跳过。

第 2 轮:模型边查边修,中途派生子 agent

  • Agent memory(派生子 agent 瞬间)loadAgentMemoryPrompt 执行:读该 agent 类型自己的 MEMORY.md,拼到子 agent 的系统提示尾部。强制注入 Write/Edit/Read 工具,写权限通过 isAgentMemoryPath 开窗。它读的是 agent 类型专属目录,和主会话的 auto memory 完全隔离。
  • Session 写(子 agent 跑完后):阈值可能达标了(token 增长 ≥5k 且 ≥3 次工具调用):fork 出 runForkedAgent,只允许 FileEditTool 编辑那一个 session-memory 文件,更新各节。子 agent 期间可能往自己的内存目录写了新 .md(比如发现 auth 中间件的某个坑),这些写不会进主会话的 auto memory

第 N 轮:query loop 结束(模型给出最终回复)

  • Auto 抽取(handleStopHooks 触发)extractMemories 启动:runForkedAgent fork 主对话(共享 prompt cache),先 scanMemoryFiles 预注入现有内存 manifest,让 fork agent 从这轮对话里提炼持久、跨会话的事实。互斥检查 hasMemoryWritesSince:如果主 agent 这轮已自己写过,fork 就跳过。写完新 .md + 更新 MEMORY.md 索引。

第 N+1 轮:自动压缩(autocompact)

  • Session memory 发挥作用:压缩前,waitForSessionMemoryExtraction 先等 ≤15s 让在途抽取跑完。然后 session-memory 文件被作为上下文喂给压缩:压缩后的对话保留了"当前状态/涉及的文件函数/工作日志",不会因为删掉旧消息而失忆。
  • Auto memory 与压缩的关系:MEMORY.md 在系统提示里,不在消息历史里,压缩碰不到它,照常在。但之前动态注入的那些 <system-reminder> 相关内存附件在消息历史里——会被压缩删掉。这反而是设计意图:压缩删掉旧附件后去重集自然重置,下一轮可以重新按需注入。

会话结束 → 下次新会话

  • 会话结束(Auto)drainPendingExtraction 等 ≤60s 让在途的 extractMemories 跑完再关进程,避免半截抽取丢失。若开了 team memory,stopTeamMemoryWatcher 把 debounce 里的待推送 flush 上服务器。
  • 下次新会话(Auto)loadMemoryPrompt 把上一会话 extractMemories 写进 MEMORY.md 的新条目全部加载进来。用户这次说"再帮我测下登录",模型一开始就知道"测试别 mock 数据库"、"login.tsx 之前改过哪里"等。这就是 auto memory 的核心价值。
  • 下次新会话(Agent):下次派生同类型子 agent 时,从磁盘读到上次该 agent 写的内存。不同 agent 类型互不共享。
  • 下次新会话(Session):上次的文件不加载。新会话从空白开始,等阈值达标后重新建笔记。

8. Team Memory:跨成员同步

Team memory 是 auto memory 的子目录 <autoMemPath>/team/,按 git repo scope,跨 org 成员共享。

同步语义

  • Pull 覆盖本地(server wins per-key)
  • Push 只传 delta:只上传本地 hash 与 serverChecksums 不同的 key
  • 删除不传播:删本地文件不会删服务器,下次 pull 会恢复

乐观锁与冲突解决

PUT 带 If-Match: "<checksum>",服务器返 412 表示冲突。冲突时 fetchTeamMemoryHashes 探测 per-key checksum(不下载正文,省带宽),重算 delta,重试(最多 MAX_CONFLICT_RETRIES = 2)。本地编辑不被静默丢弃:本地 wins on conflict。

批处理与上限

  • batchDeltaByBytes:按 key 排序后贪心装箱到 MAX_PUT_BODY_BYTES = 200_000,避开网关 413
  • MAX_FILE_SIZE_BYTES = 250_000 单文件上限
  • entry 数上限从服务器结构化 413 的 extra_details.max_entries 学到,缓存后下次按它截断

Watcher 与密钥扫描

  • 启动时先 pull,再 fs.watch({recursive:true}) 监听目录,DEBOUNCE_MS = 2000 写后 2s 静默再 push
  • isPermanentFailure:no_oauth/no_repo/4xx → 设 pushSuppressedReason 抑制重试,防无限循环(注释提到一个 no_oauth 设备 2.5 天发 167K push 事件)
  • 密钥扫描:每个文件在上传前 scanForSecrets(gitleaks 规则)。命中则整个文件跳过不上传,只记 ruleId,不记路径、不记值

9. 关键设计决策与反直觉点

① 没有 schema 版本/迁移系统

系统靠优雅降级parseMemoryType 对未知 type 返 undefined,parseFrontmatter 吞 YAML 错误并重试自动加引号,scanMemoryFilesPromise.allSettled 容错单文件。没有迁移脚本,没有版本号。

② 没有压缩/compaction

唯一的容量控制是读取时截断 MEMORY.md(200 行/25KB)。主题文件本身从不截断。靠行为提示词(“按主题组织,不要按时间”、“不要写重复内存”)维持整洁。这是一种"用模型理解力替代工程机制"的设计选择。

③ 没有向量检索

召回完全靠 Sonnet sideQuery 读 filename + description。这意味着 description 的质量直接决定记忆的可用性。

④ AgentSummary 不是内存

src/services/AgentSummary/agentSummary.ts 只是每 30s fork 一次生成 3-5 词的 UI 进度字符串(“Reading runAgent.ts”),skipTranscript: true,存 AgentProgress,不进任何内存库。

⑤ Prompt cache 友好

freshness header 在附件创建时预算好,避免渲染时 Date.now() 把 “3 days ago” 变 “4 days ago” 而打碎 prompt cache。这类细节贯穿整个设计——一切可能让 prompt 变化的操作都被提前固化。

⑥ extractMemories 的 prompt-cache 共享

runForkedAgent 是主对话的完美分叉,共享父对话的 prompt cache。这意味着后台抽取几乎不花额外的 prompt 编码成本——它复用了主对话已经编码好的上下文。

设计哲学总结

整个系统的设计可以概括为一句话:用模型的理解力替代工程机制。没有向量检索(用 LLM 选),没有 schema 迁移(用优雅降级),没有 compaction(用行为提示词)。唯一投入大量工程的是安全防线(路径校验、权限沙箱、密钥扫描)和性能优化(prompt cache 共享、预取、节流)。


三者分工一句话

  • Auto Memory —— 为未来的我记笔记。 每轮都积极召回(MEMORY.md 常驻 + Sonnet 选 ≤5 注入),整轮结束时抽取。压缩不丢,下次会话直接生效。
  • Agent Memory —— 某个专家 agent 自己的小本本。 只在它上场时翻开,按 agent 类型隔离,和主会话井水不犯河水。没有后台抽取,靠子 agent 自己写。
  • Session Memory —— 这场对话的草稿纸。 只为撑过压缩,每轮按门槛尝试更新,压缩前必读,但对话结束就作废,永不跨会话。

三者都用 runForkedAgent 这个原语来写,但写的目录、写的时机、召回的方式各管一段,互不越界。这正是"六个独立子系统、不是一个大内存系统"的具体体现——每个子系统都有自己的生命周期、自己的安全边界、自己的召回路径,通过 query loop 的不同节点接入,共同构成了 Claude Code 的完整记忆能力。


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐