第 2 卷
错、这次不压,宁可不动,也不做无用功。
这件事由压缩后端完成,它在日志里落下三个事件,都是只写日志、不进 surface 的:
事件作用
compaction/start先落下,当锁用——表示"这次压缩开始了"
记下摘要内容、被遮蔽的范围与 seq、估算 token 数,以及这 compaction/summary 次摘要由哪个 provider/model 生成、花掉多少 token
compaction/end释放锁,结束这一次
这把锁括住的是整个操作:先写 compaction/start,再做摘要、写 compaction/summary、把摘要替换进 surface,最后才写 compaction/end。end 放在
最后,所以日志里一旦出现它,就说明这次压缩收尾了(正常收尾那条不带 error,失败收尾的会带上);中途崩了只会留下“有 start 没 end”的遗留锁,一眼可辨。
严格说,这两个标记是时间点、不是排他的容器,摘要跑着的时候,注入的上下文可以落在这一对标记之间;也正因如此,手动压缩收尾时只复核“我挑的那段”没被改写,自动压缩才要求
整张 surface 原封不动。
那这种“没闭合的锁”会不会把后来的压缩一直挡住?会,只要还有一把“当代的”未配对的锁,所有压缩入口都会直接报 busy(the session compaction lock is already active)。怎么算
“当代”?看有没有更晚的 session/end-seed:比最新一条 session/end-seed 更早的未配对 compaction/start,是上一代生命周期留下的陈旧证据,会被忽略掉;比它更晚的
才算数,正好借上一讲那条边界标记,把“这一代”和“上一代遗留”分开。
摘要怎么进 surface?靠“追加一条、替换一段”:压缩不改动任何已有的 surface 事件,它
跑 npm run start:ch10:compact:它先铺一份 4 轮对话的会话,再手动压缩一次,示例把 auto 关掉、直接调 compactNow,所以你这里看到的是手动入口,不是后面要讲的自
动触发。
复制代码 1--- 压缩前 ---
2surface 消息数:8(4 轮,每轮一问一答)
| 3 | 第1条(前20字):第1轮的问题,内容比较长。第1轮… |
|---|---|
| 4 | ---压缩后--- |
| 5 | 被遮蔽的事件seq:1,4,8,10,14,16,20 |
| 6 | 估算遮蔽token数:362 |
| 7 | surface消息数:8→2 |
| 8 | 第1条(替换进去的checkpoint节点)全文: |
| 9 | Thisisan automatically generated checkpoint condensing an earlier spanoft |
10
| 11 | <compacted-summary>前面几轮在讨论:日志怎么落盘、会话怎么续接、崩溃怎么修复。</compacte |
|---|---|
| 12 | 第2条(保留的最近尾部):第4轮的回答,同样不短。第4轮的… |
| 13 ---压缩新落的几条事件--- | |
| 14 | seq25 compaction/start |
| 15 | seq26 compaction/summary |
| 16 | seq27 user/message |
| 17 | seq28 compaction/end |
输出里有两件事要一起看:
- 压缩后,日志和 surface 各发生了什么?
user/message、step/start、assistant/message、step/end、turn/end),第 1 轮还多一条 request/header,4 轮正好 25 条(seq 0–24);其中浮到 surface 上的只
有 user/message 和 assistant/message 两种(这份示例里没有工具结果, tool/result 同样是 surface 事件,下一节就用到了),所以清单是 8 条,seq 1、4、
8、10、14、16、20、22。
日志只有“加”,清单的 8 条压缩到 2 条,是照这份日志算出来的。留下的为什么是 22?因为压缩的规矩是压掉旧的、留住最近的尾巴,22 正是清单上的最后一条(第 4 轮的回答)。那
7 个事件在日志里一条没删,只是不再出现在清单上。

尾巴留多大,规矩只有一句:按窗口的一个比例留。自动压缩用默认的 retainRatio = 0.16,也就是窗口的 16%;另外两条,手动按需压的那条,和已经溢出时走的那条,传的都