Claude code学习2 Claude code的上下文管理
从管理上下文的角度上来看,如果把读文件的内容原封不动给回llm,llm大概率是会超出上下文或者注意力涣散的
压缩管道
压缩管道是 Claude Code 处理上下文窗口管理的核心机制。
为什么需要压缩
Claude 的上下文窗口有 token 上限(比如 200k)。但一次对话可能涉及:
- 读了很多文件
- 多轮工具调用返回大量结果
- 长对话历史累积
放着不管,上下文会爆。
压缩管道做什么
大致流程:
原始上下文 → 评估 token 量 → 触发压缩 → 输出精简上下文
具体手段包括:
- 摘要化 — 把长对话历史浓缩成关键信息的摘要,丢掉细节
- 工具结果裁剪 — 大文件读取结果只保留关键片段,不塞完整内容
- 系统提示动态调整 — 根据对话阶段调整注入的上下文量
- 优先级排序 — 哪些信息更重要,优先保留
为什么说它重要
这就是 Harness 的核心价值之一:
- 不压缩 → 上下文溢出,模型丢失早期信息,任务失败
- 粗暴压缩 → 丢掉关键上下文,模型做出错误决策
- 智能压缩 → 保留关键信息,丢弃噪音,维持长对话质量
整体流程
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
let tracking = autoCompactTracking
const persistReplacements =
querySource.startsWith('agent:') ||
querySource.startsWith('repl_main_thread')
if (feature('HISTORY_SNIP')) {
messagesForQuery = snipCompactIfNeeded(messagesForQuery).messages
}
const microcompactResult = await deps.microcompact(messagesForQuery, toolUseContext)
messagesForQuery = microcompactResult.messages
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
messagesForQuery = (await contextCollapse.applyCollapsesIfNeeded(...)).messages
}
const { compactionResult } = await deps.autocompact(messagesForQuery, ...)
if (compactionResult) {
const postCompactMessages = buildPostCompactMessages(compactionResult)
for (const message of postCompactMessages) yield message
}
整体流程上分为 预算缩减:先限制单个工具结果体积,把超大日志、搜索结果或媒体描述替换成预览与引用,避免一个工具输出支配整轮请求。 Snip:轻量裁剪最旧历史,并把释放的 snipTokensFreed 传给后续计数逻辑,避免误触发更昂贵压缩。 Microcompact:专门清理过期工具日志,优先处理低价值、高体积的工具结果。 Context collapse:读时投影,把历史折叠成当前请求可用的视图,但不直接改写底层本地消息数组。 Auto-compact:最昂贵的全量重构,请 LLM 生成摘要边界并替换旧历史,只在前面几层无法回到安全水位时触发。
预算缩减
persistReplacements用于判断是否需要进行持久化,像是来自于agent和repl_main_thread的是需要才持久化,因为这些来源恢复时需要读回记录 临时的 agent 调用(如 agent_summary)不需要持久化 messagesForQuery = await applyToolResultBudget涉及到了两种压缩方式 一种是context内容上的压缩(applyToolResultBudget),一种是tool映射的结果来决定是否要压缩(cached microcompact) 为什么这很重要 因为 cached microcompact 的缓存机制是按 tool_use_id 建立映射的: 第一次运行: abc_123 → “保留” 的结果被缓存 如果 applyToolResultBudget 在 microcompact 之后运行,它修改了 content,但 tool_use_id 没变。microcompact 的缓存不会失效——缓存认为消息没变,但内容其实变了。 反过来,先运行 applyToolResultBudget(改内容),再运行 microcompact(按 ID 操作),microcompact 看到的 content 已经是裁剪过的,缓存就和实际内容一致了。
怎么推断需要压缩呢?因为会自动压缩的工具都会填上maxChars的字段,所以没填的例如读文件之类的操作就需要压缩了 注释里的那句话
// For cached microcompact (cache editing), defer boundary message until after // the API response so we can use actual cache_deleted_input_tokens.
意思是:缓存编辑不是立刻生效的,要等 API 响应回来,拿到实际的 cache_deleted_input_tokens(真正被删掉的 token 数)之后,才更新缓存边界消息。这保证了缓存里的数据和实际情况一致。
简单理解
applyToolResultBudget → 改内容(不涉及缓存) cached microcompact → 按 ID 做决策(决策结果缓存到磁盘)
缓存 = microcompact 过去做的”保留/丢弃”决策记录,下次遇到同样的 tool_use_id 直接复用,不用重新算。
总的来说,工具结果预算先限制单个输出膨胀,避免 grep、日志、PDF 或搜索结果吞掉整轮窗口。
Snip
if (feature("HISTORY_SNIP")) {
messagesForQuery = snipCompactIfNeeded(messagesForQuery).messages;
}
Snip 剪裁的是对话历史中间部分的消息内容。
为什么是中间
一段对话的历史可以分成三段:
┌──────────────┬───────────────────┬──────────────┐ │ 开头部分 │ 中间部分 │ 最近部分 │ │ 系统指令 │ 工具调用/结果 │ 当前上下文 │ │ 初始上下文 │ 中间推理过程 │ 最新消息 │ └──────────────┴───────────────────┴──────────────┘ 不能动 可以剪裁 不能动
- 开头:系统指令、初始上下文,删了模型不知道自己是谁
- 最近:当前对话状态,删了模型不知道在干嘛
- 中间:大量工具调用结果、已经处理过的中间过程,是最大的 token 消耗者,但信息密度最低
具体剪裁什么
主要是旧的工具调用结果:
用户: 读一下 src/main.ts 模型: (调用 read_file) 结果: “import xxx\n…” (几千行代码)
用户: 这个文件有什么问题? 模型: (分析了一堆)
用户: 再读一下 src/utils.ts 模型: (调用 read_file) 结果: “function xxx…” (几千行代码)
… 过了很多轮 …
用户: 帮我重构一下
此时中间那些 read_file 的结果已经没用了——模型早就消化过了。Snip 就是把这些旧的、已处理的大块内容删掉。
和其他压缩的区别
┌───────────────────────┬──────────────────────┬────────┐ │ 方式 │ 操作什么 │ 粒度 │ ├───────────────────────┼──────────────────────┼────────┤ │ applyToolResultBudget │ 单条工具结果的内容 │ 字段内 │ ├───────────────────────┼──────────────────────┼────────┤ │ microcompact │ 单条消息的保留/丢弃 │ 消息级 │ ├───────────────────────┼──────────────────────┼────────┤ │ snip │ 一批旧消息的整体内容 │ 批次级 │ ├───────────────────────┼──────────────────────┼────────┤ │ autocompact │ 全部历史的摘要 │ 整体 │ └───────────────────────┴──────────────────────┴────────┘
Snip 的特点就是批量清理旧内容,轻量、快速、不调用模型。
boundaryMessage 的作用
剪完之后,消息历史里会出现一个”空洞”:
[消息1] [消息2] … [空洞] … [消息N-1] [消息N]
boundaryMessage 就是插在空洞位置的一个标记,告诉模型:
▎ “从消息 X 到消息 Y 之间的内容已经被移除了,你可以假设它们发生过但不需要关注细节。”
这样模型不会因为突然缺少上下文而困惑。 总的来说 对于很多工具的中间调用以及失败的尝试 作为一名十分合格的agent工程师,你必须需要去考虑中间上下文的管理,而snip就是上下文管理中的一环,给我的启示就是对于一些中间的例如工具调用的过程,为了成本以及模型效果考虑,需要我们人为去设计相关的管理机制 Snip 释放最旧历史,并把释放 token 数传给后续逻辑,减少重复压缩。
microcompact
export async function microcompactMessages(
messages: Message[],
toolUseContext?: ToolUseContext,
querySource?: QuerySource
): Promise<MicrocompactResult>;
这个是核心的函数头,接收完整消息列表、工具上下文、查询来源,返回压缩结果。
// Time-based trigger runs first and short-circuits. If the gap since the
// last assistant message exceeds the threshold, the server cache has expired
// and the full prefix will be rewritten regardless — so content-clear old
// tool results now, before the request, to shrink what gets rewritten.
// Cached MC (cache-editing) is skipped when this fires: editing assumes a
// warm cache, and we just established it's cold.
这个注释其实讲的挺详细的,这是一个基于时间来出发的压缩策略,主要是关于prompt的缓存,如果离开的时间比较长,缓存早就没了,再去做所谓的缓存操作是没有意义的 缓存编辑的前提是缓存还热着。如果缓存已经冷了(过期了),缓存编辑就没意义了——因为反正整个 prefix 都要重写。所以时间触发时跳过 cached MC,直接做暴力清空
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);
}
}
三个条件全部满足才走缓存编辑路径:
条件 1:isCachedMicrocompactEnabled()
功能是否全局开启。
条件 2:isModelSupportedForCacheEditing(model)
当前模型是否支持缓存编辑。不是所有模型都支持这个能力。
条件 3:isMainThreadSource(querySource)
只允许主线程执行。注释解释了为什么:
▎ Only run cached MC for the main thread to prevent forked agents (session_memory, prompt_suggestion, etc.) from registering their tool_results in the global cachedMCState
问题是:forked agent(比如后台的 session_memory、prompt_suggestion 任务)有自己的工具调用。如果让它们也注册到全局缓存状态里,主线程的缓存编辑会误删不存在的工具结果。
主线程: toolu_abc → 注册到缓存状态 ✓ 子 agent: toolu_xyz → 不注册到全局缓存状态 ✗(防止污染) 总的来说是这么一个逻辑
API 缓存还热吗?
│
├── 已过期 → 暴力清空旧结果 → return
│
└── 还热 → 模型支持缓存编辑吗?
│
├── 支持 + 主线程 → cached MC(增量编辑)→ return
│
└── 不支持 → 什么都不做 → return
│
└─ 后续 autocompact 兜底
Microcompact 面向老化工具结果,尤其是巨大但当前价值低的工具日志。
Context collapse
把一组相关消息折叠成一个摘要,用一条消息替代原来的一堆消息。
// Project the collapsed context view and maybe commit more collapses.
// Runs BEFORE autocompact so that if collapse gets us under the
// autocompact threshold, autocompact is a no-op and we keep granular
// context instead of a single summary.
//
// Nothing is yielded — the collapsed view is a read-time projection
// over the REPL's full history. Summary messages live in the collapse
// store, not the REPL array. This is what makes collapses persist
// across turns: projectView() replays the commit log on every entry.
// Within a turn, the view flows forward via state.messages at the
// continue site (query.ts:1192), and the next projectView() no-ops
// because the archived messages are already gone from its input.
可以理解为在整个消息链路上,进行了一次投影,不改变原本的内容 Collapse 不往消息数组里插入新消息。它是一个只读投影——在读取时实时计算,不修改原始数据
投影的时刻
当需要发消息给模型时,实时计算:
projectView(messages, collapseStore)
│
▼
遍历消息数组
│
├── [0] system → 保留
├── [1] user → 保留
├── [2] assistant → 查 collapse store → 被折叠了 → 跳过
├── [3] tool_result → 查 collapse store → 被折叠了 → 跳过
├── [4] assistant → 查 collapse store → 被折叠了 → 跳过
├── [5] tool_result → 查 collapse store → 被折叠了 → 跳过
├── [6] assistant → 保留
│
▼
输出:
[0] system
[1] user
[1.5] collapse_summary: "读了 src/ 和 package.json"
[6] assistant
在一个 turn 内:
- 第一次 projectView() 计算折叠视图
- 视图通过 state.messages 向前传递
- 下一次 projectView() 发现旧消息已经被移除,不需要重新计算
autocompact
这就是最狠的 让llm直接生成摘要的地方
export async function autoCompactIfNeeded(
messages: Message[],
toolUseContext: ToolUseContext,
cacheSafeParams: CacheSafeParams,
querySource?: QuerySource,
tracking?: AutoCompactTrackingState,
snipTokensFreed?: number
): Promise<{
wasCompacted: boolean;
compactionResult?: CompactionResult;
consecutiveFailures?: number;
}>;
如果真的是太大了无法压缩的话 那就直接断了,因为再这么下去也是浪费重试错误
if (tracking?.consecutiveFailures !== undefined && tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
return { wasCompacted: false };
}
// Circuit breaker: stop retrying after N consecutive failures.
// Without this, sessions where context is irrecoverably over the limit
// hammer the API with doomed compaction attempts on every turn.
用已经提取好的 session memory 文件作为摘要,替代传统压缩中”调用模型生成摘要”的步骤。 核心思路:对话过程中已经在后台持续提取关键信息存到 session memory 文件了,压缩时直接用这个文件当摘要,不用再调模型。
const sessionMemoryResult = await trySessionMemoryCompaction(messages, toolUseContext.agentId, recompactionInfo.autoCompactThreshold);
if (sessionMemoryResult) {
setLastSummarizedMessageId(undefined);
runPostCompactCleanup(querySource);
if (feature("PROMPT_CACHE_BREAK_DETECTION")) {
notifyCompaction(querySource ?? "compact", toolUseContext.agentId);
}
markPostCompaction();
return {
wasCompacted: true,
compactionResult: sessionMemoryResult,
};
}
如果开启了自动总结类似的选项的时候 产生了session的总结 这个时候就直接先用这个了
try {
const compactionResult = await compactConversation(
messages,
toolUseContext,
cacheSafeParams,
true, // Suppress user questions for autocompact
undefined, // No custom instructions
true, // isAutoCompact
recompactionInfo
);
setLastSummarizedMessageId(undefined);
runPostCompactCleanup(querySource);
return {
wasCompacted: true,
compactionResult,
consecutiveFailures: 0, // 成功,重置失败计数
};
} catch (error) {
if (!hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT)) {
logError(error);
}
const prevFailures = tracking?.consecutiveFailures ?? 0;
const nextFailures = prevFailures + 1;
if (nextFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
logForDebugging(`autocompact: circuit breaker tripped after ${nextFailures} consecutive failures`, { level: "warn" });
}
return { wasCompacted: false, consecutiveFailures: nextFailures };
}
compactConversation就是llm直接生成对应的总结,最后最重的一环 也因为太重,模型通常会“断片”(忘记当前打开了什么文件、启用了什么技能)。因此,源码在压缩完成后设计了一个“重建阶段”,系统会自动将最近操作的最多 5 个关键文件(每文件限制 5K Tokens)和活跃的 Skill 指令作为附件重新注入回话流
总的来说这是一个渐进式的压缩过程
总结
压缩管线完整总结
工具结果预算 → Snip → Microcompact → Context Collapse → Session Memory → Auto-compact (轻)───────────────────────────────────────────────────────────→(重)
- 工具结果预算(applyToolResultBudget)
操作什么:单条工具结果的内容
怎么做:太长的内容替换成占位符 “…” 成本:极低,纯字符串操作 为什么先做:改内容,为后续按 ID 操作的 microcompact 准备好一致的数据
- Snip
操作什么:一批旧消息的整体内容
怎么做:从对话中间部分批量删除已处理的旧内容 成本:低,不调模型 为什么要做:旧的工具结果是最大的 token 消耗者,但信息密度最低 关键点:释放的 token 数传给后续逻辑,避免重复压缩
- Microcompact
操作什么:单条工具结果的保留/丢弃
怎么做:按 tool_use_id 做决策,决策可缓存 成本:低,增量操作 两种路径:
- 缓存热 + 模型支持 → 缓存编辑(最省)
- 缓存过期 → 暴力清空旧结果内容(快但粗暴)
- Context Collapse
操作什么:一组相关消息
怎么做:把相关消息折叠成结构化摘要,存在独立 store 里 成本:低,不调模型 关键点:读时投影,不修改原始 REPL 数组,跨 turn 持久化 优势:保留分组关系,比 autocompact 更精细
- Session Memory Compact
操作什么:整个对话历史
怎么做:用后台持续提取的 session memory 文件作为摘要,替换旧消息 成本:几乎为零,读文件 前提:有高质量的 session memory 文件 关键点:压缩后仍超阈值则回退到 auto-compact
- Auto-compact
操作什么:整个对话历史
怎么做:调用模型生成摘要,替换所有消息 成本:最高,一次 API 调用 产物:compact boundary + post-compact messages 保护机制:熔断器防止无限重试 关键点:配合缓存复用和反遗忘重注入
一句话总结
┌──────┬──────────────────┬────────────────────┐ │ 层级 │ 方式 │ 核心思路 │ ├──────┼──────────────────┼────────────────────┤ │ 1 │ 工具结果预算 │ 单条太长就截断 │ ├──────┼──────────────────┼────────────────────┤ │ 2 │ Snip │ 旧的批量删 │ ├──────┼──────────────────┼────────────────────┤ │ 3 │ Microcompact │ 按 ID 增量裁剪 │ ├──────┼──────────────────┼────────────────────┤ │ 4 │ Context Collapse │ 相关的折叠成摘要 │ ├──────┼──────────────────┼────────────────────┤ │ 5 │ Session Memory │ 用现成笔记替代摘要 │ ├──────┼──────────────────┼────────────────────┤ │ 6 │ Auto-compact │ 调模型重写历史 │ └──────┴──────────────────┴────────────────────┘
核心原则:能在前面解决的就不到后面去。 前面的轻量操作能压到阈值以下,就不用调模型做重压缩。每一层各司其职,从轻到重,逐级降级。
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- Google Gemini updates: Flash 1.5, Gemma 2 and Project Astra
- Displaying External Posts on Your al-folio Blog
- Graph RAG with Milvus —— 纯向量库造图的多跳推理
- Hierarchical Indices 层级索引 —— 先粗后细的两级检索
- HyDE 与 HyPE —— 假设检索技术的两个方向
- 二叉树刷题总结
- RAG 技术体系化分类——从 Pipeline 阶段到失败模式
- MemoRAG 记忆增强型 RAG 总结
- Microsoft GraphRAG 基于知识图谱的 RAG 总结
- Multimodal RAG with Captioning 图像描述型多模态 RAG 总结