前面两篇分别分析了 Pi Session Tree 和 DeepSeek Harness Session Event Log。两者的持久化模型不同,但都遵循一个共同原则:进程退出以后,新的 Runtime 应当从持久状态重新构造会话,而不是依赖旧 Runtime 对象继续存在。
这一篇用一个很小的 TypeScript Demo 验证这个原则。
Demo 不实现真实 LLM,也不复制 DSH 的完整 SessionEvent 协议。它只保留四个机制:
append-only Event Log
continuous seq
Message Projection
Crash Repair
最终要验证的是:
Process A
执行到一半退出
↓
JSONL Session Log
↓
Process B
重新读取和修复
↓
恢复 Message / Model Context
↓
继续新的 Turn
如果这条链能够成立,Session 和 Runtime 的生命周期就已经真正分开。
先定义最小事件模型
Demo 中只保留几类事件:
export type SessionEventData = {
'turn/start': { turn: number }
'turn/end': {
turn: number
reason: 'completed' | 'failed' | 'cancelled' | 'interrupted'
}
'user/message': { message: Message }
'assistant/message': { message: Message }
'tool/call': {
callId: string
name: string
args: unknown
}
'tool/result': { message: Message }
'request/header': {
model: string
systemPrompt: string
tools: string[]
}
}
这里已经刻意区分了三种信息。
第一类是模型可见历史:
user/message
assistant/message
tool/result
第二类是执行事实:
turn/start
turn/end
tool/call
第三类是请求环境:
request/header
这种划分和 DSH 的完整模型相似,但 Demo 删除了 Step、Chunk、Surface Replace、Request Context 等高级能力。
每个 Event 都带一个连续 seq:
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
seq: number
time: number
type: K
data: SessionEventData[K]
}
}[T]
seq 不是普通展示字段。它定义了 Replay 顺序,也提供最基本的完整性校验。
Durable Append 先于内存 Projection
append() 的实现很短:
append<T extends SessionEventType>(
type: T,
data: SessionEventData[T],
): SessionEvent<T> {
const event = {
seq: this.events.length,
time: Date.now(),
type,
data,
} as SessionEvent<T>
appendFileSync(this.file, `${JSON.stringify(event)}\n`, 'utf8')
this.events.push(event)
return event
}
这里有一个有意保留的写入顺序:
append JSONL
↓ success
push in-memory events
Demo 用同步文件写入,因此这个边界比较容易表达。
真实系统通常会使用异步批处理,此时需要显式的 flush / checkpoint 语义。DSH 的 Persistence Plugin 就将 session/event 复制到后台 buffer,在需要耐久性检查点时通过 session/flush 排空。[1]
Demo 采用同步写入只是为了集中验证状态模型,不代表生产系统应该在 Agent Loop 中对每个 Chunk 执行同步磁盘 IO。
Message 是 Event Log 的 Projection
Session 内没有独立维护一份可持久化的 messages.json。
deriveMessages() 每次都从 Event Log 构造:
deriveMessages(): Message[] {
const result: Message[] = []
for (const event of this.events) {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
result.push(event.data.message)
break
}
}
return result
}
因此:
Event Log
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
↓ deriveMessages()
Messages
user/message
assistant/message
tool/result
turn/start 和 tool/call 没有消失,它们仍然属于 Session Fact,只是不进入 Model Message Projection。
这能够避免两份持久状态之间的 checkpoint 问题:
session.jsonl authoritative
messages derived
如果未来 UI 需要显示 Tool Call,可以增加另一个 Projection,而不用修改 Model Message Projection。
Request Header 让 Context 不只依赖 Message
如果只恢复 Message,还无法完整构造下一次模型请求。
所以 Demo 保存一个简化的 request/header:
session.append('request/header', {
model: 'demo-model',
systemPrompt: 'You are a coding agent.',
tools: ['readFile', 'runTest'],
})
恢复 Context 时读取最新 Header:
buildModelContext() {
const header = this.latestRequestHeader()
return {
systemPrompt: header.systemPrompt,
model: header.model,
tools: [...header.tools],
messages: this.deriveMessages(),
}
}
这说明 Model Context 本身仍然不是持久化对象。
它由两类 Durable State 组合得到:
request/header
+
derived messages
↓
Model Context
真实系统还可能继续加入:
Memory
Compaction
Current Agent Policy
Runtime Injection
但构造原则保持不变。
模拟一次未完成的 Turn
Process A 创建 Session:
const sessionA = EventSession.create('demo', file)
随后正常追加:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
代码故意不写:
turn/end
直接让 Phase 1 停在这里,用来模拟进程在 Turn 中途退出。
此时磁盘上的事实已经足够表达两件事:
Turn 1 曾经开始
Tool Result 已经完成并落盘
同时也能明确看到:
Turn 1 没有正常结束
这比只有一个 run.status = RUNNING 更有恢复信息,因为具体完成到哪些事实仍然保留在日志中。
open() 先验证 seq
Process B 启动后读取 JSONL:
session.events = readEvents(file)
session.assertContiguousSeq()
session.repairInterruptedTurn()
assertContiguousSeq() 验证:
for (const [index, event] of this.events.entries()) {
if (event.seq !== index) {
throw new Error(
`invalid session log: expected seq ${index}, got ${event.seq}`
)
}
}
如果日志变成:
0
1
2
4
系统不会静默 Replay。
因为无法判断 seq=3 是完全没有发生,还是已经发生但丢失。
这类严格校验是 Event Log 能成为事实源的前提之一。DSH 同样要求 Session seq 连续,并在 Surface Fold 和加载边界校验事件结构。[2]
Crash Repair 追加一个 interrupted
恢复时扫描 Turn Boundary:
private repairInterruptedTurn(): void {
let openTurn: number | undefined
for (const event of this.events) {
if (event.type === 'turn/start') {
openTurn = event.data.turn
}
if (event.type === 'turn/end' && event.data.turn === openTurn) {
openTurn = undefined
}
}
if (openTurn !== undefined) {
this.append('turn/end', {
turn: openTurn,
reason: 'interrupted',
})
}
}
所以重新打开后,日志从:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
变成:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
6 turn/end:interrupted
旧 Event 没有被删除,也没有伪造成 completed。
新的恢复事实只是说明:
当前进程接管时,发现上一 Turn 未闭合;
该 Turn 因中断结束。
这个处理思路直接对应 DSH 对冷 Session 的崩溃修复策略。[1]
恢复后的 Message 仍然确定
turn/end:interrupted 不属于 Message Projection。
所以 Process B 执行:
sessionB.deriveMessages()
仍然得到:
User
Check UserService.
Assistant
I will inspect the file first.
Tool Result
class UserService { ... }
已经耐久化的 Tool Result 没有因为 Turn 中断而消失。
这体现了 Event Log 的一个实际优势:执行完整性和事实保留可以分开处理。
Turn 可以失败或中断,但此前成功完成的事件仍然存在。
后续业务可以根据事件语义决定:
保留 Tool Result
忽略半完成 Assistant Chunk
重新执行未完成 Tool
要求用户确认
自动继续下一 Turn
这些策略不需要在 Crash 时修改历史。
新 Runtime 可以继续工作
恢复完成以后,Demo 继续追加第二个 Turn:
sessionB.append('turn/start', { turn: 2 })
sessionB.append('user/message', {
message: {
role: 'user',
content: 'Continue from the recovered state.'
}
})
sessionB.append('assistant/message', {
message: {
role: 'assistant',
content: 'The previous tool result is still available.'
}
})
sessionB.append('turn/end', {
turn: 2,
reason: 'completed'
})
完整历史现在是:
Turn 1
start
messages
tool result
interrupted
Turn 2
start
messages
completed
Process B 从未获得 Process A 的任何内存对象。
它只依赖:
Session Event Log
这就是 Session 和 Runtime 解耦之后最基本的恢复能力。
这个 Demo 还缺什么
当前实现只是状态模型实验,距离生产级 Session 有明显距离。
至少还缺:
异步 Persistence + flush checkpoint
fsync / database transaction
并发 writer 协调
Event schema version
未知 Event 兼容策略
Streaming chunk provenance
Step Boundary
Tool 执行幂等性
Compaction / Surface Replace
Snapshot 加速
Session Fork
大型日志分页
其中最难的问题通常不在 append(),而在外部副作用。
例如日志里存在:
tool/call
进程随后执行了支付、发邮件或修改数据库,但在 tool/result 落盘之前崩溃。
恢复后不能简单重新执行 Tool,否则可能产生重复副作用。
这要求 Tool 层进一步设计:
Idempotency Key
Execution Record
External Transaction ID
Reconciliation
因此 Event Log 可以准确记录 Agent 已知的事实,但不能自动解决所有分布式一致性问题。
这是一个重要边界。Agent Session 的 Crash Recovery 和外部系统的 Exactly-once Execution 属于不同问题。
从 Demo 得到的四个设计结论
这个小实现能够验证四点。
第一,Session 不需要保存 Runtime 对象。只要持久事实足够,新进程可以重新建立 Runtime State。
第二,Message 可以作为 Projection。这样执行 Event、UI Event 和 Model Message 不需要被压进同一种数据结构。
第三,Model Context 也可以重建。Message History 之外,System Prompt、Tools 和模型配置等请求状态需要有明确来源。
第四,Crash Recovery 最好表达新的恢复事实,而不是篡改已经耐久化的历史。未完成 Turn 可以被标记为 interrupted,已经完成的 Tool Result 继续保留。
将这个模型和前两篇放在一起,可以看到三种复杂度层级:
简单 Chat
messages[]
Pi
JSONL Entry Tree
→ current path
→ buildSessionContext()
DSH
SessionEvent Log
→ Surface / Projection
→ Model / UI / Replay / Recovery
系统应该选择与产品需求匹配的最低复杂度模型。
如果只需要保存聊天内容,Message 足够;如果需要可分支的长期 Coding Session,Pi 的 Tree 很有针对性;如果 Replay、恢复、多 Projection 和请求重建都成为核心能力,DSH 这类 Event-sourced Session 会更有价值。
单元二到这里完成了一个完整闭环:从“Session 应该保存什么”开始,经过两种真实框架设计,再回到一个可以执行的最小实现。
下一个单元会进入另一个问题:当 Tool、Memory、Sandbox、MCP、SubAgent、UI 等能力不断进入 Agent 系统,Runtime Core 如何避免持续膨胀。Pi Extension、Cordis 和 DeepSeek Harness 的 Everything is a Plugin 都是在回答这个问题。
Demo
目录结构:
demo/
├── package.json
├── README.md
└── src/
├── event-session.ts
└── demo.ts
运行:
cd demo
npm run demo
参考资料
[1] DeepSeek Harness Persistence: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[2] DeepSeek Harness Session / Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md ; https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts