过去几周,我完成了一个学习项目:深度阅读 Claude Code 原版 Memory 子系统约 5,200 行 TypeScript 代码,然后用 Go 从零重实现了核心功能(约 1,194 行)。这不是要造一个替代品,而是用”重实现”作为最深层的阅读方式——当你必须让代码跑起来时,你没法跳过任何不理解的部分。
做完之后,我对”Agent Memory 到底是什么”的理解发生了根本性的变化。
核心洞察:Memory 的本质
Memory 不是一个数据库,不是一个 KV 存储,也不是一个向量索引。
Memory 是一个”教 LLM 如何管理自己的笔记本”的系统。
它的核心工作不是存储和检索——那只是基础设施。它的核心工作是通过 prompt 工程,让 LLM 学会:
- 什么值得记(语义过滤:不保存可推导的信息)
- 怎么组织(frontmatter 分类 + 索引策展)
- 什么时候用(访问时机 + 过时验证)
- 什么时候不用(用户说”忽略记忆”时遵守)
这意味着 prompt 模板是整个系统最重要的部分,比任何代码逻辑都重要。
我会保留的设计
文件驱动,不用数据库
原版选择纯 Markdown 文件 + frontmatter,而不是 SQLite 或其他数据库。原因不是”简单”——而是透明。
- 用户可以直接查看和编辑自己的记忆
- 记忆可以 commit 进 git(project scope agent memory)
- 调试时
cat/grep就够了,不需要 db client - LLM 用标准的 Read/Write/Edit 工具操作文件,不需要新增工具
如果用数据库,用户怎么审查”AI 记住了什么关于我”?打开 SQLite 用 SQL 查?这不是面向人类的设计。
Fail-open 设计原则
整个 Memory 系统没有任何操作会阻塞主对话。
记忆是增强(enhancement),不是核心功能(core)。Agent 没有记忆照样能工作,只是没那么聪明。把记忆设计成 fail-open 意味着:
- 记忆损坏 → 对话继续
- LLM 召回失败 → 对话继续
- 磁盘不可写 → 对话继续
唯一的 fail-closed 点在写入侧的安全校验(team memory secret scanning)——安全比可用性更重要,这个例外是对的。
三方分工
主模型 — 管行为(决定保存什么、使用什么)
侧模型 — 管召回(选哪些记忆与查询相关)
Harness — 管安全 + 时序 + 降级
这个分工让每一方做自己最擅长的事。Harness 不做语义判断(除了 secret scanning),LLM 不做路径安全。
索引由 LLM 维护
让 LLM 自己维护 MEMORY.md 索引,而不是自动生成,是一个精妙的设计:
- LLM 决定哪些记忆值得”常驻”在索引中(体积预算 200 行)
- 索引的一行描述是 LLM 自己写的”策展摘要”,比 frontmatter 的 description 更精练
- 这是一个意图信号:写入索引 = 确认”这条记忆每次都值得出现”
我会改变的设计
用 embedding + LLM rerank 替代纯 LLM 召回
原版用纯 LLM(Sonnet)做召回选择。这在 200 个文件的规模下可以工作,但有两个问题:
- 每次召回一次 API 调用(成本 + 延迟)
- Sonnet 的选择不是确定性的(同样输入可能选出不同记忆)
我的方案:
- 写入时生成 embedding(一次性成本,存在
.embedding文件中) - 召回时先 embedding 检索 top-20(本地计算,毫秒级)
- 再用 LLM rerank top-5(只在需要精确选择时调 API)
- 保留
recentTools和alreadySurfaced作为 post-filter(这些确实不适合向量化)
大部分查询只需要本地 embedding 检索,不调 API。只有复杂查询才走 LLM rerank。
合并指令和内容路径
原版把”指令”和”内容”分成两条路径(一个在 system prompt 动态区,一个在第一条 user message),理由是 prompt cache。
但这造成了:
- 架构复杂(两条独立路径,两种缓存失效机制)
- 初学者极难理解(我花了 3 轮 Codex 审查才搞清楚)
- 内容路径在迁移中(always-on → on-demand),增加了更多复杂度
我的方案:指令 + 内容合为一个模块输出,由上层(Context Assembly)决定放在 system prompt 还是 user message。Memory 模块不需要知道自己的输出会被塞到 prompt 的哪个位置。
简化模式分发
原版有 4 种模式(disabled / auto-only / combined / KAIROS),通过 feature flag 动态切换。这导致 loadMemoryPrompt() 是一个 4 路分发器,每条路径的行为微妙不同。
我的方案:
- 配置时确定模式,不在运行时切换
- 不同模式是不同的
PromptBuilder实现,不是一个函数里的 if-else - 需要 A/B 测试时,在 main 层根据配置选择实现,不在 Memory 层做条件分支
YAML 解析要健壮
原版的 frontmatterParser 有 retry + 引号包裹的 fallback。我的 Go 简易解析器只做 key: value 行分割。
如果重来,我会直接用 gopkg.in/yaml.v3,只解析需要的字段(yaml:"name" struct tag),不做自定义解析。解析失败仍然保留文件(fail-open),但记日志便于调试。
加入 NFC 规范化
macOS 的 HFS+ 文件系统用 NFD(分解形式)存文件名。如果 memory 目录中有 café.md(NFC)和 cafe\u0301.md(NFD),它们指向同一个文件但字符串比较不等。原版在 paths.ts 里做了 NFC 规范化,Go 版应该在路径输入层做一次 NFC 规范化(golang.org/x/text/unicode/norm)。这个优先级很高——macOS 用户会遇到,而且 bug 很难调试。
学到的设计模式
”教 LLM 做事”比”代码做事”更有效
Memory 系统的大部分”智能”在 prompt 里,不在代码里:
- 什么值得记?→ prompt 里的
WHAT_NOT_TO_SAVE_SECTION - 怎么分类?→ prompt 里的 4 种类型定义 + 示例
- 什么时候验证?→ prompt 里的
TRUSTING_RECALL_SECTION
代码只做”笨工作”:读文件、截断、扫描、拼字符串。
这是 Agent Harness 的核心模式:Harness 管约束,LLM 管决策。
对抗性 TDD 确实有效
让不同的 subagent 写测试和实现,发现了 3 个 bug:
- 测试方的
ex[:40]panic(短字符串越界) - 实现方的 JSON
nullvsmissing混淆 - 测试方的时间计算错误(27h ≠ 51h)
如果同一个人写测试和实现,这些 bug 不会被发现——因为测试会不自觉地”迁就”实现思路。
Feature flag 不是技术,是文化
原版有 6+ 个 feature flag 控制记忆行为的不同方面。这不是技术选择——是团队运营模式:每个假设都通过 A/B 测试验证,而不是在代码审查中辩论。
我的 Go 重实现去掉了 feature flag,改用配置项。这在学习项目中合理,但在生产环境中,feature flag 的价值不在技术实现,而在于”让数据说话”的决策文化。
Prompt 是”数据资产”,不是”实现细节”
Memory 的 prompt 模板(类型定义、保存指南、验证规则)经过多轮调优。每个措辞都有意义:
"Record from failure AND success"→ 引导 LLM 同时记录纠正和确认"ask what was *surprising* or *non-obvious*"→ 防止 LLM 保存可推导信息"The memory says X exists" is not the same as "X exists now."→ 触发验证行为
这些文本应该作为数据资产管理(版本控制、A/B 测试),而不是随意修改。我的 Go 重实现中最重要的决策之一就是逐字迁移模板,不自行重写。
给未来自己的备忘
-
扩展 Memory 时,先改 prompt,后改代码。 大部分行为改变可以通过 prompt 实现,不需要改架构。
-
加 embedding 前先测量。 200 个文件 x Sonnet sideQuery 可能只要 200ms + $0.001。embedding 基础设施(生成、存储、更新、检索)的复杂度远高于这个成本。
-
NFC 规范化优先级高于任何新功能。 macOS 用户会遇到,而且 bug 极难调试——文件”存在”但程序找不到。
-
别急着加缓存。 先确认性能是问题,再加缓存。当前 Go 版每次读磁盘,但 memory 目录通常很小(<200 个文件),读一遍几毫秒。