记忆子系统:机制图解
一段对话怎么变成磁盘上的主题文件,写到一半失败会怎样,会话分叉之后靠什么认出哪些已经写过,
每次会话都注入的那一小块记忆归谁维护。
正文在 docs/reference/design/memory/overview.md,第 04 节的完整设计在 written-marker.md,这页只画机制。
别家怎么做的、我们的选择落在哪一格,在 记忆写入:八个框架横向对比。
在跑的
两个写入入口、五步写入、暂存事务、夜间整理、沉默即成功的返回值契约,
以及第 05 节的常驻块派生视图:core.md是topics/core.md的渲染结果,
预算不再拒事务。
定稿了还没落地
第 04 节的写入游标:跑着的仍是位置游标,分叉之后整条分支漏写,
或者只写下一个从对话中间开始的片段。要走的是在节点上打「已写」标记,
从 head 往回走到第一个有标记的为止。
接口留着没接线
name is_available initialize shutdown
extract_before_discard:五个方法运行时没有任何调用方。
01一条对话怎么变成长期记忆
两个入口,一段写入逻辑。两条路的区别只有 force 这一个开关:一个够了才写,一个剩多少写多少。
怎么读:左边三个入口,中间是它们共用的那段逻辑,右边是磁盘。
绿色那条每轮都触发,但只有攒够 16k token 才真的写;蓝色那条在会话闲置后触发,把剩下的全部写完,所以它要循环。
紫色那条不产生新内容。
为什么每轮那次不传消息:会话存储本来就是持久有序的,从它读回来的每条消息都有稳定 id,
进程里再缓一份只会在重启时丢掉,而且给出的位置每次运行都不一样。
02写入的五个步骤
一趟里发生的事。阈值只回答"值不值得叫一次模型",不回答"一次写多少",所以跑了一天的会话要走好几趟。
03事务:改在暂存,验过才装
模型不直接改记忆库。它改的是一份临时拷贝,只有整份验过才被整体安装;验不过整份丢掉,记忆库一个字节没动。
04游标:身份不是位置
一条会话是一张图,不是一条线。重问一句、重试一次回答、派一个子 agent,都会从中间分叉出一条新分支。
分支上说的话都该进记忆,共享前缀上说的话不该进第二遍。
为什么位置当不了身份:编号是链表被展开成列表的那一刻数出来的,它描述的是这次展开,
不是消息本身。展开之前没有这个号,展开之后这个号和消息的 ID 没有关系。
同一条消息沿两条线走到会拿到两个号,两条线上深度相同的两条不同消息会拿到同一个号;
而且 get_branch 会滤掉 rewind 标记过的轮次、压缩会把摘要节点接在中间,
所以同一条线在两次读取之间也不稳定。
这个形状连纠正都不接受:advance_cursor 在新序号低于已存序号时抛
cursor cannot move backwards,而往回走恰恰是分叉需要的。
单调计数器和会分叉的对话是两个形状,其中一个修不成另一个。
两种失效不是一样响的:分支还没超过已写高度时一条都不认领,是漏掉一条分支;
超过之后只认领超出去的那一段,是写下一个从对话中间开始的片段,然后把整条分支记成已写。
后一种是成功返回的,没有任何东西会提示它出过错。
先做能幂等重放的那一步:三步的顺序是先归档证据、再写主题文件、最后打标记。
归档是追加式、按内容寻址的,同一批归档两次逐字节不变;主题文件带块 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是渲染预算而不是闸门。
改之前它是被写的正本,顶到上限的那一刻就是它最后一次改变的时刻,而且此后每一笔事务都被它拒掉。
两个方向的区别:现在是拒绝,拒绝落在写入的路上,代价是内容进不来;
计划是不装,不装落在渲染的路上,代价只是这一次没被看见。
为什么不给它加第二道闸:第二道闸仍然是闸,仍然在写入的路上,仍然会把稳定事实挡在外面。
别家要区分"人写的"和"自动写的"才敢裁,是因为它们的常驻文件是唯一一份,丢一行等于销毁一行;
这里正本是全量的,所以归属这件事根本不用判。
06接口的九个方法
记忆和 agent 运行时之间只有这一个接口。除了 name 之外每个方法都有默认实现,
所以换一套记忆系统只需要写它有事可做的那几个。下图按会话的生命周期摆开,并如实标出哪些还没接线。
另外两条约定:一是回忆文本在产生的地方就已经包好 <memory-context>,出口不再包一次,
包两层会把里面那层剥掉、只剩一个空壳。二是接口里没有"注册工具"这一项:
带工具的记忆系统按插件接进来,插件本来就能注册命令、技能、MCP、hook 和 agent,再开一条私道只会绕过它。
07失败会怎样
写入的返回值只有三种,观察者读的就是它。不吭声等于成功,失败要说清是哪一种:会自己好的,和再试也一样的。
08分支相关的实现状态
节点写入标记和会话边界的活分支枚举已经实现。其余问题分别是互斥分支的语义表达、跨会话spawn关系和压缩摘要去重;这些不属于位置记录本身。
已实现。强制会话边界写入先处理当前head,再枚举其他未归档分支tip;共享前缀由节点标记去重。当前仍通过闲置观察器触发,尚未改成head变化事件通知。
主题文件的格式里没有"这两条是互斥选项"这种说法,所以来自不同分支的记录只能并排堆着。
同一会话内的 spawn 分支共用这个会话的游标和存档,不需要额外东西。跨会话那种,记忆里看不出两段是一回事。
于是同一批事实被换一种说法又写了一遍。平常不发生,因为摘要节点是旁支而不是主线上的一员。
实现状态(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_scope到authority_tier的只读兼容后全部解析;2条待处理消息提交为3个Topic文件、5个唯一block,6个source引用和relation目标均通过校验,两条节点marker写入成功,第二次扫描处理数为0且revision不变。该验收未执行历史backfill,因此152个未引用frame仍存在。仍未实现:越权请求hold队列、按请求方档位过滤读取结果、互斥分支语义、跨会话spawn关系和由head变化事件直接触发写入。写入失败原因码是封闭枚举,受限writer阶段看不到Source归档。