第八章:会话管理 -- 持久化与分支
"记忆是身份的基础。"
8.1 为什么需要会话管理?
Agent 的对话不是一次性的。你可能:
- 中途关闭终端,第二天继续
- 想回到之前的某个决策点,尝试不同的方向
- 在不同项目之间切换
Pi 的会话管理系统解决了所有这些问题。它把对话历史持久化为 JSONL 文件,并支持树形分支——你可以在任意点"分叉",尝试不同方向。
📌 源码定位 ─────────────────────────────────────
packages/coding-agent/src/core/session-manager.ts-- 会话管理器- 会话文件存储在
~/.pi/agent/sessions/目录下─────────────────────────────────────────────────
8.2 JSONL 文件格式
Pi 使用 JSONL(JSON Lines)格式存储会话——每行一个 JSON 对象。
文件结构:
第 1 行: SessionHeader ← 会话元数据
第 2 行: SessionEntry ← 消息或其他事件
第 3 行: SessionEntry
...
第 N 行: SessionEntrySessionHeader
interface SessionHeader {
type: "session";
version: number; // 当前版本 3
id: string; // 会话 UUID
timestamp: number; // 创建时间
cwd: string; // 工作目录
parentSession?: string; // 父会话路径(用于分叉)
}SessionEntry -- 10 种条目类型
type SessionEntry =
| SessionMessageEntry // 普通消息
| ThinkingLevelChangeEntry // 推理级别变更
| ModelChangeEntry // 模型变更
| CompactionEntry // 上下文压缩
| BranchSummaryEntry // 分支摘要
| CustomEntry // 扩展状态(不进入 LLM 上下文)
| CustomMessageEntry // 扩展消息(进入 LLM 上下文)
| LabelEntry // 用户标签
| SessionInfoEntry; // 会话名称每个条目都有共同的基础字段:
interface SessionEntryBase {
type: string; // 条目类型
id: string; // 唯一 ID
parentId: string; // 父条目 ID(形成树结构)
timestamp: number; // 创建时间
}★ Insight ─────────────────────────────────────parentId 是树结构的关键。 每个条目都指向它的父条目,形成一棵树。默认情况下,新条目是当前叶子节点的子节点。但当你"分支"时,新条目会挂到历史中的某个节点下面,形成一个新的分支。这就是 Git 的 commit DAG 的简化版本。 ─────────────────────────────────────────────────
8.3 树形结构可视化
session.jsonl 内部结构:
[Header] (root)
│
├── [Message: "读一下 package.json"] (id: a1, parentId: root)
│ │
│ └── [Message: "我来读..."] (id: a2, parentId: a1)
│ │
│ └── [ToolResult: read] (id: a3, parentId: a2)
│ │
│ ├── [Message: "这个文件是..."] (id: a4, parentId: a3)
│ │ │
│ │ └── [Message: "帮我改一下"] (id: a5, parentId: a4)
│ │ │
│ │ └── [Message: "好的,我来修改..."] (id: a6)
│ │
│ └── [Branch: "试试另一种方案"] (id: b1, parentId: a3) ← 分支点!
│ │
│ └── [Message: "让我用另一种方法..."] (id: b2, parentId: b1)leafId 指向当前活跃分支的最后一个条目。切换分支就是移动 leafId。
⚡ Java 对照 ───────────────────────────────────── 这和 Git 的引用系统非常类似:leafId = HEAD,branch() = git checkout,appendMessage() = git commit。每个条目是一个 commit,parentId 是 parent commit。 ─────────────────────────────────────────────────
8.4 SessionManager 的工厂方法
SessionManager 使用私有构造函数 + 静态工厂方法(Java 中常见的创建模式):
class SessionManager {
private constructor(...) { ... }
// 创建新会话
static create(cwd: string, sessionDir?: string): SessionManager;
// 打开已有会话
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager;
// 继续最近的会话
static continueRecent(cwd: string, sessionDir?: string): SessionManager;
// 内存会话(不持久化,用于测试)
static inMemory(cwd?: string): SessionManager;
// 从其他项目分叉
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager;
}⚡ Java 对照 ───────────────────────────────────── 这就是 Java 中的静态工厂方法模式(List.of(), Map.of() 等)。私有构造函数防止外部直接 new,静态方法提供了有意义的创建语义。比 Java 的 Builder 模式更简洁。 ─────────────────────────────────────────────────
8.5 追加操作:Append-Only
所有写操作都是追加的——永远不修改已有条目:
class SessionManager {
// 追加消息
appendMessage(message: AgentMessage): void;
// 追加推理级别变更
appendThinkingLevelChange(level: ThinkingLevel): void;
// 追加模型变更
appendModelChange(provider: string, modelId: string): void;
// 追加压缩条目
appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): void;
// 追加分支摘要
appendBranchSummary(branchFromId: string, summary: string): void;
// 追加自定义条目
appendCustomEntry<T>(customType: string, data?: T): void;
}★ Insight ─────────────────────────────────────Append-Only 是最安全的持久化策略。 你永远不需要担心"写了一半崩溃导致数据损坏"——因为旧数据从来不会被修改。如果写入中断,最坏情况是丢掉最后一条记录,但之前的记录完好无损。这和 Event Sourcing(事件溯源)模式是同一个思想。Git 的 object database 也是 append-only 的。 ─────────────────────────────────────────────────
8.6 分支操作
创建分支
class SessionManager {
// 移动 leafId 到指定条目(下次 append 会成为该条目的子节点)
branch(branchFromId: string): void;
// 创建分支并附带摘要
branchWithSummary(branchFromId: string, summary: string, ...): void;
// 将一个分支提取为独立的会话文件
createBranchedSession(leafId: string): SessionManager;
}分支流程:
1. 当前状态:leafId 指向 a6
2. 调用 branch(a3):leafId 移动到 a3
3. appendMessage("新方案"):创建条目 b1,parentId = a3
4. 现在有两条路径:
路径 1: root → a1 → a2 → a3 → a4 → a5 → a6
路径 2: root → a1 → a2 → a3 → b1 (当前)构建会话上下文
当需要把会话历史发送给 LLM 时,需要从 leafId 回溯到 root:
buildSessionContext(): SessionContext {
// 1. 从 leafId 向 root 回溯,收集路径
const path = this.getBranch(); // [root, ..., leafId]
// 2. 提取推理级别和模型设置
let thinkingLevel = "off";
let model = defaultModel;
for (const entry of path) {
if (entry.type === "thinking_level_change") thinkingLevel = entry.thinkingLevel;
if (entry.type === "model_change") model = resolveModel(entry);
}
// 3. 构建消息列表(处理压缩)
const messages = [];
for (const entry of path) {
if (entry.type === "compaction") {
// 先添加压缩摘要
messages.push(createCompactionSummary(entry.summary));
// 然后只添加 firstKeptEntryId 之后的消息
// ...
}
if (entry.type === "message") {
messages.push(entry.message);
}
}
return { messages, thinkingLevel, model };
}★ Insight ─────────────────────────────────────buildSessionContext() 是会话恢复的核心。 它做的本质上是从 Git 历史中"checkout"出一条路径——从 root 到 leafId 的所有条目,按顺序组装成 LLM 能理解的消息数组。压缩条目就像一个"squashed commit"——用一个摘要替代了大量原始消息。 ─────────────────────────────────────────────────
8.7 延迟写入
SessionManager 有一个巧妙的优化——延迟写入:
_persist(entry: SessionEntry): void {
// 第一条消息不立即写入磁盘
// 等到第一条 assistant 消息到达时才开始持久化
if (!this._startedWriting) {
if (entry.type === "message" && entry.message.role === "assistant") {
this._startedWriting = true;
// 写入 header + 之前缓存的条目 + 当前条目
} else {
this._pendingEntries.push(entry);
return;
}
}
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
}★ Insight ─────────────────────────────────────为什么要延迟写入? 因为用户可能输入一条消息后立刻按 Ctrl+C 取消。如果立即写入,磁盘上会留下一个只有 UserMessage 的空会话。延迟写入确保只有真正产生了 AI 回复的会话才会被持久化,避免了"僵尸会话"文件的积累。 ─────────────────────────────────────────────────
8.8 会话列表和恢复
class SessionManager {
// 列出指定项目的所有会话
static list(cwd: string, sessionDir?: string): SessionInfo[];
// 列出所有项目的会话
static listAll(): SessionInfo[];
}
interface SessionInfo {
path: string; // 文件路径
id: string; // 会话 ID
cwd: string; // 工作目录
name?: string; // 用户设置的名称
created: number; // 创建时间
modified: number; // 最后修改时间
messageCount: number; // 消息数量
firstMessage?: string; // 第一条消息摘要
allMessagesText: string; // 所有消息文本(用于搜索)
}8.9 本章小结
会话文件 (session.jsonl)
┌─────────────────────────────────────────────┐
│ Header: { type: "session", id, cwd, ... } │
├─────────────────────────────────────────────┤
│ Entry: { type: "message", id, parentId, ...}│ ← append-only
│ Entry: { type: "message", id, parentId, ...}│
│ Entry: { type: "compaction", summary, ... } │ ← 压缩点
│ Entry: { type: "message", id, parentId, ...}│
│ Entry: { type: "message", id, parentId, ...}│
│ ... │
└─────────────────────────────────────────────┘
↑
│
leafId 指向当前活跃的最后一个条目
树形结构:
root → msg1 → msg2 → msg3 → msg4 (leaf)
↘
msg5 (另一个分支)关键设计决策:
- Append-Only:永不修改,只追加
- 树形结构:通过
parentId支持分支 - JSONL 格式:每行一个 JSON,便于流式写入
- 延迟写入:避免空会话文件