上一篇讲的是"上下文快满时,怎样把旧对话压成摘要";本篇讲的是另一个更容易混淆的问题:
Claude Code 到底怎样把信息保存下来,并在未来的会话里重新想起来?
配合下面这个 demo 一起看:
用户:记住,我看复杂源码时喜欢"角色地图 + 逐行中文注释 + 反事实表"。阅读顺序:先看 第 0 章 建立地图,再沿着"开门 → 告知规则 → 加载索引 → 写入 → 召回"的主链路阅读。
目录
- [第 0 章:先建立一张地图](#第 0 章:先建立一张地图 "#%E7%AC%AC-0-%E7%AB%A0%E5%85%88%E5%BB%BA%E7%AB%8B%E4%B8%80%E5%BC%A0%E5%9C%B0%E5%9B%BE")
- [第 1 章:总开关和目录定位 ------ Memory 存到哪里](#第 1 章:总开关和目录定位 —— Memory 存到哪里 "#%E7%AC%AC-1-%E7%AB%A0%E6%80%BB%E5%BC%80%E5%85%B3%E5%92%8C%E7%9B%AE%E5%BD%95%E5%AE%9A%E4%BD%8D--memory-%E5%AD%98%E5%88%B0%E5%93%AA%E9%87%8C")
- [第 2 章:先告诉模型怎么记 ------
loadMemoryPrompt](#第 2 章:先告诉模型怎么记 —— loadMemoryPrompt "#%E7%AC%AC-2-%E7%AB%A0%E5%85%88%E5%91%8A%E8%AF%89%E6%A8%A1%E5%9E%8B%E6%80%8E%E4%B9%88%E8%AE%B0--loadmemoryprompt") - [第 3 章:启动时加载什么 ------
getMemoryFiles与getUserContext](#第 3 章:启动时加载什么 —— getMemoryFiles 与 getUserContext "#%E7%AC%AC-3-%E7%AB%A0%E5%90%AF%E5%8A%A8%E6%97%B6%E5%8A%A0%E8%BD%BD%E4%BB%80%E4%B9%88--getmemoryfiles-%E4%B8%8E-getusercontext") - [第 4 章:模型亲自写记忆 ------ 主题文件 +
MEMORY.md](#第 4 章:模型亲自写记忆 —— 主题文件 + MEMORY.md "#%E7%AC%AC-4-%E7%AB%A0%E6%A8%A1%E5%9E%8B%E4%BA%B2%E8%87%AA%E5%86%99%E8%AE%B0%E5%BF%86--%E4%B8%BB%E9%A2%98%E6%96%87%E4%BB%B6--memorymd") - [第 5 章:模型漏记了怎么办 ------
extractMemories](#第 5 章:模型漏记了怎么办 —— extractMemories "#%E7%AC%AC-5-%E7%AB%A0%E6%A8%A1%E5%9E%8B%E6%BC%8F%E8%AE%B0%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E--extractmemories") - [第 6 章:未来怎么想起来 ------ 相关记忆异步召回](#第 6 章:未来怎么想起来 —— 相关记忆异步召回 "#%E7%AC%AC-6-%E7%AB%A0%E6%9C%AA%E6%9D%A5%E6%80%8E%E4%B9%88%E6%83%B3%E8%B5%B7%E6%9D%A5--%E7%9B%B8%E5%85%B3%E8%AE%B0%E5%BF%86%E5%BC%82%E6%AD%A5%E5%8F%AC%E5%9B%9E")
- [第 7 章:Session Memory ------ 当前会话的滚动会议纪要](#第 7 章:Session Memory —— 当前会话的滚动会议纪要 "#%E7%AC%AC-7-%E7%AB%A0session-memory--%E5%BD%93%E5%89%8D%E4%BC%9A%E8%AF%9D%E7%9A%84%E6%BB%9A%E5%8A%A8%E4%BC%9A%E8%AE%AE%E7%BA%AA%E8%A6%81")
- [第 8 章:Agent Memory 与 Team Memory ------ 作用域扩展](#第 8 章:Agent Memory 与 Team Memory —— 作用域扩展 "#%E7%AC%AC-8-%E7%AB%A0agent-memory-%E4%B8%8E-team-memory--%E4%BD%9C%E7%94%A8%E5%9F%9F%E6%89%A9%E5%B1%95")
- [第 9 章:Memory、Compact、Context Collapse 到底什么关系](#第 9 章:Memory、Compact、Context Collapse 到底什么关系 "#%E7%AC%AC-9-%E7%AB%A0memorycompactcontext-collapse-%E5%88%B0%E5%BA%95%E4%BB%80%E4%B9%88%E5%85%B3%E7%B3%BB")
- [附:五套 Memory 对照表](#附:五套 Memory 对照表 "#%E9%99%84%E4%BA%94%E5%A5%97-memory-%E5%AF%B9%E7%85%A7%E8%A1%A8")
- [附:反事实表 ------ 少一层会发生什么](#附:反事实表 —— 少一层会发生什么 "#%E9%99%84%E5%8F%8D%E4%BA%8B%E5%AE%9E%E8%A1%A8--%E5%B0%91%E4%B8%80%E5%B1%82%E4%BC%9A%E5%8F%91%E7%94%9F%E4%BB%80%E4%B9%88")
- 一页纸总结
第 0 章:先建立一张地图
Claude Code 源码里的 memory 不是一个单独功能,而是五套名字相似、职责不同的机制。
| 角色 | 类比 | 保存时间 | 主要文件 |
|---|---|---|---|
| Instructions Memory | 公司规章 | 跨会话 | CLAUDE.md、.claude/rules/*.md |
| Auto Memory | 个人知识库 | 跨会话 | memory/MEMORY.md + 主题文件 |
| Session Memory | 本次会议纪要 | 当前会话 | session-memory/.../summary.md |
| Agent Memory | 某类专家自己的笔记 | 跨 Agent 调用 | agent-memory/<agentType>/ |
| Team Memory | 团队共享 Wiki | 跨用户、跨机器 | memory/team/ + 服务端同步 |
本篇主角
本篇的主角是 Auto Memory。它的完整生命线是:
text
启动 Claude Code
│
├─ loadMemoryPrompt()
│ 告诉模型:什么值得记、写到哪里、格式是什么
│
├─ getMemoryFiles()
│ 加载 CLAUDE.md 与 Auto Memory 的 MEMORY.md 索引
│
▼
用户说:"记住,我喜欢逐行中文注释"
│
├─ 路 A:主 Agent 当场 Write/Edit 记忆
│
└─ 路 B:本轮结束后 extractMemories 后台补记
│
▼
主题文件 feedback_source_walkthrough.md
+
MEMORY.md 中的一行目录
│
▼
未来某次用户又问源码
│
├─ 扫描最多 200 个主题文件的 frontmatter
├─ sideQuery 最多选择 5 个相关文件
├─ 异步读取正文
└─ 作为 relevant_memories attachment 注入主对话
一句话主线
Claude Code Memory 不是"把整段聊天永久塞进模型",而是把值得长期保留的信息写成 Markdown 文件;平时只加载短索引,真正需要时再选择少量主题文件注入上下文。
先纠正三个常见误解
| 误解 | 实际情况 |
|---|---|
| Memory 是向量数据库 | 不是。主体是本地 Markdown + frontmatter + LLM 相关性选择 |
MEMORY.md 存全部记忆 |
不是。它是目录,正文放在独立主题文件 |
| Session Memory 等于长期记忆 | 不是。它主要服务当前长会话和 compact |
第 1 章:总开关和目录定位 ------ Memory 存到哪里
📄 文件:src/memdir/paths.ts 第 21-55、79-89、198-258 行
🎬 对应 demo:Claude Code 启动,先判断这次是否允许使用长期记忆
1.1 总开关 isAutoMemoryEnabled
ts
export function isAutoMemoryEnabled(): boolean {
// ① 环境变量明确要求关闭:立即关闭
const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY
if (isEnvTruthy(envVal)) {
return false
}
// ② 环境变量明确写成 0/false:相当于明确要求打开
if (isEnvDefinedFalsy(envVal)) {
return true
}
// ③ --bare 简化模式:关闭自动发现和后台记忆行为
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
return false
}
// ④ 远程环境没有持久盘:即使写了,下次也不存在,所以关闭
if (
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
!process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
) {
return false
}
// ⑤ 用户 settings.json 显式配置优先
const settings = getInitialSettings()
if (settings.autoMemoryEnabled !== undefined) {
return settings.autoMemoryEnabled
}
// ⑥ 默认开启
return true
}
这是一条"第一个明确答案获胜"的优先级链:
text
环境变量
> --bare
> 远程环境是否有持久目录
> settings.json
> 默认开启
1.2 基础目录 getMemoryBaseDir
ts
export function getMemoryBaseDir(): string {
// 远程/Cowork 挂载了持久盘时,使用挂载目录
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
}
// 本机默认使用 ~/.claude
return getClaudeConfigHomeDir()
}
1.3 项目级 Auto Memory 路径
ts
function getAutoMemBase(): string {
// 优先找 canonical git root
// 所有 worktree 因此共享同一份项目记忆
return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot()
}
export const getAutoMemPath = memoize(
(): string => {
// ① Cowork/用户可信设置可以直接覆盖完整目录
const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
if (override) {
return override
}
// ② 默认:~/.claude/projects/<规范化项目根>/memory/
const projectsDir = join(getMemoryBaseDir(), 'projects')
return (
join(projectsDir, sanitizePath(getAutoMemBase()), 'memory') + sep
).normalize('NFC')
},
// ③ 按 projectRoot 做 memoize key
() => getProjectRoot(),
)
export function getAutoMemEntrypoint(): string {
// ④ 入口索引固定叫 MEMORY.md
return join(getAutoMemPath(), 'MEMORY.md')
}
demo 假设项目是 /repo/shop,最终大致得到:
text
~/.claude/projects/-repo-shop/memory/
├── MEMORY.md
└── ...
🔑 小白重点(第 1 章)
- Memory 是项目隔离的:不同仓库默认进入不同目录。
- worktree 又是共享的:通过 canonical git root,避免每个 worktree 各记一套、互相矛盾。
- 没有持久盘就不开:远程临时容器里写"长期记忆"没有意义。
- 路径覆盖有安全边界 :项目提交的
.claude/settings.json不能把目录偷偷改成~/.ssh;只有可信设置来源可以覆盖。
第 2 章:先告诉模型怎么记 ------ loadMemoryPrompt
📄 文件:src/constants/prompts.ts 第 491-506 行
📄 文件:src/memdir/memdir.ts 第 187-315、409-506 行
🎬 对应 demo:模型还没有读任何记忆正文,先收到一份"记忆系统使用说明书"
Memory 要生效,第一步不是读文件,而是让模型知道:
- 哪些内容值得保存;
- 哪些内容绝对不要保存;
- 保存到哪个目录;
- 使用什么文件格式;
- 什么时候应该召回。
2.1 Memory Prompt 被放进系统提示
ts
const dynamicSections = [
// ...其他动态系统提示...
// ① 把 Memory 规则作为一个独立的 system prompt section
systemPromptSection('memory', () => loadMemoryPrompt()),
// ...其他动态系统提示...
]
systemPromptSection 会缓存这一段,所以不是每一轮都重新扫描目录、重新拼提示。
2.2 loadMemoryPrompt 选择运行模式
ts
export async function loadMemoryPrompt(): Promise<string | null> {
// ① 判断 Auto Memory 总开关
const autoEnabled = isAutoMemoryEnabled()
// ② 新召回模式开启时,不要求维护 MEMORY.md 索引
const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_moth_copse',
false,
)
// ③ 长生命周期助手模式:新信息写每日 append-only 日志
if (feature('KAIROS') && autoEnabled && getKairosActive()) {
return buildAssistantDailyLogPrompt(skipIndex)
}
// ④ Team Memory 开启:生成"私有 + 团队"组合规则
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) {
const autoDir = getAutoMemPath()
const teamDir = teamMemPaths!.getTeamMemPath()
await ensureMemoryDirExists(teamDir)
return teamMemPrompts!.buildCombinedMemoryPrompt(
extraGuidelines,
skipIndex,
)
}
// ⑤ 普通模式:只有个人 Auto Memory
if (autoEnabled) {
const autoDir = getAutoMemPath()
await ensureMemoryDirExists(autoDir)
return buildMemoryLines(
'auto memory',
autoDir,
extraGuidelines,
skipIndex,
).join('\n')
}
// ⑥ 总开关关闭:不向模型介绍 Memory
return null
}
2.3 buildMemoryLines 告诉模型什么
核心规则可以压缩成下面几段:
text
# auto memory
你有一个文件型持久记忆系统,目录是 <memoryDir>
用户明确说"记住"时:
立即保存
用户说"忘掉"时:
找到对应文件并删除或更新
允许的类型:
user / feedback / project / reference
保存步骤:
1. 写独立主题文件
2. 在 MEMORY.md 增加一行指针
不要保存:
代码结构、Git 历史、临时任务、CLAUDE.md 已有内容
召回时:
Memory 只是过去某时刻的观察
涉及当前代码、文件、flag 时必须重新验证
生成逻辑见 memdir.ts L187-L265。
2.4 四种长期记忆类型
ts
export const MEMORY_TYPES = [
'user', // 用户是谁、目标是什么、知识背景如何
'feedback', // 用户纠正或确认过的协作方式
'project', // 无法从代码推导的项目背景、决策、期限
'reference', // 外部系统和资料的入口
] as const
我们的 demo:
text
"我看复杂源码时喜欢角色地图 + 逐行注释 + 反事实表"
它既描述了用户偏好,也描述了希望助手如何协作。实际保存时最适合 feedback,正文可以写成:
markdown
---
name: source_explanation_style
description: 用户阅读复杂源码时偏好角色地图、逐行中文注释和反事实表
type: feedback
---
解释复杂源码时,先给角色地图,再沿调用链逐行添加中文注释,最后补反事实表。
**Why:** 用户需要先建立全局心智模型,再进入复杂控制流。
**How to apply:** 面对跨文件调用链、状态机、压缩或调度逻辑时采用此结构。
🔑 小白重点(第 2 章)
loadMemoryPrompt()注入的是使用规则,不是所有记忆正文。- Prompt 明确禁止保存可从代码重新推导的信息,避免 Memory 退化成过期的代码快照。
MEMORY.md是目录,不是正文仓库。- "Memory 说某函数存在"不等于"这个函数现在仍存在",所以源码要求再次
grep或读文件验证。
第 3 章:启动时加载什么 ------ getMemoryFiles 与 getUserContext
📄 文件:src/utils/claudemd.ts 第 790-1007、1136-1195 行
📄 文件:src/context.ts 第 152-188 行
🎬 对应 demo:未来开启一次新会话,CC 先加载规章和短索引
这一章要分清两个动作:
text
loadMemoryPrompt()
= 告诉模型"怎么使用记忆系统"
getMemoryFiles()
= 真的去磁盘找本次需要注入的文件
3.1 getMemoryFiles 的加载顺序
ts
export const getMemoryFiles = memoize(async () => {
const result: MemoryFileInfo[] = []
const processedPaths = new Set<string>()
// ① 企业/管理员级指令
result.push(...await processMemoryFile(
getMemoryPath('Managed'),
'Managed',
processedPaths,
includeExternal,
))
// ② 用户全局指令 ~/.claude/CLAUDE.md
if (isSettingSourceEnabled('userSettings')) {
result.push(...await processMemoryFile(
getMemoryPath('User'),
'User',
processedPaths,
true,
))
}
// ③ 从文件系统根部向当前目录逐级加载项目规则
for (const dir of dirs.reverse()) {
result.push(...await processMemoryFile(
join(dir, 'CLAUDE.md'),
'Project',
processedPaths,
includeExternal,
))
result.push(...await processMemoryFile(
join(dir, '.claude', 'CLAUDE.md'),
'Project',
processedPaths,
includeExternal,
))
// 还会递归读取 .claude/rules/*.md
// 以及私有的 CLAUDE.local.md
}
// ④ 最后加载 Auto Memory 的短索引 MEMORY.md
if (isAutoMemoryEnabled()) {
const { info: memdirEntry } = await safelyReadMemoryFileAsync(
getAutoMemEntrypoint(),
'AutoMem',
)
if (memdirEntry) {
result.push(memdirEntry)
}
}
// ⑤ Team Memory 开启时,再加载团队索引
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) {
const { info: teamMemEntry } = await safelyReadMemoryFileAsync(
teamMemPaths!.getTeamMemEntrypoint(),
'TeamMem',
)
if (teamMemEntry) {
result.push(teamMemEntry)
}
}
return result
})
3.2 为什么加载顺序很重要
源码从"范围大、优先级低"加载到"范围小、优先级高":
text
Managed
↓
User
↓
仓库上层目录
↓
更靠近 CWD 的目录
↓
Local
↓
AutoMem / TeamMem 索引
越靠后的内容越接近当前工作环境,模型通常会给予更高注意力。
3.3 @include 怎么处理
processMemoryFile 会:
- 标准化路径;
- 用
processedPaths防止重复和循环; - 检查
claudeMdExcludes; - 解析 symlink;
- 读取正文并找出
@path; - 递归处理引用文件;
- 达到最大深度时停止。
核心代码见 claudemd.ts L615-L685。
3.4 真正注入 userContext
ts
export const getUserContext = memoize(async () => {
// ① --bare 或禁用变量可能关闭自动发现
const shouldDisableClaudeMd =
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
// ② 找文件 → 按实验开关过滤 → 拼成一整段上下文
const claudeMd = shouldDisableClaudeMd
? null
: getClaudeMds(
filterInjectedMemoryFiles(await getMemoryFiles()),
)
// ③ 作为名为 claudeMd 的 userContext 字段返回
return {
...(claudeMd && { claudeMd }),
currentDate: `Today's date is ${getLocalISODate()}.`,
}
})
这里有一个很容易讲错的细节:
text
Memory 的"行为规则"
→ loadMemoryPrompt()
→ system prompt
CLAUDE.md / MEMORY.md 的"文件内容"
→ getMemoryFiles() + getClaudeMds()
→ userContext
不是所有 Memory 内容都直接塞进 system prompt。
3.5 新召回模式下为什么不再注入索引
ts
export function filterInjectedMemoryFiles(files) {
const skipMemoryIndex =
getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false)
if (!skipMemoryIndex) return files
// 新模式下,AutoMem/TeamMem 由相关性预取按需注入
return files.filter(
f => f.type !== 'AutoMem' && f.type !== 'TeamMem',
)
}
因此存在两种召回范式:
text
旧模式:
每次加载 MEMORY.md 短索引
模型根据索引自行决定要不要 Read 主题文件
新模式 tengu_moth_copse:
不注入 MEMORY.md
后台 selector 直接挑相关主题文件并附加正文
🔑 小白重点(第 3 章)
CLAUDE.md是"规章",Auto Memory 是"经验";两者共用部分加载基础设施,但语义不同。getMemoryFiles被 memoize,同一会话不会每轮都遍历磁盘。- compact、切换 worktree、设置同步等场景会清缓存,让下一次重新读取。
- 新召回模式的核心变化是:从"总给目录"变成"直接给最多几个相关正文"。
第 4 章:模型亲自写记忆 ------ 主题文件 + MEMORY.md
📄 文件:src/memdir/memdir.ts 第 199-265 行
📄 文件:src/memdir/memoryTypes.ts 第 180-270 行
🎬 对应 demo:主 Agent 识别到用户明确要求"记住",当场保存
4.1 为什么必须拆成两层
假设所有内容都直接追加到 MEMORY.md:
text
MEMORY.md
├─ 用户偏好 100 行
├─ 项目背景 300 行
├─ 外部系统 200 行
└─ 历史反馈 500 行
每次新会话都会付出整本加载成本,而且越用越大。
所以 CC 使用"图书馆目录 + 独立书籍":
text
memory/
├── MEMORY.md # 只放一行一条的目录
├── feedback_source_walkthrough.md # 详细正文
├── user_role.md
└── reference_dashboard.md
4.2 两步保存协议
ts
const howToSave = [
'Saving a memory is a two-step process:',
// ① 主题文件:保存完整内容和 frontmatter
'**Step 1** --- write the memory to its own file ...',
...MEMORY_FRONTMATTER_EXAMPLE,
// ② MEMORY.md:只添加一行短指针
'**Step 2** --- add a pointer to that file in `MEMORY.md`...',
// ③ 明确强调:索引不是正文
'`MEMORY.md` is an index, not a memory...',
]
demo 最终写成:
text
memory/
├── MEMORY.md
└── feedback_source_walkthrough.md
MEMORY.md:
markdown
- [源码讲解偏好](feedback_source_walkthrough.md) --- 复杂源码先给角色地图,再逐行注释并补反事实表
主题文件保存完整的 Why 和 How to apply。
4.3 索引的硬上限
ts
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000
export function truncateEntrypointContent(raw: string) {
// ① 先按 200 行截断
// ② 再按约 25 KB 截断
// ③ 尽量在换行处切,避免切断半行
// ④ 末尾追加 WARNING,提醒模型整理索引
}
4.4 什么不应该写
源码明确排除:
text
× 代码模式、架构、文件路径、项目结构
原因:应读取当前代码
× Git 历史、谁改了什么
原因:git log / git blame 才是事实源
× 调试过程和修复配方
原因:最终代码和 commit 才是事实源
× CLAUDE.md 已经写过的规则
原因:重复注入
× 当前任务进度、临时状态
原因:只对当前会话有用
4.5 用户说"忘掉"怎么办
Prompt 没有设计单独数据库删除 API,而是让模型:
text
找到对应主题文件
↓
删除错误条目或修改正文
↓
同步更新 MEMORY.md 指针
也就是说,Memory 的 CRUD 本质上就是受权限控制的文件 CRUD。
🔑 小白重点(第 4 章)
- 主题文件是知识,
MEMORY.md是导航。 - frontmatter 的
description非常关键,未来的相关性选择器主要靠它判断是否值得召回。 - 按"语义主题"组织,而不是按"2026-08-09 第 3 次对话"组织。
- 显式说"记住"也不能突破"不保存临时噪声"的规则。
第 5 章:模型漏记了怎么办 ------ extractMemories
📄 文件:src/query/stopHooks.ts 第 133-156 行
📄 文件:src/services/extractMemories/extractMemories.ts 第 296-615 行
🎬 对应 demo:主 Agent 没有当场写文件,本轮结束后后台书记员补记
5.1 触发点:完整一轮结束
ts
if (!isBareMode()) {
if (
feature('EXTRACT_MEMORIES') &&
!toolUseContext.agentId &&
isExtractModeActive()
) {
// fire-and-forget:不挡住用户看到主回复
void extractMemoriesModule!.executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage,
)
}
}
这里的时机不是"每次工具调用后",而是 query loop 已经给出最终回答、没有继续调工具时。
5.2 initExtractMemories 保存哪些运行状态
ts
export function initExtractMemories(): void {
// 尚未结束的后台提取 Promise
const inFlightExtractions = new Set<Promise<void>>()
// 游标:上一次处理到哪条消息
let lastMemoryMessageUuid: string | undefined
// 防止并行写同一目录
let inProgress = false
// 距离上次提取经过了多少个合格回合
let turnsSinceLastExtraction = 0
// 忙碌期间又来一次触发:只保留最新上下文,稍后补跑
let pendingContext
}
源码:extractMemories.ts L290-L325
这套状态可以想成快递分拣站:
text
lastMemoryMessageUuid = 上次包裹处理到哪
inProgress = 当前传送带是否有人
pendingContext = 忙碌时新来的最后一批包裹
inFlightExtractions = 关机前仍需等待的任务
5.3 主 Agent 写过就不重复提取
ts
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
// ① 主对话已经 Write/Edit 过 Auto Memory
// ② 后台 Agent 再跑只会制造重复
// ③ 所以跳过,但仍推进游标
const lastMessage = messages.at(-1)
if (lastMessage?.uuid) {
lastMemoryMessageUuid = lastMessage.uuid
}
return
}
源码:extractMemories.ts L345-L360
于是两条写入路径是同一回合互斥的:
text
主 Agent 已写
→ 后台不写
主 Agent 没写
→ 后台检查有没有值得保存的内容
5.4 正式启动后台 fork
ts
// ① 扫描已有主题文件的 frontmatter
// 直接把清单给后台 Agent,省掉一次 ls
const existingMemories = formatMemoryManifest(
await scanMemoryFiles(memoryDir, createAbortController().signal),
)
// ② 生成"只分析最近 N 条消息"的提取指令
const userPrompt = teamMemoryEnabled
? buildExtractCombinedPrompt(
newMessageCount,
existingMemories,
skipIndex,
)
: buildExtractAutoOnlyPrompt(
newMessageCount,
existingMemories,
skipIndex,
)
// ③ 完美 fork 主会话,复用 prompt cache
const result = await runForkedAgent({
promptMessages: [
createUserMessage({ content: userPrompt }),
],
cacheSafeParams,
canUseTool: createAutoMemCanUseTool(memoryDir),
querySource: 'extract_memories',
forkLabel: 'extract_memories',
skipTranscript: true,
maxTurns: 5,
})
源码:extractMemories.ts L388-L427
5.5 权限沙箱
后台提取 Agent 可以:
text
✓ Read / Grep / Glob
✓ 只读 Bash
✓ 在 Auto Memory 目录内 Write/Edit
不可以:
text
× 修改项目源码
× 执行写操作 Bash
× 调 MCP
× 再启动 Agent
× 写到 Memory 目录之外
核心权限判断见 extractMemories.ts L166-L221。
5.6 为什么还要限制 maxTurns: 5
正常提取只需要:
text
Turn 1:并行 Read 可能要更新的已有文件
Turn 2:并行 Edit/Write
Turn 3:必要时修正
限制为 5 可以防止后台 Agent 因"验证得更彻底"进入搜索兔子洞,无限消耗 token。
5.7 并发触发怎么收敛
text
提取 A 正在运行
│
├─ 触发 B:不并行,保存 pendingContext = B
├─ 触发 C:覆盖 pendingContext = C
│
提取 A 完成
│
└─ 用最新 C 补跑一次 trailing extraction
因为 C 的 messages 已经包含 B 的历史,所以保留最新上下文即可。
🔑 小白重点(第 5 章)
- 后台提取是 best effort:失败只打日志,不影响主回复。
- 它不是每轮无脑总结全部历史,而是通过 UUID 游标只处理增量。
- 主 Agent 和后台 Agent 不会在同一段消息上重复写。
- fork 复用主会话缓存,降低额外提取成本。
- 退出前
drainPendingExtraction()会软等待仍在运行的任务,避免刚写一半进程就结束。
第 6 章:未来怎么想起来 ------ 相关记忆异步召回
📄 文件:src/memdir/memoryScan.ts 第 21-94 行
📄 文件:src/memdir/findRelevantMemories.ts 第 18-140 行
📄 文件:src/utils/attachments.ts 第 2196-2415 行
📄 文件:src/query.ts 第 300-304、1592-1613 行
🎬 对应 demo:几天后,用户再次要求解析复杂源码
这是 Auto Memory 最有意思的一段:它没有向量数据库,而是使用"两级筛选"。
6.1 第一级:只扫描轻量 header
ts
const MAX_MEMORY_FILES = 200
const FRONTMATTER_MAX_LINES = 30
export async function scanMemoryFiles(memoryDir, signal) {
// ① 递归找所有 .md,但排除索引 MEMORY.md
const entries = await readdir(memoryDir, { recursive: true })
const mdFiles = entries.filter(
f => f.endsWith('.md') && basename(f) !== 'MEMORY.md',
)
// ② 每个文件只读前 30 行,拿 frontmatter
const headerResults = await Promise.allSettled(
mdFiles.map(async relativePath => {
const { content, mtimeMs } = await readFileInRange(
join(memoryDir, relativePath),
0,
FRONTMATTER_MAX_LINES,
)
const { frontmatter } = parseFrontmatter(content)
return {
filename: relativePath,
description: frontmatter.description || null,
type: parseMemoryType(frontmatter.type),
mtimeMs,
}
}),
)
// ③ 最新优先,最多保留 200 个候选
return fulfilled(headerResults)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.slice(0, MAX_MEMORY_FILES)
}
扫描得到的 manifest 类似:
text
- [feedback] feedback_source_walkthrough.md (2026-08-09T...)
用户阅读复杂源码时偏好角色地图、逐行中文注释和反事实表
- [reference] reference_dashboard.md (2026-07-20T...)
线上延迟看板入口
6.2 第二级:让 sideQuery 最多选 5 个
ts
export async function findRelevantMemories(
query,
memoryDir,
signal,
recentTools = [],
alreadySurfaced = new Set(),
) {
// ① 扫描 header,并排除本会话已经展示过的文件
const memories = (await scanMemoryFiles(memoryDir, signal))
.filter(m => !alreadySurfaced.has(m.filePath))
if (memories.length === 0) return []
// ② 把"用户问题 + 候选清单"交给一个短 sideQuery
const selectedFilenames = await selectRelevantMemories(
query,
memories,
signal,
recentTools,
)
// ③ 只接受清单里真实存在的文件名
return selectedFilenames
.map(filename => byFilename.get(filename))
.filter(Boolean)
.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))
}
源码:findRelevantMemories.ts L39-L75
selector 的要求是:
text
最多返回 5 个
只有"确定有帮助"才选择
不确定就不选
没有相关内容可以返回空数组
这不是传统 embedding Top-K,而是一个结构化 JSON side query。
6.3 读取正文时还有三层预算
text
候选主题文件数:最多 5
每个文件:最多前 200 行
每个文件:最多 4096 bytes
整个会话已注入相关记忆:最多约 60 KB
对应常量和读取逻辑见 attachments.ts L269-L289 与 attachments.ts L2279-L2321。
超长文件不会整份丢弃,而是注入开头并告诉模型:
text
这份记忆被截断了,如需完整内容请使用 Read 工具读取原文件。
6.4 为什么在 query() 一开始就启动
ts
// query.ts 进入主循环前
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
state.messages,
state.toolUseContext,
)
它会和主模型流式输出、工具执行并行运行。
6.5 为什么消费时"只看是否已完成,不等待"
ts
if (
pendingMemoryPrefetch &&
pendingMemoryPrefetch.settledAt !== null &&
pendingMemoryPrefetch.consumedOnIteration === -1
) {
// ① 只在 promise 已经完成时读取
const memoryAttachments = filterDuplicateMemoryAttachments(
await pendingMemoryPrefetch.promise,
toolUseContext.readFileState,
)
// ② 作为 attachment 加入当前消息流
for (const memAttachment of memoryAttachments) {
const msg = createAttachmentMessage(memAttachment)
yield msg
toolResults.push(msg)
}
// ③ 标记已消费,避免下一次循环重复注入
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
如果 selector 还没完成:
text
本次 iteration 不等它
↓
主 Agent 继续工作
↓
下一次 query loop iteration 再检查
这叫 zero-wait consume:记忆召回不能成为首 token 延迟的硬依赖。
6.6 旧记忆的新鲜度警告
两天以上的记忆会带上类似提示:
text
This memory is 47 days old.
Memories are point-in-time observations, not live state.
Verify against current code before asserting as fact.
🔑 小白重点(第 6 章)
- CC 用的是 Markdown header 检索 + LLM rerank,不是向量库。
description写得越准确,未来召回质量越高。- 召回最多 5 个主题文件,且有行数、字节数、会话总量限制。
- 召回异步预取,不让 Memory 搜索拖慢主循环。
- "已经展示过"与"模型自己 Read 过"的文件都会去重。
第 7 章:Session Memory ------ 当前会话的滚动会议纪要
📄 文件:src/services/SessionMemory/sessionMemory.ts 第 134-180、272-375 行
📄 文件:src/services/SessionMemory/sessionMemoryUtils.ts 第 31-36 行
📄 文件:src/services/compact/sessionMemoryCompact.ts 第 514-620 行
🎬 对应 demo:当前对话很长,书记员定期更新 summary.md
Session Memory 和 Auto Memory 最大区别:
text
Auto Memory:
为未来其他会话保存长期事实
Session Memory:
为当前这次长会话保存滚动工作状态
7.1 默认触发阈值
ts
export const DEFAULT_SESSION_MEMORY_CONFIG = {
// 上下文达到 10k token 才第一次创建会议纪要
minimumMessageTokensToInit: 10_000,
// 之后至少再增长 5k token 才更新
minimumTokensBetweenUpdate: 5_000,
// 通常还要求新增至少 3 次工具调用
toolCallsBetweenUpdates: 3,
}
源码:sessionMemoryUtils.ts L31-L36
7.2 shouldExtractMemory
ts
export function shouldExtractMemory(messages) {
const currentTokenCount = tokenCountWithEstimation(messages)
// ① 第一次没到 10k:不记
if (!isSessionMemoryInitialized()) {
if (!hasMetInitializationThreshold(currentTokenCount)) {
return false
}
markSessionMemoryInitialized()
}
// ② 距上次是否增长至少 5k token
const hasMetTokenThreshold =
hasMetUpdateThreshold(currentTokenCount)
// ③ 距上次是否至少发生 3 次工具调用
const hasMetToolCallThreshold =
countToolCallsSince(messages, lastMemoryMessageUuid) >=
getToolCallsBetweenUpdates()
// ④ 最后一轮没有工具调用,说明是自然对话断点
const hasToolCallsInLastTurn =
hasToolCallsInLastAssistantTurn(messages)
// ⑤ token 门槛永远必须满足
return (
hasMetTokenThreshold &&
(hasMetToolCallThreshold || !hasToolCallsInLastTurn)
)
}
7.3 后台书记员
text
post-sampling hook
↓
只允许主 REPL 线程
↓
sequential 防止两个书记员并发写
↓
读取/创建 summary.md
↓
fork session_memory Agent
↓
权限只允许 Edit 这一个 summary.md
↓
推进 lastSummarizedMessageId 书签
主流程见 sessionMemory.ts L272-L350。
7.4 它为什么能帮助 compact
上下文快满时:
text
trySessionMemoryCompaction()
↓
等待正在写的 summary.md,最多 15 秒
↓
读取 summary.md
↓
用 lastSummarizedMessageId 找到"纪要覆盖到哪"
↓
summary.md 代替旧消息
+
保留书签之后的近期原始消息
源码:sessionMemoryCompact.ts L514-L620。
拿不到有效 Session Memory 时,才降级到传统 LLM 现场压缩。
🔑 小白重点(第 7 章)
- Session Memory 保存的内容可以包含当前任务进度;Auto Memory 反而明确禁止保存这种临时状态。
- Session Memory 的目标是"压缩后还能无缝继续干活",不是建立用户长期画像。
lastSummarizedMessageId是摘要覆盖边界,不是长期记忆索引。- 这就是为什么 Session Memory 和 Auto Memory 必须分成两个目录、两套 Prompt。
第 8 章:Agent Memory 与 Team Memory ------ 作用域扩展
8.1 Agent Memory:每类专家有自己的笔记
📄 文件:src/tools/AgentTool/agentMemory.ts 第 12-177 行
📄 文件:src/tools/AgentTool/loadAgentsDir.ts 第 593-674、713-746 行
自定义 Agent frontmatter 可以写:
yaml
---
name: code-reviewer
memory: project
---
支持三种作用域:
| scope | 路径 | 含义 |
|---|---|---|
user |
~/.claude/agent-memory/<agentType>/ |
跨所有项目 |
project |
.claude/agent-memory/<agentType>/ |
项目共享,可进版本控制 |
local |
.claude/agent-memory-local/<agentType>/ |
当前项目、当前机器私有 |
路径分派:
ts
export function getAgentMemoryDir(agentType, scope) {
const dirName = sanitizeAgentTypeForPath(agentType)
switch (scope) {
case 'project':
return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
case 'local':
return getLocalAgentMemoryDir(dirName)
case 'user':
return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
}
}
Agent 声明 memory 后,加载器会做两件事:
ts
// ① 即使 Agent 原 tools 没写,也补齐 Read/Edit/Write
if (isAutoMemoryEnabled() && memory && tools !== undefined) {
tools = addMissingTools(tools, [
FILE_WRITE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_READ_TOOL_NAME,
])
}
// ② 在 Agent 自己的 system prompt 后拼上专属 Memory Prompt
getSystemPrompt: () => {
if (isAutoMemoryEnabled() && memory) {
return systemPrompt + '\n\n' +
loadAgentMemoryPrompt(agentType, memory)
}
return systemPrompt
}
源码:loadAgentsDir.ts L659-L674 与 loadAgentsDir.ts L713-L746。
它解决的问题是:
text
主 Agent 的用户偏好
不应该淹没
code-reviewer 对常见缺陷的专业积累
8.2 Team Memory:把主题文件同步给团队
📄 文件:src/memdir/teamMemPaths.ts 第 66-94 行
📄 文件:src/services/teamMemorySync/watcher.ts 第 231-352 行
📄 文件:src/services/teamMemorySync/index.ts 第 760-1188 行
Team Memory 位于 Auto Memory 子目录:
text
<auto-memory>/
├── MEMORY.md # 私有索引
├── feedback_user.md
└── team/
├── MEMORY.md # 团队索引
├── project_release.md
└── reference_dashboard.md
入口路径见 teamMemPaths.ts L66-L94。
启动同步系统时:
ts
export async function startTeamMemoryWatcher() {
// ① build flag、功能开关、OAuth、GitHub repo 缺一不可
if (!feature('TEAMMEM')) return
if (!isTeamMemoryEnabled() || !isTeamMemorySyncAvailable()) return
const repoSlug = await getGithubRepo()
if (!repoSlug) return
// ② 先从服务端拉取
syncState = createSyncState()
await pullTeamMemory(syncState)
// ③ 再监听本地目录
// 即使服务端当前为空也必须监听,避免首次写入无法上传
await startFileWatcher(getTeamMemPath())
}
上传采用:
text
本地文件计算 hash
↓
只上传与 serverChecksums 不同的 delta
↓
服务端返回 412 冲突
↓
只拉最新 hashes,不拉全部正文
↓
重新计算更小的 delta
↓
重试
见 teamMemorySync/index.ts L869-L1138。
安全措施包括:
- 路径穿越校验;
- symlink 逃逸校验;
- 单文件大小限制;
- 上传前 secret scan;
- 检测到密钥的文件跳过同步;
- OAuth 和 GitHub repo scope;
- 冲突重试次数限制。
8.3 /memory 命令只是管理入口
/memory 不负责自动总结,它做的是:
text
清除并重新预热 Memory 文件缓存
↓
展示 User / Project / Auto / Team / Agent Memory 选项
↓
必要时创建空文件
↓
用 $VISUAL / $EDITOR 打开
入口见 memory.tsx L21-L88,选项组装见 MemoryFileSelector.tsx L44-L152。
🔑 小白重点(第 8 章)
- Agent Memory 解决"不同专家各自积累什么"的隔离问题。
- Team Memory 不是 Git 自动同步,而是本地目录 + 服务端 API + watcher。
- Team Memory 冲突策略偏向保住当前本地编辑,不做复杂内容级自动合并。
/memory是查看和编辑入口,不是 Memory 的核心运行时。
第 9 章:Memory、Compact、Context Collapse 到底什么关系
📄 文件:src/query.ts 第 365-467 行
你当前 IDE 打开的代码是:
ts
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
const collapseResult =
await contextCollapse.applyCollapsesIfNeeded(
messagesForQuery,
toolUseContext,
querySource,
)
messagesForQuery = collapseResult.messages
}
它处在 query 主循环的这个位置:
text
原始 messages
↓
Tool Result Budget
↓
History Snip
↓
Microcompact
↓
Context Collapse ← query.ts:440
↓
Auto Compact
├─ Session Memory Compact
└─ 传统 LLM Compact
↓
调用主模型
↓
工具执行
↓
Relevant Memory 附件消费
↓
回合结束后 extractMemories
四者的时间尺度
| 机制 | 处理什么 | 是否跨会话 | 是否改写 messages |
|---|---|---|---|
| Auto Memory | 长期偏好和背景 | 是 | 通过 context/attachment 注入 |
| Session Memory | 当前会话工作纪要 | 通常服务当前会话 | compact 时作为摘要 |
| Context Collapse | 当前历史中的局部阶段 | 否 | 是,做读时投影 |
| Auto Compact | 接近上下文上限的全局换代 | 否 | 是,产生新边界和摘要 |
为什么 Context Collapse 在 Auto Compact 前
源码注释已经给出答案:
text
先尝试细粒度折叠
↓
如果已经降到 auto-compact 阈值以下
↓
就不必把整个历史压成一份大摘要
也就是:
能局部收纳就先局部收纳,实在装不下才整体换箱。
Memory 会不会被 compact 丢掉
分两种:
text
CLAUDE.md / MEMORY.md:
getUserContext 有独立加载与缓存
compact 后会清理相关缓存并重新注入
relevant_memories attachment:
属于消息历史的一部分
compact 后旧 attachment 可能消失
collectSurfacedMemories 因此自然重置,未来允许再次召回
这也是 attachments.ts L2244-L2265 特意扫描当前 messages,而不是维护永久全局 Set 的原因。
🔑 小白重点(第 9 章)
- Auto Memory 解决"跨会话记得什么"。
- Session Memory 解决"当前长会话压缩后如何继续"。
- Context Collapse 解决"局部旧阶段如何折叠"。
- Auto Compact 解决"整个上下文即将溢出"。
- 它们不是竞争关系,而是不同时间尺度上的分层治理。
附:五套 Memory 对照表
| 维度 | CLAUDE.md | Auto Memory | Session Memory | Agent Memory | Team Memory |
|---|---|---|---|---|---|
| 核心语义 | 指令 | 长期经验 | 当前会话纪要 | 专家经验 | 团队知识 |
| 主要入口 | getMemoryFiles |
loadMemoryPrompt |
post-sampling hook | Agent frontmatter | watcher + sync API |
| 入口文件 | CLAUDE.md |
MEMORY.md |
summary.md |
MEMORY.md |
team/MEMORY.md |
| 详细正文 | 文件本身 | 独立主题文件 | 同一个结构化 summary | 独立主题文件 | 独立主题文件 |
| 默认注入方式 | eager | 索引 eager / 正文按需 | compact 时使用 | Agent prompt | 索引/正文按需 |
| 写入者 | 用户/仓库 | 主 Agent 或后台提取 Agent | Session Memory fork | 对应 Agent | 主/后台 Agent |
| 典型内容 | 测试命令、编码规范 | 用户偏好、项目背景 | Current State、Worklog | review 常见缺陷 | 发布约束、共享入口 |
| 是否允许临时任务状态 | 不建议 | 明确禁止 | 允许且重要 | 取决于 scope 规则 | 不建议 |
| 是否跨用户 | 项目文件可以 | 默认否 | 否 | project scope 可以 | 是 |
| 过期风险 | 中 | 高,需验证 | 随会话更新 | 中 | 高,需同步 |
附:反事实表 ------ 少一层会发生什么
| 如果删掉...... | 会发生什么 | 为什么现有设计要保留 |
|---|---|---|
MEMORY.md 索引 |
旧模式下模型不知道有哪些主题文件 | 用很小 token 成本提供导航 |
| 独立主题文件 | 索引无限膨胀,每次启动都加载全部正文 | 将"发现"和"读取"分离 |
frontmatter description |
selector 只能靠模糊文件名猜相关性 | description 是轻量召回摘要 |
| 200 文件候选上限 | 目录越大,扫描和 selector prompt 越贵 | 对召回成本设硬上限 |
| 最多 5 个召回结果 | 一次普通问题可能注入几十份旧记忆 | 控制噪声和上下文占用 |
| 新鲜度提示 | 旧 file:line 会被模型当成当前事实 | 强迫模型回到代码验证 |
hasMemoryWritesSince |
主 Agent 和后台 Agent 同时写重复记忆 | 两条写入路径按消息区间互斥 |
| UUID 增量游标 | 后台每轮重新分析整个会话 | 只处理上次之后的新消息 |
pendingContext 合并 |
多轮快速结束会并发写同一目录 | 单写者 + 最新上下文补跑 |
| 权限沙箱 | "整理记忆"的子 Agent 可能改项目代码 | 将副作用限制在 Memory 目录 |
| Session Memory | compact 时只能每次现场调用 LLM 总结 | 平时滚动记,临界点直接复用 |
| canonical git root | 每个 worktree 形成互相矛盾的记忆 | 同仓库共享同一知识空间 |
| Team secret scan | API key 可能随团队记忆上传 | 共享前阻断敏感信息 |
一页纸总结
text
【启动】
isAutoMemoryEnabled
├─ env / --bare / remote persistent dir / settings
└─ 默认开启
getAutoMemPath
└─ ~/.claude/projects/<canonical-git-root>/memory/
【告诉模型规则】
constants/prompts.ts
└─ systemPromptSection('memory', loadMemoryPrompt)
├─ 四种类型:user / feedback / project / reference
├─ 什么不保存
├─ 主题文件格式
└─ MEMORY.md 只做索引
【加载】
getMemoryFiles
├─ Managed CLAUDE.md
├─ User CLAUDE.md
├─ Project CLAUDE.md / .claude/rules
├─ Local CLAUDE.local.md
├─ Auto Memory MEMORY.md
└─ Team Memory MEMORY.md
getUserContext
└─ getClaudeMds(filterInjectedMemoryFiles(...))
【写入】
用户明确说"记住"
├─ 路 A:主 Agent 直接 Write/Edit
└─ 路 B:回合结束后 extractMemories 后台补记
├─ UUID 游标,只看增量
├─ 主 Agent 写过则跳过
├─ fork 复用 prompt cache
├─ 最多 5 turns
└─ 写权限仅限 Memory 目录
【召回】
startRelevantMemoryPrefetch
├─ scanMemoryFiles:最多 200 文件,只读前 30 行 frontmatter
├─ sideQuery:最多选择 5 个
├─ 正文:每个最多 200 行 / 4096 bytes
├─ 会话总注入约 60 KB
├─ settled 才消费,不阻塞主循环
└─ 旧记忆附带"请验证当前代码"提示
【当前会话连续性】
Session Memory
├─ 10k token 初始化
├─ 每增长 5k token 更新
├─ summary.md + lastSummarizedMessageId
└─ compact 时优先拿它代替旧消息
【作用域扩展】
Agent Memory
└─ user / project / local 三种 scope
Team Memory
└─ team/ 目录 + pull/push + watcher + hash delta + secret scan
【和 query.ts:440 的关系】
History Snip
→ Microcompact
→ Context Collapse
→ Auto Compact
→ 主模型与工具
→ Relevant Memory 消费
→ 回合结束后长期记忆提取
一句话总结全篇:
Claude Code Memory 是一套分层的文件型记忆系统:
CLAUDE.md管"必须遵守什么",Auto Memory 管"未来值得记住什么",Session Memory 管"本次长会话做到哪里",Agent Memory 管"某类专家学到了什么",Team Memory 管"团队共同知道什么";它通过短索引、frontmatter 召回、异步预取、权限沙箱和过期校验,把持久化能力控制在可解释、可编辑、可审计的 Markdown 文件上。