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 了多少个功能模块------超过一只手,就是这篇说的「总管病」。

相关推荐
子兮曰2 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
1点东西2 天前
做了近两年的Agent开发,其实真正要学的就是这五件事
llm·agent·ai编程
晨米酱2 天前
AGENTS.md:Agent 的上下文策略层
面试·架构·agent
invicinble2 天前
记录一个学习技术栈的想法和思路
agent
染指11102 天前
122.Agent-LangChain核心组件-中间件-动态提示词(dynamic_promapt)
人工智能·langchain·agent·agents
是Dream呀2 天前
中秋国庆回家不背电脑,用ToDesk远程反连学校设备,查资料、改作业
人工智能·agent·todesk
漂着的圆木2 天前
Agent 功能参与度:Copilot 怎么算
sql·数据分析·agent·githubcopilot·度量
程序猿编码2 天前
告别改源码适配模型:纯 C++ 可配置 LLM 推理引擎,全格式全结构兼容
c++·大模型·llm·推理引擎
全栈弄潮儿²⁰²⁴2 天前
AI Agent 开发实战(30):限流、缓存与成本控制
人工智能·gpt·缓存·agent·限流·agi·成本控制
瑶山2 天前
开源编程Agent-OpenCode完整使用教程
开源·agent·ai编程·opencode