实现一个 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 成独立会话。这些「时间旅行」式的操作在树上具体怎么实现?下一章见。

相关推荐
jt君424262 小时前
React Native JSI 深入剖析 — 第 5 部分中文技术整理:用 HostObject 把 C++ 类暴露给 JavaScript
前端·react native
胡萝卜术2 小时前
滑动窗口最大值:从暴力到单调队列,层层优化全解析
前端·javascript·面试
fluffyox2 小时前
Notion 的公式栏里,藏着一台虚拟机——逆向 + 用 600 行 JS 复刻它的编译器与栈式 VM
前端
kyriewen3 小时前
2026 年了,这 6 个 npm 包可以卸载了——浏览器原生 API 已经能替代
前端·javascript·npm
沉默王二4 小时前
面试结束后,我反问:“就面个实习至于上这么大强度吗?”面试官:“你对 RAG、Agent、MCP、Skill 理解得很到位,所以要求高一点。”
面试·agent·ai编程
怕浪猫5 小时前
第一章:AI Agent概览:开启智能体时代
aigc·agent·ai编程
JouYY5 小时前
简单聊一下Harness层中的人机协同(HITL)
前端框架·llm·agent
Csvn6 小时前
Monorepo 迁移血泪史:从 Multi-Repo 到 Turborepo,这 3 个坑我帮你踩完了
前端
leeyi6 小时前
Multi-Agent:让多个 AI 分工协作完成复杂任务
后端·aigc·agent