前面四章讲的都是 dsh 如何把自己组装起来:bin 入口、profile 与 bundle、Loader 挂载、Cordis 原语。从本篇开始,视线转向运行时——agent 跑起来之后,状态存在哪里、如何流转、如何活下来。
第一个问题是:模型的历史消息存在哪?dsh 的答案会让习惯"数据库存消息"的读者愣一下:** nowhere**。仓库里没有消息表。模型的历史、fork 的祖先、崩溃后的恢复、遥测的原始素材,全部从一份东西派生——会话事件日志(packages/core/session/src/,内存模型见 docs/subsystems/session.zh.md)。读这份代码之前,建议先放下两个直觉:一,消息不是一等公民,事件才是;二,写入不是留待落盘的缓冲,而是当场生效的事实。带着这两个提醒,很多看似绕路的设计——比如写入前冗长的校验、flush 独立成屏障——都会显露出它们各自的必要性。本篇拆解这份日志:它的事件模型、写入路径、从日志到消息的投影规则,以及叠加在日志上的会话投影。本文基于 2026 年 10 月的 master 分支(5badb15,0.2.1-alpha.1 前后),项目处于 developer preview 阶段,API 可能变动。
为什么日志是唯一事实源
先看"消息历史"这个最容易想到的存储目标。如果直接把消息存成数组,你会立刻遇到三个麻烦:消息被压缩(compaction)替换时旧消息怎么办;崩溃恢复时怎么知道上一轮工具调用有没有跑完;fork 一个会话时怎么复用历史。dsh 的解法是把这些问题的答案统一成一条原则:模型可见即已记录——凡是会被模型看到的内容,必然先以事件形式追加进日志;任何时刻的"消息历史"都是从日志重新投影出来的视图,从不单独存储。
这个选择在代码里处处可见。回放(Session 支持以已有事件日志为种子构造,packages/core/session/src/index.ts:498 附近的 seed 参数)就是把同一组事件重新喂给投影;fork 是复制前缀再补合成事件(第 6 篇细讲);request/header 事件把每个请求的完整配置快照(config + adapterDefaults + tools)写进日志,于是"某个请求为什么这样发"事后可以纯函数式地回答,不用猜当时的配置是什么。遥测素材同理:token 用量与消息同行存储,会话统计、计费分析都从日志折叠而来,不需要第二条采集管道。
日志还是追加式的,这带来一个工程红利:写入永不改写已有字节,天然适配第 6 篇要讲的 zstd 帧式压缩、批窗口落盘和崩溃截尾。如果历史是一张三栏的关系表,这些机制每一项都会难一个数量级。
SessionEvent:连续性契约与四类事件
SessionEvent<T> 是一个可辨识联合,四个字段:{ type, seq, time, data }(packages/core/session/src/types.ts:281 起的 SessionEventMap 定义了 14 种核心类型)。time 是 epoch 毫秒;seq 的约定是 seq = log.length——事件一旦追加,序号就等于它在日志里的下标,连续性由构造保证,不存在空洞。读方可以把 seq 当作数组下标直接用,这是全系统大量随机访问的事实基础。
surface 事件(会产生消息的 5 种类型,types.ts:439 的 SurfaceEventType)额外携带 surfaceOp:要么是 'append',要么是 { op: 'replace', startSeq, endSeq }。replace 的语义值得停下来看:它遮蔽 [startSeq, endSeq] 区间内的旧节点,而不是删除它们——被替换的旧事件仍留在日志里,只是不再出现在派生视图中。append 处对 replace 的校验很严:被遮蔽的节点必须完整覆盖、来源引用必须指向更早的真实事件,坏标记直接拒绝写入(index.ts:709 附近的注释列了完整清单)。日志永不篡改已提交内容,这个原则在第 6 篇的迁移设计里还会以更强的形式出现。surface 事件的 surfaceOp 由编译期类型强制,非 surface 事件根本不允许带这个字段——想带也编不过。
事件按用途分四类,每类的"读者"不同:
flowchart TD
E["SessionEvent
{type, seq, time, data}"] --> B["边界事件"]
E --> S["surface 事件"]
E --> L["结算与信封事件"]
E --> M["标记事件"]
B --> B1["turn/start、turn/end
step/start、step/end"]
S --> S1["system/developer/user/assistant message
tool/result"]
L --> L1["assistant/attempt、tool/call
request/header、request/context"]
M --> M1["session/end-seed
fork 切点与生命周期边界"]边界事件给日志装骨架:turn/end 携带 TurnEndReason(completed/aborted/blocked/error/max-tokens/interrupted/forked),其中 interrupted 只由崩溃恢复合成、forked 只由 fork 合成——读到这两个值,你就知道自己站在一段被修补的历史里,修补者不是模型而是恢复逻辑。step/start、step/end 把一轮(turn)切成若干步(step,一次模型调用及其触发的工具执行),token 计量与重试策略都挂在步的粒度上。surface 事件是模型可见的全部内容,tool/result 可以带 error{name, code, reason?} 与工具私有的 meta——工具实现可以把诊断信息留给未来的自己,而不污染模型上下文。结算与信封事件只进日志、不上 surface:assistant/attempt 记录没有产出消息的失败尝试(重试、取消),request/header 存完整请求头快照,用量与消息同行。这类事件是"事后审计"的抓手:一次糟糕的模型调用,你能从 attempt 与 header 里复原出当时的全部输入。标记事件只有一个,session/end-seed,留到第 6 篇讲 fork 时展开。
词汇表是可扩展的:插件经 TypeScript 声明合并追加事件类型(compaction/*、hook/*、llm/retry 等),全部属于"仅日志"类——它们改变会话的内部记账,但不直接出现在模型上下文里。扩展带来一个读方问题——旧进程读到新插件写的事件怎么办?答案是一个显式标记 ignorable?: true:带标记的未知类型可以被安全跳过,缺省即 required,读到无标记的未知类型必须拒绝回放(fail-closed)。已知类型的白名单在 packages/core/session/src/known-event-types.ts:22,核心与合并扩展的词汇都在这里登记;docs/persistence-catalog.zh.md 生成的目录则把每种事件的载荷、surface 标记与声明位置逐项列出,写插件前先查它,能避开绝大多数"事件写了但回放拒绝"的调试。
append():写入路径
日志的写入只有一个入口:Session.append(type, data, opts?)(packages/core/session/src/index.ts:718)。打开这个函数,第一感受是它的大部分篇幅在拒绝:一次迭代内完成无损 JSON 校验(snapshotJsonValue 拒绝 BigInt、循环引用、稀疏数组、-0、Map/Set/Date 等非 JSON 值)、surfaceOp 与 sourceEventSeqs 的合法性校验,任何一项不过都在追加点直接抛错。注释把理由写得很直白:日志是持久的事实源,坏事件必须在 append 处失败,而不是拖到后端落盘时才暴露。
通过校验之后是一次紧凑的提交:
sequenceDiagram
participant C as 调用方插件
participant S as Session
participant L as 内存 log
participant O as session/event 观察者
participant W as JSONL 批缓冲
C->>S: append(type, data, opts)
S->>S: 无损 JSON 快照与校验
S->>L: push 事件, seq 等于 log.length
S->>O: 同步发布 session/event
Note over S,W: 热路径零 I/O:事件进批窗口
W->>W: 窗口到点 persistContiguous 落盘
Note over C,W: session/flush 排空并 fsync,持久性屏障三个细节值得记住。一是观察者的快照时机:listener 列表在 push 之前解析,回调在 push 之后同步执行,观察者抛出的异常被隔离——一个坏插件挡不住日志提交。这个顺序还保证了观察者读到的事件视图包含刚 push 的这条,不会出现"看到了一半的状态"。
二是热路径零 I/O:append() 本身不碰磁盘,JSONL 后端的 JsonlBackendTracker.install(packages/session/session-persistence-jsonl/src/storage.ts:534)按会话 id 把事件路由进活跃写句柄,首个事件开一个固定截止时间的批窗口,到点由 persistContiguous(storage.ts:319)整批落盘;落盘失败时事件按序留在缓冲里、暂停自动路径,等下一次 flush 重试——连续性不在磁盘上维护,而在内存 log 里维护,磁盘只是 log 的异步镜像。
三是持久性屏障只有一个:session/flush 取消等待并排空(drain + flush),ctx.sessions.flush(session) 是统一入口,dsh-session-checkpoint-policy 每个请求结束时调用。seq 的连续性由内存 log 保证,flush 才是"真的写进磁盘"的那条线。这也让"何时落盘"成为一道策略题而不是正确性题:checkpoint 策略可以按请求、按 turn、按时间调,日志本身永远是对的。
Session 对象之上还有一层发布管理:SessionStore(index.ts:921,ctx.sessions)负责 create、prepare、enter、announce、flush、fork 的生命周期。值得留意的是它故意不做持久化——文件头部的注释写明,持久化由 agent 生命周期把日志写句柄挂到每个已发布会话上,在 store 之外完成;绕开生命周期直接构造的 detached 会话,内存里照样完整,只是不落盘。这条 seam(能力缝隙)让同一套会话模型可以跑在无持久化的测试里,也可以接上 JSONL 后端跑在产品里,第 6 篇从这里接着讲。
deriveMessages:从日志到消息
deriveMessages()(index.ts:856)回答"模型现在看到了什么"。投影规则集中在 deriveEventMessage(packages/core/session/src/surface.ts:120),一张表说完:
user/message→ 原样 user 消息。system/developer/assistant/message→ 对应消息;但空内容返回 null——max-tokens 截断的步骤只存 usage,不该以空消息身份混进 transcript。tool/result→ 一等的 tool 消息,带错误与工具私有 meta。- 其余全部(边界、attempt、request/*、end-seed、插件纯日志)→ null。
性能靠增量:投影缓存到 surface 节点列表,surface 未变(纯尾部增长)时只处理新节点,复杂度 O(新增);一旦发生 replace 或消息投影触发 contentGeneration 变更才整体重建。返回数组每次是新的,但消息对象共享且深冻结——调用方拿不到任何改写日志的通道。独立的重建器 foldSurface(surface.ts:594)把同一套规则暴露成纯函数:给它事件数组,它产出 {nodes, replacements, projectedMessages}。session query 与冷读路径都用它从原始日志重建视图,不需要先构造一个活的 Session 对象——视图与对象解耦,正是"投影即视图"的另一半。
assistant/message 的载荷里内嵌一个值得单独看的结构:stream: AssistantStreamRecord[](packages/llm/llm/src/assistant-stream.ts:20)。它是模型原始流的紧凑编码,把连续同类型的 delta 压成运行段:
{ type: 'text-chunks', time0, index, dt: number[], texts: string[] }time0 是基准时间,dt 是每个 delta 的相对时长,texts 是文本数组——无损,且保留了原始时序;无法打包的原始块(如 usage 更新)单独以 {type:'chunk'} 记录。压包有明确边界:不跨 delta 边界拼接,展开路径 expandAssistantStream(assistant-stream.ts:202)是校验性的,随时可以把运行段还原成带时间戳的原始块序列。设计意图一句话:一条 assistant 事件包含的流,可以完整重放出这条消息。这让日志里存一份"结算"就够了——消息内容不需要另存一份可能与流脱节的副本。持久化 verifier 会真的这么干——用 BlockAssembler 重放流,断言 message.content、usage 与流完全一致(packages/session/session-persistence-jsonl/src/generation.ts:554),流与消息不符的文件根本没有资格被发布。
会话投影:叠加在日志上的 fold
消息历史是一种视图,agent 还需要别的视图:当前在哪个 turn、哪个 step、上一轮进行到哪。这类状态由 session projections 提供。一个投影就是 ProjectionDefinition{key, stateSchema, init, apply(state, event), wire?, stateVersion}——一个纯同步 fold:给初值,每个事件流过 apply,返回新状态。约定是"同一引用返回即零下游工作":apply 没改状态就返回原引用,框架据此跳过全部后续计算。这条约定把投影的成本模型固定下来:一个事件 N 个投影的总开销,取决于真正改变了状态的投影数,而不是注册总数。
注册表 SessionProjectionRegistry(packages/session/session-projection/src/index.ts:199)只订阅一次 session/event,把每个事件分发给每个单元的 apply;每会话每 key 惰性建一个带 watermark 的 cell,注册本身是调用方 fiber 的 effect,插件卸载投影即消失——还记得第 4 篇的规约吗,这里又见一次:投影不拥有独立生命周期,它跟着注册者走。读接口有两个语义层次:stateOf(session, key) 只算 host 状态;snapshot(session) 同步给出一致 cut(asOfSeq + 全量值),用于跨进程传输。客户端可见的单元进 snapshot,纯服务端优化(如缓存)则声明为 host-only。
一个具体例子:turnBoundary 投影由 agent loop 注册(packages/core/agent-loop/src/index.ts:57,stateVersion: 2),折叠出 openTurnStartSeq、lastStepStartSeq、lastStepBoundary、lastTurn 四个字段。UI 显示"第 3 轮 · 第 2 步"、agent loop 判断"上一个 turn 是否已闭合",读的都是这个投影,而不是去日志里倒扫描。stateVersion 字段不是摆设:状态结构演进时版本号随之递增,持久缓存据此丢弃旧格式的行。
更重的投影还有持久缓存(dsh-session-projection-cache,域 session_projcache 按 (sessionId, key, ver, seq, val) 存 state,write-behind 加三个强制点:创建、turn/end、dispose),恢复时 restoreFloor 取"最低可用水位线减一"作为尾读起点,崩溃后不必从头 fold。日志很长时(长会话轻松上万条事件),这条缓存是把投影成本从 O(全长)压回 O(增量)的关键。
把第 4 篇的 Cordis 视角叠回来会更清楚:投影注册是 fiber 的 effect,事件订阅走 ctx.on 式的登记,一切派生状态的生死都挂在插件生命周期上。日志本身是唯一的"长命"对象——它存活于进程之外,这也是下一篇的主题。
小结
本篇的主线是一条纪律:日志是唯一事实源,其余全是投影。SessionEvent 用 seq = log.length 的连续性契约和 fail-closed 的 ignorable 标记保证可读;append() 把校验、发布、批窗口落盘分层,flush 是唯一的持久性屏障;deriveMessages 用一张投影规则表定义"模型看到了什么",紧凑流让每条 assistant 消息可完整重放;session projections 用纯 fold 把任意派生状态搬出日志,又不复制日志。replace 遮蔽而非删除、观察者失败被隔离、事件在写入点拒绝——这些选择在为下一篇铺路:日志写在内存里,进程死了怎么办?第 6 篇看持久化:磁盘布局、格式代际、迁移链与崩溃恢复。