上一篇拆 Agent Loop 时,我们看到一次 Turn 会被拆成多个 Step。用户的消息会先进入 Session,模型生成的内容也会持续地写入 Session,工具调用及对应的执行结果也会依次追加到同一条会话记录中。
到了发起模型请求时,Agent Loop 会执行:
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 只维护一份状态,后续的模型消息和恢复结果都从这份状态中生成:
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 种事件并不会全部进入模型上下文。能够生成模型消息的只有三种:
type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result'
turn/start、
step/end、
tool/call、
assistant/chunk,以及重试、审批和压缩过程中产生的事件,都会保留在日志里,但不会进入模型消息历史。dsh 还把这条限制写进了类型系统:
append( type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] ): SessionEvent
三种 Surface Event 调用
append() 时必须传入
SurfaceIntent,其他事件则不能传入这组参数。也就是说,哪些事件可以进入模型上下文,在
Session.append() 的类型定义里就已经被限定下来。这条追加式日志还有一项连续性约束:
seq = log.length
seq 表示事件在日志中的连续位置。负责 Session 日志实际存储的 Persistence Backend,可以自行决定磁盘编码、批处理方式和存储结构,但重新
load() Session 时,仍要按原来的顺序完整还原这条事件日志。这也是
assistant/chunk 要保留在日志中的原因。assistant/chunk assistant/chunk assistant/chunk ↓ assistant/message
Chunk 可以服务于流式回放、UI 和 usage 等信息;模型上下文只消费最终的
assistant/message。所以,SessionEvent Log 保存的是 Agent 的执行历史,模型消息只是从这份执行历史中生成的一部分。
Surface 与上下文重组
只追加的日志适合持久化和回放,但模型上下文还需要支持压缩和裁剪。
假设原始历史是:
Message A Message B Tool Result C Message D Message E
压缩之后,模型下一次可能只需要看到:
Summary Message D Message E
dsh 因此在日志之上维护了一层
SessionSurface:interface SessionSurface {
readonly nodes: readonly number[]
readonly replaceGeneration: number
}
Surface Event 通过
surfaceOp 声明自己如何进入当前模型历史:type SurfaceOp =
| 'append'
| { op: 'replace'; start: number; end: number }
append 把新节点加入尾部,
replace 用当前事件替换 Surface 中的一段节点。被替换的事件依旧留在 SessionEvent Log 中。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 定义了四种持久化事件:
compaction/start compaction/summary compaction/end compaction/prune
摘要压缩时,压缩过程由
compaction/* 事件记录,真正进入模型上下文的摘要仍是一条
user/message,通过
replace 接管原来的 Surface 区间。Tool Result Pruner 也在用这套机制。它会先追加一条
compaction/prune,记录被替换的 Surface 节点和 token 数量,再同步追加替换用的新
tool/result:compaction/prune ↓ replacement tool/result ↓ Surface replace
原始 Tool Result 留在日志中,裁剪后的版本进入当前模型上下文;token 统计等消费者则可以根据
compaction/prune 调整当前 Surface 的计价状态。
deriveMessages() 最终按照 Surface 节点顺序,将三类事件转换成模型消息:user/message → user Message assistant/message → assistant Message tool/result → 带 tool-result block 的 user Message
没有实际内容的
assistant/message 会被跳过。生成
Message[] 的过程也使用了增量缓存:普通追加时只处理新节点,发生 Replace 后再重新构建对应的消息历史。因此,每次从 Session 生成
Message[] 时,都不需要重新遍历和复制整段会话。请求状态与持久化边界
只有
Message[],还不足以还原一次完整的模型请求。一次大模型调用会带上模型配置、System Prompt 和工具定义等信息:provider model system prompt tool schemas reasoning effort sampling config ...
因此,dsh 定义了
request/header来保存完整的
EpochHeader:interface EpochHeader {
config: LlmCallConfig
adapterDefaults?: LlmCallConfigAdapterDefaults
system?: string
tools?: ToolSchema[]
}
第一次发起请求时会写入
initial;新的 Agent Loop 实例接手现有 Session 时会写入
resume;后续如果模型配置、System Prompt 或工具定义发生变化,则写入
change。没有变化的 Step 继续沿用上一份 Header。这样,一次模型请求可以由两部分状态还原:
Session Event Log │ ├── Surface │ ↓ │ Message[] │ └── request/header ↓ config / system / tools │ ↓ Conversation Request
adapterDefaults 用来标记哪些配置来自 Adapter 的默认值。进入下一次
agent/request 前,Agent Loop 会移除由上一轮 Adapter 补入的默认参数,再由当前路由重新确定这些默认值。调用方明确设置的参数则会继续保留。这样可以避免切换模型后,上一轮 Adapter 的默认配置继续影响新的请求。
模型路由的容量信息则单独记录在
request/context 中:{
provider,
model,
contextWindow
}
它只记录当前模型的上下文容量,不参与 Request Header 的变化判断和恢复。dsh 还提供了
@deepseek-ai/dsh-agent-loop/invariant 插件。它会从 Session Log 中重新生成消息边界和 Request Header,再与 Agent Loop 实际准备发送的请求进行核对。这项检查要保证的是:Agent Loop 实际发送的请求,必须和 Session Log 能够还原出的请求一致。
真实 LLM Request = Session Log 可重建出的 Request
当 SessionEvent Log 成为事实依据后,另一个问题随之出现:
哪些事件必须在外部操作执行前真正写入持久化存储?dsh 把“日志存到哪里”和“哪些位置必须先完成持久化”拆成两个职责:
Persistence Backend 负责存到哪里 Checkpoint Policy 负责什么时候必须落盘
Persistence Backend 可以批量写入事件,需要立即完成持久化时,可以通过
session/flush 排空待写入事件。
dsh-session-checkpoint-policy 则会在三个关键位置先确认相关事件完成持久化:模型请求派发前 模型发起的工具调用执行前 下一次 agent/pre-step 前
模型请求这边,Checkpoint Policy 会延迟创建
llm/stream。相关请求事件完成持久化之前,下游 stream 不会启动。工具执行这边,checkpoint 会放在执行前策略和安全检查之后、工具真正开始执行之前。也就是说,工具通过前置检查后,还要先确认相关事件完成持久化,才能进入实际执行。tool/call ↓ policy / guard ↓ checkpoint ↓ tool body ↓ tool/result
如果 checkpoint 失败,模型请求和模型直接发起的工具调用都不会执行。如果持久化过程中收到取消信号,工具包装层会返回
ABORTED_BEFORE_DISPATCH,工具也不会进入实际执行。如果一个工具在执行过程中又调用了其他工具,内部调用可以复用外层工具调用的 checkpoint,不需要每次都单独执行一次 flush。
这套机制保证了一个明确的执行顺序:
记录调用 ↓ 确认耐久 ↓ 允许外部执行
这套机制可以缩小崩溃后状态不确定的范围,但对于外部工具,系统仍然无法保证操作不会被重复执行。
崩溃恢复与工具状态
工具调用既会写入 Session,也会在外部系统中执行。崩溃发生在不同阶段,恢复时的处理方式也不同。dsh 将这类情况分成两种:
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 根据工具语义继续处理:只读或幂等操作可以考虑重试,可能产生副作用的操作需要先检查外部状态或请求用户确认。
这里有一条重要边界:
已记录调用 ≠ 已知执行结果
Checkpoint 只能保证关键操作开始前,相关日志已经完成持久化;外部系统中的实际执行结果仍然无法由 Session 完全控制。崩溃后的日志结构也需要修复。
正常 Turn 会形成:
turn/start ↓ step/start ↓ ... ↓ step/end ↓ turn/end
如果进程在一个 Turn 还没有结束时退出,持久化日志可能停在:
turn/start ↓ step/start ↓ ... ↓
注意一件事,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:turn/end {
reason: {
kind: 'interrupted'
}
}
interrupted 只会在会话恢复时生成,正常运行的 Agent Loop 不会写入这个状态。Repair 只负责处理 Turn、Step 和 Tool 这些核心执行记录。像
compaction/start、
compaction/end 这样的插件事件,则由对应插件自己判断和处理。为了区分构造 Session 时传入的历史事件和当前运行阶段产生的新事件,Session 还会写入
session/end-seed: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 则复制一个稳定的事件前缀:
fork(source, boundary?, childSessionId?)
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 判断状态有没有变化:same reference ↓ no state change ↓ no downstream work
这样,即使一条 Session Event 日志同时被多个 Projection 使用,与当前 Projection 无关的事件也不会触发额外的状态更新。于是,同一份 SessionEvent Log 可以成为多个子系统的共同输入:
┌─ 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 对无法识别的历史事件采取了更保守的恢复策略。
小结
回到开头的:
this.session.deriveMessages()
它连接的远不止一份聊天历史。
Agent Loop 负责推动 Turn 和 Step 向前执行,Session 则把这些过程记录成连续的事件。模型需要上下文时,Surface 会从日志中确定当前可见的内容;发起请求时,Request Header 提供还原这次调用所需的配置;执行模型或工具前,Checkpoint 会先确认相关事件完成持久化;如果进程中途崩溃,Repair 再根据日志中留下的记录恢复会话状态。
这样一来,上下文压缩、请求还原、会话恢复,以及 Replay、Fork 和 Projection,都可以建立在同一套 SessionEvent 记录之上。