青雲的博客

第四部:事件账本——Session是append-only日志

Session不是一个可变状态对象,而是一个只追加的事件日志。从append校验到surface投影,从JSONL原子写到崩溃修复,从fork到compaction——所有操作都是日志追加。

[图片占位:一本发光的账本,每一页是一个事件信封,seq编号连续。append-only的钢笔在追加新条目,投影灯光从账本投向多个屏幕(模型历史、UI、持久化)。深海蓝鲸背景,账本浮在水中发光。色调:深海蓝+金色光。]
展开阅读路线与实验入口

你以为 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一个对象”,背后有七层防御。

深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 20 章

append-only Session 与 Event 信封

Session 不是可变状态容器,而是一条 append-only 的事件日志。每条事件携带结构化信封——seq、type、time、data、ignorable、surfaceOp——构成可重放、可审计、可投影的因果链。本章拆解这套机制的全部细节。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你打开 Session 对象,以为会看到一个对话状态容器:消息列表、工具结果、当前轮次——像大多数 chat 框架那样把”当前状态”直接存在内存里。

不。Session 里没有任何”当前状态”字段。它只有一个东西:一条 append-only 的事件日志。所有你认为的”状态”——对话历史、surface 投影、request header——全部是从这条日志派生出来的。事件一旦进入日志,永远不会被修改或删除。

这不是学术上的 event sourcing 概念展示。这是整个 Harness 能做到可重放、可审计、可分支、可压缩的机制基础。而这套机制的核心,是每条事件携带的那个结构化信封。

如果你第一次读第四部,很容易把“日志”“事件”“surface”“投影”混成一团。这里别绕弯子:Session 真实持有的就是事件日志;你平时喊的“对话历史”“header”“context”“goal”,都只是这条日志长出来的视图。

带着这个前提读本章,有三件事会一下子顺起来:

  1. 为什么 append() 必须是唯一入口——因为你不是在改状态,而是在给事实源追加新事实。
  2. 为什么每条事件都要带完整信封——因为后面所有投影、恢复、审计都靠它重放。
  3. 为什么后面还要单独讲 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/startassistant/messagetool/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 的是纯信息性记录,跳过不影响重建正确性。

surfaceOpsourceEventSeqs——仅出现在 surface-eligible 类型(user/messageassistant/messagetool/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 中从 startend(inclusive)的已有节点,用于压缩等场景

sourceEventSeqs 记录这个事件引用了哪些更早的事件。比如 assistant/messagesourceEventSeqs 列出产生它的所有 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 节点。这意味着你可以从压缩后的摘要回溯到被压缩的原始对话。

这条因果链使得:

  1. 精确重放成为可能——给定 seq N 的 assembled message,你能精确重建产生它的流式过程
  2. 审计追溯成为可能——任何对话片段都能追溯到它的来源事件
  3. 压缩验证成为可能——验证一个 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/callargumentsraw 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/messageassistant/messagetool/result 才有资格出现在 surface 上。

从日志到重建:为什么 append-only 是唯一正确选择

把所有”状态”变成”事件日志的投影”,你获得的不只是”好看的架构”:

  1. Fork 是 O(1)——共享日志前缀的引用,子 session 从 fork 点开始独立 append
  2. Time travel 是截断——截断日志到 seq N 就回到了那一刻
  3. 压缩是追加——用一个 replace surface event 遮蔽旧节点,原始事件仍在日志中,可审计
  4. 持久化是流式写——每条事件按 seq 顺序写 JSONL,恢复是逐行读回
  5. 调试是重放——完整因果链在日志中,sourceEventSeqs 连接因果

如果你允许原地修改,以上每一条都需要额外的 diff/snapshot/undo 机制。append-only 让这些复杂能力变成了日志上的简单操作。

收口:信封的五个字段就是全部规则

Session 事件系统的规则,最后都压在这几个字段上:

字段含义不变式
seq全局顺序标识恒等于数组索引,无间隙
type事件类型键决定 data 形状,区分 surface/log-only
time人类时间戳非因果权威,仅供展示
data类型化负载必须 lossless JSON,deepFreeze 后不可变
ignorable版本兼容缺失=required,reader不认识必须拒绝

加上 surface-eligible 类型的两个条件字段:

字段含义适用类型
surfaceOp如何进入 surfaceuser/message, assistant/message, tool/result
sourceEventSeqs因果来源引用同上

这就是 Event 信封的全部。再往下看 SurfaceManager,就会看到它怎样从扁平事件数组里实时折出模型能看到的有序历史。