📂 本章配套代码 :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 上,三个坑立刻浮出来:
- 崩溃 / 断电会丢全部数据。read-modify-write 写到一半挂了,整个文件可能被截断成空文件------几百条历史一起丢掉。
- 多 process 写同一文件会撞。我开两个终端窗口对着同一个会话操作,这种 lock-modify-unlock 写法非常脆弱。
- 追加代价跟历史长度成正比。一万条消息的对话,每加一条都要把 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.leafId 和 this.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 / parentId,shortId 生成 |
SessionEntryBase :44-49,generateId :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 |
带总结的 branchWithSummary(agent-session.ts:2693-2837)→ 第 9 章 |
表里有一处 why 值得单独记:
buildSessionContext返回的不只是messages,还有model和thinkingLevel------它们从路径上的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 才加入树形。打开旧文件时 migrateV1ToV2(session-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 认得的格式 。它们没把请求搞挂,是因为发请求前一刻有两道转换:
convertToLlm(coding-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翻译过滤 → LLMMessage[]
后面第 11 章(压缩)、第 15 章(扩展注入消息)都站在这条边界上。
8.12 三层记忆
初学者对 agent 最大的误解之一是:「它有个记忆库吧?」------以为存在一个会自动学习、跨会话积累知识的「大脑」。pi 没有这个东西。 把「记忆」拆清楚,对建立正确的心智模型很重要。
pi 实际上有三层、性质完全不同的「记忆」,我用一个类比帮你区分:
- 短期记忆 = 会话文件本身 。就是这一章的 JSONL,当前对话的完整历史。重启后
continueRecent把它读回来,于是「接着上次说」。它只在单条会话内有效------像你脑子里这一场对话的上下文。 - 工作记忆 = compaction summary (第 11 章)。会话太长时,前面的细节被压成一段结构化摘要,继续留在上下文里。它是「短期记忆的有损压缩版」,仍然只在本会话内------像你把一长段经历浓缩成几句话记着。
- 长期记忆 =
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 踩过的坑
- JSONL 解析要跳过空行 + 跳过损坏行 ------
continue,别throw。崩溃过的文件末尾经常挂着半截行。 leafId是指针,不是数组下标------别拿数组 index 标识 entry。pi v1 当年就是用 index,改 v2 的一大动因就是分支会把 index 语义撕裂。- 延迟落盘首次写用
wx独占创建 ------避免极端情况下两个进程同时新建同名文件、互相静默覆盖。pi 的 timestamp+uuid 命名几乎不会撞,但保险还是wx(mini 版为了简单用了writeFileSync,见 8.8 末尾)。 - buildContext 一定沿 parentId 走,别按 entries 顺序顺读------entries 数组里可能混着好几个分支的 entry,顺读会把别的分支的消息也带进 LLM context。
parentId是null不是undefined------pi 在协议层显式区分。JSON.stringify保留null、跳过undefined,意义不一样。- 整文件重写只在两个场景做(版本迁移、fork),日常永远 append------别写成 read-modify-write,那正是 8.3 我们要躲开的坑。
continueRecent找不到文件时要 fallback 到create------否则用户第一次在新项目里跑直接报错。- cwd 序列化要把
/→-再包--------避免深目录路径里的奇怪字符撞库。
8.14 总结
回头看这一章,会话系统能支撑「重启续聊 + 多会话隔离 + 回退重走」这三件看着挺难的事,靠的不是什么复杂技巧,而是几个简单决定的叠加:
- 用 append-only 的 JSONL 换来「崩溃不丢数据、追加 O(1)、并发安全」------把「写入可靠性」这件事,安放在文件格式这一层。
- 用 id/parentId 树 + 一个 leaf 指针换来「回退重走、分支隔离」------把「历史与导航」这件事,安放在数据结构这一层。
- 用
convertToLlm/transformContext这道转换边界,把「我想存什么、想展示什么」和「模型协议允许什么」彻底解耦------把「持久化格式 vs 协议格式」这件事,安放在「发请求前那一刻」这一层。
每一层只解决一件事,互不干扰。压缩摘要、分支总结、扩展注入的消息,将来都能塞进会话树而不污染 LLM 协议,正是因为这几层边界一开始就划清楚了。这套思路------把复杂度拆散,分别安放在合适的层级里,而不是堆在一个「大脑」里------你在后面的压缩、扩展、多供应商各章还会反复见到。
不过会话终究是一棵树,不是一条线。用户能回退、能编辑历史消息重发、能把一条路径 fork 成独立会话。这些「时间旅行」式的操作在树上具体怎么实现?下一章见。