会话(session) 是一个智能体完整交互历史的唯一可信来源。具体来说,它是一份由 packages/core/session/src/index.ts 中 Session 类拥有的、只追加的类型化 SessionEvent 日志,事件词表与信封在 packages/core/session/src/types.ts 中声明。循环的工作词典——消息、内容块、回放——都从这份日志派生,从不单独存储,因此回放只是从同一批不可变事件重新派生。
| 包 | 负责 |
|---|---|
@deepseek-ai/dsh-session | Session、SessionEventMap、SessionStore(ctx.sessions)、deriveMessages() |
@deepseek-ai/dsh-session-persistence | ctx.sessionPersistence:JSONL/SQLite 后端、flush 检查点、崩溃恢复 |
SessionEvent——一条日志条目
每条日志都不可变、无损 JSON、单调递增。seq 始终是追加时的日志长度(seq = log.length,这是整个系统赖以成立的连续契约),time 是 epoch 毫秒。因为它是按 type 判别(discriminated union)的,switch (event.type) 无需强制转换就能收窄 event.data。
type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
seq: number // 单调递增位置;追加时 = log.length
time: number // Unix epoch 毫秒
data: SessionEventMap[K]
ignorable?: true // 读者遇到未知类型时可跳过;缺失 = 必读
} & (K extends SurfaceEventType ? {
sourceEventSeqs?: number[] // 该事件引用的更早事件 seq
surfaceOp?: SurfaceOp // 该事件如何进入有序 surface
} : object)
}[T]ignorable 关系到向前兼容:遇到一个未识别且必读的事件时,读者会拒绝重建会话而不是悄悄丢弃,因为它可能改变整份日志的解释方式。
三个可落 surface 的类型(user/message、assistant/message、tool/result)可以携带 surfaceOp 和 sourceEventSeqs。surfaceOp 要么是 'append'(正常的尾部插入),要么是 { op: 'replace', start, end }(compaction 用它来遮蔽 surface 节点并在 sourceEventSeqs 中引用它们)。
SessionEventMap——事件类别
事件词表是一个可合并扩展的 map;插件通过声明合并(declaration merging)增加事件类型而不必改拥有包。核心的十三种类型及其所属类别:
| 类别 | 事件类型 | 载荷(关键字段) | 对模型可见? |
|---|---|---|---|
| 生命周期 | turn/start | { turn } | — |
| 生命周期 | turn/end | { turn, reason: TurnEndReason } | — |
| 生命周期 | step/start | { turn, step } | — |
| 生命周期 | step/end | { turn, step } | — |
| 消息 | user/message | UserMessage(role user) | surface |
| 消息 | assistant/chunk | { turn, step, chunk: StreamChunk } | 仅回放 |
| 消息 | assistant/message | { turn, step, message, usage? } | surface |
| 工具 | tool/call | { turn, step, callId, name, arguments } | — |
| 工具 | tool/result | { turn, step, message, error?, meta? } | surface |
| 状态 | todo/write | { todos: TodoItem[] }(整表快照) | — |
| 状态 | request/header | { header: EpochHeader, reason } | — |
| 状态 | request/context | RequestContext | — |
| 生命周期 | session/end-seed | {}(边界标记) | — |
user/message 覆盖三种不同的生产者,它们的 content 都原样投影:直接的人类提示、合成的 agent.inject() 上下文(文件变更通知、技能内容、cron 通知……),或进入的目标续做回合。它们通过 UserMessage 上的 source 字段区分。tool/call 记录模型产出的原始 arguments JSON 字符串(未解析);callId 把它和对应的 tool/result 配对。
request/header 事件记录完整的 EpochHeader——调用配置、适配器提供的默认值、渲染后的系统提示、组装好的工具 schema——从而让每次对话请求都变成日志的纯函数。reason 为 'initial' 或 'resume' 的完整快照标记每个循环实例的边界;之后发生变化的请求会以 reason 'change' 追加另一份完整快照。foldRequestHeader() 选取最后一份快照来重建最新 header。request/context 是独立的路由元数据(provider、model、contextWindow),刻意放在 EpochHeader 之外,以免容量变化被当成请求信封的变化。
会话身份
会话的身份是 SessionId,一个带品牌标记(branded)的字符串,派生自身的持久 SessionHeader(其中持有 id、格式 version、cwd、fork 血统、createdAt、种子边界)。session.header 属于存储关注点,放在事件日志外——它不是可回放的会话状态。SessionStore(ctx.sessions)铸出 id(session-<n>)并维护一个 Map<SessionId, SessionEntry>;create() 构建全新会话。恢复持久化会话是 AgentRegistry 的职责:ctx.agents.resume() 先经会话持久化层加载。
日志作为唯一可信来源
Session 类持有私有的 log 数组,并提供两条读取路径:
events—— 只追加日志的冻结快照(在下一次追加前复用)。deriveMessages()—— 把有序 surface(增量SurfaceManager)投影成 LLM 的Message[],并在各次追加之间缓存:
deriveMessages(): Message[] {
const surface = this.surface
const nodes = surface.nodes
const generation = surface.replaceGeneration
if (generation !== this.derivedGeneration) {
this.derived = []; this.derivedNodes = 0; this.derivedGeneration = generation
}
for (const seq of nodes.slice(this.derivedNodes)) {
const msg = this.deriveEventMessage(this.log[seq]!)
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length
return [...this.derived]
}append(type, data, opts) 是唯一的变更入口。它校验 data 是无损 JSON,把冻结副本快照进日志,分配 seq/time,应用 surface 元数据(生产消息的事件必须携带,仅日志事件禁止携带),并同步通知观察者——热路径永不等 I/O,因为持久化是异步缓冲的。事件一旦进入日志,追加即视为已提交:观察者失败会按监听器逐个记录并包含,不会改变返回值或阻止后续监听器观察同一已接受事件。
这就是为什么回放和 fork 只是构造:用既有事件日志为 Session 播种(ctx.sessions.create(id, { seed }))时,会按照 append 施加的同一套不变量校验种子(seq 从 0 连续),并插入 session/end-seed 标记。SessionForkError 的码(SESSION_NOT_FOUND、SESSION_NOT_LIVE、SESSION_ALREADY_EXISTS、INVALID_BOUNDARY、OPEN_TURN)守护 fork 边界。
让日志变得可持久化
持久化刻意不在 dsh-session 中。插件订阅 store 的 session/event firehose,把日志写入某个后端 SessionPersistence,而 store 只拥有内存中的活跃 Session。packages/session/ 下相关的包:
| 包 | 角色 |
|---|---|
dsh-session-persistence | SessionPersistence 接口、抽象 append(id, events)/load(id)、PersistenceCoordinator、write-behind 分批 |
dsh-session-persistence-jsonl | JSONL 后端(一行一个事件,header 在前) |
dsh-session-persistence-sqlite | SQLite 后端 |
dsh-session-checkpoint-policy | 拥有每次请求的 session/flush 持久性检查点 |
dsh-session-projection | 从日志派生投影状态(统计、标题、遥测) |
写入路径由 SessionWriteBehind 控制器按会话分批:事件被 structuredClone 进一个待写队列,按固定期限 flush,并通过一个静止点合并进同一道屏障。共享的 PersistenceCoordinator 保留连续、无损 JSON 可序列化的事件,以及一份与可回放状态分离的 SessionHeader。append 仅在后端持久化完成后才 resolve;load 物化完整校验日志(meta + events),原始产物文本在支持时经 readRaw(id) 单独读取——不存在仅探测 header 的方法。崩溃在 append 中途会让尾部孤立,load 会平衡并持久关闭这个被中断的尾部。flush(id) 是显式的静止屏障;session/flush 检查点策略按请求驱动它。
由于下游消费者可能发出读取路径不认识的事件类型,packages/core/session/src/known-event-types.ts 维护着 KNOWN_SESSION_EVENT_TYPES 集合;日志里出现该集合之外的类型时会被拒绝,除非该事件携带 ignorable 标记——静默跳过必读事件会重建出错误的会话。
存储与事件 firehose
SessionStore 暴露 ctx.sessions,并提供 create / prepare / enter / announce / get。create() 铸出 id、构建 Session,并把它生命周期折叠进调用方 fiber 的效应里——销毁该 fiber 会停止事件通知并移除会话。组合路径(prepare + enter + announce)是异步智能体工厂用来把会话与其智能体按顺序销毁的进阶有序生命周期原语。
ctx.sessions.create(id, { seed?, meta? })
│ 构建 Session(校验种子、冻结事件、插入 end-seed)
▼
enter(session) ── 安装发布钩子(模块私有)
▼
announce(session) ── 挂到存储、通知快照读者
│
▼
session/event 通知 ── 按监听器包含
│
└─ 持久化后端(异步、缓冲)→ JSONL / SQLite每次 append 都会同步地经 store 拥有、模块私有的发布钩子喂给观察者;持久化插件订阅并异步缓冲,因此热路径从不阻塞 I/O。快照读者(session/event firehose)在 firehose 的顺序重要时把日志回放用作发布替身——遥测采用从 firstLiveSeq 开始,因为构造函数种子不会发出。
requestHeader 与 header 相等
两个 Session 访问器把请求调用循环牢牢锚定在日志上。requestHeader() 折叠最新的 request/header 事件;requestContext() 折叠最新的 request/context。foldRequestHeader 与 canonicalHeader/headerEquals 让循环在记录 change 快照之前判断 header 是否真的变了——这是一个纯日志的决策,让重建的请求保持确定性(见 model-selection.md)。
会话统计与回放
因为历史是派生投影、每条日志都不可变,"统计"、回放与 fork 都不过是同一份日志上的折叠。循环的 assistant/message 事件携带每个 step 的 usage(当适配器报告 token 核算时),因此模型输出与其核算始终同行——不存在独立的 usage 记录。request/context 事件暴露所解析路由的 contextWindow。
回放就是重新派生:重新打开一个会话(或用一个 { seed } 播种的 fork),在同样一批不可变事件上运行投影。请求所依赖的一切——渲染后的系统提示与工具 schema(request/header)、路由容量(request/context)、surface 历史(deriveMessages())——都可以纯粹从日志重建,这正是循环所坚持的可重建性不变量。投影则属于另一关注点:dsh-session-projection 及其兄弟包(session-stats、session-title-*、session-telemetry、session-telemetry-otel)把日志折叠成派生的 UI/可观测性状态,例如会话统计与标题。
当持久日志被重新打开以恢复时,Session.fromRestore 在冻结之前校验存储格式、事件信封、序列连续性、surface 转换与 header 字段,load 会持久关闭任何因崩溃而孤儿化的开启 turn。持久的 fork 边界是 header.seedLength,而进程内的构造边界是 firstLiveSeq(投影到日志中作为 session/end-seed)。
延伸阅读
- 智能体循环 —— 循环如何在每一步追加
user/message、tool/call、tool/result并派生历史。 - 作用域系统 ——
ctx.sessions如何按智能体进行作用域隔离。 - 系统提示组装 ——
request/header的system/tools字段携带什么。 - 仓库源码:
packages/core/session/src/types.ts、packages/core/session/src/index.ts、packages/session/session-persistence/src/write-behind.ts。 - 官方脚手架:
docs/subsystems/session.md、docs/subsystems/persistence.md、docs/glossary.md("session" 唯一可信来源与 "replay" 条目)。