01-想做 Code Agent,但不想只会调 API?我把 Claude Code 源码拆成了一套教程

25 阅读6分钟

关键词:Code Agent / Claude Code / CLI / Bootstrap / QueryEngine / Agent 架构 / 工程设计

别把 Code Agent 当聊天机器人:先看懂 Claude Code 的总架构和启动链路

很多人分析 Code Agent,一上来就盯着模型调用,结果越看越碎。

真正的问题不是“它调了哪个模型”,而是这套系统怎么从一个 CLI 命令,变成一个能长期执行任务的 Agent。Claude Code 的前两章其实就在回答这件事:一个 Code Agent 的骨架到底长什么样,它又是怎么被启动起来的。

一、先把范式分清:Chatbot、Copilot、Agent 不是同一种东西

从工程上看,这三者的差异不在 UI,而在执行边界。

类型能做什么不能做什么
Chatbot一问一答、生成文本不主动行动
Copilot读编辑器上下文、给建议通常不闭环执行
Code Agent调工具、看结果、继续推进不能没有状态机

Code Agent 的本质不是“更强的聊天”,而是下面这条循环:

flowchart TD
    U["用户输入"] --> C["组装上下文"]
    C --> M["调用模型"]
    M --> R{"返回类型"}
    R --> |"最终回答"| END["结束"]
    R --> |"tool_use"| T["调用工具"]
    T --> TR["工具结果回写历史"]
    TR --> M

只要系统进入这个闭环,它就不再是 Chatbot,而是执行器。

二、Claude Code 的总架构,其实是七块东西咬在一起

把源码抽掉细节,Claude Code 的主骨架是这样的:

CLI / Bootstrap
    ↓
QueryEngine / queryLoop
    ├─ Context Management
    ├─ Tool System
    ├─ Permissions / Hooks
    ├─ Skills / Plugins / MCP
    └─ UI Layer (Ink/React)

这里真正的主干不是 UI,也不是模型 SDK,而是中间三层:

  • queryLoop:负责让任务一轮轮继续;
  • Context:负责让模型每一轮都知道自己处在什么状态;
  • Tool System:负责把模型意图变成真实操作。

换句话说,Claude Code 不是“终端里包了一个 LLM”,而是“用 LLM 驱动的一套工具执行框架”。

三、CLI 启动的第一原则:快路径不能被慢路径拖累

Claude Code 在 cli.tsx 里先做了快速路径分流。像 --version 这种命令,根本不值得把主系统拉起来:

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

  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
    console.log(`${MACRO.VERSION} (Claude Code)`);
    return;
  }
}

这个做法看起来普通,但它代表一个很成熟的 CLI 判断:

非主路径必须延迟加载,不能污染主路径的冷启动时间。

所以内部 MCP 模式、daemon worker、后台会话管理等路径,全部走动态 import()
不需要的模块,不在普通交互场景里承担启动成本。

四、main.tsx 做得最好的地方,不是功能多,而是“等待重叠”

进入 main.tsx 后,Claude Code 立刻做三件事:

import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');

import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();

import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();

这意味着:

  • 入口开始时先打性能点;
  • 企业配置读取立即启动;
  • Keychain 中的 token / API key 预取立即启动;
  • 后续模块加载继续往下跑。
sequenceDiagram
    participant M as main.tsx
    participant K as Keychain
    participant L as 模块加载

    M->>K: startKeychainPrefetch()
    M->>L: 继续加载 Ink / Commander / React
    par 并行
      K-->>K: 读取凭证
      L-->>L: 模块求值
    end

它不是在做复杂优化,而是在贯彻一个很基本的工程原则:把等待和计算重叠起来
CLI 工具的启动体验,往往就输赢在这种细节上。

五、执行模式必须尽早判断:交互式和无头模式根本不是一回事

Claude Code 很早就判断当前是不是非交互模式:

const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print');
const isNonInteractive =
  hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;

关键点不是 -p,而是这一句:

!process.stdout.isTTY

这意味着只要输出不是终端,比如:

  • 被管道消费;
  • 被重定向到文件;
  • 跑在 CI 里;

它就自动转成非交互模式。

这才是一个能被脚本和流水线真正利用的 Agent CLI。
如果一个 Agent 只能服务“人在终端前手动敲命令”的场景,它的工程价值会被大幅限制。

六、参数解析不是装饰,它定义了系统有多少种工作姿态

main.tsx 里用 Commander.js 注册了大量参数。重要的不是“参数多”,而是参数直接映射系统姿态:

  • --print:无头执行;
  • --bare:跳过 hooks、LSP、插件同步等附加能力;
  • --permission-mode:切换权限模型;
  • --model / --effort:改变推理策略;
  • --allowed-tools / --disallowed-tools:收紧或放宽工具池;
  • --add-dir:调整文件可访问范围。

这说明 Claude Code 不是只有一种运行方式。
它是同一套核心循环,在不同环境里切换不同外壳。

七、真正的入口不是 main(),而是 query()

CLI 解决的是“怎么启动”,真正决定 Agent 行为的是 src/query.ts 里的 query()

export async function* query(
  params: QueryParams,
): AsyncGenerator<StreamEvent | RequestStartEvent | Message | ...> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

最值得注意的是它为什么是 async function*

因为 Agent 不是“跑完再返回”的程序,而是会在过程中持续产生事件:

  • 模型输出;
  • 工具调用;
  • 工具执行进度;
  • 工具结果;
  • 结束原因。

如果不用 async generator,就很难同时做好实时 UI 和中间状态分发。

八、queryLoop() 是整套系统的心跳

真正执行循环的是 queryLoop()。它维护一份跨轮次状态:

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  turnCount: number
  transition: Continue | undefined
}

这份状态告诉我们,Claude Code 从来不是“多调几次模型”。
它是一个明确的状态推进器。

每轮循环都做这几件事:

  1. 取当前消息;
  2. 检查是否需要压缩;
  3. 调模型;
  4. 看到 tool_use 就派发工具;
  5. 收集结果回写历史;
  6. 判断是否继续。
flowchart TD
    A["准备消息"] --> B["必要时压缩上下文"]
    B --> C["调用模型"]
    C --> D{"出现 tool_use?"}
    D --> |"是"| E["执行工具"]
    E --> F["工具结果回写 messages"]
    F --> A
    D --> |"否"| G{"是否完成?"}
    G --> |"是"| H["返回 Terminal"]
    G --> |"否"| A

只要你理解了这一步,就会知道为什么很多 Agent Demo 看起来会动,但一进真实任务就塌:
它们没有把“任务推进”做成状态机,只是做成了多轮问答。

九、第一篇该记住的,不是某个函数,而是三条原则

Claude Code 的前两章合起来,核心其实只有三条:

1. Agent 不是聊天产品,而是执行系统

只要它进入“调用工具-观察结果-继续决策”的闭环,它就不是普通聊天。

2. 启动路径本身就是工程能力的一部分

快路径分流、并行预热、模式早判,这些都不是边角优化,而是主能力。

3. 一切最终都收敛到 queryLoop 这个状态推进器

CLI、参数、模式、上下文、工具,最后都只是为了让这条循环稳定工作。

最后

如果把 Claude Code 当成“会写代码的聊天机器人”,后面很多设计你都会看不懂。
只有把它当成一个长期运行的执行框架,你才会理解:

  • 为什么启动要这么抠延迟;
  • 为什么模式要这么早分流;
  • 为什么 query() 要做成 async generator;
  • 为什么整个系统要围绕 queryLoop 组织。

这才是看 Claude Code 前两章最该拿到的东西。