本文以 earendil-works/pi-mono 为样本,分析其会话存储设计。所引用的代码位于 packages/agent/src/harness/session/、packages/coding-agent/src/core/session-manager.ts。

一、为什么不是一条线性消息列表

大多数 chat 应用的会话存储是这样的:

{
    "id": "chat-123",
    "messages": [
        { "role": "user", "content": "..." },
        { "role": "assistant", "content": "..." },
        { "role": "user", "content": "..." },
        ...
    ]
}

够用,但有几个先天问题:

  1. 不能精确回放历史的某一节点。如果用户在第 5 条消息时反悔,想从那里再问一个不同的问题,要么覆盖第 5 条之后的所有内容,要么复制整个会话。
  2. 不能并行探索分支。给 LLM 同一个上下文问两个不同问题,结果应该并存比较,但线性结构里没办法表达。
  3. 崩溃恢复粒度大。整个文件需要事务写入,部分成功部分失败时难以处理。
  4. 多端编辑容易冲突。两个客户端同时往同一会话追加消息,简单 append 也能撞车。

pi 的解法:会话文件是一棵 append-only 的事件树。每条 entry 携带 id 和 parentId,逻辑上构成树;物理上仍然按时间顺序追加到同一个 .jsonl 文件。“当前活跃位置” 用一个独立的 leaf entry 显式标记。

下文从文件格式开始,逐步展开它的两种 fork 与上下文构建。


二、文件格式

2.1 路径与命名

~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
  • <encoded-cwd> 是把工作目录绝对路径编码后的子目录名(这样不同项目的会话天然隔离);
  • 每个会话一个 .jsonl 文件,不可变更名;
  • 文件名带时间戳便于排序,带 UUID 防止冲突。

2.2 行格式

文件每一行是一个 JSON 对象,第一行是 header,之后每行是一个 entry:

{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/abs/path/to/parent.jsonl"}
{"type":"model_change","id":"<8hex>","parentId":null,"timestamp":"...","provider":"...","modelId":"..."}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"user","content":"..."}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"assistant","content":[...],...}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"toolResult","toolCallId":"...","content":[...]}}
{"type":"compaction","id":"<8hex>","parentId":"<8hex>","timestamp":"...","summary":"...","firstKeptEntryId":"<8hex>","tokensBefore":N}
{"type":"label","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>","label":"v1 attempt"}
{"type":"leaf","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>"}
{"type":"session_info","id":"<8hex>","parentId":"<8hex>","timestamp":"...","name":"refactor auth flow"}

类型一览:

type用途
session文件 header(仅第一行)
message用户 / assistant / toolResult 消息
model_change切换模型记录
thinking_level_change切换 thinking 等级记录
compaction历史折叠摘要
branch_summary分支被放弃前的摘要
label给某个 entry 打的人类可读标签
leaf当前活跃叶子指针(可多次写)
session_info显示名等元信息
custom_message / custom扩展自定义消息 / 数据

每个 entry 必有四个字段:type、id(在文件内唯一)、parentId(指向上一条逻辑前驱)、timestamp(ISO string)。

2.3 树结构示例

下面这段对话产生的 entry 树(id 简化为字母):

[session header]

A: model_change (parentId: null)
B: message (parentId: A, role: user, "改一下登录")
C: message (parentId: B, role: assistant, "好的,先看一下文件")
D: message (parentId: C, role: assistant, toolCall: read auth.ts)
E: message (parentId: D, role: toolResult, ...)
F: message (parentId: E, role: assistant, "用 X 方案改")

leaf: { targetId: F }

物理上是 7 行 JSON。逻辑上是一条从 A 到 F 的线性链,leaf 指向 F 表示当前活跃位置。


三、append-only 与 leaf 指针

整个 session 文件只追加,不修改。这是 pi 设计中最强的不变量。

3.1 写入只走 appendFile

存储后端实现里只有 appendEntry 方法做写入:

// packages/agent/src/harness/session/jsonl-storage.ts
async appendEntry(entry: SessionTreeEntry): Promise<void> {
    getFileSystemResultOrThrow(
        await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
        ...
    );
    this.byId.set(entry.id, entry);
    updateLabelCache(this.labelsById, entry);
    this.currentLeafId = leafIdAfterEntry(entry);
}

没有 updateEntry、没有 deleteEntry。session 一旦写入就是历史。

3.2 leaf 是一种独立的 entry 类型

"当前我们在哪一条 message 上"这种状态如果存在内存里,崩溃后就丢了。pi 的做法是:leaf 也是一条持久化的 entry:

// packages/agent/src/harness/session/jsonl-storage.ts
async setLeafId(leafId: string | null): Promise<void> {
    if (leafId !== null && !this.byId.has(leafId)) {
        throw new SessionError("not_found", `Entry ${leafId} not found`);
    }
    const entry: LeafEntry = {
        type: "leaf",
        id: generateEntryId(this.byId),
        parentId: this.currentLeafId,
        timestamp: new Date().toISOString(),
        targetId: leafId,
    };
    getFileSystemResultOrThrow(
        await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
        `Failed to append session leaf ${entry.id}`,
    );
    this.entries.push(entry);
    this.byId.set(entry.id, entry);
    this.currentLeafId = leafId;
}

每次切换叶子(用户回退、navigate 到旧分支)都会 append 一个 leaf entry。重开文件时只需读到最后一个 leaf 就能恢复出"上次活跃在哪":

function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
    return entry.type === "leaf" ? entry.targetId : entry.id;
}

// loadJsonlStorage 加载时:
let leafId: string | null = null;
for (let i = 1; i < lines.length; i++) {
    const entry = parseEntryLine(lines[i]!, filePath, i + 1);
    entries.push(entry);
    leafId = leafIdAfterEntry(entry);   // ← 普通 entry 推进 leaf;leaf entry 显式跳转
}

普通消息 entry 写入会让 leaf 推进到自己;只有 leaf entry 是显式的"跳过去"。

3.3 上层包装:SessionManager

coding-agent 在 storage 之上加了一层 SessionManager,把 entry 创建的细节封住:

// packages/coding-agent/src/core/session-manager.ts
appendMessage(message: AgentMessage): string {
    const entry: MessageEntry = {
        type: "message",
        id: generateId(this.byId),
        parentId: this.leafId,                  // ← 当前 leaf 作为 parent
        timestamp: new Date().toISOString(),
        message,
    };
    this.fileEntries.push(entry);
    this.byId.set(entry.id, entry);
    this.leafId = entry.id;                     // ← 自动推进 leaf
    this._persist(entry);
    return entry.id;
}

appendCompaction<T>(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T): string {
    const entry: CompactionEntry = {
        type: "compaction",
        id: generateId(this.byId),
        parentId: this.leafId,
        timestamp: new Date().toISOString(),
        summary,
        firstKeptEntryId,
        tokensBefore,
        details,
    };
    // ... 同样 append
}

SessionManager 还提供 branch(branchFromId) 来切换 leaf 到一个旧 entry:

// packages/coding-agent/src/core/session-manager.ts
branch(branchFromId: string): void {
    if (!this.byId.has(branchFromId)) {
        throw new Error(`Entry ${branchFromId} not found`);
    }
    this.leafId = branchFromId;
}

切完 leaf 后下一次 appendMessage 写入的 entry,parentId 就是 branchFromId——这样多个孩子共享同一个 parentId,树就长出新分支了。


四、两种 fork:fork 与 navigateTree

pi 提供两种"分叉",底层数据结构相同,但物理位置不同,用法也不同。

4.1 fork:复制路径到新文件

SessionManager.createBranchedSession(leafId) 把从根到指定 leaf 的路径复制到一个新的 .jsonl 文件:

// packages/coding-agent/src/core/session-manager.ts
createBranchedSession(leafId: string): string | undefined {
    const path = this.getBranch(leafId);

    if (path.length === 0) {
        throw new Error(`Entry ${leafId} not found`);
    }

    // 创建新 session 文件,header 里 parentSession 指回原文件
    // 然后把 path 上的所有 entry 重新 append(id 不变)
    ...
}

效果:

原文件 a.jsonl                       新文件 b.jsonl
─────────────────                   ─────────────────
session header (parentSession: -)   session header (parentSession: a.jsonl)
A: model_change                     A: model_change   ← 复制
B: message (user)                   B: message (user) ← 复制
C: message (asst)                   C: message (asst) ← 复制
D: message (asst, toolCall)         leaf: { target: C }
E: toolResult                       
F: message (asst, "X 方案")
leaf: { target: F }

新文件从 C 之后是空的,可以独立追加完全不同的对话。两份文件完全独立,互不影响。

适用场景:

  • 用户在某个用户消息处点 “Fork from here”,想从这里发起一个完全独立的探索;
  • 以一个旧会话作为"模板"开新会话;
  • 给团队成员分享一段对话历史让他从某点继续。

UI 上通常表现为 sidebar 树里多出一个子会话节点(通过 header 里的 parentSession 字段反查得到)。

4.2 navigateTree:在同一文件里建多分支

AgentSession.navigateTree(targetId) 不复制文件,而是把 leaf 指针跳到 targetId,下次写入就会形成树的新分支:

单个文件,leaf 跳到 C 之后再 append M1:

[session header]
A: model_change
B: message (user)
C: message (asst)
D: message (asst, toolCall, parentId=C)    ← 旧分支
E: toolResult (parentId=D)
F: message (asst, parentId=E)
leaf: { target: F }
[这里调 navigateTree(C),写一个 leaf entry]
leaf: { target: C }
[现在再 prompt 一句,会 append 一个 message,parentId=C]
M1: message (user, parentId=C)             ← 新分支起点
M2: message (asst, parentId=M1)
leaf: { target: M2 }

文件里同时存在两个分支:C → D → E → F 和 C → M1 → M2,共享前面的 B → C。

适用场景:

  • 用户在对话里点回退到第 N 条消息,从那里换个问法;
  • 比较"同一个上下文下两种问法"的效果;
  • LLM 自己生成多个候选答案让用户挑(每个候选作为一个分支)。

UI 上通常表现为同一会话里的"分支选择器"(pi-web 里叫 BranchNavigator):用户能在分支间来回切,每次切都 append 一个 leaf entry 做持久化。

4.3 两种 fork 的对比

维度fork (createBranchedSession)navigateTree
物理位置新文件同文件
文件 header新文件 parentSession 反指原文件不变
是否复制 entry是(路径上的所有 entry 重写一份)否(只 append 新的 leaf 跳转)
元数据可继承性弱(独立会话)强(共享所有元数据)
sidebar 表现树里新增子节点同会话内分支选择器
适用完全独立的探索当前对话内"换个问法"

它们底层是同一套树结构,只是物理上的分割粒度不同。这是 pi 比许多框架更成熟的一处设计——大多数项目要么只支持一种、要么把两种概念混淆。


五、上下文构建:buildSessionContext

写入是 append-only 的,但喂给 LLM 的"对话历史"必须是线性的。buildSessionContext 把树降维成线:

// packages/agent/src/harness/session/session.ts
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
    let thinkingLevel = "off";
    let model: { provider: string; modelId: string } | null = null;
    let compaction: CompactionEntry | null = null;

    for (const entry of pathEntries) {
        if (entry.type === "thinking_level_change") {
            thinkingLevel = entry.thinkingLevel;
        } else if (entry.type === "model_change") {
            model = { provider: entry.provider, modelId: entry.modelId };
        } else if (entry.type === "message" && entry.message.role === "assistant") {
            model = { provider: entry.message.provider, modelId: entry.message.model };
        } else if (entry.type === "compaction") {
            compaction = entry;
        }
    }

    const messages: AgentMessage[] = [];
    const appendMessage = (entry: SessionTreeEntry) => {
        if (entry.type === "message") {
            messages.push(entry.message as AgentMessage);
        } else if (entry.type === "custom_message") {
            messages.push(createCustomMessage(...));
        } else if (entry.type === "branch_summary" && entry.summary) {
            messages.push(createBranchSummaryMessage(...));
        }
    };

    if (compaction) {
        // 用 summary 替换 compaction 之前的所有内容(保留 firstKeptEntryId 之后的)
        messages.push(createCompactionSummaryMessage(...));
        const compactionIdx = pathEntries.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
        let foundFirstKept = false;
        for (let i = 0; i < compactionIdx; i++) {
            const entry = pathEntries[i]!;
            if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
            if (foundFirstKept) appendMessage(entry);
        }
        for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
            appendMessage(pathEntries[i]!);
        }
    } else {
        for (const entry of pathEntries) {
            appendMessage(entry);
        }
    }

    return { messages, thinkingLevel, model };
}

输入是 pathEntries:从 leaf 沿 parentId 回溯到根的 entry 序列。处理三件事:

  1. 沿路径累计配置:thinkingLevel、model 用最后一次出现的为准(assistant message 也会更新 model,因为它记录了"这条消息实际用的什么模型");
  2. 跳过非消息类 entry:leaf、label、session_info、thinking_level_change、model_change 都不进入 messages;
  3. Compaction 折叠:如果路径上有 compaction entry,把它之前的内容用 summary 替换,但保留 firstKeptEntryId 之后的 entry(这是 compaction 算法决定要保留的近期上下文)。

注意这是纯函数——每次 turn 开始都会重算一次。pi 不缓存"对话历史",而是从 source of truth(session 文件)每次推导。这让任何对 session 的修改都能立刻反映到下一个 turn。

SessionManager 暴露的 buildSessionContext() 包了一层,从当前 leaf 开始:

buildSessionContext(): SessionContext {
    return buildSessionContext(this.getEntries(), this.leafId, this.byId);
}

getBranch(fromId?: string): SessionEntry[] {
    const path: SessionEntry[] = [];
    const startId = fromId ?? this.leafId;
    let current = startId ? this.byId.get(startId) : undefined;
    while (current) {
        path.unshift(current);
        current = current.parentId ? this.byId.get(current.parentId) : undefined;
    }
    return path;
}

getBranch 沿 parentId 回溯,得到从根到 leaf 的线性路径。即使文件里有多个分支共存,只有当前 leaf 所在的那一条会被读出来。


六、和"线性消息列表"的对比总结

能力线性 messagespi JSONL 树
写入整文件覆盖或 appendappend-only
回退到旧消息截断后续 / 复制全部写一条 leaf entry
同一上下文多分支不支持navigateTree 原生支持
跨会话分支复制整个文件fork (createBranchedSession)
历史折叠重写文件写一条 compaction entry
崩溃恢复整文件事务行级,最后一行损坏只丢一个 entry
多端编辑易冲突append 天然顺序,但仍需 leader
元数据(model 切换、label)混在 message 里独立 entry 类型

代价:

  • 文件读取要解析整棵树,复杂度比线性高(pi 的实现是把 entry 全部读进 Map<id, entry> 一次构建);
  • 历史 entry 不能删除(pi 通过 compaction 来"逻辑删除",把折叠的内容用 summary 替换);
  • 文件会持续增长,需要 compaction + 必要时手工归档。

收益:

  • 任意一条 entry 都能稳定引用(id 永久有效);
  • 任何"修改 session"的操作都不会破坏历史;
  • 重开文件能精确恢复到上次活跃位置;
  • 分支不需要新文件,单文件能表达完整对话探索过程。

七、设计中的几个关键不变量

  1. session 文件 append-only。任何代码不允许 truncate 或 update 已有行(需要重写整个文件的场景,比如 cascade reparent,pi 通过完全重写新文件来实现,原文件保持只读)。

  2. leaf 由 entry 承载。内存里的 leafId 字段只是缓存,source of truth 是文件里最后一个 leaf entry(或最后一条普通 entry)。

  3. id 在文件内唯一。generateEntryId(byId) 在每次 append 前确保 id 不重复,跨文件可以重复。

  4. parentId 可以为 null。表示这是路径的根(通常是第一条 model_change 或 message)。

  5. compaction entry 替换历史,不删除。LLM 看到的是 summary,但原始 entry 仍在文件里——如果将来需要,可以读取原始内容做审计或重新 compact。

  6. buildSessionContext 是纯函数。从 entry 数组到 messages 数组的转换不依赖任何外部状态,意味着任何对 session 的改动都能在下个 turn 立即生效。


八、可借鉴的工程要点

如果你正在做需要"历史回放"或"分支探索"的对话系统,从 pi 这套设计里能直接搬走的几条:

  1. 每条记录配 id + parentId。即使一开始只用线性,后续要加分支也只是切 leaf。

  2. 当前位置作为独立 entry 持久化。运行时缓存可以丢,文件里的 leaf entry 不能丢。

  3. "复制文件"和"同文件分支"分别提供 API,因为它们是两种用户意图。混用会让 UI 设计变得困难。

  4. 元数据用独立 entry 类型(model 切换、label、session_info),不要塞 message 字段。这让文件的语义层级更清晰。

  5. 历史折叠用 entry 而不是重写。compaction entry 是逻辑层的"折叠",物理层文件不变,给审计、回滚、二次处理留了空间。

  6. buildContext 永远从 source 重算。不要做"增量维护一个内存对话历史"的优化,每次 turn 重算才能保证多分支切换 / compaction / extension 写入都立即生效。


写在最后

把会话历史从"线性数组"升级成"append-only 事件树",看上去只是数据结构的小调整,落到产品上是一系列能力解锁:精确回放、并行探索、历史折叠、崩溃恢复、跨会话 fork。这些能力 ChatGPT 与 Claude 在产品层都做了,但在公开的开源项目里,pi 是少有的把这套数据结构清晰暴露出来的样本。

更难得的是它的实现非常克制——核心代码不到一千行,没有引入任何额外的数据库、没有索引、没有事务系统,只用 appendFile 加几个 entry 类型就把所有能力承载起来。这是一种"用约束换能力"的工程哲学:越是对写入做严格约束(append-only),就越能稳定地暴露丰富的读取语义(历史回放、分支、折叠)。

仓库地址:https://github.com/earendil-works/pi-mono
关键文件:

  • packages/agent/src/harness/session/jsonl-storage.ts
  • packages/agent/src/harness/session/session.ts
  • packages/coding-agent/src/core/session-manager.ts
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐