Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第7章

第7章:上下文压缩 —— 五层防线

17 分钟 · 更新于 2026-09-01

第7章:上下文压缩 —— 五层防线

Pi 有一层压缩,dsh 有一层,Codex 有两层(本地 + 远端)。Claude Code 有五层,而且它们在主循环里有严格的执行顺序和短路逻辑。本章讲这五层各管什么、为什么顺序不能换、以及一条压缩自己失败时的断路器。

本章的取证边界 第 2 章 §2.4 讲过:这份 sourcemap 来自外部构建,feature() 关掉的模块被物理删除了。五层里有四层的实现代码不在快照里

开关实现是否可见
① snipHISTORY_SNIP✗ 只有调用点
② microcompact(缓存编辑版)CACHED_MICROCOMPACT✗ 只有调度层 microCompact.ts
② microcompact(时间触发版)无开关✓ 完整可见
③ context-collapseCONTEXT_COLLAPSE✗ 只有调用点
④ autocompact无开关✓ 完整可见
⑤ reactive compactREACTIVE_COMPACT✗ 只有调用点

所以本章对 ①③⑤ 的描述来自调用签名 + 类型标注 + 注释,会明确标注 [推断];②(时间触发部分)和 ④ 是逐行可读的。


一、五层的位置与顺序

第 3 章那条流水线,压缩相关的部分是这样的 [源码 src/query.ts:365-543]:

text
messagesForQuery = getMessagesAfterCompactBoundary(messages)
        ↓
① applyToolResultBudget      单条消息里工具结果的总量上限(内容替换)
        ↓
② snipCompact                掐掉历史片段              [HISTORY_SNIP]
        ↓
③ microcompact               清空/删除老工具结果
        ↓
④ applyCollapsesIfNeeded     折叠视图投影              [CONTEXT_COLLAPSE]
        ↓
⑤ autoCompactIfNeeded        整段摘要
        ↓
   blocking-limit 检查        还是超了 → return
        ↓
   发请求 →  413  →  ⑥ context-collapse 排空 → ⑦ reactive compact

applyToolResultBudget 严格说是第 0 层,因为它不是"压缩历史"而是"限制单条结果",但它在同一条流水线上,本章一并讲。)

顺序原则写在注释里,两条:

ts
// Enforce per-message budget on aggregate tool result size. Runs BEFORE
// microcompact — cached MC operates purely by tool_use_id (never inspects
// content), so content replacement is invisible to it and the two compose
// cleanly.

[源码 src/query.ts:369]

ts
// Runs BEFORE autocompact so that if collapse gets us under the
// autocompact threshold, autocompact is a no-op and we keep granular
// context instead of a single summary.

[源码 src/query.ts:429]

第二条是全章的设计原则:

便宜的、保真度高的手段先上;只有它们都没把上下文压下去,才动用最贵、最有损的整段摘要。

"保真度"在这里是可以排序的:

手段丢失了什么可逆性
工具结果落盘 + 给路径什么也没丢,模型可以再读完全可逆
microcompact 清空老工具结果老工具的原文部分(文件还在磁盘上)
snip 掐掉片段 [推断]那段对话不可逆(但 transcript 里还有)
context-collapse 折叠 [推断]细节,留摘要transcript 里有原文
autocompact 整段摘要除摘要外的一切给了 transcript 路径

二、第 ⓪ 层:工具结果的单条预算

这一层不在 compact/ 目录里,它在工具接口上 [源码 src/Tool.ts:466]:

ts
/**
 * Maximum size in characters for tool result before it gets persisted to disk.
 * When exceeded, the result is saved to a file and Claude receives a preview
 * with the file path instead of the full content.
 *
 * Set to Infinity for tools whose output must never be persisted (e.g. Read,
 * where persisting creates a circular Read→file→Read loop and the tool
 * already self-bounds via its own limits).
 */
maxResultSizeChars: number

主循环里的调用 [源码 src/query.ts:379]:

ts
messagesForQuery = await applyToolResultBudget(
  messagesForQuery,
  toolUseContext.contentReplacementState,
  persistReplacements ? records => void recordContentReplacement(records, toolUseContext.agentId).catch(logError) : undefined,
  new Set(toolUseContext.options.tools.filter(t => !Number.isFinite(t.maxResultSizeChars)).map(t => t.name)),
)

最后那个 Set豁免名单maxResultSizeCharsInfinity 的工具不参与预算。

persistReplacements 的判断值得看 [源码 src/query.ts:376]:

ts
// Persist only for querySources that read records back on resume: agentId
// routes to sidechain file (AgentTool resume) or session file (/resume).
// Ephemeral runForkedAgent callers (agent_summary etc.) don't persist.
const persistReplacements =
  querySource.startsWith('agent:') || querySource.startsWith('repl_main_thread')

只有"以后可能被恢复"的会话才把替换记录落盘。 一次性的 forked agent(生成摘要、生成标题)跑完就没了,落盘纯属浪费。

你在本会话里见过这个机制 本教程写作时有一次 cat 多个源文件的输出超了限,工具结果变成:

Output too large (47.8KB). Full output saved to: …/tool-results/b6aedwiph.txt Preview (first 2KB): …

这就是 ⓪ 层。它是唯一完全可逆的一层——原文在磁盘上,模型想要随时 Read。


三、第 ② 层:microcompact —— 只清工具结果

microCompact.ts唯一一个调度层完整可见的压缩模块。

3.1 只有 8 种工具的结果可以被清

ts
const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME,
  ...SHELL_TOOL_NAMES,
  GREP_TOOL_NAME,
  GLOB_TOOL_NAME,
  WEB_SEARCH_TOOL_NAME,
  WEB_FETCH_TOOL_NAME,
  FILE_EDIT_TOOL_NAME,
  FILE_WRITE_TOOL_NAME,
])

[源码 src/services/compact/microCompact.ts:40]

判据是"这个结果能不能重新获得":读文件、跑 shell、搜索、抓网页、编辑、写文件——全都可以重跑。而 AskUserQuestion(用户的回答)、Agent(子 agent 的报告)、TodoWrite(待办状态)不在名单里,因为它们重跑拿不回来。

清空后留下的占位符:

ts
export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'

而第 5 章那两条系统提示词正是它的模型侧配套:

Old tool results will be automatically cleared from context to free up space. The {keepRecent} most recent results are always kept.

When working with tool results, write down any important information you might need later in your response, as the original tool result may be cleared later.

"重要的东西自己抄到回复里" —— 因为回复不会被清,工具结果会。

3.2 时间触发:缓存反正过期了,那就顺手清

这一段是本章最漂亮的一处设计,而且完整可见 [源码 src/services/compact/timeBasedMCConfig.ts]:

ts
/**
 * Triggers content-clearing microcompact when the gap since the last main-loop
 * assistant message exceeds a threshold — the server-side prompt cache has
 * almost certainly expired, so the full prefix will be rewritten anyway.
 * Clearing old tool results before the request shrinks what gets rewritten.
 *
 * Runs BEFORE the API call (in microcompactMessages, upstream of callModel)
 * so the shrunk prompt is what actually gets sent. Running after the first
 * miss would only help subsequent turns.
 *
 * Main thread only — subagents have short lifetimes where gap-based eviction
 * doesn't apply.
 */
export type TimeBasedMCConfig = {
  enabled: boolean
  /** Trigger when (now − last assistant timestamp) exceeds this many minutes.
   *  60 is the safe choice: the server's 1h cache TTL is guaranteed expired
   *  for all users, so we never force a miss that wouldn't have happened. */
  gapThresholdMinutes: number
  /** Keep this many most-recent compactable tool results. */
  keepRecent: number
}

const TIME_BASED_MC_CONFIG_DEFAULTS: TimeBasedMCConfig = {
  enabled: false,
  gapThresholdMinutes: 60,
  keepRecent: 5,
}

逻辑链条

  1. 你去吃了个午饭,一小时没动
  2. 服务端的 prompt cache 一定过期了(1h TTL 是上限,5min 是默认)
  3. 下一个请求无论如何都要重写整个前缀
  4. 既然反正要重写,那就先把老工具结果清掉,让重写的量小一点

而且阈值取 60 分钟的理由写得很清楚:"60 是安全选择——服务端 1h TTL 对所有用户都保证过期了,所以我们永远不会制造一次本来不会发生的 miss。"

这是把"缓存反正已经断了"变成一个可利用的时机。 平时清工具结果会破坏缓存,只有这个窗口是免费的。

代码里的调用顺序也体现了它的优先级 [源码 src/services/compact/microCompact.ts:250]:

ts
// Time-based trigger runs first and short-circuits. … Cached MC (cache-editing)
// is skipped when this fires: editing assumes a warm cache, and we just
// established it's cold.
const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
if (timeBasedResult) return timeBasedResult

缓存编辑那条路假定缓存是热的,而我们刚刚确认它是冷的——所以两者互斥。

可迁移的判断 ⑬ 找到"代价本来就要付"的时机,把有代价的维护操作挪到那个时机去做。

这是一类很通用的优化模式:GC 挑在系统空闲时跑、索引重建挑在全量刷新时做、schema 迁移挑在本来就要重启时做。判断标准是能不能论证"这个操作在这个时机的边际代价为零"——Claude Code 论证得很干净:TTL 上限是 1 小时,超过 1 小时缓存必然已失效。

3.3 缓存编辑版 [推断]

另一条路是 cachedMicrocompactPath,实现不在快照里,但调度层和类型足够还原它的设计:

ts
export type PendingCacheEdits = {
  trigger: 'auto'
  deletedToolIds: string[]
  // Baseline cumulative cache_deleted_input_tokens from the previous API response,
  // used to compute the per-operation delta (the API value is sticky/cumulative)
  baselineCacheDeletedTokens: number
}

[源码 src/services/compact/microCompact.ts:207]

ts
/**
 * Cached microcompact path - uses cache editing API to remove tool results
 * without invalidating the cached prefix.
 *
 * Key differences from regular microcompact:
 * - Does NOT modify local message content (cache_reference and cache_edits are
 *   added at API layer)
 */

它不改本地消息,而是在 API 层发一个 cache_edits 指令,让服务端把某些 tool_use_id 对应的内容从缓存里删掉——缓存前缀不失效。

这是个了不起的能力:在不破坏前缀缓存的前提下缩小前缀。普通的"改历史"必然断缓存,缓存编辑不会。

主循环里对应的收尾逻辑 [源码 src/query.ts:870]:

ts
if (feature('CACHED_MICROCOMPACT') && pendingCacheEdits) {
  const usage = assistantMessages.at(-1)?.message.usage
  // The API field is cumulative/sticky across requests, so we
  // subtract the baseline captured before this request to get the delta.
  const cumulativeDeleted = usage?.cache_deleted_input_tokens ?? 0
  const deletedTokens = Math.max(0, cumulativeDeleted - pendingCacheEdits.baselineCacheDeletedTokens)
  if (deletedTokens > 0) {
    yield createMicrocompactBoundaryMessage(pendingCacheEdits.trigger, 0, deletedTokens, pendingCacheEdits.deletedToolIds, [])
  }
}

边界消息要等 API 响应回来才发,因为只有服务端知道真的删掉了多少 token;而且那个字段是累积的,得减去请求前的基线才是本次增量。

还有一处很实在的 bug 修复注释 [源码 src/services/compact/microCompact.ts:243]:

ts
// Prefix-match because promptCategory.ts sets the querySource to
// 'repl_main_thread:outputStyle:<style>' when a non-default output style
// is active. … the pre-existing cached-MC `=== 'repl_main_thread'` check
// was a latent bug — users with a non-default output style were silently
// excluded from cached MC.
function isMainThreadSource(querySource: QuerySource | undefined): boolean {
  return !querySource || querySource.startsWith('repl_main_thread')
}

一个 === 写成了应该是 startsWith 的地方,导致所有用了自定义输出风格的用户静默地失去了缓存微压缩。 这类 bug 不报错、不崩溃,只是让一部分用户多花钱。


四、第 ④ 层:autocompact —— 阈值、缓冲区、断路器

这一层完整可见,而且全是硬数字。

4.1 阈值怎么算

ts
// Reserve this many tokens for output during compaction
// Based on p99.99 of compact summary output being 17,387 tokens.
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(getMaxOutputTokensForModel(model), MAX_OUTPUT_TOKENS_FOR_SUMMARY)
  let contextWindow = getContextWindowForModel(model, getSdkBetas())
  …
  return contextWindow - reservedTokensForSummary
}

export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000

export function getAutoCompactThreshold(model: string): number {
  return getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS
}

[源码 src/services/compact/autoCompact.ts:29-73]

四条线,从松到紧:

text
上下文窗口
  −20000  ← 给摘要输出留的空间(p99.99 是 17387 token)
= 有效窗口
  −13000  ← autocompact 触发线
  −20000  ← 警告线 / 错误线
  −3000   ← blocking limit(关掉自动压缩时的硬墙,留空间给手动 /compact)

20_000 这个数字有出处:压缩摘要输出长度的 p99.99 是 17387 token。 不是拍脑袋,是分布统计加余量。

4.2 四层递归守卫

shouldAutoCompact() 开头是四个 early return,每个防一种死锁 [源码 src/services/compact/autoCompact.ts:159]:

ts
// Recursion guards. session_memory and compact are forked agents that would deadlock.
if (querySource === 'session_memory' || querySource === 'compact') return false

// marble_origami is the ctx-agent — if ITS context blows up and
// autocompact fires, runPostCompactCleanup calls resetContextCollapse()
// which destroys the MAIN thread's committed log (module-level state
// shared across forks).
if (feature('CONTEXT_COLLAPSE')) { if (querySource === 'marble_origami') return false }

if (!isAutoCompactEnabled()) return false

// Reactive-only mode: suppress proactive autocompact, let reactive compact
// catch the API's prompt-too-long.
if (feature('REACTIVE_COMPACT')) {
  if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) return false
}

第一条是显而易见的:压缩这件事本身是靠一个 forked agent 做的(它要调模型来写摘要),如果那个 agent 的上下文触发了压缩,就无限递归了。

第二条更隐蔽:marble_origami(context-collapse 的辅助 agent)如果触发压缩,压缩后的清理会重置一个模块级共享状态,而那个状态属于主线程。fork 共享进程内存,所以子进程的清理动作可能炸掉父进程的状态。

可迁移的判断 ⑭ 凡是"用 agent 来解决 agent 的问题"的设计,都要显式列出递归守卫,并且守卫要按调用来源(querySource)而不是按标志位。

用来源判断的好处是它天然可传播——fork 出去的子 agent 带着自己的 querySource,不需要额外传参数。Claude Code 在这里用了四条来源判断,覆盖压缩、会话记忆、上下文折叠三个子系统。

4.3 断路器:一个真实事故的产物

ts
// Stop trying autocompact after this many consecutive failures.
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

[源码 src/services/compact/autoCompact.ts:66]

ts
// Without this, sessions where context is irrecoverably over the limit
// hammer the API with doomed compaction attempts on every turn.
if (tracking?.consecutiveFailures !== undefined
    && tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
  return { wasCompacted: false }
}

[源码 src/services/compact/autoCompact.ts:260]

失败时递增并在跨过阈值时打日志:

ts
if (nextFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
  logForDebugging(
    `autocompact: circuit breaker tripped after ${nextFailures} consecutive failures — skipping future attempts this session`,
    { level: 'warn' },
  )
}

单个会话里连续失败 3272 次、全球每天浪费 25 万次 API 调用。 失败计数从 autoCompactIfNeeded 返回、经过 query.tsState 传到下一圈——这就是第 3 章那个 autoCompactTracking.consecutiveFailures 字段的来历。

4.4 压缩自己撞上下文超限怎么办

ts
// CC-1180: compact request itself hit prompt-too-long. Truncate the
// oldest API-round groups and retry rather than leaving the user stuck.

[源码 src/services/compact/compact.ts:462]

压缩请求本身也可能太长。 解法是按"API 轮次分组"砍掉最老的几组再重试。而分组逻辑(grouping.ts,63 行)有个很实际的约束 [源码 src/services/compact/compact.ts:279]:

ts
// groupMessagesByApiRound puts the preamble in group 0 and starts every
// subsequent group with an assistant message. Dropping group 0 leaves an
// assistant-first sequence which the API rejects (first message must be
// role=user). Prepend a synthetic user marker — ensureToolResultPairing
// already handles any orphaned tool_results this creates.

砍掉第一组会让消息序列以 assistant 开头,API 拒收。 所以要补一条合成的 user 消息。而且注释还提醒:这会造成孤儿 tool_result,但另一个函数已经处理了。

再上面还有一条更微妙的:

ts
// Strip our own synthetic marker from a previous retry before grouping.
// Otherwise it becomes its own group 0 and the 20% fallback stalls
// (drops only the marker, re-adds it, zero progress on retry 2+).

[源码 src/services/compact/compact.ts:247]

上一次重试补的那条合成消息,会在下一次分组时变成新的 group 0,于是"砍掉第一组"只砍掉了那条合成消息——重试永远没有进展。 这是个非常典型的"自己制造的状态污染了自己的算法"的 bug。

4.5 压缩走的是 forked agent,为了复用缓存

ts
// 3P default: true — forked-agent path reuses main conversation's prompt cache.
// Experiment (Jan 2026) confirmed: false path is 98% cache miss, costs ~0.76% of
// fleet cache_creation (~38B tok/day), concentrated in ephemeral envs (CCR/GHA/SDK)
// with cold GB cache and 3P providers where GB is disabled. GB gate kept as kill-switch.

[源码 src/services/compact/compact.ts:431]

不走 forked agent 的那条路是 98% 缓存未命中。 因为压缩要把整个对话历史再发一遍——如果不复用主对话的缓存前缀,那就是纯粹的全量重发。

而 forked agent 复用缓存又要求"工具集必须和父进程一致",这就把我们带回第 4 章和第 12 章。这条链上所有的设计决定都是同一个约束推出来的。

4.6 压缩之后要重新公告

ts
// Compaction ate prior delta attachments. Re-announce from the current
// state so the model has tool/instruction context on the first
// post-compact turn. Empty message history → diff against nothing →
// announces the full set.

[源码 src/services/compact/compact.ts:563]

第 6 章那个"delta 从对话历史重建状态"的设计,代价在这里付:压缩把历史吃了,delta 的记忆也没了,所以要全量重发一次。 而实现上很优雅——空历史 diff 空集合,天然得到全集。

但技能清单故意不重发 [源码 src/services/compact/compact.ts:524]:

ts
// Intentionally NOT resetting sentSkillNames: re-injecting the full
// skill_listing (~4K tokens) post-compact is pure cache_creation with
// marginal benefit. The model still has SkillTool in its schema and
// invoked_skills attachment (below) preserves used-skill content.

4K token 的技能清单重发一次是纯成本、收益边际。 模型还有 Skill 工具的 schema,而且已经用过的技能内容通过 invoked_skills 附件保留着。


五、压缩提示词:一份九段式摘要模板

第 5 章讲了系统提示词,这里是另一份完整的生产提示词——压缩用的 [源码 src/services/compact/prompt.ts]。

5.1 开头是一段"不许调工具"的硬警告

ts
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.
`

上面的注释解释了它为什么要这么凶,而且给了数据:

ts
// Aggressive no-tools preamble. The cache-sharing fork path inherits the
// parent's full tool set (required for cache-key match), and on Sonnet 4.6+
// adaptive-thinking models the model sometimes attempts a tool call despite
// the weaker trailer instruction. With maxTurns: 1, a denied tool call means
// no text output → falls through to the streaming fallback (2.79% on 4.6 vs
// 0.01% on 4.5). Putting this FIRST and making it explicit about rejection
// consequences prevents the wasted turn.

因果链:为了共享缓存 → fork 必须继承父进程的全部工具 → 模型看见工具就想用 → maxTurns: 1 下用了工具就没有文本输出 → 失败率从 0.01% 涨到 2.79% → 所以要把警告放到最前面,并且明说"工具调用会被拒绝,你会失败"

而且结尾还要再说一遍:

ts
const NO_TOOLS_TRAILER =
  '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' +
  'an <analysis> block followed by a <summary> block. ' +
  'Tool calls will be rejected and you will fail the task.'

头尾各一遍。 这是提示词工程里的经典手法(首因 + 近因),这里有明确的失败率数据支撑。

5.2 九个必填段落

摘要模板要求九节 [源码 src/services/compact/prompt.ts:BASE_COMPACT_PROMPT]:

  1. Primary Request and Intent —— 用户所有明确的请求和意图
  2. Key Technical Concepts —— 技术概念、技术栈
  3. Files and Code Sections —— 看过/改过/创建过的文件与代码段,要带完整代码片段,并说明为什么重要
  4. Errors and fixes —— 遇到的错误和怎么修的,特别注意用户给的反馈,尤其是"你应该换个做法"这类
  5. Problem Solving —— 解决了什么、还在排查什么
  6. All user messages —— 列出所有非工具结果的用户消息
  7. Pending Tasks —— 待办
  8. Current Work —— 紧接着这次摘要请求之前在干什么,要带文件名和代码片段
  9. Optional Next Step —— 下一步。这一节的要求最长:

IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.

第 6 节和第 9 节是这份模板的灵魂。

  • 第 6 节要求列出所有用户消息——因为压缩最容易丢的就是用户中途说的话,而那些话往往是最重要的约束。
  • 第 9 节要求逐字引用——防止"任务解释漂移"。压缩之后模型接着干活,如果它对任务的理解在摘要过程中偏了一点,后面就一路偏下去。

5.3 <analysis> 是草稿纸,会被丢掉

ts
// Two variants: BASE scopes to "the conversation", PARTIAL scopes to "the
// recent messages". The <analysis> block is a drafting scratchpad that
// formatCompactSummary() strips before the summary reaches context.
ts
export function formatCompactSummary(summary: string): string {
  // Strip analysis section — it's a drafting scratchpad that improves summary
  // quality but has no informational value once the summary is written.
  formattedSummary = formattedSummary.replace(/<analysis>[\s\S]*?<\/analysis>/, '')
  …
}

让模型先写一遍分析提高摘要质量,然后把分析扔掉只留摘要。 分析的价值在于生成过程,不在于内容——这是"思考痕迹"和"输出"分离的一个干净例子。

分析部分的指令本身也很具体:

  1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: 用户的明确请求 / 你的应对 / 关键决策与代码模式 / 具体细节(文件名、完整代码片段、函数签名、文件编辑)/ 遇到的错误和修法 / 特别注意用户的具体反馈,尤其是让你换做法的那些
  2. Double-check for technical accuracy and completeness

5.4 三种方向

模板有三个变体,对应三种压缩形态:

变体摘要的是什么摘要放在哪
BASE_COMPACT_PROMPT整段对话全部替换
PARTIAL_COMPACT_PROMPTdirection: 'from'最近这一段(更早的保留原文)保留的原文之后
PARTIAL_COMPACT_UP_TO_PROMPTdirection: 'up_to'前面这一段(后面的保留原文)保留的原文之前

第三种的提示词措辞很讲究:

This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after your summary (you do not see them here). Summarize thoroughly so that someone reading only your summary and then the newer messages can fully understand what happened and continue the work.

而且它的第 9 节标题从 "Optional Next Step" 换成了 "Context for Continuing Work"——因为"下一步"这个概念在这个方向上不成立(后面的消息已经发生了)。

同一件事,三个方向,三套措辞。 注释解释了 up_to 存在的理由:'up_to': model sees only the summarized prefix (cache hit)——摘要前缀,后面的原文不变,于是缓存能命中。

5.5 恢复消息

压缩完给模型的那条消息 [源码 src/services/compact/prompt.ts:getCompactUserSummaryMessage]:

text
This session is being continued from a previous conversation that ran out of context.
The summary below covers the earlier portion of the conversation.

{摘要}

If you need specific details from before compaction (like exact code snippets, error
messages, or content you generated), read the full transcript at: {transcriptPath}

Recent messages are preserved verbatim.

给了 transcript 的路径。 压缩是有损的,但损失是可恢复的——模型可以自己去读原文。这是本章开头那张"可逆性"表里 autocompact 那一行"给了 transcript 路径"的来源。

自动压缩时还会追加一段:

Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.

"就当中断从没发生过" —— 这条是纯体验设计。没有它,每次自动压缩用户都会看到一段"我看了一下之前的摘要,我们之前在做……"的复述。


六、第 ⑤ 层:reactive compact —— 撞墙之后 [推断]

前四层都是主动的:请求发出去之前先算一下够不够。第 ⑤ 层是被动的:请求发出去,API 回了 prompt_too_long,再补救。

从第 3 章的调用点能还原出它的接口 [源码 src/query.ts:1120]:

ts
const compacted = await reactiveCompact.tryReactiveCompact({
  hasAttempted: hasAttemptedReactiveCompact,
  querySource,
  aborted: toolUseContext.abortController.signal.aborted,
  messages: messagesForQuery,
  cacheSafeParams: { systemPrompt, userContext, systemContext, toolUseContext, forkContextMessages: messagesForQuery },
})

以及它的三个能力:

ts
reactiveCompact.isWithheldPromptTooLong(message)     // 判断要不要扣住这条错误
reactiveCompact.isWithheldMediaSizeError(message)    // 媒体太大也归它管
reactiveCompact.isReactiveCompactEnabled()

它一次会话只试一次hasAttemptedReactiveCompact 守卫),理由第 3 章引用过:重置这个布尔曾经造成无限循环、烧掉几千次 API 调用。

为什么需要被动层? 因为主动层的估算是近似的:

ts
/**
 * Estimate token count for messages by extracting text content
 * Used for rough token estimation when we don't have accurate API counts
 * Pads estimate by 4/3 to be conservative since we're approximating
 */
export function estimateMessageTokens(messages: Message[]): number

[源码 src/services/compact/microCompact.ts:164]

估算方法是数字符再乘 4/3 保守放大,图片一律按 2000 token 算。系统提示词、工具 schema、userContext 又是另外 20–40K,只能从上一次 API 响应的 usage.input_tokens 里推。估算不准是常态,所以必须有一层兜底。

autoCompact.ts 里有一处注释把这个不确定性说得很直白 [源码 src/services/compact/compact.ts:633]:

ts
// Message-payload estimate of the resulting context. The next iteration's
// shouldAutoCompact will see this PLUS ~20-40K for system prompt + tools +
// userContext (via API usage.input_tokens). So `willRetriggerNextTurn: true`

压缩完了还能算出"下一轮会不会又触发压缩" ——并且把这个预测打成了事件字段。


七、还有第六层:会话记忆

sessionMemoryCompact.ts(630 行)是另一条路,在 autoCompactIfNeeded 里被优先尝试 [源码 src/services/compact/autoCompact.ts:26]:

ts
import { trySessionMemoryCompaction } from './sessionMemoryCompact.js'

它对应的附件类型是 current_session_memory,对应的 querySource 是 session_memory(也是第 4.2 节那个递归守卫拦的两个之一)。

从第 6 章那张附件表和 services/SessionMemory/ 目录能看出它的定位:把会话里值得长期保留的东西提炼出来存成记忆,而不是每次都靠摘要重述。 这是压缩的另一个方向——不是"把历史压小",是"把历史沉淀成知识"。

本教程不展开这一层(它更接近记忆系统而不是压缩),但要指出它在流水线里的位置:它在 autocompact 之前被尝试,成功了就不用整段摘要。


八、动手复核

bash
cd claude-code-deep-dive/extracted-source

# 1. 五层在主循环里的位置
sed -n '365,470p' src/query.ts

# 2. 完整可见的两层
cat src/services/compact/timeBasedMCConfig.ts
sed -n '29,110p' src/services/compact/autoCompact.ts

# 3. 可压缩工具白名单
sed -n '38,50p' src/services/compact/microCompact.ts

# 4. 断路器与那次事故
grep -n -B4 -A4 'BQ 2026-03-10' src/services/compact/autoCompact.ts
grep -n -A12 'circuit breaker tripped' src/services/compact/autoCompact.ts

# 5. 四层递归守卫
sed -n '159,200p' src/services/compact/autoCompact.ts

# 6. 压缩提示词全文(374 行,值得整读)
cat src/services/compact/prompt.ts

# 7. 验证「四层实现不在快照里」
for m in reactiveCompact snipCompact cachedMicrocompact cachedMCConfig; do
  ls src/services/compact/$m.ts 2>/dev/null || echo "缺失 $m"
done
ls src/services/contextCollapse/ 2>/dev/null || echo "缺失 contextCollapse/"

本机侧:

bash
# 看当前上下文占用与各部分构成
# (在 Claude Code 里敲)
/context

# 手动压缩,观察摘要的九个段落
/compact

九、总结

  1. 五层压缩,顺序原则是"便宜的、保真度高的先上":单条结果落盘(完全可逆)→ snip → microcompact(清老工具结果)→ context-collapse(折叠)→ autocompact(整段摘要,最有损)→ 撞墙后 reactive compact 兜底
  2. 只有 8 种工具的结果可以被清,判据是"这个结果能不能重跑拿回来";系统提示词配套告诉模型"重要的抄到回复里"
  3. 时间触发的 microcompact 是最漂亮的一处:闲置超过 60 分钟 → 服务端 1h TTL 必然过期 → 前缀反正要重写 → 趁这个边际代价为零的窗口把老结果清掉
  4. 缓存编辑版 microcompact 能在不破坏前缀的前提下缩小前缀 [推断],边界消息要等 API 返回才发,因为只有服务端知道真删了多少
  5. autocompact 的四条阈值线全有出处:给摘要留 20000 是因为输出长度 p99.99 是 17387
  6. 四层递归守卫按 querySource 判断,其中一条防的是"子 agent 的清理动作炸掉父进程的模块级状态"
  7. 断路器来自真实事故:单会话连续失败 3272 次、全球每天浪费 25 万次 API 调用 → 连续失败 3 次就停
  8. 压缩提示词头尾各喊一遍"不许调工具",因为共享缓存要求继承父进程工具集,导致失败率从 0.01% 涨到 2.79%
  9. 九段式摘要模板里第 6 节(列出所有用户消息)和第 9 节(逐字引用防解释漂移)是灵魂<analysis> 是草稿纸,写完就丢
  10. 主动层的估算是"数字符 × 4/3"的粗估,所以必须有被动层兜底——而且压缩完还会预测"下一轮会不会又触发"

下一章转到工具:一个 Tool 接口凭什么有 47 个成员,以及工具太多喂不下时的延迟加载。


  • 第6章-附件与system-reminder-第二条注入通道
  • 第8章-工具系统-47个成员的接口与延迟加载
  • 第3章-Agent-Loop-query这一个循环 —— 五层在主循环里的调用点
  • 第4章-模型调用与缓存经济学 —— 时间触发 microcompact 与缓存编辑的前提
  • 第12章-Agent调度-fork与fresh两条路 —— 压缩自己走的就是 fork 路径
  • Codex 教程第 7 章 —— 两层压缩的对照
  • Pi 教程第 9 章 —— 单层压缩的对照

本章目录
一、五层的位置与顺序二、第 ⓪ 层:工具结果的单条预算三、第 ② 层:microcompact —— 只清工具结果四、第 ④ 层:autocompact —— 阈值、缓冲区、断路器五、压缩提示词:一份九段式摘要模板六、第 ⑤ 层:reactive compact —— 撞墙之后 [推断]七、还有第六层:会话记忆八、动手复核九、总结Related Documents
苏ICP备2025204887号-2