第四部:事件账本——Session是append-only日志
Session不是一个可变状态对象,而是一个只追加的事件日志。从append校验到surface投影,从JSONL原子写到崩溃修复,从fork到compaction——所有操作都是日志追加。
展开阅读路线与实验入口
你以为 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 不是反序列化一个对象,是把磁盘上的事件读进来,从最后一个完整边界继续追加。
所有证据来自固定 commit 的源码。实验命令只读不写,用 $DSH_SOURCE_DIR 指向官方 checkout。
本节源码依据(1 处)
从最核心的 append 机制开始。你会发现,一个看似简单的”往数组里push一个对象”,背后有七层防御。
会话里没有状态,只有一本只追加的流水账
DeepSeek Harness 的 Session 核心就是一个 private 的、只追加不修改的事件数组:append() 依次做 JSON 快照、重入防护、深冻结、关系校验、表面投影校验,然后才 push 并同步派发观察者。快照隔离让历史读取永不被后续 append 污染。
Written in Chinese
现在是一个会话进行到一半的普通时刻:模型刚吐出一段 assistant 消息,工具正要执行,用户随时可能再敲一句话。你以为这一刻 Session 内部是一个”可以读写的状态对象”,存着当前目标、存着消息列表、存着工具进度,像个普通的服务端 session 那样,哪个字段变了就改哪个。其实不是。这一刻 Session 的内部根本没有”状态”,只有一本正在往后写的流水账:一个 private 的事件数组,新发生的一切(用户说的话、assistant 的每一段、工具的每次调用与结果)都是往后追加一条记录,旧记录一个字节都不动。你看到的”当前状态”,全都是从这本流水账现算出来的投影。
上一章 第 21 章 讲的是工具产出怎么被搬运出上下文。本章往更底层走一步:这些被搬运、被截断、被重放的东西,在 Harness 里到底是以什么形态存着的。答案就是这个 append-only 账本本身。
账本本体:一个拿不到引用的 private 数组
打开 Session 类,它最核心的成员不是某个”当前消息列表”,而是:
private readonly log: SessionEvent[] = []
注意三个词叠加起来的含义:private 意味着类外部根本拿不到这个数组的引用;readonly 意味着连类内部都不能把整个数组换掉;数组元素是 SessionEvent。整个类没有任何 update、delete、splice 方法——它不提供任何一条”修改历史”的路径。想改一条已经写进去的事件?做不到。唯一的动作是 append:在末尾加一条新记录。
这不是技术限制,是账本语义。它带来的直接后果有两个:
第一,纠错只能靠追加,不能靠改写。发现上一条工具结果错了,正确做法是再追加一条带 surfaceOp: 'replace' 的更正事件,由投影层在算”当前表面”时把旧节点替换掉——历史本身仍然完整保留。这也是为什么上一章 spill、compaction、fork 这些机制都能安全地”重放”一本账:账一旦写定,就永远是它当时的样子。
第二,编号是追加的天然副产品。每条新事件的序号就是 this.log.length:
const event = deepFreeze({
type,
seq: SessionSeq(this.log.length),
time: Date.now(),
data: dataSnapshot,
...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
})
seq 不需要全局计数器,不需要分布式协调——它就是这条记录落进数组时的下标。这天然保证了 seq 从 0 开始、严格单调连续、永不重复。这套不变量是后面所有恢复、投影、迁移机制的地基:只要”seq = log.length”成立,给定任意前缀就能无损重放。
| 账本性质 | 代码体现 | 带来的保证 |
|---|---|---|
| 私有 | private readonly log | 外部无法篡改历史 |
| 只追加 | 无 update/delete/splice | 历史一旦写定永不改变 |
| 自动编号 | seq = SessionSeq(this.log.length) | seq 从 0 单调连续 |
| 深冻结 | deepFreeze({...}) | 读到的 event 不可被任何人改 |
| 同步时间 | time: Date.now() | 每条记录有落盘时刻 |
为什么 Harness 宁可”永远不改历史”,也不给一个方便的 update 接口? 因为这本账同时是”崩溃恢复的数据源”和”多 Agent 重放的依据”。一旦允许就地修改,你就永远无法区分”这条记录是当时真的发生的”还是”后来被人改过的”——恢复出来的会话就不再是真实发生过的那个会话。append-only 把”历史真实性”从一个需要额外机制维护的承诺,变成了一个由类型系统和方法集直接强制的物理事实。
本节源码依据(4 处)
append() 的七层守卫:顺序为什么不能乱
append() 看起来只是”往数组里 push 一下”,但它在 push 之前排了七层守卫,每一层的顺序都经过推敲。按代码顺序读:
第 1 层,组装表面元数据。 从可选的 SurfaceIntent 里抽出 surfaceOp 与 sourceEventSeqs,拼成一个小对象。这一步先做,是因为它要和 data 一起被快照。
第 2 层,data 深快照。 snapshotJsonValue(data) 把调用方传进来的 data 做一次无损 JSON 快照。这层极其关键:调用方完全可能传一个还在手里继续改的对象。如果账本直接引用这个对象,调用方事后一改,历史记录就被偷偷改写了。所以 append 在这里强制拷一份。拷不出来就抛错:
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
什么样的 data 拷不出来?BigInt、函数、symbol、undefined、负零、非有限数、循环引用、稀疏数组,以及 Map/Set/Date/类实例这类”异类对象”。注释写明这是”one iterative pass reads, validates, and copies each nested value once”——一次迭代同时完成校验和拷贝,一个有状态的 getter 没法对校验方出示一个值、对存储方出示另一个值。表面元数据走完全一样的快照流程。
第 3 层,重入防护。
const entry = attachments.get(this)
if (entry?.appending) {
throw new Error('session append cannot reenter while another append is being published')
}
发布边界是一个临界区:一旦进入”正在 append”状态,就不许再嵌套 append。这防止了观察者回调里又触发 append、导致发布顺序和预期不一致。
第 4 层,深冻结事件。 把组装好的 {type, seq, time, data, surfaceMetadata} 整个 deepFreeze。从此这条记录谁都改不了——哪怕是 Session 自己。
第 5 层,事件数据校验。 validateSessionEventData(event, ...) 按事件类型校验其业务一致性(比如 request-header 的空字段规则、工具错误的一致性规则)。
第 6 层,表面投影校验。 this.surfaceManager.validateNext(event) 校验这条事件作为下一条表面变更是否合法(替换区间、引用完备性等——这是下一章和第 24 章的主题)。
第 7 层,才是 push 与派发。 先收集 session/event 回调,然后 log.push,把旧快照置空,再同步派发观察者,最后在 finally 里复位 appending 标记。
| 顺序 | 守卫 | 失败时 | 保护的是什么 |
|---|---|---|---|
| 1 | 组 surfaceMetadata | — | 表面元数据纳入快照 |
| 2 | snapshotJsonValue(data) | 抛 non-JSON-serializable | 调用方可变对象不污染账本 |
| 3 | appending 重入检查 | 抛 cannot reenter | 发布边界不被嵌套打破 |
| 4 | deepFreeze(event) | — | 落盘事件永久不可变 |
| 5 | validateSessionEventData | 抛类型规则错误 | 业务一致性 |
| 6 | surfaceManager.validateNext | 抛表面契约错误 | 投影可重放 |
| 7 | push + 派发 | — | 真正入账并通知 |
为什么”校验”和”拷贝”必须在 push 之前,而且合在一次遍历里? 因为账本是 durable source of truth。注释原话:“a bad event fails at the append site rather than later during a backend flush”。如果 push 之后才校验,或者校验和拷贝分两步,就可能出现”校验过的值”和”实际存的值”不一致——一个 getter 在两次读取间变了。把校验和拷贝压在同一次无损遍历里,append 点的失败就是”这条事件根本没进账本”,而不是”进了账本但将来 flush 时才暴露”。
本节源码依据(7 处)
快照隔离:读过的历史,后来改不了
账本是只追加的,但”读取”需要一个稳定的视图。如果调用方拿到的事件数组随下一次 append 就被改写,那么正在读历史做重放的代码就会读到一半变样。Harness 的解法是快照隔离:
snapshotEvents(fromSeq = 0, toSeqExclusive = this.seq): readonly SessionEvent[] {
if (fromSeq === 0 && toSeqExclusive === this.log.length) {
this.eventsSnapshot ??= Object.freeze([...this.log])
return this.eventsSnapshot
}
return Object.freeze(this.log.slice(fromSeq, toSeqExclusive))
}
读全量(fromSeq=0、toSeqExclusive=整个 log)走一条快速路:把整个账本 [...this.log] 拷贝一份、Object.freeze,并缓存到 eventsSnapshot。下一次全量读直接返回这份缓存,O(1)。而每次 append 成功后第一件事就是 this.eventsSnapshot = undefined——旧快照作废,下一次全量读才重新拷贝。
分片读(指定 from/to)不走缓存,每次 slice 出一个新的冻结数组。
这套机制保证了两个性质:
- 稳定:任何一次
snapshotEvents返回的数组,在它返回之后,永远不变。后续 append 只是让eventsSnapshot缓存失效,绝不去改已经发出去的那份。 - 省:在两次 append 之间会有很多次全量读(投影、序列化、观察者),它们共享同一份冻结拷贝,不重复分配。
注释说得很直白:“A full current snapshot is reused until the next append; every previously returned snapshot remains stable after later appends.”
| 读路径 | 行为 | 复杂度 | 后续 append 是否影响 |
|---|---|---|---|
| 全量读(0→末) | 缓存 Object.freeze([...log]) | O(1) 复用 | 不影响(旧快照冻结) |
| 分片读(from→to) | 每次新 slice + freeze | O(分片长) | 不影响 |
| 缓存失效 | append 后 eventsSnapshot=undefined | — | 下次全量读重建 |
为什么全量读要缓存、分片读却每次新拷? 因为全量读在会话运行中会被反复调用(每次派生消息、每次写盘、每次通知),而分片读往往是一次性的范围查询。缓存全量快照把”热路径”的分配次数压到每个 append 一次;分片读不缓存,是因为它的结果取决于当时的边界,缓存反而要管理失效。这个取舍本质是:稳定优先,复用其次。
本节源码依据(4 处)
两条构造路径:create 与 fromRestore
账本不会凭空长出来——它要么是一个新会话从零 append,要么是从外部(磁盘、fork)把一段已有的事件”接”进来。Session 用两个静态工厂区分这两种来源:
static create(id, seed?, header?, inheritedEventCount?, projections?): Session {
return new Session(id, seed, header, 'snapshot', inheritedEventCount, projections)
}
static fromRestore(id, seed, header, inheritedEventCount, eventState, projections?): Session {
return new Session(id, seed, header, eventState, inheritedEventCount, projections)
}
差别在第四个参数 mode:
create用'snapshot':它信任外部传入的 seed,但会自己做一次无损 JSON 快照和深冻结。注释写明这是给”borrowed replay or fork events”用的——你给它一段事件,它拷贝成自己的、不可变的版本。fromRestore用eventState:它借用外部已经独立拥有或已深冻结的 seed,不再拷贝,直接采纳。这是从磁盘恢复时的路径——磁盘解出来的事件本来就是不可变的,再拷一遍纯属浪费。
但无论走哪条路,构造 seed 时都复用了 append 的同一套不变量校验:
for (const [index, source] of seed.entries()) {
const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
}
assertSessionEventEnvelope(snapshot, index)
if (snapshot.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
}
this.surfaceManager.validateNext(snapshot)
this.log.push(...)
}
注释点出了核心动机:“Validate the seed to the SAME invariants append enforces, so a replay/fork cannot construct a live log that no persistence backend could store”。也就是说,从磁盘恢复或 fork 重放得到的日志,和运行期直接 append 出来的日志,必须形状完全一致——否则就会出现”内存里一本账、磁盘上另一本账”的分叉。
最关键的一条是 seed must be contiguous from 0:seed 里第 i 条事件的 seq 必须正好等于 i。这把”seq = log.length”不变量从运行期延伸到了恢复期。
| 工厂 | mode | 对 seed 的处理 | 适用场景 |
|---|---|---|---|
create | 'snapshot' | 自己快照 + 深冻结 | fork / replay 借用事件 |
fromRestore | eventState | 直接借用,不拷贝 | 从磁盘恢复 |
两条路径共用:seed 必须无损 JSON 可序列化、信封形状合法、seq 从 0 连续、表面投影可重放。
为什么恢复路径不能跳过校验、直接信磁盘? 因为磁盘文件可能是损坏的、被截断的、或者来自一个更新版本的 Harness。如果构造时不校验,一本非法的账会”看起来正常”地进入运行期,直到某次写盘或重放才炸——那时已经很难定位是哪里坏了。把校验放在构造点,等于让”非法账”在诞生的瞬间就被拒掉。
本节源码依据(6 处)
SessionHeader 与 seed 裁剪点
账本之外,Session 还带着一份不可变元数据 header(id、version、createdAt、cwd、是否 seeded、父会话、委派深度等)。它和账本是分开管理的,并有一条与”fork/恢复”直接相关的规则:seeded 的会话必须有一个明确的继承前缀长度,并且这个裁剪点要有一个 session/end-seed 标记事件。
构造时连着抛了一串校验:
if (this.header.isSeeded && seed === undefined) throw new Error('seeded session requires an explicit constructor seed')
if (this.header.isSeeded && suppliedInheritedEventCount === undefined) throw new Error('seeded session requires an inherited event count')
if (!this.header.isSeeded && inheritedEventCount !== 0) throw new Error('unseeded session inherited event count must be 0')
if (inheritedEventCount > this.log.length) throw new Error('session inherited event count exceeds its event log')
也就是说:一个声称”是从别处 fork 来的”会话,必须同时给出它继承了多长一段前缀,并且这个前缀长度不能超过它实际的账本长度。末尾还会视情况追加一条 session/end-seed 标记,把裁剪点永久钉在账本里。
这套规则是第 26 章崩溃恢复和第 28 章 fork/compaction 的前提:账本自己记得”从哪一条开始是我自己的历史,之前的是继承来的”。恢复时据此区分”我的事件”和”借来的事件”。
本节源码依据(3 处)
小结
- Session 是一本流水账,不是状态对象。 它的心脏是
private readonly log,只追加、不修改;所谓”当前状态”全是从这本账现算的投影。 - append() 是唯一入口,七层守卫顺序不可乱。 JSON 快照、重入防护、深冻结、数据校验、表面校验全部在 push 之前完成,失败绝不污染账本。
- 读到的是落盘快照,不是可变输入。
snapshotJsonValue把调用方的可变对象拷成不可变副本,事后修改进不了账本。 - 快照隔离让历史稳定。 全量快照 O(1) 复用、append 即失效,已发出的历史永远不被后续追加改写。
- 恢复与运行同形状。 create/fromRestore 以同一套 seq 连续与 JSON 可序列化校验接纳 seed,从磁盘恢复的账和运行期 append 的账长得一模一样。
下一章:第 23 章
只读验证
# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD
# 逐字核对本章引用的关键错误消息与不变量
grep -n "carries non-JSON-serializable" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "session append cannot reenter" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "seed must be contiguous from 0" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"
grep -n "SessionSeq(this.log.length)" "$DSH_SOURCE_DIR/packages/core/session/src/index.ts"