深色模式
源码拆解 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.ts 的 Session.append 与 deriveMessages → surface.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 追加自己的事件类型
}三个值得注意的设计点:
- 事件可扩展:插件用
declare module '@deepseek-ai/dsh-session/types'追加新事件类型——"模型可见即已记录"铁律不封死未来,而是给每个新事实一个规范的落点(第 1 章表里的agent/inbox/spliced就是 agent 包追加的)。 - 三种"表面事件"特殊:
user/message、assistant/message、tool/result是唯一能上"有序表面"(surface)的事件,append 时必须携带SurfaceIntent(追加 or 压缩替换 + 源事件序号引用)。TypeScript 条件类型让编译器强制这一区别:传错参数直接编译失败。 tool/result的error字段:内部失败的身份是{ 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
SessionStore(ctx.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 → announce 与 ctx.agents.create 的"先组合后发布"是同一个纪律:任何观察者都看不到半成品会话——这在多消费者并发读日志的场景里至关重要。
3.7 本章小结
dsh-session 的代码证明了"架构决策可以极小":类型(词汇表)+ 一个 append 方法 + 一个投影函数 + 一套断言 + 一个 fork。复杂的是纪律(追加式、可序列化、配对校验),代码反而简单——这是好架构的标志。
小练习:给日志加一种事件
假设你要给会话日志增加 rating/given(用户打分)事件。请写出:1)SessionEventMap 的声明合并代码;2)它要不要 SurfaceIntent?为什么?3)如果打分要能撤销(用户改分),按追加式日志的纪律,你应该追加什么?
(提示:想想 todo/write 的做法——快照覆盖也是一种事件。)