← 博客 三方上下文压缩机制对比 关键源码节选 Claude Code · Pi · Codex
三方上下文压缩机制 从摘要请求、历史保留和调用入口检查实现差异 Claude Code Pi Codex 源码级对比 · 源码对照 源码阅读角度:缓存 · 结构 · 边界 — 触发阈值与错误恢复另列 摘要请求 · 缓存 请求构造 会话历史 · 保留 历史组织 调用入口 · 扩展 接口位置 Claude Code 共享请求参数 fork 沿用主对话参数,命中率未测 Pi 独立缓存设置 cacheRetention:"none",一次性 prompt Codex 超限重试 移除最旧项,保留近期内容 Claude Code 线性 一条链 + compact boundary 打点 Pi 树 + 分支摘要 切分支时保留被抛弃分支的上下文 Codex 线性 rollout + UUIDv7 窗口谱系 Claude Code 单体 客户端内置编排与错误恢复 Pi 可插拔 钩子可换模型 / 换策略 / 取消 Codex 分派 按开关和 provider 分派 选型:请求参数 → Claude Code · 要分支/要定制 → Pi · 要外推后端 → Codex Claude Code(leaked) · earendil-works/pi · openai/codex

Claude Code、Pi 与 Codex 的上下文压缩

对比对象:
- Claude Code — @anthropic-ai/claude-code@0.0.0-leaked(2026-03-31 泄露),src/services/compact/
- Pi — earendil-works/pi(TypeScript),packages/coding-agent/src/core/compaction/
- Codex — openai/codex(Rust),codex-rs/core/src/
代码块头部标注 来源: 仓库 · 路径:行号,均从源码原样摘取(Pi/Codex 经 gh api 拉取,Claude Code 为本地泄露仓库)。

目录


长会话压缩时,究竟留下了什么

长会话接近上下文上限时,Agent 需要缩短下一次请求。Claude Code、Pi 和 Codex 都有相应实现,但“生成一份摘要”只描述了其中一步:摘要请求本身可能超限,工具调用与结果不能被随意拆开,压缩后还要恢复继续工作所需的上下文。

我把文中这几份源码放在一起,主要查看缓存参数、历史保留和调用入口。相似的触发阈值,并不意味着它们发出的请求或保存的会话记录相同。

下面先对照摘要调用,再看消息怎样切分,最后检查哪些参数由客户端、服务端或扩展决定。代码块是用于解释具体路径的节选,有些包含省略号,不能当作完整可运行程序。

这份对照涉及的主要差别如下:

这些角度用于组织源码阅读,不代表已经穷尽压缩质量、成本、延迟或错误恢复的全部问题。


摘要请求如何处理缓存

Agent下注一句话
Claude Code共享请求参数压缩调用本身也要命中缓存
Codex超限重试压缩请求不追求命中,但删历史从头删以保后续 turn
Pi独立缓存设置压缩是一次性 prompt,复用无价值,索性关掉

Claude Code:fork 调用复用主对话参数

这份 Claude Code 实现通过 fork 调用生成摘要,并把主对话的 system prompt、tools、model 和 messages 等参数传入。CacheSafeParams 用来维持请求前缀的一致性;thinking 配置也会影响这条路径的缓存行为。这里讨论的是代码为复用缓存做了什么,并没有测量实际命中率。

源码注释特别提醒了 maxOutputTokens:设置它会经过 Math.min 影响 budget_tokens,进而改变 thinking 配置。所以这条 fork 路径避免单独设置该参数。注释中的缓存损失数字是原实现记录的背景,不能当作本次对照的实测结果。

TypeScript
// 来源: claude-code · src/utils/forkedAgent.ts:46-103(节选)
/**
 * Parameters that must be identical between the fork and parent API requests
 * to share the parent's prompt cache. The Anthropic API cache key is composed of:
 * system prompt, tools, model, messages (prefix), and thinking config.
 */
export type CacheSafeParams = {
  systemPrompt: SystemPrompt
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
  toolUseContext: ToolUseContext
  forkContextMessages: Message[]
}
// maxOutputTokens 文档警告:
//   CAUTION: setting this changes both max_tokens AND budget_tokens (via clamping
//   in claude.ts). If the fork uses cacheSafeParams to share the parent's prompt
//   cache, a different budget_tokens will invalidate the cache — thinking config
//   is part of the cache key. Only set this when cache sharing is not a goal.

压缩也会让后续请求的 cache_read token 下降。为了不把这种预期变化记作缓存故障,notifyCompaction 会清空检测器的 prevCacheReadTokens 基线。下方注释提到的 20% 是源码对历史误报的说明。

TypeScript
// 来源: claude-code · src/services/api/promptCacheBreakDetection.ts:684-698
/**
 * Call after compaction to reset the cache read baseline.
 * Compaction legitimately reduces message count, so cache read tokens
 * will naturally drop on the next call — that's not a break.
 */
export function notifyCompaction(querySource: QuerySource, agentId?: AgentId): void {
  const key = getTrackingKey(querySource, agentId)
  const state = key ? previousStateBySource.get(key) : undefined
  if (state) {
    state.prevCacheReadTokens = null
  }
}
源码注释(compact.ts:431–434)记录过关闭 fork 缓存共享后的缓存变化,涉及 98% cache miss、约 0.76% cache_creation 和约 380 亿 token/天。这些数字是实现注释中的历史背景,本文没有复现测量。

Codex:压缩请求超限时删去最旧项再试

Codex 的压缩请求若仍然超限,会调用 remove_first_item() 删除最旧的一项,然后重试。下面的注释提到前缀缓存,但单看“删除头部”这个动作,不能推出“前缀保持不变”或命中率提高。能从这段代码确认的是它优先保留较新的内容;缓存效果还取决于实际请求结构和服务端策略。

Rust
// 来源: codex · codex-rs/core/src/compact.rs:309-324
Err(e) if matches!(e.details(), CodexErrorDetails::ContextWindowExceeded) => {
    if turn_input_len > 1 {
        // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact.
        error!("Context window exceeded while compacting; removing oldest history item. Error: {e}");
        history.remove_first_item();
        retries = 0;
        continue;
    }
    // ... 只剩一项仍超限则报错 ...
}

这组重试复用同一个 client session,让 sticky routing 和 websocket 增量追踪等 turn 级状态跨重试保留。它与历史中删去哪一项是两件事,需要分开理解。

Rust
// 来源: codex · codex-rs/core/src/compact.rs:260-264
let mut client_session = sess.services.model_client.new_session();
// Reuse one client session so turn-scoped state (sticky routing, websocket incremental
// request tracking)
// survives retries within this compact turn.

Pi:这次摘要调用设置 cacheRetention 为 none

Pi 在这里给摘要请求设置 cacheRetention: "none" 和新的 sessionId,与主对话调用分开。代码能确认这条摘要路径采用了不同的缓存与会话参数,不能据此概括 Pi 所有请求都关闭缓存,也不能替作者推断选择它的成本账本。

TypeScript
// 来源: pi · packages/coding-agent/src/core/compaction/compaction.ts:562-581
export async function completeSummarization(
	model: Model<any>, context: Context, options: SimpleStreamOptions,
	streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
	// Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
	const requestOptions: SimpleStreamOptions = {
		...options,
		cacheRetention: "none",
		sessionId: uuidv7(),
	};
	const produce = async (): Promise<AssistantMessage> =>
		streamFn
			? (await streamFn(model, context, requestOptions)).result()
			: completeSimple(model, context, requestOptions);
	return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
}

三条路径不能直接换算成成本排名

Claude Code 显式传递主对话参数,Codex 保留重试所用的 client session,Pi 单独设置摘要请求的缓存选项。这些是实现差异。要比较花费,还需要同一会话负载下的输入量、缓存命中、调用次数和计费规则,源码长度不能代替这些测量。


压缩后保留哪些历史

Agent结构由此得到的能力
Claude Code / Codex线性链简单、易做前缀缓存;切走的思路只能丢弃
Pi树唯一拥有「分支摘要」:切分支时保留被抛弃分支的上下文

2.1 Pi — 保留原始消息 + 树上的分支摘要

Pi 从最新消息向前累计估算 token,达到 keepRecentTokens(默认 20k)后寻找合法切点。切点以前的内容用于摘要,后面的原始消息保留,重新加载时从 firstKeptEntryId 继续。切分还要避免把工具调用和对应的 tool_result 拆开;下方两段代码分别展示切点判定和向前累计的过程。

TypeScript
// 来源: pi · compaction.ts:308-321
function isCutPointMessage(message: AgentMessage): boolean {
	switch (message.role) {
		case "user":
		case "assistant":
		case "bashExecution":
		case "custom":
		case "branchSummary":
		case "compactionSummary":
			return true;
		case "toolResult":
			return false; // must follow their tool call
	}
	return false;
}
TypeScript
// 来源: pi · compaction.ts:403-461
export function findCutPoint(
	entries: SessionEntry[], startIndex: number, endIndex: number, keepRecentTokens: number,
): CutPointResult {
	const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
	if (cutPoints.length === 0) {
		return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
	}
	// Walk backwards from newest, accumulating estimated message sizes
	let accumulatedTokens = 0;
	let cutIndex = cutPoints[0];
	for (let i = endIndex - 1; i >= startIndex; i--) {
		const entry = entries[i];
		const messageTokens = sessionEntryToContextMessages(entry).reduce(
			(sum, message) => sum + estimateTokens(message), 0);
		if (messageTokens === 0) continue;
		accumulatedTokens += messageTokens;
		if (accumulatedTokens >= keepRecentTokens) {
			for (let c = 0; c < cutPoints.length; c++) {
				if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; break; }
			}
			break;
		}
	}
	// Scan backwards to include adjacent metadata entries that do not affect context.
	while (cutIndex > startIndex) {
		const prevEntry = entries[cutIndex - 1];
		if (prevEntry.type === "compaction" || sessionEntryToContextMessages(prevEntry).length > 0) break;
		cutIndex--;
	}
	const cutEntry = entries[cutIndex];
	const startsTurn = isTurnStartEntry(cutEntry);
	const turnStartIndex = startsTurn ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
	return { firstKeptEntryIndex: cutIndex, turnStartIndex, isSplitTurn: !startsTurn && turnStartIndex !== -1 };
}

Pi 另有分支摘要:collectEntriesForBranchSummary 沿 parentId 查找旧分支与目标分支的公共祖先,收集离开分支上的记录,按时间顺序交给模型总结,再把摘要带到目标分支。它利用了这份会话树结构;本文没有在另外两份压缩路径里找到对应实现,但这不等于线性记录在原理上无法扩展分支功能。

TypeScript
// 来源: pi · branch-summarization.ts:108-146
export function collectEntriesForBranchSummary(
	session: ReadonlySessionManager, oldLeafId: string | null, targetId: string,
): CollectEntriesResult {
	if (!oldLeafId) return { entries: [], commonAncestorId: null };
	// Find common ancestor (deepest node that's on both paths)
	const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
	const targetPath = session.getBranch(targetId);
	let commonAncestorId: string | null = null;
	for (let i = targetPath.length - 1; i >= 0; i--) {
		if (oldPath.has(targetPath[i].id)) { commonAncestorId = targetPath[i].id; break; }
	}
	// Collect entries from old leaf back to common ancestor
	const entries: SessionEntry[] = [];
	let current: string | null = oldLeafId;
	while (current && current !== commonAncestorId) {
		const entry = session.getEntry(current);
		if (!entry) break;
		entries.push(entry);
		current = entry.parentId;
	}
	entries.reverse(); // chronological
	return { entries, commonAncestorId };
}

2.2 Codex — 线性:仅保留 ≤20k tokens 的近期 user 消息

这里检查的是 Codex 的本地压缩分支。它从较新的用户消息开始填充 COMPACT_USER_MESSAGE_MAX_TOKENS(20k)预算,必要时截断边界消息,再追加生成的摘要。assistant 回复和工具过程由摘要承接,不能把这个保留策略直接套到下文的远程压缩分支。

Rust
// 来源: codex · codex-rs/core/src/compact.rs:56, 624-685(节选)
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;

fn build_compacted_history_with_limit(
    mut history: Vec<ResponseItem>,
    user_messages: &[CompactedUserMessage],
    summary_text: &str,
    max_tokens: usize,
) -> Vec<ResponseItem> {
    let mut selected_messages: Vec<CompactedUserMessage> = Vec::new();
    if max_tokens > 0 {
        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(CompactedUserMessage { message: truncated, .. });
                break;
            }
        }
        selected_messages.reverse();
    }
    for message in &selected_messages {
        history.push(ResponseItem::Message { role: "user".to_string(), /* ... */ });
    }
    // 末尾追加摘要(空则 "(no summary available)")
    history.push(ResponseItem::Message { role: "user".to_string(),
        content: vec![ContentItem::InputText { text: summary_text.into() }], /* ... */ });
    history
}

2.3 Claude Code — 线性:近期消息 + 摘要 + 从头截断重试

Claude Code 也要处理“用于压缩的请求自身超限”的情况。truncateHeadForPTLRetry 针对 Prompt Too Long 错误缩短历史,处理步骤如下:

这里的函数从旧消息开始缩短输入。它与下文提及的 reactive compact 属于不同的错误处理路径,需要结合各自调用条件阅读。

TypeScript
// 来源: claude-code · src/services/compact/compact.ts:243-291(节选)
export function truncateHeadForPTLRetry(
  messages: Message[], ptlResponse: AssistantMessage,
): Message[] | null {
  const groups = groupMessagesByApiRound(input);
  if (groups.length < 2) return null;
  const tokenGap = getPromptTooLongTokenGap(ptlResponse);
  let dropCount: number;
  if (tokenGap !== undefined) {
    let acc = 0; dropCount = 0;
    for (const g of groups) { acc += roughTokenCountEstimationForMessages(g); dropCount++; if (acc >= tokenGap) break; }
  } else {
    dropCount = Math.max(1, Math.floor(groups.length * 0.2)); // 丢 20%
  }
  dropCount = Math.min(dropCount, groups.length - 1); // 至少留 1 组
  const sliced = groups.slice(dropCount).flat();
  if (sliced[0]?.type === 'assistant') {
    return [createUserMessage({ content: PTL_RETRY_MARKER, isMeta: true }), ...sliced];
  }
  return sliced;
}

区分下一次请求和持久化历史

压缩后发给模型的消息,和磁盘上是否仍保存原始记录,不是同一个问题。Pi 的分支摘要和 reload、Codex 的 replacement_history、Claude Code 的重建步骤,都要分别查看。仅凭“树状”或“线性”标签,无法断言谁能回放历史、谁一定丢失全部过程。


压缩入口由谁决定

Agent边界形态谁掌控定位
Claude Code重客户端单体内部 feature flag(用户改不动)客户端内置编排
Pi可插拔框架扩展钩子(换模型/换策略/取消)可二次开发的工具包
Codex按 provider 分派四条路径,能推给服务端就推与自家后端深度集成

3.1 Codex — 按 provider/flag 分派四条路径

Codex 按功能开关和 provider 分派:启用 TokenBudget 时走窗口裁剪;支持远程压缩时,再按 RemoteCompactionV2 选择相应服务端路径;其余情况使用本地模型摘要。下面的决策树说明客户端怎样选择实现,不说明服务端内部使用哪种压缩算法。

Rust
// 来源: codex · codex-rs/core/src/tasks/compact.rs:36-78
if ctx.config.features.enabled(Feature::TokenBudget) {
    crate::compact_token_budget::run_manual_compact_task(session, ctx).await?;   // 纯丢弃
    return Ok(None);
}
let result = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) {
    if ctx.config.features.enabled(codex_features::Feature::RemoteCompactionV2) {
        crate::compact_remote_v2::run_remote_compact_task(session.clone(), ctx).await   // 服务端流式
    } else {
        crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await       // 服务端 endpoint
    }
} else {
    let input = vec![UserInput::Text {
        text: ctx.config.compact_prompt.as_deref()
            .unwrap_or(crate::compact::SUMMARIZATION_PROMPT).to_string(),          // 本地摘要
        text_elements: Vec::new(),
    }];
    crate::compact::run_compact_task(session.clone(), ctx, input).await
};

3.2 Pi — 摘要调用可被扩展换模型/换策略

Pi 的 generateSummaryWithUsage 接受 model、apiKey、streamFn、customInstructions 和 previousSummary 等参数。扩展可以在这个入口调整摘要模型、请求实现和聚焦指令;提供 previousSummary 时,会使用更新已有摘要的提示词。下方代码节选展示了可注入的参数。

TypeScript
// 来源: pi · compaction.ts:622-668(节选,展示可注入的模型/prompt/自定义指令)
export async function generateSummaryWithUsage(
	currentMessages: AgentMessage[],
	model: Model<any>,                    // ← 可换成任意便宜模型
	reserveTokens: number,
	apiKey: string | undefined,
	/* ... */ customInstructions?: string,
	previousSummary?: string,             // ← 有则走 UPDATE 增量 prompt
	/* ... */ streamFn?: StreamFn,        // ← 可注入自定义流式函数
): Promise<{ text: string; usage: Usage }> {
	const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Infinity);
	let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
	if (customInstructions) basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
	const conversationText = serializeConversation(convertToLlm(currentMessages));
	let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
	if (previousSummary) promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
	promptText += basePrompt;
	/* ... completeSummarization(...) ... */
}
Pi 的可插拔进一步体现在 session_before_compact / session_before_tree 扩展事件(可 {cancel:true} 或提供自定义 {compaction:{summary,...}}),官方示例 custom-compaction.ts 用 gemini-2.5-flash 换模型压缩。

Claude Code:压缩编排包含在客户端内

这份 Claude Code 客户端包含微压缩、自动压缩、全量摘要与重建逻辑,部分行为受内部 feature flag 控制。这里没有看到与 Pi 相同的参数注入接口。下方 NO_TOOLS_PREAMBLE 解决的是一个具体限制:全量压缩只允许 maxTurns: 1,fork 又继承工具列表,提示词因此要求模型不要用唯一的回合调用工具,而要直接返回摘要。

TypeScript
// 来源: claude-code · src/services/compact/prompt.ts:19-26
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.

- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
`

扩展接口与实现位置

Codex 提供分派到本地或远程实现的路径,Pi 在摘要函数入口暴露参数,这份 Claude Code 则把编排放在客户端中。它们影响的是如何接入、调试和替换策略,不能单独证明哪套压缩质量更好。


触发阈值、token 估算与错误恢复

另外一些细节横跨前面的几个问题,值得单独列出。触发得早晚、估算是否准确、失败后如何重试,都可能改变一次长任务的实际表现。

次级事实三方情况归属支柱为何不是独立差异
触发阈值CC 有效窗口−13k / Pi window−16384 / Codex 90%—参数趋同,都是「窗口−预留」,本身无本质分歧
token 计数三家都是尾部真实 usage + 其后 char/4 粗估—完全趋同,已是事实标准(见下代码)
摘要格式CC 9 段 / Pi Goal-Progress / Codex handoff入口详尽度差异服务于各自定位(单体求全/框架求简/集成求快)
保留策略Pi 原始消息 / Codex 仅 20k user / CC 近期+文件缓存 + 历史保留越多越难维持缓存(一);树才能 replay 原始消息(二)
工具结果截断Pi 序列化截 2000 字符 / CC 冷缓存整块替换 / Codex token 截断缓存都是为控制发送体积以配合各自缓存策略
熔断器仅 CC(3 次)入口单体自带的运维保护,别家把责任外推
压缩后警告仅 Codex入口厂商集成视角下的 UX 决策
分支摘要仅 Pi历史树结构的直接产物

以下是 token 估算的代码节选。共同使用字符或字节近似,不意味着计数结果相同:

TypeScript
// 来源: pi · compaction.ts:202-230 —— 尾部真实 usage + 其后粗估
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
	const usageInfo = getLastAssistantUsageInfo(messages);
	if (!usageInfo) { /* 全部估算 */ }
	const usageTokens = calculateContextTokens(usageInfo.usage);
	let trailingTokens = 0;
	for (let i = usageInfo.index + 1; i < messages.length; i++) {
		trailingTokens += estimateTokens(messages[i]);   // char/4
	}
	return { tokens: usageTokens + trailingTokens, /* ... */ };
}

Claude Code 的 tokenCountWithEstimation 与 Codex 的 history 估算都参考已有 usage,再估算新增内容。JSON、图片和字节计数的处理不同;例如 CC 对 JSON 使用 /2,Pi 对图片使用固定字符估算。接近上下文边界时,这些差异可能改变触发时机。


附录 A · 按系统查看调用顺序

前文按问题交叉阅读,下面把各自的调用顺序放回一起,便于沿源码继续检查。

A.1 Claude Code

主动路径按 snip、microcompact、collapse、autocompact 编排;请求遇到 413 时,另有 collapse drain 和 reactive compact 等处理。全量 compact 涉及 fork、摘要与 PTL 重试,之后再恢复文件、skill、plan 和工具差量。各步骤的开关和预算应以对应源码为准。

A.2 Pi

主要顺序是 shouldCompact 检查阈值、findCutPoint 确定保留边界、生成摘要、追加包含 summary 与 firstKeptEntryId 的 CompactionEntry,再 reload。会话树上的分支摘要是另一条入口。agent 与 coding-agent 的 retainedTail、reload 实现要分别阅读。

A.3 Codex

入口分为 TokenBudget、remote v1、remote v2 和本地摘要。文中记录的保留预算包括 remote v2 的 64k 和本地 user 消息的 20k。rollout JSONL 中的 replacement_history 用于恢复压缩后的历史;初始上下文重注入、跨模型回退和 turn 状态延续是需要一起检查的部分。


附录 B · 证据边界


本文保留了用于对照的源码节选及接口名,没有重新运行三套实现的同题长会话测试。实际压缩质量、缓存命中和任务恢复效果,仍需在固定版本与同一负载下测量。

文章目录8
Silent Star约 14 分钟