Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/DeepSeek Harness/第8章

第8章:会话 —— 事件溯源与 surface 投影

8 分钟 · 更新于 2026-09-01

第8章:会话 —— 事件溯源与 surface 投影

前面几章反复出现一个说法:"消息历史是派生物"。这一章把它讲透: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 的做法是:

text
会话日志(append-only,事件流)
    │  派生(surface 投影)
    ▼
模型请求用的消息数组

为什么值得翻转? 三个原因:

  1. 无损回放 —— 日志里有原始流分片(assistant/chunk),token 级保真。出问题时你能精确看到模型当时流出了什么
  2. 崩溃恢复 —— 日志是持久化的,崩溃后从日志重建,不依赖内存状态
  3. 压缩不丢数据 —— 第 9 章的压缩只是往日志追加一个"遮蔽"节点,原始事件还在

二、事件词汇:14 个核心成员

dsh-session 定义了 14 个核心事件类型 [官方文档 + 源码]:

事件载荷含义
turn/start{turn}打开轮次(领取输入前)
turn/end{turn, reason}关闭轮次
step/start{turn, step}打开 step(一次模型调用 + 工具执行)
step/end{turn, step}关闭 step
user/messageUserMessage用户消息:直接提示 / 合成上下文 / 续轮
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/contextRequestContext路由元数据,仅在路由或容量变化时记录
session/end-seed{}构造 seed 的结束标记
agent/inbox/splicedsplice 坐标inbox 变更的持久投影

【来源:dsh-session/lib/types/types.d.ts:223-354】

全部插件合计的事件类型更多(本机实测 43 种,含 compaction/*llm/retrygoal/changeapproval/*tool-workflow/* 等)—— 它们由各插件通过类型声明合并追加。

todo/write 是个精妙的设计 待办列表不进派生历史(模型看不到),只是 UI 状态。这意味着你可以让 agent 维护一个待办,而不污染模型上下文。整表快照 + 最新写入者胜,也让回放后的状态确定。


三、实测:一次会话的事件分布

本机某次会话的 569 条事件统计 [实测]:

text
assistant/chunk        355      ← 流式分片占大头
tool/call               44
tool/result             43
step/start              33
assistant/message       33
step/end                32
agent/inbox/spliced      6
user/message             6
turn/start               3
turn/end                 2
session/title            2
session                  1
permission/preset        1
sandbox/mode             1
approval/policy          1
request/header           1
request/context          1
goal/change              1
todo/write               1
...

注意 assistant/chunk 的 355 条 —— 这就是第 5 章流水线里 assistant/chunk × 355 的日志视角。日志记录了每一个流式分片

还有 sandbox/modeapproval/policypermission/preset —— 会话的沙箱模式、审批策略、权限预设都作为事件入账(第 10 章展开)。


四、surface:怎么从日志到消息

事件溯源与 surface 投影|900

配图说明:左侧是仅追加的会话日志(每个事件无损 JSON,含原始分片);中间是 surface 层 —— 只有 user/message、assistant/message、tool/result 三类事件能上 surface,操作有 append 与 replace(压缩遮蔽)两种;右侧是模型请求(= surface 折叠结果)。模型读 surface(压缩后视图),UI 投影追加事件(完整历史)—— 两个消费方读不同视图。压缩只是追加 replace 节点,原始事件永不删除。

4.1 只有三类事件产生消息

typescript
type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';

【来源:dsh-session/lib/types/types.d.ts:357-362】

这是唯一能出现在 surface 上的三类事件。其他事件(chunk、tool/call、turn/start…)只存在于日志,不直接进派生历史。

4.2 SurfaceOp:追加与替换

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 区间遮蔽掉。

4.3 面向人 vs 面向模型

一个值得注意的取舍 [官方文档]:

面向人的 transcript 必须投影追加来源事件,而不是 session.surface,因为已落地的替换会遮蔽读者已经看到的历史;面向模型的消费方继续读取 session.surface。 —— dsh-session/README.zh.md

UI 显示模型上下文读的是两个不同的视图:UI 显示完整历史(压缩前的也显示),模型只看到 surface(压缩后的)。配套两个类型守卫:isAppendSurfaceEvent / isReplacementSurfaceEvent

4.4 逐节点投影规则

deriveEventMessage(event) 是"THE per-node projection rule":

Session.deriveMessages 在实时 surface 上折叠它,外部重建器在日志前缀的 surface 上折叠同一函数即可重建任何一次请求所依据的确切消息。 —— dsh-session/lib/types/surface.d.ts

同一个函数既服务实时路径又服务离线重建 —— 这是"可验证重建"的保证。


五、存储格式:分片行 + 多帧 zstd

5.1 chunk-rows:分片打包

流式分片(assistant/chunk)有个特点:一条消息产生几百条几乎一样的 JSON。模块注释给了实测数据:

提供方流式输出 token 级增量,因此日志存储了数百条几乎相同的事件行,其 JSON 外壳比载荷大得多(真实 DeepSeek 会话实测约 56×)。 —— dsh-session/lib/types/chunk-rows.d.ts

解法:把连续的同类 chunk 打包成一行存储行,三种 tag:

text
text-chunks          { texts: string[] }      // 每成员一条,绝不拼接:token 边界是数据
reasoning-chunks     { texts: string[] }
tool-call-chunks     { id, name?, args: string[] }

成员 k 重建为 seq seq0 + k、time time0 + 前 k 个 gapgap 可以为负——墙钟回拨)。最小打包长度 MIN_RUN = 3

安全约定(很重要):

存储行是可持久编码词汇,不是会话事件:绝不进入 Session.events,用裸(无斜杠)类型 tag,读者不会混淆。 编码器有精确形态 allowlist:不能完整识别的原样存储 —— 未知字段或未来的分片变体失去压缩,绝不丢数据。 解码器先校验再展开,畸形行响亮失败而不静默丢整段。

【来源:dsh-session/lib/types/chunk-rows.d.ts】

效果:逻辑日志实测约小 60%。

5.2 多帧 zstd:为什么不是一个大压缩块

本机实测会话文件 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

三个理由:

  1. 仅追加:追加一个新 frame 不需要重写已有字节(日志只追加的特性)
  2. 崩溃恢复粒度:加载时验证每个完整 frame;最后一个 frame 结构不完整时,从 frame 开头截断,用合成的 closer 重新编码 —— 崩溃最多丢半个帧
  3. 廉价列举:列表只解 header frame 即可,不必解压整个日志

一个根只属于一种编码(zstd 或明文),启动发现会拒绝相反的 suffix。


六、projection:从日志到状态

6.1 投影单元

dsh-session-projection 定义投影的数学形状

typescript
ProjectionDefinition<K, S> = {
  key; schema; init(); apply(state, event); view(state); stateVersion;
}

【来源:dsh-session-projection/README.zh.md】

由三个纯同步函数外加若干声明构成的状态驱动计算单元,绝不是一个不透明的 getter。 —— dsh-session-projection/README.zh.md

三条承重约定:

  • 框架驱动,领域计算:注册表只订阅一次 session/event,每个已提交事件主动经过每个单元的 apply;领域不持有任何订阅
  • 同引用即无工作:无关事件的 apply 必须返回同一个状态引用,驱动以 Object.is 把守变更流
  • 全量值事件规则:携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量

6.2 冷读阶梯

dsh-session-projection-cache 把投影状态持久化为检查点,并提供冷读阶梯coldSnapshot):

读取阶梯,正常路径无需加载全量日志:缓存行 → restoreFloor(锚定在最低可用水位之前一个事件)→ 持久化 readFrom(id, floor) → restore → 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读。 —— dsh-session-projection-cache/README.zh.md

翻译:恢复一个会话的投影状态时,不重新读整个日志 —— 先读缓存行,再只重放缓存之后的事件。如果日志被截断了(崩溃),缓存越界就整段重读。缓存是"折叠捷径",可能是陈旧的,但绝不会错seq 精确说明陈旧到哪)。


七、checkpoint:在关键边界落盘

dsh-session-checkpoint-policy 决定什么时候 flush 日志到持久化。三个时机(源码就 15 行):

javascript
function apply(ctx) {
  ctx.on("llm/stream", (options, next) => {
    if (options.sessionId === void 0) return next();
    const session = ctx.sessions.get(options.sessionId);
    return session === void 0 ? next() : afterCheckpoint(ctx, session, next);
  });
  ctx.on("tools/execute", async (exec, next) => {
    if (exec.agent === void 0 || exec.parent !== void 0) return next();
    await ctx.sessions.flush(exec.agent.session);
    ...
  });
  ctx.on("agent/pre-step", async ({ agent }, next) => {
    await ctx.sessions.flush(agent.session);
    return next();
  });
}

【来源:dsh-session-checkpoint-policy/lib/index.js:60-76】

即三个边界:① 模型收到请求前(缓冲的请求事件先持久化);② 顶层工具副作用前(嵌套分派跳过,复用外层检查点);③ 每个 pre-step 边界

失败策略是 fail-closed

在模型和工具边界,检查点被拒绝时会按失败即阻止原则处理:适配器和顶层工具正文都不运行。

为什么和持久化后端拆成两个插件

持久化后端会为追加事件启动有界后台批次,并把每个已请求的 flush 变成即时屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失批处理窗口内的事件。

dsh-session-checkpoint-policy/README.zh.md

理解:后端负责"怎么持久化"(批处理),策略负责"什么时候必须持久化"(三个屏障)。这是第 4 章"政策是事件门禁"的又一个实例 —— 检查点策略本身不实现持久化,只监听事件决定何时要求 flush。


八、动手复核

powershell
# 1. 会话文件的 zstd 魔数
$bytes = [System.IO.File]::ReadAllBytes($env:DSH_SESSION_JSONL)[0..7]
($bytes | ForEach-Object { $_.ToString('X2') }) -join ' '   # 期望 28 B5 2F FD ...

# 2. 分帧统计(node 脚本,用 zstdDecompressSync 逐帧解)
# 参考第 5 章 1.4 节的取证方法,数一下你有多少帧

# 3. 事件类型分布(解压后按 type 统计)

# 4. 核心事件类型定义
Select-String -Path "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-session\lib\types\types.d.ts" `
  -Pattern "'turn/start'|'assistant/chunk'|'todo/write'" | Select-Object -First 5

# 5. 全部 43 种已知事件类型
Get-Content "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-session\lib\types\known-event-types.js" | Select-Object -First 20

九、总结

会话是 dsh 事件溯源思想的落点:

  1. 日志是真源,消息是派生 —— 传统做法整个翻转
  2. 14 个核心事件 + 43 个全集 —— assistant/chunk 记录原始分片,todo/write 不进历史
  3. surface 只有三类消息事件 —— append 正常追加,replace 留给压缩
  4. 分片行打包 —— 同类 chunk 合成一行存储行(实测省 60%),绝不丢数据
  5. 多帧 zstd —— 追加友好、崩溃恢复粒度、廉价列举
  6. 投影 + 冷读阶梯 —— 恢复不重读全日志,缓存陈旧但绝不错
  7. checkpoint 三屏障 —— 模型请求前、工具副作用前、pre-step 边界

下一章是事件溯源的终极受益者:上下文工程 —— 系统提示词的装配与压缩


  • README-教程总览
  • 第5章-Agent-Loop-唯一含循环逻辑的包
  • 第9章-上下文工程-系统提示词的装配与压缩
  • 第10章-沙箱与权限-fail-closed的四层防线 —— sandbox/mode 事件的完整展开

本章目录
一、核心翻转:日志是真源,消息是派生二、事件词汇:14 个核心成员三、实测:一次会话的事件分布四、surface:怎么从日志到消息五、存储格式:分片行 + 多帧 zstd六、projection:从日志到状态七、checkpoint:在关键边界落盘八、动手复核九、总结Related Documents
苏ICP备2025204887号-2