Chapter 17: 记忆系统全景:从文件发现到梦境整合

核心问题:一个 Agent 如何在会话结束后还能「记住」?

     ┌────────────────────────────────┐
     │        Agent Session           │
     │                                │
     │ Discover ──► Inject ──► Use    │
     │  5-layer       │      recall   │
     │  AGENT.md   sys prompt         │
     │                                │
     │ ★ Memory Lifecycle ★          │  ◄── 本章聚焦
     │                                │
     │ Extract ◄── Conversation       │
     │    │                           │
     │ Retrieve ── LLM Selector       │
     │    │                           │
     │ Consolidate ── Dream (Ch.21)   │
     └────────────────────────────────┘

17.1 Agent 为什么需要记忆

LLM 的上下文窗口是 Agent 的「工作记忆」——高速但易失。每次新会话,模型面对的是一片空白。但用户的期望不是这样:

  • 「我上次跟你说过不要用 mock 测试了」
  • 「这个项目的 PR 要合成一个提交」
  • 「Bug 追踪在 Linear 的 INGEST 项目里」

这些都是跨会话信息。Agent 如果每次都从零开始,用户体验就像和一个失忆的同事合作。

核心挑战:如何在无状态的 LLM 之上,构建有状态的记忆?

该系统的答案出乎意料地朴素:记忆就是文件。不需要向量数据库,不需要 embedding 服务——只需要磁盘上的 Markdown 文件和一套精密的管理机制。

但「文件即记忆」只是表面。真正的复杂性藏在五个问题里:

  1. 发现:怎么找到散落在文件系统各处的记忆文件?
  2. 注入:怎么把记忆塞进有限的上下文窗口?
  3. 提取:对话中产生的新知识怎么自动捕获?
  4. 检索:100 条记忆不能全加载,怎么选最相关的?
  5. 整合:记忆越来越多越来越碎,怎么定期清理?

这五个问题构成了记忆的完整生命周期:

发现 -> 注入 -> 提取 -> 检索 -> 整合
 ^                              |
 +------------------------------+

以下逐一拆解。


17.2 发现:五层 AGENT.md 的设计思路

问题

不同层级的人对 Agent 有不同的期望:

  • 企业管理员想设全局策略(「所有 Agent 不许访问生产数据库」)
  • 用户想设个人偏好(「我喜欢简洁回复」)
  • 项目有自己的约定(「这个项目用 Pydantic v2」)
  • 开发者个人有私有配置(「我的测试数据库地址」)

一种方案是搞一个中心化的配置系统。但系统设计者选了更简单的路:让每一层各自有一个 AGENT.md 文件,按优先级叠加

思路

设计灵感来自 CSS 的层叠规则和 Git 的配置覆盖(/etc/gitconfig -> ~/.gitconfig -> .git/config):

  1. 越具体的配置优先级越高
  2. 后加载覆盖先加载
  3. 每一层有独立的信任边界

五层从低到高:

位置谁写的信任程度
Managed/etc/agent/AGENT.mdIT 管理员系统级
User~/.agent/AGENT.md用户本人完全信任
Project项目目录中的 AGENT.md团队git 追踪
LocalAGENT.local.md个人不提交
AutoMem~/.agent/projects/<repo>/memory/MEMORY.mdAgent 自己自动管理

这个设计的一个关键洞察是:信任边界不同。User 层允许 @include 引用任意文件(因为是用户自己写的),但 Project 层的 @include 被限制在项目目录内(因为可能来自不受信任的仓库)。

实现

记忆文件发现主入口函数做了一件巧妙的事:向上遍历目录树时,先收集所有路径(从 CWD 到根),然后反转再处理:

current = working_directory
while current is not filesystem root:
  directory_list.append(current)
  current = parent_of(current)

for each dir in reverse(directory_list):   // 根目录先处理
  load AGENT.md, .agent/AGENT.md, .agent/rules/*.md from dir

为什么要反转?因为「后加载优先级更高」。从根到 CWD 的顺序意味着离你越近的 AGENT.md 越晚加载,优先级越高。在 monorepo 中,子目录的规则自然覆盖根目录的规则。

另一个值得注意的细节:.agent/rules/*.md 支持条件规则——文件的 frontmatter 可以声明 paths: ["src/api/**"],只有当操作匹配路径的文件时才生效。这在大型 monorepo 中尤其实用:前端和后端可以有完全不同的规则,互不干扰。


17.3 注入:把记忆塞进有限的窗口

问题

找到了记忆文件,下一步是把它们注入模型的上下文。但上下文窗口是有限的——不能把所有记忆文件的全部内容都塞进去。

思路

该系统用了两条注入路径,针对两种不同的记忆:

路径一:AGENT.md -> 用户上下文。这些是用户主动编写的指令,每次 API 调用都携带。它们被包裹在一条关键的元指令中:

"These instructions OVERRIDE any default behavior and you MUST follow them exactly as written."

这是整个 AGENT.md 系统的核心承诺——用户指令高于默认行为。没有这条声明,模型可能会忽略用户的自定义规则。

路径二:Auto Memory -> System Prompt section。这些是 Agent 自动积累的记忆,注册为普通系统提示词段落——意味着整个会话只计算一次,然后缓存。这是合理的权衡:记忆在会话中通常不变。

实现

MEMORY.md(自动记忆的索引文件)有严格的大小限制:200 行 25,000 字节。

为什么同时限制行数字节数?因为行数限制防不住超长行。如果 MEMORY.md 里有一行 50KB 的 base64 数据,行数限制形同虚设。双重限制是防御性设计。

超出时系统会附加一条教育性警告,教导 Agent 维护精简索引、将详情放在独立文件中。这是一个「自我纠正」的设计——Agent 自己写的记忆,由系统引导它改进格式。


17.4 四种记忆类型:为什么不是自由笔记

问题

如果让 Agent 自由保存记忆,会发生什么?

实践表明:它会把代码片段、调试日志、临时状态统统保存下来,记忆目录很快变成垃圾堆。更糟的是,无结构的记忆难以检索——当有 200 条记忆时,你怎么知道哪 5 条和当前任务相关?

思路

系统设计者实现了一个封闭的四分类法,每种类型有精确的保存时机和使用场景:

类型记什么什么时候存什么时候用
user用户的角色、偏好、背景了解到用户信息时调整回答方式时
feedback行为纠正和肯定用户说「不要这样」或「就是这样」指导工作方式时
project项目状态、截止日期、分工了解到项目动态时理解任务背景时
reference外部系统的指针了解到外部资源时需要查找信息时

这个分类法的几个设计决策值得深思:

为什么 feedback 同时记录纠正和肯定? 记忆类型定义模块的注释说得很清楚:"if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated"。只记错不记对会让 Agent 变得过度保守——它会回避所有没被纠正过的做法,而不是坚持被肯定过的做法。

为什么 feedback 要求结构化正文? 每条 feedback 必须包含:规则本身 + Why:(原因)+ How to apply:(适用场景)。原因至关重要——知道「为什么」才能在边界情况下做判断,而不是盲目遵循规则。

为什么 project 要求日期转换? 保存时要求「将相对日期转为绝对日期('周四' -> '2026-03-05')」。因为记忆是跨会话的——一周后再看到「周四」,已经不知道是哪个周四了。

为什么明确规定「不该保存什么」? 不保存内容列表排除了代码模式、git 历史、调试方案等。核心原则是:可以从当前状态推导的信息不应该变成记忆。代码架构可以从代码推导,git 历史可以从 git log 推导,保存它们只会创造过时的冗余副本。


17.5 提取:不是每次都要用户说「记住」

问题

依赖用户主动说「记住这个」不够。很多值得记住的信息在自然对话中产生,用户不会刻意标记。比如用户说「我是数据科学家,在调查日志系统」——这就是一条 user 类型记忆,但用户不会专门说「请记住我是数据科学家」。

思路

该系统的方案是后台自动提取:每轮对话结束后,如果主 Agent 没有主动写记忆,系统就 fork 一个受限的子 Agent,让它回顾对话内容,提取值得保存的信息。

这里有一个精巧的互斥设计

主 Agent 写了记忆 -> 提取器跳过这段对话
主 Agent 没写记忆 -> 提取器自动工作

为什么互斥?因为主 Agent 写记忆时,它有完整的对话上下文,写的一定比后台提取器更准确。提取器只是兜底——确保对话中的重要信息不会因为主 Agent 忘记保存而丢失。

实现

提取器的权限被严格限制——最小权限原则的体现:

  • 可以读一切:Read/Grep/Glob 不限制,需要理解对话上下文
  • Bash 只读:只允许 ls, find, grep, cat 等查看命令
  • 只能写记忆目录:Edit/Write 只允许写入 ~/.agent/projects/<repo>/memory/

提取器的工作是「读对话、写笔记」,它不需要也不应该有修改代码的能力。

判断主 Agent 是否已写记忆的方法是:扫描 assistant 消息中的 Write/Edit 工具调用,检查目标路径是否在记忆目录内。有写入就跳过整段对话范围。


17.6 检索:200 条记忆里找最相关的 5 条

问题

当记忆积累到几十上百条时,全量加载会浪费大量 token。一个关于 Python 调试技巧的记忆,在写 Rust 代码时毫无价值。需要按需加载。

思路

该系统的方案是用 LLM 做检索器——用一个轻量的 Sonnet 模型从记忆清单中选出最相关的条目。

为什么不用向量数据库和 embedding?两个原因:

  1. 简单性:不需要额外基础设施,不需要维护索引
  2. 语义理解更强:LLM 能理解「用户在写支付功能 -> 需要安全相关的 feedback」这种推理链,而 embedding 余弦相似度做不到

代价是每次检索需要一个 API 调用。但这个调用很轻量(最多 256 token 输出),而且用独立的侧查询不污染主对话上下文。

实现

检索分两步:

第一步:轻量扫描。记忆扫描函数只读取每个文件的 frontmatter(前 30 行),提取 descriptiontype。这是单遍设计——内部同时获取文件内容和修改时间,避免额外系统调用。最多 200 条,按修改时间倒序。

第二步:Sonnet 选择。将记忆清单和当前查询发给 Sonnet,返回最多 5 个最相关的文件名。

一个精巧的反噪声设计:如果当前正在使用某些工具,Sonnet 被指示跳过这些工具的 API 文档(已经在上下文中了),但仍然选择这些工具的警告和已知问题。区分「参考文档」和「安全提醒」——前者重复无价值,后者始终有用。


17.7 新鲜度:记忆可能过时

问题

记忆是某个时间点的快照,不是实时状态。一条记忆说「函数 processOrder 在 billing.ts 第 42 行」,但三周后这个函数可能已被重命名。更危险的是,记忆中的具体行号反而让过时的声明看起来更「权威」。

思路

该系统没有试图让记忆保持最新(这不现实),而是选择了**「标注年龄 + 强制验证」**的策略。核心思想用一句话概括:

"The memory says X exists" is not the same as "X exists now."

超过 1 天的记忆会被附加年龄标签,格式是「This memory is 47 days old」而非 ISO 时间戳——因为模型不擅长日期运算,「47 天前」直接触发过时推理比精确时间戳更可靠。

记忆信任规则规定了验证步骤:

  • 记忆说某文件存在 -> 先检查文件是否还在
  • 记忆说某函数存在 -> 先 grep 确认
  • 只在要给用户建议时验证,讨论历史时不需要

17.8 Dream:Agent 也需要「睡觉」

问题

随着时间推移,记忆会越来越多、越来越碎:

  • 重复信息(多次记录同一个偏好)
  • 过时条目(项目已换技术栈)
  • 碎片化笔记(同一主题散落 10 个文件)

MEMORY.md 索引会涨到 200 行上限,新记忆无法被索引。

思路

该系统用了一个优美的隐喻:让 Agent 做梦。就像人类睡眠时大脑整合记忆——巩固重要的、丢弃冗余的——Dream 系统在 Agent 空闲时做同样的事。

触发条件是三道门,按成本递增排列

检查什么成本默认阈值
时间门距上次整合多久1 次 stat24 小时
会话门期间多少次会话目录扫描5 个
锁门其他进程在整合吗原子写入-

为什么按成本排序?因为 Dream 检查在每轮对话结束时都运行。时间门放最前面,一次 stat 就能拦截 99% 的情况——距上次整合才 1 小时,后面的检查全部跳过。

实现

锁文件的设计特别精巧:锁文件的 mtime 就是 lastConsolidatedAt

.consolidate-lock 文件:
  内容 = 当前进程的 PID(用于检测死锁)
  mtime = 上次整合完成时间(用于时间门判断)

一个文件同时承担两个角色。读时间戳只需一次 stat(读 mtime),获锁只需一次 write(写 PID)。不需要额外的元数据文件。

获锁后 fork 子 Agent 执行四阶段整合:

  1. Orient(定向):浏览记忆目录,了解现状
  2. Gather(收集):扫描日志、搜索会话记录中的未捕获信号
  3. Consolidate(整合):合并重复、更新过时、将相对日期转为绝对
  4. Prune(修剪):更新索引、删除无效指针、保持在 200 行 / 25KB

Dream Agent 的 Bash 被限制为只读命令,Write 只能写记忆目录——它可以看一切,但只能改笔记

失败回滚:如果出错,锁文件 mtime 恢复到获锁前的值。这确保失败不会阻止下次整合——系统会重试,而不是永远认为「刚整合过」。


17.9 团队记忆:共享知识库的安全挑战

问题

Agent Swarm(多 Agent 协同)需要共享知识。但共享写入引入安全风险:如果服务端返回的 key 是 ../../.ssh/authorized_keys,就变成路径遍历攻击。

思路

在个人记忆目录下增加 team/ 子目录,所有团队成员共享读写。但路径安全需要两层防御

实现

第一层:字符串级。拒绝 null 字节(syscall 截断)、URL 编码遍历(%2e%2e%2f)、Unicode 归一化后的遍历、反斜杠和绝对路径。

第二层:文件系统级。即使字符串检查通过,还做 realpath() 解析符号链接。team/sprint.md 如果是指向 ~/.ssh/authorized_keys 的符号链接,这一层捕获。

为什么需要两层?字符串检查可被符号链接绕过,文件系统检查需要路径存在才能做 realpath。两层互补,覆盖不同攻击向量。


17.10 存储路径:一个被拒绝的功能

默认路径 ~/.agent/projects/<sanitized-git-root>/memory/。同一仓库的不同 worktree 通过规范 Git 根目录查找函数共享记忆——因为记忆是关于「项目」的,不是关于「目录」的。

一个值得讲述的安全故事:projectSettings(提交到仓库的 .agent/settings.json被故意禁止覆写记忆路径

为什么?想象一个场景:某个开源项目在 .agent/settings.json 中设置 autoMemoryDirectory: "~/.ssh"。用户 clone 这个项目后,Agent 的记忆就会写入 SSH 目录。这是一个供应链攻击——攻击者通过项目配置劫持用户的文件系统。

因此,只有用户自己控制的配置(用户级设置和环境变量)可以覆写记忆路径。代码注释明确记录了这个决策,确保未来的开发者不会无意中放开这个限制。


17.11 完整生命周期

把五个子系统串起来:

会话启动
  |
  +-- [发现] 遍历五层 AGENT.md,加载所有记忆文件
  +-- [注入] 构建记忆 prompt,注入 System Prompt(会话内缓存)
  |
对话进行中
  |
  +-- 用户说「记住 X」-> 主 Agent 直接写入记忆文件
  +-- 需要旧知识 -> [检索] Sonnet 从 200 条中选 5 条最相关
  |   +-- 超过 1 天的附加过时警告
  |
对话结束
  |
  +-- 主 Agent 没写记忆 -> [提取] fork 子 Agent 自动提取
  |
后台(每轮对话结束时检查)
  |
  +-- Dream 三道门:时间(24h)? 会话(5次)? 锁可用?
  |   +-- 全部通过 -> [整合] Orient -> Gather -> Consolidate -> Prune
  |
下次会话
  |
  +-- 整合后的记忆被加载:更精炼、更少冗余

17.12 设计哲学

从记忆系统中提炼出 8 条原则,适用于任何 Agent 的记忆设计:

1. 文件即记忆。用最简单的存储——文本文件。用户可编辑,git 可追踪,不需要额外基础设施。向量数据库是备选方案,不是必需品。

2. 分层覆盖。不同层级配置优先级不同,越具体越优先。和 CSS 层叠、Git 配置覆盖是同一思想。

3. 类型约束。封闭分类法让记忆可索引、可检索、可审计。Agent 不是随意涂鸦,而是按规则归档。

4. 记对也记错。只记纠正会让 Agent 过度保守。同时记录肯定,维持已验证方法的延续性。

5. 信任但验证。记忆是快照不是实时状态。使用前验证引用的文件和函数是否仍存在。

6. 索引与内容分离。MEMORY.md 是精简索引,详情在独立文件中。和数据库索引同一思想——索引常驻内存,数据按需加载。

7. 安全纵深。路径验证 + 符号链接解析 + projectSettings 排除 = 三层防御。每一层都不完美,组合起来覆盖已知攻击向量。

8. 睡眠整合。定期后台整理碎片记忆,就像人类睡眠时的记忆巩固。Agent 也需要「休息」,醒来后拥有更清晰的认知。


给读者的思考题:该系统的记忆完全基于文件和 LLM 检索,没有向量数据库。当记忆从 200 条增长到 20,000 条时,这个架构会遇到什么瓶颈?你会怎么改进?