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

事件是流水,状态是折叠出来的投影

除了表面投影,Harness 还有另一类跨事件折叠出业务状态的投影:SessionProjectionRegistry 把每个 ProjectionDefinition 装进一个 UnitCell,随 session/event 急切 drive,靠 Object.is 同引用比较决定下游是否重算。goal 就是一个实例。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

上一章讲的表面投影,折出来的是”模型能看到什么消息”。但 Harness 里还有另一类状态——当前目标是什么、待办列表有几项、动态上下文快照是什么——它们不是一条事件能说清的,而是随一长串事件慢慢累积、不断修正出来的业务视图。这类”跨事件折叠”,由本章的 SessionProjectionRegistry 统一管理。它和表面投影共享同一条 append-only 账本,却折出另一组结果:表面给模型看,投影给业务逻辑和 UI 看。

上一章 第 24 章 拆了 SurfaceManager 怎么折消息。本章回答:除了消息,账本还能折出哪些状态,这套”注册一个投影单元、让它随事件自动累积”的机制是怎么跑起来的。

session-projections 机制图:左侧 SessionProjectionRegistry 是 Cordis Service,监听 session/created(且 seq===0)为每个注册的 ProjectionDefinition 建一个 UnitCell,state 由 init(header,inheritedEventCount) 起步;中间是 UnitCell(state、observedSeq、views 前后两代),右侧每条 session/event 触发 drive:apply(state,event) 折叠新 state,再算 view 与上一次 views[prev] 做 Object.is 同引用比较——引用没变就不发布变更;下方是 ProjectionDefinition 契约六件套 key/stateSchema/init/apply/wire/stateVersion,以及 goal 实例(key='goal'、只关心 goal/change、fold 失败记 failure、stateVersion=6)。

两类投影:一个折消息,一个折业务状态

先把两类投影分清楚,否则很容易混。

表面投影(Surface)SessionProjection
折出什么模型可见的有序消息历史跨事件累积的业务状态
谁拥有SurfaceManagerSessionProjectionRegistry
典型例子nodes 节点顺序、deriveEventMessagegoal 当前目标、todo 列表、运行时上下文
触发append 时增量折表面session/event 时 drive 注册单元
下游消费者下次 LLM 请求的 messagesUI、业务逻辑、持久化缓存

表面投影回答”模型这一轮看到哪几条消息”;SessionProjection 回答”跑了这么久,现在的目标是什么、上下文快照是什么”。两者都从同一本账本现算,都不独立存状态,差别只在”折出来喂给谁”。

为什么要把业务状态也做成投影,而不是一个普通的可变字段? 因为和表面一样,业务状态也要求”可重放、可崩溃恢复”。goal 不是某次 append 时随手写进去的一个变量,而是”从会话开头到现在,所有 goal/change 事件折叠出来的结果”。把它做成投影,崩溃后只要重放账本就能重建,不需要单独维护一份数据库。

本节源码依据(2 处)

ProjectionDefinition:一份纯函数契约

每个投影单元是一份类型化契约:

interface ProjectionDefinition<K, S> {
  key: K                                    // 它拥有的状态槽
  stateSchema: ZodType<S>                   // 校验持久化状态
  init(header, inheritedEventCount): S     // 空日志起步
  apply(state: S, event: SessionEvent): S   // pure transition
  wire?: { viewSchema; view(state): V }     // 客户端视图
  stateVersion: number                      // 持久化缓存失效版本
}

逐件看:

  • key——这个投影单元在 SessionProjectionStateMap 里拥有哪个状态槽。比如 goal 占的就是 'goal' 这个 key。
  • stateSchema——一份 zod schema,用来校验”从持久化缓存恢复出来的状态”是不是合法的。注释:“Validates persisted state before it seeds a fold”。
  • init(header, inheritedEventCount)——给定空日志和不可变 header,给出初始状态。注意它接收 inheritedEventCount——fork 来的会话继承了多长前缀,投影要据此起步。
  • apply(state, event)——核心纯函数:旧 state + 一条事件 → 新 state。
  • wire——可选的客户端视图。host-only 单元可以省略;带 wire 的会定义 viewSchema(出 host 前校验)和 view(state)(状态 → 发给客户端的值)。
  • stateVersion——持久化缓存的失效版本。序列化字段或折叠语义一变就 bump,旧版本缓存行直接作废。
契约字段职责谁来实现
key标识状态槽每个单元自己声明
stateSchema校验持久化状态配 zod schema
init空日志起步接收 header + inheritedEventCount
apply旧 state + 事件 → 新 state纯函数
wire.view状态 → 客户端视图host-only 可省略
stateVersion缓存失效版本语义变更时 bump

为什么要把契约压成”纯函数 + zod”,而不是一个带方法的 class? 因为纯函数最好重放、最好测试、最好持久化。一个 (state, event) => state 没有任何隐式状态——给它同一条事件序列,它永远折出同一个 state。这让崩溃恢复退化成”从 init 起步,把账本里的事件逐条 apply 一遍”,不需要任何额外的状态迁移逻辑。

本节源码依据(6 处)

whole-value event rule:同引用就是零工作

契约里最容易被忽略、却最关键的一条注释写在 apply 上:

“A unit uninterested in an event MUST return the same state reference — an unchanged reference (Object.is) produces zero downstream work.”

这就是 whole-value event rule:一个投影单元如果对某条事件不感兴趣(比如 goal 投影只关心 goal/change),它必须返回原来那个 state 引用,而不是返回一个内容相同的新对象。

为什么这条是硬规定?因为注册表判断”要不要重算下游”,靠的就是 Object.is(prevState, nextState):

  • 引用相同(Object.is 为真)→ 内容必然没变 → 不触发任何下游重算。
  • 引用不同(即使内容逐字相同)→ 下游当成”状态变了”,全部重算一遍。

这把”状态变没变”的判定,从”深比较内容”降级成了”比较引用”——O(1),且零分配。前提是:不感兴趣的事件必须老老实实返回同引用。

// goal 投影的写法:不感兴趣就 return state(同一个引用)
if (event.type !== 'goal/change'
    && (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state
apply 的返回下游判定成本
同一引用(不感兴趣)Object.is 真 → 不重算零
新引用(即使内容相同)Object.is 假 → 全重算冗余
真正变了的新引用重算(正确)必要

为什么不直接深比较内容,而要靠”返回同引用”这个约定? 因为深比较一条可能很大的业务状态,每来一条事件都做一次,本身就是浪费。让投影作者自己负责”没改就别造新对象”,把比较压成引用相等——这是函数式数据结构里标准的”结构共享换增量”思路。契约把这条写成 MUST,正是为了避免一个不小心 return {...state} 就让整个下游白跑。

本节源码依据(4 处)

注册表与 UnitCell:随事件急切 drive

SessionProjectionRegistry 是一个 Cordis Service。它在构造时做两件事:

constructor(ctx: Context) {
  super(ctx, 'sessionProjections')
  ctx.on('session/created', (session) => {
    if (session.seq !== 0) return                 // 只处理全新会话
    for (const registration of this.registrations.values()) {
      if (registration.cells.has(session)) continue
      registration.cells.set(session, {
        state: registration.def.init(session.header, session.inheritedEventCount),
        observedSeq: -1,
        views: [undefined, undefined],
      })
    }
  })
  ctx.on('session/event', (session, event) => {
    this.drive(session, event)                    // 每条事件急切 drive
  })
}

两件事:

  1. session/created(且 seq===0):为每个已注册的投影单元,在该 session 上建一个 UnitCell。state 从 init(header, inheritedEventCount) 起步,observedSeq=-1,views=[undefined, undefined]。
  2. session/event:每条已提交事件都调 drive(session, event)——把事件喂给每个单元的 apply,得到新 state,再算 view 与上一次做 Object.is 比较,没变就不发布。

UnitCell 就是每个 session × 每个投影单元的运行时状态:

interface UnitCell {
  state: unknown
  observedSeq: SessionSeqCursor          // 已 apply 到哪条事件(无论是否变更)
  readonly views: [unknown, unknown]     // [prevView, currentView]
}

注意 cells 是一个 WeakMap<Session, UnitCell>——session 没人引用了,cell 自动回收。

时机注册表动作
session/created(seq===0)为每个注册单元建 UnitCell,state=init(…)
session/eventdrive:apply → 算 view → Object.is 比较 → 变了才发布
session 释放WeakMap 自动回收 cell

为什么用 WeakMap 挂 cell,而不是每个 session 上直接挂字段? 因为投影单元是”全局注册”的,session 是”临时创建销毁”的。WeakMap 让注册表不用自己管理”哪个 session 死了要清掉它的单元”——session 对象一旦不可达,它的所有 cell 跟着被 GC。这把生命周期管理从手动簿记变成了语言自带的回收。

本节源码依据(6 处)

实例:goal 投影怎么写

goal 是一个完整例子。它的投影函数只认两类事件:

export function applyGoalProjection(state, event) {
  if (state.failure !== null) return state        // 已经失败,不再 fold
  if (event.type !== 'goal/change'
      && (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state
  const folded = goalFoldState(state)
  try {
    applyGoalEvent(folded, event)
    return goalProjectionState(folded)
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    return { ...state, failure: `goal replay failed at session event ${event.seq}: ${message}` }
  }
}

export const goalProjectionDefinition = {
  key: 'goal',
  stateSchema: goalProjectionStateSchema,
  init: () => ({ current: null, seenGoalIds: [], failure: null }),
  apply: applyGoalProjection,
  wire: { viewSchema: goalProjectionSchema, view: state => state.current },
  stateVersion: 6,
} satisfies ProjectionDefinition<'goal', GoalProjectionState>

三个值得学的设计:

  1. 不感兴趣就同引用返回——第一行 if (state.failure !== null) return state 和第二行的类型过滤,都是 whole-value rule 的体现。
  2. fold 失败记字段、不崩 drive——try/catch 把 goal fold 的异常接住,写成 state.failure = 'goal replay failed at session event N'。一个投影单元折炸了,只标记自己失败,不让整个注册表 drive 崩掉。
  3. view 只露当前值——view: state => state.current 把内部的 seenGoalIds、failure 都藏起来,只把”当前目标”发给客户端。
投影契约字段goal 实例的取值
key'goal'
init{current:null, seenGoalIds:[], failure:null}
apply只认 goal/change 与 goal 来源 user/message
viewstate => state.current
stateVersion6

为什么 fold 失败要记一个 failure 字段,而不是直接抛断 drive? 因为 goal 只是众多投影单元之一。如果它折一条坏事件就把整个注册表 drive 炸了,表面投影、其他业务投影全都跟着停摆——一个单元的 bug 不该拖垮所有人。把失败降级成”该单元进入 failure 态、不再 fold”,故障被隔离在单元内部,这是注册表作为”共享基础设施”必须有的韧性。

本节源码依据(4 处)

小结

  • 两类投影分工:表面投影折模型可见消息,SessionProjection 折跨事件业务状态(goal、todo、上下文)。
  • ProjectionDefinition 是纯函数契约:key + stateSchema + init + apply + wire + stateVersion,给同一事件序列永远折出同一 state。
  • whole-value event rule:apply 对不感兴趣的事件必须返回同引用,Object.is 不变就零下游工作。
  • 注册表急切 drive:session/created 建 UnitCell,session/event 折叠,WeakMap 自动回收。
  • goal 是范本:只认自己的事件、fold 失败记 failure 不崩全局、view 只露当前值。

下一章:第 26 章

只读验证

# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD

# 逐字核对本章引用的投影契约
grep -n "produces zero downstream work" "$DSH_SOURCE_DIR/packages/session/session-projection/src/index.ts"
grep -n "stateVersion: 6" "$DSH_SOURCE_DIR/packages/goal/goal/src/index.ts"
grep -n "goal replay failed at session event" "$DSH_SOURCE_DIR/packages/goal/goal/src/index.ts"