Pi 的 Session 文件表面上非常简单:一个 JSONL 文件,每一行是一条 JSON 记录。
它没有把一段会话直接存成 messages[],也没有为每个分支创建独立数据库表。当前版本的 Session Entry 通过 id / parentId 连接成树,文件继续按追加顺序增长,当前 leaf 决定当前所在的历史路径。[1]
因此同一份 Session 同时具有两种结构:
物理结构
append-only JSONL
逻辑结构
entry tree
这两个结构的分离,是理解 Pi Session 设计的关键。
先看文件本身
Session 文件第一行是 Header:
{
"type": "session",
"version": 3,
"id": "...",
"timestamp": "...",
"cwd": "/project"
}
后续 Entry 才进入树结构。基础字段接近:
interface SessionEntryBase {
type: string
id: string
parentId: string | null
timestamp: string
}
例如一次普通对话可能写成:
line 1 session header
line 2 user message id=A parent=null
line 3 assistant message id=B parent=A
line 4 user message id=C parent=B
line 5 assistant message id=D parent=C
从磁盘上看,它仍然只是顺序追加:
A
B
C
D
但 parentId 已经给这些记录增加了逻辑关系:
A
└── B
└── C
└── D
如果用户随后回到 B,再从那里继续输入,新的 Entry 继续追加到文件尾部:
line 6 user message id=E parent=B
line 7 assistant message id=F parent=E
物理顺序变成:
A B C D E F
逻辑结构则变成:
A
└── B
├── C
│ └── D
│
└── E
└── F ← current leaf
文件不需要回写 C、D,也不需要复制 A、B。创建分支只需要让新的 Entry 指向旧节点。
这是一种很适合本地 Coding Agent 的持久模型:追加写简单,历史仍然可检查,同时允许用户在同一个 Session 中回到旧位置继续工作。
当前 Session 由 leaf 决定
树本身包含多个分支,但模型不能一次把所有分支都当成当前历史。
所以 SessionManager 还需要一个“当前位置”。Pi 文档把它称为当前 leaf。构造当前路径时,从 leaf 沿 parentId 一直回到 root,再反转顺序即可。[1]
概念代码接近:
function getPath(leaf: SessionEntry | null): SessionEntry[] {
const result: SessionEntry[] = []
let current = leaf
while (current) {
result.push(current)
current = current.parentId
? byId.get(current.parentId) ?? null
: null
}
return result.reverse()
}
对于前面的树,如果 leaf 是 F:
A
└── B
├── C
│ └── D
└── E
└── F
当前 path 是:
A → B → E → F
C、D 仍然保存在 Session 文件里,但不会进入当前分支的模型历史。
这说明 Session File 和 Model Context 从一开始就不是同一个对象:
Session File
A B C D E F
Current Branch
A B E F
Model Context
由 A B E F 再经过 Compaction / Message conversion 构造
后续很多能力都建立在这三层区别上。
/tree 的本质是移动当前位置
Pi 支持在 Session Tree 中导航。用户选择较早的 Entry 后,可以从那里继续工作。
从数据结构看,这个动作并不需要修改旧 Entry。SessionManager 只需要把后续 append 的 parent 切换到目标节点。
例如原路径是:
A → B → C → D
↑
original path
回到 B 后继续:
A → B → C → D
\
E → F
这种设计和 Git 的提交图有一定相似性:节点保存父引用,当前 leaf 类似当前工作位置。但 Pi 的 Entry 语义、Context 构造和 Branch Summary 都是 Agent 会话特有的,不能直接套用 Git 的全部模型。
更重要的是,树结构让“保留旧历史”和“改变当前 Context”可以同时成立。
如果系统使用普通数组:
messages.splice(branchIndex + 1)
用户回到旧节点时,后续历史会被直接删除。
Session Tree 则保留旧分支,新的输入只是增加另一条路径。
这对于 Coding Agent 很有价值。用户可能尝试方案 A,发现不合适后回到早期状态尝试方案 B。之前模型的分析和执行记录仍可保留,而当前 Context 可以只沿 B 分支继续。
Branch Summary 解决离开分支后的信息损失
完全切换分支会带来一个问题:旧分支上可能已经产生有价值的信息。
例如:
A → B → C → D
D 中发现:
某个接口不能修改,因为另一个模块依赖它
如果用户回到 B,再从 B 创建 E,当前 path 变成:
A → B → E
D 不再属于当前路径,那条有价值的信息也不会自然进入后续 Context。
Pi 因此支持 BranchSummaryEntry。当树导航需要保留离开分支中的信息时,可以生成一段摘要并把它作为新路径上的 Entry。[1]
结构大致是:
A
└── B
├── C
│ └── D
│
└── BranchSummary
└── E
Branch Summary 表达的是:
旧分支虽然不再是当前 path,
其中一部分有价值的上下文被摘要带入新分支。
它与直接把 D 留在 Context 中不同。新分支并没有继承旧分支的完整模型轨迹,只继承经过压缩后的必要信息。
这里可以看到 Pi Session Tree 的一个重要特征:树决定历史选择,Summary 负责跨路径传递必要信息。
Compaction 同样通过 Entry 改变 Context
长会话需要压缩早期历史。Pi 没有删除原始 Entry,而是追加 CompactionEntry。[2]
当前结构包含:
interface CompactionEntry {
type: 'compaction'
id: string
parentId: string
summary: string
firstKeptEntryId: string
tokensBefore: number
// ...
}
假设当前分支有:
A B C D E F G H
Context 太长以后,系统可能生成 Compaction:
summary(A...E)
firstKeptEntryId = F
Session 中仍然保存:
A B C D E F G H Compaction
但 buildSessionContext() 构造模型历史时会使用:
Summary(A...E)
F
G
H
这再次体现同一个设计:持久历史保持完整,模型视图可以压缩。
Pi 的 Compaction 文档还专门规定切点不能随意落在 Tool Result 上,因为 Tool Result 必须与对应的 Tool Call 保持结构完整。[2]
这说明 Context Builder 不能只考虑 Token 数量,还必须满足 Provider Message 的结构约束。
例如下面的历史不能被错误切成:
Assistant(tool_call)
--- cut ---
ToolResult
因为模型协议通常要求 Tool Call 和 Tool Result 保持合法配对。
因此 Compaction 实际上同时处理两个问题:
Token budget
+
Message protocol invariants
buildSessionContext() 才是真正的恢复入口
Session 文件被重新打开以后,运行时并不会简单执行:
agent.messages = allSessionEntries
Pi 的 SessionManager.buildSessionContext() 会从当前 leaf 构造 path,再解释路径中的 Entry。[1]
文档给出的流程包括:
- 收集 current leaf 到 root 的路径;
- 恢复路径上的 model / thinking level 等设置;
- 如果存在 Compaction,使用 Summary 和 firstKeptEntryId 重建消息;
- 将 Branch Summary、Custom Message 等 Entry 转成对应消息;
- 形成当前模型会话需要的 Message History。
SDK 创建 Agent Session 时也会先调用 sessionManager.buildSessionContext() 判断是否存在旧会话,并据此恢复已有消息和模型相关状态。[3]
所以恢复链路更接近:
session.jsonl
↓ parse
Session Entries
↓ build indexes / current path
SessionManager
↓ buildSessionContext()
Current Session Context
↓
Agent State
↓
Agent Loop
这里的 SessionManager 承担了一个明显的 Projection 角色:磁盘中的 Entry Tree 是持久事实,Agent Runtime 需要的是当前分支对应的运行状态。
为什么一个 Session 文件可以保存整棵树
很多系统会把 Branch 直接建模成新的 Session:
Session A
↓ fork
Session B
Pi 选择在同一文件中保留 Tree,有明显的产品背景。
Coding Agent 的用户经常需要:
查看历史节点
回到某个节点
尝试另一个方案
再回到原分支
这些操作如果每次都创建一个新的 Session,会产生大量会话文件,用户也需要管理多个 Session 身份。
Tree 模型把这些路径视为同一段工作历史的不同路线:
Session
└── Tree
├── branch A
├── branch B
└── branch C
当用户确实需要把某个分支独立出去时,再使用 fork / branched session 创建新的 Session 文件。Pi 的 Session Header 也提供 parentSession 字段记录这种 Session 间关系。[1]
因此这里存在两层分支:
同一个 Session 内
Entry Tree Branch
跨 Session
Fork / parentSession
把它们区分开,能够避免把“历史导航”和“创建新的长期工作单元”混成同一种操作。
Tree 模型的代价
Session Tree 很轻量,但它也增加了几个约束。
第一,所有 Context 构造都必须先确定当前 path。不能再假设“文件中最后 N 条消息就是当前历史”。
第二,Entry 的 parent 链必须保持完整。一条损坏的 parentId 会直接影响当前分支恢复。
第三,Tool Call、Tool Result 等具有协议配对要求的节点不能被任意当成 Branch Boundary。树结构允许回到任何节点,并不意味着所有节点都适合作为合法模型历史的终点。
第四,跨分支信息需要显式处理。Branch Summary 就是为了避免有价值的旧分支信息完全丢失。
第五,Session Entry 的语义会逐渐变宽。除了 Message,还有 Compaction、Branch Summary、设置变化等类型。SessionManager 必须理解这些 Entry 如何影响当前 Context。
这和完整 Event Sourcing 仍然有本质差异。Pi 的 Entry 主要围绕“如何恢复 Coding Agent 会话和模型 Context”组织,没有试图记录运行中的每一个 Step、Chunk、Tool 生命周期事件。
Pi Session 更接近“可分支的持久历史”
可以把 Pi 的 Session 模型压缩成下面四层:
JSONL
物理追加存储
↓
Entry Tree
保存完整可分支历史
↓
Current Path
选择当前分支
↓
buildSessionContext()
构造当前模型需要的历史
Tree 和 Compaction 都没有直接重写过去。
Branch 改变当前路径,Compaction 改变当前 Context 的解释方式,旧 Entry 仍然保留。
这让 Pi 获得了几个非常实用的性质:
文件格式简单
历史可人工检查
分支不需要复制前缀
Compaction 不破坏原始历史
恢复逻辑集中在 SessionManager
它也明确限制了 Session 的职责范围:Pi Session 主要保存能够重建会话语义的 Entry,而 Runtime 级的完整执行轨迹并没有全部进入这棵树。
下一篇的 DeepSeek Harness 选择了另一种边界。它把 Turn、Step、Raw Stream Chunk、Tool Call、Tool Result、Request Header 都放进 append-only SessionEvent Log,再通过 Surface 投影出模型历史。这样一来,Session 不再只承担“当前会话怎么恢复”,还成为 Replay、UI、Persistence 和请求重建的统一事实源。
参考资料
[1] Pi Session File Format: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] Pi Compaction: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md
[3] Pi SDK Session Restore: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] Pi SDK Session API: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md