会话与持久化(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 事件上:surfaceOp 与 sourceEventSeqs。
surface 与 log-only:什么会进模型历史
产生模型消息的事件只有三种(SurfaceEventType):user/message、assistant/message、tool/result。它们必须携带 surface 意图:surfaceOp('append' 追加到尾部,或 { op: 'replace', start, end } 遮蔽一段闭区间范围——压缩就是这样把早期历史折叠成一条摘要)与 sourceEventSeqs(引用的来源事件 seq)。surface 是派生模型历史的唯一来源。
其余事件——turn/start、step/*、assistant/chunk、request/header、todo/write、插件贡献的 compaction/* 等等——都是仅日志事件:占一个 seq、参与持久化,但绝不投影成消息。assistant/chunk 尤其如此:它只为 token 级回放保真而存在;派生历史用的是组装好的 assistant/message(内容为空的消息也会跳过,尽管它仍记录 usage 与 provider/model)。
由此得到一条铁律:模型可见 ⟺ 已记录。模型看到的每条消息必然来自日志中的 surface 事件,日志之外不存在任何「旁路」的模型可见内容;反过来,凡写入日志的 surface 事件必然出现在派生历史里。往模型上下文里塞内容的正道只有一条:追加一条携带 surfaceOp 的事件。
SessionPersistence:让日志落地
持久化是 ctx.sessionPersistence 抽象 seam 的职责:locate(解析逐会话产物位置)、create、append(连续批次,首事件 seq 必须等于存储 next-seq)、prepare / load / inspect(恢复与检查)、readFrom(物理后缀读)、list / listSnapshots。两个可互换后端实现同一契约:JSONL(每会话一份仅追加逻辑日志,默认 Zstandard 压缩)与 SQLite(node: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 之间。
在自己的插件里监听与追加
// 会话插件示例:监听事件 + 追加自己的仅日志事件
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)。