Skip to content

8.4 Session 保存与恢复

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:到 8.3 为止,我们的 Harness 已经会转圈、会调工具了,但进程一退出,context.messages 就随内存一起消失。本章给它加上会话(Session):把每条消息追加进一个 JSONL 文件,下次启动用 --continue 读回来接着聊。

前置知识8.3 Agent Loop 与工具执行(本步骤在它之上增量修改)、2.8 Node.js 文件、路径与进程 APIappendFile / readdir / stat)、3.6 Agent Harness、Session 与状态(内存状态与磁盘记录为什么是两份东西)。

学习目标:读完本章后你能

  • 说清会话文件为什么用 JSONL 追加写,而不是「一个大 JSON 数组 + 整文件重写」;
  • 读懂 header + entry 的两段式文件结构,以及 version / id / parentId 三个字段各自防的是什么;
  • 解释「已持久化条数」这个游标如何让 Agent Loop(Agent 循环)对持久化一无所知;
  • 独立写出 --continue:找最近的会话文件 → 重放条目 → 重建 context.messages → 把游标对齐;
  • 在 Pi 源码里指认出与我们这套设计一一对应的位置,并说出 Pi 多做了哪几件事。

建立直觉:账本,不是快照

先明确一件事:会话文件存的是账本,不是快照

快照的做法是「每次都把当前状态完整写一遍」——writeFile(file, JSON.stringify(messages))。账本的做法是「只在末尾记这一次新增了什么」——appendFile(file, oneLine)。两者最终都能还原出同一份消息历史,但代价完全不同:

快照(整文件重写)账本(追加写)
写一条消息的成本与历史长度成正比常数
写到一半崩溃整个文件毁掉最后一行残缺,前面全都有效
读取必须整体解析可以边读边处理
修改历史容易做不到(只能再追加一条)

最后一行看着像缺点,其实是这套设计的核心约束:append-only(只追加、不修改)。历史一旦写下就不再改动,所以任何时刻回头看,看到的都是当初真实发生的顺序。3.6 里提过的「分叉不是删掉后面的消息,而是从中间挂一条新线」,能成立正是因为这条约束。

📘 概念JSONL(JSON Lines)
一种文本格式:文件的每一行是一个独立的、完整的 JSON 对象,行与行之间没有逗号、整体也没有外层的 []。它牺牲了「整个文件是一个合法 JSON」这一点,换来三件事:末尾追加不需要读前面的内容、崩溃时已写下的行仍然可解析、超大文件可以逐行流式处理。会话记录、日志、数据集导出都常用它。
🌱 初学者提示为什么不用 SQLite 之类的数据库
用数据库当然可以,Pi 就有一个可选的 SQLite 后端([6.5](/pi-modules/session-storage) 会讲)。但会话数据的访问模式非常单一——**几乎只有「在末尾追加」和「从头读一遍」**,没有随机更新、没有并发写、没有复杂查询。这种模式下一个文本文件就够了,而且它还有数据库给不了的好处:出问题时你能直接 cat 它、用 grep 找它、用肉眼看懂它。本项目的约束是零运行依赖,正好也只能这么做。

最小示例:跑一次 step06

bash
cd labs/mini-agent-harness/step06-sessions
npm install
npm run demo

demo 在一个进程里演示两次「运行」:先新建会话聊两句,再把同一个文件读回来接着聊。下面是 expected-output.txt 的真实内容,只在最后一次文件打印处省略了与上面重复的六行:

Mini Agent Harness · step06 sessions(demo)

=== 第一次运行:新建会话 ===

你> 你好
[第 1 轮]
助手> 你好!这次的对话会被写进 .sessions 目录。
[第 1 轮结束]

你> 算一下 12*8
[第 1 轮]
助手> 先算数。
[工具调用] calc {"expression":"12*8"}
[工具结果] calc → 96
[第 1 轮结束]
[第 2 轮]
助手> 12*8 = 96。
[第 2 轮结束]

本次运行结束,6 条消息已落盘。

[会话文件] .sessions/demo.jsonl(7 行)
  header  version=1 id=demo
  e1   parent=-    role=user       你好
  e2   parent=e1   role=assistant  你好!这次的对话会被写进 .sessions 目
  e3   parent=e2   role=user       算一下 12*8
  e4   parent=e3   role=assistant  先算数。
  e5   parent=e4   role=toolResult 96
  e6   parent=e5   role=assistant  12*8 = 96。

=== 重新启动:--continue 恢复会话 ===

已恢复 6 条消息,角色依次是:
  user → assistant → user → assistant → toolResult → assistant
最后一条内容:12*8 = 96。

你> 刚才算的结果是多少
[第 1 轮]
助手> 刚才算的是 12*8,结果是 96。
[第 1 轮结束]

恢复后继续对话,现在共 8 条消息。

[会话文件] .sessions/demo.jsonl(9 行)
  header  version=1 id=demo
  ……(省略:e1–e6 与上面完全相同)
  e7   parent=e6   role=user       刚才算的结果是多少
  e8   parent=e7   role=assistant  刚才算的是 12*8,结果是 96。

有三处值得停下来看。

第一,6 条消息来自两次输入。 「你好」只产生 2 条(user + assistant),「算一下 12*8」产生 4 条(user + 带 Tool Call(一次工具调用请求)的 assistant + toolResult + 最终 assistant)。一次输入产生几条消息,取决于循环转了几圈——所以落盘代码不能假设「一次输入 = 一条消息」。

第二,e5 是一条 toolResult 消息。 工具结果也是对话的一部分,必须持久化。少了它,恢复出来的上下文里会出现一次「发起了却没有结果」的工具调用,模型看到这种历史通常会重新调一次工具——8.3 讲过 toolCallId 的配对关系,这里就是它必须落盘的直接后果。

第三,第二次打印时前 7 行一字未变,只在末尾多了两行。 这就是 append-only 的样子。

打开真实生成的文件(.sessions/demo.jsonl),前两行长这样:

json
{"type":"header","version":1,"id":"demo","createdAt":"2026-01-01T00:00:00.000Z","cwd":"/…/labs/mini-agent-harness/step06-sessions"}
{"type":"message","id":"e1","parentId":null,"message":{"role":"user","content":[{"type":"text","text":"你好"}]}}

带工具调用的那一条(e4)和它的结果(e5):

json
{"type":"message","id":"e4","parentId":"e3","message":{"role":"assistant","content":[{"type":"text","text":"先算数。"},{"type":"toolCall","id":"call_1","name":"calc","arguments":{"expression":"12*8"}}],"stopReason":"toolUse"}}
{"type":"message","id":"e5","parentId":"e4","message":{"role":"toolResult","toolCallId":"call_1","toolName":"calc","content":[{"type":"text","text":"96"}],"isError":false}}

注意 message 字段里装的就是 8.2 定义的 Message 原样——条目(entry)包着消息(message),条目管存储(我在文件里的身份和位置),消息管对话(模型看到的内容)。这两层不能混在一起:id / parentId 是存储的事,模型永远不该看到它们。

文件格式:一行 header,其余是条目

src/session.ts 是本步骤唯一的新文件。它先定义两个类型:

ts
/** 文件第一行:这个会话的元信息。 */
export interface SessionHeader {
  type: "header";
  /** 格式版本号。以后改结构时靠它兼容旧文件。 */
  version: number;
  id: string;
  createdAt: string;
  cwd: string;
}

export interface MessageEntry {
  type: "message";
  id: string;
  parentId: string | null;
  message: Message;
}

export type SessionEntry = MessageEntry;

三个字段值得逐个解释,因为它们各自防着一类问题。

version 防的是「未来的你改了格式」。 会话文件会在用户磁盘上活很久,而代码每周都在变。没有版本号,新代码读旧文件时不会报错——它会安静地解析出一堆 undefined,然后在几十行之外抛一个看不懂的错。open() 因此显式检查版本:

ts
if (header.version !== 1) {
  throw new Error(`不支持的会话格式版本:${header.version}`);
}

cwd 防的是「路径漂移」。 会话里的工具调用(比如读文件)都是相对某个工作目录解释的。把当时的 cwd 记进 header,将来才有可能判断「这份会话是在哪儿产生的」。我们的教学版记下来但不使用;Pi 会用它做校验(本章末尾对照)。

parentId 防的是「历史只能是一条直线」。 我们的实现只往最后一条后面追加,实际形成的确实是直线。那为什么现在就要留这个字段?因为一旦文件格式发过版,改它的代价就变成了「所有已存在的用户文件」。parentId 是给未来的分支(branch)留的接口——Pi 正是靠它让会话成为一棵树。

⚠️ 常见误解以为 parentId 是「上一条消息的 id」
它是「**我挂在哪个条目下面**」。在只追加的直线情形里两者恰好一样,所以很容易混淆。区别在分支时暴露:从 e3 分出一条新线时,新条目的 parentIde3,而文件里排在它前面的最后一条可能是 e6顺序(文件里的行序)和结构(parentId 指向)是两回事,读会话文件时必须按结构走,不能按行序走。

写:一次一行,唯一入口

Session 类只有三个方法:create 建文件写 header、open 读回来、append 追加一条。写入口只有一个:

ts
/** 追加一条消息。这是唯一的写入口,一次一行。 */
async append(message: Message): Promise<void> {
  this.seq += 1;
  const entry: MessageEntry = {
    type: "message",
    id: `e${this.seq}`,
    parentId: this.lastEntryId,
    message,
  };
  await appendFile(this.file, `${JSON.stringify(entry)}\n`, "utf8");
  this.lastEntryId = entry.id;
}

这十来行里藏着三个约定。第一,id 用递增序号 e1 / e2(真实实现用随机短 id,见本章末尾);第二,parentId 取自上一条的 id,写完再前移,所以「挂在谁下面」由类自己维护,调用方管不着;第三,JSON.stringify 的结果里不可能出现裸换行(\n 会被转义成 \\n),所以「一行一条」这个约定是安全的——正是它让读取时可以简单地 split("\n")

游标:让循环对持久化一无所知

现在到了本章的设计核心。Agent Loop 一圈可能往 context.messages 里塞 1 条助手消息 + N 条工具结果(8.3runAgentLoop 里那两处 context.messages.push),外面还有一条用户消息。到底新增了几条,只有跑完才知道。

有两种办法应对。一种是在循环里边跑边写——每次 push 之后紧跟一次 append。这么做循环就必须持有 Session 对象,从此它不再是一个纯函数,测试时也得给它造一个假的会话。另一种是用游标做差集:记住「已经写进文件的条数」,跑完之后把多出来的部分补写进去。step06 选的是后者:

ts
export async function persistNewMessages(
  session: Session,
  messages: Message[],
  persisted: number,
): Promise<number> {
  for (const message of messages.slice(persisted)) {
    await session.append(message);
  }
  return messages.length;
}

于是主循环长这样(src/main.ts):

ts
while (true) {
  const line = await ask("你> ");
  if (line === undefined || line.trim() === "/exit") {
    break;
  }
  context.messages.push(userText(line));
  await renderEvents(
    runAgentLoop({ streamFn, context, tools, toolContext: { cwd: projectRoot } }),
  );
  // 一轮结束后统一落盘:用户消息、助手消息、工具结果一起写。
  persisted = await persistNewMessages(session, context.messages, persisted);
}

runAgentLoop 的参数里没有 session——循环根本不知道自己被存到了哪里。这正是 3.6 那张分层图里「Session 挂在 Harness 下面而不是循环下面」的代码形态。

图加载中…

图 8.4-1 一轮对话里,内存改了很多次,文件只在末尾追加
阅读顺序:从左到右就是时间顺序,数字取自上面 demo 的第二次输入。要关注的是 C 与 D 之间那条边——C 阶段完全不碰磁盘,循环只往内存数组里塞;到了 D 才用游标做差集,一次算出「这一轮新增了哪几条」。E 是唯一的写盘动作,写几行取决于这一轮实际转了几圈。F 回到 A,游标始终等于「文件里已有的条目数」。

代价也要说清楚:每轮结束才落盘,意味着崩溃时会丢掉当前这一轮。逐条写更抗崩溃,但要在循环里插 I/O。教学版选了后者,因为它能干净地展示解耦;真实系统(包括 Pi)倾向于更细的写入粒度——本章后面会看到 Pi 是怎么在保持解耦的同时把粒度做细的。

读:重放条目,重建 messages

恢复不是「把文件当状态用」,而是重放条目、重建出一份新的内存状态

ts
static async open(file: string): Promise<{ session: Session; messages: Message[] }> {
  const raw = await readFile(file, "utf8");
  const lines = raw.split("\n").filter((line) => line.trim().length > 0);
  if (lines.length === 0) {
    throw new Error(`会话文件是空的:${file}`);
  }

  const header = JSON.parse(lines[0]) as SessionHeader;
  if (header.type !== "header") {
    throw new Error(`会话文件第一行不是 header:${file}`);
  }
  if (header.version !== 1) {
    throw new Error(`不支持的会话格式版本:${header.version}`);
  }

  const messages: Message[] = [];
  let lastEntryId: string | null = null;
  let seq = 0;

  for (const line of lines.slice(1)) {
    const entry = JSON.parse(line) as SessionEntry;
    if (entry.type !== "message") continue;
    messages.push(entry.message);
    lastEntryId = entry.id;
    seq += 1;
  }

  return { session: new Session(file, header, lastEntryId, seq), messages };
}

这段代码回答了三个问题。

恢复出什么? 一个 Message[]——正好是 context.messages 的初值。条目的 id / parentId 在这一步被剥掉了,它们只用来重建 Session 自己的内部指针(lastEntryIdseq),不进上下文。

为什么 if (entry.type !== "message") continue 现在只有一种条目类型,这行看着多余。但它是向前兼容的开关:将来加入 model_changecompaction 等条目类型时,旧代码遇到不认识的类型会跳过而不是崩溃。一行代码换一个演进空间。

要不要重放工具? 不要。工具结果已经作为消息存在文件里了,重放既浪费时间又可能产生副作用——想象一下重放一次「删除文件」。会话恢复的是对话记录,不是执行过程。

⚠️ 常见误解恢复之后忘了把游标对齐
恢复出 6 条消息后,如果 persisted 还是 0,第一次落盘就会把这 6 条**再写一遍**,文件里出现两份历史,下次恢复就变成 12 条。src/main.ts 里那行 let persisted = restored.length; 就是防这个的。自查方法:恢复后立刻退出,文件行数应当纹丝不动。

--continue 的实现

只剩最后一块拼图:怎么知道「最近一次会话」是哪个文件。

ts
/** 找出目录里最近修改过的会话文件,供 --continue 使用。 */
export async function findLatestSession(dir: string): Promise<string | undefined> {
  let names: string[];
  try {
    names = await readdir(dir);
  } catch {
    return undefined;
  }

  const candidates = names.filter((name) => name.endsWith(".jsonl"));
  let latest: { file: string; mtimeMs: number } | undefined;
  for (const name of candidates) {
    const file = path.join(dir, name);
    const info = await stat(file);
    if (!latest || info.mtimeMs > latest.mtimeMs) {
      latest = { file, mtimeMs: info.mtimeMs };
    }
  }
  return latest?.file;
}

readdir 外面那个 try/catch 处理的是「目录还不存在」——第一次运行时 .sessions/ 根本没被创建过,这不是错误,返回 undefined 让调用方去新建就好。

启动时的分支(src/main.ts 开头):

ts
const wantsContinue = argv.slice(2).includes("--continue");

let session: Session;
let restored: Message[] = [];

const latest = wantsContinue ? await findLatestSession(sessionDir) : undefined;
if (latest) {
  const opened = await Session.open(latest);
  session = opened.session;
  restored = opened.messages;
} else {
  if (wantsContinue) {
    console.log("没有找到可恢复的会话,将新建一个。");
  }
  const id = `s${Date.now().toString(36)}`;
  session = await Session.create({ dir: sessionDir, id, cwd: projectRoot });
}

注意 --continue 但没找到文件时的处理:打印一句话,然后正常新建,而不是报错退出。这是命令行界面(CLI,Command-Line Interface)的一条通用取舍——用户的意图是「接着上次聊」,没有上次时最合理的行为是「那就从头开始」,而不是让他先去创建一个会话再回来。

图加载中…

图 8.4-2 --continue 的启动分支:两条路汇进同一个循环
阅读顺序:从上往下,两条路最后都汇到 I。要关注的是 G 和 H 这一层——它们做的是同一件事的两个版本:**把上下文和游标同时设好**。这两个值必须成对设置,只设一个就会出现上一节说的「历史写两遍」。B 的两个出口都合法:找不到文件不是错误,直接走新建那条路。

想亲手验证,用交互模式跑两次:

bash
npm start                 # 聊两句,输入 /exit 退出
npm start -- --continue   # 应当打印「已恢复 N 条消息」

npm start 后面那个多出来的 -- 是 npm 的规矩:告诉它后面的参数原样传给脚本,而不是被 npm 自己吃掉。直接跑 npx tsx src/main.ts --continue 效果相同。

回到 Pi 源码

Pi 的会话存储在同一套思路上做了更多事。先看骨架——它同样是「第一行 header + 其后每行一个条目」的 JSONL(源码事实):

earendil-works/pi@c13ffe1第 30–39 行在 GitHub 查看 ↗
Pi 的会话文件头:字段与我们的一一对应(type / version / id / timestamp / cwd),当前版本号是 3。parentSession 是我们没有的——它记录这个会话是从哪个会话 fork 出来的。

条目的公共基类比我们多一个 timestamp,并且 parentId 的语义与我们完全一致(源码事实):

earendil-works/pi@c13ffe1第 46–51 行在 GitHub 查看 ↗
所有条目共享的四个字段:type、id、parentId、timestamp。parentId 就是本章反复强调的那个「我挂在谁下面」。

真正的差距在条目种类。我们只有 message 一种,Pi 有九种(源码事实):

earendil-works/pi@c13ffe1第 143–153 行在 GitHub 查看 ↗
九种条目构成的可辨识联合(Discriminated Union):消息、思考等级切换、模型切换、上下文压缩、分支摘要、扩展自定义数据、自定义消息、标签、会话信息。会话文件因此不只是消息记录,而是「这段会话里发生过的一切」的账本。

这解释了我们 open() 里那行 if (entry.type !== "message") continue 为什么重要:在 Pi 那边,重建上下文时要按类型分别处理——模型切换条目用来恢复「上次用的是哪个模型」,压缩条目用来决定哪些旧消息该被摘要替换。

落盘时机。Pi 是事件驱动的:Agent Loop 每条消息定稿时发一个 message_end 事件,会话层监听它并立刻追加一条(源码事实):

earendil-works/pi@c13ffe1第 625–643 行在 GitHub 查看 ↗
AgentSession 的事件处理器 _handleAgentEvent 中的一段:message_end 时按消息角色分派到不同的 append 方法。循环本身照样不知道持久化的存在——它只管发事件,谁想存谁去订阅。

这是我们那个游标方案的「更细粒度」版本:解耦的手段从「跑完做差集」换成了「订阅事件」,但被解耦的两方没变。写盘动作本身也和我们一样是逐行追加,只是多了一层「延迟建文件」的巧思(源码事实):

earendil-works/pi@c13ffe1第 1015–1042 行在 GitHub 查看 ↗
落盘函数:只要还没有出现过 assistant 消息,条目就只留在内存;第一条 assistant 消息到达时才用 "wx" 一次性写出全部条目,此后每条 appendFileSync 追加一行。效果是:只输入了一句话就退出的会话,不会在磁盘上留下垃圾文件。

读取与恢复。Pi 的读取比我们的 split("\n") 稳健得多——它按缓冲区流式扫描换行符,并且跳过解析失败的行而不是整体报错(源码事实):

earendil-works/pi@c13ffe1第 514–556 行在 GitHub 查看 ↗
逐块读取、按行解析,坏行直接跳过,最后再校验第一行确实是 header。上一节说的「崩溃只毁掉最后一行」,在这里变成了实实在在的容错能力。

版本处理上两边选了相反的策略,值得对照。我们和 Pi 的新 harness 实现都是「不认识就报错」,而 pi CLI 现役的 SessionManager自动迁移旧文件(源码事实):

earendil-works/pi@c13ffe1第 277–291 行在 GitHub 查看 ↗
读取时按 header 里的版本号依次跑 v1→v2、v2→v3 迁移,迁移过就把整个文件重写一遍。version 字段的价值在这里兑现。
earendil-works/pi@c13ffe1第 65–77 行在 GitHub 查看 ↗
pi-agent-core 里较新的 JSONL 存储实现则直接拒绝非 3 版本的文件,不做迁移。一种看法是这让新实现保持了简洁,代价是它读不了历史文件——两套实现的磁盘布局刻意保持一致,但兼容策略并不相同(详见 6.5)。

--continue 本身。Pi 的 pi -c 落到的代码只有两行(源码事实):

earendil-works/pi@c13ffe1第 386–388 行在 GitHub 查看 ↗
CLI 启动时的分支:--continue 直接交给 SessionManager.continueRecent。同一个函数里还并列着 --resume--session--fork--no-session 等分支。
earendil-works/pi@c13ffe1第 635–656 行在 GitHub 查看 ↗
「最近一次会话」的判定:读目录下所有 .jsonl 的 header,按 mtime 倒序,取第一个。这正是我们 findLatestSession 的完整版——多做的是读 header 校验合法性、并按工作目录过滤。
earendil-works/pi@c13ffe1第 1557–1565 行在 GitHub 查看 ↗
找到就打开它,找不到就新建一个——和我们 src/main.ts 里的分支逻辑完全同构。

恢复出来的消息最终去了哪里?和我们把它赋给 context.messages 一样,Pi 把它赋给 Agent 的状态(源码事实):

earendil-works/pi@c13ffe1第 362–374 行在 GitHub 查看 ↗
恢复已有会话时把消息灌回 agent;新会话则先写入 model_change 与 thinking_level_change 两个条目,好让**下次**恢复时能连模型和思考等级一起还原。

最后是我们没有做而 Pi 做了的那件事:parentId 真的被用来长成一棵树。

图加载中…

图 8.4-3 parentId 的两种用法:直线与树
阅读顺序:先看上半部分,我们的每个条目都挂在前一个后面,于是「文件行序」恰好等于「结构顺序」。再看下半部分:e5 的 parentId 是 e2 而不是 e4,于是同一个文件里出现了两条时间线。文件里 e5 仍然排在 e4 后面——行序没变,结构变了。Pi 靠一个「当前叶子(leaf)」指针记住自己站在哪条线上,/tree 命令就是在移动这个指针。分支与压缩的完整机制见 5.56.5

把全部差异汇总一次:

关注点step06Pi
文件格式JSONL,header + 条目同左(session-manager.ts:30-39
版本号1,不认识就报错3,CLI 侧自动迁移 v1→v3;harness 侧拒绝(277-291jsonl-storage.ts:65-77
条目类型1 种(message)9 种(143-153
条目字段id / parentId / message多一个 timestamp(46-51
条目 id递增 e1e2随机短 id,带冲突检测
落盘时机每轮结束批量补写message_end 事件逐条追加(agent-session.ts:625-643
建文件时机create() 时立刻写 header延迟到第一条 assistant 消息(1015-1042
读取容错split("\n"),坏行会抛错流式扫描,坏行跳过(514-556
结构直线树 + leaf 指针
存放位置步骤目录下的 .sessions/~/.pi/agent/sessions/--<编码后的 cwd>--/
存储后端只有文件抽象出接口,另有内存与 SQLite 实现(6.5

官方文档 packages/coding-agent/docs/sessions.md 也印证了这套用户可见的行为:会话自动保存在 ~/.pi/agent/sessions/,按工作目录分组,pi -c 继续最近一次会话,pi -r 从历史会话里挑一个。

🌱 初学者提示为什么 Pi 要按工作目录分目录存
因为「最近一次会话」这个概念只在某个项目内部才有意义。你在 A 项目里敲 pi -c,想接的是 A 的对话,而不是十分钟前在 B 项目里那次。Pi 把工作目录编码进目录名来实现这一点。我们的 step06 因为整个项目就一个目录,不需要这层。

实践任务

🛠 实践任务给会话文件加上第二种条目类型labs/mini-agent-harness/step06-sessions

目标:把 step06 的条目联合从 1 种扩成 2 种,亲手体验一次「格式演进」。你会加入一个 model_change 条目,记录对话中途换过模型——这正是 Pi 九种条目里的一种。

步骤

① 在 src/session.ts 里加一个条目类型,并把它并进联合:

ts
export interface ModelChangeEntry {
  type: "modelChange";
  id: string;
  parentId: string | null;
  model: string;
}

export type SessionEntry = MessageEntry | ModelChangeEntry;

② 给 Session 类加一个 appendModelChange(model: string),照着 append() 写:递增 seq、用 this.lastEntryIdparentIdappendFile 一行、再前移 lastEntryId

③ 在 src/demo.ts 的两次 say() 之间插一行 await first.appendModelChange("fake-model-v2");

④ 先不要open(),直接 npm run demo

预期现象:第二段「重新启动」里恢复出来的消息数应当仍是 6,角色序列不变——因为 open() 里那行 if (entry.type !== "message") continue 把新条目跳过了。而 printSessionFile 打印的行数会从 7 变成 8。这就是向前兼容的样子:加了新条目类型,旧的读取逻辑不崩溃、也不产生错误的上下文。

如何判断成功:① 文件行数增加而恢复出的消息数不变;② 把 continue 那行临时改成 messages.push((entry as MessageEntry).message),重跑,观察恢复出的第 3 条消息变成 undefined——这就是没有类型开关时会发生的事。看完把它改回去。

常见错误:① 忘了在 appendModelChange 里前移 lastEntryId,导致后面的消息条目挂错父节点(打印出来能看到两个条目的 parent 相同);② 直接改 version 却没改 open() 的检查,于是自己写出来的文件自己读不了——这恰好演示了版本号的双向约束。

进阶:让 open() 真正处理这种条目:返回值里多带一个 lastModel: string | undefined,重放时遇到 modelChange 就更新它。这就是 Pi 在 sdk.ts:362-374 做的事——恢复的不只是消息,还有「上次是怎么配置的」。

源码位置labs/mini-agent-harness/step06-sessions/src/session.tsSessionEntrySession.appendSession.open)、src/demo.ts。对照 Pi:packages/coding-agent/src/core/session-manager.ts:143-153(九种条目)与 :63-67ModelChangeEntry 的真实定义)。

更多实践入口见实践任务索引

本章小结

  • 会话文件是账本不是快照:JSONL 追加写让「写一条消息」的成本与历史长度无关,崩溃时最多毁掉最后一行,而整文件重写两样都做不到。
  • 文件结构是一行 header + 其后每行一个条目version 让未来的格式变更有据可依,cwd 记住会话产生的位置,parentId 给分支留出接口。
  • 条目包着消息id / parentId 属于存储层,模型永远看不到它们;message 才是对话内容。
  • 游标(已持久化条数) 让 Agent Loop 对持久化一无所知:循环只管往 context.messages 里塞,跑完由调用方做差集补写。代价是崩溃会丢当前这一轮。
  • 恢复 = 重放条目重建 messages,不是重放执行过程;工具结果作为消息存在文件里,绝不能重新执行一次。恢复后必须把游标对齐到已恢复条数,否则历史会被写两遍。
  • --continue 三步:按 mtime 找最近的 .jsonlopen() 校验并重放 → 把消息和游标同时设好。找不到文件时新建,而不是报错。
  • Pi 是同一套骨架的完整版:九种条目、事件驱动的逐条落盘、延迟建文件、坏行跳过的流式读取、自动版本迁移,以及真正长成树的 parentId
  • 关键术语:会话(Session)JSONLappend-only(只追加、不修改)条目(entry)与消息(message)游标(persisted 条数)向前兼容branch(分支)
  • 关键源码索引:packages/coding-agent/src/core/session-manager.ts:30-39(header)、:46-51(条目基类)、:143-153(九种条目)、:514-556(读取容错)、:277-291(版本迁移)、:1015-1042(延迟建文件与追加写)、:635-656(找最近会话)、:1557-1565(continueRecent)、packages/coding-agent/src/main.ts:386-388--continue 分支)、packages/coding-agent/src/core/agent-session.ts:625-643(事件驱动落盘)、packages/coding-agent/src/core/sdk.ts:362-374(把消息灌回 Agent)、packages/agent/src/harness/session/jsonl-storage.ts:65-77(拒绝旧版本)。
  • 自测问题:
    1. 为什么 toolResult 消息必须写进会话文件?不写会导致模型看到什么样的历史?
    2. 一次用户输入可能往文件里追加几行?说出决定这个数字的因素。
    3. 恢复会话后如果忘了设置 persisted,第一次落盘会发生什么?第二次恢复呢?
    4. open()if (entry.type !== "message") continue 这一行,在只有一种条目类型时是不是多余的?为什么?
  • 下一章:8.5 取消与 Extension Hook——会话解决了「进程退出后还活着」,接下来解决「运行途中要停下来」:一条 AbortSignal 怎么贯穿到最底层的等待,以及怎么让外部文件在不改核心代码的前提下注册新工具。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)