开源编程 Agent pi 的会话怎么存、怎么续、怎么翻回去看
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
**pi 把一次对话存成一棵追加写入的 JSONL 树,而不是一条线性消息列表——这一个决定,直接划出了你在它上面能做什么、不能做什么的边界。**你能原地回到三十轮之前改一句话再往下走,能把当前分支复制成另一个文件,能用 tail 直接看最后一条发生了什么,都是这个存储形态给的;反过来,它没有服务端、没有索引、没有跨机器同步,也是同一个决定的代价。
站内已经有两篇讲通用方法的文章:上下文管理的一般思路讲的是窗口紧张时该怎么取舍,Agent 的检查点与长任务续跑讲的是任务中断后如何接续,它们是方法论层面的。这一篇不重复那套东西,只回答一个具体问题:pi 这个开源项目,把上面那些想法落到磁盘上,究竟落成了什么样子。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上约有 8 万 star,你可以随时把仓库拉下来对照本文逐条核对。
一、先看磁盘:会话到底躺在哪
pi 的会话默认自动保存,落点是 ~/.pi/agent/sessions/,并按工作目录分组。完整路径形如:
~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl
其中 <path> 就是启动 pi 时的工作目录,把 / 换成了 -。这意味着两件事:同一个项目下的历次会话天然聚在一个文件夹里,不需要额外的索引;换个目录启动,看到的历史列表就换了一批。
存储位置可以改。文档里给出的优先级是:命令行 --session-dir 最高,其次是环境变量 PI_CODING_AGENT_SESSION_DIR,最后是 settings.json 里的 sessionDir。整个配置目录也能整体挪走,对应的环境变量是 PI_CODING_AGENT_DIR,默认值就是 ~/.pi/agent。
删除同样朴素——直接删 .jsonl 文件即可。交互模式里也能删:在 /resume 打开的选择器里选中一条按 Ctrl+D 再确认。文档里特别写了一句:当系统里有 trash 这个 CLI 时,pi 会走它而不是永久删除。这是个小细节,但它透露了设计者对”会话文件是用户资产”的态度。
还有一类你会在脚本里用到的入口:bash 工具执行命令时,pi 会把当前会话状态注入环境变量,其中 PI_SESSION_ID 是会话 ID,PI_SESSION_FILE 是当前会话 JSONL 的绝对路径(临时会话下不设置)。文档给的示例是这样的:
if [ -n "$PI_SESSION_FILE" ]; then
tail -n 1 "$PI_SESSION_FILE"
fi
一行 tail 就能看到刚刚写进去的最后一条。会话格式不是黑盒,这点从一开始就摆明了。
如果你这次只是想问一句话、不想留痕,用 pi --no-session 走临时模式,什么都不写盘。
二、每一行是一个条目,条目靠 parentId 拼成树
JSONL 的第一行是会话头,只放元数据,不参与树结构——它带的 id 是整个会话的 UUID,不是树节点编号,而且它没有 parentId,所以后面所有条目的父子链跟它无关:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
如果这个会话是通过 /fork、/clone 或 SDK 的 newSession({ parentSession }) 创建出来的,头里会多一个 parentSession 字段,指向原始会话文件的路径。血缘关系写在文件里,不靠命名约定。
从第二行开始,每一行都是一个条目,它们共享同一个基类:
interface SessionEntryBase {
type: string;
id: string; // 8-char hex ID
parentId: string | null; // Parent entry ID (null for first entry)
timestamp: string; // ISO timestamp
}
树就是靠这两个字段拼出来的:首条目的 parentId 为 null,之后每条指向自己的父条目,从某个早期条目再长出一个孩子就是一次分支,当前所处的位置叫 leaf(叶子)。文档里画的形状是这样:
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
│
└─ [branch_summary] ─── [user msg] ← alternate branch
条目的 type 一共有这么几类,每一类解决一件具体的事:
message:一条对话消息,message字段里装的是AgentMessage。model_change:中途换模型时写一条,记下provider和modelId。thinking_level_change:中途改思考等级时写一条。compaction:上下文压缩,存摘要。branch_summary:用/tree切分支时,对被离开那条路径生成的摘要。custom:扩展的状态持久化,明确不进入 LLM 上下文。custom_message:扩展注入的消息,会进入 LLM 上下文。label:给某个条目打的书签,targetId指向被标记的条目。session_info:会话显示名,/name、--name或扩展里的pi.setSessionName()都写这一类。
custom 和 custom_message 这一对区分值得单独记一下:前者是扩展自己的状态,重载时靠 customType 认领,交互模式可以用 pi.registerEntryRenderer(customType, renderer) 渲染出来,但它不会被送进模型;后者才是注入上下文的消息,还带一个 display 字段决定要不要在界面上显示。把”我要记住这个状态”和”我要让模型看到这段话”拆成两种条目,是这套格式里比较清爽的一处。
消息本身的结构也是公开的。AssistantMessage 上带着 api、provider、model、usage、stopReason;ToolResultMessage 带 toolCallId、toolName、isError,还有一个可选的 usage 用来记录”这个工具内部自己调了模型”所产生的开销。usage 里同时有 token 数和成本明细。也就是说,一次会话花了多少,答案就在文件里,不需要另找一套记账系统——这跟可观察日志怎么设计里讲的思路是一致的,只是 pi 把它直接压进了会话本身。
格式还有版本:v1 是线性条目序列(遗留),v2 引入 id/parentId 树结构,v3 把原来的 hookMessage 角色改名成了 custom。老会话在加载时自动迁移到当前版本。
三、这些东西分别在仓库哪里
想动手改或者自己写解析器,下面这张对照表能省不少翻目录的时间。路径都是仓库里的真实位置:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 会话文件本体 | 一次对话的全部条目,逐行追加的 JSONL | ~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl | 想直接读历史、写外部分析脚本时 |
| SessionManager | 条目类型定义、追加写入、树导航、上下文重建 | packages/coding-agent/src/core/session-manager.ts | 写扩展、用 SDK 嵌入 pi 时 |
| 扩展消息类型 | BashExecutionMessage、CustomMessage、两种摘要消息 | packages/coding-agent/src/core/messages.ts | 解析到非标准 role 时 |
| 基础消息类型 | UserMessage、AssistantMessage、ToolResultMessage | packages/ai/src/types.ts | 对齐 content block 结构时 |
AgentMessage 联合类型 | 把上面两组并成一个类型 | packages/agent/src/types.ts | 写类型安全的解析器时 |
| 压缩与分支摘要实现 | 生成 compaction / branch_summary 条目 | packages/coding-agent/src/core/compaction/ | 上下文吃紧、频繁切分支时 |
| 权威文档 | 命令清单、字段定义、格式版本 | packages/coding-agent/docs/sessions.md、packages/coding-agent/docs/session-format.md | 任何时候先查这两份 |
如果你是在自己项目里以依赖形式引入的,文档给的建议是去 node_modules/@earendil-works/pi-coding-agent/dist/ 和 node_modules/@earendil-works/pi-ai/dist/ 看 TypeScript 定义。
解析一个会话文件不需要引任何库,官方给的示例就是逐行 JSON.parse 再按 type 分派:
import { readFileSync } from "fs";
const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
for (const line of lines) {
const entry = JSON.parse(line);
switch (entry.type) {
case "session":
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
break;
case "message":
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
break;
case "compaction":
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
break;
}
}
这段代码能跑通,本身就是”会话是普通文件”这件事最直接的证据。想统计团队一周里跑了多少次 bash 工具、哪些文件被改得最频繁,写个几十行脚本就够了,不需要等官方出报表——复现与回放那一类需求,在这个格式下的实现成本被压得很低。
四、续接、回溯、分叉:四个入口的分工
启动时能接续的方式有几个,各管各的场景:
pi -c # Continue most recent session
pi -r # Browse and select from past sessions
pi --no-session # Ephemeral mode; do not save
pi --name "my task" # Set session display name at startup
pi --session <path|id> # Use a specific session file or partial session ID
pi --fork <path|id> # Fork a session file or partial session ID into a new session
--session 和 --fork 都接受”文件路径”或”部分会话 ID”,这点在脚本里很实用——你不必拼出完整的 UUID 和时间戳文件名。
进到交互模式之后,回溯相关的是三个命令,它们的差别是这篇里最该记住的一段:
/tree在同一个会话文件里导航整棵树。选中一条用户消息,leaf 会移到它的父节点,消息文本被放回编辑器,你改一改再提交就长出一条新分支;选中助手消息、工具结果、压缩条目这类非用户条目,leaf 直接移到该条目,编辑器留空,你从那里继续说话。选中根用户消息则把 leaf 重置成空对话,原始提示词回到编辑器。/fork产出新的会话文件,界面是一个用户消息选择器,用来”从某一句早期提问重新开一局”。/clone也产出新文件,但复制的是当前活跃分支,用来在继续折腾之前先留个副本。
一句话概括:想让几条路子留在一起、方便来回比对,用 /tree;想让它们各走各的、互不干扰,用 /fork 或 /clone。
/tree 的操作是键盘驱动的:上下键移动,左右键翻页,Ctrl+←/Ctrl+→(或 Alt 版本)折叠展开、在分支段之间跳,Shift+L 给选中条目打标签或清标签,Shift+T 切换标签时间戳显示,Ctrl+O 循环切换过滤模式,回车选中,Escape 取消。过滤模式有 default、no-tools、user-only、labeled-only、all 五种,默认值可以用 settings 里的 treeFilterMode 固定下来。会话一长,no-tools 和 labeled-only 这两档就是刚需——满屏工具调用里找那句关键提问,靠翻是翻不动的,得靠平时用 Shift+L 顺手打的标签。
此外还有一组管理类命令:/new 开新会话,/name <name> 起名,/session 查看当前会话文件、会话 ID、消息数、token 与成本,/resume 打开选择器,/export [file] 导出 HTML,/share 上传成私有 GitHub gist 并给出可分享的 HTML 链接(/share 用的查看器地址可以用 PI_SHARE_VIEWER_URL 覆盖)。
/resume 的选择器里,输入即搜索,Ctrl+P 切换路径显示,Ctrl+S 切换排序,Ctrl+N 只看有名字的会话,Ctrl+R 改名,Ctrl+D 删除。起过名的会话在这里会显示名字而不是第一条消息——session_info 条目的用处就在这。
五、上下文被裁掉之后,文件里留下了什么
会话文件是全量的,但送给模型的不是。两者之间隔着两个函数,名字很直白:buildContextEntries() 从当前 leaf 一路走到根,产出活跃分支上的条目列表;buildSessionContext() 在这个列表之上,产出真正交给模型的消息列表,同时从整条路径里提取出当前该用哪个模型、哪个思考等级。
中间要处理的是压缩。压缩触发的条件写在文档里,是一个很朴素的判断:当前上下文 token 超过”窗口减去预留量”就触发,预留量对应设置项 reserveTokens,找切点时向前累计到 keepRecentTokens 为止。你也可以手动 /compact [prompt],带上一句话让摘要往你关心的方向偏。
压缩在文件里落成一条 compaction 条目,带 summary 和 tokensBefore。这里有个新旧两代的差别,解析器作者要留心:老格式用 firstKeptEntryId 指出”从哪一条开始保留”,重建上下文时要回头去捞那一段原始条目;新一些的压缩条目会把压缩后保留的那段上下文直接以 retainedTail 数组的形式内嵌在条目上,于是这条 compaction 本身就成了一个自包含的检查点,重建时不必再往压缩点之前走。文档明确说了,retainedTail 之所以是可选字段,纯粹是为了兼容只存 firstKeptEntryId 的老会话。
branch_summary 是另一条线。用 /tree 从一条分支切到另一条时,pi 可以对被放弃的那条路径(一直到共同祖先)生成摘要,并挂到新位置上。交互时给你三个选择:不摘要、用默认提示词摘要、用自定义关注点摘要。两种摘要条目都带可选的 usage,摘要本身消耗的 token 与成本会算进会话总账;也都带可选的 details,默认实现里放的是 readFiles 和 modifiedFiles 两个文件清单。
还有个细节能帮你少写一段防御代码:pi-ai 导出的 StopReason 类型里有 "pending" 这个值,但它只用于流式事件中的部分消息,落盘前一定会被换成终态原因。也就是说,你在会话 JSONL 里不该看到 "pending"。
关于成本这件事,多说一句:usage 里的金额是按各家服务商的计费规则算出来的,而这些规则各家不同且会调整,以官方最新说明为准。另外,几家海外模型服务商官方对中国大陆存在区域限制、不支持直连,实际能不能跑通取决于你的接入方式;市面上存在第三方中转服务,这里不做背书也不给具体渠道,自己评估合规与数据风险。会话文件里躺着完整的对话内容和工具输出,这也是上下文预算怎么算之外,另一个你必须自己扛的责任。
六、边界与代价:它明确不管的事
任何存储设计都是在换。pi 这套换掉了什么,说清楚比说优点更有用。
没有服务端,也就没有同步。 会话是本地文件,按本机的工作目录路径分组。换台机器、换个容器、把项目目录挪个位置,历史就跟不过来。想跨机器接着干,只能自己拷文件,或者用 --session <path> 指过去。
没有索引,检索能力就是”文本搜索 + 排序”。 /resume 里能输入搜索、能按排序模式切换、能只看命名会话,仅此而已。跨会话做语义检索、按代码文件反查”哪些会话动过它”,这些框架不提供,得你自己在 JSONL 上另建一层。
追加写入意味着文件只涨不缩。 SessionManager 的注释把自己定义成”append-only trees stored in JSONL files”,实现上就是一行行 appendFileSync。你在 /tree 里退回去重来,旧分支不会消失,只是不在活跃路径上——好处是永远可回溯,代价是长期项目下的会话目录会持续变大,清理靠你自己删文件。
分支不等于代码回滚。 /tree 移动的是对话的 leaf,不是你工作区的文件状态。回到三十轮之前那句话继续说,磁盘上的代码还是现在这个样子。真要连代码一起退,得靠版本控制自己来,pi 的会话树不替你管这件事。对话状态和工作区状态是两条独立的时间线,别混着想。
摘要是有损的。 压缩和分支摘要都要调一次模型把旧内容压成一段文字,压完就回不去原文了——原文还在文件里,但不在送给模型的上下文里。什么被摘要器判成”不重要”,你事后是看不见的。所以关键约定该写进项目文件而不是留在对话里,这条对任何 Agent 都成立。
没有加密。 文件是明文 JSONL,工具输出、读过的文件片段、报错信息全在里面。团队机器上共享账户、或者往公共仓库里误提交,风险是实打实的。/share 上传的是私有 gist,但”私有”不等于”保密”。
七、上手与避坑清单
下面每条都写清楚为什么会踩、怎么避。
1. 别用 /fork 当”回退一步”用。 会踩是因为 /fork 和 /tree 表面上都是”回到早先某一句”,但 /fork 会生成新的会话文件,于是同一件事被切成好几个文件,第二天用 /resume 看到一排相似的会话,分不清哪个是最终版。回退和试错留在 /tree 里,只有”这条路我要长期独立走下去”才 /fork。
2. 起名要在开始时起,不要等结束。 会踩是因为 /resume 列表在没有 session_info 时显示的是第一条消息,而你的第一条消息往往是”帮我看下这个报错”这种毫无辨识度的句子,一周后全是同款。启动时就用 pi --name "..." 或 -n,或者进去先 /name,代价是两秒钟。
3. 长会话里养成按 Shift+L 打标签的习惯。 会踩是因为等到需要回溯的时候,树已经几百个条目,工具调用刷屏,靠上下键找那个”改坏之前的位置”根本找不到。打了标签之后,Ctrl+O 切到 labeled-only 一眼就能定位。事前十秒,事后省十分钟。
4. 写解析器时不要假设 compaction 一定有 retainedTail。 会踩是因为你手头的新会话都带这个字段,代码写得理所当然,一碰到老会话(或者从别处拷来的会话)就崩。文档写明了它是为兼容老格式而设的可选字段,两条路径都要处理:有 retainedTail 就当自包含检查点,没有就退回 firstKeptEntryId 的老逻辑。
5. 不要把扩展状态塞进 custom_message。 会踩是因为两个条目类型长得像,随手选了带”message”的那个,结果扩展的内部状态被当成上下文送进了模型,白烧 token 还可能干扰输出。规则很清楚:不想让模型看见就用 custom,想让它看见才用 custom_message。
6. 改了 --session-dir 之后别忘了 -c 的含义也变了。 会踩是因为 pi -c 续的是”当前工作目录下最近的一次会话”,而会话目录被你改到别处之后,同一个项目在两个目录里各有一份历史,-c 到底续了哪一份取决于当次启动时的配置。优先级是 --session-dir > PI_CODING_AGENT_SESSION_DIR > settings.json 里的 sessionDir,团队里统一一种写法,别三种混用。
7. 在 CI 或批处理脚本里默认加 --no-session。 会踩是因为自动化任务跑得频繁,每次都写一个会话文件,几周下来目录里堆满没人会看的记录,还把真正有价值的人工会话淹掉了。确实需要留证据的,就在脚本里读 PI_SESSION_FILE 主动归档到别处。
8. 别指望删了文件就等于删干净。 会踩是因为 /resume 里的 Ctrl+D 在有 trash CLI 时走的是回收站而非永久删除,你以为敏感内容没了,其实还在。真要清,自己确认落点。
收尾:一份自检清单
把这篇的判断压缩成几条,供你在自己的项目里对照:
- 你知道当前会话文件的绝对路径吗?(
/session或PI_SESSION_FILE里就有) - 你的会话有名字吗?一周后还能从
/resume列表里认出来吗? - 你上一次回溯是用
/tree还是/fork?这个选择和”我要不要保留另一条路”匹配吗? - 你的会话目录多久没清过了?里面有多少是 CI 跑出来的噪音?
- 如果要给团队做一份”本周 Agent 用量与改动文件”的统计,你打算解析哪几种
type?
想继续往下挖,仓库里按顺序读这三份就够:packages/coding-agent/docs/sessions.md 建立整体印象,packages/coding-agent/docs/session-format.md 把字段一条条对齐,然后直接翻 packages/coding-agent/src/core/session-manager.ts 看 buildContextEntries() 和 buildSessionContext() 的实现——从文件里的一堆条目,到最终递给模型的那串消息,转换逻辑全在这两个方法里。看懂它们,你对这个项目”能做什么、不能做什么”的判断就不会再靠猜。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 把开源编程 Agent pi 嵌进自己的程序 和 把开源编程 Agent pi 调顺手。