开源编程 Agent pi 的会话存储:JSONL 追加树与恢复流程
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 的会话文件从来不做原地修改:所有操作——发一条消息、切模型、跳回上一个岔路口、压缩上下文——都是往同一个 .jsonl 文件尾部追加一行,连”我现在站在哪一条上”这件事本身也是追加一行记下来的。 理解了这一条,磁盘上那堆文件、/tree 的分叉、恢复时的读取顺序,全部能顺下来。
pi 是 earendil-works 的开源编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。下面讲的每一处实现,你都可以打开仓库当场核对。
站内已经有两篇讲方法论的文章:Agent 检查点与长任务 讲什么时候该存档、存什么粒度;Agent 复现与回放 讲怎么把一次跑过的过程重放出来。那两篇是”该怎么想”,本篇是”一个真实项目怎么落到磁盘上”——它们互为正反面,方法论看完不知道怎么落地的,可以拿 pi 当参照实现。
一、会话在磁盘上长什么样
会话文件的路径规则写在 packages/coding-agent/docs/session-format.md 里:
~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl
中间那层目录是把工作目录编码进去的。编码逻辑在 packages/agent/src/harness/session/jsonl-repo.ts,只有一行:
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
去掉开头的斜杠,把剩下的斜杠、反斜杠、冒号统统换成连字符,前后各包一对 --。所以同一个项目的所有会话天然聚在一个目录里,pi -r 列表只列当前项目的会话,靠的就是这个目录名,不需要额外的索引数据库。
文件名由创建时间戳和会话 ID 拼成,时间戳里的冒号和点号也被换成连字符(${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl),会话 ID 是 uuidv7。uuidv7 的前缀本身就带时间序,所以文件名同时具备”按时间排”和”全局唯一”两个性质。
文件内部是标准 JSONL:一行一个 JSON 对象,行与行之间没有任何包裹结构。第一行固定是会话头:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
从第二行开始,每行都是一条会话条目。条目的公共字段只有四个:type、id、parentId、timestamp。id 取 uuidv7 的末 8 位(generateEntryId 会重试至多 100 次避开已有 ID,实在撞不开就退回完整 uuidv7),parentId 指向上一条,第一条为 null。就这两个字段,把一个扁平的文件变成了一棵树。
条目类型不算多,也不难记:message 装一条对话消息,model_change 和 thinking_level_change 记录你中途换了模型或改了思考档位,compaction 记一次上下文压缩,branch_summary 记一次分支切换时对被放弃那条路的总结,label 是你给某条打的书签,session_info 是会话显示名,custom 和 custom_message 留给扩展写自己的状态。还有一类 leaf,下一节讲。
二、为什么是追加,而不是”保存当前状态”
大多数人写会话持久化的第一反应是:内存里维护一个消息数组,每轮结束把数组整个序列化覆盖写回文件。pi 没这么做,它的写入路径只有一个动作——appendFile。
packages/agent/src/harness/session/jsonl-storage.ts 里的 appendEntry 就是全部:
async appendEntry(entry: SessionTreeEntry): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = leafIdAfterEntry(entry);
}
序列化一行、追加、更新三份内存索引。没有临时文件、没有 rename、没有全量重写。
这个选择带来的第一个好处是崩溃语义干净。覆盖写在写到一半掉电时,你可能同时失去新内容和旧内容;追加写最坏的结果是文件末尾多出半行残缺 JSON,前面所有历史完好无损。第二个好处是写入代价与会话长度无关——一次几百轮的长任务,第 400 轮的写入开销和第 1 轮一样。
真正把追加式用出花来的是分支。/tree 让你跳回三十轮之前继续,这在覆盖写的模型里意味着要么截断文件、要么复制出一个新文件。pi 的做法是:跳位置这个动作本身也是一条条目。setLeafId 追加的是一条 type: "leaf" 的记录,它的 targetId 指向你跳过去的那个条目 ID。于是”当前站在哪”这件事,由文件里最后一条条目决定:
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
普通条目追加完,叶子就是它自己;leaf 条目追加完,叶子是它指向的那个旧条目。接下来你再发消息,新条目的 parentId 就挂到那个旧条目上,一条新分支自然长出来。原来那条路一个字节都没动,/tree 里还能切回去。
这也解释了为什么 pi 把 /tree、/fork、/clone 分成三个命令:/tree 在同一个文件里换位置,/fork 和 /clone 才产生新文件。想把几条探索路线留在一起对照,用前者;想彻底分家,用后者。
三、恢复一次会话,程序到底读了什么
这是最容易想当然的地方。很多人以为恢复会话就是”把文件读进来喂给模型”,实际有两层,读盘只是第一层。
第一层:列表阶段只读一行。 JsonlSessionRepo.list() 遍历目录下所有 .jsonl,对每个文件调 loadJsonlSessionMetadata,而它用的是 readTextLines(filePath, { maxLines: 1 })——只读第一行,解析出会话头里的 id、创建时间、cwd、父会话路径,然后按创建时间倒序排。所以 /resume 的列表再长也不会把几百 MB 的历史全读进内存。这里还有个容错细节:某个文件头解析失败抛出 invalid_session 时,list() 会跳过它继续;但如果是别的错误(比如磁盘读不出来),会直接抛。坏掉一个会话文件不会连累整个列表,这个边界划得很克制。
第二层:真正打开时全量读、逐行解析。 JsonlSessionStorage.open 调 loadJsonlStorage,用 readTextFile 把整个文件读成字符串,按 \n 切开并过滤空行,第一行交给 parseHeaderLine,其余每行交给 parseEntryLine。校验相当严:头部 version 不等于 3 直接报 unsupported session version,缺 id、timestamp、cwd 都报错;每条条目缺 type、缺 id、parentId 既不是 null 也不是字符串、缺 timestamp,都会带上行号报 invalid_entry。
逐行解析的循环里只顺手做了一件事:每解析出一条条目就按 leafIdAfterEntry 把叶子 ID 覆盖一次,所以最后一行说了算,loadJsonlStorage 返回的就是 header、条目数组和这个叶子 ID 三样。剩下两份内存索引是解析完成后一次性建起来的——byId 由条目数组直接 new Map(entries.map(...)),labelsById 由 buildLabelsById 把所有 label 条目按 targetId 归并一遍(updateLabelCache 的规则是:label 字段 trim 后非空就写入,空或缺失就把该 targetId 上的书签删掉,所以后来的 label 条目既能改名也能取消)。之后每追加一条,appendEntry 再增量更新这三份,不重新扫全文。
第三层:从叶子往上走,而不是从头往下读。 到这里文件已经全在内存里了,但要送给模型的不是全部条目,只是当前这条路径。getPathToRootOrCompaction 从叶子出发沿 parentId 一路 unshift 到根,途中遇到 compaction 条目时有两种停法:
if (current.type === "compaction") {
if (current.retainedTail) break;
stopAtEntryId = current.firstKeptEntryId ?? null;
}
如果这条压缩记录带了 retainedTail(压缩后保留下来的那截消息被直接物化在条目上),它就是一个自足的检查点,往上不用再走了;老格式只有 firstKeptEntryId 的,就记下”走到那条为止”再停。文档里明确说 retainedTail 可选只是为了兼容旧会话,新版压缩都会带上——这正是”检查点自足”这个想法的具体落法。
第四层:条目变消息。 packages/agent/src/harness/session/session.ts 里的 buildContextEntries 先跑 defaultContextEntryTransform 把压缩规则应用一遍,再跑调用方注册的额外 transform;buildSessionContext 在此基础上做两件事:一是 deriveSessionContextState 遍历完整路径,拿到当前的思考档位、模型、启用的工具集(模型既可能来自显式的 model_change,也可能从最后一条 assistant 消息上的 provider/model 反推);二是把选中的条目逐条投影成消息——message 直接取出内部的 AgentMessage,compaction 展开成一条压缩摘要消息加上 retainedTail,branch_summary 展开成分支摘要消息,custom_message 还原成扩展消息,而 custom 默认投影成空数组。
最后这一点值得记住:扩展写进会话的 custom 条目默认不进模型上下文,除非你在 entryProjectors 里为它注册一个投影函数。想存状态又不想污染上下文,用 custom;想让模型看见,用 custom_message。这个二分做得比”存进去再想办法过滤”干净得多,具体权衡可以对照 Agent 记忆分层 那篇看。
四、各部分职责对照
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
JsonlSessionRepo | 决定文件落在哪个目录、列举/新建/删除/fork 会话 | packages/agent/src/harness/session/jsonl-repo.ts | 用 pi -r 或 /resume 拉会话列表时 |
JsonlSessionStorage | 单文件的追加写、逐行解析、内存索引、沿 parentId 上溯 | packages/agent/src/harness/session/jsonl-storage.ts | 每发一条消息、每次 /tree 换位置 |
Session | 把各类条目包成 appendMessage 等方法,并对外给出上下文构建入口 | packages/agent/src/harness/session/session.ts | 写扩展往会话里写东西、或自己组装上下文时 |
getEntriesToFork | 决定 /fork 从哪一条切、切出哪些条目 | packages/agent/src/harness/session/repo-utils.ts | 用 /fork 或 pi --fork |
| 会话格式文档 | 全部条目类型的字段清单与 JSON 样例 | packages/coding-agent/docs/session-format.md | 自己写解析脚本或做统计时 |
| 会话用法文档 | CLI 参数与斜杠命令的行为差异 | packages/coding-agent/docs/sessions.md | 分不清 /tree、/fork、/clone 时 |
五、边界与代价:这个设计放弃了什么
追加式不是免费的,它换来的东西也很明确地要了几样代价。
文件只涨不缩。 每一次跳转、每一次改名、每一条被你放弃的分支,都留在文件里。一个跑了很久的会话,其中真正在当前路径上的条目可能只占一小部分,剩下的都是历史。仓库里没有压紧(compact 文件)这一步——/compact 压的是喂给模型的上下文,不是磁盘文件。想清掉,只能整个删会话文件。
打开必须全量读。 列表阶段只读一行很省,但一旦真的 open,readTextFile 会把整个文件读进内存并逐行 JSON.parse。会话越长,恢复越慢、常驻内存越大。这套实现把”随机访问单条历史”这个需求整个让掉了:它假设你要么只看头,要么全都要。
没有并发写保护。 appendEntry 直接追加,内存索引也直接更新。同一个会话文件被两个进程同时写会发生什么,这套代码没有承诺。多路并行的编排要另想办法隔离,思路可以参考 Agent 并发编排。
版本兼容靠迁移,不靠宽容解析。 文档写明 v1 是线性序列、v2 引入树、v3 把 hookMessage 角色改名为 custom,旧会话在加载时自动迁移到 v3;而 parseHeaderLine 对非 3 的版本是直接拒绝的。也就是说迁移是前置的、显式的,解析器本身不做多版本兼容——好处是解析逻辑始终只有一套,坏处是迁移这一环出问题就打不开。
它明确不管的事:不管你的上下文该压到多少、不管敏感信息该不该落盘(会话里存的是原样的消息内容,包括工具输出)、不管跨机器同步、不管加密。这些都在会话存储这层之外。
六、上手与避坑清单
别拿”最后一条 message 条目”当当前位置。 会踩是因为直觉上文件末尾就是现场,但 leaf 条目的存在意味着末尾可能是一条”我跳回去了”的记录。怎么避:按 leafIdAfterEntry 的规则逐行覆盖算叶子——普通条目取自己的 id,leaf 条目取 targetId——最后那个值才是当前位置。
别顺着文件顺序拼上下文。 会踩是因为 JSONL 看起来就是时间线,for 一遍很自然。但文件里可能有好几条分支,顺序读会把互相矛盾的两条路混在一起喂给模型。怎么避:从叶子沿 parentId 上溯取路径,这是唯一正确的读法。
遇到 compaction 条目要先看 retainedTail。 会踩是因为老资料只讲 firstKeptEntryId,写解析时容易只处理这一个字段,结果新会话被你多读了压缩点之前的一大段,token 白花。怎么避:带 retainedTail 就当自足检查点,到此为止;没有才回退到 firstKeptEntryId 的老逻辑。
别把 custom 和 custom_message 当同一个东西。 会踩是因为名字只差一截。前者是扩展的私有状态、不进模型上下文;后者会被投影成消息送进去。怎么避:写扩展前先想清楚这条记录是给程序看还是给模型看,选错了不是报错,是模型行为莫名其妙地变了——这类问题的排查思路见 Agent 上下文管理。
/fork 从非用户消息切会直接报错。 会踩是因为你在 /tree 里随手停在某条 assistant 消息上就想分家。getEntriesToFork 在默认的 before 位置下会校验目标必须是 user 角色的 message 条目,否则抛 invalid_fork_target。怎么避:要从助手回复处分家,用 /clone 复制当前分支,或者先把位置移到下一条用户消息。
别手工编辑会话文件。 会踩是因为 JSONL 看起来太好改了。但解析器对每一行都做结构校验,缺字段会带行号抛错;改坏一行,整个会话打不开。怎么避:要做统计、导出、批量分析,写只读脚本;要改内容,用 /fork 造一个新会话。
注意工具输出会原样落盘。 会踩是因为你只盯着自己打的字,忘了 toolResult 里可能有配置文件全文、环境变量、接口返回。怎么避:把 ~/.pi/agent/sessions/ 当作和源码同级的敏感目录对待,别提交进仓库、别随手 /share,导出会话给人看之前先自己翻一遍工具输出段。
顺带说一句模型侧:会话里记录的 provider 和 modelId 只是”这一轮用了谁”,能不能真的调通是另一回事。海外几家模型服务商官方对中国大陆存在区域限制、不支持直连,市面上有第三方中转但这里不做背书也不给具体渠道;各家的规则和策略不同且会调整,以官方最新说明为准。
收个尾
把这套设计压成一句话:用一个只追加的 JSONL 文件承载一棵树,用”最后一行”表达当前位置,用带 retainedTail 的压缩条目把上下文重建的成本封在一个检查点里。 简单到可以用二十行脚本解析,同时把分支、压缩、扩展状态三件事都装下了。
如果你要照着做一套自己的会话存储,先问自己三个问题:跳回历史位置这件事,你打算怎么表达——新文件、截断,还是像 pi 这样再追加一条记录?上下文重建时,你能不能只靠一个检查点停下来,而不是每次都走到根?扩展往会话里写的东西,默认进不进模型上下文?
想继续读代码的话,顺序建议是:packages/agent/src/harness/session/jsonl-storage.ts 看写入与解析,然后 session.ts 看条目怎么变成消息,最后 packages/coding-agent/docs/session-format.md 当字段字典查。两个源码文件各三百多行、文档四百多行,加起来一千出头,一个下午能读透。真要动手验证,最省事的办法是随便跑一次 pi,然后到 ~/.pi/agent/sessions/ 下找到对应目录,用任何能按行读 JSON 的工具把文件打开:第一行的 version 应该是 3,往下每行都带 type/id/parentId/timestamp 四件套,用 /tree 跳一次再看文件尾部,就能亲眼看到那条 leaf 记录被追加上去。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的文件编辑 和 开源编程 Agent pi 的上下文压缩。