跳到主要内容

番外一 · 记忆系统(下):持久记忆与 CLAUDE.md

前置阅读

本文是记忆系统的下篇。Session Memory 详见 上篇,上下文压缩详见 中篇

前两篇讲的是单次会话内的记忆机制——Session Memory 在后台提取笔记,Compaction 在上下文耗尽时用笔记或 API 总结来恢复空间。但这些都随会话结束而消失。

本篇讲的是跨会话的记忆机制:如何让下一次新会话记住上一次学到的东西?以及静态指令(CLAUDE.md)是如何始终生效的?


A1.6 持久记忆:跨会话的大脑

文件系统即数据库

Claude Code 不用任何数据库——所有记忆都是文件系统中的 Markdown 文件:

~/.claude/
└── projects/
└── {project-slug}/
└── memory/
├── MEMORY.md ← 索引文件(始终加载到系统提示词)
├── user_role.md ← 独立记忆文件
├── feedback_testing.md
├── project_deadline.md
└── reference_linear.md

这个设计的好处:零依赖、人类可读、可用 git 管理、可在任何编辑器中直接修改。

四类记忆

记忆被严格限定为四类——且有一个核心原则:只记录不可从当前代码状态推导出的信息。代码风格、架构决策、文件结构这些都不应该被记住,因为它们可以通过 grepgit log、CLAUDE.md 获取。

src/memdir/memoryTypes.ts
export const MEMORY_TYPES = ['user', 'feedback', 'project', 'reference'] as const

记什么: 用户的角色、偏好、知识背景。

为什么重要: 与高级工程师协作和教初学者写代码,需要完全不同的沟通方式。

---
name: 用户背景
description: 10年 Go 经验,首次接触 React
type: user
---

用户是有 10 年经验的 Go 后端工程师,首次接触 React。
解释前端概念时用后端类比(如 useEffect ≈ goroutine 的 defer)。

MEMORY.md:200 行索引

MEMORY.md 始终加载到系统提示词中——但它不是记忆本身,而是索引。每条记忆只占一行、不超过 ~150 字符:

- [用户角色](user_role.md) — 10 年 Go 经验,首次接触 React
- [测试规范](feedback_testing.md) — 集成测试必须连真实数据库
- [Auth 重写](project_deadline.md) — 法务合规驱动,Q2 截止

索引有硬性限制:

src/memdir/memdir.ts:34-38
export const MAX_ENTRYPOINT_LINES = 200       // 最多 200 行
export const MAX_ENTRYPOINT_BYTES = 25_000 // 最多 ~25KB

超出时先按行截断(在自然换行处),再按字节截断(在最后一个换行符前)。

语义过期:不自动删除,但标注新鲜度

持久记忆没有 TTL——不会到期自动删除。但超过 1 天的记忆会被附加新鲜度警告:

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.`
)
}

为什么不用硬过期?因为不同记忆的时效性完全不同。"用户是数据科学家"一年后仍然有效,但"src/auth/index.ts:42 有 bug"第二天可能就过时了。固定的 TTL 无法区分这两种情况,让模型根据上下文判断则可以。

注意代码注释中的背景:

Motivated by user reports of stale code-state memories (file:line citations to code that has since changed) being asserted as fact — the citation makes the stale claim sound more authoritative, not less.

用户反馈说,包含具体行号引用的旧记忆反而让过时信息看起来更可信——因为有行号让它显得更权威。这个真实问题驱动了新鲜度警告的设计。

记忆加载到系统提示词的过程

loadMemoryPrompt() 根据不同模式加载记忆:

src/memdir/memdir.ts:419-507(简化)
export async function loadMemoryPrompt(): Promise<string | null> {
// KAIROS 自主模式:使用日志式记忆(append-only 日志文件)
if (feature('KAIROS') && autoEnabled && getKairosActive()) {
return buildAssistantDailyLogPrompt(skipIndex)
}

// 团队记忆模式:合并个人 + 团队记忆
if (feature('TEAMMEM') && teamMemPaths.isTeamMemoryEnabled()) {
await ensureMemoryDirExists(teamDir)
return teamMemPrompts.buildCombinedMemoryPrompt(extraGuidelines, skipIndex)
}

// 标准模式:个人记忆
if (autoEnabled) {
await ensureMemoryDirExists(autoDir)
return buildMemoryLines('auto memory', autoDir, extraGuidelines, skipIndex)
.join('\n')
}

return null
}

注意 ensureMemoryDirExists() 在加载前自动创建目录——然后系统提示词告诉模型"This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence)"。这避免了模型浪费一轮工具调用去检查目录是否存在。

Auto-Dream:记忆整理

持久记忆会随时间积累——矛盾条目叠加、索引变臃肿。Auto-Dream 是一个定期自动触发的后台子代理,专门负责整理记忆文件。

📍 源码:src/services/autoDream/

触发条件:三重门控

Auto-Dream 挂载在每轮对话结束时,但不是每轮都执行。它要依次通过三个门控,成本从低到高排列,任意一个不满足就提前返回:

  1. 时间门(一次 stat):距上次整理 ≥ 24 小时?
  2. 会话门(扫目录):上次整理以来,至少 5 个不同的会话被使用过?
  3. 进程锁:没有其他 Claude Code 进程正在整理?

顺序是刻意的——绝大多数对话结束时,门控 1 一次文件 stat 就能拦住,消耗极低。

门控 1 和门控 2 之间还有一个 10 分钟扫描节流:如果时间门通过但会话数不足,锁文件的时间戳不会前进,下轮触发时时间门还是会通过——没有节流的话,会每轮都扫描会话目录。

执行:四阶段整理

三重门控通过后,系统启动一个 forked agent,工具权限被严格限制:Bash 只允许只读命令,文件写入只能发生在内存目录内。

整理提示词将任务分成四个阶段:

阶段做什么
Phase 1 — Orient读 MEMORY.md 索引,浏览现有记忆,理解当前状态
Phase 2 — Gather查找新信号:优先读日志文件,按需 grep 会话记录(不穷举)
Phase 3 — Consolidate合并新信号到现有记忆文件,修正矛盾事实,转换相对日期为绝对日期
Phase 4 — Prune更新 MEMORY.md 索引,删除过时指针,保持 ≤ 200 行

对用户的可见性

整理在后台静默运行,不出现在对话历史里。能感知到它的地方只有两处:

  • 底部状态栏的后台任务胶囊,Shift+Down 可以查看详情(检视了几个会话、修改了哪些文件)
  • 整理完成后,如果有文件被修改,会在对话中插入一条 "Improved N files" 的系统消息

用户可以随时在后台任务对话框中中止整理。中止后锁会回滚到整理前的时间戳,下次会话可以重新触发。


A1.7 CLAUDE.md:静态指令层

CLAUDE.md 不属于"记忆"——它是静态指令,在每次会话启动时加载(在压缩后也会重新注入)。

四层加载优先级

项目级 CLAUDE.md 会从当前目录向上遍历到根目录——这意味着 monorepo 中不同子目录可以有不同的指令。

条件规则

.claude/rules/*.md 文件支持 YAML 前言中的 paths 字段:

---
paths: "src/**/*.ts, !src/node_modules/**"
---
TypeScript 文件的编码规范...

这种条件规则只在编辑匹配的文件时才会加载到上下文——避免不相关的指令占用 token。

加载时机

src/context.ts(简化)
export const getUserContext = memoize(async () => {
const claudeMd = shouldDisableClaudeMd
? null
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))

return {
...(claudeMd && { claudeMd }),
currentDate: `Today's date is ${getLocalISODate()}.`,
}
})

getUserContext()memoize() 包装——整个会话只调用一次,然后缓存结果。这意味着如果你在会话期间修改了 CLAUDE.md,变更不会立即生效(需要新会话或压缩触发重新加载)。


A1.8 完整的系统提示词组装

所有记忆最终都通过系统提示词到达模型。提示词分为静态缓存段动态段

静态段的内容在会话中不变,可以被 Anthropic 的 Prompt Cache 缓存——后续请求只需要发送动态段的增量部分,大幅降低 API 成本。

CLAUDE.md 的注入位置不在系统提示词段中——它作为 userContextclaudeMd 字段,在用户消息前面注入(以 <system-reminder> 标签包装)。这样它属于动态内容,但通过缓存控制标记可以获得部分缓存命中。


A1.9 设计洞察

Session Memory 是压缩的"预制件"

这是整个记忆系统最精妙的设计:Session Memory 不是一个独立功能,而是为压缩预制的摘要素材。通过在后台持续提取笔记,它把"总结对话"的成本从压缩时刻分摊到了整个对话过程中——而且这个分摊是通过共享 prompt cache 的分叉子代理实现的,边际成本极低。

文件系统 > 数据库

对于一个 CLI 工具来说,引入 Redis 或 SQLite 是不必要的复杂性。Markdown 文件在文件系统上的好处远不止"简单":它们可以被人类直接阅读和编辑,可以用 git 管理版本,可以用任何文本编辑器修改,可以在没有网络的环境中工作。

只记不可推导的知识

这条原则让记忆系统保持精炼。代码风格?读代码就知道。架构决策?看 CLAUDE.md。Git 历史?用 git log。记忆系统只存储这些工具无法获取的信息——用户的角色、项目的非技术背景、用户的偏好纠正。

语义过期 > 硬过期

不删除记忆,而是标注新鲜度让模型自行验证——这解决了"用户角色永远有效,但代码行号隔天过期"的异质过期问题。缺点是消耗 token 来注入警告,但比误删有价值的长期记忆要好得多。

压缩后必须重注入

压缩不只是"删旧消息、加摘要"——它还必须重新注入 CLAUDE.md、工具定义和 MCP 指令。如果忘了这一步,模型在压缩后会突然"忘记"项目规则和可用工具。这个重注入通过 processSessionStartHooks('compact', ...) 实现,效果等同于新建会话时的启动流程。

番外小结

Claude Code 的记忆系统用四个协作的子系统解决了 LLM 智能体的上下文有限问题。最核心的洞察是 Session Memory 和 Compaction 的协作模式(详见上篇):后台子代理在对话过程中持续提取笔记到磁盘文件,当上下文耗尽时,笔记直接替代旧消息作为摘要,不需要额外的 API 调用。持久记忆和 CLAUDE.md 则确保跨会话的知识和项目规则不会丢失——它们在每次新会话启动时加载,在每次压缩后重新注入。


附录:关键源码文件索引

文件职责
src/services/compact/autoCompact.ts自动压缩触发逻辑、阈值计算、熔断器
src/services/compact/compact.ts传统压缩的完整实现、消息重建
src/services/compact/sessionMemoryCompact.tsSession Memory 压缩路径
src/services/compact/prompt.ts压缩总结的 Prompt 模板(三种变体)
src/services/compact/microCompact.ts微压缩预处理
src/services/SessionMemory/sessionMemory.ts会话记忆后台提取主逻辑
src/services/SessionMemory/prompts.ts笔记模板和提取 Prompt
src/services/SessionMemory/sessionMemoryUtils.ts配置、阈值、lastSummarizedMessageId
src/memdir/memdir.ts持久记忆加载和 Prompt 构建
src/memdir/memoryTypes.ts四类记忆的定义和行为指导
src/memdir/memoryAge.ts新鲜度计算和过时警告
src/services/autoDream/autoDream.ts自动记忆整理主逻辑(三重门控、forked agent)
src/services/autoDream/consolidationLock.ts锁机制(mtime = 上次整理时间、PID 竞争、回滚)
src/services/autoDream/consolidationPrompt.ts四阶段整理提示词构建
src/services/autoDream/config.tsAuto-Dream 启用状态(用户设置 + GB flag)
src/tasks/DreamTask/DreamTask.ts后台任务状态(UI 可见、进度追踪、kill)
src/utils/claudemd.tsCLAUDE.md 发现和加载
src/context.tsSystem/User 上下文注入
src/constants/prompts.ts系统提示词组装(静态段 + 动态段)

附录:环境变量与特性标志

变量作用
DISABLE_COMPACT完全禁用压缩
DISABLE_AUTO_COMPACT仅禁用自动压缩(手动 /compact 仍可用)
CLAUDE_CODE_AUTO_COMPACT_WINDOW覆盖有效上下文窗口大小
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE以百分比覆盖压缩阈值 (0-100)
ENABLE_CLAUDE_CODE_SM_COMPACT强制启用 Session Memory 压缩
DISABLE_CLAUDE_CODE_SM_COMPACT强制禁用 Session Memory 压缩
CLAUDE_CODE_DISABLE_AUTO_MEMORY禁用持久记忆
CLAUDE_CODE_DISABLE_CLAUDE_MDS禁用 CLAUDE.md 加载