上一篇我们了解了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 时立即开新轮"三种典型场景
9. 一图总结


44

被折叠的 条评论
为什么被折叠?



