记忆子系统:机制图解

一段对话怎么变成磁盘上的主题文件,写到一半失败会怎样,会话分叉之后靠什么认出哪些已经写过, 每次会话都注入的那一小块记忆归谁维护。 正文在 docs/reference/design/memory/overview.md,第 04 节的完整设计在 written-marker.md,这页只画机制。 别家怎么做的、我们的选择落在哪一格,在 记忆写入:八个框架横向对比

在跑的

两个写入入口、五步写入、暂存事务、夜间整理、沉默即成功的返回值契约, 以及第 05 节的常驻块派生视图:core.mdtopics/core.md的渲染结果, 预算不再拒事务。

定稿了还没落地

第 04 节的写入游标:跑着的仍是位置游标,分叉之后整条分支漏写, 或者只写下一个从对话中间开始的片段。要走的是在节点上打「已写」标记, 从 head 往回走到第一个有标记的为止。

接口留着没接线

name is_available initialize shutdown extract_before_discard:五个方法运行时没有任何调用方。

01 一条对话怎么变成长期记忆 02 写入的五个步骤 03 事务:改在暂存,验过才装 04 游标:身份不是位置 05 常驻块:谁在维护它 06 接口的九个方法 07 失败会怎样 08 还没解决的

01一条对话怎么变成长期记忆

两个入口,一段写入逻辑。两条路的区别只有 force 这一个开关:一个够了才写,一个剩多少写多少。

一条对话怎么变成长期记忆 对话不在写入时缓存在进程里。每一次写入都重新从会话存储读回来,所以进程重启不丢。 会话存储 持久、有序,每条消息一个稳定 id 用户 助手 用户 助手 用户 助手 get_branch(session_id) 沿 predecessor 边往回走, 拿回 head 那条分支,按顺序 只有人说的和助手答的算数。工具调用和结果、runtime 自己排的轮次(子 agent 回报、分支合并提示)、空消息,都不进记忆。 入口一 · 每轮结束 agent/dispatcher write(session_id=…) force=False 不传消息,自己去会话存储读 每轮都调,多数时候什么也不做 入口二 · 会话闲下来 memory/session_watcher write(messages, …) force=True 5 分钟扫一次,30 分钟没动静算闲 剩多少写多少,不管够不够一批 每天 03:00 · 整理 memory/scheduler reorganize(model=…) 不写新内容,只重排已经落盘的 writing.write() 两条路在这里汇合 force=False 走一趟就回来。不到阈值 就一趟也不写。 force=True while 还欠着:一趟接一趟, 直到不欠为止。 会话结束后没有下一批, 停在半路就永远补不上。 write_session() 一趟 五步,见第 02 节 1 挑欠账 · 游标没记过的 2 分批 · 累加到 16k token 3 调模型 · 在暂存里改文件 4 整理 · 攒够了就重排 5 提交 · 验过才整体安装 记忆库 <state>/memory/ topics/ 主题文件。模型只改这里 sources/ 原文存档,只增不改 core.md 每次会话都注入 timeline/ 等三处 派生视图,写完就重建 .scriptorium/ 游标、写锁、写手历史 写成一次,就 commit 一版 force 时:还欠着就再来一趟 整理不走写入这条路:它只重排 topics/,不动 sources/,也不推游标
怎么读:左边三个入口,中间是它们共用的那段逻辑,右边是磁盘。 绿色那条每轮都触发,但只有攒够 16k token 才真的写;蓝色那条在会话闲置后触发,把剩下的全部写完,所以它要循环。 紫色那条不产生新内容。
为什么每轮那次不传消息:会话存储本来就是持久有序的,从它读回来的每条消息都有稳定 id, 进程里再缓一份只会在重启时丢掉,而且给出的位置每次运行都不一样。

02写入的五个步骤

一趟里发生的事。阈值只回答"值不值得叫一次模型",不回答"一次写多少",所以跑了一天的会话要走好几趟。

1 挑欠账 分支上的每一条 减去游标记过的 = 这个会话还欠着的 runtime.pending() 顺手滤掉工具调用 和 runtime 自排的轮次 2 分批 从头一条条累加 够 16k token 就切 切下来的是这一批 剩的留给下一趟, 一次模型调用装不下 一整天的对话 3 调模型改主题文件 写手拿到这一批原文 和当前的目录结构 用文件工具改 topics/ 改的是暂存目录里的 拷贝,见第 03 节。跑在 用户自己的登录上 4 整理 攒够 5 批或 4 万 token → 重排一次 距上次全局整理满一天 → 再重排一次 同一个动作,两个触发 条件。都不满足就跳过 5 事务提交 校验通过 → 整体安装 不通过 → 整批丢弃 装进去了才推游标 这一批的原文在调模型 之前就已经存档,所以 存档永远不落后于游标 整趟持一把跨进程写锁,等 1 秒拿不到就放弃:正在聊天的会话要写记忆是常事,让用户等不如下一轮再来。

03事务:改在暂存,验过才装

模型不直接改记忆库。它改的是一份临时拷贝,只有整份验过才被整体安装;验不过整份丢掉,记忆库一个字节没动。

一次写入事务的全过程 记忆库 topics/ sources/ core.md 事务期间它是只读的 参照物,谁也不在这里 直接改 整份拷贝 暂存目录 系统临时目录里新建 topics/ 可写。写手这一轮的每次编辑都落这儿 sources/ chmod 0444 文件系统这一层就拒写,不等到最后才发现 基准线在拷贝之前从记忆库读:拷完再读,等于 拿这次编辑去量它自己,丢掉的块 ID 会像从没存在过 四道校验 1 sources/ 的指纹没变 原文存档只增不改 2 改之前的块 ID 一个不少 段落可以改写、合并、搬家 3 主题间的链接指到 #^块 ID 4 引用的来源真的存在 core.md 不超额,块链接不悬空 通过 · 整体安装 现有的先挪进备份 暂存整份装入 重建派生视图,commit 不通过 · 整批丢弃 暂存目录重新拷一份 记忆库保持原样 抛出被拒的原因 被驳回之后:只有一次修复机会 第一次驳回 把被拒的原文,加上一句针对这条 规则的具体改法,发回写手重做 工作区已经回到这一轮之前的样子 重做一遍 同样在暂存里改,同样四道校验 基准线在修复之前重新取一次:改完再取会去 解析模型刚写坏的文本 第二次还驳回 → 这一批彻底不写 报 COMMIT_REJECTED,游标不动,这些轮次仍然欠着。 在这里报成功,就等于让调用方把游标推过一批从没落盘的内容。 同一条管线也给人用:手改主题文件走的是同一套暂存和校验,改坏了当场被拒,提交过的文件一字不动。 块 ID 是别的段落、时间轴和关系图找到这一段的唯一途径,所以"内容随便改、ID 必须还在"是这套校验里最硬的一条。

04游标:身份不是位置

一条会话是一张图,不是一条线。重问一句、重试一次回答、派一个子 agent,都会从中间分叉出一条新分支。 分支上说的话都该进记忆,共享前缀上说的话不该进第二遍。

同一个会话,主干写完之后从第三条消息分出去 主干 9 条已经写进记忆。用户回到 m3 重新问,新分支往下又聊了 6 条,head 现在在 b9。 主干 m1 m2 m3 m4 m5 m6 m7 m8 m9 这 9 条已经写过 从 m3 分出去 新分支 b4 b5 b6 b7 b8 b9 head 在这里 这 6 条从没写过 共享前缀 现在跑的 · 记「写到第几条」 游标存 ordinal=9,只收编号大于 9 的 45 67 89 六条全部 ≤ 9 一条都不写 要走的 · 在节点上打「已写」标记 从 head 往回走,收没标记的,遇到有标记的就停 无标记无标记无标记 无标记无标记无标记 六条全部欠着 走到 m3 有标记,停 另一个能走通的 · 记忆自己存 ID 集合 走完整条分支,减去集合里的 ID 新 id新 id新 id 新 id新 id新 id 六条全部欠着 结果一样,代价不一样 分叉点之前为什么自动不会重复写: 分支这条线往回走,走到 m3 还是那条消息,标记就在它身上,走行到此为止。 两条线之间不需要任何顺序,也不需要互相知道:谁先写,谁就给共享的那几条打上标记,另一条来的时候正好停在它们上面。 标记写在哪 节点 metadata,键名带 provider 值是记忆工作区的标识,导出的会话 带着标记去到别处,不会被误认 rewind 盖 rewound 就是同一条路 什么时候打上 两个条件:事务安装成功,并且这一趟 确实改动过文件 实测:二十轮撞同一处被拒编辑, 返回成功,主题文件只有一个字节 这一节的状态 设计定了,代码还没换。跑着的仍是位置 游标,上面那排红叉是改之前的真实行为 实测:存的是 9,一条五轮的分支被算成零轮, 一条十一轮的分支被算成最后两轮
为什么位置当不了身份:编号是链表被展开成列表的那一刻数出来的,它描述的是这次展开, 不是消息本身。展开之前没有这个号,展开之后这个号和消息的 ID 没有关系。 同一条消息沿两条线走到会拿到两个号,两条线上深度相同的两条不同消息会拿到同一个号; 而且 get_branch 会滤掉 rewind 标记过的轮次、压缩会把摘要节点接在中间, 所以同一条线在两次读取之间也不稳定。
这个形状连纠正都不接受advance_cursor 在新序号低于已存序号时抛 cursor cannot move backwards,而往回走恰恰是分叉需要的。 单调计数器和会分叉的对话是两个形状,其中一个修不成另一个。
两种失效不是一样响的:分支还没超过已写高度时一条都不认领,是漏掉一条分支; 超过之后只认领超出去的那一段,是写下一个从对话中间开始的片段,然后把整条分支记成已写。 后一种是成功返回的,没有任何东西会提示它出过错。
第一层 · 我们现在 位置游标:runtime.json 里一个 thread 一条 {message_id, ordinal},只认领序号更大的记录 序号来自 enumerate(get_branch(...)),而 advance_cursor 明确拒绝往回移动,所以连改对都做不到 第二层 · 别人怎么做 读了九个参考框架。三个存的对话会分叉(claude-code、openclaw、pi-mono,都是一份追加写的文件里每条带父指针);三个是直线;一个在重试时把整个会话重写掉、旧分支直接毁掉;两个不存对话。 直线让位置就是身份,所以大多数根本碰不到这个问题。没有任何一个把「记忆取走过这一轮」写到对话轮次上。 codex-cli:分叉开一份新文件,抽取任务按 thread 记水位和租约,把单位放粗到分叉不再重要 pi-mono:存储根本不提供改动一条记录的方法,加注解和移游标都靠追加一条指向它的记录,分支摘要覆盖谁在切换的那一刻从树上现算,从拓扑推增量 claude-code:游标放进程内存,指的消息找不到就整个会话重新处理;openclaw:写 promotedAt,但写在派生记录上,它的记忆钩子读对话时平着取最后十五行 第三层 · 两种做法并排 在节点上打标记 欠账:从 head 往回走,遇到第一个有标记的就停 要存的:一个本来就存在的节点上多一个字段 增长:不增长;一次读取只走几步 迁移:把归档覆盖到的节点标上 丢一个标记:走行提前停下,更老的全部搁浅 耦合:记忆往会话存储里写一个字段 记忆自己存一份 ID 集合 欠账:走完整条分支,减去集合 要存的:每个 thread 一个文件,装下写过的每个 ID 增长:每条消息一项;一次读取要整份清单 迁移:用同一份归档播种同一个集合 丢一个条目:那一轮被重新拿出来,精确 耦合:记忆自己记账,但带出文件、迁移、只增不删 第四层 · 取哪一个 取节点标记。 「记在外面」一旦要在分叉之后仍然正确,就只能是集合,而集合带出文件怎么放、怎么迁移、ID 为什么不能删、读取代价随会话增长这一串。 代价是集合精确而走行不精确:走行假定标记构成一段前缀,靠「一批永远是最老的那几轮」成立,它是假定不是不变量。会话存储本来就在给节点存来源、显示类别、分支根、rewind 状态,多一个字段是同一类东西。
先做能幂等重放的那一步:三步的顺序是先归档证据、再写主题文件、最后打标记。 归档是追加式、按内容寻址的,同一批归档两次逐字节不变;主题文件带块 ID 和脚注, 半写会污染,所以走整装或不装的事务。写入器死在两步之间,留下的是没人引用的证据、 没有标记、下次仍然欠着的同一批,也就是一次重做。反过来先打标记,同一次崩溃变成安静地丢掉这些轮次。
「做完了」都要有可核对的产物:标记要事务安装成功并且这一趟确实改动过文件, 夜间那一趟要报出改过哪些文件而不是看过哪些文件。没有报错不是产出。
它让会话存储付出什么(核实过):会话那边没有整树哈希, 唯一那个逐字节整树哈希是记忆工作区的版本号,根在 <state>/memory, 和 <state>/sessions 不相交,所以标记不会被读成一次并发的记忆写入。 会被碰到的是会话索引缓存:过期判定看 history 目录的 mtime,一次重写和一次追加一样会让它变, 别的进程重建一次索引,实测 289 个节点 4.2 MB 是 14 到 50 毫秒,而且自限。 真正会增长的是 git:会话目录每轮 git add -A 一次, 字节变过的节点都会成为新 blob,所以标记只打在这一批上,不做全量扫一遍, 否则每轮加上整个会话的重量,一生下来是平方级。 另外两条硬约束:标记不能动 updated_at(闲置看门狗就是按它判断"已处理", 动了等于把会话送回一次强制写入),也不能走"把会话记成已同步"的那条路径。

05常驻块:谁在维护它

core.md是每次会话开头都注入的那一小块。它现在是 topics/core.md的渲染结果,2000 token是渲染预算而不是闸门。 改之前它是被写的正本,顶到上限的那一刻就是它最后一次改变的时刻,而且此后每一笔事务都被它拒掉。

同一个文件,两种身份:被写的,和被渲染的 左边是改之前的,右边是现在跑着的。分界线就一句话:正本在哪。 改之前 · core.md就是正本 写入agent直接编辑它 prompts/write.py · prompts/system.py 2000 token闸,超了就抛 management/block_views.py的_synchronize 整笔事务被拒,这一轮的主题改动一起回滚 修复话术只有一句:core.md满了,别动它,这条放进主题文件。 从此每一条稳定事实都被劝走 而且没有任何东西让它变短:夜间整理的文件列表只从topics/**.md来。 闸量的是暂存里的整份core.md,不是这次改动 所以一份已经超标的core.md会拒掉它之后的每一笔事务,包括只改主题文件的, 和一个字都没改的。而它本来就允许人手编辑,是普通Markdown。 实测:人在编辑器里把它撑大之后,一笔没有任何改动的事务报 Core Memory exceeds 2000 tokens: 4004 两次被拒之后:这个会话的对话不进记忆,下一个也不进 报COMMIT_REJECTED,它不在可重试集合里,所以观察者照样把会话标成已处理。 这个状态要靠人去改短那个文件才会结束。 现在 · topics/core.md是正本,core.md是渲染结果 topics/core.md正本 一个普通主题文件,不设上限 段落^a1 段落^b2 段落^c3 · 这次没装进去 写入方和夜间整理都只认识它 渲染 core.md派生 每次写入装成之后跟着重建 段落^a1 段落^b2 装到2000 token为止 装不下的不报错,只报出块ID 预算是渲染上限,不是闸门 被挤出去的段落还在正本里,照样被索引,照样能被search和memory_get找到。 所以留在外面只损失可见性,不损失内容,裁剪也就不必先分清是谁写的。 想让某一段一直在,就把它在正本里排到前面,这是普通编辑。 为什么必须先变成派生视图,才谈得上裁 事务有一条硬契约:改之前存在的块ID,改之后必须还找得到。 core.md里今天可能有独苗,裁掉它整笔就被拒;渲染结果的ID永远是正本ID 的子集,裁掉一段,ID在正本里活着。 这一节的状态 已经落地。左边画的是改之前的行为,不再发生。 实测:一份11883 token的手写core.md,一笔不碰它的写入照样装成,渲染出1983 token。 老实现里这件事是有人管的:memory/core.py的refresh_from_wiki把core.md从一个指定的wiki页重建出来,按标题边界截到预算,由sleep的deep阶段调用。 换成topics/sources这套结构时那条流程被删掉。右边那一栏就是把它接回来,只是指定页从wiki页换成主题文件。
两个方向的区别:现在是拒绝,拒绝落在写入的路上,代价是内容进不来; 计划是不装,不装落在渲染的路上,代价只是这一次没被看见。 为什么不给它加第二道闸:第二道闸仍然是闸,仍然在写入的路上,仍然会把稳定事实挡在外面。 别家要区分"人写的"和"自动写的"才敢裁,是因为它们的常驻文件是唯一一份,丢一行等于销毁一行; 这里正本是全量的,所以归属这件事根本不用判。

06接口的九个方法

记忆和 agent 运行时之间只有这一个接口。除了 name 之外每个方法都有默认实现, 所以换一套记忆系统只需要写它有事可做的那几个。下图按会话的生命周期摆开,并如实标出哪些还没接线。

按生命周期摆开的九个方法 线上方 = 运行时真的在调;线下方 = 接口留着,运行时还没有任何调用方。 system_prompt() 会话开始注入 core.md 包在 memory-context 里, 免得被当成用户现在说的 search(query) 每轮开跑前搜一次 默认 BM25,取前五条 时间预算很紧,要快 write(…) 每轮一次,闲置再一次 两次的差别只有 force, 所以是一个方法不是两个 reorganize() 每天 03:00 重排一次 也可以手动触发: openprogram memory sleep 选中 会话开始 每轮开跑前 上下文压缩时 每轮结束 会话结束 夜里 03:00 name is_available() 选哪一套记忆系统要用的 两件事。但现在没有"选"这 个动作:直接构造出来的 initialize(…) 本该在会话开始时调一次 附带后果:内置实现拿它记 会话 id,那条兜底路是死的 extract_before_discard() 方向和别的都相反:它什么也不存。 压缩器手里攥着一批准备丢掉的消息, 问记忆哪些该留进摘要 shutdown() 本该在最后一轮之后调 现在收尾靠闲置观察者, 它走的是 write(force=True) 这五个不是多余的方法,缺的是运行时那几行接线。 它们是留给第三方记忆系统的插件点:外部系统往往要建连接、要在会话结束时冲刷缓冲、要在压缩前抢救内容。 内置实现恰好这几件事都不需要做,所以缺了接线也没人发现。真接进来一套 mem0 之类的,第一件事就是把这几个调用点补上。
另外两条约定:一是回忆文本在产生的地方就已经包好 <memory-context>,出口不再包一次, 包两层会把里面那层剥掉、只剩一个空壳。二是接口里没有"注册工具"这一项: 带工具的记忆系统按插件接进来,插件本来就能注册命令、技能、MCP、hook 和 agent,再开一条私道只会绕过它。

07失败会怎样

写入的返回值只有三种,观察者读的就是它。不吭声等于成功,失败要说清是哪一种:会自己好的,和再试也一样的。

write() 返回什么,闲置观察者就怎么做 write() 一次调用 三种回答 什么也不返回 写完了,或者本来就没到阈值不欠什么 两种情况都是"没有问题要说" 观察者:标记已处理 这个会话不再被提起 下次轮询直接跳过 为什么方向不能反 忘了写 return 也是"什么也不返回"。 反过来读,这种疏忽就成了永久重试 还欠着 · 值得再试 别的写手正拿着写锁 git 提交失败、向量后端不可用 模型不可达、写手进程起不来 观察者:不标记 下一轮轮询原样再来 对话本来就安全躺在 会话存储里,不会丢 这一类的共同点 条件自己会消失:锁会释放,模型会 恢复。同样的内容下次送过去,结果 可能就不一样 还欠着 · 再试也一样 写手改两遍都被事务驳回 补丁引用了没提供的来源、格式不合 压根没给会话 id 观察者:照样标记 并且把原因发上事件总线 memory.ingest_ended ok: false 为什么明知没写完还标记 同样的内容再送一次还是同样的驳回, 一直重试只烧模型额度。而没人看见 的失败,就是永远不会被修的失败 每轮那条路用同一套返回值:它也会撞上被别人占着的写锁。以前那次撞锁被当成"还不够写"咽掉,和真的没到阈值长得一模一样,于是一轮没写成、什么也没说。 每轮那条路拿到"还欠着"只记一行调试日志:下一轮自然会再来。真正要把一个会话收尾的是闲置观察者,所以判断标不标记的责任在它那边。 无论哪一种,记忆都不会把对话带下水:每个钩子都自己吞掉自己的异常并记日志。吞掉不等于装作没发生。

08分支相关的实现状态

节点写入标记和会话边界的活分支枚举已经实现。其余问题分别是互斥分支的语义表达、跨会话spawn关系和压缩摘要去重;这些不属于位置记录本身。

一条分支只在会话边界上被回访 head,会被写 要等会话闲下来 每一轮里,两条写入路径要的都是"结束在head的那条分支"。 会话边界上才会枚举每条分支各要一遍。
已实现。强制会话边界写入先处理当前head,再枚举其他未归档分支tip;共享前缀由节点标记去重。当前仍通过闲置观察器触发,尚未改成head变化事件通知。
两条互斥的分支,一份主题文件 分支甲:用 Flask 分支乙:改用 FastAPI topics/projects/x.md 这个项目用 Flask 这个项目用 FastAPI 两段并排 谁也没说 它们互斥 检索会把两条都返回;夜里整理甚至可能把它们合成一段。
主题文件的格式里没有"这两条是互斥选项"这种说法,所以来自不同分支的记录只能并排堆着。
跨会话 spawn:两半各记各的账 会话 A · 主对话 cursors/openprogram/A.json 会话 B · 子 agent 的分支 cursors/openprogram/B.json spawn 线程键就是会话 id,所以两半落在两个游标文件下, 两次回溯谁也不会走进对方的图。 不重、不漏。 缺的是"这条分支接着那段对话"的那条链接。
同一会话内的 spawn 分支共用这个会话的游标和存档,不需要额外东西。跨会话那种,记忆里看不出两段是一回事。
压缩摘要一旦成为 head 原始轮次,已经写过 压缩摘要 它取的是被压那一段 第一条的前驱,所以平常是旁支 head 一旦挪到摘要这条线上,摘要就成了一条没写过的助手轮次: 内容是记忆里已有内容的复述,id 却是游标从没见过的。
于是同一批事实被换一种说法又写了一遍。平常不发生,因为摘要节点是旁支而不是主线上的一员。
实现状态(2026-08-11):每轮写入与闲置写入使用同一事务;节点自身记录工作区写入标记;会话边界枚举活分支;常驻块由topics/core.md渲染;后台writer沿用默认聊天agent,并允许memory.writer.model实时覆盖;未分类异常默认不可重试。memory.backend=none既关闭提示、召回、自动写入、整理和记忆线程,也在创建工作区前拒绝全部CLI memory动词与/api/memory/*路由。writer最近成功、失败分类、retryable判定和待处理数可由工具、CLI及Web查询。一次性memory backfill已实现,只处理未被Topic引用的trusted Source、排除pending并按批复用正常writer事务;组合测试覆盖SessionDB、默认模型解析、managed tools、暂存安装、Topic、marker与watcher状态。正式工作区验收中,23个旧v1 source文件(154个frame)经origin_scopeauthority_tier的只读兼容后全部解析;2条待处理消息提交为3个Topic文件、5个唯一block,6个source引用和relation目标均通过校验,两条节点marker写入成功,第二次扫描处理数为0且revision不变。该验收未执行历史backfill,因此152个未引用frame仍存在。仍未实现:越权请求hold队列、按请求方档位过滤读取结果、互斥分支语义、跨会话spawn关系和由head变化事件直接触发写入。写入失败原因码是封闭枚举,受限writer阶段看不到Source归档。