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

每条事件都装在带五格字段的信封里

DeepSeek Harness 的每条会话事件都是一个携带结构化信封的记录:type/seq/time/data/ignorable,外加仅 surface-eligible 类型才有的 surfaceOp 与 sourceEventSeqs。信封在 append 点与构造点双重校验,因果引用契约保证压缩、重放与恢复都能逐字对得上。

本章以中文撰写

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

你翻开一本会计账簿,每一页都不是光秃秃一个数字——它有日期、有凭证号、有摘要、有金额、有经手人。Harness 的 Session 账本也一样:每条写进 private readonly log 的记录,都不是一坨裸 data,而是一个带齐字段的信封。上一章我们看了这本账”怎么追加”,这一章拆开一个信封,看它里面到底装了哪几格、每一格由谁把关。

上一章 第 22 章 讲了账本本体与 append 七层守卫。本章聚焦守卫放行之后、那条真正 push 进去的记录长什么样——因为后面所有投影、压缩、恢复,全靠信封上这几格字段才能正确重放。

event-envelope 机制图:中央是一个 SessionEvent 信封卡片,五格基础字段 type/seq/time/data/ignorable,右侧用条件分支挂着 surface 两字段 surfaceOp 与 sourceEventSeqs;信封上方标注只有 5 种 SurfaceEventType 才允许携带表面字段(system/message、developer/message、user/message、assistant/message、tool/result);左路 surfaceOp 二选一——append 追尾或 op=replace(startSeq,endSeq 闭区间)替换;右路 sourceEventReferences 校验漏斗——非数组拒、空数组拒、重复拒、指向未来 seq 拒、未覆盖全部被遮蔽节点拒;底部是白名单 assertSessionEventEnvelope 与 KNOWN_SESSION_EVENT_TYPES,以及 SESSION_FORMAT_VERSION=4 的版本戳。

信封的五格基础字段

一条 SessionEvent 的形状是一个按 type 判别联合(discriminated union)出来的对象。无论哪种事件,它都带着下面这几格:

type SessionEvent<T extends SessionEventType = SessionEventType> = {
  [K in SessionEventType]: {
    type: K
    seq: SessionSeq
    time: number
    data: SessionEventMap[K]
    ignorable?: true
  } & (K extends SurfaceEventType ? SurfaceIntent<K> : {})
}[T]

逐格看:

  • type——事件类型键,比如 turn/start、assistant/message、tool/result。它是判别联合的钥匙:switch (event.type) 时 TypeScript 自动把 data 收窄成对应形状。
  • seq——全局顺序号,等于它在数组里的下标(上一章讲过,seq = SessionSeq(this.log.length))。它是因果引用的”地址”。
  • time——Date.now() 在 append 时打的毫秒时间戳。注意它不是因果权威(时钟会跳),seq 才是;time 仅供调试和 UI 展示。
  • data——类型化负载。SessionEventMap[K] 决定它的形状:turn/start 的 data 是 {turn},tool/result 的 data 是 {message: ToolResultMessage, ...}。而且上一章讲过,data 在 append 点必须是无损 JSON 可序列化的。
  • ignorable?: true——版本兼容标记。reader 遇到不认识的 type 时:没有这个标记的是 required,拒载;标了 ignorable 的纯信息事件,安全跳过。

最后那行 & (K extends SurfaceEventType ? SurfaceIntent<K> : {}) 是整封信封最精巧的地方:表面两字段不是人人都有,只有特定类型才被允许带。

字段含义不变量
type事件类型键决定 data 形状,收窄判别联合
seq全局顺序号恒等于数组下标,从 0 连续
time落盘毫秒戳非因果权威,仅供展示
data类型化负载无损 JSON,深冻结后不可变
ignorable版本兼容缺省=required,reader 不认识必须拒

为什么要把字段收进一个”信封”,而不是让 data 随便长? 因为信封是跨进程、跨版本、跨恢复路径的契约。磁盘上一行 JSONL 解出来,第一件事就是按信封核对——你怎么知道这行是合法的、能被当前版本理解的、可以安全并入账本的?靠的就是这五格。没有信封,恢复路径就得猜”这串 JSON 到底是哪种事件”,猜错就静默损坏。

本节源码依据(4 处)

两大阵营:五种能上表面的事件

所有事件类型被切成两个阵营,切分的标准只有一个:它要不要变成模型看得到的消息?

export type SurfaceEventType =
  | 'system/message'
  | 'developer/message'
  | 'user/message'
  | 'assistant/message'
  | 'tool/result'

注意是五种,不是三种。除了常见的 user/assistant/tool,还包括 system/message 与 developer/message——这两个是注入系统提示与开发者指令的特殊表面节点。运行时也有一份对应的运行时集合:

const SURFACE_EVENT_TYPES = new Set<string>([
  'system/message', 'developer/message',
  'user/message', 'assistant/message', 'tool/result',
])

其余一切——turn/start、turn/end、step/start、step/end、tool/call、request/header、goal/change、todo/write、session/end-seed、compaction/start|end|summary……——都是 log-only:它们进账本、可审计、可重放,但永远不会变成一条给模型的消息。

这套切分是编译期 + 运行时双重强制的:

  • 编译期:判别联合让非 surface 类型的事件对象根本没有 surfaceOp 这个字段,你想加也加不上去。
  • 运行时:surfaceOpOf(event) 在 append 点再拦一道,硬塞进来的会被抛错。
阵营类型是否产生 LLM 消息可否带 surfaceOp
surface-eligiblesystem/message是(系统指令)必须带
surface-eligibledeveloper/message是(开发者指令)必须带
surface-eligibleuser/message是(用户输入)必须带
surface-eligibleassistant/message是(模型输出)必须带
surface-eligibletool/result是(工具结果)必须带
log-onlyturn/start、tool/call、request/header、goal/change…否禁止

为什么要把 system/developer 也算进 surface? 因为对模型而言,系统提示和开发者指令和用户消息一样,都是”上下文窗口里的一段消息”。把它们纳入同一套表面节点管理,compaction、截断、重排就有了统一的操作对象——你不能只压缩 user/assistant 而留下 system 不动。这也是为什么 surface 不是”对话消息列表”,而是一个更一般的”有序表面”概念。

本节源码依据(4 处)

surfaceOp:要么追尾,要么按闭区间替换

一条 surface 事件怎么进入有序表面?靠 surfaceOp 声明。它只有两种取值:

export type SurfaceOp =
  | 'append'
  | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }
  • 'append'——正常路径:往表面尾部加一个节点。用户发消息、模型回一段、工具出结果,都是 append。
  • {op:'replace', startSeq, endSeq}——用这条新节点,把当前表面里从 startSeq(含)到 endSeq(含)的一段节点整个替换掉。这是 compaction 的机制:不删历史,而是追加一个摘要节点,遮蔽被压缩的那段旧节点。

注意区间端点叫 startSeq/endSeq,不是旧文档里的 start/end。注释明确:两端都必须是当前表面里真实存在的节点 seq;startSeq === endSeq 时就是替换单个节点。

surfaceOpOf 在 append 点把这套规则硬编码成五道检查:

function surfaceOpOf(event): SurfaceOp | undefined {
  const raw = event
  if (!SURFACE_EVENT_TYPES.has(event.type)) {
    if (raw.surfaceOp !== undefined) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
    if (raw.sourceEventSeqs !== undefined) throw new Error(`... cannot carry sourceEventSeqs`)
    return undefined
  }
  const op = raw.surfaceOp
  if (op === undefined) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
  if (...) throw new Error(`... carries an invalid surfaceOp`)
  if (...) throw new Error(`... carries an invalid replace surfaceOp`)
  ...
}
情形触发的错误
非 surface 事件带了 surfaceOpis not surface-eligible and cannot carry surfaceOp
非 surface 事件带了 sourceEventSeqsis not surface-eligible and cannot carry sourceEventSeqs
surface 事件没带 surfaceOpis surface-eligible and requires a surfaceOp marker
surfaceOp 形状非法carries an invalid surfaceOp
replace 形状非法carries an invalid replace surfaceOp

为什么 replace 要用闭区间 seq,而不是”删几条再插”? 因为 append-only 账本没有”删”这个动作。replace 不是真的去删旧节点,而是追加一个新节点、声明”我遮蔽了 seq [a,b] 这段”。旧节点原样留在账里,表面投影时折掉它们即可。这样一来,压缩既是可追加的、又是可审计的——你随时能从摘要节点反查它遮蔽了哪些原始消息。

本节源码依据(5 处)

sourceEventSeqs:压缩节点必须交代出处

如果说 surfaceOp 声明”我怎么进表面”,那 sourceEventSeqs 声明”我是从哪些事件长出来的”。它是事件之间的因果指针,而它有一套近乎苛刻的契约。

assertSourceEventReferences(event, shadowedSeqs) 在每次 replace 时跑一遍:

function assertSourceEventReferences(event, shadowedSeqs) {
  const raw = event.sourceEventSeqs
  // assistant/message 例外:它内嵌 provider 流,不许带来源
  if (event.type === 'assistant/message' && raw !== undefined) {
    throw new Error('assistant/message embeds its source stream and cannot carry sourceEventSeqs')
  }
  if (raw !== undefined) {
    if (!Array.isArray(raw)) throw new Error('sourceEventSeqs ... must be an array when present')
    if (raw.length === 0)    throw new Error('sourceEventSeqs must not be empty')
    for (const source of raw) {
      if (!isEventSeq(source)) throw new Error('... must densely contain non-negative safe integers')
      if (source >= event.seq) throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`)
    }
    if (sources.size !== raw.length) throw new Error('sourceEventSeqs must not contain duplicates')
  }
  // 关键:replace 遮蔽的每个旧节点,都必须被列进来源
  const missing = shadowedSeqs.filter(seq => !sources.has(seq))
  if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}

把规则列成表:

规则违反时抛错
带了就必须是数组must be an array when present
数组不可为空must not be empty
元素必须是非负安全整数must densely contain non-negative safe integers
每个来源 seq 必须严格早于自身must reference earlier events
不可重复must not contain duplicates
replace 必须覆盖全部被遮蔽节点must include every shadowed surface node
assistant/message 禁止带来源embeds its source stream and cannot carry

为什么”必须覆盖全部被遮蔽节点”是最重要的一条? 因为它是压缩可审计性的根基。一个摘要节点说”我替换了 seq [10..20]“,那它的 sourceEventSeqs 就必须把这 11 个旧节点全部列出来——少列一个,你就永远无法验证”这个摘要到底概括了哪些原文”。这条规则把”声称的遮蔽”和”交代的出处”绑死,杜绝了摘要偷偷漏压一段对话的可能。

而 assistant/message 的例外也很讲究:模型消息是从一个 provider 流式流里拼出来的,它的来源就是那个流本身,不是账本里别的事件——所以它压根不允许走 sourceEventSeqs 这条路。

本节源码依据(5 处)

从信封到表面:planSurfaceEvent 与 foldSurface

光有信封字段还不够——一条新事件”作为下一条表面变更”合不合法,要结合当前表面状态才算。这件事由 planSurfaceEvent 统筹:

function planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) {
  if (event.seq !== expectedSeq) throw ...   // seq 连续性
  const surfaceOp = validateSurfaceMetadata(event)
  if (surfaceOp === undefined) return        // log-only,不动表面
  if (surfaceOp === 'append') return { op: 'append' }
  const range = replacementRange(state, surfaceOp)          // 定位被遮蔽节点
  assertSourceEventReferences(event, range.shadowedSeqs)    // 出处完备
  assertToolResultRewrite(event, range.shadowedSeqs, ...)   // 工具改写一致
  assertSystemHeadRewrite(event, state, range.startIdx, ...)// system-head 改写一致
  return { op: 'replace', start: surfaceOp.startSeq, end: surfaceOp.endSeq }
}

它把上两节讲的所有校验串成一条流水线:先核 seq 连续,再判类型,replace 就定位区间、核出处、核工具与 system-head 改写。这个”先规划、后提交”的动作,在 SurfaceManager.validateNext 里被调用——上一章讲过,它发生在 log.push 之前。

而离线重放整条日志时,用的是同一个折叠函数 foldSurface(events, projections):从空表面开始,逐条 fold,把一串事件折成”当前表面长什么样”。SurfaceManager 只是把这套折叠做成了增量维护。

还有一个边角:known-event-types.ts 里有一个 MESSAGE_PROJECTION_EVENT_TYPES 集合,目前唯一成员是 'image/offload'——这类事件不直接上表面,但会在投影时影响消息内容(比如图片已卸载成引用)。

函数职责时机
surfaceOpOf单条事件的表面字段合法性append 点
replacementRange在当前表面里定位 replace 区间plan 内
assertSourceEventReferences出处完备性plan 内(replace)
assertToolResultRewrite工具结果改写一致plan 内(replace)
assertSystemHeadRewritesystem 头部改写一致plan 内(replace)
planSurfaceEvent串起全部校验,产出表面变更计划push 前
foldSurface离线把事件串折成当前表面恢复/重放

为什么”先规划后提交”要独立成一步,而不是 push 时顺手算? 因为 validateNext 的产出是一份”计划”,SurfaceManager 把它缓存成”待提交的候选”,真正 push 时才落地(注释写:“Candidate already validated by validateNext, pending exact log admission”)。这个分离让校验可以在不碰账本的情况下预演——一次 replace 到底合不合法,在事件真正入账之前就知道了。

本节源码依据(6 处)

小结

  • 每条事件是一个结构化信封:type/seq/time/data/ignorable 五格,外加仅 surface 类型才有的表面两字段。
  • 只有五种事件能上表面:system、developer、user、assistant/message 与 tool/result;其余全是 log-only。
  • surfaceOp 二选一:append 追尾,或按闭区间 startSeq/endSeq replace——历史不删,只追加遮蔽。
  • 出处必须交代干净:replace 节点的 sourceEventSeqs 必须覆盖它遮蔽的每个旧节点,这是压缩可审计的根基。
  • 信封在 append 与构造两点双校验:unknown required 事件、非法表面字段、断裂 seq 全在入账前被拒。

下一章:第 24 章

只读验证

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

# 逐字核对本章引用的信封契约
grep -n "SESSION_FORMAT_VERSION = " "$DSH_SOURCE_DIR/packages/core/session/src/types.ts"
grep -n "is not surface-eligible and cannot carry" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"
grep -n "must include every shadowed surface node" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"
grep -n "must reference earlier events" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"