Claude Code 源码分析(一):核心模块与数据流

用 AI 读 AI 的源码,这件事本身就很有趣

Posted by laosuan on April 1, 2026
中文 English

为什么要读 Claude Code 的源码

大多数人用 Claude Code,停留在”会用”这个层面。

会用,当然不错。但”会用”和”懂它”之间,有一条巨大的鸿沟。你在鸿沟的这边,它就是一个黑盒——你输入一句话,它输出一堆代码,中间发生了什么,你一无所知。

而当你跨过这条鸿沟,站到另一边,你会发现:Claude Code 的架构设计,本身就是一堂极好的软件工程课。

这是一个系列的第一篇。我会带你从入口文件开始,一路追踪到 API 调用和工具执行,把整个数据流完整地走一遍。

读完这篇文章,你再去读源码,效率会高很多——因为你已经有了一张地图。

从哪里开始

拿到一个陌生的代码库,很多人的第一反应是”从哪里开始看”。

这个问题其实有一个非常简单的答案:从入口文件和配置文件开始。

入口文件告诉你程序怎么启动的,配置文件告诉你程序依赖什么。这两样东西搞清楚了,你就知道了程序的”骨架”。剩下的,都是往骨架上填肉。

Claude Code 的入口文件是 src/entrypoints/cli.tsx

第一层:cli.tsx —— 一切的起点

打开 cli.tsx,你首先看到的是一堆”polyfill”——在程序真正执行之前,它先往全局环境里注入了一些东西:

// cli.tsx 顶部
const feature = (_name: string) => false;  // 所有 feature flag 全部返回 false
globalThis.MACRO = {
    VERSION: "1.0.x",
    // ... 构建时常量
};

这行 feature = () => false 非常关键。Claude Code 内部有大量的 feature flag:COORDINATOR_MODE(多 agent 协调)、DAEMON(后台守护进程)、VOICE_MODE(语音模式)……这些都是 Anthropic 内部用的功能。开源版本里,一个 () => false 把它们全关了。

这是一种非常实用的工程手段——用 feature flag 控制功能开关,发布时只需改一行代码就能裁剪掉所有内部功能。

真正的启动逻辑在文件底部:

// cli.tsx:319
void main();

// cli.tsx:59
async function main(): Promise<void> {
    const args = process.argv.slice(2);

    // 快速路径:--version 直接输出,零模块加载
    if (args[0] === "--version") {
        console.log(`${MACRO.VERSION} (Claude Code)`);
        return;
    }

    // ...其他快速路径(大部分被 feature flag 跳过)

    // 关键转换点:动态导入 main.tsx
    const { main: cliMain } = await import("../main.jsx");  // L312
    await cliMain();                                          // L314
}

注意这里用的是动态 import(),不是静态 import。这意味着只有真正需要启动完整 CLI 的时候,才会加载 main.tsx 及其所有依赖。如果你只是跑 --version,整个程序几乎零开销就退出了。

这就是软件工程里的”快速路径”思维:常见的简单场景,要尽量快地处理掉,不要让它去走复杂的完整流程。

第二层:main.tsx —— Commander 解析与初始化

main.tsx 大约有 1700 行,是整个 CLI 的定义中心。它用 Commander.js 来解析命令行参数,然后执行初始化流程。

核心调用链:

main.tsx:585    export async function main()
  ↓
main.tsx:4504   program.parseAsync(process.argv)   // Commander 解析
  ↓
main.tsx:1006   .action(async (prompt, options) => { ... })  // 主命令处理器
  ↓
main.tsx:916    await init()    // 初始化
  ↓
main.tsx:3798   await launchRepl(...)   // 启动交互式 REPL

init() 定义在 src/entrypoints/init.ts:57,是一个 memoized 函数(只执行一次)。它做的事情很多,但每一件都有明确的目的:

export const init = memoize(async (): Promise<void> => {
    enableConfigs();                        // 加载配置系统
    applySafeConfigEnvironmentVariables();  // 安全环境变量
    applyExtraCACertsFromConfig();          // TLS 证书
    setupGracefulShutdown();               // 优雅关闭
    populateOAuthAccountInfoIfNeeded();    // OAuth 信息
    configureGlobalMTLS();                 // mTLS 配置
    configureGlobalAgents();               // 代理配置
    preconnectAnthropicApi();              // TCP 预连接 API
});

最后一行 preconnectAnthropicApi() 很有意思——在你还没输入任何内容的时候,程序就已经在跟 API 服务器建立 TCP 连接了。这种”预连接”策略,能显著减少你第一次提问时的等待时间。

初始化完成后,launchRepl() 启动交互式界面。它定义在 src/replLauncher.tsx:12

export async function launchRepl(root, appProps, replProps, renderAndRun) {
    const { App } = await import('./components/App.js');
    const { REPL } = await import('./screens/REPL.js');
    await renderAndRun(root, <App {...appProps}>
        <REPL {...replProps} />
    </App>);
}

没错,终端界面是用 React 写的。它用了一个叫 Ink 的库,把 React 的组件模型搬到了终端里。你在终端里看到的每一个输入框、每一条消息、每一个加载动画,都是一个 React 组件。

第三层:REPL.tsx —— 用户输入的入口

REPL.tsx 是交互界面的核心组件。当你在终端里输入一段话按下回车,触发的就是这里的 onQuery 回调。

关键调用链:

用户按下回车
  ↓
REPL.tsx:2859   onQuery(newMessages, ...)
  ↓
REPL.tsx:2922   → onQueryImpl(...)
  ↓
REPL.tsx:2772   → await Promise.all([    // 并行加载上下文
                      getSystemPrompt(...),   // 系统 prompt
                      getUserContext(),        // CLAUDE.md 等用户指令
                      getSystemContext()       // git status 等环境信息
                  ])
  ↓
REPL.tsx:2797   → for await (event of query({...}))  // 流式查询
  ↓
REPL.tsx:2806       onQueryEvent(event)   // 处理每个流式事件

这里有两个值得注意的设计:

第一,上下文是并行加载的。 Promise.all 同时获取系统 prompt、用户上下文(你项目里的 CLAUDE.md 文件)和系统上下文(git status、当前日期等)。串行加载也能工作,但并行加载更快。

第二,查询是流式的。 for await...of 消费一个 AsyncGenerator,每收到一个事件就立刻处理。这就是为什么你能看到 Claude 的回答一个字一个字地”打”出来——不是等全部生成完再显示,而是边生成边显示。

还有一个容易忽略的细节:REPL.tsx 直接调用 query(),不经过 QueryEngine。QueryEngine(定义在 QueryEngine.ts:186)是给 SDK/headless 模式用的编排器。两条路径最终都汇入同一个 query() 函数。

第四层:query.ts —— 对话循环的心脏

query.ts 是整个系统的核心——一个 while(true) 循环。

// query.ts:219
export async function* query(params: QueryParams) {
    yield* queryLoop(params, consumedCommandUuids);
}

// query.ts:241
async function* queryLoop(params, consumedCommandUuids) {
    const deps = params.deps ?? productionDeps();

    // 主循环:每次迭代 = 一次 API 调用 + 工具执行
    while (true) {
        // 1. 调用模型
        for await (const message of deps.callModel({
            messages, systemPrompt, tools, signal, ...
        })) {
            // 处理流式事件:text、thinking、tool_use...
        }

        // 2. 执行工具调用
        const toolUpdates = runTools(toolUseBlocks, ...);
        for await (const update of toolUpdates) {
            yield update.message;
        }

        // 3. 判断:有 tool_use → continue,end_turn → return
    }
}

这个循环的逻辑用一句话概括就是:调用 API → 收到回复 → 如果回复里包含工具调用,就执行工具,把结果塞回去,再调一次 API → 直到模型说”我说完了”(end_turn)。

这就是所谓的 Agentic Loop——Agent 的核心模式。Claude 不只是回答你的问题,它会通过调用工具(读文件、写文件、执行命令)来主动完成任务。每调用一次工具,就是循环的一次迭代。

deps.callModel 是一个依赖注入点。在生产环境中,它指向 queryModelWithStreaming(来自 src/services/api/claude.ts):

// query/deps.ts:33
export function productionDeps(): QueryDeps {
    return {
        callModel: queryModelWithStreaming,  // → claude.ts
        microcompact: microcompactMessages,
        autocompact: autoCompactIfNeeded,
        uuid: randomUUID,
    };
}

依赖注入在这里的好处是:测试的时候可以替换成 mock 的 API 调用,不用真的去请求 Claude API。 这是写可测试代码的标准做法。

第五层:claude.ts —— 真正发出请求的地方

services/api/claude.ts 是最底层,直接调用 Anthropic SDK。

// claude.ts:753
export async function* queryModelWithStreaming({ messages, systemPrompt, ... }) {
    yield* queryModel(messages, systemPrompt, ...);
}

// claude.ts:1018
async function* queryModel(messages, systemPrompt, ...) {
    // ... 构建请求参数、计算 beta 头、过滤工具 ...

    // 最终 API 调用
    const result = await anthropic.beta.messages
        .create(
            { ...params, stream: true },
            { signal, headers }
        )
        .withResponse();

    // 遍历流式事件
    for await (const event of stream) {
        // message_start, content_block_delta, tool_use, message_stop...
        // 构建 AssistantMessage 并 yield 回 query.ts
    }
}

这里的 stream: true 就是流式请求的关键。它让 API 不是等所有内容生成完再返回,而是一个 token 一个 token 地往回推。

Claude Code 还支持多个 Provider:Anthropic Direct、AWS Bedrock、Google Vertex、Azure。你可以通过配置切换不同的后端。

完整数据流:一次交互的全景

把五层串起来,一次完整的交互是这样的:

用户在终端输入 "帮我写一个排序函数"
  │
  │  cli.tsx:319     void main()
  │  cli.tsx:312     动态 import main.tsx
  │  cli.tsx:314     cliMain()
  ▼
  main.tsx:585       main() 启动
  main.tsx:916       init() → 加载配置、认证、预连接 API
  main.tsx:3798      launchRepl() → 渲染 React/Ink 组件树
  │
  ▼  用户输入 → React 组件捕获
  REPL.tsx:2859      onQuery()
  REPL.tsx:2772      并行加载:系统 prompt + CLAUDE.md + git status
  REPL.tsx:2797      for await (event of query({...}))
  │
  ▼
  query.ts:307       while (true) {  ← 主循环开始
  query.ts:659         调用 API(流式)
  │                      ↓
  claude.ts:1823         anthropic.beta.messages.create({stream: true})
  claude.ts:1880+        遍历流式事件 → yield 回 query.ts
  │                    ↑
  query.ts:1385        如果有 tool_use → 执行工具(读文件、写文件等)
  query.ts              把工具结果塞回消息 → continue(再调一次 API)
  query.ts              如果 end_turn → return(退出循环)
  │
  ▼
  REPL.tsx:2806      onQueryEvent(event) → 更新终端 UI
  REPL.tsx:2851      resetLoadingState() → 恢复输入状态

技术栈选型:每个选择都有原因

技术 为什么选它
Bun 比 Node.js 快,原生支持 TSX/ESM,启动速度极快
React + Ink 用 React 组件模型构建终端 UI,复用前端开发者的心智模型
React Compiler 自动 memoization,减少不必要的终端重渲染
Commander.js 成熟的 CLI 参数解析框架
Anthropic SDK 官方 SDK,原生支持流式响应
Zustand 风格 Store 轻量状态管理,适合单进程 CLI
MCP 协议 Anthropic 的工具扩展协议,允许接入外部工具服务器

其中最有意思的选择是 React + Ink。用 React 写终端 UI,听起来有点奇怪,但想想看——终端 UI 本质上也是一棵组件树,也需要状态管理、也需要响应式更新。React 的声明式编程模型在这里完全适用。

而且 Claude Code 甚至 fork 了 Ink 框架,做了自己的 reconciler 和虚拟列表实现。这说明他们对终端渲染性能有很高的要求。

工具系统:Agent 的”手”

Claude Code 的核心能力来自工具系统。每个工具就是一个函数:

interface Tool {
    name: string
    description: string
    inputSchema: JSONSchema    // 参数定义
    call(input, context): Result  // 执行逻辑
}

核心工具包括:

  • BashTool —— 执行 shell 命令
  • FileReadTool / FileEditTool / FileWriteTool —— 文件操作
  • GlobTool / GrepTool —— 搜索
  • AgentTool —— 启动子 agent(Agent 可以启动 Agent!)
  • WebFetchTool / WebSearchTool —— 网络操作

当 Claude 的回复中包含 tool_use 时,query.ts 的主循环会执行对应的工具,把结果作为 tool_result 消息追加到对话历史中,然后再次调用 API。这个”调用-执行-反馈”的循环,就是 Agent 能够自主完成复杂任务的秘密。

读源码的方法论

最后,分享一下我读这个代码库的方法。

第一步:读入口文件和配置文件。 这给了你程序的骨架——它从哪里开始,依赖什么,怎么构建。

第二步:追踪主数据流。 找到用户输入到达系统的入口点,然后一路追踪它经过的每个模块,直到最终的输出。不要一开始就陷入细节,先把主干走通。

第三步:深入关键模块。 主干走通之后,再挑你感兴趣的模块深入。比如工具系统、权限系统、自动压缩机制等。

这和读一本书的方法是一样的:先看目录,再通读一遍,最后精读重点章节。 很多人读源码的时候,上来就一行一行地啃,啃了半天也不知道自己在看什么。那是因为没有先建立全局认知。

Claude Code 的代码库大约有 1341 个 TypeScript 错误(因为是反编译的),但完全不影响 Bun 运行。这也是一个有趣的工程现实:类型系统是工具,不是目的。能跑才是硬道理。


下一篇,我会深入分析工具系统和权限机制——Claude Code 是怎么决定该不该执行一个危险操作的。

敬请期待。

Read this post in English