⚠️ 学习声明:本文档基于 Claude Code 2.1.88 源码分析整理,仅供个人学习研究使用,不做任何商业用途。
这是 CC 的”长期记忆”机制——让 Claude 在会话之间保持对用户习惯、项目状态、历史反馈的持久记忆。
一、核心概念
问题
LLM 上下文窗口有限,每次新会话 Claude 都”失忆”——不记得用户偏好、项目约定、历史决策。
解决方案
MemDir(Memory Directory):在文件系统上维护结构化的记忆文件,每次会话启动时注入 System Prompt,让 Claude “想起”以往的信息。
二、目录结构
~/.claude/projects/<project-slug>/memory/ ← 个人自动记忆(auto memory) |
三、记忆类型分类法(四大类型)
CC 将记忆严格约束为四种类型,不存储可从代码/git推导出的信息:
| 类型 | 范围 | 存储内容 | 示例 |
|---|---|---|---|
user |
永远 private | 用户角色、目标、技能水平 | “是数据科学家,精通Go但不熟悉React” |
feedback |
默认 private,全局约定→team | 用户对 Claude 行为的纠正和确认 | “不要用 mock 数据库,上季度出过事故” |
project |
强烈偏向 team | 进行中的工作、目标、bug、incident | “周四前需要完成 migration,Alice 在做 API 层” |
reference |
private 或 team | 无法从当前项目推导的外部资料 | “该客户使用 UTC+8,日志时区需转换” |
关键约束:代码架构、文件结构、git 历史 → 不存(可通过 grep/git 实时获取)。
四、MEMORY.md 入口文件
最大限制:200行 OR 25,000字节(先触发者截断) |
--- |
truncateEntrypointContent() 负责截断并追加警告:
> WARNING: MEMORY.md is 250 lines (limit: 200). Only part of it was loaded. |
五、记忆加载流程
Session 启动 |
关键设计:DIR_EXISTS_GUIDANCE 文本告知 Claude “目录已存在,直接用 Write 工具写入,不要先 mkdir”——节省了每次写记忆都要检查目录的 turn。
六、记忆写入时机
Claude 在以下情况主动写入记忆(通过 FileWriteTool):
- 用户纠正 Claude 的行为(feedback 类型)
- 用户确认某种非显然的做法有效(feedback 类型)
- 了解到用户背景信息(user 类型)
- 获知项目进展/目标(project 类型)
写入规则:
- 记忆文件有 Frontmatter(
type:,created:) - 反馈记忆包含 Why: 和 How to apply: 段落(便于未来判断边界情况)
- 团队记忆放在
team/子目录,个人记忆放主目录
七、团队记忆(TEAMMEM)
Swarm 模式下,Leader 和 Teammate 共享 team/ 目录:
// 团队记忆路径:~/.claude/teams/<teamName>/memory/ |
用途:项目级约定(如代码风格、测试策略)写入 team 记忆,每个 Agent 都自动遵守。
八、findRelevantMemories — AI 驱动的语义检索
这是 MemDir 最精妙的设计:不加载全部记忆,而是用一个轻量 Sonnet 调用(Side Query)来选择最相关的记忆文件。
8.1 整体流程
用户发出查询 |
8.2 scanMemoryFiles 扫描机制(memoryScan.ts)
// 单次 readdir 递归扫描(包含子目录) |
性能优化:readFileInRange 只读前 30 行,内部同时 stat 获取 mtime,一次 syscall 同时完成”读内容”和”获取时间戳”。
8.3 Sonnet Side Query 选择机制
const SELECT_MEMORIES_SYSTEM_PROMPT = ` |
关键设计:
- 最多选 5 个:控制注入到上下文的记忆总量
- 工具感知过滤:传入
recentTools列表,避免选”已在用”工具的使用说明 - 仍选警告类:即使工具在用,仍选该工具的 gotcha/已知 bug 记忆
8.4 alreadySurfaced 去重
// 跨轮次去重:本轮已展示过的记忆不再重复选 |
8.5 mtime 线程穿透
type RelevantMemory = { |
主模型收到记忆时可以看到”这条记忆最后更新时间”,判断是否过时(project 类型记忆尤其重要,快速过时)。
8.6 与 MEMORY.md 的分工
| 机制 | 加载时机 | 内容 | 令牌成本 |
|---|---|---|---|
| MEMORY.md | 每次会话启动 | 全部索引(200行上限) | 固定开销 |
| findRelevantMemories | 每次用户查询 | 动态选 ≤5 个文件全文 | 按需开销 |
这是一个两阶段检索:MEMORY.md 提供快速索引,Sonnet 根据查询语义精选详细文件。
九、面试要点
Q:CC 的记忆系统与 RAG 有什么区别?
CC 的 MemDir 是”主动写入式”记忆:Claude 在对话中主动识别值得记住的信息并写入文件。RAG 是”被动检索式”:从固定知识库向量检索。MemDir 更像人类的笔记本——Claude 自己整理、分类、持续更新;RAG 更像图书馆——只读、不更新。
Q:为什么要限制 MEMORY.md 最多 200 行?
MEMORY.md 会被注入 System Prompt,受 prompt cache 约束,过长会增加每次请求的 token 成本,并稀释模型对其他重要信息的注意力。200 行约 25KB 是经验得出的性价比边界。
十、findRelevantMemories 两阶段检索深度剖析
10.1 整体架构
findRelevantMemories 是 MemDir 的查询时检索引擎,实现了一个经典的”粗排 + 精排”两阶段架构:
用户查询 query |
10.2 第一阶段:memoryScan.ts 文件系统扫描
源码位置:src/memdir/memoryScan.ts
核心设计决策
const MAX_MEMORY_FILES = 200 |
关键设计点:
| 设计 | 理由 |
|---|---|
readdir({ recursive: true }) 一次调用 |
避免深度优先递归产生大量 stat 系统调用,单次 readdir 拿到所有路径 |
| 只读前 30 行 | frontmatter 一般在文件头部,无需读全文,大幅减少 I/O |
Promise.allSettled 而非 Promise.all |
单个文件读取失败不阻塞其他文件,静默跳过坏文件 |
排除 MEMORY.md |
入口索引已在 System Prompt 中加载,无需进入检索池 |
| 按 mtime 降序 + 截取 200 | 优先考虑最近修改的记忆,限制规模避免 LLM 上下文超限 |
| 单遍扫描(read-then-sort) | 比 stat-sort-read 节省一半 syscall;N≤200 时读少量多余文件代价可接受 |
MemoryHeader 数据结构
type MemoryHeader = { |
formatMemoryManifest:候选列表的序列化格式
export function formatMemoryManifest(memories: MemoryHeader[]): string { |
输出示例:
- [feedback] feedback/no_summaries.md (2026-05-20T10:00:00.000Z): 用户不喜欢响应末尾的总结段落 |
这个格式是精心设计的:Sonnet 只需要 filename + description,timestamp 作为时效性辅助,type 作为分类提示。
10.3 第二阶段:Sonnet 语义精选
完整 System Prompt(SELECT_MEMORIES_SYSTEM_PROMPT):
You are selecting memories that will be useful to Claude Code as it processes |
sideQuery 参数:
const result = await sideQuery({ |
关键设计:
max_tokens: 256:极低 token 上限,因为输出只是文件名列表,防止模型冗余输出output_format强制 JSON Schema:结构化输出,避免解析失败skipSystemPromptPrefix: true:不携带主系统提示,该 sideQuery 是独立的轻量判断,无需 CC 的全套上下文- 结果经过
validFilenames白名单过滤:防止模型幻觉出不存在的文件名
10.4 recentTools 过滤:避免”正在用的工具文档”噪音
const toolsSection = |
场景:用户在用 mcp__git__spawn,此时查询中含 “spawn”,恰好也有一个 memory 文件描述 spawn API 参数。如果不做过滤,Sonnet 会误选这个参考文档——但主对话已经在使用该工具,文档是噪音。
但例外:含 warnings/gotchas/known issues 的记忆仍然选——正在使用时恰恰是最需要知道潜在陷阱的时刻。
10.5 alreadySurfaced 去重:防止 5-slot 预算浪费
const memories = (await scanMemoryFiles(memoryDir, signal)).filter( |
alreadySurfaced 是调用方(通常是对话循环)传入的 ReadonlySet<string>,记录本次对话中已向模型展示过的记忆路径。在过滤之后再进行 Sonnet 精选,保证 5-slot 预算全部用于”新鲜候选”。
10.6 遥测:记忆召回形状
if (feature('MEMORY_SHAPE_TELEMETRY')) { |
即使一条记忆都没选中也会触发上报。注释明确说明:selection-rate needs the denominator(选择率统计需要分母),且 -1 ages 区分”运行了但没选中”与”从未运行”。
十一、extractMemories 自动提取机制深度剖析
11.1 触发时机
extractMemories 不是实时触发,而是在每次完整查询循环结束时(模型产生最终响应、无挂起工具调用)由 stopHooks.ts 中的 handleStopHooks 异步调用:
用户提问 → 模型响应(含工具调用) → ... → 模型最终响应(无 tool_use) |
触发前的守卫链(executeExtractMemoriesImpl 中):
// 1. 仅主 Agent 触发,子 Agent 不触发 |
11.2 forkedAgent 模式:共享 prompt cache
extractMemories 使用完美 fork(runForkedAgent)而非独立会话:
const cacheSafeParams = createCacheSafeParams(context) |
为什么用 fork 而不是独立会话?
fork 继承父会话的完整消息历史(也就是待提取内容本身),并且共享 prompt cache。独立会话需要把所有内容重新传输,cache 命中率会大幅下降,额外花费大量 token。
11.3 互斥机制:主 Agent 写了记忆则跳过
function hasMemoryWritesSince(messages, sinceUuid): boolean { |
设计哲学:主 Agent 的系统提示已经包含完整的记忆写入指令。当用户明确说”记住这个”时,主 Agent 会直接写。后台提取代理只是”兜底”——当主 Agent 遗漏时补充提取。两者互斥,避免重复写入。
11.4 串行化:in-flight 保护与 trailing run
let inProgress = false |
多次触发时的行为:
- 第 1 次:立即开始提取,设
inProgress = true - 第 2、3 次(在第 1 次未完成时):只保存最新 context(覆盖),不启动新的提取
- 第 1 次完成后:用最后一次保存的 context 执行 trailing run
注意 pendingContext 只保存最新的——中间被覆盖的 context 丢弃,因为 trailing run 会用最新消息计算游标差,捕获所有积累的内容。
11.5 提取 Prompt 构造
buildExtractAutoOnlyPrompt 的 opener 核心:
You are now acting as the memory extraction subagent. Analyze the most |
关键约束:
- 明确声明
maxTurns: 5,且 prompt 解释了最优策略(并行读→并行写,2轮完成) - 只能基于近 N 条消息,不得额外 grep 源码验证(避免 rabbit-hole)
- 预注入现有记忆列表(
scanMemoryFiles的结果),省去第一轮ls调用
11.6 记忆文件去重策略
代理被要求在写入前检查是否已有可更新的记忆文件:
- Prompt 明确说:
Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. - 预注入的 manifest 包含所有现有文件的 description,让代理在无需 Read 的情况下判断是否有重叠
MEMORY.md的更新是机械性的(加一行索引),不计入实际记忆数量(memoryPaths过滤掉了MEMORY.md)
11.7 工具权限沙箱
createAutoMemCanUseTool 为提取代理创建受限的工具权限:
export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn { |
Write/Edit 必须写到 isAutoMemPath() 返回 true 的路径,防止提取代理意外修改项目文件。
十二、记忆文件格式规范
12.1 Frontmatter 字段
每个记忆文件必须以 YAML frontmatter 开头(parseFrontmatter 解析):
--- |
三个必须字段:
| 字段 | 作用 | 使用位置 |
|---|---|---|
name |
人类可读标题 | 未直接用于检索,但约定要存在 |
description |
一行精准描述,用于 Sonnet 精选时判断相关性 | formatMemoryManifest 输出,传给精选 Sonnet |
type |
user / feedback / project / reference |
memoryScan.ts 解析,用于 manifest 格式化标签 |
description 是检索性能的关键:Sonnet 精选阶段看不到文件正文,只看 description。写得模糊则召回率低,写得精准则能在 N≤200 候选中被准确命中。
12.2 四种记忆类型的内容规范
| 类型 | 内容 | 体结构建议 | 时效性 |
|---|---|---|---|
user |
用户角色、专业背景、沟通偏好 | 无固定格式,以人物画像为主 | 长期有效 |
feedback |
被纠正/确认的行为模式 | 规则 + Why: + How to apply: | 长期有效,可被后续否定 |
project |
进行中的工作、截止日期、决策背景 | 事实/决策 + Why: + How to apply: | 快速衰减(weeks) |
reference |
外部系统的路径/入口(Linear、Grafana 等) | 指针 + 用途说明 | 中期有效,需验证 |
12.3 文件命名规则
无强制命名约定,Prompt 给出的示例是语义化命名:
user_role.md(用户角色)feedback_testing.md(测试相关反馈)project_merge_freeze.md(合并冻结项目状态)
实际文件名只影响 filename 字段(用于 manifest),不影响检索性能(检索靠 description)。建议:{type}_{topic}.md。
12.4 MEMORY.md 索引格式
- [用户偏好:不要响应末尾总结](feedback/no_summaries.md) — 用户明确要求停止添加总结段落 |
每行约 150 字符以内,格式:- [Title](file.md) — one-line hook。MEMORY.md 是纯索引,不含 frontmatter,不含记忆正文。
十三、memdir 路径体系(paths.ts)
13.1 路径解析优先级
export const getAutoMemPath = memoize((): string => { |
13.2 默认路径结构
~/.claude/ |
getAutoMemBase() 使用 git 规范根:
function getAutoMemBase(): string { |
同一 git 仓库的所有 worktree 共享同一个 memory/ 目录(通过规范根 findCanonicalGitRoot 实现)。这是 Issue #24382 的修复:worktree 切换不会导致记忆目录碎片化。
13.3 路径安全校验
validateMemoryPath 对自定义路径进行多层安全检查:
function validateMemoryPath(raw, expandTilde): string | undefined { |
为什么排除 projectSettings? .claude/settings.json 是 git 追踪的文件,恶意仓库可设置 autoMemoryDirectory: "~/.ssh" 获得写入权限(filesystem.ts 的 write carve-out 对 isAutoMemPath() 的路径放行)。policySettings/localSettings/userSettings 都不在仓库内,可信。
13.4 isAutoMemEnabled 优先级链
CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 → 禁用 |
十四、sessionMemoryCompact 会话记忆压缩
14.1 与全局 compact 的区别
| 维度 | 传统 compact | sessionMemoryCompact |
|---|---|---|
| 触发 | 上下文窗口接近上限时 | 同上,但优先尝试 SM 路径 |
| 摘要来源 | 调用 Claude 生成摘要(额外 API 开销) | 从 SessionMemory 文件读取已有摘要 |
| token 成本 | 高(需 LLM 生成摘要) | 低(直接读文件,无 LLM 调用) |
| 信息完整性 | 依赖摘要质量 | 依赖 SessionMemory 提取质量 |
| 适用前提 | 无 | tengu_session_memory + tengu_sm_compact 双 flag 均开启 |
14.2 SM Compact 触发流程
export async function trySessionMemoryCompaction( |
14.3 保留消息计算:calculateMessagesToKeepIndex
配置参数(来自 GrowthBook tengu_sm_compact_config,默认值):
const DEFAULT_SM_COMPACT_CONFIG = { |
算法逻辑:
从 lastSummarizedIndex + 1 开始(已被 SM 摘要的消息之后) |
14.4 压缩结果的持久化
function createCompactionResultFromSessionMemory(...): CompactionResult { |
压缩后的 SM 内容以 isCompactSummary: true 标记,在 transcript 中可见但 isVisibleInTranscriptOnly: true,不直接进入 API 调用的消息流(由 buildPostCompactMessages 重组)。
十五、记忆注入到 System Prompt
15.1 加载时机与调用链
会话启动 |
15.2 注入内容结构
loadMemoryPrompt 返回的字符串包含:
# auto memory |
token 预算:MAX_ENTRYPOINT_BYTES = 25_000(约 25KB)。注意这是每次请求都会携带的固定成本,受 prompt cache 保护(稳定部分被缓存,动态部分—实际 MEMORY.md 内容—会随记忆更新而失效缓存)。
15.3 truncateEntrypointContent 双重截断
export function truncateEntrypointContent(raw: string): EntrypointTruncation { |
行截断先于字节截断的原因:行是语义边界(一行一条索引),优先在语义边界截断;字节上限是兜底,处理超长行(单行 >125 字符的异常情况,p100 实测 197KB 超过 200 行上限但字节超标)。
15.4 KAIROS 模式:日志驱动记忆
对于长期运行的 assistant 会话(feature('KAIROS') && getKairosActive()),记忆策略完全不同:
普通模式:维护 MEMORY.md + 主题文件(实时更新) |
日志路径按日期分组,Prompt 里描述的是模式路径(logs/YYYY/MM/YYYY-MM-DD.md)而非今天的字面路径,保证 prompt cache 跨日期有效(防止日期变更导致每天缓存失效)。
十六、面试深度题(进阶版)
Q1:为什么记忆检索用文件系统而不是向量数据库?
核心原因有三:
- 无需嵌入基础设施:向量数据库需要 embedding 模型、索引维护、相似度搜索服务,显著增加部署复杂度。MemDir 只需文件系统 + Sonnet sideQuery。
- LLM 本身是最好的语义检索器:Sonnet 理解 description 的语义,能做 “query 说’测试数据库’,description 说’不 mock 数据库’→ 高相关” 这样的跨语义匹配,这是向量相似度难以准确捕获的。
- 记忆数量 N 很小(上限 200):向量数据库在 N=10^6 时有优势,在 N=200 时 overhead 完全不值得。两阶段架构(文件扫描 + Sonnet 精选)在这个规模下延迟更低、成本更小。
代价:无法对记忆正文做全文语义搜索(只能搜 description)。但这是刻意的设计边界——description 是对内容的精准摘要,足够用于判断相关性。
Q2:两阶段检索相比单阶段(直接让 Sonnet 读所有记忆全文)有什么优势?
- token 成本:200 个记忆文件如果每个 1KB,直接传递全文需要 200KB+ token。两阶段只传 manifest(每条约 100 字符,200 条约 3KB),精选后最多读 5 个全文(约 5KB)。成本差了约 40 倍。
- 延迟:manifest 构建是纯 I/O(只读 30 行 frontmatter),比读全文快;sideQuery 的 max_tokens=256 极小,比大上下文推理快。
- 质量:Sonnet 在小上下文(manifest)中精选比在大上下文(全文)中噪音更少,不会被无关正文内容干扰判断。
Q3:记忆系统的一致性风险有哪些?如何缓解?
主要风险:
- 过时记忆(stale memory):project 类型记忆衰减快,但系统无自动过期机制。缓解:系统提示包含
MEMORY_DRIFT_CAVEAT——推荐模型在使用记忆前验证(如检查文件是否仍存在、函数是否仍存在)。- 写入竞争:主 Agent 写记忆 vs 后台 extractMemories 写记忆可能产生覆盖。缓解:
hasMemoryWritesSince互斥机制——同一轮只有一方写入。- 游标丢失:
lastMemoryMessageUuid是内存状态,进程崩溃后丢失,下次会话重新从头提取。缓解:extractMemories 的去重 Prompt 保证”先检查再写,有则更新,无则新建”。- MEMORY.md 索引与文件不同步:跳过 skipIndex 模式下不写 MEMORY.md,记忆文件存在但索引没有记录。缓解:检索走
scanMemoryFiles(直接扫文件系统),不依赖 MEMORY.md 的完整性。
Q4:extractMemories 的 forked agent 共享 prompt cache 是如何实现的?如果主会话有 100K token,fork 会导致什么?
createCacheSafeParams(context)将主会话的 messages(截止当前)和 system prompt 封装成参数传给runForkedAgent。由于 Anthropic API 的 prompt cache 以消息前缀为 key,fork 和主会话共享相同的消息前缀,cache 命中率极高(extractMemories 日志中可以看到hitPct统计)。100K token 的会话场景:fork 的 API 调用携带完整的 100K token 历史,但其中绝大部分命中 cache(只收 cache read 费用,约为 base price 的 10%)。真正新增的只有 extraction userPrompt 和 agent 的 5 轮输出(约 1-2K token)。整体额外成本约等于 1-2K 的 output token + 100K 的 cache read token,远小于用独立会话重传 100K 的成本。
十一、MemDir 与其他记忆系统的对比
| 维度 | MemDir | MemGPT (Packer et al., 2024) | RAG 向量存储 | 简单文件记忆 |
|---|---|---|---|---|
| 记忆提取 | LLM 主动提取(forked agent) | LLM 主动管理 | 被动检索 | 用户手动写入 |
| 存储格式 | Markdown 文件 | 层次化记忆块 | 向量嵌入 | 自由格式 |
| 检索方式 | System Prompt 注入 | LLM 决定检索 | 向量相似度搜索 | 全文 grep |
| 跨 Session | ✅ 文件持久化 | ✅ 数据库 | ✅ 向量库 | ✅ |
| 隐私 | 本地文件 | 本地 | 本地/云端 | 本地 |
| 成本 | 低(cache 命中 + 少量输出) | 中(需频繁 LLM 调用) | 低(仅嵌入成本) | 零 |
11.1 MemDir 的设计哲学
MemDir 的核心理念是 **”让 LLM 自己管理自己的记忆”**:
人类记忆模型: |
这种设计受启发于认知科学中的记忆巩固理论(McGaugh, 2000, Memory–a Century of Consolidation, Science)。
11.2 实际使用建议
<!-- MemDir 记忆文件示例: ~/.claude/memdir/project-patterns.md --> |
11.3 避免记忆污染
MemDir 需要注意的一个问题是过时记忆:
- 如果项目架构变化了,旧记忆可能误导 Agent
- 解决:定期清理
/memdir,或让 Agent 在提取时判断信息的时效性 - 类似于知识管理中的知识衰减(Knowledge Decay)概念
涉及源文件
涉及源文件
src/memdir/


