Claude Code Harness 第11章:微压缩——精准上下文修剪
前言:在压缩之外寻找第三条路
在前几章中,我们探讨了两种极端的上下文管理策略:
- 保持完整上下文:保留所有历史消息,直到触及上下文窗口上限
- 自动压缩:当上下文接近满时,调用 LLM 将整个对话浓缩为摘要
这两种策略各有代价:完整上下文会迅速消耗 token 预算,而自动压缩虽然释放空间,但需要一次完整的 LLM 调用来生成摘要,并且会永久丢失原始对话的细节。
微压缩(Micro-Compression)是介于两者之间的第三条路。它不生成摘要,不调用 LLM,而是直接清除或删除那些对模型当前推理任务已经"过时"的工具调用结果。
想象这样一个场景:你在上午 10 点用 Claude Code 完成了一次复杂的重构,过程中使用了 50 次 grep 搜索、30 次 cat 读取文件、20 次 bash 命令执行。这些工具结果占据了数十万 tokens。下午 1 点你回来继续工作——这时候,上午的搜索结果和文件内容对模型来说已经"过时"了。与其让它们占据宝贵的上下文空间,不如精准地移除它们。
微压缩的核心哲学是:The cheapest token is the one you never send(最便宜的 token 是你从未发送的)。
本章将深入解析 Claude Code 实现的三种微压缩机制:
- 基于时间的微压缩:当长时间离开后返回时,批量清除旧工具结果
- 缓存微压缩:使用
cache_editsAPI 在不破坏缓存的前提下精准删除 - API Context Management:声明式的服务端上下文管理策略
让我们从源码出发,理解这个精妙的修剪系统。
11.1 微压缩的触发时机:三种机制的协同
在深入每种机制之前,我们需要理解它们在整体架构中的位置和触发时机。下图展示了微压缩系统在 API 调用流程中的位置:
flowchart TD
Start["用户输入消息"] --> PreProcess["消息预处理"]
PreProcess --> MicrocompactCheck{"微压缩检查"}
MicrocompactCheck --> TimeBased{"时间触发
gap > 60分钟?"}
TimeBased -->|是| TimeMC["基于时间的微压缩
修改消息内容
破坏缓存前缀"]
TimeBased -->|否| CachedMCCheck{"缓存微压缩
工具数 > 阈值?"}
CachedMCCheck -->|是| CachedMC["缓存微压缩
生成 cache_edits
保持缓存前缀"]
CachedMCCheck -->|否| NoMC["不执行微压缩"]
TimeMC --> APIContextCheck["API Context Management
声明式策略"]
CachedMC --> APIContextCheck
NoMC --> APIContextCheck
APIContextCheck --> BuildRequest["构建 API 请求"]
BuildRequest --> AddCacheEdits{"插入 cache_edits?"}
AddCacheEdits -->|有待发送编辑| InsertEdits["插入到消息数组"]
AddCacheEdits -->|无| AddContextMgmt["添加 context_management"]
InsertEdits --> AddContextMgmt
AddContextMgmt --> APICall["调用 Anthropic API"]
APICall --> Response["API 响应"]
Response --> CacheBreakCheck{"缓存中断检测"}
CacheBreakCheck -->|cache_read 下降 >5%| CacheBreak["记录缓存中断事件"}
CacheBreakCheck -->|正常| Complete["完成"]
CacheBreak --> Complete这个流程图展示了关键设计决策:
- 优先级排序:时间触发优先执行,因为它会破坏缓存前缀
- 互斥机制:时间触发短路,不会同时执行缓存微压缩
- 声明式层:API Context Management 始终存在,作为额外的服务端保护层
- 副作用协调:所有微压缩操作都会通知缓存中断检测器,防止误报
11.2 基于时间的微压缩:缓存过期后的批量清理
11.2.1 设计直觉:缓存失效 = 重写机会
基于时间的微压缩(Time-based Microcompact)的核心直觉来自服务端 prompt cache 的 TTL 机制:
- 标准 TTL:5 分钟
- 扩展 TTL:1 小时
当用户离开超过 1 小时后再返回,服务端缓存已经失效。这意味着下一次 API 调用会将完整的对话历史重新写入缓存——每一个 token 都要重新计费为 cache creation。
基于时间的微压缩的逻辑因此非常自然:既然缓存已经过期,整个前缀都要重写,那不如先把不需要的旧内容清掉,让重写的内容更小更便宜。
11.2.2 配置参数:GrowthBook 功能开关
配置通过 GrowthBook 功能开关 tengu_slate_heron 下发,类型定义在 services/compact/timeBasedMCConfig.ts:
export type TimeBasedMCConfig = {
/** 主开关。当为 false 时,基于时间的微压缩不执行 */
enabled: boolean
/** 当(当前时间 - 上次助手消息时间)超过此分钟数时触发 */
gapThresholdMinutes: number
/** 保留最近 N 个可压缩工具结果 */
keepRecent: number
}
const TIME_BASED_MC_CONFIG_DEFAULTS: TimeBasedMCConfig = {
enabled: false, // 默认关闭,通过 GrowthBook 灰度发布
gapThresholdMinutes: 60, // 对齐服务端 1 小时 cache TTL
keepRecent: 5, // 保留最近 5 个工具结果
}三个参数的设计考量:
enabled默认关闭:这是一个需要灰度发布的实验性特性gapThresholdMinutes: 60:选择 1 小时是"安全选择"——源码注释明确说明:"the server's 1h cache TTL is guaranteed expired for all users, so we never force a miss that wouldn't have happened"keepRecent: 5:保留最近 5 个工具结果,为模型提供最小工作上下文,避免完全清空导致模型"失忆"
11.2.3 触发判定:evaluateTimeBasedTrigger
触发判定是一个纯函数,不产生副作用。位于 services/compact/microCompact.ts:422-444:
export function evaluateTimeBasedTrigger(
messages: Message[],
querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
// 1. 获取配置
const config = getTimeBasedMCConfig()
// 2. 检查前置条件
if (!config.enabled || !querySource || !isMainThreadSource(querySource)) {
return null
}
// 3. 找到最后一条助手消息
const lastAssistant = messages.findLast(m => m.type === 'assistant')
if (!lastAssistant) {
return null
}
// 4. 计算时间差(分钟)
const gapMinutes =
(Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
// 5. 检查是否超过阈值
if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
return null
}
return { gapMinutes, config }
}注意第 428 行的守卫条件:!querySource 时直接返回 null。源码注释解释了原因:
"/context、/compact 等分析性调用会在不带 source 的情况下调用 microcompactMessages(),它们不应该触发时间清理。"
这是一个重要的防御性设计:分析性工具调用不应该触发微压缩,因为它们只是查看对话状态,而非真正的用户对话轮次。
11.2.4 执行逻辑:不可变消息修改
当触发条件满足时,maybeTimeBasedMicrocompact() 执行清理操作。核心逻辑分为三步:
flowchart TD
A["maybeTimeBasedMicrocompact"] --> B["collectCompactableToolIds
收集所有可压缩工具 ID"]
B --> C["keepRecent = Math.max1, config.keepRecent
至少保留 1 个"]
C --> D["keepSet = compactableIds.slice-keepRecent
保留最近 N 个"]
D --> E["clearSet = 其余全部清除"]
E --> F["遍历 messages"]
F --> G{"block.type == tool_result
&& clearSet.hasid"}
G -->|是| H["替换 content 为
TIME_BASED_MC_CLEARED_MESSAGE"]
G -->|否| I["保持原样"]
H --> J["累加 tokensSaved"]
I --> J
J --> K["返回新的 messages 数组"]关键实现细节(microCompact.ts:470-492):
let tokensSaved = 0
const result: Message[] = messages.map(message => {
// 1. 只处理 user 消息
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return message
}
let touched = false
// 2. 遍历消息内容块
const newContent = message.message.content.map(block => {
// 3. 检查是否是需要清除的工具结果
if (
block.type === 'tool_result' &&
clearSet.has(block.tool_use_id) &&
block.content !== TIME_BASED_MC_CLEARED_MESSAGE // 幂等性守卫
) {
tokensSaved += calculateToolResultTokens(block)
touched = true
return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
}
return block
})
// 4. 如果有修改,创建新的消息对象
if (!touched) return message
return {
...message,
message: { ...message.message, content: newContent },
}
})这个设计有几个精妙之处:
- 不可变修改:使用
map+ 展开运算符创建新的消息数组,而不是原地修改。这确保了如果微压缩逻辑有 bug,原始消息不会被污染 - 幂等性保证:第 479 行的
block.content !== TIME_BASED_MC_CLEARED_MESSAGE守卫防止对已清除的内容重复计算tokensSaved - 精确 token 统计:
calculateToolResultTokens()处理不同类型的内容(文本、图片、文档),提供准确的节省量
11.2.5 副作用链:三个重要通知
时间触发执行完毕后,会产生三个重要的副作用:
flowchart LR
TimeMC["时间微压缩执行"] --> Suppress["suppressCompactWarning
抑制上下文压力警告"]
TimeMC --> Reset["resetMicrocompactState
重置缓存 MC 状态"]
TimeMC --> Notify["notifyCacheDeletion
通知缓存中断检测器"]
Suppress --> Effect1["用户不会看到
'上下文即将满'警告"]
Reset --> Effect2["缓存 MC 的工具注册状态清空
防止脏数据"]
Notify --> Effect3["下一次 API 响应的
cache_read 下降不会误报"]第三个副作用特别微妙。源码注释(microCompact.ts:520-522)解释了为什么使用 notifyCacheDeletion 而不是 notifyCompaction:
"notifyCacheDeletion (not notifyCompaction) because it's already imported here and achieves the same false-positive suppression — adding the second symbol to the import was flagged by the circular-deps check."
这是循环依赖约束下的务实选择:两个函数的效果相同(都防止误报),但引入额外的 import symbol 会触发循环依赖检测。这种务实的妥协在大型代码库中很常见——完美的模块边界让位于构建系统的约束。
11.3 缓存微压缩:不破坏缓存的精准手术
11.3.1 核心挑战:缓存前缀的连续性
时间触发的微压缩有一个本质局限:它必须修改消息内容,这意味着缓存前缀被改变,下一次 API 调用会产生完整的 cache creation 费用。
在实时会话中,这是不可接受的——你刚积累的缓存前缀可能价值数万 tokens 的 cache creation 费用。如果每次微压缩都破坏缓存,那就失去了"微"的意义。
缓存微压缩通过 Anthropic API 的 cache_edits 特性解决了这个问题:
- 不修改本地消息内容
- 向 API 发送"在服务端缓存中删除指定工具结果"的指令
- 服务端在缓存前缀中原地移除这些内容,保持前缀的连续性
11.3.2 cache_edits 工作原理:完整生命周期
以下序列图展示了缓存微压缩的完整生命周期:
sequenceDiagram
participant MC as microCompact.ts
participant State as CachedMCState
participant API as claude.ts
participant Server as Anthropic API
Note over MC,State: 第一阶段:工具注册
MC->>State: registerToolResult(toolId)
MC->>State: registerToolMessage(groupIds)
MC->>State: getToolResultsToDelete()
Note over MC,API: 第二阶段:创建编辑指令
MC->>MC: createCacheEditsBlock(state, toolsToDelete)
MC->>MC: pendingCacheEdits = cacheEdits
Note over API,Server: 第三阶段:发送 API 请求
API->>MC: consumePendingCacheEdits()
MC-->>API: cacheEdits
API->>API: getPinnedCacheEdits()
API->>API: addCacheBreakpoints(messages, cacheEdits)
API->>API: 为 tool_result 添加 cache_reference
API->>Server: POST /v1/messages
包含 cache_edits block
Note over Server: 第四阶段:服务端处理
Server->>Server: 在缓存中删除对应的 tool_result
Server->>Server: 缓存前缀保持连续
Server-->>API: Response
cache_deleted_input_tokens
Note over MC,State: 第五阶段:状态更新
API->>MC: markToolsSentToAPIState()
API->>API: pinCacheEdits(userMessageIndex, cacheEdits)让我们逐步拆解这个流程。
11.3.3 工具注册与阈值判定
cachedMicrocompactPath() 函数(microCompact.ts:305-399)首先扫描所有消息,注册可压缩的工具结果:
// 1. 收集所有可压缩工具的 tool_use ID
const compactableToolIds = new Set(collectCompactableToolIds(messages))
// 2. 按用户消息分组注册 tool_result
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. 获取需要删除的工具列表
const toolsToDelete = mod.getToolResultsToDelete(state)注册分两步:
- collectCompactableToolIds() 先从 assistant 消息中收集所有属于可压缩工具集的
tool_useID - 在 user 消息中找到对应的
tool_result,按消息分组注册
分组是因为 cache_edits 的删除粒度是单个 tool_result,但触发判定基于工具总数。
11.3.4 cache_edits block 的创建与消费
当有工具需要删除时,代码创建一个 CacheEditsBlock 并存入模块级变量 pendingCacheEdits:
if (toolsToDelete.length > 0) {
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
if (cacheEdits) {
pendingCacheEdits = cacheEdits // 存入模块级变量
}
}这个 pendingCacheEdits 变量的消费者是 API 层的 claude.ts。在构建 API 请求参数前(claude.ts:1531),代码调用 consumePendingCacheEdits() 一次性取出待发送的编辑指令:
const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null
const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : []consumePendingCacheEdits() 的设计是单次消费(microCompact.ts:88-94):调用后立即清空 pendingCacheEdits。源码注释(claude.ts:1528-1530)解释了为什么不能在 paramsFromContext 内部消费:
"paramsFromContext is called multiple times (logging, retries), so consuming inside it would cause the first call to steal edits from subsequent calls."
这是一个重要的单次消费语义设计,防止在 API 重试场景下的重复消费。
11.3.5 在 API 请求中插入 cache_edits
addCacheBreakpoints() 函数(claude.ts:3063-3162)负责将 cache_edits 指令织入消息数组。核心逻辑分三步:
第一步:重新插入已固定的编辑
for (const pinned of pinnedEdits ?? []) {
const msg = result[pinned.userMessageIndex]
if (msg && msg.role === 'user') {
const dedupedBlock = deduplicateEdits(pinned.block)
if (dedupedBlock.edits.length > 0) {
insertBlockAfterToolResults(msg.content, dedupedBlock)
}
}
}每一轮 API 调用,之前已发送过的 cache_edits 必须在相同位置重新发送——服务端需要看到完整一致的编辑历史才能正确重建缓存前缀。这就是 pinnedEdits 的作用。
第二步:插入新的编辑
新的 cache_edits block 被插入到最后一个 user 消息中,然后通过 pinCacheEdits(i, newCacheEdits) 固定位置索引,确保后续调用在同一位置重复发送。
第三步:去重
deduplicateEdits() 辅助函数(claude.ts:3116-3125)使用 seenDeleteRefs Set 确保同一个 cache_reference 不会在多个 block 中重复出现:
const seenDeleteRefs = new Set()
const deduplicateEdits = (block: CachedMCEditsBlock): CachedMCEditsBlock => {
const uniqueEdits = block.edits.filter(edit => {
if (seenDeleteRefs.has(edit.cache_reference)) {
return false // 去重
}
seenDeleteRefs.add(edit.cache_reference)
return true
})
return { type: 'cache_edits', edits: uniqueEdits }
} 这防止了一种边缘情况:同一个工具结果在不同轮次被标记为待删除。
11.3.6 cache_edits 数据结构
在 API 层,cache_edits block 的类型定义(claude.ts:3052-3055)非常简洁:
type CachedMCEditsBlock = {
type: 'cache_edits'
edits: { type: 'delete'; cache_reference: string }[]
}每个 edit 是一个 delete 操作,指向一个 cache_reference——这是服务端为每个 tool_result 分配的唯一标识符。客户端在之前的 API 响应中获取这些引用,然后在后续请求中引用它们来指定要删除的内容。
11.3.7 baseline 与 delta 追踪
cachedMicrocompactPath() 在返回结果时,记录了一个 baselineCacheDeletedTokens 值(microCompact.ts:374-383):
const lastAsst = messages.findLast(m => m.type === 'assistant')
const baseline =
lastAsst?.type === 'assistant'
? ((lastAsst.message.usage as unknown as Record)
?.cache_deleted_input_tokens ?? 0)
: 0 API 返回的 cache_deleted_input_tokens 是一个累积值——它包含本次会话中所有 cache_edits 操作删除的总 token 数。为了计算当前操作的实际 delta,需要记录操作前的 baseline,然后用 API 响应中的新累积值减去它。
这个设计避免了在客户端做不精确的 token 估算。
11.4 API Context Management:声明式上下文管理
11.4.1 从命令式到声明式
前两种微压缩机制都是命令式的——客户端决定删除哪些工具、何时删除、怎么删除。API Context Management 则是声明式的:客户端只需描述"当上下文超过 X tokens 时,清除 Y 类型的内容,保留最近 Z 个",API 服务端自动执行。
这段逻辑位于 services/compact/apiMicrocompact.ts,函数 getAPIContextManagement() 构建一个 ContextManagementConfig 对象,随 API 请求一起发送:
export type ContextManagementConfig = {
edits: ContextEditStrategy[]
}11.4.2 两种策略类型
ContextEditStrategy 联合类型定义了两种服务端可执行的编辑策略:
策略一:clear_tool_uses_20250919——清除工具使用
| {
type: 'clear_tool_uses_20250919'
trigger?: {
type: 'input_tokens'
value: number // 当 input tokens 超过此值时触发
}
keep?: {
type: 'tool_uses'
value: number // 保留最近 N 个工具使用
}
clear_tool_inputs?: boolean | string[] // 清除哪些工具的输入
exclude_tools?: string[] // 排除哪些工具
clear_at_least?: {
type: 'input_tokens'
value: number // 至少清除这么多 tokens
}
}策略二:clear_thinking_20251015——清除思考过程
| {
type: 'clear_thinking_20251015'
keep: { type: 'thinking_turns'; value: number } | 'all'
}这种策略专门处理 thinking blocks——extended thinking 模型(如 Claude Sonnet 4 with thinking)会生成大量思考过程,这些内容在后续轮次中的价值迅速衰减。
11.4.3 策略组合逻辑
getAPIContextManagement() 根据运行时条件组合多个策略:
export function getAPIContextManagement(options?: {
hasThinking?: boolean
isRedactThinkingActive?: boolean
clearAllThinking?: boolean
}): ContextManagementConfig | undefined {
const strategies: ContextEditStrategy[] = []
// 策略 1: thinking 管理
if (hasThinking && !isRedactThinkingActive) {
strategies.push({
type: 'clear_thinking_20251015',
keep: clearAllThinking
? { type: 'thinking_turns', value: 1 }
: 'all',
})
}
// 策略 2: 工具清除(ant-only)
if (process.env.USER_TYPE === 'ant') {
if (useClearToolResults) {
strategies.push({
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: triggerThreshold },
clear_at_least: { type: 'input_tokens', value: triggerThreshold - keepTarget },
clear_tool_inputs: TOOLS_CLEARABLE_RESULTS,
})
}
if (useClearToolUses) {
strategies.push({
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: triggerThreshold },
clear_at_least: { type: 'input_tokens', value: triggerThreshold - keepTarget },
exclude_tools: TOOLS_CLEARABLE_USES,
})
}
}
return strategies.length > 0 ? { edits: strategies } : undefined
}thinking 策略的三个分支:
| 条件 | 行为 | 原因 |
|---|---|---|
hasThinking && !isRedactThinkingActive && !clearAllThinking |
keep: 'all' |
保留所有 thinking(正常工作状态) |
hasThinking && !isRedactThinkingActive && clearAllThinking |
keep: { type: 'thinking_turns', value: 1 } |
只保留最后 1 轮 thinking(超过 1 小时空闲 = 缓存失效) |
isRedactThinkingActive |
不添加策略 | redacted thinking 块没有模型可见内容,无需管理 |
11.4.4 工具清除的两种模式
在 clear_tool_uses_20250919 策略中,工具清除有两种互补模式:
模式一:清除工具结果(clear_tool_inputs)
const TOOLS_CLEARABLE_RESULTS = [
...SHELL_TOOL_NAMES, // Bash 等命令行工具
GLOB_TOOL_NAME, // Glob
GREP_TOOL_NAME, // Grep
FILE_READ_TOOL_NAME, // Read
WEB_FETCH_TOOL_NAME, // WebFetch
WEB_SEARCH_TOOL_NAME, // WebSearch
]这些工具的输出量大但可丢弃——搜索结果或文件内容,模型已经处理过了,清除它们不影响后续推理。
模式二:清除工具使用(exclude_tools)
const TOOLS_CLEARABLE_USES = [
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
NOTEBOOK_EDIT_TOOL_NAME, // NotebookEdit
]这些工具的输入比输出更大——模型发送的编辑指令本身可能比工具返回的结果占更多 token。exclude_tools 的语义是"清除除这些工具外的所有工具使用",这让 API 侧可以更激进地清理。
两种模式的默认参数相同:
triggerThreshold = 180,000(约等于自动压缩的警告阈值)keepTarget = 40,000(保留最后 40K tokens)clear_at_least = triggerThreshold - keepTarget = 140,000(至少释放 140K tokens)
这些值可通过 API_MAX_INPUT_TOKENS 和 API_TARGET_INPUT_TOKENS 环境变量覆盖。
11.5 可压缩工具集清单:分层设计
三种微压缩机制各自定义了不同的可压缩工具集。理解这些差异对于预测哪些工具结果会被清除至关重要。
11.5.1 COMPACTABLE_TOOLS(时间触发 + 缓存微压缩共用)
const COMPACTABLE_TOOLS = new Set([
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
]) 11.5.2 TOOLS_CLEARABLE_RESULTS(API clear_tool_inputs)
const TOOLS_CLEARABLE_RESULTS = [
...SHELL_TOOL_NAMES,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
FILE_READ_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
]11.5.3 TOOLS_CLEARABLE_USES(API exclude_tools)
const TOOLS_CLEARABLE_USES = [
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
NOTEBOOK_EDIT_TOOL_NAME, // NotebookEdit
]11.5.4 差异对比表
| 工具 | COMPACTABLE_TOOLS | CLEARABLE_RESULTS | CLEARABLE_USES |
|---|---|---|---|
| Shell (Bash) | yes | yes | -- |
| Grep | yes | yes | -- |
| Glob | yes | yes | -- |
| FileRead (Read) | yes | yes | -- |
| WebSearch | yes | yes | -- |
| WebFetch | yes | yes | -- |
| FileEdit (Edit) | yes | -- | yes |
| FileWrite (Write) | yes | -- | yes |
| NotebookEdit | -- | -- | yes |
关键差异:
- NotebookEdit 只出现在 API 的
TOOLS_CLEARABLE_USES中——客户端微压缩不处理它 - FileEdit 和 FileWrite 在客户端清除的是结果(tool_result),在 API 模式下则从
clear_tool_inputs中排除、改为在exclude_tools中处理 - 分层设计让客户端和服务端各自处理最适合的部分
这种分层设计体现了关注点分离的原则:客户端专注于清除"已处理"的搜索结果,服务端专注于清除"输入大"的编辑操作。
11.6 缓存中断检测的协调:防止误报
11.6.1 问题:微压缩会触发误报
promptCacheBreakDetection.ts 模块持续监控 API 响应中的 cache_read_tokens。当该值相比上次请求下降超过 5% 且绝对值超过 2,000 tokens 时,它会报告一次"缓存中断"(cache break)——这通常意味着某些变更(系统提示词修改、工具列表变化)导致缓存前缀失效。
但微压缩故意减少了缓存内容。如果不做协调,每次微压缩都会触发一次误报。Claude Code 通过两个通知函数解决这个问题。
11.6.2 notifyCacheDeletion:标记缓存删除
export function notifyCacheDeletion(
querySource: QuerySource,
agentId?: AgentId,
): void {
const key = getTrackingKey(querySource, agentId)
const state = key ? previousStateBySource.get(key) : undefined
if (state) {
state.cacheDeletionsPending = true
}
}调用时机:
- 缓存微压缩发送 cache_edits 后(
microCompact.ts:366) - 时间触发修改消息内容后(
microCompact.ts:526)
效果:设置 cacheDeletionsPending = true。当下一次 API 响应到来时,checkResponseForCacheBreak()(promptCacheBreakDetection.ts:472-481)看到此标志,直接跳过中断检测:
if (state.cacheDeletionsPending) {
state.cacheDeletionsPending = false
logForDebugging(
`[PROMPT CACHE] cache deletion applied, cache read: ${prevCacheRead} → ${cacheReadTokens} (expected drop)`,
)
state.pendingChanges = null
return // 跳过中断检测
}11.6.3 notifyCompaction:重置基线
export function notifyCompaction(
querySource: QuerySource,
agentId?: AgentId,
): void {
const key = getTrackingKey(querySource, agentId)
const state = key ? previousStateBySource.get(key) : undefined
if (state) {
state.prevCacheReadTokens = null // 重置为 null
}
}调用时机:完整压缩(compact.ts:699)和自动压缩(autoCompact.ts:303)完成后。
效果:将 prevCacheReadTokens 重置为 null,这意味着下一次 API 响应时没有"上一次的值"可以比较——检测器会将其视为"第一次调用",不报告中断。
11.6.4 两个函数的区别
| 函数 | 重置方式 | 适用场景 |
|---|---|---|
notifyCacheDeletion |
标记 cacheDeletionsPending = true,下次检测时跳过但保留 baseline |
微压缩(部分删除,baseline 仍有参考价值) |
notifyCompaction |
将 prevCacheReadTokens 置 null,完全重置 baseline |
完整压缩(消息结构彻底改变,旧 baseline 无意义) |
下图展示了缓存中断检测与微压缩的协调机制:
flowchart TD
Start["API 调用完成"] --> CheckPending{"cacheDeletionsPending == true?"}
CheckPending -->|是| SkipCheck["跳过中断检测"]
CheckPending -->|否| CheckDrop{"cache_read 下降 >5%
且 >2000 tokens?"}
SkipCheck --> ResetFlag["cacheDeletionsPending = false"]
ResetFlag --> LogExpected["记录预期的缓存下降"]
LogExpected --> Return["返回"]
CheckDrop -->|是| CheckChanges{"pendingChanges 存在?"}
CheckDrop -->|否| Return
CheckChanges -->|是| BuildReason["构建中断原因说明"]
CheckChanges -->|否| CheckTTL{"时间间隔 > TTL?"}
CheckTTL -->|是| TTLReason["标记为 TTL 过期"]
CheckTTL -->|否| UnknownReason["标记为服务端原因"]
BuildReason --> LogEvent["记录 tengu_prompt_cache_break 事件"]
TTLReason --> LogEvent
UnknownReason --> LogEvent
LogEvent --> WriteDiff["写入 diff 文件(ant)"]
WriteDiff --> Return
subgraph MicrocompactTrigger["微压缩触发时"]
MC["缓存微压缩 / 时间微压缩"]
MC --> Notify["notifyCacheDeletion"]
Notify --> SetFlag["cacheDeletionsPending = true"]
end
subgraph CompactionTrigger["完整压缩触发时"]
AC["自动压缩 / 完整压缩"]
AC --> NotifyC["notifyCompaction"]
NotifyC --> ResetBaseline["prevCacheReadTokens = null"]
end这个协调机制确保了微压缩不会产生误报,同时保持了对真正缓存中断的检测能力。
11.7 子代理隔离:防止状态污染
11.7.1 问题:全局状态与多实例冲突
微压缩系统必须处理的一个重要场景是子代理(sub-agent)。Claude Code 的主线程可以 fork 出多个子代理(session_memory、prompt_suggestion 等),每个子代理有独立的对话历史。
cachedMicrocompactPath 只对主线程执行(microCompact.ts:275-285):
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)
}
}源码注释(第 272-276 行)解释了原因:
"Only run cached MC for the main thread to prevent forked agents 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."
11.7.2 全局状态的陷阱
cachedMCState 是一个模块级全局变量。如果子代理注册了自己的工具 ID,主线程在下次执行时会尝试删除这些 ID——但它们不存在于主线程的消息中,导致无效的 cache_edits 指令。
通过 isMainThreadSource(querySource) 守卫,子代理被完全排除在缓存微压缩之外。
11.7.3 前缀匹配:修复 latent bug
isMainThreadSource() 的实现(microCompact.ts:249-251)使用前缀匹配而非精确匹配:
function isMainThreadSource(querySource: QuerySource | undefined): boolean {
return !querySource || querySource.startsWith('repl_main_thread')
}这是因为 promptCategory.ts 会将 querySource 设置为 'repl_main_thread:outputStyle:<style>'——如果使用严格的 === 'repl_main_thread' 检查,使用非默认输出样式的用户会被静默排除在缓存微压缩之外。
源码注释(第 246-248 行)将旧的精确匹配标注为"latent bug"(潜在 bug)——这是一个重要的修复,确保所有用户都能平等地享受微压缩的好处。
11.8 用户能做什么:理解与策略
理解微压缩的三种机制后,你可以采取以下策略来优化日常使用体验:
11.8.1 理解"工具结果消失"的原因
当你发现模型在对话中后期"忘记"了之前某次 grep 或 cat 的结果,这很可能不是模型的幻觉,而是微压缩主动清除了旧的工具结果。被清除的工具结果会被替换为 [Old tool result content cleared] 占位文本。
最佳实践:如果你需要模型重新参考某个搜索结果,直接要求它重新执行搜索即可——这比试图让模型"回忆"已被清除的内容更可靠。
11.8.2 长时间离开后的预期管理
如果你离开超过 1 小时再回来继续对话,基于时间的微压缩可能已经清除了大部分旧工具结果(只保留最近 5 个)。这是设计如此——因为服务端缓存已经过期,清除旧内容可以显著减少下一次 API 调用的 cache creation 成本。
最佳实践:回来后,让模型重新读取关键文件是正常且高效的操作。这不是 bug,而是 token 优化的设计行为。
11.8.3 利用 CLAUDE.md 保留关键上下文
微压缩只清除工具调用的结果,不影响系统提示词中注入的 CLAUDE.md 内容。如果某些信息(如项目约定、架构决策、关键文件路径)需要在整个会话中持续生效,将它们写入 CLAUDE.md 是最可靠的方式——它们不受任何压缩或微压缩机制的影响。
最佳实践:将稳定的、跨会话的上下文信息放在 CLAUDE.md;将临时的、单次使用的查询依赖工具结果。
11.8.4 并行工具调用的成本意识
当模型同时发起多个搜索或读取操作时,这些结果的聚合大小受 200K 字符的消息级预算限制。如果你观察到某些并行工具的结果被持久化到磁盘(模型会提示"Output too large, saved to file"),这是预算机制在防止上下文膨胀。
最佳实践:通过更精确的搜索条件来减少单次工具输出的大小。例如,使用 grep -A 10 -B 5 而不是无限制的输出。
11.8.5 不可压缩工具的认知
并非所有工具结果都会被微压缩清除。FileEdit、FileWrite 等写入类工具的结果在客户端微压缩中是可清除的,但像 ToolSearch、SendMessage 等工具不在可压缩集合中。
最佳实践:了解哪些工具结果会被清除(参见 11.5 节的对比表),有助于你理解模型在长会话中的行为变化。
11.9 设计模式总结:工程智慧
微压缩系统展现了几个值得学习的工程模式:
11.9.1 分层降级
三种机制形成层次——API Context Management 作为声明式基线始终存在;缓存微压缩在支持 cache_edits 的环境中提供精准手术;时间触发作为缓存失效后的兜底。
flowchart TD
subgraph Layers["微压缩分层架构"]
Layer1["API Context Management
声明式,始终存在
服务端执行"]
Layer2["缓存微压缩
命令式,cache_edits
客户端发起,服务端执行"]
Layer3["时间触发微压缩
命令式,内容清除
客户端执行"]
end
Layer1 --> Fallback1{"失效?"}
Fallback1 -->|是| Layer2
Fallback1 -->|否| Stay1["使用 API 策略"]
Layer2 --> Fallback2{"缓存冷?"}
Fallback2 -->|是| Layer3
Fallback2 -->|否| Stay2["使用 cache_edits"]
Layer3 --> Stay3["清除内容"]每一层都有明确的前提条件和退化路径。这种分层设计确保了系统在各种环境下都能找到合适的上下文管理策略。
11.9.2 副作用协调
微压缩不是孤立操作——它必须通知缓存中断检测器(防误报)、重置相关状态(防脏数据)、抑制用户警告(防困惑)。这三个副作用通过显式的函数调用(notifyCacheDeletion、resetMicrocompactState、suppressCompactWarning)而非事件系统协调,保持了因果链的可追踪性。
flowchart LR
MC["微压缩执行"] --> S1["notifyCacheDeletion
防止误报"]
MC --> S2["resetMicrocompactState
防止脏数据"]
MC --> S3["suppressCompactWarning
防止用户困惑"]
S1 --> Effect1["缓存中断检测器跳过检查"]
S2 --> Effect2["缓存 MC 状态重置"]
S3 --> Effect3["不显示警告对话框"]
Effect1 --> Success["无副作用完成"]
Effect2 --> Success
Effect3 --> Success这种显式的副作用协调比隐式的事件系统更易于调试和维护——因果链清晰可见。
11.9.3 单次消费语义
consumePendingCacheEdits() 返回数据后立即清空——这防止了在 API 重试场景下的重复消费。这种模式在需要跨模块传递一次性状态时非常实用。
export function consumePendingCacheEdits(): CacheEditsBlock | null {
const edits = pendingCacheEdits
pendingCacheEdits = null // 立即清空
return edits
}应用场景:任何需要"确保只处理一次"的跨模块状态传递。
11.9.4 不可变消息修改
时间触发路径使用 map + 展开运算符创建新的消息数组,而不是原地修改:
const result: Message[] = messages.map(message => {
// ... 创建新对象
return {
...message,
message: { ...message.message, content: newContent },
}
})这确保了如果微压缩逻辑有 bug,原始消息不会被污染。缓存微压缩更进一步——它完全不修改本地消息,所有修改都在服务端完成。
设计原则:在可能失败的操作中,保持输入不变是最安全的防御策略。
11.9.5 循环依赖规避
notifyCacheDeletion 被复用来替代 notifyCompaction,仅仅是因为后者的 import 会触发循环依赖检测。这种务实的妥协在大型代码库中很常见——完美的模块边界让位于构建系统的约束。
源码注释坦诚记录了这个取舍:
"notifyCacheDeletion (not notifyCompaction) because it's already imported here and achieves the same false-positive suppression — adding the second symbol to the import was flagged by the circular-deps check."
工程哲学:坦诚地记录技术债务,比追求完美但不可维护的架构更有价值。
11.10 实战案例:微压缩在长会话中的表现
让我们通过一个实际案例来观察微压缩系统如何工作。
11.10.1 场景设置
假设你在上午 10:00 开始使用 Claude Code 进行一次复杂的重构任务:
10:00-10:30: 使用 grep 搜索 50 次,读取 30 个文件
10:30-11:00: 执行 20 次 bash 命令,进行初步修改
11:00-12:00: 继续搜索和编辑,上下文达到 150K tokens
12:00: 去吃午饭
13:00: 回来继续工作11.10.2 微压缩的执行
13:00 返回时(基于时间的微压缩):
- 触发判定:
evaluateTimeBasedTrigger()检测到距离上次助手消息已经 60 分钟 - 工具收集:找到 100 个可压缩工具结果(50 grep + 30 read + 20 bash)
- 保留策略:保留最近 5 个工具结果,清除其余 95 个
- 内容清除:将 95 个工具结果的
content替换为[Old tool result content cleared] - Token 节省:假设每个工具结果平均 1,500 tokens,节省约 142,500 tokens
- 副作用:
- 抑制"上下文即将满"警告
- 重置缓存 MC 状态
- 通知缓存中断检测器预期 cache_read 下降
13:01 下一次 API 调用:
- 服务端缓存已过期(超过 1 小时 TTL)
- 由于微压缩已清除旧内容,重写的缓存前缀更小
- Cache creation 成本从 200K tokens 降到约 60K tokens
- 节省成本:约 140K cache creation tokens
11.10.3 如果没有微压缩
如果没有基于时间的微压缩:
- 返回后的第一次 API 调用需要将完整的 150K tokens 写入缓存
- Cache creation 成本:200K tokens
- 多付成本:140K tokens(约 70% 的开销)
这个案例清晰地展示了微压缩的价值:在缓存失效的场景下,提前清理可以大幅减少重写成本。
11.11 与自动压缩的对比:何时使用哪种策略
下表总结了微压缩与自动压缩的关键差异:
| 维度 | 微压缩 | 自动压缩 |
|---|---|---|
| 触发时机 | 时间间隔 / 工具数量阈值 | 上下文接近窗口上限(200K tokens) |
| LLM 调用 | 不需要 | 需要(生成摘要) |
| 信息保留 | 保留原始消息,只清除工具结果 | 将对话浓缩为摘要,丢失原始细节 |
| 缓存影响 | 时间触发破坏缓存,缓存微压缩保持缓存 | 破坏缓存前缀 |
| 执行位置 | 客户端(时间触发)或服务端(API Context Management) | 客户端,调用 LLM |
| Token 成本 | 几乎为零(不调用 LLM) | 一次完整的 LLM 调用成本 |
| 适用场景 | 旧工具结果的精准清理 | 上下文接近满时的紧急压缩 |
| 用户感知 | 工具结果被 [Old tool result content cleared] 替换 |
出现 "Compacting conversation..." 消息 |
11.11.1 互补而非竞争
微压缩和自动压缩不是竞争关系,而是互补的保护层:
flowchart TD
Start["会话开始"] --> Accumulate["累积上下文"]
Accumulate --> MicrocompactCheck{"微压缩条件满足?"}
MicrocompactCheck -->|是| Microcompact["执行微压缩
清除旧工具结果"}
MicrocompactCheck -->|否| Accumulate
Microcompact --> Accumulate
Accumulate --> AutoCompactCheck{"上下文 > 190K?"}
AutoCompactCheck -->|是| AutoCompact["自动压缩
生成摘要"}
AutoCompactCheck -->|否| Accumulate
AutoCompact --> Accumulate微压缩在会话早期持续清理,延迟自动压缩的触发时间。当微压缩不足以控制上下文增长时,自动压缩作为最后一道防线介入。
11.12 未来演进:冷压缩与用户控制
根据 v2.1.91 的 bundle 信号分析,微压缩系统正在向更智能的方向演进:
11.12.1 冷压缩
cold compact 事件暗示在现有的"热压缩"(紧急、上下文即将满时自动触发)之外,新增了一种"冷压缩"策略:
| 对比维度 | 热压缩 | 冷压缩(推测) |
|---|---|---|
| 触发时机 | 上下文达到阻塞阈值 | 上下文接近满但未到阻塞点 |
| 紧迫性 | 高——不压缩则无法继续 | 低——可延迟到下一回合 |
| 用户感知 | 静默执行 | 可能有对话框确认 |
11.12.2 压缩对话框
autocompact_dialog_opened 事件表明引入了压缩确认 UI——用户可以在压缩发生前看到通知并选择是否继续。这提升了压缩操作的透明度。
11.12.3 快速回填熔断器
auto_compact_rapid_refill_breaker 解决了一个边缘情况:压缩后,如果大量工具结果迅速填满上下文(如读取多个大文件),系统可能进入"压缩→回填→再压缩"的循环。这个熔断器在检测到快速回填模式时中断循环,避免无意义的 API 开销。
11.12.4 手动压缩追踪
autocompact_command 将用户手动触发的 /compact 命令与系统自动触发的压缩区分开来,使遥测数据能够准确反映用户意图 vs 系统行为。
11.13 总结:微压缩的设计哲学
微压缩系统体现了上下文管理的核心哲学:在保留必要信息和控制成本之间找到精妙的平衡。
三种机制各有其适用场景:
- 基于时间的微压缩:缓存失效后的批量清理,利用"反正都要重写"的机会提前瘦身
- 缓存微压缩:实时会话中的精准手术,利用 cache_edits 在不破坏缓存的前提下删除内容
- API Context Management:声明式的服务端策略,作为最后一道防线
它们共同构成了一个分层、互补、自适应的上下文修剪系统。这个系统不需要 LLM 参与,成本几乎为零,却能在会话的大部分时间里维持合理的上下文大小。
更重要的是,微压缩展现了工程设计的智慧:
- 承认现实:缓存会失效,旧工具结果会过时,这是不可避免的自然规律
- 顺势而为:在缓存已经失效时清理内容,而不是试图维持完美的缓存前缀
- 渐进式降级:从最轻量的清理到最激进的压缩,层层递进
- 副作用协调:通过显式函数调用保持系统的因果链清晰
正如本章开头的引用所言:The cheapest token is the one you never send。微压缩的核心价值就是让系统在大多数情况下,根本不需要发送那些已经失去价值的 tokens。
在下一章中,我们将探讨另一个极端场景:当压缩和微压缩都不够用时,系统如何通过更激进的策略来维持会话的连续性。但在此之前,让我们记住微压缩给我们的启示:最好的优化不是让事情更快,而是让事情根本不需要发生。
参考资源
- Claude Code 源码:https://github.com/anthropics/claude-code
- 本书源码:https://github.com/ZhangHanDong/harness-engineering-from-cc-to-ai-coding
- 相关章节:
- 第9章:自动压缩的触发时机
- 第10章:压缩后的状态恢复
- 第12章:会话持久化与跨会话上下文(待完成)
章节元数据:
- 源码版本:v2.1.91
- 分析时间:2026-04-05
- 核心文件:
src/services/compact/microCompact.tssrc/services/compact/apiMicrocompact.tssrc/services/compact/timeBasedMCConfig.tssrc/services/api/promptCacheBreakDetection.tssrc/services/api/claude.ts
- 关键函数:
microcompactMessages()- 主入口evaluateTimeBasedTrigger()- 时间触发判定cachedMicrocompactPath()- 缓存微压缩路径getAPIContextManagement()- API 声明式策略notifyCacheDeletion()- 缓存中断协调