分享Claude Code上下文管理个人学习笔记

2026-04-29 08:532阅读0评论SEO问题
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

[!IMPORTANT]
原文来自我的个人博客,可以在这里阅读,也可以去博客
由于mermaid流程图是AI生成,本文的mermaid都是截图
不支持移动和缩放

上下文管理

[!NOTE]
本文借助CodeX辅助阅读源码,文章内容纯手工,mermaid图片为AI生成

水平有限,仅供自己学习参考,不保证深度

关于cc的源码,网上有太多分析了,但是很多一眼AI生成,很多感觉是追热点新闻。

只要一看到是AI的端倪,我就怀有不安,内心深处总觉得不太能相信,所以我自己亲自动手看看。

虽然我也借助了AI,但是我至少让他给出每个部分的源码在哪,自己理解总结。

而且这部分内容我会复用,作为prompt给我自己的Coding Agent项目重新设计feature/context分支。

光是这一个部分(上下文管理),我就大概花了一周时间弄了个大概,以下是我的学习笔记产出


Context Management在Agent系统中极其重要,下面我将参考Claude Code泄露的源码,针对上下文管理的模块,整理学习。

一轮对话包含的上下文

对于这样的Coding Agent,简单来说

当用户输入消息之后,传递给LLM的消息就包含这样的结构:

+ System Prompt + Tools + Messages ......

如果会话持续特别久,工具产生的消息和用户/AI产生的消息就会充满这个结构,直到超出Context Window

而且,就算没有超出Context Window,也可能因为Transformer的特性,导致模型注意力分散,被一些噪音污染


此外,考虑到后续可能会生成摘要,所以有效的上下文窗口会预留一部分(src/services/compact/autoCompact.ts定义的常量MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000),保证压缩正常运转

Canonical Transcript

在详细分析压缩策略之前,先补充说明一下Canonical Transcript

不知道怎么翻译这个名词,权威抄本?总之这个应该是会话恢复的唯一真信息来源。

当执行会话恢复的时候,会读取这个脚本(一般是JSONL格式存储),通过里面的字段,恢复之前的会话。

JSONL文件存储了包括用户/AI/Tool等消息,这种文件根据项目名和会话id存储在~/.claude/projects/{project_name}/{session_id}.jsonl

每一轮对话或者执行工具,都会记录到这个cannonical transcript中

与JSONL同名的{session_id}这个文件夹下,还可能有subagentstool-results文件夹

本文只针对这个transcript的内容说明一下,特别是对于工具结果,这个脚本有两个字段相关:

transcript1106×1234 133 KB

压缩策略

Claude Code采用了好几种上下文压缩策略,说实话,这部分内容,我搜了不少版本看了一下,感觉太乱了。

好多都说四种、五种压缩策略,然后每一个“四层压缩策略”里面的四个内容,每个文章还不一样!

我不敢说我这个有多么正确,我尽量参考源码给出的内容,输出自己理解的上下文管理。

参考src/query.ts里面的async function* queryLoop函数

里面基本上每个queryCheckpoint('xxx_start')queryCheckpoint('xxx_end')包围的,就是一个模块。

我认为总的来说,应该包含这几个模块:

  • Tool Result Budget: 处理工具结果的时候就考虑context的预算,如果结果很大,不可能一下子给LLM
  • History Snip: 这个部分用feature('HISTORY_SNIP')判定执行的,实际实现未知,本文不讨论
  • Microcompact: 分两种,time-based和cached,前者代码层面处理过时的工具结果;后者应该是Anthropic那边特殊的处理,也用了feature()包围
  • Context Collapse: 这个部分也是`feature(‘CONTEXT_COLLAPSE’)包围的,实际实现未知,本文不讨论
  • Auto-Compact: 这个Auto-Compact模块,检测上下文压力,具体执行压缩又分为Session Memory CompactFull Compact

feature1284×1348 179 KB

Tool Result Budget

大概就是限制工具结果的逻辑

对于工具执行产生的结果,如果工具产生的结果太大,那就得持久化它。(比如执行find或者grep产生的结果)

持久化的路径一般是~/.claude/projects/{project_name}/{session_id}/tool-results/{tool_use_id}.txt

Claude Code工具产生的结果的处理非常精妙,不同工具产生结果的处理策略也完全不同。

一般而言,每个工具内置声明了maxResultSizeChars,然后结果经过mapToolResultToToolResultBlockParam()处理之后,再给LLM或者前端渲染。

如果工具结果太大,前端就不会渲染具体结果。

src/Tools.ts对此有这样的定义:

mapToolResultToToolResultBlockParam( content: Output, toolUseID: string, ): ToolResultBlockParam /** * Optional. When omitted, the tool result renders nothing (same as returning * null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite * updates the todo panel, not the transcript). */


具体来说,每个类型的工具对于结果阈值(决定是否需要持久化)如下表:

类别 工具 单个结果外置阈值
Shell 工具 Bash, PowerShell 30,000 字符
搜索工具 Grep 20,000 字符
认证工具 McpAuth 10,000 字符
Read 工具 FileReadTool 特殊处理
其他普通工具 xxx 50,000 字符

也就是说,如果单个工具产生的结果超过了这个阈值,就会将结果存储到~/.claude/projects/{project_name}/{session_id}/tool-results/{tool_use_id}.txt,最大保留64MB,超过会截断。

如果没单个工具超过阈值,但是同一轮多个并行执行的工具结果合并到一起,超过某个阈值(src/constants/toolLimits.ts定义了MAX_TOOL_RESULTS_PER_MESSAGE_CHARS是200K字符),就会持久化最大、最新的工具结果,JSONL额外写入标记content-replacement metatdata

如果单个工具没超过阈值,并且一轮工具结果合并也没超过,就保留原文到JSONL里

工具结果聚合的逻辑大概是不断聚合tool_result然后看是否超过200K字符,如果超过,就不断从最新的、最大结果开始外置到文件夹tool-results,然后JSONL的内容替换成persisted-output preview。其实也很复杂,本文不多赘述吧。


我觉得这里面最特殊的就是Read,因为如果读了一个超大文件,用同样的方法,因为超过阈值就把结果外置到{tool_use_id}.txt,那这个txt文件还是一个超大文件,根本没啥用。

所以Read工具首先对文本文件设置了默认限制maxSizeBytes为256KB,maxTokens设定为25K,并且根据函数参数,分段读取大文件。(参考src/tools/FileReadTool/limits.ts

Read工具有几个参数:file_path, offset, limitpagespages用在读取pdf文件)

其中,offsetlimit分别用来控制,从哪一行开始读、读多少行。如果发现前面三个参数都一样,并且文件修改时间没变,那就不需要读,直接返回file_unchanged

参考: src/tools/FileReadTool/FileReadTool.ts

每一次读执行成功(可能是大文件分段读)之后,结果写入canonical transcript JSONL文件

也就是:

{ ... "message": { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_xxx", "content": "101\t这是第101行\n102\t ..." } ] }, ... "toolUseResult": { "type": "text", "file": { "filePath": "FileReadTool.ts", "content": "...这部分原文,用于前端渲染/会话恢复...", "numLines": 80, "startLine": 101, "totalLines": 1000 } }, ... }

这两份信息都可以在JSONL找到,第一个message.content就是实际给大模型的,这个内容是带行号的,会进入上下文。

注意,第一个字段的message.content里面的工具结果是可能会被加工的,比如带行号(cat -n风格)、安全提醒、空输出提醒、图片的结果、被压缩之后的结果。

(比如bash工具产生的超大输出可能会被外置,然后给LLM看的内容只有<persisted-output>这种标记)

第二个toolUseResult是工具调用时的、结构化的原始结果,用来前端渲染/会话恢复,所以渲染UI的时候,基本是原始版本,不会把参杂加工版本的内容渲染。

(比如被工具消息压缩之后,关掉了CLI,然后再resume会话,UI统计渲染这个工具读了多少行,就得参考原始结果,而不是压缩之后的行数)

这里是我用GPT-5.4分析读工具执行流程之后,生成的mermaid流程图,描述了Read工具的流程:

00READ1536×2486 255 KB

ipynbimagepdf的读取就不赘述了。


当然,如果文件真的很大,就算分段读,最后发给大模型仍然逼近/超过上下文窗口怎么办?

所以得启动压缩策略。

Microcompact

src/services/compact/microCompact.ts的第253行开始可以看到,微压缩分为两种路径,第一种是time-based microcompact,第二种是cached microcompact。


Time-based Microcompact

第一种是基于时间的微压缩,其实就是因为服务器那边缓存了prompt,但是prompt也是有TTL的,时间过去很久的话,这个缓存会失效。

我查了一下deepseek和qwen的API文档,都有大概说明缓存的TTL。DeepSeek文档说时间一般为几个小时到几天?Qwen说显式缓存5分钟,隐式缓存不确定。

Claude Code这部分在src/services/compact/microCompact.ts有设定阈值,默认是60分钟(gapThresholdMinutes = 60)。根据当前时间和最后一条assistant message的时间戳,来决定是否出触发。

假如时间过的很久,那么上一次调用的缓存大概率失效了,既然缓存失效了,下一次请求无论如何肯定要把前面的context给LLM重新计算。

既然这个开销无论如何都会产生,那就不如在发送请求之前,把旧工具的结果清除了,减少重写内容和开销。

个人理解大概是这样: A. 缓存还在 [system + tools + history] + {新对话}: 只需要计算{新对话} B. 缓存失效 {system + tools + history + 新对话}: 全都要重新计算

这部分compact设定了白名单,只有Read, Shell, Grep, Glob, WebSearch, WebFetch, EditWrite工具才能微压缩。

这部分工具,要么是可以通过canonical transcript中结构化的toolUseResult字段的参数来重新获取/复现结果;要么是信息量比较低/时效性比较强的结果。

而且,还有个参数keepRecent = 5设定了保留最近的5个工具的结果,只把比较旧的结果清理,也就是结果换成[Old tool result content cleared]

所以如果启用了time-based microcompact,并且满足触发条件。在本轮API请求之前,即将发送给LLM的上下文里面,会把旧工具结果替换掉(不影响canonical transcript)。参考src/query.ts


Cached Microcompact

第二种是已经缓存的内容的压缩,这个好像是API服务端那边做的事情。

大概是,cache在API服务器还存在的时候,借助API的cache editing能力删除缓存里的旧工具结果。

这个完全是Claude Code独占,下面po一个grok expert模式的调研:

grok1614×1814 389 KB


总的来说,Microcompact的mermaid流程图如下:

01MICRO1536×3778 342 KB

Auto Compact

参考:src/services/compact/autoCompact.ts,这个文件写的真不错,挺清晰的,其实也不用分析什么了。

和前文说的一样,这里会计算有效的上下文窗口:getEffectiveContextWindowSize(model)

预留了20_000的token,用于后续摘要/压缩

有效窗口再扣除AUTOCOMPACT_BUFFER_TOKENS = 13_000,得到的就是Auto Compact的触发阈值gautocompactThreshold

然后开始判断是否需要compact,查看函数async function shouldAutoCompact

他先做了几个判断,首先如果传入的参数querySourcesession_memory/compact/marble_origami/*tengu_cobalt_raccoon*就返回false

[!TIP]
这里后面两个名字挺有意思的,日语和英语混杂
最后一个cobalt和raccoon联合google一下居然能扯上生化危机,浣熊市是吧
目前意义不明,感兴趣可以看看源码

ps: marble_origami看注释说是上下文agent,泄露的源码里面就这里出现了,还有一处是src/utils/analyzeContext.ts的注释

再进一步估算当前消息的token数量,通过calculateTokenWarningState状态判断函数计算几个层级的阈值

看一下这个函数的定义就知道了:

export function calculateTokenWarningState( tokenUsage: number, model: string, ): { percentLeft: number isAboveWarningThreshold: boolean isAboveErrorThreshold: boolean isAboveAutoCompactThreshold: boolean isAtBlockingLimit: boolean }

简单理解就是当前的tokenCount要是超过了auto compact的阈值,那么这个shouldAutoCompact函数肯定返回true

然后这个函数最终在autoCompactIfNeeded这个函数被调用,这个函数总体大概流程是这样:

  1. 熔断保护

    如果连续auto compact失败三次,就不压缩了 (三次是因为MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES常量设定,可能是因为有些消息/输入本身就是直接超出上下文窗口了)

  2. 判断触发条件

    就是调用shouldAutoCompact函数,检查上下文有没有达到阈值

  3. 构造RecompactionInfo

    这部分应该是主要记录当前上下文是否是连续压缩的/上一次压缩过了多少轮对话/上一次压缩的turnID/模型auto compact阈值

  4. 尝试Session Memory Compact

    不过这里注释写的是EXPERIMENT: Try session memory compaction first,不知道实际使用的时候有没有开启

  5. 如果Session Memroy Compact失败,调用Full Compact

    调用的是compactConversation函数,这里就是真正总结历史,压缩成摘要了。

  6. 收尾

    看这个代码挺直观的

    try { const compactionResult = await compactConversation( messages, toolUseContext, cacheSafeParams, true, // Suppress user questions for autocompact undefined, // No custom instructions for autocompact true, // isAutoCompact recompactionInfo, ) // Reset lastSummarizedMessageId since legacy compaction replaces all messages // and the old message UUID will no longer exist in the new messages array setLastSummarizedMessageId(undefined) // 收尾1 runPostCompactCleanup(querySource) // 收尾2 return { wasCompacted: true, compactionResult, // Reset failure count on success consecutiveFailures: 0, } } catch (error) ...

  7. 如果full compact也失败,就是上面catch到的error处理,连续压缩失败次数加一


Session Memory Compact

那么具体来说,Session Memory Compact是怎么压缩的?

  1. Session Memory Extraction
  2. Session Memory Compact

首先得知道Session Memory这个名词的含义,其实就是由forked agent创建的结构化的markdown文件

这里涉及multi-agent的系统,但是我还不太清楚。

目前知道的是,主对话每次LLM完成回复,都会经过Post-Sampling Hook,关于是否提取session memory,有一系列判断条件。

比如,必须是主线程、开启了这个功能以及shouldExtractMemory(messages)函数返回真值。

这个函数判断的条件是根据当前上下文 + 上一次提取session memory增长的token数 + 工具调用次数 联合判断的:

  1. session memory初始化的触发条件是当前上下文超过10K tokens
  2. 初始化之后,必须达到currentTokenCount - tokensAtLastExtraction >= 5K
  3. 要么是上一轮对话中LLM没有调用工具;要么是上一轮工具调用超过三次,就会触发提取session memory

直接看源码src/services/SessionMemory/sessionMemory.ts:

// Trigger extraction when: // 1. Both thresholds are met (tokens AND tool calls), OR // 2. No tool calls in last turn AND token threshold is met // (to ensure we extract at natural conversation breaks) // // IMPORTANT: The token threshold (minimumTokensBetweenUpdate) is ALWAYS required. // Even if the tool call threshold is met, extraction won't happen until the // token threshold is also satisfied. This prevents excessive extractions. const shouldExtract = (hasMetTokenThreshold && hasMetToolCallThreshold) || (hasMetTokenThreshold && !hasToolCallsInLastTurn) // 这里hasMetTokenThreshold是session memory已经初始化后,意思是增长超过5K tokens

触发之后,就会启动一个forked agent

这个forked agent在后台异步进行总结之前会话,然后生成结构化的summary.md

这个agent只能用文件工具,只能编辑summary.md,所以比较安全,然后文件在~/.claude/projects/{project_name}/{session_id}/session-memory/summary.md

这个文件的结构如下:

export const DEFAULT_SESSION_MEMORY_TEMPLATE = ` # Session Title _A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_ # Current State _What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._ # Task specification _What did the user ask to build? Any design decisions or other explanatory context_ # Files and Functions _What are the important files? In short, what do they contain and why are they relevant?_ # Workflow _What bash commands are usually run and in what order? How to interpret their output if not obvious?_ # Errors & Corrections _Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_ # Codebase and System Documentation _What are the important system components? How do they work/fit together?_ # Learnings _What has worked well? What has not? What to avoid? Do not duplicate items from other sections_ # Key results _If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_ # Worklog _Step by step, what was attempted, done? Very terse summary for each step_ `

传给这个forked agent的prompt是src/srvices/SessionMemory/prompt.tsgetDefaultUpdatePrompt返回的字符串。

最后这个部分完成之后,记录这次提取的tokens数、更新lastSummarizedMessageId(方便知道这次memory覆盖到哪条消息)


参考src/services/compact/sessionMemoryCompact.ts

比如,必须是主线程、开启了这个功能以及shouldExtractMemory(messages)函数返回真值。

前面条件都符合之后,就开始进入async function trySessionMemoryCompaction函数

首先检查功能是否启用,等待正在进行session memory提取的工作(可能会有后台运行的forked agent还没完成summary.md)。

接下来,首先获取更新的lastSummarizedMessageId以及这个session memory的内容summary.md

通过这个消息的uuid查找在消息内的索引lastSummarizedIndex;当然有一个特殊情况就是lastSummarizedMessageId不存在,这是因为当前会话是resume来的,这个uuid只在内存存在,新开的会话resume之后,还没有这个uuid。这种情况Index设置为最后一条消息的位置。

然后要通过这个lastSummarizedIndex计算startIndex(也就是真正开始保留消息的索引),从startIndex往后的消息都保留原样。

正常逻辑来说,lastSummarizedIndex表示的是summary.md能覆盖到的消息的索引,所以startIndex应该就是从lastSummarizedIndex + 1开始。

但是有很多特殊情况,需要通过calculateMessagesToKeepIndex来计算真正的startIndex(这个startIndex是有可能比summraized index + 1小的)

比如:

index N - 1 -> xxxxx index N -> compact_boundary index N + 1 -> 用户问问题 index N + 2 -> LLM回答 index N + 3 -> tool_use index N + 4 -> tool_result <- lastSummarizedIndex index N + 5 -> 用户追问 <- 理论上的startIndex index N + 6 -> LLM只回答 index N + 7 -> 用户问问题2 index N + 8 -> LLM只回答2 ......

[startIndex, ..., end]后面的内容是保留区域,startIndex默认是在summarized index后面,但是会动态调整。

  • 比如,保留区域的token数不能太小,否则就会往前扩展(startIndex减小)。默认是保留区域希望是10K tokens并且至少包含5个text block的消息(// TODO: 解释text block)

  • 往前扩展的时候,往前一个下标,就计算一次token,如果保留区域的token数太大,达到大约40K tokens就停止;

  • 并且,往前扩展的时候,不能超过compact_boundary这个标记;

  • 并且,往前扩展的时候,不能把tool_use和tool_result这一对拆分开,这两个必须对应。

  • 还有一种情况是LLM回答的时候分片了,多个message共享一个id,这个也不能拆开。

(参考:src/services/compact/sessionMemoryCompact.tscalculateMessagesToKeepIndexadjustIndexToPreserveAPIInvariants)

最后完成session memory compact之后,通过buildPostCompactMessages组装得到:

[compact_boundary] [session memory summary] [messages to keep] [attachments] [hook results]

如果Session Memory Compact不能用,或者压缩之后,仍然超过Auto-Compact的阈值,就执行最后的压缩Full Compact

Full Compact

除了前面的步骤(Auto-Compact触发),还可以用户通过slash命令(/compact [user_messages])主动触发。

[!NOTE]
不过通过/compact命令,没有输入任何指示的时候,仍然会先尝试Session Memory Compact;如果带有指示,就会直接进入Full Compact
参考src/commands/compact/compact.ts

下面参考src/services/compact/compact.ts分析

主要是async function compactConversation这个函数

除了一开始的前置检查+token估算,就是执行PreCompact Hooks,这个函数第一个参数内就包含了判断是auto | manual

const hookResult = await executePreCompactHooks( { trigger: isAutoCompact ? 'auto' : 'manual', customInstructions: customInstructions ?? null, }, ... ) // 这个地方,如果是/compact触发的,参数customInstrcutions就不会是null // 如果是自动触发,这个地方就是null

后续针对这个hookResult,会合并instructions

这部分其实就是额外补充一下compact的prompt

然后开始构造compact prompt

compact prompt大概如下: [NO_TOOLS_PREAMBLE]: 简单来说就是禁止调用工具以及用特定的 <analysis> block和<summary> block [BASE_COMPACT_PROMPT]: 要求结构化的压缩信息 [Additional Instructions] [NO_TOOLS_TRAILER]: 再次强调不要调用工具以及用特定的 <analysis> block和<summary> block

然后这个compactPrompt封装用户消息,变量名summaryRequest,作为参数传给streamCompactSummary()函数(真正的核心部分)

summaryResponse = await streamCompactSummary({ messages: messagesToSummarize, // compactConversation的参数,在auto-compact那里传进来的 summaryRequest, // compact prompt包装的用户消息 appState, // context, // ToolUseContext preCompactTokenCount, // token统计 cacheSafeParams: retryCacheSafeParams, }) // 这个函数内部实现,也是异步调用`runForkedAgent()` summary = getAssistantMessageText(summaryResponse) // 提取文本

这个summary就是最终的压缩结果,这个大概是:

<analysis> ... </analysis> <summary> # 结构化摘要 每个section固定 1. Primary Request and Intent: 2. xxx # 完整模版查阅`src/services/compact/prompt.ts`的BASE_COMPACT_PROMPT </summary>


后续还有针对summary更健壮的处理,有几种情况:

  1. 带有PTL标记: (Prompt Too Long)会把旧消息裁剪(truncateHeadForPTLRetry),再重试
  2. 不带PTL标记: 退出死循环,检查是否为空,空的话直接抛出错误;检查是否带有api error前缀,同样抛出错误

然后就是考虑把旧的状态存储一下、缓存清除;然后把新的summary替换原来的旧历史

首先是清理压缩之前的context.readFileState,清理之前先保存;这部分还跟工具缓存有点关系,太复杂了,建议看源码。

其次,再对压缩进行一些后处理

因为压缩之后清理了一些状态,这里要补充一些关键的状态,比如:

  • 前面保存的readFileState
  • 压缩后需要重新注入的运行时状态
  • plan和plan模式的一些指示
  • 调用涉及的skill相关的
  • deferred/agent/MCP相关的

这些状态都通过postCompactFileAttachments.push()补充。

然后才执行SessionStart hooks: 收集这些hooks返回的消息,最后追加到最终的上下文


然后再创建compact boundary标记,用来作文新的上下文的起点

封装summary成用户消息;记录压缩前后的大小、统计压缩后到token、判断是否下一轮再次压缩;

最终Full Compact完成后,得到的消息大概如下:

[compact boundary] [summary messages] [attachments] [hook results]

最后这组值,加上一些token、usage和userDisplayMessage返回给auto-compact

再交给query.ts通过buildPostCompactMessages()组装压缩后的消息,把当前的messages替换成上述这份新的上下文

杂项

apiMicrocompact.ts

这个文件名感觉有点误导,但是实际上做的事情只有构造请求体好像

Reactive Compact

这个应该算是错误处理的一部分,泄露的源码好像也没有出现实现的细节。

参考src/query.ts的1119行,还有src/commands/compact/compact.ts

多个文件提及reactiveCompact.ts但是泄露的源码似乎没有这个文件

Partial Compact

部分压缩?这部分也是一样

src/services/compact/compact.ts能找到partialCompactConversation函数(看了一下和full compact一样都是调用streamCompactSummary()函数,后台agent生产结构化摘要)

流程和Full Compact很相似,先决定压缩/保留区域,构造prompt,生成summary,生成attachments,执行sessionStart hooks和创建compact boundary再包装成message

最终可能是这样的:

[compact boundary] [partial compact summary] [messagesToKeep]: 有点像session memory compact保留区域 [attachments] [hookResults]

但是这个函数,从泄露的代码来看,只有src/screens/REPL.tsx调用了它,更像是前端交互层的功能。

简单google一下,发现官方repo有不少issue关于这个,比如issue#26488

从源码来看,在claude code的CLI页面,双击esc会弹出一个rewind,选中消息之后,有一个选项是Summarize from here

也就是我们用户自己主动选择范围触发的partial compact

这个触发之后,UI会渲染一个Summarized conversation,如果你ctrl + o展开的话:

⏺ Summarized conversation ⎿ This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.` Summary: 1. Primary Request and Intent: xxxx 2. Key Technical Concepts: xxxx 3. Files and Code Sections: xxxx 4. Errors and fixes: xxxx 5. Problem Solving: xxxx 6. All user messages: xxxx 7. Pending Tasks: xxxx 8. Current Work: xxxx 9. Optional Next Step: If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/chen/.claude/projects/{project_name}/{session_id}.jsonl

能看见很明显的结构化的markdown格式,并且最后给出了canonical transcript的路径,这一点和Full Compact几乎一样(src/services/compact/prompt.tsBASE_COMPACT_PROMPTPARTIAL_COMPACT_PROMPT)。

感悟

真的复杂,而且这还只是冰山一角,似乎有好多内容是这份源码没有逆向到的。

之前还经常有人说agent不过是prompt engineering,但是我看了这个内容,太复杂了…

这不是简单的一句prompt工程就能构造出来的

这里面还有很多我不知道的,比如hooks系统、工具系统、MCP、Multi-Agents等等等等

而且,API服务器那边,还有KV Cache、服务端怎么处理过时的消息的…

直接晕了

网友解答:
--【壹】--:

最近也在看上下文这一块,学习了 谢谢大佬。


--【贰】--:

我最开始先跟着langchain官网学了一点langgraph的:

langgraph基础

你可以看看,里面基本就是 定义状态、结点、边、构造图、内存/持久化、snapshot这几个概念就是核心


--【叁】--:

非常有价值的内容,学习到了,上下文大小、提示词是直接影响效果的关键点


--【肆】--:

我就说一下我目前的理解吧,首先langgraph主要涉及的是结点、边这样的控制逻辑(也就是graph)

也可以理解为状态机,我先用langgraph构造了一个ReAct循环,然后添加一些tools,加一些context管理和session resume的功能。大概就是目前的样子。

最开始我其实是参考gemini-cli的开源代码的,然后一开始自己构思+搓的graph。就分CLI层, Core层, LLM和Tool层,大概这样,然后参考的gemini-cli的event bus机制(cc里面是hook 感觉都差不多)就可以每一层通信了。


--【伍】--:

佬,能不能先说一下你github这个项目是纯langgraph就搭起来做成现在这种cli产品效果吗?我不大明白具体langgraph实现出来的是个啥捏


--【陆】--:

太牛了佬,最近在研究知识图谱,就是有节点,串联知识。


--【柒】--:

哈哈,其实我现在遇到的情况和你非常像!那我也先学习一下langgraph怎么个事


--【捌】--:

对佬实现的这个MT-Agent项目有点好奇,我最近也是遇到个需求想要实现在某个硬件上对某个需求自动化处理的agent。佬你这是直接用LangGraph + 大量的vibe coding手搓了个简化版gemini cli出来吗?为什么不直接用现成的gemini cli或者说CC的安装包呢。


--【玖】--:

感谢佬友分享,期待长更!最近面试经常被问到claude code源码中的知识


--【拾】--:

这篇文章的内容是真的高质量,很多都是网上垃圾教程不会讲到分析到的东西


--【拾壹】--:

一大早吃不下啊,我先摸会鱼再来看 ,太长了


--【拾贰】--:

感谢佬友分享,写的真的太好了,会持续关注更新


--【拾叁】--:

看看后续有没有时间研究一下它的multi-agent吧

这部分真感觉有点像网上说的,agent类似操作系统:

multi-agent就是类似进程和线程,子agent能访问主agent的上下文(共享进程资源),子agent来完成具体执行。

上下文管理类似缓存管理,都是有限的窗口内、可能涉及快满了就考虑替换的策略(LRU什么的)


--【拾肆】--:

因为这只是我用来学习的项目 不是用来生产的 主要是学习langgraph框架和agent而已 (顺便糊弄一下导师)

真正生产肯定还是用gemini cli/cc/codex


--【拾伍】--:

claudecode真的很强,就是账号不太好搞

问题描述:

[!IMPORTANT]
原文来自我的个人博客,可以在这里阅读,也可以去博客
由于mermaid流程图是AI生成,本文的mermaid都是截图
不支持移动和缩放

上下文管理

[!NOTE]
本文借助CodeX辅助阅读源码,文章内容纯手工,mermaid图片为AI生成

水平有限,仅供自己学习参考,不保证深度

关于cc的源码,网上有太多分析了,但是很多一眼AI生成,很多感觉是追热点新闻。

只要一看到是AI的端倪,我就怀有不安,内心深处总觉得不太能相信,所以我自己亲自动手看看。

虽然我也借助了AI,但是我至少让他给出每个部分的源码在哪,自己理解总结。

而且这部分内容我会复用,作为prompt给我自己的Coding Agent项目重新设计feature/context分支。

光是这一个部分(上下文管理),我就大概花了一周时间弄了个大概,以下是我的学习笔记产出


Context Management在Agent系统中极其重要,下面我将参考Claude Code泄露的源码,针对上下文管理的模块,整理学习。

一轮对话包含的上下文

对于这样的Coding Agent,简单来说

当用户输入消息之后,传递给LLM的消息就包含这样的结构:

+ System Prompt + Tools + Messages ......

如果会话持续特别久,工具产生的消息和用户/AI产生的消息就会充满这个结构,直到超出Context Window

而且,就算没有超出Context Window,也可能因为Transformer的特性,导致模型注意力分散,被一些噪音污染


此外,考虑到后续可能会生成摘要,所以有效的上下文窗口会预留一部分(src/services/compact/autoCompact.ts定义的常量MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000),保证压缩正常运转

Canonical Transcript

在详细分析压缩策略之前,先补充说明一下Canonical Transcript

不知道怎么翻译这个名词,权威抄本?总之这个应该是会话恢复的唯一真信息来源。

当执行会话恢复的时候,会读取这个脚本(一般是JSONL格式存储),通过里面的字段,恢复之前的会话。

JSONL文件存储了包括用户/AI/Tool等消息,这种文件根据项目名和会话id存储在~/.claude/projects/{project_name}/{session_id}.jsonl

每一轮对话或者执行工具,都会记录到这个cannonical transcript中

与JSONL同名的{session_id}这个文件夹下,还可能有subagentstool-results文件夹

本文只针对这个transcript的内容说明一下,特别是对于工具结果,这个脚本有两个字段相关:

transcript1106×1234 133 KB

压缩策略

Claude Code采用了好几种上下文压缩策略,说实话,这部分内容,我搜了不少版本看了一下,感觉太乱了。

好多都说四种、五种压缩策略,然后每一个“四层压缩策略”里面的四个内容,每个文章还不一样!

我不敢说我这个有多么正确,我尽量参考源码给出的内容,输出自己理解的上下文管理。

参考src/query.ts里面的async function* queryLoop函数

里面基本上每个queryCheckpoint('xxx_start')queryCheckpoint('xxx_end')包围的,就是一个模块。

我认为总的来说,应该包含这几个模块:

  • Tool Result Budget: 处理工具结果的时候就考虑context的预算,如果结果很大,不可能一下子给LLM
  • History Snip: 这个部分用feature('HISTORY_SNIP')判定执行的,实际实现未知,本文不讨论
  • Microcompact: 分两种,time-based和cached,前者代码层面处理过时的工具结果;后者应该是Anthropic那边特殊的处理,也用了feature()包围
  • Context Collapse: 这个部分也是`feature(‘CONTEXT_COLLAPSE’)包围的,实际实现未知,本文不讨论
  • Auto-Compact: 这个Auto-Compact模块,检测上下文压力,具体执行压缩又分为Session Memory CompactFull Compact

feature1284×1348 179 KB

Tool Result Budget

大概就是限制工具结果的逻辑

对于工具执行产生的结果,如果工具产生的结果太大,那就得持久化它。(比如执行find或者grep产生的结果)

持久化的路径一般是~/.claude/projects/{project_name}/{session_id}/tool-results/{tool_use_id}.txt

Claude Code工具产生的结果的处理非常精妙,不同工具产生结果的处理策略也完全不同。

一般而言,每个工具内置声明了maxResultSizeChars,然后结果经过mapToolResultToToolResultBlockParam()处理之后,再给LLM或者前端渲染。

如果工具结果太大,前端就不会渲染具体结果。

src/Tools.ts对此有这样的定义:

mapToolResultToToolResultBlockParam( content: Output, toolUseID: string, ): ToolResultBlockParam /** * Optional. When omitted, the tool result renders nothing (same as returning * null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite * updates the todo panel, not the transcript). */


具体来说,每个类型的工具对于结果阈值(决定是否需要持久化)如下表:

类别 工具 单个结果外置阈值
Shell 工具 Bash, PowerShell 30,000 字符
搜索工具 Grep 20,000 字符
认证工具 McpAuth 10,000 字符
Read 工具 FileReadTool 特殊处理
其他普通工具 xxx 50,000 字符

也就是说,如果单个工具产生的结果超过了这个阈值,就会将结果存储到~/.claude/projects/{project_name}/{session_id}/tool-results/{tool_use_id}.txt,最大保留64MB,超过会截断。

如果没单个工具超过阈值,但是同一轮多个并行执行的工具结果合并到一起,超过某个阈值(src/constants/toolLimits.ts定义了MAX_TOOL_RESULTS_PER_MESSAGE_CHARS是200K字符),就会持久化最大、最新的工具结果,JSONL额外写入标记content-replacement metatdata

如果单个工具没超过阈值,并且一轮工具结果合并也没超过,就保留原文到JSONL里

工具结果聚合的逻辑大概是不断聚合tool_result然后看是否超过200K字符,如果超过,就不断从最新的、最大结果开始外置到文件夹tool-results,然后JSONL的内容替换成persisted-output preview。其实也很复杂,本文不多赘述吧。


我觉得这里面最特殊的就是Read,因为如果读了一个超大文件,用同样的方法,因为超过阈值就把结果外置到{tool_use_id}.txt,那这个txt文件还是一个超大文件,根本没啥用。

所以Read工具首先对文本文件设置了默认限制maxSizeBytes为256KB,maxTokens设定为25K,并且根据函数参数,分段读取大文件。(参考src/tools/FileReadTool/limits.ts

Read工具有几个参数:file_path, offset, limitpagespages用在读取pdf文件)

其中,offsetlimit分别用来控制,从哪一行开始读、读多少行。如果发现前面三个参数都一样,并且文件修改时间没变,那就不需要读,直接返回file_unchanged

参考: src/tools/FileReadTool/FileReadTool.ts

每一次读执行成功(可能是大文件分段读)之后,结果写入canonical transcript JSONL文件

也就是:

{ ... "message": { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_xxx", "content": "101\t这是第101行\n102\t ..." } ] }, ... "toolUseResult": { "type": "text", "file": { "filePath": "FileReadTool.ts", "content": "...这部分原文,用于前端渲染/会话恢复...", "numLines": 80, "startLine": 101, "totalLines": 1000 } }, ... }

这两份信息都可以在JSONL找到,第一个message.content就是实际给大模型的,这个内容是带行号的,会进入上下文。

注意,第一个字段的message.content里面的工具结果是可能会被加工的,比如带行号(cat -n风格)、安全提醒、空输出提醒、图片的结果、被压缩之后的结果。

(比如bash工具产生的超大输出可能会被外置,然后给LLM看的内容只有<persisted-output>这种标记)

第二个toolUseResult是工具调用时的、结构化的原始结果,用来前端渲染/会话恢复,所以渲染UI的时候,基本是原始版本,不会把参杂加工版本的内容渲染。

(比如被工具消息压缩之后,关掉了CLI,然后再resume会话,UI统计渲染这个工具读了多少行,就得参考原始结果,而不是压缩之后的行数)

这里是我用GPT-5.4分析读工具执行流程之后,生成的mermaid流程图,描述了Read工具的流程:

00READ1536×2486 255 KB

ipynbimagepdf的读取就不赘述了。


当然,如果文件真的很大,就算分段读,最后发给大模型仍然逼近/超过上下文窗口怎么办?

所以得启动压缩策略。

Microcompact

src/services/compact/microCompact.ts的第253行开始可以看到,微压缩分为两种路径,第一种是time-based microcompact,第二种是cached microcompact。


Time-based Microcompact

第一种是基于时间的微压缩,其实就是因为服务器那边缓存了prompt,但是prompt也是有TTL的,时间过去很久的话,这个缓存会失效。

我查了一下deepseek和qwen的API文档,都有大概说明缓存的TTL。DeepSeek文档说时间一般为几个小时到几天?Qwen说显式缓存5分钟,隐式缓存不确定。

Claude Code这部分在src/services/compact/microCompact.ts有设定阈值,默认是60分钟(gapThresholdMinutes = 60)。根据当前时间和最后一条assistant message的时间戳,来决定是否出触发。

假如时间过的很久,那么上一次调用的缓存大概率失效了,既然缓存失效了,下一次请求无论如何肯定要把前面的context给LLM重新计算。

既然这个开销无论如何都会产生,那就不如在发送请求之前,把旧工具的结果清除了,减少重写内容和开销。

个人理解大概是这样: A. 缓存还在 [system + tools + history] + {新对话}: 只需要计算{新对话} B. 缓存失效 {system + tools + history + 新对话}: 全都要重新计算

这部分compact设定了白名单,只有Read, Shell, Grep, Glob, WebSearch, WebFetch, EditWrite工具才能微压缩。

这部分工具,要么是可以通过canonical transcript中结构化的toolUseResult字段的参数来重新获取/复现结果;要么是信息量比较低/时效性比较强的结果。

而且,还有个参数keepRecent = 5设定了保留最近的5个工具的结果,只把比较旧的结果清理,也就是结果换成[Old tool result content cleared]

所以如果启用了time-based microcompact,并且满足触发条件。在本轮API请求之前,即将发送给LLM的上下文里面,会把旧工具结果替换掉(不影响canonical transcript)。参考src/query.ts


Cached Microcompact

第二种是已经缓存的内容的压缩,这个好像是API服务端那边做的事情。

大概是,cache在API服务器还存在的时候,借助API的cache editing能力删除缓存里的旧工具结果。

这个完全是Claude Code独占,下面po一个grok expert模式的调研:

grok1614×1814 389 KB


总的来说,Microcompact的mermaid流程图如下:

01MICRO1536×3778 342 KB

Auto Compact

参考:src/services/compact/autoCompact.ts,这个文件写的真不错,挺清晰的,其实也不用分析什么了。

和前文说的一样,这里会计算有效的上下文窗口:getEffectiveContextWindowSize(model)

预留了20_000的token,用于后续摘要/压缩

有效窗口再扣除AUTOCOMPACT_BUFFER_TOKENS = 13_000,得到的就是Auto Compact的触发阈值gautocompactThreshold

然后开始判断是否需要compact,查看函数async function shouldAutoCompact

他先做了几个判断,首先如果传入的参数querySourcesession_memory/compact/marble_origami/*tengu_cobalt_raccoon*就返回false

[!TIP]
这里后面两个名字挺有意思的,日语和英语混杂
最后一个cobalt和raccoon联合google一下居然能扯上生化危机,浣熊市是吧
目前意义不明,感兴趣可以看看源码

ps: marble_origami看注释说是上下文agent,泄露的源码里面就这里出现了,还有一处是src/utils/analyzeContext.ts的注释

再进一步估算当前消息的token数量,通过calculateTokenWarningState状态判断函数计算几个层级的阈值

看一下这个函数的定义就知道了:

export function calculateTokenWarningState( tokenUsage: number, model: string, ): { percentLeft: number isAboveWarningThreshold: boolean isAboveErrorThreshold: boolean isAboveAutoCompactThreshold: boolean isAtBlockingLimit: boolean }

简单理解就是当前的tokenCount要是超过了auto compact的阈值,那么这个shouldAutoCompact函数肯定返回true

然后这个函数最终在autoCompactIfNeeded这个函数被调用,这个函数总体大概流程是这样:

  1. 熔断保护

    如果连续auto compact失败三次,就不压缩了 (三次是因为MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES常量设定,可能是因为有些消息/输入本身就是直接超出上下文窗口了)

  2. 判断触发条件

    就是调用shouldAutoCompact函数,检查上下文有没有达到阈值

  3. 构造RecompactionInfo

    这部分应该是主要记录当前上下文是否是连续压缩的/上一次压缩过了多少轮对话/上一次压缩的turnID/模型auto compact阈值

  4. 尝试Session Memory Compact

    不过这里注释写的是EXPERIMENT: Try session memory compaction first,不知道实际使用的时候有没有开启

  5. 如果Session Memroy Compact失败,调用Full Compact

    调用的是compactConversation函数,这里就是真正总结历史,压缩成摘要了。

  6. 收尾

    看这个代码挺直观的

    try { const compactionResult = await compactConversation( messages, toolUseContext, cacheSafeParams, true, // Suppress user questions for autocompact undefined, // No custom instructions for autocompact true, // isAutoCompact recompactionInfo, ) // Reset lastSummarizedMessageId since legacy compaction replaces all messages // and the old message UUID will no longer exist in the new messages array setLastSummarizedMessageId(undefined) // 收尾1 runPostCompactCleanup(querySource) // 收尾2 return { wasCompacted: true, compactionResult, // Reset failure count on success consecutiveFailures: 0, } } catch (error) ...

  7. 如果full compact也失败,就是上面catch到的error处理,连续压缩失败次数加一


Session Memory Compact

那么具体来说,Session Memory Compact是怎么压缩的?

  1. Session Memory Extraction
  2. Session Memory Compact

首先得知道Session Memory这个名词的含义,其实就是由forked agent创建的结构化的markdown文件

这里涉及multi-agent的系统,但是我还不太清楚。

目前知道的是,主对话每次LLM完成回复,都会经过Post-Sampling Hook,关于是否提取session memory,有一系列判断条件。

比如,必须是主线程、开启了这个功能以及shouldExtractMemory(messages)函数返回真值。

这个函数判断的条件是根据当前上下文 + 上一次提取session memory增长的token数 + 工具调用次数 联合判断的:

  1. session memory初始化的触发条件是当前上下文超过10K tokens
  2. 初始化之后,必须达到currentTokenCount - tokensAtLastExtraction >= 5K
  3. 要么是上一轮对话中LLM没有调用工具;要么是上一轮工具调用超过三次,就会触发提取session memory

直接看源码src/services/SessionMemory/sessionMemory.ts:

// Trigger extraction when: // 1. Both thresholds are met (tokens AND tool calls), OR // 2. No tool calls in last turn AND token threshold is met // (to ensure we extract at natural conversation breaks) // // IMPORTANT: The token threshold (minimumTokensBetweenUpdate) is ALWAYS required. // Even if the tool call threshold is met, extraction won't happen until the // token threshold is also satisfied. This prevents excessive extractions. const shouldExtract = (hasMetTokenThreshold && hasMetToolCallThreshold) || (hasMetTokenThreshold && !hasToolCallsInLastTurn) // 这里hasMetTokenThreshold是session memory已经初始化后,意思是增长超过5K tokens

触发之后,就会启动一个forked agent

这个forked agent在后台异步进行总结之前会话,然后生成结构化的summary.md

这个agent只能用文件工具,只能编辑summary.md,所以比较安全,然后文件在~/.claude/projects/{project_name}/{session_id}/session-memory/summary.md

这个文件的结构如下:

export const DEFAULT_SESSION_MEMORY_TEMPLATE = ` # Session Title _A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_ # Current State _What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._ # Task specification _What did the user ask to build? Any design decisions or other explanatory context_ # Files and Functions _What are the important files? In short, what do they contain and why are they relevant?_ # Workflow _What bash commands are usually run and in what order? How to interpret their output if not obvious?_ # Errors & Corrections _Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_ # Codebase and System Documentation _What are the important system components? How do they work/fit together?_ # Learnings _What has worked well? What has not? What to avoid? Do not duplicate items from other sections_ # Key results _If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_ # Worklog _Step by step, what was attempted, done? Very terse summary for each step_ `

传给这个forked agent的prompt是src/srvices/SessionMemory/prompt.tsgetDefaultUpdatePrompt返回的字符串。

最后这个部分完成之后,记录这次提取的tokens数、更新lastSummarizedMessageId(方便知道这次memory覆盖到哪条消息)


参考src/services/compact/sessionMemoryCompact.ts

比如,必须是主线程、开启了这个功能以及shouldExtractMemory(messages)函数返回真值。

前面条件都符合之后,就开始进入async function trySessionMemoryCompaction函数

首先检查功能是否启用,等待正在进行session memory提取的工作(可能会有后台运行的forked agent还没完成summary.md)。

接下来,首先获取更新的lastSummarizedMessageId以及这个session memory的内容summary.md

通过这个消息的uuid查找在消息内的索引lastSummarizedIndex;当然有一个特殊情况就是lastSummarizedMessageId不存在,这是因为当前会话是resume来的,这个uuid只在内存存在,新开的会话resume之后,还没有这个uuid。这种情况Index设置为最后一条消息的位置。

然后要通过这个lastSummarizedIndex计算startIndex(也就是真正开始保留消息的索引),从startIndex往后的消息都保留原样。

正常逻辑来说,lastSummarizedIndex表示的是summary.md能覆盖到的消息的索引,所以startIndex应该就是从lastSummarizedIndex + 1开始。

但是有很多特殊情况,需要通过calculateMessagesToKeepIndex来计算真正的startIndex(这个startIndex是有可能比summraized index + 1小的)

比如:

index N - 1 -> xxxxx index N -> compact_boundary index N + 1 -> 用户问问题 index N + 2 -> LLM回答 index N + 3 -> tool_use index N + 4 -> tool_result <- lastSummarizedIndex index N + 5 -> 用户追问 <- 理论上的startIndex index N + 6 -> LLM只回答 index N + 7 -> 用户问问题2 index N + 8 -> LLM只回答2 ......

[startIndex, ..., end]后面的内容是保留区域,startIndex默认是在summarized index后面,但是会动态调整。

  • 比如,保留区域的token数不能太小,否则就会往前扩展(startIndex减小)。默认是保留区域希望是10K tokens并且至少包含5个text block的消息(// TODO: 解释text block)

  • 往前扩展的时候,往前一个下标,就计算一次token,如果保留区域的token数太大,达到大约40K tokens就停止;

  • 并且,往前扩展的时候,不能超过compact_boundary这个标记;

  • 并且,往前扩展的时候,不能把tool_use和tool_result这一对拆分开,这两个必须对应。

  • 还有一种情况是LLM回答的时候分片了,多个message共享一个id,这个也不能拆开。

(参考:src/services/compact/sessionMemoryCompact.tscalculateMessagesToKeepIndexadjustIndexToPreserveAPIInvariants)

最后完成session memory compact之后,通过buildPostCompactMessages组装得到:

[compact_boundary] [session memory summary] [messages to keep] [attachments] [hook results]

如果Session Memory Compact不能用,或者压缩之后,仍然超过Auto-Compact的阈值,就执行最后的压缩Full Compact

Full Compact

除了前面的步骤(Auto-Compact触发),还可以用户通过slash命令(/compact [user_messages])主动触发。

[!NOTE]
不过通过/compact命令,没有输入任何指示的时候,仍然会先尝试Session Memory Compact;如果带有指示,就会直接进入Full Compact
参考src/commands/compact/compact.ts

下面参考src/services/compact/compact.ts分析

主要是async function compactConversation这个函数

除了一开始的前置检查+token估算,就是执行PreCompact Hooks,这个函数第一个参数内就包含了判断是auto | manual

const hookResult = await executePreCompactHooks( { trigger: isAutoCompact ? 'auto' : 'manual', customInstructions: customInstructions ?? null, }, ... ) // 这个地方,如果是/compact触发的,参数customInstrcutions就不会是null // 如果是自动触发,这个地方就是null

后续针对这个hookResult,会合并instructions

这部分其实就是额外补充一下compact的prompt

然后开始构造compact prompt

compact prompt大概如下: [NO_TOOLS_PREAMBLE]: 简单来说就是禁止调用工具以及用特定的 <analysis> block和<summary> block [BASE_COMPACT_PROMPT]: 要求结构化的压缩信息 [Additional Instructions] [NO_TOOLS_TRAILER]: 再次强调不要调用工具以及用特定的 <analysis> block和<summary> block

然后这个compactPrompt封装用户消息,变量名summaryRequest,作为参数传给streamCompactSummary()函数(真正的核心部分)

summaryResponse = await streamCompactSummary({ messages: messagesToSummarize, // compactConversation的参数,在auto-compact那里传进来的 summaryRequest, // compact prompt包装的用户消息 appState, // context, // ToolUseContext preCompactTokenCount, // token统计 cacheSafeParams: retryCacheSafeParams, }) // 这个函数内部实现,也是异步调用`runForkedAgent()` summary = getAssistantMessageText(summaryResponse) // 提取文本

这个summary就是最终的压缩结果,这个大概是:

<analysis> ... </analysis> <summary> # 结构化摘要 每个section固定 1. Primary Request and Intent: 2. xxx # 完整模版查阅`src/services/compact/prompt.ts`的BASE_COMPACT_PROMPT </summary>


后续还有针对summary更健壮的处理,有几种情况:

  1. 带有PTL标记: (Prompt Too Long)会把旧消息裁剪(truncateHeadForPTLRetry),再重试
  2. 不带PTL标记: 退出死循环,检查是否为空,空的话直接抛出错误;检查是否带有api error前缀,同样抛出错误

然后就是考虑把旧的状态存储一下、缓存清除;然后把新的summary替换原来的旧历史

首先是清理压缩之前的context.readFileState,清理之前先保存;这部分还跟工具缓存有点关系,太复杂了,建议看源码。

其次,再对压缩进行一些后处理

因为压缩之后清理了一些状态,这里要补充一些关键的状态,比如:

  • 前面保存的readFileState
  • 压缩后需要重新注入的运行时状态
  • plan和plan模式的一些指示
  • 调用涉及的skill相关的
  • deferred/agent/MCP相关的

这些状态都通过postCompactFileAttachments.push()补充。

然后才执行SessionStart hooks: 收集这些hooks返回的消息,最后追加到最终的上下文


然后再创建compact boundary标记,用来作文新的上下文的起点

封装summary成用户消息;记录压缩前后的大小、统计压缩后到token、判断是否下一轮再次压缩;

最终Full Compact完成后,得到的消息大概如下:

[compact boundary] [summary messages] [attachments] [hook results]

最后这组值,加上一些token、usage和userDisplayMessage返回给auto-compact

再交给query.ts通过buildPostCompactMessages()组装压缩后的消息,把当前的messages替换成上述这份新的上下文

杂项

apiMicrocompact.ts

这个文件名感觉有点误导,但是实际上做的事情只有构造请求体好像

Reactive Compact

这个应该算是错误处理的一部分,泄露的源码好像也没有出现实现的细节。

参考src/query.ts的1119行,还有src/commands/compact/compact.ts

多个文件提及reactiveCompact.ts但是泄露的源码似乎没有这个文件

Partial Compact

部分压缩?这部分也是一样

src/services/compact/compact.ts能找到partialCompactConversation函数(看了一下和full compact一样都是调用streamCompactSummary()函数,后台agent生产结构化摘要)

流程和Full Compact很相似,先决定压缩/保留区域,构造prompt,生成summary,生成attachments,执行sessionStart hooks和创建compact boundary再包装成message

最终可能是这样的:

[compact boundary] [partial compact summary] [messagesToKeep]: 有点像session memory compact保留区域 [attachments] [hookResults]

但是这个函数,从泄露的代码来看,只有src/screens/REPL.tsx调用了它,更像是前端交互层的功能。

简单google一下,发现官方repo有不少issue关于这个,比如issue#26488

从源码来看,在claude code的CLI页面,双击esc会弹出一个rewind,选中消息之后,有一个选项是Summarize from here

也就是我们用户自己主动选择范围触发的partial compact

这个触发之后,UI会渲染一个Summarized conversation,如果你ctrl + o展开的话:

⏺ Summarized conversation ⎿ This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.` Summary: 1. Primary Request and Intent: xxxx 2. Key Technical Concepts: xxxx 3. Files and Code Sections: xxxx 4. Errors and fixes: xxxx 5. Problem Solving: xxxx 6. All user messages: xxxx 7. Pending Tasks: xxxx 8. Current Work: xxxx 9. Optional Next Step: If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/chen/.claude/projects/{project_name}/{session_id}.jsonl

能看见很明显的结构化的markdown格式,并且最后给出了canonical transcript的路径,这一点和Full Compact几乎一样(src/services/compact/prompt.tsBASE_COMPACT_PROMPTPARTIAL_COMPACT_PROMPT)。

感悟

真的复杂,而且这还只是冰山一角,似乎有好多内容是这份源码没有逆向到的。

之前还经常有人说agent不过是prompt engineering,但是我看了这个内容,太复杂了…

这不是简单的一句prompt工程就能构造出来的

这里面还有很多我不知道的,比如hooks系统、工具系统、MCP、Multi-Agents等等等等

而且,API服务器那边,还有KV Cache、服务端怎么处理过时的消息的…

直接晕了

网友解答:
--【壹】--:

最近也在看上下文这一块,学习了 谢谢大佬。


--【贰】--:

我最开始先跟着langchain官网学了一点langgraph的:

langgraph基础

你可以看看,里面基本就是 定义状态、结点、边、构造图、内存/持久化、snapshot这几个概念就是核心


--【叁】--:

非常有价值的内容,学习到了,上下文大小、提示词是直接影响效果的关键点


--【肆】--:

我就说一下我目前的理解吧,首先langgraph主要涉及的是结点、边这样的控制逻辑(也就是graph)

也可以理解为状态机,我先用langgraph构造了一个ReAct循环,然后添加一些tools,加一些context管理和session resume的功能。大概就是目前的样子。

最开始我其实是参考gemini-cli的开源代码的,然后一开始自己构思+搓的graph。就分CLI层, Core层, LLM和Tool层,大概这样,然后参考的gemini-cli的event bus机制(cc里面是hook 感觉都差不多)就可以每一层通信了。


--【伍】--:

佬,能不能先说一下你github这个项目是纯langgraph就搭起来做成现在这种cli产品效果吗?我不大明白具体langgraph实现出来的是个啥捏


--【陆】--:

太牛了佬,最近在研究知识图谱,就是有节点,串联知识。


--【柒】--:

哈哈,其实我现在遇到的情况和你非常像!那我也先学习一下langgraph怎么个事


--【捌】--:

对佬实现的这个MT-Agent项目有点好奇,我最近也是遇到个需求想要实现在某个硬件上对某个需求自动化处理的agent。佬你这是直接用LangGraph + 大量的vibe coding手搓了个简化版gemini cli出来吗?为什么不直接用现成的gemini cli或者说CC的安装包呢。


--【玖】--:

感谢佬友分享,期待长更!最近面试经常被问到claude code源码中的知识


--【拾】--:

这篇文章的内容是真的高质量,很多都是网上垃圾教程不会讲到分析到的东西


--【拾壹】--:

一大早吃不下啊,我先摸会鱼再来看 ,太长了


--【拾贰】--:

感谢佬友分享,写的真的太好了,会持续关注更新


--【拾叁】--:

看看后续有没有时间研究一下它的multi-agent吧

这部分真感觉有点像网上说的,agent类似操作系统:

multi-agent就是类似进程和线程,子agent能访问主agent的上下文(共享进程资源),子agent来完成具体执行。

上下文管理类似缓存管理,都是有限的窗口内、可能涉及快满了就考虑替换的策略(LRU什么的)


--【拾肆】--:

因为这只是我用来学习的项目 不是用来生产的 主要是学习langgraph框架和agent而已 (顺便糊弄一下导师)

真正生产肯定还是用gemini cli/cc/codex


--【拾伍】--:

claudecode真的很强,就是账号不太好搞