Surface 投影:模型看到的历史不等于你看到的
深入拆解Surface投影机制——只有user/message、assistant/message、tool/result三类事件经surfaceOp标记进入模型可见面。Surface通过append追加和replace范围替换两种操作构建有序投影;deriveMessages()基于replaceGeneration做增量缓存与全量重建;投影结果是frozen Message共享于delivery/history/request三条路径。
你打开 Session 的事件日志,按 seq 从 0 读到末尾,以为那就是模型在每次请求时看到的对话历史。
错了。
模型看到的东西叫做 Surface——它是日志的一个有序投影,而不是日志本身。投影意味着三件事:
- 日志里绝大多数事件类型(
turn/start、assistant/chunk、tool/call、step/end……)模型根本看不见——它们不参与投影。 - 那些”能被看见”的事件,也不一定以原始顺序出现——
replace操作会用一个新节点替换掉一段旧节点。 - 你在 UI 里看到的完整对话 transcript 和模型收到的
messages数组,是两份不同的东西。前者来自 append-origin 事件(人类看到过的就永远不消失),后者来自当前 Surface(replacement 会把旧消息从模型视野中删除)。
这一章拆的是 Surface 投影:哪些事件有资格、怎么进去、怎么被替换、最后又怎么长成模型看到的 messages 数组。
别一上来就纠结 append / replace。先把分层摆正:日志记录的是发生过什么,Surface 决定的是“当前这一刻模型该看见什么”。
这层分清了,后面三个反直觉点就都顺了:
- 为什么很多日志事件模型根本看不见——因为它们属于追踪和结构边界,不属于模型历史。
- 为什么 replace 不会删除旧事件却能让模型“忘记”旧内容——因为它改的是投影视图,不是事实源。
- 为什么 UI transcript 和模型 messages 数组不是一回事——因为人类要保留见过的内容,模型只需要当前有效 surface。
这一章的作用很简单:把日志、模型历史、人类 transcript 这三层拆开。后面你再去看 replaceGeneration、增量缓存、重建逻辑,就不会串线。
一、Surface 资格门控:只有三类事件可以进入
不是所有事件都能出现在模型面前。资格由一个硬编码的 Set 决定:
const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
])
就这三个。Session 日志里的六十多种事件类型——turn/start、turn/end、step/start、step/end、assistant/chunk、tool/call、compaction/start、approval/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 上从 start 到 end(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 的语义是:
- 遇到
surfaceOp: 'append'的事件:state.nodes.push(seq) - 遇到
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
}
}
关键设计点:
- 懒处理 delta:
nodes和replaceGeneration的 getter 都先检查是否有未处理的新事件。如果有,先把新事件 fold 进来,再返回结果。 - 不保留 replacement 历史:运行时只需要当前 Surface 的 nodes 和 replaceGeneration 计数。完整的 replacement 历史只在
foldSurface(全量重放)时返回。 - 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]
}
缓存策略非常清晰:
- 正常 append 路径:
replaceGeneration没变,只对nodes.slice(derivedNodes)的新节点做投影。已投影的旧节点不重复处理。复杂度 O(new nodes)。 - replace 发生时:
replaceGeneration变了,清空整个derived数组,从头重新投影。这是因为 replace 可能影响任意位置——中间节点被删掉了,索引全变了。 - 返回快照:每次返回
[...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 串起来:
- Agent loop 发消息:调用
session.append('user/message', msg, { surfaceOp: 'append' }) - Session.append:校验 surfaceOp(通过 SurfaceManager.validateNext),写入日志
- SurfaceManager 懒处理:下次访问
nodes时,把新事件 fold 进_state.nodes - Agent loop 构建请求:调用
this.session.deriveMessages()拿到Message[]
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
- 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 时,事件才有 surfaceOp 和 sourceEventSeqs 字段。非 surface 事件的类型里根本不存在这些字段——编译器会拒绝你在 turn/start 事件上设置 surfaceOp。
运行时的 surfaceOpOf 函数是类型系统的镜像:它在运行时检查同样的约束,防止绕过类型系统的错误数据进入日志。
十一、投影缓存的失效时机
deriveMessages 的缓存失效策略极其简单——只看一个数字:replaceGeneration。
private derived: Message[] = []
private derivedNodes = 0
private derivedGeneration = 0
derivedNodes记录”已投影到 Surface nodes 的哪个位置”derivedGeneration记录”这个缓存是在第几次 replace 之后建的”
每次调用 deriveMessages():
- 检查
surface.replaceGeneration是否变了 - 如果变了:清空
derived,derivedNodes = 0,从头来 - 如果没变:只投影
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/message、assistant/message、tool/result 能进 Surface;append 只追加,replace 只改视图不删事实;deriveMessages() 做缓存,遇到 replace 就会触发重建;投影出来的 message 是 frozen 的,delivery/history/request 三条路径复用同一份对象;人类 transcript 只看 append-origin,模型看的是“当前 Surface”。