开源编程 Agent pi 的会话怎么存、怎么续、怎么翻回去看

2026-07-29

本文基于 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
}

树就是靠这两个字段拼出来的:首条目的 parentIdnull,之后每条指向自己的父条目,从某个早期条目再长出一个孩子就是一次分支,当前所处的位置叫 leaf(叶子)。文档里画的形状是这样:

[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf

                                                            └─ [branch_summary] ─── [user msg] ← alternate branch

条目的 type 一共有这么几类,每一类解决一件具体的事:

  • message:一条对话消息,message 字段里装的是 AgentMessage
  • model_change:中途换模型时写一条,记下 providermodelId
  • thinking_level_change:中途改思考等级时写一条。
  • compaction:上下文压缩,存摘要。
  • branch_summary:用 /tree 切分支时,对被离开那条路径生成的摘要。
  • custom:扩展的状态持久化,明确不进入 LLM 上下文。
  • custom_message:扩展注入的消息,会进入 LLM 上下文。
  • label:给某个条目打的书签,targetId 指向被标记的条目。
  • session_info:会话显示名,/name--name 或扩展里的 pi.setSessionName() 都写这一类。

customcustom_message 这一对区分值得单独记一下:前者是扩展自己的状态,重载时靠 customType 认领,交互模式可以用 pi.registerEntryRenderer(customType, renderer) 渲染出来,但它不会被送进模型;后者才是注入上下文的消息,还带一个 display 字段决定要不要在界面上显示。把”我要记住这个状态”和”我要让模型看到这段话”拆成两种条目,是这套格式里比较清爽的一处。

消息本身的结构也是公开的。AssistantMessage 上带着 apiprovidermodelusagestopReasonToolResultMessagetoolCallIdtoolNameisError,还有一个可选的 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 时
扩展消息类型BashExecutionMessageCustomMessage、两种摘要消息packages/coding-agent/src/core/messages.ts解析到非标准 role 时
基础消息类型UserMessageAssistantMessageToolResultMessagepackages/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.mdpackages/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 条目,带 summarytokensBefore。这里有个新旧两代的差别,解析器作者要留心:老格式用 firstKeptEntryId 指出”从哪一条开始保留”,重建上下文时要回头去捞那一段原始条目;新一些的压缩条目会把压缩后保留的那段上下文直接以 retainedTail 数组的形式内嵌在条目上,于是这条 compaction 本身就成了一个自包含的检查点,重建时不必再往压缩点之前走。文档明确说了,retainedTail 之所以是可选字段,纯粹是为了兼容只存 firstKeptEntryId 的老会话。

branch_summary 是另一条线。用 /tree 从一条分支切到另一条时,pi 可以对被放弃的那条路径(一直到共同祖先)生成摘要,并挂到新位置上。交互时给你三个选择:不摘要、用默认提示词摘要、用自定义关注点摘要。两种摘要条目都带可选的 usage,摘要本身消耗的 token 与成本会算进会话总账;也都带可选的 details,默认实现里放的是 readFilesmodifiedFiles 两个文件清单。

还有个细节能帮你少写一段防御代码: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 时走的是回收站而非永久删除,你以为敏感内容没了,其实还在。真要清,自己确认落点。

收尾:一份自检清单

把这篇的判断压缩成几条,供你在自己的项目里对照:

  • 你知道当前会话文件的绝对路径吗?(/sessionPI_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.tsbuildContextEntries()buildSessionContext() 的实现——从文件里的一堆条目,到最终递给模型的那串消息,转换逻辑全在这两个方法里。看懂它们,你对这个项目”能做什么、不能做什么”的判断就不会再靠猜。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 把开源编程 Agent pi 嵌进自己的程序把开源编程 Agent pi 调顺手

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。