普通聊天系统很容易把会话理解成一组消息:用户发一条,助手回一条,重新打开页面时把 messages[] 读回来即可。
Agent 会话的状态更复杂。一次交互可能包含模型流式输出、Tool Call、Tool Result、上下文压缩、分支切换、审批、后台任务和中途输入。系统如果只保存最终消息,界面可能能够恢复,但很多运行事实已经丢失;如果把运行中的所有对象都直接序列化,又会把 AbortController、网络连接、Promise 等临时状态带进持久层。
因此,Session 设计首先需要确定状态边界:哪些状态属于长期事实,哪些状态只在当前进程中存在,以及模型每次请求真正需要看到哪一部分。数据库选择属于这之后的实现问题。
这个边界可以先拆成四个概念:
Session
长期存在的一段会话状态
Message
具有对话语义的内容
Event
会话过程中已经发生的事实
Context
当前一次模型请求实际可见的输入
四者经常同时出现在同一套系统里,但生命周期和职责并不相同。
messages[] 为什么很快不够用
先从最简单的模型开始:
interface Session {
id: string
messages: Message[]
}
对于普通聊天,这个结构已经能覆盖大部分需求。历史展示和下一轮模型输入都从同一个数组读取。
Agent 引入 Tool Calling 后,Message 本身仍然可以承载一部分执行结果:
user message
assistant message + tool call
tool result
assistant message
问题出现在越来越多状态无法自然归入 Message。
例如一次模型请求开始前,系统可能已经确定:
当前模型
System Prompt
Tool schemas
Thinking / Reasoning 配置
Context Compaction 结果
当前分支
本次注入的环境信息
执行过程中还会产生:
Assistant streaming chunks
Tool execution started
Tool execution completed
Turn started / ended
Step started / ended
Cancellation / interruption
这些状态并不都适合变成聊天消息。如果强行塞进 Message[],Message 会同时承担 UI 展示、模型历史、执行日志和恢复协议四类职责。每增加一种能力,都要继续扩展 Message 类型,最终很难判断某条记录究竟应该给用户看、给模型看,还是仅用于运行时恢复。
因此需要先建立一个更稳定的原则:持久化层保存可恢复的事实,模型 Context 和 UI History 从这些事实中构造。
这个原则并不要求所有系统都采用 Event Sourcing。Pi 和 DeepSeek Harness 就采用了明显不同的持久模型。关键在于长期事实与临时执行状态不能依赖同一份对象图。
Session 表示长期边界
Session 的生命周期通常覆盖多次模型请求和多次 Agent 执行。
它至少需要提供两个能力:
- 给持久化状态一个稳定的归属;
- 在新的运行时实例启动后重新构造继续工作所需的状态。
因此 Session 中适合保存的内容通常具备一个共同特征:进程退出后仍然有意义。
例如:
用户已经发送过的消息
Agent 已经完成的回复
Tool 已经产生的结果
会话使用过的分支
Compaction 生成的摘要
会话元数据
模型可见配置的必要快照
相反,下面这些对象通常属于 Runtime:
AbortController
正在等待的 Promise
SSE connection
当前 socket
stream reader
进程句柄
内存锁
它们描述的是“此刻怎么执行”,进程恢复后通常需要重新创建。
可以把边界画成:
Session
durable / reconstructable
│
┌──────────────┼──────────────┐
▼ ▼ ▼
History Metadata Durable Facts
│ restore
▼
Agent Runtime
│
┌──────────────┼──────────────┐
▼ ▼ ▼
AbortSignal Streams Active Tools
Session 提供恢复材料,Runtime 重新建立当前进程中的执行对象。
这也意味着“保存 Session”不能简单等价为把 Agent 实例 JSON 序列化。一个成熟 Runtime 应当能够从 Session 恢复出新的 Runtime,而不是依赖旧 Runtime 对象继续存在。
Message 负责对话语义
Message 仍然是 Agent 系统中非常重要的对象,因为模型和用户都以消息作为主要交互形式。
问题在于 Message 更适合作为一种语义视图。
例如用户输入:
帮我检查 UserService 的权限判断
随后 Agent 读取文件、搜索引用、执行测试,最终回复分析结果。
用户界面可能只需要显示:
User
帮我检查 UserService 的权限判断
Assistant
...分析结果...
开发者模式可能还会展示 Tool Call:
Read UserService.java
Search hasPermission
Run ./gradlew test
模型下一次请求需要的历史又可能不同。它需要 Tool Result,但不一定需要 UI 中的折叠状态、耗时、按钮、错误栈展示格式。
所以至少存在三份视图:
Durable Session State
│
├──→ Conversation Messages
│ 面向用户
│
├──→ Model Messages
│ 面向 LLM Provider
│
└──→ Execution / Debug View
面向开发和观测
如果 Message 本身被定义为唯一持久事实,这三个视图之间很容易互相污染。
一个更清晰的设计是:Message 表示对话层能够稳定识别的语义对象,同时允许 Session 保存比 Message 更丰富的事实。
Pi 的 Session Entry 就体现了这一点。Session 文件中除了 message,还会出现 compaction、branch_summary、模型变化等 Entry。Session 的持久状态天然比模型消息列表更宽。[1]
DeepSeek Harness 采用更彻底的方式:Session 直接保存 typed SessionEvent,user/message、assistant/message 和 tool/result 只是其中能够投影成模型消息的一部分。[2]
Event 解决“发生过什么”
Event 的价值在于它把状态变化表达为事实。
例如只保存最终 Message 时:
assistant:
测试失败,原因是权限配置错误。
这条结果无法告诉系统:
本次 Turn 是否已经开始
模型请求了哪些 Tool
Tool 是否真正执行过
流式输出中途是否发生过断开
当前输出由哪些 chunk 组成
这次工作是否因为进程崩溃而中断
如果这些信息对恢复、回放、UI 或观测有价值,就需要独立记录。
一个最小 Event Log 可以写成:
turn/start
user/message
step/start
assistant/message
tool/call
tool/result
step/end
assistant/message
turn/end
这里有一个重要区别:Event 描述过去已经发生的事情,因此持久事件最好具有不可变语义。
例如:
session.append({
type: 'tool/result',
data: { ... }
})
表示某个 Tool Result 已经进入会话事实。
后续如果需要展示另一种 UI,或者改变模型消息的构造方式,可以重新投影 Event;原始事实不需要跟随 UI 一起修改。
这也是 Event Log 能支持多种 Projection 的基础:
SessionEvent Log
│
├──→ Message Projection
├──→ UI Projection
├──→ Telemetry Projection
└──→ Recovery Projection
但 Event Log 也会带来额外复杂度:事件 schema 需要版本管理,Projection 要保证确定性,长日志要考虑压缩,历史事件一旦发布后不能随意更改语义。
所以不能只因为“Event Sourcing 更高级”就把所有 Agent 产品改成 Event Log。判断依据应该是系统是否真正需要 replay、多个独立 Projection、审计和精确恢复。
Context 只服务当前模型请求
Session 和 Context 最容易被混淆。
如果 Session 中已经保存了完整历史,下一次请求仍然不会简单把全部内容发送给模型。
原因包括:
Context Window 有上限
历史可能已经经过 Compaction
当前 Agent 可能只允许看到部分消息
不同模型需要不同消息转换
System Prompt 会动态组装
Tools 会随当前能力变化
临时 Context 可能只对下一次请求有效
因此 Context 更适合被定义为一个运行时 Projection:
Session Durable State
│
├── 当前分支
├── Compaction
├── Memory
├── Agent Policy
├── System Prompt
├── Tool Schemas
└── Runtime Injection
│
▼
Context Builder
│
▼
Model Context
Pi 在 Agent Loop 之前先从长期 Agent State 创建 AgentContext snapshot;Coding Agent 恢复已有 Session 时,则通过 SessionManager.buildSessionContext() 从当前 Session 路径构造消息,再放回 Agent State。[1][3]
DeepSeek Harness 的边界更严格:模型历史由 Session Event Surface 投影产生,同时 request/header 记录实际请求使用的模型配置、System Prompt 和 Tool schemas,使一次模型请求能够从 Session Log 重建。[2][4]
两种设计深度不同,但都说明 Context 具有明确的时效性。它描述“这一次模型调用看到了什么”,Session 描述“这段会话长期保存了什么”。
Compaction 进一步证明两者必须分开
长会话最终都会遇到 Context Window。
假设 Session 中已经存在:
M1 M2 M3 ... M200
模型无法持续接收全部历史。最直接的处理是把早期历史压缩成 Summary:
Session History
M1 M2 M3 ... M200
Model Context
Summary(M1...M150)
+ M151 ... M200
如果 Session 和 Context 是同一个数组,Compaction 很容易变成“删除历史,替换成 Summary”。这样虽然节省了 Context Token,但用户历史、审计信息和分支恢复也一起丢失。
Pi 的设计具有很强的解释性:Compaction 本身作为一个 Session Entry 追加到树中,记录 summary、firstKeptEntryId 和 tokensBefore;构造 Context 时再根据这条 Entry 决定哪些旧消息由 Summary 代替。原始 Entry 仍保留在 JSONL 中。[1][5]
因此 Compaction 更准确的含义是:改变后续 Context 的构造方式,而不是改写已经发生的历史。
对于需要可审计、可分支的 Agent,这是一个非常重要的边界。
Snapshot 和 Event Tail 为什么容易产生一致性问题
很多 Web Agent 系统不会完整采用 Event Sourcing,而是选择:
Message Snapshot
+
Recent Event Tail
这种设计完全可行,但必须显式处理 checkpoint。
假设数据库中的 Message Snapshot 已经包含 Event 0..105,而 Event Log 已经写到 109:
Snapshot checkpoint = 105
Event Log = 0 ... 109
恢复时应该执行:
load Snapshot@105
↓
replay Event 106..109
↓
current state
真正的风险来自写入顺序。
如果 Projection 已经写到 Message 109,checkpoint 仍然停在 105,恢复时又 replay 106..109,同一批状态可能被重复应用。
反过来,如果 checkpoint 已经更新到 109,Message Projection 只写到了 105,恢复逻辑会错误地跳过尾部事件。
所以 checkpoint 和 Projection 必须满足原子提交或可验证的幂等约束:
Event Log
│
▼
Projector
│
├── update Message Snapshot
└── advance checkpointSeq
两者必须形成一致提交边界
如果系统已经把 Event Log 设为唯一事实源,这类双写问题会减少,因为 Message 可以随时重新 Projection。代价是读取路径更依赖 Projection 性能和事件 schema 的稳定性。
哪些状态应该进入 Session
可以用一个很实用的判断标准:
如果进程立即退出,恢复后是否仍需要知道这个事实?
如果答案是肯定的,它通常应该进入 Session 或其他 Durable Store。
例如:
| 状态 | 通常是否持久化 | 原因 |
|---|---|---|
| 用户消息 | 是 | 会话历史 |
| 完成的 Assistant Message | 是 | 对话结果 |
| Tool Result | 是 | 后续模型决策可能依赖 |
| Compaction Summary | 是 | 决定后续 Context |
| 当前分支 | 是或可重建 | 决定当前历史路径 |
| Approval 决策 | 通常是 | 恢复后不能重新猜测 |
AbortController | 否 | 进程局部对象 |
| SSE Connection | 否 | Transport 状态 |
| Streaming iterator | 否 | 无法跨进程恢复 |
| Tool 子进程句柄 | 视实现而定 | 通常需要转换成可恢复任务状态 |
还可以增加第二个判断:
如果这个状态会影响未来模型请求,它是否能够从持久事实中重建?
这会把 Session 的要求提高一层。
例如一个 Agent 恢复后换了完全不同的 System Prompt 和 Tools,历史消息虽然还在,之前的 Tool Result 可能已经失去语义环境。DSH 因此把 request header 和 agentPreset 等信息纳入可持久重建范围。[4]
简单产品未必需要保存到这个粒度,但设计时应该明确“恢复”的目标究竟是恢复聊天文本,还是恢复模型当时的执行语义。
一个稳定的状态分层
把前面的关系收敛后,可以得到下面这套模型:
Session
长期持久化边界
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Messages Events Metadata
│ │ │
└─────────────┼─────────────┘
│
▼
Projection
│
┌─────────────┼─────────────┐
▼ ▼ ▼
UI History Model Context Recovery State
│
▼
Agent Runtime
这里的核心判断有三点。
第一,Session 应当保存能够跨进程继续成立的状态,而不是整个 Runtime 对象。
第二,Message 是重要的语义模型,但它不必承担所有持久事实。Tool 生命周期、Compaction、Branch、请求配置等状态可以有独立表示。
第三,Context 应当由当前 Session 和 Runtime 条件构造。Compaction、Memory、Agent 配置变化都不应该迫使系统篡改完整历史。
下一篇进入 Pi 的具体实现。Pi 没有把 Session 设计成完整的执行 Event Log,它选择了一种更轻量的结构:JSONL 物理追加,Entry 通过 id / parentId 形成逻辑树,当前 leaf 决定当前会话路径。这个结构非常适合分析“持久历史”和“模型 Context”如何在同一份 Session 中保持不同形态。
参考资料
[1] Pi Session File Format: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] DeepSeek Harness Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[3] Pi SDK session restore: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] DeepSeek Harness Architecture / Persistence: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md ; https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[5] Pi Compaction: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md