一份面向 AI Agent 开发者的本地记忆系统建设方法论。 从"为什么做"到"怎么做"到"怎么演进",覆盖完整链路。 基于生产级实践经验提炼,辅以业界方案参考。
目录
- [第一部分:为什么需要本地 Memory](#第一部分:为什么需要本地 Memory "#%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E6%9C%AC%E5%9C%B0-memory")
- 第二部分:核心心智模型
- [第三部分:存储策略 --- Markdown First](#第三部分:存储策略 — Markdown First "#%E7%AC%AC%E4%B8%89%E9%83%A8%E5%88%86%E5%AD%98%E5%82%A8%E7%AD%96%E7%95%A5--markdown-first")
- [第四部分:记忆提取 --- 最关键的环节](#第四部分:记忆提取 — 最关键的环节 "#%E7%AC%AC%E5%9B%9B%E9%83%A8%E5%88%86%E8%AE%B0%E5%BF%86%E6%8F%90%E5%8F%96--%E6%9C%80%E5%85%B3%E9%94%AE%E7%9A%84%E7%8E%AF%E8%8A%82")
- [第五部分:检索与注入 --- Token 预算管理](#第五部分:检索与注入 — Token 预算管理 "#%E7%AC%AC%E4%BA%94%E9%83%A8%E5%88%86%E6%A3%80%E7%B4%A2%E4%B8%8E%E6%B3%A8%E5%85%A5--token-%E9%A2%84%E7%AE%97%E7%AE%A1%E7%90%86")
- [第六部分:生命周期管理 --- Consolidation](#第六部分:生命周期管理 — Consolidation "#%E7%AC%AC%E5%85%AD%E9%83%A8%E5%88%86%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E7%AE%A1%E7%90%86--consolidation")
- 第七部分:安全与防护
- 第八部分:性能与可观测性
- [第九部分:演进路线 --- 从 v1 到 v3](#第九部分:演进路线 — 从 v1 到 v3 "#%E7%AC%AC%E4%B9%9D%E9%83%A8%E5%88%86%E6%BC%94%E8%BF%9B%E8%B7%AF%E7%BA%BF--%E4%BB%8E-v1-%E5%88%B0-v3")
- 附录
第一部分:为什么需要本地 Memory
1.1 AI Agent 的无状态困境
当前大多数 AI Agent 应用是无状态的------每次新会话,Agent 对用户和项目一无所知。用户不得不反复交代背景、偏好和约束,体验割裂。
这种无状态带来的具体痛点:
| 痛点 | 场景举例 | 用户感受 |
|---|---|---|
| 重复交代背景 | 每次都要说"我们项目用 TypeScript + React" | 烦躁,觉得 Agent 很笨 |
| 遗忘历史决策 | 上周刚讨论过用 JWT 替换 Session,这周又从头讨论 | 浪费时间,信任下降 |
| 不了解用户偏好 | 用户喜欢简洁回复,Agent 每次都长篇大论 | 体验差,想换工具 |
| 无法积累项目知识 | 项目的架构约定、编码规范每次都要重新说明 | 效率低下 |
| 跨会话断裂 | 上个会话修了一半的 bug,新会话完全不知道进度 | 工作连续性差 |
Memory 系统的目标是让 Agent 能够:
- 记住:用户偏好、项目背景、历史决策、编码规范
- 召回:在新会话中自动注入相关记忆,无需用户重复交代
- 积累:随时间整理和沉淀知识,形成越来越深的项目理解
- 遗忘:过时的信息能被自动衰减或手动清除,避免噪音干扰
1.2 本地 vs 云端 Memory
| 维度 | 本地 Memory | 云端 Memory |
|---|---|---|
| 隐私 | 数据不出设备,用户完全掌控 | 依赖服务商安全策略 |
| 延迟 | 本地文件读写,毫秒级 | 网络往返,百毫秒级 |
| 离线 | 完全可用 | 断网不可用 |
| 成本 | 零额外成本 | 存储 + API 调用费用 |
| 同步 | 需额外实现多设备同步 | 天然多设备可用 |
| 容量 | 受限于本地磁盘(通常足够) | 弹性扩展 |
| 可审查性 | 用户可直接打开文件查看/编辑 | 需要专门的管理界面 |
| 可移植性 | 文件复制即迁移 | 受限于服务商导出能力 |
| 版本控制 | 天然支持 git 追踪 | 需要额外实现版本历史 |
架构建议:
Markdown 文件] --> B[验证闭环可行性] B --> C[v1.5: 预留 MemoryProvider 接口] C --> D[按需扩展] D --> E[v2: 实现 CloudMemoryProvider
云端同步] E --> F[两条路并行] F --> G[v3: 本地 + 云端混合
按策略选择] style A fill:#e1f5ff style E fill:#fff4e1 style G fill:#e8f5e9
核心原则:本地方案先跑通,架构上通过 Provider 接口预留云端扩展能力,两条路并行不冲突。
1.3 Memory 的本质:从对话到认知
Memory 不是"存对话历史"。对话历史是原始素材,Memory 是从中提炼出的结构化认知。
这个区别至关重要:
markdown
对话历史(原始素材):
用户: 我们项目之前用 Jest,但最近迁移到 vitest 了,以后测试都用 vitest
助手: 好的,我会使用 vitest 来编写测试...
Memory(结构化认知):
- 项目使用 vitest 做测试(已从 Jest 迁移)
类型: semantic | 置信度: 0.99 | 更新: 2026-03-14
不同类型的对话产生不同类型的记忆:
| 对话内容 | 提取结果 | 记忆类型 |
|---|---|---|
| "我们项目用 pnpm" | 项目包管理器: pnpm |
语义记忆(项目信息) |
| "上次那个 bug 是因为缓存没清" | 缓存未清导致 xxx bug |
情景记忆(问题解决) |
| "叫我老王就行" | 称呼偏好: 老王 |
用户画像(profile) |
| "我喜欢简洁的回复风格" | 回复风格: 简洁 |
人格偏好(personality) |
| "项目启动命令是 pnpm dev" | 启动命令: pnpm dev |
工具环境(tool) |
| "今天把登录模块重构完了" | 完成登录模块重构 |
情景记忆(里程碑) |
这个"从对话中提取结构化记忆"的过程,是整个系统最关键的环节------第四部分会详细展开。
1.4 业界现状与参考
当前主流的 AI Agent Memory 方案:
| 方案 | 核心思路 | 存储 | 优势 | 局限 |
|---|---|---|---|---|
| OpenClaw | 三层记忆 + Markdown 文件 + SQLite 索引 | 本地文件 + SQLite | 成熟、人类可读、可 git 追踪 | 依赖 SQLite native 模块 |
| Mem0 | 自动提取 + 用户画像 + 向量检索 | 向量数据库 | 语义检索强、SaaS 开箱即用 | 云端依赖、成本高 |
| Zep | 自动摘要 + 实体提取 + 混合检索 | PostgreSQL + 向量 | 企业级、支持 RBAC | 部署复杂、重量级 |
| Letta (MemGPT) | Agent 自主管理记忆 | 分层存储 | 最接近人类记忆模型 | 实现复杂、LLM 调用多 |
关键洞察:
- 核心不是向量数据库,而是"写入-检索-注入"闭环。v1 完全可以不用向量,先把闭环跑通。
- Markdown 文件是被验证过的好选择。OpenClaw 用 Markdown 是对的------人类可读、可编辑、可 git 追踪、无供应商锁定。
- 记忆提取是最关键的环节。不是把整段对话存下来,而是从对话中提取结构化的"记忆条目"。
- 检索要有预算。注入 Prompt 的 memory 必须有 token 上限,否则会挤占用户真正的对话空间。
第二部分:核心心智模型
2.1 第一性原理:"写入 → 检索 → 注入"闭环
整个 Memory 系统的灵魂可以用一句话概括:
对话完成后提取记忆写入存储,下次对话前检索相关记忆注入 Prompt。
完整数据流:
最近 2-3 轮对话] A3 -->|跳过| A99[结束] A4 --> A5[返回 JSON 数组] A5 --> A6[安全检查
sanitizeMemoryEntry] A6 --> A7[添加来源追溯
source/extractedAt] A7 --> A8[去重检测
倒排索引] A8 --> A9{按 type 分流} A9 -->|profile| A10[USER.md] A9 -->|personality| A11[SOUL.md] A9 -->|tool| A12[TOOLS.md] A9 -->|memory| A13[daily log +
MEMORY.md] A10 --> A14[性能监控记录] A11 --> A14 A12 --> A14 A13 --> A14 end subgraph read["读取链路 (Read Path)"] B1[用户发送新消息] --> B2[buildMemoryContext] B2 --> B3[读取 session memory
内存态,全量] B3 --> B4[读取 MEMORY.md
解析为结构化 sections] B4 --> B5[读取 daily log
今天 + 昨天] B5 --> B6[token 预算裁剪
总上限 5000 tokens] B6 --> B7[组装 memory context block] B7 --> B8[安全标签包裹] B8 --> B9[注入 system message] B9 --> B10[发送给 LLM] end style write fill:#fff4e1 style read fill:#e1f5ff
v1 完全可以不用向量数据库、不用 GraphRAG,先把这个闭环跑通。闭环跑通后再优化每个环节。
2.2 三层记忆模型
借鉴认知科学的记忆分层(工作记忆 → 短期记忆 → 长期记忆),设计三层存储:
(会话记忆)"] S1[当前目标
约束
中间决策] end subgraph workspace["Workspace Memory
(工作区记忆)"] W1[语义记忆
用户偏好
项目信息
编码规范] W2[情景记忆
重要决策
里程碑] end subgraph daily["Daily Memory
(每日记忆)"] D1[今天活动
昨天活动] end session -->|会话结束
落盘| workspace session -->|每轮写入| daily daily -->|高价值条目
晋升| workspace style session fill:#ffe1e1 style workspace fill:#e1f5ff style daily fill:#fff4e1
| 层级 | 作用域 | 生命周期 | 存储方式 | 注入策略 | 典型内容 |
|---|---|---|---|---|---|
| Session Memory | 当前会话 | 会话结束即落盘 | 内存(运行时状态) | 全量注入(体量小) | 当前目标、约束、中间决策 |
| Workspace Memory | 当前工作区 | 长期持久 | MEMORY.md 文件 |
按 token 预算裁剪 | 项目信息、用户偏好、编码规范 |
| Daily Memory | 按日期 | 30 天过期 | memory/YYYY-MM-DD.md |
今天 + 昨天最近 N 条 | 每日活动记录、临时笔记 |
Session Memory(会话记忆)
当前会话中积累的临时认知。生命周期跟随会话,会话结束时落盘到文件。
css
Session Memory 数据结构:
{
sessionId: "ses_abc123",
entries: [
{ type: "goal", content: "重构登录模块", addedAt: "14:30" },
{ type: "constraint", content: "不能改变现有 API 接口", addedAt: "14:31" },
{ type: "decision", content: "使用 OAuth2 + PKCE", addedAt: "14:45" },
{ type: "pending", content: "需要确认 token 过期策略", addedAt: "15:00" },
],
maxEntries: 20, // 超过时按 LRU 淘汰
maxTokens: 500, // 注入预算
}
淘汰策略 :当条目超过上限时,按类型优先级淘汰:pending > decision > constraint > goal(goal 最不容易被淘汰)。
晋升机制:同一事实在 3+ 个不同 session 中出现时,自动晋升到 Workspace Memory。
Workspace Memory(工作区记忆)
经过验证的长期知识,存储在 MEMORY.md 文件中。分为两大类:
语义记忆(Semantic Memory)------稳定的事实和知识:
| 子类别 | 示例 | 优先级 |
|---|---|---|
| 用户偏好 | "偏好函数式风格,避免 class" | 最高 |
| 项目信息 | "项目使用 TypeScript + React 18" | 高 |
| 编码规范 | "统一使用 ESLint flat config" | 高 |
| 技术栈 | "数据库用 PostgreSQL 15" | 中 |
| 常见问题 | "热更新失败时需要清除 .cache 目录" | 中 |
情景记忆(Episodic Memory)------发生过的重要事件:
| 子类别 | 示例 | 优先级 |
|---|---|---|
| 重要决策 | "决定将认证从 Session 迁移到 JWT" | 高 |
| 里程碑 | "v2.0 发布,包含新的权限系统" | 中 |
| 问题解决 | "生产环境 OOM 是因为未关闭数据库连接" | 中 |
Daily Memory(每日记忆)
每日活动日志,记录当天发生的事情。作用:
- 短期回溯:Agent 可以知道"昨天我们做了什么"
- 晋升候选:高价值条目可以晋升到 MEMORY.md
- 审计追溯:记录记忆变更历史(冲突更新、删除等)
2.3 Bootstrap 文件体系
除了动态记忆,Agent 还需要一组静态身份文件来定义"我是谁、用户是谁、怎么行动"。这套文件体系借鉴了 OpenClaw 的 7 文件设计,每个文件一个职责:
| 文件 | 职责 | 更新频率 | 典型大小 |
|---|---|---|---|
IDENTITY.md |
Agent 身份定义(名字、类型、风格) | 极少变更 | < 200 tokens |
USER.md |
用户画像(基本信息 + 自动提取的偏好) | 自动 + 手动 | < 200 tokens |
SOUL.md |
人格与行为边界(回复风格、交互规则) | 极少变更 | < 500 tokens |
AGENTS.md |
工作区操作指南(Memory 约定、安全规则) | 偶尔变更 | < 800 tokens |
TOOLS.md |
工具/环境笔记(命令、路径、配置) | 自动 + 手动 | < 800 tokens |
HEARTBEAT.md |
定期任务清单(清理、检查、整理) | 极少变更 | < 200 tokens |
MEMORY.md |
长期记忆(语义 + 情景) | 频繁自动更新 | < 2000 tokens |
为什么要分成 7 个文件而不是一个大文件?
- 职责清晰:每个文件有明确的边界,不会混杂
- 独立更新:USER.md 可以频繁更新而不影响 IDENTITY.md
- 选择性注入:不同渠道可以选择注入哪些文件(如外部渠道不注入 USER.md)
- 用户可编辑:用户可以只编辑自己关心的文件
- Token 预算可控:每个文件有独立的 token 预算
Bootstrap 文件的生命周期
ini
应用首次启动
→ 检测 config_dir 是否存在
→ 不存在:创建目录 + 从模板初始化 7 个文件
→ 已存在:检查版本号,按需升级模板
会话创建
→ 加载所有 Bootstrap 文件(静态注入)
→ 计算每个文件的内容 hash
→ 缓存到内存
会话进行中
→ 每轮发送消息前,检查文件 hash 是否变更
→ 有变更:重新加载变更的文件(热更新)
→ 无变更:使用缓存
对话完成后
→ 提取结果中 type=profile 的条目 → 更新 USER.md
→ 提取结果中 type=personality 的条目 → 更新 SOUL.md
→ 提取结果中 type=tool 的条目 → 更新 TOOLS.md
2.4 认知科学视角:为什么这样分层
这套分层设计并非随意拍脑袋,而是对应了认知科学中的记忆模型:
Working Memory] C2[短期记忆
Short-term Memory] C3[长期记忆 - 语义
Semantic LTM] C4[长期记忆 - 情景
Episodic LTM] C5[自我认知
Self-awareness] C6[他人模型
Theory of Mind] C7[程序性记忆
Procedural Memory] end subgraph system["Memory 系统设计"] S1[Session Memory] S2[Daily Memory] S3[MEMORY.md
语义记忆 section] S4[MEMORY.md
情景记忆 section] S5[IDENTITY.md
SOUL.md] S6[USER.md] S7[TOOLS.md
AGENTS.md] end C1 -.对应.-> S1 C2 -.对应.-> S2 C3 -.对应.-> S3 C4 -.对应.-> S4 C5 -.对应.-> S5 C6 -.对应.-> S6 C7 -.对应.-> S7 style cognitive fill:#e8f5e9 style system fill:#e1f5ff
| 认知科学概念 | 对应设计 | 说明 |
|---|---|---|
| 工作记忆(Working Memory) | Session Memory | 当前任务的临时存储,容量有限,用完即释放 |
| 短期记忆(Short-term Memory) | Daily Memory | 近期事件的缓冲区,会自然衰减 |
| 长期记忆 - 语义(Semantic) | MEMORY.md 语义记忆 section | 稳定的知识和事实 |
| 长期记忆 - 情景(Episodic) | MEMORY.md 情景记忆 section | 具体事件的记忆 |
| 自我认知(Self-awareness) | IDENTITY.md + SOUL.md | Agent 对自身身份和行为边界的认知 |
| 他人模型(Theory of Mind) | USER.md | Agent 对用户的理解和建模 |
| 程序性记忆(Procedural) | TOOLS.md + AGENTS.md | 如何操作、如何行动的知识 |
第三部分:存储策略 --- Markdown First
3.1 为什么 Markdown 是 v1 最优解
| 优势 | 详细说明 |
|---|---|
| 人类可读 | 用户可以直接打开查看、编辑、审查 Agent 的记忆,不需要专门的管理工具 |
| 零依赖 | 不需要数据库、不需要额外服务、不需要 native 模块编译 |
| 可版本控制 | 天然支持 git 追踪,记忆变更有迹可循,可以 diff、blame、revert |
| 无供应商锁定 | 纯文本文件,迁移零成本,任何编辑器都能打开 |
| LLM 友好 | Markdown 是 LLM 最擅长读写的格式,解析和生成都很自然 |
| 调试友好 | 出问题时直接打开文件就能看到 Agent 记住了什么,不需要查数据库 |
| 备份简单 | 复制文件夹即完成备份,不需要数据库导出 |
常见质疑与回应:
"Markdown 文件性能不够怎么办?"
v1 阶段,MEMORY.md 通常不超过几 KB,daily log 也很小。直接读文件解析的性能完全足够(< 10ms)。当文件增长到性能瓶颈时(通常 50KB+),再引入索引层------但 Markdown 仍然是真相源。
"没有数据库怎么做复杂查询?"
v1 不需要复杂查询。按 section 解析 + 关键词匹配 + token 预算裁剪就够了。v2 需要语义检索时,加 IndexedDB/SQLite 做索引,Markdown 文件不变。
"并发写入怎么办?"
通过写入队列串行化 + 原子写入(write-then-rename)解决。详见 3.5 节。
3.2 目录结构设计
yaml
{config_dir}/
├── .gitignore # 忽略临时文件
├── .version # Bootstrap 模板版本号
├── IDENTITY.md # Agent 身份定义
├── USER.md # 用户画像
├── SOUL.md # 人格与行为边界
├── AGENTS.md # 工作区操作指南
├── TOOLS.md # 工具/环境笔记
├── HEARTBEAT.md # 定期任务清单
├── MEMORY.md # 长期记忆(语义 + 情景)
├── memory/ # 每日活动日志
│ ├── 2026-03-15.md
│ ├── 2026-03-14.md
│ └── ...
└── sessions/ # 会话级记忆落盘
├── ses_abc123.memory.md
└── ...
config_dir 的选择:
| 方案 | 路径示例 | 优点 | 缺点 |
|---|---|---|---|
| 用户配置目录 | ~/.config/{app}/ |
统一管理,不污染项目 | 不跟随项目走 |
| 项目目录 | {project}/.{app}/ |
跟随项目,可 git 追踪 | 每个项目独立,不共享 |
| 混合方案 | 全局 ~/.config/ + 项目级覆盖 |
兼顾两者 | 实现复杂度高 |
建议 :v1 使用用户配置目录(如 ~/.config/{app}/),统一管理所有记忆。如果需要项目级隔离,v2 再加项目级覆盖层。
3.3 文件格式约定
MEMORY.md --- 两级结构
markdown
## 语义记忆
### 用户偏好
- 偏好使用函数式风格,避免 class <!-- confidence: 0.95 | updated: 2026-03-12 | source: ses_abc -->
- 喜欢简洁的回复,不要长篇大论 <!-- confidence: 0.90 | updated: 2026-03-13 | source: ses_def -->
### 项目信息
- 项目使用 TypeScript + React 18 <!-- confidence: 0.99 | updated: 2026-03-10 | source: ses_ghi -->
- 数据库使用 PostgreSQL 15 <!-- confidence: 0.95 | updated: 2026-03-11 | source: ses_jkl -->
### 编码规范
- 统一使用 ESLint flat config <!-- confidence: 0.95 | updated: 2026-03-14 | source: ses_mno -->
- 组件文件使用 PascalCase 命名 <!-- confidence: 0.85 | updated: 2026-03-12 | source: ses_pqr -->
### 常见问题
- 热更新失败时需要清除 .cache 目录 <!-- confidence: 0.80 | updated: 2026-03-09 | source: ses_stu -->
## 情景记忆
### 重要决策
- 决定将认证模块从 Session 迁移到 JWT <!-- confidence: 0.90 | updated: 2026-03-10 | source: ses_vwx -->
- 选择 Zustand 替代 Redux 做状态管理 <!-- confidence: 0.95 | updated: 2026-03-08 | source: ses_yza -->
### 里程碑
- v2.0 发布,包含新的权限系统 <!-- confidence: 1.00 | updated: 2026-03-05 | source: ses_bcd -->
格式设计要点:
- HTML 注释存元数据 :
<!-- key: value | key: value -->格式,LLM 读取时自然忽略,解析器可以提取 - 两级标题组织 :一级
##为认知类型(语义/情景),二级###为主题分类 - 每条记忆一行 :以
-开头,便于解析和去重 - 宽进严出:解析时容忍格式不规范(如缺少元数据注释),写入时严格遵循格式
Daily Log --- 按时间 + 会话组织
markdown
## 14:30 | ses_abc123
- 用户讨论了登录模块重构方案
- 决定使用 OAuth2 + PKCE 流程
- 需要调研 PKCE 在移动端的兼容性
## 16:15 | ses_def456
- 修复了 token 刷新的竞态条件
- [冲突更新] 旧: 项目使用 Jest 做测试 → 新: 项目使用 vitest 做测试
## 18:00 | ses_ghi789
- 完成了权限模块的单元测试(覆盖率 85%)
USER.md --- 手动 + 自动混合
markdown
# 用户画像
## 基本信息
- 名字:(手动填写或自动提取)
- 称呼偏好:(如何称呼用户)
- 时区:Asia/Shanghai
- 语言偏好:中文
## 自动提取
<!-- 由 memory-extractor 自动维护,每条带时间戳和来源 -->
- 角色:全栈开发工程师 <!-- extracted: 2026-03-12, source: ses_abc -->
- 技术栈偏好:TypeScript + React <!-- extracted: 2026-03-12, source: ses_abc -->
- 沟通风格:直接、不喜欢废话 <!-- extracted: 2026-03-13, source: ses_def -->
- 常用 IDE:VS Code <!-- extracted: 2026-03-14, source: ses_ghi -->
## 备注
<!-- 用户可手动添加任何补充信息 -->
3.4 Token 估算
由于 Memory 内容最终要注入 LLM Prompt,需要准确估算 token 数量。中英文混合场景下的估算公式:
javascript
function estimateTokens(text) {
if (!text) return 0
let tokens = 0
for (const char of text) {
if (/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(char)) {
tokens += 1.5 // 中文字符约 1.5 tokens
} else {
tokens += 0.25 // 英文字符约 0.25 tokens(≈ 4 字符/token)
}
}
return Math.ceil(tokens)
}
截断策略 :当内容超出 token 预算时,使用二分查找定位截断点,确保不会在 Markdown 语法中间截断(如不截断在 <!-- --> 注释中间)。
3.5 原子写入与并发安全
文件写入必须保证原子性,避免写入中途崩溃导致数据损坏:
markdown
写入流程:
1. 将内容写入临时文件 {target}.tmp.{random}
2. 调用 fsync 确保数据落盘
3. rename 临时文件为目标文件(原子操作)
4. 清理可能残留的旧临时文件
并发控制:所有写入操作通过队列串行化:
javascript
class MemoryIO {
constructor() {
this._queue = Promise.resolve()
}
async _enqueue(fn) {
// 所有写入操作排队执行,避免并发冲突
this._queue = this._queue.then(fn).catch(err => {
console.error('[MemoryIO] Write error:', err)
})
return this._queue
}
async writeFile(path, content) {
return this._enqueue(async () => {
const tmpPath = `${path}.tmp.${Date.now()}`
await fs.writeFile(tmpPath, content, 'utf-8')
await fs.rename(tmpPath, path) // 原子替换
})
}
async appendFile(path, content) {
return this._enqueue(async () => {
await fs.appendFile(path, content, 'utf-8')
})
}
}
路径安全:所有文件操作前需要校验路径,防止路径穿越攻击:
javascript
_resolveSafePath(baseDir, relativePath) {
const resolved = path.resolve(baseDir, relativePath)
if (!resolved.startsWith(baseDir)) {
throw new Error(`Path traversal detected: ${relativePath}`)
}
return resolved
}
3.6 何时引入数据库索引层
| 信号 | 阈值 | 说明 |
|---|---|---|
| MEMORY.md 文件大小 | > 50KB | 直接读文件解析变慢 |
| Daily log 文件数量 | > 100 个 | 遍历文件列表变慢 |
| 检索召回率不足 | 用户反馈"记不住" | 关键词匹配不够用,需要语义检索 |
| 需要结构化过滤 | 按 confidence/时间/类型筛选 | 文件解析做不到高效过滤 |
引入方式:
markdown
Markdown 文件(真相源)
↓ 写入时同步更新
IndexedDB / SQLite(索引层)
↓ 检索时查询
返回匹配的记忆条目
↓ 回到 Markdown 读取完整内容(可选)
注入 Prompt
核心原则:数据库只做索引加速,Markdown 文件始终是真相源。数据库丢失可以从 Markdown 重建,反之不行。
第四部分:记忆提取 --- 最关键的环节
记忆提取是整个系统中最核心、最复杂的环节。它决定了 Agent 能"记住"什么、记得多准确、记忆质量有多高。
4.1 提取方式选择
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| LLM 提取 | 理解语义、能判断重要性、能分类、能检测冲突 | 有 API 成本、有延迟(1-3s) | 主力方案 |
| 规则提取 | 零成本、零延迟、确定性强 | 只能匹配模式,无法理解语义 | 辅助/降级方案 |
| 混合提取 | 兼顾两者优势 | 实现复杂度高 | 成熟阶段 |
建议:v1 以 LLM 提取为主,规则提取作为降级兜底。当 LLM API 不可用时,自动切换到规则提取,保证基本的记忆能力不中断。
4.2 提取 Prompt 设计
提取的核心是让 LLM 从对话中输出结构化 JSON。Prompt 设计的好坏直接决定提取质量。
输出格式定义
json
[
{
"content": "项目统一使用 ESLint flat config,不用 .eslintrc",
"memory_type": "semantic",
"category": "coding_convention",
"durability": "long_term",
"confidence": 0.95,
"conflict_with": null,
"type": "memory"
}
]
字段详解
| 字段 | 类型 | 作用 | 取值说明 |
|---|---|---|---|
content |
string | 记忆内容 | 简洁一句话,不超过 100 字。要求自包含------脱离对话上下文也能理解 |
memory_type |
enum | 认知类型 | semantic(稳定知识)/ episodic(具体事件) |
category |
string | 细分类别 | user_preference / project_info / coding_convention / decision / milestone / problem_solution |
durability |
enum | 持久级别 | long_term(写入 MEMORY.md)/ daily(仅写 daily log)/ session(仅当前会话) |
confidence |
float | 置信度 | 0-1。用户明确说的 > 0.9,推断的 0.5-0.8,不确定的 < 0.5 |
conflict_with |
string|null | 冲突标记 | 如果与已有记忆冲突,填写旧内容原文;无冲突填 null |
type |
enum | 路由类型 | memory(→ MEMORY.md/daily)/ profile(→ USER.md)/ personality(→ SOUL.md)/ tool(→ TOOLS.md) |
完整 Prompt 模板
bash
从以下对话中提取值得长期记住的信息。
## 提取规则
1. 只提取明确的事实、偏好、决策,不提取闲聊和临时讨论
2. 每条记忆用一句话概括,不超过 100 字
3. 内容必须自包含------脱离对话上下文也能理解其含义
4. 标注类型:semantic(稳定知识)或 episodic(具体事件)
5. 标注置信度:
- 用户明确说的("我们用 xxx"、"以后都 xxx")→ 0.9-1.0
- 可以合理推断的 → 0.7-0.8
- 不太确定的 → 0.5-0.6
6. 如果与已知记忆冲突,在 conflict_with 中标注旧内容原文
7. 不要提取:密码、API Key、token、密钥等敏感信息
8. 不要提取:一次性的调试过程、临时的报错信息
9. 不值得记忆的对话返回空数组 []
## 类型路由
- 关于用户个人信息(名字、角色、技术栈)→ type: "profile"
- 关于回复风格、交互偏好 → type: "personality"
- 关于工具、命令、环境配置 → type: "tool"
- 其他所有 → type: "memory"
## 已有记忆(用于冲突检测)
{existing_memories}
## 对话内容
{conversation}
## 输出格式
返回 JSON 数组,每个元素格式:
{"content": "...", "memory_type": "semantic|episodic", "category": "...",
"durability": "long_term|daily|session", "confidence": 0.0-1.0,
"conflict_with": null|"旧内容", "type": "memory|profile|personality|tool"}
只返回 JSON 数组,不要其他内容。不值得记忆时返回 []。
提取示例
示例 1:应该提取(语义记忆)
arduino
用户:我们项目统一用 ESLint flat config,不要用旧的 .eslintrc
助手:好的,我会使用 flat config 格式。
→ 提取:
[{"content": "项目统一使用 ESLint flat config,不用 .eslintrc",
"memory_type": "semantic", "category": "coding_convention",
"durability": "long_term", "confidence": 0.95,
"conflict_with": null, "type": "memory"}]
示例 2:应该提取(用户画像)
json
用户:我是做后端的,主要用 Go 和 Rust
助手:了解,那我在推荐方案时会优先考虑这两个语言的生态。
→ 提取:
[{"content": "后端开发,主要使用 Go 和 Rust",
"memory_type": "semantic", "category": "user_preference",
"durability": "long_term", "confidence": 0.95,
"conflict_with": null, "type": "profile"}]
示例 3:不应该提取
css
用户:帮我看看这个报错是什么意思
助手:这是 TypeScript 的类型错误,你需要...
用户:哦,原来如此,谢谢
→ 提取:[]
(一次性的调试过程,不值得记忆)
示例 4:冲突检测
css
已有记忆:项目使用 Jest 做测试
用户:我们迁移到 vitest 了,以后都用 vitest
→ 提取:
[{"content": "项目使用 vitest 做测试(已从 Jest 迁移)", "memory_type": "semantic", "category": "project_info", "durability": "long_term", "confidence": 0.99, "conflict_with": "项目使用 Jest 做测试", "type": "memory"}]
示例 5:情景记忆
json
用户:今天终于把权限系统上线了,搞了两周
助手:恭喜!这是个大里程碑。
→ 提取:
[{"content": "权限系统上线完成(耗时两周)",
"memory_type": "episodic", "category": "milestone",
"durability": "long_term", "confidence": 0.95,
"conflict_with": null, "type": "memory"}]
4.3 提取分流
根据 type 字段将提取结果路由到不同文件:
带 extracted 注释] C3 --> C4[写回 USER.md] B -->|personality| D[解析 SOUL.md] D --> D1[追加到对应 section] D1 --> D2[写回 SOUL.md] B -->|tool| E[解析 TOOLS.md] E --> E1[追加到对应 section] E1 --> E2[写回 TOOLS.md] B -->|memory| F{durability 字段} F -->|long_term
+ confidence ≥ 0.7| G[解析 MEMORY.md] G --> G1[按 memory_type
定位 section] G1 --> G2[按 category
定位子 section] G2 --> G3{去重检查} G3 -->|conflict_with 非空| G4[覆盖旧条目
记录到 daily log] G3 -->|已有相似| G5[跳过] G3 -->|无重复| G6[追加新条目
带元数据注释] G4 --> G7[写回 MEMORY.md] G6 --> G7 G7 --> G8[同时写入 daily log] F -->|daily| H[追加到 daily log
memory/YYYY-MM-DD.md] F -->|session| I[添加到内存中的
session memory] style C fill:#e1f5ff style D fill:#ffe1e1 style E fill:#fff4e1 style G fill:#e8f5e9
4.4 节流策略
不是每轮对话都触发提取。频繁提取会浪费 API 调用、增加延迟、产生重复记忆。需要智能节流:
节流参数
| 参数 | 默认值 | 说明 |
|---|---|---|
minTurns |
1 | 至少 N 轮对话后才触发下一次提取 |
minIntervalMs |
30000 | 两次提取间隔不少于 30 秒 |
maxConsecutiveEmpty |
3 | 连续 N 次空结果后进入退避 |
emptyResultMultiplier |
2 | 空结果退避倍数(间隔翻倍) |
maxBackoffMs |
300000 | 最大退避间隔(5 分钟) |
节流流程
markdown
对话完成,触发提取请求
│
├─ 检查:距上次提取是否 >= minIntervalMs?
│ └─ 否 → 跳过(除非检测到重要信号)
│
├─ 检查:距上次提取是否 >= minTurns 轮?
│ └─ 否 → 跳过(除非检测到重要信号)
│
├─ 检查:是否在退避期?
│ └─ 是 → 跳过
│
├─ 检查:对话中是否包含重要信号?
│ └─ 是 → 跳过所有节流,立即提取
│
└─ 通过所有检查 → 执行提取
│
├─ 提取结果非空 → 重置退避计数器
└─ 提取结果为空 → 退避计数器 +1
└─ 达到 maxConsecutiveEmpty → 进入退避
→ 下次间隔 = 当前间隔 × emptyResultMultiplier
→ 上限 maxBackoffMs
重要信号检测
当对话中出现以下模式时,跳过节流立即提取:
javascript
const IMPORTANCE_SIGNALS = [
// 显式记忆指令
/记住|记一下|别忘了|以后都|以后不要/,
/remember|don't forget|always use|never use/i,
// 偏好表达
/偏好|喜欢用|习惯|风格/,
/prefer|like to use|my style/i,
// 决策表达
/决定|确定|最终方案|就这样定了/,
/decided|final decision|let's go with/i,
// 变更表达
/迁移到|切换到|升级到|改为|换成/,
/migrate to|switch to|upgrade to|change to/i,
// 禁止表达
/不要再|别再|禁止|不允许/,
/stop using|don't use|forbidden|not allowed/i,
// 身份信息
/我是|我叫|叫我|我的名字/,
/I am|my name is|call me/i,
// 项目信息
/我们项目|项目用的|技术栈是/,
/our project|we use|tech stack/i,
]
建议准备 60+ 个正则模式,覆盖中英文常见表达。
4.5 去重与冲突检测
去重算法
新提取的记忆需要与已有记忆去重,避免重复存储。推荐使用倒排索引方案:
建立索引:
csharp
已有记忆:
[0] "项目使用 TypeScript + React 18"
[1] "偏好函数式风格,避免 class"
[2] "数据库使用 PostgreSQL 15"
倒排索引:
"项目" → [0]
"TypeScript" → [0]
"React" → [0]
"偏好" → [1]
"函数式" → [1]
"class" → [1]
"数据库" → [2]
"PostgreSQL" → [2]
查重流程:
css
新记忆:"项目使用 TypeScript 5.0"
→ 分词:["项目", "TypeScript", "5.0"]
→ 查倒排索引:
"项目" → [0]
"TypeScript" → [0]
→ 候选集:[0](出现次数最多)
→ 计算相似度:
新: "项目使用 TypeScript 5.0"
旧: "项目使用 TypeScript + React 18"
相似度: 0.75
→ 0.75 < 阈值 0.8 → 不算重复,允许写入
相似度计算:使用 Jaccard 系数或编辑距离。建议阈值 0.8------过低会误杀不同记忆,过高会漏检重复。
时间复杂度:从暴力比较的 O(n×m) 降到 O(n×log(m)),其中 n 是新记忆的分词数,m 是已有记忆总数。
冲突检测与处理
当新记忆的 conflict_with 字段非空时,说明 LLM 检测到了与已有记忆的冲突:
bash
冲突处理流程:
1. 在 MEMORY.md 中搜索 conflict_with 的内容
2. 找到匹配:
→ 用新内容替换旧内容
→ 更新元数据(confidence、updated、source)
→ 在 daily log 中记录:[冲突更新] 旧: xxx → 新: yyy
3. 未找到匹配:
→ 作为新条目追加(可能旧条目已被手动删除)
4.6 错误恢复与自动降级
提取过程依赖 LLM API,可能因网络故障、API 限流、服务不可用等原因失败。必须设计优雅的降级机制:
三级降级策略
LLM 提取,完整功能 Level0 --> Level1: API 单次失败 Level1: Level 1: 降级模式
跳过本次提取
记录失败日志 Level1 --> Level0: 下次成功 Level1 --> Level2: 连续失败 5 次 Level2: Level 2: 暂停模式
暂停自动提取
切换到规则提取
定时探测恢复 Level2 --> Level0: 探测成功 Level2 --> Level3: 探测持续失败 Level3: Level 3: 关闭模式
完全关闭提取
仅保留检索和注入 Level3 --> Level0: 用户手动恢复
或 API 恢复 note right of Level0 完整功能 最佳体验 end note note right of Level2 每 5 分钟探测 间隔翻倍(上限 30 分钟) end note
自动恢复
暂停模式下:
→ 每 5 分钟发送一次轻量级探测请求
→ 探测成功 → 恢复到正常模式
→ 探测失败 → 继续暂停,探测间隔翻倍(上限 30 分钟)
核心原则:Memory 提取失败绝不能影响主对话流程。提取是"锦上添花",不是"必要条件"。
4.7 来源追溯
每条记忆附带完整的来源元数据,便于审计、调试和回溯:
| 元数据字段 | 说明 | 示例 |
|---|---|---|
confidence |
置信度 | 0.95 |
updated |
最后更新日期 | 2026-03-14 |
source |
来源会话 ID | ses_abc123 |
extractedAt |
提取时间戳 | 2026-03-14T14:30:00Z |
extractedFrom |
提取来源 | user_message / assistant_message |
decay |
衰减标记(可选) | 0(未衰减)/ 1 / 2 / 3 |
在 Markdown 中以 HTML 注释形式存储:
markdown
- 项目使用 vitest 做测试 <!-- confidence: 0.99 | updated: 2026-03-14 | source: ses_abc | extractedFrom: user_message -->
第五部分:检索与注入 --- Token 预算管理
5.1 两阶段注入模型
Memory 注入分为静态和动态两个阶段,避免每轮重复加载不变的内容:
| 阶段 | 时机 | 内容 | 频率 | 预算 |
|---|---|---|---|---|
| 静态注入 | 会话创建/切换时 | Bootstrap 文件(IDENTITY、USER、SOUL、TOOLS、AGENTS、HEARTBEAT) | 每会话一次 | ~2700 tokens |
| 动态注入 | 每轮发送消息前 | MEMORY.md + session memory + daily log | 每轮 | ~1500 tokens |
为什么这样分:
- Bootstrap 文件在一个会话内几乎不变,加载一次缓存即可,避免每轮重复读取 6 个文件
- MEMORY.md 和 daily log 可能在会话中被写入新内容(因为提取是异步的),需要每轮检测变更
- 通过文件 hash 检测变更,无变更则用缓存,避免无效 I/O
- Session memory 本身就在内存中,每轮直接读取,零 I/O 开销
静态注入流程
bash
会话创建
→ MemoryBootstrap.loadAll()
→ 并行读取 6 个 Bootstrap 文件
→ 计算每个文件的内容 hash(simpleHash)
→ 缓存文件内容和 hash
→ 按 token 预算裁剪
→ 拼装为静态 context block
→ 注入到 system message 的固定位置
会话进行中(每轮消息前)
→ MemoryBootstrap.detectChanges()
→ 重新计算文件 hash
→ 与缓存 hash 对比
→ 有变更的文件:重新加载
→ 无变更的文件:跳过
→ 如果有任何文件变更,重新拼装静态 context block
动态注入流程
css
用户发送消息
→ MemoryRetriever.buildContext()
→ 1. 读取 session memory(内存态,全量,体量小)
→ 2. 读取 MEMORY.md
→ 检查文件 hash,无变更用缓存
→ MemoryParser.parseMemoryMd() 解析为结构化 sections
→ 如果 < 1000 tokens,全量注入
→ 如果较大:
a. 语义记忆:按 section 优先级保留
b. 情景记忆:按 recency 排序,取最近 N 条
c. 按 confidence 和 updated 时间加权排序
→ 3. 读取 daily log(今天 + 昨天)
→ 取最近 N 条 entries(默认 5 条)
→ 4. 按优先级排序 + token 预算裁剪
→ 5. 组装 memory context block
→ 6. 安全标签包裹
→ 注入到 system message
5.2 分层 Token 预算
注入 Prompt 的 memory 必须有 token 上限,否则会挤占用户的对话空间。预算设计需要平衡"记忆丰富度"和"对话空间"。
预算分配表
| 内容 | 预算 | 说明 | 裁剪策略 |
|---|---|---|---|
| IDENTITY.md | ~200 tokens | 通常极小,几乎不需要裁剪 | 全量加载 |
| USER.md | ~200 tokens | 通常极小 | 全量加载 |
| SOUL.md | ~500 tokens | 较小 | 全量加载 |
| AGENTS.md | ~800 tokens | 中等 | 全量加载,超预算按 section 裁剪 |
| TOOLS.md | ~800 tokens | 中等 | 全量加载,超预算按 section 裁剪 |
| HEARTBEAT.md | ~200 tokens | 通常极小或为空 | 全量加载 |
| 静态小计 | ~2700 tokens | 会话创建时一次性注入 | --- |
| MEMORY.md | ~1000 tokens | 核心记忆 | 按 section 优先级裁剪 |
| Session memory | ~300 tokens | 当前会话上下文 | 按类型优先级淘汰 |
| Daily log | ~200 tokens | 近期活动 | 取最近 N 条 |
| 动态小计 | ~1500 tokens | 每轮注入 | --- |
| 弹性预留 | ~800 tokens | 应对文件超预期增长 | --- |
| 总预算 | ~5000 tokens | 可在设置中调整 | --- |
为什么是 5000 tokens?
- 主流 LLM 的 context window 为 8K-200K tokens
- System prompt(含 memory)通常占 10-20% 为宜
- 以 32K context 为例,system prompt 预算约 3200-6400 tokens
- 5000 tokens 是一个保守但足够的默认值
- 用户可根据实际使用的模型调整
5.3 裁剪策略详解
当内容超出预算时,按以下优先级裁剪:
全局优先级(从高到低)
markdown
1. IDENTITY.md --- Agent 必须知道自己是谁
2. USER.md --- Agent 必须知道用户是谁
3. SOUL.md --- Agent 必须知道行为边界
4. Session memory --- 当前会话的上下文最重要
5. AGENTS.md --- 工作区规则
6. TOOLS.md --- 工具环境
7. MEMORY.md --- 长期记忆(按 section 优先级裁剪)
8. Daily log --- 近期活动(最先被裁剪)
9. HEARTBEAT.md --- 定期任务(最先被裁剪)
MEMORY.md 内部裁剪优先级
当 MEMORY.md 超出预算时,按 section 优先级保留:
markdown
语义记忆(优先保留):
1. 用户偏好 --- 直接影响回复质量
2. 项目信息 --- 基础上下文
3. 编码规范 --- 代码质量相关
4. 常见问题 --- 可以后续再查
情景记忆(次优先):
5. 重要决策 --- 避免重复讨论
6. 里程碑 --- 项目进度感知
在同一 section 内,按 updated 时间倒序排列,优先保留最近更新的条目。
5.4 安全注入
Memory 内容注入 Prompt 时,必须用安全标签包裹,防止 LLM 将记忆内容误解为指令:
xml
<agent-memory readonly="true">
## 当前会话记忆
- 用户目标:重构登录模块
- 约束:不能改变现有 API 接口
## 项目记忆(来自 MEMORY.md)
- 项目使用 TypeScript + React 18
- 偏好函数式风格,避免 class
- 统一使用 ESLint flat config
## 近期活动
- 昨天:修复了 token 刷新竞态条件
- 今天:开始重构登录模块
</agent-memory>
安全标签的作用:
readonly="true"提示 LLM 这是只读上下文,不应被修改- 标签名
agent-memory明确标识这是记忆内容,不是用户指令 - 与用户消息在结构上隔离,降低 prompt injection 风险
5.5 多渠道注入策略
不同渠道(本地桌面、IM 机器人等)的信任级别不同,注入策略应可配置:
| 策略 | 注入内容 | 适用场景 | 安全考量 |
|---|---|---|---|
full |
所有 Bootstrap + MEMORY.md + USER.md + session + daily | 完全信任的本地桌面 | 无限制 |
safe |
IDENTITY + SOUL + AGENTS(不含 USER.md 和 MEMORY.md) | IM 机器人等外部渠道 | 不暴露用户私人信息 |
minimal |
仅 IDENTITY | 低信任渠道 | 最小信息暴露 |
off |
不注入任何 memory | 完全关闭 | 零信息暴露 |
配置方式:
javascript
const CHANNEL_STRATEGIES = {
local: 'full', // 本地桌面
feishu: 'safe', // IM 渠道
dingtalk: 'safe', // IM 渠道
qq: 'safe', // IM 渠道
api: 'minimal', // API 调用
}
// 用户可在设置中覆盖默认策略
const DEFAULT_CHANNEL_STRATEGY = 'safe'
safe 策略的额外限制:
- 不注入包含用户个人信息的文件(USER.md)
- 不注入长期记忆(MEMORY.md)------可能包含敏感项目信息
- 不注入 session memory------可能包含当前任务的敏感上下文
- 不注入 TOOLS.md------可能包含内部工具和路径信息
第六部分:生命周期管理 --- Consolidation
记忆不是只增不减的。没有生命周期管理的 Memory 系统会逐渐充满噪音和过时信息,最终降低 Agent 的回复质量。
6.1 生命周期总览
6.2 Session 落盘与晋升
落盘时机
| 事件 | 动作 |
|---|---|
| 会话切换 | 将当前 session memory 写入 sessions/{sessionId}.memory.md |
| 会话关闭 | 同上 |
| 应用退出 | 将所有未落盘的 session memory 写入文件 |
| 定时保护 | 每 5 分钟自动落盘一次(防止崩溃丢失) |
落盘文件格式
markdown
# Session Memory: ses_abc123
<!-- created: 2026-03-15T14:30:00 -->
<!-- lastUpdated: 2026-03-15T16:45:00 -->
## 目标
- 重构登录模块
## 约束
- 不能改变现有 API 接口
- 需要兼容移动端
## 决策
- 使用 OAuth2 + PKCE 流程
- Token 存储使用 httpOnly cookie
## 待确认
- Token 过期策略:滑动窗口 vs 固定过期
晋升机制
当同一事实在多个 session 中重复出现时,说明它是稳定的长期知识,应该晋升到 MEMORY.md:
markdown
晋升条件:
1. 同一事实在 >= 3 个不同 session 中出现
2. 且 MEMORY.md 中尚无该条目(去重检查)
3. 且该事实的平均 confidence >= 0.7
晋升流程:
1. 扫描 sessions/ 目录下的所有 session memory 文件
2. 提取所有条目,按内容相似度聚类
3. 出现次数 >= 3 的聚类 → 候选晋升条目
4. 与 MEMORY.md 现有条目去重
5. 通过去重的条目 → 追加到 MEMORY.md 对应 section
6. 在 daily log 中记录晋升事件
6.3 Daily Log 管理
| 操作 | 触发条件 | 说明 |
|---|---|---|
| 创建 | 当天首次写入时 | 自动创建 memory/YYYY-MM-DD.md |
| 追加 | 每次提取产生 daily 级别的记忆时 | 追加到当天文件 |
| 检索 | 每轮对话前 | 读取今天 + 昨天的 daily log |
| 过期清理 | 应用启动时 | 删除超过 RETENTION_DAYS(默认 30 天)的文件 |
清理前的保护:在删除过期 daily log 之前,可选择扫描其中的高价值条目(confidence >= 0.9 且未在 MEMORY.md 中出现),提示用户是否晋升。
6.4 记忆衰减与遗忘
过时的记忆会产生噪音,需要衰减机制。设计灵感来自认知科学中的"遗忘曲线"------不被回忆的记忆会逐渐淡化。
衰减机制
xml
每周执行一次衰减扫描(或手动触发):
遍历 MEMORY.md 中的每条记忆
│
├─ updated 距今 < 30 天 → 跳过(活跃期)
│
├─ updated 距今 30-60 天
│ └─ 最近 30 天内被检索命中过?
│ ├─ 是 → 重置 updated 为命中日期(记忆被"回忆"了)
│ └─ 否 → 标记 <!-- decay: 1 -->(开始衰减)
│
├─ 已有 decay 标记
│ └─ decay 值 +1
│ ├─ decay < 3 → 检索时降权(排序靠后)
│ └─ decay >= 3 → 移入归档或标记为待清理
│
└─ confidence = 1.0 的条目(用户显式记忆)
→ 永不衰减,除非用户手动删除
衰减对检索的影响
ini
检索排序权重 = base_weight × decay_factor × recency_factor
其中:
base_weight = confidence 值(0-1)
decay_factor = decay 标记为 0 时 1.0,每增加 1 减少 0.3
即 decay=0: 1.0, decay=1: 0.7, decay=2: 0.4, decay=3: 0.1
recency_factor = 基于 updated 时间的衰减
7 天内: 1.0, 30 天内: 0.8, 60 天内: 0.5, 更早: 0.3
6.5 冲突审计
当记忆被冲突更新时,必须留下审计记录,便于用户回溯:
markdown
## 15:30 | 冲突更新
- 旧: 项目使用 Jest 做测试
- 新: 项目使用 vitest 做测试(已从 Jest 迁移)
- 来源: ses_abc123
- 置信度: 0.99
冲突处理策略:
| 场景 | 处理方式 |
|---|---|
| 新记忆 confidence > 旧记忆 confidence | 直接覆盖 |
| 新记忆 confidence ≈ 旧记忆 confidence | 覆盖(以最新为准) |
| 新记忆 confidence < 旧记忆 confidence | 仍然覆盖,但在 daily log 中标记"低置信度覆盖" |
| 用户显式记忆(confidence=1.0)被自动提取覆盖 | 不覆盖,保留用户显式记忆 |
6.6 用户可控性
Memory 系统必须让用户保持掌控感。用户应该能够完全控制 Agent 记住什么、忘掉什么。
控制能力清单
| 能力 | 实现方式 | 说明 |
|---|---|---|
| 全局开关 | 设置页开关 | 一键开启/关闭整个 memory 功能 |
| 自动提取开关 | 设置页开关 | 单独控制是否自动从对话中提取记忆 |
| 查看记忆 | 设置页 Memory 查看器 | 展示 MEMORY.md 内容,按 section 分组 |
| 编辑记忆 | 设置页编辑器 / 直接编辑文件 | 修改任何记忆条目 |
| 删除记忆 | 设置页删除按钮 / 直接编辑文件 | 删除单条或批量删除 |
| 显式记忆 | 对话中说"记住:xxx" | 直接写入,confidence=1.0,不经过 LLM 提取 |
| 显式遗忘 | 对话中说"忘掉 xxx" | 在 MEMORY.md 中搜索匹配条目并删除 |
| 渠道策略 | 设置页下拉选择 | 为每个渠道配置注入策略 |
| 文件直接编辑 | 任何文本编辑器 | 所有 Markdown 文件用户可直接修改 |
| Bootstrap 状态 | 设置页状态面板 | 展示每个 Bootstrap 文件的大小、修改时间、状态 |
设置页面设计建议
设置页面建议分为 4 个 section:
ini
┌─────────────────────────────────────────┐
│ Memory 设置 │
├─────────────────────────────────────────┤
│ 1. 全局设置 │
│ [✓] 启用 Memory 功能 │
│ [✓] 启用自动提取 │
│ 总 Token 预算: [5000] tokens │
│ │
│ 2. 渠道策略 │
│ 本地桌面: [full ▼] │
│ 飞书: [safe ▼] │
│ 钉钉: [safe ▼] │
│ QQ: [safe ▼] │
│ │
│ 3. Memory 查看器 │
│ ┌─ MEMORY.md ──────────────────┐ │
│ │ ## 语义记忆 │ │
│ │ ### 用户偏好 │ │
│ │ - 偏好函数式风格... [删除] │ │
│ │ ### 项目信息 │ │
│ │ - 项目使用 TypeScript... [删除] │ │
│ │ ... │ │
│ └───────────────────────────────┘ │
│ 文件大小: 2.3 KB | 最后修改: 5 分钟前 │
│ │
│ 4. Bootstrap 文件状态 │
│ IDENTITY.md ✅ 0.2 KB 3 天前 │
│ USER.md ✅ 0.5 KB 1 小时前 │
│ SOUL.md ✅ 0.3 KB 3 天前 │
│ AGENTS.md ✅ 0.8 KB 3 天前 │
│ TOOLS.md ✅ 0.6 KB 2 天前 │
│ HEARTBEAT.md ✅ 0.1 KB 3 天前 │
│ MEMORY.md ✅ 2.3 KB 5 分钟前 │
└─────────────────────────────────────────┘
第七部分:安全与防护
Memory 系统引入了一个新的攻击面:恶意用户可以通过对话注入危险指令到记忆中,这些指令会在后续会话中被注入到 Prompt,影响 Agent 行为。必须在多个层面建立防护。
7.1 威胁模型
| 威胁 | 攻击方式 | 影响 | 防护层 |
|---|---|---|---|
| Prompt 注入 | 在对话中嵌入"忽略之前的指令"等内容,被提取为记忆 | Agent 在后续会话中执行恶意指令 | 写入前检测 |
| 记忆投毒 | 故意提供错误信息,被提取为高置信度记忆 | Agent 在后续会话中给出错误建议 | 置信度机制 + 用户审查 |
| 敏感信息泄露 | 对话中包含密码/密钥,被提取并存储 | 敏感信息持久化到文件 | 提取 Prompt 指令 + 写入前检测 |
| 路径穿越 | 通过构造的文件名访问 config_dir 之外的文件 | 读写任意文件 | 路径安全校验 |
| 渠道泄露 | 外部渠道注入后获取到用户私人记忆 | 隐私泄露 | 渠道注入策略 |
7.2 Prompt 注入检测
在记忆写入前,对每条记忆内容进行注入模式检测:
javascript
const INJECTION_PATTERNS = [
// 英文注入模式
/ignore previous instructions/i,
/ignore all previous/i,
/disregard (all |any )?previous/i,
/forget (all |any )?previous/i,
/you are now/i,
/act as/i,
/pretend to be/i,
/new instructions/i,
/override (all |any )?instructions/i,
/system:\s/i,
/\[INST\]/i,
/\[SYSTEM\]/i,
/<\|im_start\|>/,
/<<SYS>>/,
/\bDAN\b/, // "Do Anything Now" jailbreak
/jailbreak/i,
// 中文注入模式
/忽略之前的指令/,
/忽略所有指令/,
/无视之前的/,
/你现在是/,
/假装你是/,
/扮演/,
/新的指令/,
/覆盖指令/,
/系统提示[::]/,
]
检测流程:
css
记忆条目写入前
→ isInjectionAttempt(content)
→ 遍历 INJECTION_PATTERNS
→ 匹配到任何模式?
├─ 是 → 丢弃该条记忆
│ → 记录安全日志:[SECURITY] Injection attempt blocked: {content}
│ → 不影响其他正常记忆的写入
└─ 否 → 通过,继续写入流程
误报处理:注入检测可能产生误报(如用户讨论 prompt engineering 时提到"ignore instructions")。建议:
- 记录被拦截的内容到安全日志,供用户审查
- 提供"安全日志查看器",用户可以手动恢复被误拦截的记忆
- 不要因为检测到注入就中断整个提取流程------只丢弃可疑条目
7.3 内容 Sanitize
所有写入的记忆内容需要清洗,防止格式注入和内容污染:
javascript
function sanitizeMemoryEntry(entry) {
let content = entry.content
// 1. 移除 HTML 标签(防止渲染注入)
content = content.replace(/<[^>]*>/g, '')
// 2. 移除 Markdown 链接中的可疑 URL
content = content.replace(/\[([^\]]*)\]\(javascript:[^)]*\)/g, '$1')
content = content.replace(/\[([^\]]*)\]\(data:[^)]*\)/g, '$1')
// 3. 截断超长内容(单条记忆不超过 500 字符)
if (content.length > 500) {
content = content.substring(0, 497) + '...'
}
// 4. 移除控制字符(保留换行和制表符)
content = content.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
// 5. 规范化空白字符
content = content.replace(/\s+/g, ' ').trim()
return { ...entry, content }
}
7.4 敏感信息过滤
Memory 系统不应存储敏感信息。防护分两层:
第一层:提取 Prompt 指令
在提取 Prompt 中明确指示 LLM:
vbnet
不要提取:密码、API Key、token、密钥、证书、私钥等敏感信息
第二层:写入前正则检测
javascript
const SENSITIVE_PATTERNS = [
// API Keys & Tokens
/(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S+/i,
/(?:sk|pk|ak)-[a-zA-Z0-9]{20,}/, // OpenAI/Stripe 风格的 key
/ghp_[a-zA-Z0-9]{36}/, // GitHub Personal Access Token
/xoxb-[a-zA-Z0-9-]+/, // Slack Bot Token
// 私钥
/-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
/-----BEGIN CERTIFICATE-----/,
// 常见凭证格式
/Bearer\s+[a-zA-Z0-9._-]+/i,
/Basic\s+[a-zA-Z0-9+/=]+/i,
// 数据库连接字符串
/(?:mysql|postgres|mongodb|redis):\/\/[^\s]+/i,
// PII(可选,按需启用)
/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, // 电话号码
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, // 邮箱
]
检测到敏感信息时:丢弃该条记忆,记录安全日志。
7.5 路径安全
所有文件操作前需要校验路径,防止路径穿越攻击:
javascript
function resolveSafePath(baseDir, relativePath) {
// 规范化路径
const resolved = path.resolve(baseDir, relativePath)
// 确保解析后的路径仍在 baseDir 内
if (!resolved.startsWith(path.resolve(baseDir) + path.sep) &&
resolved !== path.resolve(baseDir)) {
throw new Error(`Path traversal detected: ${relativePath}`)
}
// 禁止符号链接指向 baseDir 外部(可选,更严格)
// const realPath = fs.realpathSync(resolved)
// if (!realPath.startsWith(baseDir)) throw ...
return resolved
}
7.6 渠道隔离
外部渠道(IM 机器人等)不应暴露用户的私人记忆。通过渠道注入策略(第五部分 5.5)实现隔离。
额外安全措施:
- 外部渠道的对话不触发 memory 提取(或使用独立的提取策略)
- 外部渠道的 session memory 与本地 session memory 隔离
- 外部渠道不能触发"显式记忆"和"显式遗忘"指令
- 外部渠道的交互不写入 daily log(或写入独立的 daily log)
7.7 安全检查清单
在上线前,确保以下安全措施全部到位:
- Prompt 注入检测覆盖中英文模式
- 内容 sanitize 处理 HTML、URL、控制字符
- 敏感信息正则检测覆盖常见凭证格式
- 路径穿越防护已实现并测试
- 渠道注入策略已配置,外部渠道默认
safe - 安全日志记录已启用
- 用户可以查看被拦截的记忆
- 原子写入防止文件损坏
- 写入队列防止并发冲突
-
.gitignore已配置,防止敏感文件被提交
第八部分:性能与可观测性
8.1 性能目标
| 操作 | 目标延迟 | 说明 |
|---|---|---|
| Bootstrap 文件加载(冷启动) | < 50ms | 会话创建时一次性加载 |
| Bootstrap 文件检测变更(热路径) | < 5ms | 每轮消息前检查 hash |
| Memory context 构建 | < 30ms | 每轮消息前执行 |
| 记忆提取(LLM 调用) | < 5s | 异步执行,不阻塞对话 |
| 文件写入(单次) | < 10ms | 原子写入 |
| 去重检测 | < 10ms | 倒排索引查询 |
8.2 写入优化
批量写入
一次提取可能产生多条记忆,需要写入多个文件(daily log + MEMORY.md + USER.md 等)。逐个写入会产生多次 I/O,批量写入可以显著减少开销:
javascript
class MemoryBatchWriter {
constructor(memoryIO) {
this._io = memoryIO
this._pending = new Map() // file → [operations]
this._flushTimer = null
this._flushIntervalMs = 100 // 100ms 内的写入合并
}
// 添加写入操作到批次
add(filePath, operation) {
if (!this._pending.has(filePath)) {
this._pending.set(filePath, [])
}
this._pending.get(filePath).push(operation)
// 设置延迟刷新
if (!this._flushTimer) {
this._flushTimer = setTimeout(() => this.flush(), this._flushIntervalMs)
}
}
// 批量执行所有待写入操作
async flush() {
clearTimeout(this._flushTimer)
this._flushTimer = null
const batches = new Map(this._pending)
this._pending.clear()
// 按文件分组,每个文件只写入一次
const writes = Array.from(batches.entries()).map(
async ([filePath, operations]) => {
if (operations.every(op => op.type === 'append')) {
// 多个 append 合并为一次
const combined = operations.map(op => op.content).join('\n')
await this._io.appendFile(filePath, combined)
} else {
// 有 overwrite 操作,取最后一个
const lastWrite = operations.filter(op => op.type === 'write').pop()
if (lastWrite) {
await this._io.writeFile(filePath, lastWrite.content)
}
}
}
)
await Promise.all(writes)
return batches.size // 返回写入的文件数
}
}
收益:减少 I/O 次数 30-50%,特别是在一次提取产生多条记忆时效果显著。
写入队列串行化
所有写入操作通过 Promise 链串行化,避免并发写同一文件:
javascript
class MemoryIO {
constructor() {
this._queue = Promise.resolve()
}
async _enqueue(fn) {
this._queue = this._queue
.then(fn)
.catch(err => console.error('[MemoryIO] Write error:', err))
return this._queue
}
}
8.3 读取优化
文件 Hash 缓存
避免每轮都重新读取和解析未变更的文件:
javascript
class MemoryBootstrap {
constructor() {
this._cache = new Map() // filePath → { hash, content, parsedAt }
}
// 简单的字符串 hash(不需要加密强度)
_simpleHash(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash |= 0 // 转为 32 位整数
}
return hash.toString(36)
}
async loadFile(filePath) {
const content = await fs.readFile(filePath, 'utf-8')
const hash = this._simpleHash(content)
const cached = this._cache.get(filePath)
if (cached && cached.hash === hash) {
return cached.content // 文件未变更,使用缓存
}
// 文件有变更,更新缓存
this._cache.set(filePath, { hash, content, parsedAt: Date.now() })
return content
}
// 快速检测是否有文件变更(不读取完整内容)
async detectChanges() {
const changes = []
for (const [filePath, cached] of this._cache) {
const stat = await fs.stat(filePath).catch(() => null)
if (!stat) continue
// 先用 mtime 快速判断,mtime 变了再读内容算 hash
if (stat.mtimeMs > cached.parsedAt) {
const content = await fs.readFile(filePath, 'utf-8')
const hash = this._simpleHash(content)
if (hash !== cached.hash) {
changes.push(filePath)
this._cache.set(filePath, { hash, content, parsedAt: Date.now() })
}
}
}
return changes
}
}
优化要点:
- 先用
mtime快速判断文件是否可能变更(< 1ms) mtime变了才读取内容计算 hash(避免无效读取)- Hash 不变则使用缓存(避免重复解析)
解析结果缓存
MEMORY.md 解析为结构化数据后缓存,文件未变则复用:
javascript
class MemoryRetriever {
constructor() {
this._parsedCache = null // { hash, sections, entries }
}
async getMemoryEntries(filePath) {
const content = await this._bootstrap.loadFile(filePath)
const hash = this._bootstrap._simpleHash(content)
if (this._parsedCache && this._parsedCache.hash === hash) {
return this._parsedCache // 使用缓存的解析结果
}
// 重新解析
const parsed = MemoryParser.parseMemoryMd(content)
this._parsedCache = { hash, ...parsed }
return this._parsedCache
}
}
8.4 性能监控
建议实现一个轻量级的性能监控模块,持续追踪关键指标:
javascript
class MemoryMonitor {
constructor() {
this._metrics = {
extraction: { count: 0, totalMs: 0, errors: 0, emptyResults: 0, values: [] },
retrieval: { count: 0, totalMs: 0, errors: 0, values: [] },
write: { count: 0, totalMs: 0, errors: 0, values: [] },
dedup: { count: 0, hits: 0 },
}
}
// 记录一次操作的耗时
record(category, durationMs, success = true) {
const m = this._metrics[category]
m.count++
m.totalMs += durationMs
if (!success) m.errors++
m.values.push(durationMs)
// 只保留最近 100 个值(滑动窗口)
if (m.values.length > 100) m.values.shift()
}
// 计算 P50/P95
getPercentile(category, p) {
const values = [...this._metrics[category].values].sort((a, b) => a - b)
if (values.length === 0) return 0
const index = Math.ceil(values.length * p / 100) - 1
return values[index]
}
// 获取汇总报告
getReport() {
const report = {}
for (const [cat, m] of Object.entries(this._metrics)) {
report[cat] = {
count: m.count,
avgMs: m.count > 0 ? Math.round(m.totalMs / m.count) : 0,
p50Ms: this.getPercentile(cat, 50),
p95Ms: this.getPercentile(cat, 95),
errorRate: m.count > 0 ? (m.errors / m.count * 100).toFixed(1) + '%' : '0%',
...(m.emptyResults !== undefined ? { emptyRate: (m.emptyResults / m.count * 100).toFixed(1) + '%' } : {}),
...(m.hits !== undefined ? { dedupHitRate: (m.hits / m.count * 100).toFixed(1) + '%' } : {}),
}
}
return report
}
}
监控指标详解
| 指标 | 计算方式 | 健康阈值 | 告警阈值 | 说明 |
|---|---|---|---|---|
| 提取延迟 P50 | 最近 100 次提取的中位数耗时 | < 2s | > 5s | LLM API 响应时间 |
| 提取延迟 P95 | 最近 100 次提取的 95 分位耗时 | < 4s | > 8s | 长尾延迟 |
| 提取成功率 | 成功次数 / 总次数 | > 95% | < 90% | API 可用性 |
| 提取空结果率 | 空结果次数 / 总次数 | < 50% | > 70% | 提取质量指标 |
| 检索延迟 P50 | 最近 100 次检索的中位数耗时 | < 10ms | > 30ms | 文件读取 + 解析 |
| 检索延迟 P95 | 最近 100 次检索的 95 分位耗时 | < 30ms | > 100ms | 长尾延迟 |
| 写入延迟 P50 | 最近 100 次写入的中位数耗时 | < 5ms | > 20ms | 文件写入性能 |
| 去重命中率 | 被去重过滤的条目 / 总提取条目 | < 30% | > 50% | 过高说明提取质量需优化 |
| Token 使用率 | 实际注入 tokens / 预算上限 | < 80% | > 95% | 持续高位需扩预算 |
8.5 调试模式
开发和排查问题时,需要详细的调试信息。建议提供可开关的调试模式:
javascript
const DEBUG = process.env.MEMORY_DEBUG === 'true'
function debugLog(category, message, data) {
if (!DEBUG) return
console.log(`[Memory:${category}] ${message}`, data ? JSON.stringify(data, null, 2) : '')
}
// 使用示例:
debugLog('extract', 'Extraction prompt:', { prompt, messageCount: messages.length })
debugLog('extract', 'LLM response:', { entries: result.length, raw: result })
debugLog('retrieve', 'Memory context built:', { tokens: estimateTokens(context), sections: Object.keys(sections) })
debugLog('dedup', 'Dedup check:', { candidate: entry.content, matched: existingEntry?.content, similarity })
debugLog('write', 'File written:', { path: filePath, size: content.length, duration: `${ms}ms` })
调试模式输出示例:
css
[Memory:extract] Extraction triggered: { turns: 3, interval: 45000, hasSignal: true }
[Memory:extract] Extraction prompt: { messageCount: 4, existingMemories: 12 }
[Memory:extract] LLM response: { entries: 2, duration: "1.8s" }
[Memory:dedup] Dedup check: { candidate: "项目使用 vitest", matched: "项目使用 Jest", similarity: 0.72 }
[Memory:write] Conflict update: { old: "项目使用 Jest", new: "项目使用 vitest" }
[Memory:write] File written: { path: "MEMORY.md", size: 2341, duration: "3ms" }
[Memory:retrieve] Memory context built: { tokens: 1234, sections: ["session", "semantic", "episodic", "daily"] }
第九部分:演进路线 --- 从 v1 到 v3
9.1 演进总览
MEMORY.md > 50KB
Daily log > 100 个| v2 v2 -->|多设备同步
复杂关联查询
企业合规| v3 style v1 fill:#e1f5ff style v2 fill:#fff4e1 style v3 fill:#e8f5e9
9.2 v1:Markdown 文件闭环(零依赖,先跑通)
目标:用最小成本跑通"写入 → 检索 → 注入"闭环,验证 Memory 对用户体验的提升。
功能清单
| 模块 | 功能 | 优先级 |
|---|---|---|
| 存储层 | .config/ 目录结构 + 7 个 Bootstrap 文件模板 |
P0 |
| 存储层 | Bootstrap 文件首次初始化 + 加载 + 缓存 | P0 |
| 存储层 | 原子写入 + 写入队列串行化 + 路径安全 | P0 |
| 提取 | LLM-based 自动提取(结构化 JSON 输出) | P0 |
| 提取 | 节流策略(最小轮次 + 最小间隔 + 重要信号检测) | P0 |
| 提取 | 提取分流(profile/personality/tool/memory) | P0 |
| 检索 | 直接读文件 + token 预算裁剪 | P0 |
| 检索 | 两阶段注入(静态 Bootstrap + 动态记忆) | P0 |
| 安全 | Prompt 注入检测 + 内容 sanitize | P0 |
| 安全 | 敏感信息过滤 | P0 |
| 去重 | 倒排索引去重 | P1 |
| 生命周期 | Session 落盘 + daily log 过期清理 | P1 |
| 生命周期 | 冲突审计记录 | P1 |
| 监控 | 基础性能监控(延迟、成功率) | P1 |
| 设置 | Memory 全局开关 + 自动提取开关 | P1 |
| 设置 | 渠道注入策略配置 | P1 |
| 错误恢复 | 自动降级 + 自动恢复 | P1 |
| 批量写入 | 写入操作合并 | P2 |
| 来源追溯 | 每条记忆带 source/extractedAt 元数据 | P2 |
技术选型
| 决策点 | 选择 | 理由 |
|---|---|---|
| 存储格式 | Markdown 文件 | 人类可读、零依赖、LLM 友好 |
| 提取方式 | LLM API 调用 | 语义理解能力强,能判断重要性和分类 |
| 检索方式 | 直接读文件 + 解析 | v1 数据量小,直接读文件性能足够 |
| 去重算法 | 倒排索引 + 关键词相似度 | 不需要 embedding,零额外依赖 |
| Token 估算 | 字符级估算(中英文分别计算) | 精度足够,不需要 tokenizer 依赖 |
代码架构建议
bash
src/lib/memory/
├── memory-constants.js # 配置常量、token 估算、预算截断
│ # - 所有魔法数字集中管理
│ # - estimateTokens() / truncateToTokenBudget()
│
├── memory-bootstrap.js # Bootstrap 文件初始化、加载、缓存
│ # - 7 个文件的模板定义
│ # - initialize() / loadAll() / detectChanges()
│ # - 文件 hash 缓存 + 热更新
│
├── memory-extractor.js # LLM 提取、节流、降级、分流
│ # - 提取 Prompt 构建
│ # - 节流逻辑(minTurns/minInterval/重要信号)
│ # - 提取结果分流(profile/personality/tool/memory)
│ # - 错误恢复(自动降级 + 自动恢复)
│
├── memory-retriever.js # 检索、组装、token 预算裁剪
│ # - buildMemoryContext()
│ # - 两阶段注入(静态 + 动态)
│ # - 优先级排序 + 预算裁剪
│
├── memory-parser.js # MEMORY.md / daily log 解析
│ # - parseMemoryMd() / parseDailyLog()
│ # - 格式容错(宽进严出)
│ # - 中英文 section 别名匹配
│
├── memory-safety.js # 注入检测、sanitize
│ # - isInjectionAttempt()
│ # - sanitizeMemoryEntry()
│ # - wrapMemoryContext()(安全标签包裹)
│
├── memory-settings.js # 设置管理
│ # - enabled / autoExtract 开关
│ # - 渠道策略配置
│
├── memory-deduplicator.js # 倒排索引去重
│ # - 建立索引 / 查询相似条目
│ # - 相似度阈值管理
│
├── memory-consolidator.js # 整理、清理、晋升
│ # - session 落盘
│ # - daily log 过期清理
│ # - session → MEMORY.md 晋升
│
├── memory-batch-writer.js # 批量写入
│ # - 写入操作合并
│ # - 延迟刷新
│
├── memory-monitor.js # 性能监控
│ # - P50/P95 延迟
│ # - 成功率、去重命中率
│
├── session-memory.js # 会话内存态管理
│ # - add / evict / markPromoted
│ # - toMarkdown()(落盘格式化)
│
└── index.js # 统一导出
v1 的验证标准
v1 上线后,通过以下指标验证 Memory 系统的价值:
| 指标 | 衡量方式 | 目标 |
|---|---|---|
| 用户重复交代率 | 统计用户在新会话中重复说明背景的频率 | 下降 50%+ |
| 提取准确率 | 抽样检查提取结果的准确性 | > 80% |
| 注入相关性 | 抽样检查注入的记忆是否与当前对话相关 | > 70% |
| 性能影响 | 对话首 token 延迟增加量 | < 100ms |
| 用户满意度 | 用户反馈 | 正面 > 负面 |
9.3 v2:索引层 + 向量检索
目标:当 memory 量增长到文件直读性能不足时,引入索引加速和语义检索。
进入信号
出现以下任一信号时,考虑启动 v2:
| 信号 | 阈值 | 检测方式 |
|---|---|---|
| MEMORY.md 文件大小 | > 50KB | 监控文件大小 |
| Daily log 文件数量 | > 100 个 | 监控文件数量 |
| 检索延迟 P95 | > 200ms | 性能监控 |
| 检索召回率不足 | 用户反馈"记不住" | 用户反馈 |
| 去重误判率 | > 10% | 抽样检查 |
新增能力
| 能力 | 实现方式 | 说明 |
|---|---|---|
| 索引层 | IndexedDB(浏览器/Electron)或 SQLite | Markdown 仍为真相源,DB 只做索引 |
| 向量检索 | 本地 embedding 模型(如 all-MiniLM-L6-v2) | 语义相似度检索 |
| 混合检索 | 70% 向量 + 30% BM25 关键词 | 兼顾语义和精确匹配 |
| 分块索引 | 400 token chunk / 80 token overlap | 长文档分块后独立索引 |
| 增量索引 | 内容 hash 去重,只索引变更部分 | 避免全量重建索引 |
| 时间衰减 | Time-weighted index | 检索时自动降权过时内容 |
| 冲突检测增强 | LLM 辅助判断新旧记忆是否冲突 | 比关键词匹配更准确 |
架构变化
css
v1 架构:
Markdown 文件 → 直接读取 → 解析 → 注入
v2 架构:
Markdown 文件(真相源)
↓ 写入时同步更新
IndexedDB/SQLite(索引层)
↓ 存储:文本 + embedding 向量 + 元数据
↓ 检索时查询
混合检索(向量 + BM25)
↓ 返回 top-K 结果
token 预算裁剪 → 注入
MemoryProvider 接口设计
v2 通过 Provider 接口实现存储层可插拔:
javascript
// 抽象接口
class MemoryProvider {
async initialize(config) {}
async writeMemory(entry) {}
async searchMemory(query, options) {}
async deleteMemory(id) {}
async getMemoryById(id) {}
async listMemories(filter) {}
async rebuildIndex() {} // 从 Markdown 重建索引
}
// v1 实现:直接读文件
class FileMemoryProvider extends MemoryProvider {
async searchMemory(query, options) {
// 读取 MEMORY.md → 解析 → 关键词匹配 → 返回
}
}
// v2 实现:IndexedDB 索引 + 向量检索
class IndexedDBMemoryProvider extends MemoryProvider {
async searchMemory(query, options) {
// 1. 向量检索:query → embedding → cosine similarity top-K
// 2. BM25 检索:query → 关键词 → 全文搜索 top-K
// 3. 混合排序:0.7 * vector_score + 0.3 * bm25_score
// 4. 返回 top-N
}
}
9.4 v3:GraphRAG + 云端 + 多 Agent
目标:企业级记忆能力,支持复杂关联查询、多设备同步、多 Agent 协作。
进入信号
| 信号 | 说明 |
|---|---|
| 多人/多设备使用同一 Agent | 需要云端同步 |
| 需要跨 Agent 知识共享 | 多个 Agent 间共享项目信息 |
| 复杂关联查询 | "找出所有与 xxx 相关的决策" |
| 企业合规要求 | 记忆审计、访问控制 |
新增能力
| 能力 | 实现方式 | 说明 |
|---|---|---|
| GraphRAG | 知识图谱(Neo4j / NebulaGraph) | 实体关系建模,支持关联查询 |
| 云端同步 | CloudMemoryProvider | 多设备记忆同步 |
| 多 Agent 共享 | 共享记忆空间 + 私有记忆空间 | 项目信息共享,个人偏好私有 |
| 记忆审计 | 审计日志 + 访问记录 | 合规要求 |
| RBAC | 基于角色的记忆访问控制 | 不同角色看到不同记忆 |
| 智能提取策略 | 多策略选择器 | 根据对话类型选择最优提取策略 |
| 记忆优先级 | 动态优先级调整 | 基于使用频率和反馈自动调整 |
实体关系建模示例
css
GraphRAG 将记忆组织为实体和关系:
[用户: 老王] --偏好--> [风格: 函数式]
| |
|--使用--> [项目: MyApp]
| |
| --技术栈--> [TypeScript]
| |
| --技术栈--> [React 18]
| |
| --决策--> [认证: JWT]
| |
| --替代--> [认证: Session](已废弃)
|
|--解决过--> [Bug: OOM]
|
--原因--> [未关闭数据库连接]
这种结构支持复杂查询:
- "老王在 MyApp 项目中做过哪些技术决策?"
- "哪些 bug 与数据库相关?"
- "项目的技术栈有哪些变更历史?"
9.5 演进原则
-
每个阶段都是完整可用的
- 不要为了 v2 的能力在 v1 做半成品
- v1 上线就能给用户带来价值
- v2 是在 v1 稳定运行后的增强,不是 v1 的补丁
-
通过接口解耦
- 预留 MemoryProvider 接口
- v2/v3 通过实现新 Provider 扩展,不改核心逻辑
- 提取、检索、注入三个环节独立演进
-
Markdown 始终是真相源
- 即使引入数据库,Markdown 文件仍然是最终权威
- 数据库丢失可以从 Markdown 重建,反之不行
- 用户始终可以直接编辑 Markdown 文件
-
按信号升级,不按时间
- 没有性能瓶颈就不要过早优化
- 没有用户反馈就不要过早加功能
- 每次升级都应该有明确的触发信号和预期收益
-
向后兼容
- v2 必须能读取 v1 的 Markdown 文件
- v3 必须能读取 v1/v2 的数据
- 升级过程不丢失任何已有记忆
附录
A. 核心模块参考架构
bash
src/lib/memory/ # 核心模块(~2900 行)
├── memory-constants.js # 配置常量、token 估算
├── memory-bootstrap.js # Bootstrap 文件管理
├── memory-extractor.js # LLM 提取引擎
├── memory-retriever.js # 检索与注入
├── memory-parser.js # Markdown 解析器
├── memory-safety.js # 安全防护
├── memory-settings.js # 设置管理
├── memory-deduplicator.js # 去重引擎
├── memory-consolidator.js # 生命周期管理
├── memory-batch-writer.js # 批量写入
├── memory-monitor.js # 性能监控
├── session-memory.js # 会话记忆
└── index.js # 统一导出
io/ # I/O 层(~230 行)
├── memory-io.js # 文件 I/O(原子写入、队列、路径安全)
└── memory-config.js # 配置持久化
hooks/ # 框架集成层
└── use-memory.js # React Hook(或其他框架的适配层)
pages/settings/ # UI 层
└── MemorySettingsPage.jsx # 设置页面
__tests__/ # 测试(~1000 行)
├── memory-constants.test.js # token 计数、预算截断
├── memory-parser.test.js # Markdown 解析
├── memory-safety.test.js # 注入检测、误报率
└── session-memory.test.js # 会话记忆管理
B. 业界方案详细对比
OpenClaw
| 维度 | 详情 |
|---|---|
| 存储 | Markdown 文件 + SQLite(向量 + FTS5 全文检索) |
| 记忆分层 | 三层:Ephemeral(daily log)、Durable(MEMORY.md)、Session(transcript) |
| 提取方式 | Agent 在对话中隐式写入 daily log,定期整理到 MEMORY.md |
| 检索方式 | memory_search + memory_get 两步检索 |
| 分块策略 | 400 token chunk / 80 token overlap / SHA-256 去重 |
| 混合检索 | 70% 向量 + 30% BM25 关键词 |
| 特色 | Heartbeat 机制(定期自主整理)、7 个 Bootstrap 文件 |
| 适用场景 | 桌面 AI 助手、开发者工具 |
Mem0
| 维度 | 详情 |
|---|---|
| 存储 | 向量数据库(Qdrant/Pinecone/ChromaDB) |
| 记忆分层 | 用户级 + 会话级 |
| 提取方式 | 自动提取 + 用户画像动态维护 |
| 检索方式 | 语义检索(embedding cosine similarity) |
| 特色 | 用户画像自动提取与更新、SaaS 开箱即用 |
| 适用场景 | SaaS 产品、需要用户画像的场景 |
Zep
| 维度 | 详情 |
|---|---|
| 存储 | PostgreSQL + pgvector |
| 记忆分层 | 事实(Facts)+ 对话历史(Messages)+ 摘要(Summaries) |
| 提取方式 | 自动摘要 + 实体提取 + 事实提取 |
| 检索方式 | 混合检索(向量 + 关键词 + 元数据过滤) |
| 特色 | 企业级 RBAC、多租户、审计日志 |
| 适用场景 | 企业级 Agent、需要权限控制的场景 |
Letta (MemGPT)
| 维度 | 详情 |
|---|---|
| 存储 | 分层存储(核心记忆 + 归档记忆 + 外部存储) |
| 记忆分层 | Core Memory(固定上下文)+ Archival Memory(无限存储)+ Recall Memory(对话历史) |
| 提取方式 | Agent 自主决定何时读写记忆(通过 function calling) |
| 检索方式 | Agent 自主发起检索请求 |
| 特色 | 最接近人类记忆模型,Agent 完全自主管理 |
| 适用场景 | 研究项目、需要高度自主性的 Agent |
方案选型决策树
markdown
你的场景是什么?
│
├─ 桌面应用 / 本地优先
│ └─ 推荐:本文方案(Markdown First)
│ 理由:零依赖、隐私友好、用户可审查
│
├─ SaaS 产品 / 需要用户画像
│ └─ 推荐:Mem0
│ 理由:开箱即用、用户画像能力强
│
├─ 企业级 / 需要权限控制
│ └─ 推荐:Zep
│ 理由:RBAC、多租户、审计
│
└─ 研究项目 / 高度自主 Agent
└─ 推荐:Letta (MemGPT)
理由:Agent 自主管理记忆,最灵活
C. 完整提取 Prompt 模板
markdown
你是一个记忆提取助手。从以下对话中提取值得长期记住的信息。
## 提取规则
1. 只提取明确的事实、偏好、决策,不提取闲聊和临时讨论
2. 每条记忆用一句话概括,不超过 100 字
3. 内容必须自包含------脱离对话上下文也能理解其含义
4. 标注认知类型:
- semantic:稳定的知识和事实(用户偏好、项目信息、编码规范)
- episodic:具体的事件(重要决策、里程碑、问题解决)
5. 标注置信度(0-1):
- 0.9-1.0:用户明确说的("我们用 xxx"、"以后都 xxx"、"记住 xxx")
- 0.7-0.8:可以合理推断的(用户多次使用某工具,可推断为偏好)
- 0.5-0.6:不太确定的(可能是临时选择,不一定是长期偏好)
6. 标注持久级别:
- long_term:稳定的长期知识,值得写入永久记忆
- daily:当天的活动记录,不一定长期有价值
- session:仅当前会话有意义的临时信息
7. 冲突检测:如果新信息与已有记忆冲突,在 conflict_with 中标注旧内容原文
8. 不要提取:
- 密码、API Key、token、密钥等敏感信息
- 一次性的调试过程、临时的报错信息
- 纯粹的闲聊、寒暄、感谢
9. 不值得记忆的对话返回空数组 []
## 类型路由
根据内容性质路由到不同文件:
- 关于用户个人信息(名字、角色、技术栈、工作经验)→ type: "profile"
- 关于回复风格、交互偏好(简洁/详细、语言、格式)→ type: "personality"
- 关于工具、命令、环境配置(IDE、包管理器、启动命令)→ type: "tool"
- 其他所有(项目信息、编码规范、决策、事件)→ type: "memory"
## 分类参考
category 可选值:
- user_preference:用户个人偏好
- project_info:项目基本信息
- coding_convention:编码规范和约定
- tech_stack:技术栈信息
- decision:重要的技术或产品决策
- milestone:项目里程碑
- problem_solution:问题和解决方案
- workflow:工作流程和习惯
## 已有记忆(用于冲突检测)
{existing_memories}
## 对话内容
{conversation}
## 输出格式
返回 JSON 数组,每个元素格式:
{
"content": "简洁的一句话描述",
"memory_type": "semantic|episodic",
"category": "分类",
"durability": "long_term|daily|session",
"confidence": 0.0-1.0,
"conflict_with": null 或 "旧内容原文",
"type": "memory|profile|personality|tool"
}
只返回 JSON 数组,不要其他内容。不值得记忆时返回 []。
D. Bootstrap 文件完整模板
IDENTITY.md
markdown
# Agent 身份
- 名字:(你的 Agent 名称)
- 类型:AI 助手
- 风格:专业、友好、高效
- Emoji:(可选,如 🤖)
## 自我介绍
<!-- 当用户问"你是谁"时使用 -->
我是 [名字],你的 AI 助手。我可以帮你编写代码、解答问题、管理项目。
USER.md
markdown
# 用户画像
## 基本信息
- 名字:
- 称呼偏好:
- 时区:Asia/Shanghai
- 语言偏好:中文
## 自动提取
<!-- 由 memory-extractor 自动维护,勿手动删除此 section -->
<!-- 每条带 extracted 时间戳和来源 session -->
## 备注
<!-- 用户可手动添加任何补充信息 -->
SOUL.md
markdown
# 人格与边界
## 回复风格
- 回复简洁直接,不绕弯子
- 需要时主动提问澄清
- 默认使用中文回复
## 行为边界
- 不在外部消息渠道发送未完成的流式回复
- 不主动执行破坏性操作
- 遇到不确定的情况,先确认再行动
AGENTS.md
markdown
# 工作区操作指南
## Memory 约定
- 长期稳定事实写入 MEMORY.md
- 每日活动记录写入 daily log
- 不存储密码、token、敏感凭证
## 安全默认值
- 不执行破坏性命令,除非用户明确要求
- 聊天中保持简洁,长内容写入文件
## 工作区特定规则
<!-- 用户可自定义补充 -->
TOOLS.md
markdown
# 工具与环境笔记
## 项目命令
- 启动开发:`npm run dev`
- 运行测试:`npm test`
- 构建:`npm run build`
## 环境信息
- Node 版本:
- 包管理器:
- IDE:
## 常用路径
- 配置文件:
- API 入口:
HEARTBEAT.md
markdown
# 定期任务
# 以下任务在应用启动或空闲时自动执行:
- 检查 memory/ 下过期文件(> 30 天),提示清理
- 检查 MEMORY.md 是否有重复条目
- 检查 sessions/ 下过期文件(> 7 天),提示清理
# 留空此文件可跳过所有定期任务
MEMORY.md
markdown
# 长期记忆
## 语义记忆
### 用户偏好
<!-- 用户的个人偏好和习惯 -->
### 项目信息
<!-- 项目的基本信息和技术栈 -->
### 编码规范
<!-- 项目的编码约定和规范 -->
### 常见问题
<!-- 反复出现的问题和解决方案 -->
## 情景记忆
### 重要决策
<!-- 重要的技术和产品决策 -->
### 里程碑
<!-- 项目的重要里程碑 -->
E. 测试策略
单元测试重点
| 模块 | 测试重点 | 关键用例 |
|---|---|---|
| memory-constants | token 估算准确性 | 中英文混合文本、纯中文、纯英文、空字符串 |
| memory-parser | Markdown 解析正确性 | 标准格式、缺少元数据、格式不规范、空文件 |
| memory-safety | 注入检测准确性 | 已知注入模式、正常内容(误报率)、边界情况 |
| session-memory | 状态管理正确性 | add/evict/markPromoted、容量上限、LRU 淘汰 |
| memory-deduplicator | 去重准确性 | 完全相同、高度相似、完全不同、阈值边界 |
集成测试重点
| 场景 | 验证内容 |
|---|---|
| 完整写入链路 | 对话 → 提取 → 分流 → 写入文件 → 文件内容正确 |
| 完整读取链路 | 读取文件 → 解析 → 预算裁剪 → 注入 Prompt → 格式正确 |
| 冲突更新 | 新记忆覆盖旧记忆 → MEMORY.md 更新 → daily log 记录审计 |
| 错误恢复 | LLM API 故障 → 自动降级 → 恢复后自动恢复 |
| 并发安全 | 多个写入同时发生 → 文件不损坏 → 内容完整 |
本文档基于生产级 AI Agent Memory 系统的实践经验提炼,结合业界主流方案(OpenClaw、Mem0、Zep、Letta)的调研分析。适用于任何需要为 AI Agent 构建本地记忆能力的项目。