附录 B|持久化存的是事件流,不是消息数组
先别把持久化当成"存一份对话记录"
第 05 章把内存里的事件日志和外层的持久化数据面分开了:前者是进程里的事件日志,后者是"持久化、投影、标题、遥测"那条数据面,把落盘细节留给了这里。现在补上这半。
第一次做持久化的人,几乎都会写同一个结构:把循环里那份消息数组序列化存盘,启动时读回来。看着省事。
这条路的后果和第 05 章是同一套,只是晚几个月才发作:你存的是一份投影的快照,不是真相。压缩、分叉、注入、界面对齐,全都建立在一个假设上——"盘上这份和内存里那份永远一样"。这个假设在第一次压缩发生之后就破了。
这个项目的选择被钉得很死:被持久化的单元就是事件本身(事件溯源,日志是唯一真相源),所以不存在一个平行的"已持久化消息"类型。落盘的是事件流,不是消息数组。投影函数在每次恢复会话时重新计算一遍,不读一份"存好的消息"。
1. 持久化是一条能力,不是一个"存储模块"
先给判断:怎么落盘是可换的实现者,落什么(事件流本身)是不可变的合同。
持久化这条数据面上有几个角色:持久化能力定义(拥有一个共享键)、语义化的"什么时候算是一个耐久检查点"的政策、以及两个具体的存储实现者——一个基于文本行的后端,一个基于关系型数据库的后端。
这是一条能力:抽象的持久化定义是能力定义,要求某个后端去存、载、列,却不规定具体存储实现是什么。第 06 章的三人组在这里又出现一次——能力定义规定"存事件流、只增不改、序号连续",两个实现者各自把这份合同翻译成文本文件的字节,或者数据库里的行。
有一类元数据被特意跟"可以从事件重放出来的状态"分开放:格式版本号、工作目录、血缘关系、种子边界、来源、委托深度这些不能靠重放事件推算出来的东西,走一份独立的会话头信息,由内存里的会话日志能力自己拥有。正文第 08 章讲的血缘数据,落盘时就在这份头信息上,不在事件流里。
如果没有这层能力划分会怎样?两个存储后端各写各的"加载/修复/批写"逻辑,崩溃恢复语义两份、两处 bug。现在两个后端共享同一套协调逻辑,只有底层存储原语不同,生命周期的正确性只需要维护一份。
2. 落盘挂在事件广播上,flush 是一道静默屏障
先给判断:持久化不主动"轮询"会话,它是事件流上的一个订阅者。写入有一个批处理窗口,flush 把这个窗口关掉。
每一次事件广播都会把这条事件复制进这个会话专属的一个控制结构里;第一条待写事件会启动一个固定的批处理窗口,之后陆续到达的事件加入这个窗口,但不会重新计时;专门的落盘屏障动作会取消等待、作为一个共享的静默屏障,把窗口期内新加入的事件也一并排干净再返回。
这和第 05 章"插件监听事件广播,在落盘屏障时刻才真正落盘"是一致的。追加是同步提交的,但落盘是批处理 + 屏障排干——两者不矛盾:内存里的追加立刻可见,物理写盘可以合批处理,直到有人真正需要一个确定的持久化点时才强制排干。
四条所有后端都必须遵守的不变量:只增不改(已经落盘的事件绝不重写)、崩溃留下的 turn 是关闭而不是截断(下一节详细拆这条)、序号连续、数据必须能被序列化、追加操作返回时数据已经持久化完成。第二条最反直觉。
如果没有批处理这一层会怎样?每个返回片段都触发一次强制同步写盘,磁盘 IO 会被打满;如果 flush 不排干窗口期内的新事件,你拿到的"确定持久化点"其实是漏了一批的假象。批处理窗口和 flush 屏障合起来,才让"耐用"和"便宜"同时成立。
3. 两个后端是同一份合同的两种物理表达
先给判断:文本行后端和数据库后端不是"两个功能",是"一份只增不改的事件流"的两种字节表达。换后端不换合同。
文本行后端:每个会话对应一份只增不改的逻辑日志,默认经过压缩存储。第一行是不可变的会话头信息;委托深度落盘必填、顶层默认为零,所属的预设配置也是耐久的——因为它决定恢复会话之后这个会话该用哪套工具和提示词,换了构图,历史就重放不出来了。
为了把"落盘便宜"也做到极致,这里还做了一步无损打包:连续同一类的模型返回片段会被压成一行紧凑的批量记录(这类事件的 JSON 信封远大于实际载荷,实测能省下几十倍的体积),解码时能精确还原每一个事件原本的序号和时间。打包只改变字节表达方式,不丢事件——第 05 章"返回片段也进日志"这条承诺不因此打折。
数据库后端:同一份合同,表达成关系型数据库里的行。每一条事件映射成一行,追加动作本身是一个事务——中间失败整批回滚,内存里的游标和存储状态保持一致。这个后端的所有会话共享同一个数据库文件,所以没有"一个会话对应一个独立文件"这个概念。
两者共享一个设计:懒物化。创建一个会话本身什么都不写,第一次真正追加事件才落盘。一个被创建了但从未追加过任何事件的会话,磁盘上什么都没有,列出会话列表时也看不到它。这让"空会话"不会产生垃圾文件。
如果没有"一份合同、两个实现者"会怎样?你会得到两套完全独立的加载和修复语义,崩溃恢复一个补关闭、一个丢尾部。现在它们都实现同一个小接口,协调逻辑只关心存储原语层面的差异。
4. 崩溃恢复是"补一个关闭",不是"丢一个 turn"
先给判断:这是只增不改原则最狠的一刀。进程崩在 turn 中间,那一 turn 的事件是真实发生过的,不能因为"没写完"就丢掉。恢复动作是追加合成的关闭事件,把 turn 合法地关掉。
崩溃留下的未闭合 turn 事件是真实的、可能还很大;加载时会保留它们,再耐久地追加一批合成的"关闭者"事件——每个没答完的模型工具调用请求,都补一条明确标记为"结果未知"的工具结果,然后补上 step 结束和 turn 结束(标记为"被中断")——把日志配平,让重新水化出来的历史仍然是一段合法的、可以喂给模型的历史。只有从来没写完的尾部碎片才会被真正丢弃。
恢复之后模型看见什么?一种是"工具从未真正开始执行"(有模型的请求但没有对应的耐久调用记录),一种是"工具的结果未知"(有调用记录但没有结果)——后者会引导模型只去重试只读或幂等的操作、去核实副作用是否已经发生、或者直接问人,而不是盲目重跑一个可能已经产生副作用的操作。
合成关闭的具体细节在两个后端各有各自的翻译:文本行后端把被截断的尾部截到最后一个完整帧的开头,重新编码并补齐关闭者;数据库后端直接删掉被截断的尾部行,再补上关闭者。但语义是协调逻辑里的同一份,崩溃修复只会对确认是"冷"的会话标识执行。
如果没有"补关闭"这层会怎样?崩溃后恢复要么直接拒绝加载(用户丢掉整段没答完的工作),要么粗暴截断(模型看见一段没有 turn 结束标记的残局,历史状态本身就不合法)。补关闭让"崩溃"从"数据损坏"降级成"一次被明确记录下来的中断"。
5. 投影是折出来的读模型,不是第二份持久化真相
先给判断:这条数据面里还有一类东西容易读错——会话投影。它不是"把消息另存一份给界面读",而是从已经落盘的日志上折出来的、给客户端用的读模型。
一个专门的投影注册表拥有自己的共享键,驱动每一个注册进来的投影单元在已经提交的事件上运行,把计算结果送给对应的载体。业务领域只负责注册纯粹的计算逻辑,框架负责驱动——注册表只订阅一次事件广播,每个提交的事件都会被依次喂给每一个投影单元的计算函数;业务领域自己不持有任何订阅。
这是第 05 章"消息、界面、恢复、分叉、压缩全是日志上的投影"这句话在数据面上的又一次兑现——投影本身也算一种投影,只是它的消费者是客户端界面,不是模型。
如果没有这层纪律会怎样?界面会自己维护一份"当前待办/当前长期目标"的可变状态,和日志渐行渐远,恢复会话之后界面和模型对不上。把这类读模型也收成"日志的纯函数",重启后重新折一遍日志就能恢复。
6. 结语:带走一句话
持久化存的是事件流,不是消息数组——落盘挂在事件广播上、flush 是一道屏障、两个后端共用一份只增不改的合同、崩溃是补关闭不是丢 turn;投影、模型历史、会话标题,全是从这份日志重新折出来的读模型,不是另一份真相。