CHAPTER 2 / 5

Context Assembly & Compaction 上下文组装与压缩

概念与为什么

Context window 是 agent 的工作内存 —— 相当于 RAM。第 1 章的 loop 每转一轮,都要回答两个问题:

  1. 装什么(assembly) —— system prompt、项目规则、环境信息、tool 定义、对话历史……这些分别从哪来、按什么顺序拼?
  2. 满了怎么办(compaction) —— 历史单调增长,window 是固定的。装不下时,扔什么、留什么、谁来总结?
flowchart LR
    A[历史单调增长] --> B{接近 window 上限?}
    B -- 否 --> C[照常调模型]
    B -- 是 --> D[让模型总结旧历史]
    D --> E["新历史 = summary<br/>+ 保留的近期内容"]
    E --> C

这个子系统直接决定三件事:

一个反直觉的事实先说在前面:三家都没有用真 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:29pub base_instructions: BaseInstructions,Prompt 走,不进历史。

codex-rs/core/src/session/mod.rs:3559let 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:1257const [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:9src/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:158run_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 use BeforeLastUserMessage because the model is trained to see the…” —— 布局是训练契约,不是自由设计。

codex-rs/core/src/compact.rs:611for 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:352stripMedia: 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:481ctx.needsCompaction = true(step-finish 时)。

packages/opencode/src/session/processor.ts:679if (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:291history.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:326let 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:611for message in user_messages.iter().rev() { 原始锚点。

三段连起来看,codex 的赌注很清晰:用户说过的话(需求)必须 verbatim 保住,agent 说过做过的话(summary 能概括就行)。这是一个既便宜又不容易跑偏的取舍 —— 需求丢了任务就完了,过程丢了 summary 还能兜。

设计权衡

决策点Claude CodeCodex CLIOpenCode
项目规则住哪<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)

几个值得玩味的对立:

迁移到你自己的 agent

  1. 防线按 cheapest-first 排:先给单个 tool result 设上限(超限落盘给预览),再清旧 tool result,最后才叫模型总结。总结是阶梯里最贵的一步,别一上来就用。
  2. 阈值 = window − 输出预留 − buffer,永远别等 413;但 413 的 reactive 兜底路径也必须有,因为估算是会漂的(三家都用估算,没有真 tokenizer)。
  3. 别让 summary 独自承担记忆。compact 之后,把”最近读过的文件、当前 plan”这类关键上下文做成确定性重建(重读、重注入)—— 比祈祷模型总结得全可靠得多。
  4. compact 请求自己也会超窗,准备降载路径:截头、逐条丢最旧;丢的时候从最旧开始,保 prefix cache。
  5. 认真考虑”compaction 是数据,不是删除”:旧历史留着、读时过滤,你就免费得到 revert 和审计能力 —— 这对 coding agent 尤其值钱。
  6. 给压缩加熔断:连续失败 N 次(Claude Code 是 3)就停手报错,否则 error → hook → retry 会烧成死亡螺旋。
  7. 保住用户的原话。codex 的 recency-only 策略证明:verbatim 保留 user 消息 + 总结其余,是一个既便宜又不容易跑偏的默认解。