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

投影(Projection):同一份日志长出不同状态

Session支持多种projection从同一份event log派生状态。requestHeader()增量fold,requestContext()同理,RuntimeContextProjection检查最后一个owned user/message去重,applyGoalProjection轻量last-wins。所有投影结果deepFreeze防consumer修改。

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

你每次调用 session.requestHeader(),它不是从头遍历所有 events 算出来的。它只看上次 fold 到哪里了,从那个位置往后处理新事件。这就是投影(Projection)模式:同一份不可变日志,派生多种只读状态,每种状态有自己的增量缓存。

Surface 是投影(给模型看的 messages)。Header 是投影。Context 是投影。Goal 是投影。Runtime context 也是投影。它们都从同一份 log 长出来,但各有各的缓存策略和 fold 逻辑。

如果前两章回答的是“事实源是什么”和“模型历史怎么长出来”,这一章回答的就是另一件事:同一份事实源怎么长出多份状态视图,而且每份视图都只增量处理新事件。

别把 Projection 想成“把日志从头重算一遍”。在 Harness 里,它更像一组各自带缓存的折叠器:Header 关心配置快照,Context 关心路由元数据,Goal 关心 last-wins 状态,Runtime context 关心注入去重。它们共用一份日志,但折叠规则不是一套。

先把这个模式看明白,再去看 requestHeader()requestContext()RuntimeContextProjection。不然你很容易陷在某个 fold 细节里,读到后面不知道它到底在解决什么。

requestHeader:增量 fold + 规范化比较

每次 step 准备发请求前,需要知道”当前生效的 request header 是什么”——model、temperature、maxTokens 这些配置。但 header 不是存在一个可变对象里的。它通过 fold 日志里的 request/header 事件得到。

Session 维护两个字段:

private headerFold: EpochHeader | undefined
private headerFoldSeq = 0

调用 requestHeader() 时:

  1. 如果 headerFoldSeq >= this.log.length,缓存命中,直接返回 `headerFold
  2. 否则 slice 出新 events(this.log.slice(this.headerFoldSeq)),从当前 headerFold 开始 fold
  3. fold 结果 deepFreeze,更新 headerFoldSeq 到 log.length
  4. 返回新的 headerFold

这里有个关键细节:不是简单的 latest-winsfoldRequestHeader 做规范化处理(canonicalHeader),然后用 headerEquals 做深度比较。如果新 fold 出来的 header 和旧的语义等价,返回旧引用。这意味着调用方可以用 ===Object.is 来判断 header 是否真的变了——没变就不需要重建请求配置。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "headerFold" "$repo/packages/core/session/src/index.ts"
grep -n "foldRequestHeader" "$repo/packages/core/session/src/index.ts"

requestContext() 更简单——它也是增量 fold,但逻辑就是展开运算符合并对象:遇到 request/context 事件就 { ...event.data } 覆盖。Context 没有 header 那么复杂的规范化需求,直接 latest-wins。

RuntimeContextProjection:反向扫描去重

RuntimeContext 是一个外部投影,不在 Session 类里面,在 agent-loop 包里。它解决一个具体问题:动态上下文(比如当前打开的文件、AGENTS.md 内容、cwd 状态)每步都可能变,但如果内容没变,你不想每步都往 session 里 append 一个相同的 user/message——那会污染历史、浪费 token。

RuntimeContextProjection 的策略很聪明:

构造时(session 已存在): 从日志尾部反向扫描,找到最后一个 isOwned(event.data) 的 user/message(owned 表示是 runtime context 自己注入的,不是用户发的)。记住它的 seq 和内容 text。反向扫描是因为你要的是”最后一个”,从后往前找第一个就停。

运行时(监听 session/event):

  • 如果是新的 owned user/message,更新 retained 为 {seq, text}
  • 如果是 replace 事件且 sourceEventSeqs 包含当前 retained 的 seq(说明我们的 context 消息被 compaction 替换掉了),把 retained 设为 null——需要重新注入

project() 方法: 传入 current(当前渲染出的完整 context 文本),和 retained 比较:

  • 没有 retained 且 current 为空 → undefined(不需要注入)
  • retained.text === snapshot(snapshot 是 current 或 CLEARED 标记)→ undefined(没变,不重复注入)
  • 否则返回一个新的 UserMessage,append 到 session

这就是”去重注入”。Runtime context 不会每步都发消息,只在内容真正变化时发。

flowchart TD
    A["project(current, sections)"] --> B{"retained === undefined\n且 current 为空?"}
    B -->|是| Z["return undefined\n无需注入"]
    B -->|否| C["snapshot = current || CLEARED"]
    C --> D{"retained?.text === snapshot?"}
    D -->|是| Z
    D -->|否| E["return new UserMessage\nappend到session"]
    style Z fill:#666,color:#fff
    style E fill:#1a3a5c,stroke:#d4af37,color:#fff

GoalProjection:轻量 last-wins + Object.is 门控

Goal 投影是最轻量的。applyGoalProjection(state, event) 是一个纯函数:

  • 如果 event.type 不是 ‘goal/change’,直接返回原 state(Object.is 保证引用不变)
  • 如果是 ‘goal/change’,解码 payload
  • operation === ‘clear’ → null
  • 否则返回新的 goal state 对象

对非 goal 事件直接返回同一个 state 引用,这让消费方可以用引用相等来快速判断”goal 变了没有”。如果每次都返回新对象,即使 goal 没变,消费方也会以为状态变了做不必要的重渲染/重计算。Object.is 门控就是防止这种情况。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "if (event.type !== 'goal/change') return state" "$repo/packages/goal/goal/src/index.ts"

为什么所有投影结果都 deepFreeze

你注意到一个 pattern 了吗?headerFold、contextFold、deriveMessages 返回的 Message 对象、goal state——全部 deepFreeze。

为什么?因为投影结果是 session 内部状态的引用暴露。如果消费方拿到 session.requestHeader() 返回的对象,随手改了 header.temperature = 0,就直接改了 session 内部的缓存。下一次 fold 看到 headerFold 存在而且 headerFoldSeq 到尾了,直接返回这个被篡改过的对象。投影缓存就被污染了。

deepFreeze 让这种篡改在严格模式下直接抛 TypeError。这不是”建议不要修改”,是运行时强制不可变。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "deepFreeze" "$repo/packages/core/session/src/index.ts" | head -10

投影缓存的通用模式

所有投影缓存遵循同一个模式:

投影缓存字段增量位置重建触发相等性策略
eventseventsSnapshot无(整个log)append后失效Object.freeze浅拷贝
surfacenodes/replaceGeneration_lastProcessedSeqreplace时重建增量append
deriveMessagesderived/derivedNodes/derivedGenerationderivedNodesreplaceGeneration变化时重建增量投影新节点
requestHeaderheaderFold/headerFoldSeqheaderFoldSeq新eventsheaderEquals深度比较
requestContextcontextFold/contextFoldSeqcontextFoldSeq新events对象展开覆盖
goal外部fold外部维护goal/change事件Object.is引用门控
runtimeContextretained{seq,text}监听session/eventreplace/retained被覆盖text字符串===

核心思想:缓存计算结果,记录处理到哪里了,下次从断点继续。结构变化(replace)时从头来,追加变化时增量走。返回冻结结果防篡改。

容易踩的坑

坑一:修改 requestHeader() 返回的对象。 它是 deepFrozen 的,严格模式下直接 TypeError。非严格模式下静默失败但下次 fold 可能覆盖你的修改。不要改,要改就自己拷贝一份再改。

坑二:每步都注入相同的 runtime context。 RuntimeContextProjection 的存在就是防止这个。如果你自己在 agent-loop 外面写逻辑注入 context,一定要比较内容是否变化,否则每步都加一条一模一样的 context 消息,历史越来越长,token 浪费严重。

坑三:用 === 比较两个语义等价但引用不同的 header。 headerEquals 做深度比较。如果你自己 session.requestHeader() 拿到 header,和之前存的 oldHeader 用 ===` 比较,可能不相等但语义一样。用 headerEquals 或者依赖 Session 内部的引用相等(在同一个 session 实例上连续调用 requestHeader(),没变时返回同一个引用)。

坑四:投影不是 O(1)。 第一次调用 requestHeader() 或发生 replace 后调用 deriveMessages(),都是 O(n) 重建。不要在热路径上无意义地重复调用。但正常增量路径下,每个事件只被 fold 一次,摊还 O(1)。

这章讲完了,还没讲什么

你知道了内存里怎么从日志投影出各种状态。但日志不能只呆在内存里——进程退出就没了。怎么持久化到磁盘?怎么保证写文件时崩溃不会损坏数据?崩溃后重启怎么修复未闭合的 turn?

下一章讲 JSONL 原子写和崩溃恢复。