Skip to content

第八章:会话管理 -- 持久化与分支

"记忆是身份的基础。"


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 行: SessionEntry

SessionHeader

typescript
interface SessionHeader {
    type: "session";
    version: number;          // 当前版本 3
    id: string;               // 会话 UUID
    timestamp: number;        // 创建时间
    cwd: string;              // 工作目录
    parentSession?: string;   // 父会话路径(用于分叉)
}

SessionEntry -- 10 种条目类型

typescript
type SessionEntry =
    | SessionMessageEntry         // 普通消息
    | ThinkingLevelChangeEntry    // 推理级别变更
    | ModelChangeEntry            // 模型变更
    | CompactionEntry             // 上下文压缩
    | BranchSummaryEntry          // 分支摘要
    | CustomEntry                 // 扩展状态(不进入 LLM 上下文)
    | CustomMessageEntry          // 扩展消息(进入 LLM 上下文)
    | LabelEntry                  // 用户标签
    | SessionInfoEntry;           // 会话名称

每个条目都有共同的基础字段:

typescript
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 = HEADbranch() = git checkoutappendMessage() = git commit。每个条目是一个 commit,parentId 是 parent commit。 ─────────────────────────────────────────────────

8.4 SessionManager 的工厂方法

SessionManager 使用私有构造函数 + 静态工厂方法(Java 中常见的创建模式):

typescript
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

所有写操作都是追加的——永远不修改已有条目:

typescript
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 分支操作

创建分支

typescript
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:

typescript
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 有一个巧妙的优化——延迟写入:

typescript
_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 会话列表和恢复

typescript
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 (另一个分支)

关键设计决策

  1. Append-Only:永不修改,只追加
  2. 树形结构:通过 parentId 支持分支
  3. JSONL 格式:每行一个 JSON,便于流式写入
  4. 延迟写入:避免空会话文件

第七章:工具系统 | 第九章:上下文压缩 -- Compaction →

基于 MIT 许可证发布