Claude Code 上下文管理(一):为什么 Agent 会"失忆"?

系列导航


Claude Code 上下文管理(一):为什么 Agent 会"失忆"?

🔥 你是否遇到过这些问题?

开发 AI Agent 时,这些场景可能让你困惑:

场景 1:"为什么我和 Claude 聊了 50 轮,它突然说'上下文太长了'?明明前 40 轮都好好的啊!"
场景 2:"同样的对话,在 ChatGPT 能继续,在我自己搭的 Agent 里就卡住了?"
场景 3:"让 AI 分析日志文件,读了 3 个文件后就说 token 不够了,怎么回事?"

如果你正在构建 AI Agent,这些问题几乎无法避免。背后藏着一个反直觉的真相------LLM 并没有真正的"记忆"

本系列将通过 Claude Code v2.1.88 源码,揭示生产级的上下文管理方案。第一篇先破解"失忆"的本质,建立完整的问题认知。


📍 本篇导航

  • 🎯 理解核心矛盾:为什么多轮对话给人"AI 记得上文"的错觉?
  • 🔍 追踪消息增长:Agent Loop 如何一步步走向爆炸?
  • 🗺️ 全景解决方案:Claude Code 的四层压缩管道设计

第一部分:上下文的本质 🤔

前提:LLM 是无状态的

多轮对话给人一种"AI 记得上文"的错觉,但真相是------模型本身没有记忆

每一次 API 请求,都是把从头到尾的全部对话历史 重新拼接成一个大字符串,完整地发给模型。模型读完这一整段,生成回复,然后就"忘得一干二净"。下一轮再问,客户端又得把更长的历史重新发一遍。

flowchart TD subgraph T1[&#34;第 1 轮请求&#34;] A1[&#34;[用户问题1]&#34;] end subgraph T2[&#34;第 2 轮请求&#34;] A2[&#34;[用户问题1]<br/>[AI回复1]<br/>[用户问题2]&#34;] end subgraph T3[&#34;第 3 轮请求&#34;] A3[&#34;[用户问题1]<br/>[AI回复1]<br/>[用户问题2]<br/>[AI回复2]<br/>[用户问题3]&#34;] end T1 --> T2 --> T3 T3 --> N[&#34;...每轮都把全部历史重发一遍<br/>请求体越来越大&#34;] style T1 fill:#d4edda style T2 fill:#fff3cd style T3 fill:#f8d7da style N fill:#f8d7da

所谓"上下文",就是这个每次都要重新拼接、完整发送的历史。它带来两个绕不开的约束:

  1. 有上限 ------模型的上下文窗口是固定的(Opus 4.5 为 200K tokens,Sonnet 4.6 为 1M)。历史一旦超过窗口,请求直接被拒绝(prompt_too_long)。
  2. 有成本 ------每次请求按输入 token 计费。历史越长,每一轮都在为同样的旧内容重复付费。

这就是压缩的根本原因:既然每轮都要把全部历史重发,那么历史越臃肿,代价越高、越容易触顶。

💡 关键洞察:LLM 无状态,"记忆"是靠客户端每轮重发全部历史模拟出来的。这个历史有窗口上限、有重复计费成本------压缩就是为了给它"瘦身"。


消息的数据结构

理解了"上下文 = 每轮重发的历史",再看它的具体形态:在 Claude Code 中,这段历史就是一个 messages 数组。

Claude Code 内部使用的消息结构(基于 src/types/message.ts):

go 复制代码
// 消息类型联合
type Message = UserMessage | AssistantMessage | SystemMessage | ...

// 用户消息
type UserMessage = {
  type: 'user'                       // 内部类型标识
  uuid: string                       // 唯一标识,用于 snip 过滤
  message: { 
    role: 'user', 
    content: BetaContentBlockParam[]  // API 格式的内容块
  }
  isMeta?: boolean                   // 标记是否为元信息(工具结果等)
}

// 助手消息
type AssistantMessage = {
  type: 'assistant'
  uuid: string
  message: { 
    role: 'assistant', 
    id: string,                      // API 返回的消息 ID
    content: BetaContentBlock[]      // text | tool_use | thinking
  }
  stop_reason?: 'end_turn' | 'tool_use' | 'max_tokens' | ...
}

一个完整的对话回合示例:

go 复制代码
messages = [
  // 用户输入
  { 
    type: "user", 
    uuid: "msg-001",
    message: { 
      role: "user",
      content: [{ type: "text", text: "创建 hello.py 并运行" }] 
    } 
  },

  // AI 回复:文本 + 工具调用可以在同一条消息
  { 
    type: "assistant", 
    uuid: "msg-002",
    message: { 
      role: "assistant",
      content: [
        { type: "text", text: "我来创建文件" },
        { 
          type: "tool_use", 
          id: "toolu_1", 
          name: "Write", 
          input: { file_path: "/path/hello.py", content: "print('hello')" }
        }
      ]
    },
    stop_reason: "tool_use"
  },

  // 工具结果:由系统产生,却以 user 角色回流
  { 
    type: "user", 
    uuid: "msg-003", 
    isMeta: true,  // 标记为系统生成,非人类输入
    message: { 
      role: "user",
      content: [
        { 
          type: "tool_result", 
          tool_use_id: "toolu_1", 
          content: "Wrote 15 bytes to /path/hello.py" 
        }
      ]
    } 
  }
]

三个要点

  • 内部用 type 标识,发送给 API 时转换为 role
  • 每条消息有唯一 uuid------这是后面动态过滤的关键
  • 工具结果以 user 消息回流,isMeta 区分它与真正的人类输入

💡 关键洞察 :上下文就是消息数组。工具结果虽由系统产生,却以 user 角色回流------这是 Anthropic API 的设计,也是理解后续压缩的前提。


Agent Loop 的消息增长

以简单的"hello"输入为例,展示 queryLoop 如何运作:

sequenceDiagram participant U as User participant L as queryLoop participant API as Claude API U->>L: hello Note over L: 第 1 轮 L->>L: 预处理(压缩检查) L->>API: messages: [user: &#34;hello&#34;] API->>L: assistant: &#34;Hello! How can I help?&#34; L->>L: stop_reason: end_turn → 退出 L->>U: 显示回复

增长模式

  • 简单对话(无工具调用):+1 assistant → 退出
  • 有工具调用:+1 assistant(tool_use)+ 1 user(tool_result)→ 继续循环
  • 公式:初始 1 条 + N×2 条(N 为工具调用轮次)

为什么会消息爆炸

问题场景

css 复制代码
用户:"帮我分析这个项目的日志文件"

AI 执行流程:
轮 1-3:  read_file("server.log")    → 500KB 日志
        read_file("database.log")   → 800KB 日志
        read_file("network.log")    → 600KB 日志
轮 4:    bash("grep ERROR *.log")   → 200KB 错误
轮 5:    分析并给出建议

问题:
- 仅工具结果就占用 2.15MB ≈ 500K+ tokens
- 5 轮循环产生 11 条消息
- 单个日志文件占用 125K tokens
- Opus 4.5 的 200K 上限直接被突破!

三个维度的爆炸

维度 简单任务 复杂任务 后果
消息数量 10 条 100+ 条 历史冗长
单条大小 1KB 500KB 撑爆窗口
累积 token 5K 500K+ 超过上限

典型爆炸路径

复制代码
用户输入
  ↓
探索阶段(读取 10+ 个文件)
  ↓
分析阶段(多轮推理 + 工具调用)
  ↓
执行阶段(修改文件 + 测试)
  ↓
💥 第 50 轮:prompt_too_long

💡 关键洞察:Agent Loop 的每轮循环会增加 1-2 条消息,工具结果可能包含大量内容。三个维度的爆炸导致上下文快速耗尽。


第二部分:Claude Code 的四层压缩实现 🗺️

面对三维爆炸,传统方案(增大窗口、限制轮次、完全丢弃、全部摘要)要么治标不治本,要么代价太高。

Claude Code 的方案:四层渐进式压缩

Claude Code 的核心思想是:不同类型的冗余,用不同成本的方法解决

flowchart LR P[&#34;上下文爆炸&#34;] --> Q{&#34;问题分类&#34;} Q -->|单条工具结果太大| L3[&#34;L3: 持久化<br/>0 API&#34;] Q -->|消息数量太多| L1[&#34;L1: 删除中间消息<br/>0 API&#34;] Q -->|旧工具结果占空间| L2[&#34;L2: 占位符<br/>0 API&#34;] Q -->|前三层仍不够| L4[&#34;L4: LLM 摘要<br/>1 API&#34;] L3 --> Result[[&#34;上下文瘦身<br/>继续执行&#34;]] L1 --> Result L2 --> Result L4 --> Result style P fill:#f8d7da style L3 fill:#d4edda style L1 fill:#d4edda style L2 fill:#d4edda style L4 fill:#fff3cd style Result fill:#e1f5ff

核心特点

  1. 分而治之 --- 大文件用 L3、历史消息用 L1、旧结果用 L2
  2. 成本分层 --- 先用免费方法(L3/L1/L2),实在不行才 LLM 摘要(L4)
  3. 按需触发 --- 每层都有明确的触发条件,不浪费

三个核心设计原则

1. Cheap First, Expensive Last(成本优先)

makefile 复制代码
免费方法能解决的,绝不调用 LLM
↓
L3/L1/L2: 0 API 成本,纯规则过滤
↓
只有前三层仍然超标时,才触发 L4 摘要

为什么这么设计?

  • 大部分场景下,免费方法就能降低 50-70% 的 token
  • LLM 摘要虽然压缩比高(99%+),但有信息损失
  • 每次 L4 调用 = 2.5−2.5- 2.5−5,频繁触发会让成本失控

2. 渐进式压缩(分层解决)

makefile 复制代码
局部 → 全局
L3: 单条工具结果(最近一条 user 消息)
L1: 中间消息批量删除(保留头尾)
L2: 所有旧工具结果(保留最近 3 个)
L4: 全局摘要(所有历史 → 1 条摘要)

机械 → 语义
L3/L1/L2: 按大小、位置、时间等规则过滤
L4: LLM 理解语义,提取关键信息

3. 信息可恢复性(压缩≠丢弃)

层级 如何恢复 恢复成本
L3 AI 可以 read_file(.task_outputs/...) 1 次工具调用
L1 AI 可以重新执行被删除的工具 N 次工具调用
L2 AI 可以重新执行占位符化的工具 N 次工具调用
L4 摘要保留任务目标、关键决策 不可恢复细节

核心理念:压缩是换一种更紧凑的存储方式,而不是粗暴地丢弃信息

💡 关键洞察:Claude Code 不是"忘掉历史",而是聪明地决定"什么放在 API 上下文里,什么放在别处"。


四层管道概览

flowchart TD Q1[&#34;问题 1<br/>单次工具输出过大<br/>500KB 日志&#34;] --> L3 L3[&#34;<b>L3: tool_result_budget</b><br/>持久化到磁盘 + 2KB 预览<br/>0 API|节省 498KB ≈ 125K tokens&#34;] L3 --> Q2[&#34;问题 2<br/>历史消息太多<br/>100+ 条对话&#34;] Q2 --> L1[&#34;<b>L1: snip_compact</b><br/>保留前 3 + 后 47,删除中间<br/>0 API|100 条 → 51 条&#34;] L1 --> Q3[&#34;问题 3<br/>旧工具结果占空间<br/>之前读过的文件&#34;] Q3 --> L2[&#34;<b>L2: micro_compact</b><br/>保留最近 3 个,其余占位符<br/>0 API|节省 184KB ≈ 46K tokens&#34;] L2 --> Q4{&#34;问题 4<br/>压缩后仍超标?<br/>85K > 50K 阈值&#34;} Q4 -->|是| L4[&#34;<b>L4: compact_history</b><br/>LLM 生成摘要<br/>1 API|99.4% 压缩比&#34;] Q4 -->|否| Send([发送给 API]) L4 --> Send style L3 fill:#d4edda style L1 fill:#d4edda style L2 fill:#d4edda style L4 fill:#fff3cd style Send fill:#e1f5ff

绿色 = 0 API 成本(规则压缩)|黄色 = 1 API(LLM 摘要)


执行时机:在 queryLoop 的每轮开始前

这四层压缩发生在每轮 API 调用之前,作为预处理步骤。以下是完整的 queryLoop 伪代码:

javascript 复制代码
/**
 * Claude Code 的核心执行循环
 * 展示消息累积、四层压缩、工具调用的完整流程
 * 
 * 源码位置:src/core/session/queryLoop.ts
 * 版本:v2.1.88
 * 以下为简化示例,实际实现更复杂
 */
async function queryLoop(userInput: string) {
  // 历史消息累积器(持久化在内存中)
  const allMessages: Message[] = [...existingHistory]
  
  // 添加用户输入
  allMessages.push({
    type: 'user',
    uuid: generateUUID(),
    message: { role: 'user', content: [{ type: 'text', text: userInput }] }
  })
  
  let loopCount = 0
  const maxTurns = 100  // 防止无限循环
  
  while (loopCount < maxTurns) {
    loopCount++
    console.log(`\n=== 第 ${loopCount} 轮 Agent Loop ===`)
    
    // ===== 四层压缩管道 =====
    
    // 0. 获取压缩边界之后的消息
    let messages = getMessagesAfterCompactBoundary(allMessages)
    console.log(`压缩前: ${messages.length} 条消息, ${estimateTokens(messages)}K tokens`)
    
    // L3: 持久化大型工具结果(单条 > 200KB)
    // 源码:src/compaction/toolResultBudget.ts
    messages = await applyToolResultBudget(messages, {
      maxSize: 200 * 1024,        // 200KB 阈值
      previewSize: 2 * 1024,      // 保留 2KB 预览
      outputDir: '.task_outputs'  // 持久化目录
    })
    // 效果:500KB 日志 → 2KB 预览 + 磁盘文件引用
    
    // L1: Snip - 删除中间消息(总数 > 50)
    // 源码:src/compaction/snipCompact.ts
    messages = snipCompactIfNeeded(messages, {
      threshold: 50,       // 超过 50 条触发
      keepRecent: 47,      // 保留最近 47 条
      keepFirst: 3         // 保留开头 3 条(system + 初始目标)
    })
    // 效果:100 条 → 51 条(3 头 + 1 boundary + 47 尾)
    
    // L2: Microcompact - 旧工具结果占位符(> 60 分钟)
    // 源码:src/compaction/microcompact.ts
    messages = await microcompact(messages, {
      ageThreshold: 60 * 60 * 1000,  // 60 分钟
      keepRecentCount: 3              // 保留最近 3 个
    })
    // 效果:旧的 read_file 结果 → "Previously read 15 bytes from file.txt"
    
    // L4: LLM 摘要(仍然 > 50K tokens)
    // 源码:src/compaction/compactHistory.ts
    const currentTokens = estimateTokens(messages)
    if (currentTokens > 50000) {
      console.log(`⚠️ 触发 L4 摘要: ${currentTokens}K tokens > 50K 阈值`)
      
      messages = await compactConversation(messages, {
        summaryPrompt: '提取任务目标、关键决策、待解决问题',
        preserveRecent: 10  // 保留最近 10 条原始消息
      })
      // 效果:80 条历史 → 1 条摘要 + 10 条最近消息
      // 压缩比:85K tokens → 5K tokens (99.4%)
    }
    
    console.log(`压缩后: ${messages.length} 条消息, ${estimateTokens(messages)}K tokens`)
    
    // ===== 调用 Claude API =====
    
    const response = await callClaudeAPI({
      model: 'claude-opus-4-8',
      messages: messages,
      tools: availableTools,
      max_tokens: 4096
    })
    
    // 保存 AI 回复到历史
    const assistantMessage: AssistantMessage = {
      type: 'assistant',
      uuid: generateUUID(),
      message: response,
      stop_reason: response.stop_reason
    }
    allMessages.push(assistantMessage)
    
    // ===== 处理停止原因 =====
    
    if (response.stop_reason === 'end_turn') {
      // 正常结束,显示回复给用户
      console.log('✅ 对话结束')
      break
    }
    
    if (response.stop_reason === 'tool_use') {
      // 提取工具调用
      const toolCalls = response.content.filter(block => block.type === 'tool_use')
      console.log(`🔧 执行 ${toolCalls.length} 个工具调用`)
      
      // 执行工具并收集结果
      const toolResults = await Promise.all(
        toolCalls.map(async (toolCall) => {
          const result = await executeTool(toolCall.name, toolCall.input)
          return {
            type: 'tool_result',
            tool_use_id: toolCall.id,
            content: result  // 这里可能是 500KB 的日志!
          }
        })
      )
      
      // 工具结果以 user 消息回流
      allMessages.push({
        type: 'user',
        uuid: generateUUID(),
        isMeta: true,  // 标记为系统生成
        message: {
          role: 'user',
          content: toolResults
        }
      })
      
      // 继续下一轮循环(带着更长的历史)
      continue
    }
    
    if (response.stop_reason === 'max_tokens') {
      console.log('⚠️ 回复被截断,继续生成')
      continue
    }
  }
  
  return allMessages
}

// ===== 辅助函数 =====

function estimateTokens(messages: Message[]): number {
  // 粗略估算:1 token ≈ 4 字符
  const totalChars = JSON.stringify(messages).length
  return Math.ceil(totalChars / 4)
}

function getMessagesAfterCompactBoundary(messages: Message[]): Message[] {
  // 找到最后一个 boundary message(L4 摘要产生的)
  const lastBoundary = messages.findLastIndex(m => 
    m.type === 'user' && m.message.content[0]?.type === 'text' && 
    m.message.content[0].text.startsWith('# Conversation Summary')
  )
  
  // 如果有 boundary,只返回它之后的消息
  return lastBoundary >= 0 ? messages.slice(lastBoundary) : messages
}

代码解读

  1. 消息累积 (第 6-13 行):allMessages 贯穿整个会话,每轮都在增长
  2. 四层压缩(第 26-66 行):按 L3 → L1 → L2 → L4 顺序,每层都有明确触发条件
  3. 工具调用循环 (第 87-111 行):stop_reason === 'tool_use' 会添加 1 条 assistant + 1 条 user(tool_result),然后 continue 进入下一轮
  4. 消息爆炸场景:每个工具调用轮次增加 2 条消息,工具结果可能带来 500KB 内容

关键数值

  • L3 触发:单条 > 200KB → 压缩到 2KB
  • L1 触发:总数 > 50 条 → 保留 51 条
  • L2 触发:工具结果 > 60 分钟 → 占位符
  • L4 触发:总量 > 50K tokens → 摘要压缩

💡 关键洞察 :四层压缩按 L3 → L1 → L2 → L4 顺序执行,遵循 "cheap first" 原则。每层只在触发条件满足时才执行。L3/L1/L2 是规则过滤( 0),L4是LLM调用(0),L4 是 LLM 调用( 0),L4是LLM调用(3-5)。


📊 四层压缩对比一览表

层级 源码位置 成本 压缩比 信息损失 适用场景 触发条件
L3 toolResultBudget.ts $0 99.6% 极低 单次大输出 单条 > 200KB
L1 snipCompact.ts $0 50% 中等 消息过多 > 50 条
L2 microcompact.ts $0 90% 旧结果堆积 > 60 分钟
L4 compactHistory.ts $3-5 99% 中等 前三层无效 > 50K tokens

📐 Claude Code 的参数设计依据

了解了四层压缩的架构后,你可能好奇:这些阈值是怎么定的? 以下是 Claude Code 的设计依据。

L3: 为什么 200KB?

arduino 复制代码
// src/compaction/toolResultBudget.ts
const DEFAULT_MAX_SIZE = 200 * 1024  // 200KB
const DEFAULT_PREVIEW_SIZE = 2 * 1024 // 2KB

设计依据

  • Opus 4.5 窗口 200K tokens ≈ 800KB 文本(按 1 token ≈ 4 字符估算)
  • 单条工具结果占 25% 以下不会触发爆炸
  • 200KB ≈ 50K tokens,符合 25% 安全比例
  • 2KB 预览足够保留文件路径、错误信息等关键上下文

L1: 为什么 50 条?

arduino 复制代码
// src/compaction/snipCompact.ts
const THRESHOLD = 50      // 超过 50 条触发
const KEEP_RECENT = 47    // 保留最近 47 条
const KEEP_FIRST = 3      // 保留开头 3 条

设计依据

diff 复制代码
假设平均每条消息 1K tokens
Opus 4.5 窗口 200K,期望保留 40% 给历史 = 80K tokens
80K / 1K = 80 条理论上限

但保守起见:
- 触发点设在 50 条(留出 buffer)
- 压缩后保留 51 条(3 头 + 1 boundary + 47 尾)
- 删除中间消息,保留任务目标(开头)和最近上下文(结尾)

L2: 为什么 60 分钟?

arduino 复制代码
// src/compaction/microcompact.ts
const AGE_THRESHOLD = 60 * 60 * 1000  // 60 分钟
const KEEP_RECENT_COUNT = 3           // 保留最近 3 个

设计依据

  • 时间维度的信息衰减:60 分钟前的工具结果,AI 重新执行成本低于保留在上下文的成本
  • 最近 3 个:足够覆盖当前子任务的上下文(通常 1 个子任务 ≤ 3 次工具调用)
  • 可恢复性 :AI 可以重新 read_file 或执行工具,比 L4 摘要的信息损失小

L4: 为什么 50K tokens?

arduino 复制代码
// src/compaction/compactHistory.ts
const COMPACTION_THRESHOLD = 50000  // 50K tokens

设计依据

diff 复制代码
模型窗口分配:
- 输入上下文:期望 < 150K tokens(留 75% 给历史)
- 输出 max_tokens:4-8K
- 安全边界:20%(避免临界拒绝)

实际可用 = 200K × (1 - 0.2) = 160K
分配给历史 = 160K - 8K (输出) = 152K

触发点 = 152K × (1/3) = 50K

为什么是 1/3 而不是更晚触发?

  • L4 是有 API 成本的($3-5 每次)

  • 但太晚触发会导致:

    • 前三层免费方法来不及生效
    • 单次摘要要处理的内容过多,质量下降
  • 50K 是"尽量延迟,但不能太晚"的平衡点


💡 本篇总结

三个核心认知

  1. LLM 无状态

    • 每次 API 请求都要重发全部历史
    • 上下文 = messages 数组,有上限、有成本
  2. 三维爆炸

    • 消息数量爆炸(100+ 条)
    • 单条消息过大(500KB 工具结果)
    • 累积 token 爆炸(轻易超过 200K)
  3. 四层分治

    • L3 持久化:单个大文件 → 磁盘 + 预览
    • L1 Snip:历史过多 → 删除中间
    • L2 Micro:旧结果 → 占位符
    • L4 摘要:仍超标 → LLM 语义压缩

设计哲学

Claude Code 的上下文管理,本质上是在信息保真度、成本与窗口大小之间的动态寻优。它从不追求"完美记忆",而是优雅地实现"合理的遗忘"。


🚀 下篇预告

理解了上下文爆炸的本质和四层压缩全景后,下一篇我们将深入 L3/L1/L2 三层免费压缩策略的实现细节

  • ✅ 如何 0 成本把 500KB 压缩到 2KB?(L3 持久化方案)
  • ✅ boundary message 如何保证 role 交替?(L1 生产级实现)
  • ✅ 双重视图架构的精妙设计(源码分析)
  • ✅ 旧工具结果的时间窗口策略(L2 触发机制)

👉 Claude Code 上下文管理(二):零成本压缩三板斧


💬 讨论和交流

你在开发 Agent 时,遇到过"上下文爆炸"的问题吗?

欢迎在评论区分享:

  • 你是怎么发现问题的?
  • 当时的场景和任务是什么?
  • 你的临时解决方案是什么?

如果这篇文章对你有帮助,欢迎 点赞收藏,让更多人看到 🙏


系列导航

相关推荐
两万五千个小时1 小时前
Claude Code 上下文管理(二):零 Token 消耗的压缩三板斧
人工智能·程序员·开源
Xiaoda111 小时前
从一个请求开始:LLM 推理系统如何完成一次生成?
架构
冬奇Lab1 小时前
每日一个开源项目(第150篇):caveman - 为什么用很多 token,少 token 也行——给 AI Agent 装上穴居人嘴巴
人工智能·开源·资讯
贵慜_Derek1 小时前
MAI-04|干净数据在工程上意味着什么:MAI 预训练数据治理
人工智能·算法·llm
feelmylife591 小时前
Agent 记忆设计架构 — 分层记忆:什么时候该记住,什么时候该忘记
人工智能
杉氧2 小时前
性能优化实战:如何定位冗余重组并榨干 Compose 的每一帧性能?
android·架构·android jetpack
阿黎梨梨2 小时前
揭秘大语言模型的底层逻辑:从文本分词到高维向量的计算之旅
javascript·人工智能
行者全栈架构师2 小时前
PolarDB + Spring Boot 实战:从自建MySQL到云原生数据库的零停机迁移
java·后端·架构