CHAPTER 2 / 5
Context Assembly & Compaction 上下文组装与压缩
概念与为什么
Context window 是 agent 的工作内存 —— 相当于 RAM。第 1 章的 loop 每转一轮,都要回答两个问题:
- 装什么(assembly) —— system prompt、项目规则、环境信息、tool 定义、对话历史……这些分别从哪来、按什么顺序拼?
- 满了怎么办(compaction) —— 历史单调增长,window 是固定的。装不下时,扔什么、留什么、谁来总结?
flowchart LR
A[历史单调增长] --> B{接近 window 上限?}
B -- 否 --> C[照常调模型]
B -- 是 --> D[让模型总结旧历史]
D --> E["新历史 = summary<br/>+ 保留的近期内容"]
E --> C
这个子系统直接决定三件事:
- 成本 —— token 就是钱。每轮带多少历史、prompt cache 能不能命中,账单差一个数量级。
- 能力边界 —— compaction 是有损压缩。“agent 忘了刚才干过什么”几乎都是这里的取舍出了问题。
- 可用时长 —— 没有 compaction,agent 干半小时就撞墙;有了它,session 可以无限延续(代价是逐渐”失忆”)。
一个反直觉的事实先说在前面:三家都没有用真 tokenizer。所有 token 预算都靠”API 上报的 usage + 字符数启发式估算” —— 工程上够用,别为了精确引入 tokenizer 依赖。
三家实现对比
Assembly:上下文从哪来
Claude Code 把 system prompt 切成若干 section 拼装(身份/语气、memory 索引、环境信息、output style、MCP 说明),memory dir 的索引是其中一个具名 section:
src/constants/prompts.ts:495— memory 目录索引被组装为 system prompt 的一个具名区段;路径相对reference/claude-code/,闭源快照仅标注位置。
项目规则(CLAUDE.md)和当天日期不打进 system prompt,而是作为一条 <system-reminder> user message 前置;git status 则是会话开头的一次性快照:
src/utils/api.ts:462— 项目规则与日期以系统提醒语义的 user message 注入。
src/context.ts:97— git status 在会话开始时采集一次,后续 turn 不刷新该快照。
Codex CLI 做了一个不同的切口:base instructions 随请求 payload 走,但项目/环境上下文不进 system prompt,而是作为普通 history items 注入 —— 首轮(或 baseline 丢失后)全量,之后只发与 baseline 的 diff:
codex-rs/core/src/client_common.rs:29—pub base_instructions: BaseInstructions,随Prompt走,不进历史。
codex-rs/core/src/session/mod.rs:3559—let should_inject_full_context = reference_context_item.is_none();—— 有 baseline 就只注入 diff。
OpenCode 每轮迭代并行收集全部系统上下文,再拼成 system 数组;项目规则文件同时认 AGENTS.md / CLAUDE.md / CONTEXT.md:
packages/opencode/src/session/prompt.ts:1257—const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([—— 每轮并行收集。
packages/opencode/src/session/instruction.ts:64— instruction files 列表:"AGENTS.md"…"CONTEXT.md", // deprecated。
packages/opencode/src/session/llm/request.ts:60— agent 自定义 prompt 优先于 per-provider 默认 prompt。
Budgeting:怎么知道快满了
三家的口径一致:API 上报的 usage 为准,本地估算兜底。
src/utils/tokens.ts:226— Claude Code 以最近一次 API 响应的 usage 为锚,再加上其后消息的估算值。
packages/core/src/util/token.ts:5— OpenCode 的全部估算故事:input.length / CHARS_PER_TOKEN(chars÷4)。
阈值策略则各有性格:
src/utils/context.ts:9与src/services/compact/autoCompact.ts:62— 默认上下文窗口为 200K;触发线从窗口中扣除 20K 输出预留和 13K 缓冲。
codex-rs/protocol/src/openai_models.rs:460— codex 的 auto-compact 触发线默认是 window 的 90%:(context_window * 9) / 10。
packages/opencode/src/session/overflow.ts:15— OpenCode 的usable()= context limit − 预留输出;:32 判断是否溢出看的是上一步的 total tokens(含 cache read/write)。
Compaction:满了怎么办
Claude Code:cheapest-first 阶梯。 loop 内每轮调模型之前,按便宜到贵的顺序过一道防线:
src/query.ts:412— loop 明确先执行 microcompact,再执行 autocompact。
阶梯大致是:先做 tool result 的总量预算(超大会溢写磁盘、模型只看预览)→ microcompact(清除固定工具集的旧 tool result,不叫模型)→ 还不够就 auto-compact(全量总结)。auto-compact 连续失败 3 次会熔断;如果 API 仍然返回 prompt_too_long,loop 层还有一次 reactive compact 兜底:
src/services/compact/microCompact.ts:41— microcompact 只处理预先限定的一组工具结果。
src/services/compact/microCompact.ts:291— 这个快照中 microcompact 的大部分路径已停用,上下文压力主要交给 autocompact 处理。
src/services/compact/autoCompact.ts:70— autocompact 连续失败三次后触发熔断。
src/query.ts:1120— API 返回 413 后,loop 会尝试一次响应式压缩恢复。
src/constants/toolLimits.ts:13— 单个工具结果上限为 50K 字符;超限内容溢写到磁盘,模型只接收预览。
压缩之后不是只有一个 summary:旧的 file-read cache 被清掉(之后重读拿到的是新内容),summary 本身以”上一轮对话用完了 context”的措辞成为继续对话的种子:
src/services/compact/compact.ts:521— compact 后清除文件读取缓存,使后续读取获取更新后的内容。
src/services/compact/prompt.ts:345— summary 以先前对话因上下文耗尽而续接的语义作为开场。
Codex CLI:三个触发点 + 整体替换。 pre-turn(每轮第一次请求前)、mid-turn(token 超限且还要续转)、manual /compact 各一个入口:
codex-rs/core/src/session/turn.rs:158—run_pre_sampling_compact(...)—— 每个 turn 的第一次 sampling 之前先检查。
codex-rs/core/src/session/turn.rs:361— mid-turn 触发:CompactionReason::ContextLimit,。
codex-rs/core/src/tasks/compact.rs:71— manual/compact走同一个 summarization prompt(可被 config 的compact_prompt覆盖)。
替换后的历史非常”狠”:assistant 消息和 tool 内容全部丢弃,只保留最近的 user 消息(新的优先,20K token 预算)+ 一条 summary。summary 放在最后一条 user message 之后,这个位置是模型训练时定下的规矩:
codex-rs/core/src/compact.rs:62— 原注释:“Mid-turn compaction must useBeforeLastUserMessagebecause the model is trained to see the…” —— 布局是训练契约,不是自由设计。
codex-rs/core/src/compact.rs:611—for message in user_messages.iter().rev() {—— 只留 user 消息,新的优先。
OpenCode:compaction 是数据,不是删除。 旧历史永远留在 SQLite 里;compact 只是写入一条带 compaction part 的 user 消息 + 一条 summary assistant 消息,读取时由 filterCompacted 把视图重排成 [compaction 标记, summary, 保留的 tail…]:
packages/opencode/src/session/message-v2.ts:566— 读时重排:...result.slice(compactionIndex, summaryIndex + 1), ...result.slice(tailIndex, compactionIndex),—— summary + verbatim tail。
总结输入本身也做了降载:media 剥掉、tool output 截到 2K 字符;总结用的不是便宜小模型,而是 session 当前模型自己。被 prune 的旧 tool result 不会被删,只是在视图里替换为占位符:
packages/opencode/src/session/compaction.ts:352—stripMedia: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,。
packages/opencode/src/session/compaction.ts:331— 总结模型默认取 session 当前模型(provider.getModel(userMessage.model...))—— 质量优先于成本。
packages/opencode/src/session/message-v2.ts:294— 被清掉的旧 tool result 显示为"[Old tool result content cleared]"。
触发链路:step-finish 时发现溢出 → processor 返回 "compact" → loop 创建 compaction 任务,下一轮执行:
packages/opencode/src/session/processor.ts:481—ctx.needsCompaction = true(step-finish 时)。
packages/opencode/src/session/processor.ts:679—if (ctx.needsCompaction) return "compact"—— 交还给 loop。
packages/opencode/src/session/prompt.ts:1166— loop 把溢出变成一个 compaction 任务:yield* compaction.create({ ... auto: true })。
代码走读
这章走读 codex 的 replacement-history 构造 —— 三家中最”看得见摸得着”的一个:几个纯函数,输入旧历史,输出新历史,Apache-2.0 可放心引用。(Claude Code 的 post-compact 重建更丰富,但源码不能引用;OpenCode 上章已读过。)
先看一个容易忽略的工程细节:compact 请求本身也会超窗。处理方式是逐条丢最旧 —— 注释明说动机是保 prefix cache:
// compact.rs:285-293(节选)
Err(e @ CodexErr::ContextWindowExceeded) => {
if turn_input_len > 1 {
// Trim from the beginning to preserve cache (prefix-based)
// and keep recent messages intact.
history.remove_first_item();
retries = 0; // 丢了一条,重试计数清零再来
continue;
}
...
}
codex-rs/core/src/compact.rs:291—history.remove_first_item();原始锚点。
然后是 summary 的诞生:不是一次专门的”结构化总结调用”,而是把历史 + 总结 prompt 发给模型,取它最后的 assistant 回复作为 summary,加上固定前缀:
// compact.rs:323-329(节选)
let history_snapshot = sess.clone_history().await;
let history_items = history_snapshot.raw_items();
let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default();
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_user_messages(history_items);
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
codex-rs/core/src/compact.rs:326—let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
最后是保留策略,一个赤裸裸的 recency 贪心:只留 user 消息,从最新往最旧选,预算 20K tokens;塞不下的最后一条截断硬塞:
// compact.rs:608-621(节选)
let mut remaining = max_tokens;
for message in user_messages.iter().rev() { // 新的优先
if remaining == 0 { break; }
let tokens = approx_token_count(&message.message);
if tokens <= remaining {
selected_messages.push(message.clone());
remaining = remaining.saturating_sub(tokens);
} else {
let truncated = truncate_text(&message.message, TruncationPolicy::Tokens(remaining));
selected_messages.push(...truncated...);
...
}
}
codex-rs/core/src/compact.rs:611—for message in user_messages.iter().rev() {原始锚点。
三段连起来看,codex 的赌注很清晰:用户说过的话(需求)必须 verbatim 保住,agent 说过做过的话(summary 能概括就行)。这是一个既便宜又不容易跑偏的取舍 —— 需求丢了任务就完了,过程丢了 summary 还能兜。
设计权衡
| 决策点 | Claude Code | Codex CLI | OpenCode |
|---|---|---|---|
| 项目规则住哪 | <system-reminder> user message 前置(api.ts:462) | history items,首轮全量之后 diff(session/mod.rs:3559) | system 数组每轮收集(prompt.ts:1257) |
| token 计量 | usage 锚 + 估算(tokens.ts:226) | server usage + byte 启发式(codex-rs/core/src/context_manager/history.rs:162) | provider usage + chars÷4(token.ts:5) |
| 触发线 | window − 20K 输出 − 13K buffer(autoCompact.ts:62) | window 的 90%(openai_models.rs:460) | 上一步 total ≥ usable(window − 预留)(overflow.ts:15) |
| 压缩粒度 | 阶梯:budget → micro → full(query.ts:412) | 整体替换 | head 总结 + tail verbatim(message-v2.ts:566) |
| 压缩后历史 | summary 作 continuation seed + 重建(prompt.ts:345) | 最近 user 消息 + summary(compact.rs:611) | [compaction, summary, tail] 读时重排(message-v2.ts:566) |
| 旧历史命运 | microcompact 清除 + 替换 | 整体替换 | 永不删除,读时过滤(message-v2.ts:294) |
| 总结模型 | 主模型,结构化 prompt | 主模型,取最后回复(compact.rs:326) | session 当前模型(compaction.ts:331) |
| compact 自身超窗 | head 截断重试(compact.ts:467) | 逐条丢最旧,保 prefix cache(compact.rs:291) | 输入预降载:剥 media + 截 tool output(compaction.ts:352) |
几个值得玩味的对立:
- summary 必须无损吗? 三家都说”不必”,但补偿方式不同:Claude Code 靠 compact 后确定性重建(清 file cache 重读),codex 靠 verbatim 保住 user 消息,OpenCode 靠 tail 保留 + 旧历史可回查。共同点:与其赌模型总结得全,不如设计上让”忘了也能找回来”。
- 删除 vs 过滤:替换式(Claude/Codex)简单直接;OpenCode 的”compaction 是数据”让 revert、审计、多 client 重放全部免费,代价是读路径不再按时间序,复杂度藏在
filterCompacted里。 - diff vs 全量:codex 把项目上下文放 history 里做 diff 注入,cache 友好;Claude 把易变的 git status 做成一次性快照,避免每轮打爆 prefix cache。两家都在为 prompt cache 打工,只是切口不同。
迁移到你自己的 agent
- 防线按 cheapest-first 排:先给单个 tool result 设上限(超限落盘给预览),再清旧 tool result,最后才叫模型总结。总结是阶梯里最贵的一步,别一上来就用。
- 阈值 = window − 输出预留 − buffer,永远别等 413;但 413 的 reactive 兜底路径也必须有,因为估算是会漂的(三家都用估算,没有真 tokenizer)。
- 别让 summary 独自承担记忆。compact 之后,把”最近读过的文件、当前 plan”这类关键上下文做成确定性重建(重读、重注入)—— 比祈祷模型总结得全可靠得多。
- compact 请求自己也会超窗,准备降载路径:截头、逐条丢最旧;丢的时候从最旧开始,保 prefix cache。
- 认真考虑”compaction 是数据,不是删除”:旧历史留着、读时过滤,你就免费得到 revert 和审计能力 —— 这对 coding agent 尤其值钱。
- 给压缩加熔断:连续失败 N 次(Claude Code 是 3)就停手报错,否则 error → hook → retry 会烧成死亡螺旋。
- 保住用户的原话。codex 的 recency-only 策略证明:verbatim 保留 user 消息 + 总结其余,是一个既便宜又不容易跑偏的默认解。