6 · 上下文治理 ★

这是全系统工程密度最高的一块。它要解决的问题只有一句话:智能体的上下文会自己长大,而上下文窗口有硬上限。

6.1 五级流水线

每一次调用模型之前,消息历史都要穿过这条流水线。顺序按成本从低到高排列:

上下文治理五级阶梯
上下文治理五级阶梯 — 左侧成本轴从 $0 递增到「一次完整的模型调用」。绿色那条旁路边是设计的点睛之笔:如果便宜的级别已经降到阈值以下,最贵的第 5 级直接跳过点击放大
// query.ts 主循环内,每次迭代开头
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
//   先取"上次压缩分界点之后"的消息,之前的已被摘要替代

// ① 工具结果预算:单条 / 每条消息聚合超限 → 落盘换引用
messagesForQuery = await applyToolResultBudget(
  messagesForQuery,
  toolUseContext.contentReplacementState,
  persistReplacements ? records => void recordContentReplacement(...) : undefined,
  new Set(toolUseContext.options.tools
    .filter(t => !Number.isFinite(t.maxResultSizeChars))    // 排除声明为无穷大的工具
    .map(t => t.name)),
)

// ② 裁剪:删除僵尸消息与失效标记
let snipTokensFreed = 0
if (feature('HISTORY_SNIP')) {
  const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
  messagesForQuery = snipResult.messages
  snipTokensFreed = snipResult.tokensFreed
  if (snipResult.boundaryMessage) yield snipResult.boundaryMessage
}

// ③ 微压缩:按工具调用 id 精确删除旧结果
const microcompactResult = await deps.microcompact(messagesForQuery, toolUseContext, querySource)
messagesForQuery = microcompactResult.messages
const pendingCacheEdits = feature('CACHED_MICROCOMPACT')
  ? microcompactResult.compactionInfo?.pendingCacheEdits : undefined

// ④ 上下文折叠:投影式,可重放
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
  const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
    messagesForQuery, toolUseContext, querySource)
  messagesForQuery = collapseResult.messages
}

// ⑤ 自动摘要压缩:一次完整的模型调用
const { compactionResult, consecutiveFailures } = await deps.autocompact(
  messagesForQuery, toolUseContext, { systemPrompt, userContext, systemContext,
  toolUseContext, forkContextMessages: messagesForQuery }, querySource, tracking, snipTokensFreed)

// ⑥ 硬阻断检查(只在自动压缩被关闭时生效)
if (!compactionResult && querySource !== 'compact' && ... ) {
  const { isAtBlockingLimit } = calculateTokenWarningState(
    tokenCountWithEstimation(messagesForQuery) - snipTokensFreed,
    toolUseContext.options.mainLoopModel)
  if (isAtBlockingLimit) {
    yield createAssistantAPIErrorMessage({ content: PROMPT_TOO_LONG_ERROR_MESSAGE, ... })
    return { reason: 'blocking_limit' }
  }
}

顺序的理由

关于第 ④ 级为什么必须排在第 ⑤ 级之前,源码里有一句精确的说明:

「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.」

译:它跑在自动压缩之前,这样如果折叠已经把我们降到自动压缩的阈值以下,自动压缩就成了空操作 —— 于是我们保住了细粒度上下文,而不是把它换成了一坨摘要。

这句话是整条阶梯的设计哲学:能保住细粒度上下文,就绝不换成摘要。

这里的「贵」不只是钱,更是信息损失。摘要是有损、不可逆的 —— 30 轮对话总结成 500 字,那些具体的代码片段、行号、报错信息就永久丢失了。折叠是可重放的、保留结构的。

所以宁可多跑几级便宜的处理,也要尽量不触发最贵的那一级。

第 ⑥ 级的反直觉设计

注意硬阻断的触发条件:只在自动压缩被关闭时才生效。注释解释了:

「Block if we've hit the hard blocking limit (only applies when auto-compact is OFF). This reserves space so users can still run /compact manually.」

译:如果达到硬阻断上限就拦住(只在自动压缩关闭时适用)。这样预留出空间,让用户还能手动执行 /compact 命令。

逻辑:自动压缩开着的时候,撞线了就自动压缩,没必要拦用户。只有用户手动关掉了自动压缩,系统才需要预留 20,000 token 的空间 —— 因为用户可能想自己敲压缩命令,而那个命令本身也要占上下文。不预留就会陷入「上下文满了 → 想手动压缩 → 但压缩命令自己也放不进去」的死锁。

6.2 第 ① 级:工具结果预算

单条结果的落盘

export const PREVIEW_SIZE_BYTES = 2000                     // 预览多少字节
export const PERSISTED_OUTPUT_TAG = '<persisted-output>'    // 包裹标签
export const TOOL_RESULTS_SUBDIR = 'tool-results'          // 落盘子目录
export const TOOL_RESULT_CLEARED_MESSAGE = '[Old tool result content cleared]'

claude-code/src/utils/toolResultStorage.ts

流程:结果超过工具声明的 maxResultSizeChars → 完整内容写到 tool-results/ 目录 → 模型收到「前 2000 字节预览 + 文件路径」→ 需要全文时模型自己去 Read 那个文件。

那个 Infinity 的例外

「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).」

译:对那些输出绝对不能落盘的工具,把上限设成无穷大(比如 Read 工具 —— 落盘会造成「读文件 → 结果落盘成文件 → 又要读那个文件」的循环套娃,而且这个工具本身已经有自己的长度限制了)。

这类自引用陷阱在设计通用机制时非常容易踩:你写了一条「所有工具的大结果都落盘」的规则,却忘了其中有个工具的职责恰好就是「读文件」。

每条消息的聚合预算

除了单条限制,还有一个 ContentReplacementState(内容替换状态):

export type ContentReplacementState = {
  seenIds: ...        // 已经通过预算检查的结果 id
  ...
}
export function createContentReplacementState(): ContentReplacementState
export function cloneContentReplacementState(...): ContentReplacementState

它防的是:模型一次并行发出 20 个搜索调用,每个结果都在单条上限之内,但加起来爆掉。

而且它的生命周期很讲究,注释说明了三种情况:

「Main thread: REPL provisions once (never resets — stale UUID keys are inert). Subagents: createSubagentContext clones the parent's state by default (cache-sharing forks need identical decisions), or resumeAgentBackground threads one reconstructed from sidechain records.」

译:主线程:交互界面创建一次,永不重置(过期的 UUID 键是惰性的,不影响)。子智能体:默认克隆父的状态(因为共享缓存的分叉需要做出完全相同的决策),或者由后台恢复流程从支链记录里重建一个。

「共享缓存的分叉需要做出完全相同的决策」这句是关键:如果两个分叉子智能体对「哪些结果该落盘」的判断不一致,它们的上下文就不再字节相同,缓存共享失效(第 8 章详述)。

6.3 第 ③ 级:微压缩与缓存编辑

困境

目标很朴素:把没用了的旧工具结果删掉。但直接删有致命副作用:

删掉消息历史第 15 条里的内容 ↓ 发给接口的上下文,从第 15 条开始就和上次不同了 ↓ 提示词缓存是前缀匹配的 ↓ 第 15 条之后的全部内容缓存失效,必须重新处理 ↓ 省下 3,000 token,却让 80,000 token 从"缓存价(10%)"变回"全价(100%)" ↓ 净结果:更贵了

解法:让服务端在缓存里删

/**
 * Cached microcompact path - uses cache editing API to remove tool results
 * without invalidating the cached prefix.
 *
 * - Does NOT modify local message content
 *   (cache_reference and cache_edits are added at API layer)
 * - Uses count-based trigger/keep thresholds from GrowthBook config
 * - Takes precedence over regular microcompact (no disk persistence)
 */
async function cachedMicrocompactPath(messages, querySource) {
  const mod    = await getCachedMCModule()
  const state  = ensureCachedMCState()
  const config = mod.getCachedMCConfig()

  // 1. 扫出所有"可压缩工具"的调用 id
  const compactableToolIds = new Set(collectCompactableToolIds(messages))

  // 2. 按"用户消息"分组,注册这些工具结果
  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)
    }
  }

  // 3. 问状态机:该删哪些?(保留最近 N 个)
  const toolsToDelete = mod.getToolResultsToDelete(state)

  if (toolsToDelete.length > 0) {
    // 4. 生成 cache_edits 指令块,排队交给接口层
    const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
    if (cacheEdits) pendingCacheEdits = cacheEdits
    ...
    // 5. ★ 消息原样返回 —— 本地一个字都没改
    return { messages, compactionInfo: { pendingCacheEdits: {...} } }
  }
  return { messages }
}

claude-code/src/services/compact/microCompact.ts

只有 8 种工具的结果允许被删

const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME,      // 读文件
  ...SHELL_TOOL_NAMES,      // Bash / PowerShell
  GREP_TOOL_NAME,           // 内容搜索
  GLOB_TOOL_NAME,           // 文件名搜索
  WEB_SEARCH_TOOL_NAME,     // 网络搜索
  WEB_FETCH_TOOL_NAME,      // 抓网页
  FILE_EDIT_TOOL_NAME,      // 编辑文件
  FILE_WRITE_TOOL_NAME,     // 写文件
])

这 8 种的共同特征是:结果是一次性的观察数据 —— 读了个文件、搜了个词、跑了个命令,模型消化完就不需要原文了。

而其他工具(比如 TodoWrite 维护待办清单)的结果代表持续有效的状态,删掉会造成失忆。

删了多少 token,要等服务端告诉你

// query.ts,流式响应结束后
if (feature('CACHED_MICROCOMPACT') && pendingCacheEdits) {
  const lastAssistant = assistantMessages.at(-1)
  const usage = lastAssistant?.message.usage
  // ★ 这个字段是"累积/粘性"的(从会话开始的总量),不是本次增量
  const cumulativeDeleted = usage
    ? ((usage as unknown as Record<string, number>).cache_deleted_input_tokens ?? 0) : 0
  const deletedTokens = Math.max(0,
    cumulativeDeleted - pendingCacheEdits.baselineCacheDeletedTokens)   // 减去请求前的基线
  if (deletedTokens > 0) {
    yield createMicrocompactBoundaryMessage(
      pendingCacheEdits.trigger, 0, deletedTokens, pendingCacheEdits.deletedToolIds, [])
  }
}

因为本地什么都没改,客户端不知道实际省了多少,所以「已压缩」的通知消息被推迟到接口响应之后才发。

副作用:存在一个短暂的「认知偏差窗口」 —— 从「决定删除」到「响应回来」这几秒内,客户端对上下文大小的估算是偏高的。这也是为什么后面自动压缩的阈值检查里到处是手工补偿项(比如 - snipTokensFreed)。

三处防止状态串味的保护

// 保护 1:只对主线程跑缓存编辑
if (mod.isCachedMicrocompactEnabled() &&
    mod.isModelSupportedForCacheEditing(model) &&
    isMainThreadSource(querySource)) {
  return await cachedMicrocompactPath(messages, querySource)
}

注释:「Only run cached MC for the main thread to prevent forked agents (session_memory, prompt_suggestion, etc.) from registering their tool_results in the global cachedMCState, which would cause the main thread to try deleting tools that don't exist in its own conversation.」

译:只对主线程运行缓存微压缩,防止分叉出的智能体(会话记忆、提示建议等)把它们的工具结果注册进全局状态,从而导致主线程试图删除自己对话里根本不存在的工具。

问题的根源是:cachedMCState 是一个模块级的全局单例,和消息数组各存一份真相。这是这套机制最大的复杂度来源。

反过来的那条路径:时间触发

export function evaluateTimeBasedTrigger(messages, querySource) {
  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 }
}

注释说明了这条路径的逻辑:

「Time-based trigger runs first and short-circuits. If the gap since the last assistant message exceeds the threshold, the server cache has expired and the full prefix will be rewritten regardless — so content-clear old tool results now, before the request, to shrink what gets rewritten. Cached MC (cache-editing) is skipped when this fires: editing assumes a warm cache, and we just established it's cold.」

译:时间触发先跑并短路。如果距上一条模型消息的间隔超过阈值,服务端缓存已经过期,整个前缀无论如何都要重写 —— 那就现在、在发请求之前把旧工具结果清掉,缩小要重写的量。此时跳过缓存编辑:编辑的前提是缓存还热着,而我们刚确认它已经凉了。

那个 Math.max(1, ...) 的边界陷阱

// Floor at 1: slice(-0) returns the full array (paradoxically keeps everything),
// and clearing ALL results leaves the model with zero working context.
// Neither degenerate is sensible — always keep at least the last.
const keepRecent = Math.max(1, config.keepRecent)
const keepSet  = new Set(compactableIds.slice(-keepRecent))
const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))

译:下限设为 1:因为 slice(-0) 会返回整个数组(矛盾地导致什么都不删),而清空所有结果又会让模型完全没有工作上下文。两种退化情形都不合理。

slice(-N) 在 JavaScript 里是「取最后 N 个」。但 -0 在数值上等于 0,而 slice(0) 是「从头取全部」。所以配置成「保留最近 0 个」时,实际行为是「全部保留」—— 一个典型的边界值陷阱。)

清理完还要重置状态并通知监控

suppressCompactWarning()
// 缓存微压缩的全局状态里存着之前几轮注册的工具 id。我们刚刚清空了其中一些的内容,
// 并且通过改变提示词内容使服务端缓存失效了。如果下一轮缓存微压缩带着过期状态运行,
// 它会试图去 cache_edit 那些服务端已经不存在的条目。所以重置它。
resetMicrocompactState()

// 我们刚改了提示词内容 —— 下一次响应的缓存读取量会很低,但这是我们自己造成的,
// 不是缓存断裂。告诉检测器预期会有一次下跌。
if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) {
  notifyCacheDeletion(querySource)
}

最后那个 notifyCacheDeletion 指向第 12 章会讲的缓存断裂检测系统 —— 它监控「缓存命中率突然下跌」并告警。而这里是一次「合法的下跌」,所以要主动通知它别误报。

6.4 第 ⑤ 级:自动摘要压缩

阈值

export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000   // 警告线缓冲
export const ERROR_THRESHOLD_BUFFER_TOKENS   = 20_000   // 错误线缓冲

const threshold        = getAutoCompactThreshold(model)
const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS
const errorThreshold   = threshold - ERROR_THRESHOLD_BUFFER_TOKENS

压缩是一次「分叉子智能体」调用

压缩本身要调模型做摘要。Claude Code 用分叉子智能体跑它,并且让分叉继承父的完整工具集 —— 不是因为摘要需要工具,而是为了让缓存键匹配上、复用父已建立的缓存前缀。

这个决定带来了一个真实的生产问题,源码注释里连数字都留了:

来自源码的生产观测数据

「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.」

译:强硬的「禁用工具」前置说明。共享缓存的分叉路径继承了父的完整工具集(缓存键匹配的必要条件),而在 Sonnet 4.6 及之后的自适应思考模型上,即使有那条较弱的结尾指令,模型有时仍会尝试调用工具。由于最大轮次被设为 1,一次被拒绝的工具调用意味着完全没有文字输出 → 于是掉进流式回退分支(4.6 上发生率 2.79%,4.5 上只有 0.01%)。把这段话放在最前面并明确说明被拒的后果,避免了这次浪费掉的调用。

模型升级导致压缩功能的失败率涨了 279 倍 —— 因为新模型「更主动」了,看到工具就想用。

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.

`

claude-code/src/services/compact/prompt.ts

手法为什么有效
放在最前面原来这条指令放在结尾(源码里叫 trailer instruction),效果弱。模型对开头指令的服从度明显更高
穷举点名不说「任何工具」这种抽象表述,而是把具体工具名一个个列出来。抽象禁令容易被模型解读为「大概是指别的工具,我这个应该没关系」
说明后果「会被拒绝」「会浪费你唯一的机会」「你会任务失败」—— 明确代价比单纯说「不许」有效

两段式输出:草稿纸模式

const DETAILED_ANALYSIS_INSTRUCTION_BASE = `Before providing your final summary,
wrap your analysis in <analysis> tags to organize your thoughts and ensure
you've covered all necessary points. In your analysis process:

1. Chronologically analyze each message and section of the conversation.
   For each section thoroughly identify:
   - The user's explicit requests and intents
   - Your approach to addressing the user's requests
   - Key decisions, technical concepts and code patterns
   - Specific details like:
     - file names
     - full code snippets
     - function signatures
...`

而处理函数 formatCompactSummary()把 analysis 块整个剥掉,只把 summary 块放进上下文。源码注释:「The <analysis> block is a drafting scratchpad that formatCompactSummary() strips before the summary reaches context.」

这笔账算一下

凡是「一次生成、后续每轮都要重读」的产物,都值得用这个模式:让模型充分思考,然后只保留结论。

压缩成功后的埋点

logEvent('tengu_auto_compact_succeeded', {
  originalMessageCount:  messages.length,
  compactedMessageCount: compactionResult.summaryMessages.length +
                         compactionResult.attachments.length +
                         compactionResult.hookResults.length,
  preCompactTokenCount, postCompactTokenCount, truePostCompactTokenCount,
  compactionInputTokens:  compactionUsage?.input_tokens,
  compactionOutputTokens: compactionUsage?.output_tokens,
  compactionCacheReadTokens:     compactionUsage?.cache_read_input_tokens ?? 0,
  compactionCacheCreationTokens: compactionUsage?.cache_creation_input_tokens ?? 0,
  compactionTotalTokens: ...,
  queryChainId: ..., queryDepth: ...,
})

注意有三个「压缩后 token 数」:postCompactTokenCounttruePostCompactTokenCount。两者的区别正是前面讲的「认知偏差窗口」—— 一个是客户端估算,一个是服务端返回的真实值。把估算值和真实值都埋点上报,就能持续监控估算算法的偏差。

6.5 上下文真的超了:三级恢复瀑布

API 返回 413(上下文过长) │ 这个错误被"扣留",不吐给外部调用方(见第 3.5 节) ▼ ① collapse_drain_retry 排空所有暂存的上下文折叠 最便宜,保住细粒度 限次:上一轮的 transition ≠ collapse_drain_retry ▼ drained.committed === 0(没什么可排的) ② reactive_compact_retry 反应式全量摘要压缩 贵,但通常有效 限次:hasAttemptedReactiveCompact === false ▼ 压缩失败,或本轮已压缩过 ③ 放弃 把扣留的错误吐出去 executeStopFailureHooks() ★ 但明确不走"结束前检查"钩子

第 ③ 步为什么不能走结束钩子

「Do NOT fall through to stop hooks: the model never produced a valid response, so hooks have nothing meaningful to evaluate. Running stop hooks on prompt-too-long creates a death spiral: error → hook blocking → retry → error → … (the hook injects more tokens each cycle).」

译:不要落到结束钩子那条路:模型从来没有产出过一个有效回复,所以钩子没有任何有意义的东西可以评估。在「上下文过长」的情况下运行结束钩子会造成一个死亡螺旋:报错 → 钩子判定不合格要求重试 → 又报错 → …(每一圈钩子自己还会往上下文注入更多 token)。

体会一下这个循环的形状。「结束前质量检查」这个功能本身完全合理,但当失败原因是「上下文已经装不下了」时,它会:

  1. 看到一个失败的回复
  2. 判定不合格,生成一段「你的回答有以下问题……」的反馈
  3. 把这段反馈注入上下文 —— 上下文变得更长了
  4. 重试 → 更超了 → 又失败 → 回到第 1 步
通用规则:失败必须分类

失败路径必须能够识别「这一类失败不该触发常规的质量重试机制」。至少要区分两类:
· 「模型答得不好」 → 可以让质量检查介入、注入反馈、重试
· 「系统层面走不通了」(上下文超限、认证失败、配额耗尽)→ 必须绕过所有质量检查,直接向上报错

混在一起处理,就会得到上面那个死亡螺旋。