别急着看解释。下面这个沙盘里有一份"账本"和一根"书签",先动手把它弄到一个你觉得不合理的状态——比如让模型看到的内容变少,但账本一条都没减。
先说清楚这门课在干什么:我们要照着 Pi 的源码,自己写一个 agent。Pi 是一个开源的 agent 框架,代码干净、没有魔法,是目前最适合拿来学的一份。
不过这一课不讲代码。先玩。
动手:三个关卡
下面是一个可操作的 Session 沙盘。左边是账本,右边是模型实际看到的内容。四个按钮往账本里加东西,点任意一行可以把书签挪过去。
三个关卡就在沙盘底下,做完再往下读。
卡住的话给三个提示:
- 第一关:先多加几条,再点「压缩」
- 第二关:往回点任意一条早一点的 entry,盯着左上角那个「账本」数字
- 第三关:往回挪书签之后,再加一条新消息
你刚才在操作什么
那份账本,在 Pi 的源码里叫 Session。右边那栏叫 Context——每次调用大模型时,真正发出去的东西。
为什么要这么一份东西? 因为大模型本身不记事:每次调用都是一张白纸,上一轮说过什么、用的哪个模型、调过哪个工具,它全不知道。要它接着上文往下做,就得有人替它把”发生过什么”存下来,再在每次调用前算出”这一次该发什么”。这件事——存住历史与状态、并派生出该发的 context——就是 Session 干的活。

三个关卡分别让你撞上了三件事:
一 · 压缩之后,账本一条没少
模型看到的从 8 条变成 2 条,左边还是 8 条。压缩没有删除任何东西,它只是往账本里又加了一条特殊记录,然后右边那栏在计算时把前面的跳过了。
二 · 往回挪书签,等于回退
这就是 rewind。后面那些 entry 还在账本里(它们变灰了,但还在)。所以撤销回退也只是把书签再挪回去——不可见 ≠ 不存在。
三 · 挪完再加一条,树就分叉了
同一条 entry 有了两个”下一条”。两条路各自往下长,共用前面那一段。
现在看这个问题
如果你自己写过 agent,大概率是这么存对话的:
const messages = [];
messages.push({ role: "user", content: "..." });
messages.push({ role: "assistant", content: "..." });
// 每次调模型:sendToLLM(messages)
一个数组,一路 push。用这个数据结构,上面三关一个都做不到。
| 你想做的 | 数组方案的结局 |
|---|---|
| 回退到第 10 条 | splice 删掉后面的。删了就没了,撤销不了 |
| 从第 10 条分叉出一个新会话 | 深拷贝整个数组,之后两份各自演化,永远回答不了”这俩从哪分开的” |
| 压缩上下文 | 就地替换一段元素,原文永久丢失。用户问”刚才那个文件内容是什么”,你答不上来 |
| 界面显示完整历史、模型只看压缩版 | 一个数组没法同时是两个东西 |
最后一行是根因:
你把「发生了什么」和「模型该看什么」压成了同一个数组。
它们从来就不是同一件事。
账本 + 书签
Pi 的做法是把这两件事拆成两层。用一个比喻先建立直觉——
Session 是一本只能往后写的账本,加一根书签。
- 账本:只追加,从不涂改、从不撕页
- 书签:指着”当前读到哪一页”
- 当前对话 = 从书签那页,沿着”上一页”的指针往回翻到第一页
就这两样东西。你刚才在沙盘里做的所有操作,都是这两样东西的组合:
| 你做的 | 实际发生的 |
|---|---|
| 加消息 | 往账本追加一页,书签前移 |
| 压缩 | 追加一页”前情提要”,书签前移 |
| 回退 | 只把书签往前挪,账本不动 |
| 分叉 | 挪完书签再追加——新页挂在了旧页下面 |
至于右边那栏——模型看到的 context 根本没有被存起来过。它是每次调模型之前,从书签沿着账本往回翻,现算出来的。
这个”现算”的动作,本课后面会反复用到,它有个正经名字——投影(projection):把账本投影成”模型这次该看的”那一份。(源码里那个负责投影的角色,作者就叫它 Projector。)比喻叫”现算”,出门叫”投影”——术语地图里都对得上。
这就是为什么压缩之后还能回溯:账本上的原文一个字都没动,只是算 context 的时候跳过了它们。
下一课
比喻建立了直觉,但还不够写代码。下一课把它落到实处:
- 账本上的一页具体长什么样(Pi 有 7 种 entry,其中 3 种不产生任何消息却很关键)
- “从书签往回翻”这个动作,代码上是 10 行
- 为什么压缩要设计成”追加一条特殊记录”,而不是”替换一段”
我们会用一个从头到尾的真实案例——修一个 login.ts 的 bug——把账本一页一页写出来,每一步都先让你猜结果。