Claude Code源码剖析 - Claude Code 上下文压缩机制

Phase 8:Claude Code 上下文压缩机制

面试前先建立整体心智模型

Claude Code 的上下文压缩不是一个孤立的 /compact 命令,而是一套多层上下文治理系统。它要解决的问题不是"把旧消息删掉",而是:在 Agent Loop 继续运行的同时,让模型下一轮看到的 messages 不超过上下文窗口,并且不破坏工具调用配对、文件状态、技能状态、MCP 指令、hook 注入、transcript 恢复链和 prompt cache。

你可以先这样记:

text 复制代码
Claude Code 上下文压缩 =
  模型输入视图切片
  + 工具结果小粒度清理
  + 会话 summary 压缩
  + compact boundary 标记
  + 压缩后上下文重注入
  + prompt-too-long 失败恢复

如果面试官问"Claude Code 怎么处理上下文爆炸",不要只说"它会总结历史"。更准确的回答是:

text 复制代码
每轮 query loop 调模型前,Claude Code 会构造 messagesForQuery。
这个模型输入视图会依次经过:

1. getMessagesAfterCompactBoundary:从最近 compact boundary 之后切片。
2. applyToolResultBudget:限制单条工具结果体积。
3. HISTORY_SNIP:特性门控下裁剪历史视图。
4. microcompact:清理旧工具结果,尤其是 Read / Bash / Grep / Glob 等高产出工具。
5. contextCollapse:如果启用,先做更细粒度的上下文折叠。
6. autocompact:超过阈值时,fork 一个 compact agent 生成 summary。
7. reactive compact:API 已经 prompt-too-long 后,再尝试恢复并重试。

这和 Phase 1 的 Agent Loop 是连在一起的:表面上是"模型输出 tool_use -> 本地执行工具 -> tool_result 回填 -> 下一轮模型",但每一轮模型调用前,Claude Code 都会重新整理模型真正能看到的上下文。


总览:Phase8 源码地图

本章主要读这些源码:

text 复制代码
src/query.ts
  -> query loop 中的上下文压缩流水线
  -> HISTORY_SNIP / microcompact / contextCollapse / autocompact
  -> prompt-too-long 后的 reactive compact 恢复

src/utils/messages.ts
  -> createCompactBoundaryMessage()
  -> createMicrocompactBoundaryMessage()
  -> isCompactBoundaryMessage()
  -> getMessagesAfterCompactBoundary()

src/services/compact/autoCompact.ts
  -> effective context window
  -> autocompact threshold
  -> shouldAutoCompact()
  -> autoCompactIfNeeded()

src/services/compact/microCompact.ts
  -> 工具结果级别的小压缩
  -> time-based microcompact
  -> cached microcompact 调用协议

src/services/compact/compact.ts
  -> compactConversation()
  -> buildPostCompactMessages()
  -> partialCompactConversation()
  -> streamCompactSummary()

src/services/compact/prompt.ts
  -> compact prompt
  -> formatCompactSummary()
  -> getCompactUserSummaryMessage()

src/services/compact/sessionMemoryCompact.ts
  -> session memory compact
  -> messagesToKeep 选择
  -> tool_use/tool_result 不变量保护

src/commands/compact/compact.ts
  -> 手动 /compact 命令入口

有一个需要明确说明的缺口:这个公开源码快照里可以看到 src/query.ts 引用 src/services/contextCollapse/index.jssnipCompact.jsreactiveCompact.jscachedMicrocompact.js,但对应实现文件不完整或不存在。因此本文对这些模块只依据真实调用点和注释解释它们在架构里的位置,不伪造内部实现。


src/query.ts:压缩流水线接入 Agent Loop

从 compact boundary 构造本轮模型输入

源码位置:src/query.ts:372-377

ts 复制代码
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]

let tracking = autoCompactTracking

// 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.

这一行是上下文压缩在主循环里的入口。

messages 是 loop state 中保存的历史。messagesForQuery 是这一轮准备发给模型的工作副本。Claude Code 不是每次都把完整历史送给模型,而是先调用 getMessagesAfterCompactBoundary(messages),只取最近一次 compact boundary 之后的消息。

为什么这样做?

因为 compact 之后,旧历史已经由 summary message 代表。如果还把 boundary 前的原始消息发给模型,compact 就没有任何节省效果。

可以这样记:

text 复制代码
state.messages =
  UI / transcript / loop state 里的历史

messagesForQuery =
  本轮模型输入视图

getMessagesAfterCompactBoundary =
  从最近一次 compact boundary 开始切片

C++ 类比:

cpp 复制代码
std::vector<Message> full_history = state.messages;
std::vector<Message> model_view =
    slice_from_last_compact_boundary(full_history);

面试怎么讲:

text 复制代码
Claude Code 没有把压缩理解成"清空数组"。
它在消息流里插入一个 system compact_boundary。
后续每轮构造模型输入时,从最近 boundary 开始切片。
这样 summary 之前的旧消息不会再进入模型上下文,但 transcript/UI 仍可以保留更多历史。

applyToolResultBudget:压缩前先限制工具结果体积

源码位置:src/query.ts:376-402

ts 复制代码
const persistReplacements =
  querySource.startsWith('agent:') ||
  querySource.startsWith('repl_main_thread')

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),
  ),
)

这一步不叫 compact,但它是上下文治理的一部分。工具结果是上下文膨胀最常见的来源。比如:

text 复制代码
Read 读出大文件
Bash 输出长日志
Grep 返回大量命中
WebFetch 拉回长网页

这些都会被包装成 user/tool_result,进入下一轮模型上下文。Claude Code 在 microcompact 之前先跑 applyToolResultBudget,对过大的工具结果做替换或记录。

源码注释强调它要在 microcompact 前执行,因为 cached microcompact 主要按 tool_use_id 工作,并不检查内容本身。也就是说:

text 复制代码
applyToolResultBudget 关心内容多大;
cached microcompact 关心哪些 tool_use_id 可以删。

HISTORY_SNIP:在 microcompact 之前裁剪历史视图

源码位置:src/query.ts:404-418

ts 复制代码
// Apply snip before microcompact (both may run --- they are not mutually exclusive).
// snipTokensFreed is plumbed to autocompact so its threshold check reflects
// what snip removed; tokenCountWithEstimation alone can't see it.
let snipTokensFreed = 0
if (feature('HISTORY_SNIP')) {
  queryCheckpoint('query_snip_start')
  const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
  messagesForQuery = snipResult.messages
  snipTokensFreed = snipResult.tokensFreed
  if (snipResult.boundaryMessage) {
    yield snipResult.boundaryMessage
  }
  queryCheckpoint('query_snip_end')
}

HISTORY_SNIP 是特性门控路径。当前快照能看到调用点,但看不到完整实现。仅从调用点可以确定:

  • 它发生在 microcompact 之前。
  • 它和 microcompact 不互斥。
  • 它返回新的 messagesForQuery
  • 它返回 tokensFreed,用于后面的 autocompact 阈值判断。
  • 它可能 yield 一个 boundary message。

这里最关键的是 snipTokensFreed。源码注释说明,snip 可能移除了历史视图,但 surviving assistant message 的 usage 仍可能反映 snip 前上下文大小,所以 tokenCountWithEstimation 本身看不到这部分节省。Claude Code 就把 snipTokensFreed 显式传给 autocompact。

你可以这样记:

text 复制代码
HISTORY_SNIP =
  改变模型视图
  + 估算释放 token
  + 把释放量告诉 autocompact

面试怎么讲:

text 复制代码
Claude Code 不完全相信单一 token 估算来源。
snip 改变的是历史视图,但 assistant usage 可能还是旧窗口数据。
所以它把 snip 释放量显式传给 autocompact,避免刚 snip 完又被误判为超过阈值。

microcompact:小粒度清理旧工具结果

源码位置:src/query.ts:421-435

ts 复制代码
queryCheckpoint('query_microcompact_start')
const microcompactResult = await deps.microcompact(
  messagesForQuery,
  toolUseContext,
  querySource,
)
messagesForQuery = microcompactResult.messages

const pendingCacheEdits = feature('CACHED_MICROCOMPACT')
  ? microcompactResult.compactionInfo?.pendingCacheEdits
  : undefined
queryCheckpoint('query_microcompact_end')

microcompact 是压缩流水线中最便宜的一层。它不总结整个会话,而是处理旧工具结果。

为什么工具结果适合小粒度压缩?

因为很多工具结果只是中间证据。模型已经读过文件、搜索过代码、运行过命令后,旧的完整输出未必还需要原样放在上下文里。

你可以这样记:

text 复制代码
microcompact =
  小刀修剪旧工具结果

autocompact =
  大刀把旧对话总结成 summary

这里还有一个工程点:cached microcompact 不一定修改本地 messages,它可能只产生 pendingCacheEdits,等 API 层用 cache editing 删除服务端缓存里的旧工具结果。

contextCollapse:比 autocompact 更细粒度的折叠层

源码位置:src/query.ts:437-456

ts 复制代码
// Project the collapsed context view and maybe commit more collapses.
// 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.
//
// Nothing is yielded --- the collapsed view is a read-time projection
// over the REPL's full history. Summary messages live in the collapse
// store, not the REPL array.
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
  const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
    messagesForQuery,
    toolUseContext,
    querySource,
  )
  messagesForQuery = collapseResult.messages
}

contextCollapse 的实现目录在当前公开快照中缺失,但注释已经说明它在系统里的位置:

text 复制代码
contextCollapse:
  read-time projection
  summary 存在 collapse store
  不直接 yield UI message
  优先于 autocompact
  目标是保留更细粒度上下文

为什么要放在 autocompact 前面?

因为如果 collapse 足以把上下文降到阈值以下,就不需要把旧内容压成一个大 summary。也就是说,Claude Code 的策略不是"快满了就总结",而是先尝试保真度更高的折叠。

面试怎么讲:

text 复制代码
Claude Code 优先尝试更便宜、更保真、更细粒度的上下文折叠。
只有这些层不够时,才走 autocompact 的大 summary 压缩。

autocompact:超过阈值后生成 summary 并替换模型输入

源码位置:src/query.ts:462-544

ts 复制代码
const { compactionResult, consecutiveFailures } = await deps.autocompact(
  messagesForQuery,
  toolUseContext,
  {
    systemPrompt,
    userContext,
    systemContext,
    toolUseContext,
    forkContextMessages: messagesForQuery,
  },
  querySource,
  tracking,
  snipTokensFreed,
)

if (compactionResult) {
  tracking = {
    compacted: true,
    turnId: deps.uuid(),
    turnCounter: 0,
    consecutiveFailures: 0,
  }

  const postCompactMessages = buildPostCompactMessages(compactionResult)

  for (const message of postCompactMessages) {
    yield message
  }

  messagesForQuery = postCompactMessages
}

这是真正的自动压缩主路径。

deps.autocompact 会判断是否超过阈值。如果没有超过,就什么都不做。如果超过,它会返回 compactionResult。成功后 query loop 做三件事:

text 复制代码
1. 重置 autoCompactTracking。
2. buildPostCompactMessages(compactionResult) 构造压缩后的消息包。
3. yield 压缩后的消息,并把 messagesForQuery 替换成 postCompactMessages。

这说明 autocompact 在当前 query call 中立即生效,不是等下一轮才生效。

prompt-too-long 后的 reactive compact

源码位置:src/query.ts:1073-1175

ts 复制代码
const isWithheld413 =
  lastMessage?.type === 'assistant' &&
  lastMessage.isApiErrorMessage &&
  isPromptTooLongMessage(lastMessage)

if (isWithheld413) {
  if (
    feature('CONTEXT_COLLAPSE') &&
    contextCollapse &&
    state.transition?.reason !== 'collapse_drain_retry'
  ) {
    const drained = contextCollapse.recoverFromOverflow(
      messagesForQuery,
      querySource,
    )
    if (drained.committed > 0) {
      state = {
        messages: drained.messages,
        transition: { reason: 'collapse_drain_retry', committed: drained.committed },
        ...
      }
      continue
    }
  }
}

if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
  const compacted = await reactiveCompact.tryReactiveCompact({
    hasAttempted: hasAttemptedReactiveCompact,
    querySource,
    aborted: toolUseContext.abortController.signal.aborted,
    messages: messagesForQuery,
    cacheSafeParams: { ... },
  })

  if (compacted) {
    const postCompactMessages = buildPostCompactMessages(compacted)
    state = {
      messages: postCompactMessages,
      hasAttemptedReactiveCompact: true,
      transition: { reason: 'reactive_compact_retry' },
      ...
    }
    continue
  }
}

这是一条失败恢复路径。前面的 autocompact 是 proactive:调模型前预判快满了就压缩。reactive compact 是 reactive:API 已经返回 prompt-too-long 或媒体过大错误后,再尝试恢复。

恢复顺序是:

text 复制代码
prompt-too-long
  -> 先尝试 contextCollapse.recoverFromOverflow()
  -> 如果 collapse drain 有效果,更新 state 并 continue 重试
  -> 如果还不行,再尝试 reactiveCompact.tryReactiveCompact()
  -> 成功则 buildPostCompactMessages,更新 state 并 continue 重试
  -> 失败才暴露错误

媒体过大错误不同。源码注释说 collapse 不会 strip images,所以 oversized media 会跳过 collapse drain,直接进入 reactive compact 的 strip-retry 路径。

面试怎么讲:

text 复制代码
Claude Code 把 prompt-too-long 视作可恢复错误。
它先尝试最保真的 collapse drain,再尝试更重的 reactive compact。
并且用 hasAttemptedReactiveCompact 防止压缩-仍太长-再压缩的无限循环。

src/utils/messages.ts:compact boundary 如何定义模型视图

compact boundary 是系统消息,不是普通文本

源码位置:src/utils/messages.ts:4530-4569

ts 复制代码
export function createCompactBoundaryMessage(
  trigger: 'manual' | 'auto',
  preTokens: number,
  lastPreCompactMessageUuid?: UUID,
  userContext?: string,
  messagesSummarized?: number,
): SystemCompactBoundaryMessage {
  return {
    type: 'system',
    subtype: 'compact_boundary',
    content: `Conversation compacted`,
    isMeta: false,
    timestamp: new Date().toISOString(),
    uuid: randomUUID(),
    level: 'info',
    compactMetadata: {
      trigger,
      preTokens,
      userContext,
      messagesSummarized,
    },
    ...(lastPreCompactMessageUuid && {
      logicalParentUuid: lastPreCompactMessageUuid,
    }),
  }
}

compact boundary 是一条 system message,subtypecompact_boundary。它不是普通文本,而是 runtime 和 transcript/session loader 使用的结构化标记。

compactMetadata 里有:

  • trigger:manual 还是 auto。
  • preTokens:压缩前 token 数。
  • userContext:用户给 partial/manual compact 的说明。
  • messagesSummarized:被总结的消息数量。
  • logicalParentUuid:压缩边界在 transcript 逻辑链中的父节点。

你可以这样记:

text 复制代码
compact_boundary =
  一条本地 runtime 用的系统分隔符
  + 压缩元数据
  + transcript 逻辑链锚点

getMessagesAfterCompactBoundary:模型只看最近边界之后

源码位置:src/utils/messages.ts:4608-4656

ts 复制代码
export function isCompactBoundaryMessage(
  message: Message | NormalizedMessage,
): message is SystemCompactBoundaryMessage {
  return message?.type === 'system' && message.subtype === 'compact_boundary'
}

export function findLastCompactBoundaryIndex<
  T extends Message | NormalizedMessage,
>(messages: T[]): number {
  for (let i = messages.length - 1; i >= 0; i--) {
    const message = messages[i]
    if (message && isCompactBoundaryMessage(message)) {
      return i
    }
  }
  return -1
}

export function getMessagesAfterCompactBoundary<
  T extends Message | NormalizedMessage,
>(messages: T[], options?: { includeSnipped?: boolean }): T[] {
  const boundaryIndex = findLastCompactBoundaryIndex(messages)
  const sliced = boundaryIndex === -1 ? messages : messages.slice(boundaryIndex)
  if (!options?.includeSnipped && feature('HISTORY_SNIP')) {
    const { projectSnippedView } =
      require('../services/compact/snipProjection.js')
    return projectSnippedView(sliced as Message[]) as T[]
  }
  return sliced
}

这个函数解释了 compact boundary 的真正作用。

算法很简单:

text 复制代码
1. 从后往前找最后一个 compact_boundary。
2. 找不到,就返回全部 messages。
3. 找到,就返回 boundaryIndex 到末尾这一段。
4. 如果 HISTORY_SNIP 开启,再投影 snipped view。

为什么从后往前找?

因为一次会话可能发生多次 compact。最近一次 compact 的 summary 已经覆盖更早历史,所以模型只需要最近 boundary 之后的内容。

C++ 类比:

cpp 复制代码
int find_last_boundary(const std::vector<Message>& messages) {
  for (int i = messages.size() - 1; i >= 0; --i) {
    if (messages[i].type == "system" &&
        messages[i].subtype == "compact_boundary") {
      return i;
    }
  }
  return -1;
}

面试怎么讲:

text 复制代码
Claude Code 用 boundary message 作为模型上下文视图的切片点。
它不需要物理删除所有旧消息,每轮进入模型前从最近 boundary 开始取即可。
这让 UI scrollback、transcript 和模型输入可以拥有不同视图。

src/services/compact/autoCompact.ts:自动压缩阈值与触发条件

effective context window:给 summary 输出预留空间

源码位置:src/services/compact/autoCompact.ts:28-49

ts 复制代码
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())

  const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
  if (autoCompactWindow) {
    const parsed = parseInt(autoCompactWindow, 10)
    if (!isNaN(parsed) && parsed > 0) {
      contextWindow = Math.min(contextWindow, parsed)
    }
  }

  return contextWindow - reservedTokensForSummary
}

自动压缩不是等上下文窗口彻底满了才开始。它先从模型 context window 中扣掉 summary 输出预留空间。

原因很直接:压缩本身也要调用模型生成 summary。如果已经塞满上下文,模型连 summary 都没空间输出,就会出现"想压缩但压缩请求自己也太长"的问题。

源码把 summary 输出预留上限定为 20,000 token,注释说这是基于 compact summary 输出 p99.99 为 17,387 token。

可以这样记:

text 复制代码
真实模型窗口
  - summary 输出预留空间
  = effective context window

autocompact threshold:effective window 再减 buffer

源码位置:src/services/compact/autoCompact.ts:62-90

ts 复制代码
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)

  const autocompactThreshold =
    effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS

  const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
  if (envPercent) {
    const parsed = parseFloat(envPercent)
    if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
      const percentageThreshold = Math.floor(
        effectiveContextWindow * (parsed / 100),
      )
      return Math.min(percentageThreshold, autocompactThreshold)
    }
  }

  return autocompactThreshold
}

自动压缩阈值是:

text 复制代码
threshold = effectiveContextWindow - 13,000

也就是再留出 13k token buffer,防止临界点附近震荡。

面试怎么讲:

text 复制代码
Claude Code 不是按固定百分比简单判断。
它先给 summary 输出保留空间,再减一个 13k buffer。
这样触发点比硬上限早,避免 compact 请求自己失败,也避免下一轮马上又超限。

shouldAutoCompact:不只是 token 判断

源码位置:src/services/compact/autoCompact.ts:160-238

ts 复制代码
export async function shouldAutoCompact(
  messages: Message[],
  model: string,
  querySource?: QuerySource,
  snipTokensFreed = 0,
): Promise<boolean> {
  if (querySource === 'session_memory' || querySource === 'compact') {
    return false
  }

  if (!isAutoCompactEnabled()) {
    return false
  }

  if (feature('REACTIVE_COMPACT')) {
    if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) {
      return false
    }
  }

  if (feature('CONTEXT_COLLAPSE')) {
    const { isContextCollapseEnabled } =
      require('../contextCollapse/index.js')
    if (isContextCollapseEnabled()) {
      return false
    }
  }

  const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
  const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
    tokenCount,
    model,
  )

  return isAboveAutoCompactThreshold
}

这个函数回答"这一轮是否应该自动压缩"。

它不是只看 token 数,而是先做几层保护:

text 复制代码
1. querySource 是 compact 或 session_memory 时不压缩,避免 forked agent 死锁。
2. 用户或环境变量关闭 auto compact 时不压缩。
3. reactive-only 模式下关闭 proactive autocompact。
4. contextCollapse 开启时关闭 autocompact,避免两套上下文系统竞态。
5. 最后才计算 tokenCountWithEstimation(messages) - snipTokensFreed 是否超过阈值。

面试怎么讲:

text 复制代码
shouldAutoCompact 体现了工程里的"不要只看阈值"。
压缩可能发生在主线程、compact fork、session memory fork、context collapse agent 等不同 querySource。
如果不做递归保护,很容易出现 compact 触发 compact 的死锁或循环。

autoCompactIfNeeded:先试 session memory,再走传统 summary

源码位置:src/services/compact/autoCompact.ts:241-345

ts 复制代码
export async function autoCompactIfNeeded(
  messages: Message[],
  toolUseContext: ToolUseContext,
  cacheSafeParams: CacheSafeParams,
  querySource?: QuerySource,
  tracking?: AutoCompactTrackingState,
  snipTokensFreed?: number,
) {
  if (
    tracking?.consecutiveFailures !== undefined &&
    tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
  ) {
    return { wasCompacted: false }
  }

  const shouldCompact = await shouldAutoCompact(...)
  if (!shouldCompact) {
    return { wasCompacted: false }
  }

  const sessionMemoryResult = await trySessionMemoryCompaction(...)
  if (sessionMemoryResult) {
    setLastSummarizedMessageId(undefined)
    runPostCompactCleanup(querySource)
    markPostCompaction()
    return { wasCompacted: true, compactionResult: sessionMemoryResult }
  }

  try {
    const compactionResult = await compactConversation(...)
    setLastSummarizedMessageId(undefined)
    runPostCompactCleanup(querySource)
    return { wasCompacted: true, compactionResult, consecutiveFailures: 0 }
  } catch (error) {
    const nextFailures = (tracking?.consecutiveFailures ?? 0) + 1
    return { wasCompacted: false, consecutiveFailures: nextFailures }
  }
}

这里有三个关键点:

text 复制代码
第一,连续失败熔断。
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3。
如果一个会话已经连续压缩失败 3 次,就不再每轮白白尝试。

第二,优先尝试 session memory compaction。
如果长期 session memory 已经可用,可以直接用 memory + 近期消息构造结果。

第三,传统路径调用 compactConversation。
成功后清理 post compact 状态;失败时只增加失败计数。

src/services/compact/microCompact.ts:工具结果级别的小压缩

哪些工具结果适合 microcompact

源码位置:src/services/compact/microCompact.ts:38-50

ts 复制代码
const IMAGE_MAX_TOKEN_SIZE = 2000

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,
])

microcompact 只处理某些工具:

  • Read
  • Shell / Bash
  • Grep
  • Glob
  • WebSearch
  • WebFetch
  • Edit
  • Write

这些工具的共同点是:结果大、可重复、经常只是中间证据。

比如模型已经读过某个文件,并在后续 assistant message 里提炼了关键结论,那么旧的完整文件读取结果就未必需要继续保留在上下文里。

token 估算:工具结果、图片、thinking 都要算

源码位置:src/services/compact/microCompact.ts:137-205

ts 复制代码
function calculateToolResultTokens(block: ToolResultBlockParam): number {
  if (!block.content) return 0

  if (typeof block.content === 'string') {
    return roughTokenCountEstimation(block.content)
  }

  return block.content.reduce((sum, item) => {
    if (item.type === 'text') {
      return sum + roughTokenCountEstimation(item.text)
    } else if (item.type === 'image' || item.type === 'document') {
      return sum + IMAGE_MAX_TOKEN_SIZE
    }
    return sum
  }, 0)
}

export function estimateMessageTokens(messages: Message[]): number {
  ...
  if (block.type === 'text') ...
  else if (block.type === 'tool_result') ...
  else if (block.type === 'image' || block.type === 'document') ...
  else if (block.type === 'thinking') ...
  else if (block.type === 'tool_use') ...

  return Math.ceil(totalTokens * (4 / 3))
}

这是一个实用工程取舍:主循环里不能为了每次 microcompact 都阻塞等待精确 token count,所以用粗估算,并乘以 4 / 3 做保守 padding。

估算覆盖:

  • text block
  • tool_result
  • image / document,按约 2000 token
  • thinking / redacted_thinking
  • tool_use 的工具名和 input
  • 其他 server block 的 JSON 形式

面试怎么讲:

text 复制代码
microcompact 用粗估算而不是每次调用精确 token API,是为了低延迟。
它宁愿保守高估,也不希望估算过低导致上下文溢出。

microcompactMessages:先 time-based,再 cached MC

源码位置:src/services/compact/microCompact.ts:253-292

ts 复制代码
export async function microcompactMessages(
  messages: Message[],
  toolUseContext?: ToolUseContext,
  querySource?: QuerySource,
): Promise<MicrocompactResult> {
  clearCompactWarningSuppression()

  const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
  if (timeBasedResult) {
    return timeBasedResult
  }

  if (feature('CACHED_MICROCOMPACT')) {
    const mod = await getCachedMCModule()
    const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel()
    if (
      mod.isCachedMicrocompactEnabled() &&
      mod.isModelSupportedForCacheEditing(model) &&
      isMainThreadSource(querySource)
    ) {
      return await cachedMicrocompactPath(messages, querySource)
    }
  }

  return { messages }
}

顺序很重要:

text 复制代码
1. time-based microcompact
2. cached microcompact
3. 否则不做事,交给 autocompact

time-based 先执行,是因为如果距离上一条 assistant message 很久,服务端 prompt cache 大概率已经冷了。这时保护 cache prefix 的收益不大,可以直接修改本地消息内容。

cached microcompact 则适用于 cache 仍然温热、模型支持 cache editing 的情况。

cached microcompact:本地 messages 不变,只排队 cache_edits

源码位置:src/services/compact/microCompact.ts:295-399

ts 复制代码
async function cachedMicrocompactPath(
  messages: Message[],
  querySource: QuerySource | undefined,
): Promise<MicrocompactResult> {
  const mod = await getCachedMCModule()
  const state = ensureCachedMCState()
  const config = mod.getCachedMCConfig()

  const compactableToolIds = new Set(collectCompactableToolIds(messages))
  for (const message of messages) {
    if (message.type === 'user' && Array.isArray(message.message.content)) {
      const groupIds: string[] = []
      for (const block of message.message.content) {
        if (
          block.type === 'tool_result' &&
          compactableToolIds.has(block.tool_use_id) &&
          !state.registeredTools.has(block.tool_use_id)
        ) {
          mod.registerToolResult(state, block.tool_use_id)
          groupIds.push(block.tool_use_id)
        }
      }
      mod.registerToolMessage(state, groupIds)
    }
  }

  const toolsToDelete = mod.getToolResultsToDelete(state)

  if (toolsToDelete.length > 0) {
    const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
    if (cacheEdits) {
      pendingCacheEdits = cacheEdits
    }

    return {
      messages,
      compactionInfo: {
        pendingCacheEdits: {
          trigger: 'auto',
          deletedToolIds: toolsToDelete,
          baselineCacheDeletedTokens: baseline,
        },
      },
    }
  }

  return { messages }
}

cached microcompact 的关键不是修改本地消息,而是:

text 复制代码
追踪哪些 tool_result 可以删除
-> 创建 cache_edits block
-> API 层发送 cache_edits
-> 服务端缓存删除旧工具结果
-> 本地 messages 保持完整

为什么要保持本地 messages 不变?

因为 transcript、UI、resume 仍需要完整记录。真正需要变小的是下一次 API 请求中参与 token 计算的上下文。通过 cache editing,可以减少模型侧缓存占用,又不破坏本地历史。

time-based microcompact:缓存冷了就直接替换旧结果

源码位置:src/services/compact/microCompact.ts:422-530

ts 复制代码
export function evaluateTimeBasedTrigger(
  messages: Message[],
  querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
  const config = getTimeBasedMCConfig()
  if (!config.enabled || !querySource || !isMainThreadSource(querySource)) {
    return null
  }
  const lastAssistant = messages.findLast(m => m.type === 'assistant')
  if (!lastAssistant) {
    return null
  }
  const gapMinutes =
    (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
  if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
    return null
  }
  return { gapMinutes, config }
}

function maybeTimeBasedMicrocompact(...) {
  const compactableIds = collectCompactableToolIds(messages)
  const keepRecent = Math.max(1, config.keepRecent)
  const keepSet = new Set(compactableIds.slice(-keepRecent))
  const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))

  const result: Message[] = messages.map(message => {
    ...
    if (
      block.type === 'tool_result' &&
      clearSet.has(block.tool_use_id) &&
      block.content !== TIME_BASED_MC_CLEARED_MESSAGE
    ) {
      tokensSaved += calculateToolResultTokens(block)
      return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
    }
  })

  resetMicrocompactState()
  return { messages: result }
}

time-based microcompact 的判断条件是"距离上一条 assistant message 是否超过阈值"。如果间隔太久,说明 prompt cache 可能已经冷了。这时它直接把旧工具结果替换成:

text 复制代码
[Old tool result content cleared]

但它至少保留最近一个 compactable 工具结果:

ts 复制代码
const keepRecent = Math.max(1, config.keepRecent)

面试怎么讲:

text 复制代码
Cached MC 和 time-based MC 的差异在于 prompt cache 是否值得保护。
cache 热时,用 cache_edits 删除服务端缓存里的旧工具结果;
cache 冷时,直接改本地消息内容,把旧 tool_result 替换成占位符。

src/services/compact/compact.ts:传统 summary 压缩

CompactionResult:压缩结果不是只有 summary

源码位置:src/services/compact/compact.ts:325-337

ts 复制代码
export function buildPostCompactMessages(result: CompactionResult): Message[] {
  return [
    result.boundaryMarker,
    ...result.summaryMessages,
    ...(result.messagesToKeep ?? []),
    ...result.attachments,
    ...result.hookResults,
  ]
}

这是理解 compact 的核心。

压缩后模型看到的不是一条 summary,而是一个有顺序的消息包:

text 复制代码
1. boundaryMarker
2. summaryMessages
3. messagesToKeep
4. attachments
5. hookResults

每一部分都有作用:

  • boundaryMarker:给 runtime 切片和 transcript relink 用。
  • summaryMessages:旧历史的语义压缩。
  • messagesToKeep:partial / reactive / session memory 路径保留的原始消息。
  • attachments:恢复文件、plan、skill、agent、MCP 等上下文。
  • hookResults:SessionStart / compact hook 重新注入的上下文。

你可以这样记:

text 复制代码
压缩不是 summary-only;
压缩后是一个"可继续工作的上下文包"。

preservedSegment:保留原始消息时修复 transcript 链

源码位置:src/services/compact/compact.ts:340-367

ts 复制代码
export function annotateBoundaryWithPreservedSegment(
  boundary: SystemCompactBoundaryMessage,
  anchorUuid: UUID,
  messagesToKeep: readonly Message[] | undefined,
): SystemCompactBoundaryMessage {
  const keep = messagesToKeep ?? []
  if (keep.length === 0) return boundary
  return {
    ...boundary,
    compactMetadata: {
      ...boundary.compactMetadata,
      preservedSegment: {
        headUuid: keep[0]!.uuid,
        anchorUuid,
        tailUuid: keep.at(-1)!.uuid,
      },
    },
  }
}

如果压缩后还保留了一段原始消息,就会产生 transcript 链接问题。旧消息在磁盘上可能保留原来的 parent UUID,而新的 summary 和 boundary 又要成为新的逻辑前缀。

preservedSegment 记录:

  • headUuid:保留段第一条消息。
  • anchorUuid:保留段应该接在哪个锚点之后。
  • tailUuid:保留段最后一条消息。

面试怎么讲:

text 复制代码
Claude Code 的压缩不只是处理 token,还处理 transcript 恢复。
preservedSegment 是压缩后保留原始尾部消息时的 relink metadata。

compactConversation:完整压缩主流程

源码位置:src/services/compact/compact.ts:387-645

ts 复制代码
export async function compactConversation(
  messages: Message[],
  context: ToolUseContext,
  cacheSafeParams: CacheSafeParams,
  suppressFollowUpQuestions: boolean,
  customInstructions?: string,
  isAutoCompact: boolean = false,
  recompactionInfo?: RecompactionInfo,
): Promise<CompactionResult> {
  if (messages.length === 0) {
    throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
  }

  const preCompactTokenCount = tokenCountWithEstimation(messages)

  const hookResult = await executePreCompactHooks(...)
  customInstructions = mergeHookInstructions(
    customInstructions,
    hookResult.newCustomInstructions,
  )

  const compactPrompt = getCompactPrompt(customInstructions)
  const summaryRequest = createUserMessage({ content: compactPrompt })

  for (;;) {
    summaryResponse = await streamCompactSummary(...)
    summary = getAssistantMessageText(summaryResponse)
    if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break

    const truncated =
      ptlAttempts <= MAX_PTL_RETRIES
        ? truncateHeadForPTLRetry(messagesToSummarize, summaryResponse)
        : null
    ...
  }

  const preCompactReadFileState = cacheToObject(context.readFileState)
  context.readFileState.clear()
  context.loadedNestedMemoryPaths?.clear()

  const [fileAttachments, asyncAgentAttachments] = await Promise.all([...])
  ...
  const boundaryMarker = createCompactBoundaryMessage(...)
  const summaryMessages = [
    createUserMessage({
      content: getCompactUserSummaryMessage(...),
      isCompactSummary: true,
      isVisibleInTranscriptOnly: true,
    }),
  ]
}

可以拆成 8 步:

text 复制代码
1. 校验 messages 不能空。
2. 估算压缩前 token。
3. 执行 PreCompact hooks,允许 hook 追加压缩说明。
4. 构造 compact prompt 和 summaryRequest。
5. streamCompactSummary 生成 summary。
6. 如果 compact 请求自己 prompt-too-long,就 truncateHeadForPTLRetry 后重试。
7. 清理 readFileState / loadedNestedMemoryPaths。
8. 生成压缩后要重新注入的 attachments、boundary、summary message。

压缩后为什么要重注入 attachments

源码位置:src/services/compact/compact.ts:517-594

ts 复制代码
const preCompactReadFileState = cacheToObject(context.readFileState)

context.readFileState.clear()
context.loadedNestedMemoryPaths?.clear()

const [fileAttachments, asyncAgentAttachments] = await Promise.all([
  createPostCompactFileAttachments(...),
  createAsyncAgentAttachmentsIfNeeded(context),
])

const planAttachment = createPlanAttachmentIfNeeded(context.agentId)
const planModeAttachment = await createPlanModeAttachmentIfNeeded(context)
const skillAttachment = createSkillAttachmentIfNeeded(context.agentId)

for (const att of getDeferredToolsDeltaAttachment(...)) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getAgentListingDeltaAttachment(context, [])) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getMcpInstructionsDeltaAttachment(...)) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}

const hookMessages = await processSessionStartHooks('compact', {
  model: context.options.mainLoopModel,
})

压缩会吃掉旧上下文,但 Claude Code 不能让模型压缩后突然忘掉:

  • 刚读过的重要文件。
  • 当前 plan mode 状态。
  • 已 invoked 的 skill。
  • deferred tool schemas。
  • agent listing。
  • MCP instructions。
  • CLAUDE.md / session start hooks。

所以它在 compact 后主动重建这些 attachments。

你可以这样记:

text 复制代码
compact 后:
  丢掉旧大历史
  保留 summary
  重新注入继续工作必需的 runtime context

streamCompactSummary:优先 forked agent 共享 prompt cache

源码位置:src/services/compact/compact.ts:1150-1326

ts 复制代码
if (promptCacheSharingEnabled) {
  const result = await runForkedAgent({
    promptMessages: [summaryRequest],
    cacheSafeParams,
    canUseTool: createCompactCanUseTool(),
    querySource: 'compact',
    forkLabel: 'compact',
    maxTurns: 1,
    skipCacheWrite: true,
    overrides: { abortController: context.abortController },
  })
  const assistantMsg = getLastAssistantMessage(result.messages)
  if (assistantMsg && assistantText && !assistantMsg.isApiErrorMessage) {
    return assistantMsg
  }
}

const streamingGen = queryModelWithStreaming({
  messages: normalizeMessagesForAPI(
    stripImagesFromMessages(
      stripReinjectedAttachments([
        ...getMessagesAfterCompactBoundary(messages),
        summaryRequest,
      ]),
    ),
    context.options.tools,
  ),
  systemPrompt: asSystemPrompt([
    'You are a helpful AI assistant tasked with summarizing conversations.',
  ]),
  thinkingConfig: { type: 'disabled' },
  tools,
  signal: context.abortController.signal,
  options: {
    querySource: 'compact',
    maxOutputTokensOverride: Math.min(
      COMPACT_MAX_OUTPUT_TOKENS,
      getMaxOutputTokensForModel(context.options.mainLoopModel),
    ),
  },
})

压缩 summary 本身也是一次模型调用。Claude Code 优先使用 runForkedAgent,因为它可以复用主会话的 prompt cache prefix。

关键配置:

  • querySource: 'compact':标记这是 compact fork。
  • maxTurns: 1:只允许一轮。
  • canUseTool: createCompactCanUseTool():compact agent 不允许调用工具。
  • skipCacheWrite: true:避免污染缓存。
  • 不设置 maxOutputTokens,避免 thinking config mismatch 破坏 cache key。

如果 forked agent 路径失败,就 fallback 到普通 queryModelWithStreaming

普通路径还会做:

text 复制代码
stripImagesFromMessages:
  图片 / 文档替换成 [image] / [document],避免 compact 请求自己过大。

stripReinjectedAttachments:
  去掉压缩后会重新注入的 skill discovery/listing,避免 summary 污染。

面试怎么讲:

text 复制代码
Compact summary 不是普通 API 调用。
Claude Code 优先 fork 一个只输出文本、不允许工具、最多一轮的 compact agent,并尽量复用主会话 prompt cache。
失败后才走普通 streaming fallback。

src/services/compact/prompt.ts:summary prompt 如何保证可继续工作

compact prompt 要求模型输出 analysis + summary

源码位置:src/services/compact/prompt.ts:12-143

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.

`

const BASE_COMPACT_PROMPT = `Your task is to create a detailed summary of the conversation so far...

Your summary should include the following sections:

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
`

这个 prompt 的目标不是写漂亮摘要,而是保证"压缩后还能继续干活"。

它强制模型覆盖:

  • 用户真实意图。
  • 技术概念。
  • 文件和代码段。
  • 错误与修复。
  • 已解决问题。
  • 所有非 tool-result 的用户消息。
  • 待办任务。
  • 当前正在做什么。
  • 下一步是什么,而且必须贴近最近请求。

这解释了为什么 Claude Code 的 compact summary 通常很长:它不是聊天摘要,而是工作恢复摘要。

formatCompactSummary:analysis 是草稿,summary 才进入上下文

源码位置:src/services/compact/prompt.ts:305-335

ts 复制代码
export function formatCompactSummary(summary: string): string {
  let formattedSummary = summary

  formattedSummary = formattedSummary.replace(
    /<analysis>[\s\S]*?<\/analysis>/,
    '',
  )

  const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/)
  if (summaryMatch) {
    const content = summaryMatch[1] || ''
    formattedSummary = formattedSummary.replace(
      /<summary>[\s\S]*?<\/summary>/,
      `Summary:\n${content.trim()}`,
    )
  }

  formattedSummary = formattedSummary.replace(/\n\n+/g, '\n\n')

  return formattedSummary.trim()
}

模型被要求先写 <analysis>,但这部分不会进入压缩后的上下文。Claude Code 会删除 <analysis>...</analysis>,只保留 <summary> 内容。

这是一个典型技巧:

text 复制代码
让模型先思考,提高 summary 质量;
但不把思考草稿放回上下文,避免浪费 token。

getCompactUserSummaryMessage:summary 以 user message 形式进入下一轮

源码位置:src/services/compact/prompt.ts:337-360

ts 复制代码
export function getCompactUserSummaryMessage(
  summary: string,
  suppressFollowUpQuestions?: boolean,
  transcriptPath?: string,
  recentMessagesPreserved?: boolean,
): string {
  const formattedSummary = formatCompactSummary(summary)

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

${formattedSummary}`

  if (transcriptPath) {
    baseSummary += `\n\nIf you need specific details from before compaction ..., read the full transcript at: ${transcriptPath}`
  }

  if (recentMessagesPreserved) {
    baseSummary += `\n\nRecent messages are preserved verbatim.`
  }

  if (suppressFollowUpQuestions) {
    return `${baseSummary}
Continue the conversation from where it left off without asking the user any further questions...`
  }
}

压缩 summary 最终会作为 UserMessage 放回 messages。

这和 Phase2 的消息模型一致:Claude Code 没有引入单独的 "summary" role。压缩摘要是用户侧消息,只是带有 isCompactSummary: true 等内部 metadata。


src/services/compact/sessionMemoryCompact.ts:用长期 session memory 替代重新总结

session memory compact 的目标

源码位置:src/services/compact/sessionMemoryCompact.ts:44-61

ts 复制代码
export type SessionMemoryCompactConfig = {
  /** Minimum tokens to preserve after compaction */
  minTokens: number
  /** Minimum number of messages with text blocks to keep */
  minTextBlockMessages: number
  /** Maximum tokens to preserve after compaction (hard cap) */
  maxTokens: number
}

export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
  minTokens: 10_000,
  minTextBlockMessages: 5,
  maxTokens: 40_000,
}

session memory compaction 是一个实验路径。

传统 compact 每次都让模型总结旧对话;session memory compact 则尝试复用已经抽取好的长期 session memory,再保留最近一段原始消息。

好处:

  • 不一定需要再发起昂贵 summary 调用。
  • 长期上下文来自持续维护的 session memory。
  • 最近消息保留原文,减少 summary 丢细节。

adjustIndexToPreserveAPIInvariants:不能切断 tool_use/tool_result

源码位置:src/services/compact/sessionMemoryCompact.ts:188-220

ts 复制代码
/**
 * Adjust the start index to ensure we don't split tool_use/tool_result pairs
 * or thinking blocks that share the same message.id with kept assistant messages.
 *
 * If ANY message we're keeping contains tool_result blocks, we need to
 * include the preceding assistant message(s) that contain the matching tool_use blocks.
 *
 * Additionally, if ANY assistant message in the kept range has the same message.id
 * as a preceding assistant message (which may contain thinking blocks), we need to
 * include those messages so they can be properly merged by normalizeMessagesForAPI.
 */
export function adjustIndexToPreserveAPIInvariants(
  messages: Message[],
  startIndex: number,
): number {
  ...
}

这是上下文压缩里非常重要的 API 不变量。

Claude API 要求:

text 复制代码
assistant/tool_use(id=X)
必须在前面存在;
user/tool_result(tool_use_id=X)
才能出现。

如果保留尾部时切在中间,就可能出现:

text 复制代码
保留了 user/tool_result
但丢掉了前面的 assistant/tool_use

这会导致 API 报错:tool_result 引用了不存在的 tool_use。

另外 streaming 时,一个 assistant response 可能被拆成多条内部 message,但共享同一个 message.id,比如 thinking block 和 tool_use block。如果只保留 tool_use,不保留同 id 的 thinking block,normalizeMessagesForAPI 合并时也会丢上下文。

面试怎么讲:

text 复制代码
Claude Code 在保留尾部消息时会向前扩展切片点,避免切断 tool_use/tool_result 配对,也避免切断同一个 assistant message.id 下的 thinking/tool_use 分块。
这是 agent 系统比普通聊天系统更复杂的地方。

calculateMessagesToKeepIndex:从 last summarized 后面开始,向前扩展

源码位置:src/services/compact/sessionMemoryCompact.ts:324-397

ts 复制代码
export function calculateMessagesToKeepIndex(
  messages: Message[],
  lastSummarizedIndex: number,
): number {
  const config = getSessionMemoryCompactConfig()

  let startIndex =
    lastSummarizedIndex >= 0 ? lastSummarizedIndex + 1 : messages.length

  let totalTokens = 0
  let textBlockMessageCount = 0
  for (let i = startIndex; i < messages.length; i++) {
    const msg = messages[i]!
    totalTokens += estimateMessageTokens([msg])
    if (hasTextBlocks(msg)) {
      textBlockMessageCount++
    }
  }

  const idx = messages.findLastIndex(m => isCompactBoundaryMessage(m))
  const floor = idx === -1 ? 0 : idx + 1
  for (let i = startIndex - 1; i >= floor; i--) {
    ...
    if (totalTokens >= config.maxTokens) break
    if (
      totalTokens >= config.minTokens &&
      textBlockMessageCount >= config.minTextBlockMessages
    ) {
      break
    }
  }

  return adjustIndexToPreserveAPIInvariants(messages, startIndex)
}

这个函数决定 session memory compact 后保留哪些原始消息。

算法:

text 复制代码
1. 从 lastSummarizedIndex 后一条开始,默认只保留未被 session memory 覆盖的消息。
2. 统计保留段 token 和 text message 数。
3. 如果不足最低保留量,就向前扩展。
4. 但最多扩展到 maxTokens。
5. floor 是最近 compact boundary 之后,避免跨旧 boundary 破坏 preserved segment 链。
6. 最后调用 adjustIndexToPreserveAPIInvariants 修正 tool_use/tool_result 和 thinking block。

这比"保留最近 N 条消息"更稳,因为它同时考虑 token、文本消息数量、compact boundary 和 API 不变量。

trySessionMemoryCompaction:能用就用,不能用就回退传统 compact

源码位置:src/services/compact/sessionMemoryCompact.ts:514-630

ts 复制代码
export async function trySessionMemoryCompaction(
  messages: Message[],
  agentId?: AgentId,
  autoCompactThreshold?: number,
): Promise<CompactionResult | null> {
  if (!shouldUseSessionMemoryCompaction()) {
    return null
  }

  await initSessionMemoryCompactConfig()
  await waitForSessionMemoryExtraction()

  const lastSummarizedMessageId = getLastSummarizedMessageId()
  const sessionMemory = await getSessionMemoryContent()

  if (!sessionMemory) return null
  if (await isSessionMemoryEmpty(sessionMemory)) return null

  let lastSummarizedIndex: number
  if (lastSummarizedMessageId) {
    lastSummarizedIndex = messages.findIndex(
      msg => msg.uuid === lastSummarizedMessageId,
    )
    if (lastSummarizedIndex === -1) {
      return null
    }
  } else {
    lastSummarizedIndex = messages.length - 1
  }

  const startIndex = calculateMessagesToKeepIndex(
    messages,
    lastSummarizedIndex,
  )

  const messagesToKeep = messages
    .slice(startIndex)
    .filter(m => !isCompactBoundaryMessage(m))

  const compactionResult = createCompactionResultFromSessionMemory(...)
  const postCompactMessages = buildPostCompactMessages(compactionResult)
  const postCompactTokenCount = estimateMessageTokens(postCompactMessages)

  if (
    autoCompactThreshold !== undefined &&
    postCompactTokenCount >= autoCompactThreshold
  ) {
    return null
  }

  return {
    ...compactionResult,
    postCompactTokenCount,
    truePostCompactTokenCount: postCompactTokenCount,
  }
}

它是一个"尝试型"路径:成功就返回 CompactionResult,不适用就返回 null,让调用方回退传统 compact。

不适用的情况包括:

  • feature flag 没开。
  • session memory 文件不存在。
  • session memory 还是空模板。
  • lastSummarizedMessageId 找不到。
  • 压缩后的 token 仍超过 autocompact threshold。
  • 内部发生预期错误。

面试怎么讲:

text 复制代码
Session memory compact 是 opportunistic optimization。
它不会让主流程依赖它;能用就用,不能用就回退 legacy compact。
这样实验路径不会破坏稳定性。

src/commands/compact/compact.ts:手动 /compact 入口

手动 compact 和自动 compact 共用底层能力

源码位置:src/commands/compact/compact.ts:1-120

ts 复制代码
export const call: LocalCommandCall = async (args, context) => {
  let { messages } = context

  messages = getMessagesAfterCompactBoundary(messages)

  if (messages.length === 0) {
    throw new Error('No messages to compact')
  }

  const customInstructions = args.trim()

  if (!customInstructions) {
    const sessionMemoryResult = await trySessionMemoryCompaction(...)
    if (sessionMemoryResult) {
      ...
      return {
        type: 'compact',
        compactionResult: sessionMemoryResult,
        displayText: buildDisplayText(context),
      }
    }
  }

  if (reactiveCompact?.isReactiveOnlyMode()) {
    return await compactViaReactive(...)
  }

  const microcompactResult = await microcompactMessages(messages, context)
  const messagesForCompact = microcompactResult.messages

  const result = await compactConversation(
    messagesForCompact,
    context,
    await getCacheSharingParams(context, messagesForCompact),
    false,
    customInstructions,
    false,
  )

  return {
    type: 'compact',
    compactionResult: result,
    displayText: buildDisplayText(context, result.userDisplayMessage),
  }
}

手动 /compact 和自动 compact 的底层是同一套机制:

  • 都从 getMessagesAfterCompactBoundary 后的视图开始。
  • 都可以尝试 session memory compaction。
  • 都可能走 compactConversation
  • 都会返回 CompactionResult

不同点在于:

text 复制代码
手动 /compact:
  可以带 customInstructions
  suppressFollowUpQuestions = false
  出错时会通知用户
  displayText 会显示 Compacted

自动 autocompact:
  suppressFollowUpQuestions = true
  不带用户自定义说明
  出错通常只记录 failure count
  成功后当前 query loop 直接继续

几条上下文压缩路径的对比

机制 触发时机 是否生成 summary 是否修改本地 messages 主要目标
getMessagesAfterCompactBoundary 每轮 query 开始 从最近 compact 后构造模型视图
applyToolResultBudget 每轮 query 开始 可能替换工具结果 限制单条工具结果体积
HISTORY_SNIP feature 开启且需要时 修改模型视图 裁剪历史视图
time-based microcompact cache 冷、工具结果多 把旧 tool_result 替换成占位符
cached microcompact cache 热、模型支持 cache editing 用 cache_edits 删除服务端旧工具结果
contextCollapse feature 开启且接近阈值 有折叠 summary,但存在 collapse store 投影视图 细粒度保留上下文
autocompact 调模型前超过阈值 替换 messagesForQuery 主动防止上下文溢出
reactive compact API prompt-too-long / media too large 更新 state 后重试 失败恢复
manual /compact 用户手动触发 由命令结果替换 用户主动缩短上下文
session memory compact feature 开启且 memory 可用 使用 session memory 构造 post-compact 包 减少重新总结成本

面试回答模板

如果问:Claude Code 为什么不直接删旧消息?

可以回答:

text 复制代码
因为 Agent Loop 的消息不只是聊天文本。
里面有 assistant/tool_use、user/tool_result、附件、hook result、文件读取状态、skill 状态、MCP 工具提示、plan mode 信息。
直接删除旧消息可能破坏工具调用配对、API 消息合法性和会话恢复链。

Claude Code 使用 compact boundary + summary message + messagesToKeep + attachments + hookResults 的方式,把旧历史压成可继续工作的上下文包。

如果问:自动压缩什么时候触发?

可以回答:

text 复制代码
先计算 effective context window:
  model context window - summary 输出预留空间

再计算 auto compact threshold:
  effective window - 13k buffer

每轮 query 调模型前,Claude Code 用 tokenCountWithEstimation(messages) - snipTokensFreed 判断是否超过阈值。
但它还会检查 querySource、防递归、用户配置、reactive-only 模式、contextCollapse 模式等条件。

如果问:microcompact 和 autocompact 有什么区别?

可以回答:

text 复制代码
microcompact 是工具结果级别的小压缩,主要清理 Read/Bash/Grep/Glob/WebFetch 等工具结果。
它可以直接替换旧 tool_result,也可以通过 cache_edits 删除服务端缓存中的工具结果。

autocompact 是会话级别的大压缩,会 fork 一个 compact agent 生成 summary,然后构造 boundary + summary + attachments 的新上下文。

如果问:压缩后模型怎么继续工作?

可以回答:

text 复制代码
压缩后不是只有 summary。
buildPostCompactMessages 会按顺序放入:

1. compact boundary
2. summary messages
3. messagesToKeep
4. attachments
5. hookResults

attachments 会恢复文件上下文、plan mode、invoked skills、deferred tools、agent listing、MCP instructions;SessionStart hooks 会重新注入 CLAUDE.md 等上下文。

如果问:有哪些防止压缩失败或循环的机制?

可以回答:

text 复制代码
1. autocompact threshold 预留 summary 输出空间和 13k buffer。
2. compact 请求自己 prompt-too-long 时,会 truncate oldest API-round groups 后重试。
3. autocompact 连续失败超过 3 次会熔断。
4. querySource === 'compact' 或 'session_memory' 时禁止再次 autocompact,避免递归死锁。
5. reactive compact 用 hasAttemptedReactiveCompact 防止无限重试。
6. contextCollapse 开启时主动 autocompact 会被抑制,避免两套上下文管理系统竞态。

Phase 8 总结

Claude Code 的上下文压缩机制可以抽象成三层:

text 复制代码
第一层:视图层
  getMessagesAfterCompactBoundary
  HISTORY_SNIP
  contextCollapse projection

第二层:工具结果层
  applyToolResultBudget
  time-based microcompact
  cached microcompact

第三层:会话摘要层
  autocompact
  manual /compact
  reactive compact
  session memory compact

真正要记住的是:上下文压缩服务于 Agent Loop,而不是独立功能。它必须保证下一轮模型调用仍然满足这些条件:

text 复制代码
1. 旧任务意图没有丢。
2. 最近工作状态能恢复。
3. 工具调用配对不被破坏。
4. 文件、计划、技能、MCP、agent 信息能重新注入。
5. API prompt cache 尽量复用。
6. prompt-too-long 可以恢复。
7. 多次压缩后 transcript 逻辑链仍可恢复。

一句话总结:

text 复制代码
Claude Code 的 compact 不是"把历史总结一下",而是在 Agent Loop 内部维护模型可见上下文、工具结果、缓存、summary、附件和恢复链的一整套上下文操作系统。
相关推荐
甲维斯1 小时前
MiMo Code 初体验,免费,易上手,适合新手!
人工智能
2301_764441331 小时前
主流手机pc品牌的端侧模型部署梳理
人工智能·windows·机器学习·智能手机·产品运营
虾壳云智能1 小时前
阿里云百炼 API 配置 OpenClaw 2.7.9 环境搭建
人工智能·阿里云百炼·open claw安装·open claw教程
Xzh04231 小时前
AI Agent 学习路线(Java 后端方向)
java·人工智能·学习
zhangpba1 小时前
IntelliJ IDEA 集成通义灵码
ai·idea
身如柳絮随风扬2 小时前
LangGraph State记忆机制深度解析:短期与长期记忆的实现原理与实战
ai
醒醒该学习了!2 小时前
视觉与声音大模型(理论篇)
人工智能
Cloud_Shy6182 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第五章 Item 33 - 35)
开发语言·人工智能·笔记·python·学习方法
救救孩子把2 小时前
HyperFrames by HeyGen 入门教程
人工智能·视频生成·heygen