createrole

约 12 分钟

Codex、Pi、OpenCode 的多轮上下文压缩:三种做法对比

读了 Codex、Pi、OpenCode 三个开源 agent 的源码,对比它们在上下文即将溢出时如何做摘要、保留多少最近原文、怎样从溢出中恢复。结论:压缩的本质是构造一个可恢复的状态 checkpoint,而不是总结聊天记录。

  • 上下文压缩
  • Agent 工程
  • 源码调研

长时间运行的 agent 迟早会撞到模型的上下文窗口。怎样在不中断任务的前提下把历史压下去,是每个 agent 运行时都要回答的问题。本文基于 2026 年 7 月对 Codex、Pi、OpenCode 三个开源项目源码的阅读,只描述当时代码中可验证的行为,不掺入产品宣传或历史版本。

共同范式

三者都没有把「删掉最老的几条消息」当作主算法。它们共享同一个骨架:

  1. 持久化完整或近似完整的原始会话;
  2. 检测模型上下文即将溢出或已经溢出;
  3. 把旧前缀交给模型生成可继续工作的结构化摘要;
  4. 保留一段最近原文作为高保真尾部;
  5. 后续请求只投影「摘要 + 最近尾部 + 新消息」,原始数据仍可用于恢复、分支或审计。

分析这类系统时要区分四层「上下文」:磁盘或数据库里的持久化历史;当前分支和最近 checkpoint 投影出的活动历史;过滤模态、修复 tool call 配对、截断大输出后真正发给 provider 的请求历史;以及主要来自上一次 provider usage 的 token 计数状态。压缩通常只替换第二、三层的视图,不物理删除第一层。

一张表看差异

维度CodexPiOpenCode V1OpenCode V2 core
主存储形态线性 rollout + compaction checkpoint存储后端中的树形 entry数据库消息/part顺序化 session entry
自动阈值服务端实际 usage 或本地估算;可按总上下文或减去 prefix 后的 body 计contextTokens > contextWindow - reserveTokenstokens >= usableWindow序列化请求估算值超过 context - max(output, buffer)
默认预留由模型、配置和可选 fallback buffer 决定reserveTokens = 16384默认最多预留 20k 或最大输出buffer = 20000
最近原文本地摘要保留最近用户消息,合计最多 20k;remote v2 最多 64k 消息文本从尾部反向累计约 20k,可切在同一 turn 内默认最多 2 个 turn,且动态预算 2k–8k默认约 8k,允许把单条序列化消息按字符切开
工具输出预处理写入历史时按策略截断;remote compact 前必要时把最靠后的 tool output 改成占位摘要输入中每个 tool result 截到 2000 字符摘要输入每个 tool output 截到 2000 字符;另有后台 prune摘要序列化每个 tool output 截到 2000 字符
已溢出恢复compact 请求溢出时逐个移除最老 item;远端路径有模型 fallback去掉错误 assistant,compact 后只自动重试一次建 compaction task;可能重放最后一个真实 user turn,媒体降级为文本占位显式 compactAfterOverflow
特殊能力本地/remote/remote-v2/token-budget 四路径;turn 前和 tool-loop 中途均可压缩同一 turn 可拆成「前缀摘要 + 原文后缀」;支持树分支摘要保留最近完整 turn;单个 turn 超预算时保留其内部消息尾部;工具结果懒清理独立的新 core,逻辑更小,表达力也更简单

工程取向上:Codex 最看重请求缓存稳定性、服务端能力适配和恢复一致性;Pi 最看重本地树形会话、可解释的切点算法,以及对超长单轮工具循环的拆分;OpenCode V1 把数据库全历史与模型消费投影分离,并用「最近完整 turn」和工具结果 prune 做两级降载;OpenCode V2 core 则把压缩抽象成更独立、可测试的 summary + recent

Codex:把压缩做成上下文窗口的生命周期

Codex 的阈值判断有一个统一公式:活动上下文 token(或减去当前窗口 prefix 后的 body)达到 auto_compact_limit + fallback_buffer,或活动上下文达到模型硬窗口,就触发。prefix 基线取当前窗口第一次服务端 usage;服务端样本一旦出现,不再被本地估值覆盖。

它有三个触发点。一是新 turn 采样前,先检查旧活动历史是否已达阈值。二是 tool-loop 中途:每次采样结束后同时检查是否需要 follow-up、是否有 pending input、token 是否达阈值、模型是否通过 new_context 工具主动请求换窗;只有还需继续采样时才立即压缩,压缩完成后先恢复原 tool-loop,再接纳中途到达的输入。三是模型切换:新旧模型声明的 comp_hash 不同,或从大窗口切到小窗口且历史已超阈值时压缩,优先用旧模型做摘要,因为它能读完整旧上下文。

执行路径有四条。启用 TokenBudget 时直接换窗,不请求摘要,依赖模型在换窗前收到的提醒把重要状态写进工作区。否则看 provider 是否支持远端压缩:不支持走本地摘要;支持则按是否启用 v2 决定是在普通请求尾部追加 compaction trigger,还是调用独立的 compact endpoint。

本地摘要路径值得展开。摘要成功后的新历史不是只有一条 summary,而是:

new_history = 最近真实 user 消息(合计最多 20k 近似 token) + [摘要,role 为 user]

assistant 原文、reasoning 和旧 tool 记录不进入新历史,它们的信息只能靠摘要承载。摘要请求本身溢出时,从历史开头逐个移除最老 item(连同 tool call 配对项),直到只剩一个输入仍溢出才报失败。远端 v1 的处理不同:估算超窗时只从历史末尾向前连续扫描可改写的 tool output,替换为占位文本,遇到第一个不可改写 item 就停。远端 v2 则从最新向旧保留最多 64k token 的消息文本,末尾追加服务端返回的加密 compaction item。

每次安装替换历史时,Codex 持久化包含 summary、完整替换历史和 window 编号链的 checkpoint,并重建 usage 基线。恢复时以最近 checkpoint 的替换历史为基点重放其后的 item。

Pi:树形会话上的投影

Pi 的会话是 id + parentId 构成的树,压缩只是追加一个子节点,记录 summary、firstKeptEntryId、压缩前 token 数和文件操作细节,旧节点不删。构造请求时找当前路径上最新的 compaction 节点,返回「摘要 + 从 firstKeptEntryId 开始的旧尾部 + 之后的新消息」。

默认 reserveTokens = 16384keepRecentTokens = 20000contextTokens 超过 contextWindow - reserveTokens 就触发。token 数优先取最后一个成功 assistant 消息的 usage,之后未采样的消息用 ceil(chars / 4) 增量估算,图片固定按 4800 字符计。

切点算法从最新往旧累计估算 token,达到 20k 时选第一个合法切点。合法切点包括 user、assistant、bash、分支摘要和 compaction 摘要,但不能是 tool result,因为它必须跟在对应的 assistant tool call 之后。因此保留的是「约 20k」而非严格不超过。

Pi 最有辨识度的做法是切在 turn 中间时的双摘要:对该 turn 之前的旧历史生成或更新标准摘要,对 turn 从 user 开始到切点之前的前缀生成一份较短的专用摘要,两者拼接后作为新摘要,firstKeptEntryId 指向 turn 内原文后缀的第一项。这显式解决了「单轮工具链本身就很长」的问题。

标准摘要要求固定输出 Goal、Constraints、Progress、Key Decisions、Next Steps、Critical Context 六段;已有旧摘要时用更新 prompt 滚动合并。Pi 不完全信任摘要模型能保住文件变更,会额外扫描 tool call 中的读写路径,去重后以 <read-files><modified-files> 附在摘要后。摘要请求使用独立 sessionId 且不写缓存,输出上限为 min(0.8 × reserveTokens, maxTokens)

溢出恢复方面,Pi 同时识别多家 provider 的溢出错误、成功返回但 input + cacheRead 超窗的静默溢出,以及 stopReason = length 且输出为零的静默截断。真正报错时,把错误 assistant 留在会话供审计,从活动上下文中移除它,压缩后自动重试一次;第二次仍失败则停止。切换树分支时还会为离开的分支生成 branch summary,这与普通压缩是两回事。

OpenCode:数据库全历史上的 summary/tail 视图

OpenCode 仓库里有两套实现:产品主路径的 V1 和新的 V2 session core,模型与配置结构不同。

V1 的可用窗口是输入上限减去预留量,预留量默认取 min(20000, maxOutputTokens);usage 达到可用窗口即标记需要压缩。provider 直接报溢出错误时,只要自动压缩未禁用,也转为压缩而非终止。

最近原文选择先限制最多 2 个真实 user turn,再施加 token 预算:clamp(usableWindow × 25%, 2000, 8000)。从最新 turn 往旧累加,整 turn 装得下就保留;装不下时从该 turn 第二条消息起找最早一个能装下的后缀;连最新 turn 也找不到可保留后缀时放弃原文,只留摘要。与 Pi 的差别是被切掉的 turn 前缀直接进入摘要输入,没有独立的前缀摘要。

压缩不删数据库消息,而是在合成的 compaction user part 里记录 tail_start_id。投影给模型的顺序是「compaction user、summary assistant、旧历史尾部、之后的新消息」,有意不按时间单调,因此找最新消息要按单调递增的 MessageID 而非数组位置。

在当前 user turn 上溢出时,V1 建一个 overflow compaction task:找到最后一个真实 user 消息,把它和后续内容作为 replay,用更早的历史生成摘要,压缩成功后新建 user 消息重放原内容,媒体附件降级为 [Attached mime: filename] 文本。

V1 还有独立的第二层压缩:每轮结束后后台扫描已完成的 tool part,跳过最近 2 个 user turn,遇到摘要边界就停,先保护最近累计 40k 估算 token 的工具输出,更老的成为候选,只有候选总量超过 20k 才批量标记为已 compacted。这避免为很小的收益频繁改数据库并打破 prompt cache。

V2 core 把消息序列化为带 [User][Assistant tool call][Tool result] 等标记的纯文本,从最新向前累计默认 8k token;边界消息只剩部分额度时按 remainingTokens × 4 个字符切开,前半进摘要,后半作原文。它允许在一条序列化消息内部切开,算法简单但不保证结构边界完整。摘要输出最多 4096 token,请求前先检查摘要 prompt 本身能否装进 context - summaryOutput

横向比较

切点保真度由强到弱:Pi(不切 tool result,为被切开的 turn 前缀单独摘要)、OpenCode V1(优先整 turn,必要时从 turn 内某条消息起保留)、Codex 本地(只恢复最近 user 原文,assistant 和 tool 细节全靠摘要)、OpenCode V2 core(可在单条序列化字符串内部切开)。Codex remote v2 是另一种取舍:保留最多 64k 用户消息文本,旧 assistant 和 tool 状态由加密 compaction item 承担。

token 精度上三者都不做全程精确计数:Codex 用 provider usage 加字节和模态启发式,Pi 用最近有效 usage 加尾部 chars/4,OpenCode 用 assistant usage 判阈值、round(chars/4) 选尾部。原因是 tokenizer 与 provider 强耦合,工具 JSON、图片、加密 reasoning 很难在客户端精确计算。工程上更重要的是留够 buffer、有溢出恢复路径、压缩后重建计数基线。

prompt cache方面,压缩必然改变前缀,三者都在减少额外扰动:Codex 平时只增量追加历史;Pi 的摘要请求用独立 session 且不写缓存;OpenCode 的工具 prune 只在可释放超过 20k 时批量发生。

累积误差是共同的弱点。三者都采用「旧摘要 + 新历史 → 更新摘要」,精确参数、错误字符串和边缘约束可能逐轮丢失,旧结论也可能因「保留」指令长期存活。缓解手段各异:Codex 保留最近 user 原文或服务端 compaction item;Pi 额外保留 20k 原文和文件清单;OpenCode V1 保留最近 turn 原文并允许插件注入。

失败模式CodexPiOpenCode
摘要请求也超窗本地逐项删最老;远端先改写尾部 tool output靠 16k 预留和 tool output 截断;失败则不安装 compactionV1 去媒体、截 tool output,仍超窗则标错停止;V2 先验证 prompt 能装下
provider 静默截断主要依赖 provider 错误和 usage 硬窗显式识别 usage 超窗和 length + 零输出usage 达可用阈值;adapter 抛溢出错误
压缩后立即再触发新 window 基线 + 重算 usage排除 compaction 时间戳之前的 usage隐藏已完成的 compaction 对,按新 usage 继续
中途新 user 输入先恢复 tool 续跑,再处理 pending input队列在 compact 后按 agent 队列语义继续合成 continue/replay 消息进入持久化循环
恢复/重启checkpoint 含替换历史和 window ID原始树 + compaction entry 重建投影数据库原历史 + compaction part/summary 对

可复用的设计

综合三者,一个稳健的实现应拆成几个可单独测试的组件,而不是一个巨大的 compact():不可变的 transcript 存储,压缩只追加 checkpoint;从 checkpoint 投影活动历史并修复 tool 配对的 projector;以 provider usage 为主、本地估值补差、每个 checkpoint 重建基线的 accountant;先按 turn 保留、超长 turn 时在安全消息边界切分、绝不拆散 tool call 与 result 的 tail selector;固定 schema、工具结果限长、文件和命令走结构化侧通道的 summary builder;阈值前主动压缩、溢出后被动压缩、重试次数有限的 overflow recovery;以及摘要成功前不动活动历史、一次性安装 summary、尾指针、usage 基线和 window ID 的 checkpoint installer。

一句话总结

上下文压缩的本质不是「总结聊天记录」,而是构造一个可持久化、可恢复、有 token 边界、保持最近高保真原文、能在工具循环与 provider 溢出下继续执行的状态 checkpoint。Codex 把它实现成上下文窗口生命周期,Pi 把它实现成树形会话投影,OpenCode 把它实现成数据库全历史上的 summary/tail 视图。

createrole 的数字员工同样在带终端和文件工具的 agent loop 里长时间工作,我们从这次调研里带走的是上面这套组件划分:原始记录不删、摘要与原文尾部分离、tool 配对不拆、计数基线随 checkpoint 重建、溢出恢复有限次重试。