第四部分 · 会话与长任务
第 12 章:压缩——在遗忘中保持记忆
转录可以瘦身,kernel 不许失忆,长任务状态根本不在被压缩的世界里
RLM 给压缩出的新难题
上下文压缩是所有长对话 Agent 的必修课,pi 已经有一套成熟实现(触发阈值、切点、摘要、分支摘要)。Prime 继承了这套骨架,然后被自己的 RLM 设计逼出一个新问题:
压缩丢掉的是转录文字,可模型真正的工作状态有一半在 IPython kernel 里——那些变量、导入、数据帧、子代理句柄,压缩器看不见它们。
传统 Agent 里,转录就是全部状态,压缩摘要写得够好就能无缝接手。RLM 里,摘要再好也无法复述 df 里清洗过的三万行数据。Prime 的解法不是「把 kernel 状态写进摘要」,而是一个更激进的立场:kernel 根本不需要被压缩拯救——它一直活着;压缩要做的只是告诉模型它还活着。
本章先走压缩管道本身(它有几处自己的精彩),再看这个立场如何落地。
触发:三条路径,一个核心
判定函数是个纯粹的阈值:
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
if (contextWindow <= 0) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
上下文 token 计数优先取最近一条正常 assistant 消息的 usage——注意 output 也算进去,因为它下一轮就会成为 prompt 的一部分。三条触发路径在 _checkCompaction() 里按优先级分流:
| 路径 | 触发条件 | 后续 |
|---|---|---|
| overflow | 模型报上下文溢出错误 | 摘掉错误消息 → 压缩 → 自动重试;状态机 idle → attempted → reported,只给一次机会,再失败就提示换更大窗口的模型 |
| requested | compact 技能的 compact.run 登记 | 即使自动压缩被禁用也执行(模型的显式请求优先于配置) |
| threshold | shouldCompact 命中 | autonomous 开启时先替它排一条续跑消息,再压缩——长任务不因压缩断档 |
两条防抖守卫值得注意:陈旧消息守卫——早于最新压缩条目的 assistant 消息不得再触发压缩(防止压缩前的遗留 usage 在压缩后引爆连环压缩);换模型守卫——从小窗口模型切到大窗口模型后,旧模型的 overflow 错误不再作数。
三条路径最终汇聚到同一个 _performCompaction()——源码注释自述:"Shared compaction core behind /compact, auto-compaction, and the compact skill"。compact.run 的 kernel 侧约束很有意思:只有 isStreaming(模型正在干活)时才能请求,因为压缩会 abort 正在执行请求 cell 的 run——所以它只能调度,turn 结束时执行,回执写着 "you resume automatically afterwards. Continue working normally."
管道:滚动摘要与 split turn
prepareCompaction() 的第一处巧意是摘要区间的起点:从上一次压缩的 firstKeptEntryId 开始,不是从上一个压缩条目开始。上次压缩「幸存」的消息这次会重新进入摘要——摘要不是一次性产物,而是随每次压缩滚动更新的检查点。
切点选择 findCutPoint():从新到旧累加 token,累计达到 keepRecentTokens(默认 20,000)处找最近的合法切点。合法切点可以是 user/assistant/custom 等消息,绝不在 toolResult 上切——工具结果必须跟随它的工具调用。若切点落在一个超大 turn 中间,触发 split turn:历史与 turn 前缀并行摘要(各占 0.8× 与 0.5× reserveTokens 的预算),再合并成一份。
摘要生成本身有几处对 LLM 行为的防御:
/* serializeConversation:把会话拍平成 [User]: / [Assistant]: / [Assistant tool calls]: / [Tool result]: 文本, 包进 <conversation> 标签。Tool result 截断到 2000 字符。 */
- 拍平对话形态,防止摘要模型把输入当成要继续的对话聊下去(系统提示词也明说 "Do NOT continue the conversation")。工具结果截到 2000 字符——ipython 输出通常是体积大头。
- 固定节结构:Goal / Constraints & Preferences / Progress(Done|In Progress|Blocked)/ Key Decisions / Next Steps / Critical Context,「保留精确文件路径、函数名、错误消息」。摘要不是历史叙述,是给下一个 LLM 的交接检查点。
- 迭代式更新:滚动压缩时旧摘要放在
<previous-summary>标签里,要求 PRESERVE/ADD/UPDATE——控制摘要自身的膨胀。
kernel 状态的双轨保全
回到本章的核心问题。压缩对 kernel 状态做了两件事,一软一硬:
软轨:叮嘱摘要模型。摘要系统提示词的尾部追加一段:
const KERNEL_PERSIST_SUMMARY_NOTE = "Note: the IPython kernel keeps running after this summary — every Python variable, import, and helper you defined stays available. The cells that defined them won't appear above, so record in the summary any names worth remembering so you reuse them instead of redefining them.";
硬轨:压缩后现场点名。软约束靠不住,宿主在压缩条目之后追加一条 display: false(只进 LLM 上下文,不进 TUI)的消息,内容来自对 kernel 的实时探测:
names = await provisioner.listNamespaceNames(abort.signal).catch(() => null);
...
const content = [
"<ipython_state>",
`Your IPython kernel persisted through compaction; all variables, imports, and helpers you defined remain available.${detail}`,
"</ipython_state>",
].join("\n");
listNamespaceNames 向 kernel 提交一个内部 cell 枚举用户定义的名字,带 5 秒超时。于是模型在压缩后的第一个上下文里看到的不是「摘要里可能提到了某些变量」,而是事实清单:这些名字现在仍然有定义。摘要写没写名字反而不重要了——硬轨道是权威,而且 kernel 若死了探测就失败,宿主宁可不说也不撒谎。
第三层保全不需要压缩器做任何事:kernel 进程从不因压缩重启。压缩只重建消息数组,解释器里的三万行数据原封不动。这正是 RLM 范式的回报:把状态放进进程,压缩就从「状态迁移问题」降级成了「通知问题」。
压缩杀不死的东西
把第 6、9、11 章的线索汇总成一张表——这是「状态放在哪」的全景:
| 状态 | 存放处 | 压缩的影响 |
|---|---|---|
| goal | JSONL custom 条目(thread_goal_state) | 无——读取时倒扫整条分支,不走可见上下文 |
| 子代理注册表 | 内存 + 父 artifact 目录的 JSONL | 无;压缩后还会重试清理压缩期间删除失败的子代理 |
| kernel 变量空间 | 活着的进程(+ dill 快照兜底重启) | 无——压缩后发 <ipython_state> 点名 |
| heartbeat / cron | scheduled-jobs.json | 无——与消息面完全解耦 |
| refine 历史 | 会话 custom 条目 / 全局 JSONL | 无 |
官方文档把这条边界写成了明文:"Compaction is not a completion signal. It does not stop goals, autonomous continuations, heartbeats, or existing child sessions." 在 Prime 里,压缩只是消息面的事件。所有要活得久的东西,都故意不住在消息面里。
一处 fork 的化石
压缩的 details 里照例维护 read/modified 文件清单,但消息级提取器收窄得耐人寻味:
switch (block.name) {
case "edit":
fileOps.edited.add(path);
break;
}
只认 edit。read/written 集合从不被消息填充,清单全靠历次压缩 details 的累积继承。这是 fork 后向「kernel 为主工具面」收窄的痕迹:文件读写大多发生在 IPython 里(open()、%%bash),宿主侧根本没有 read/write 工具调用可供提取。pi 上游的三件套语义,在这里退化成了对唯一 Python 编辑技能的追踪。
分支摘要:压缩的近亲
/tree 在会话树上切换到另一分支时,被抛弃分支上的工作会被摘成一条 branch_summary 条目挂到目标处:求新旧位置的最深公共祖先,收集分叉段的条目(不在压缩边界停——沿途摘要一并纳入),在 contextWindow - reserveTokens 的预算内从新到旧收消息,用略简的提示词生成摘要。它复用压缩的全部序列化机制,是「上下文资产不随地形丢失」的另一半。
实践应用
- 把状态移出压缩面,而不是让压缩器学会保全状态。压缩器的职责越少,长任务越稳。Prime 的答案是选址问题:goal/注册表/调度根本不住在转录里。
- 软约束配硬校验。「请摘要模型记住变量名」是软约束;压缩后实时枚举 kernel 命名空间是硬校验。前者提高基线,后者保证事实。
- 摘要是交接检查点,不是历史回顾。固定节结构、精确路径与错误消息、Next Steps——写给接手者的文档有自己的文体。
- 滚动摘要要重新包含上次的幸存者。从 firstKeptEntryId 而非上次压缩点起笔,让早期信息在每次滚动中持续被蒸馏而不是被冻结。
- 失败恢复给确定的次数。overflow 只尝试一次 compact-and-retry——无限重试会把模型卡在溢出循环里,一次失败后的诚实报错反而是更好的 UX。
总结
Prime 的压缩沿用了 pi 的骨架(阈值、切点、滚动摘要、分支摘要),加上三路径触发与 overflow 的一次性恢复。它对 RLM 的独特回答是双轨保全:摘要叮嘱是软轨,压缩后的 kernel 现场点名是硬轨,而最深的保全是范式本身——工作状态住在进程里,压缩只是消息面的事。所有长任务状态都故意不在压缩的世界里。
下一章看那些「故意活得久」的东西:goal 如何跨轮次续跑,heartbeat 如何把一句话变成周期性的重逢,cron 如何在崩溃中保持「至多一次」,autonomous 如何在预算与质量门之间克制地继续。