从 19 篇架构文档中提炼出的设计心智模型、核心机制与可迁移的工程经验——写给想自己设计一套 agentic 系统的人。
Tool 插件契约展开,每一次工具调用都要穿过「校验 → 权限 → hook → 执行」的安全闸门。
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
整个系统几乎不区分「对话历史」和「状态」——绝大多数跨回合要记住的东西,都被编码成历史里的一条消息。历史始终是一维的 Message[],可随意切片、压缩、持久化。
| 内容 | 编码方式 | 出处文档 |
|---|---|---|
| 工具执行结果 | tool_result 的 user 消息(带 tool_use_id) | 01 · 主循环 |
| 子 agent 完成结果 | 注入的 <task-notification> user 消息 | 11 · 多 Agent 编排 |
| CLAUDE.md / git / 记忆 | 包在 <system-reminder> 里的 user 消息(isMeta) | 13 / 14 / 16 |
| 上下文压缩后的摘要 | 一条合成 user 消息 | 15 · 压缩 |
| 跨回合「控制簿记」 | 不落盘的可变状态(turnCount、readFileState…) | 03 · 状态持久化 |
把 18 个子系统摊开看,同样的几条原则一遍遍出现——它们才是「为什么好用」的真正答案。
AsyncGenerator<Message>,yield* 组合,共用一根管子、天然背压。01transition 字段记录「为什么继续」。01needsFollowUp),而非脆弱的 stop_reason。01callModel/autocompact/uuid 全可注入;不可变 config 快照 vs 可变 State。01这套代码里数量惊人的复杂度,都是在伺候两件「看不见」的事——prompt 缓存命中率 和 上下文预算。功能逻辑往往几十行就写完,真正的工程量在「如何让缓存前缀稳定」和「如何在不丢关键信息的前提下把 token 压下去」。要认真做 agent,请从第一天就把这两件事当一等公民。
主循环是整个系统的地基:组装上下文 → 调模型 → 收 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
主循环把「重试 / 恢复」实现为若干种特殊的 continue(写进 State.transition),把「终止」收敛成十来种带 reason 的 return。三套恢复与两套重试是本章主线:
| 机制 | 触发条件 | 处理方式 | 关键细节 |
|---|---|---|---|
网络重试 withRetry | 429 / 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 也安全。
一个工具在 Claude Code 里不是一个函数,而是一份契约:要能把自己序列化成发给模型的 schema、能自证输入合法、能自证权限、能声明并发语义、能执行并产出结果、能把结果映射成 API 的 tool_result。新增一个工具 = 实现一份契约,编排 / 权限 / 持久化 / UI 全部是面向接口的通用代码。
buildTool(def) 把 7 个默认方法补齐成完整契约,默认值刻意保守:
| 键 | 默认值 | 语义 | 谁必须覆盖 |
|---|---|---|---|
isEnabled | () => true | 工具启用 | 需按 flag/平台关闭的工具 |
isConcurrencySafe | () => false | 不可并发 | 只读/幂等工具(如 Glob) |
isReadOnly | () => false | 假设会写 | 只读工具 |
isDestructive | () => false | 非破坏 | 删除/覆盖/发送类工具 |
checkPermissions | → allow | 下放给通用权限系统 | 文件/命令类安全工具 |
toAutoClassifierInput | () => '' | 分类器跳过 | 安全相关工具(否则分类器看不到它) |
从「一堆 Tool 对象」到「发给 API 的 tools 数组」有一条流水线,隐藏主轴是 prompt 缓存——工具块渲染在服务端 position 2,任何一个字节抖动都会击穿约 11K token 的工具块及其后全部缓存。三条互相纠缠的不变量:
服务端把全局缓存断点放在「最后一个前缀匹配到的内置工具之后」。分区排序(内置在前、MCP 在后、各自 sort)保证断点不漂移;uniqBy 让同名冲突时内置赢过 MCP。
toolSchemaCache 锁住基座 schema,叠加层只碰 defer_loading/cache_control。GrowthBook gate 中途翻转不抖缓存。inputJSONSchema 纳入 cacheKey 修掉了 StructuredOutput 同名异 schema 的事故。
isDeferredTool 判定 + ToolSearch 的 tool_reference 回填:模型需要时通过一次工具往返「发现」工具,而非重发全量定义。常驻工具块因此最小。
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 --> [*]
sibling abort 级联:三层 AbortController。仅 Bash 出错才触发级联——命令间常有隐式依赖(mkdir 失败 → 后续无意义),而 Read/WebFetch 相互独立。被取消的兄弟收到合成的 <tool_use_error>,不误伤整轮。
双输入:processedInput(可观测、可被派生字段污染)与 callInput(API 绑定、逐字对齐模型原文)分离——因为 tool result 字符串会把 input 路径逐字嵌进去,改路径就改了 transcript/VCR 哈希。
权限决策引擎回答一个问题:模型请求调用某个工具、带着一组具体入参,允许吗?答案只有 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"]
resolveHookPermissionDecision 保证 hook allow 只跳过弹窗,settings.json 的 deny/ask 规则仍生效(inc-4788 事故教训)。09auto 模式的核心矛盾是「既要无人值守自动放行、又不能对危险操作放水」。成本从低到高倒排:先零成本的规则/白名单 → 本地 acceptEdits 复算 → 最后才付分类器 API 调用。分类器结果默认 fail-closed(挂了就拦),并带「连续 3 次或累计 20 次拒绝 → 强制回落到人工」的熔断。
allow 匹配只剥白名单 env(防 DOCKER_HOST=evil docker ps 命中 allow);deny 匹配则剥一切 env 前缀(用户 deny 了 claude,即便被 FOO=bar claude 包裹也应被拦)。这是 HackerOne #3543050 报告驱动的设计。
Claude Code 的多 Agent 建立在一个惊人简单的递归之上:AgentTool.call() 是一个工具,它的实现体又调用了一遍主循环 query()。复杂度集中在「隔离边界」与「结果如何回来」两个问题。
每次递归都换一份隔离的 ToolUseContext,全部集中在 createSubagentContext() 一个函数:可变状态克隆或置空、queryTracking.depth 递增、agentId 全新。外部构建把 Agent 工具剔除出子池,递归被硬限制在深度 1。
setAppState 对异步子 agent 是 no-op,但必须留一条 setAppStateForTasks 通道直达根 store——否则异步 agent 的后台 bash 任务永远不会被注册和杀掉(PPID=1 的僵尸进程)。
子 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 消息
三个不变量:先转终态、后润色(completeAsyncAgent 永远早于 handoff 分类与 worktree 清理,防挂起拖住状态机);notified 是唯一去重闩兼逐出前置(只通知一次、通知后才回收内存);agentId scoping(主线程领 undefined,子 agent 领本 id 的通知,用户 prompt 永不外泄给子 agent)。
| 维度 | 协调者模式(Coordinator) | 团队 / 蜂群(Swarm Team) |
|---|---|---|
| 主体 | main session 扮演 coordinator | main session 扮演 team-lead |
| worker 是谁 | AgentTool 起的异步后台 agent | TeamCreate 建队后的 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 不能含 @ 的类型约束——它把两个命名空间在类型层面隔开。
这一章是整套架构「真正值钱」的地方。功能逻辑往往几十行就写完,真正的工程量在「如何让缓存前缀稳定」和「如何在不丢关键信息的前提下把 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 是唯一的重置点。
| 路径 | 时机 | 机制 | 代价 |
|---|---|---|---|
| micro-compact | 每回合请求前 | 清老 tool_result 正文(时间驱动)或用 cache_edits 删 | 最轻,不发 API、不动结构 |
| session-memory | 触发线附近,优先尝试 | 用后台维护的 session memory 当摘要,剪旧消息 | 中等,省一次摘要 API |
| 结构化摘要 | 触发线附近,兜底 | 一次 LLM 摘要,历史换成 9 段式摘要 + ≤5 文件重新水化 | 重,连续失败 3 次熔断 |
| reactive / context-collapse | 413 兜底 | 运行时对超限响应做 collapse | 实验/兜底路径 |
持久记忆不是一个数据库,而是一个文件系统目录(memdir),围绕它有两条原则值得记住:记忆只存「无法从当前工程状态推导」的东西(代码模式、偏好、教训——grep/git/CLAUDE.md 就能查到的都不该存);「共享落盘、独立控制」——写入按 UUID 游标推进(回合末后台 forked agent)、读取按会话字节封顶(回合首 Sonnet 选择器召回 ≤5 条)、索引注入按 GB flag 二选一,互不阻塞。
Sonnet 选择器做语义选择而非关键词匹配(防假阳性);已成功使用的工具不召回其文档但保留 warnings/gotchas;每条记忆打「freshness 文案」而非裸时间戳——模型对日期运算很差,但「47 天前」能触发陈旧性推理。
三件咬合的事共享同一个约束——任何改变发给模型字节的决策都必须可确定性重放。Token 锚点(最后一次真实 usage + 之后 char/4 粗估)、成本的 sessionId 守卫、落盘的 wx 幂等、预算的 seenIds/replacements 冻结、resume 的逐字节重建——都是为了让「同一段历史无论跑几次进程,产出的 wire prefix 完全相同」,从而把 prompt 缓存命中率(= 省钱 + 省上下文)钉死。
可扩展性由三条正交通道构成,粒度、加载时机、谁定义各不相同。它们共享同一个不变式:能力可以无限扩展,而常驻上下文只按「发现清单」线性增长。
| 通道 | 单元 | 加载 / 披露时机 | 常驻成本 |
|---|---|---|---|
| Tool | 一次 API 调用的原语 | 进程启动即注册;可经 ToolSearch 延迟 | 每个工具一份 JSONSchema |
| Skill | 一段 Markdown 提示词 + 可选引用文件 | frontmatter 常驻;正文 load-on-invoke | 每个 skill 约几十 token 的一行清单 |
| MCP | 远程 server 暴露的 tools/prompts/skills | 连接握手后拉取;tools 走 deferred | 描述 + instructions 各截断到 2048 字符 |
Skill 通道把「渐进披露」做到了极致——三层披露逐层压榨常驻成本:frontmatter 常驻(只有 name / description / whenToUse 三字段计 token)→ paths 条件延迟(带 paths frontmatter 的条件 skill 连清单都不进,直到相关文件被触碰)→ 正文 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 待遇。
把 19 篇文档蒸馏到最后,真正可以带走的是下面这些——它们不绑定 Claude Code 的特定实现,而是一个成熟 agentic 系统的共性答案。
transition 字段记录续跑原因——既避免递归深度问题,又让恢复路径可测试断言。stop_reason。内容驱动、与 API 元数据解耦的判据最稳健。setAppStateForTasks、同步共享)才共享。退出即清理,不留状态残渣。wx 幂等、预算决策冻结、resume 逐字节重建——同一段历史无论跑几次进程,wire prefix 必须完全相同。这套架构的答案不是某一个炫技的机制,而是一组朴素原则在 51 万行代码里被不妥协地贯彻。读它的最大收获,或许不是学会某个具体的 StreamingToolExecutor 或 splitSysPromptPrefix,而是理解:agentic 系统的复杂度守恒——你可以选择把复杂度放在清晰的架构里,也可以选择把它放在无法调试的运行时里。