青雲的博客
深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 22 章

Surface 投影:模型看到的历史不等于你看到的

深入拆解Surface投影机制——只有user/message、assistant/message、tool/result三类事件经surfaceOp标记进入模型可见面。Surface通过append追加和replace范围替换两种操作构建有序投影;deriveMessages()基于replaceGeneration做增量缓存与全量重建;投影结果是frozen Message共享于delivery/history/request三条路径。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你打开 Session 的事件日志,按 seq 从 0 读到末尾,以为那就是模型在每次请求时看到的对话历史。

错了。

模型看到的东西叫做 Surface——它是日志的一个有序投影,而不是日志本身。投影意味着三件事:

  1. 日志里绝大多数事件类型(turn/startassistant/chunktool/callstep/end……)模型根本看不见——它们不参与投影。
  2. 那些”能被看见”的事件,也不一定以原始顺序出现——replace 操作会用一个新节点替换掉一段旧节点。
  3. 你在 UI 里看到的完整对话 transcript 和模型收到的 messages 数组,是两份不同的东西。前者来自 append-origin 事件(人类看到过的就永远不消失),后者来自当前 Surface(replacement 会把旧消息从模型视野中删除)。

这一章拆的是 Surface 投影:哪些事件有资格、怎么进去、怎么被替换、最后又怎么长成模型看到的 messages 数组。

别一上来就纠结 append / replace。先把分层摆正:日志记录的是发生过什么,Surface 决定的是“当前这一刻模型该看见什么”。

这层分清了,后面三个反直觉点就都顺了:

  1. 为什么很多日志事件模型根本看不见——因为它们属于追踪和结构边界,不属于模型历史。
  2. 为什么 replace 不会删除旧事件却能让模型“忘记”旧内容——因为它改的是投影视图,不是事实源。
  3. 为什么 UI transcript 和模型 messages 数组不是一回事——因为人类要保留见过的内容,模型只需要当前有效 surface。

这一章的作用很简单:把日志、模型历史、人类 transcript 这三层拆开。后面你再去看 replaceGeneration、增量缓存、重建逻辑,就不会串线。


一、Surface 资格门控:只有三类事件可以进入

不是所有事件都能出现在模型面前。资格由一个硬编码的 Set 决定:

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

就这三个。Session 日志里的六十多种事件类型——turn/startturn/endstep/startstep/endassistant/chunktool/callcompaction/startapproval/asked……全部不参与 Surface 投影。

isSurfaceEligibleType 函数是唯一的门控入口:

export function isSurfaceEligibleType(type: string): boolean {
  return SURFACE_EVENT_TYPES.has(type)
}

这个设计的含义是:模型的历史视野中只存在”完整消息”级别的事件。流式 chunk 不是消息(它是构建消息的中间产物);turn/step 边界不是消息(它是结构性 bookkeeping);tool/call 不是消息(它是 assistant/message 内部 tool_calls 数组的子结构)。模型看到的是最终组装好的、可以直接塞进 API messages 数组的东西。


二、surfaceOp:事件如何进入 Surface

仅仅类型匹配还不够。一个 surface-eligible 类型的事件必须携带 surfaceOp 标记,否则它不会被认为是一个 Surface 事件——甚至会在 fold 时抛出异常。

function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
  if (!isSurfaceEligibleType(event.type)) {
    if (raw.surfaceOp !== undefined) {
      throw new Error(`"${event.type}" is not surface-eligible and cannot carry surfaceOp`)
    }
    return
  }
  if (op === undefined) {
    throw new Error(`"${event.type}" is surface-eligible and requires a surfaceOp marker`)
  }
  // ... validate append or replace shape
}

这是一个双向约束:

  • 如果你的事件类型在那三个之中,你必须surfaceOp。不带就炸。
  • 如果你的事件类型不在那三个之中,你不能surfaceOp。带了也炸。

surfaceOp 本身是一个联合类型:

type SurfaceOp =
  | 'append'
  | { op: 'replace'; start: number; end: number }

两种操作,两种语义:

2.1 append——追加到 Surface 尾部

绝大多数正常对话流走这条路。用户发消息、模型回复、工具返回结果——它们各自以 surfaceOp: 'append' 加入 Surface 的末尾。这是最简单的路径:Surface 的 nodes 数组 push 一个新的 seq。

// agent-loop 里用户消息的典型 append
this.session.append('user/message', message, { surfaceOp: 'append' })

2.2 replace——用新节点替换一段旧节点

当 compaction 发生时(或者任何需要”重写模型历史”的场景),一个新事件可以声明:我要替换 Surface 上从 startend(inclusive)的所有现有节点。被替换的旧节点叫做 shadowed nodes——它们从 Surface 上消失了,但仍然留在日志里。

session.append('assistant/message', {
  turn: 2, step: 1,
  message: compactionSummary,
}, {
  surfaceOp: { op: 'replace', start: 1, end: 2 },
  sourceEventSeqs: [1, 2],
})

执行这个 replace 后:原来 Surface 上 seq 1 和 seq 2 的位置被新事件(seq 4)替代。模型在下次请求时只看到 seq 4 的 compaction summary,看不到原始的 seq 1 和 seq 2。


三、Surface Fold:从日志重放出当前 Surface 状态

Surface 不是一个独立的持久化结构——它是从日志中确定性派生出来的。foldSurface 函数对整个日志做一遍线性扫描,应用每个 surface 事件的 surfaceOp,最终输出当前的 nodes 序列和所有 replacement 记录:

export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
  const state = createFoldState()
  const replacements: SurfaceFoldReplacement[] = []
  for (const [index, event] of events.entries()) {
    const replacement = applySurfaceEvent(state, event, index, events, 0)
    if (replacement !== undefined) replacements.push(replacement)
  }
  return { nodes: [...state.nodes], replacements }
}

Fold 的语义是:

  1. 遇到 surfaceOp: 'append' 的事件:state.nodes.push(seq)
  2. 遇到 surfaceOp: { op: 'replace', start, end } 的事件:在 nodes 中找到 start 和 end 对应的索引,splice 掉整个范围,换上新 seq

结果是一个 nodes: number[]——按模型可见顺序排列的事件 seq 列表。这就是模型看到的”历史”。

3.1 Replace 的范围校验

Replace 操作有严格的前置校验。start 和 end 必须是当前 Surface 上实际存在的节点,且 start 的索引不能在 end 之后:

function replacementRange(state, op) {
  const startIdx = state.nodes.indexOf(op.start)
  if (startIdx === -1) throw new Error(`start seq ${op.start} not found in surface`)
  const endIdx = state.nodes.indexOf(op.end)
  if (endIdx === -1) throw new Error(`end seq ${op.end} not found in surface`)
  if (startIdx > endIdx) throw new Error(`start is after end`)
  return { startIdx, endIdx, shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1) }
}

注意这里用的是 indexOf——在一个小数组上做线性查找。Surface 的 nodes 数组通常很短(几十到几百个消息级事件),所以这不是性能瓶颈。

3.2 sourceEventSeqs 溯源约束

每个 replace 事件必须在 sourceEventSeqs 中列出所有它 shadow 掉的节点。这是一个完整性审计:你不能悄悄删掉一个 Surface 节点却不声明你知道它的存在。

const missing = shadowedSeqs.filter(seq => !sources.has(seq))
if (missing.length > 0) {
  throw new Error(`sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}

这个设计让日志保留了完整的因果链:任何 replacement 都能追溯到它替换了哪些原始事件。

3.3 tool/result 替换的特殊限制

Tool result 的 replace 比一般替换更严格——它只能替换一个当前的 tool/result 节点,且只能改变 content 字段:

function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
  if (event.type !== 'tool/result') return
  if (shadowedSeqs.length !== 1) {
    throw new Error('tool/result replacement must rewrite exactly one current node')
  }
  // ... structural equality check on everything except content
}

这意味着你可以”修改”一个工具的输出内容(比如截断过长的结果),但不能改变它是哪个 callId 的返回、不能改变 isError 标志。工具输出的身份不变,只有正文可以被重写。


四、SurfaceManager:增量 Fold 与懒处理

foldSurface 对整个日志做全量重放——适合测试和外部重建器。但运行时的 Session 对象不会每次都从头 fold。它用 SurfaceManager 做增量处理:

export class SurfaceManager implements SessionSurface {
  private _state = createFoldState()
  private _lastProcessedSeq: number
  private _pendingPlan: { event; expectedSeq; plan } | undefined

  get nodes(): readonly number[] {
    if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
    return this._state.nodes
  }

  get replaceGeneration(): number {
    if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
    return this._state.replaceGeneration
  }
}

关键设计点:

  1. 懒处理 deltanodesreplaceGeneration 的 getter 都先检查是否有未处理的新事件。如果有,先把新事件 fold 进来,再返回结果。
  2. 不保留 replacement 历史:运行时只需要当前 Surface 的 nodes 和 replaceGeneration 计数。完整的 replacement 历史只在 foldSurface(全量重放)时返回。
  3. validateNext 预校验:在事件还没进入日志之前,可以先调用 validateNext 验证它的 surfaceOp 是否合法。如果校验失败,事件不会被 append 到日志里——Surface 状态不受影响。

这个”先验证再提交”的模式保证了 Surface 的原子性:一个非法的 replace 不会把 Surface 弄成半坏状态。


五、deriveMessages:从 Surface 到模型的 messages 数组

Surface 的 nodes 告诉你”哪些事件按什么顺序对模型可见”。但模型 API 需要的是 Message[] 数组。这个转换由 deriveMessages() 完成:

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]!)
    if (msg) this.derived.push(msg)
  }
  this.derivedNodes = nodes.length
  return [...this.derived]
}

缓存策略非常清晰:

  1. 正常 append 路径replaceGeneration 没变,只对 nodes.slice(derivedNodes) 的新节点做投影。已投影的旧节点不重复处理。复杂度 O(new nodes)。
  2. replace 发生时replaceGeneration 变了,清空整个 derived 数组,从头重新投影。这是因为 replace 可能影响任意位置——中间节点被删掉了,索引全变了。
  3. 返回快照:每次返回 [...this.derived]——一个新数组。调用方持有的旧数组不会因为后续 append 而增长。但数组里的 Message 对象是共享的、frozen 的。

5.1 per-node 投影规则:deriveEventMessage

每个 Surface 节点通过 deriveEventMessage 投影为一个 Message(或 null):

export function deriveEventMessage(event: SessionEvent): Message | null {
  switch (event.type) {
    case 'user/message':
      return event.data        // 整个 UserMessage 就是一条消息
    case 'assistant/message':
      if (event.data.message.content.length === 0) return null
      return event.data.message  // 非空 content 的 assistant 消息
    case 'tool/result':
      return event.data.message  // tool result 消息
    default:
      return null              // 非 surface 事件,无投影
  }
}

注意 assistant/message 的特殊处理:空 content 投影为 null。为什么?因为 harness 会为 max-tokens 中断的 step 写一个空 content 的 assistant/message 来承载 usage 数据。这个事件在 Surface 上有位置(它确实是 surface-eligible 的),但它不应该作为一条消息出现在模型的 transcript 里——模型不需要看到一条空回复。

5.2 投影结果的不可变性

投影返回的 Message 是 frozen 的。这是因为事件本身在 append 时就被 freeze 了,而投影直接返回事件内部嵌套的 message 对象——没有 clone,没有拷贝。delivery(发给 UI 的流)、durable history(持久化的日志)、model requests(下次 LLM 调用的 messages 参数)三条路径共享同一份冻结对象。

这意味着:如果你试图修改 deriveMessages() 返回的某条消息的 content,会得到 TypeError。不变性是结构性保证的,不是靠”请不要改”的注释。


六、Surface vs. 人类 Transcript:append-origin 的意义

代码里有一对类型守卫明确区分了两种 Surface 事件:

export function isAppendSurfaceEvent(event) {
  return isSurfaceEvent(event) && event.surfaceOp === 'append'
}

export function isReplacementSurfaceEvent(event) {
  return isSurfaceEvent(event) && event.surfaceOp !== 'append'
}

为什么要分?因为它们服务于不同的消费者:

  • 模型看的是当前 Surface——包括 replacement 节点。replacement 节点”替代”了被它 shadow 的旧节点。
  • 人类 transcript(UI 里的对话历史)看的是所有 append-origin 事件——replacement 节点对人不可见,因为人已经看到了被替换的原始消息。

isAppendSurfaceEvent 的 JSDoc 说得很直白:

The model-visible surface deliberately shadows replaced ranges, so it is the wrong source for a human transcript — a landed replacement would erase conversation the user already saw. Append-origin events are that transcript’s durable source material; replacement copies stay model-only.

这是一个关键的认知分裂:模型和人类看到的历史在 compaction 发生后不再相同。模型看到的是压缩后的摘要;人类看到的是完整的原始对话。两者都从同一份 append-only 日志派生,但走的是不同的投影路径。


七、完整的数据流:从 append 到模型请求

把整个 pipeline 串起来:

  1. Agent loop 发消息:调用 session.append('user/message', msg, { surfaceOp: 'append' })
  2. Session.append:校验 surfaceOp(通过 SurfaceManager.validateNext),写入日志
  3. SurfaceManager 懒处理:下次访问 nodes 时,把新事件 fold 进 _state.nodes
  4. Agent loop 构建请求:调用 this.session.deriveMessages() 拿到 Message[]
const { request, preparedCall } = await this.buildRequest(
  turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
  1. LLM 收到的 messages:就是 Surface 投影后的结果。没有 chunks,没有 boundaries,没有被 shadow 的旧消息。

八、Replace 的实际执行:splice 语义

applySurfacePlan 执行一个 replace 操作时,它做的事情非常直接——数组 splice:

function applySurfacePlan(state, plan) {
  if (plan?.kind === 'append') {
    state.nodes.push(plan.seq)
  } else if (plan?.kind === 'replace') {
    state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
    state.replaceGeneration += 1
  }
}

一个 replace 操作:

  • startIdx 开始,删除 endIdx - startIdx + 1 个元素
  • 在同一位置插入新节点的 seq
  • replaceGeneration 加 1(触发 deriveMessages 缓存重建)

替换后新节点占据了被替换范围的起始位置。如果 Surface 原来是 [0, 1, 2],replace start=0, end=1 变成 [3, 2]——新节点 3 在位置 0,原来的 seq 2 保持不动。

这意味着 replace 保持了非替换区域的相对顺序。


九、SurfaceManager 的窗口化支持

SurfaceManager 构造时接受一个 baseSeq 参数:

constructor(
  private log: readonly SessionEvent[],
  private readonly baseSeq = 0,
) {
  this._lastProcessedSeq = baseSeq - 1
}

这允许它在一个”窗口”内工作——不从 seq 0 开始,而是从某个 baseSeq 开始。用途是:当日志被分段加载时(比如只加载最近 N 个事件),SurfaceManager 仍然能在这个窗口内正确 fold。

但有一个限制:如果一个 replace 的 start 引用了窗口之外的 seq(比 baseSeq 更早的),fold 会失败——因为那个节点不在当前 Surface 的 nodes 中。这是有意的:你不能对你没加载的历史做 replace。


十、类型系统的双层保护

Session 的类型定义在编译时就区分了 surface-eligible 和非 surface-eligible 事件:

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

SessionEvent 的类型定义用条件类型做了静态约束:

type SessionEvent<T> = {
  [K in SessionEventType]: {
    // ... 基础字段
  } & (K extends SurfaceEventType ? {
    sourceEventSeqs?: number[]
    surfaceOp?: SurfaceOp
  } : object)
}[T]

只有当 K 是 SurfaceEventType 时,事件才有 surfaceOpsourceEventSeqs 字段。非 surface 事件的类型里根本不存在这些字段——编译器会拒绝你在 turn/start 事件上设置 surfaceOp。

运行时的 surfaceOpOf 函数是类型系统的镜像:它在运行时检查同样的约束,防止绕过类型系统的错误数据进入日志。


十一、投影缓存的失效时机

deriveMessages 的缓存失效策略极其简单——只看一个数字:replaceGeneration

private derived: Message[] = []
private derivedNodes = 0
private derivedGeneration = 0
  • derivedNodes 记录”已投影到 Surface nodes 的哪个位置”
  • derivedGeneration 记录”这个缓存是在第几次 replace 之后建的”

每次调用 deriveMessages()

  1. 检查 surface.replaceGeneration 是否变了
  2. 如果变了:清空 derivedderivedNodes = 0,从头来
  3. 如果没变:只投影 nodes.slice(derivedNodes) 的新节点

为什么 replace 要全量重建而不是局部修补?因为 replace 可以替换 Surface 任意位置的节点——中间、开头、末尾都可能。局部修补需要知道”哪些位置变了”,而 replace 的 splice 语义让旧索引全部失效。全量重建是最简单、最正确的策略,对于几百个节点的规模完全可以接受。


十二、空 assistant/message 的特殊处理

这个边界案例值得单独说明。当模型的 output 因为 max_tokens 被截断,harness 会写一个 assistant/message 事件,但它的 content 是空数组——因为流被截断了,没有产生任何完整的 content block。

这个事件:

  • surface-eligible(类型是 assistant/message
  • 带有 surfaceOp: 'append'(它确实进入了 Surface)
  • 承载 usage 数据(input/output tokens)
  • deriveEventMessage 对它返回 null
case 'assistant/message': {
  if (event.data.message.content.length === 0) return null
  return event.data.message
}

所以它在 Surface 的 nodes 里有一个位置(占据了一个 seq),但 deriveMessages() 跳过了它(null 不 push 进 derived 数组)。模型在下次请求时不会看到一条空的 assistant 消息——这会让模型困惑。


十三、从全量 fold 到增量 fold 的一致性保证

源码里有一个重要的不变量:SurfaceManager 的增量结果必须和 foldSurface 的全量结果完全一致。测试里用 “scratch oracle” 模式验证这一点:

function scratch(session: Session): unknown {
  return Session.create(
    SessionId(`${session.id}-scratch-${session.seq}`),
    [...session.events]
  ).deriveMessages()
}

// 每次操作后验证一致性
expect(session.deriveMessages()).toEqual(scratch(session))

这意味着你可以用 foldSurface 做独立的离线重建(比如 debug 工具),结果和运行时的增量派生完全一样。Surface 投影是确定性的——给定相同的日志,总是产生相同的 nodes 序列和 messages 数组。


十四、为什么 Surface 是投影而不是独立存储

你可能会问:为什么不直接维护一个独立的 messages 数组?每次有新消息就 push,compaction 时就 splice?为什么要绕一圈通过 surfaceOp 标记再从日志里派生?

答案在 append-only 日志的核心约束里:日志不可修改。一旦事件写入,它的 seq、time、data 永远不变。如果你要”修改”模型看到的历史,你不能去改旧事件——你只能写一个新事件,声明”我替代了旧的那些”。

Surface 是这个约束的自然产物。它把”模型当前看到什么”这个可变状态,编码为了一系列不可变的操作(append、replace)记录在日志里。任何时候,你只要重放日志就能重建出 Surface 的当前状态。

这带来了几个好处:

  • 可审计:每次 replace 都有记录,你能看到”什么时候、什么东西被替换成了什么”
  • 可重放:crash 后重启,从日志重放即可恢复 Surface 状态
  • 无歧义:不存在”日志里说一套,Surface 里是另一套”的不一致

十五、实验:亲手验证投影行为

实验 1:观察 Surface 过滤

在一个有 chunks 和 boundaries 的 Session 里调用 deriveMessages(),观察返回数组的长度远小于 session.events.length

const s = Session.create(SessionId('exp'))
s.append('turn/start', { turn: 1 })           // 不进 Surface
s.append('assistant/chunk', { ... })           // 不进 Surface
s.append('assistant/chunk', { ... })           // 不进 Surface
s.append('user/message', msg, { surfaceOp: 'append' })      // 进 Surface
s.append('assistant/message', reply, { surfaceOp: 'append' }) // 进 Surface
s.append('turn/end', { ... })                  // 不进 Surface

// events.length = 6, deriveMessages().length = 2

实验 2:观察 replace 的 shadow 效果

s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], ... }),
  { surfaceOp: 'append' })  // seq 0
s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'b' }], ... }),
  { surfaceOp: 'append' })  // seq 1

// Surface: [0, 1] -> deriveMessages = ['a', 'b']

s.append('assistant/message', { message: summary },
  { surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] })  // seq 2

// Surface: [2] -> deriveMessages = ['summary']
// 旧事件 seq 0, 1 仍在日志里,但模型看不到了

收口

Surface 投影的本质是:在不可变日志之上,用声明式操作(append/replace)维护一个可变的有序视图。这个视图就是模型的历史——它不等于日志本身,也不等于 UI 的 transcript,而是专门为模型请求服务的一层派生结构。

你把这几个事实抓牢就够了:只有 user/messageassistant/messagetool/result 能进 Surface;append 只追加,replace 只改视图不删事实;deriveMessages() 做缓存,遇到 replace 就会触发重建;投影出来的 message 是 frozen 的,delivery/history/request 三条路径复用同一份对象;人类 transcript 只看 append-origin,模型看的是“当前 Surface”。