第 7 章:Context Compaction --- 自动压缩与记忆恢复
源码位置:src/services/compact/compact.ts、src/services/compact/autoCompact.ts
7.1 为什么需要上下文压缩?
Claude API 有上下文窗口限制(通常是 200K tokens)。在长时间的编程会话中,对话历史会不断增长:
- 代码文件读取(可能几千行)
- 工具调用结果(Bash 输出、搜索结果)
- 来回的对话消息
当上下文接近限制时,有两个选择:
- 直接截断历史(丢失重要信息)
- 智能压缩:让 Claude 总结之前的对话,保留关键信息
Claude Code 选择了方案 2,并实现了多层次自动触发机制。
7.2 压缩类型
| 类型 | 文件 | 触发方式 |
|---|---|---|
| 主压缩(Full Compact) | compact.ts |
手动 /compact 或自动触发 |
| 自动压缩(Auto Compact) | autoCompact.ts |
Token 用量达到阈值时自动触发 |
| 微压缩(Micro Compact) | microCompact.ts |
单个大型工具结果的增量压缩 |
| API 上下文管理 | apiMicrocompact.ts |
利用 API beta 功能 |
| 反应式压缩(Reactive Compact) | reactiveCompact.ts |
max_output_tokens 错误后触发(功能门控) |
| 会话记忆压缩 | sessionMemoryCompact.ts |
自动压缩前优先尝试;基于跨会话长期记忆 |
7.3 核心常量与恢复预算
源码位置:src/services/compact/compact.ts:122-131
typescript
// compact 后恢复文件数量上限
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
// compact 后文件恢复总 token 预算
export const POST_COMPACT_TOKEN_BUDGET = 50_000
// 每个文件最多恢复 5K token
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000
// 每个技能最多恢复 5K token
// 注:verify=18.7KB, claude-api=20.1KB;之前无限制导致每次 compact 5-10K token
// 截断优于丢弃------技能文件头部是最重要的指令部分
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000
// 技能恢复总 token 预算(约 5 个技能)
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000
// 压缩流式请求最大重试次数(PTL 重试)
const MAX_PTL_RETRIES = 3
7.4 阈值计算体系(4 个层次)
源码位置:src/services/compact/autoCompact.ts
Auto Compact 引入了 4 个不同层次的 token 警戒线,全部在 calculateTokenWarningState() 中统一计算:
typescript
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 // 阻塞限制缓冲
有效上下文窗口(effectiveContextWindow)
scss
effectiveContextWindow
= min(contextWindow, CLAUDE_CODE_AUTO_COMPACT_WINDOW)
- min(getMaxOutputTokensForModel(model), 20_000)
预留 min(maxOutput, 20_000) tokens 给摘要输出(基于 p99.99 实测最大摘要为 17,387 tokens)。
4 个阈值
| 阈值 | 公式 | 含义 |
|---|---|---|
autoCompactThreshold |
effectiveWindow - 13_000 |
触发自动压缩 |
warningThreshold |
threshold - 20_000 |
显示橙色百分比警告 |
errorThreshold |
threshold - 20_000 |
显示红色错误状态(与 warning 相同,行为不同) |
blockingLimit |
effectiveWindow - 3_000 |
完全阻塞新输入 |
注:
threshold在calculateTokenWarningState内部的值:启用 auto compact 时 =autoCompactThreshold;禁用时 =effectiveContextWindow。
calculateTokenWarningState 的签名
typescript
// src/services/compact/autoCompact.ts
export function calculateTokenWarningState(
tokenUsage: number, // 当前 token 数量(不是 messages 数组)
model: string, // 模型名称(用于查询上下文窗口大小)
): {
percentLeft: number // 剩余百分比(0-100)
isAboveWarningThreshold: boolean // 是否超过警告线
isAboveErrorThreshold: boolean // 是否超过错误线
isAboveAutoCompactThreshold: boolean // 是否触发自动压缩
isAtBlockingLimit: boolean // 是否达到阻塞限制
}
注意 :签名是 (tokenUsage: number, model: string) 而非 (messages, contextLimit),返回值是 5 个布尔字段的对象而非 'warning' | 'critical' | null。
getAutoCompactThreshold 也接受 model 参数:
typescript
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
const autocompactThreshold = effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
// 支持环境变量 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 覆盖(用于测试)
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
if (envPercent) {
const percentageThreshold = Math.floor(effectiveContextWindow * (parsed / 100))
return Math.min(percentageThreshold, autocompactThreshold)
}
return autocompactThreshold
}
7.5 isAutoCompactEnabled 与 shouldAutoCompact
isAutoCompactEnabled()
typescript
export function isAutoCompactEnabled(): boolean {
if (isEnvTruthy(process.env.DISABLE_COMPACT)) return false
// 只禁用自动压缩,保留手动 /compact
if (isEnvTruthy(process.env.DISABLE_AUTO_COMPACT)) return false
return getGlobalConfig().autoCompactEnabled
}
检查顺序:DISABLE_COMPACT → DISABLE_AUTO_COMPACT → 用户配置。
shouldAutoCompact() 的递归防护
源码位置:src/services/compact/autoCompact.ts:160-238
压缩本身也会启动 forked agent(querySource 为 'compact'),如果允许在这些子调用中再次触发压缩,将导致死锁。因此 shouldAutoCompact() 设置了多个递归防护:
typescript
export async function shouldAutoCompact(
messages: Message[],
model: string,
querySource?: QuerySource,
snipTokensFreed = 0,
): Promise<boolean> {
// 防护 1:自身递归(compact 的 forked agent 不能再触发 compact)
if (querySource === 'session_memory' || querySource === 'compact') {
return false
}
// 防护 2:feature gate - marble_origami (ctx-agent) 触发会破坏主线程
if (feature('CONTEXT_COLLAPSE') && querySource === 'marble_origami') {
return false
}
// 防护 3:feature gate - 反应式压缩模式下禁止主动压缩
if (feature('REACTIVE_COMPACT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) {
return false
}
// 防护 4:Context Collapse 模式已经管理上下文,不应与 autocompact 竞争
if (feature('CONTEXT_COLLAPSE') && isContextCollapseEnabled()) {
return false
}
if (!isAutoCompactEnabled()) return false
const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
const { isAboveAutoCompactThreshold } = calculateTokenWarningState(tokenCount, model)
return isAboveAutoCompactThreshold
}
7.6 autoCompactIfNeeded:熔断器 + Session Memory 优先
源码位置:src/services/compact/autoCompact.ts:241-351
熔断器(Circuit Breaker)
typescript
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
// 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.
当上下文不可恢复地超过限制(如 prompt_too_long),每轮都重试只会浪费 API 调用。熔断器在连续失败 3 次后停止尝试,通过 AutoCompactTrackingState.consecutiveFailures 在 query loop 轮次间传递状态。
完整执行流程
typescript
export async function autoCompactIfNeeded(
messages,
toolUseContext,
cacheSafeParams,
querySource?,
tracking?,
snipTokensFreed?,
): Promise<{ wasCompacted: boolean; compactionResult?: CompactionResult; consecutiveFailures?: number }>
执行顺序:
scss
1. 检查 DISABLE_COMPACT 环境变量
2. 检查熔断器(consecutiveFailures >= 3 → 跳过)
3. shouldAutoCompact() --- 递归防护 + token 检查
4. 优先尝试 trySessionMemoryCompaction() ← 先用会话记忆压缩
├─ 成功 → notifyCompaction() + markPostCompaction() + return
└─ 失败 → 继续主压缩
5. compactConversation() ← 主压缩
├─ 成功 → consecutiveFailures: 0(重置)
└─ 失败 → consecutiveFailures: prevFailures + 1
关键设计:Session Memory Compact 在 Full Compact 之前尝试,因为 Session Memory 是增量裁剪而不是全量重写,对 prompt cache 更友好。
7.7 主压缩函数:compactConversation
源码位置:src/services/compact/compact.ts:387-763
注意 :compactConversation 是普通的 async function,返回 Promise<CompactionResult>,不是 async generator。
typescript
export async function compactConversation(
messages: Message[],
context: ToolUseContext,
cacheSafeParams: CacheSafeParams,
suppressFollowUpQuestions: boolean,
customInstructions?: string,
isAutoCompact: boolean = false,
recompactionInfo?: RecompactionInfo,
): Promise<CompactionResult>
CompactionResult 接口
typescript
export interface CompactionResult {
boundaryMarker: SystemMessage // compact 边界消息
summaryMessages: UserMessage[] // 摘要消息(1条)
attachments: AttachmentMessage[] // 恢复的附件
hookResults: HookResultMessage[] // SessionStart hook 结果
messagesToKeep?: Message[] // 保留的原始消息(reactive/SM compact 用)
userDisplayMessage?: string // 显示给用户的消息
preCompactTokenCount?: number
postCompactTokenCount?: number // 实为 compact API 调用的总消耗(非结果大小)
truePostCompactTokenCount?: number // 压缩后消息的实际 token 估计
compactionUsage?: ReturnType<typeof getTokenUsage>
}
执行步骤详解
源码位置:src/services/compact/compact.ts:395-762
css
步骤 1: 执行 PreCompact hooks
├─ hookResult.newCustomInstructions 合并到 customInstructions
└─ hookResult.userDisplayMessage 保存备用
步骤 2: stripImagesFromMessages(messages)
└─ 替换图像为 [image] 文本占位符,避免压缩请求自身触发 prompt-too-long
步骤 3: stripReinjectedAttachments(messages)
└─ 过滤 skill_discovery/skill_listing 附件(压缩后会重新注入,无需送入摘要)
步骤 4: PTL 重试循环(最多 MAX_PTL_RETRIES = 3 次)
├─ streamCompactSummary() --- 流式调用 Claude 生成摘要
├─ 若摘要以 PROMPT_TOO_LONG_ERROR_MESSAGE 开头:
│ └─ truncateHeadForPTLRetry() 截断最旧的 API 轮次 → 重试
└─ 超过重试次数 → throw ERROR_MESSAGE_PROMPT_TOO_LONG
步骤 5: 保存 preCompactReadFileState 快照
└─ preCompactReadFileState = cacheToObject(context.readFileState)
!! 必须在 readFileState.clear() 之前保存 !!
步骤 6: 清除状态缓存
├─ context.readFileState.clear()
├─ context.loadedNestedMemoryPaths?.clear()
└─ sentSkillNames 故意不重置(重注入收益极低,且会产生 cache_creation 浪费)
步骤 7: 并行构建附件
├─ createPostCompactFileAttachments(preCompactReadFileState, context, 5)
│ └─ 使用步骤 5 的快照,最多 5 个文件,各 ≤5K token
└─ createAsyncAgentAttachmentsIfNeeded(context)
步骤 8: 构建额外附件
├─ createPlanAttachmentIfNeeded() --- 当前计划
├─ createPlanModeAttachmentIfNeeded() --- Plan 模式指令
├─ createSkillAttachmentIfNeeded() --- 已调用技能
├─ getDeferredToolsDeltaAttachment(tools, model, [], ...) --- Deferred 工具(diff against [])
├─ getAgentListingDeltaAttachment(context, []) --- Agent 列表(diff against [])
└─ getMcpInstructionsDeltaAttachment(mcpClients, tools, model, []) --- MCP 指令(diff against [])
注:传入空 [] 作为 history,意味着 delta = "全部重新宣告",确保压缩后模型知道所有工具
步骤 9: 执行 SessionStart hooks(trigger='compact')
└─ processSessionStartHooks('compact', { model }) → hookMessages
步骤 10: 创建 boundaryMarker
├─ createCompactBoundaryMessage(isAutoCompact ? 'auto' : 'manual', preCompactTokenCount, lastMsgUuid)
└─ 提取 preCompactDiscoveredTools(已加载的 Deferred 工具名列表)写入 compactMetadata
确保压缩后 schema filter 仍然正确发送已加载的 Deferred 工具 schema
步骤 11: notifyCompaction() + markPostCompaction() + reAppendSessionMetadata()
└─ 重置 prompt cache 基线,避免压缩后的缓存变化被误报为 cache break
步骤 12: 执行 PostCompact hooks
└─ executePostCompactHooks({ trigger, compactSummary }, signal)
返回 CompactionResult
7.8 buildPostCompactMessages
源码位置:src/services/compact/compact.ts:330-337
typescript
export function buildPostCompactMessages(result: CompactionResult): Message[] {
return [
result.boundaryMarker,
...result.summaryMessages,
...(result.messagesToKeep ?? []),
...result.attachments,
...result.hookResults,
]
}
注意 :这是一个非常简单的函数,只是将 CompactionResult 的各字段按固定顺序组装成消息数组。复杂的恢复逻辑(文件、技能、delta 附件)发生在 compactConversation() 内部,结果已包含在 attachments 字段里。
顺序:boundaryMarker → summaryMessages → messagesToKeep → attachments → hookResults
7.9 图像移除(stripImagesFromMessages)
源码位置:src/services/compact/compact.ts:145-200
typescript
export function stripImagesFromMessages(messages: Message[]): Message[] {
return messages.map(message => {
if (message.type !== 'user') return message // 只处理 user 消息
const content = message.message.content
if (!Array.isArray(content)) return message
let hasMediaBlock = false
const newContent = content.flatMap(block => {
if (block.type === 'image') {
hasMediaBlock = true
return [{ type: 'text', text: '[image]' }]
}
if (block.type === 'document') {
hasMediaBlock = true
return [{ type: 'text', text: '[document]' }]
}
// 处理 tool_result 内部嵌套的图像
if (block.type === 'tool_result' && Array.isArray(block.content)) {
// ... 同样替换为 '[image]' / '[document]'
}
return [block]
})
if (!hasMediaBlock) return message
return { ...message, message: { ...message.message, content: newContent } }
})
}
为什么要移除图像? CCD(Claude.ai 桌面)用户频繁附加图像。如果压缩 API 调用本身包含太多图像,会触发 prompt-too-long 错误。移除图像后只保留文本占位符,仍能让 Claude 知道"那里有过图像"。
7.10 stripReinjectedAttachments
源码位置:src/services/compact/compact.ts:211-223
typescript
export function stripReinjectedAttachments(messages: Message[]): Message[] {
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
return messages.filter(
m =>
!(
m.type === 'attachment' &&
(m.attachment.type === 'skill_discovery' ||
m.attachment.type === 'skill_listing')
),
)
}
return messages
}
skill_discovery 和 skill_listing 附件在压缩后会通过下一轮的 delta 机制重新注入,无需送入摘要 prompt 浪费 token。
7.11 PTL 重试机制(truncateHeadForPTLRetry)
源码位置:src/services/compact/compact.ts:243-291
当压缩 API 请求本身触发 prompt-too-long 时(CC-1180 场景),不能直接报错让用户卡住,需要自动重试:
sql
最多重试 MAX_PTL_RETRIES = 3 次
│
▼
truncateHeadForPTLRetry(messagesToSummarize, summaryResponse)
├─ 解析 PTL 错误响应中的 tokenGap
├─ 按 tokenGap 累积丢弃最旧的 API-round groups
│ └─ 若无法解析 gap → 按比例丢弃 20% groups
├─ 保留至少 1 个 group(避免没有内容可摘要)
└─ 若裁剪后首条是 assistant 消息 → 在头部插入 PTL_RETRY_MARKER user 消息
(API 要求第一条消息角色为 user)
连续重试时会先检测并移除上次插入的 PTL_RETRY_MARKER,避免虚假的 group 0 影响统计
这是"有损但能解锁用户"的最后逃生路线,丢失最旧上下文但总比完全卡死强。
7.12 微压缩(Micro Compact)
源码位置:src/services/compact/microCompact.ts:41-50
微压缩处理单个大型工具结果的就地压缩,不需要重写整个对话历史。只针对特定工具:
typescript
const COMPACTABLE_TOOLS = new Set<string>([
FILE_READ_TOOL_NAME, // Read
...SHELL_TOOL_NAMES, // Bash / Shell
GREP_TOOL_NAME, // Grep
GLOB_TOOL_NAME, // Glob
WEB_SEARCH_TOOL_NAME, // WebSearch
WEB_FETCH_TOOL_NAME, // WebFetch
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
])
对于每个符合条件的工具调用结果,若其 token 数量超过阈值,微压缩会将其内容替换为 [Old tool result content cleared],释放空间而不影响对话结构。
7.13 Session Memory Compact
源码位置:src/services/compact/sessionMemoryCompact.ts
Session Memory Compact 是 Full Compact 的前置候选。autoCompactIfNeeded() 在调用 compactConversation() 之前先尝试 trySessionMemoryCompaction():
- 区别:Session Memory 是增量裁剪(删除可摘要的旧消息),而不是全量重写
- 对 prompt cache 更友好:保留前缀结构,cache miss 更少
- 失败时降级:Session Memory 压缩不足,再走完整 Full Compact
- 后处理一致 :两者都会调用
setLastSummarizedMessageId(undefined)+runPostCompactCleanup()+notifyCompaction()
7.14 Hook 集成
压缩过程有三个 hook 点:
typescript
// PreCompact Hook:压缩前
await executePreCompactHooks({ trigger: 'auto' | 'manual', customInstructions }, signal)
// 用户可以:
// - 注入额外的"不要压缩这些信息"指令(newCustomInstructions)
// - 记录压缩发生
// - 注入用户显示消息(userDisplayMessage)
// SessionStart Hook:摘要生成后,boundaryMarker 创建前
await processSessionStartHooks('compact', { model })
// 结果作为 hookMessages 写入 CompactionResult.hookResults
// PostCompact Hook:压缩完成后
await executePostCompactHooks({ trigger, compactSummary }, signal)
// 用户可以:
// - 注入恢复信息(userDisplayMessage)
// - 通知外部系统
7.15 流程图:Auto Compaction 完整流程
scss
Agent Loop 轮次结束
│
▼
autoCompactIfNeeded()
│
├─ DISABLE_COMPACT=true → 跳过
├─ consecutiveFailures >= 3 → 熔断器跳过
│
▼
shouldAutoCompact()
│
├─ querySource in [session_memory, compact, marble_origami] → false
├─ !isAutoCompactEnabled() → false
├─ tokenCount < autoCompactThreshold → false
└─ true ↓
│
▼
trySessionMemoryCompaction()
│
┌────┴─────┐
成功 失败
│ │
▼ ▼
notifyCompaction compactConversation()
markPostCompaction │
return wasCompacted ▼
[PreCompact hooks]
│
stripImagesFromMessages()
stripReinjectedAttachments()
│
[PTL 重试循环 ≤3次]
streamCompactSummary()
│
preCompactReadFileState = snapshot()
readFileState.clear()
│
[并行] createPostCompactFileAttachments
createAsyncAgentAttachments
│
[串行] plan/planMode/skill/deferred/agent/mcp 附件
│
processSessionStartHooks('compact')
│
createCompactBoundaryMessage()
├─ 写入 preCompactDiscoveredTools
│
notifyCompaction()
markPostCompaction()
reAppendSessionMetadata()
│
[PostCompact hooks]
│
return CompactionResult
│
├─ 成功: consecutiveFailures = 0
└─ 失败: consecutiveFailures++
│
▼
继续 Agent Loop
小结
| 机制 | 触发条件 | 核心操作 | 源码位置 |
|---|---|---|---|
| Auto Compact 判断 | 每轮 agent loop | calculateTokenWarningState(tokenUsage, model) |
autoCompact.ts |
| 熔断器 | 连续失败 ≥ 3 次 | 停止重试,避免 API 浪费 | autoCompact.ts:70 |
| Session Memory Compact | 优先于 Full Compact | 增量裁剪,cache 友好 | sessionMemoryCompact.ts |
| 主压缩 | 手动或自动 | compactConversation() 普通 async 函数 |
compact.ts:387 |
| PTL 重试 | compact 自身触发 prompt-too-long | truncateHeadForPTLRetry() ≤3 次 |
compact.ts:243 |
| 图像移除 | 压缩前 | 替换 image/document 为文本占位符 | stripImagesFromMessages() |
| 附件移除 | 压缩前 | 过滤 skill_discovery/listing | stripReinjectedAttachments() |
| 文件恢复 | 压缩后 | 最多 5 个文件,各 ≤5K token;使用压缩前快照 | createPostCompactFileAttachments() |
| sentSkillNames | 压缩后 | 故意不重置,避免 cache_creation 浪费 | compact.ts:526-530 |
| Delta 附件重注入 | 压缩后 | deferred/agent/mcp 以 [] 为基准全量宣告 |
compact.ts:567-585 |
| preCompactDiscoveredTools | boundaryMarker | 持久化已加载 Deferred 工具列表 | compact.ts:606-611 |
| 微压缩 | 单个大结果过大 | 就地替换为占位文本 | microCompact.ts |
| Pre/Post Hook | 压缩前后 | 用户自定义扩展 | hooks.ts |