模型看到的历史,是现折出来的
模型看到的有序消息历史不是一份独立存储,而是 SurfaceManager 从 append-only 事件账本里实时折出来的:它只在表面状态里存一串节点 seq,deriveEventMessage 做逐字透传,SystemPromptProjection 与 RuntimeContextProjection 决定何时 append、何时 replace。
本章以中文撰写
你打开一个跑了很久的 Agent 会话,想知道”模型这一轮到底看到了什么”。直觉上你会去找一个 messages: Message[] 字段——一个按时间排好的消息列表。Harness 里没有这个字段。模型看到的历史,是每次你问的时候,从那本 append-only 账本现折出来的。上一章我们拆了事件信封,本章看这套”折”是怎么运作的:谁存什么、谁在什么时候算、为什么算出来的东西既能被增量维护、又能在崩溃后原样重放。
上一章 第 23 章 讲了每条事件的字段与表面契约。本章回答:这些事件进来之后,“模型可见的有序表面”是怎么被维护出来的。
表面只存一串 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(读前) | 尾追折到账本末尾 | 是 |
命中 _pendingPlan | applySurfacePlan | 落地预演计划 |
| 未命中 | 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 事件投影成无消息
}
}
四条规则:
- 有投影缓存就用投影——如果某个 message projection(比如 image/offload)改写过这条消息,用改写版。
- user/message 逐字返回,不做任何包装。
- system/developer/assistant 三类,空 content 投影成 null——空 assistant 事件可能只是一次 max-tokens 步的 usage 记录,不该往 provider 转录里塞一条空轮。
- 其余一切事件(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/result | Message |
| 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"