Session:仅追加的事件日志,模型上下文的唯一真源
专栏:DeepSeek Harness · 第 6 / 14 篇进入核心主干。第一个要读的服务是 ctx.sessions——它看起来只是「存聊天记录的地方」,实际上是整个 dsh 的真源(source of truth):模型历史、UI 状态、回放、fork、压缩,全都从同一份事件日志推导。理解了 Session,后面读 agent-loop 才不迷路。
一个倒过来的设计
传统聊天应用会存「消息列表」,事件(什么时候开始新轮次、哪次调用失败了)顶多做元数据。dsh 把它倒了过来:只存事件,消息是投影。
好处是自由度:任何「模型看到了什么」的疑问,答案永远是「从日志重算」——不存在第二份可能不同步的数据。
12 种核心事件
SessionEventMap 定义了核心词汇(packages/core/session/src/types.ts:260起),插件还可以用声明合并扩展仅日志事件(全仓已知约 50 种)。核心 12 种:
| 事件 | 语义 |
|---|---|
turn/start / turn/end | 轮次边界;end 带 reason(completed/aborted/blocked/error/max-tokens/interrupted) |
step/start / step/end | 步骤边界:一次模型请求 + 它调用的工具 |
user/message | 模型可见的用户消息(人类输入、注入上下文、goal 续轮) |
assistant/message | 成功的模型回复,内嵌精确的带时间流 |
assistant/attempt | 到达终局但没产生可见消息的尝试(失败/重试/取消) |
tool/call | 模型请求的工具调用(arguments 是模型原产 JSON 串,不解析) |
tool/result | 工具结果,用 sourceEventSeqs 回指它的 tool/call |
request/header | 请求信封快照(config + system + tools),reason: initial/resume/change/series |
request/context | 路由元数据(provider/model/上下文窗口),仅变化时落 |
session/end-seed | 构造 seed 的终点标记 |
其中只有 3 种是 surface 事件(user/message、assistant/message、tool/result)——它们投影为模型消息;其余是边界与诊断数据。看类型定义里的注释,语义精确到苛刻:
// packages/core/session/src/types.ts:260-288(节选)
export interface SessionEventMap {
/** Opens turn `turn` before the loop claims queued input or runs pre-step. */
'turn/start': { turn: number }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn
* with no entered step has no `step/start` or `step/end`. ……
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/**
* A user-role message on the model-visible surface: a direct human prompt
* …, a synthetic `agent.inject()` context …, or an entered goal
* continuation round. All three project their `content` verbatim; `source`
* tells them apart.
*/
'user/message': UserMessage
deriveMessages:日志 → 消息历史的投影
投影规则本身只有几十行,值得全读:
// packages/core/session/src/surface.ts:90-121
export function deriveEventMessage(event: SessionEvent): Message | null {
// Intentionally non-exhaustive: only message-producing events derive
// history; turn/step boundaries, failed attempts, and errors are trace/replay
// data.
switch (event.type) {
case 'user/message': {
return event.data // 原样投影,不加任何包装
}
case 'assistant/message': {
// 空内容的 assistant/message 只为承载 max-tokens 步骤的 usage 而存在,
// 不得给 provider 造一个空 assistant 轮次
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': {
return event.data.message // 带工具结果块的 user 消息
}
default:
return null // 边界/attempt/日志事件不产生消息
}
}
实例方法 deriveMessages() 沿 surface 节点增量折叠这个函数——每个节点只投影一次,压缩(compaction)替换 surface 时才整体重建:
// packages/core/session/src/index.ts:820-841
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]
}
细节:replace(压缩用)只遮蔽 surface 上的旧节点,原始日志条目永不删除——审计与回放始终完整。
模型可见即已记录
这是全仓库我最喜欢的一条不变量(docs/architecture.zh.md:115):
抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件。
断言的实现就在 agent-loop 里:每次发请求前,把实际请求消息与 session.deriveMessages() 逐 JSON 比较,分歧即报 log-reconstruction desync。也就是说,「请求是日志的纯函数」不是文档承诺,是每次请求都在验证的运行时事实。你给模型注入的任何上下文,必须先落成事件(这就是为什么 inject 上下文会以 user/message 出现在日志里)。
持久化:JSONL 与 generation 迁移
持久化是独立 seam(ctx.sessionPersistence),默认后端把日志写成 zstd 压缩的 JSONL:
- 目录:
<root>/<--project-key-->/<encoded-session-id>/session.v2.jsonl.zstd - v0 文件名
session.jsonl,v1 起带版本号session.vN.jsonl:
// packages/session/session-format/src/filename.ts:14-17
export function sessionFormatLogFilename(version: number): string {
const generation = sessionFormatVersion(version, 'Session log generation version')
return generation === 0 ? 'session.jsonl' : `session.v${generation}.jsonl`
}
版本升级(v0→v1→v2)靠相邻迁移链:open 时在内存组合迁移、校验,然后原子 link 发布新文件——已提交的 generation 路径绝不重命名、替换或删除。遇到未来版本直接拒绝并提示「升级 harness」,而不是把日志当损坏数据。
投影 seam:UI 状态也走同一事件流
ctx.sessionProjections 是第二层投影:领域插件注册纯同步的折叠单元(init/apply/view),注册表只订阅一次 session/event,把每个事件增量折叠进各单元。上面 tool-todo 的注册就是一例——todo/write 事件折叠成 todo 列表,turn/start 时清空。UI 从此不再自己解析事件流,stateOf() 读现成的类型化状态。
一次用户消息的生命周期
把流程串起来(下一步我们就要深入第 4~6 步所在的 agent-loop):
与其他模块的联系
- agent-loop(第 7 篇)是日志唯一的写入者,
deriveMessages()是它的读取接口; - JSONL 持久化 是独立 seam:后端订阅
session/event写入,session/flush是持久性屏障; - 投影 seam(
ctx.sessionProjections)把同一事件流折叠成 UI 状态——todo、标题、计划模式都从事件折叠而来(第 14 篇的 todo 工具是一例); - 压缩(第 7 篇 companion)只遮蔽 surface、永不删除原始条目——审计与回放始终完整。
记住一条阅读捷径:在 dsh 里看到任何「模型可见」的数据,问一句「它是哪种会话事件、在什么时候 append」;看到任何「从哪恢复」的数据,问一句「它是从哪个事件折叠出来的」。两问皆有答案,说明这块代码读懂了。
下一篇把镜头对准写这些事件的那个循环:agent-loop。