Skip to content

会话与持久化(Session 与 Persistence)

本章目标

读完本章后,你将能够:

  • 理解 Session 事件溯源模型:仅追加日志是唯一真源,消息历史从日志派生;
  • 分清 surface 事件与仅日志事件,知道什么会进模型历史;
  • 理解 SessionPersistence seam、JSONL/SQLite 双后端与崩溃恢复语义;
  • 在自己的插件里监听会话事件并追加自己的事件。

会话:一份仅追加的事件日志

dsh-session 的内存模型用一句话就能说清:Session 是一份由类型化 SessionEvent 组成的仅追加日志,是 agent 完整交互历史的唯一真源。LLM 消息历史从不单独存储——Session.deriveMessages() 每次都从日志派生;恢复、fork、回放同样是从同一组事件重新派生。持久化不是另一套平行的模型,而是这份日志的落地,由兄弟 seam 负责。

SessionEvent 信封

每条事件是真正基于 type 的可辨识联合:seq(单调连续,seq = log.length)、time(epoch 毫秒)、data(类型化载荷)。data 必须是无损 JSON 可序列化——append() 在源头递归校验,坏事件当场失败,而不是等到后端 flush 才爆。ignorable: true 标记纯信息性记录:读到一个不带该标记的未知类型,后端必须拒绝解读,因为未知的必需事件可能改变整条日志的解读方式。默认「必需」意味着忘写标记只会过度拒绝(体验问题),而不会静默接续一份残缺的会话。

信封上还有两个条件字段,只出现在 surface 事件上:surfaceOpsourceEventSeqs

surface 与 log-only:什么会进模型历史

产生模型消息的事件只有三种(SurfaceEventType):user/messageassistant/messagetool/result。它们必须携带 surface 意图:surfaceOp'append' 追加到尾部,或 { op: 'replace', start, end } 遮蔽一段闭区间范围——压缩就是这样把早期历史折叠成一条摘要)与 sourceEventSeqs(引用的来源事件 seq)。surface 是派生模型历史的唯一来源

其余事件——turn/startstep/*assistant/chunkrequest/headertodo/write、插件贡献的 compaction/* 等等——都是仅日志事件:占一个 seq、参与持久化,但绝不投影成消息。assistant/chunk 尤其如此:它只为 token 级回放保真而存在;派生历史用的是组装好的 assistant/message(内容为空的消息也会跳过,尽管它仍记录 usage 与 provider/model)。

由此得到一条铁律:模型可见 ⟺ 已记录。模型看到的每条消息必然来自日志中的 surface 事件,日志之外不存在任何「旁路」的模型可见内容;反过来,凡写入日志的 surface 事件必然出现在派生历史里。往模型上下文里塞内容的正道只有一条:追加一条携带 surfaceOp 的事件。

SessionPersistence:让日志落地

持久化是 ctx.sessionPersistence 抽象 seam 的职责:locate(解析逐会话产物位置)、createappend(连续批次,首事件 seq 必须等于存储 next-seq)、prepare / load / inspect(恢复与检查)、readFrom(物理后缀读)、list / listSnapshots。两个可互换后端实现同一契约:JSONL(每会话一份仅追加逻辑日志,默认 Zstandard 压缩)与 SQLitenode:sqlite,每个事件一行,字段与事件 1:1 对应)。日志旁的 SessionHeader(version、cwd、血统、seedLength)与事件分开存储,永不进入事件词汇。

写入走「有界批处理」:session/event 是同步通知,持久化插件把它复制进逐会话缓冲而不阻塞生产方;第一个待处理事件开启固定窗口,之后的事件加入但不重置截止。session/flush 是 awaited parallel 检查点,把缓冲排空到完全停稳——需要即时持久性屏障的生产方显式等待它。

崩溃恢复:关闭被中断的轮次

后端重载一份在轮次中途崩溃的日志时,会看到一个打开的 turn/start 而没有 turn/end。它不截断:单个长轮次可能非常庞大,且这些事件在崩溃前已持久追加。它改为合成一条 turn/end { reason: { kind: 'interrupted' } } 配平被中断的执行——interrupted 是唯一不由 loop 发出的结束原因。修复只针对冷会话:对仍活跃的 id,load 会等待权威内存快照完成持久化,绝不在活跃轮次上叠加合成边界。注意轮次包围的是一次模型循环执行,不是整个会话日志;你的插件贡献的仅日志事件可以自由出现在 turn/end 与下一个 turn/start 之间。

在自己的插件里监听与追加

ts
// 会话插件示例:监听事件 + 追加自己的仅日志事件
import type { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'

// 1. 用 declaration merging 扩展事件词汇表(仅日志事件,不携带 surfaceOp)
declare module '@deepseek-ai/dsh-session/types' {
  interface SessionEventMap {
    'my-plugin/note': { text: string }
  }
}

export const name = 'session-observer'
export const inject = ['sessions'] // 等 SessionStore 就绪

export function apply(ctx: Context) {
  // 2. 监听已提交的事件(post-commit fire-and-forget 通知流)
  ctx.on('session/event', (session, event) => {
    if (event.type === 'assistant/message') {
      // 组装好的 assistant 消息:携带 usage、provider/model 与回放状态
      // event.type 已收窄,event.data 直接可用
    }
  })

  // 3. 向某个会话追加自己的事件
  const id: SessionId = /* 来自你的业务上下文 */ ''
  const session = ctx.sessions.get(id)
  if (session) {
    // 仅日志事件:直接 append;data 必须无损 JSON 可序列化。
    // 非 surface 类型在编译期禁止传 surface 元数据。
    session.append('my-plugin/note', { text: '记录一条备注' })

    // 想让模型看到内容?追加 user/message 并携带 SurfaceIntent:
    // session.append('user/message', { role: 'user', id, content, source }, {
    //   surfaceOp: 'append',            // 进入 surface → 进入派生历史
    //   sourceEventSeqs: [/* 来源事件 seq */],
    // })

    // 需要即时持久性屏障时显式 flush(await parallel 检查点)
    await ctx.sessions.flush(session)
  }
}

小结

会话是 DSH 的「记忆」:一份仅追加的 SessionEvent 日志,派生模型历史,落地到 JSONL 或 SQLite。分清 surface 与仅日志事件的边界,你的插件就能安全地观察、记录并持久化自己的状态。

下一步

有了记忆,下一步可以探索如何压缩记忆(compaction seam)或把会话状态投影给界面(session projection)。