前面几章反复出现一个说法:"消息历史是派生物"。这一章把它讲透:dsh 的会话不是"存消息列表",而是一条事件溯源(event sourcing)的追加日志 —— 消息只是从日志派生出来的视图。
这带来三个让 Pi 教程读者会心一笑的结果:完整回放、崩溃恢复、可压缩 —— 而且压缩不丢任何原始事件。
dsh-session 的 README 第一段就是结论:
事件溯源的会话日志和内存存储。Session 是 agent 全部交互历史的仅追加真源,LLM 消息历史由它派生。原始日志之上维护一个 surface 层(产生消息事件的有序投影),以便高效派生和压缩。 —— dsh-session/README.zh.md
类型定义里说得更完整:
可合并扩展的、仅追加的 agent 交互真源。消息历史从这条日志派生。 每个事件都是无损 JSON,序列号保持连续,包括原始分片,因此持久化可以原样存储规范日志。 —— dsh-session/lib/types/types.d.ts
传统做法是存 messages: [{role, content}, ...],agent 每次请求直接用这个数组。dsh 的做法是:
为什么值得翻转? 三个原因:
dsh-session 定义了 14 个核心事件类型 [官方文档 + 源码]:
| 事件 | 载荷 | 含义 |
|---|---|---|
| turn/start | {turn} | 打开轮次(领取输入前) |
| turn/end | {turn, reason} | 关闭轮次 |
| step/start | {turn, step} | 打开 step(一次模型调用 + 工具执行) |
| step/end | {turn, step} | 关闭 step |
| user/message | UserMessage | 用户消息:直接提示 / 合成上下文 / 续轮 |
| assistant/chunk | {turn, step, chunk} | 原始流分片,token 级回放保真 |
| assistant/message | {turn, step, message, usage?} | 组装后的 assistant 消息 |
| tool/call | {turn, step, callId, name, arguments} | 工具调用;arguments 是模型原样的未解析 JSON 字符串 |
| tool/result | {turn, step, message, error?, meta?} | 工具结果 |
| todo/write | {todos} | 整表快照,最新写入者胜;绝不进入派生历史 |
| request/header | {header, reason} | 请求的完整 header(第 6 章讲过) |
| request/context | RequestContext | 路由元数据,仅在路由或容量变化时记录 |
| session/end-seed | {} | 构造 seed 的结束标记 |
| agent/inbox/spliced | splice 坐标 | inbox 变更的持久投影 |
【来源:dsh-session/lib/types/types.d.ts:223-354】
全部插件合计的事件类型更多(本机实测 43 种,含 compaction/*、llm/retry、goal/change、approval/*、tool-workflow/* 等)—— 它们由各插件通过类型声明合并追加。
todo/write 是个精妙的设计 待办列表不进派生历史(模型看不到),只是 UI 状态。这意味着你可以让 agent 维护一个待办,而不污染模型上下文。整表快照 + 最新写入者胜,也让回放后的状态确定。
本机某次会话的 569 条事件统计 [实测]:
注意 assistant/chunk 的 355 条 —— 这就是第 5 章流水线里 assistant/chunk × 355 的日志视角。日志记录了每一个流式分片。
还有 sandbox/mode、approval/policy、permission/preset —— 会话的沙箱模式、审批策略、权限预设都作为事件入账(第 10 章展开)。

配图说明:左侧是仅追加的会话日志(每个事件无损 JSON,含原始分片);中间是 surface 层 —— 只有 user/message、assistant/message、tool/result 三类事件能上 surface,操作有 append 与 replace(压缩遮蔽)两种;右侧是模型请求(= surface 折叠结果)。模型读 surface(压缩后视图),UI 投影追加事件(完整历史)—— 两个消费方读不同视图。压缩只是追加 replace 节点,原始事件永不删除。
【来源:dsh-session/lib/types/types.d.ts:357-362】
这是唯一能出现在 surface 上的三类事件。其他事件(chunk、tool/call、turn/start…)只存在于日志,不直接进派生历史。
surface 节点带操作标记:
| 操作 | 含义 |
|---|---|
| append | 加到尾部 —— user/assistant/tool 消息的正常路径 |
| { op: 'replace', start, end } | 用本节点替换 surface 上 start~end 的节点;sourceEventSeqs 必须包含每个被遮蔽节点 |
【来源:dsh-session/lib/types/types.d.ts:375-392】
压缩就是 replace 的消费者(第 9 章):压缩不删除任何日志事件,只是追加一个新节点把旧 surface 区间遮蔽掉。
一个值得注意的取舍 [官方文档]:
面向人的 transcript 必须投影追加来源事件,而不是 session.surface,因为已落地的替换会遮蔽读者已经看到的历史;面向模型的消费方继续读取 session.surface。 —— dsh-session/README.zh.md
UI 显示和模型上下文读的是两个不同的视图:UI 显示完整历史(压缩前的也显示),模型只看到 surface(压缩后的)。配套两个类型守卫:isAppendSurfaceEvent / isReplacementSurfaceEvent。
deriveEventMessage(event) 是"THE per-node projection rule":
Session.deriveMessages 在实时 surface 上折叠它,外部重建器在日志前缀的 surface 上折叠同一函数即可重建任何一次请求所依据的确切消息。 —— dsh-session/lib/types/surface.d.ts
同一个函数既服务实时路径又服务离线重建 —— 这是"可验证重建"的保证。
流式分片(assistant/chunk)有个特点:一条消息产生几百条几乎一样的 JSON。模块注释给了实测数据:
提供方流式输出 token 级增量,因此日志存储了数百条几乎相同的事件行,其 JSON 外壳比载荷大得多(真实 DeepSeek 会话实测约 56×)。 —— dsh-session/lib/types/chunk-rows.d.ts
解法:把连续的同类 chunk 打包成一行存储行,三种 tag:
成员 k 重建为 seq seq0 + k、time time0 + 前 k 个 gap(gap 可以为负——墙钟回拨)。最小打包长度 MIN_RUN = 3。
安全约定(很重要):
存储行是可持久编码词汇,不是会话事件:绝不进入 Session.events,用裸(无斜杠)类型 tag,读者不会混淆。 编码器有精确形态 allowlist:不能完整识别的原样存储 —— 未知字段或未来的分片变体失去压缩,绝不丢数据。 解码器先校验再展开,畸形行响亮失败而不静默丢整段。
【来源:dsh-session/lib/types/chunk-rows.d.ts】
效果:逻辑日志实测约小 60%。
本机实测会话文件 session.jsonl.zstd 以 zstd 魔数 28 B5 2F FD 开头,且有 272 个帧。官方 README 解释了多帧设计:
默认产物是独立 Zstandard frame 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。 —— dsh-session-persistence-jsonl/README.zh.md
三个理由:
一个根只属于一种编码(zstd 或明文),启动发现会拒绝相反的 suffix。
dsh-session-projection 定义投影的数学形状:
【来源:dsh-session-projection/README.zh.md】
由三个纯同步函数外加若干声明构成的状态驱动计算单元,绝不是一个不透明的 getter。 —— dsh-session-projection/README.zh.md
三条承重约定:
dsh-session-projection-cache 把投影状态持久化为检查点,并提供冷读阶梯(coldSnapshot):
读取阶梯,正常路径无需加载全量日志:缓存行 → restoreFloor(锚定在最低可用水位之前一个事件)→ 持久化 readFrom(id, floor) → restore → 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读。 —— dsh-session-projection-cache/README.zh.md
翻译:恢复一个会话的投影状态时,不重新读整个日志 —— 先读缓存行,再只重放缓存之后的事件。如果日志被截断了(崩溃),缓存越界就整段重读。缓存是"折叠捷径",可能是陈旧的,但绝不会错(seq 精确说明陈旧到哪)。
dsh-session-checkpoint-policy 决定什么时候 flush 日志到持久化。三个时机(源码就 15 行):
【来源:dsh-session-checkpoint-policy/lib/index.js:60-76】
即三个边界:① 模型收到请求前(缓冲的请求事件先持久化);② 顶层工具副作用前(嵌套分派跳过,复用外层检查点);③ 每个 pre-step 边界。
失败策略是 fail-closed:
在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。
为什么和持久化后端拆成两个插件:
持久化后端会为追加事件启动有界后台批次,并把每个已请求的 flush 变成即时屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失批处理窗口内的事件。
(dsh-session-checkpoint-policy/README.zh.md)
理解:后端负责"怎么持久化"(批处理),策略负责"什么时候必须持久化"(三个屏障)。这是第 4 章"政策是事件门禁"的又一个实例 —— 检查点策略本身不实现持久化,只监听事件决定何时要求 flush。
会话是 dsh 事件溯源思想的落点:
下一章是事件溯源的终极受益者:上下文工程 —— 系统提示词的装配与压缩。