Pi 的 Session 主要围绕“可分支历史如何恢复成当前 Context”组织。DeepSeek Harness(DSH)选择了更强的持久化边界:Session 本身就是 append-only 的 typed SessionEvent 日志,模型消息、UI、Replay 和恢复状态都从同一条日志派生。[1]
官方文档直接把这条日志定义为 Agent 全部交互历史的 single source of truth。模型消息不会作为另一份权威状态单独维护,而是通过 deriveMessages() 从 Session Log 中投影得到。[1]
这种设计首先解决的是多份状态之间的一致性问题。
当 Message 和执行历史分别持久化
一个常见 Agent 存储模型是:
messages table
runs table
tool_calls table
stream_events table
每张表都合理,但一次执行会同时修改多份状态。
例如模型开始响应:
创建 Run
追加 streaming chunks
生成 Assistant Message
写 Tool Call
写 Tool Result
更新 Run 状态
如果其中一次写入失败,就需要处理“哪个状态已经提交”。恢复逻辑也必须决定哪张表更可信。
DSH 将这类事实先统一为一条有序事件流:
turn/start
step/start
user/message
request/header
assistant/chunk
assistant/chunk
assistant/message
tool/call
tool/result
step/end
turn/end
每个事件具有单调递增的 seq。正常日志要求 seq 连续,因此事件在 Session 中拥有确定位置。[1]
这样恢复问题变成:
读取 SessionEvent[]
↓
按 seq Replay
↓
重新构造需要的 Projection
核心状态的来源只有一份。
SessionEvent 记录的范围比 Message 更宽
DSH 的事件词汇包含多类事实:[1]
生命周期
turn/start
turn/end
step/start
step/end
模型可见消息
user/message
assistant/message
tool/result
执行轨迹
assistant/chunk
tool/call
请求状态
request/header
request/context
其他持久状态
todo/write
session/end-seed
这组事件说明 DSH 对 Session 的定义已经超出 Conversation Transcript。
例如 assistant/chunk 不会直接形成下一次 LLM History,但它保留原始流式输出轨迹;turn/start 和 step/start 用于表达执行边界;request/header 保存一次请求使用的模型配置、System Prompt 和 Tool schemas。
因此同一个 Event Log 可以支撑多个消费者:
SessionEvent Log
│
├── Model History
├── UI Replay
├── Transcript
├── Telemetry
├── Crash Recovery
└── Fork / Resume
这也是 Event Sourcing 在 Agent 场景中的主要价值:Tool、Stream、模型消息和运行边界天然就是时间序列。
Surface:Event Log 和模型历史之间的中间层
如果把全部 SessionEvent 直接转换成模型消息,会出现明显问题。
模型不需要看到:
turn/start
step/end
assistant/chunk
request/context
所以 DSH 在完整 Event Log 上定义了一层 Surface。
当前核心只允许三类事件进入模型可见 Surface:[2]
type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
deriveEventMessage() 的逻辑也很直接:
user/message → UserMessage
assistant/message → AssistantMessage
tool/result → ToolResultMessage
其他事件 → null
于是形成:
Full Session Log
↓
Surface Fold
↓
Surface Nodes
↓
deriveEventMessage()
↓
Model Messages
这层设计很重要,因为它允许 Session 保存比 Context 更丰富的事实,同时保持 Model History 的结构稳定。
surfaceOp 让 Compaction 仍然保持 append-only
普通消息进入 Surface 时使用 append:
M1 → M2 → M3 → M4
Compaction 会产生一个特殊问题。模型后续需要看到 Summary 代替一段旧历史,但 Event Log 又不应该删除旧事件。
DSH 的 Surface 支持 replacement operation:[1][2]
type SurfaceOp =
| 'append'
| { op: 'replace'; start: number; end: number }
假设 Surface 当前是:
10 15 21 30 35
新的 Summary Event 可以声明:
replace 10..30
新的 Surface 变成:
42(summary) 35
但 Session Log 仍然保留原来的 10、15、21、30。
因此:
Log
只追加事实
Surface
允许改变模型可见顺序和范围
这和 Pi Compaction 的思想有相似之处:都保留原始历史,改变的是后续模型 Context 的构造方式。DSH 把这个原则进一步形式化成通用的 Surface Operation。
sourceEventSeqs 保存派生关系
DSH 的 Surface Event 还可以记录 sourceEventSeqs。[1]
最直观的例子是 Streaming:
assistant/chunk seq=20
assistant/chunk seq=21
assistant/chunk seq=22
↓ assemble
assistant/message seq=23
sourceEventSeqs=[20,21,22]
这样完整 Message 和原始 Chunk 同时存在,但两者之间的来源关系没有丢失。
这个字段在 Surface Replace 中同样重要。一个 Summary 替换若干旧 Surface Node 时,需要声明它引用了哪些被遮蔽的节点。Surface fold 会验证这些 provenance 关系。[2]
这使 Event Log 不只是“按照时间把 JSON 存起来”,还保存一部分派生图:
Raw Events
↓ provenance
Derived Surface Event
对于 Debug、Replay 和审计,这比只保存最终 Message 更可靠。
为什么连 Request Header 都要记录
DSH 架构文档提出了一个很严格的要求:抵达模型请求的输入必须能够从日志重建。[3]
仅保存 Message 还不够,因为一次真实 LLM Request 还包括:
provider / model
reasoning config
sampling config
System Prompt
Tool schemas
这些信息由 request/header 保存。[1]
当前 EpochHeader 大致包含:
interface EpochHeader {
config: LlmCallConfig
adapterDefaults?: ...
system?: string
tools?: ToolSchema[]
}
每个 Loop 实例开始时记录完整 Header;配置发生变化时继续追加新的 Snapshot。恢复时读取最新 Header 即可重建请求环境。[1]
这解决了一个常被忽略的问题:
消息完全相同
+
System Prompt / Tools 不同
=
实际模型语义可能完全不同
如果一个系统承诺“精确 Replay”,只保存 Message 并不足够。
DSH 因此把“模型可见状态可重建”当成 Session 的运行时不变量。[3]
这种完整性会增加日志规模和 schema 复杂度,但它使 Resume、Fork 和 Replay 的语义更明确。
Persistence 只负责让同一条日志耐久化
Event Sourcing 容易出现另一种复杂化:内存有一套 Event,数据库又定义另一套 Persistence Event。
DSH 当前刻意避免这一点。Persistence seam 直接持久化现有 SessionEvent,没有平行事件模型。[4]
运行时追加 Event 后,会同步发出 session/event 通知;Persistence Plugin 将事件复制到 per-session buffer,随后批量写入后端。生产 Event 的 Agent Loop 不必等待每一条磁盘写入。[4]
流程接近:
Session.append(event)
↓
In-memory log committed
↓
session/event
↓
Persistence buffer
↓ batch
JSONL / SQLite
需要确定持久化边界时,通过 session/flush 排空缓冲区。
这里将两个概念分得很清楚:
Session
定义事实和顺序
Persistence
决定这些事实如何耐久化
因此 JSONL 和 SQLite 可以是可替换 Backend,而上层 Event Model 不需要变化。[4]
Crash Recovery 不删除中断中的 Turn
Agent 进程可能在 Turn 中途崩溃。
磁盘可能已经存在:
turn/start
step/start
user/message
assistant/message
tool/call
但没有:
tool/result
step/end
turn/end
一种恢复方式是截断最后一个未完成 Turn。DSH 当前选择保留已经耐久化的事实,并在冷恢复时追加一个 synthetic:
turn/end {
reason: interrupted
}
用于关闭未配平的 Turn。[4]
这样恢复后的日志仍然明确表达:
这个 Turn 曾经发生
其中一部分工作已经完成
进程在结束前被中断
这比把整个 Turn 删除更符合 append-only 事实模型。
对于长任务尤其重要。一个 Turn 可能包含很多 Step 和大量 Tool Result,前面已经产生的事实不能因为最后一次崩溃全部消失。
Session Header 为什么放在 Event Log 外面
DSH 仍然没有把所有数据都做成 Event。
Session 的存储元数据放在独立 SessionHeader 中,例如:[4]
version
id
createdAt
cwd
parentSession
seedLength
origin
delegationDepth
agentPreset
这些字段描述 Session 本身和存储血统,不属于交互时间线。
例如:
parentSession
这份 Session 从哪个 Session fork
seedLength
前多少条 Event 属于继承前缀
agentPreset
恢复这个 Session 应使用哪种 Agent 组合
这里体现了一个值得保留的边界:Event Log 记录“会话内发生了什么”,Header 保存“这份会话记录是什么”。
Event Sourcing 并不要求所有 Metadata 都转换成事件。
DSH 的 Fork 更接近 Session Lineage
Pi 可以在一个 Session 文件内部形成树。
DSH 的 Session Log 本身保持线性:
seq 0
seq 1
seq 2
...
Fork 时创建新的 Session,并把稳定前缀作为 Seed。Header 使用 parentSession 和 seedLength 记录血统。[4]
可以表示为:
Session A
0 1 2 3 4 5
│
└──── fork at prefix
↓
Session B
0 1 2 3 | 4' 5' 6'
所以 Pi 与 DSH 在 Branch 上形成了两个很有代表性的模型:
Pi
一个 Session 内保存 Entry Tree
DSH
每个 Session 保持线性 Event Log
分支形成 Session Lineage
前者适合频繁在一个 Coding Session 内进行历史导航;后者保持 Event seq 的线性和 Replay 模型简单,代价是 Fork 会产生新的 Session Identity。
很难脱离产品行为判断哪一种更优。
Event Log 的成本
DSH 的设计获得了很强的重建能力,也承担了明显成本。
第一,事件数量大。Streaming Chunk 也进入日志,一次响应可能生成大量 Event。
第二,Event Schema 属于持久协议。一旦发布,类型变更需要考虑旧日志。当前 Persistence 会对格式版本和未知 required event 进行严格校验,避免静默忽略影响重建语义的事件。[4]
第三,所有 Projection 必须确定性。Surface、Message、UI、Transcript 如果采用不同解释规则,会重新产生一致性问题。
第四,Event Log 需要 Compaction 或 Surface Replace 等机制控制模型 Context,但原始日志仍可能长期增长。
第五,开发者必须区分:
事实 Event
实时 Runtime Event
模型 Surface Event
UI-only Projection
DSH 的架构文档也明确分开 Session Event、Agent Event 和 capability event。需要跨 reload 保留的事实进入 Session Event;运行中拦截工作使用 agent/*。[3]
这条边界非常重要。把所有 callback 都写成持久 Event,会让日志成为低层调试 Trace;把所有执行事实都只做内存 callback,又无法恢复。
什么时候值得采用这种设计
一个简单 Chat Bot 通常没有必要保存 turn/start、Raw Chunk 和 Request Header。
Event Log 的收益在以下需求同时出现时会明显提高:
执行过程需要恢复
UI 需要完整 Replay
一个事实需要产生多个 Projection
需要精确审计 Tool 行为
需要 Fork / Resume
需要解释一次模型请求当时看到的完整状态
需要跨进程观察 Agent 工作
如果系统只需要:
展示聊天历史
把最近消息发送给模型
messages[] + metadata 仍然是更低成本的设计。
DSH 提供的价值并不在“Event Sourcing 本身”,而在于它把 Agent 中几个容易分裂的状态源统一起来:
SessionEvent Log
↓
Surface
↓
Model Messages
SessionEvent Log
↓
UI / Replay / Transcript
SessionEvent Log
↓
Persistence / Recovery
整个系统围绕同一份事实流工作。
下一篇会把这个模型缩小成一个可运行 Demo。Demo 不复制 DSH 的所有事件,也不实现 Pi 的 Tree,而是保留四个最关键的机制:append-only Event、连续 seq、Message Projection、Crash Repair。目标是验证一个问题:只保存 Event Log 时,进程重新启动后是否能够确定性地恢复 Conversation 和下一次 Model Context。
参考资料
[1] DeepSeek Harness Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[2] DSH Session Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts
[3] DeepSeek Harness Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md
[4] DeepSeek Harness Persistence: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[5] DSH Session implementation: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/index.ts