深扒 Claude Code 源码(一):启动、会话循环与五套上下文压缩
会话内核三章 · 启动性能 / 查询状态机 / 压缩降级阶梯
本文是《深扒 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=8192(cli.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.tsx 的 main() 函数(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-sources,main.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(...)。launchRepl(replLauncher.tsx:12-22)本身又是动态导入:await import('./components/App.js') 和 await import('./screens/REPL.js'),再 renderAndRun(root, <App><REPL/></App>)。renderAndRun(interactiveHelpers.tsx:98-103)做了三件事的"标准收尾":root.render(element) → startDeferredPrefetches() → await root.waitUntilExit() → gracefulShutdown(0)。startDeferredPrefetches() 紧贴在 render() 之后、waitUntilExit() 之前,这正是"首帧已提交、用户开始打字、把剩余预热塞进这个窗口"的关键缝合点。
1.2 cli.tsx 的 fast-path 分流顺序与设计意图
main() 内的分流是严格有序的,顺序本身编码了"成本递增 + 频率/性能敏感度"的优先级:
--version/-v/-V(零导入):直接console.log(\${MACRO.VERSION} (Claude Code)`)并 return(cli.tsx:36-42)。这是整条链路里唯一一条**连 startupProfiler 都不加载**的路径——注释强调"zero module loading needed"。考量是:版本号是脚本/包管理器/CI 高频探测的东西(npm view`、安装校验),必须毫秒级返回。它排在最前面,确保任何其他模块都不会被求值。- 加载 profiler:只有越过 version 快路径,才
await import('../utils/startupProfiler.js')并打profileCheckpoint('cli_entry')(cli.tsx:44-48)。 --dump-system-prompt(ant-only):feature('DUMP_SYSTEM_PROMPT')门控,只enableConfigs()+ 取模型 +getSystemPrompt()并打印(cli.tsx:53-71)。- Chrome MCP / native host / computer-use MCP:被外部进程 spawn 的 MCP server 子进程入口(
cli.tsx:72-93)。 --daemon-worker(DAEMON):注释强调"spawned per-worker, so perf-sensitive. No enableConfigs(), no analytics sinks — workers are lean."(cli.tsx:95-106)。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)。- templates / environment-runner / self-hosted-runner(feature 门控的 headless 入口)。
--worktree --tmuxexec 快路径(cli.tsx:247-274)。- 回退到完整 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 路径并行 spawnplutil(先用同步existsSync跳过不存在的文件,省掉每次约 5ms 的 ENOENT spawn 成本),Windows 上并行reg queryHKLM/HKCU。startKeychainPrefetch()解决一个具体 65ms 问题:isRemoteManagedSettingsEligible()原本顺序同步 spawn 两次security find-generic-password(OAuth 凭据约 32ms + 旧版 API key 约 33ms)。预热把这两次改成并行 fire,与 import 重叠。模块注释还专门说明为什么只 importmacOsKeychainHelpers.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) 首帧后的 startDeferredPrefetches。 由 renderAndRun 在 root.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
init(init.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 与安全设计
这是启动链路里安全模型最关键的部分。showSetupScreens(interactiveHelpers.tsx:104)只在交互会话执行。时序:
- 必要时显示 Onboarding。
- 信任对话框:“workspace trust boundary”。已信任则 fast-path 跳过
TrustDialog的动态 import 和渲染。 - 信任建立后才做:
setSessionTrustAccepted(true)→ 用新鲜鉴权 header 重建 GrowthBook →getSystemContext()(信任前不预暖,因为 git 命令可经 hook/config 执行任意代码)→ MCP server 审批 + CLAUDE.md 外部 include 审批。 applyConfigEnvironmentVariables():这是安全设计核心——init 阶段只应用了 safe env,完整 env(含LD_PRELOAD/PATH等来自不可信源的潜在危险变量)只在信任对话框接受后才应用。- 遥测初始化:
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。同步集合以模型别名迁移为主:migrateSonnet1mToSonnet45(sonnet[1m] → 显式版本,因为 sonnet 别名现在指向 4.6)、migrateSonnet45ToSonnet46(1P 用户的显式 Sonnet 4.5 → sonnet 别名,并对老用户写时间戳触发一次性 REPL 通知)、migrateLegacyOpusToCurrent 等。这些迁移的共性原则:只读写 userSettings 源(不动 project/local/policy)、幂等、运行时仍有 parseUserSpecifiedModel 兜底 remap。
2. 核心会话循环与查询状态机
本章解析 Claude Code 的会话执行核心:从 SDK/无头入口 QueryEngine.submitMessage 到 query()→queryLoop() 的主链路,并把 queryLoop 作为一个显式状态机来剖析它的 State 结构、转移枚举、每轮固定执行顺序、依赖注入设计以及一组错误恢复机制。
2.1 主链路:submitMessage → query → queryLoop
QueryEngine 是"一会话一实例"的容器,跨 turn 持有 mutableMessages、readFileState、totalUsage、permissionDenials 等状态(QueryEngine.ts:184-207)。每次 submitMessage()(QueryEngine.ts:209)就是同一会话内的一个新 turn。其执行序列:
- 清空 turn 级技能发现集合、
setCwd、记录startTime。 - 用
wrappedCanUseTool包裹注入的权限回调,把所有非allow决策记进permissionDenials(QueryEngine.ts:244-271)。 - 通过
fetchSystemPromptParts(queryContext.ts:44)并行取回三段构成 API 缓存键前缀的内容:defaultSystemPrompt、userContext、systemContext。customSystemPrompt存在时会跳过默认系统提示和getSystemContext,因为自定义提示是整体替换而非追加。 - 组装
systemPrompt = asSystemPrompt([custom 或 default, memoryMechanics?, append?])。 - 用
processUserInput处理输入(slash 命令、附件展开),并在进入查询循环前先把用户消息落盘 transcript——为了让会话在"用户消息已接受但 API 尚未响应即被杀进程"时仍可--resume。 - yield
system_init后,若shouldQuery为假(纯本地 slash 命令)则直接产出result并 return。 - 否则进入核心
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 = next 并 continue、每个"终止"分支 return { reason: ... }。
State 结构体字段(query.ts:204-217)含 messages、toolUseContext(迭代内唯一就地重赋值的字段)、autoCompactTracking、maxOutputTokensRecoveryCount(上限 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3)、hasAttemptedReactiveCompact、maxOutputTokensOverride、pendingToolUseSummary、stopHookActive、turnCount、transition(上一轮为何 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_blocking、token_budget_continuation。
Terminal 终止原因枚举:blocking_limit、image_error、model_error、aborted_streaming、prompt_too_long、stop_hook_prevented、completed、aborted_tools、hook_stopped、max_turns。
一个逆向工程实证细节:Continue 与 Terminal 类型从 ./query/transitions.ts 导入(query.ts:104),但该文件在还原树里只是一个 passthrough 桩 transitionQueryState<T>(value: T): T { return value }——真正的判别联合类型定义在还原时丢失了。不过其完整形状可以从所有 transition: {...} 与 return {...} 字面量无歧义地重建。
2.3 每轮迭代的固定执行顺序
每轮严格按下列顺序,顺序本身承载正确性约束:
- 解构 state、初始化 query 链追踪。
- 取压缩边界后的消息
getMessagesAfterCompactBoundary(messages)。 - 工具结果预算:在 microcompact 之前强制单条消息聚合 tool_result 大小上限。注释解释顺序原因:缓存版 microcompact 纯按
tool_use_id操作、从不读内容,所以内容替换对它不可见,两者可干净组合。 - snip(
HISTORY_SNIP):在 microcompact 之前运行,snipTokensFreed透传给 autocompact。 - microcompact:缓存版边界消息延迟到 API 响应后再发,以便用真实
cache_deleted_input_tokens。 - context collapse(
CONTEXT_COLLAPSE):在 autocompact 之前——若折叠已把上下文压到阈值以下,autocompact 就成 no-op,保留颗粒化上下文而非单条摘要。 - 组装
fullSystemPrompt。 - autocompact:成功则
buildPostCompactMessages替换消息、重置 tracking。 - 阻断预检:
isAtBlockingLimit命中则 yield PTL 错误并return blocking_limit。注释详述为何 reactive-compact/collapse 开启时要跳过:合成 413 会在 API 调用前返回,饿死那两条恢复路径。 - callModel 流式:外层套
attemptWithFallback重试环。对 tool_use 做backfillObservableInput克隆后再 yield(原消息保持不变以免破坏 prompt cache 字节匹配);对可恢复错误(PTL/max_output_tokens/媒体)扣留不 yield。 - 工具调度:用
streamingToolExecutor.getRemainingResults()或runTools(...)执行。 - 拼回:处理附件、消费 memory/skill 预取、刷新工具,最后以
transition: {reason: 'next_turn'}重写 state。
needsFollowUp 是循环退出的唯一信号(注释:“stop_reason === ‘tool_use’ is unreliable”)——流式中只要出现 tool_use 块就置 true。
2.4 QueryDeps 依赖注入的可测试性设计
QueryDeps(deps.ts:21-31)只注入 4 个 I/O 依赖:callModel(= queryModelWithStreaming)、microcompact、autocompact、uuid。设计意图:这几个是最常被 mock 的对象,过去测试要在 6–8 个文件里用 module-import-and-spy 样板逐个 spyOn,注入 deps 后可直接塞假实现。用 typeof fn 作为类型让签名自动跟随真实实现同步。与 QueryConfig 互补:config 在 query 入口快照一次不可变的 env/statsig/session 状态,故意排除 feature() 门控(后者是 tree-shaking 边界必须内联在守卫块上)。uuid 注入还有隐藏价值:turnId 和 chainId 都用 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,直接 executeStopFailureHooks 并 return 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 continuation(TOKEN_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.ts、reactiveCompact.ts、contextCollapse/*、cachedMicrocompact.ts在此还原树中是空壳/桩(这些是 ant-only / feature-gated 私有模块,未随发布包附带可还原的实现体)。但调用方(query.ts、QueryEngine.ts、messages.ts)以及数据契约和大量解释性注释是完整的,足以重建设计意图。microCompact.ts(时间触发路径)、autoCompact.ts、compact.ts、apiMicrocompact.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 滚动回看的同时,给"喂给模型"的路径做一层投影裁剪——getMessagesAfterCompactBoundary 在 HISTORY_SNIP 开启时额外调用 projectSnippedView,把已 snip 的消息从模型可见视图里过滤掉,并接受 {includeSnipped: true} 让 REPL 全屏 compact handler 在滚动回看里保留它们。它产出一个 boundary message 作为信号;QueryEngine 用注入的 snipReplay 回调在 mutableMessages 上重放,从而清掉僵尸消息和过期标记,避免 mutableMessages 永不收缩(SDK 长会话内存泄漏)。snip 的关键性质是无 API 调用、无摘要、可投影。
3.3 microcompact——时间触发与缓存编辑两条路径
microcompactMessages(microCompact.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) tryReactiveCompact 带 hasAttempted 标志保证单次重试;(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 这套设计反映的工程投入
- 遥测驱动的常量。20k 摘要预留来自 p99.99=17,387 实测,8k 输出 cap 来自 p99=4,911,3 次熔断来自"1,279 会话连续失败、25 万次/天浪费 API"的 BQ 查询。每个魔数背后都有一条 BQ 注释。
- 缓存语义的极致压榨。time-based MC 专门攻击 1h cache TTL 过期窗口(“never force a miss that wouldn’t have happened”);cached MC 用 cache_edits 删 tool_result 而不破前缀;compact 用 forked-agent 复用主会话缓存。
- 死亡螺旋防御无处不在。熔断、单次重试、不 fall-through 到 stop hooks、PTL 合成 marker 去重——每处都是为了防止"压缩失败→重试→更糟"的正反馈环。
总体判断:这不是"上下文满了就摘要"的朴素实现,而是一套分层降级、缓存感知、遥测调参、螺旋防御的生产级系统。
下一篇(二)进入工具层:工具抽象与执行管线、Bash 安全引擎、API 客户端与 MCP 集成。
原文链接:https://www.ssssmy.com/notes/claude-code-deep-dive-core