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 路径避免单独设置该参数。注释中的缓存损失数字是原实现记录的背景,不能当作本次对照的实测结果。
// 来源: 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% 是源码对历史误报的说明。
// 来源: 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() 删除最旧的一项,然后重试。下面的注释提到前缀缓存,但单看“删除头部”这个动作,不能推出“前缀保持不变”或命中率提高。能从这段代码确认的是它优先保留较新的内容;缓存效果还取决于实际请求结构和服务端策略。
// 来源: 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 级状态跨重试保留。它与历史中删去哪一项是两件事,需要分开理解。
// 来源: 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 所有请求都关闭缓存,也不能替作者推断选择它的成本账本。
// 来源: 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 拆开;下方两段代码分别展示切点判定和向前累计的过程。
// 来源: 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;
}// 来源: 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 查找旧分支与目标分支的公共祖先,收集离开分支上的记录,按时间顺序交给模型总结,再把摘要带到目标分支。它利用了这份会话树结构;本文没有在另外两份压缩路径里找到对应实现,但这不等于线性记录在原理上无法扩展分支功能。
// 来源: 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 回复和工具过程由摘要承接,不能把这个保留策略直接套到下文的远程压缩分支。
// 来源: 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 错误缩短历史,处理步骤如下:
- 先把消息按 API 轮次(一次请求-响应)分组;
- 从最老的组开始丢——丢多少?如果错误信息里给了「超了多少 token」(
tokenGap),就一组组累加、丢到刚好够;给不了就丢 20%; - 至少留 1 组(不能全丢光);
- 丢完如果剩下的第一条变成了
assistant(而 API 要求历史首条必须是user),就补一条合成的 user 占位消息PTL_RETRY_MARKER。
这里的函数从旧消息开始缩短输入。它与下文提及的 reactive compact 属于不同的错误处理路径,需要结合各自调用条件阅读。
// 来源: 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 选择相应服务端路径;其余情况使用本地模型摘要。下面的决策树说明客户端怎样选择实现,不说明服务端内部使用哪种压缩算法。
// 来源: 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 时,会使用更新已有摘要的提示词。下方代码节选展示了可注入的参数。
// 来源: 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 又继承工具列表,提示词因此要求模型不要用唯一的回合调用工具,而要直接返回摘要。
// 来源: 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 估算的代码节选。共同使用字符或字节近似,不意味着计数结果相同:
// 来源: 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 · 证据边界
- Claude Code:所用可读代码已定位主要路径;4 个 feature-gated 模块(cachedMicrocompact/reactiveCompact/contextCollapse/snip)实现被 DCE,只见接口;补充版本仅做二进制字符串检查(见 04)。
- Pi:对应公开源码可查;仓库有两套平行实现(
packages/agent/packages/coding-agent),本文代码以 coding-agent 版为准。 - Codex:对应公开源码可查;每模型真实
context_window来自后端/models运行时返回,源码只有 272k fallback,故 90%/95% 绝对 token 值是推断;remote v1/v2 的服务端压缩逻辑不在客户端源码内。
本文保留了用于对照的源码节选及接口名,没有重新运行三套实现的同题长会话测试。实际压缩质量、缓存命中和任务恢复效果,仍需在固定版本与同一负载下测量。