Agentpath
原理10 / 31 节 · 预计 30 分钟

会话状态:为什么 Pi 把历史存成一棵树

线性日志只能后悔,树可以回到任意一步重新走。这个选择直接改变了使用方式。

学完这节你能做到

  • 解释树状会话与线性日志的差别,以及 fork 解决了什么实际问题
  • 用 `/tree` 回到指定节点重开一条分支
  • 设计自己应用里的会话持久化格式

大多数 agent 把会话存成一条线性日志:一问一答一路往下追加。Pi 存成一棵树。 这个选择不是炫技 —— 它改变了你使用 agent 的方式。

线性日志的问题

你和 agent 折腾了四十分钟,走到第 18 轮发现:第 6 轮那个决定错了, 后面全都建立在错的前提上。线性日志给你两个选择:

  • 继续往下说「刚才第 6 步做错了,重来」—— 错误的上下文还在,模型会被它拖着走, 而且这些 token 你要一直付到会话结束。
  • 全部重开 —— 前 5 轮辛苦建立的上下文一起没了。

两个都不好。真正想要的是「回到第 6 轮之前,换个方向重走」。

树是怎么存的

Pi 的会话文件是 JSONL,每行一个 entry,靠 id / parentId 串成树:

entry-1 (user: 帮我重构这个模块)
  └ entry-2 (assistant + tool calls)
      └ entry-3 (user: 用策略模式吧)
      │   └ entry-4 ... entry-9      ← 走岔的那条
      └ entry-10 (user: 算了,用组合)  ← fork 出来的新分支
          └ entry-11 ...

会话文件在 ~/.pi/agent/sessions/,按工作目录分组。一整棵树在一个文件里 —— 分支不会散成一堆文件,这是它比「复制一份重开」高明的地方。

SDK 里的接口把这个结构摆得很清楚:

const entries = sm.getEntries()        // 全部 entry
const tree    = sm.getTree()           // 完整树结构
const path    = sm.getPath()           // 从根到当前叶子的路径
sm.branch(entryId)                     // 把叶子移到更早的 entry
sm.createBranchedSession(leafId)       // 把某条路径抽成新文件
sm.appendLabelChange(id, "checkpoint") // 打标签

注意 buildContextEntries()getBranch() 的区别:后者是当前分支的全部 entry, 前者是应用了 compaction 之后真正会发给模型的那些。写扩展时经常搞混这两个。

几个命令的实际用法

命令做什么什么时候用
/tree浏览整棵树,跳到任意节点「回到那个决定之前」
/fork从更早的用户消息开一条新分支换个方向重走
/clone把当前分支复制成新会话文件想留一份干净的基线
/compact压缩早期历史窗口紧了
/export/share导出 HTML / 推到 gist给人看、留证据
/name、标签给会话和节点命名长会话里找路

/fork 有两种落点:默认 position: "before" 会把那条用户消息恢复到编辑器里 让你改了再发;"at" 则是原样复制那条路径。前者是「改问法重问」,后者是「从这里岔开」。

/tree 走开时它会问你要不要总结

Pi 有个很实用的设计:从一条分支跳走时,可以把「被放弃的那条分支干了什么」 总结一段,注入到目标分支里。这样试错的收获不会白丢 —— 你在 A 路撞的墙,B 路能知道。这叫 branch summarization, 存成 BranchSummaryEntry

什么该进会话,什么不该

Pi 分了两个口子,这个划分值得抄:

pi.sendMessage(msg)          // 进 LLM 上下文(也持久化)
pi.appendEntry(type, data)   // 只持久化,不进上下文

appendEntry 用来记「审计信息」:某次权限确认的结果、某个扩展的内部状态、 一次外部 API 调用的元数据。它们对复盘有价值,但没必要占模型的窗口。

!有状态的扩展要能从会话重建

Pi 文档给的做法是:把状态写进工具结果的 details,然后在 session_start 时 遍历分支重建。原因就是树 —— 用户 fork 到另一条分支后,你的内存状态是那条路上的, 必须能按当前分支重算,否则状态和会话就分叉了。

自己应用里的会话该怎么存

如果你在做嵌入式集成(L3 的 SDK 那节),照抄这几条就够:

  1. 一棵树一个文件,追加写。JSONL 天生适合追加,崩了也只丢最后一行。
  2. entry 要自包含:idparentIdtimestamp、类型、载荷。 别把「第几轮」这种位置信息编码进去 —— fork 之后就错了。
  3. 区分「进上下文」和「只存档」,两类 entry 分开。
  4. 压缩记录也是 entry(Pi 的 CompactionEntrysummary + firstKeptEntryId), 这样重建上下文时只按 entry 走,不需要额外状态。
检查点单选

会话到第 18 轮,你发现第 6 轮的技术选型选错了。用树状会话最合适的动作是?

这一节的结论

  1. 线性日志只能后悔,树可以回到任意一步重走 —— 一整棵树存在一个文件里。
  2. /tree + /fork 是长会话的主要工具;走开时的分支总结让试错不白费。
  3. 区分「进上下文」与「只存档」两类 entry。
  4. 有状态的扩展必须能从当前分支重建状态,因为用户随时可能 fork。

延伸资料