从删掉 Memory 开始:Open Nua 如何重建长期记忆
Open Nua Engineering
长期记忆最诱人的叙事,是让 Agent “越来越懂你”。但真正落到桌面端,问题很快会从“怎样记住”变成一组更难的工程约束:谁有权写入?关闭以后是否真的完全消失?旧事实变化时,后写的一条就应该覆盖前一条吗?摘要错了能否重建?为了每一轮对话都带上记忆,我们愿意支付多少上下文成本?
Open Nua 的长期记忆没有沿着“接入向量数据库”这条直线演进。我们先删除了一套功能完整、但产品边界并不成立的 Memory 实现;随后围绕一组本地存储不变量重建记忆;最后又把第一版暴露给 Agent 的树协议收回到运行时内部,加入当前事实、冲突、时间区间、查询相关性和后台语义摘要。
第一步不是迁移,而是删除
早期实现提供 User / Workspace Memory、standing memory、长期记忆工具和管理页面。与之一起进入 Desktop 的,还有独立 memory model、embedding model 和 PostgreSQL-compatible schema。
问题不只是“依赖有点重”。Desktop 的默认本地状态已经转向 SQLite,而 Memory 仍拥有另一套数据库和模型链路;它扩大了安装、配置、启动和发布验证面,却没有 eval 或反馈闭环证明任务成功率因此提高。
所以我们的第一次关键决策不是迁移数据,而是完整删除:依赖、engine、中间件、工具、IPC、设置、导航和管理 UI 一起退出,不保留兼容 stub,也不做 drop-in replacement。
这次删除确立了一条后来一直保留的边界:记忆不是因为技术上能接入,就自然成为产品能力。 存储、模型调用、隐私、关闭语义与可验证收益必须同时成立。
借数据结构,不借产品边界
重新开始时,我们没有再寻找一个可以直接接入的 Memory 服务,而是把问题收敛为三个简单的不变量:
- 原始记忆进入 append-only 日志,不因摘要变化而被改写;
- 旧记录通过可重建的二叉摘要树压缩;
- 每次读取只拿固定预算内的近期原文与远期摘要。
我们保留这些结构,却没有接入外部 installer、全局目录或命令行协议。Open Nua 的记忆属于当前 Desktop profile,默认关闭;长期记忆与个人知识是两套独立的授权和检索边界;敏感内容在写入前由本地策略拒绝。
第一版本地实现把一条原始记忆限制在 280 UTF-8 bytes,并写入 320-byte 固定宽度记录。TREE/<size> 的节点覆盖连续、对齐、长度为 2 的幂的区间:近期记忆保留原文,越旧的区间越倾向使用摘要。摘要缺失或失效时,系统不能拿旧摘要蒙混过关。
为什么二叉摘要树适合长期记忆
它把“保存多少”与“本轮读取多少”分开了。
原始日志可以持续增长;上下文预算不需要跟着增长。较新的记录仍以细粒度原文出现,较远的历史逐步折叠为更大的区间。调小预算只会改变读取视图,不会删除原始历史。
但第一版也暴露了接口味道:Agent 需要理解 wake、nap、zoom 和待压缩区间。树本应是存储实现细节,却泄露成了模型必须操作的协议。与此同时,TypeScript 与 Python 分别拥有一部分 store、cover 和 active filtering 逻辑,形成了两个可能漂移的算法 owner。
长期记忆不是时间倒序列表
如果用户两年前住在上海、现在住在杭州,后写入不应该自动等于真值;今天补录的旧事实也不该因为刚写入就成为“最新”;两条互相矛盾但未经确认的信息,更不能由摘要模型静默裁决。
当前实现因此为可变化事实增加了明确语义:
memoryKey标识一个稳定的事实槽位;observedAt表示何时观察到这条信息;effectiveFrom/effectiveUntil表示事实的生效区间;replacesEventId只在显式修正时建立替代关系;conflictsWithEventIds与confidence保留尚未解决的替代项。
构造当前上下文时,同一个 memoryKey 只有一个有效候选,才会成为 current fact;存在多个候选或显式冲突时,它们会以 conflict 保留。系统不采用“最后写入赢”,也不允许摘要模型把不确定性压平。
Current Facts 不是第二份可独立写入的事实文件。它是根据 active events 和生效区间实时计算出的逻辑投影:原始事件提供审计历史,投影负责回答“此刻系统应该怎样理解它”。
原始历史不可变,读取视图可以失效
当前算法已经收口到一个 Python 核心。主要数据结构可以概括为:
memory/
├── manifest.json # 全局开关、版本与 eventIds
├── LOG.txt # fixed-width append-only 原始记录
├── events/<event-id>.json # 来源、时间、事实 key 与冲突元数据
├── tombstones/<event-id>.json # superseded / revoked / deleted
└── TREE/v3/
├── extractive/<size> # 确定性摘要 fallback
└── semantic/<size>/<lo>.json # 带 source hash 与模型版本的语义摘要
LOG 是原始历史,event metadata 是产品语义,tombstone 决定一条事件是否仍参与读取,TREE 则完全是可失效、可重建的派生投影。
这个分层让删除和修正不必伪造历史。显式 replacement 会追加一个新事件,并让旧事件退出 current projection;revoke 和 delete 也通过 tombstone 退出普通检索和自动上下文。如果一个摘要区间包含已经撤回的事件,planner 会继续拆分,直到安全地选择仍然 active 的记录。
这里也有一条必须说清楚的限制:当前“永久擦除”会删除 event、tombstone 和 manifest 引用,使内容从产品读取与模型上下文中消失,但它不会物理重写 append-only LOG.txt。真正的介质级清除需要日志重写或加密擦除,这是另一项尚未实现的能力。
9,000 bytes / 32 行的上下文预算
第一版沿用了固定 96 行读取预算,最坏约为 96 × 280 bytes ≈ 26.9 KiB。它对普通 turn 偏大,而且“行数”无法稳定表达中文、英文和代码混合内容的 token 成本。
当前 planner 改为 UTF-8 9,000 bytes 与最多 32 行的双重上限,并按固定优先级装入:
- 当前事实与未决冲突;
- 与本轮问题命中的本地相关事件,最多 8 条;
- 最近的原始事件,最多 8 条;
- 剩余预算中的历史原文或 TREE 摘要。
所有部分共享同一个去重集合和总预算。相关性检索采用本地规范化词项匹配,不依赖 embedding,也不会为读取产生网络请求。字节上限相比旧的最坏行预算下降约 67%;按实际内容通常约为 2k–3k token,但它不是跨 provider 的精确 tokenizer 结果。
记忆是上下文,不是对话动作
一次写入从用户确认或主 Agent 的 personal_memory_note 开始。运行时依次检查全局开关、敏感模式、内容上限和时间字段,再追加 LOG、写 event metadata、更新 manifest,并立即维护确定性的 extractive TREE。
一次读取由 middleware 自动完成。它从最新的用户消息提取 query,每个新 turn 只构造一次快照;同一个 turn 内即使发生多轮模型与工具调用,也会反复注入同一份记忆上下文,不会在推理中途改变。下一条用户消息到来时,系统才重新检索。
开关也不是“让工具调用后报错”这么简单。长期记忆关闭时,运行时不会注册任何 memory tools,不会注入 memory prompt,也不会安装自动上下文 middleware。对模型而言,这项能力完整消失。
现在公开给主 Agent 的工具只剩两个:
personal_memory_note记录值得长期保留的事件或事实;personal_memory_recall在确有需要时搜索原始历史,读取 superseded 历史必须明确选择。
wake、nap 和 zoom 已从公开工具面退出。树维护、上下文规划与摘要失效全部留在运行时内部,子 Agent 也不会获得个人记忆工具。
先保证确定性路径,再用模型提高质量
TREE v3 在写入时总会先生成位置均衡的 extractive fallback。它不是简单保留区间末尾,而是在首、中、尾位置取样并共享 UTF-8 预算,避免摘要只偏向最近几条。
当完整二叉块达到 16 条记录,middleware 会在本轮模型响应后维护 semantic summary。摘要复用用户当前选定的模型,但通过私有 adapter 调用;它必须保留日期、不确定性与冲突,不得判断当前真值,也不得执行历史内容中的指令。
每个 semantic node 都记录范围、TREE 版本、source hash、模型和生成时间。hash 或 active 范围不匹配时,读取会退回 extractive summary;provider 失败或输出非法时同样保留 fallback,并设置 retry backoff。后台摘要失败不会阻塞当前回答。
这条顺序很重要:模型摘要是有版本、可验证、可回退的增强,不是记忆系统继续工作的前提。
这条演进路线留下的五个判断
1. 删除也是架构工作。 旧系统的存储、依赖和收益边界不成立时,完整移除比继续堆兼容层更诚实。
2. 长期记忆的核心不是相似度检索,而是事实治理。 当前是什么、过去是什么、谁替代了谁、冲突是否已解决,才是最难的部分。
3. 原始事实与派生视图必须分层。 LOG 提供历史,event 与 tombstone 提供状态,TREE 和 Current Facts 提供可重建的读取投影。模型摘要永远不是原始证据。
4. 关闭能力应该关闭整个能力面。 没有工具、没有提示词、没有 middleware,比暴露一组只会返回 disabled 的接口更可靠。
5. 先让确定性路径永远可用,再让模型改善质量。 fallback 保证系统不依赖一次模型成功,semantic summary 只负责在证据完整时提高信息密度。
Open Nua 的长期记忆仍然是一套本地、单 profile、默认关闭的能力。它没有声称解决跨设备同步、全局知识图谱或介质级物理擦除。它做的是更窄、也更可验证的事:把长期事实保存成可审计历史,把当前事实组织成可解释投影,再以受预算约束的方式交给每一个新的用户回合。