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 决定哪些指令进得来,窗口监控决定什么时候开始紧张,分层压缩决定紧张之后舍弃什么。

这一章回答:

  1. CLAUDE.md 的加载顺序怎么定,为什么不支持 AGENTS.md;
  2. 「有效上下文窗口」怎么算,13k / 20k / 3k 三个 buffer 各管什么;
  3. 从 microcompact 到 autocompact 的调用顺序,以及为什么压缩要用 fork 出来的模型调用;
  4. 附件与相关记忆的注入预算。

一、两块上下文与缓存失效

context.ts 提供两个 memoize 入口:

失效点在 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()) 逐层遍历。每一层依次尝试四类文件:

注意这里与常见描述有出入:上溯终止于文件系统根,不是 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 默认成 []。

其它常量与机制:

关于 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 始终调用,内部自判

顺序的动机都在注释里:

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 定义了三组注入预算:

还有一条与压缩交互的设计:已注入记忆的扫描是遍历消息而不是存在 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 个相关文件不会被主动提示——模型只能自己去找。

自测题

  1. getEffectiveContextWindowSize 用 Math.min(getMaxOutputTokensForModel(model), 20_000)。请说明为什么是 min 而不是 max,以及对一个 max output 只有 8k 的模型会发生什么。
  2. shouldAutoCompact 对 querySource === 'compact' 直接返回 false。如果把这条护栏去掉,请描述一次具体会话中死锁是怎么形成的。
  3. snip 排在 microcompact 之前,但注释说两者「not mutually exclusive」。请推演一个既有大量可 snip 历史、又有大量可清理工具结果的会话,说明为什么 snipTokensFreed 必须显式传给 autocompact。
  4. CLAUDE.md 的上溯终止于文件系统根,而 worktree 去重靠 Git 根。请举出一个「两者不一致导致指令被加载两次」的目录布局,并说明 skipProject 为什么不会误伤 CLAUDE.local.md。
  5. 相关记忆的 5 个槽位由 slice(0, 5) 截断,但注释说过滤器在 selector 内部也要做一次。请说明这种「双重过滤」在多个记忆目录场景下防的是什么 bug。

进入 keel 阅读