Skip to content

源码拆解 03 · dsh-session:追加式事件日志

本篇拆解 packages/core/session(npm 包 @deepseek-ai/dsh-session)。这是 dsh 里"概念密度最高、代码却最可控"的包:核心思想全部落在一个 append-only 日志和它的投影函数上。

3.1 文件地图

text
packages/core/session/src/
├── index.ts            # Session 类 + SessionStore 服务(~1150 行,主体)
├── types.ts            # SessionEventMap 事件词汇表(先读这个)
├── surface.ts          # "有序表面":从日志投影对话消息序列
├── chunk-rows.ts       # assistant/chunk 的聚合投影(流式回放的数据结构)
├── invariant.ts        # 关系型运行时断言(日志自洽性)
├── repair.ts           # 崩溃恢复:打开状态的回合怎么关
├── json.ts             # 无损 JSON 序列化
└── preparation.ts      # 持久化加载前的准备

推荐阅读顺序:types.ts(词汇表)→ index.tsSession.appendderiveMessagessurface.ts(投影)→ invariant.ts(守卫)→ repair.ts(容错)。

3.2 词汇表:SessionEventMap

types.ts 的核心是 SessionEventMap——声明合并的接口映射,事件类型名就是 key(完整清单见原理篇 7.2,此处看代码形态):

ts
// types.ts(简化摘录)
interface SessionEventMap {
  'turn/start': { turn: number }
  'turn/end': { turn: number; reason: TurnEndReason }
  'step/start': { turn: number; step: number }
  'user/message': UserMessage
  'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
  'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
  'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: …; meta?: JsonValue }
  'request/header': { header: EpochHeader; reason: 'initial' | 'resume' | 'change' }
  // …插件可以 declare module 追加自己的事件类型
}

三个值得注意的设计点:

  1. 事件可扩展:插件用 declare module '@deepseek-ai/dsh-session/types' 追加新事件类型——"模型可见即已记录"铁律不封死未来,而是给每个新事实一个规范的落点(第 1 章表里的 agent/inbox/spliced 就是 agent 包追加的)。
  2. 三种"表面事件"特殊user/messageassistant/messagetool/result 是唯一能上"有序表面"(surface)的事件,append 时必须携带 SurfaceIntent(追加 or 压缩替换 + 源事件序号引用)。TypeScript 条件类型让编译器强制这一区别:传错参数直接编译失败。
  3. tool/resulterror 字段:内部失败的身份是 { name, code } 而非原始异常对象——日志必须可无损序列化,异常对象不是。

3.3 Session:append 与投影

Session 类(index.ts)的公开面(简化):

ts
class Session {
  readonly header: SessionHeader        // 不可变元数据:id/cwd/parentSession/origin/…
  get events(): readonly SessionEvent[] // 深度冻结的日志快照
  get seq(): number                     // 下一个事件序号

  append<T extends SessionEventType>(type: T, data: SessionEventMap[T], …): SessionEvent<T>
  deriveMessages(): Message[]           // 日志 → 模型历史(缓存 + 冻结)
  requestHeader(): EpochHeader | undefined   // 最近请求头的折叠结果
}

append 做的事(第 726 行附近能看到投影实现):

ts
deriveMessages(): Message[] {
  // 遍历 surface(有序表面),跳过非模型可见事件,
  // 按序拼接 user / assistant / tool-call / tool-result 消息,
  // 结果深度冻结并缓存,直到下一次 append 才失效。
}

surface.ts 维护"有序表面":表面事件按 SurfaceIntent 排列成有序序列;压缩(compaction)时旧的表面片段被 { op: 'replace' } 整体替换,但原始事件仍在日志里——历史不动,投影可变。这解决了追加式日志与"上下文压缩需要删旧消息"之间的矛盾:删的是投影,不是事实。

chunk-rows.ts 回答"UI 流式回放怎么高效":assistant/chunk 是高频小事件(一个字一个 chunk),按行聚合存储,避免回放时线性扫描全日志。

3.4 不变量:日志的自我守卫

invariant.ts 是"模型可见即已记录"的落地。它维护每个会话的追踪状态(SessionTrace):

ts
interface SessionTrace {
  lastSeq: number
  openTurn: number | null      // 当前打开中的 turn
  openStep: number | null      // 当前打开中的 step
  pendingCalls: Set<CallId>    // 尚未收到 tool/result 的工具调用
}

每个候选事件在追加前经过校验,例如:

  • step 内的事件必须指名当前打开的 turn/step(requireOpenStep);
  • seq 必须严格递增;
  • tool/result 必须对应一个 pending 的 tool/call
  • turn/step 的开关必须成对。

校验失败 = 抛错,而不是静默写入。这层"关系型断言"的价值:任何"偷偷给模型塞内容"或"事件顺序错乱"的 bug,都会在开发期当场炸出来,而不是变成生产环境里无法解释的模型行为。

3.5 崩溃恢复:repair.ts

追加式日志还有个现实问题:进程崩溃时,日志里可能留着"打开了但没关闭"的 turn/step。repair.ts 在持久化加载时处理它们——发 turn/end { reason: { kind: 'interrupted' } } 这类补偿事件把结构缝合。这印证了第 7 章的话:追加式日志不修改历史,修正也以新事件表达,连"崩溃修复"都不例外。

3.6 SessionStore:仓库与 fork

SessionStorectx.sessions)本身是薄的服务层:

ts
class SessionStore extends Service {
  create(id?, options?): Session            // 建会话(可带 seed 种子日志)
  get(id): Session | undefined
  fork(source, boundary?, childSessionId?): Session  // 分支
  flush(session): Promise<boolean>          // 触发持久化检查点
  // prepare/enter/announce:三段式有序发布(先组装完整、再一次性宣布)
}

fork 的实现直白:取源日志的 [0, boundary] 前缀(校验 boundary 落在已闭合的 turn 上),拷贝给新 Session,并把 parentSession 写进元数据。第 5 章说的 subagent 就是"fork + 新 agent"。

三段式 prepare → enter → announcectx.agents.create 的"先组合后发布"是同一个纪律:任何观察者都看不到半成品会话——这在多消费者并发读日志的场景里至关重要。

3.7 本章小结

dsh-session 的代码证明了"架构决策可以极小":类型(词汇表)+ 一个 append 方法 + 一个投影函数 + 一套断言 + 一个 fork。复杂的是纪律(追加式、可序列化、配对校验),代码反而简单——这是好架构的标志。

小练习:给日志加一种事件

假设你要给会话日志增加 rating/given(用户打分)事件。请写出:1)SessionEventMap 的声明合并代码;2)它要不要 SurfaceIntent?为什么?3)如果打分要能撤销(用户改分),按追加式日志的纪律,你应该追加什么?

(提示:想想 todo/write 的做法——快照覆盖也是一种事件。)

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写