哪些轮次已经写进记忆

一个会话是DAG,不是一条直线。每条分支上说过的话都该进记忆,共享前缀上说过的话不该进两次。 这一页画的就是决定这两件事的那一个事实。正文在 docs/reference/design/memory/written-marker.md,中文对照版是 written-marker.zh.md;它外面的记忆子系统在 overview.md,机制图在 memory-architecture.html

第一层 · 现在跑的

位置游标:一个会话一个整数,存在runtime.json里。分叉之后整条分支漏写, 或者只写下一段从对话中间开始的残片,并且返回成功。

第三层 · 定稿未落地

在节点上打「已写」标记,从分支尖端往回走到第一个有标记的为止。 七个文件、改约200行新增约130行,外加一次性迁移。

第四层 · 没有排期

不存这本账,欠账从记忆自己的内容里查出来。挡路的是索引与失效规则, 比整个第三层都大。

01 现在怎么做的 02 别人怎么做的 03 计划怎么改 04 理想形态

01第一层 · 迁移前的实现

写到哪了,记的是一个整数,键是会话id。一个会话有六条分支,六条分支共用这一个整数。

同一个会话:主干写完之后,用户回到 m3 重新问了两次 主干9条已经写进记忆,游标存的是 ordinal=9。两条分支从 m3 岔出去,各自往下聊了5轮和11轮。 主干 m1m2 m3 m4m5m6 m7m8m9 这9条已经写进记忆 游标 {"ordinal": 9} 分支A 5轮,一条都没写过 分支B 11轮,一条都没写过 读的时候发生了什么:链先被展开成列表,编号才产生 get_branch(分支A尖端) 012 345 67 ordinal > 9 才认领 认领 0 轮 整条分支被读成已经写过 get_branch(分支B尖端) 编号仍然 ≤ 9 的那一段 越过 9 的尾巴 ordinal > 9 才认领 认领 2 轮(实测) 写下一段从对话中间开始的残片,然后把整条记成已写 更糟的是下面这一行: 它返回成功。没有任何一条代码路径报出什么,而前面那一段之后再也不会被拿出来。
编号是展开的产物,不是消息的属性。_records里就是 for index, message in enumerate(messages):链被展开成列表的那一刻才有这个号, 列表没了它也就没了。同一条消息沿两条线走到会拿到两个号,两条线上深度相同的两条不同消息会拿到同一个号。
编号轴上还有记忆不记录的行。_records的序号是"这一行在整条分支里的位置,跳过的行也算", 工具调用行和运行时自己调度的轮次都占编号。所以越过游标的是几行、对应几轮,不是一次简单的减法, 上面两个数字是实测出来的,不是算出来的。
这个形状连纠正都不接受。advance_cursor在新序号低于已存序号时抛 cursor cannot move backwards,而往回走恰恰是分叉需要的。单调计数器和会分叉的对话是两个形状, 其中一个修不成另一个。

02第二层 · 别人怎么做的

references/下九个目录,claude-code/只有某个工具的五个文件, 所以真正有答案的是八个框架。八个里四个真的有长期记忆。

框架 对话形状 长期记忆 决定写哪些轮次的那本账 分叉时它会怎样 claude-code-leaked DAG 按消息UUID的游标 lastMemoryMessageUuid 只在进程里的闭包变量,从不落盘 拷到新文件时保留原UUID,游标仍然找得到 找不到那条消息时:把全部消息重新数一遍,不返回零 codex-cli 直线 · 分叉开新文件 按thread的一行:水位 + 租约 单独的 memories_1.sqlite,键是 thread_id 新 thread_id 就是没有行,于是从头抽取一遍 没有任何地方去 join 血缘,所以共享前缀被抽了两次 openclaw DAG 按行号的游标 lastContentLine,一个会话文件一个 promotedAt 有,但写在派生出来的候选记录上 新文件行号从0开始,拷过来的前缀被再摄入一遍 摄入时平着读整份文件,完全不走父指针 hermes-agent 直线 · 会话粒度分叉 记忆这边什么都不记:每轮结束触发一次,程序顺序就是账 刷盘另有进程内下标 _last_flushed_db_idx 每一处分叉手工把下标重新对齐 分叉钩子 on_session_switch 八个provider里七个没实现 pi-mono DAG · 严格追加写 没有 分支摘要覆盖哪些轮次,在切换的那一刻从树上现算,什么都不存 加注解是追加一条 label,移动叶子是追加一条 leaf 没有东西会过期,因为每次切换都重新算一遍 opencode 直线 · SQLite 没有 不适用 revert 先标一个点,下一次发消息把这个点之后全部硬删,更早的分支一条不留 pi-ai 不存对话 没有 无状态流式客户端,每次调用由调用方交出整个消息数组 没有分支这个概念 weclaw 自己不存 没有 CLI/ACP后端只缓存下游会话id;HTTP后端在进程里留最近二十轮 没有分叉这个概念 没有任何一个把「记忆取走过这一条」写到对话记录上。 openclaw离得最近也差了一步:它的标志写在派生候选记录上,不在转录行上。

真正在用的形状只有两种

把单位放粗到分叉不再重要 codex-cli 按 thread、openclaw 按会话文件、hermes-agent 按一轮 分叉之前 整份文件 / 整个 thread = 一个键 分叉之后 新键,账目从零开始 代价:绿色那三条是拷过去的同一段对话,被抽取了第二遍 一轮都没漏。漏的是"不重复"这条,而重复恰恰是可以挽回的那一种。 从树上现算这次的增量 pi-mono:走到最近公共祖先,收走中间那一段,什么都不存 最近公共祖先 被放弃的叶子 新叶子 从被放弃的叶子往上走到公共祖先,中间这两条就是这次要摘要的 它的存储撑得住这种做法,是因为根本改不了已存的记录 加注解 = 追加一条指向目标的 label;移动活动叶子 = 追加一条指向节点的 leaf 这是"改动节点"之外真实存在的第三个选项,第三层把它否掉是有理由的,不是没有先例。
别人在分叉处的失效是重复,我们的是漏掉。codex-cli 把分叉出来的 thread 从头抽一遍, openclaw 的新文件从第0行重扫一遍,两家都没漏掉任何一轮,都把读过的又读了一遍。 重复挽回得了:夜间重排会合并说着同一件事的段落,一轮被写两次的代价是一次模型调用。 漏掉挽回不了,因为再也不会有人回来找那些轮次。
值得拿过来的两条。①claude-code-leaked 在游标指的消息找不到时把全部消息重新数一遍, 注释写明返回零会"在这个会话剩下的时间里彻底停掉抽取";同一份代码里紧挨着它的那个计数器没有这条退路, 会安静地一直停在零,所以这是一个决定不是一个默认值。②pi-mono 的追加式标记记录。

03第三层 · 当前方案:节点标记

边界只能落在能标识一条消息的东西上,而标识一条消息的就是它这个节点本身。 所以"记忆写过这一轮"这件事,记在这一轮身上。

同一个会话,同一次分叉:从分支尖端往回走,停在第一个有标记的轮次上 主干9条身上都有标记,两条分支身上都没有。走行不需要任何编号,也不需要两条分支互相知道。 主干 m1m2 m3 m4m5m6 m7m8m9 九条都带着标记 分支A 从尖端往回走,一路收,走到 m3 有标记,停 5轮全部交出 分支B 同一条走行,同一个停点 11轮全部交出 两条分支之间不需要谁先谁后: 谁先被写,共享的那几轮就由谁打上标记,另一条来的时候正好停在它们上面。

标记写在哪、什么时候打上

写在哪 history/0007-u-a3f1c2.json {"id": "a3f1c2", "role": "user", "predecessor": "9d0e77", "metadata": {"rewound": null, "memory_written_scriptorium": "w-4f21c8e0"}} 键上带 provider 的名字:接口可插拔, 一个两边共用的布尔值在第二个provider出现那天就是错的 值是 记忆工作区的标识:标记会跟着节点走, 拷到一台记忆是空的机器上,每一轮都会声称自己写过 走行只在标记指着自己这个工作区时才停 什么时候打上:三步的顺序由"哪一步能重放"决定 1 归档证据 追加式、按内容寻址,归两次逐字节不变 2 装主题文件 带块ID和脚注,半写会污染,整装或不装 改过文件吗 审计里 commit/ok 的主题文件路径,空列表就是没有 3 打标记 事务安装成功,并且这一趟确实改过文件 死在这两处:留下没人引用的证据、没有标记、下次同一批仍然欠着 这是一次重做。反过来先打标记,同一次崩溃就变成安静地丢掉这些轮次。 一趟没改过任何文件也要打标记的话 实测:二十轮撞同一处被拒编辑,返回成功,主题文件只有一个字节 两条不能碰: ① 不能动 updated_at,闲置看门狗就是拿它判"这个会话处理过了",动了等于把会话送回一次强制写入和里面那次模型调用。 ② 不能走 mark_synced(),那个指纹会盖住打标记的进程从没读过的写入,漏掉那些节点的索引从此再也不会重建。
用的都是已经在跑的形状。metadata是自由字典,消息适配器把不认识的字段全放进去, 读取方再把每个键摊回顶层,所以键名不能和消息字段撞(id role content predecessor caller timestamp status 这些都被占了)。往已存节点上写也不是新事:rewindrewoundrevertreverted、一轮的收尾流程就地重写节点文件盖检查点戳记; 而SessionNodeWriter.update已经在做标记需要的那个动作,只是它先经过 SessionStore._open(会把过期索引重建掉),再用atomic_write_text直接重写, 绕开了上面两条。
实测过的代价。会话索引缓存会重建一次,289个节点、4.2 MB是14到50毫秒,而且自限。 会增长的是git:会话目录每轮git add -A一次,所以标记只打在这一批上,永远不做全量扫一遍, 否则每轮加上整个会话的重量,一生下来是平方级。

要改哪些文件

按依赖顺序。1到6是一组连贯改动可以一起落地;7可以拆出来跟在后面;8必须一次做对。 1 · session_store.py 新增 merge_node_metadata 新增约20行 2 · session_node_writer.py update 委托给第1项 删约12行 3 · memory/store.py 新增 workspace_id() 新增约12行 4 · runtime/state.py 去掉 cursors / advance_cursor 删约10行 5 · runtime/online.py pending → unwritten_turns,加 mark 回调 改约25行 6 · memory/writing.py 走行、写入器返回改过的文件 改约40行 7 · 会话边界每条分支都问 list_branches + 每尖端一次 新增约30行 8 · mark_archived_turns.py 从 sources/ 播种,一次性 新增约50行 测试 test_memory_writing.py(现有) _pending 断言改成读标记 test_memory_write_timing.py(现有) 阈值与闲置行为不变 新增 test_memory_written_marker.py 两个实测案例、共享前缀只写一次、 被拒的一批不打标记、迁移播种 合计: 七个文件里改约200行、新增约130行,外加一个新测试文件。改完之后唯一值得重测的运行时代价是那次索引重建。

一次性迁移:只标记可验证的连续前缀

归档先产生候选ID;候选ID不能直接变成标记 legacy 只接受文件标题后字节位置上的第一组合法header 没有正文长度;第二组合法header可能逐字来自用户正文,因此后续记录重新写入。 v2 读取严格record-lines parser的合法前缀 正文按行数跳过;首个非法或截断frame终止解析,不从后面的内容重新开始。 旧ordinal错误可能留下: m1 ✓ m2 ✓ m3 ✓ b1 × b2 × b3 ✓ 共享前缀 + 未归档中段 + 已归档尾部 直接标记所有候选:会永久漏写 b3得到标记后,从分支尖端倒序读取会立即停止。 b1、b2不再进入任何写入批次。 当前迁移:在真实DAG上保留连续前缀 复用writing._records过滤;从第一条可写记录开始,遇到b1缺口即停止。 只标m1–m3 b1、b2、b3下次一起写;b3允许重复,缺口不会被跳过。 提交顺序: 所有session的安全前缀都成功写入节点后才删除cursors;任一批失败,cursors保留供重试。

反面意见:外部集合精确,走行不精确

在节点上打标记记忆自己存一份 ID 集合
怎么算出还没写的从尖端往回走,停在第一个带标记的走完整条分支,减去集合
分叉时走到共享前缀,发现有标记,停前缀的 ID 在集合里,于是被跳过
要存什么一个本来就存在的节点上多一个字段每个 thread 一个文件,装下写过的每一个 ID
怎么增长不增长每条消息一项,会话在多久就留多久
一次读取的代价从尖端往回走几步这个 thread 的整份清单
迁移从可信归档候选中只标DAG连续前缀用同一份安全前缀播种集合
标记或条目丢了走行提前停下,比它更老的全部搁浅那一轮会被重新拿出来
耦合记忆往会话存储里写一个字段记忆自己记账
这一方的说法站得住:位置游标是一次 O(1) 的小文件读取,完全在会话树之外; 在节点上打标记等于把同一件事记进第二个地方,而那个目录的 mtime 正是别处都在依赖的变更探测器。 集合是精确的,走行不是:走行假定标记构成分支的一段前缀,这一条成立是因为一批永远是最老的那几轮, 但它是一个假定,不是一条被校验的不变量。
决定胜负的是另一半:"在外面"只在这本账是一个数字的时候才便宜。 一旦它必须在分叉之后仍然正确,"在外面"就只能是一个 ID 集合,而集合带出文件怎么放、怎么迁移、 为什么一个 ID 永不能删、以及读取代价随会话增长这一串。会话存储本来就在给每个节点存这一轮从哪来、 是不是运行时在自言自语、它是不是某条分支的根、rewind有没有把它退掉; 多一个"记忆写过这一轮"的字段,和那些是同一类东西。

04第四层 · 理想形态

第三层回答"下一步动手做什么",这一层回答"如果重新设计这块该是什么样"。 下面三条不是标记的分阶段版本,它们是同一个问题的另一种摆法。

同一个事实,今天记在三个地方 三份都在描述"哪些轮次记忆取走过",三份都可能互相说不上话。 ① 位置游标 runtime.json 的 cursors 第三层要把它换成节点上的标记 ② 来源归档里的 source-id 集合 sources/<provider>/<thread>.md 每一次写入都要读它一遍,用来跳过已归档的轮次 ③ 主题文件里的脚注 topics/*.md 每条记录指回来源 这是唯一能回答"内容真的进了文件"的一份 重新设计的话:别的地方一个字都不存,欠账是一次查询 还没写的 = 从任意一个活着的尖端可达,并且没有被任何一条记忆记录引用。 ②那个集合本来就在每次写入时被算出来一遍,它已经是这个问题的答案,只是没被当成答案用。 它不可能和记忆对不上,因为它就是记忆 删掉一份主题文件里的一段,它对应的轮次 就重新变成欠着的,这是正确答案 会话搬家不需要任何东西 不需要工作区标识、不需要导入路径、 不需要迁移,也没有"标记丢了"这个情形 挡在路上的三件事 归档是正则解析的markdown,读一次 O(文件); 建索引要一套失效规则;归档过 ≠ 被引用过
一条记录知道自己来自哪条分支 现在 分支A 分支B 同一批主题文件 检索把两条都返回给 一个只在其中一条线上的读者 理想 记录带着来源分支,检索优先本分支 差距同时在三处:主题格式没有这个字段、检索没有按分支过滤、夜间重排也没有"互为可能"这个概念 记忆是被通知的,不是靠轮询的 现在 每轮之后问一次阈值 看门狗每五分钟扫一次 结果:一小时前被人离开的分支,要等整个会话闲置才写得进去 理想 会话存储说"这个尖端停了" 记忆据此反应 挡路的:head 的写入散在五处,它们唯一共同经过的那个函数把旧值丢掉了,也没有任何事件携带这次移动。 加上这个事件很小,让五条路都走它不小,而且现在等着并不丢东西。
实现状态(2026-08-11)。第一层记录迁移前的实现,第二层是开源框架审计,第三层已经实现: 会话节点用metadata.memory_written_scriptorium = <workspace-id>记录写入状态,当前分支按节点标记计算待写后缀, 成功且实际修改记忆的批次才打标记;会话结束时先处理当前head,再处理其他活分支。 RuntimeState.cursorsadvance_cursor已经移除。旧安装首次写入时同时校验legacy header和 sources/openprogram/_v2/*.md的严格合法前缀:legacy只接受标题后第一组合法header,v2遇到非法frame停止。 候选ID在真实DAG路径上经过与在线写入相同的记录过滤,只标记第一个缺口之前的连续前缀;缺口后的尾部允许重写。 所有session标记成功后才删除旧cursors,失败时保留供重试。 结构化speaker字段、可信v2 frame和speaker过滤也已经实现。历史memory backfill有意忽略节点marker, 只选择未被Topic引用的trusted Source并排除pending;它不增加或删除session marker。节点marker记录在线writer处理状态, Topic引用记录历史Source是否已经进入可召回内容,两项定义互不替代。正式工作区尚未执行历史backfill。第四层仍未实现。