Claude Code 的整体架构
这一章先解决一个定位问题:Claude Code 不是“CLI 外壳包一个模型调用”,而是一套运行在终端里的 Agent Runtime。
它的关键不在 claude 这个命令本身,而在这几条运行时链路:
- 入口层提前发起配置和凭证读取,压低首屏延迟。
- 构建期
feature()决定哪些能力进入产物,也决定模型能看见哪些工具 schema。 REPL.tsx把终端当 React Ink UI 渲染,而不是到处console.log()。AppState让 UI、工具执行、权限、MCP、团队上下文读写同一份事实。query.ts的 Agent Loop 把模型输出、工具执行结果和下一轮上下文接起来。
核心直觉
普通 CLI 的控制权在程序参数和函数调用里。Claude Code 的控制权在“模型可见能力 + 当前状态 + 工具结果回写”之间流动。
先看源码入口
读这套架构,先不要从组件树或工具目录开始。更稳的入口是下面这些文件:
| 源码定位点 | 关键符号 | 负责什么 | 读它要看什么 |
|---|---|---|---|
restored-src/src/main.tsx | profileCheckpoint()、startMdmRawRead()、startKeychainPrefetch() | CLI 启动编排 | 哪些 I/O 被提前发起,哪些模块被延迟加载 |
restored-src/src/main.tsx | feature()、require() | 构建期能力门控 | 哪些模块在 flag 关闭时根本不进 bundle |
restored-src/src/tools.ts | getAllBaseTools()、getTools()、assembleToolPool() | 工具池装配 | 最终发送给模型的工具列表如何产生 |
restored-src/src/screens/REPL.tsx | useAppState(...) | 终端 UI | UI 如何订阅状态切片并响应任务变化 |
restored-src/src/state/store.ts | createStore() | 外部状态容器 | 状态如何更新、通知和触发副作用 |
restored-src/src/state/AppStateStore.ts | AppState | 全局运行事实 | 权限、MCP、任务、插件、团队上下文存在哪些字段 |
restored-src/src/query.ts | query()、queryLoop() | Agent Loop | 工具结果如何回写并驱动下一轮模型调用 |
这几个点串起来,才是 Claude Code 的骨架。Tool.ts、提示词、权限、压缩、缓存都挂在这条骨架上。
总流程图
下面这张图不是概念分层,而是从启动到模型下一轮决策的运行路径:
这条路径里最重要的架构判断是:模型不能直接操作世界,它只能提出 tool_use;工具执行结果必须变成 tool_result 回到下一轮上下文。
启动阶段:顶层副作用不是随手写的
main.tsx 的前几行就能看出 Claude Code 对 CLI 启动延迟的处理方式。原文给出的关键片段是:
import { profileCheckpoint, profileReport } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();
import {
ensureKeychainPrefetchCompleted,
startKeychainPrefetch,
} from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();
这段代码证明的不是“入口文件会 import 一些工具函数”,而是三个具体行为:
| 调用 | 读取什么 | 为什么放在入口顶层 |
|---|---|---|
profileCheckpoint('main_tsx_entry') | 启动时间轴 | 在重量级 import 评估之前记录起点,后面才能定位启动慢点 |
startMdmRawRead() | MDM 托管配置,macOS 上可能调用 plutil,Windows 上可能调用 reg query | 让设备策略读取和后续模块加载并行 |
startKeychainPrefetch() | OAuth token 和旧版 API key | 避免首次鉴权路径同步读 Keychain,原文提到可少约 65ms |
这里还有一个容易漏掉的信号:原文指出源码为这些调用豁免了 custom-rules/no-top-level-side-effects。也就是说,团队不是不知道顶层副作用会让测试和依赖关系更难管理,而是明确选择在入口处承担这部分复杂度。
容易误解
这不是“随便在 import 后面发起 I/O”。它只适合确定会用到、可重试、失败安全、可超时回退的启动依赖。否则顶层副作用会把启动路径变成不可控的隐式依赖网。
启动链路可以拆成:
进入 main.tsx
-> 记录 startup checkpoint
-> 发起 MDM raw read
-> 发起 Keychain prefetch
-> 继续评估后续 import
-> 需要凭证或策略时 ensureXxxCompleted()
-> 预取成功则复用结果,失败则走同步/交互式回退
源码体现出来的取舍很清楚:入口层用可控副作用换首屏响应速度。对终端 Agent 来说,用户输入 claude 后的第一秒非常敏感;把确定会发生的 I/O 藏进模块加载窗口,是比“代码纯净”更优先的产品目标。
能力裁剪:模型能看见什么,比 UI 有什么按钮更重要
Claude Code 的能力边界首先在构建期被裁剪。入口层有这样的条件 require:
const coordinatorModeModule = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js')
: null;
const assistantModule = feature('KAIROS')
? require('./assistant/index.js')
: null;
工具层也有同样模式:
const WebBrowserTool = feature('WEB_BROWSER_TOOL')
? require('./tools/WebBrowserTool/WebBrowserTool.js').WebBrowserTool
: null;
这些片段说明两个事实:
feature()不是普通运行时配置。原文明确把它定位为来自bun:bundle的构建期常量,flag 关闭时分支可以被 DCE 移除。- 对 Agent 来说,模块有没有进 bundle 会进一步影响工具列表,最后影响模型能看到的 tool schema。
能力不是在一个地方一次性决定的,而是经过多层过滤:
| 层级 | 典型源码 | 判断问题 | 影响 |
|---|---|---|---|
| 构建期 | feature('KAIROS') ? require(...) : null | 这个模块是否进入产物 | 关闭时模块树可能不存在 |
| 内部/环境 | process.env.USER_TYPE === 'ant' | 当前运行身份是否能使用内部能力 | 代码存在,但普通用户不可见 |
| 工具注册 | getAllBaseTools() | 基础工具池包含哪些工具 | 形成候选工具列表 |
| 权限过滤 | getTools(permissionContext)、filterToolsByDenyRules() | 当前会话 deny 规则是否拒绝工具 | 被拒工具不发送给模型 |
| MCP 融合 | assembleToolPool(permissionContext, mcpTools) | 内置工具和 MCP 工具如何合并 | 名称冲突时内置工具优先 |
tools.ts 里有一个很关键的装配入口:
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext);
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext);
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name);
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
);
}
这段代码证明了一个很具体的行为:最终工具池不是“内置工具 + MCP 工具直接拼接”,而是先各自按名称排序,再拼接,再用 uniqBy(..., 'name') 去重。因为内置工具排在前面,所以名称冲突时内置工具胜出。
这个排序也不只是审美。原文指出它服务于 prompt cache 稳定性:内置工具作为稳定前缀,MCP 工具变化时不至于把内置工具顺序全部冲散。
核心直觉
Agent 的“能力边界”不是 UI 层决定的,而是 tool schema 暴露边界决定的。模型看不见某个工具,就不会把它纳入计划;模型看见了但后续被权限拒绝,就会浪费一轮推理和上下文。
终端 UI:REPL.tsx 是状态订阅者,不是事实来源
Claude Code 用 React Ink 渲染终端 UI。这个选择的价值不在“终端变漂亮”,而在它需要同时承载这些变化:
- 模型流式输出。
- 多个工具的运行进度。
- 权限确认和拒绝。
- 用户中断。
- MCP 连接和团队上下文变化。
- 历史消息折叠、隐藏、回填和重绘。
如果用传统 readline + console.log(),这些状态会互相抢输出流。React Ink 的意义是把终端界面变成一棵可重渲染的 UI 树。
但 REPL.tsx 不应该成为系统事实来源。它通过 useAppState() 订阅切片:
export function useAppState(selector) {
const store = useAppStore();
const get = () => selector(store.getState());
return useSyncExternalStore(store.subscribe, get, get);
}
这段代码证明了 REPL.tsx 和状态层的边界:
AppState 变化
-> store.subscribe 通知
-> useSyncExternalStore 读取 selector(getState())
-> React Ink 重渲染对应组件
-> 用户输入、确认、中断再通过 action 写回状态或执行层
UI 是状态变化的投影,不是唯一状态容器。这个边界非常重要,因为工具执行器、MCP 管理、hook 回调并不都在 React 组件树里,它们也需要读同一份事实。
全局状态:把运行事实放进 AppState
原文给出的 store.ts 片段非常短,但它解释了 Claude Code 为什么能把 React UI 和非 React 执行逻辑接到一起:
export function createStore<T>(
initialState: T,
onChange?: OnChange<T>,
): Store<T> {
let state = initialState;
const listeners = new Set<Listener>();
return {
getState: () => state,
setState: (updater: (prev: T) => T) => {
const prev = state;
const next = updater(prev);
if (Object.is(next, prev)) return;
state = next;
onChange?.({ newState: next, oldState: prev });
for (const listener of listeners) listener();
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
这段代码证明了四个设计点:
| 机制 | 源码位置 | 作用 |
|---|---|---|
getState() | 返回闭包里的 state | 非 React 逻辑可以命令式读取当前事实 |
setState(updater) | 用 prev -> next 更新 | 强制更新逻辑显式依赖旧状态 |
Object.is(next, prev) | 引用没变就跳过 | 避免无意义通知,也鼓励不可变更新 |
onChange?.(...) | 状态变化边界触发 | 把权限模式同步、凭证缓存清理等副作用集中到状态变化处 |
subscribe() | 注册 listener | React 或其他订阅者能响应变化 |
AppStateStore.ts 的类型体量也说明它不是普通 UI state。原文提到它有 60+ 个顶层字段,覆盖 settings、toolPermissionContext、mcp、plugins、tasks、teamContext、speculation 等。
| 状态区域 | 影响的运行行为 |
|---|---|
settings | 模型、权限模式、用户配置、实验开关等运行参数 |
toolPermissionContext | 工具是否 allow / ask / deny |
mcp | MCP 客户端连接和外部工具可用性 |
plugins | 插件能力、技能、扩展入口 |
tasks | 当前任务和可取消请求 |
teamContext | 团队/协作上下文注入 |
speculation | 推测执行相关状态 |
读源码抓手
看到某个模块调用 store.getState(),不要把它当“全局变量偷懒”。先问:它读取的字段会不会影响工具执行、权限、模型上下文或 UI 展示?如果会,它属于运行事实,而不是组件私有状态。
任务回流:Agent Runtime 的闭环
前面几层最后都要接到 query.ts。这里先不展开 Agent Loop 的细节,第二章会专门讲。第一章只需要抓住闭环:
用户输入
-> REPL 收集并写入任务现场
-> queryLoop 构造模型请求
-> 模型返回 text / tool_use
-> 工具系统执行 tool_use
-> tool_result 写回 messages
-> AppState / UI 看到新状态
-> queryLoop 带着新证据进入下一轮
这就是 Claude Code 和普通 CLI 的分界线。普通 CLI 的输出是终点;Claude Code 里的输出经常只是下一轮模型决策的输入。
query() 的薄包装也能看出边界:
export async function* query(params: QueryParams) {
const consumedCommandUuids: string[] = [];
const terminal = yield* queryLoop(params, consumedCommandUuids);
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed');
}
return terminal;
}
这段代码证明:query() 负责外层生命周期收尾,真正推进任务状态机的是 queryLoop()。入口、UI、状态、工具都不是彼此平行的“模块介绍”,它们在运行时都汇入这个循环。
设计取舍
| 取舍点 | 源码体现 | 为什么这么做 | 代价 |
|---|---|---|---|
| 顶层预取 | main.tsx 里紧跟 import 的 startMdmRawRead()、startKeychainPrefetch() | 把配置/凭证 I/O 藏进模块加载窗口,降低 CLI 首屏等待 | 顶层副作用让测试和依赖关系更敏感 |
| 构建期裁剪 | feature('X') ? require(...) : null | flag 关闭时模块树不进入产物,模型也看不见相关工具 | 构建系统和 bun:bundle 定制能力耦合 |
| 工具可见性前置过滤 | getTools()、filterToolsByDenyRules() | 被 deny 的工具不浪费 schema token,也不诱导模型计划不可执行动作 | 需要保证过滤逻辑和权限系统语义一致 |
| UI 订阅外部 store | useSyncExternalStore(store.subscribe, get, get) | React Ink UI 和非 React 执行逻辑共享同一状态源 | 状态结构会变大,需要清晰字段边界 |
| 内置工具稳定前缀 | sort().concat(...).uniqBy('name') | 降低 MCP 工具变化对 prompt cache 的扰动 | 工具排序策略成为缓存协议的一部分 |
这里最值得保留的判断是:Claude Code 在模型决策之前,尽量先缩小模型可见世界;在模型决策之后,再把现实反馈完整回流。
读源码抓手
按下面顺序读,不容易被 40+ 工具和 5000+ 行 UI 组件冲散:
- 先看
restored-src/src/main.tsx前 100 行,标出哪些动作在 UI 挂载前发生。 - 再找所有
feature()守卫的require(),区分构建期能力和运行期配置。 - 进入
restored-src/src/tools.ts,只追getAllBaseTools()、getTools()、assembleToolPool()三个函数。 - 看
restored-src/src/state/store.ts和AppState.tsx的useAppState(),确认 UI 怎么订阅外部状态。 - 扫
AppStateStore.ts顶层字段,建立“哪些事实会影响任务推进”的地图。 - 最后进入
query.ts,只找query()如何把控制权交给queryLoop()。
小结
- Claude Code 的启动入口用并行预取处理 CLI 首屏延迟。 - feature() 和工具注册决定模型能看见什么能力,这比 UI 呈现更关键。 - REPL.tsx 是状态投影,AppState 才是 UI 和执行层共享的事实来源。 - 工具结果必须写回下一轮上下文,Agent 才能基于真实世界继续判断。 - 这套架构的核心不是“调用模型”,而是维护一个可裁剪、可观察、可回流的 Agent Runtime。