第二部分 · 核心循环

第 4 章:会话日志——可重建的真相源

append-only 事件流、声明合并的类型词表、以及「模型可见 ⟺ 已记录」不变式

不存消息,只存事件

几乎每个 agent 系统都有「消息列表」:一个 Message[] 数组,模型请求时把它序列化发出去。dsh 的激进选择是:消息从不被存储。每次需要模型历史时,从日志现算;而日志里存的是比消息更底层的东西——SessionEvent

packages/core/session/src/index.tsSession 类只有两个核心事实:

  • 一段 append-onlySessionEvent[] 日志,seq 从 0 连续编号(seq = log.length);
  • 一个从日志派生的模型可见表面surface),deriveMessages() 从中投影出 Message[]

「模型可见 ⟺ 已记录」是这个系统的第一定律。任何到达模型请求的输入,必须能从日志重建;反过来,任何写进日志的东西都必须是无损 JSON。这条不变式不是文档里的口号,而是运行时检查——第 3 章那个 deriveMessages() 调用点,就是这条定律的执行位。

事件词表:SessionEventMap

日志事件由 SessionEventMaptypes.ts)定义——一个声明可合并的接口,插件通过 declare module 往里面加自己的事件类型。核心词表分五组:

事件说明
turn/*turn/startturn/end开/关一个 turn;turn/end 必带 TurnEndReason(completed/aborted/blocked/error/max-tokens/interrupted)
step/*step/startstep/end一次模型调用 + 它请求的工具执行
消息user/messageassistant/chunkassistant/messagetool/calltool/resultchunk 是 token 级原始流(重放保真);message 是组装结果(投影历史用);tool/callarguments 是模型原始 JSON 字符串,不解析
request/*request/headerrequest/contextlog-only:最新快照重建请求头(provider/model/effort/system/tools)与路由元数据
其他todo/writesession/end-seed整表快照、种子边界标记;插件合并追加 compaction/*plan/modehook/*approval/*goal/change 等 30+ 个

事件本体是一个在 type 上可辨识的判别联合(types.ts:404),编译器保证 switch (event.type) 直接收窄 event.data,无需类型断言。两个条件字段值得单独说:

  • surfaceOp(只在三种「消息产生事件」上必带):'append' 追加到表面尾部;{op: 'replace', start, end} 替换表面的一段(compaction 用)。编译器强制:surface 类型必须传、非 surface 类型传了即编译错。
  • sourceEventSeqs:一条 assistant/message 引用组成它的 assistant/chunk 的 seq;一条 compaction 替换节点引用它遮蔽的所有表面节点。这是「剪辑引用表」——重放与 UI 都能顺着它溯源。

append:计划先行、快照、深冻结、发布

Session.append()index.ts:604)的签名把「surface 事件必须带意图」写进类型:

  append<T extends SessionEventType>(
    type: T,
    data: SessionEventMap[T],
    ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
  ): SessionEvent<T> {
    const surfaceOpts: SurfaceIntent | undefined = opts[0]
    const surfaceMetadata = {
      ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
      ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
    }
    const dataSnapshot = snapshotJsonValue(data)
    if (dataSnapshot === undefined) {
      throw new Error(`session event "${type}" carries non-JSON-serializable data`)
    }
    // ...
    const event = deepFreeze({
      type,
      seq: this.log.length,
      time: Date.now(),
      data: dataSnapshot,
      ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
    } as unknown as SessionEvent<T>)
    this.surfaceManager.validateNext(event as SessionEvent)
    // ... push + publish session/event

四道工序:

  1. 无损 JSON 快照snapshotJsonValuejson.ts):拒绝 BigInt、函数、undefined、-0、非有限数、环、稀疏数组与 exotic 对象;迭代遍历(受内存而非调用栈约束)。每个属性只读一次——stateful getter 无法给验证与存储不同的值。
  2. 深冻结:整个事件 deepFreeze。日志里的东西从此不可变。
  3. 表面规划先行surfaceManager.validateNext 在入 log 前先验证(plan-first)——失败不污染表面。
  4. 入 log + 发布log.push 后发 session/event 广播。

「坏事件在 append 处失败,而不是在后端 flush 时失败」——不可变性在源头汇合。

三层演进策略:声明合并、ignorable、版本号

日志词表怎么增长?dsh 给了三层互补的机制,这可能是全系统最优雅的设计之一:

机制管什么
类型层declare module 声明合并插件自由扩词表,SessionEventType = keyof SessionEventMap 自动收编
运行层ignorable?: true 标记词表增长不需升版:未知类型带此标记可安全跳过;缺省是必读——不认识又不带标记的事件,读取端必须拒绝重建
格式层SESSION_FORMAT_VERSION只有结构性变化(header 形状、事件信封、核心语义、surface 机制)才升版;由写入方决定

一个真实的合并样例——plan mode 给日志加事件(packages/plan/plan-mode/src/index.ts):

declare module '@deepseek-ai/dsh-session/types' {
  interface SessionEventMap {
    /**
     * Whether plan mode is in force from this point on: log-only, non-surface,
     * whole-value replace. The last `plan/mode` wins; a log with none folds to
     * inactive through {@link foldPlanMode}.
     */
    'plan/mode': { active: boolean }
  }
}

ignorable 的缺省方向是个反直觉但正确的决定:忘记标记的代价是「过度拒绝」(新读端读旧日志时宁可拒)而不是「静默读错」。对事件溯源系统来说,过度拒绝是可诊断的不便,静默阉割是不可恢复的灾难。

deriveMessages:历史是投影,不是存储

deriveMessages()index.ts:726)沿表面节点序列逐个投影:

  deriveMessages(): Message[] {
    const surface = this.surface
    const nodes = surface.nodes
    const generation = surface.replaceGeneration
    if (generation !== this.derivedGeneration) {
      this.derived = []
      this.derivedNodes = 0
      this.derivedGeneration = generation
    }
    for (const seq of nodes.slice(this.derivedNodes)) {
      const msg = this.deriveEventMessage(this.log[seq]!)
      // A surface node is one of the five message-producing types, but an
      // empty-content assistant/message (a max-tokens step that hosts only
      // usage) derives to null and must not enter the transcript.
      if (msg) this.derived.push(msg)
    }
    this.derivedNodes = nodes.length
    return [...this.derived]
  }

注意这个「水位」缓存:每个节点只投影一次,表面被 replace(compaction)时 generation 变化才整段重建。投影规则本身很薄:user/message → 其本身;assistant/message → message(空 content 返回 null——max-tokens 截断的 usage 载体不进转录);tool/result → message;其余 → null。

于是「重放保真」与「投影历史」两条消费路径共存在同一份日志里:assistant/chunk 是 token 级胶片,assistant/message 是剪辑后的正片,sourceEventSeqs 是剪辑引用表,派生历史只是放映机——放映永远不改胶片。

不变式:模型可见 ⟺ 已记录

这条定律的机器执行在两个层面。循环侧(agent-loop/src/invariant.ts:39)在每次 llm/stream 前断言:

    const expected = session.deriveMessages()
    if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
      fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
    }

请求的 messages 与日志的派生结果逐字节比对,不一致就在请求发出前 fail。会话侧(core/session/src/invariant.ts)还有配对不变式:执行事件必须 turn 封闭、tool/result 必须有同 step 的 tool/call。崩溃后的孤儿事件由 repair.tsinterruptedTurnClosers 确定性合成缺失的边界(含 TOOL_NOT_STARTED/TOOL_OUTCOME_UNKNOWN 错误码),不重写已提交事件——恢复路径与主路径同构。

作者解读:日志即账本

把会话日志想成一本只追加的流水账:seq 是账页号,session/end-seed 是「旧账本装订线」(区分继承账目与本轮记账),ignorable 是「不认识但可忽略的备注」。账本的纪律是:任何账目变动先记后行(inbox 的 splice、工具的 result 都遵守),任何一笔都能凭账页重放。dsh 把「数据库」做成了事件流——持久化(第 14 章)、重放、fork、UI(第 17 章)全部从这同一个源头派生。

实践应用

  1. 双层记录换双赢:chunk 级原始流 + message 级组装结果 + sourceEventSeqs 显式链接——同一份日志既满足逐 token 重放,又满足高效投影,两条消费路径不互相妥协。
  2. 兼容性工程压缩成一条决策规则:类型层自由扩展 + ignorable 运行守卫 + 版本号只对结构变化升版、由写入方决定——「向前兼容 vs 向后安全」不再是每改一处都要拍脑袋的问题。
  3. 「模型看到什么」是可审计的契约:把模型可见性做成编译器强制(surface 类型必带意图)+ 运行时校验(逐字节比对)的契约,任何 desync 在请求发出前 fail——这是写 agent 产品最值得抄的纪律之一。
  4. plan-first 原子提交:append 先规划后入 log(失败零污染)、快照-冻结-发布一条龙,让不可变性与正确性在源头汇合。

总结

这一章拆完了会话日志:事件词表怎么声明合并、append 怎么保证无损与不可变、三层演进策略怎么让词表增长不破坏兼容、deriveMessages 怎么把日志投影成模型历史、以及「模型可见 ⟺ 已记录」如何被机器执行。日志是系统的账本,而账本只记录「发生了什么」——真正「做事」的是工具。下一章进入工具系统:模型输出一个 tool-call 之后,五段管道如何把它变成日志里的 tool/result