上一篇拆 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/dsh0.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/start、step/end、tool/call、assistant/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/start、compaction/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 记录之上。