三方上下文压缩机制对比 · 金字塔结构 + 完整源码
严谨科技报告。按金字塔原理组织:结论先行 → 三支柱(MECE)→ 每支柱附三方核心函数完整源码 → 次级事实归位 → 纵向附录。
对比对象:
- 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 为本地泄露仓库)。
目录
引子与核心结论
情境(S):LLM 上下文窗口有限,长会话必然溢出。Claude Code、Pi、Codex 三个编码 agent 都实现了上下文压缩来续命。
冲突(C):三者压缩的参数看起来大同小异——都是「窗口 − 预留」触发、都用 char/4 估算、都生成结构化摘要,容易让人以为压缩「就那么回事」。
疑问(Q):三者的压缩到底有没有本质区别?区别在哪?
回答(A):
核心结论(金字塔顶):三者的压缩差异不在参数,而在三个根本问题上的不同下注:缓存、结构、边界。参数趋同是表象;这三个取舍决定了各自的能力天花板。
下文三支柱相互独立、完全穷尽(MECE),按影响面从大到小排列;每支柱先给论点,再给三方完整源码证据。
支柱一 · 缓存取舍:压缩要不要迁就 prompt 缓存?
论点:三者对「压缩时是否保护 prompt 前缀缓存」给出三种互斥答案,这决定了各自的成本模型与代码复杂度。这是影响面最大的一个取舍——Claude Code 几乎每个压缩动作都在回答「会不会破前缀」,复杂度最高;Pi 因放弃缓存,实现最干净。
| Agent | 下注 | 一句话 |
|---|---|---|
| Claude Code | 全力复用 | 压缩调用本身也要命中缓存 |
| Codex | 前缀保护 | 压缩请求不追求命中,但删历史从头删以保后续 turn |
| Pi | 主动放弃 | 压缩是一次性 prompt,复用无价值,索性关掉 |
1.1 Claude Code — fork 复用主对话前缀(省 98% miss)
机制讲清楚:Claude Code 生成摘要,不是在主对话里直接调一次模型,而是 fork 出一个子 agent 去做这件事。要让这次「子调用」也命中缓存,它的请求就得和主对话的前缀完全一致——Anthropic 的缓存键由五样东西拼成:system prompt + tools + model + messages 前缀 + thinking config。CacheSafeParams 这个结构体就是把前四样打包传给 fork,保证一致。
有一个反直觉的坑:如果顺手给 fork 设了 maxOutputTokens,它会连带把 budget_tokens 也改掉(claude.ts 里有个 Math.min 钳制),而 budget_tokens 属于 thinking config、又恰恰是缓存键的一部分——于是缓存瞬间失效。所以这条压缩路径绝不能设 maxOutputTokens。下面代码块里的注释就是在警告这一点:
// 来源: 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 自然会下降。而 Claude Code 有一个「缓存断裂检测」一直在盯着 cache_read——一旦发现它掉了,就怀疑是缓存被意外破坏并报警。但压缩造成的下降是合法的,不该报警。所以每次压缩后必须主动调 notifyCompaction,把检测的基线 prevCacheReadTokens 清成 null,让下一次的下降不被误判。注释里记着:漏调这一步曾导致 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,占全队 cache_creation 约 0.76%(~380 亿 token/天)。这是「全力复用」这一下注的量化收益。
1.2 Codex — 超限从头部删,保护前缀缓存
机制讲清楚:Codex 压缩历史时,可能连「压缩请求」本身都还超上下文。这时它的选择是删掉最旧的那一项、然后重试,而不是删最新的。为什么删头不删尾?因为服务端 prompt 缓存按前缀匹配——只要开头不变,后面就能复用;而删尾会让整段后缀的缓存全部作废。删头既腾出了空间,又保住了前缀缓存。下面的 remove_first_item() 就是这个动作,注释直接点明「preserve cache (prefix-based)」:
// 来源: 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;
}
// ... 只剩一项仍超限则报错 ...
}压缩这一整个 turn 里可能重试很多次。Codex 复用同一个 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.1.3 Pi — 直接关闭缓存(退出这场博弈)
机制讲清楚:Pi 的判断很干脆——摘要是一次性请求,几乎不会有第二次一模一样的调用来复用它的缓存,那么「写缓存」本身反而是浪费(占额度、还徒增复杂度)。于是它给摘要请求设两个东西:cacheRetention:"none"(明确不写 prompt cache)+ 一个全新的 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);
}1.4 支柱一小结
这是三者最深刻的分歧,也是复杂度差异的根源。 同样一件事(压缩),CC 为「压缩这次调用也命中缓存」付出了 microcompact 的 cache_edits、fork 的 maxOutputTokens 禁令、两阶段断裂检测三重复杂度;Codex 折中(只保后续 turn 前缀);Pi 一行 cacheRetention:"none" 甩掉整个问题。下注不同 → 成本模型不同 → 代码复杂度差一个数量级。
支柱二 · 结构取舍:会话是线性还是树?
论点:Pi 把会话建模成树,CC/Codex 是线性。这一个结构选择,决定了「能不能保留探索过的分支上下文」——这不是参数问题,是结构问题。Pi 的一切独特性(分支摘要、跨分支累计追踪)都长在树上。
| Agent | 结构 | 由此得到的能力 |
|---|---|---|
| Claude Code / Codex | 线性链 | 简单、易做前缀缓存;切走的思路只能丢弃 |
| Pi | 树 | 唯一拥有「分支摘要」:切分支时保留被抛弃分支的上下文 |
2.1 Pi — 保留原始消息 + 树上的分支摘要
机制讲清楚:Pi 压缩的做法是「摘要旧的、原样保留近期」——关键是从哪切。findCutPoint 从最新消息往回走,把每条消息的估算 token 累加,直到累计够了 keepRecentTokens(默认 20k)就停下;停下的位置再往后找最近的一个合法切点。什么是合法切点?user / assistant / bash 等都可以,唯独 tool_result 不行——因为 tool_result 必须紧跟它对应的 tool call,切在它们中间会让 API 报「tool_use/tool_result 不配对」的错。切点确定后,切点之前的消息拿去摘要,切点之后的原始消息原样保留(reload 时从 firstKeptEntryId 重放)。下面两段代码:先是「哪些能当切点」的判定,再是完整的往回累加算法:
// 来源: 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 的会话是一棵树,你可以在多条思路间跳。当你从分支 A(old leaf)切到分支 B(target),collectEntriesForBranchSummary 做三步:① 找 A 和 B 的最深公共祖先(两条路径都经过的最后一个节点);② 从 A 的叶子沿 parentId 一路回溯到那个祖先,把途中所有 entry 收集起来;③ 反转成时间序,交给模型总结,再把这段摘要注入 B。于是你在 B 里还「记得」刚才在 A 探索过什么。CC/Codex 是线性链,根本没有「另一条分支」可回溯,所以这个机制它们结构上就不存在:
// 来源: 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 本地压缩是三家里最激进的——它把历史里所有的 assistant 回复、工具调用、reasoning 全部丢掉,只从最新往旧挑「用户消息」,挑到累计 COMPACT_USER_MESSAGE_MAX_TOKENS(20k)为止;超出预算的那一条按 token 截断塞进去。最后把生成的摘要作为一条 user 消息接在末尾。为什么敢这么狠?因为它赌「摘要 + 用户原话」已经够下一轮继续,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 — 线性:近期消息 + 摘要 + 从头截断重试
机制讲清楚:这段处理的是一个边界情况——压缩本身也是一次 API 调用,那如果要压缩的历史太长,连「压缩请求」都超上下文了怎么办?CC 的兜底是 truncateHeadForPTLRetry(PTL = Prompt Too Long),步骤是:
- 先把消息按 API 轮次(一次请求-响应)分组;
- 从最老的组开始丢——丢多少?如果错误信息里给了「超了多少 token」(
tokenGap),就一组组累加、丢到刚好够;给不了就丢 20%; - 至少留 1 组(不能全丢光);
- 丢完如果剩下的第一条变成了
assistant(而 API 要求历史首条必须是user),就补一条合成的 user 占位消息PTL_RETRY_MARKER。
注意方向:CC 这里是从头丢(丢最老的),这和反应式路径里 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;
}2.4 支柱二小结
结构决定能力上限。 Pi 的 collectEntriesForBranchSummary 之所以存在,是因为它的会话是树——沿 parentId 回溯、求公共祖先。CC/Codex 的线性链上没有「另一条分支」可回溯,结构上就不可能有分支摘要。这也解释了保留策略的差异:Pi 保留原始消息(树可重放)、CC/Codex 只能保留近期片段(线性丢弃)。
支柱三 · 边界取舍:压缩逻辑放在哪、谁能改?
论点:三者把压缩的控制权放在不同位置,对应三种产品定位。这决定了「谁能演进压缩」——用户?框架?还是只有厂商?
| Agent | 边界形态 | 谁掌控 | 定位 |
|---|---|---|---|
| Claude Code | 重客户端单体 | 内部 feature flag(用户改不动) | 开箱即用的最优 |
| Pi | 可插拔框架 | 扩展钩子(换模型/换策略/取消) | 可二次开发的工具包 |
| Codex | 按 provider 分派 | 四条路径,能推给服务端就推 | 与自家后端深度集成 |
3.1 Codex — 按 provider/flag 分派四条路径
机制讲清楚:Codex 的「边界」体现在一个 dispatch 决策树——压缩逻辑放在哪、用哪种,完全由运行环境决定,而不是写死一套。判定顺序是:① 开了 TokenBudget feature → 纯丢弃(不摘要,直接装新窗口);② 否则,如果 provider 支持远程压缩(OpenAI / Azure)→ 走服务端压缩(再按 RemoteCompactionV2 flag 分 v2 流式 / v1 endpoint);③ 都不是(第三方 API)→ 本地模型摘要。哲学是「能把重活推给自家后端就推」,客户端只在没有服务端能力时才自己扛。下面就是这棵决策树:
// 来源: 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 把摘要函数的关键依赖全部做成参数——model、apiKey、streamFn(流式实现)、customInstructions(聚焦指令)、previousSummary(上一次的摘要)。这意味着扩展可以传入任意模型(官方示例就用便宜的 gemini-2.5-flash 来压缩,省钱)、任意 prompt、任意流式实现。此外有 previousSummary 时会自动切到「增量更新」prompt(把上次摘要 + 新消息合并),而不是从头重写。这就是「可插拔」的具体形态:压缩的每个可变部分都从外面注入。下面节选展示这些可注入的参数:
// 来源: 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换模型压缩。
3.3 Claude Code — 单体:能力最全,但锁在客户端
机制讲清楚:和 Pi 相反,CC 的压缩是四层递进的单体——所有能力(微压缩、自动压缩、全量摘要、重建)都编译进客户端,用内部 feature flag(ant-only)门控,普通用户改不了一行,也换不了压缩模型。连摘要 prompt 都是内嵌的常量。下面贴 NO_TOOLS_PREAMBLE 作为例子,顺带讲一个细节:CC 全量压缩用 maxTurns:1、而 fork 会继承主对话的全套工具,于是模型有时会「手贱」去调工具——一调就废掉了它唯一的一轮、拿不到摘要。所以这个 prompt 开头用极强的措辞(「工具调用会被拒绝、你会失败」)把模型摁住。这既展示了「单体内嵌、用户改不动」,也展示了单体为了自洽要处理的边角:
// 来源: 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.
`3.4 支柱三小结
边界决定演进权。 Codex 的 dispatch 是「厂商预设四条路,按环境选一条」,把重活推给服务端;Pi 的 generateSummaryWithUsage 把 model/prompt/streamFn 全暴露成参数,「用户可以拿自己的模型来压缩」;CC 把最全的能力(四层 + 熔断 + 重建)编译进单体,换来开箱即用但用户改不动。
次级事实归位(MECE 校验)
金字塔原理要求「以上统下」:下面这些看似独立的参数/机制,其实都归属于上面三支柱,不构成新的差异维度。逐一归位以证明三支柱完全穷尽:
| 次级事实 | 三方情况 | 归属支柱 | 为何不是独立差异 |
|---|---|---|---|
| 触发阈值 | 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(tokens.ts:226-261)、Codex 的 context_manager/history.rs:297-315 是同一思路:找最后一次 API 的权威 usage,加上其后新增消息的字符/字节粗估。差异仅在常量(CC 对 JSON 用 /2、Pi 图片按 4800 字符、Codex 字节下界)。
MECE 结论:三支柱相互独立(缓存/结构/边界互不重叠),完全穷尽(以上全部次级事实均可归入其一或判定为趋同,无遗漏)。金字塔成立。
附录 A · 三系统纵向完整机制(参考)
金字塔是横向论证(按支柱);此处补纵向(按系统),供需要完整脉络时查阅。
A.1 Claude Code
四层递进:① microcompact(删工具结果,三条路:cached MC / time-based / API-native)② autocompact(阈值+熔断,先试 session memory)③ 全量 compact(fork 缓存共享 + 9 段摘要 + PTL 重试)④ 压缩后重建(恢复 5 文件/50k、skill/plan/tool delta)。运行时编排:主动链 snip→microcompact→collapse→autocompact;反应式链 413→collapse drain→reactive compact→抛错。详见 01/05/06。
A.2 Pi
单一滑动 checkpoint:shouldCompact(window−16384 触发)→ findCutPoint(从新往回留 20k)→ 生成结构化摘要 → append CompactionEntry{summary, firstKeptEntryId} → reload。特色:树状会话 + 分支摘要、split-turn 双摘要、迭代 update prompt、累计文件追踪、可插拔钩子、关闭缓存。两套平行实现(agent 用 retainedTail / coding-agent 用 reload)。详见 07 §1.B。
A.3 Codex
四路分派:TokenBudget(纯丢弃)/ remote v1(服务端 endpoint)/ remote v2(服务端流式,保 64k)/ 本地(LLM handoff 摘要,保 20k user)。触发 90%/95%,无熔断。rollout JSONL 内嵌完整 replacement_history,resume 无需重跑模型。特色:初始上下文重注入、x-codex-turn-state 有状态延续、压缩后警告用户、跨模型 fallback。详见 07 §1.C。
附录 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 的服务端压缩逻辑不在客户端源码内。
*金字塔结构:结论先行(核心结论)→ 三支柱 MECE(缓存/结构/边界,各附完整源码)→ 次级事实归位(MECE 校验)→ 纵向附录。逐维度旧版见 git 历史;分析描述见 07,金字塔执行摘要见 09。*