系列导航
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 请求,都是把从头到尾的全部对话历史 重新拼接成一个大字符串,完整地发给模型。模型读完这一整段,生成回复,然后就"忘得一干二净"。下一轮再问,客户端又得把更长的历史重新发一遍。
所谓"上下文",就是这个每次都要重新拼接、完整发送的历史。它带来两个绕不开的约束:
- 有上限 ------模型的上下文窗口是固定的(Opus 4.5 为 200K tokens,Sonnet 4.6 为 1M)。历史一旦超过窗口,请求直接被拒绝(
prompt_too_long)。 - 有成本 ------每次请求按输入 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 如何运作:
增长模式:
- 简单对话(无工具调用):+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 的核心思想是:不同类型的冗余,用不同成本的方法解决。
核心特点:
- 分而治之 --- 大文件用 L3、历史消息用 L1、旧结果用 L2
- 成本分层 --- 先用免费方法(L3/L1/L2),实在不行才 LLM 摘要(L4)
- 按需触发 --- 每层都有明确的触发条件,不浪费
三个核心设计原则
1. Cheap First, Expensive Last(成本优先)
makefile
免费方法能解决的,绝不调用 LLM
↓
L3/L1/L2: 0 API 成本,纯规则过滤
↓
只有前三层仍然超标时,才触发 L4 摘要
为什么这么设计?
- 大部分场景下,免费方法就能降低 50-70% 的 token
- LLM 摘要虽然压缩比高(99%+),但有信息损失
- 每次 L4 调用 = 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 上下文里,什么放在别处"。
四层管道概览
绿色 = 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
}
代码解读:
- 消息累积 (第 6-13 行):
allMessages贯穿整个会话,每轮都在增长 - 四层压缩(第 26-66 行):按 L3 → L1 → L2 → L4 顺序,每层都有明确触发条件
- 工具调用循环 (第 87-111 行):
stop_reason === 'tool_use'会添加 1 条 assistant + 1 条 user(tool_result),然后continue进入下一轮 - 消息爆炸场景:每个工具调用轮次增加 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调用(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 是"尽量延迟,但不能太晚"的平衡点
💡 本篇总结
三个核心认知
-
LLM 无状态
- 每次 API 请求都要重发全部历史
- 上下文 = messages 数组,有上限、有成本
-
三维爆炸
- 消息数量爆炸(100+ 条)
- 单条消息过大(500KB 工具结果)
- 累积 token 爆炸(轻易超过 200K)
-
四层分治
- L3 持久化:单个大文件 → 磁盘 + 预览
- L1 Snip:历史过多 → 删除中间
- L2 Micro:旧结果 → 占位符
- L4 摘要:仍超标 → LLM 语义压缩
设计哲学
Claude Code 的上下文管理,本质上是在信息保真度、成本与窗口大小之间的动态寻优。它从不追求"完美记忆",而是优雅地实现"合理的遗忘"。
🚀 下篇预告
理解了上下文爆炸的本质和四层压缩全景后,下一篇我们将深入 L3/L1/L2 三层免费压缩策略的实现细节:
- ✅ 如何 0 成本把 500KB 压缩到 2KB?(L3 持久化方案)
- ✅ boundary message 如何保证 role 交替?(L1 生产级实现)
- ✅ 双重视图架构的精妙设计(源码分析)
- ✅ 旧工具结果的时间窗口策略(L2 触发机制)
👉 Claude Code 上下文管理(二):零成本压缩三板斧
💬 讨论和交流
你在开发 Agent 时,遇到过"上下文爆炸"的问题吗?
欢迎在评论区分享:
- 你是怎么发现问题的?
- 当时的场景和任务是什么?
- 你的临时解决方案是什么?
如果这篇文章对你有帮助,欢迎 点赞收藏,让更多人看到 🙏
系列导航
- (一)为什么 Agent 会"失忆"? ← 当前
- (二)零成本压缩三板斧 👈 下一篇
- (三)语义压缩与生产实践