第二部分 · 核心循环
第 4 章:会话日志——可重建的真相源
append-only 事件流、声明合并的类型词表、以及「模型可见 ⟺ 已记录」不变式
不存消息,只存事件
几乎每个 agent 系统都有「消息列表」:一个 Message[] 数组,模型请求时把它序列化发出去。dsh 的激进选择是:消息从不被存储。每次需要模型历史时,从日志现算;而日志里存的是比消息更底层的东西——SessionEvent。
packages/core/session/src/index.ts 的 Session 类只有两个核心事实:
- 一段 append-only 的
SessionEvent[]日志,seq从 0 连续编号(seq = log.length); - 一个从日志派生的模型可见表面(
surface),deriveMessages()从中投影出Message[]。
「模型可见 ⟺ 已记录」是这个系统的第一定律。任何到达模型请求的输入,必须能从日志重建;反过来,任何写进日志的东西都必须是无损 JSON。这条不变式不是文档里的口号,而是运行时检查——第 3 章那个
deriveMessages()调用点,就是这条定律的执行位。
事件词表:SessionEventMap
日志事件由 SessionEventMap(types.ts)定义——一个声明可合并的接口,插件通过 declare module 往里面加自己的事件类型。核心词表分五组:
| 组 | 事件 | 说明 |
|---|---|---|
| turn/* | turn/start、turn/end | 开/关一个 turn;turn/end 必带 TurnEndReason(completed/aborted/blocked/error/max-tokens/interrupted) |
| step/* | step/start、step/end | 一次模型调用 + 它请求的工具执行 |
| 消息 | user/message、assistant/chunk、assistant/message、tool/call、tool/result | chunk 是 token 级原始流(重放保真);message 是组装结果(投影历史用);tool/call 的 arguments 是模型原始 JSON 字符串,不解析 |
| request/* | request/header、request/context | log-only:最新快照重建请求头(provider/model/effort/system/tools)与路由元数据 |
| 其他 | todo/write、session/end-seed 等 | 整表快照、种子边界标记;插件合并追加 compaction/*、plan/mode、hook/*、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
四道工序:
- 无损 JSON 快照(
snapshotJsonValue,json.ts):拒绝 BigInt、函数、undefined、-0、非有限数、环、稀疏数组与 exotic 对象;迭代遍历(受内存而非调用栈约束)。每个属性只读一次——stateful getter 无法给验证与存储不同的值。 - 深冻结:整个事件
deepFreeze。日志里的东西从此不可变。 - 表面规划先行:
surfaceManager.validateNext在入 log 前先验证(plan-first)——失败不污染表面。 - 入 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.ts 的 interruptedTurnClosers 确定性合成缺失的边界(含 TOOL_NOT_STARTED/TOOL_OUTCOME_UNKNOWN 错误码),不重写已提交事件——恢复路径与主路径同构。
把会话日志想成一本只追加的流水账:seq 是账页号,session/end-seed 是「旧账本装订线」(区分继承账目与本轮记账),ignorable 是「不认识但可忽略的备注」。账本的纪律是:任何账目变动先记后行(inbox 的 splice、工具的 result 都遵守),任何一笔都能凭账页重放。dsh 把「数据库」做成了事件流——持久化(第 14 章)、重放、fork、UI(第 17 章)全部从这同一个源头派生。
实践应用
- 双层记录换双赢:chunk 级原始流 + message 级组装结果 +
sourceEventSeqs显式链接——同一份日志既满足逐 token 重放,又满足高效投影,两条消费路径不互相妥协。 - 兼容性工程压缩成一条决策规则:类型层自由扩展 +
ignorable运行守卫 + 版本号只对结构变化升版、由写入方决定——「向前兼容 vs 向后安全」不再是每改一处都要拍脑袋的问题。 - 「模型看到什么」是可审计的契约:把模型可见性做成编译器强制(surface 类型必带意图)+ 运行时校验(逐字节比对)的契约,任何 desync 在请求发出前 fail——这是写 agent 产品最值得抄的纪律之一。
- plan-first 原子提交:append 先规划后入 log(失败零污染)、快照-冻结-发布一条龙,让不可变性与正确性在源头汇合。
总结
这一章拆完了会话日志:事件词表怎么声明合并、append 怎么保证无损与不可变、三层演进策略怎么让词表增长不破坏兼容、deriveMessages 怎么把日志投影成模型历史、以及「模型可见 ⟺ 已记录」如何被机器执行。日志是系统的账本,而账本只记录「发生了什么」——真正「做事」的是工具。下一章进入工具系统:模型输出一个 tool-call 之后,五段管道如何把它变成日志里的 tool/result。