Claude Code 启动的那 200 毫秒里发生了什么

21 阅读3分钟

你输入 claude 按下回车,到看到交互界面,中间只有不到 200ms。但这段时间里发生的事情,比你想象的要精心得多。


启动的三个阶段

flowchart LR
    A["📦 模块加载\n~135ms\nimport 语句执行"] --> B["⚙️ 环境初始化\nmain() 函数\n安全设置、模式判断"]
    B --> C["🎯 CLI 解析\nrun() 函数\n解析参数、启动 REPL"]

    style A fill:#dbeafe,stroke:#3b82f6
    style B fill:#dcfce7,stroke:#22c55e
    style C fill:#fef9c3,stroke:#eab308

三个阶段里,第一个阶段最有意思——它藏着一个很聪明的性能技巧。


阶段一:把慢操作藏在模块加载里

Node.js/Bun 程序启动时,所有 import 语句会依次执行,大约需要 135ms。这段时间 CPU 在忙,但有些 I/O 操作可以同步进行。

Claude Code 利用了这一点:在第一行 import 之前,就触发两个后台子进程

gantt
    title 启动时间线
    dateFormat X
    axisFormat %dms

    section 主线程
    模块加载(135ms)   :0, 135
    等待子进程结果      :135, 140

    section 后台子进程
    读取 macOS Keychain(65ms)  :0, 65
    读取企业 MDM 策略            :0, 80

这两个子进程是:

  • Keychain 读取:读取 macOS 密钥链里存的 OAuth token 和 API Key,同步方式需要 65ms
  • MDM 读取:读取企业设备管理策略(大公司用的)

通过在 import 语句期间就触发这两个操作,等模块加载完,子进程也差不多跑完了。等于把 65ms 的等待时间"藏"进了本来就要花的 135ms 里,净节省约 65ms

这个技巧在 Claude Code 里反复出现:把慢操作藏在不可避免的等待时间里。


阶段二:启动时的特殊模式判断

main() 函数做的第一件事是搞清楚"我现在是什么模式"。同一个 claude 命令,根据参数不同,会走完全不同的路径:

flowchart TD
    A["claude 启动"] --> B{"检测特殊参数"}
    B --> C["cc:// 开头的 URL\n直接连接模式"]
    B --> D["claude assistant\nAssistant 模式(企业版)"]
    B --> E["claude ssh host\n远程 SSH 模式"]
    B --> F["-p / --print\n非交互模式(管道输出)"]
    B --> G["无特殊参数\n普通交互模式"]

    C --> H["改写 process.argv\n让主流程统一处理"]
    D --> H
    E --> H

有意思的是,这些特殊模式都通过改写 process.argv 来处理,然后让同一套 Commander.js 解析逻辑统一处理,而不是各自写一套入口。这样代码复用,也方便测试。


阶段三:Commander.js 的 preAction 钩子

进入 CLI 解析阶段后,有一个关键的设计:真正的初始化不在程序启动时,而在命令执行前

sequenceDiagram
    participant 用户
    participant Commander as Commander.js
    participant preAction as preAction 钩子
    participant 业务逻辑

    用户->>Commander: claude --model sonnet "帮我写代码"
    Commander->>preAction: 执行命令前先跑这个
    preAction->>preAction: 等待 Keychain/MDM 子进程完成
    preAction->>preAction: init():认证、配置、遥测初始化
    preAction->>preAction: runMigrations():配置文件版本升级
    preAction->>preAction: 异步加载远程配置(不阻塞)
    preAction->>业务逻辑: 好了,你来
    业务逻辑->>用户: 显示交互界面

为什么要用 preAction 而不是直接在启动时初始化?因为当用户运行 claude --help 时,根本不需要初始化任何东西。只有真正要执行命令时,才值得花这些时间。


配置迁移:每次启动都检查一次

每次 Claude Code 启动,都会检查本地配置文件是否需要升级。这个机制叫 runMigrations()

flowchart LR
    A["读取本地配置\nmigrationVersion"] --> B{"版本 == 11?\n(当前最新)"}
    B -- 是 --> C["跳过,直接继续"]
    B -- 否 --> D["依次执行所有迁移"]
    D --> E["迁移 1:自动更新设置"]
    E --> F["迁移 2:权限记录格式"]
    F --> G["..."]
    G --> H["迁移 11:模型名称更新"]
    H --> I["写回 migrationVersion = 11"]

每次发布新版本的模型(比如从 Sonnet 4.5 升级到 Sonnet 4.6),都会加一个迁移函数,把用户本地存的旧模型名称自动改成新的。用户感知不到,配置文件自动跟上最新版本。


首屏渲染后:还有一波后台任务

界面显示出来之后,启动并没有结束。还有一批任务在后台悄悄跑:

flowchart TD
    A["✅ 界面显示出来了"] --> B["后台开始跑这些"]

    subgraph 后台任务
        C["初始化用户信息"]
        D["预取 git 状态"]
        E["统计项目文件数量\n用于估算上下文窗口"]
        F["刷新模型能力配置"]
        G["初始化 Feature Flag"]
        H["预取官方 MCP 服务器列表"]
        I["监听配置文件变更\n支持热重载"]
    end

    B --> C
    B --> D
    B --> E
    B --> F
    B --> G
    B --> H
    B --> I

这些任务都是"用户开始打字的时候就在跑",等用户发出第一条消息,大部分都已经准备好了。


Feature Flag:用编译期裁剪控制功能

Claude Code 有很多实验性功能,通过 feature('FLAG_NAME') 来控制是否启用。

这不是普通的运行时开关——Bun 在打包时会静态分析这些 feature() 调用,把 false 分支的代码完全从打包产物里删掉。

graph LR
    subgraph "源码"
        A["feature('KAIROS')\n? 加载 assistant 模块\n: null"]
    end

    subgraph "外部版本打包产物"
        B["null\n(assistant 模块代码完全不存在)"]
    end

    subgraph "内部版本打包产物"
        C["加载 assistant 模块\n(完整功能)"]
    end

    A --> B
    A --> C

好处是:外部发布版本里,企业内部功能的代码完全不存在,不只是"禁用",而是根本不在包里。


小结:启动优化的核心思路

Claude Code 启动优化贯穿了一个思路:把不可避免的等待时间利用起来

优化点做法节省时间
Keychain 读取在 import 期间提前触发~65ms
用户信息、git 状态首屏渲染后后台预取用户打字时完成
重量级模块(OpenTelemetry 等)懒加载,用到才导入减少启动开销
Feature Flag编译期删除无用代码减少包体积

200ms 的启动时间,不是"写得快",是"每一毫秒都想清楚了"。