Claude Code 整体架构与设计

1 总体架构

Claude Code 是一个基于大模型的编程 Agent:模型负责决策,它自己这几十万行 TypeScript 负责把决策变成动作------执行工具、管理上下文、守住权限。它有三种交付形态:命令行工具(CLI)、可嵌入的 SDK、MCP 服务。三种形态不是三份代码,来自同一条代码库。

这条代码库整体上分五层:

分层 职责 代表位置
入口 每种运行形态一个入口文件 src/entrypoints/
装配 启动时初始化与组装 src/main.tsx
引擎 驱动会话的主循环与工具协议 src/query.ts
能力 工具、命令、服务 src/tools/src/commands/src/services/
支撑 权限、编排、扩展、界面 src/utils/permissions/

2 运行视图

一次完整的运行分两个阶段。

**启动阶段,每次运行走一遍。**你在终端输入 claude

  1. 入口层 cli.tsx 补环境、按运行模式分拣;
  2. 控制权交给装配层 main.tsx
  3. main.tsx 并行预取系统配置和钥匙串、解析参数、按当前环境拼出命令集;
  4. 挂载终端界面,等待输入。

**会话阶段,每一轮对话走一遍。**你输入一句话:

  1. 消息进入引擎层 query.ts 主循环;
  2. 主循环把消息连同工具清单(装配时注入的那份)发给模型;
  3. 模型要求调用工具时,执行交给能力层------先过校验、权限、Hook(权限系统在支撑层),再真正执行;
  4. 执行结果包装成消息回灌对话,回到第 2 步;
  5. 模型不再要求工具,给出最终回答,流式渲染到终端。

注意:主循环自己不执行任何工具,整个流程里也不存在一个统一调度的「总管」------每一步只和相邻层的接口打交道。

3 入口层:三种形态

入口 = 每种运行形态自己的启动文件。Claude Code 的入口全部位于 src/entrypoints/

  • cli.tsx 起命令行(可执行入口,包的 bin 指向它)
  • mcp.ts 起 MCP 服务
  • sdk/ 给嵌入方

以最常用的 cli.tsx 为例。它做三件事:补环境、分拣运行模式、交接。前两件是例行准备,值得看的是交接的那一刻:

typescript 复制代码
// src/entrypoints/cli.tsx
profileCheckpoint('cli_before_main_import');
const {
  main: cliMain
} = await import('../main.js');
profileCheckpoint('cli_after_main_import');
await cliMain();

三种形态共用一套核心

mcp.ts 文件头的引入:

typescript 复制代码
// src/entrypoints/mcp.ts
import review from '../commands/review.js'
import type { Command } from '../commands.js'
import { getTools } from '../tools.js'
import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'

工具清单、命令注册表、权限检查,引的都是命令行模式下的同一批模块。三种形态的差异被拦截在入口文件里;核心模块一套,零改动。

这一层的设计理念是同核多形态:每新增一种形态,就新增一个入口文件;核心一行不动。

为什么不用单入口+参数来区分形态?

替代方案是单入口 + 参数区分形态,但这有两个问题:

  • 参数表随形态数量膨胀;
  • 每接入一种新宿主都要改核心代码------而改核心,意味着所有既有形态都要回归测试。

「新形态 = 新入口文件」则完全不触碰既有核心模块。这个取舍后面每一层都会再次出现:新增的能力放在外围,不触碰核心。

4 装配层:冷启动与按需加载

main.tsx负责装配:解析命令行参数、挂载终端界面、准备各子系统,把程序带到「可以开始对话」的状态。

装配层要解决一个 CLI 特有的问题:冷启动 。服务端程序启动一次可以运行数月,初始化开销摊得薄;CLI 每次运行都从零启动------你每输入一次 claude,启动开销就重复支付一次。

所以装配的原则是「用多少,装多少」,落到四个机制:

  • 并行预取。 main.tsx 的文件头,先把启动路径上最慢的两件事先启动,不等结果;后面的 import 继续加载,两件事在后台并行跑:
typescript 复制代码
// src/main.tsx(文件头,省略性能打点与部分注释)
// These side-effects must run before all other imports:
// 2. startMdmRawRead fires MDM subprocesses (plutil/reg query) so they run in
//    parallel with the remaining ~135ms of imports below
// 3. startKeychainPrefetch fires both macOS keychain reads (OAuth + legacy API
//    key) in parallel --- ... (~65ms on every macOS startup)
// 译:读系统配置的子进程、两笔钥匙串读取,与下面剩余约 135ms 的 import 并行跑;
//    否则钥匙串会被顺序读,每次 macOS 启动多花约 65ms(数字都来自源码注释)
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();
import { ensureKeychainPrefetchCompleted, startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();
import { feature } from 'bun:bundle';
// ...往下还有 160 多行 import
  • 懒加载。 main.tsx 里有大量 await import。遥测、分析这些模块体量大,启动时又用不上------先不加载,第一次真正用到才加载进内存。

  • **按环境加载。**交互终端、CI、无头环境里可用的命令集不同;加载哪些命令,由启动时按当前环境现查。

  • **编译期裁剪。**光 main.tsx 一个文件里就有 61 处 feature(...) 调用------编译期开关,构建时决定哪些能力进入产物。关闭的功能不打进包里,而不是运行时绕行:死代码不进产物。

四个机制合起来一句话:能推迟的推迟,能排除的排除,两者都做不到的,并行执行。

5 引擎层:主循环、工具协议与会话封装

引擎是驱动整个会话的核心,由三个文件构成,全部直接放在 src/ 根目录、与各子目录平级:

文件 职责
query.ts 主循环------问模型、执行工具、结果回灌
Tool.ts 工具协议------规定一只工具长什么样
QueryEngine.ts 会话封装------包着 query 循环,管用量统计、中断、给 SDK 用

引擎文件里没有工具

打开 query.ts 搜索工具名------BashTool、FileReadTool、GlobTool------一个都找不到。引擎三个文件对 tools/ 目录的引用只有四处,没有一处 import 工具实现------引的全是类型定义和工具名常量。比如判断「刚才执行的是不是等待一秒那只」,比的是工具名,不是工具本身。

工具从哪来?

看主循环请求模型的代码:

typescript 复制代码
// src/query.ts
for await (const message of deps.callModel({
  messages: ...,
  systemPrompt: fullSystemPrompt,
  tools: toolUseContext.options.tools,
  ...

toolUseContext.options.tools:工具清单由启动时的装配层注入。引擎面对的永远是「这批工具」,而不是固定的某批工具。

工具的执行也不在引擎里

工具的执行同样不在引擎里,query.ts 把它整体委托给服务层:

typescript 复制代码
// src/query.ts
import { runTools } from './services/tools/toolOrchestration.js'

引擎认识的不是工具,而是协议

Tool.ts 定义的工具契约,规定一个工具必须提供什么:

typescript 复制代码
// src/Tool.ts
export type Tool<
  Input extends AnyObject = AnyObject,
  Output = unknown,
  ...
> = {
  ...
  call(...): Promise<ToolResult<Output>>
  description(...): Promise<string>
  readonly inputSchema: Input
  ...

名称、说明、参数定义、执行函数、权限模型,全部写在工具自己身上,按契约实现;引擎只验收契约,不验收具体工具。

主循环代码量很少,它的能力扩展------新工具、新命令、新服务、新入口------几乎全部在引擎之外实现。

为什么引擎不内置工具清单?

工具清单的特点,一是多、清单很大,二是经常变化。两种设计,代价不同:

  • 写死在引擎里:每次变动都要改全库最不能出错的主循环,一处回归问题影响全部能力;
  • 放在核心之外、只认协议:加一只工具,引擎一行不改;换掉一半工具,引擎不受影响。

引擎保持稳定,能力层才能持续扩展。

6 能力层:工具、命令、服务

能力 = 模型和用户可用的功能,分三类:

  • 工具------模型执行动作用。 src/tools/ 下的工具目录,一个工具一个目录,目录名即能力名:BashToolFileReadToolFileEditToolGlobToolAgentTool......参数定义、权限规则、执行函数全部自带------一个工具的全部实现,都在自己的目录里。
  • 命令------用户以斜杠调用。 src/commands/ 下的实现文件,不少命令有专属子目录;注册表不是一张静态表,而是 commands.ts里的一个函数,每次调用按当前环境现拼一份命令清单返回。
  • 服务------支撑性子系统。 src/services/ 下的子目录,一个服务一个。

三类能力同构:接入点由引擎和装配层提供,内容由能力模块自带。

这一层的设计理念是自包含模块:新增一个能力 = 新增一个目录 + 注册表加一行,不碰引擎、也不碰其他能力模块。

为什么不按职能设计?

替代方案是按职能设计------参数定义、执行函数、权限规则各集中一个目录,看起来更整齐。但两种设计,代价不同:

  • 按职能设计:每加一个能力,要横跨多个目录改多处;复查一个改动,要跨几十个文件核对;
  • 自包含:一个能力的增删都在同一个目录内完成------新增不影响其他模块,删除不产生遗留引用。

7 支撑层:权限、编排、扩展、界面

其余代码归入支撑层:让上面四层能够安全运行、承接更大的任务、对外连接的外围系统。

  • **utils/permissions/------权限。**模型的每个动作过三态检查:直接放行 / 直接拒绝 / 必须询问用户。权限是独立的一层,不是散落在各工具里的判断:同一套规则全局一致,可配置、可审计。
  • **coordinator/------多 Agent 编排。**一个上下文装不下的任务,派给多个 Agent:通信、分发、互不干扰。
  • **skills/plugins/、MCP------扩展。**能力边界不写死在代码里:工作流以文档形式渐进加载,外部工具按协议动态接入。清单永远列不全,所以留扩展点。
  • **components/------界面。**用 React + Ink 渲染终端界面------React 把「画到哪里」交给渲染器:react-dom 画到浏览器,Ink 画到终端,同一套组件模型。

另有 bridge/(IDE 与远程桥接)、state/(状态管理)、schemas/(配置校验)等,不逐一举例。

支撑层同样没有总管:这些目录服务整机,但没有任何一个位于所有目录之上做统一调度。

8 设计理念:靠边界,不靠总管

把五层的理念收成一张表:

理念 为什么
入口 同核多形态 新形态 = 新入口文件,核心零改动
装配 用多少装多少 CLI 每次冷启动,启动开销重复支付
引擎 只认协议,不认工具 核心不动,清单常新
能力 自包含模块 新增不改动其他模块,删除不留遗留引用
支撑 权限是层,边界留扩展点 规则全局一致;清单永远列不全

回到 §2 留下的事实:没有总管,靠什么保持秩序?答案就是这五层:**靠边界。**每层只依赖下一层的接口------入口把控制权交给装配,装配把工具清单注入引擎,引擎只认协议,能力按协议实现,支撑在外围提供保障。由此得到的性质是具体的:

  • 改一个工具,不触及引擎;
  • 加一种形态,不触及核心;
  • 替换大半能力,不影响主循环。

这样的秩序,不来自一个什么都管的中央模块,来自一组谁都不越的边界。

9 三条经验,用在你自己的系统上

Claude Code 的设计讲解完了;下面三条,换成你自己搭系统的时候直接能用:

  • **新增的能力放外围,不碰核心------开闭原则。**多一种运行形态,加一个入口文件;多一只工具,加一个目录。反过来,「加个新功能要改核心代码」就是该修的信号------核心每被碰一次,所有既有能力都要跟着回归测试。
  • **冷启动按「用多少,装多少」------按需加载、懒加载。**能推迟的推迟(懒加载),能排除的排除(编译期裁剪),都做不到的并行执行(预取)。每次都从零启动的程序,启动路径上每一毫秒都在重复支付。
  • **靠边界,不靠总管------分层架构、松耦合。**每层只依赖下一层的接口,不让任何一个模块位于所有模块之上做统一调度。检验标准就一条:你的系统敢不敢让人替换掉大半能力?敢,说明边界立住了。

今晚就能试:翻开你手头最大的项目,数一数核心流程文件直接 import 了多少个功能模块------超过一只手,就是这篇说的「总管病」。

相关推荐
简单风28 分钟前
给 Claude Code 装个仪表盘:钱花多少、上下文剩多少、Git 状态一眼看清
agent
leeyi2 小时前
把软件装进不能上网的机房——一套建好了、还没上过战场的交付工程(第101篇)
docker·aigc·agent
SelectDB3 小时前
为什么 JSON 正在成为分析数据库新的竞争点?
数据库·json·agent
机械改造鹅3 小时前
从零开始拆解Pi系列——(7)Extension API
agent
plainGeekDev4 小时前
Agent代码审查与批量修复流水线
agent·ai编程·claude
桃西西呀4 小时前
上下文窗口都卷到 100 万了,大模型为什么还在为"位置"发愁?
人工智能·llm·ai编程
然我4 小时前
模型不是 Agent:从零实现一个最小 Agent Loop
前端·人工智能·agent
深蓝AI4 小时前
Mem0 实战:给 AI 应用加上长期记忆,从 Hello World 到生产用法
agent
AI效率君4 小时前
Deer‑Flow 2.0 + Go‑MCP‑Server(add加法工具)保姆级完整教程
人工智能·agent