拆解 dsh:Session 的事件溯源与状态重建

上一篇拆 Agent Loop 时,我们看到一次 Turn 会被拆成多个 Step。用户的消息会先进入 Session,模型生成的内容也会持续地写入 Session,工具调用及对应的执行结果也会依次追加到同一条会话记录中。

到了发起模型请求时,Agent Loop 会执行:

bash 复制代码
const { request, preparedCall } = await this.buildRequest(
  turn,
  step,
  assembly.tools,
  system,
  this.session.deriveMessages(),
  signal,
)

其中有一个关键调用 this.session.deriveMessages()。每次发起模型请求前,Session 都会从事件日志中取出当前需要进入模型上下文的内容,生成这一次请求携带的 messages

这对应了 dsh 对 Agent 会话状态的一项核心设计:Session 维护一条持续追加的事件日志。模型请求所需的消息历史从这条日志中生成,上下文压缩、请求状态记录和崩溃恢复也都建立在同一套事件记录之上。

本文基于 @deepseek-ai/dsh 0.1.1-rc.2,对应仓库标签 dsh-v0.1.1-rc.2。dsh 当前仍处于 developer preview,会话格式为 SESSION_FORMAT_VERSION = 0,暂不提供格式兼容承诺。

模型上下文的生成

如果模型请求使用的 messages 和持久化日志分开维护,Agent 运行过程中就需要一直同步两份状态。工具调用产生新记录,两边都要更新;上下文发生压缩,两边也要一起调整;到了会话恢复时,还得确认它们对应的是同一段执行历史。

dsh 的 Session 只维护一份状态,后续的模型消息和恢复结果都从这份状态中生成:

bash 复制代码
SessionEvent Log
        ↓
     Surface
        ↓
 deriveMessages()
        ↓
     Message[]

用户输入和模型输出会写入 Session,工具调用的过程也会留下对应事件。Step、Turn 的执行边界,以及插件运行过程中产生的状态,同样会以 SessionEvent 记录进日志。

模型需要历史消息时,Session 会先根据当前 Surface 确定哪些事件需要进入上下文,以及这些事件的排列顺序。Surface 表示当前模型可见的消息历史,deriveMessages() 再据此生成对应的 Message[]。恢复 Session 时,系统也会重新加载这些事件,并沿用同一套规则还原模型可见的消息历史。

在 rc.2 生成的 persistence-catalog.md 中,共定义了 48 种可持久化事件。其中 13 种由 dsh-session 定义,其他事件由各个插件扩展到 SessionEventMap 中。

不过,这 48 种事件并不会全部进入模型上下文。能够生成模型消息的只有三种:

bash 复制代码
type SurfaceEventType =
  | 'user/message'
  | 'assistant/message'
  | 'tool/result'

turn/startstep/endtool/callassistant/chunk,以及重试、审批和压缩过程中产生的事件,都会保留在日志里,但不会进入模型消息历史。

dsh 还把这条限制写进了类型系统:

bash 复制代码
append<T extends SessionEventType>(
  type: T,
  data: SessionEventMap[T],
  ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T>

三种 Surface Event 调用 append() 时必须传入 SurfaceIntent,其他事件则不能传入这组参数。也就是说,哪些事件可以进入模型上下文,在 Session.append() 的类型定义里就已经被限定下来。

这条追加式日志还有一项连续性约束:

bash 复制代码
seq = log.length

seq 表示事件在日志中的连续位置。负责 Session 日志实际存储的 Persistence Backend,可以自行决定磁盘编码、批处理方式和存储结构,但重新 load() Session 时,仍要按原来的顺序完整还原这条事件日志。这也是 assistant/chunk 要保留在日志中的原因。

bash 复制代码
assistant/chunk
assistant/chunk
assistant/chunk
       ↓
assistant/message

Chunk 可以服务于流式回放、UI 和 usage 等信息;模型上下文只消费最终的 assistant/message

所以,SessionEvent Log 保存的是 Agent 的执行历史,模型消息只是从这份执行历史中生成的一部分。

Surface 与上下文重组

只追加的日志适合持久化和回放,但模型上下文还需要支持压缩和裁剪。

假设原始历史是:

bash 复制代码
Message A
Message B
Tool Result C
Message D
Message E

压缩之后,模型下一次可能只需要看到:

bash 复制代码
Summary
Message D
Message E

dsh 因此在日志之上维护了一层 SessionSurface

bash 复制代码
interface SessionSurface {
  readonly nodes: readonly number[]
  readonly replaceGeneration: number
}

Surface Event 通过 surfaceOp 声明自己如何进入当前模型历史:

bash 复制代码
type SurfaceOp =
  | 'append'
  | { op: 'replace'; start: number; end: number }

append 把新节点加入尾部,replace 用当前事件替换 Surface 中的一段节点。被替换的事件依旧留在 SessionEvent Log 中。

bash 复制代码
Event Log
──────────────────────────────
seq 10  user/message
seq 11  assistant/message
seq 12  tool/result
seq 13  assistant/message
seq 14  user/message(summary)
──────────────────────────────

Surface before
10 → 11 → 12 → 13

Surface after replace
14

目前,Replace 主要用于 dsh 的 Compaction 子系统。Compaction 定义了四种持久化事件:

bash 复制代码
compaction/start
compaction/summary
compaction/end
compaction/prune

摘要压缩时,压缩过程由 compaction/* 事件记录,真正进入模型上下文的摘要仍是一条 user/message,通过 replace 接管原来的 Surface 区间。

Tool Result Pruner 也在用这套机制。它会先追加一条 compaction/prune,记录被替换的 Surface 节点和 token 数量,再同步追加替换用的新 tool/result

bash 复制代码
compaction/prune
       ↓
replacement tool/result
       ↓
 Surface replace

原始 Tool Result 留在日志中,裁剪后的版本进入当前模型上下文;token 统计等消费者则可以根据 compaction/prune 调整当前 Surface 的计价状态。deriveMessages() 最终按照 Surface 节点顺序,将三类事件转换成模型消息:

bash 复制代码
user/message       → user Message
assistant/message  → assistant Message
tool/result        → 带 tool-result block 的 user Message

没有实际内容的 assistant/message 会被跳过。生成 Message[] 的过程也使用了增量缓存:普通追加时只处理新节点,发生 Replace 后再重新构建对应的消息历史。

因此,每次从 Session 生成 Message[] 时,都不需要重新遍历和复制整段会话。

请求状态与持久化边界

只有 Message[],还不足以还原一次完整的模型请求。一次大模型调用会带上模型配置、System Prompt 和工具定义等信息:

bash 复制代码
provider
model
system prompt
tool schemas
reasoning effort
sampling config
...

因此,dsh 定义了 request/header来保存完整的 EpochHeader

bash 复制代码
interface EpochHeader {
  config: LlmCallConfig
  adapterDefaults?: LlmCallConfigAdapterDefaults
  system?: string
  tools?: ToolSchema[]
}

第一次发起请求时会写入 initial;新的 Agent Loop 实例接手现有 Session 时会写入 resume;后续如果模型配置、System Prompt 或工具定义发生变化,则写入 change。没有变化的 Step 继续沿用上一份 Header。

这样,一次模型请求可以由两部分状态还原:

bash 复制代码
Session Event Log
       │
       ├── Surface
       │      ↓
       │   Message[]
       │
       └── request/header
              ↓
      config / system / tools
              │
              ↓
        Conversation Request

adapterDefaults 用来标记哪些配置来自 Adapter 的默认值。

进入下一次 agent/request 前,Agent Loop 会移除由上一轮 Adapter 补入的默认参数,再由当前路由重新确定这些默认值。调用方明确设置的参数则会继续保留。

这样可以避免切换模型后,上一轮 Adapter 的默认配置继续影响新的请求。

模型路由的容量信息则单独记录在 request/context 中:

bash 复制代码
{
  provider,
  model,
  contextWindow
}

它只记录当前模型的上下文容量,不参与 Request Header 的变化判断和恢复。dsh 还提供了 @deepseek-ai/dsh-agent-loop/invariant 插件。它会从 Session Log 中重新生成消息边界和 Request Header,再与 Agent Loop 实际准备发送的请求进行核对。

这项检查要保证的是:Agent Loop 实际发送的请求,必须和 Session Log 能够还原出的请求一致。

bash 复制代码
真实 LLM Request
        =
Session Log 可重建出的 Request

当 SessionEvent Log 成为事实依据后,另一个问题随之出现:**哪些事件必须在外部操作执行前真正写入持久化存储?**dsh 把"日志存到哪里"和"哪些位置必须先完成持久化"拆成两个职责:

bash 复制代码
Persistence Backend
负责存到哪里

Checkpoint Policy
负责什么时候必须落盘

Persistence Backend 可以批量写入事件,需要立即完成持久化时,可以通过 session/flush 排空待写入事件。dsh-session-checkpoint-policy 则会在三个关键位置先确认相关事件完成持久化:

bash 复制代码
模型请求派发前
模型发起的工具调用执行前
下一次 agent/pre-step 前

模型请求这边,Checkpoint Policy 会延迟创建 llm/stream。相关请求事件完成持久化之前,下游 stream 不会启动。工具执行这边,checkpoint 会放在执行前策略和安全检查之后、工具真正开始执行之前。也就是说,工具通过前置检查后,还要先确认相关事件完成持久化,才能进入实际执行。

bash 复制代码
tool/call
   ↓
policy / guard
   ↓
checkpoint
   ↓
tool body
   ↓
tool/result

如果 checkpoint 失败,模型请求和模型直接发起的工具调用都不会执行。如果持久化过程中收到取消信号,工具包装层会返回 ABORTED_BEFORE_DISPATCH,工具也不会进入实际执行。

如果一个工具在执行过程中又调用了其他工具,内部调用可以复用外层工具调用的 checkpoint,不需要每次都单独执行一次 flush。

这套机制保证了一个明确的执行顺序:

bash 复制代码
记录调用
   ↓
确认耐久
   ↓
允许外部执行

这套机制可以缩小崩溃后状态不确定的范围,但对于外部工具,系统仍然无法保证操作不会被重复执行。

崩溃恢复与工具状态

工具调用既会写入 Session,也会在外部系统中执行。崩溃发生在不同阶段,恢复时的处理方式也不同。dsh 将这类情况分成两种:

bash 复制代码
assistant 请求工具
        │
        ├── 没有持久化的 tool/call
        │        ↓
        │ TOOL_NOT_STARTED
        │
        └── 有 tool/call
             没有可靠结果
                 ↓
        TOOL_OUTCOME_UNKNOWN
  • TOOL_NOT_STARTED 表示模型提出了工具请求,但日志中没有对应的持久化 tool/call。恢复时,系统可以判断这次工具调用还没有进入执行。如果任务仍然需要这次操作,Agent 可以重新发起调用。
  • TOOL_OUTCOME_UNKNOWN 表示日志中存在 tool/call,但没有可靠的完成结果。工具可能还没有执行,也可能只执行了一部分,还可能已经在外部系统中完成,只是执行结果还没有写回 Session。

这时,Session 无法独自判断真实结果。dsh 会把"结果未知"转换成模型可见状态,交给 Agent 根据工具语义继续处理:只读或幂等操作可以考虑重试,可能产生副作用的操作需要先检查外部状态或请求用户确认。

这里有一条重要边界:

bash 复制代码
已记录调用 ≠ 已知执行结果

Checkpoint 只能保证关键操作开始前,相关日志已经完成持久化;外部系统中的实际执行结果仍然无法由 Session 完全控制。崩溃后的日志结构也需要修复。

正常 Turn 会形成:

bash 复制代码
turn/start
  ↓
step/start
  ↓
...
  ↓
step/end
  ↓
turn/end

如果进程在一个 Turn 还没有结束时退出,持久化日志可能停在:

bash 复制代码
turn/start
  ↓
step/start
  ↓
...
  ↓
<EOF>

注意一件事,Repair 只处理非活跃的 Session。

对于当前还在运行的 Session,SessionPersistence.load(id) 会先等待内存中的最新事件完成持久化。只有当前 Turn 和 Step 都完整结束后,系统才会返回这份 Session。如果当前还有一个 Turn 正在执行,load() 会拒绝加载,也不会额外补上一条 interrupted 来结束这个 Turn。

运行时热更新并替换 Agent Loop 实例时,也不会给当前正在执行的 Turn 补上 turn/end。因此,这一轮任务可以继续沿用原来的执行状态。

非活跃的 Session 从持久化日志恢复时,Repair 会保留崩溃前已经完成持久化的事件,并补齐缺失的 Tool、Step 和 Turn 记录。对于还没有结束的 Turn,它会追加一条 turn/end,并将结束原因标记为 interrupted

bash 复制代码
turn/end {
  reason: {
    kind: 'interrupted'
  }
}

interrupted 只会在会话恢复时生成,正常运行的 Agent Loop 不会写入这个状态。Repair 只负责处理 Turn、Step 和 Tool 这些核心执行记录。像 compaction/startcompaction/end 这样的插件事件,则由对应插件自己判断和处理。

为了区分构造 Session 时传入的历史事件和当前运行阶段产生的新事件,Session 还会写入 session/end-seed

bash 复制代码
seed events
seed events
session/end-seed
───────────────
live events
live events

插件可以通过这条边界判断,一个尚未结束的状态是来自之前的会话历史,还是当前运行阶段产生的。

Replay、Fork 与 Projection

同一条 SessionEvent Log 还可以支持 Replay、Fork 和领域状态 Projection。

Replay 重新加载日志,再执行相同的 Surface 与 Message 推导规则,就能恢复模型历史;规范日志中的 assistant/chunk 也可以继续提供细粒度 UI 回放。

Fork 则复制一个稳定的事件前缀:

bash 复制代码
fork(source, boundary?, childSessionId?)
bash 复制代码
Session A

0 ─ 1 ─ 2 ─ 3 ─ 4 ─ 5
            ↑
         boundary

              │
              └──── Fork
                      ↓

Session B

0 ─ 1 ─ 2 ─ 3

boundary 不能落在一个还没有结束的 Turn 中。如果指定的位置仍处在某个 Turn 内部,Fork 会拒绝执行。

Projection 允许插件注册一个同步的状态更新函数:(state, event) => nextState。Todo、运行状态等信息都可以按照 SessionEvent 的顺序更新。

Projection Registry 统一接收事件,各个 Projection 只需要提供自己的状态更新规则。如果某个事件与当前 Projection 无关,就返回原来的 state。Registry 再通过 Object.is 判断状态有没有变化:

bash 复制代码
same reference
     ↓
no state change
     ↓
no downstream work

这样,即使一条 Session Event 日志同时被多个 Projection 使用,与当前 Projection 无关的事件也不会触发额外的状态更新。于是,同一份 SessionEvent Log 可以成为多个子系统的共同输入:

bash 复制代码
                     ┌─ Message History
                     ├─ Replay
                     ├─ Persistence
SessionEvent Log ────┼─ Crash Recovery
                     ├─ Fork
                     └─ Projection

新增 Projection 时,不需要修改底层的持久化实现;更换持久化方式时,也不会影响模型消息的生成逻辑。

长任务下的状态成本

SessionEvent Log 会随着任务的运行不断增长,因此会带来存储、加载、查询和事件格式维护的成本。像 assistant/chunk 这样的事件也会一直保留在日志中。任务运行时间越长,从头读取并重新处理全部事件的成本也会越高。

为了解决这些问题,dsh 又把查询和状态缓存拆成了独立能力。session-query 提供统一的 Session 查询接口,可以同时查询当前正在运行的 Session 和已经持久化的 Session。全文检索由具体的存储实现负责,session-query-sqlite 提供了一套 SQLite 实现。

session-projection-cache 则会缓存 Projection 处理后的状态,并用 seq 记录当前处理到了日志中的哪个位置。再次读取时,只需要从这个位置之后继续处理新增事件。

这里有一条关键规则:**日志先完成持久化,缓存再更新。**缓存可以落后于日志,但不能领先于日志。当前正在运行的 Session 写入 Projection Cache 前,会先确保对应事件完成持久化。

因此,即使进程崩溃,缓存最多只会少记录一部分状态。恢复时,系统重新处理缓存位置之后的日志即可。如果缓存版本不匹配,或者 Session 对应的日志发生变化,这份缓存也会被丢弃,再从 SessionEvent Log 重新生成。

查询索引和 Projection Cache 用来降低查询和状态计算的成本,SessionEvent Log 仍然是恢复和重建状态时的最终依据。

事件格式本身也会带来维护成本。一旦事件写入持久化存储,后续版本就需要考虑这些历史数据能否继续读取。当前 SESSION_FORMAT_VERSION = 0,遇到未知且没有标记 ignorable: true 的事件时,Session 会拒绝恢复。

这说明在 developer preview 阶段,dsh 对无法识别的历史事件采取了更保守的恢复策略。

小结

回到开头的:

bash 复制代码
this.session.deriveMessages()

它连接的远不止一份聊天历史。

Agent Loop 负责推动 Turn 和 Step 向前执行,Session 则把这些过程记录成连续的事件。模型需要上下文时,Surface 会从日志中确定当前可见的内容;发起请求时,Request Header 提供还原这次调用所需的配置;执行模型或工具前,Checkpoint 会先确认相关事件完成持久化;如果进程中途崩溃,Repair 再根据日志中留下的记录恢复会话状态。

这样一来,上下文压缩、请求还原、会话恢复,以及 Replay、Fork 和 Projection,都可以建立在同一套 SessionEvent 记录之上。

相关推荐
梨想橙汁36 分钟前
JS BOM 浏览器对象:定时器与同步异步底层理解
前端·javascript
云烟成雨TD40 分钟前
LlamaIndex 系列【14】数据接入流水线(IngestionPipeline)
ai·agent·rag·llamaindex
梨想橙汁42 分钟前
ES6+ 必用新特性:箭头函数、解构、模板字符串、扩展运算符
前端·javascript
梨想橙汁43 分钟前
DOM 与 JS 事件:页面元素操作、事件冒泡、事件委托实战
前端·javascript
一个游离的指针1 小时前
浏览器的渲染原理
前端·javascript
linxid【智子纪元】2 小时前
Nature Medicine | 谷歌最新医疗Agent 论文 Multimodal AMIE 深度拆解
agent·谷歌·医疗·nature
luckystar513~2 小时前
Hermes 实战 :多渠道接入——日报推送到飞书/Telegram
人工智能·agent·智能体开发·hermes实战·智能体网关
GoGeekBaird2 小时前
Agent 时代,你的生产环境,真的敢让它裸奔吗
后端·agent
吴佳浩2 小时前
大模型是怎么来的:从数据到 Foundation Model
人工智能·llm·agent