Appearance
第16章 上下文管理与自动压缩
"The art of programming is the art of organizing complexity, of mastering multitude and avoiding its bastard chaos." -- Edsger W. Dijkstra
本章要点
- 大语言模型上下文窗口的物理限制及其对长对话的深层影响
- Token 追踪体系:从 API 精确计量到客户端粗略估算的双轨机制
- 自动压缩(Auto-compact)的触发条件、压缩算法与渐进式策略
- Session Memory 压缩:一种无需 API 调用的轻量级替代方案
- Microcompact:针对工具结果的细粒度内容清理机制
- 持久化记忆系统(memdir):从文件组织到智能检索的全链路设计
- 会话历史管理与
/resume会话恢复的实现细节 - CompactBoundaryMessage 如何在压缩前后建立语义连续性
对于一个交互式 AI 编程助手而言,上下文管理是一个贯穿始终的核心挑战。用户与 Claude Code 的对话可能持续数小时,涉及数十个文件的阅读与修改、数百次工具调用的结果,以及不断演进的任务目标。然而,大语言模型的上下文窗口终究是有限的——即使是拥有 200K 甚至 1M token 容量的模型,在一个复杂的编码会话中也会迅速逼近极限。
Claude Code 为此构建了一套精密的多层上下文管理体系。这个体系不仅仅是"在上下文快满时做一次摘要"这么简单——它包含 Token 的实时追踪与估算、多级压缩策略的协同调度、跨会话的持久化记忆、以及在压缩过程中对关键信息的精确保留。更深层来看,它反映了一个核心设计哲学:在有限的资源约束下,如何最大化地保留信息的价值密度。本章将深入剖析这套体系的每一个层次,从底层的度量机制到顶层的策略编排,完整呈现这个子系统的设计全貌。
16.1 为什么上下文管理如此重要
16.1.1 模型上下文窗口的物理限制
大语言模型的上下文窗口是一个硬性约束。在 Claude Code 中,不同模型的上下文窗口大小定义在 src/utils/context.ts 中:
typescript
// src/utils/context.ts
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
export function getContextWindowForModel(
model: string,
betas?: string[],
): number {
// 环境变量覆盖(仅 ant 内部使用)
// 1M 上下文检测
// 默认 200K
}默认的上下文窗口为 200K token。对于支持 1M 上下文的模型(如 Claude Sonnet 4 和 Opus 4.6),系统会通过 has1mContext 函数检测模型名称中的 [1m] 标记来启用更大的窗口:
typescript
// src/utils/context.ts
export function has1mContext(model: string): boolean {
if (is1mContextDisabled()) {
return false
}
return /\[1m\]/i.test(model)
}
export function modelSupports1M(model: string): boolean {
if (is1mContextDisabled()) {
return false
}
const canonical = getCanonicalName(model)
return canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6')
}这里有一个重要的设计决策:即使模型本身支持 1M 上下文,管理员也可以通过 CLAUDE_CODE_DISABLE_1M_CONTEXT 环境变量强制禁用,这是为 HIPAA 等合规场景设计的。此外,getContextWindowForModel 函数还支持通过 CLAUDE_CODE_CONTEXT_WINDOW_OVERRIDE 环境变量手动设定有效上下文窗口大小,允许内部测试人员在不更换模型的前提下模拟较小的上下文环境,从而验证压缩策略在不同窗口尺寸下的行为。
16.1.2 长对话的记忆丢失问题
上下文窗口的限制带来的不仅仅是"放不下"的问题,更深层的挑战是信息的优先级排序。一个典型的 Claude Code 会话中,上下文空间被以下内容占据:
- 系统提示词:包含角色定义、工具说明、记忆内容、项目规则等,通常占用 20K-40K token
- 用户消息:用户的每一次输入和反馈
- 工具调用结果:文件读取内容、命令输出、搜索结果等,这些往往是上下文中最大的消耗者
- 助手响应:包含思考过程(thinking blocks)和文本输出
当上下文接近满载时,如果不进行管理,模型将无法接收新的输入,API 会返回 prompt_too_long 错误,整个对话被迫中止。更糟糕的是,简单的截断策略(丢弃最早的消息)会导致关键的早期决策信息丢失——比如用户在对话开始时提出的架构约束、中间发现并修复的关键 bug、或者用户明确表达的偏好和反馈。这些信息虽然在时间维度上较为久远,但在语义维度上可能具有贯穿整个会话的重要性。因此,上下文管理的本质不是简单的空间释放,而是一个信息价值的优先级排序问题。
Claude Code 的解决方案是一个分层递进的体系:
+-----------------------+
| 持久化记忆 (memdir) | 跨会话
+-----------------------+
|
+-----------------------+
| Session Memory 压缩 | 会话内,无API调用
+-----------------------+
|
+-----------------------+
| 自动压缩 (compact) | 会话内,需API调用
+-----------------------+
|
+-----------------------+
| 微压缩 (microcompact) | 细粒度工具结果清理
+-----------------------+
|
+-----------------------+
| Token 实时追踪 | 贯穿始终的度量基础
+-----------------------+我们将自底向上地逐层剖析这个体系。
16.2 Token 追踪
Token 追踪是整个上下文管理体系的度量基础。没有准确的 token 计量,就无法判断何时触发压缩、压缩效果如何、预算是否超支。Claude Code 采用了"API 精确计量 + 客户端粗略估算"的双轨策略。
16.2.1 API 返回的精确用量
每次 API 调用返回的响应中都包含精确的 token 用量数据。src/utils/tokens.ts 中的 getTokenUsage 函数负责从助手消息中提取这些数据:
typescript
// src/utils/tokens.ts
export function getTokenUsage(message: Message): Usage | undefined {
if (
message?.type === 'assistant' &&
'usage' in message.message &&
!(
message.message.content[0]?.type === 'text' &&
SYNTHETIC_MESSAGES.has(message.message.content[0].text)
) &&
message.message.model !== SYNTHETIC_MODEL
) {
return message.message.usage
}
return undefined
}注意这里有两个过滤条件:合成消息(SYNTHETIC_MESSAGES)和合成模型(SYNTHETIC_MODEL)的用量会被排除。这是因为 Claude Code 内部会创建一些不经过 API 的虚拟消息,它们的 usage 字段没有实际意义。
从 API 用量中计算完整的上下文窗口占用:
typescript
// src/utils/tokens.ts
export function getTokenCountFromUsage(usage: Usage): number {
return (
usage.input_tokens +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
usage.output_tokens
)
}这个公式将输入 token、缓存创建 token、缓存读取 token 和输出 token 全部累加,得到该次 API 调用时的完整上下文大小。
16.2.2 客户端粗略估算
然而,API 用量只能告诉我们上一次调用时的上下文大小。在两次 API 调用之间,如果用户又输入了新消息、产生了工具结果,我们需要估算当前的上下文大小。这就是 tokenCountWithEstimation 的职责——它是 Claude Code 中判断是否需要压缩的核心函数:
typescript
// src/utils/tokens.ts
export function tokenCountWithEstimation(messages: readonly Message[]): number {
let i = messages.length - 1
while (i >= 0) {
const message = messages[i]
const usage = message ? getTokenUsage(message) : undefined
if (message && usage) {
// 处理并行工具调用产生的消息分裂
const responseId = getAssistantMessageId(message)
if (responseId) {
let j = i - 1
while (j >= 0) {
const prior = messages[j]
const priorId = prior ? getAssistantMessageId(prior) : undefined
if (priorId === responseId) {
i = j // 回退到同一 API 响应的第一条拆分消息
} else if (priorId !== undefined) {
break
}
j--
}
}
return (
getTokenCountFromUsage(usage) +
roughTokenCountEstimationForMessages(messages.slice(i + 1))
)
}
i--
}
return roughTokenCountEstimationForMessages(messages)
}这个函数的实现揭示了一个精妙的设计。它从消息列表末尾向前搜索,找到最近一条带有 API 用量数据的助手消息,以此作为基准,然后对基准之后新增的消息进行粗略估算。关键的复杂性在于并行工具调用的处理:当模型在一次响应中发起多个工具调用时,流式处理代码会为每个内容块创建独立的助手消息记录,它们共享同一个 message.id。函数必须回退到同一 API 响应的第一条拆分消息,以确保夹在中间的所有 tool_result 都被包含在估算中。
源码注释中明确指出了这一点:
Implementation note on parallel tool calls: when the model makes multiple tool calls in one response, the streaming code emits a SEPARATE assistant record per content block (all sharing the same message.id and usage)...