上一篇我们了解了Pi的Session的整体架构设计、Session的树形数据结构、三种队列管理等等.
本篇我们继续探索Pi的Session工作原理
5.2 持久化时机:Save Point
Session 不是"每改一个字符就 flush 一次磁盘",而是按"回合"批量写:
- message_end
:单条消息收尾时写一次(user、assistant、toolResult 各写一次)
- turn_end
:整个 turn 结束时 flush 所有"待写"配置变更(model_change、thinking_level_change、active_tools_change)
- agent_end
:整个 Agent 运行结束时发"settled"事件
这种设计的取舍:批量写减少 IO 次数,但若程序在中途崩溃,最后一小段配置变更可能丢失——但用户消息和 AI 回复都已经写盘了,不会丢对话内容。
5.2.1 三个事件代表什么:三层嵌套的"生命周期"
message_end / turn_end / agent_end 不是三个并列的事件,而是三层嵌套的"生命周期边界"。一次 Agent 运行可以包含多个 turn,一个 turn 可以包含多条 message。从外到内:
一句话总结:agent_end是"外层天花板",turn_end是"中段里程碑",message_end是"内层原子"。三者是包含关系,不是并列出三次。
三个事件分别代表什么:
事件 | 生命周期含义 | 每次 AgentLoop 触发次数 | 携带数据 | 典型用途 |
|---|---|---|---|---|
| agent_start / agent_end | 一次完整的 | 各1 次(agent_end 会发 | agent_end 带 | TUI 知道"AI 这轮干完了,可以给我看结果 / 让我继续打字"了 |
| turn_start / turn_end | 一个 turn 的开始和结束。一个 turn = 一次 assistant 回复 + 它所触发的所有工具调用 + 所有 toolResult | 可能多次。如果 assistant 调用了工具,AgentLoop 会再开新 turn 把工具结果喂回去,直到 assistant 给出 | turn_end 带 | 在 turn 边界批量 flush 配置变更(model_change 等);也是 |
| message_start / message_update* / message_end | 单条消息(user / assistant / toolResult)的开始、更新(仅 assistant 流式期间)、结束 | 每个 turn 内至少 2 条(user + assistant),如有工具则更多 | 始终带 | Session 落盘的最小单位 。message_end 一触发,那条消息就立刻 |
实际触发的时序(看 agent-loop.ts:109-198):
emit({ type: "agent_start" }) // 整个 loop 启动 1 次 emit({ type: "turn_start" }) // 第一个 turn 开始 emit({ type: "message_start", prompt }) // user prompt emit({ type: "message_end", prompt }) // user prompt 立刻结束(无流式) emit({ type: "message_start", assistantPartial }) // assistant 开始流式 emit({ type: "message_update", ... }) // 流式过程中触发 N 次 emit({ type: "message_end", assistantFinal }) // assistant 流完 // 如果有工具调用: emit({ type: "tool_execution_start", ... }) emit({ type: "tool_execution_end", ... }) emit({ type: "message_start", toolResult }) emit({ type: "message_end", toolResult }) emit({ type: "turn_end", message, toolResults }) // turn 边界 // 如果需要继续(assistant 还要看 toolResult 再回话): emit({ type: "turn_start" }) // 开新 turn // ... 再次流式 assistant ... emit({ type: "turn_end", ... }) // 直到 assistant stopReason === "stop" emit({ type: "agent_end", messages }) // 整个 loop 收尾为什么是三层而不是两层?
- message 层
保证"AI 一句话讲完就落盘"——断电也不丢用户已看到的字。
- turn 层
是真正的"工作单元"——一个 turn 结束后 harness 才会去检查"要不要换模型/压缩/插入 steer 消息",这些批量配置变更在 turn_end 时统一落盘,避免每改一个就写一次。
- agent 层
是"是否还活着"的信号——agent_end 一发,TUI 立刻解锁输入框;如果长时间没收到,TUI 知道 Agent 还在忙,可以显示"AI 正在输入..."。
简单说:message_end 关心"内容不能丢",turn_end 关心"配置可以批量",agent_end 关心"什么时候让人继续打字"。三者职责分明、各管一段。
常见误解:agent_start / agent_end ≠ 一次 Session
关键区别:Session 是整本对话笔记本(可能跨多个工作日、几百条消息),而 agent_start / agent_end 只是"AI 响应一次用户输入"的完整流程。一次 Session 里会有很多次agent_start / agent_end。
概念 | 范围 | 触发时机 | 持续多久 |
|---|---|---|---|
| Session | 整本对话笔记 | 用户开/关应用、加载历史 | 可能跨小时、天、甚至周 |
| agent_start / agent_end | 一次 | 用户敲一次回车 / 扩展主动 | 几秒到几分钟,取决于工具调用 |
| turn_start / turn_end | 一次 assistant 回复 + 它的工具结果 | 工具调用会开新 turn(直到 assistant | 一次 LLM 调用 + 工具执行 |
| message_start / message_end | 单条消息 | 每条 user/assistant/toolResult 都有 | 毫秒到秒(流式) |
用代码佐证:AgentHarness.prompt()是用户每发一条消息就会调一次的方法(agent-harness.ts:608),它内部executeTurn→runAgentLoop→ 触发一次完整的agent_start...agent_end。所以一次agent_start严格对应"用户的一次输入 + AI 完成这次输入处理的全过程",而不是"一次完整的会话"。
算账式理解:
一次 Session =N 次agent_start / agent_end(你每说一句话算一次)
一次 agent_start / agent_end = 1~M 次 turn_start / turn_end(AI 调一次工具就多一个 turn)
一次 turn_start / turn_end = 2~K 条消息(user + assistant + 0~N 个 toolResult)
所以三层事件和 Session 之间的关系是:Session 是"账本",三层事件是"这次记账里具体写哪几行、什么时候结算"。把 agent_start / agent_end 误当成 Session 是常见误解——前者是"这次响应",后者是"整本历史"。
5.3 上下文构建:buildSessionContext
从磁盘的整棵树,到 LLM 看到的一维消息流,中间有一个关键的"翻译"步骤:
这里的关键洞见:树的形状由用户和工具决定(分支、压缩),但 LLM 看到的永远是一条线性消息流。实际干这件"扁平化"工作的是buildSessionContext这个纯函数(session.ts:22)——它接收"从根到当前叶子的全部条目",吐出SessionContext。Session 类本身只负责提供路径(getBranch()),Session.buildContext()是把这两步粘在一起的胶水方法。
6. 架构设计:分层与数据流
6.1 分层架构
分层的好处:
- 存储可替换
:默认是 JSONL 文件,但通过 SessionStorage 接口,可以换成 SQLite、内存、远程存储等。AgentHarness 不用改一行代码
- 运行时和持久化解耦
:AgentHarness 只跟 Session 这个抽象打交道,不关心 JSONL 怎么写
- 易于测试
:用 MemorySessionStorage 就能跑 AgentHarness 单元测试,不用碰磁盘
6.2 关键数据流(一次 prompt)
注意一个微妙之处:同一回合内,buildContext 被调用了两次——一次在 prepareNextTurn(AgentHarness 准备上下文),一次在 runAgentLoop 内部(AgentLoop 真正调 LLM 前)。这是因为 AgentHarness 的 prepareNextTurn 会先注入 steer 消息,再让 AgentLoop 拿到最新上下文。
7. 设计优点和缺点
7.1 优点
优点 | 说明 |
|---|---|
| 断电可恢复 | JSONL 追加写,每条 message_end 都已落盘。程序崩溃后重开能完整恢复 |
| 分支可追溯 | 树形结构天然支持"换个方向试试"。旧分支不删除,只是叶子指针移走 |
| 压缩可回滚 | compaction 是插入式节点,不删除老消息。需要时可以"反压缩"看到原文 |
| 配置变更留痕 | 模型切换、thinking level 变更、工具集变更都是 SessionTreeEntry。下次恢复时知道当时用的是什么 |
| 可文本编辑器查看 | JSONL 一行一条 cat 就能读。开发者和用户都能用熟悉的工具排查问题 |
| 存储后端可插拔 | SessionStorage 接口让 JSONL / 内存 / 远程存储都能用同一套代码 |
| 扩展可注入消息 | CustomMessageEntry 让扩展往 LLM 上下文里塞东西,不用改核心 |
7.2 缺点 / 取舍
缺点 | 说明 |
|---|---|
| 无并发写保护 | JSONL 追加写假设只有单一进程。多个 Agent 共享同一 session 会冲突 |
| 文件会无限增长 | 每条消息、每次配置变更都追加。没有"老条目归档"机制。Compaction 减少喂给 LLM 的内容,但 JSONL 文件本身仍然在变长 |
| 压缩会损失细粒度 | compaction 摘要由 LLM 生成,可能丢失关键细节。一旦压缩,老内容必须"反摘要"才能找回 |
| 不支持流式读 | JSONL 是文本格式,10MB 以上的 session 启动会比较慢。重启时要把整本读进来 |
| 没有内置索引 | 找"包含某关键字的消息"必须全量扫一遍 |
| 压缩时机需要手动或启发式 | 何时触发 compaction 由上层决定,Session 本身不主动判断 |
7.3 设计哲学总结
Pi Session 的核心哲学可以归纳为一句话:
"把对话和它发生的所有上下文都当成可追加的事件日志,而不只是聊天内容。"
这与传统的"聊天历史"概念有本质区别:传统的 IM 系统存的是消息内容,Pi Session 存的是"对话这台状态机的完整演化轨迹"。这种设计让"恢复"、"分支"、"压缩"都变成了"在树上操作"而不是"在聊天记录上操作",从而获得了前面列出的所有优点。
8. 与著名 Agent 实现的对比
这一节挑几个有代表性的 Agent 框架,看它们的"会话/状态"设计,对比 Pi 的方案。
8.1 对比表
实现 | 状态模型 | 持久化 | 分支 | 压缩 | 断电恢复 |
|---|---|---|---|---|---|
| Pi Session | JSONL 条目树 | 本地文件 | 原生支持 | 原生支持 | 原生支持 |
| LangGraph | StateGraph, 显式节点和边 | Checkpointer 抽象, 默认内存 | 通过多图分支 | 不内置 | 需要 Postgres 等后端 |
| OpenAI Assistants API | Thread + Message + Run | 服务端托管 | 不支持 | 服务端自动 | 服务端自动 |
| Anthropic Claude SDK | messages 数组, 客户端管理 | 无, 客户端自行实现 | 客户端实现 | 客户端实现 | 客户端实现 |
| AutoGPT / BabyAGI | 任务列表 + 记忆 | 本地文件 / 向量库 | 不支持 | 不内置 | 部分 |
| Mastra | 类似 LangGraph 的工作流 + Memory 抽象 | 可插拔存储后端 | 通过多工作流 | 通过 working memory | 通过 checkpointer |
8.2 几个有代表性的设计差异
(1) Pi vs OpenAI Assistants:客户端 vs 服务端
OpenAI Assistants 把 Thread 状态完全托管在服务端,你只需要调 API。好处是简单,坏处是你看不见状态、不能改格式、不能跑本地模型。Pi 反过来:状态全在客户端 JSONL 里,你拥有全部数据,可以换存储、换模型、换 UI。
(2) Pi vs LangGraph:事件日志 vs 状态机
LangGraph 的核心是"图"——开发者显式定义节点(函数)和边(条件),状态在节点之间流动。Pi 的核心是"事件日志"——没有显式定义流程,所有流程都从事件序列中浮现。LangGraph 适合"流程固定、状态复杂"的场景,Pi 适合"流程自由、事件丰富"的场景(尤其是 Coding Agent 这种"用户问什么就做什么"的场景)。
(3) Pi vs Anthropic SDK:自己实现 vs 自己实现
Anthropic 官方的 Python/TS SDK 把 messages 数组完全交给开发者自己管理,连持久化都没有。Pi 实际上是"把 Anthropic SDK 应该做但没做的事做了"——给你一个完整的、持久化的、可分支的会话层。两者是互补关系,Pi 在 SDK 之上又建了一层。
(4) Pi 的独特之处
Pi Session 在几个维度上有自己的特色:
- JSONL 条目树 + parentId 链表
:同时支持线性追加(最近路径)和树形分支(历史路径)
- 配置变更也是一类条目
:模型切换、thinking level 变更都被记到 Session 里,这是少有的设计
- compaction 不删除原文
:只插入摘要节点,原文物理保留
- 三种队列
:steer / followUp / nextTurn 覆盖了"打断当前轮"、"等本轮结束后开新轮"、"idle 时立即开新轮"三种典型场景