Layer 5: 上下文管理 —— 长对话不爆窗口的工程
模型的上下文窗口是有限的(100K-1M tokens),但 Agent 的对话可以无限长。如何在有限的窗口中维持无限的对话?
这是 Agent 工程中被严重低估的问题。Claude Code 用了四层防御来解决它。
1. 四层防御体系
Section titled “1. 四层防御体系”Layer D: Token Budget(预算控制) → 每轮对话的 token 预算,超过 90% 就停止Layer C: Auto-Compact(自动压缩) → 接近窗口上限时自动触发对话摘要Layer B: Tool Result Persistence(大结果外置) → 超过阈值的工具输出存磁盘,消息中只保留摘要Layer A: Prompt Cache(提示词缓存) → 系统提示词跨轮次复用,避免重复消耗 token每一层解决不同的问题,层层叠加形成完整的上下文管理策略。
2. Layer A: Prompt Cache(系统提示词缓存)
Section titled “2. Layer A: Prompt Cache(系统提示词缓存)”系统提示词(包含工具描述、CLAUDE.md 内容、Git 状态等)通常有 10K-50K tokens。如果每轮对话都重新发送,成本和延迟都很高。
// query.ts:449-451const fullSystemPrompt = asSystemPrompt( appendSystemContext(systemPrompt, systemContext),)系统提示词在 query 循环中是不可变的——它在循环开始前构建一次,之后所有迭代都复用相同的对象。这保证了 Anthropic API 的 prompt cache 可以跨轮次命中。
Fork 子 Agent 的 Cache 共享:
// forkSubagent.ts — 子 Agent 复用父上下文的系统提示词// 生成字节级相同的前缀,最大化 cache 命中率context.renderedSystemPrompt // 父的冻结快照传给子★ 设计洞察:
renderedSystemPrompt是在 turn 开始时冻结的快照。为什么不在 fork 时重新生成?因为 GrowthBook 的 feature flag 可能在运行期间从 cold→warm 发生变化,导致生成的系统提示词出现差异,从而打破 cache。冻结快照保证了缓存一致性。
3. Layer B: Tool Result Persistence(大结果外置)
Section titled “3. Layer B: Tool Result Persistence(大结果外置)”一个 Bash("cat large_file.py") 可能返回 100K 字符的输出。如果全部留在消息历史中,几轮对话就会撑爆上下文窗口。
// toolResultStorage.ts — 两级控制
// Level 1: 每个工具的阈值function getPersistenceThreshold(toolName, declaredMax): number { // 工具自己声明的 maxResultSizeChars // 与全局默认值 50K 取最小值 // GrowthBook 可以按工具名覆盖 return Math.min(declaredMaxResultSizeChars, DEFAULT_MAX_RESULT_SIZE_CHARS)}
// Level 2: 每条消息的聚合预算// 一条 user message 中所有 tool_result 的总大小预算// 由 ContentReplacementState 跟踪持久化流程:
工具执行完毕,返回 result │ ├── 结果为空?→ 注入 "(toolName completed with no output)" 防止模型误判 │ ├── 结果 ≤ 阈值?→ 正常返回 │ └── 结果 > 阈值? ├── 1. 写入磁盘:~/.claude/tool-results/{sessionId}/{toolUseId}.json ├── 2. 生成预览(前 N 字符) └── 3. 消息中替换为:文件路径 + 预览 + 大小信息ContentReplacementState 的精妙设计:
// toolResultStorage.ts:390-412type ContentReplacementState = { decisions: Map<string, 'keep' | 'replace'> // toolUseId → 决策 totalKeptSize: number // 已保留的总大小 budget: number // 每条消息的预算}为什么需要 ContentReplacementState?因为 prompt cache。
如果轮次 A 中某个工具结果被保留(keep),但轮次 B 中同一个结果因为预算不足被替换(replace),那么轮次 B 的消息内容就与轮次 A 不同了——prompt cache 就失效了。所以替换决策必须是稳定的:一旦决定 keep 或 replace,后续轮次的决策不能变。
★ 设计洞察:这是一个非常容易被忽略的一致性问题。大多数 Agent 框架不会注意到 tool result truncation 会破坏 prompt cache。Claude Code 通过持久化
ContentReplacementState并在轮次间传递来保证一致性。
4. Layer C: Auto-Compact(自动对话压缩)
Section titled “4. Layer C: Auto-Compact(自动对话压缩)”// autoCompact.ts:225-238const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreedconst threshold = getAutoCompactThreshold(model)// threshold = effectiveContextWindow - 13K (AUTOCOMPACT_BUFFER_TOKENS)
return tokenCount >= threshold // tokens 接近窗口上限即:当消息 token 数 ≥ (上下文窗口 - 13K 缓冲区) 时触发。
1. 触发条件满足 │ ├── 2. PreCompact Hook(可阻止压缩) │ └── exit 2 → 跳过本次压缩 │ ├── 3. 剥离图片和可重注入的附件 │ └── 避免压缩 API 自身 prompt-too-long │ ├── 4. 调用 Claude 生成对话摘要 │ ├── 成功 → 摘要替换旧消息 │ └── prompt-too-long?→ 渐进式丢弃最旧的消息轮次,重试(最多 3 次) │ ├── 5. 后处理 │ ├── 重注入最近编辑的文件(最多 5 个,50K token 预算) │ ├── 重注入活跃的 Skill │ └── 重注入当前 Plan │ ├── 6. SessionStart Hook(触发 source='compact') │ └── 让 Hook 知道这是压缩后的「新会话」 │ └── 7. PostCompact Hook └── 可选的后续处理关键工程决策
Section titled “关键工程决策”PTL 重试循环:
压缩请求本身超出上下文 → 丢弃最旧的消息轮次 → 重试最多重试 3 次(MAX_PTL_RETRIES)如果还是超出 → 报错给用户记忆提取:
// 压缩时自动提取关键信息到持久记忆// 避免压缩后丢失重要的上下文熔断器:
// autoCompact.ts:257-265const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3// 连续 3 次压缩失败 → 停止尝试// 避免在不可恢复的场景下反复浪费 API 调用★ 设计洞察:压缩后「重注入」最近编辑的文件是一个关键细节。压缩摘要只能记住「做了什么」,但模型经常需要「看到」正在编辑的文件的当前内容。重注入机制解决了这个「压缩后失忆」问题。
5. Layer D: Token Budget(预算控制)
Section titled “5. Layer D: Token Budget(预算控制)”// tokenBudget.ts:45-93function checkTokenBudget(tracker, agentId, budget, globalTurnTokens): Decision { // 子 Agent 不参与预算控制(由父控制) if (agentId || budget === null) return { action: 'stop' }
const pct = Math.round((turnTokens / budget) * 100) const delta = globalTurnTokens - tracker.lastGlobalTurnTokens
// 「边际递减」检测:连续 3+ 次续写,每次 < 500 tokens const isDiminishing = tracker.continuationCount >= 3 && delta < 500 && tracker.lastDelta < 500
// 未达 90% 且有效产出 → 继续 if (!isDiminishing && turnTokens < budget * 0.9) { return { action: 'continue', nudgeMessage: "..." } }
// 达到 90% 或边际递减 → 停止 return { action: 'stop' }}两个停止条件
Section titled “两个停止条件”- 硬上限:token 使用量达到预算的 90%
- 边际递减:连续 3 次续写每次都不到 500 tokens(说明模型没什么实质内容产出了)
★ 设计洞察:「边际递减」检测是一个巧妙的启发式。模型有时会进入一种「空转」状态——每次续写都只产出少量无意义的 token。这个检测可以尽早终止这种空转,节省 token。
6. 错误恢复:413 Prompt Too Long
Section titled “6. 错误恢复:413 Prompt Too Long”当 API 返回 413(prompt-too-long)时,query 循环有一套多层恢复策略:
API 返回 413 │ ├── Step 1: Collapse Drain(上下文折叠排水) │ └── 如果启用了上下文折叠,先尝试释放折叠区域 │ ├── Step 2: Reactive Compact(响应式压缩) │ └── 立即触发一次完整的对话压缩 │ └── Step 3: Surface Error(暴露错误) └── 前两步都失败 → 告诉用户和模型// query.ts:1062-1183 — 错误恢复逻辑if (withheldError && is413Error(withheldError)) { // Step 1: Collapse drain const drained = await collapseDrain(messages) if (drained) { state = { ..., transition: { reason: 'collapse_drain_retry' } } continue // 重试 }
// Step 2: Reactive compact if (!hasAttemptedReactiveCompact) { await compactConversation(messages) state = { ..., transition: { reason: 'reactive_compact_retry' } } continue // 重试 }
// Step 3: Surface error yield errorMessage}7. Max Output Tokens 恢复
Section titled “7. Max Output Tokens 恢复”当模型因为 max_output_tokens 限制被截断时:
模型被截断(stop_reason: 'max_tokens') │ ├── 第 1 次:尝试升级 max_output_tokens(8K → 64K) │ └── 用更大的限制重试 │ └── 第 2+ 次:多轮恢复 └── 注入 recovery message:"你的回复被截断了,请从断点继续" → 模型接着上次的输出继续// query.ts:1185-1256if (stopReason === 'max_tokens') { if (maxOutputTokensRecoveryCount < 1) { // 升级 max_output_tokens state = { ..., maxOutputTokensOverride: 64000, transition: { reason: 'max_output_tokens_escalate' } } continue } // 多轮恢复 const recoveryMessage = createRecoveryMessage("Continue from where you left off...") state = { ..., transition: { reason: 'max_output_tokens_recovery' } } continue}8. 会话持久化与恢复
Section titled “8. 会话持久化与恢复”会话存储位置:~/.claude/sessions/{sessionId}/ ├── transcript # 完整消息历史(JSON) ├── agents/ │ └── {agentId}/ │ └── transcript # 子 Agent 的消息历史 └── metadata # 会话元数据(标题、成本、指标)
工具结果存储:~/.claude/tool-results/{sessionId}/ └── {toolUseId}.json # 大型工具结果
会话恢复: claude -r # 恢复最近的会话 claude --resume {id} # 恢复指定会话 /resume [search] # 搜索并恢复恢复流程:deserializeMessages() 重建完整的消息历史,包括从磁盘加载大型工具结果。
Agent 工程实践 Takeaway
Section titled “Agent 工程实践 Takeaway”上下文管理需要多层防御
Section titled “上下文管理需要多层防御”单一策略不够。需要:
- 预防(大结果外置、prompt cache)
- 检测(token 计数、阈值监控)
- 恢复(自动压缩、413 恢复)
- 终止(token budget、边际递减检测)
Prompt Cache 一致性被严重低估
Section titled “Prompt Cache 一致性被严重低估”任何可能改变消息内容的操作(工具结果截断、压缩、附件注入)都可能破坏 prompt cache。需要通过持久化决策状态(ContentReplacementState)来保证跨轮次一致性。
压缩 ≠ 遗忘
Section titled “压缩 ≠ 遗忘”好的压缩需要:
- 生成摘要(记住做了什么)
- 重注入关键文件(看到当前状态)
- 提取记忆(持久化重要信息)
- 触发 Hook(让外部系统知道)
熔断器模式防止无限循环
Section titled “熔断器模式防止无限循环”任何自动触发的恢复操作(压缩、重试)都需要熔断器。连续 3 次压缩失败就停止尝试,避免浪费 API 调用。
与其他层的交互
Section titled “与其他层的交互”- → Tool 系统(L2):工具通过
maxResultSizeChars声明自己的结果大小,超限结果被持久化 - → Hook 系统(L4):PreCompact/PostCompact Hook 控制压缩行为,SessionStart(compact) 通知压缩发生
- → Agent Loop(L1):413 恢复和 token budget 检查是 query 循环的一部分
- → 权限系统(L3):压缩不影响权限状态,
ToolPermissionContext是不可变的