13 · 构建与分发

最后一章讲:51 万行 TypeScript 是怎么变成一个能双击运行的文件的,以及这个构建过程本身如何反过来塑造了代码的写法。

13.1 Bun 单文件可执行程序

先看事实:

$ ls -la ~/.local/share/claude/versions/
-rwxr-xr-x  272553824  2.1.223      ← 260 MB
-rwxr-xr-x  279661952  2.1.226      ← 267 MB
-rwxr-xr-x  310740672  2.1.234      ← 296 MB

一个文件,296 MB,直接可执行。不需要装 Node.js,不需要 npm install,不需要任何运行时依赖。

这是 Bun 的一个能力:它可以把「JavaScript 运行时 + 你的全部代码 + 全部依赖包 + 所有静态资源」打包进一个二进制文件。

对比传统 Node.js 命令行程序Bun 单文件
用户要装什么Node.js(版本还要对)+ npm 包什么都不用
体积几 MB(但依赖几百 MB)296 MB(自包含)
启动速度要解析和加载几千个模块文件模块已经内联,更快
版本冲突用户的 Node 版本可能不兼容不存在
能内嵌原生程序困难可以(见下)

内嵌原生程序

第 4.4 节提到过一个条件判断:

// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
// trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),

译:内部原生构建版把 bfs / ugrep 内嵌进了 bun 可执行文件(用的是和 ripgrep 一样的 ARGV0 技巧)。当它们可用时,Claude 的 shell 里的 find/grep 被别名指向这些快速工具,所以独立的 Glob/Grep 工具就不必要了。

「ARGV0 技巧」是什么

Unix 程序启动时能知道「自己是用什么名字被调用的」(这个值叫 argv[0])。

所以一个可执行文件可以这样写:如果我被以 grep 这个名字调用,我就表现得像 grep;如果被以 claude 调用,我就是 Claude Code。

这样一个二进制文件就能扮演多个程序。BusyBox 就是用这个技巧把几百个 Unix 命令塞进一个文件的。

对 Claude Code 的意义:模型执行 grep -r "foo" . 时,实际跑的是内嵌的高性能搜索程序,而不是系统自带的 grep。速度快很多,而且行为在所有平台上一致。连带的好处是不再需要独立的 Grep 工具 —— 少一个工具就少一份说明文字常驻上下文(第 4.5 节)。

13.2 编译期特性开关:89 个

源码里到处是这样的写法:

import { feature } from 'bun:bundle'

const reactiveCompact = feature('REACTIVE_COMPACT')
  ? (require('./services/compact/reactiveCompact.js') as typeof import('...'))
  : null

if (feature('CONTEXT_COLLAPSE')) {
  collapseOwnsIt = (contextCollapse?.isContextCollapseEnabled() ?? false) && isAutoCompactEnabled()
}

统计下来共有 89 个不同的编译期开关。部分列表:

上下文与压缩: CACHED_MICROCOMPACT REACTIVE_COMPACT CONTEXT_COLLAPSE HISTORY_SNIP COMPACTION_REMINDERS TOKEN_BUDGET 子智能体: FORK_SUBAGENT COORDINATOR_MODE BG_SESSIONS UDS_INBOX VERIFICATION_AGENT TEAMMEM 权限与安全: TRANSCRIPT_CLASSIFIER BASH_CLASSIFIER POWERSHELL_AUTO_MODE 工具: WEB_BROWSER_TOOL MONITOR_TOOL TERMINAL_PANEL WORKFLOW_SCRIPTS AGENT_TRIGGERS AGENT_TRIGGERS_REMOTE OVERFLOW_TEST_TOOL 记忆与技能: EXTRACT_MEMORIES SKILL_IMPROVEMENT EXPERIMENTAL_SKILL_SEARCH MCP_SKILLS RUN_SKILL_GENERATOR 集成: BRIDGE_MODE DAEMON CHICAGO_MCP SSH_REMOTE DIRECT_CONNECT CCR_AUTO_CONNECT CCR_MIRROR CCR_REMOTE_SETUP 产品线: KAIROS KAIROS_BRIEF KAIROS_CHANNELS KAIROS_DREAM KAIROS_GITHUB_WEBHOOKS KAIROS_PUSH_NOTIFICATION PROACTIVE 可观测: PROMPT_CACHE_BREAK_DETECTION PERFETTO_TRACING SLOW_OPERATION_LOGGING ENHANCED_TELEMETRY_BETA MEMORY_SHAPE_TELEMETRY COWORKER_TYPE_TELEMETRY 实验与消融: ABLATION_BASELINE ANTI_DISTILLATION_CC TREE_SITTER_BASH TREE_SITTER_BASH_SHADOW ULTRATHINK ULTRAPLAN TORCH LODESTONE 界面: AUTO_THEME STREAMLINED_OUTPUT MESSAGE_ACTIONS HISTORY_PICKER QUICK_SEARCH VOICE_MODE NATIVE_CLIPBOARD_IMAGE 平台: IS_LIBC_GLIBC IS_LIBC_MUSL NATIVE_CLIENT_ATTESTATION

13.3 死代码消除:为什么这不只是「if 判断」

feature() 和普通的运行时判断有本质区别:它在打包时被替换成字面量 truefalse,然后打包器会把不可达的分支整段删除。

源码里写的: const snipModule = feature('HISTORY_SNIP') ? require('./services/compact/snipCompact.js') : null 外部版本打包后变成: const snipModule = null ★ snipCompact.js 这个模块及其全部依赖,根本不在产物里 内部版本打包后变成: const snipModule = require('./services/compact/snipCompact.js')

这带来三个后果:

后果说明
体积外部版本不携带内部功能的代码,可执行文件更小
安全内部功能的代码物理上不存在于外部产物里,无法被逆向分析出来
字符串消除连字符串常量都被删除 —— 这一点催生了一种特殊的编码风格,见下

「排除字符串」检查催生的编码风格

源码里有多处这样的注释:

// Entire block gated behind feature() so the excluded string
// is eliminated from external builds.
if (feature('CACHED_MICROCOMPACT') && pendingCacheEdits) { ... }
// The subtype check lives inside the injected callback so feature-gated
// strings stay out of this file (excluded-strings check).
snipReplay?: (yieldedSystemMsg, store) => { messages, executed } | undefined

第二段尤其能说明问题。为了让某个内部功能的字符串不出现在外部产物里,他们把一段逻辑改成了「由外部注入的回调函数」 —— 这样那个字符串就只存在于注入方(内部构建才编译的模块)里。

这是一个真实的架构约束反过来影响代码结构的例子。

正常的写法是在 QueryEngine 里直接判断 message.subtype === 'snip_boundary'。但那个字符串会出现在外部产物里,泄露内部功能的存在。

所以改成:QueryEngine 接受一个 snipReplay 回调,自己完全不知道判断条件是什么。代码变复杂了,但满足了「外部产物不含内部字符串」的硬约束。

源码注释还提到这个改动的一个副作用是好的:「keeps QueryEngine free of excluded strings and testable despite feature() returning false under bun test」 —— 在测试环境下 feature() 返回 false,但通过注入回调,这段逻辑仍然可测

另一处:ESLint 规则也参与了

// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
/* eslint-disable custom-rules/no-top-level-side-effects */

可以看到多条自定义的 lint 规则

这些规则是构建约束的自动化守卫。不是靠代码评审时人肉检查,而是让违规的代码直接过不了检查。

13.4 编译期宏

// MACRO.VERSION is inlined at build time
console.log(`${MACRO.VERSION} (Claude Code)`)

MACRO 是构建时被替换成字面量的宏。所以 --version 这条快路径连读一个配置文件都不需要(第 1.2 节)。

13.5 运行时特性开关:另一套系统

除了编译期开关,还有一套运行时开关,用的是 GrowthBook(一个 A/B 实验平台):

const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false)

注意这个函数名:getFeatureValue_CACHED_MAY_BE_STALE(获取特性值_已缓存_可能是过期的)。

把「这个值可能是过期的」直接写进函数名,是一个很好的 API 设计。

为什么重要?回顾第 8.3 节那个分叉子智能体的坑:「Reconstructing by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and bust the prompt cache」 —— 配置从冷缓存变成热缓存,导致两次生成的系统提示词字节不同。

如果这个函数叫 getFeatureValue(),调用者很容易假设它每次返回相同的值。而名字里带上 MAY_BE_STALE,你在写代码时就会想一下「如果这个值在两次调用之间变了会怎样」。

两套开关的分工

编译期 feature()运行时 GrowthBook
什么时候决定打包时程序运行时从服务器拉取
能否按用户区分不能(同一份产物所有人一样)能(可以给 5% 的用户开启)
代码是否存在关掉的代码完全不存在代码存在,只是不执行
能否紧急关闭不能(要重新发版)(改一下配置,所有用户立即生效)
典型用途内部 / 外部版本差异、产品线区分灰度发布、A/B 实验、紧急止血

两者经常叠加使用:编译期开关决定「这段代码在不在」,运行时开关决定「在的话要不要执行」。第 6.3 节的缓存微压缩就是这样:

if (feature('CACHED_MICROCOMPACT')) {              // 编译期:外部版本没有这段代码
  const mod = await getCachedMCModule()
  if (mod.isCachedMicrocompactEnabled() &&         // 运行时:可以随时关掉
      mod.isModelSupportedForCacheEditing(model) &&
      isMainThreadSource(querySource)) {
    return await cachedMicrocompactPath(messages, querySource)
  }
}

13.6 版本与更新

安装目录的结构说明了更新策略:

~/.local/share/claude/
├── ClaudeCode.app/                桌面应用
└── versions/
    ├── 2.1.223                    ← 旧版本保留
    ├── 2.1.226                    ← 旧版本保留
    └── 2.1.234                    ← 当前版本

~/.local/bin/claude  →  符号链接指向 versions/2.1.234

多个版本并存,通过符号链接切换当前版本。这样:

代价是磁盘占用 —— 三个版本就是 800 MB。所以 utils/nativeInstaller/installer.ts(53 KB)里应该有清理旧版本的逻辑。

13.7 从构建方式反推的架构约束

这一章的内容其实在前面每一章都留下了痕迹。汇总一下「构建方式如何塑造了代码」

构建约束对代码的影响出现在
单文件、零依赖 可以内嵌原生搜索程序 → 少两个工具 → 系统提示词更短 第 4.4 节
快路径要零加载 入口全部用动态导入;禁止顶层副作用和顶层读环境变量(自定义 lint 规则强制) 第 1.2 节
死代码消除 feature() 必须写在 if / 三元表达式里,不能组合成变量再判断 第 3 章多处
排除字符串检查 把逻辑改成注入回调,让内部字符串不进入外部产物 第 2 章 snipReplay
导入顺序不能重排 禁用自动整理导入的工具 tools.ts 顶部
测试环境下 feature() 返回 false 被开关保护的逻辑必须能通过注入的方式单独测试 第 2 章
这一章想说明的事

架构讨论通常止步于「模块怎么划分」。但在一个真实的产品里,「怎么构建、怎么分发、怎么灰度、怎么回滚」这些工程约束,会实实在在地反过来改变代码的写法

Claude Code 里那些看起来奇怪的写法 —— 三元表达式里的 feature()、注入式回调、动态导入、禁用 lint 规则的注释 —— 单独看每一个都像坏味道。放到构建约束的语境里看,它们都是必要的。

这也是读源码相比读架构文章的价值所在:架构文章讲的是「应该怎样」,源码里留着的是「实际付了什么代价」。


《Claude Code 架构全解》完

十四章走完了从进程启动到消息落盘的完整链路。如果要用一句话概括这个系统的设计立场:

它把「提示词缓存命中率」当成一等公民约束,然后围绕这个约束重新设计了工具装配、子智能体派生、上下文压缩、甚至日志字段的补全方式。凡是和这个目标冲突的整洁性,都被牺牲掉了。

文档里任何看不懂或想深挖的地方,选中那段文字点「提问」就行。