L6 · 会话即日志
Lesson 06 · 模块 B · 读懂

会话即日志:
SessionEvent 与事件溯源。

每一轮对话、每一个 token、每一次工具调用,在这套 harness 里都以同一种形式存在: 追加到会话日志的一条 SessionEvent。读懂这条只增日志,就看懂了 dsh 的记忆系统——模型历史、fork、resume、transcript、遥测,全是它的投影。

精读 20 分钟 练习 10 分钟 · 6 题 前置 L5 事件系统
学完你将能够
  • 解释为什么「只增日志是唯一真源」让状态与日志在结构上不可能分歧
  • 说清 deriveMessages() 的投影规则,以及 assistant/chunkassistant/message 的分工
  • 列举从同一条事件流派生的能力,并说明 SESSION_FORMAT_VERSIONignorable 的作用
  • 用 model-visible ⟺ logged 铁律,判断一种新输入该以什么方式接入
01 · Event Sourcing

事件溯源心智:日志就是状态

在 dsh 里,一个 Session 不是「带历史的消息对象」,而是一份仅追加(append-only)的、 类型化的 SessionEvent 日志——它是这次 agent 交互的唯一真源。 模型看到的消息历史从不单独存储,而是每次从日志派生;回放,就是用同一组事件重新派生一遍。 这是 packages/core/session(dsh-session)的核心决策,记录在 Agent Note 2026-06-11-event-sourced-sessions 里。

这个设计有一个被明确否决的对照组:「可变消息数组 + 事件仅作通知」。那条路实现更简单, 但内存状态与日志可能悄悄分叉。事件溯源让日志本身就是状态——分歧在结构上不可能发生。 这就是它被称为全仓库中心的原因:改日志 = 改产品的记忆,下游一切(重放、分支、审计、统计)随之改变。

日志有几条不容妥协的硬约定,读代码前先把它们刻进脑子:

  • seq 单调且连续seq === log.length,不能跳号、不能过滤——连原始的流式 chunk 都占一个序号。
  • 事件不可变:进入日志时被深冻结;append 是同步操作,热路径从不阻塞 I/O。
  • 无损 JSON:所有 event.data 必须可无损序列化为 JSON,Session.append 在源头校验并直接拒绝坏事件。
  • 先提交后广播:追加提交后通过 session/event 同步通知;持久化插件异步缓冲,在 session/flush 检查点排空。

一份持久化日志(JSONL 后端的逻辑视图)长这样——第一行是 header,之后每行一条事件:

{"type":"session","version":3,"id":"session-…","createdAt":…} ← SessionHeader 行 {"type":"turn/start","seq":0,"time":…,"data":{"turn":1}} {"type":"step/start","seq":1,…,"data":{"turn":1,"step":1}} {"type":"user/message","seq":2,…,"data":{…},"surfaceOp":"append"} ← 你的输入 {"type":"request/header","seq":3,…,"data":{"header":{…},"reason":"initial"}} {"type":"assistant/chunk","seq":4,…,"data":{"chunk":{…}}} ← 原始流分片 {"type":"assistant/chunk","seq":5,…,"data":{"chunk":{…}}} {"type":"assistant/message","seq":6,…,"surfaceOp":"append","sourceEventSeqs":[4,5]} {"type":"tool/call","seq":7,…,"data":{"callId":…,"name":"bash","arguments":…}} {"type":"tool/result","seq":8,…,"surfaceOp":"append"} ← 工具结果 {"type":"step/end","seq":9,…} {"type":"turn/end","seq":10,…,"data":{"turn":1,"reason":{"kind":"completed"}}}
词汇表不是封闭的

SessionEventMap 可以被插件用 declaration merging(声明合并)扩展——压缩 seam 的 compaction/start / compaction/summary / compaction/end、钩子桥接的 hook/invoked / hook/result 都是这么来的。所以「日志」是一个可扩展的真源, 而不是一张写死的表。生成的 docs/persistence-catalog.md 列举了全部成员。

02 · Projection

deriveMessages():从日志投影模型历史

日志里的大多数事件并不直接变成消息。只有三种 surface 事件——user/messageassistant/messagetool/result——携带 surfaceOp 标记, 声明自己如何进入有序的 surface。deriveMessages() 沿 surface 节点逐条折叠纯函数 deriveEventMessage(),投影出模型看到的 Message[]。它是缓存的 (每个节点只在首次出现时投影一次,surface 被 replace 重写时重建)且冻结的 (调用方拿到共享的深冻结消息,改历史这件事在类型上就写不出来)。

投影规则一共四条,其余事件一律是结构信息:

  • user/message → 一条 user 消息,content 原样呈现。注入的上下文(agent.inject() 的产物:文件变更通知、skill 内容、cron 提醒……)也是它,靠 source 字段区分来源。
  • assistant/message → 一条 assistant 消息。内容为空的会被跳过(比如 max-tokens 截断且无输出的步骤),但事件仍留在日志里保存 usage、提供方与模型。
  • tool/result → 一条携带 tool-result 块的 user 消息。
  • turn/*step/*request/*assistant/chunk 等其余事件 → 不产生任何消息。

assistant/chunk 为什么存在?因为它保的是另外两样东西:token 级 replay 保真UI 流式渲染保真。原始分片原样落日志,派生历史时却被跳过——组装后的 assistant/message 才是权威,并用 sourceEventSeqs 回指生成它的那些 chunk 的 seq。 token 记账走同一条路:读每步的 usage 分片,没有时回退到 assistant/message.usage

压缩(compaction)是这套投影的另一半:surfaceOp: {op:'replace',start,end} 会遮蔽一段 surface 节点,让派生历史变短,而日志原文一字不动。派生成本随日志长度增长,压缩是预期的缓解手段, 不是改写日志——日志永远只增。

03 · One Stream, Many Consumers

同一条流的派生物

因为一切皆日志的投影,下面这些看似无关的能力,共享同一条事件流:

  • forkctx.sessions.fork(source, boundary?) 取到指定 seq 为止的前缀做种子,开出一个活跃的子会话;header 里记下 parentSessionseedLength。以开放轮次结尾的前缀会被拒绝,而不是静默截断。
  • resume — 重启后 ctx.agents.resume({ resumeSessionId }) 用整段持久日志做构造种子;session/end-seed 事件标记「种子历史」与「本次生命周期的实时写入」之间的边界。
  • transcript — 面向人类的文本记录是另一个投影,读的是追加来源的事件——surface 会有意遮蔽被 replace 概括掉的范围。
  • telemetry — 订阅 session/event 的消费方;接手一份已有日志的会话时从 firstLiveSeq 开始,不重复计算种子历史。
  • 持久化 — 持久化插件订阅 session/event 异步缓冲落盘,session/flush 排空。JSONL 后端(每会话一个 session.v3.jsonl.zstd)遵守同一份约定:每个事件包括 chunk 都无损保存,seq 保持连续。(SQLite 如今只以 session-query-sqlite 的身份作为查询索引层存在,不承担持久化。)
只增 SessionEvent 日志 seq 0 turn/start seq 1 step/start seq 2 user/message seq 4 assistant/chunk ×N seq 6 assistant/message seq 8 tool/result seq 10 turn/end 模型历史 · deriveMessages() 每次模型请求的消息数组 fork · ctx.sessions.fork() 取前缀做种子,开出分支会话 resume · 重启续跑 持久日志重放为构造种子 transcript · 文本记录 面向人类的追加来源投影 telemetry · 遥测 订阅 session/event 的统计 持久化同样订阅 session/event:每个事件(含 chunk)无损落盘、seq 连续——resume 才有源可放。
图 6-1 · 一条只增日志,五个派生消费者。它们不各自存一份状态,而是从同一条流重新推导。

版本闸门:SESSION_FORMAT_VERSION 与 ignorable

日志是要落盘的,落盘就有格式问题。dsh-session 的 SESSION_FORMAT_VERSION 现在是 3,且这个格式已随产品正式发布——「哪个版本已发布」的单一真源是权威文档 docs/session-format-status.md。header 里的 version 在创建时盖戳, 兼容则靠相邻迁移:迁移包族 session-format-v0-to-v1v1-to-v2v2-to-v3 一环扣一环,打开旧日志时沿这条链升级为当前代——写打开还会把迁移结果 发布成一个新的 generation 文件。历史 generation 一旦落盘就不可变:不移动、不覆盖、 不删除已提交的 generation,源文件逐字节保留,于是一个会话目录里可能同时躺着 session.jsonl(v0)与 session.v1/v2/v3.jsonl,运行时永远选择数值最高的那一代。 SessionFormatUnsupportedError 只拒绝没有迁移路径可走的版本——比如比当前构建更新的日志 (错误消息提示先升级 harness)——绝不猜着读。

另一条闸门在事件信封上:ignorable: true。读取器遇到不认识的事件类型时默认必须拒绝重建—— 因为一条不认识的必需事件可能改变日志其余部分的解读方式;只有丢失也不影响重建的纯信息记录, 写入方才允许标 ignorable: true 让它可跳过。默认必需意味着忘标记的后果只是「过度拒绝」 (一种不便),而不是静默恢复出一个被掏空的会话(一次事故)。这条规则由 KNOWN_SESSION_EVENT_TYPES 清单(gen-persistence-catalog 生成)在加载边界执行。

04 · The Invariant

铁律:model-visible ⟺ logged

这条双向约束是整套设计的承重墙:抵达模型请求的一切,都必须能从日志重建;反过来,模型历史也只从日志渲染。 连请求信封都不例外——request/header 事件把调用配置、渲染后的系统提示词、组装好的工具 schema 一并写进日志,因此每个对话请求都是日志的纯函数。一项运行时不变量持续断言这个关系, 而不是靠 Code Review 时的人肉记忆。

它直接规定了你扩展时的动作顺序。想让模型看到一种新输入(比如一种新的环境通知),正确路径是: 先扩展 SessionEventMap 声明一个 session 事件、把它 append 进日志,再由投影把它渲染进请求。 内建的 agent.inject() 就是这个模式的实例:注入的上下文落到下一次获准的请求里, 成为一条带 source 标记的 user/message——于是 fork、resume、transcript、 遥测全都自动看见了它。

陷阱 · 请求前临时塞数据

反模式:在请求发出前,临时往 messages 数组里塞一段不入日志的内容。当时模型「看见」了, 但重放、fork、resume、遥测里它都不存在——日志与模型所见就此分叉。 这正是事件溯源要在结构上消灭的事故,也是运行时不变量会抓住的违规。

05 · Hands On

动手试:解剖一份真实日志

默认配置下,会话持久化在 ~/.dsh/sessions 下(root 来自 base 组合包里的 !!js dshHomePath('sessions')),文件是 Zstandard 压缩的 session.v3.jsonl.zstd, 不能直接按行阅读。为了看清每一条事件,我们用 L2 学过的 patch 技能做一个实验 overlay: 换成未压缩格式,并写入一个独立的 root(一个 root 只属于一种编码,混用会被拒绝)—— 新会话由当前构建写出,落盘为 session.v3.jsonl

动手试 · 8 分钟
  1. 新建 plain-sessions.yml,按 id 替换 JSONL 后端的整条 config:
# plain-sessions.yml —— 未压缩 + 独立 root 的实验 overlay - id: session-persistence-jsonl # 定位 base 组合包里的条目 config: # 整条替换它的 config root: !!js dshHomePath('sessions-plain') compression: 'none'

2. 带着 overlay 跑一次会动用工具的任务:

pnpm dsh --profile headless --patch ./plain-sessions.yml "用 bash 工具看看当前目录有什么文件,再用一句话总结"

3. 找到这次会话的日志文件(项目目录 → 会话目录 → transcript):

find ~/.dsh/sessions-plain -name session.v3.jsonl

4. 统计事件类型,再各揪一条出来看(把路径换成上一步找到的那个):

grep -o '"type":"[a-z/-]*"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl | sort | uniq -c | sort -rn
grep -m1 '"type":"user/message"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl; grep -m1 '"type":"assistant/chunk"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl; grep -m1 '"type":"tool/result"' ~/.dsh/sessions-plain/*/*/session.v3.jsonl

在输出里指认三件事:user/message 是你的输入进入模型可见 surface 的那一刻; assistant/chunk 通常有几十上百条,是 token 级原始流;tool/result 是工具结果的模型面内容。 如果这轮没有 tool/result(模型没调用工具),换一个明确要读文件的任务再来一次。

观察重点

注意 assistant/chunk 的数量级:它们占掉了大部分 seq。这就是「seq 不能过滤、chunk 也占号」的直观含义—— 删掉它们,replay 和 UI 流式回放就都失真了。

06 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
在 dsh 里,发给模型的消息历史(Message[])来自哪里?

消息历史从不单独存储:surface 事件(user/message / assistant/message / tool/result)携带 surfaceOpderiveMessages() 沿它折叠出历史。A 正是被明确否决的替代方案——可变数组 + 通知会让状态与日志悄悄分叉。

单选
你想让模型看到一种新的输入(例如新的环境通知),正确的做法是?

铁律 model-visible ⟺ logged:进模型请求的必须能从日志重建。临时塞数据(A)或 UI 层拼接(D)都让模型「看见」了日志里不存在的东西,重放、fork、resume、遥测全部丢失它,运行时不变量会断言违规。内建的 agent.inject() 就是 C 的实例。

多选
下列哪些能力是从同一条 SessionEvent 日志派生的?(选出所有正确项)

架构文档的原话:fork、恢复、transcript、遥测和持久化都派生自该事件流。E 错:工具结果的模型面内容以 tool/result 事件进入同一条日志(它还要参与模型历史投影),没有平行的私有存储。

多选
关于事件溯源会话,哪些说法是正确的?

B 违反只增约定与 seq 连续性——压缩用 replace 遮蔽 surface,也不动日志原文。D 直接撞上 model-visible ⟺ logged 铁律。A 对应崩溃恢复用 turn/end { kind: 'interrupted' } 配平遗留轮次的设计;C 是持久化约定明文:每个事件包括 chunk 无损保存。

问答为什么 session 日志被称为「一切的源头」?写下答案后点击对照

参考要点:① 它是唯一真源——消息历史不单独存储,deriveMessages() 从日志派生,状态即日志,分歧在结构上不可能;② fork / resume / transcript / telemetry / 持久化全部从同一条流派生,改日志等于改产品所有下游的记忆与行为;③ 连请求都是日志的纯函数——request/header 把调用配置、系统提示词、工具 schema 都写进日志,重放即可完整重建一次对话。

问答解释 model-visible ⟺ logged 的含义,以及它约束了什么扩展行为。写下答案后点击对照

参考要点:双向等价——抵达模型请求的一切必须能从日志重建(→),模型历史也只从日志渲染(←),由运行时不变量持续断言。约束:新增模型可见输入必须先扩展 SessionEventMap 增加 session 事件并在投影中渲染它,禁止在请求前临时塞数据;否则重放、fork、resume、遥测都看不到这份内容,日志与模型所见分叉——这正是事件溯源要消灭的事故。agent.inject() 是合规路径的内建实例。

本课小结

现在你知道 dsh 的记忆长什么样了:一条只增的 SessionEvent 日志是唯一真源, deriveMessages() 沿 surface 投影出模型历史,fork / resume / transcript / 遥测 / 持久化 全是同一条流的派生物,而 model-visible ⟺ logged 铁律保证了这一切不发散。 下一课把模块 B 串起来:跟着一次 turn 从 inbox 到 turn/end 走完完整生命周期, 看这条日志是在哪里、以什么顺序一笔一笔写出来的。