实现一个 Coding Agent(8):会话持久化与多会话

📂 本章配套代码github.com/yinguangyao...

前几章我写的那个循环有个问题:每次跑都是一次性的。tsx loop.ts "..." 跑完,进程一退,刚才跟模型聊了什么、它读过哪些文件、试过哪条不通的路------全没了。第二天打开终端,又得从头交代一遍上下文。

可你用过的 Cursor、Claude Code 都不是这样:关掉再开能接着上次说;同一个目录开两个窗口互不干扰;昨晚让它 rm -rf 删错了的那一步,今天还能撤回去重走。

这三件事------重启续聊、多会话隔离、回退重走------合起来就是「会话」要解决的问题。

8.1 安装和运行

这一章的入口是 code/ch08/chat.ts。沿用前几章那份共享的 package.json,配好环境变量就能跑:

bash 复制代码
cd code
npm install
export PI_BASE_URL=https://api.deepseek.com/v1
export PI_API_KEY=sk-xxxx
export PI_MODEL=deepseek-v4-flash

npx tsx ch08/chat.ts

这里连续跑两次,中间用 Ctrl+C 退出进程:

shell 复制代码
$ npx tsx ch08/chat.ts                                # 第一次:新建会话
> 现在几点
[现在是 23:14]
> ^C                                                  # 直接退出

$ npx tsx ch08/chat.ts                                # 第二次:重启,它自己接上了
[session ...已恢复 2 条消息]
> 那再过 10 分钟呢
[再过 10 分钟是 23:24,按你前一句"现在几点"算的]

第二次启动我什么都没说,它就记得「现在几点」那句。答案就在磁盘上的一个文件里:

ruby 复制代码
$ ls ~/.pi/agent/sessions/--Users-you-tmp--/
2026-05-28T15-14-01-993Z_019042a3-....jsonl       ← 整段对话就躺在这儿

那么问题来了:这个文件里到底装了什么、为什么是这个样子、重启时它又是怎么被准确读回来的?跑完这一章你会有:一个从零搭起、跟 pi 同款 API 的 mini SessionManager;一套支持「回退重走」的树形 leaf 指针模型;以及一份能彻底读懂 pi 那个 JSONL session 文件的能力。

8.2 一条消息的旅程

理解一个系统最好的方式,就是跟着一条数据走完它的全部旅程。

现在,假设我在终端敲下「现在几点」,回车。让我们跟着这句话,看它在会话系统里要经过哪几站:

javascript 复制代码
我敲下「现在几点」
   │
   ├─①  存哪儿、用什么格式?        → JSONL,一行一条(8.3)
   ├─②  存到哪个文件?             → 按 cwd 隔离的目录(8.4)
   ├─③  我想回头重来怎么办?        → 从一条线升级成一棵树(8.5)
   ├─④  这棵树怎么喂回给模型?      → 从 leaf 回溯取路径(8.6)
   ├─⑤  回头重来具体怎么落地?      → branch:拨一下指针(8.7)
   └─⑥  这句话什么时候真写进磁盘?   → 延迟落盘,躲开孤儿文件(8.8)

下面就一站一站地走。每一站我都先抛一个问题,再追着代码找答案------搭到第 ⑥ 站,mini SessionManager 就齐了。

8.3 JSONL vs 大 JSON

要「重启接着说」,最直接的想法就是:每来一条消息,写进一个文件;下次启动读回来当历史。问题只剩一个------用什么格式存?

第一反应几乎都是「存一个大 JSON」:

ts 复制代码
// 每来一条新消息:
const data = JSON.parse(readFileSync(path));   // 读出整个文件
data.messages.push(newMessage);                // 改数组
writeFileSync(path, JSON.stringify(data));     // 整个写回去

{ messages: [...] },read → 改 → write。能跑。但你把它放到一个会跑几百轮、随时可能被 Ctrl+C 的 agent 上,三个坑立刻浮出来:

  1. 崩溃 / 断电会丢全部数据。read-modify-write 写到一半挂了,整个文件可能被截断成空文件------几百条历史一起丢掉。
  2. 多 process 写同一文件会撞。我开两个终端窗口对着同一个会话操作,这种 lock-modify-unlock 写法非常脆弱。
  3. 追加代价跟历史长度成正比。一万条消息的对话,每加一条都要把 50KB 重写一遍。

那有没有一种格式,让「加一条消息」永远只是「往末尾追加」、绝不回头动已经写过的东西?有,就是 JSONL(JSON Lines) :文件每一行是一段独立合法的 JSON,行与行之间用 \n 隔开。

json 复制代码
{"type":"session","id":"019042a3-...","timestamp":"2026-05-28T15:14:01.993Z","cwd":"/Users/you/tmp"}
{"type":"message","role":"user","content":"现在几点"}
{"type":"message","role":"assistant","content":[{"type":"text","text":"23:14"}]}

3 行 = 1 个 session 文件 = 1 段对话。第一行是会话的元信息(header),后面每行是一条消息。就这么一个「一行一条、只往后加」的小改动(业内叫 append-only),刚才三个坑全被堵上了------这里有几点值得说明:

  • 首先,写一条 = appendFileSync 一行 ,这是原子操作(一次 syscall,POSIX 保证 O_APPEND 写入不会跟其他 process 的 append 撕裂)。多进程并发追加也不会互相撕烂。
  • 其次,崩溃也不丢前面的数据。已经落盘的整行都还在,最坏只是最后那行没写完、不完整------解析时跳过它就行。
  • 最后,追加代价 O(1)。不管历史多长,append 一行就是几个字节的 I/O,再也不用整文件重写。

读回来也对称地简单------按行 JSON.parse

ts 复制代码
function parseEntries(text: string): FileEntry[] {
  const out: FileEntry[] = [];
  for (const line of text.split("\n")) {
    if (!line.trim()) continue;          // 跳过空行
    try {
      out.push(JSON.parse(line));
    } catch {
      /* 跳过损坏行,不让整个文件连坐 */
    }
  }
  return out;
}

注意那个 try/catch 后 continue 。这是 append-only 带来的好处:损坏永远只可能停在最后一行,跳过它,前面几百条照样能读出来。要是大 JSON 文件坏了一个字节,整个 JSON.parse 直接抛错,你一条都拿不回来。

到这儿第一站走完:这句话以一行 JSON 的形态,被追加进了文件。下一个问题------它该被追加到哪个文件里?

8.4 文件放哪儿

「重启接着说」其实是两步:(1) 每个会话存成独立文件;(2) 重启时找到「最近那一个」读回来。第二步依赖一个规整的目录结构。

我让 mini 版用跟 pi 一模一样的路径:

xml 复制代码
~/.pi/agent/sessions/--<把 cwd 的 / 换成 - >--/<timestamp>_<uuid>.jsonl

实例:

javascript 复制代码
~/.pi/agent/sessions/--Users-you-tmp--/2026-05-28T15-14-01-993Z_019042a3-7b4f-....jsonl

为什么是这个样子?拆开每一层看,每个设计都对着一个具体需求:

  • ~/.pi/agent/sessions/:全局根目录,所有会话都收在这。
  • --Users-you-tmp--/ :把 cwd 里的 / 换成 -,外面再包一对 --。这是按 cwd 隔离会话 ------我在 ~/projects/a 里开的会话,跟在 ~/projects/b 里开的,列表完全分开,互不影响。那为什么还要外面那对 --?因为 a--b 本身就是个合法目录名,不包一下,编码后的文件名可能跟真实项目名撞上。
  • <timestamp>_<uuid>.jsonl :timestamp 用 ISO 8601、把 :. 都换成 -。这一步的好处是------文件名按字典序排,正好就是按时间序排ls 看到的顺序就是创建顺序,「挑最近一个」直接 sort() 取末尾即可。后面的 uuid 只是兜底,防同一毫秒内撞名。

对应到代码,三个静态构造方法就覆盖了「新建 / 打开指定 / 接着最近」三种入口:

ts 复制代码
function sessionDirFor(cwd: string): string {
  const encoded = cwd.replace(/[\\/]/g, "-");
  return join(homedir(), ".pi", "agent", "sessions", `--${encoded}--`);
}

static create(cwd: string): MiniSession {
  const dir = sessionDirFor(cwd);
  mkdirSync(dir, { recursive: true });
  const ts = new Date().toISOString();
  const file = join(dir, `${ts.replace(/[:.]/g, "-")}_${randomUUID()}.jsonl`);
  return new MiniSession(cwd, file);
}

static continueRecent(cwd: string): MiniSession {
  const dir = sessionDirFor(cwd);
  if (!existsSync(dir)) return MiniSession.create(cwd);    // 第一次跑,没目录 → 新建
  const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
  if (files.length === 0) return MiniSession.create(cwd);  // 有目录但空 → 新建
  return MiniSession.open(join(dir, files[files.length - 1])); // 字典序最后一个 = 最近
}

continueRecent 就是 8.1 里「第二次启动自动接上」的全部逻辑:列目录、排序、挑最新的 open。这里有个容易漏的细节------它找不到文件时要 fallback 到 create,否则用户第一次在一个新项目里跑就会直接报错。

走到这儿,这句话不仅被存成了一行 JSON,还被存进了「属于这个项目的、按时间排好序的」文件里。重启续聊已经成立。但真正的 coding agent 还差最关键的一步------我想回到上一步重来。

8.5 从线性到树

到目前为止,我们存的就是一串行,纯线性。绝大多数时候够用。但 agent 场景里有个高频动作,线性结构扛不住。看这个真实例子:

c 复制代码
> 用 sed 把所有 .ts 文件里的 console.log 删掉
[agent 跑了 bash,结果不对------把字符串里的 "console.log" 也给删了]
> 不对,回到上一条,换个思路

那么问题来了:如果会话是一个线性数组,「回到上一条」该怎么实现?

只能是「截断后面、重写文件」。可这么一来历史就丢了------万一新思路也不行,我还想回头看看刚才那条 sed 到底改坏了哪几个文件呢?我既不想丢历史,又想自由地回去重走。这两个诉求合起来,指向同一个数据结构:

把「一条线」升级成「一棵树」,只要给每个 entry 加两个字段:

ts 复制代码
type SessionEntry = {
  type: "message";
  id: string;               // 这条 entry 自己的 id
  parentId: string | null;  // 指向父节点;根节点是 null
  timestamp: string;
  message: any;             // user / assistant / tool 那些
};

每条 entry 记住自己的父节点是谁,整个文件就从一串行变成一棵树。这下「回到上一条重走」不再是截断,而是在老节点下面长出一根新枝

css 复制代码
[user] → [assistant] → [user "用 sed..."] → [tool_calls] → [tool_result] → [assistant: 删错了]
                              │
                              └──(我在这儿回退)→ [user "改用 grep..."] → ...(新分支)

我第二次的输入会挂在原来「用 sed...」那条消息的同一个父节点底下,长成新分支。原来那条 sed 的分支整条还躺在文件里,只是当前的「活动路径」切到了新枝上。这就是 pi 为什么选树而不选线性数组------既不丢历史,又能让用户自由回退重走

这里说一句 id 的取值。我用 8 位 hex(randomBytes(4).toString("hex"))就够了:16⁸ ≈ 43 亿种可能,一段对话通常 100 条以内 entry,撞的概率极低;生成时顺手做次冲突检查,实在撞了再 fallback 到完整 UUID。为什么直接用满 UUID?因为这字段每一行都要写一遍,太长了。

有了分叉,就得有个东西记着「我现在站在树上哪个位置」------这就是 leafId。它只有三种状态,理解了它就理解了整个会话模型:

leafId 取值 含义 下次 append 的后果
null 还没有任何 entry 创建一个 parentId: null 的根节点
"abc12345" 当前路径末端是这条 新节点 parentId = "abc12345",线性增长
被拨回某个旧节点 我回退了 新节点挂在那个旧节点下,长出新分支

于是「追加一条消息」就是两个动作:在当前 leaf 下面挂个新节点,再把 leaf 推进到新节点。

ts 复制代码
appendMessage(message: any): string {
  const id = this._genId();
  const entry: SessionEntry = {
    type: "message",
    id,
    parentId: this.leafId,     // ← 挂在当前 leaf 下面
    timestamp: new Date().toISOString(),
    message,
  };
  this.entries.push(entry);     // 内存累计
  this.byId.set(id, entry);     // 内存索引,按 id 快速查
  this.leafId = id;             // ← 新节点成为新的 leaf
  this._persist(entry);         // 落盘(第六站细说)
  return id;
}

parentId: this.leafIdthis.leafId = id 这两行,就是整棵树的全部精髓:挂上去、再前进。线性对话时它是一条单链;我回退过,它就分叉。

8.6 buildContext

存的是一棵树,可发给 LLM 的 messages 必须是一条线性数组。那么这棵树怎么变回一条线?

答案是只取「当前活动路径」------从 leafId 出发,沿着 parentId 一路往上回溯到根,再翻转过来:

ts 复制代码
buildContext(): any[] {
  if (this.leafId === null) return [];
  const path: SessionEntry[] = [];
  let cur = this.byId.get(this.leafId);
  while (cur) {
    path.unshift(cur);                                    // 往头部塞 → 自动翻转
    cur = cur.parentId ? this.byId.get(cur.parentId) : undefined;
  }
  return path.map((e) => e.message);
}

核心就一句话:从 leaf 沿 parentId 反向走到根,沿途收集,翻转成「根 → leaf」。这是树里取一条路径的标准做法。

它的好处在于------别的分支根本不用你去过滤,天然就被排除了。来看个有两条分支的例子,文件里 7 条 entry:

scss 复制代码
A (root)
├── B (user) ── C (assistant)                          ← 旧分支(被弃用的"用 sed"那条)
└── D (user, 重写过的) ── E (assistant) ── F (user) ── G (assistant)  ← 当前 leaf

leafId = "G" 时调 buildContext(),从 G 往上回溯,只会走出 A→D→E→F→G 。B 和 C 虽然还在文件里、也在 byId 索引里,但它们不在 G 的祖先链上 ,自然一条都不会进 LLM context。这就是「分支互不干扰」的根本机制------不需要任何删除、不需要任何标记过滤,全靠 leaf 的祖先链天然隔离

那如果我用 /tree 切回 C 呢?leafId = "C",下次 build 出来就是 A→B→C,D 到 G 全跳过。一句话:当前站在哪个 leaf,就决定了模型能看见哪段历史。

8.7 branch

前面铺了那么多树形结构,那「回退 / 分支」这个操作,代码到底长什么样?很简单------就是把指针往回拨一下

ts 复制代码
branch(id: string): void {
  if (!this.byId.has(id)) throw new Error(`Entry ${id} not found`);
  this.leafId = id;
}

就这三行。leaf 是个指针,分支就是把它指回某个旧节点。之后我输入新消息,appendMessage 自然会创建一个 parentId 指向那个旧节点的 entry------新分支就形成了。前面 8.5、8.6 铺的那些树形结构,到这里就是为了让这三行成立。

那问题来了:回退之后,旧分支不就没用了吗,删掉省地方不好吗?pi 不删,这是一个有意的权衡------

  • 首先是可恢复 :我后悔了能 branch 回去,这正是「时间旅行」的价值所在。
  • 其次是审计和 debug:旧路径上那些工具调用的结果,后面可能还要回头引用。
  • 最后是 append-only 本身:从不修改、从不删除任何已写的 entry,文件操作就只剩 append 这一种------并发出错的概率被压到极低。

代价当然有:文件会比「线性截断」模型大一些。但 JSONL 一行通常也就 100~1000 字节,一段长会话最多几 MB,不值得为这点空间去牺牲上面三条好处。

这里提醒一句,免得你误以为「分支永远免费」:纯 branch() 确实只是拨指针、零成本。但 pi 还有个带总结的变体 branchWithSummary------当你回退、离开一条已经做了不少事的分支时,它会先调一次 LLM 把那条被你抛下的分支总结成一段记忆,挂到你导航过去的新位置上。这样「回去重走」时,模型还记得上一条路学到的东西(读过哪些文件、哪些方案试过不通),而不是凭空失忆。那个变体要花一次模型调用,留到第 9 章细讲。记住这句就行:纯指针移动免费,带记忆的回退要花钱。

8.8 延迟落盘

最后一站,细节很小但很重要。先看一个实际踩到的坑:

用户经常新建一个会话、敲下第一句、然后发现不对、直接 Ctrl+C 退出 。如果我老老实实「每条消息立刻落盘」,磁盘上就会攒下一堆只有 user 消息、没有 assistant 回复 的文件。更麻烦的是,continueRecent 还会把这些空壳当成「最近会话」恢复回来。

那怎么办?pi 的做法是:先在内存里 buffer,一直攒到拿到第一条 assistant 消息(这说明这次对话至少有来有回、值得保留了),才一次性把 buffer 全部落盘;从此往后每条再走标准 append。mini 版照搬这套:

ts 复制代码
private _persist(entry: SessionEntry): void {
  if (!this.filePath) return;                              // inMemory 模式不落盘
  const hasAssistant = this.entries.some((e) => e.message?.role === "assistant");
  if (!hasAssistant) {
    return;                                                // 还没 assistant → 只在内存 buffer
  }
  if (!this.flushed) {
    // 第一次拿到 assistant:把 header + 之前所有 entry 一次性写下去
    const lines = [
      JSON.stringify(this.header),
      ...this.entries.map((e) => JSON.stringify(e)),
    ];
    writeFileSync(this.filePath, lines.join("\n") + "\n");
    this.flushed = true;
  } else {
    appendFileSync(this.filePath, JSON.stringify(entry) + "\n");  // 之后标准追加
  }
}

逻辑就三段:没 assistant 就先攒着不写 → 第一次拿到 assistant 把整个 buffer 一次性 flush → 之后每条 append 追加。用户中途 Ctrl+C,磁盘上干干净净,一个孤儿文件都不留。

这里跟 pi 有个差别,值得专门点出来 :首次落盘 mini 版用的是 writeFileSync(直接覆盖),pi 真实实现用的是 openSync(path, "wx")------wx 表示「独占创建,文件已存在就抛错」。单进程下两者没区别;但万一我同时开了两个进程、continueRecent 又恰好选中同一个文件名,writeFileSync 会让后写的那个静默覆盖 先写的(数据被悄悄丢掉),而 wx 会当场报错让你发现。mini 版为了简单用了 writeFileSync,要更稳就换 wx(见 8.13 坑 #3)。

走到这儿,「现在几点」这句话的旅程就完整了:它被存成一行 JSON(第一站),落进按项目隔离的文件(第二站),挂上 id/parentId 成为树上一个节点(第三站),能被 buildContext 沿祖先链取回来喂模型(第四站),能被 branch 分叉重走(第五站),并且只在确实有了模型回复后才真正写进磁盘(第六站)。一个能「新建 / 打开 / 接着最近 / 内存」的 mini SessionManager 就此成型------完整代码 code/ch08/session.ts 约 180 行,省略了压缩、分支总结、custom 消息(后续章节再加)、跨版本迁移、TUI 渲染这些工业实现。

8.9 无限对话

零件齐了,串起来就是 8.1 跑的那个 chat.ts。它用一个 readline 循环把 ch06 的循环套起来,每读一行用户输入就接着 session 跑一轮。

那跟 ch06 比,关键差别在哪?ch06 的循环入参是 userInput + systemPrompt,每次从零开始;而会话模式下,我得把整段历史 当「种子」喂进去。所以这里写了个 runOneTurn(messages, tools)------它就是 ch06 循环的「接受 messages 数组」版本,跑完只返回这一轮新产生的 entry(assistant + 各条 tool 消息),方便原样回写 session:

ts 复制代码
import { createInterface } from "node:readline";
import { makeCodingTools } from "../ch05/tools.js";
import { type RuntimeTool } from "../ch06/loop.js";
import { MiniSession } from "./session.js";

const SYSTEM = `你是一个能调用 read/bash/edit/write 工具的中文助手。回答简短。`;

// 从 messages 数组跑一轮(内部可能多 turn),只返回这一轮新增的 entry。
// 完整实现见 code/ch08/chat.ts。
async function runOneTurn(messages: any[], tools: RuntimeTool[]): Promise<any[]> {
  const newEntries: any[] = [];
  const next = [...messages];
  // ... ch06 的 while 循环:调模型 → 收集 assistant → 执行 tool_calls → 回填,
  //     每产生一条 assistant/tool 消息就 push 进 newEntries
  return newEntries;
}

const cwd     = process.cwd();
const session = MiniSession.continueRecent(cwd);    // ← 自动接着上次
const tools   = makeCodingTools(cwd) as RuntimeTool[];

const prevMessages = session.buildContext();        // ← 恢复历史
console.log(`[session ${session.getFile()} 已恢复 ${prevMessages.length} 条消息]`);

const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.setPrompt("> ");
rl.prompt();

rl.on("line", async (line) => {
  const userInput = line.trim();
  if (!userInput) { rl.prompt(); return; }

  session.appendMessage({ role: "user", content: userInput });    // 1) 用户消息进 session

  const messages = [                                              // 2) system + 整段历史
    { role: "system", content: SYSTEM },
    ...session.buildContext(),
  ];

  const newEntries = await runOneTurn(messages, tools);           // 3) 跑一轮

  for (const e of newEntries) session.appendMessage(e);           // 4) 新 entry 回写 session

  rl.prompt();
});

runOneTurn 的循环体跟 ch06 一字不差,只是把「返回完整 messages」改成「只返回新增的 newEntries」------这样回写时就不用再 slice(messages.length) 去算差集了,省去一步。跑起来:

css 复制代码
$ npx tsx ch08/chat.ts
[session .../2026-05-28T15-14-01-993Z_xxx.jsonl 已恢复 0 条消息]
> ls 一下当前目录有什么
[turn] [tool] bash({"command":"ls"})
[result] file1.ts\nfile2.ts\n
[turn] 当前目录有 file1.ts 和 file2.ts。
> ^C

$ npx tsx ch08/chat.ts
[session .../2026-05-28T15-14-01-993Z_xxx.jsonl 已恢复 3 条消息]
> 给我看下 file1.ts 内容
[turn] [tool] read({"path":"file1.ts"})
[result] ...
>

第二次启动自动恢复了 3 条历史,模型记得我刚让它 ls 过。第 4~6 章那个「跑完就忘」的循环,到这里终于变成了「无限对话」。

8.10 对照 pi

mini 版搭完了,接下来把它跟 pi 的 packages/coding-agent/src/core/session-manager.ts(约 1500 行)对一遍。你会发现:pi 无非是在我们这套骨架上,加了「更多 entry 类型 + 版本迁移 + 落盘加固」而已。骨架一样,你就能读懂全部。先看一一对应的部分:

概念 mini 版 pi 对应(session-manager.ts 差别 / 要点
树节点字段 id / parentIdshortId 生成 SessionEntryBase :44-49generateId :213-221 同款,都是 randomUUID().slice(0,8) + 冲突检查
message 包 envelope message 单独一个字段 SessionMessageEntry 外层树元数据、内层 LLM 协议分层,各自演进(v3 加 custom 只动内层)
header SessionHeader :30-37 pi 多 version(迁移)和 parentSession(fork 源);cwd 决定对话归属哪个项目(cwdOverride :1348 才能改)
append / 落盘 appendMessage + _persist appendMessage :902-912_appendEntry :889-894_persist :860-887 同款延迟落盘;pi 首次写用 openSync(path,"wx") 独占创建
从树取路径 buildContext buildSessionContext :323-431 pi 还会扫非 message entry,返回值除 messages 外含 model / thinkingLevel:362-377
分支 branch(拨指针) branch :1193-1198 带总结的 branchWithSummaryagent-session.ts:2693-2837)→ 第 9 章

表里有一处 why 值得单独记:buildSessionContext 返回的不只是 messages还有 modelthinkingLevel ------它们从路径上的 model_change / thinking_level_change 这类非消息 entry 扫出来。这就解释了为什么回退到旧分支时,模型和思考等级也跟着回到当时那一刻:路径决定的不止对话内容,还有运行时状态。这点 8.11 接着展开。

剩下几样 mini 版没做的:

pi 的能力 出处 mini 版 何时登场
更多 entry 类型(compaction / branch_summary / custom / label / session_info...) session-manager.ts:138-147 只做了 message 第 9/11/15/17 章
版本迁移 v1→v2→v3 migrateToCurrentVersion :274-284 不兼容旧版 ------
forkFrom(跨项目 fork,/clone 用) :1337-1378 第 9 章
wx 独占创建落盘 _persist :860-887 用了 writeFileSync ------

其中版本迁移多说一句:pi v1 是没有 id/parentId 的纯线性版,v2 才加入树形。打开旧文件时 migrateV1ToV2session-manager.ts:224-250)会给每条 entry 补上 id(随机 hex)和 parentId(指向前一条),把老的线性文件改写成一条纯线性的树 ,语义完全等价。迁移完用 _rewriteFile() 整体写回------这是 pi 里少数会重写整个 session 文件的场景之一(另一个是 fork)。日常追加永远走 append,绝不 read-modify-write。

8.11 存的 ≠ 发的

会话文件里存的,不等于发给模型的------这是 coding agent 里最容易被忽略、又最重要的一条边界。

pi 循环操作的是 AgentMessage :它是「LLM 协议消息(user/assistant/tool)」加上 应用自塞的自定义消息(扩展注入的提示、分支总结、压缩摘要)的并集。所以会话树里有一部分东西根本不是 OpenAI / Anthropic 认得的格式 。它们没把请求搞挂,是因为发请求前一刻有两道转换:

  • convertToLlmcoding-agent/src/core/messages.ts)------把自定义消息翻译成模型能读的形态,比如 compaction / branch_summary 摘要都被包成一条普通 role:"user" 消息(...summary:\n<summary>...)。模型只看到普通 user 消息,不知道内部那些 entry 类型。
  • transformContext (harness hook,agent/src/agent-loop.ts:283)------在转换之前AgentMessage 这层做裁剪 / 注入。压缩就挂在这儿:不改 session 文件,只在喂模型前把前 N 条换成摘要。

这层边界的价值是解耦:「我想持久化 / 展示什么」和「协议允许什么」各自演进------扩展塞自定义消息、压缩塞摘要、分支塞总结,都不污染 LLM 协议,因为出门前都被收敛成合法消息或过滤掉。记住这条流水线:

会话树(AgentMessage,含自定义类型)→ transformContext 裁剪/注入 → convertToLlm 翻译过滤 → LLM Message[]

后面第 11 章(压缩)、第 15 章(扩展注入消息)都站在这条边界上。

8.12 三层记忆

初学者对 agent 最大的误解之一是:「它有个记忆库吧?」------以为存在一个会自动学习、跨会话积累知识的「大脑」。pi 没有这个东西。 把「记忆」拆清楚,对建立正确的心智模型很重要。

pi 实际上有三层、性质完全不同的「记忆」,我用一个类比帮你区分:

  1. 短期记忆 = 会话文件本身 。就是这一章的 JSONL,当前对话的完整历史。重启后 continueRecent 把它读回来,于是「接着上次说」。它只在单条会话内有效------像你脑子里这一场对话的上下文。
  2. 工作记忆 = compaction summary第 11 章)。会话太长时,前面的细节被压成一段结构化摘要,继续留在上下文里。它是「短期记忆的有损压缩版」,仍然只在本会话内------像你把一长段经历浓缩成几句话记着。
  3. 长期记忆 = AGENTS.md / CLAUDE.md第 10 章)。这是唯一 跨会话、跨项目持续生效的东西。但请注意:它是用户/项目手写的偏好和约定 ,不是 agent 自己学来的。全局 ~/.pi/agent/AGENTS.md 管你所有项目,项目级 AGENTS.md 管这个仓库------像贴在工位上的便利贴,得你自己写。

所以结论是:pi 不会「记住」你上周让它干过什么,除非那条会话还在、或者你把结论写进了 AGENTS.md 没有那种模型自己往里写的、自动的长期记忆存储。理解了这三层,你就不会再问「它为什么忘了我昨天说的话」------答案永远是:要么那条会话没续上,要么你没写进 AGENTS.md

⚠️ 读源码时有个坑,顺手帮你避开:packages/agent/src/harness/session/memory-repo.ts 里那个 "memory" 不是长期记忆 ------它是一个不落盘的内存版 session 后端InMemorySessionRepo,测试 / 临时用,对应我们 mini 版的 inMemory()),跟落盘的 jsonl-repo.ts 是平级的两种存储实现。看到 "memory" 不要误会,它不是记忆库。

倒是扩展有个「跨 reload 恢复状态」的机制------CustomEntry第 15 章),扩展把自己的状态当作特殊 entry 写进会话,reload 时扫出来重建。但那是扩展的状态持久化,不是模型的记忆,两者不要混淆。

8.13 踩过的坑

  1. JSONL 解析要跳过空行 + 跳过损坏行 ------continue,别 throw。崩溃过的文件末尾经常挂着半截行。
  2. leafId 是指针,不是数组下标------别拿数组 index 标识 entry。pi v1 当年就是用 index,改 v2 的一大动因就是分支会把 index 语义撕裂。
  3. 延迟落盘首次写用 wx 独占创建 ------避免极端情况下两个进程同时新建同名文件、互相静默覆盖。pi 的 timestamp+uuid 命名几乎不会撞,但保险还是 wx(mini 版为了简单用了 writeFileSync,见 8.8 末尾)。
  4. buildContext 一定沿 parentId 走,别按 entries 顺序顺读------entries 数组里可能混着好几个分支的 entry,顺读会把别的分支的消息也带进 LLM context。
  5. parentIdnull 不是 undefined ------pi 在协议层显式区分。JSON.stringify 保留 null、跳过 undefined,意义不一样。
  6. 整文件重写只在两个场景做(版本迁移、fork),日常永远 append------别写成 read-modify-write,那正是 8.3 我们要躲开的坑。
  7. continueRecent 找不到文件时要 fallback 到 create------否则用户第一次在新项目里跑直接报错。
  8. cwd 序列化要把 /- 再包 --------避免深目录路径里的奇怪字符撞库。

8.14 总结

回头看这一章,会话系统能支撑「重启续聊 + 多会话隔离 + 回退重走」这三件看着挺难的事,靠的不是什么复杂技巧,而是几个简单决定的叠加:

  • append-only 的 JSONL 换来「崩溃不丢数据、追加 O(1)、并发安全」------把「写入可靠性」这件事,安放在文件格式这一层。
  • id/parentId 树 + 一个 leaf 指针换来「回退重走、分支隔离」------把「历史与导航」这件事,安放在数据结构这一层。
  • convertToLlm / transformContext 这道转换边界,把「我想存什么、想展示什么」和「模型协议允许什么」彻底解耦------把「持久化格式 vs 协议格式」这件事,安放在「发请求前那一刻」这一层。

每一层只解决一件事,互不干扰。压缩摘要、分支总结、扩展注入的消息,将来都能塞进会话树而不污染 LLM 协议,正是因为这几层边界一开始就划清楚了。这套思路------把复杂度拆散,分别安放在合适的层级里,而不是堆在一个「大脑」里------你在后面的压缩、扩展、多供应商各章还会反复见到。

不过会话终究是一棵,不是一条线。用户能回退、能编辑历史消息重发、能把一条路径 fork 成独立会话。这些「时间旅行」式的操作在树上具体怎么实现?下一章见。

相关推荐
AlienZHOU6 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc9 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师11 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen11 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
65岁退休Coder12 小时前
PI Agent 开发一个生产级 Harness
后端·node.js·agent
贾伟康12 小时前
【HarmonyOS 7新能力|026】Agent Framework Kit工程封装:把接入逻辑放进可维护的分层结构
agent·harmonyos·arkts·a2a·harmonyos 7
前端snow12 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81812 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007812 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端