s-blog

深扒 Claude Code 源码(一):启动、会话循环与五套上下文压缩

会话内核三章 · 启动性能 / 查询状态机 / 压缩降级阶梯

ssssmy · 2026-06-11 · 22 min · LLM

本文是《深扒 Claude Code 源码》系列第一篇,聚焦会话内核。建议先读总纲篇了解来源、方法与整体架构。本篇所有论点均带 file:line 证据,指向 v2.0.5x 反编译源码树。

1. 启动链路与启动性能

本章剖析 Claude Code 从进程入口到 REPL 首帧的完整启动链路,以及其中嵌入的一整套启动性能工程手法。整个设计的中心矛盾是:CLI 需要做大量初始化工作(配置、鉴权、遥测、MCP、插件、git 上下文……),但用户对"敲下 claude 到看到提示符"的延迟极其敏感。源码用一套"按需付费"的分流 + 模块级并行预热 + 推迟到首帧后的策略来化解这个矛盾。

1.1 从入口到 REPL 的完整调用链

进程真正的入口是 src/bootstrap-entry.ts,只有三行:调用 ensureBootstrapMacro() 注入构建宏,然后 await import('./entrypoints/cli.tsx')bootstrap-entry.ts:1-5)。ensureBootstrapMacro() 的作用是在 globalThis 上挂一个 MACRO 对象,提供 VERSION/PACKAGE_URL 等构建期常量的运行时回退——正常发布构建里 bun:bundle 会把 MACRO.VERSION 内联成字面量,但在这个从 source map 还原的源码树里宏不存在,于是用 package.json 的字段兜底(bootstrapMacro.ts:13-29,仅当 !('MACRO' in globalThis) 时才写入)。

cli.tsx 是"轻量分流层",其顶层注释明确写着设计意图:“All imports are dynamic to minimize module evaluation for fast paths. Fast-path for --version has zero imports beyond this file.”(cli.tsx:28-32)。它在模块顶层先做几件无法推迟的事:关掉 corepack 自动 pin(COREPACK_ENABLE_AUTO_PIN='0'cli.tsx:5)、CCR 容器里设 --max-old-space-size=8192cli.tsx:9-14)、以及 ABLATION_BASELINE 实验环境变量预置(cli.tsx:21-26,注释解释这必须内联在 cli.tsx 而非 init.ts,因为 BashTool/AgentTool 在 import 时就把 DISABLE_BACKGROUND_TASKS 捕获进模块级 const,等 init() 跑就太晚了)。随后 main() 做一长串 fast-path 分流,所有非快路径最终落到末尾:startCapturingEarlyInput()profileCheckpoint('cli_before_main_import') → 动态 import('../main.js')cliMain()。注意"动态导入 main.js"这一跳本身就是性能设计:只有确认不是 --version/daemon/bg 等快路径后,才付出加载约 790KB 的 main.tsx 模块图的代价。

main.tsxmain() 函数(main.tsx:585)做命令行解析前的环境准备:把 -d2e 重写成 --debug-to-stderr、设 NoDefaultCurrentDirectoryInExePath='1' 防 Windows PATH 劫持(main.tsx:594-597)、装 warning handler、注册 exit/SIGINT 处理。然后处理一批 OS 级 fast-path(cc:// deep-link、--handle-uri、macOS URL handler),确定 clientType,调用 eagerLoadSettings()(提前解析 --settings/--setting-sourcesmain.tsx:858),最后 await run()main.tsx:860)。

run()main.tsx:890)构造 Commander 程序,并注册一个 preAction hook(main.tsx:913-973)——这是初始化的真正聚合点:先 await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()])(汇合模块级并行预热),然后 await init()initSinks()runMigrations()loadRemoteManagedSettings() 等。preAction 之所以承载初始化而非 main 顶层,注释说得很清楚:“run initialization only when executing a command, not when displaying help”(main.tsx:911-912)——claude --help 不该付出 init() 的代价。

进入默认命令的 action handler 后,交互路径的关键序列是:init...setup()main.tsx:1909-1940,与 getCommands()/agent 加载并行)→ createRoot() → 记一次 tengu_timer startup 事件 → showSetupScreens()(信任门控,main.tsx:2247)→ 最终 launchRepl(...)launchReplreplLauncher.tsx:12-22)本身又是动态导入:await import('./components/App.js')await import('./screens/REPL.js'),再 renderAndRun(root, <App><REPL/></App>)renderAndRuninteractiveHelpers.tsx:98-103)做了三件事的"标准收尾":root.render(element)startDeferredPrefetches()await root.waitUntilExit()gracefulShutdown(0)startDeferredPrefetches() 紧贴在 render() 之后、waitUntilExit() 之前,这正是"首帧已提交、用户开始打字、把剩余预热塞进这个窗口"的关键缝合点

1.2 cli.tsx 的 fast-path 分流顺序与设计意图

main() 内的分流是严格有序的,顺序本身编码了"成本递增 + 频率/性能敏感度"的优先级:

  1. --version/-v/-V(零导入):直接 console.log(\${MACRO.VERSION} (Claude Code)`) 并 return(cli.tsx:36-42)。这是整条链路里唯一一条**连 startupProfiler 都不加载**的路径——注释强调"zero module loading needed"。考量是:版本号是脚本/包管理器/CI 高频探测的东西(npm view`、安装校验),必须毫秒级返回。它排在最前面,确保任何其他模块都不会被求值。
  2. 加载 profiler:只有越过 version 快路径,才 await import('../utils/startupProfiler.js') 并打 profileCheckpoint('cli_entry')cli.tsx:44-48)。
  3. --dump-system-prompt(ant-only)feature('DUMP_SYSTEM_PROMPT') 门控,只 enableConfigs() + 取模型 + getSystemPrompt() 并打印(cli.tsx:53-71)。
  4. Chrome MCP / native host / computer-use MCP:被外部进程 spawn 的 MCP server 子进程入口(cli.tsx:72-93)。
  5. --daemon-worker(DAEMON):注释强调"spawned per-worker, so perf-sensitive. No enableConfigs(), no analytics sinks — workers are lean."(cli.tsx:95-106)。
  6. remote-control/daemon/ps/logs/bg 等子命令cli.tsx:112-208):需要 enableConfigs() 但不需要完整 REPL。bridge 路径内部还有精心排序的鉴权检查——“Auth check must come before the GrowthBook gate check — without auth, GrowthBook has no user context and would return a stale/default false”(cli.tsx:132-141)。
  7. templates / environment-runner / self-hosted-runner(feature 门控的 headless 入口)。
  8. --worktree --tmux exec 快路径cli.tsx:247-274)。
  9. 回退到完整 CLI:动态 import('../main.js')cliMain()

设计意图一以贯之:每条快路径只加载自己绝对需要的模块,把"加载 790KB 的 main.tsx + 整个 React/Ink/工具图"这个最昂贵的动作推到最后、只在确认是真正的交互/查询会话时才付费。

1.3 启动性能手法逐条剖析

(a) 模块级并行预热——startMdmRawRead / startKeychainPrefetch main.tsx 顶部前 20 行是全文件最讲究的部分。三个顶层副作用按序触发:profileCheckpoint('main_tsx_entry')startMdmRawRead()startKeychainPrefetch()全部在其余约 135ms 的 import 语句之前执行。核心机巧是:ES 模块的求值顺序保证这几个 start*() 调用先跑——它们 fire 出去的子进程(plutil/reg query/security)随后与剩余模块图的加载在墙钟上重叠

  • startMdmRawRead() 在 macOS 上对每个 plist 路径并行 spawn plutil(先用同步 existsSync 跳过不存在的文件,省掉每次约 5ms 的 ENOENT spawn 成本),Windows 上并行 reg query HKLM/HKCU。
  • startKeychainPrefetch() 解决一个具体 65ms 问题:isRemoteManagedSettingsEligible() 原本顺序同步 spawn 两次 security find-generic-password(OAuth 凭据约 32ms + 旧版 API key 约 33ms)。预热把这两次改成并行 fire,与 import 重叠。模块注释还专门说明为什么只 import macOsKeychainHelpers.ts 而非 macOsKeychainStorage.ts——后者会拖入 execa→human-signals→cross-spawn,约 58ms 的同步模块初始化。

两个 promise 在 preAction 里 await Promise.all([...]) 汇合,注释称此处"Nearly free — subprocesses complete during the ~135ms of imports above"。

(b) 动态 import 推迟 OpenTelemetry 约 400KB。 init.ts 顶部注释明确:“initializeTelemetry is loaded lazily via import() … to defer ~400KB of OpenTelemetry + protobuf modules. gRPC exporters (~700KB) are further lazy-loaded”(init.ts:44-46)。遥测初始化还被信任门控延后,且在 showSetupScreens 里用 setImmediate(() => initializeTelemetryAfterTrust()) 推到下一个 tick——“Defer to next tick so the OTel dynamic import resolves after first render”(interactiveHelpers.tsx:188-190)。

© preconnectAnthropicApi TCP/TLS 预热。 init() 在配好 CA 证书和代理后 fire 一个 fetch(baseUrl, {method:'HEAD', signal: AbortSignal.timeout(10_000)}) 并忽略结果。原理:“The TCP+TLS handshake is ~100-200ms that normally blocks inside the first API call. Kicking a fire-and-forget fetch during init lets the handshake happen in parallel”(apiPreconnect.ts:1-11)。Bun 的 fetch 全局共享 keep-alive 连接池,真实请求复用暖好的连接。配了 proxy/mTLS/unix socket 或 Bedrock/Vertex/Foundry 时直接 skip。

(d) 首帧后的 startDeferredPrefetchesrenderAndRunroot.render() 之后立即调用,把一批东西从 init()/setup() 挪到首帧后:initUser()getUserContext()getRelevantTips()countFilesRoundedRg()prefetchOfficialMcpUrls()refreshModelCapabilities() 等。两个早退保护:CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER(benchmark 模式)和 isBareMode()(脚本化 -p 没有"用户打字"窗口可藏这些工作)。

(e) profileCheckpoint 埋点体系。 双模式采样:详细模式 CLAUDE_CODE_PROFILE_STARTUP=1,采样模式 100% ant 用户 + 0.5% 外部用户上报 Statsig。关键设计是采样决策在模块加载时只做一次SHOULD_PROFILE 是模块级 const,未被采样的用户在 profileCheckpoint() 第一行 if (!SHOULD_PROFILE) return 直接零成本返回。PHASE_DEFINITIONS 把离散 checkpoint 聚合成四个阶段:import_time/init_time/settings_time/total_time

1.4 init() 初始化时序与 memoize

initinit.ts:57)用 lodash-es/memoize 包裹,保证整个进程生命周期只真正执行一次。其内部时序(每步都有计时):enableConfigs()applySafeConfigEnvironmentVariables()应用安全 env)→ applyExtraCACertsFromConfig()(必须在首次 TLS 握手前,因为 Bun 在 boot 时经 BoringSSL 缓存证书库)→ setupGracefulShutdown() → 异步 fire 1P 事件日志 + OAuth account 填充 + JetBrains 检测 + GitHub repo 检测 → 初始化 remote settings loading promise → configureGlobalMTLS()configureGlobalAgents()preconnectAnthropicApi()。注意 init 的网络配置顺序有依赖:mTLS → 代理 agent → preconnect,preconnect 必须最后,这样暖的连接用的是正确传输层。

1.5 信任门控 showSetupScreens 与安全设计

这是启动链路里安全模型最关键的部分。showSetupScreensinteractiveHelpers.tsx:104)只在交互会话执行。时序:

  1. 必要时显示 Onboarding。
  2. 信任对话框:“workspace trust boundary”。已信任则 fast-path 跳过 TrustDialog 的动态 import 和渲染。
  3. 信任建立后才做:setSessionTrustAccepted(true) → 用新鲜鉴权 header 重建 GrowthBook → getSystemContext()(信任前不预暖,因为 git 命令可经 hook/config 执行任意代码)→ MCP server 审批 + CLAUDE.md 外部 include 审批。
  4. applyConfigEnvironmentVariables():这是安全设计核心——init 阶段只应用了 safe env,完整 env(含 LD_PRELOAD/PATH 等来自不可信源的潜在危险变量)只在信任对话框接受后才应用
  5. 遥测初始化setImmediate(() => initializeTelemetryAfterTrust())——遥测在 env 应用后才初始化。

安全要点:信任建立前——只应用安全 env、不初始化第三方遥测、不预暖 git 上下文;信任建立后——应用全部 env、初始化遥测、预暖 git。此外 tengu_timer startup 事件刻意在任何阻塞对话框渲染记录,否则会把用户停在信任/OAuth 上的时间算进 p99(旧位置 p99 高达约 70s 全是对话框等待)。

1.6 migration 体系

migration 在 preAction 里 init() 之后调用 runMigrations()CURRENT_MIGRATION_VERSION = 11,只有当磁盘 migrationVersion 不等于它时才跑全套同步 migration。同步集合以模型别名迁移为主:migrateSonnet1mToSonnet45sonnet[1m] → 显式版本,因为 sonnet 别名现在指向 4.6)、migrateSonnet45ToSonnet46(1P 用户的显式 Sonnet 4.5 → sonnet 别名,并对老用户写时间戳触发一次性 REPL 通知)、migrateLegacyOpusToCurrent 等。这些迁移的共性原则:只读写 userSettings(不动 project/local/policy)、幂等、运行时仍有 parseUserSpecifiedModel 兜底 remap。


2. 核心会话循环与查询状态机

本章解析 Claude Code 的会话执行核心:从 SDK/无头入口 QueryEngine.submitMessagequery()queryLoop() 的主链路,并把 queryLoop 作为一个显式状态机来剖析它的 State 结构、转移枚举、每轮固定执行顺序、依赖注入设计以及一组错误恢复机制。

2.1 主链路:submitMessage → query → queryLoop

QueryEngine 是"一会话一实例"的容器,跨 turn 持有 mutableMessagesreadFileStatetotalUsagepermissionDenials 等状态(QueryEngine.ts:184-207)。每次 submitMessage()QueryEngine.ts:209)就是同一会话内的一个新 turn。其执行序列:

  1. 清空 turn 级技能发现集合、setCwd、记录 startTime
  2. wrappedCanUseTool 包裹注入的权限回调,把所有非 allow 决策记进 permissionDenialsQueryEngine.ts:244-271)。
  3. 通过 fetchSystemPromptPartsqueryContext.ts:44)并行取回三段构成 API 缓存键前缀的内容:defaultSystemPromptuserContextsystemContextcustomSystemPrompt 存在时会跳过默认系统提示和 getSystemContext,因为自定义提示是整体替换而非追加。
  4. 组装 systemPrompt = asSystemPrompt([custom 或 default, memoryMechanics?, append?])
  5. processUserInput 处理输入(slash 命令、附件展开),并在进入查询循环前先把用户消息落盘 transcript——为了让会话在"用户消息已接受但 API 尚未响应即被杀进程"时仍可 --resume
  6. yield system_init 后,若 shouldQuery 为假(纯本地 slash 命令)则直接产出 result 并 return。
  7. 否则进入核心 for await (const message of query({...})) 循环(QueryEngine.ts:675-686),按 message.type 分派:记录 transcript、累加 usage、捕获 lastStopReason、处理终止信号。

query() 本身是一层极薄包装(query.ts:219-239):只做 yield* queryLoop(...),拿到 Terminal 返回值后仅在正常返回时notifyCommandLifecycle(uuid, 'completed')。这种"started-without-completed"的不对称信号让上层能区分 turn 是否真正跑完。

2.2 queryLoop 作为显式状态机

queryLoop 是一个 while (true) 无限循环(query.ts:307),状态机本质体现在三处:跨迭代可变状态收敛进一个 State 结构体、每个"继续"分支整体重写 state = nextcontinue、每个"终止"分支 return { reason: ... }

State 结构体字段query.ts:204-217)含 messagestoolUseContext(迭代内唯一就地重赋值的字段)、autoCompactTrackingmaxOutputTokensRecoveryCount(上限 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3)、hasAttemptedReactiveCompactmaxOutputTokensOverridependingToolUseSummarystopHookActiveturnCounttransition上一轮为何 continue,让测试无需检查消息内容即可断言恢复路径触发)。

State 设计取舍很明确:把 9 个跨迭代变量收进一个结构体,每个 continue 站点写 state = { ...全部字段 } 而非 9 个独立赋值——这强制每个分支显式声明所有字段的新值,避免遗漏导致的状态泄漏。

Continue 转移原因枚举next_turn(正常路径,模型产出 tool_use→执行→拼回)、collapse_drain_retry(413 后先尝试便宜的 context-collapse 排空)、reactive_compact_retry(collapse 不够时做完整摘要压缩)、max_output_tokens_escalate(8k→64k 单次升级)、max_output_tokens_recovery(多轮注入续写消息)、stop_hook_blockingtoken_budget_continuation

Terminal 终止原因枚举blocking_limitimage_errormodel_erroraborted_streamingprompt_too_longstop_hook_preventedcompletedaborted_toolshook_stoppedmax_turns

一个逆向工程实证细节:ContinueTerminal 类型从 ./query/transitions.ts 导入(query.ts:104),但该文件在还原树里只是一个 passthrough 桩 transitionQueryState<T>(value: T): T { return value }——真正的判别联合类型定义在还原时丢失了。不过其完整形状可以从所有 transition: {...}return {...} 字面量无歧义地重建。

2.3 每轮迭代的固定执行顺序

每轮严格按下列顺序,顺序本身承载正确性约束:

  1. 解构 state、初始化 query 链追踪。
  2. 取压缩边界后的消息 getMessagesAfterCompactBoundary(messages)
  3. 工具结果预算:在 microcompact 之前强制单条消息聚合 tool_result 大小上限。注释解释顺序原因:缓存版 microcompact 纯按 tool_use_id 操作、从不读内容,所以内容替换对它不可见,两者可干净组合。
  4. snipHISTORY_SNIP):在 microcompact 之前运行,snipTokensFreed 透传给 autocompact。
  5. microcompact:缓存版边界消息延迟到 API 响应后再发,以便用真实 cache_deleted_input_tokens
  6. context collapseCONTEXT_COLLAPSE):在 autocompact 之前——若折叠已把上下文压到阈值以下,autocompact 就成 no-op,保留颗粒化上下文而非单条摘要。
  7. 组装 fullSystemPrompt
  8. autocompact:成功则 buildPostCompactMessages 替换消息、重置 tracking。
  9. 阻断预检isAtBlockingLimit 命中则 yield PTL 错误并 return blocking_limit。注释详述为何 reactive-compact/collapse 开启时要跳过:合成 413 会在 API 调用前返回,饿死那两条恢复路径。
  10. callModel 流式:外层套 attemptWithFallback 重试环。对 tool_use 做 backfillObservableInput 克隆后再 yield(原消息保持不变以免破坏 prompt cache 字节匹配);对可恢复错误(PTL/max_output_tokens/媒体)扣留不 yield。
  11. 工具调度:用 streamingToolExecutor.getRemainingResults()runTools(...) 执行。
  12. 拼回:处理附件、消费 memory/skill 预取、刷新工具,最后以 transition: {reason: 'next_turn'} 重写 state。

needsFollowUp 是循环退出的唯一信号(注释:“stop_reason === ‘tool_use’ is unreliable”)——流式中只要出现 tool_use 块就置 true。

2.4 QueryDeps 依赖注入的可测试性设计

QueryDepsdeps.ts:21-31)只注入 4 个 I/O 依赖:callModel(= queryModelWithStreaming)、microcompactautocompactuuid。设计意图:这几个是最常被 mock 的对象,过去测试要在 6–8 个文件里用 module-import-and-spy 样板逐个 spyOn,注入 deps 后可直接塞假实现。用 typeof fn 作为类型让签名自动跟随真实实现同步。与 QueryConfig 互补:config 在 query 入口快照一次不可变的 env/statsig/session 状态,故意排除 feature() 门控(后者是 tree-shaking 边界必须内联在守卫块上)。uuid 注入还有隐藏价值:turnIdchainId 都用 deps.uuid() 生成,测试可注入确定性 uuid 断言压缩边界与链路追踪。

2.5 max_output_tokens 升级(8k→64k)与多轮恢复(上限 3)

两级错误恢复机制,触发点都在 isWithheldMaxOutputTokens(lastMessage)。背景:为做 slot-reservation 优化,默认输出上限被压到 CAPPED_DEFAULT_MAX_TOKENS = 8_000(BQ p99 输出仅 4911 tokens,32k/64k 默认会过度预留 8–16 倍 slot),<1% 命中上限的请求走重试。

  • 第一级——升级:把 maxOutputTokensOverride 设为 ESCALATED_MAX_TOKENS = 64_000,以 max_output_tokens_escalate 重试同一请求——“no meta message, no multi-turn dance”,每 turn 仅触发一次。
  • 第二级——多轮恢复:若 64k 仍命中,在 recoveryCount < 3 时注入一条 isMeta 续写引导消息(“Output token limit hit. Resume directly… Break remaining work into smaller pieces”),计数 +1 重试。三次耗尽后才把先前扣留的错误真正 yield。

"扣留再恢复"是关键设计:若在流式中就把中间错误 leak 给 SDK,像 cowork/desktop 这类一见 error 字段就终会话的消费者会提前终止,而恢复循环还在空转无人接收。

2.6 stop hooks 与 token budget continuation

两者都在 !needsFollowUp 的终止决策块里,且有严格前置短路:若 lastMessage?.isApiErrorMessage,直接 executeStopFailureHooksreturn completed——为避免"error → hook blocking → retry → error"的死亡螺旋。

stop hooks 在真正跑 hook 前先做一批旁路工作(保存 CacheSafeParams 供 /btw 复用、模板 job 分类、fire-and-forget 的 prompt suggestion / memory 提取 / auto-dream)。hasAttemptedReactiveCompact 的保留是经验教训:注释记载若在此重置为 false 会造成 “compact → still too long → error → stop hook blocking → compact” 烧掉数千次 API 调用的死循环。

token budget continuationTOKEN_BUDGET 门控):当未进入收益递减turnTokens < budget * 0.9 时返回 continue,附 nudge 文案(“Stopped at N% of token target… Keep working — do not summarize”)。收益递减判据是连续 ≥3 次续跑且 delta 均 < 500 tokens。值得注意这个 +500k token budget 与 API 侧的 taskBudget(beta task-budgets-2026-03-13)是两套独立机制:前者纯客户端自动续跑,后者是服务端预算随压缩边界递减下发——解耦使 token budget 续跑不污染 API 协议。


3. 上下文压缩机制

Claude Code 在长会话场景下不是用"一把锤子"解决 token 膨胀问题,而是叠了五层职责不同、触发条件不同、代价不同的机制。它们在查询循环入口处按严格顺序串联(snip → microcompact → context-collapse → proactive autocompact,外加流式响应失败后的 reactive compact),形成一条"从最便宜到最昂贵、从最无损到最有损"的降级阶梯。

关于本逆向树的一个重要事实:snipCompact.tsreactiveCompact.tscontextCollapse/*cachedMicrocompact.ts 在此还原树中是空壳/桩(这些是 ant-only / feature-gated 私有模块,未随发布包附带可还原的实现体)。但调用方(query.tsQueryEngine.tsmessages.ts)以及数据契约和大量解释性注释是完整的,足以重建设计意图。microCompact.ts(时间触发路径)、autoCompact.tscompact.tsapiMicrocompact.ts 是有完整实现体的。

3.1 五套机制的串联顺序与"为何是这个顺序"

排序不是随意的。query.ts:428-439 给出 collapse 先于 autocompact 的理由:collapse 若能把 token 压到 autocompact 阈值以下,autocompact 就成了 no-op,于是保留了细粒度上下文而非塌缩成一段摘要。这是整套设计的核心价值取向——“能用便宜的无损手段就绝不用昂贵的有损摘要”。snip 同理排在最前(最便宜)。snipTokensFreed 一路被 plumb 到 autocompact 的阈值判断里,因为 tokenCountWithEstimation 读的是受保护尾部 assistant 消息的 usage,看不到 snip 删掉的部分,必须手动减掉这个 delta,否则阈值判断会虚高。

3.2 snip(HISTORY_SNIP)——最廉价的尾部投影裁剪

snip 是阶梯第一级。它的角色:在 REPL 保留完整历史用于 UI 滚动回看的同时,给"喂给模型"的路径做一层投影裁剪——getMessagesAfterCompactBoundaryHISTORY_SNIP 开启时额外调用 projectSnippedView,把已 snip 的消息从模型可见视图里过滤掉,并接受 {includeSnipped: true} 让 REPL 全屏 compact handler 在滚动回看里保留它们。它产出一个 boundary message 作为信号;QueryEngine 用注入的 snipReplay 回调在 mutableMessages 上重放,从而清掉僵尸消息和过期标记,避免 mutableMessages 永不收缩(SDK 长会话内存泄漏)。snip 的关键性质是无 API 调用、无摘要、可投影

3.3 microcompact——时间触发与缓存编辑两条路径

microcompactMessagesmicroCompact.ts:253-293)内部有两条互斥路径。两条都只动一个白名单集合 COMPACTABLE_TOOLS:FileRead、Shell、Grep、Glob、WebSearch、WebFetch、FileEdit、FileWrite。设计判断很明确——只有这些工具的结果是"可重新获取的派生数据",清掉它们模型可以重读;而 Task 等工具的结果可能是不可重建的推理产物,不在清理范围。

路径 A:时间触发。 evaluateTimeBasedTrigger 计算"距上一条 assistant 消息的分钟数",超过 gapThresholdMinutes(默认 60)就触发。工程洞察非常精到:服务端 prompt cache 的 TTL 是 1 小时,gap 超过 60 分钟意味着缓存几乎必然已过期,整个前缀无论如何都要被重写——那么"在请求前就把旧 tool_result 内容清掉、缩小被重写的量"就是纯赚的,绝不会强制一次本不会发生的 cache miss。它直接把旧结果替换成常量 '[Old tool result content cleared]',保留最近 keepRecent 个(注释解释 slice(-0) 会反常地返回整个数组)。

路径 B:cached microcompact(CACHED_MICROCOMPACT)。 ant-only、按 feature() gate 做死代码消除。本质区别是不修改本地消息内容,而是通过 API 的 cache-editing 能力删除 tool_result,从而不使前缀缓存失效。boundary message 被故意推迟到 API 响应之后,以便用 API 真实返回的 cache_deleted_input_tokens 而非客户端估算。API 侧的等价能力在 apiMicrocompact.ts 中完整:用 clear_tool_uses_20250919 策略,trigger.input_tokens 默认 180k、clear_at_least = trigger − keepTarget(40k),区分清结果与清调用入参(FileEdit/Write 的入参是有价值的变更记录,排除)。还有 clear_thinking_20251015:>1h 空闲时只保留最后一个 thinking turn。

3.4 context-collapse——为何先于 autocompact

collapse(CONTEXT_COLLAPSE,ant-only)是介于 microcompact 和 autocompact 之间的中间层。它是一套90% 提交 / 95% 阻塞式 spawn 的阈值阶梯,把旧的细粒度 span 折叠成存于独立 collapse store 的摘要,而摘要消息不在 REPL 数组里——projectView() 在每次进入时重放 commit log,这是 collapse 能跨回合持久化的机制。

它必须先于 autocompact 的根本原因:当 collapse 开启时,它就是上下文管理系统。autocompact 在"有效窗口 − 13k"(约 93%)触发,正好夹在 collapse 的 commit-start(90%)与 blocking(95%)之间——若不抑制,autocompact 会和 collapse 赛跑且通常赢,把 collapse 即将无损保存的细粒度上下文一把摧毁。因此 shouldAutoCompact 在 collapse 开启时直接 return false

3.5 proactive autocompact——阈值公式、有效窗口与三次熔断

这是阶梯里第一个会有损摘要整段会话的机制。

阈值公式。 getEffectiveContextWindowSize = getContextWindowForModel − min(getMaxOutputTokensForModel, 20_000)。这个 20k(MAX_OUTPUT_TOKENS_FOR_SUMMARY)基于"compact 摘要输出 p99.99 = 17,387 tokens"的实测。getAutoCompactThreshold = 有效窗口 − AUTOCOMPACT_BUFFER_TOKENS(13_000)。即 阈值 = 上下文窗口 − min(maxOutput, 20k) − 13k buffer

三次失败熔断。 MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3,注释附带触目惊心的数据:BQ 2026-03-10 有 1,279 个会话单会话连续失败 50+ 次(最高 3,272),每天浪费约 25 万次 API 调用。consecutiveFailures >= 3 直接返回不再尝试。

多重抑制 gate。 session_memory/compact 这两个 forked agent 会死锁所以直接 false;marble_origami(ctx-agent)若 autocompact 会摧毁主线程 commit log;reactive-only 模式抑制 proactive 让 reactive 接 413;collapse 抑制。

3.6 reactive compact——413 / 媒体错误后只重试一次

这是阶梯最后一级,响应式而非主动式:只有当 API 真的返回 413 或媒体尺寸错误,才在流式循环失败后触发。关键设计是withhold-then-recover:流式循环里检测到错误时不立即 yield,而是扣住。恢复顺序是:(1) drain 所有 staged collapse(最便宜);(2) tryReactiveCompacthasAttempted 标志保证单次重试;(3) 都不行就 yield 扣住的错误并 return。注释特别强调不 fall through 到 stop hooks,否则 error→hook 阻塞→retry→error 形成死亡螺旋。

3.7 为什么需要五套而非一套

这五套在三个维度上互补,没有冗余:

  • 代价维度:snip(无 API、投影)< time-based MC(借已注定的 cache 重写)< cached MC(cache_edits,不破缓存)< collapse(折叠成 store 摘要,跨回合无损持久)< autocompact(一次摘要 API + 丢弃细节)< reactive(错误后兜底)。
  • 触发维度:time-based 由时间 gap触发(攻击 cache TTL 过期窗口);microcompact/collapse/autocompact 由token 阈值触发;reactive 由 API 真实错误触发(兜底估算不准)。token 估算永远有误差,所以必须有 reactive 这道"事后真相"防线。
  • 无损 vs 有损:snip/cached MC/collapse 尽量无损或可重建;只有 autocompact/reactive 才真正把会话摘要成不可逆的一段文本。设计的全部努力就是尽量延迟到达"有损"那一级

3.8 compact_boundary 与 QueryEngine splice 释放内存

真正的内存释放发生在 QueryEngine.ts:917-942:当一个 compact_boundary 被 push 进 mutableMessages(必然是最后一个元素),引擎立即 mutableMessages.splice(0, boundaryIdx),把边界之前的所有 pre-compaction 消息从数组里物理删除让 GC 回收。这对长会话内存至关重要——否则 mutableMessages 会无限增长。

3.9 这套设计反映的工程投入

  1. 遥测驱动的常量。20k 摘要预留来自 p99.99=17,387 实测,8k 输出 cap 来自 p99=4,911,3 次熔断来自"1,279 会话连续失败、25 万次/天浪费 API"的 BQ 查询。每个魔数背后都有一条 BQ 注释。
  2. 缓存语义的极致压榨。time-based MC 专门攻击 1h cache TTL 过期窗口(“never force a miss that wouldn’t have happened”);cached MC 用 cache_edits 删 tool_result 而不破前缀;compact 用 forked-agent 复用主会话缓存。
  3. 死亡螺旋防御无处不在。熔断、单次重试、不 fall-through 到 stop hooks、PTL 合成 marker 去重——每处都是为了防止"压缩失败→重试→更糟"的正反馈环。

总体判断:这不是"上下文满了就摘要"的朴素实现,而是一套分层降级、缓存感知、遥测调参、螺旋防御的生产级系统。


下一篇(二)进入工具层:工具抽象与执行管线、Bash 安全引擎、API 客户端与 MCP 集成。

原文链接:https://www.ssssmy.com/notes/claude-code-deep-dive-core