KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 上下文管理 — keel 龙骨
上下文窗口是 Agent 最稀缺的资源。Claude Code 2.1.88 在这一点上采取「多道防线 + 用模型压缩模型」的组合:CLAUDE.md 决定哪些指令进得来,窗口监控决定什么时候开始紧张,分层压缩决定紧张之后舍弃什么。
上下文窗口是 Agent 最稀缺的资源。Claude Code 2.1.88 在这一点上采取「多道防线 + 用模型压缩模型」的组合:CLAUDE.md 决定哪些指令进得来,窗口监控决定什么时候开始紧张,分层压缩决定紧张之后舍弃什么。
这一章回答:
CLAUDE.md的加载顺序怎么定,为什么不支持AGENTS.md;- 「有效上下文窗口」怎么算,13k / 20k / 3k 三个 buffer 各管什么;
- 从 microcompact 到 autocompact 的调用顺序,以及为什么压缩要用 fork 出来的模型调用;
- 附件与相关记忆的注入预算。
一、两块上下文与缓存失效
context.ts 提供两个 memoize 入口:
getSystemContext()(第 116 行)——目前主要是gitStatus,由同样 memoize 的getGitStatus()(第 36 行)取;返回体里带has_git_status遥测字段。getUserContext()(第 155 行)——注入claudeMd等用户级信息,并记录claudemd_length与claudemd_disabled两个可分析字段。
失效点在 context.ts: setSystemPromptInjection(第 29 行):它先 set,然后依次 getUserContext.cache.clear?.() 与 getSystemContext.cache.clear?.()。注释说明这是 ant-only 的「cache breaking」调试状态。文件第 174 行还有一句说明:这里刻意不直接 import claudemd.ts,以避免循环依赖。
二、CLAUDE.md 的层级
utils/claudemd.ts: getMemoryFiles(第 790 行)是唯一加载点,本身 memoize。加载顺序严格按四段排列。
第 1 段:Managed(策略级,总是加载)
getMemoryPath('Managed') → processMemoryFile(..., 'Managed', processedPaths, includeExternal);紧接着 getManagedClaudeRulesDir() → processMdRules({ rulesDir, type: 'Managed', conditionalRule: false, ... })。getMemoryPath 定义在 utils/config.ts 第 1779 行,Managed 分支返回 join(getManagedFilePath(), 'CLAUDE.md'),注释强调「always loaded - policy settings」。
第 2 段:User(受 userSettings 开关控制)
~/.claude/CLAUDE.md(getMemoryPath('User') = join(getClaudeConfigHomeDir(), 'CLAUDE.md'))加 ~/.claude/rules/*.md。用户记忆的 includeExternal 被硬编码为 true,注释说明「User memory can always include external files」。
第 3 段:Project 与 Local,从根逐层向下到 cwd
实现是「先向上收集、再反转」:从 getOriginalCwd() 一路 dirname 收集到 parse(currentDir).root,然后 for (const dir of dirs.reverse()) 逐层遍历。每一层依次尝试四类文件:
CLAUDE.md(Project,受projectSettings开关);.claude/CLAUDE.md(Project);.claude/rules/*.md(Project,走processMdRules);CLAUDE.local.md(Local,受localSettings开关)。
注意这里与常见描述有出入:上溯终止于文件系统根,不是 Git 根。findGitRoot 与 findCanonicalGitRoot 只用在一个地方——worktree 去重。注释记录了一个真实 issue(附编号 29599):在 .claude/worktrees/<name>/ 里运行时,向上走会同时经过 worktree 根与主仓库根,两边都有同名 checked-in 文件,内容会被加载两次。处理办法是计算 isNestedWorktree,然后对「在 canonicalRoot 之内、gitRoot 之外」的目录设置 skipProject = true,跳过它们的 Project 类文件;CLAUDE.local.md 因为是 gitignored 所以不受影响。
第 4 段:--add-dir 附加目录(默认关闭)
由环境变量 CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD 控制(isEnvTruthy 判断)。对每个附加目录,依次读 CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md,类型都记作 Project。注释说明它刻意不检查 isSettingSourceEnabled('projectSettings')——因为 --add-dir 是用户的显式动作,而 SDK 在未指定时会把 settingSources 默认成 []。
其它常量与机制:
MAX_MEMORY_CHARACTER_COUNT = 40000——单个记忆面的字符上限(第 92 行);MAX_INCLUDE_DEPTH = 5——记忆文件之间@引用的最大嵌套深度(第 537 行);processedPaths去重集合,防止同一文件被多条路径重复加载;- 条件规则(conditional rules):
processConditionedMdRules/getConditionedMdRules支持按目标文件路径匹配的规则;processMdRules调用时用conditionalRule: false显式区分「无条件」与「条件」两遍; claudeMdExcludes可在路径层面排除记忆文件。
关于 AGENTS.md:整个提取树里 AGENTS.md 只有一处命中——commands/init.ts 第 46 行与第 108 行,出现在 /init 的提示词里,让模型「去读 AGENTS.md 等其它 AI 工具的配置,并把重要部分写进 CLAUDE.md」。也就是说这个版本不把 AGENTS.md 当作记忆加载路径,只把它当作一次性迁移来源。
三、窗口监控
有效窗口的计算在 services/compact/autoCompact.ts: getEffectiveContextWindowSize(第 33 行):先取 reservedTokensForSummary = Math.min(getMaxOutputTokensForModel(model), MAX_OUTPUT_TOKENS_FOR_SUMMARY),再取 getContextWindowForModel(model, getSdkBetas()),若设了 CLAUDE_CODE_AUTO_COMPACT_WINDOW 就 Math.min 压小,最后返回两者之差。
MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000,注释给了依据:「Based on p99.99 of compact summary output being 17,387 tokens.」留 2 万是为了保证压缩请求本身有足够输出空间。环境变量那条只能把窗口压小,用于测试。
四个 buffer 常量(第 62–65 行):
| 常量 | 值 | 用途 |
|---|---|---|
AUTOCOMPACT_BUFFER_TOKENS |
13_000 | 自动压缩阈值 = 有效窗口 − 13k |
WARNING_THRESHOLD_BUFFER_TOKENS |
20_000 | 警告线 = 阈值 − 20k |
ERROR_THRESHOLD_BUFFER_TOKENS |
20_000 | 错误线 = 阈值 − 20k |
MANUAL_COMPACT_BUFFER_TOKENS |
3_000 | blocking limit = 有效窗口 − 3k |
于是自动压缩点是「有效窗口减去 13k」(对 18 万的有效窗口约 93%),blocking limit 是「有效窗口减去 3k」(约 98%)——两者之间正是 collapse 与 reactive compact 的活动区间。calculateTokenWarningState(tokenUsage, model) 把上述判定折叠成五个字段:percentLeft、isAboveWarningThreshold、isAboveErrorThreshold、isAboveAutoCompactThreshold、isAtBlockingLimit。测试覆盖开关是 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 与 CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE。
熔断器:MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3(第 70 行)。注释带了一组真实的 BQ 数据:「1279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally.」所以 autoCompactIfNeeded 开头就先看 tracking.consecutiveFailures >= 3 直接放弃——上下文已不可恢复地超限,重试只是徒劳地打 API。开关优先级是 DISABLE_COMPACT > DISABLE_AUTO_COMPACT > 用户配置 autoCompactEnabled(isAutoCompactEnabled,第 147 行)。
四、分层的调用顺序
在 query.ts 的每轮开头,压缩相关步骤串行且有序:
| 顺序 | 步骤 | 位置 | 门控 |
|---|---|---|---|
| 1 | applyToolResultBudget |
query.ts: applyToolResultBudget |
仅 contentReplacementState 存在时生效 |
| 2 | snip | query.ts: snipCompactIfNeeded |
feature('HISTORY_SNIP') |
| 3 | microcompact | query.ts: deps.microcompact |
始终调用 |
| 4 | contextCollapse | query.ts: applyCollapsesIfNeeded |
feature('CONTEXT_COLLAPSE') |
| 5 | autocompact | query.ts: deps.autocompact |
始终调用,内部自判 |
顺序的动机都在注释里:
- snip 排在 microcompact 之前,且两者不互斥;
snipTokensFreed必须一路传进 autocompact,因为tokenCountWithEstimation看不到 snip 的收益(它读的是受保护尾部 assistant 的 usage); - collapse 排在 autocompact 之前,理由是「if collapse gets us under the autocompact threshold, autocompact is a no-op and we keep granular context instead of a single summary」——先尝试保留细粒度上下文,实在不行才退化成一整段摘要。
microCompact.ts 的关键常量:TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'、IMAGE_MAX_TOKEN_SIZE = 2000、COMPACTABLE_TOOLS(可清理工具白名单)。入口是 microcompactMessages(第 253 行)。
autoCompactIfNeeded 内部还有一层优先顺序:先 trySessionMemoryCompaction(messages, toolUseContext.agentId, recompactionInfo.autoCompactThreshold),成功就直接返回;否则才 compactConversation(...)。前者来自 services/compact/sessionMemoryCompact.ts(第 514 行),配置 DEFAULT_SM_COMPACT_CONFIG = { minTokens: 10_000, minTextBlockMessages: 5, maxTokens: 40_000 }——压缩后至少保留 1 万 token、至少 5 条含文本块的消息,硬上限 4 万。代码里标着 // EXPERIMENT: Try session memory compaction first。
关于另外几级,这里必须给一个基于本提取树的更正:reactiveCompact、snipCompact、contextCollapse 在 query.ts 里确实被 require() 引用(分别在 feature('REACTIVE_COMPACT') / feature('HISTORY_SNIP') / feature('CONTEXT_COLLAPSE') 门控的三元表达式里),但 services/compact/reactiveCompact.ts、services/compact/snipCompact.ts、services/contextCollapse/ 这三个路径在提取出的源码树里并不存在——services/compact/ 下只有 apiMicrocompact / autoCompact / compact / compactWarningHook / compactWarningState / grouping / microCompact / postCompactCleanup / prompt / sessionMemoryCompact / timeBasedMCConfig。最合理的解释是(推断):feature() 是编译期常量,这些模块在 external build 里被整体 dead-code-eliminate,没有贡献任何字节,也就未被 source map 收录。
shouldAutoCompact 里有一组递归护栏必须记住:if (querySource === 'session_memory' || querySource === 'compact') return false(第 171 行)。注释说得很清楚——这两个 querySource 对应 fork 出来的 agent,在它们内部再触发 autocompact 就会死锁。此外 reactive-only 模式与 collapse 启用时也会让 shouldAutoCompact 提前返回;后者的注释解释,autocompact 若与 collapse 同时触发会「race collapse and usually win, nuking granular context」。
五、压缩用模型做摘要
services/compact/compact.ts: compactConversation(第 387 行)执行真正的压缩,核心是调用 runForkedAgent(来自 utils/forkedAgent.ts):
const result = await runForkedAgent({
promptMessages: [summaryRequest],
querySource: 'compact',
forkLabel: 'compact',
maxTurns: 1,
skipCacheWrite: true,
})
四个参数各有含义:maxTurns: 1——摘要只需一次模型往返;querySource: 'compact'——同时被 shouldAutoCompact 当作递归护栏、也是 prompt cache 1h TTL allowlist 的匹配对象;forkLabel: 'compact'——遥测里区分 fork 来源;skipCacheWrite: true——不写缓存,因为压缩后的前缀不会再用。
压缩后的重新注入预算是另一组常量:
| 常量 | 值 | 含义 |
|---|---|---|
POST_COMPACT_TOKEN_BUDGET |
50_000 | 回填附件的总预算 |
POST_COMPACT_MAX_FILES_TO_RESTORE |
5 | 最多恢复几个文件 |
POST_COMPACT_MAX_TOKENS_PER_FILE |
5_000 | 单文件上限 |
POST_COMPACT_SKILLS_TOKEN_BUDGET |
25_000 | 技能附件总预算 |
POST_COMPACT_MAX_TOKENS_PER_SKILL |
5_000 | 单技能上限 |
配套函数:buildPostCompactMessages、annotateBoundaryWithPreservedSegment、createPostCompactFileAttachments、createPlanAttachmentIfNeeded、createSkillAttachmentIfNeeded、createPlanModeAttachmentIfNeeded、createAsyncAgentAttachmentsIfNeeded、stripImagesFromMessages、stripReinjectedAttachments。
压缩边界是一条真实消息:createCompactBoundaryMessage / isCompactBoundaryMessage(在 utils/messages.ts),而 getMessagesAfterCompactBoundary(messages) 正是 query 循环每轮的起点;压缩后的清理走 services/compact/postCompactCleanup.ts: runPostCompactCleanup(querySource)。
这里有一处值得单独记下的副作用顺序:走 session memory 路径时,必须手动补一次缓存基线重置——if (feature('PROMPT_CACHE_BREAK_DETECTION')) notifyCompaction(querySource ?? 'compact', toolUseContext.agentId)。注释说明 compactConversation 内部会自己做,但 SM-compact 不会;漏掉会导致 20% 的 tengu_prompt_cache_break 事件变成误报(标注 BQ 2026-03-01)。
六、附件与相关记忆预算
utils/attachments.ts 定义了三组注入预算:
TURNS_BETWEEN_ATTACHMENTS = 5(第 260 / 265 行,plan 模式与 auto 模式各一份配置)——同类附件至少隔 5 轮才重新注入;MAX_MEMORY_LINES = 200(第 269 行)与MAX_MEMORY_BYTES = 4096(第 277 行)——记忆文件作为<system-reminder>注入时的行数/字节双重截断,走readFileInRange的truncateOnByteLimit;截断时会在正文后追加一句提示,给出完整路径并让模型用 Read 去看;- 相关记忆每轮最多 5 条:
allResults.flat()后先.filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path)),再.slice(0, 5)(第 2234 行)。注释称这个 5 是「Sonnet's 5-slot budget」,并说明alreadySurfaced的过滤在 selector 内部也要做一次,是为了让这 5 个槽位花在新候选上;readFileState则过滤模型已读过、不需要再提示的文件。
还有一条与压缩交互的设计:已注入记忆的扫描是遍历消息而不是存在 toolUseContext 里。注释解释这样「compact naturally resets both」——旧附件在压缩后的 transcript 里已不存在,于是重新注入是合法的。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 用户上下文 | context.ts: getUserContext |
第 155 行;memoize,含 claudeMd |
| 系统上下文 | context.ts: getSystemContext |
第 116 行;memoize,含 gitStatus |
| 缓存失效 | context.ts: setSystemPromptInjection |
第 29 行;清空两个 memoize 缓存 |
| 记忆加载总入口 | utils/claudemd.ts: getMemoryFiles |
第 790 行;Managed → User → Project/Local → add-dir |
| Managed 路径 | utils/config.ts: getMemoryPath |
第 1779 行;Managed = getManagedFilePath()/CLAUDE.md |
| 逐层上溯与反转 | utils/claudemd.ts: getMemoryFiles |
第 854 行 while 到文件系统根,第 878 行 dirs.reverse() |
| worktree 去重 | utils/claudemd.ts: getMemoryFiles |
第 868 行;findGitRoot vs findCanonicalGitRoot,issue 29599 |
| 条件规则 | utils/claudemd.ts: processConditionedMdRules |
第 1354 行;按目标路径匹配 |
| 记忆面上限 | utils/claudemd.ts: MAX_MEMORY_CHARACTER_COUNT |
第 92 行;值为 40000 |
| 引用嵌套上限 | utils/claudemd.ts: MAX_INCLUDE_DEPTH |
第 537 行;值为 5 |
| 有效窗口 | services/compact/autoCompact.ts: getEffectiveContextWindowSize |
第 33 行;窗口 − min(maxOutput, 20000) |
| 自动压缩阈值 | services/compact/autoCompact.ts: getAutoCompactThreshold |
第 72 行;effective − 13000 |
| 窗口常量组 | services/compact/autoCompact.ts: AUTOCOMPACT_BUFFER_TOKENS |
第 62–65 行;13000 / 20000 / 20000 / 3000 |
| 熔断器 | services/compact/autoCompact.ts: MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES |
第 70 行;值为 3,注释含 BQ 数据 |
| 递归护栏 | services/compact/autoCompact.ts: shouldAutoCompact |
第 171 行;session_memory / compact 直接 false |
| 压缩主函数 | services/compact/compact.ts: compactConversation |
第 387 行;用 runForkedAgent 做摘要 |
| 压缩 fork 参数 | services/compact/compact.ts: runForkedAgent |
第 1188 行;maxTurns 1、skipCacheWrite true、forkLabel compact |
| 会话记忆压缩 | services/compact/sessionMemoryCompact.ts: trySessionMemoryCompaction |
第 514 行;配置 10k / 5 条 / 40k |
| 微压缩 | services/compact/microCompact.ts: microcompactMessages |
第 253 行;按 tool_use_id 清理,不看内容 |
| 压缩后清理 | services/compact/postCompactCleanup.ts: runPostCompactCleanup |
压缩成功路径统一调用 |
| 附件预算 | utils/attachments.ts: MAX_MEMORY_LINES |
200 行 / 4096 字节;TURNS_BETWEEN_ATTACHMENTS = 5 |
| 相关记忆槽位 | utils/attachments.ts: slice(0, 5) |
第 2234 行;每轮最多 5 条 |
关键取舍
CLAUDE.md 从文件系统根逐层向下加载,代价是深层目录可能重复加载祖先指令。
终止条件是文件系统根而不是 Git 根,意味着在 /home/u/proj 里运行会读到 /CLAUDE.md、/home/CLAUDE.md……每一层都可能命中。processedPaths 只去掉「同一文件被多路径命中」,去不掉「不同层的不同文件写了同样的规矩」。worktree 场景更极端,需要 isNestedWorktree + skipProject 专门处理,代码里还挂着 issue 编号。
预留 2 万 token 给摘要输出,代价是可用窗口被永久削掉一截。MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 的依据是压缩摘要输出的 p99.99 为 17,387 token;不预留就会在「该压缩」的那一刻发现压缩请求自己都放不下。这个常数对所有模型生效(取 Math.min),对小窗口模型尤其昂贵。
多层压缩并存,代价是「这次压缩到底是谁干的」需要读五处代码。
好消息是有明确顺序与护栏:snip 与 micro 可叠加、collapse 先于 autocompact、querySource 为 session_memory / compact 时直接跳过。坏消息是 reactiveCompact / snipCompact / contextCollapse 的实现在这份源码里不可见,只能从 query.ts 的调用点反推契约。
压缩用 fork 的模型调用,代价是一次额外 API 往返与不确定性。maxTurns: 1 + skipCacheWrite: true 已尽量压低成本,但同一份 transcript 可能产出不同摘要,而摘要一旦写入就是新的对话起点——这引入「同一会话两次 resume 可能得到不同上下文」的固有不确定性。
相关记忆每轮最多 5 条且去重,代价是可能漏掉真正需要的记忆。
5 个槽位由 selector 分配,alreadySurfaced 保证不重复占用。在大仓库里,第 6 个相关文件不会被主动提示——模型只能自己去找。
自测题
getEffectiveContextWindowSize用Math.min(getMaxOutputTokensForModel(model), 20_000)。请说明为什么是min而不是max,以及对一个 max output 只有 8k 的模型会发生什么。shouldAutoCompact对querySource === 'compact'直接返回false。如果把这条护栏去掉,请描述一次具体会话中死锁是怎么形成的。- snip 排在 microcompact 之前,但注释说两者「not mutually exclusive」。请推演一个既有大量可 snip 历史、又有大量可清理工具结果的会话,说明为什么
snipTokensFreed必须显式传给 autocompact。 CLAUDE.md的上溯终止于文件系统根,而 worktree 去重靠 Git 根。请举出一个「两者不一致导致指令被加载两次」的目录布局,并说明skipProject为什么不会误伤CLAUDE.local.md。- 相关记忆的 5 个槽位由
slice(0, 5)截断,但注释说过滤器在 selector 内部也要做一次。请说明这种「双重过滤」在多个记忆目录场景下防的是什么 bug。