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

模型看到的历史,是现折出来的

模型看到的有序消息历史不是一份独立存储,而是 SurfaceManager 从 append-only 事件账本里实时折出来的:它只在表面状态里存一串节点 seq,deriveEventMessage 做逐字透传,SystemPromptProjection 与 RuntimeContextProjection 决定何时 append、何时 replace。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

你打开一个跑了很久的 Agent 会话,想知道”模型这一轮到底看到了什么”。直觉上你会去找一个 messages: Message[] 字段——一个按时间排好的消息列表。Harness 里没有这个字段。模型看到的历史,是每次你问的时候,从那本 append-only 账本现折出来的。上一章我们拆了事件信封,本章看这套”折”是怎么运作的:谁存什么、谁在什么时候算、为什么算出来的东西既能被增量维护、又能在崩溃后原样重放。

上一章 第 23 章 讲了每条事件的字段与表面契约。本章回答:这些事件进来之后,“模型可见的有序表面”是怎么被维护出来的。

surface-projection 机制图:左侧是 append-only 事件账本(一串 SessionEvent,seq 连续);中间 SurfaceManager 持有表面状态 _state,里面只存 nodes: SessionSeq[] 节点序号列表,不存消息正文;每次访问前 _processDelta 把尚未折叠的尾部事件逐条折进状态——append 就往 nodes 尾加一个 seq,replace 就按闭区间把被遮蔽节点摘掉;右侧 deriveEventMessage 按 seq 回账本逐字透取出 Message(五类映射,空 assistant 产 null);底部两个具体投影 SystemPromptProjection 决定 append 还是 replace system head,RuntimeContextProjection 跟踪 retained 快照、文本未变就不产新消息。

表面只存一串 seq,不存消息

先破除一个误解:表面不是消息列表。它的核心状态是:

class SurfaceManager implements SessionSurface {
  private _state = createFoldState()   // replacement history is not retained
  private _lastProcessedSeq: SessionSeqCursor
  private _pendingPlan: { event; expectedSeq; plan } | undefined
  ...
  get nodes(): readonly SessionSeq[] {
    this._assertProjections()
    if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
    return this._state.nodes
  }
}

nodes 是一串 seq——表面上每个节点对应账本里哪一条事件。它不存消息正文。这是一个极其关键的取舍:

  • 表面回答的是”顺序”问题:模型可见的节点按什么顺序排、哪个被压缩遮蔽了。
  • 正文回答的是”内容”问题:这条节点具体写了什么——那要按 seq 回账本里取。

为什么要这么切分?因为正文会被投影改写(比如图片卸载成引用、系统提示被替换),而”哪些节点还活着”是一个结构事实。把结构和内容分开存,压缩 replace 时只需改 nodes 的 membership,不用复制一大段文本;重放时只要 nodes 对了,内容随时能重新折。

注释里那句 “replacement history is not retained” 点得很透:表面状态不保留被替换掉的历史——它只记得”当前活着哪些节点”。被遮蔽的节点照样在账本里,只是不在 nodes 里了。

表面状态存什么不存什么
nodes模型可见节点的 seq 顺序消息正文
_state.projectedMessages投影改写后的消息缓存被遮蔽节点的历史
_lastProcessedSeq已折叠到哪一条—

为什么”不保留替换历史”反而是优点? 因为表面是一个”当前快照”,不是”历史日志”。历史本来就在账本里,表面再存一份只会造成双写和不一致。表面保持轻、账本保持全——需要追溯时回账本,需要当前视图时读 nodes。这是单一事实源原则:正文只在账本里有一份权威。

本节源码依据(4 处)

增量折叠:validateNext 预演,_processDelta 落地

上一章讲了 foldSurface(events) 是离线把整条日志折成表面。SurfaceManager 做的是同一件事的增量版——它不每次重折全量,而是只处理”上次折到现在”之间的新事件。

关键是 validateNext 与 _processDelta 的配合:

validateNext(event: SessionEvent): void {
  this._assertProjections()
  if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
  const expectedSeq = SessionSeq(this.baseSeq + this.log.length)
  this._pendingPlan = {
    event, expectedSeq,
    plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq, this.projections),
  }
}

private _processDelta(): void {
  const tailSeq = this.baseSeq + this.log.length - 1
  for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
    const event = this.log[seq - this.baseSeq]
    const pending = this._pendingPlan
    if (pending?.event === event && pending.expectedSeq === seq) {
      applySurfacePlan(this._state, pending.plan)      // 用预演好的计划
    } else {
      applySurfaceEvent(this._state, event, ...)        // 直接折
    }
    if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
    this._lastProcessedSeq = SessionSeq(seq)
  }
}

两个动作分工:

  • validateNext(push 前):拿候选事件在不动表面的情况下,用 planSurfaceEvent 算出一份计划,存进 _pendingPlan。这就是上一章说的”先规划后提交”。
  • _processDelta(每次访问前惰性触发):从 _lastProcessedSeq+1 一路折到账本末尾。遇到恰好是那条已预演的事件,就 applySurfacePlan 落地计划;否则直接 applySurfaceEvent。

注意 _processDelta 是惰性的——它不主动跑,而是在每次读 nodes、deriveEventMessage、replaceGeneration 之前先检查”尾部有没有没折的”,有才折。这把折叠成本摊到实际读取点上。

SurfaceManager 还暴露两个单调计数:

get replaceGeneration(): number   // 折过的 positional replace 次数
get contentGeneration(): number  // 已有模型可见内容被改写的次数
时机动作是否动表面
validateNext(push 前)算计划存 _pendingPlan否(预演)
_processDelta(读前)尾追折到账本末尾是
命中 _pendingPlanapplySurfacePlan落地预演计划
未命中applySurfaceEvent直接折

为什么要用惰性折叠,而不是 append 完立刻折? 因为一次 append 可能产生多个观察者读取,惰性折叠让”第一次读”付出折叠成本,“后续读”直接读现成状态。更重要的是,validateNext 在 push 之前就已经预演过计划了,真正入账时 _processDelta 只需落地,不必重新校验——这样校验只发生一次,折叠和校验解耦。

本节源码依据(5 处)

deriveEventMessage:一条事件变成什么消息

表面状态只回答”有哪些节点”,那”某节点具体是哪条 Message”由 deriveEventMessage 负责。它是一个刻意极简的逐字透传:

export function deriveEventMessage(event, projectedMessages?): Message | null {
  const projected = projectedMessages?.get(event.seq)
  if (projected !== undefined) return projected     // 投影改写过的优先
  switch (event.type) {
    case 'user/message': return event.data
    case 'system/message':
    case 'developer/message':
    case 'assistant/message': {
      if (event.data.message.content.length === 0) return null
      return event.data.message
    }
    case 'tool/result': return event.data.message
    default: return null     // 边界、attempt、log-only 事件投影成无消息
  }
}

四条规则:

  1. 有投影缓存就用投影——如果某个 message projection(比如 image/offload)改写过这条消息,用改写版。
  2. user/message 逐字返回,不做任何包装。
  3. system/developer/assistant 三类,空 content 投影成 null——空 assistant 事件可能只是一次 max-tokens 步的 usage 记录,不该往 provider 转录里塞一条空轮。
  4. 其余一切事件(turn/step 边界、tool/call、request/header…)投影成 null——它们是 trace/replay 数据,不进模型历史。

注释里专门警告了一件事:不要在这里重新加 per-type framing(比如给 user 消息包一层 <context>)。framing 是 producer 的责任——要么烤进 content,要么由事件的 meta map 驱动专门 renderer。投影层只做 verbatim pass-through。

事件类型deriveEventMessage 产出
user/message原样 Message
system/developer/message(非空)Message
system/developer/assistant/message(空)null
tool/resultMessage
turn/step、tool/call、request/header…null
被 projection 改写过的改写版 Message

为什么”空 assistant 投影成 null”这种细节要专门设计? 因为模型转录是有代价的:一条空内容消息混进去,会让 provider 以为上一轮模型说了点什么、结果是空的,干扰对话连贯性。把”只承载 usage 的空轮”挡在转录之外,是投影层对”什么才算一条真消息”的精确定义。

本节源码依据(5 处)

两个具体投影:SystemPrompt 与 RuntimeContext

通用表面折叠管的是”节点顺序”,而两类特殊内容需要专门的投影逻辑。

SystemPromptProjection——它管系统提示节点。每次渲染完一版系统提示,它要决定”是往表面尾 append 一个新 system 节点,还是 replace 掉旧的”:

project(rendered, input): SystemPromptCommit[] {
  const nodes = this.systemNodes()
  const head = nodes[0]
  if (head === undefined) return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
  if (!input.inHistory || input.startsSeries || rendered.length === 0) {
    // 新序列开始:清掉其余旧 system 节点,replace head
    ...
  }
  if (latest.text === rendered) return []      // 文本没变:什么都不做
  return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
}

核心判断:没有 head 就 append;文本和最新节点一样就返回空数组(不产 commit);否则要么清旧 replace head,要么 append 新节点。它用的正是上一章的 replace 语义——surfaceOp: {op:'replace',startSeq,endSeq} 加 sourceEventSeqs:[seq]。

RuntimeContextProjection——它跟踪一段”运行时上下文快照”(比如当前工作目录、加载的文件清单这类动态注入的 user 消息)。它构造时反向扫描账本,找最近一条还在表面里、归自己所有的 user/message 作为 retained;之后监听 session/event 增量更新:

ctx.on('session/event', (subject, event) => {
  if (subject !== session) return
  if (event.type === 'user/message' && isOwned(event.data)) {
    this.retained = { seq: event.seq, text: textOf(event.data) }
  } else if (this.retained && isReplacementSurfaceEvent(event)
             && event.sourceEventSeqs?.includes(this.retained.seq)) {
    this.retained = null     // 压缩把我遮蔽了
  }
})

project(current, sections): UserMessage | undefined {
  if (this.retained?.text === snapshot) return    // 文本没变:不产新消息
  return createUserMessage({ content: [{type:'text', text: snapshot}], ... })
}

两个精妙之处:一是文本相同就 return undefined——避免系统/上下文没变时反复往表面塞 replace;二是被压缩遮蔽就把 retained 置 null——下次自然重新 append。

投影管什么何时 append何时 replace
SystemPromptProjection系统提示节点无 head / 新内容新序列开始时清旧、改 head
RuntimeContextProjection运行时上下文快照retained 被遮蔽后内容变化时(文本同则不动)

为什么这两个投影要独立于通用折叠? 因为它们管理的是”会随每轮重算的动态内容”——系统提示和上下文快照不是一次性事件,而是每一步都可能重新渲染。通用折叠只负责”事件进来怎么排”,而这两个投影负责”这段动态内容该产生哪个表面变更”。把决策逻辑放在投影里,账本里追加的就只是最终结果,而不是每次重算的中间态。

本节源码依据(6 处)

小结

  • 表面只存节点 seq,不存正文:nodes 是当前活着的模型可见节点顺序,正文按 seq 回账本取。
  • 折叠是增量的:validateNext 预演计划、_processDelta 尾追落地,惰性触发,校验只发生一次。
  • deriveEventMessage 是逐字透传:五类事件映射成消息,空 assistant 产 null,其余 log-only 全投成 null。
  • 动态内容有专门投影:SystemPromptProjection 管系统提示的 append/replace 决策,RuntimeContextProjection 跟踪上下文快照,文本未变就不动。
  • 表面不保留替换历史:被遮蔽的节点留在账本里,表面只维护当前快照——单一事实源。

下一章:第 25 章

只读验证

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

# 逐字核对本章引用的表面投影契约
grep -n "replacement history is not retained" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"
grep -n "was removed or replaced" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"
grep -n "content.length === 0" "$DSH_SOURCE_DIR/packages/core/session/src/surface.ts"