Harness Engineering

第12章 长期记忆:持久化与检索

作者 杨艺韬 · 15,979 字

"Memory is the treasury and guardian of all things." — Cicero

本章要点

  • 长期记忆让 Agent 跨会话保持连续性——记住用户 / 项目 / 反馈 / 外部引用
  • 四种记忆类型:User / Feedback / Project / Reference——各有不同的写入时机与使用场景
  • 三大存储方案:文件系统(Claude Code)/ 向量数据库(RAG)/ 键值存储(LangGraph Store)——以及文件方案必须自己解决的膨胀问题
  • 关键四问:何时写?写什么?如何检索?如何不让记忆过时?
  • 记忆≠事实——是某个时间点的快照,使用前必须验证
  • 隐私红线:绝不存储密钥、token、.env 内容、未脱敏的 PII

12.1 为什么需要长期记忆:金鱼困境

没有长期记忆的 Agent,每次对话都是"失忆"重启。用户不得不反复教 Agent 相同的事情:

会话 1: 用户说"我是数据科学家,用 Python"
会话 2: Agent 问"请问你用什么语言?"
会话 3: 用户说"别用 mock 测试,上次生产事故就是 mock 导致的"
会话 4: Agent 又生成了 mock 测试
会话 5: 用户说"项目代号是 phoenix,内部叫这个"
会话 6: Agent 生成代码用了 "my-project" 作为包名

这就是"金鱼困境"——短期记忆无限循环,永远学不到教训。长期记忆解决的就是这个问题:让 Agent 跨会话积累对用户和项目的理解

flowchart TD
    S1["会话 1<br/>用户画像"] -->|"user type"| M[(长期记忆<br/>持久化存储)]
    S2["会话 2<br/>反馈修正"] -->|"feedback type"| M
    S3["会话 3<br/>项目状态"] -->|"project type"| M
    S4["会话 4<br/>外部资源"] -->|"reference type"| M
    M -->|"相关记忆<br/>自动注入"| S5[会话 N<br/>智能响应]
    S5 -->|"新的学习"| M
    style M fill:#dcfce7,stroke:#22c55e,stroke-width:3px
    style S5 fill:#dbeafe,stroke:#3b82f6

长期记忆的三层价值

  1. 效率价值——用户不用每次重新教育 Agent
  2. 质量价值——Agent 能避免重复过去的错误,复用过去的成功
  3. 关系价值——用户感觉"这个 Agent 懂我",而不是冷冰冰的一次性工具

第三层是最隐性但最重要的——Agent 的"温度"来自它对用户的持久理解

长期记忆 vs 短期记忆

维度 短期记忆(上下文) 长期记忆(持久化)
生命周期 单次会话 跨会话、跨月甚至跨年
存储位置 模型上下文窗口 文件系统 / 数据库
规模 几十 KB-1MB tokens 几乎无限
访问速度 免费(已在上下文) 需要检索
写入频率 每轮对话自动更新 选择性写入
内容类型 当前任务状态 跨会话的事实与偏好
失效方式 会话结束 时效性判定

两者是互补的——短期记忆承载"正在做什么",长期记忆承载"我是谁/项目背景"。

12.2 记忆类型分类:Claude Code 的四分法

Claude Code 的记忆系统定义了四种类型,这个分类法经过实战检验、值得借鉴:

graph TD
    Memory[长期记忆]
    Memory --> User[User<br/>用户画像]
    Memory --> Feedback[Feedback<br/>反馈修正]
    Memory --> Project[Project<br/>项目动态]
    Memory --> Reference[Reference<br/>外部引用]
    User --> U1[角色/职业]
    User --> U2[知识水平]
    User --> U3[偏好风格]
    Feedback --> F1[纠正: 不要这样]
    Feedback --> F2[认可: 就是这样]
    Feedback --> F3[Why + How to apply]
    Project --> P1[截止日期]
    Project --> P2[stakeholders]
    Project --> P3[决策背景]
    Reference --> R1[文档 URL]
    Reference --> R2[数据源]
    Reference --> R3[Linear/Jira 项目]
    style User fill:#fef3c7,stroke:#f59e0b
    style Feedback fill:#fecaca,stroke:#dc2626
    style Project fill:#dbeafe,stroke:#3b82f6
    style Reference fill:#dcfce7,stroke:#22c55e

User 类型

内容:用户的角色、职业、知识水平、偏好。

写入时机:了解到用户信息时,尤其是第一次会话。

示例:

---
name: user_role
type: user
---
用户是数据科学家,深度使用 Python,新接触 Rust。
**写代码时优先给出 Python 类比**,帮助构建心智模型。

使用场景:调整交互风格和建议深度——给新手多解释原理,给专家直接给结论。

Feedback 类型(最重要)

内容:用户的纠正和认可,带 Why 和 How to apply。

写入时机:

  • 用户说"不要这样做"、"停止做 X"——纠正信号
  • 用户说"对,就是这样"、"完美,继续这么做"——认可信号

两种信号都要写——只存纠正会让 Agent 变得过度保守,只存认可会让 Agent 学不到教训。

示例:

---
name: feedback_testing
type: feedback
---
集成测试必须连接真实数据库,不使用 mock。
**Why:** 上季度 mock 测试通过但生产迁移失败,导致线上事故 2 小时。
**How to apply:** 写测试时默认使用 testcontainers + 真实 DB;
纯逻辑单元测试可以 mock(因为不涉及数据库)。

这里 Why 和 How to apply 是关键——让 Agent 能在边界情况下自行判断。遇到纯函数单元测试时,它能判断"这不违反规则,因为不涉及数据库"。

Project 类型

内容:项目动态、截止日期、stakeholder、决策背景。

写入时机:了解到项目状态时,尤其是时效性信息

示例:

---
name: project_release_freeze
type: project
created: 2026-04-10
expires: 2026-04-16
---
代码冻结至 2026-04-16,不合并非关键 PR。
**Why:** 移动端团队 4 月 17 日切发布分支。
**How to apply:** 收到 PR 合并请求时先确认是否 critical bug fix;
如是 feature 应提醒用户延后。

关键:项目类记忆必须带时间戳——可能会过期。

Reference 类型

内容:外部资源的指针——文档 URL、数据源、ticket 系统。

写入时机:发现外部信息源时。

示例:

---
name: reference_bug_tracker
type: reference
---
pipeline 相关 bug 追踪在 Linear 项目 "INGEST"。
**How to apply:** 用户问 bug 状态时,提醒去 Linear INGEST 查,
不要自己猜测优先级。

什么不应该存入记忆

同样重要的是知道什么不该存:

类型 为什么不存 正确做法
代码结构、文件路径 代码会改,记忆会过时 用 Glob/Grep/Read 实时读取
Git 历史 git log 是权威来源 运行 git 命令查询
调试修复方案 修复已在代码中 读代码或 commit message
临时任务状态 当前会话范围 用 TodoList 或对话上下文
CLAUDE.md 内容 避免重复 依赖 CLAUDE.md 自动加载
敏感信息 安全红线 永远不存

原则:如果能从代码或工具实时获取的信息,不要存入记忆。记忆只存那些无法从代码推断的人类知识

teamMem vs 私有 memory:两级可见性

前面讲的四种 memory type 是一个维度,Claude Code 还有一个和它正交的维度:private 还是 team scope

memoryTypes.ts:38-40 的说明是这样的:

There are several discrete types of memory that you can store in your memory system. Each type below declares a <scope> of private, team, or guidance for choosing between the two.

四个 type 乘两个 scope 理论上有八种组合,但每一类都有明确的默认偏向,而且理由都很直接。User 类型永远是 private——个人画像不该团队共享。Feedback 默认 private,但团队级规则可以是 team,比如"所有 PR 必须带测试"这种约定,本来就该所有人都遵守。Project 强烈偏向 team——项目状态是人人都该知道的事。Reference 则看情况:一个 Linear 的 URL 通常适合 team,个人书签留在 private。

实现上,两者都在用户目录的 auto-memory 树下:私有 memory 位于 <memoryBase>/projects/<项目>/memory/,team memory 是它的 memory/team/ 子目录(teamMemPaths.ts:81-90),每次会话开始时自动同步。两个目录独立扫描,team 的优先级更高——私有规则不能覆盖团队规则。有一点值得特别注意:team memory 不在项目仓库里,也不经过项目的 git

这一维是协作型 Agent 的关键基建。第3章《Agent Loop:心跳与决策循环》讲的是单用户场景,而这里讨论的是多用户共享上下文——"跨会话记忆"由此升级为"跨用户记忆"

Team memory 的共享路径与 git-native 备选

既然 team memory 不放在项目仓库里,那它放哪儿?答案上面已经给了:用户目录 auto-memory 树的 memory/team/ 子目录,每次会话开始时自动同步(teamMemPrompts.ts:74)。这样做的直接好处是不经过项目 git,也就没有误提交的风险

但如果你的自研 Agent 想走"git-native"路线——把团队共享记忆直接放进项目仓库,比如自定义一个 .claude/team-memory/ 目录——这条路是可行的,只是要清楚它是一种自定义约定,不是 Claude Code 的机制,而且有两个前提必须守住。

第一,私有 memory 绝不能放进项目目录。这是 §12.7 的红线:放进仓库就等于向所有仓库读者公开,而私有 memory 里恰恰最可能有不该公开的内容。第二,memory 的改动要走 code review,这样"团队级规则"才是真的有共识;顺带还能拿到一个不错的副作用——新人克隆仓库就自动获得团队记忆,等于"入职即懂项目"。

git-native 路线的好处是不用发明新协议,直接复用 git 的 diff、review、history 这一整套基础设施;代价是共享边界要自己守住、误提交的风险自己承担。相比之下,把 team memory 放到 Notion、LangSmith 这类云服务上,要额外的账号、权限和维护,工程负担反而更大,也不如 git 直观。

12.3 存储方案与硬上限

记忆存在哪里,决定了后面所有事怎么做——检索多快、能存多少、出了问题好不好查。这一节先横向比三种存储方案,再看 Claude Code 的选择及其代价:它用最朴素的 Markdown 文件,因此必须自己解决"文件会无限膨胀"这个问题,而它给出的答案是两条写死的硬上限。

三大存储方案对比

方案一:文件系统(Claude Code 的做法)

Claude Code 用纯 Markdown 文件存储记忆:

~/.claude/projects/{sanitized-project-path}/memory/   # 用户主目录下,按项目路径隔离
├── MEMORY.md              # 索引文件,列出所有记忆
├── user_role.md           # 用户画像
├── feedback_testing.md    # 测试偏好
├── project_deadline.md    # 项目截止日期
└── reference_linear.md    # 外部系统指针

每个记忆文件有 frontmatter:

---
name: testing-preferences
description: 用户要求集成测试用真实数据库,不用 mock
type: feedback
---

集成测试必须连接真实数据库,不使用 mock。
**Why:** 上季度 mock 测试通过但生产迁移失败,导致线上事故。
**How to apply:** 写测试时默认使用测试数据库连接,只在单元测试隔离纯逻辑时才 mock。

MEMORY.md 是索引,每行一条,控制在 200 行以内:

# Memory Index
- [Testing Preferences](feedback_testing.md) — 集成测试用真实数据库不用 mock
- [User Role](user_role.md) — 数据科学家,Python 为主,新接触前端
- [Release Freeze](project_release_freeze.md) — 冻结至 2026-04-16

优势

  • 人类可读可编辑——用户可以手动查看和修改
  • Git 友好——可以版本控制,回滚错误的记忆
  • 无需额外基础设施——一个目录就够了
  • 索引文件轻量——每次对话加载成本低(~1K tokens)
  • 透明——用户随时能看到"Agent 记住了什么"

劣势

  • 语义搜索能力弱(只能关键词匹配)
  • 记忆多了索引文件膨胀
  • 并发写入需要处理(多个 Agent 同时写可能覆盖)

适用:桌面端 Agent、小团队 Agent、隐私敏感场景。

方案二:向量数据库(RAG 风格)

将记忆文本转为 embedding,存入向量数据库,检索时用语义相似度:

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# 存储
memory_store = Chroma(
    collection_name="agent_memory",
    embedding_function=OpenAIEmbeddings()
)

memory_store.add_texts(
    texts=["用户偏好:集成测试不用 mock,原因是上季度事故"],
    metadatas=[{
        "type": "feedback",
        "created": "2026-04-15",
        "weight": 1.0  # 用于加权排序
    }]
)

# 检索
results = memory_store.similarity_search(
    "应该怎么写测试?",
    k=3,
    filter={"type": "feedback"}  # 按类型筛选
)

优势

  • 语义检索强大——能找到措辞不同但语义相关的记忆
  • 可扩展——百万级记忆依然高效
  • 支持多模态——文本、代码、图片 embedding 混合

劣势

  • 需要额外服务(Chroma、Pinecone、Milvus)
  • embedding 有成本(每次写入一次 API 调用)
  • 检索结果可能"相似但不相关"(语义近但实际无关)
  • 不透明——用户很难手动检查

适用:企业级 Agent、多用户 SaaS、记忆量超过 1000 条。

方案三:键值存储(LangGraph Store)

LangGraph 的 Store 抽象提供了命名空间化的键值存储:

from langgraph.store.memory import InMemoryStore
from langgraph.store.postgres import AsyncPostgresStore

# 开发环境:内存存储
store = InMemoryStore()

# 生产环境:Postgres——基于 psycopg,标准用法是
# from_conn_string 异步上下文管理器 + 首次 setup() 建表
async with AsyncPostgresStore.from_conn_string(
    "postgresql://user:pass@localhost:5432/db",
    index={  # 可选的向量索引
        "dims": 1536,
        "embed": OpenAIEmbeddings(),
    },
) as store:
    await store.setup()  # 首次使用建表
    ...  # 后续的 put/get/search 调用需在此上下文内执行

# 按命名空间组织
await store.put(
    ("user", "yyt", "preferences"),  # 分层命名空间
    "testing",
    {
        "value": "不使用 mock,连接真实数据库",
        "reason": "上季度 mock 导致生产事故",
        "created": "2026-04-15",
    }
)

# 精确查询
item = await store.get(("user", "yyt", "preferences"), "testing")

# 命名空间搜索
items = await store.search(("user", "yyt"))

# 语义搜索(如果配置了 index)
items = await store.search(
    ("user", "yyt"),
    query="应该怎么写测试"
)

优势

  • 结构化——程序化访问清晰
  • 命名空间隔离——多用户/多项目天然分离
  • 混合能力——KV + 可选向量索引
  • 分布式友好——Postgres/Redis 后端

劣势

  • 不如文件系统直观
  • 持久化需要额外配置
  • 用户编辑门槛高

适用:生产级多用户系统、需要精确结构化查询。

三方案对比表

维度 文件系统 向量数据库 KV Store
基础设施 需要服务 需要 DB
写入成本 极低 有 embedding 成本
检索能力 关键词 语义 精确 + 可选语义
用户透明度 高(可读 MD)
多用户隔离 目录隔离 metadata 过滤 命名空间
扩展性 中(万级) 高(百万级)
学习成本
适用场景 桌面/小团队 SaaS/大规模 生产多租户

Claude Code memdir/ 模块 1736 行——真实实现

前面讨论的抽象,在 Claude Code 里对应 src/memdir/ 这 1736 行代码:

文件 职责
memoryTypes.ts 271 四类 type 常量 + prompt 模板
memdir.ts 507 MEMORY.md 读写 + 截断
paths.ts 278 路径解析 + isAutoMemoryEnabled
memoryAge.ts 53 时效性判定
memoryScan.ts 94 扫描目录、读 frontmatter
findRelevantMemories.ts 141 Sonnet-based relevance selection
teamMemPaths.ts 292 团队共享记忆路径
teamMemPrompts.ts 100 团队 vs 私有的 prompt 差异

值得注意的是这张表的比例:真正处理"记忆是什么"的 memoryTypes.ts 只有 271 行,而路径解析、截断、时效判定这些边界处理加起来占了大半。记忆系统的复杂度不在概念上,在边界上——后面几节拆的正是这些地方。

MEMORY.md 的双硬上限——200 行 & 25 KB

memdir.ts:35-38 只有四行,却是整个记忆系统最重要的一道闸:

export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
// ~125 chars/line at 200 lines. At p97 today; catches long-line indexes that
// slip past the line cap (p100 observed: 197KB under 200 lines).
export const MAX_ENTRYPOINT_BYTES = 25_000

两条上限各管一件事:200 行覆盖正常用法,25,000 字节防御"一行超长内容"绕过行数限制。

真正值得留意的是那句注释——p100 observed: 197KB under 200 lines。这是一条生产教训:有用户把一整页 wiki 塞成了一行,行数完全合规,字节数却炸了。如果只有行数上限,这份 MEMORY.md 会原封不动地进入每一次 system prompt。

任何基于行数的限制都必须配一个字节兜底,否则一定会被绕过——不是因为有人恶意,而是因为"一行"这个单位对内容量根本没有约束力。

truncateEntrypointContent 的三重智慧

有了上限,接下来的问题是超限时怎么截。memdir.ts:57-103truncateEntrypointContent 只有五十行,但三个决定都值得单独看。

第一,先按行截、再按字节截。

let truncated = wasLineTruncated
  ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
  : trimmed

if (truncated.length > MAX_ENTRYPOINT_BYTES) {
  const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
  truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
}

按行截是因为换行是自然边界,不会破坏 markdown 结构;再按字节截时,它会往回找上一个 \n避免把一条索引条目从中间切断——半条索引比没有索引更糟,模型会照着残缺的描述去猜。实在找不到换行符才硬切,这个 fallback 保证函数任何情况下都有输出。

第二,警告消息里带具体原因。

WARNING: MEMORY.md is 247 lines (limit: 200). Only part of it was loaded.
Keep index entries to one line under ~200 chars; move detail into topic files.

它没有说"内容太多、被截断了",而是说清了超了多少、以及具体该怎么改。可操作的错误信息和不可操作的错误信息,差别就在这里

第三,警告同时给到模型和用户。 这段警告被直接 append 进 MEMORY.md 的可见内容里——模型在 system prompt 里看得到,用户 cat MEMORY.md 也看得到。双方都不会被悄悄截断坑到,这正是"静默失败"最好的解药。

这五十行是边界处理的教科书案例:容错、反馈、可观测,三件事一次做齐。

MEMORY.md 超长的三种典型原因

上面那句注释里的分布数据(p97 在限内,p100 出现过 200 行内 197KB 的极端索引)能推出三种典型的超限模式,它们的成因和对策都不一样。

模式 A 是行数激增:Agent 把每一次小 feedback 都加成新条目,几个月后 MEMORY.md 涨到四百行。对策是每三个月合并一次语义相近的条目——这本质上是 §12.4 那个"第三档合并"策略的定期版本。

模式 B 是单行超长:用户把整篇 markdown 文档塞进了 "one-line description"。对策是把索引条目严格限制在 200 字符以内,这也正是双上限里字节那一条要防的情况。

模式 C 是 body 污染 index:Agent 误把详细内容直接写进 MEMORY.md,而不是放到独立的 topic 文件里。对策是在写入路径上做自动判定——内容超过 300 字符就必须落到独立文件。

三种模式最终都由 truncateEntrypointContent 兜底,但更好的做法是在写入路径就防御,而不是等 entrypoint 超限了才截断。截断是止损,不是解决。

为什么 MEMORY.md 是 entry point——而不是散文件

前面反复提到 MEMORY.md 的特殊地位,这里说清它为什么值得被单独设计。Claude Code 的 memory 目录布局是这样的:

~/.claude/projects/{sanitized-project-path}/memory/   # 用户主目录下,按项目路径隔离
├── MEMORY.md                 <- 唯一 entrypoint (≤200 行 & ≤25KB)
├── user_profile.md           <- 单个 memory file
├── feedback_testing.md
├── project_phoenix.md
└── ...

MEMORY.md 是唯一在每次会话启动时 100% 加载进 system prompt 的文件,其余 memory 文件默认都不加载。它里面只存索引——- [标题](文件名.md) — 一句话描述——模型据此判断需不需要真的去读某一份完整记忆。

这样做的收益是量级上的。假如全部加载,100 条 memory × 500 token 就是 5 万 token,光是开场就把上下文吃掉一大半;而"索引 + 按需加载"把成本从 O(N) 压到 O(1) + O(k × 单文件大小),其中 k 通常不超过 5(正是 §12.5 selector 那条 "up to 5" 的来历)。

这是典型的两层 indirection:用一个小而恒定的索引,换取对一个可能无限增长的集合的访问。inode 加 data block 是这个结构,DNS 的根服务器加递归查询也是这个结构——任何大规模持久化存储最后都会长成这样。

12.4 记忆的写入策略:何时写、写什么

存储解决了"放哪儿",接下来是更难的一半:什么值得记

写多了是噪声——每条无用记忆都在挤占后续每一次请求的上下文;写少了等于没有。本节讲触发写入的信号怎么分级、写入前如何去重、以及一条记忆该长成什么样。

何时写入——信号强弱分级

写太多记忆会制造噪声,写太少则失去价值。触发写入的信号:

graph TD
    Signal[用户消息信号]
    Signal --> S1[强信号<br/>立即写入]
    Signal --> S2[中等信号<br/>考虑写入]
    Signal --> S3[弱信号<br/>通常不写]
    S1 --> S1a["'记住这个' / '以后都这样做'"]
    S1 --> S1b["'不要这样做' / '停止 X'"]
    S1 --> S1c["'对,就是这样' + 非显而易见做法"]
    S2 --> S2a["用户自我介绍角色"]
    S2 --> S2b["提到项目截止日期"]
    S2 --> S2c["指向外部资源"]
    S3 --> S3a[常规任务执行]
    S3 --> S3b[可从代码推断]
    S3 --> S3c[临时调试状态]
    style S1 fill:#fecaca,stroke:#dc2626
    style S2 fill:#fef3c7,stroke:#f59e0b
    style S3 fill:#dcfce7,stroke:#22c55e

认可信号的重要性

工程实践中容易忽视——认可信号和纠正信号一样重要

如果只存纠正信号,Agent 会变得过度保守:"用户上次说不要 A,那我下次就不做 A,也不做 B、C、D 以避免任何风险"。这是过度防御

认可信号告诉 Agent:"这个判断是对的,以后可以继续这样做"。缺了认可信号,Agent 就会从"在任务中摸索正确做法"退化为"只知道不能做错"。

// 认可信号的识别:排除单独出现的简单肯定,只保留对具体做法的确认
function detectConfirmationSignal(userMessage: string): boolean {
  const trimmed = userMessage.trim()

  // 单独一个 "yes"/"对" 等太弱,不能说明是对 Agent 做法的认可
  const standaloneWeak = /^(yes|yeah||ok|好的|嗯)$/i
  if (standaloneWeak.test(trimmed)) return false

  const patterns = [
    /^(yes|yeah||就是这样|exactly|perfect|nice|great)\b[\s,].+/i,
    /keep doing|继续这样|保持/i,
    /looks good|不错|完美|很好/i,
  ]

  return patterns.some(p => p.test(trimmed))
}

写入前的去重检查

不要每次都写新记忆——优先更新现有记忆

async function saveMemory(newMemory: Memory): Promise<void> {
  // 1. 检查是否已有相似记忆(语义 + 主题)
  const existing = await findSimilarMemory(newMemory, {
    semanticThreshold: 0.85,
    sameType: true,
  })

  if (existing) {
    // 更新而非新建
    const merged = mergeMemories(existing, newMemory)
    await updateMemory(existing.id, merged)
    logger.info(`Updated memory: ${existing.name}`)
    return
  }

  // 2. 写入记忆文件
  await writeMemoryFile(newMemory)

  // 3. 更新索引
  await updateMemoryIndex(newMemory)

  logger.info(`Created memory: ${newMemory.name}`)
}

记忆的结构化格式:Rule + Why + How

好的记忆不只是记录事实,还要记录原因应用方式——这是让 Agent 能处理边界情况的关键。

❌ 差的记忆:
"不要用 mock 测试"

⚠️ 中等的记忆:
"集成测试不要用 mock"

✅ 好的记忆:
"规则:集成测试必须连接真实数据库
 Why: 上季度 mock/生产不一致导致迁移失败(2 小时事故)
 How to apply: 写测试时默认用 testcontainers + 测试 DB
              纯逻辑单元测试除外(不涉及数据库)"

有了 Why,Agent 在边界情况下可以推理——比如"这是一个纯函数的单元测试,不涉及数据库,所以不违反这条规则"。

五种写入模式

enum MemoryWriteMode {
  CREATE = "create",       // 新建
  UPDATE = "update",       // 覆盖式更新
  APPEND = "append",       // 追加(比如事件日志)
  MERGE = "merge",         // 合并(比如列表型)
  INVALIDATE = "invalidate" // 作废(软删除)
}

不同类型的记忆适合不同的写入模式:

  • User 类:UPDATE(新信息覆盖旧信息)
  • Feedback 类:CREATE + MERGE(纠正信号每次都有价值)
  • Project 类:UPDATE + INVALIDATE(项目状态会变)
  • Reference 类:CREATE + UPDATE(资源列表)

memory 去重的三档策略

用户说"别用 mock 测试"的时候,memory 里很可能已经躺着一条意思相近的条目了。要不要写、怎么合,取决于你选哪一档去重策略——三档的差别是精度与成本的取舍。

第一档是精确匹配,最保守:名称完全一致才算重复。它几乎不会误删任何东西,代价是重复率居高不下——"不要 mock 测试"和"测试别用 mock"会被当成两条。

第二档是语义去重,也是通常推荐的做法:用 embedding 相似度加上 LLM 确认,能识别上面那两句其实是一回事。这里有个容易被忽略的细节——相似度阈值必须用你自己的样本去调,不能照抄一个固定数值,不同语料的分布差别很大。

第三档是合并而非替换:发现重复时不做二选一,而是让 LLM 把两条合成一条,把双方的 Why 和 How to apply 都保留下来。效果最好,成本也最高。

值得注意的是,快照里的 memdir 并没有内置自动去重——写入时的去重判断是交给模型本身完成的。工程上比较务实的组合是第一档起步,再定期跑一次第三档合并:日常写入用最保守的判据保证不误删,积累一段时间后集中做一次语义合并来压缩重复。规模不大时先用第一档,等条目多起来再上第三档,是一条稳妥的演进路径。

另一个反事故:把 MCP tool 的文档写进 memory

有一类错误特别常见:用户问"XX 这个 MCP 工具怎么用",Agent 顺手写了一条 memory——"XX 工具的用法是 YYY"。等到下次 MCP 接口变了,memory 还停在旧版本,Agent 照着旧接口调用,失败。

根因和 §12.2 那条原则是同一个——只存无法从代码推断的知识:这条 memory 记录的恰恰是"本该从源系统读取"的信息。工具文档有权威来源,把它复制进 memory,等于制造了一份必然会过期的副本。

Claude Code 在 selector 层已经显式防御了这种情况——findRelevantMemories.ts 的 system prompt 里写着 "do not select memories that are usage reference or API documentation for those tools"。但要看清这道防御的位置:它拦的是"选出来",不是"写进去"。最好的防御仍然是写入时就不写。

具体做法有两条。一是写入前用另一个模型判断一次:这条信息是个人偏好、项目知识,还是一个可以随时查询到的事实?最后一种直接拒绝写入。二是把 MCP 接口文档和 API schema 列为永不入库,需要时直接从源头拉最新的。

这体现的是记忆系统的"边界感"——知道什么不该记,和知道什么该记,同等重要

12.5 记忆的检索策略:三路召回 + 相关性排序

flowchart TD
    Start[新会话开始] --> Load[加载 MEMORY.md 索引]
    Load --> Check{记忆数量 < 200 条?}
    Check -->|是| Full[全量加载索引<br/>~1K tokens]
    Check -->|否| Retrieve[多路召回]
    Retrieve --> R1[关键词匹配<br/>BM25]
    Retrieve --> R2[语义搜索<br/>embedding cosine]
    Retrieve --> R3[最近使用<br/>recency]
    Retrieve --> R4[上下文相关<br/>当前项目/文件]
    R1 & R2 & R3 & R4 --> Merge[RRF 合并排序<br/>Top-K]
    Full --> Inject[注入 System Prompt]
    Merge --> Inject
    Inject --> Detail{需要详情?}
    Detail -->|是| ReadFile[按需读取<br/>完整记忆文件]
    Detail -->|否| Done[开始对话]
    ReadFile --> Done
    style Retrieve fill:#dbeafe,stroke:#3b82f6
    style Merge fill:#dcfce7,stroke:#22c55e

全量加载(小规模)

Claude Code 的做法:每次加载完整的 MEMORY.md 索引文件。因为索引文件控制在 200 行以内,token 成本可接受。

async function loadMemoryContext(): Promise<string> {
  const memoryIndex = await readFile('MEMORY.md')

  // 注入 System Prompt
  return `
# User's Long-term Memory

This is your persistent knowledge about this user and project,
built up over multiple sessions. Use it to inform your responses.

${memoryIndex}

When you need full details, use the Read tool to load specific files.
`
}

需要详细信息时,Agent 自己决定读取哪个 .md 文件。这种"按需加载"避免了上下文浪费。

多路召回(大规模)

当记忆量超过索引文件能承载的范围(>200 条)时,需要按相关性检索:

def retrieve_relevant_memories(
    query: str,          # 当前用户消息
    context: dict,       # 当前上下文(项目、文件、时间)
    max_memories: int = 5
) -> list[Memory]:
    # === 多路召回 ===
    # 1. 关键词匹配(BM25)
    keyword_results = keyword_search(query, limit=15)

    # 2. 语义搜索(向量相似度)
    semantic_results = vector_search(query, limit=15)

    # 3. 最近使用(recency)
    recency_results = get_recent_memories(days=7, limit=5)

    # 4. 上下文相关(同项目/同文件)
    context_results = context_match(context, limit=5)

    # === 合并 ===
    # RRF (Reciprocal Rank Fusion)
    candidates = rrf_merge([
        keyword_results,
        semantic_results,
        recency_results,
        context_results,
    ], k=60)

    # === 排序 ===
    scored = [(m, compute_relevance(m, query, context)) for m in candidates]
    scored.sort(key=lambda x: x[1], reverse=True)

    # === 过滤 ===
    # 剔除过期记忆
    scored = [(m, s) for m, s in scored if not is_expired(m)]

    # 剔除已无效记忆
    scored = [(m, s) for m, s in scored if not m.invalidated]

    return [m for m, _ in scored[:max_memories]]

def compute_relevance(m: Memory, query: str, ctx: dict) -> float:
    # 多因子加权
    score = 0
    score += 0.4 * semantic_similarity(m.content, query)
    score += 0.2 * keyword_overlap(m.content, query)
    score += 0.2 * recency_score(m.created, decay_days=30)
    score += 0.1 * type_relevance(m.type, ctx.current_task)
    score += 0.1 * usage_weight(m.usage_count, m.success_rate)
    return score

记忆注入的位置

记忆应该注入到 System Prompt 而非 User Message。原因:

  • System Prompt 的注意力权重更高
  • 避免用户误以为这是自己的话
  • 可以结构化标记("这是从你过去的会话中学到的")
System Prompt:
  [工具定义]
  [人格/风格]
  [长期记忆]            ← 在这里注入
  [CLAUDE.md]

User Message:
  [当前问题]

findRelevantMemories.ts:用 LLM 做 memory 检索器

前面讨论的三类检索方法——向量、BM25、规则——Claude Code 一个都没选,它走了第四条路:让 Sonnet 直接读 memory manifest,从中挑出五条相关的

findRelevantMemories.ts:18-24 的 system prompt 是这样写的:

You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. Return a list of filenames for the memories that will clearly be useful to Claude Code as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.

这段不长的指令里有三个约束被仔细考虑过。

"up to 5" 是对 lost in the middle 的直接缓解(第11章《短期记忆:上下文窗口管理》详细讲过这个现象)——塞进去的 memory 越多,主模型的注意力越被稀释,多选反而更差。"only include memories that you are certain" 定的是宁缺勿滥的基调:低置信度的宁可不选,因为选错的代价远大于漏选。

第三条约束最见功力:"do not select memories that are usage reference or API documentation for those tools (Claude Code is already exercising them). DO still select memories containing warnings, gotchas, or known issues"。它区分的是"当前工具的用法"和"当前工具的坑"——前者模型正在用、本来就知道,后者才是 memory 能提供的额外信息。

识别"什么信息不该从 memory 拿",比识别"什么该拿"更难,而这条指令把边界划得相当清楚。

Sonnet 做 selector 的成本账

selector 看起来只是一次辅助调用,很容易让人想省钱换个更便宜的模型。但要算清这笔账,得先看清它决定了什么:它决定哪些 memory 会被注入主 prompt

弱 selector 的风险不是"少花钱",而是把不相关的 memory 塞进主上下文,给主模型造成错误的先验。反过来,强 selector 的价值也不能凭感觉宣称——必须用人工标注的 memory-query 样本集去量 precision / recall,否则你无法知道换模型到底带来了什么。

真正的成本对比是这样的:选错一条 memory,意味着整条内容进了主 prompt、误导了主模型、Agent 最终给出错误结果——这个代价通常远高于一次 selector 调用本身的费用。

这和第 18 章《评估与测试》讨论的"Judge 要用强模型"是同一个思路:辅助任务的模型能力降一档,主任务的质量可能降两档,省了小钱丢了大钱。

alreadySurfaced 参数:memory 去重的优雅实现

alreadySurfaced: ReadonlySet<string> = new Set(),

findRelevantMemories.ts:46 这个参数解决的是一个很实际的问题:用户连续问三轮,每一轮 selector 都可能选中同一条 memory,于是同一条内容被反复注入主上下文——既浪费 token,又在重复模型已经知道的信息。

解法是让调用方传入"这个 session 已经 surface 过的 memory 路径集合",selector 预先把它们过滤掉,每次只选新的。

值得注意的是这个状态放在哪里:selector 本身是 stateless 的,状态由调用方管理(RESTful 的同一条哲学)。这样做的好处是并发安全、不同 session 天然隔离——如果把"已曝光集合"记在 selector 内部,就得为每个会话维护一份状态,还得处理清理和过期。

这是个可以直接抄走的小模式:任何"重复推荐"问题,都可以用"排除已曝光集合"来解

为什么 memory 不用向量数据库

回到那个选择:Claude Code 为什么用 Sonnet 做 selector,而不是上一套向量检索?四个理由指向同一个判断。

规模用不上向量的优势。 memory 通常 < 1000 条,而向量检索真正的强项是大规模相似度搜索,这个量级完全发挥不出来。description 本身已经是摘要,Sonnet 读一遍 description 和 query 就能判断相关性,准确度并不低。

更关键的是第三条:向量检索返回的 top-k 是纯相似度排序,不考虑"当前任务的 context",而 LLM selector 能理解"这条 memory 和当前正在做的事情是什么关系"——前面那条"用法不选、坑要选"的指令,向量相似度是表达不出来的。最后还有成本:向量方案需要 embedding、存储、索引维护,运维开销比一次 Sonnet 调用还大

结论其实是一条经验法则:小规模 memory 用 LLM selector 更优,大规模外部知识用向量检索更优——规模决定工具选择。这有点反直觉,因为"用向量做一切"几乎成了默认答案,但在这个量级上它并不成立。

scanMemoryFiles 94 行——目录扫描的工业级实现

memoryScan.ts 只有 94 行,做的是记忆系统里最不起眼的一件事:列出目录里有哪些 memory。但这段代码里有四个设计决定值得单独看,它们合起来解释了什么叫"工业级"。

只扫 *.md .DS_Store.git、编辑器留下的 swap 文件都不会进来——目录扫描最常见的故障就是被这类文件噎住。

frontmatter 解析失败的文件被静默跳过。 实现上用 Promise.allSettled 并只保留 fulfilled 的结果,效果是单个坏文件不会阻断整体扫描:一条记忆写坏了,其余的照常可用,而不是整个记忆系统罢工。

返回的是 MemoryHeader 数组而不是完整内容——{ filename, filePath, description, type, mtimeMs }不含 body。这一条直接决定了检索的成本结构:selector 拿 header 就能做决策,不需要把所有 memory 的正文读进内存,既省 I/O 又省 token。

mtimeMs 顺手透传出来,下游调 memoryFreshnessText() 时就不必再 stat 一次文件。

四条合起来是同一套哲学:惰性加载、容错、可观测。第 19 章§19.26 讨论的 metadata enrichment 正是同一思路的另一处落地——先拿到足够做决策的元数据,再决定要不要付出加载正文的代价

12.6 记忆的生命周期管理:让记忆不过时

记忆会过时。上个月的项目截止日期、已离职同事的职责、已重构的代码结构——这些记忆如果不清理,会误导 Agent。

stateDiagram-v2
    [*] --> Active: 创建
    Active --> Active: 使用/更新
    Active --> Stale: 时效过期
    Active --> Invalidated: 用户显式废除
    Active --> Superseded: 被新记忆覆盖
    Stale --> Archived: 归档(保留但不使用)
    Invalidated --> Deleted: 删除
    Superseded --> Archived
    Archived --> Deleted: 清理周期到
    Deleted --> [*]

时效性标记

---
name: release-freeze
type: project
created: 2026-04-10
expires: 2026-04-16  # 显式过期日期
ttl: 7d              # 或相对 TTL
---

过了 4 月 16 日,这条记忆就应该被标记为过期或归档。

失效检测的三种信号

async function detectStaleMemories(): Promise<Memory[]> {
  const memories = await listAllMemories()
  const stale: Memory[] = []

  for (const m of memories) {
    // 信号 1: 显式过期
    if (m.expires && new Date(m.expires) < new Date()) {
      stale.push({ ...m, reason: "expired by date" })
      continue
    }

    // 信号 2: 引用失效
    if (m.referencedFiles) {
      const existing = await Promise.all(
        m.referencedFiles.map(f => fileExists(f))
      )
      if (existing.every(e => !e)) {
        stale.push({ ...m, reason: "all referenced files missing" })
        continue
      }
    }

    // 信号 3: 矛盾新记忆
    const contradictions = await findContradictions(m)
    if (contradictions.length > 0) {
      stale.push({ ...m, reason: `contradicted by ${contradictions[0].name}` })
      continue
    }
  }

  return stale
}

验证后再使用——记忆 ≠ 事实

Claude Code 的核心规则:记忆中提到的文件路径、函数名、配置项,在推荐给用户之前必须先验证

"记忆说 X 文件存在" ≠ "X 文件现在存在"
"记忆说 foo() 函数签名是 ..." ≠ "现在 foo() 还是那个签名"

记忆是某个时间点的快照。在据此行动之前,用 Glob/Grep/Read 验证当前状态:

async function actOnMemory(memory: Memory): Promise<Action> {
  // 1. 提取记忆中的具体引用
  const references = extractReferences(memory)

  // 2. 验证每一个引用仍然有效
  for (const ref of references) {
    if (ref.type === "file" && !await fileExists(ref.path)) {
      return {
        action: "refresh-memory",
        reason: `Referenced file ${ref.path} no longer exists`,
      }
    }
    if (ref.type === "function" && !await functionExists(ref.name)) {
      return {
        action: "refresh-memory",
        reason: `Function ${ref.name} no longer exists`,
      }
    }
  }

  // 3. 验证通过,可以使用
  return { action: "use", memory }
}

定期清理

async function cleanupMemories(): Promise<CleanupReport> {
  const memories = await listAllMemories()
  const report: CleanupReport = { archived: 0, deleted: 0 }

  for (const m of memories) {
    // 项目类记忆:超过 90 天未访问 → 归档
    if (m.type === 'project' && daysSinceAccess(m) > 90) {
      await archive(m)
      report.archived++
      continue
    }

    // 已归档 > 180 天 → 删除
    if (m.archived && daysSinceArchive(m) > 180) {
      await deleteMemory(m)
      report.deleted++
      continue  // 已删除的文件不能再进入后续归档分支
    }

    // 引用失效 → 归档
    if (m.referencesFile && !await fileExists(m.referencesFile)) {
      await archive(m)
      report.archived++
    }
  }

  return report
}

memoryAge.ts 的两条话术智慧

前面讲的时效性原则,在 Claude Code 里落成了 memoryAge.ts 这 53 行代码。它本身很简单,值得细读的是里面两条注释——它们解释了两个看起来多余、实际不可省的设计。

第一条:把时间算好,别让模型算。

// Models are poor at date arithmetic —
// a raw ISO timestamp doesn't trigger staleness reasoning the way
// "47 days ago" does.
export function memoryAge(mtimeMs: number): string { ... }

模型看到 2025-02-26T10:00:00Z 不会主动去算"哦这是 413 天前",但看到 413 days ago 立刻就懂。同一个事实,换一种表述就能触发它的时效性推理——这是 prompt 工程里很典型的一类收益:不是给模型更多信息,而是把信息换成它更容易用起来的形态。

第二条:过时的记忆要主动带上免责声明。

// 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.
export function memoryFreshnessText(mtimeMs: number): string { ... }

这条注释里藏着一个反直觉的观察:记忆里带 file:line 引用,反而让模型更自信。一句"根据 src/foo.ts:42 的代码,这里应该……"读起来像是有据可查,可一旦那段代码已经变了,精确的行号只会让错误的判断显得更权威。

memoryFreshnessText() 的对策是:每一条超过一天的 memory,注入时自动拼上"这是 N 天前的 observation,不是 live state,请先验证"。这正是本节"验证后使用"原则的具体落地——它不是一句口号,而是每次注入 prompt 时系统层面强制附加的提醒。

真实事故:memory 里的 "file:line 引用"如何害人

上面那条注释提到的"用户报告",还原成一个典型场景大致是这样(示意):

Agent 在 2025 年 8 月写下一条 memory——"根据 src/auth.ts:45,token 存在 localStorage"。六个月后用户问起"token 是怎么处理的",Agent 把这条记忆注入了上下文,但没有附加 age caveat。用户看到具体的文件和行号,自然相信它是权威的;而那时代码早已重构到 src/auth/session.ts:120,token 也换成了 cookie。用户照着这个错误信息改代码,引入了新的 bug。

根因很清楚:memory 写入时是事实,读取时可能已经陈旧——memoryFreshnessText() 就是为这个场景写的。

从这个事故能带走三条可执行的做法。任何 file:line 级别的 memory 都必须有 age check,因为它们恰恰是最容易过期、又最容易被当成权威的一类。超过一定天数(建议 30 天)的 memory 在注入 prompt 时必须带上"请先验证"的提示。以及定期跑一次 stale memory cleanup,自动扫描并归档半年没更新的条目。

这三条针对的其实是同一个矛盾:长期记忆与 live state 之间的张力是永恒的。记忆的价值来自它跨越了时间,而它的风险也正来自于此——Agent 工程师对这一点必须时刻警觉。

12.7 隐私与安全:记忆的红线

长期记忆是高风险的数据——处理不当会产生隐私问题和安全漏洞。

绝不存储清单

🚫 API Key、密码、token
🚫 .env 文件的内容
🚫 数据库连接字符串
🚫 证书私钥、SSH key
🚫 个人身份信息(PII)——除非用户明确要求且已脱敏
🚫 医疗、金融、法律的敏感信息

写入时的敏感信息检测

const SENSITIVE_PATTERNS = [
  /-----BEGIN (PRIVATE|RSA) KEY-----/,
  /sk-[a-zA-Z0-9]{32,}/,              // OpenAI key
  /AKIA[0-9A-Z]{16}/,                  // AWS access key
  /ghp_[a-zA-Z0-9]{36}/,               // GitHub token
  /\b\d{3}-\d{2}-\d{4}\b/,             // SSN pattern
  /\b\d{13,19}\b/,                     // credit card
  /password\s*[=:]\s*['"][^'"]+['"]/i,
  /(api[_-]?key|secret|token)\s*[=:]\s*['"][^'"]+['"]/i,
]

function detectSensitiveInfo(text: string): SensitiveMatch[] {
  const matches: SensitiveMatch[] = []
  for (const pattern of SENSITIVE_PATTERNS) {
    const m = text.match(pattern)
    if (m) {
      matches.push({ pattern: pattern.source, sample: m[0].slice(0, 20) + "..." })
    }
  }
  return matches
}

async function safeSaveMemory(memory: Memory): Promise<void> {
  // 写入前扫描
  const sensitive = detectSensitiveInfo(memory.content)
  if (sensitive.length > 0) {
    throw new SecurityError(
      `Memory contains sensitive info: ${sensitive.map(s => s.pattern).join(", ")}`
    )
  }

  // 对 PII 做脱敏
  memory.content = redactPII(memory.content)

  await writeMemoryFile(memory)
}

存储位置的选择

✅ ~/.claude/projects/{sanitized-path}/memory/  # 用户主目录,跨项目隔离
❌ /path/to/project/.claude/memory/     # 项目目录——会被意外提交!

Claude Code 的做法:记忆存储在 ~/.claude/projects/{sanitized-project-path}/memory/ 目录下:

  • 项目路径被规范化为目录名(快照实现是 sanitize、不是哈希)——按项目隔离
  • 记忆文件不在项目目录内——不会被意外提交到 Git
  • 用户主目录通常不会被备份到公开存储

加密

对云端存储的记忆,必须加密:

// 写入
const encrypted = await encrypt(memory.content, await getDEK(userId))
await cloudStorage.put(`memories/${userId}/${memory.id}`, encrypted)

// 读取
const encrypted = await cloudStorage.get(`memories/${userId}/${memory.id}`)
const content = await decrypt(encrypted, await getDEK(userId))

关键:密钥不能存在和数据同一位置。使用 KMS(AWS KMS、GCP KMS)管理密钥。

12.8 实践:构建文件记忆系统

一个最小可用的记忆系统实现:

import fs from 'fs/promises'
import path from 'path'

// 以下辅助函数/类型为最小存根;生产环境需替换为真实实现
function detectSensitiveInfo(text: string): { pattern: string; sample: string }[] {
  // 生产环境应使用更完整的敏感信息检测规则
  return []
}

class SecurityError extends Error {}

function slugify(name: string): string {
  return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
}

function groupBy<T>(arr: T[], keyFn: (item: T) => string): Record<string, T[]> {
  return arr.reduce((acc, item) => {
    const key = keyFn(item)
    acc[key] = acc[key] ?? []
    acc[key].push(item)
    return acc
  }, {} as Record<string, T[]>)
}

async function fileExists(p: string): Promise<boolean> {
  return fs.stat(p).then(() => true).catch(() => false)
}

function parseMemoryFile(content: string): Memory | null {
  const match = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/)
  if (!match) return null
  const front = Object.fromEntries(
    match[1].split('\n').map(line => line.split(': '))
  )
  return {
    id: front.id ?? front.name,
    name: front.name ?? '',
    description: front.description ?? '',
    type: front.type ?? 'reference',
    content: match[2].trim(),
    created: front.created ?? new Date().toISOString(),
    updated: front.updated ?? front.created ?? new Date().toISOString(),
    expires: front.expires,
    invalidated: front.invalidated === 'true',
    referencedFiles: front.referencedFiles
      ? JSON.parse(front.referencedFiles)
      : undefined,
  } as Memory
}

interface CleanupReport {
  archived: number
  deleted: number
}

interface Memory {
  id: string
  name: string
  description: string
  type: 'user' | 'feedback' | 'project' | 'reference'
  content: string
  created: string
  updated: string
  expires?: string
  invalidated?: boolean
  referencedFiles?: string[]
}

class FileMemoryStore {
  constructor(private dir: string) {}

  async save(memory: Memory): Promise<void> {
    // 敏感信息检查
    const sensitive = detectSensitiveInfo(memory.content)
    if (sensitive.length > 0) {
      throw new SecurityError("Sensitive info detected, cannot save")
    }

    // 去重:若找到相似记忆则更新,否则新建
    const existing = await this.findSimilar(memory)
    if (existing) {
      return this.update(existing.id, memory)
    }

    // 写文件
    const filename = `${memory.type}_${slugify(memory.name)}.md`
    const content = this.serialize(memory)
    await fs.writeFile(path.join(this.dir, filename), content)

    // 更新索引
    await this.updateIndex()
  }

  private serialize(m: Memory): string {
    const lines: (string | null)[] = [
      '---',
      `id: ${m.id}`,
      `name: ${m.name}`,
      `description: ${m.description}`,
      `type: ${m.type}`,
      `created: ${m.created}`,
      `updated: ${m.updated}`,
      m.expires ? `expires: ${m.expires}` : null,
      m.invalidated ? `invalidated: true` : null,
      m.referencedFiles ? `referencedFiles: ${JSON.stringify(m.referencedFiles)}` : null,
      '---',
      '',
      m.content,
    ]
    return lines.filter((l): l is string => l !== null).join('\n')
  }

  async loadIndex(): Promise<string> {
    const indexPath = path.join(this.dir, 'MEMORY.md')
    const exists = await fileExists(indexPath)
    return exists ? await fs.readFile(indexPath, 'utf-8') : ''
  }

  async loadMemory(filename: string): Promise<Memory | null> {
    const content = await fs.readFile(
      path.join(this.dir, filename), 'utf-8'
    )
    return parseMemoryFile(content)
  }

  private async updateIndex(): Promise<void> {
    const files = await fs.readdir(this.dir)
    const entries: string[] = ['# Memory Index', '']

    const memories: Memory[] = []
    for (const file of files.filter(f => f !== 'MEMORY.md' && f.endsWith('.md'))) {
      const m = await this.loadMemory(file)
      if (m && !m.invalidated) memories.push(m)
    }

    // 按类型分组
    const byType = groupBy(memories, m => m.type)
    for (const type of ['user', 'feedback', 'project', 'reference'] as const) {
      const items = byType[type]
      if (!items?.length) continue
      entries.push(`## ${type}`)
      entries.push('')
      for (const m of items) {
        const filename = `${m.type}_${slugify(m.name)}.md`
        entries.push(`- [${m.name}](${filename}) — ${m.description}`)
      }
      entries.push('')
    }

    await fs.writeFile(
      path.join(this.dir, 'MEMORY.md'),
      entries.join('\n')
    )
  }

  // 生产环境需实现真正的语义相似度去重
  private async findSimilar(memory: Memory): Promise<Memory | null> {
    return null
  }

  // 生产环境需实现真正的合并/覆盖逻辑
  private async update(id: string, memory: Memory): Promise<void> {
    const filename = `${memory.type}_${slugify(memory.name)}.md`
    const content = this.serialize({ ...memory, id })
    await fs.writeFile(path.join(this.dir, filename), content)
    await this.updateIndex()
  }

  // 生产环境需实现归档(如移动到 archive/ 子目录)
  private async archive(filename: string): Promise<void> {
    const src = path.join(this.dir, filename)
    const dst = path.join(this.dir, 'archive', filename)
    await fs.mkdir(path.join(this.dir, 'archive'), { recursive: true })
    await fs.rename(src, dst)
  }

  async cleanup(): Promise<CleanupReport> {
    const files = await fs.readdir(this.dir)
    const report: CleanupReport = { archived: 0, deleted: 0 }

    for (const file of files.filter(f => f.endsWith('.md') && f !== 'MEMORY.md')) {
      const m = await this.loadMemory(file)
      if (!m) continue

      // 过期
      if (m.expires && new Date(m.expires) < new Date()) {
        await this.archive(file)
        report.archived++
        continue  // 已归档的文件不能再走后面的归档分支(会 ENOENT + 重复计数)
      }

      // 引用失效
      if (m.referencedFiles?.length) {
        const stillValid = await Promise.all(
          m.referencedFiles.map(f => fileExists(f))
        )
        if (stillValid.every(v => !v)) {
          await this.archive(file)
          report.archived++
        }
      }
    }

    return report
  }
}

写给新团队的 15 分钟 memory 系统 MVP

从零搭一套能跑的记忆系统,其实用不了多久。下面这份步骤是照着做的,所以保持清单形态——每一步都有明确产出。

分钟 0-3——

  • mkdir .claude/memory
  • 新建 MEMORY.md、内容只有一行:# Memory Index

分钟 3-8——

  • 写一个系统 prompt 片段"If the user tells you something that would be useful across future conversations, save it as a new file in .claude/memory/ and add a one-line index entry to MEMORY.md."
  • 把这段塞进 Agent 的 system prompt

分钟 8-13——

  • 给 Agent 工具集加一个 WriteMemory(name, description, content) 工具
  • 加一个 ReadMemory(name) 工具
  • 别加"更新"或"删除"——先只做增量

分钟 13-15——

  • 写一个启动时加载的脚本:cat .claude/memory/MEMORY.md >> system_prompt

十五分钟之后你就有了一套最简记忆系统。它和 Claude Code 的差距,是 selector、age caveat、team scope、cleanup 这四样——而这四样每一样都是被真实问题逼出来的,不是设计之初就想好的

这也正是记忆系统的正确打开方式:入门不求完美,先能跑起来。它是典型的"每周改进一点点"的模块——先让 Agent 开始积累,再根据积累出来的问题决定下一步补哪一块。

12.9 和 LangGraph Store 的对比

Claude Code 把记忆做成了文件,LangGraph 把它做成了 API。两条路线的差别不只是实现细节,它决定了使用者需要理解多少东西才能上手,也决定了出问题时能不能直接打开文件看一眼。

对比 LangGraph Store 的 API 设计

LangGraph 也有自己的 memory 原语——BaseStoreInMemoryStorePostgresStore——它和 Claude Code 的文件系统方案在每一个维度上都不一样:

维度 Claude Code memdir LangGraph Store
存储 .claude/memory/*.md KV 数据库 / Postgres
schema frontmatter + markdown {namespace, key, value}
检索 Sonnet-based selector 向量相似度
更新 用户 / Agent 显式写 put / delete API
共享 file system 共享 namespace 隔离
版本 git 无内置版本历史(put 即覆盖;线程级历史可借 checkpointer 获得)

这张表背后是两种哲学。走文件系统这条路,换来的是人类可读、可 git diff、可以直接用编辑器改——对 coding、research 这类工具型 Agent 特别合适,因为使用者本来就活在文件系统里,出了问题打开文件看一眼就知道发生了什么。走数据库这条路,换来的是并发友好、可索引、可分布式——对客服、多租户这类高并发 Agent 更合适,那里的记忆条目数量和写入频率都不是文件系统扛得住的。

所以这不是"谁更好"的问题,而是三组工程权衡的结果:单用户还是多用户、本地还是云端、人可读优先还是机器读取优先。想清楚自己在这三个轴上的位置,选择就基本确定了。

《LangGraph 设计与实现》里讨论 LangGraph Store 的章节会讲它的具体用法。和本章合起来看会更清楚:记忆的存储模型是 Agent 框架设计的一个重要分叉点,它一旦定下来,后面的检索、共享、版本管理全都跟着变。

12.10 方法论:从反模式到六条原则

前面九节把记忆系统拆开讲了一遍。这一节反过来:先看哪些坑是必然会踩的,再把散落各处的经验收成可以直接执行的原则。

顺序是从教训到规则——反模式比正面表述更容易记住,也更容易在评审时被人指出来。

四个反模式

反模式一:过度记忆

现象:Agent 把每次对话都当作新知识写入记忆,MEMORY.md 膨胀到几千条。

根因:没有信号分级,所有信息一视同仁。

对策:只写强信号(明确纠正、明确认可),中等信号需要多次验证。

反模式二:不带 Why 的规则

现象:记忆只有"不要做 X",Agent 在边界情况下过度保守。

根因:记忆格式缺少 Why 和 How to apply。

对策:强制模板——规则 + Why + How to apply 三段式。

反模式三:不清理过期记忆

现象:记忆中提到"下周二发布",一年后还在引用。

根因:没有 TTL 机制和生命周期管理。

对策:项目类记忆必须有 expires;每周自动清理过期项。

反模式四:把敏感信息写进记忆

现象:用户不小心贴了 API key,Agent 把它写进记忆。下次会话用到这条记忆,把 key 暴露在新的上下文中。

根因:没有敏感信息过滤。

对策:写入前强制扫描;匹配到敏感模式直接拒绝。

十个"不要做"的 memory 反模式

上面四个反模式是按"现象—根因—对策"展开的,下面这十条则是执行层面的禁忌清单,本章各节的反例都收在这里。它保持清单形态是有意的——写入前逐条对照,比读一段议论更管用。

判断标准只有一条:这条信息有没有一个比 memory 更权威的来源。有,就不该写进 memory;memory 记的应该是那些"从别处推不出来"的东西。

  1. 不要记录 API 文档——代码和源系统才是权威
  2. 不要记录项目架构——CLAUDE.md 或 README 才是权威
  3. 不要记录 commit 历史——git log 才是权威
  4. 不要记录"这次修好了 X 的 bug"——git blame 才是权威
  5. 不要记录 SSH key / API token / .env——隐私红线,见前面原则 6
  6. 不要记录"我是 Claude"——self-referential memory 会污染 prompt
  7. 不要在一条 memory 里写多个主题——违反单一职责,检索精度会下降
  8. 不要用相对时间("上周""明天")——必须写绝对日期,memoryAge.ts 存在的理由就是这个
  9. 不要记录调试过程——结果比过程有价值,写清最终修复方式就够
  10. 不要"为了记而记"——信息量门槛要高,弱信号不如不记

这十条每一条背后都对应一次"看似聪明、实际搞砸"的真实场景。前四条共享同一个道理:把有权威来源的信息复制进 memory,等于制造了一份必然会过期的副本

主线小结:长期记忆的六条原则

长期记忆让 Agent 从"每次失忆的工具"进化为"持续学习的助手":

  1. 分类存储——User / Feedback / Project / Reference 四种类型,各有适用场景
  2. 选择性写入——只存无法从代码推断的人类知识;强信号才写
  3. 结构化格式——规则 + Why + How to apply,让 Agent 能处理边界情况
  4. 验证后使用——记忆是快照不是事实,行动前先验证
  5. 生命周期管理——记忆会过时,需要定期清理和失效检测
  6. 隐私优先——永远不存储敏感信息;注意存储位置和加密

工程实践的核心节奏:

感知 → 分类 → 去重 → 结构化 → 持久化 → 检索 → 验证 → 使用 → 清理

这个循环是 Agent 跨会话学习的基础。没有这个循环,Agent 就是一个永远的新手——一遍一遍听你介绍自己。

这些源码实证说明了什么

把前面几节的源码细读连起来看,会得到一个比"原则清单"更具体的结论:长期记忆是 Agent 跨会话进化能力的基础设施,而不是一个可选的增强功能。没有它的 Agent 是每次失忆的实习生,有它的 Agent 才是越用越懂你的同事。

而"工业级"这三个字的含金量,恰恰体现在那些看起来琐碎的地方:1736 行代码里,真正讲原理的部分不多,大量篇幅花在硬上限、去重、时效话术、目录扫描这些边界处理上。§12.6 那个 "file:line 引用" 的真实事故就是最好的注脚——一条格式完全正确、内容也没错的记忆,仅仅因为代码行号漂移就开始误导 Agent

如果只从本章带走两段能直接抄的代码,我建议是这两条:§12.3 的 MEMORY.md 双硬上限(200 行 + 25KB),和 §12.6 的 memoryFreshnessText() 自动附加 caveat。它们不是万能药,但覆盖了最常见的几个"记忆害人"入口——过期、重复、低置信度、以及与当前任务无关。上线之后仍然要盯误注入率和人工复核结果。

跨专栏呼应:memory 系统和整个 Agent 生态

记忆系统很容易被当成一个独立模块来实现,但它其实和 Agent 平台的每一个部分都有接口。本专栏另外六章分别从不同角度碰到了它:

第 3 章《Agent 主循环》——每一次 turn 都可能触发 memory 的读写,主循环就是记忆的时间轴。第 11 章《短期记忆与上下文压缩》——compact 产出的那份九字段摘要,在会话结束时可以升级成长期 memory,这是两种记忆之间最自然的一条通路。

第 15 章《沙箱与隔离》——.claude/memory.claude/team-memory 永远处于 denyWrite 名单(见 §15.13 反击 2),目的是防止 Agent 篡改自己的记忆;这条约束看起来简单,却是"记忆可信"的前提。第 18 章《评估与测试》——memory 是评估 golden set candidate 的重要来源,"这条 feedback 是不是真的让 Agent 做得更好"可以反向验证。

第 19 章《可观测性》——每一次 memory 的读、写、命中、过滤都应该是一个 span,trace 里才能回答"这一轮到底用了哪些记忆"。第 20 章《成本控制》——memory selector 的那次 Sonnet 调用同样要进成本账本,它不是免费的。

六章合起来读就会看清:memory 不是一个孤立模块,而是 Agent 平台的"学习子系统",和主循环、压缩、沙箱、评估、可观测性、成本全都有交互。

和"上下文工程"潮流的关系

2024 到 2026 年,业界涌现出一波围绕 "context engineering" 的讨论(Anthropic 博客、LangChain 的演讲、Simon Willison 的文章都多次提及),而记忆系统正是它的核心支柱之一。

上下文工程有三大输入:working memory(当前对话历史,本专栏第 11 章)、long-term memory(跨会话记忆,也就是本章),以及 retrieved knowledge(RAG / vector search,见第 4 章《上下文工程》及第16章《多 Agent 协调模式》)。每一轮对话的 context,本质上是这几样东西加上 system prompt、工具定义和当前用户消息拼起来的结果。

三者中最容易混淆的是 memory 和 RAG,但它们的边界其实很清楚:memory 存的是"关于用户、项目、以及 Agent 自己"的知识,RAG 存的是"关于外部世界"的知识。规模上的差异同样悬殊——memory 通常 < 1000 条 / < 5MB,RAG 可以到 GB 级,因此两者的存储与检索算法根本不同。

有了这张三分图,你就不会再把所有内容都往 memory 里塞——分不清该进哪一格,是记忆系统失控最常见的起点

12.11 落地路线图与度量

原则读完了,但原则不会告诉你从哪儿动手、也不会告诉你做得怎么样。这一节给两样东西:一张从零到生产的路线图,和一组用来判断"记忆系统健不健康"的指标。

三个月落地路线图

下面这张三个月的路线图是照着执行的,所以保持清单形态。它的排序不是随意的:每一周补的都是上一周暴露出来的问题——先让系统跑起来积累记忆,再解决积累带来的膨胀,最后才处理多人协作。

Month 1(最小可用)——

  • Week 1:§12.8 的 15 分钟 MVP 跑起来
  • Week 2:加 WriteMemory / ReadMemory 工具
  • Week 3:加 MEMORY.md 索引 + 启动加载
  • Week 4:加双硬上限(200 行 + 25KB)

Month 2(生产化)——

  • Week 5:加 4 种 type 分类
  • Week 6:加 Sonnet selector(§12.5 )
  • Week 7:加 freshness caveat(§12.6 )
  • Week 8:加 PII 扫描 + 隐私红线

Month 3(团队化)——

  • Week 9:加 team memory 目录 + git 集成
  • Week 10:加 alreadySurfaced 去重
  • Week 11:加 12 KPI 仪表盘(前面)
  • Week 12:加 stale memory 自动清理 cron job

三个月走完,这套记忆系统在能力上就对标 Claude Code 的 memdir 了。值得强调的是顺序而非速度:如果一开始就想把 selector、team scope、KPI 全都设计好,多半会卡在设计上而迟迟跑不起来——而记忆系统的价值恰恰要靠真实积累才看得出来。

四类 memory 的写入 checklist

下面这张表是写入前的对照清单,四种类型各自该存什么、不该存什么一目了然:

Type 存什么 不存什么 Why 字段 How to apply
User 角色、经验、偏好、知识水平 姓名 / 邮箱(PII)、凭证 可选 必须
Feedback 明确的纠正或认可 单次没复现的猜测 必须(下次判断边界要用) 必须
Project 进度、deadline、决策 代码结构、git 历史 必须(stakeholder 意图) 必须
Reference URL、项目 ID、dashboard 地址 实时数据、密钥 可选 可选

把这张表放在写入路径上过一遍,成本远低于事后清理——一条写错的 memory 会在之后每一次相关查询里持续误导,而清理它需要先意识到它错了。这也是 §12.3 那句"在写入路径就防御,而不是等超限才截断"的同一个道理。

记忆系统的 12 个 KPI

记忆系统要能被管理,就必须有指标——否则你无法回答"它到底在帮忙还是在添乱"。下面十二个 KPI 分成质量、成本、效果三个维度,同样保持清单形态,便于直接搬进仪表盘:

质量维度——

  1. Memory 总数(过多即过度记忆)
  2. 平均 age(太老 → 需清理)
  3. 命中率(被 selector 选中的比例、低 → 价值不高)
  4. 引用失效率(指向的文件不存在 → 需归档)

成本维度——

  1. Selector 每日调用次数 + 成本
  2. Memory body 总 token(注入 prompt 的量)
  3. Memory 读 / 写 比率(>5:1 可作为建议起点,非行业标准,应按自家数据校准)

效果维度——

  1. 含 memory 的任务 vs 不含的任务——成功率差
  2. 用户反馈 "agent 懂我" 率
  3. 重复教育率(用户反复告诉 agent 同一件事)——低越好

健康维度——

  1. MEMORY.md 字节 / 行 占比 upper bound
  2. 敏感信息扫描命中率(应该 = 0)

这 12 个 KPI 每周跑一次、画一张"Memory Health" 报告——任何维度异常立刻介入——像维护数据库一样维护 memory

12.12 速查卡与源码锚点

最后是查阅用的东西:本章引用过的源码文件清单、与下一章的衔接,以及几句收尾。

源码锚点速查表

想深挖任何一条实现,下面这张表是起点:

话题 源码位置
四类 type 常量 src/memdir/memoryTypes.ts:14-20
MEMORY.md 双硬上限 src/memdir/memdir.ts:35-38
截断函数(三重智慧) src/memdir/memdir.ts:57-103
memory 时效性话术 src/memdir/memoryAge.ts:15-42
Sonnet selector prompt src/memdir/findRelevantMemories.ts:18-24
alreadySurfaced 去重 src/memdir/findRelevantMemories.ts:46
Team memory prompts src/memdir/teamMemPrompts.ts
Team memory paths src/memdir/teamMemPaths.ts
Header 扫描(不读 body) src/memdir/memoryScan.ts
Path resolution + env check src/memdir/paths.ts

三个绕不开的取舍

看完实现再回头看,Claude Code 的记忆系统其实是在三个问题上做了明确表态。这三个答案没有一个是唯一正确的,但它们共同决定了这套系统的"性格"。

第一个问题:记忆是客观事实,还是主观观察? Claude Code 选了后者。memoryFreshnessText 明确承认 memory 是 "point-in-time observations, not live state"——这是一种务实的谦逊:AI 的"记忆"永远不等价于数据库里的"事实",承认这一点,才有 §12.6 那套 freshness caveat 的立足之地。

第二个问题:memory 应该忠实复现,还是抽象提炼? Claude Code 选了抽象提炼。每一条 memory 都带 Why 和 How to apply,它不是录音而是摘要加反思——只有这样,模型在遇到规则没覆盖的边界情况时才有判断依据。§12.10 的反模式二说的正是省掉这一步的代价。

第三个问题:记忆该由用户显式管理,还是 Agent 自动维护? Claude Code 选了"Agent 自动写入 + 用户完全可见":写什么由 Agent 决策,但用户随时可以 cat MEMORY.md 查看,也可以直接编辑 .claude/memory/ 下的文件。自动化不等于黑盒——这是这套设计里最容易被忽略、却最影响信任的一条。

三个答案合起来是谦逊、克制、透明。值得学习的不是它的 API,是这套取舍背后的价值观。

收束:记住该记的,忘记该忘的

本章从分类、存储、写入、检索、生命周期、隐私安全,一路走到最小可用实现与常见反模式,系统梳理了 Agent 长期记忆的设计要点。如果要把整章压成一句话,那就是:记忆系统的目标不是把一切都记下来,而是记住该记的、忘记该忘的、怀疑应该怀疑的

这三个动词恰好对应本章的三条主线。——§12.2 的四种 type、§12.4 的写入时机、§12.3 的上限控制;——§12.6 的过期清理、§12.4 的去重合并、§12.10 的反模式;——§12.6 的 freshness caveat 与那个 file:line 事故、§12.4 关于什么不该写的判断。三者平衡,记忆系统才是平衡的;偏向任何一端都会出问题:只记不忘会膨胀,只忘不疑会误导,只疑不记则等于没有记忆。

下一章(第 13 章)讨论多轮会话状态管理,它要回答的是这样一个问题:在短期记忆(第 11 章)和长期记忆(本章)之间,还夹着一类"任务级状态"——比如一个 draft PR、一个临时开的 issue、一批还没 commit 的变更——它们既不该随会话结束蒸发,也不值得写进长期记忆,那么该放在哪里。那一章会补完 Agent 记忆金字塔的最后一层。