最后一章讲:51 万行 TypeScript 是怎么变成一个能双击运行的文件的,以及这个构建过程本身如何反过来塑造了代码的写法。
先看事实:
$ 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 工具就不必要了。
Unix 程序启动时能知道「自己是用什么名字被调用的」(这个值叫 argv[0])。
所以一个可执行文件可以这样写:如果我被以 grep 这个名字调用,我就表现得像 grep;如果被以 claude 调用,我就是 Claude Code。
这样一个二进制文件就能扮演多个程序。BusyBox 就是用这个技巧把几百个 Unix 命令塞进一个文件的。
对 Claude Code 的意义:模型执行 grep -r "foo" . 时,实际跑的是内嵌的高性能搜索程序,而不是系统自带的 grep。速度快很多,而且行为在所有平台上一致。连带的好处是不再需要独立的 Grep 工具 —— 少一个工具就少一份说明文字常驻上下文(第 4.5 节)。
源码里到处是这样的写法:
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 个不同的编译期开关。部分列表:
feature() 和普通的运行时判断有本质区别:它在打包时被替换成字面量 true 或 false,然后打包器会把不可达的分支整段删除。
这带来三个后果:
| 后果 | 说明 |
|---|---|
| 体积 | 外部版本不携带内部功能的代码,可执行文件更小 |
| 安全 | 内部功能的代码物理上不存在于外部产物里,无法被逆向分析出来 |
| 字符串消除 | 连字符串常量都被删除 —— 这一点催生了一种特殊的编码风格,见下 |
源码里有多处这样的注释:
// 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,但通过注入回调,这段逻辑仍然可测。
// 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 规则:
custom-rules/no-process-env-top-level —— 禁止在模块顶层读环境变量(因为顶层代码在导入时就执行,会破坏快路径的「零加载」)custom-rules/no-top-level-side-effects —— 禁止顶层副作用(同上)custom-rules/require-tool-match-name —— 要求用统一的工具名匹配函数(因为工具有别名,直接比较字符串会漏)这些规则是构建约束的自动化守卫。不是靠代码评审时人肉检查,而是让违规的代码直接过不了检查。
// MACRO.VERSION is inlined at build time
console.log(`${MACRO.VERSION} (Claude Code)`)
MACRO 是构建时被替换成字面量的宏。所以 --version 这条快路径连读一个配置文件都不需要(第 1.2 节)。
除了编译期开关,还有一套运行时开关,用的是 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)
}
}
安装目录的结构说明了更新策略:
~/.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)里应该有清理旧版本的逻辑。
这一章的内容其实在前面每一章都留下了痕迹。汇总一下「构建方式如何塑造了代码」:
| 构建约束 | 对代码的影响 | 出现在 |
|---|---|---|
| 单文件、零依赖 | 可以内嵌原生搜索程序 → 少两个工具 → 系统提示词更短 | 第 4.4 节 |
| 快路径要零加载 | 入口全部用动态导入;禁止顶层副作用和顶层读环境变量(自定义 lint 规则强制) | 第 1.2 节 |
| 死代码消除 | feature() 必须写在 if / 三元表达式里,不能组合成变量再判断 |
第 3 章多处 |
| 排除字符串检查 | 把逻辑改成注入回调,让内部字符串不进入外部产物 | 第 2 章 snipReplay |
| 导入顺序不能重排 | 禁用自动整理导入的工具 | tools.ts 顶部 |
| 测试环境下 feature() 返回 false | 被开关保护的逻辑必须能通过注入的方式单独测试 | 第 2 章 |
架构讨论通常止步于「模块怎么划分」。但在一个真实的产品里,「怎么构建、怎么分发、怎么灰度、怎么回滚」这些工程约束,会实实在在地反过来改变代码的写法。
Claude Code 里那些看起来奇怪的写法 —— 三元表达式里的 feature()、注入式回调、动态导入、禁用 lint 规则的注释 —— 单独看每一个都像坏味道。放到构建约束的语境里看,它们都是必要的。
这也是读源码相比读架构文章的价值所在:架构文章讲的是「应该怎样」,源码里留着的是「实际付了什么代价」。
十四章走完了从进程启动到消息落盘的完整链路。如果要用一句话概括这个系统的设计立场:
它把「提示词缓存命中率」当成一等公民约束,然后围绕这个约束重新设计了工具装配、子智能体派生、上下文压缩、甚至日志字段的补全方式。凡是和这个目标冲突的整洁性,都被牺牲掉了。
文档里任何看不懂或想深挖的地方,选中那段文字点「提问」就行。