文件级持久记忆
把跨会话的关键偏好和事实写进可检查的 Markdown + frontmatter 文件。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
文件级持久记忆
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 文件系统适合小规模、可审计的记忆。
- 压缩是有损的,持久层不参与压缩。
- manifest、正文和大小契约要一起维护。
顺着原文把边界看清
01导读:问题背景与本章目标第 8 章解决了上下文压缩的问题:会话快满时自动生成摘要,用摘要替换历史,继续跑。但有个硬伤一直没处理。用户说的"用 tab 不用空格",压缩之后最多变成"用户有代码风格偏好",关键细节没了。换一个会话,连这句话也不会再出现。⌄
第 8 章解决了上下文压缩的问题:会话快满时自动生成摘要,用摘要替换历史,继续跑。但有个硬伤一直没处理。用户说的"用 tab 不用空格",压缩之后最多变成"用户有代码风格偏好",关键细节没了。换一个会话,连这句话也不会再出现。
LLM 没有持久状态。所有信息都在上下文窗口里,窗口满了要压缩,压缩有损,新会话从零开始。这不是压缩算法的问题,是架构问题:需要一层不参与压缩、跨会话保留的存储。
02为什么选文件系统第一反应可能是向量数据库——把偏好和背景 embed 存起来,查询时做语义检索。但这条路有几个问题:部署依赖重,调试不直观,小规模场景完全是杀鸡用牛刀。⌄
第一反应可能是向量数据库——把偏好和背景 embed 存起来,查询时做语义检索。但这条路有几个问题:部署依赖重,调试不直观,小规模场景完全是杀鸡用牛刀。
另一个思路是结构化数据库,用 SQL 或 JSON 存记忆。问题是查询逻辑要自己写,而且 LLM 读起来不自然,需要额外的序列化层。
文件系统的好处是没有这些服务负担。每个记忆是一个 .md 文件,人可以直接打开检查;LLM 生成和读取都使用自然语言;不需要部署额外数据库;出了问题可以对照 manifest 和正文定位。手工修改仍要保持 frontmatter、大小和 manifest 契约,不能直接删除 manifest 正在引用的文件。
存储格式选 Markdown + YAML frontmatter:
---
name: user-preference-tabs
description: User prefers tabs for indentation
type: user
---
User prefers using tabs, not spaces, for indentation.
**Why:** Consistency with existing codebase conventions.
**How to apply:** Always use tabs when writing or editing files.frontmatter 存元数据(name、description、type),正文存完整细节。name 和 description 用于索引和检索,body 在真正需要时才读取。
四种记忆类型,各有分工:
| 类型 | 记什么 | 举例 |
|---|---|---|
| user | 用户是谁、有什么偏好 | "用 tab 不用空格" |
| feedback | 怎么做事、踩过什么坑 | "别 mock 数据库" |
| project | 当前在发生什么 | "auth 重写是合规驱动" |
| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" |
03一份集合,两份派生视图文件记忆不是“扫描目录里所有 .md 就算当前集合”。写入或整理可能在中途失败,目录里也可能留下尚未提交的文件。当前实现因此使用三个层次:⌄
文件记忆不是“扫描目录里所有 .md 就算当前集合”。写入或整理可能在中途失败,目录里也可能留下尚未提交的文件。当前实现因此使用三个层次:
| 文件 | 职责 |
|---|---|
.memory/manifest.json | 当前记忆集合的唯一权威指针 |
.memory/MEMORY.md | 由当前集合生成的有界目录,供选择器读取 |
.memory/<name>-<id>.md | 单条记忆的 YAML frontmatter 与正文 |
例如一次成功写入后的 manifest 是:
{"version":1,"files":["user-preference-tabs-8f21c0.md"]}文件名带唯一 ID,所以提交新集合时不覆盖旧文件。MEMORY.md 可以重建,不承担事务指针的职责;读取集合只认 manifest 中列出的文件。对应实现集中在 code/chapters/ch09/src/features/memory.ts 的 MemoryStore,没有第二套目录扫描逻辑。
写操作还会创建 .memory/.lock,它不是记忆集合的一部分,而是 proper-lockfile 用来做跨进程互斥的锁文件。MemoryStore 的每次读改写都先经过进程内 Promise 队列,再获取这个文件锁。这样,同一 Node 进程里并行跑多个回合,或者多个 Agent 进程同时操作同一个 workspace,都不会交错读写 manifest、索引和记录文件。
写入入口只接受校验后的值对象:
import { MemoryRecord, MemoryStore, MemoryType } from "./features/memory.js";
const store = new MemoryStore({ workspace: process.cwd() });
await store.add(
new MemoryRecord({
name: "user-prefers-tabs",
description: "User prefers tabs for indentation",
kind: MemoryType.USER,
body: "Use tabs in this repository.",
}),
);这里没有用 replace("/", "-") 把非法输入悄悄改成另一个名字。name 必须本来就是安全的小写 slug;description 必须是单行非空文本;完整序列化文件同时受 200 行和 4096 UTF-8 bytes 双重限制。.memory 解析后还必须留在 workspace 内,目录链接不能绕过边界。
读取也不是“manifest 列了哪些文件就照单全收”。MemoryStore 每次加载都会重新校验 manifest 的严格 JSON schema、文件名唯一性、文件名安全规则、realpath 是否仍在 .memory 内,并解析 YAML frontmatter 后用同一个 MemoryRecord 构造器校验记录。手工改坏的文件、放进目录但没有登记进 manifest 的文件,或者通过链接逃出 workspace 的文件,都不会被当作有效记忆。
04加载:先选名称,再读正文每轮开始时,MemorySession.beginTurn(query) 先读取当前集合,把查询和有界目录交给选择器。模型返回的是记忆 name 数组,不是易漂移的文件下标:⌄
每轮开始时,MemorySession.beginTurn(query) 先读取当前集合,把查询和有界目录交给选择器。模型返回的是记忆 name 数组,不是易漂移的文件下标:
["user-preference-tabs", "project-database-rule"]ModelMemoryQueries 通过同一个供应商无关 ModelClient 发起 side-query,但请求明确设置 tools: []。响应必须满足三个条件:没有工具调用、finishReason 精确等于 stop、正文是非空文本。随后使用 JSON.parse() 解析整个响应。它不会从代码围栏或解释文字中截取一段“看起来像 JSON”的子串。
selector、extractor、consolidator 的输入 JSON 都由 stableJson() 按键排序生成,避免同一对象在不同运行中呈现不同字段顺序,离线测试也因此可以稳定断言。
选择最多保留 5 条。如果选择请求失败、输出不是 JSON 字符串数组、名称重复或引用未知记忆,MemorySession 会记录降级状态,再按 name + description 做确定性关键词匹配。英文按长度至少为 3 的 token,中文按 bigram 匹配;得分相同时按记忆名稳定排序。
选中的正文包在 <relevant_memories> 中。第 9 章由生命周期在模型请求前增加一条 system context 消息;第 10 章会改由动态 Prompt 的 memory section 注入,并关闭前一种注入,确保同一份记忆只出现一次。目录本身只服务于选择,不会把所有记忆正文塞给主模型。
beforeModel() 返回的 <relevant_memories> 只拼进本次模型请求,不会追加到 canonical history。因此记忆注入不会污染会话证据,提取阶段仍然只依据完整原始会话。
05写入:从 canonical history 提取用户通常不会特意说“请写入记忆”。因此 Agent 给出本轮最终回答后,MemorySession.complete() 调用 extractor,从完整的 canonical history 中提取跨会话仍有价值的信息。⌄
用户通常不会特意说“请写入记忆”。因此 Agent 给出本轮最终回答后,MemorySession.complete() 调用 extractor,从完整的 canonical history 中提取跨会话仍有价值的信息。
这里必须区分两份历史。第 8 章的 snip、micro 和摘要只生成本次模型请求使用的 request history;AgentRunner 持有的 canonical history 不会被这些请求级压缩改写。记忆提取读取后者,因此用户消息和普通 canonical 内容仍保持完整。大 ToolResult 是另一个边界:它在进入 canonical history 前就被替换成 artifact 路径与首尾预览。如果关键事实只在原文中段,必须先读取 artifact,不能假设 extractor 会自动看到完整文件。输入先复制为不可变消息快照并验证工具调用配对,extractor 也拿不到调用者的消息对象引用。
提取器只能返回完整 JSON 数组,每项必须且只能包含四个字段:
[
{
"name": "windows-examples",
"type": "project",
"description": "Project examples target Windows",
"body": "Use PowerShell for user-facing commands."
}
]任意一项 schema、类型、slug 或大小非法,整批都不提交。提取 side-query 失败也不会让主任务失败,但 lastError 会明确记录 Memory extraction failed,旧集合保持不变。
06整理:先声明替换来源,再一次提交记忆达到默认阈值 10 条时,整理器收到“当前集合 + 本轮合法提取结果”。它不能只返回一个没有来源信息的新数组,而要明确指出替换哪些旧记录:⌄
记忆达到默认阈值 10 条时,整理器收到“当前集合 + 本轮合法提取结果”。它不能只返回一个没有来源信息的新数组,而要明确指出替换哪些旧记录:
{
"source_names": ["tabs-old", "tabs-new"],
"records": [
{
"name": "tabs",
"type": "user",
"description": "Indentation preference",
"body": "Use tabs in this repository."
}
]
}source_names 必须非空、唯一且都属于整理候选;records 必须是非空合法集合。未列入 source 的记忆必须保留,不能因为一次局部整理被顺手删除。
提交顺序也不能是“先删旧文件,再逐个写新文件”。MemoryStore.applyConsolidation() 在文件锁内重新读取 manifest,并执行以下步骤:
验证参与整理的基础记录仍未变化
-> 合并未参与记录、并发新增记录、本轮新增和替换记录
-> 生成并 fsync 新文件
-> 生成有界派生索引并原子写入
-> 最后原子替换 manifest
-> 提交成功后才清理被替换的旧文件模型等待期间另一个会话新增的、未参与本次整理的记录,会在锁内重读并保留。写文件、索引或 manifest 任一步失败时,旧 manifest 仍指向完整旧集合,本轮未提交文件会清理;不会出现“整理失败后记忆全空”的状态。
这里说的原子替换由 atomicReplace() 实现:先把内容写到 .memory 下带随机名的临时文件并 sync(),再 rename 覆盖目标,所以读取方不会看到半写文件。MEMORY.md 和 manifest.json 都走同一条路径。
这版只使用“候选数达到阈值”作为整理触发条件,没有实现 24 小时间隔、会话计数或锁文件 mtime 过期等额外门控,因此文章也不把它们描述成现有能力。
07Memory 和 Session Memory 的分工第 8 章的压缩机制仍然存在,它与长期记忆解决的是不同问题。⌄
第 8 章的压缩机制仍然存在,它与长期记忆解决的是不同问题。
| Memory | Session Memory | |
|---|---|---|
| 持久范围 | 跨 Agent 实例、跨会话 | 当前会话 |
| 存储位置 | .memory/*.md + manifest.json | request history 摘要与 transcript |
| 注入位置 | 第 9 章 context 消息;第 10 章 Prompt section | 替换本次请求看到的早期历史 |
| 解决什么 | 长期偏好、约束和项目知识 | 压缩后继续当前任务 |
同一轮的实际时序是:
beginTurn(query)
-> 从 manifest 当前集合选择相关记忆
-> 组装 context 与请求级压缩历史
-> 模型和工具循环,canonical history 持续完整追加
-> 最终回答
-> complete(canonicalHistory)
-> 提取;达到阈值时与整理结果一次提交Memory 不保存“曾经发生过的一切”,只保存下个会话仍会用到的内容。适合保存用户偏好、反复出现的约束、项目架构决策、关键依赖和排查入口;临时任务状态、完整对话和只在当前步骤有意义的中间信息留给 canonical history、压缩摘要和 transcript。
08与 Claude Code 的差异Claude Code 的记忆是双轨制:CLAUDE.md 提供人工编写、跨会话加载的持久指令,auto memory 让 Claude 自己记录构建命令、调试经验和偏好;auto memory 默认开启,/memory 命令可查看与编辑(官方文档见 How Claude remembers your project)。本教学版只实现了 auto memory 风格的单轨 MemoryStore,但它比参考实现更强调可审计的集合事务。⌄
Claude Code 的记忆是双轨制:CLAUDE.md 提供人工编写、跨会话加载的持久指令,auto memory 让 Claude 自己记录构建命令、调试经验和偏好;auto memory 默认开启,/memory 命令可查看与编辑(官方文档见 How Claude remembers your project)。本教学版只实现了 auto memory 风格的单轨 MemoryStore,但它比参考实现更强调可审计的集合事务。
存储与索引。 Claude Code 的 auto memory 存储在 ~/.claude/projects/<sanitized-git-root>/memory/,按 git 仓库隔离并跨 worktree 共享;每个项目目录内以 MEMORY.md 作为入口索引,Claude 用标准文件工具按需读写 topic 文件。P09 把记忆放在 workspace 本地 .memory/,并通过 manifest.json 作为唯一权威指针、MEMORY.md 作为可重建派生视图,因此不会把目录中未登记或未提交完成的 .md 误当成有效记忆。参考实现直接扫描目录,教学版用 manifest 换取更明确的提交边界。
预算与选择。 Claude Code 每次会话加载 MEMORY.md 的前 200 行或 25KB(frontmatter 和块级 HTML 注释不计入),topic 文件按需读取;选择器最多选 5 条,单会话记忆内容预算约 60KB。P09 的派生目录默认同样按 200 行约束,但字节预算默认只有 4096,且记录文件也同时受 200 行/4096 字节限制。选择器同样最多选 5 条,并在 side-query 失败时降级为确定性关键词匹配。
提取与整理。 Claude Code 用受限子代理在 stop hook 中异步提取,整理(Dream)有四层门控:距上次合并至少 24 小时、扫描节流、自上次合并以来至少 5 个会话 transcript 有修改、并持有合并锁。P09 的提取是同一模型的无工具 side-query,失败只记录 lastError 不中断主任务;整理只由候选数达到阈值(默认 10)触发,没有时间、会话数或锁 mtime 门控,这是教学上的刻意简化。
并发与失败边界。 Claude Code 的记忆目录由 Agent 自己通过文件工具写入,MEMORY.md 超限时返回“memory index is over its read limit”错误,整理阶段有 .consolidate-lock 和过期恢复。P09 把并发问题下沉到 MemoryStore:同进程 Promise 尾链 + 跨进程 proper-lockfile,每个读改写都先加锁(stale 30 秒 / update 10 秒),再以 fsync + 原子替换提交 manifest;整理时还会在锁内重读基础记录,发现并发修改立即失败。对教程读者而言,P09 的事务模型比 CC 更保守,适合作为自己实现长期记忆时的参考。
09从 ai-agent-book 学到什么:先立评估,再谈存储本地仓库的 ai-agent-book/book/chapter3.md(配套实验在 ai-agent-book/chapter3/)是本章最接近的对照:它也把“跨会话记忆”拆成轨迹和长期记忆两层,并继续覆盖记忆表示、检索、整理和评估。P09 是教学上的最小文件实现,ai-agent-book 则展示完整生产路径。对照后能更清楚地区分“哪些设计 P09 已经做到”“哪些是后续扩展才需要补的能力”。⌄
本地仓库的 ai-agent-book/book/chapter3.md(配套实验在 ai-agent-book/chapter3/)是本章最接近的对照:它也把“跨会话记忆”拆成轨迹和长期记忆两层,并继续覆盖记忆表示、检索、整理和评估。P09 是教学上的最小文件实现,ai-agent-book 则展示完整生产路径。对照后能更清楚地区分“哪些设计 P09 已经做到”“哪些是后续扩展才需要补的能力”。
| ai-agent-book 的关键设计 | P09 对应 | 差异与边界 |
|---|---|---|
| 轨迹是当前会话只增不改的原始事件记录;长期记忆是跨会话提炼、可改写的知识 | canonical history 近似轨迹,MemoryStore 近似长期记忆 | P09 的 canonical history 只在运行期完整保留,跨运行 transcript 由第 8 章负责;记忆记录不保存证据 ID |
| 工作记忆是从长期记忆按相关性激活的动态子集 | request history + <relevant_memories> 只在模型请求中注入 | 记忆选择在 beginTurn 后只服务本次请求,不追加到 canonical history |
| 四种存储格式:Simple Notes、Enhanced Notes、JSON Cards、Advanced JSON Cards | .md frontmatter + 正文接近 Enhanced Notes | 没有嵌套槽位、backstory、person/relationship 或版本化冲突字段 |
| 记忆压缩:重要性评分、聚类、抽象、版本化冲突检测 | 候选数达到阈值后由 consolidator 返回 source_names 和新集合 | P09 不做评分、聚类、时间衰减,也不保留历史版本 |
| 文件系统范式 OpenViking:L0 摘要、L1 概览、L2 全文按需加载 | MEMORY.md 是有界目录,<name>-<id>.md 是按需正文 | P09 用 manifest 作为权威提交边界,比目录扫描严格,但没有文件间链接导航 |
| 增量更新 + 周期全量整理,Proposer/Reviewer 审核 PR | 每轮 extractor 增量提取,达到阈值后一次性整理 | P09 是单模型 side-query,没有独立 Reviewer、证据引用或 PR 审核 |
| 双层记忆:Advanced JSON Cards 常驻概览 + 上下文感知 RAG 取细节 | 目录常驻选择、正文按需注入 | P09 没有向量检索、语义召回、冲突消解或主动服务 |
| 三层次评估:基础回忆、多会话检索、主动服务 | 离线测试覆盖机制正确性 | P09 没有三层记忆评测,也未用 LLM-as-judge 验证记忆质量 |
ai-agent-book 最值得借鉴的一点,是先把“什么样的记忆系统算好”定义成三层次评估框架,再设计存储与检索。实验 3-1 为每层构造 20 个用例,共 60 条;实验 3-2 又在统一接口下切换四种存储格式,让同一组会话和查询对比 Simple Notes、Enhanced Notes、JSON Cards、Advanced JSON Cards 的提取形态与最终得分。这种“先立标尺、再做实验”的顺序,比只测“文件是否写入成功”更能暴露记忆质量问题。
另一个值得对照的是知识更新。ai-agent-book 主张把增量更新和周期整理都当成代码库 PR:Proposer Agent 依据原始证据提出 diff,异源 Reviewer Agent 独立核对证据与冲突,通过后再重建派生索引。P09 已经在存储层实现了“原始 canonical history、整理后的记录、可重建的 MEMORY.md”三者分离,并且用 manifest 原子提交避免半写状态;但提取、整理仍是同一次固定 side-query,没有审核环节,也没有在记录里保存“从哪段证据来、什么时候批准”。
因此,P09 的强项不在“记忆质量更高”,而在“边界更可审计”:manifest 唯一权威、跨进程锁、fsync 后原子替换、选择失败时确定性降级,以及 beginTurn/beforeModel/complete 三个明确生命周期点。ai-agent-book 的贡献则是把质量、检索、冲突和审核作为独立维度补上。若要在 P09 上继续演进,应优先加入记忆评测集、证据引用与 Reviewer 审核,而不是先堆向量数据库。
10配套代码索引第 9 章实现集中在 code/chapters/ch09/src/,章节入口固定选择 P09 Profile。上一章已有的 features/compaction.ts 继续负责请求级压缩和 artifact 落盘;本章新增 features/memory.ts,并通过 core/loop.ts 的 TurnLifecycle 接入 AgentRunner。⌄
第 9 章实现集中在 code/chapters/ch09/src/,章节入口固定选择 P09 Profile。上一章已有的 features/compaction.ts 继续负责请求级压缩和 artifact 落盘;本章新增 features/memory.ts,并通过 core/loop.ts 的 TurnLifecycle 接入 AgentRunner。
- 功能实现:
code/chapters/ch09/src/features/memory.ts - 组合根:
code/chapters/ch09/src/bootstrap.ts - 生命周期:
code/chapters/ch09/src/core/loop.ts - 能力与 profile:
code/chapters/ch09/src/core/profiles.ts - 章节入口:
code/chapters/ch09/src/chapters/ch09.ts - 压缩边界:
code/chapters/ch09/src/features/compaction.ts - 直接行为测试:
code/chapters/ch09/tests/memory.test.ts - 接线测试:
code/chapters/ch09/tests/ch09-memory.test.ts - Loop 边界测试:
code/chapters/ch09/tests/loop-processors.test.ts
| 实现 | 职责与边界 |
|---|---|
MemoryRecord | 不可变记忆值对象;校验安全 slug、单行 description、MemoryType、正文预算,序列化后再次执行 200 行/4096 bytes 限制 |
MemoryStore.records() / renderCatalog() | 只认 manifest 中的集合;读取前校验 workspace、.memory realpath、manifest schema 与每条记录,并加锁重读 |
MemoryStore.add() / extend() | 只追加当前集合中不存在的新记忆;生成唯一文件名、独占创建记录文件、fsync 后回读验证,再原子提交索引与 manifest |
MemoryStore.applyConsolidation() | 锁内重读 manifest,验证 baseRecords 未变化,保留未参与记录和并发新增,按 source_names 替换后一次提交 |
ModelMemoryQueries | selector/extractor/consolidator 共用无工具模型边界;要求 finishReason === "stop" 和非空全文 JSON,不从围栏或解释文字截取子串 |
MemorySession | 回合前选择记忆、请求前注入 <relevant_memories>、回合后用完整 canonical history 提取并整理;失败只记录 lastError,不中断主任务 |
keywordSelect | side-query 失败后的确定性回退;英文按至少 3 字符 token,中文按 bigram,得分相同按名称稳定排序 |
atomicReplace() / 进程互斥 / proper-lockfile | 同目录临时文件 + fsync + rename,保证读方看不到半写文件;同进程 Promise 尾链和跨进程文件锁共同串行文件事务 |
TurnLifecycle | beginTurn 在首轮模型请求前执行,beforeModel 只影响下一次模型请求,complete 使用完整 canonical history 收尾 |
bootstrap.createMemorySession() | 把 MemoryStore 和 ModelMemoryQueries 组装为 MemorySession,并按 profile 能力位决定是否注入 |
P09 | 在 P08 能力上追加 memory,使同一 AgentRunner 同时拥有请求级压缩和跨会话文件记忆 |
ch09.ts | 固定 P09 的章节入口,防止该入口意外启用其他能力 |
CompactionManager | 第 8 章继承能力;压缩只改写 request history,canonical history 保持完整,因此 memory extractor 仍能读取原始会话 |
11运行与验证Set-Location 'F:\笔记\Agent实操\code'⌄
Set-Location 'F:\笔记\Agent实操\code'
npm run ch09 -- --prompt "记住:本项目的示例只使用 PowerShell"
npm run agent-tutorial -- run --chapter 9 --prompt "这个项目的命令示例有什么约束?"离线行为验证覆盖跨实例选择、严格 side-query、路径边界、写入故障、局部整理和整理期间并发新增:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch09下一章处理 system prompt 本身:工具、workspace、Skill 和已选记忆都从运行态组装,不再维护一锅硬编码字符串。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始