乐于分享
好东西不私藏

AI Agnet的会话实现原理-Pi 的Session工作流程全解析(二)

AI Agnet的会话实现原理-Pi 的Session工作流程全解析(二)

作为龙虾(OpenClaw)早期的实现基座, 开源AI Agent项目“pi”,正被越来越多人所提及. 其优雅简洁的设计以及便捷的扩展能力让你可以轻松的基于它进行二次创作而快速地得到自己独有的Agent. 尤其在各路主流Coding Agent内置了各种臃肿上下文的情况下(比如在claude code输入一个hello则动辄携带上万token的提示词), 清爽简洁的pi则看起来别具一格.而从agent设计入门和借鉴的角度, pi也是再好不过的一个参考项目. 本系列主要解析pi核心模块的工作原理.

上一篇我们了解了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
一次完整的 runAgentLoop 调用的开始和结束
各 1 次(agent_end 会发 settled 事件,让 TUI 知道可以解锁输入框)
agent_end 带 messages: AgentMessage[],即这次 run 新产生的所有消息
TUI 知道"AI 这轮干完了,可以给我看结果 / 让我继续打字"了
turn_start / turn_end
一个 turn 的开始和结束。一个 turn = 一次 assistant 回复 + 它所触发的所有工具调用 + 所有 toolResult
可能 多次。如果 assistant 调用了工具,AgentLoop 会再开新 turn 把工具结果喂回去,直到 assistant 给出 stop
turn_end 带 message(本轮的 assistant 消息)+ toolResults(本轮产生的 toolResult 列表)
在 turn 边界批量 flush 配置变更(model_change 等);也是 prepareNextTurn 钩子的触发点,让 harness 决定下一轮要不要换模型/改 thinking
message_start / message_update* / message_end
单条消息(user / assistant / toolResult)的开始、更新(仅 assistant 流式期间)、结束
每个 turn 内至少 2 条(user + assistant),如有工具则更多
始终带 message: AgentMessagemessage_update 额外带 assistantMessageEvent(增量流式片段)
Session 落盘的最小单位
。message_end 一触发,那条消息就立刻 appendEntry 写进 JSONL

实际触发的时序(看 agent-loop.ts:109-198):

emit({ type: "agent_start" })           // 整个 loop 启动 1 次emit({ type: "turn_start" })            // 第一个 turn 开始emit({ type: "message_start", prompt }) // user promptemit({ 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
一次 prompt() 的完整执行
用户敲一次回车 / 扩展主动 prompt
几秒到几分钟,取决于工具调用
turn_start / turn_end
一次 assistant 回复 + 它的工具结果
工具调用会开新 turn(直到 assistant stop
一次 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. 一图总结