小岛AIAI 航行日志

小岛 / 岛刊社 / 文章

agent 改坏了五个文件想回退?pi 把会话存成了一棵树

小岛AI

聊天记录一定是一条直线吗?开源的 pi 只靠每条记录多存一个 parentId,就让会话能随时分叉、随时回到过去,旧路一个字不动。附 JSONL 格式、分支摘要和上下文拼装的源码拆解。

Agent 工程开源

聊天记录不就是一条从上往下的列表吗?

你一句,它一句,从头排到尾。ChatGPT 是这样,微信也是这样,好像天经地义,会话还能长成别的形状?

在 pi 里,还真不是一条线,是一棵树。

这是「Pi系列」的第四篇。前面拆了主循环、上下文压缩和工具调用,今天拆 agent 记性的另一面,它怎么存下你们聊过的每一句,又怎么让你反悔。

和聊天机器人说话,是一条直线

直线为什么不够用

和聊天机器人闲聊,直线没问题,一问一答,聊完就散。

写代码不一样。你让 agent 重构一个模块,它一口气改了五个文件,跑测试,挂了一片。你想退回去,换个思路重来。或者聊到第二十轮,你才意识到第五轮定的方案就走错了。

在一条直线的聊天记录里,这两件事都很难办。要么删掉后面的记录,之前试错的过程就没了。要么硬着头皮接着聊,错误的上下文一直留在模型眼前,时不时把它往回拽。

好家伙,这种时候最想要的,就是一个存档点。

秘诀只有一个字段

pi 的做法简单到有点反常。会话里的每一条记录,都多存一个字段,记着自己的上一条是谁,叫 parentId。

// 摘自 pi v0.66.0 packages/coding-agent/src/core/session-manager.ts
export interface SessionEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

id 是一个 8 位的短 ID,从 UUID 里截出来,还做了防撞检查。第一条记录的 parentId 是 null,就是树根。之后每条都指向它的上一条。

一条记录下面只挂一条,那就还是直线。同一条记录下面挂了两条,就是一个分叉。

想回到第三轮重来?把「当前位置」挪回第三轮那条记录,下一条新消息自然就挂在它下面了。源码里这个操作叫 branch,短得惊人

// 摘自同一文件,SessionManager.branch
branch(branchFromId: string): void {
  if (!this.byId.has(branchFromId)) {
    throw new Error(`Entry ${branchFromId} not found`);
  }
  this.leafId = branchFromId;
}

就是改了一个指针。源码注释里写得清楚,已有的记录不修改,也不删除。旧路一个字都不用动,它只是不再是你脚下这条路了。

同一条记录下面长出两条,就是一个分叉

存下来,是一个只追加的文件

这棵树落到磁盘上,是一个 JSONL 文件,放在 ~/.pi/agent/sessions/ 下面,按项目路径分目录。JSONL 就是一行一个 JSON 对象,照官方文档的样例简化一下,大概长这样

{"type":"message","id":"a1b2c3d4","parentId":"prev1234","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","message":{"role":"assistant", ...}}
{"type":"model_change","id":"d4e5f6a7","parentId":"b2c3d4e5","provider":"openai","modelId":"gpt-4o"}

平时每产生一条新记录,pi 就往文件末尾追加一行。分个叉,也只是多写几行,而且新写的那几行,parentId 指回了前面某一条。

一行一条,只往后追加

如果你装了 pi,可以直接去那个目录翻翻自己的会话。用 jq 把每行的三个字段拎出来,树的骨架就看得一清二楚

jq -c '{type, id, parentId}' ~/.pi/agent/sessions/<项目目录>/<会话文件>.jsonl

树上的叶子,一共九种

这个文件里不只有聊天消息。源码里的 SessionEntry 一共九种类型,我按用途分成三层来看。

第一层是真正会喂给模型的。对话消息 message,第二篇讲过的压缩摘要 compaction,还有分支摘要 branch_summary,这个后面细说。

第二层只记变化。换了模型 model_change,调了思考档位 thinking_level_change,改了会话名 session_info。注意它记的是事件,而不是当前状态。想知道现在用的是哪个模型?从根往下走一遍,最后一次换成谁就是谁。

第三层留给扩展和你自己。扩展的私人笔记 custom,模型看不见。扩展想塞给模型的话 custom_message,会进上下文。还有你给某条记录打的书签 label。

九种叶子,分三层

调模型时,只走一条路

一棵树有好多枝杈,每次问模型,总不能全塞进去吧。

pi 的做法是,从你当前站着的那片叶子出发,顺着 parentId 一路往回找,直到树根,把这条路上的记录排成一列。buildSessionContext 里的核心就这几行

// 摘自同一文件的 buildSessionContext,有删减
const path: SessionEntry[] = [];
let current = leaf;
while (current) {
  path.unshift(current);
  current = current.parentId ? byId.get(current.parentId) : undefined;
}

路上遇到换模型的记录,就更新当前模型。遇到压缩记录,就先放摘要,再放保留的原文。别的枝杈上的消息,一条都不带,也就一个 token 都不占。

从叶子顺着 parentId 走回树根

写到这里我想起博尔赫斯那篇《小径分岔的花园》,一本书里所有的可能性同时存在,每个选择都分出一条新的时间线。pi 的会话文件就是这么一座花园,所有你试过的路都还在,只是模型每次只走其中一条。

放弃的那条路,也不白走

还有个细节我挺喜欢。

在 pi 里用 /tree 切换到另一条枝杈时,可以让模型给你放弃的那条路写一段摘要,作为一条 branch_summary 挂在新枝上。摘要的开头是固定的一句

// 摘自 packages/coding-agent/src/core/compaction/branch-summarization.ts
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
`;

后面跟着的格式,和第二篇讲的压缩摘要几乎一样,目标、约束、进度、关键决定、下一步,外加读过和改过的文件清单。

这一下,模型在新的路上就知道,之前试过什么,为什么没走通。退回去重来,但不是失忆重来。

要是想把某条枝杈整个拎出去,单独成一个会话,还有 /fork。它会新建一个会话文件,文件头里用 parentSession 记着自己是从哪个会话分出来的。树里分叉靠 parentId,会话之间分家靠 parentSession,两个字段各管一层。

为什么不用数据库

一个 JSONL 文件存一棵树,听着有点土。为什么不上 SQLite?

我觉得理由很实在。只追加的文件,写到一半断了电,最多坏掉最后一行。pi 读文件时逐行解析,解析失败的那一行直接跳过,前面的记录完好无损。

// 摘自 session-manager.ts 的 parseSessionEntries,有删减
for (const line of lines) {
  if (!line.trim()) continue;
  try {
    entries.push(JSON.parse(line));
  } catch {
    // Skip malformed lines
  }
}

而且它是纯文本,用任何编辑器都能打开,看得懂,坏了也修得了,不用装任何存储引擎。

代价同样明白。没有索引,每次打开会话都要把整个文件读一遍,在内存里重建一张 ID 表。文件只会越来越大。也不适合好几个进程同时往里写。

对一个人在本地跑的会话来说,这些代价都付得起。厉害了,一个字段加一个文本文件,把版本树这件事办得明明白白。

从直线,长成一棵树

最后一个彩蛋。pi 的会话格式不是一开始就是树。

源码里的 CURRENT_SESSION_VERSION 是 3。第 1 版就是一条直线,记录按顺序排,没有 id 也没有 parentId。第 2 版才给每条记录补上 id 和 parentId,长成了树。第 3 版把一个叫 hookMessage 的消息角色改名成了 custom,统一到扩展体系里。

旧版本的会话文件打开时会自动迁移。从第 1 版迁到第 2 版的逻辑也很朴素,按原来的顺序,给每条记录生成一个 id,让它的 parentId 指向前一条。一条直线,就是一棵还没分过叉的树。

从 v1 的直线,长到 v3 的树

回到开头那个问题。聊天记录当然可以是一条直线,但写代码的人需要的是反悔的权利。pi 给的办法就是那一个 parentId。

下一篇拆系统提示词。你在不同项目里用同一个 agent,它怎么知道这个项目用 pnpm、那个项目不许动数据库,一套配置是怎么拼出来适配所有项目的。

你在用的 agent,改坏了代码想回退的时候,是怎么办的?


这个系列的内容,我还做成了一套彩铅动画,13 集,每集两三分钟,一集讲透一个设计决定。今天这篇对应第 7 集,树怎么分叉、上下文怎么顺着枝杈找回树根,动起来看会更直观。

课程在 B 站课堂,预告和前两集可以免费看。

本文基于 pi v0.66.0 源码,新版本如有变化,以源码为准。讲解脉络参考了开源书《pi 的设计艺术》,想系统深入的推荐直接读原书。