第四部:事件账本——Session是append-only日志
Session不是一个可变状态对象,而是一个只追加的事件日志。从append校验到surface投影,从JSONL原子写到崩溃修复,从fork到compaction——所有操作都是日志追加。
![[图片占位:一本发光的账本,每一页是一个事件信封,seq编号连续。append-only的钢笔在追加新条目,投影灯光从账本投向多个屏幕(模型历史、UI、持久化)。深海蓝鲸背景,账本浮在水中发光。色调:深海蓝+金色光。]](/static/images/handbook/deepseek-harness-internals/parts/04-event-ledger.png)
展开阅读路线与实验入口
你以为 Session 是一个聊天状态对象——里面存着 messages 数组、当前上下文、token计数。你调用 session.addMessage() 修改它,调用 session.updateContext() 覆盖它。错。
在 DeepSeek Harness 里,Session 是一本 append-only 账本。你永远不修改已写入的条目,只能在末尾追加新事件。模型看到的历史、UI渲染的对话、磁盘上的持久化文件——全部是同一份日志的不同投影。
这是整个系统最反直觉的设计决策,也是可靠性的根基。
这一部解决什么
读完这六章,你能回答六个关键问题:
- 为什么
append()是唯一入口,为什么要递归做 lossless-JSON 校验加深拷贝 - 每个事件信封必须携带哪些字段,未知事件为什么默认拒绝恢复
- Surface 投影是什么,为什么模型看到的历史和原始日志不是一回事
- 同一份日志如何长出多个投影:header、context、goal、runtime context
- JSONL 持久化怎么做原子写,崩溃时如何确定性修复未闭合的 turn
- Resume、Fork、Compaction 这三种”上下文改写术”为什么仍然是日志追加
你可能在别的 Agent 框架里见过 message 数组直接 push/pop/splice。Harness 不这么做。Compaction 不是删旧消息,是追加一个带 surfaceOp: replace 的新事件。Fork 不是深拷贝状态,是从源日志的连续前缀创建子账本。Resume 不是反序列化一个对象,是把磁盘上的事件读进来,从最后一个完整边界继续追加。
accTitle: 第四部阅读路径
accDescr: Session 是 append-only event log,Event 信封带因果链,Surface 投影派生模型可见历史,JSONL 原子写和 write-behind 保证 crash recovery,resume/fork/compaction 在事件溯源层操作
accDescription: 第四部阅读路径流程图,从 append() 唯一入口开始,经过 Event Envelope,然后分流到 Surface 投影(模型看到的消息序列)和多种 Projection(header/context/goal/runtime),再到 JSONL 原子写和 Resume/Fork/Compaction 都是 append-only 操作。
flowchart TD
A["append() 唯一入口\nlossless-JSON + deepFreeze"] --> B["Event Envelope\n{type, seq, time, data, surfaceOp?}"]
B --> C["Surface 投影\n模型看到的消息序列"]
B --> D["多种 Projection\nheader / context / goal / runtime"]
C --> E["JSONL 原子写\nwrite-behind + crash repair"]
C --> F["Resume / Fork / Compaction\n都是append-only操作"]
style A fill:#1a3a5c,stroke:#d4af37,color:#fff
style F fill:#1a3a5c,stroke:#d4af37,color:#fff
所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。
从最核心的 append 机制开始。你会发现,一个看似简单的”往数组里push一个对象”,背后有七层防御。
append-only Session 与 Event 信封
Session 不是可变状态容器,而是一条 append-only 的事件日志。每条事件携带结构化信封——seq、type、time、data、ignorable、surfaceOp——构成可重放、可审计、可投影的因果链。本章拆解这套机制的全部细节。
你打开 Session 对象,以为会看到一个对话状态容器:消息列表、工具结果、当前轮次——像大多数 chat 框架那样把”当前状态”直接存在内存里。
不。Session 里没有任何”当前状态”字段。它只有一个东西:一条 append-only 的事件日志。所有你认为的”状态”——对话历史、surface 投影、request header——全部是从这条日志派生出来的。事件一旦进入日志,永远不会被修改或删除。
这不是学术上的 event sourcing 概念展示。这是整个 Harness 能做到可重放、可审计、可分支、可压缩的机制基础。而这套机制的核心,是每条事件携带的那个结构化信封。
如果你第一次读第四部,很容易把“日志”“事件”“surface”“投影”混成一团。这里别绕弯子:Session 真实持有的就是事件日志;你平时喊的“对话历史”“header”“context”“goal”,都只是这条日志长出来的视图。
带着这个前提读本章,有三件事会一下子顺起来:
- 为什么
append()必须是唯一入口——因为你不是在改状态,而是在给事实源追加新事实。 - 为什么每条事件都要带完整信封——因为后面所有投影、恢复、审计都靠它重放。
- 为什么后面还要单独讲 Surface 和 Projection——因为“日志本身”与“日志长出来的视图”是两层不同东西。
这一章就当第四部的入口:先把事实源长什么样看明白,后面再看投影、落盘、resume/fork/compaction。
日志的物理形态:一个私有数组
Session 内部的数据结构简单到让人怀疑:
private log: SessionEvent[] = []
没有 Map,没有 B-tree,没有双向链表。就是一个 JavaScript 数组。事件按 seq 从 0 开始连续编号,而 seq 恒等于数组索引——这是铁律,构造时校验,append 时校验,恢复时也校验。
你通过 session.events 访问的不是这个数组本身,而是一个冻结快照:
get events(): readonly SessionEvent[] {
this.eventsSnapshot ??= Object.freeze([...this.log])
return this.eventsSnapshot
}
两个关键设计。第一,返回的是 [...this.log] 浅拷贝后 Object.freeze——你拿到的引用不会随后续 append 增长。第二,eventsSnapshot 有缓存:只在下次 append 时被置 undefined 失效。这是”快照隔离”——你此刻看到的 events 永远是那一刻的完整视图,后续变化不会穿透。
seq 的连续性用一个 getter 表达:
get seq(): number {
return this.log.length
}
下一个要分配的 seq 永远等于当前日志长度。这意味着 seq 0 是第一个事件,seq 1 是第二个——没有间隙,没有跳跃。任何恢复路径如果发现 seq 不连续,直接拒绝。
Event 信封:每条事件的身份证
Session 日志里的每一条记录都不是裸数据。它被包裹在一个结构化信封里:
type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
seq: number
time: number
data: SessionEventMap[K]
ignorable?: true
} & (K extends SurfaceEventType ? {
sourceEventSeqs?: number[]
surfaceOp?: SurfaceOp
} : object)
}[T]
拆开每个字段:
type——事件类型标识符。它是一个字符串键,对应 SessionEventMap 中的某个条目。turn/start、assistant/message、tool/result——类型决定了 data 的形状,TypeScript 的条件类型在 switch (event.type) 时自动收窄 event.data。
seq——单调递增序列号,永远等于该事件在日志数组中的索引。它是事件的全局顺序标识,也是因果链中引用其他事件的”地址”。两个事件的 seq 差就是它们之间隔了多少条记录。
time——Unix epoch 毫秒时间戳,Date.now() 在 append 时打上。注意这不是因果顺序的权威来源(时钟可以跳变),seq 才是。time 用于人类可读的调试和 UI 展示。
data——事件的类型化负载。类型系统保证 turn/start 事件的 data 一定是 { turn: number },tool/result 事件的 data 一定包含 message: ToolResultMessage。负载必须是 lossless JSON——入口校验确保序列化零信息丢失。
ignorable?: true——版本兼容标记。如果一个 reader 遇到不认识的 type,它需要决定是跳过还是拒绝。没有 ignorable 标记(或 ignorable 不为 true)的事件是 required:reader 不认识它就必须拒绝重建整个 session,因为一个未知的 required 事件可能改变后续日志的语义解读方式。标记为 ignorable: true 的是纯信息性记录,跳过不影响重建正确性。
surfaceOp 和 sourceEventSeqs——仅出现在 surface-eligible 类型(user/message、assistant/message、tool/result)上。它们描述这个事件如何进入模型可见的 ordered surface。非 surface 类型的事件编译期就被禁止携带这两个字段。
事件类型的两大阵营
所有事件类型被清晰地分成两个阵营。
Surface-eligible 类型:产生 LLM 消息的事件
只有三种类型有资格出现在模型可见的 surface 上:
export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
这三种事件必须携带 surfaceOp,声明它们如何进入 surface:
'append'——追加到 surface 尾部,正常路径{ op: 'replace', start, end }——替换 surface 中从start到end(inclusive)的已有节点,用于压缩等场景
sourceEventSeqs 记录这个事件引用了哪些更早的事件。比如 assistant/message 的 sourceEventSeqs 列出产生它的所有 assistant/chunk 的 seq——这就是因果链。压缩产生的 replace 节点的 sourceEventSeqs 覆盖它遮蔽的所有原始 surface 节点。
Log-only 类型:追踪、边界、元数据
其余所有类型都是 log-only 的。它们存在于事件日志中但不产生 LLM 消息,也不参与 surface 投影:
turn/start/turn/end——轮次边界标记step/start/step/end——步骤边界标记assistant/chunk——原始流式 token,提供 token 级重放保真度tool/call——模型请求的工具调用(name + raw arguments JSON)request/header——下一次请求的完整配置快照request/context——路由元数据变更todo/write——待办列表整体快照goal/change——目标状态变更session/end-seed——标记构造种子的结束位置compaction/start/compaction/end——压缩操作的开始和结束
完整的已知事件类型集在一个生成文件中列出,包含超过 40 种类型:
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'turn/start', 'turn/end', 'step/start', 'step/end',
'user/message', 'assistant/chunk', 'assistant/message',
'tool/call', 'tool/result', 'todo/write',
'request/header', 'request/context',
'goal/change', 'session/end-seed',
'compaction/start', 'compaction/end', 'compaction/summary',
// ... 还有二十多种
])
持久化读路径在加载日志时,如果遇到不在此集合中的类型且没有 ignorable 标记,直接拒绝加载。这防止了一个旧版本 runtime 静默跳过新版本写入的关键事件,导致重建出一个残缺的 session。
Surface 投影:从日志派生,而非独立存储
你可能会问:如果 surface 不是独立存储的,那对话历史怎么得到?
答案是 SurfaceManager 实时从日志派生。它维护一个 nodes: number[] 数组——不是存消息内容,而是存 surface 上每个节点对应的事件 seq。要获取对话历史,只需遍历 nodes 数组,取出对应 seq 的事件,调用 deriveEventMessage() 投影出 LLM Message:
export function deriveEventMessage(event: SessionEvent): Message | null {
switch (event.type) {
case 'user/message': return event.data
case 'assistant/message': {
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': return event.data.message
default: return null
}
}
这个设计的深刻含义:没有任何状态是”存”的,所有状态都是”算”的。fork 一个 session,只需复制事件日志前缀。压缩一段对话,只需追加一个 replace 类型的 surface event 遮蔽旧节点。回溯到某个时间点,只需截断日志到那个 seq 重新投影。
surfaceOp: 'append' 表示正常追加到尾部。surfaceOp: { op: 'replace', start, end } 表示替换现有 surface 范围——surface 中落在 [start, end] 区间内的节点被移除,替换为这个新节点。SurfaceManager 在 append 前验证 replace 操作的合法性:start 和 end 必须是当前 surface 中实际存在的节点 seq。
append() 的七层防御
append() 是唯一能向日志添加事件的入口。没有 insert()、没有 update()、没有 delete()。看它做了什么:
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T>
签名本身就透露了信息:surface-eligible 类型必须传入 SurfaceIntent(声明 surfaceOp),非 surface 类型则编译器禁止传入。这是类型级别的”必须声明你的事件如何参与 surface”。
第一层:snapshotJsonValue——递归 lossless-JSON 校验与深拷贝。
这是最关键的一层。snapshotJsonValue 在一次迭代遍历中同时完成校验和深拷贝。它拒绝的值包括:Map、Set、Date、class 实例、function、symbol、undefined、BigInt、-0、Infinity、NaN、循环引用、稀疏数组、非 plain object 原型链。
为什么如此严格?因为事件日志必须能无损序列化为 JSON 写盘。任何 JSON.stringify 会丢失信息的值,在入口就被挡掉。而且校验和拷贝在同一次递归中完成——不存在”校验时是 A,拷贝时变成 B”的 TOCTOU 问题。
function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined {
const ancestors = new Set<object>()
// ... 迭代式 DFS,检查每个节点类型
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
// ...
}
if (ancestors.has(current)) return undefined // 循环引用
if (!hasPlainObjectPrototype(current)) return undefined // 非 plain object
// ...
}
第二层:防重入。
如果 append 过程中有 observer 回调又触发了 append,直接抛错:
session append cannot reenter while another append is being published
通过 attachments.get(this)?.appending 标志位实现。这防止”在事件通知里又写事件”导致的无限递归和日志状态撕裂。
第三层:deepFreeze。
校验通过的事件对象被递归冻结:
const event = deepFreeze({
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
})
冻结后,event.data.content = 'hacked' 在严格模式下直接抛 TypeError。不可变性不是约定,是运行时强制。
第四层:surfaceManager.validateNext。
还没 push 到 log 就先验证 surface 操作的合法性。如果一个 replace 操作引用了不在当前 surface 中的节点,或者一个非 surface 类型却携带了 surfaceOp——在日志变化之前就拒绝。这是”plan-then-commit”:验证失败时日志一行都没动。
第五层:先 push 到 log,再通知 observer。
this.log.push(event as SessionEvent)
this.eventsSnapshot = undefined
顺序至关重要:事件先进入数组,append 已提交,然后才通知 observer。Observer 看到的永远是已提交的状态。
第六层:eventsSnapshot 置 undefined。
下次访问 session.events 时会生成新快照。旧快照的持有者看到的仍是 append 之前的视图——他们不受影响。
第七层:invokeContainedSessionObservers——失败隔离。
每个 listener 被单独 try-catch。一个写得烂的插件在 observer 里抛异常,不会让 append 失败,不会阻止其他 observer 收到同一个事件,也不会改变 append 的返回值。
flowchart TD
A["append(type, data, opts?)"] --> B["snapshotJsonValue(data)\n递归校验+深拷贝"]
B -->|失败| X["throw Error\n日志不变"]
B --> C{"entry.appending?"}
C -->|是| X
C -->|否| D["deepFreeze(event)\n递归冻结"]
D --> E["surfaceManager.validateNext\n预演surface操作"]
E -->|失败| X
E --> F["entry.appending = true"]
F --> G["log.push(event)\n事件提交"]
G --> H["eventsSnapshot = undefined"]
H --> I["invokeContainedSessionObservers\n每个listener异常隔离"]
I --> J["entry.appending = false"]
J --> K["return event"]
style X fill:#8b0000,color:#fff
style K fill:#006400,color:#fff
因果链:sourceEventSeqs 的设计意图
sourceEventSeqs 不是可选的装饰性元数据。它是事件之间因果关系的形式化记录。
一个 assistant/message 事件的 sourceEventSeqs 列出所有产生它的 assistant/chunk 事件的 seq。这意味着你可以从任何一个 assembled message 回溯到构成它的每一个流式 token。
一个 compaction 产生的 replace 节点的 sourceEventSeqs 覆盖它遮蔽的所有原始 surface 节点。这意味着你可以从压缩后的摘要回溯到被压缩的原始对话。
这条因果链使得:
- 精确重放成为可能——给定 seq N 的 assembled message,你能精确重建产生它的流式过程
- 审计追溯成为可能——任何对话片段都能追溯到它的来源事件
- 压缩验证成为可能——验证一个 replace 节点是否正确遮蔽了它声称遮蔽的所有节点
assistant/message 有一个特殊规则:sourceEventSeqs 可以是一个空数组(present but empty),表示”已知这个消息来自一个空的 provider stream”。当字段缺失(absent)时,表示”这个事件不记录它的来源”。区分 present-empty 和 absent 是有意义的——前者是”我知道我没有 source”,后者是”我没有记录 source 信息”。
不变式守护者:invariant companion
Session 的 append 入口保证了单条事件的合法性,但事件之间的关系合法性——比如 turn/start 必须在前一个 turn 关闭之后、step/start 的 turn/step 编号必须连续、tool/result 必须对应一个 pending 的 tool/call——由一个独立的 invariant companion 插件守护。
interface SessionTrace {
lastSeq: number
openTurn: number | null
openStep: number | null
nextTurn: number
nextStep: number
pendingCalls: Set<CallId>
}
每次 append 时,invariant companion 作为 observer 检查新事件与已有日志的关系是否满足不变式。违反时它报告 InvariantFailure,但不阻止 append——它是检测机制,不是拦截机制。这保证了生产环境的 append 路径不会因为诊断代码而变慢或失败。
SessionEventMap:类型系统驱动的事件契约
SessionEventMap 是一个 interface,每个键值对定义一种事件类型的负载形状:
export interface SessionEventMap {
'turn/start': { turn: number }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
'step/end': { turn: number; step: number }
'user/message': UserMessage
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { message: ToolResultMessage; error?: {...}; meta?: JsonValue }
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'todo/write': { todos: TodoItem[] }
'session/end-seed': Record<string, never>
// ... 更多类型由插件 merge 扩展
}
这个 interface 是 merge-extensible 的。插件可以通过 TypeScript 的声明合并向 SessionEventMap 添加新键值对,扩展事件词汇表。但物理日志的结构不变——永远是信封 + data。新类型只要在 KNOWN_SESSION_EVENT_TYPES 中注册(或标记 ignorable),就能被正确持久化和恢复。
关键设计决策:tool/call 的 arguments 是 raw string,不是 parsed JSON。这保留了模型输出的原始形态——如果模型产出了非法 JSON,你能在日志中看到它实际说了什么,而不是一个 parse error 的残影。
持久化:Session 不碰磁盘
这是一个关键的职责边界:Session 类不做任何 I/O。它不知道磁盘是什么,不知道 JSONL 是什么,不知道数据库是什么。
持久化是通过 session/event 观察者模式由外部插件实现的。默认的 session-persistence-jsonl 插件采用 write-behind 策略:事件来了先进缓冲区,默认 200ms 批量刷盘。这意味着 append 的热路径上没有任何 I/O 阻塞。
Session 也不是一个 Cordis Service。它是一个普通的 TypeScript class,由 SessionStore 创建和管理。私有构造函数,只能通过 Session.create() 或 Session.fromRestore() 创建。
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
return new Session(id, seed, header)
}
static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
return new Session(id, seed, header, 'restore')
}
create 接受可选的 seed(用于 fork/replay),对 seed 中的每条事件做同样的 snapshotJsonValue 校验+深拷贝。fromRestore 接受持久化后端传入的”fresh detached”数据,走 validate-then-freeze 路径,跳过深拷贝(因为调用方保证转移了所有权)。
SessionHeader:日志之外的元数据
有些信息不属于可重放的对话状态,而是存储层面的元数据。这些放在 SessionHeader 中:
export interface SessionHeader {
readonly version: number // 格式版本号
readonly id: SessionId // session 标识
readonly createdAt: number // 创建时间戳
readonly cwd?: string // 工作目录
readonly parentSession?: SessionId // fork 来源
readonly seedLength?: number // 种子事件数量
readonly origin?: 'subagent' // 子代理标记
readonly delegationDepth?: number // 委托深度
readonly agentPreset?: string // agent 预设
}
Header 刻意不在事件日志里。它是 store 注入的、创建时确定的、不可变的元数据。格式版本号 SESSION_FORMAT_VERSION(当前为 0)用于兼容性控制——加载时如果版本不匹配直接拒绝,没有迁移。
版本兼容与 ignorable 标记的博弈
ignorable 标记解决的是一个工程实践问题:新版本 Harness 可以添加新的事件类型(比如用于遥测的 feedback/record),而旧版本 runtime 在恢复时不应因为不认识这个类型就整个拒绝。
规则很简单:
- required(默认):reader 不认识这个类型 → 拒绝重建。因为跳过一个你不理解的 required 事件可能导致后续日志的语义被错误解读。
- ignorable: true:reader 不认识这个类型 → 安全跳过。这个事件是纯信息性的,不影响 session 重建的正确性。
默认为 required 是有意的保守决策:如果一个开发者忘了标记 ignorable,后果是”不便”(旧版本过度拒绝),而不是”静默损坏”(旧版本在重建一个残缺的 session 后继续执行)。
你容易踩的五个坑
坑一:传入非 JSON 值被拒绝。 你在 tool result 里塞了 new URL()、new Date()、或一个带原型链的 class 实例——append 直接 throw。不是 JSON.stringify 把它变成 {},是入口就挡掉了。解法:自己先转成 plain object。
坑二:observer 里不能 append。 防重入检查是硬限制。如果你在 session/event 回调里调 session.append(),得到 “session append cannot reenter while another append is being published”。正确做法:排到微任务里,或用框架提供的 deferred append 机制。
坑三:持久化是异步的。 append 返回了,事件在内存里了,但磁盘上可能还没有。进程崩溃,最近 ~200ms 的事件会丢。需要强一致的场景,要等 flush promise 完成。
坑四:events 返回的是快照。 你存了 const evts = session.events,然后发生了新 append——evts 不会变长。每次需要最新状态都要重新访问 session.events。
坑五:非 surface 类型不能携带 surfaceOp。 如果你自定义了一个事件类型但试图给它加 surfaceOp,SurfaceManager 会在 validateNext 时拒绝:session event "xxx" is not surface-eligible and cannot carry surfaceOp。只有 user/message、assistant/message、tool/result 才有资格出现在 surface 上。
从日志到重建:为什么 append-only 是唯一正确选择
把所有”状态”变成”事件日志的投影”,你获得的不只是”好看的架构”:
- Fork 是 O(1)——共享日志前缀的引用,子 session 从 fork 点开始独立 append
- Time travel 是截断——截断日志到 seq N 就回到了那一刻
- 压缩是追加——用一个 replace surface event 遮蔽旧节点,原始事件仍在日志中,可审计
- 持久化是流式写——每条事件按 seq 顺序写 JSONL,恢复是逐行读回
- 调试是重放——完整因果链在日志中,
sourceEventSeqs连接因果
如果你允许原地修改,以上每一条都需要额外的 diff/snapshot/undo 机制。append-only 让这些复杂能力变成了日志上的简单操作。
收口:信封的五个字段就是全部规则
Session 事件系统的规则,最后都压在这几个字段上:
| 字段 | 含义 | 不变式 |
|---|---|---|
seq | 全局顺序标识 | 恒等于数组索引,无间隙 |
type | 事件类型键 | 决定 data 形状,区分 surface/log-only |
time | 人类时间戳 | 非因果权威,仅供展示 |
data | 类型化负载 | 必须 lossless JSON,deepFreeze 后不可变 |
ignorable | 版本兼容 | 缺失=required,reader不认识必须拒绝 |
加上 surface-eligible 类型的两个条件字段:
| 字段 | 含义 | 适用类型 |
|---|---|---|
surfaceOp | 如何进入 surface | user/message, assistant/message, tool/result |
sourceEventSeqs | 因果来源引用 | 同上 |
这就是 Event 信封的全部。再往下看 SurfaceManager,就会看到它怎样从扁平事件数组里实时折出模型能看到的有序历史。