Architecture Deep Dive · Docs 00–18

Claude Code 源码架构
核心价值提炼

从 19 篇架构文档中提炼出的设计心智模型、核心机制与可迁移的工程经验——写给想自己设计一套 agentic 系统的人。

一句话内核:一个用 async generator 串起来的「思考 → 行动 → 观察」循环,围绕一个极完备的 Tool 插件契约展开,每一次工具调用都要穿过「校验 → 权限 → hook → 执行」的安全闸门。
19
架构文档
8
贯穿全局设计决策
10+
恢复 / 终止路径
3
扩展通道 (Tool / Skill / MCP)
01

心智模型:一个 Agent 系统的心脏

Claude Code 是 Claude 官方 CLI 的开源可读源码(约 1900 文件 / 51 万行,Bun + TypeScript,终端 UI 用 React + Ink)。整套架构围绕三个核心抽象展开:异步生成器做脊柱、消息数组做历史、工具契约做能力边界。

三层同心圆

每一层都是一个 AsyncGenerator<Message>,外层用 yield* 把内层的消息流「接」出来。没有回调地狱,天然背压,模型输出 / 工具执行 / 对外 SDK 消息全部从同一根管子流过。

flowchart TB
    subgraph L1
        direction TB
        title1["会话/回合编排
QueryEngine.submitMessage()"] desc1["跨回合状态 · 组装 system prompt + 上下文
翻译成对外 SDK 消息"] subgraph L2 direction TB title2["Agent 主循环 · query.ts queryLoop()
—— 脊柱"] desc2["一次 while 迭代 = 一次模型调用
+ 它请求的整批工具"] subgraph L3 direction TB title3["模型 + 工具
callModel / StreamingToolExecutor"] desc3["流式 API 调用 · 工具的调度/权限/执行"] end end end desc1 -.->|"依赖注入接缝:callModel / autocompact / uuid 全可注入"| desc2
图 1 · 三层同心圆架构:会话编排 → Agent 主循环 → 模型与工具

万物皆消息

整个系统几乎不区分「对话历史」和「状态」——绝大多数跨回合要记住的东西,都被编码成历史里的一条消息。历史始终是一维的 Message[],可随意切片、压缩、持久化。

内容编码方式出处文档
工具执行结果tool_result 的 user 消息(带 tool_use_id01 · 主循环
子 agent 完成结果注入的 <task-notification> user 消息11 · 多 Agent 编排
CLAUDE.md / git / 记忆包在 <system-reminder> 里的 user 消息(isMeta13 / 14 / 16
上下文压缩后的摘要一条合成 user 消息15 · 压缩
跨回合「控制簿记」不落盘的可变状态(turnCountreadFileState…)03 · 状态持久化

八条贯穿全局的设计决策

把 18 个子系统摊开看,同样的几条原则一遍遍出现——它们才是「为什么好用」的真正答案。

P1
Async generator 作脊柱每层都是 AsyncGenerator<Message>yield* 组合,共用一根管子、天然背压。01
P2
循环 + 显式 State,别递归跨迭代状态装进类型化对象;transition 字段记录「为什么继续」。01
P3
一个不含糊的退出信号「还有 tool_use 吗」(needsFollowUp),而非脆弱的 stop_reason01
P4
万物皆消息tool_result / 子 agent 结果 / 记忆都是注入的消息,历史永远线性可切片。11 / 14
P5
Fail-closed 默认并发 / 只读 / 权限 / 分类器投影,安全相关默认值全取最保守一侧。04 / 07
P6
I/O 边界做依赖注入callModel/autocompact/uuid 全可注入;不可变 config 快照 vs 可变 State。01
P7
为 prompt 缓存偏执工具排序、动态边界哨兵、agent 列表进 attachment、落盘决策跨轮冻结。05 / 13 / 17
P8
可恢复错误先「扣住」413 / 输出触顶等错误在决定能否恢复前不流给消费者。02
最容易被低估的一点

这套代码里数量惊人的复杂度,都是在伺候两件「看不见」的事——prompt 缓存命中率上下文预算。功能逻辑往往几十行就写完,真正的工程量在「如何让缓存前缀稳定」和「如何在不丢关键信息的前提下把 token 压下去」。要认真做 agent,请从第一天就把这两件事当一等公民。

02

主循环:思考–行动–观察的引擎

主循环是整个系统的地基:组装上下文 → 调模型 → 收 tool_use → 跑工具 → 拼历史 → 再来一轮。它用一个 while(true) 而非递归,跨迭代状态放进单个 State 对象。

单回合控制流

flowchart TD
    A["① 准备消息窗口
tool-result 预算 / micro-compact / autocompact"] --> B["② 流式调用模型
deps.callModel · for await 消费"] B --> C["③ 边流边收 tool_use block
只要有一个 → needsFollowUp = true"] C --> D{needsFollowUp?} D -->|否:模型不再要工具| E["return completed
循环终止"] D -->|是| F["④ 执行工具
产出 tool_result(本质是 user 消息)"] F --> G["⑤ 拼下一回合历史
[...本轮消息, ...assistant, ...toolResults]"] G -->|turnCount++| A
图 2 · 单回合控制流:唯一的退出信号是「本轮模型还请求工具吗」

恢复机制家族:可恢复错误一律先「扣住」

主循环把「重试 / 恢复」实现为若干种特殊的 continue(写进 State.transition),把「终止」收敛成十来种带 reasonreturn。三套恢复与两套重试是本章主线:

机制触发条件处理方式关键细节
网络重试 withRetry429 / 529 / 401 / 连接错误冷却后重试,用户无感禁用 SDK 自带重试,自实现分级
模型 fallback连续 529 过载且有 fallback 模型FallbackTriggeredError,换模型重放整段历史stripSignatureBlocks 清 thinking 签名(模型绑定)
流式→非流式 fallback流式请求中途出错退到非流式重试给残缺 assistant 发 tombstone 从 UI/transcript 双向删除
413 prompt-too-long上下文超限先 collapse drain(保留粒度)再 reactive compact(整段摘要)恢复失败前不 yield,防 SDK 见 error 杀会话
max_output_tokens输出触顶(默认被 cap 到 8k)先升档 64k 重发同请求,再注入「继续写」多轮恢复(≤3 次)BQ p99 输出仅 4,911 tokens,多数请求用小额度
Stop hooks 阻塞hook 要求「没跑测试不许结束」把 blocking 错误注入历史再跑一轮hasAttemptedReactiveCompact 故意不重置防死循环
为什么「扣住」错误如此关键

SDK 消费者一见 error 字段就杀会话——「the recovery loop keeps running but nobody is listening」。所以所有可恢复错误(413、max_output_tokens、media)都必须等到确定恢复不了,才把错误吐给消费者。恢复路径还刻意绕过 stop hooks,避免「error → hook 阻塞 → 重试 → error」的死亡螺旋。

跨回合状态的分层

一个 agentic 系统的「记忆」分成两层:进程内跨回合状态(QueryEngine 实例字段),和磁盘上的持久转录(~/.claude/projects/<cwd>/<sessionId>.jsonl)。resume 的唯一真相来源是 JSONL。

状态字段作用域是否落盘resume 后行为
mutableMessages每 QueryEngine,跨回合是(JSONL)由 JSONL 重建为 Message[]
permissionDenials跨回合,只增归空重新计数
totalUsage跨回合累加归零(成本另从别处恢复)
readFileState(LRU 100/25MB)每进程空缓存重建,「已读视图」归零
loadedNestedMemoryPaths跨回合,非淘汰空集合 → 每个 CLAUDE.md 重新注入一次
两条落盘路径

转录写入分「同步」(元数据类 entry,必须立刻在 EOF)与「异步写队列」(高频消息,按文件分队列、100ms 批量 drain、0o600 权限)。写入保序,assistant 消息 fire-and-forget 也安全。

03

工具系统:一份契约打通全链路

一个工具在 Claude Code 里不是一个函数,而是一份契约:要能把自己序列化成发给模型的 schema、能自证输入合法、能自证权限、能声明并发语义、能执行并产出结果、能把结果映射成 API 的 tool_result。新增一个工具 = 实现一份契约,编排 / 权限 / 持久化 / UI 全部是面向接口的通用代码。

buildTool:fail-closed 的默认值

buildTool(def) 把 7 个默认方法补齐成完整契约,默认值刻意保守:

默认值语义谁必须覆盖
isEnabled() => true工具启用需按 flag/平台关闭的工具
isConcurrencySafe() => false不可并发只读/幂等工具(如 Glob)
isReadOnly() => false假设会写只读工具
isDestructive() => false非破坏删除/覆盖/发送类工具
checkPermissions→ allow下放给通用权限系统文件/命令类安全工具
toAutoClassifierInput() => ''分类器跳过安全相关工具(否则分类器看不到它)

工具池:为 prompt 缓存偏执

从「一堆 Tool 对象」到「发给 API 的 tools 数组」有一条流水线,隐藏主轴是 prompt 缓存——工具块渲染在服务端 position 2,任何一个字节抖动都会击穿约 11K token 的工具块及其后全部缓存。三条互相纠缠的不变量:

不变量 1

内置工具必须是连续前缀且顺序稳定

服务端把全局缓存断点放在「最后一个前缀匹配到的内置工具之后」。分区排序(内置在前、MCP 在后、各自 sort)保证断点不漂移;uniqBy 让同名冲突时内置赢过 MCP。

不变量 2

工具 schema 字节要会话稳定

toolSchemaCache 锁住基座 schema,叠加层只碰 defer_loading/cache_control。GrowthBook gate 中途翻转不抖缓存。inputJSONSchema 纳入 cacheKey 修掉了 StructuredOutput 同名异 schema 的事故。

不变量 3

延迟加载把低频工具移出 turn-1

isDeferredTool 判定 + ToolSearch 的 tool_reference 回填:模型需要时通过一次工具往返「发现」工具,而非重发全量定义。常驻工具块因此最小。

配套

system prompt 分块缓存

splitSysPromptPrefix 把 system 切成 attribution/CLI 前缀/静态/动态四块,只有静态块打 scope:'global'。全请求 cache breakpoint ≤ 4 个。

执行与并发:边流边跑

新版 StreamingToolExecutor 不等一轮 block 收齐:模型每 stream 出一个 block 就 addTool,满足并发条件立即开跑。三条不变量:并发安全工具可并行、非并发工具独占、结果按接收顺序回吐。Read 恒并发安全、Write 恒独占、Bash 视命令是否只读而定(ls 安全,git commit 独占)。

stateDiagram-v2
    [*] --> queued: addTool
    queued --> executing: canExecuteTool 通过
    queued --> queued: canExecuteTool 不通过
    executing --> completed: collectResults 结束
    completed --> yielded: getCompletedResults 吐出
    yielded --> [*]
      
图 3 · 工具执行状态机:queued → executing → completed → yielded
两个精妙设计

sibling abort 级联:三层 AbortController。仅 Bash 出错才触发级联——命令间常有隐式依赖(mkdir 失败 → 后续无意义),而 Read/WebFetch 相互独立。被取消的兄弟收到合成的 <tool_use_error>,不误伤整轮。

双输入:processedInput(可观测、可被派生字段污染)与 callInput(API 绑定、逐字对齐模型原文)分离——因为 tool result 字符串会把 input 路径逐字嵌进去,改路径就改了 transcript/VCR 哈希。

04

安全闸门:deny > ask > allow

权限决策引擎回答一个问题:模型请求调用某个工具、带着一组具体入参,允许吗?答案只有 allow / ask / deny 三种终态。安全语义的全部真相,编码在步骤编号的顺序里。

确定性流水线 + 模式后处理

引擎分两层:hasPermissionsToUseToolInner 是纯确定性的规则流水线(1a→3),不发网络、不弹窗、不跑分类器;外层 hasPermissionsToUseTool 再对 ask 结果施加非确定性模式变换(dontAsk / auto 分类器 / headless 兜底)。

flowchart TD
    A["inner: 1a 整工具 deny?"] --> B{"deny?"}
    B -- yes --> D["return deny (最高优先级)"]
    B -- no --> C["1b 整工具 ask → 1c tool.checkPermissions()"]
    C --> E{"1d-1g 工具 deny / 交互 ask / content-ask / safetyCheck?"}
    E -- yes --> F["return deny / ask(bypass 也拦)"]
    E -- no --> G["2a bypassPermissions? → allow"]
    G --> H["2b 整工具 allow 规则 → allow"]
    H --> I["3 兜底 passthrough → ask"]
      
图 4 · 权限流水线:deny 永远排在最前,allow 排在最后

三条必须牢记的不变式

1
deny > ask > allow顺序编码在步骤编号里——allow 规则(2b)永远排在所有 deny/ask(1a–1g)之后。07
2
bypassPermissions 只跳过步骤 3 的兜底弹窗1a/1d 的 deny、1e/1f/1g 的 ask 都是 bypass-immune 的 carve-out。07
3
hook 的 allow 不越权resolveHookPermissionDecision 保证 hook allow 只跳过弹窗,settings.json 的 deny/ask 规则仍生效(inc-4788 事故教训)。09

auto 模式:分级探针 + 分类器 + 熔断

auto 模式的核心矛盾是「既要无人值守自动放行、又不能对危险操作放水」。成本从低到高倒排:先零成本的规则/白名单 → 本地 acceptEdits 复算 → 最后才付分类器 API 调用。分类器结果默认 fail-closed(挂了就拦),并带「连续 3 次或累计 20 次拒绝 → 强制回落到人工」的熔断。

deny 侧更「激进」,allow 侧更「保守」——不对称是刻意的

allow 匹配只剥白名单 env(防 DOCKER_HOST=evil docker ps 命中 allow);deny 匹配则剥一切 env 前缀(用户 deny 了 claude,即便被 FOO=bar claude 包裹也应被拦)。这是 HackerOne #3543050 报告驱动的设计。

05

多 Agent:隔离、回注与协作

Claude Code 的多 Agent 建立在一个惊人简单的递归之上:AgentTool.call() 是一个工具,它的实现体又调用了一遍主循环 query()。复杂度集中在「隔离边界」与「结果如何回来」两个问题。

递归与上下文隔离:默认隔离、显式共享

每次递归都换一份隔离的 ToolUseContext,全部集中在 createSubagentContext() 一个函数:可变状态克隆或置空、queryTracking.depth 递增、agentId 全新。外部构建把 Agent 工具剔除出子池,递归被硬限制在深度 1。

隔离最微妙的一处

setAppState 对异步子 agent 是 no-op,但必须留一条 setAppStateForTasks 通道直达根 store——否则异步 agent 的后台 bash 任务永远不会被注册和杀掉(PPID=1 的僵尸进程)。

结果回注:不是 await,而是通知队列

子 agent 跑完后,父 agent 不是「await 子 agent」,而是:子 agent 把结果写成一段 <task-notification> XML,塞进一个进程级全局队列;父 agent 在自己的 query loop 每轮工具调用结束时主动领取属于自己的通知,把它当成一条新到达的 user 消息注入下一次推理。

sequenceDiagram
    participant W as worker
    participant Q as commandQueue
    participant P as 父 query loop
    participant M as 模型
    W->>W: 收完最后一条 assistant
    W->>W: finalizeAgentTool
    W->>Q: enqueueAgentNotification
    Note right of Q: priority: later
    Note over P: 父 agent 下一轮工具调用结束
    P->>Q: 按 agentId 过滤领取通知
    Q-->>P: task-notification 消息
    P->>M: yield 成 user 消息
      
图 5 · 异步结果回注:一条全局队列,靠 agentId scoping 服务 N 个并发 agent

三个不变量:先转终态、后润色completeAsyncAgent 永远早于 handoff 分类与 worktree 清理,防挂起拖住状态机);notified 是唯一去重闩兼逐出前置(只通知一次、通知后才回收内存);agentId scoping(主线程领 undefined,子 agent 领本 id 的通知,用户 prompt 永不外泄给子 agent)。

协调者模式 vs 团队:被一个 SendMessage 缝合的两套模型

维度协调者模式(Coordinator)团队 / 蜂群(Swarm Team)
主体main session 扮演 coordinatormain session 扮演 team-lead
worker 是谁AgentTool 起的异步后台 agentTeamCreate 建队后的 in-process teammate
结果如何回来user 角色的 <task-notification> XML文件邮箱的 idle_notification
worker 间寻址agentNameRegistry(name → agentId)~/.claude/teams/{team}/inboxes/ 文件邮箱
SendMessage 走哪条in-process 路由(queue / resume)邮箱路由(handleMessage / broadcast)
三层寻址如何在一个工具里共存

同一个 SendMessage 按目标落在三个命名空间:注册名 / createAgentId(无 @)→ 内存 task 队列;裸 teammate 名 → 文件邮箱;uds: / bridge: → 跨会话跨机 peer。关键在 to 不能含 @ 的类型约束——它把两个命名空间在类型层面隔开。

06

上下文与记忆:看不见的工程量

这一章是整套架构「真正值钱」的地方。功能逻辑往往几十行就写完,真正的工程量在「如何让缓存前缀稳定」和「如何在不丢关键信息的前提下把 token 压下去」。

系统提示三段 × 物理四块

构建 API cache-key 前缀的三块内容是 systemPrompt / userContext / systemContext(每会话稳定)。而 systemPrompt 本身在发往 API 前又被切成 attribution header / CLI prefix / static / dynamic 四块,由 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 哨兵一分为二,只有静态块打 scope:'global' 跨 org 共享缓存。

缓存名额守恒

Anthropic API 每请求最多 4 个 cache breakpoint——system 侧 1~2 个 + tools 块 1 个 + messages 末条 1 个。section 缓存 + latch 把动态段与 beta 头钉稳;/clear/compact 是唯一的重置点。

上下文压缩:四路径,侵入性从轻到重

图 6 · 200k 上下文窗口下的阈值带(模型 maxOutput ≥ 20k)
路径时机机制代价
micro-compact每回合请求前清老 tool_result 正文(时间驱动)或用 cache_edits 删最轻,不发 API、不动结构
session-memory触发线附近,优先尝试用后台维护的 session memory 当摘要,剪旧消息中等,省一次摘要 API
结构化摘要触发线附近,兜底一次 LLM 摘要,历史换成 9 段式摘要 + ≤5 文件重新水化重,连续失败 3 次熔断
reactive / context-collapse413 兜底运行时对超限响应做 collapse实验/兜底路径

持久记忆:唯一跨会话存活的状态

持久记忆不是一个数据库,而是一个文件系统目录(memdir),围绕它有两条原则值得记住:记忆只存「无法从当前工程状态推导」的东西(代码模式、偏好、教训——grep/git/CLAUDE.md 就能查到的都不该存);「共享落盘、独立控制」——写入按 UUID 游标推进(回合末后台 forked agent)、读取按会话字节封顶(回合首 Sonnet 选择器召回 ≤5 条)、索引注入按 GB flag 二选一,互不阻塞。

召回侧的三个护栏

Sonnet 选择器做语义选择而非关键词匹配(防假阳性);已成功使用的工具不召回其文档但保留 warnings/gotchas;每条记忆打「freshness 文案」而非裸时间戳——模型对日期运算很差,但「47 天前」能触发陈旧性推理。

Token、成本与落盘:同一个工程律

三件咬合的事共享同一个约束——任何改变发给模型字节的决策都必须可确定性重放。Token 锚点(最后一次真实 usage + 之后 char/4 粗估)、成本的 sessionId 守卫、落盘的 wx 幂等、预算的 seenIds/replacements 冻结、resume 的逐字节重建——都是为了让「同一段历史无论跑几次进程,产出的 wire prefix 完全相同」,从而把 prompt 缓存命中率(= 省钱 + 省上下文)钉死。

07

可扩展性:渐进披露

可扩展性由三条正交通道构成,粒度、加载时机、谁定义各不相同。它们共享同一个不变式:能力可以无限扩展,而常驻上下文只按「发现清单」线性增长

通道单元加载 / 披露时机常驻成本
Tool一次 API 调用的原语进程启动即注册;可经 ToolSearch 延迟每个工具一份 JSONSchema
Skill一段 Markdown 提示词 + 可选引用文件frontmatter 常驻;正文 load-on-invoke每个 skill 约几十 token 的一行清单
MCP远程 server 暴露的 tools/prompts/skills连接握手后拉取;tools 走 deferred描述 + instructions 各截断到 2048 字符

Skill 的三层渐进披露

Skill 通道把「渐进披露」做到了极致——三层披露逐层压榨常驻成本:frontmatter 常驻(只有 name / description / whenToUse 三字段计 token)→ paths 条件延迟(带 paths frontmatter 的条件 skill 连清单都不进,直到相关文件被触碰)→ 正文 invoke 时才加载

图 7 · 单个 skill 的 token 核算:常驻只有 frontmatter(≈33 tok),正文(≈47 tok)仅 invoke 时进入
安全边界

MCP skill 来自远程、不可信——其 markdown 正文里的 !`...` shell 命令绝不执行;本地可信 skill 才可以做 shell 注入。两种信任级别在同一个函数里区分开。

插件:把三条通道打成可开关的分发包

插件是「一次打包多种组件(skills / hooks / MCP servers / commands / agents)」的容器。内置插件(@builtin)随二进制分发但可开关;marketplace 插件从 git 拉取、解析 plugin.json、以 sha 钉版本。插件提供的 skill 故意标 source:'bundled' 而非 'builtin',以继续享受清单不截断、analytics 记名等 bundled 待遇。

08

黄金法则:可迁移的设计经验

把 19 篇文档蒸馏到最后,真正可以带走的是下面这些——它们不绑定 Claude Code 的特定实现,而是一个成熟 agentic 系统的共性答案。

A
把「prompt 缓存」与「上下文预算」当一等公民这是整套代码里被低估最多、但贡献最大的两件事。从第一天就把「如何让缓存前缀稳定」和「如何压缩 token 不丢关键信息」纳入架构,而不是事后补救。
B
循环 + 显式状态,别递归把每轮迭代变成「纯函数式状态转移」,transition 字段记录续跑原因——既避免递归深度问题,又让恢复路径可测试断言。
C
用一个不含糊的退出信号「还有 tool_use 吗」,而非脆弱的 stop_reason。内容驱动、与 API 元数据解耦的判据最稳健。
D
安全默认值全取最保守一侧并发不安全、假设会写、分类器跳过、错误先扣住——fail-closed 让「忘记隔离/忘记校验」成为不可能的失误,而非靠纪律。
E
恢复错误前先「扣住」SDK 消费者一见 error 就杀会话。所有可恢复错误必须先尝试恢复,确定救不回来再吐给消费者,并用熔断/守卫防死亡螺旋。
F
默认隔离、显式共享多 Agent 的子上下文一律克隆或置空,只有明确标记的通道(setAppStateForTasks、同步共享)才共享。退出即清理,不留状态残渣。
G
任何改变发往模型字节的决策,必须可确定性重放落盘用 wx 幂等、预算决策冻结、resume 逐字节重建——同一段历史无论跑几次进程,wire prefix 必须完全相同。
H
渐进披露,让常驻成本只随「清单」线性增长能力可以无限扩展(Tool / Skill / MCP / 插件),但常驻上下文的代价收敛到一行清单、几段 frontmatter、几个截断的描述。
结尾

这套架构的答案不是某一个炫技的机制,而是一组朴素原则在 51 万行代码里被不妥协地贯彻。读它的最大收获,或许不是学会某个具体的 StreamingToolExecutorsplitSysPromptPrefix,而是理解:agentic 系统的复杂度守恒——你可以选择把复杂度放在清晰的架构里,也可以选择把它放在无法调试的运行时里。