上下文压缩:六个实现,四种做法
About 40 min read
同一个问题——窗口满了怎么办——Codex 会另装一个新窗口,Maka 说它只是在日志上做投影,OpenHands 砍到一半就停手。分歧不在算法,在「压缩到底在改什么」。
Contents · 13 sections · jump to a heading
2026 年 9 月,我在六个实现里各找了一遍「上下文满了怎么办」的答案,然后把每一处引用都钉在具体的 commit 上。
Codex 的压缩提示词是一个 9 行、426 字节的 markdown 文件,全文我下面会贴出来。
Apache Maka 用一个 10,589 字节的 TypeScript 文件专门判断「一份摘要能不能用」,文件名就叫 history-compact-summary-validation.ts。
Pi 的默认触发线写在一个 5 行的对象字面量里:contextWindow 减去 16384。
OpenHands 的默认参数让它在删掉一半事件时就停手,而如果删掉的不到 10%,它把这次压缩当成错误。
四家都在解同一个问题,给出的答案不在同一个层面上。
Maka 的架构文档开头是一句口号:Context is not history。同一份文档里还有一句更具体的:Compaction Is a Projection——压缩是一次投影,不是一个删除操作。
这句话把一个东西劈成了两半:你记录了什么事实,和这一次请求看到什么。在大多数系统的实现里,这两件事是同一个变量。
这篇文章想回答三个可以被验证的问题:
- 压缩到底在改什么——改历史,还是改「这次请求看到什么」?
- 压缩之后,原文去哪了?
- 压缩失败时,系统退到哪里?
先声明标注约定:后文每条事实我都会标明它是源码里写着的、项目文档自己说的、还是我读完之后推断的。推断不混进前两类。
- 我没有复现任何一家的基准数据。下面出现的性能与成本数字,都是各家自己发布的,我只做了把它们排在一起这件事。
- 我没有自己的 token 账单可以贴。所以本文不出现「省了百分之多少」这类数字,因为没有测量就没有数字。
- Claude Code 不是开源项目。关于它的那一节来自 2025 年 3 月 npm source map 泄露之后社区的逆向分析,不是官方文档。
- 我只读了每个仓库在某个具体 commit 的状态。所有源码引用都带了 40 位 commit,但项目迭代很快,你看到的时候可能已经变了。
zcode这个名字我没查到对应项目。上次列了四个,这次补到了六个,那一个仍然空着。
这一节的完整版我写在上一篇里了,这里只留结论。
上下文是一个预算,不是一条管道。Anthropic 的说法是模型有一个「注意力预算」,每引入一个新 token 都会消耗掉一部分;Chroma 在 18 个模型上跑了 194,480 次调用,测出的结论是输入越长,性能越不可靠,而且这个退化是斜坡不是断崖。
两件事合起来产生一个反直觉的后果:窗口变大不解决问题,只是把问题从「装不下」换成「装得下但读不准」。所以压缩不是一个「等窗口够大就不用做」的临时手段,它是长任务里绕不开的一层。
把六个实现摊开,它们的差别不在摘要写得好不好,而在压缩这个动作动的是哪一层。
| 做法 | 代表实现 | 源历史被改写吗 | 原文还能拿回来吗 | 失败时退到哪 |
|---|---|---|---|---|
| 原地改写 | Codex 本地压缩、OpenHands 滚动摘要、Claude Code Auto-Compact | 是 | 不能 | 旧上下文,重试一次 |
| 覆盖投影 | Apache Maka 的 history compaction | 否 | 能(事件日志) | 源派生的安全上下文 |
| 落盘 + 占位符 | Maka 的 active tool result prune、Claude Code 的大结果落盘 | 否 | 能(artifact store) | 直接读原文 |
| 直接重置 | Codex 的 token-budget compaction | 是 | 不能 | 一个全新的空窗口 |
第四行是唯一一个在特定条件下选择不总结、直接把旧窗口扔掉的实现。它最省事,也最贵。
Maka 是 Apache 基金会孵化中的本地优先 Agent 工作台,仓库在 apache/maka。它和另外几家的根本区别在于顺序:先把「压缩到底在改什么」定义清楚,再谈实现。
主文档是 docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md,中文版 40,754 字节,英文版 43,001 字节。
三层权威分离
这是整套设计的地基,三层对象不可互换:
| 层 | 保存什么 | 是事实源吗 | 允许丢细节吗 |
|---|---|---|---|
| Runtime Events Log | 用户、模型、工具与 Runtime 已发生的语义事实,只追加 | 是 | 否 |
| History Compact Checkpoint | 已校验事件前缀的续写摘要,或 provider 原生状态 + 覆盖信息 | 否(durable projection) | 是 |
| Provider Request Messages | 本次 LLM 调用实际消费的工作上下文 | 否(ephemeral projection) | 是 |
写成一个关系式是这样:
1Canonical history = RuntimeEvents[0..n]2Compact checkpoint = Project(RuntimeEvents[0..k], policy, summarizer)3Next model context = Materialize(checkpoint, RuntimeEvents[k+1..n], provider capabilities, ...)
关键类比:checkpoint 更像数据库的 materialized view 或 snapshot,而不是 WAL 截断。它可以加速读取、有版本、会失效、能从源日志重建,但不能反过来宣布源日志不再重要。
职责是这么切的。LLM 负责「怎样概括」,产出结构化摘要,包含 Goal、Done / In Progress、Key Decisions、Next Steps、Critical Context。Runtime 负责「概括了什么、能否使用、何时失效」——哪些事件属于 covered prefix、source digest 是什么、checkpoint 能否替代当前日志、哪些 raw tail 必须保留、是否已 durable、这次请求还允不允许用它。这些全是确定性代码决定,不交给模型。
Prune 和 Compaction 是两套机制
我原本以为「剪枝」和「压缩」是同一条流水线上的两个档位。读文档时改掉了这个认知:它们是两套独立机制。
| 机制 | 来源 | 何时发生 | 产物 |
|---|---|---|---|
| Active Tool Result Prune | 当前 Turn 的 provider-visible 工具结果 | 同一 Turn 的下一步之前 | 原文先归档;只追加一条 projection transition 记录,不创建 checkpoint |
| History LLM Compaction | 安全的 RuntimeEvent 前缀 | 手动请求 / pre-turn 容量 / turn 中途容量 / provider 溢出 | durable checkpoint 写入 AgentRun event ledger |
Prune 是确定性、非 LLM 的改写:不总结、不建 checkpoint、canonical 事件一个字节都不动。大工具结果的命运是这样的:
1大工具结果原文 → 归档到 artifact store(完整保留)2→ 上下文里换成占位符(artifact id + content hash)3→ 需要时按需 retrieve 重新水化
占位符的类型标记写在 packages/runtime/src/tool-result-archive.ts 里,地址形如 maka://archive/<artifactId>/<sha256>/<originalBytes>:
1export const ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND = 'maka.archived_tool_result';23export const ARCHIVED_TOOL_RESULT_REWRITE_VERSION = 1;
这条区分有直接的操作含义:剪枝可以默认开启,因为它可逆、无信息损失;压缩不能默认激进,因为它有损。
覆盖必须绑在一个有序前缀上
每个 checkpoint 绑定到它所概括的那段 RuntimeEvent 的精确前缀,也就是低 / 高 cursor 加一个 SHA-256 digest。这带来三个好处:replay 变成简单的 checkpoint + 未覆盖的 raw 后缀;high-water 只向前推进,容易比较谁覆盖得更远;rolling update 能明确知道哪些事件是新折进来的。
选前缀时有硬约束,不能跨越 partial event、pinned live event、以及 Tool Call / Result 配对。前缀匹配校验会逐项核对三件事:事件条数、末端那条的 runId / turnId / runtimeEventId、以及按稳定序列化算出的 digest。任何一项不符就记 coverage_miss 或 source_hash_mismatch,不允许用「看起来像同一段历史」蒙混过去。
Rolling 更新的写法是这样:
1Checkpoint N summary = S(events[0..k])2新淘汰 events[k+1..m]3Checkpoint N+1 summary = S(Checkpoint N.summary, events[k+1..m])4coverage = events[0..m]5previousCheckpointId = Checkpoint N.checkpointId
新摘要只有在 durable recorder 成功之后才允许作为 replacement 进入同一次 provider 请求。同 coverage 的重写相当于对同一个 materialized view 做 compare-and-swap。
摘要质量门
packages/runtime/src/history-compact-summary-validation.ts 这个文件的存在本身就是一个结论:摘要需要被校验,而校验逻辑值得单独占一个文件。
它定义了一个叫 sections_v1 的格式契约,prompt 和校验器共用同一组常量,所以两边不可能漂移:
1export const SUMMARY_FORMAT_TEMPLATE = [2'## Goal',3'[What the user is trying to accomplish]',4'',5'## Progress',6'### Done',7'- [Completed work and changes]',8'### In Progress',9'- [Current work]',10'',11'## Key Decisions',12'- **[Decision]**: [Brief rationale]',13'',14'## Next Steps',15'1. [Ordered list of what should happen next]',16'',17'## Critical Context',18'- [Files, commands/results, errors, anything needed to continue; or "(none)"]',19] as const;2021export const REQUIRED_SUMMARY_SECTIONS = [22'## Goal',23'## Progress',24'## Next Steps',25'## Critical Context',26] as const;
模板有五个章节,强制的只有四个。Key Decisions 不在 REQUIRED_SUMMARY_SECTIONS 里,作者对哪些信息「没它就活不下去」做过分级。
另一条规则是逐字复述模板不算数。源码把模板自身的每一行抽成 TEMPLATE_PLACEHOLDER_LINES,这些行永远不计入章节内容;注释自己写了这条防御要防什么——一个退化的模型把格式原样吐回来,不能冒充一份 checkpoint。
还有一条更硬的规则,注释里写了它的来历:
1// Floors for the incident's shape: folding a large span into a paragraph2// cannot be a faithful checkpoint. Folds whose summarizer request the provider3// counted above ~10k input tokens require at least ~200 output tokens of4// summary. Both sides are the summarizer call's REAL usage, so the floor holds5// in the provider's own tokenizer and needs no chars-per-token guess (#4559);6// a producer that reports no usage is not floored.7const LARGE_FOLD_INPUT_TOKENS = 10_000;8const LARGE_FOLD_SUMMARY_TOKENS_FLOOR = 200;
注意这里比对的不是字符数,是 provider 自己报的真实 usage。注释里专门解释了原因(#4559):用真实 token 数就不需要猜「一个 token 等于几个字符」,门限在 provider 自己的 tokenizer 里成立。
一次真实事故
整个文件里最值得读的是这段注释:
1// #3029: a degraded provider completion — a 138-token free-form fragment2// ending mid-sentence, delivered with a stop finish reason — was accepted as3// the checkpoint for ~235k estimated tokens of history, and the continuation4// confabulated around the missing context. Anything that does not look like5// the checkpoint the prompt mandates is a defect the caller fails open on6// (history kept, compaction retried). Returns undefined for an EMPTY summary:7// the compaction layer's empty_summary gate owns that case.
翻译一下:provider 返回了一个 138 token 的碎片,句子没写完,但 finish_reason 是正常的 stop。这个碎片被当成 checkpoint 接受,替换掉了约 235k 估算 token 的历史,后续推理于是在一个巨大的空洞周围编造内容。
从这里能读出三件事。stop 不代表内容完整,判断摘要能不能用不能信 finish reason,要自己检查结构。校验要放在接受之前而不是之后,一旦写进 checkpoint 并被后续请求消费,污染就已经发生了。Critical Context 之所以是强制章节,答案也在这条注释里——它正好是 #3029 丢掉的那部分:文件、命令、错误。
顺带一个边界处理:这个校验函数对空摘要返回 undefined,理由是空摘要归压缩层的 empty_summary 门管。
触发公式
容量来自用户为所选模型声明的 context window,provider 的 /models 报告和生成 metadata 只作提示,不是阈值。触发条件是上次成功请求的真实 inputTokens + outputTokens,加上回复预留 min(上次回复 × 2, 8000),达到用户声明的容量。
几个刻意的选择。预留的是模型实际写出的回复,不是可能写出的最大值。无声明窗口或无可用 baseline 时不主动折叠。只有 provider 真的返回 context-length rejection 才算溢出证据,finishReason: length 不算——它无法区分「窗口没空间」和「provider 自身更低的输出上限」。本地估算只能触发可逆的折叠,永远不能终止一个 turn,请求放不放得下始终由 provider 裁决。
失败时 fail open 到源派生的上下文
一句话原则:Fail open to a safe source-derived context, not to an invented summary.
模型可能暂时少看一些旧细节,但源日志没丢,之后可以重新压缩或换策略重新投影。文档里给了一张完整的失败语义表,几条关键的:
- LLM 返回空摘要:不记录新 checkpoint,保留原投影照常发出,不把空投影当成已覆盖的历史。
- Rolling summarizer 失败:保持 durable coverage 不变并 fail open,不假装旧 checkpoint 已覆盖新事件。
- Durable append 失败:不使用 candidate,回退旧 checkpoint 或安全 tail,未提交的投影不进模型。
- Prefix / digest 不匹配:拒绝 checkpoint,不用近似匹配替代。
- Bounded projection 损坏:从 canonical ledger 重建,缓存永远不是唯一事实源。
- Malformed retry 上限:每个 Session backend 最多记住 16 个精确的 malformed-input fingerprint,输入不变就直接 fail open。
它自己承认的代价
一份架构文档愿不愿意写代价,是判断它可信度的最快方式。官方自己列了这几条:
- 存储不会因为 prompt 变短而缩小。
- 需要额外维护 coverage / digest / lineage / policy gate / recovery projection / diagnostics 一整套机制。
- V2 schema 只校验 source identity 与形状,不校验摘要的语义完备性。
- rolling summary 会累积有损误差。
设计原则是「能不压就不压,必须压时从最轻手段开始」。这是目前公开资料里分层最细的一套:
| 层 | 机制 | 触发条件 | 动作 | API 开销 |
|---|---|---|---|---|
| 1 | 大结果落盘 | 单工具结果 > 50KB,或单条消息里工具结果合计 > 200KB | 完整内容写磁盘,消息里只留 2KB 预览 | 零 |
| 2 | Snip | 搭在模型本来就要跑的那一回合里 | 模型用 snip 工具按消息 id 标记删除远古探索消息 | 极轻 |
| 3 | Micro-Compact | 距上次 API 调用超过约 60 分钟 | 清空「可重新获取」的工具结果,只保留最近 5 个 | 零 |
| 4 | Context Collapse | 上下文达 90%,95% 升级 | 不改原始消息,只在调 API 时动态算一个压缩视图 | 内部灰度 |
| 5 | Auto-Compact | 有效窗口 − 20K − 13K | 全量重写对话 + 文件恢复 + 缓存清理 | 一次 API 调用 |
阈值是绝对量而不是百分比。触发线写成「窗口上限减固定值」,窗口从 200K 扩到 1M 时不会跟着膨胀;按比例算会让预留空间白白浪费。这一点和 Maka、Pi 的做法一致,三家独立收敛到了同一个结论。
压缩后的形态是全量重写,不保留「最近 N 条」。依据是 lost-in-the-middle:即使留着最近 20 轮,模型对中间几轮也看不清,不如整体压成结构化精华。这一条和 OpenHands 的选择正好相反。压缩完只剩四段:边界标记、摘要消息、附件(文件 / 技能 / 计划)、hook 结果。原来 200 条消息变成 4 段。
信息是按通道分开管理的。语义信息走摘要;状态信息(哪个文件改到第几行)走附件恢复;永久信息(CLAUDE.md)靠清空缓存重新加载。摘要 prompt 里必须枚举所有用户消息而不是概括,理由是用户中途改需求是任务转向的关键信号,概括掉就丢了。
熔断是连续失败 3 次就停。源码注释提到曾有 1000+ 会话因反复重试失败把 API 账单烧掉。
Codex 的压缩提示词全文如下,9 行:
1You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.23Include:4- Current progress and key decisions made5- Important context, constraints, or user preferences6- What remains to be done (clear next steps)7- Any critical data, examples, or references needed to continue89Be concise, structured, and focused on helping the next LLM seamlessly continue the work.
约 60 词,四个要求。对比 Claude Code 那份约 400 词、9 个固定章节的 prompt,这是两个极端:Codex 把质量压力推给模型,Claude Code 用结构换稳定。前者更轻、更省 token,但多次压缩后信息衰减更快,因为它没有任何强制章节来兜底。
摘要被拼回上下文时还套了一层包装:
1Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:
这段叫 Memento 策略,一行字干了三件事:让模型不会把这些话当成自己说过的、告知可以复用之前模型的工具状态、以及避免重复劳动。成本是一行文本。
那条不总结的路径
codex-rs/core/src/compact_token_budget.rs 里有一句注释:
1/// Runs token-budget manual compaction as a normal compaction lifecycle.2///3/// Token-budget compaction skips model/server summarization and installs a fresh context window4/// instead. It is still modeled as compaction so compact hooks and `ContextCompaction` turn items5/// observe the same lifecycle as local or remote compaction.
skips model/server summarization and installs a fresh context window instead——它跳过总结,直接装一个全新的上下文窗口。
源码没有解释为什么要有这条路径,以下是我的推断:摘要本身也要花钱,而且要在一次已经接近窗口上限的请求里再塞一个 prompt 进去。当预算是硬约束时,「花一次调用换一份有损摘要」不如「花零次调用换一个干净的窗口」。代价是任务连续性直接归零。
另外两处值得记一下。压缩被建模成一个正常的 lifecycle,pre / post compact hooks 都会跑,hook 返回 Stopped 就 abort turn。仓库里同时存在三条路径:compact.rs(本地)、compact_remote_v2.rs(远程)、compact_model_fallback.rs(模型降级);而 codex-rs/history/src/compaction_checkpoint.rs 的存在说明 Codex 后来也长出了自己的 checkpoint 概念。
Pi 的默认配置就是那 5 行:
1export interface CompactionSettings {2enabled: boolean;3reserveTokens: number;4keepRecentTokens: number;5}67export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {8enabled: true,9reserveTokens: 16384,10keepRecentTokens: 20000,11};
触发条件是 contextTokens > contextWindow - reserveTokens。两个数各管一头:reserveTokens 是给模型回复留的位置,keepRecentTokens 是要保留的原文量。
Pi 有个别家没有的东西:per-model 覆盖。文档里给的例子是 1M 窗口的模型把 reserveTokens 调到 400,000,于是压缩线落在 600K 以上,而 keepRecentTokens 保持普通的 20,000 不变。这个设计承认了一件事:窗口大小和「需要保留多少近期原文」不是同一个比例关系。
官方文档自己提示了这里的一个耦合:reserveTokens 同时决定摘要输出的长度上限(再被模型的最大输出 token 截一次),所以它不只是一个触发阈值。想把压缩线调高的人容易只想着触发时点,忘了它在另一头也生效。
分支摘要
Pi 的会话是树,可以 /tree、/fork、/clone。当你从一条分支切到另一条时,它提供把「离开的那条分支」总结掉的服务:
1导航前的树:23┌─ B ─ C ─ D (旧叶子,将被放弃)4A ───┤5└─ E ─ F (目标)67公共祖先:A8要总结的条目:B、C、D910带摘要导航后:1112┌─ B ─ C ─ D13A ───┤14└─ E ─ F ─ [B、C、D 的摘要] (新叶子)
做法是沿 parentId 找到最深公共祖先,从旧叶子往回走到祖先,按 token 预算从新到旧收集,然后生成一份 BranchSummaryEntry。线性链的数据结构做不到这件事。
重复压缩时从哪开始
第二次压缩应该从哪切?Pi 的答案是 firstKeptEntryId:从上次压缩保留的边界开始,而不是从上次的压缩条目开始。
1导航前的条目: 0 1 2 3 4 5 6 7 8 92┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐3│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│4└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘5└────────┬───────┘ └──────────────┬──────────────┘6messagesToSummarize kept messages7↑8firstKeptEntryId (条目 4)
理由是那些在上次压缩中幸存下来的消息,应该被纳入下一次摘要的范围,否则它们会被永久排除在总结之外。
摘要格式是 Goal / Constraints & Preferences / Progress(Done、In Progress、Blocked)/ Key Decisions / Next Steps / Critical Context。文件追踪是累积的:每次压缩都从被总结的消息里抽出读写过的文件,并从上一次 Pi 生成的压缩里继承文件列表。
扩展钩子有三个:session_before_compact、session_compact_failed、session_before_tree。扩展可以取消或替换压缩,把策略交给使用者而不是写死在核心。
顺带一条更正:这个仓库原来叫 badlogic/pi-mono,现在叫 earendil-works/pi。旧链接会重定向,但按旧名字搜资料会搜到一批过期的路径。
如果要照抄一份接口设计,抄这一家。CondenserBase 把「压缩」定义成一次可能返回两种东西的调用:
1If the condenser returns a `Condensation` instead of a `View`, the agent should2return `Condensation.action` instead of producing its own action. On the next agent3step the condenser will use that condensation event to produce a new `View`.4"""56@abstractmethod7def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensation:8"""Condense a sequence of events into a potentially smaller list.910New condenser strategies should override this method to implement their own11condensation logic. Call `self.add_metadata` in the implementation to record any12relevant per-condensation diagnostic information.1314Args:15view: A view of the history containing all events that should be condensed.16**Implementations must treat this view as read-only.** The view may be17a cached projection owned by ``ConversationState``, and18mutating it in place will corrupt that cache. Return a new ``View``19(e.g. ``View(events=view.events[k:])``) or a ``Condensation`` instead.
返回类型是 View | Condensation。压缩可以只返回一个更小的视图(纯函数式,无副作用),也可以返回一个 Condensation 事件,由 agent 把这个事件当成自己的动作执行,下一次再由此推出新视图。后者把压缩变成历史里一个可审计的条目,而不是一次静默的替换。
视图是只读的。注释写得很直白:这个 view 可能是 ConversationState 持有的缓存投影,原地修改会破坏那个缓存,要求实现返回新对象。这是「投影」这个概念落到类型约束上的样子。
需求分 HARD / SOFT 两档。SOFT 时如果拿不到摘要,就照常返回未压缩的视图;HARD 时先尝试 hard_context_reset,还不行才抛错。
抽象之下是三层的具体实现:CondenserBase → RollingCondenser → LLMSummarizingCondenser。参数是这样的:
1llm: LLM2max_size: int = Field(default=240, gt=0)3max_tokens: int | None = None45keep_first: int = Field(default=2, ge=0)6"""Minimum number of events to preserve at the start of the view. The first7`keep_first` events in the conversation will never be condensed or summarized.8"""910minimum_progress: float = Field(default=0.1, gt=0.0, lt=1.0)11"""Minimum fraction of events that must be condensed (0.0-1.0). If fewer than12this proportion of events would be forgotten, condensation is treated as an error.13Default 0.1 means at least 10% of events must be condensed.14"""1516hard_context_reset_max_retries: int = Field(default=5, gt=0)17"""Number of attempts to perform hard context reset before raising an error."""1819hard_context_reset_context_scaling: float = Field(default=0.8, gt=0.0, lt=1.0)20"""When performing hard context reset, if the summarization fails, reduce the max21size of each event string by this factor and retry.22"""
minimum_progress = 0.1 这条我一开始没看懂:如果这次压缩删掉的事件不到 10%,它把这次压缩当成错误。也就是说「压了等于没压」是失败,不是成功。源码注释只写了这条规则本身,没写用途;我的推断是它防无限循环——每次触发都只删掉一两条,然后马上又触发。
默认值分两套,这个容易抄错。类字段默认 max_size=240、keep_first=2,而 default_condenser() 用的是一组不同的常量:_DEFAULT_MAX_SIZE = 80、_DEFAULT_KEEP_FIRST = 4。只读类定义就抄参数,会抄到一套没人用的值。
触发原因分三种(REQUEST / TOKENS / EVENTS),同时命中多个时取最严格的那个。目标量是砍到一半:事件数按 max_size // 2,token 数按 max_tokens // 2。删减边界用 manipulation_indices 对齐到原子单元,不会把一次 tool call 和它的 result 拆开。
摘要用的是一套按任务类型自适应的模板(summarizing_prompt.j2),固定跟踪 USER_CONTEXT / TASK_TRACKING / COMPLETED / PENDING / CURRENT_STATE,代码任务再加 CODE_STATE / TESTS / CHANGES / DEPS / VERSION_CONTROL_STATUS。里面有一句是硬要求:如果被总结的事件里出现任何任务追踪信息,必须输出 TASK_TRACKING 章节,而且必须保留精确的 task ID 和状态。
还有一处工程细节:摘要用的 LLM 是独立的(llm 字段),传给 condense() 的 agent_llm 只用于数 token。摘要路径会关掉 streaming,因为摘要是一次性消费的、没有 on_token 回调。
我之前的笔记里记着 OpenHands 是 max_size=10, keep_first=2。按当前 commit 看那是过期的数字。这就是为什么引用源码必须带 commit。
Aider 的 repo map 是这条谱系里正交的一条路:它不用摘要,用 tree-sitter 把整个仓库解析成符号(类、函数、签名),再用 PageRank 在「文件被引用的关系图」上排序,挑出最重要的部分塞进固定 token 预算。
差别在于时点。压缩是「事后减」,repo map 是「事前筛」。前者对所有 agent 都适用,后者只适用于有静态结构的代码库——你没法给一份对话历史算 PageRank。
| 维度 | Apache Maka | Claude Code | Codex CLI | Pi | OpenHands |
|---|---|---|---|---|---|
| 数据结构 | 只追加事件日志 + 绑定 cursor 的 checkpoint 投影 | 线性链 | 线性链 | 树,可分支 | 事件流 |
| 触发阈值 | 用户声明的窗口;上次真实 usage + min(上次回复×2, 8000) | 有效窗口 − 33K | 90%(可选排除缓存前缀) | 窗口 − 16384 | 事件数 > max_size,或 token 超限 |
| 压缩粒度 | 有序前缀覆盖 + rolling 续写 | 五层金字塔,从轻到重 | 单次交接摘要;另有 token-budget 直接重置 | 分支摘要 + 保留窗口 | 砍到一半,对齐原子单元 |
| 保留什么 | 未覆盖的 raw 后缀 + 归档占位符 | 摘要 + 附件(最多 5 文件) | ≤20K token 的近期 user 消息 + 摘要 | 切点之后默认 20K 原文 | 最近事件 + 最初 keep_first 条 |
| 原文去哪了 | 归档进 artifact store,换成带 id + hash 的占位符,可 retrieve 重新水化 | 大结果写磁盘,消息里留 2KB 预览 | 丢弃 | 原始消息仍在树上,reload 从 firstKeptEntryId 继续 | 被摘要替换 |
| 摘要模型 | 独立 summarizer;Codex 订阅场景走 provider-native | 主对话同模型,fork 复用缓存 | 本地调用或服务端 | 可换 | 独立 LLM,agent_llm 只用于数 token |
| 缓存策略 | checkpoint 复用 + provider 原生状态 | 主动共享 prompt cache | 被动超限删最旧后重试 | 主动放弃,认为摘要是一次性 prompt | 摘要路径关掉 streaming |
| 独有机制 | 三层权威分离 · coverage digest 校验 · fail open 语义 · 摘要结构校验 | 熔断 3 次 · 递归守卫 · 压缩后附件恢复 | Memento 人格分离前缀 · 不总结的重置路径 | 分支摘要 · per-model 覆盖 · 可取消压缩的扩展钩子 | View | Condensation 返回类型 · HARD/SOFT 两档 · minimum_progress 反空转检查 |
读完这五列,分歧集中在四件事上。
第一件是压缩到底在改什么。Maka 把它严格定义为「对日志某个精确前缀生成一个有损投影」,源日志一个字节都不动;其余几家实质上都是在原地改写消息数组。这个定义决定了后面所有设计,包括能不能审计、能不能重建、失败时退到哪。
第二件是缓存策略,三家选了三条路:Claude Code 主动共享、Codex 被动重试、Pi 主动放弃。选择不同,后续设计全跟着变。
第三件是数据结构决定能力上限。Pi 用树才能做分支摘要,线性链做不到。
第四件是摘要 prompt 的详略反映定位。Codex 约 60 词,Claude Code 约 400 词 9 章节。前者更轻,但多次压缩后衰减更快。
Manus 那篇里有一条数字:Claude Sonnet 的缓存输入是 $0.30/M token,未缓存是 $3/M,差 10 倍。所以上下文只追加不修改,任何改写都会让 KV-cache 从改动点之后全部失效;序列化必须确定性,很多语言序列化 JSON 时不保证键顺序,会静默破坏缓存;别在 system prompt 里放秒级时间戳。
这三条和「压缩」直接冲突,因为压缩的本质就是改写历史。上面五个实现在这一点上做的取舍不一样:Maka 靠源日志不动来保住可审计性,Claude Code 靠 fork 复用缓存,Pi 干脆放弃摘要这一步的缓存,认为它是一次性 prompt。没有一家既压缩又完全保住缓存。
这篇文章里的常量、阈值和文件路径都会过期。我按 commit 钉住了每一处引用,但 Maka 的 context-budget.ts 从我上次看的两千多行缩到现在的一万二千字节、压缩主体搬去了 ai-sdk-compaction.ts(65,746 字节),Pi 的仓库换了名字。同一件事的代码在几个月里会换位置,这是这一行的常态。
但有一条现在就能确定:把消息变短这个动作本身不产生难点,难点在保证「变短之后,你还能说清原来发生过什么」。六个实现里只有 Maka 把这件事当成第一性的问题来解,其余几家都在不同程度上接受了「压缩之后历史就是摘要」这个前提。
这不是说 Maka 的做法更好。它的代价写在文档里:存储不会变小、需要维护一整套 coverage / digest / lineage 机制、摘要仍然是有损的。它只是把「有损」这件事从隐式变成了显式,而显式的代价是可以被度量、被回退、被审计的。
要不要付这个代价,取决于你的失败模式是什么。如果压缩出错只是让 agent 少看几行日志,原地改写就够了。如果压缩出错会让 agent 在一个空洞周围编造内容——比如 #3029 那样,用 138 token 的碎片替换掉 235k token 的历史——那你需要的是投影,不是替换。
- Apache Maka 架构文档,LLM Compaction: Events Log and Projection。本文的三层权威分离、
Context is not history、Compaction Is a Projection、coverage digest、fail open 语义、触发公式与失败语义表均出自此文档(中文版 40,754 字节)。 - Apache Maka 源码:
history-compact-summary-validation.ts、tool-result-archive.ts。sections_v1格式契约、LARGE_FOLD_INPUT_TOKENS = 10_000、LARGE_FOLD_SUMMARY_TOKENS_FLOOR = 200、#3029 与 #4559 的事故注释均以这两个文件为准。 - OpenAI Codex 源码:
prompts/templates/compact/prompt.md、summary_prefix.md、core/src/compact_token_budget.rs。两份 prompt 全文与 token-budget 压缩的定义均引自此处。 - OpenHands software-agent-sdk:
condenser/base.py、llm_summarizing_condenser.py、prompts/summarizing_prompt.j2。CondenserBase契约、View | Condensation返回类型、minimum_progress = 0.1、_DEFAULT_MAX_SIZE = 80与_DEFAULT_KEEP_FIRST = 4均以源码为准。 - Pi(earendil-works/pi,原
badlogic/pi-mono):docs/compaction.md、core/compaction/compaction.ts。触发公式、reserveTokens = 16384、keepRecentTokens = 20000、firstKeptEntryId语义、分支摘要流程与 per-model 覆盖均出自此处。 - Anthropic, Effective context engineering for AI agents,2025-09-29。「注意力预算」与 compaction / 结构化笔记 / 子智能体三策略的原始定义。
- Chroma, Context Rot: How Increasing Input Tokens Impacts LLM Performance,2025-07-14。样本:18 个模型、194,480 次 LLM 调用。
- Manus, Context Engineering for AI Agents: Lessons from Building Manus。缓存输入 $0.30/M 对未缓存 $3/M 的价格对比、「上下文只追加不修改」、「保留失败的尝试」均出自此文。
- Aider, Repository map。tree-sitter 抽符号 + PageRank 排序的做法。
- Claude Code 部分为社区逆向分析,非官方文档。源码仓库不公开,本文只把它当作一个公开可讨论的实现样本,不作为规范引用。