青雲的博客
深入浅出 DeepSeek Harness 第二部:一句话的旅程——输入怎样变成模型请求 第 09 章

历史从事件日志重建,不是从聊天界面

你以为送给模型的聊天历史就是屏幕上显示的那些消息。不是。只有三类事件是 surface-eligible:user/message、assistant/message、tool/result,且必须带 surfaceOp 标记。assistant/chunk、turn/step boundary、usage 都不入模型历史。deriveMessages 从 surface.nodes 增量投影。空 content 的 assistant/message derive 为 null。append-origin(人类看到的 transcript)和 replacement(模型看到的 surface)是分离的。surfaceOp: `{op:'replace'}` 用于 compaction,不删除原事件。

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

你可能以为,聊天历史就是一个数组。用户发一条 push,模型回一条 push,工具结果 push,送给模型的时候直接传这个数组。

错了。Harness 的历史消息是从事件日志投影出来的。事件日志是 append-only 的,永远不删不改。模型看到的”历史”是一个叫 surface 的增量视图,它维护当前可见的事件 seq 列表。屏幕上显示的 transcript 和模型看到的 surface 不是同一个东西——compaction 会替换模型看到的内容,但你实际看到过的对话永远留在日志里。

只有三类事件能进 surface

看 `SURFACE_EVENT_TYPES:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '14,28p' "$repo/packages/core/session/src/surface.ts"

只有三种事件类型是 surface-eligible:

  1. user/message:用户消息
  2. assistant/message:助手消息(完整消息,不是 chunk)
  3. tool/result:工具执行结果

除此之外所有事件都不算。你问:那 assistant/chunk 呢?逐字流式输出的那些 token?它们不进模型历史。chunk 只用于实时流式显示和 replay 保真,模型收到的是组装好的完整 assistant/message。turn/start、turn/end、step/start、step/end、request/header、llm/retry、usage 这些事件也都不进模型历史——它们是控制流和审计数据。

还有一个条件:surface-eligible 事件必须携带 `surfaceOp 标记。如果你 append 了一个 user/message 但没传 surfaceOp,会抛错。这是一种强制约束——每个消息类事件必须明确声明它如何影响 surface,是 append 还是 replace。

deriveEventMessage:THE 投影规则

`deriveEventMessage 是唯一的、权威的投影函数。注释说得很清楚:“This is THE per-node projection rule”。任何外部重建器都用同一个函数折叠事件日志,得到和运行时完全一致的消息列表。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '83,114p' "$repo/packages/core/session/src/surface.ts"

它的 switch 逻辑:

  • user/message:直接返回 event.data(UserMessage 对象)。
  • assistant/message:检查 event.data.message.content.length === 0,如果为空返回 null(不产生消息),否则返回 event.data.message。
  • tool/result:返回 event.data.message。
  • default:返回 null(非 surface 事件不产生消息)。

为什么空 content 的 assistant/message 要返回 null?因为 max-tokens 截断时,assembler 会丢弃不完整的 tool-call(blocks() 方法过滤掉 tool-call 类型),如果此时也没有 text block,content 就是空数组。这个 assistant/message 事件存在是为了记录 usage 数据(token 统计),但它不应该作为一条空的助手消息出现在模型历史里——那会破坏对话格式。

surface nodes:增量维护的可见列表

surface 的核心数据结构是一个 nodes: number[] 数组,存储当前模型可见的事件 seq 序号,按模型可见顺序排列。这个数组通过 append 和 replace 两种操作增量维护:

  • append:新事件 seq 追加到 nodes 末尾。这是正常消息流。
  • replace:用新事件 seq 替换 nodes 中一段范围 [startIdx, endIdx]。这是 compaction 用的。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '362,379p' "$repo/packages/core/session/src/surface.ts"
flowchart TD
    A["session.append(surface-eligible event)"] --> B{"surfaceOp?"}
    B -->|'append'| C["nodes.push(seq)"]
    B -->|{op:'replace',start,end}| D["查找 start/end 在 nodes 中的位置"]
    D --> E["验证 startIdx <= endIdx"]
    E --> F["验证 sourceEventSeqs 包含所有 shadowed seqs"]
    F --> G["nodes.splice(startIdx, endIdx-startIdx+1, seq)"]
    G --> H["replaceGeneration++"]
    C --> I["deriveMessages(): 遍历 nodes 映射 event→message"]
    H --> I

replace 操作有几个严格验证:

  1. start 和 end seq 必须在当前 nodes 中存在。
  2. startIdx <= endIdx(范围方向正确)。
  3. `sourceEventSeqs 必须包含所有被 shadowed(替换掉)的 seq——这保证了 provenance(来源追溯),你知道新事件是从哪些旧事件合成的。
  4. 如果是 tool/result 的 replace,只能改 content,不能改其他字段(第 287-318 行)。

append-origin 和 replacement:transcript 与 surface 的分离

这是最反直觉但也最重要的设计。

  • append-origin 事件surfaceOp: 'append'):在事件自己的 log 位置进入 surface 尾部,从未被替换过。这些事件构成了人类看到的对话 transcript——你发了什么、模型回了什么,按时间顺序排列,永远在日志里。
  • replacement 事件surfaceOp: {op:'replace', start, end}):替换了 surface 中一段范围。它们是模型看到的版本,但它们不删除原事件。原事件还在日志里,还在 transcript 里。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '40,68p' "$repo/packages/core/session/src/surface.ts"

为什么要分离?因为 compaction(上下文压缩)需要把长对话摘要成一条短消息送给模型,但你不能把用户实际看到过的对话删掉。如果你在 UI 上向上滚动,你应该看到完整的原始对话,不是压缩后的摘要。但模型不需要看到那些已经被摘要替代的内容。所以:

  • 原事件永远保留在日志中,作为 transcript。
  • replacement 事件在 surface 中替换它们,作为模型看到的版本。
  • isAppendSurfaceEvent() 筛选出给人看的。
  • surface.nodes(包含 replacement)投影出给模型看的。

看 agent.ts 第 341 行怎么用的:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '339,345p' "$repo/packages/core/agent-loop/src/agent.ts"

session.deriveMessages() 就是遍历 surface.nodes,对每个 seq 取事件,调用 deriveEventMessage,过滤掉 null,返回 Message[] 数组。这就是送给模型的历史消息。

assistant/chunk 不算消息

注意事件类型的区别:

  • assistant/chunk:流式输出的原始 chunk,一个 token 一个 chunk。每个 chunk 立即 append 到日志(agent.ts 第 349 行),带 seq 号。但 chunk 不携带 surfaceOp(因为它不是 surface-eligible 类型),不进 nodes,不进模型历史。
  • assistant/message:BlockAssembler 组装完成后的完整消息。append 时带 surfaceOp: 'append'sourceEventSeqs: chunkSeqs(引用组装它的所有 chunk 的 seq 号)。这才是进 surface 的事件。

这层分离保证了:流式 UI 可以逐字显示(监听 chunk 事件),replay 可以精确重现(从 chunk 重组),但模型看到的是干净的完整消息(从 message 事件投影)。

容易踩的坑

坑一:在日志里看到 assistant/chunk 就以为模型收到了 chunk。 模型从不收到 chunk。它收到的是 BlockAssembler 组装好的 assistant/message,通过 deriveMessages 投影出来。chunk 事件纯粹是为了流式显示和 replay。

坑二:compaction 后以为原消息被删了。 没有删。原事件还在日志里,还可以被 isAppendSurfaceEvent() 筛选出来构成 transcript。replace 只是改变了 surface.nodes 的内容,模型从 deriveMessages() 看到的是替换后的版本,但日志是 append-only 的。

坑三:空 content 的 assistant/message 以为会传给模型。 不会。deriveEventMessage 对空 content 返回 null,这一步就过滤掉了。这类消息存在的意义是承载 usage 数据(max-tokens 截断时的 token 统计)。

坑四:sourceEventSeqs 不是可选的元数据。 对 replace 操作,sourceEventSeqs 必须包含所有被 shadowed 的 nodes seq(第 239-242 行验证)。对 append 操作,assistant/message 可以引用其 chunks 的 seq(sourceEventSeqs: chunkSeqs)。这是可追溯性的保证。

坑五:turn/start 和 step/start 不是消息边界。 你可能以为 turn 边界在消息数组里有个标记,模型知道哪是新一轮。不是。模型看到的就是纯消息序列——user、assistant、tool、user、assistant… turn/step 边界只存在于事件日志里,不投影到消息。

历史消息准备好了,system prompt 组装好了,inbox 里的新消息也 claim 出来了。现在要把这些东西打包成一个请求发给模型。但请求不是直接发到模型 API 的——中间隔着一堵墙叫 LLM Service。这堵墙是什么?下一章拆给你看。