第 九 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

从上下文压缩到文件级持久化:彻底解决 AI Agent 的健忘症

💡 跨会话文件记忆:manifest 唯一权威 + 原子提交 + 锁。
压缩有损且会话结束即失。本章加一层不参与压缩、跨会话保留的存储:Markdown + YAML frontmatter。memory 集合用 manifest 作为唯一权威指针,写入先加锁再 fsync + 原子替换;提取只读 canonical history,开启后一回合最多多 2 次 side-query(整理再 +1)。
本章进度
0%
1 本章要掌握的目标
  • manifest 是当前记忆集合的唯一权威指针,MEMORY.md 是可重建派生视图;未登记的 .md 一律忽略。
  • 每条记忆 .md 受 200 行 / 4096 UTF-8 bytes 限制,且都算整个文件——正文实际上限 194 行。
  • side-query 三条硬要求:无工具调用、finishReason===stop、非空正文;length 时的 "[]" 是截断不是空结果。
  • 提取只读 canonical history(读 request history 会漏掉被 snip 裁掉的用户消息);大 ToolResult 中段事实看不到。
  • 整理只由阈值(10 条)触发,锁内重读、声明 source_names、一次提交、失败不丢数据。
2 核心知识点
为什么选文件系统

没有服务负担,每条记忆是 .md 可人读;LLM 生成读取自然;内容可对照 manifest 定位。代价:不支持语义检索,本章用名称匹配。手工修改是受支持的操作,但要守契约(frontmatter 格式、预算、不删 manifest 引用的文件)。

---
name: user-preference-tabs
description: User prefers tabs for indentation
type: user
---
User prefers using tabs, not spaces……
一份集合,两份派生视图

.memory/manifest.json 唯一权威;MEMORY.md 由当前集合生成的有界目录;<name>-<id>.md 是单条记忆。读取端不信任任何东西:manifest 必须精确含 version+files(version:true 也拒)、文件名唯一且安全、realpath 不得逃出 workspace、每条记录重新构造校验。目录里未登记的 .md 一律忽略。

.memory/manifest.json        权威指针
.memory/MEMORY.md           可重建有界目录
.memory/<name>-<id>.md      单条记忆 (frontmatter + body)
加载:先选名称,再读正文

MemorySession.beginTurn(query) 把查询和有界目录交给选择器,模型返回 name 数组(最多 5 条)。side-query 三条硬要求:没有工具调用、finishReason 精确等于 stop、正文非空——length 时 "[]" 看着合法其实是截断,不检查就静默丢信息。集合为空时短路,不叫 selector。选择失败降级为确定性关键词匹配(英文 3 字符 token,中文 bigram),只扫 name+description。

[["user-preference-tabs", "project-database-rule"]]
→ 选中正文包在 <relevant_memories> 中
→ 只进入本次请求,不污染 canonical history
写入:从 canonical history 提取

完整回答后 MemorySession.complete() 调用 extractor,必须读完整 canonical history——读 request history 会漏掉被 snip 裁掉的用户消息。例外:大 ToolResult 落盘发生在进 canonical 之前,中段事实 extractor 看不到,要先读回 artifact。任意一项非法,整批不提交(合法项也不写);提取失败只记 lastError,主任务照常。

[
  {
    "name": "windows-examples",
    "type": "project",
    "description": "Project examples target Windows",
    "body": "Use PowerShell for commands."
  }
]
整理:先声明替换来源,再一次提交

达到阈值(默认 10 条)时,consolidator 返回 source_names + records。未列入 source 的记忆必须保留;整理期间并发新增保留,基础记录被改则直接失败。触发条件只有阈值一个:达到后每回合结束都会调 consolidator(教学上的刻意简化,真实项目建议加时间/会话门控)。提取失败后不整理;整理失败时本轮提取结果也不写。

验证基础记录未变
→ 合并未参与/并发新增/本轮记录
→ 生成并 fsync 新文件
→ 原子写入索引
→ 原子替换 manifest   ← 提交点
→ 提交成功后才清理被替换旧文件
两个预算的精确边界

200 行 / 4096 bytes 都是算整个文件,不是只算正文。frontmatter 占 6 行,所以正文实际上限是 194 行;4096 bytes 里 frontmatter 也占一份,纯中文正文约 1340 多字到顶(随 name/description 长短浮动)。名称是封闭校验:安全小写 slug,还专门挡 Windows 保留名 com1/lpt9、NTFS 流语法 file:stream、结尾点 trailing.。

正文 194 行: 通过      正文 195 行: 拒绝
中文 1348 字: 通过      1349 字: 拒绝(4047 bytes + frontmatter > 4096)
预算原因: 记忆要注入上下文,8KB×5 条 = 40KB 直接进请求
3 机制流程
1
beginTurn(query)

读取当前集合;为空则短路返回,否则选择相关记忆(最多 5 条,失败降级关键词匹配)。

2
注入 <relevant_memories>

只进本次请求,不污染 canonical history。

3
主模型 + 工具循环

canonical history 完整追加。

4
complete(canonicalHistory)

从完整 canonical 提取;达到阈值时与整理一次提交(达到阈值后每回合都会尝试)。

4 术语表
manifest.json当前记忆集合的唯一权威指针;坏了必须报错,不能当空集合继续跑。
MEMORY.md由当前集合生成的有界目录,可重建;选择器看的是内存里按 manifest 重渲染的目录,不是读这个文件。
MemoryTypeuser / feedback / project / reference 四类封闭集合,写别的直接拒。
原子替换同目录临时文件 + fsync + rename,读方看不到半写文件。
proper-lockfile跨进程文件锁(stale 30s/update 10s 恢复过期锁);同进程用 Promise 队列串行。
side-query一次独立的、不带任何工具的模型请求,只为拿一段 JSON;selector/extractor/consolidator 三个角色。
确定性回退选择器失败后按 name+description 关键词匹配(英文 token/中文 bigram),子串匹配不理解语义。
5 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. 记忆集合的唯一权威指针是?
Q2. 每条记忆文件受什么限制?
Q3. 记忆正文注入到哪里?
Q4. 选择请求失败时会发生什么?
Q5. 并发的两个会话整理记忆时?
Q6. 提取任意一项非法时(schema 错)会?
6 验证与实验
  • npm run test:ch09:23 个测试文件 / 214 个用例全部通过。
  • 验证跨实例选择、严格 side-query、路径边界、写入故障、局部整理、并发新增;提取失败后不整理、整理失败提取也不写。
  • npm run ch09 -- --prompt "记住:本项目的示例只使用 PowerShell",再用 agent-tutorial 开新进程验证真的记住了。
  • .memory 无自动清理且未被 .gitignore 忽略——提交(团队共享)还是忽略(个人本地)必须是个明确决定。