每条事件都装在带五格字段的信封里
DeepSeek Harness 的每条会话事件都是一个携带结构化信封的记录:type/seq/time/data/ignorable,外加仅 surface-eligible 类型才有的 surfaceOp 与 sourceEventSeqs。信封在 append 点与构造点双重校验,因果引用契约保证压缩、重放与恢复都能逐字对得上。
Written in Chinese
你翻开一本会计账簿,每一页都不是光秃秃一个数字——它有日期、有凭证号、有摘要、有金额、有经手人。Harness 的 Session 账本也一样:每条写进 private readonly log 的记录,都不是一坨裸 data,而是一个带齐字段的信封。上一章我们看了这本账”怎么追加”,这一章拆开一个信封,看它里面到底装了哪几格、每一格由谁把关。
上一章 第 22 章 讲了账本本体与 append 七层守卫。本章聚焦守卫放行之后、那条真正 push 进去的记录长什么样——因为后面所有投影、压缩、恢复,全靠信封上这几格字段才能正确重放。
信封的五格基础字段
一条 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-eligible | system/message | 是(系统指令) | 必须带 |
| surface-eligible | developer/message | 是(开发者指令) | 必须带 |
| surface-eligible | user/message | 是(用户输入) | 必须带 |
| surface-eligible | assistant/message | 是(模型输出) | 必须带 |
| surface-eligible | tool/result | 是(工具结果) | 必须带 |
| log-only | turn/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 事件带了 surfaceOp | is not surface-eligible and cannot carry surfaceOp |
| 非 surface 事件带了 sourceEventSeqs | is not surface-eligible and cannot carry sourceEventSeqs |
| surface 事件没带 surfaceOp | is 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) |
assertSystemHeadRewrite | system 头部改写一致 | 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"