3690 字
18 分钟
Claude Code 源码里的工程课:8 个值得偷师的架构设计

上一篇我们聊了 Claude Code 源码泄露本身——遥测管道、隐私追踪、限额机制。那篇更偏”Anthropic 在监控什么”。

这篇换个角度:这份生产级 agent 系统的源码里,有哪些架构设计是可以偷师的?

我花了一下午让 6 个 agent 并行扫描了整个仓库的核心模块。以下是最有学习价值的 8 个设计。

1. 自研终端渲染引擎:React → 终端字符格#

Claude Code 的终端 UI 不是用 npm 上的 ink 包。它在 src/ink/从零实现了一套完整的终端渲染引擎,只是借用了 React 的组件模型。

架构分 6 层:

React 组件树
↓ react-reconciler
自定义 Virtual DOM (DOMElement / TextNode)
↓ Yoga (Meta 的 flexbox 引擎,WASM 版本)
布局计算 (position, size, flex)
↓ renderNodeToOutput()
2D 字符格 Screen (CharPool/StylePool 字符串驻留)
↓ LogUpdate.render() 差量对比
ANSI 转义序列 → stdout

关键优化点:

字符串驻留池(String Interning)。终端 UI 里大量重复字符和样式。CharPoolStylePool 给每个唯一字符串分配一个数字 ID,Screen 只存 ID 而不是字符串本身。内存开销从 O(cells) 降到 O(unique_strings)。

Blit 优化。如果一个子树的布局没变,直接把上一帧的像素复制过来,跳过整个渲染流程。配合 dirty flag 追踪,每帧只重绘真正变化的 cell。

差量输出LogUpdate 对比前后两帧的 Screen,只输出变化的 cell 对应的 ANSI 序列。不是每帧全量刷新。

还有个让我意外的设计——两阶段事件模型。和浏览器 DOM 一模一样的 capture/bubble 事件分发,完整的 focus 管理(tab 导航、click focus、focus 栈恢复)。一个终端应用做到这种程度的事件系统,说明它的 UI 复杂度已经不是简单的”打印文本”了。

src/ink/events/dispatcher.ts
// 两阶段事件分发,和浏览器 DOM 完全一致
// capture: root → target
// bubble: target → root
// stopPropagation() / stopImmediatePropagation() 都支持

可迁移的经验react-reconciler 是 React 官方包,允许你把 React 的组件模型接入任意渲染目标。如果你在做终端工具、Canvas 应用、甚至硬件 LED 矩阵,都可以用这套模式——React 管状态和组件生命周期,你只需要实现”怎么画”。

2. 插件化工具系统:40+ 工具的注册与分发#

Claude Code 有 40+ 内置工具(Bash、Read、Write、Grep、Agent 等)加上动态的 MCP 外部工具。src/Tool.ts 定义了一个泛型工具接口

// 核心接口(简化)
Tool<Input, Output> = {
// 执行
call(input, context): Output
validateInput(input): boolean
// 权限
checkPermissions(input, context): PermissionResult
// Schema
inputSchema: ZodSchema
// 分类
isReadOnly(): boolean
isDestructive(): boolean
isConcurrencySafe(): boolean
// UI 渲染
renderToolUseMessage(): ReactNode
renderToolResultMessage(): ReactNode
}

所有工具通过 buildTool() 工厂构建:

src/Tool.ts
// buildTool 填充默认值,让工具定义只写需要覆盖的部分
const myTool = buildTool({
name: 'MyTool',
inputSchema: z.object({ ... }),
call: async (input, context) => { ... },
// checkPermissions 不写 → 默认 allow
// isDestructive 不写 → 默认 false
})

设计上有几个值得注意的点:

延迟加载(Deferred Loading)。部分工具标记 shouldDefer: true,不会在初始化时发送给模型。模型需要时通过 ToolSearchTool 按描述搜索再加载。这减少了每次 API 请求的 token 消耗——40 个工具的 schema 加起来是很大一坨 JSON。

Feature Flag 门控。工具通过 feature() 按需加载,没开的功能对应的工具根本不会出现在工具池里。

MCP 工具统一接口。外部 MCP 服务器提供的工具用 MCPTool 模板包装,和内置工具用完全相同的接口。assembleToolPool() 合并时内置工具优先(名字冲突时),但用户可以通过 deny rules 屏蔽内置工具。

可迁移的经验:如果你在做 AI agent 或者任何插件系统——用泛型接口 + 工厂模式 + 延迟加载。接口定义执行、权限、schema、UI 四个关注点;工厂填充合理默认值减少样板代码;延迟加载控制运行时开销。

3. 27 种生命周期事件的 Hook 系统#

src/utils/hooks.ts4200+ 行,实现了一个覆盖 27 种事件的 hook 系统。这不是 React hooks——是 Claude Code 的生命周期钩子,让用户在几乎每个关键节点插入自定义逻辑。

5 种 hook 执行方式:

类型运行方式典型场景
Command启动 shell 进程,stdin 传 JSON,exit code 控制结果代码格式化、lint
Prompt调用 LLM 评估复杂条件判断
Agent完整的 agent 循环多步验证
HTTPPOST JSON 到外部服务Webhook 通知
Callback内存中的 TypeScript 函数SDK 内部快速路径

exit code 语义设计得很巧妙:

  • 0 = 成功(stdout 静默,除非开了 transcript 模式)
  • 2 = 阻断错误(stderr 立即展示给模型,阻止工具执行)
  • 其他 = 非阻断错误(stderr 只展示给用户,工具继续执行)

为什么是 2 而不是 1?因为 1 太常见了——很多程序的通用错误都返回 1。用 2 作为”明确的阻断信号”减少误判。

hook 还支持 asyncasyncRewake 模式:

async: true → 后台运行,不阻塞工具执行
asyncRewake: true → 后台运行,但如果 exit code 2,重新唤醒模型处理

if 条件过滤是另一个精妙设计。hook 可以声明只在特定工具/命令时触发:

{
"hooks": {
"PostToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "npx prettier --write $FILE",
"if": "Bash(git *)"
}]
}]
}
}

if: "Bash(git *)" 表示只有当 Bash 工具执行的是 git 命令时才触发。这个过滤在进程启动前完成,避免为不匹配的命令白白 spawn 进程。

可迁移的经验:生命周期 hook 系统的三个关键设计——exit code 语义分级(成功/阻断/非阻断)、条件过滤减少无效执行、async/sync 双模式。这套模式适用于任何需要扩展点的系统。

4. 竞争式权限决策管道#

Claude Code 的权限系统不是简单的”检查一下规则表”。它是一个多源竞争的异步管道。

决策流程:

工具请求 → tool.checkPermissions()
→ 规则匹配(6 层来源,后者覆盖前者)
→ 结果是 'ask'?
同时启动 4 个竞争者:
├─ 本地 UI(用户点按钮)
├─ Bridge(IDE 侧边栏)
├─ Hook(PreToolUse hook 返回 allow/deny)
└─ Classifier(auto 模式的 LLM 分类器)
createResolveOnce() → 第一个 resolve 的胜出

关键在 createResolveOnce():四个来源竞争,谁先回答谁说了算。这意味着如果你在 IDE 里点了”允许”,其他三个竞争者的结果会被静默丢弃。

规则来源的 6 层优先级:

policySettings → 最低优先级(组织管理员下发)
userSettings → ~/.claude/settings.json
projectSettings → .claude/settings.json
localSettings → .claude/settings.local.json
flagSettings → CLI 参数
session → 最高优先级(当次会话中的临时规则)

Auto 模式特别有意思——它用一个 YOLO Classifier 做两阶段评估:

  1. Fast 阶段:快速判断,够置信就直接决策
  2. Thinking 阶段:不够置信时启动深度推理

如果分类器连续多次被拒绝(用户否决了它的判断),会自动回退到交互式提示——一个简单的熔断器模式。

可迁移的经验:多源竞争式决策(first resolver wins)在很多场景有用——权限审批、多渠道通知确认、分布式系统的仲裁。核心是 claim() 原子操作保证只有一个胜出者。

5. CLAUDE.md 上下文发现与 @include#

Claude Code 的 system prompt 不是硬编码的。它通过 src/utils/claudemd.ts(1200+ 行)动态组装,从多个层次发现配置文件。

4 层发现顺序(越后面优先级越高):

/etc/claude-code/CLAUDE.md → Managed(组织全局)
~/.claude/CLAUDE.md → User(个人全局)
~/.claude/rules/*.md → User rules(自动扫描)
<project>/.claude/CLAUDE.md → Project(项目级)
<project>/.claude/rules/*.md → Project rules(自动扫描)
<project>/CLAUDE.local.md → Local(个人项目级,不进 Git)

从 CWD 往上到根目录,每个目录都会检查这些路径。离 CWD 越近的文件,加载越晚,优先级越高。这和 Git 的配置发现(system → global → local)是同一个思路。

@include 指令让 CLAUDE.md 可以引用其他文件:

# 我的项目规则
@./coding-standards.md
@~/shared-rules/security.md
@src/types/api.ts

实现原理:用 marked 的 Lexer 把 Markdown 解析成 token 树,递归遍历,只在 text 类型叶节点中用正则 /(?:^|\s)@((?:[^\s\\]|\\ )+)/g 提取 @path。代码块和 HTML 注释里的 @path 会被跳过。

安全限制很细致:

  • 最大递归深度 5 层,防止循环引用
  • processedPaths: Set<string> 追踪已处理路径(含 symlink 解析),重复即跳过
  • 只允许 70+ 种文本文件扩展名,图片/PDF 直接忽略
  • 项目外路径需要用户明确授权
  • 单文件 40000 字符上限
NOTE

@include 不支持 glob 通配符。@./rules/*.md 不会工作——* 被路径验证正则明确排除。但 .claude/rules/ 目录有自己的自动发现机制 processMdRules(),会递归扫描所有 .md 文件,不需要手动 include。

缓存用的是 lodash-es/memoize

// src/utils/claudemd.ts:790
export const getMemoryFiles = memoize(
async (forceIncludeExternal: boolean = false): Promise<MemoryFileInfo[]> => {
// 完整的 4 层发现 + @include 递归
}
)

memoize 按第一个参数缓存。同一会话内 getMemoryFiles(false) 只执行一次,后续调用直接返回缓存。清除缓存有两种入口——clearMemoryFileCaches() 静默清除,resetGetMemoryFilesCache() 清除并触发 InstructionsLoaded hook。

可迁移的经验:分层配置发现是经典模式,但加上 @include(递归、防循环、深度限制、类型白名单)就变成了一个轻量的配置组合系统。如果你的项目有多层配置需求(组织 → 团队 → 项目 → 个人),这套设计可以直接借鉴。

6. 极简不可变状态管理#

src/state/store.ts 实现了一个比 Redux 轻得多的状态管理器:

type Store<T> = {
getState: () => T
setState: (updater: (prev: T) => T) => void
subscribe: (listener: Listener) => () => void
}

三个方法,就这么多。没有 action、reducer、middleware、dispatch。

状态用 DeepImmutable 类型包裹,编译时阻止意外修改。更新必须通过 setState 传入 updater 函数。

所有状态变更通过一个单一出口 onChangeAppState() 同步副作用:

AppState 变更 → onChange 触发 →
1. 同步 CCR external_metadata(permission_mode 等)
2. 通知 SDK status stream
3. 持久化 settings
4. 失效凭证缓存

投机引擎(Speculation)是唯一的例外——它用 mutable ref 避免每条消息的数组拷贝。这是务实的选择:投机 agent 每秒可能产生几十条消息,在这个热路径上做 immutable copy 代价太高。

可迁移的经验:大部分应用不需要 Redux。Observer pattern + immutable state + 单一变更出口,覆盖了 90% 的状态管理需求。在性能热路径上用 mutable ref 做例外,但要明确标记。

7. Coordinator 多 Agent 编排#

src/coordinator/ 实现了一套完整的多 agent 异步编排框架。核心设计原则:Coordinator 只指挥,不干活。

Coordinator(主 agent)
├─ spawn Worker 1 → 立即返回 {status: 'async_launched', agentId}
├─ spawn Worker 2 → 立即返回
└─ spawn Worker 3 → 立即返回
(Coordinator 的 turn 结束,不阻塞等待)
Worker 1 完成 → enqueue <task-notification> XML
Worker 2 完成 → enqueue <task-notification> XML
Coordinator 下一轮:
← 读取 task-notification(作为 user message 注入)
→ 综合结果
→ SendMessage 继续 Worker 3(复用上下文)
→ 或 spawn Worker 4(干净上下文)

几个关键设计:

Task 通知是消息,不是事件。Worker 完成后,结果以 <task-notification> XML 格式注入到 Coordinator 的消息队列里,作为下一轮的 user message。这意味着不需要额外的事件系统——复用已有的对话循环。

继续 vs 重启的选择SendMessage({to: "worker-id"}) 可以继续一个已完成的 Worker,让它复用之前的上下文(包括已读的文件、已做的分析)。但如果 Worker 的上下文已经太”脏”(探索了太多不相关的内容),Coordinator 会选择 spawn 一个新 Worker。

主会话也是 Task。用户可以 Ctrl+B 把主会话推到后台,主会话变成一个 LocalAgentTaskState,和子 agent 统一管理。这个设计消除了”主会话”和”子任务”的概念差异。

Worktree 隔离。Agent 可以在独立的 git worktree 中运行,互不干扰地修改文件。Coordinator 在 spawn 时就创建 worktree,Agent 结束后自动清理(如果没改动的话)。

30 秒 Grace Period。终止的 task 不会立即从状态中移除,而是保留 30 秒让 UI 有时间展示完成通知。这是一个小细节,但对用户体验影响很大。

可迁移的经验:用消息队列(而非事件/回调)做 agent 间通信,复用已有的对话循环。“继续 vs 重启”的选择权交给 Coordinator——这比”总是重启”或”总是继续”都灵活。

8. Token-Aware 会话压缩#

上下文窗口是有限的。src/services/compact/compact.ts(1706 行)解决这个问题:

compactConversation()
├─ 计算当前 token 预算
├─ 找到压缩边界(哪些消息需要被压缩)
├─ 调用 LLM 生成摘要(压缩后的对话概要)
├─ 替换被压缩的消息
└─ 恢复关键上下文:
├─ createPostCompactFileAttachments() → 最近访问的文件
├─ createPlanAttachmentIfNeeded() → 当前 Plan
├─ createSkillAttachmentIfNeeded() → 活跃的 Skill
└─ createAsyncAgentAttachmentsIfNeeded() → 异步 Agent 元数据

精妙之处在压缩后的上下文恢复。压缩不是简单的”删掉旧消息”——它会把最近访问的文件重新注入,确保模型不会”忘记”正在编辑的代码。Plan 文件、Skill 上下文、异步 Agent 的状态也会被保留。

还有个 truncateHeadForPTLRetry()——当 prompt token 超限时,截断头部消息而不是直接报错。这是一个优雅降级:与其让请求失败,不如丢掉最早的上下文继续工作。

可迁移的经验:任何长对话 AI 应用都需要上下文管理。关键不是”怎么压缩”(调 LLM 生成摘要就行),而是”压缩后恢复什么”——活跃文件、进行中的计划、未完成的子任务。这些才是模型继续工作所需的关键上下文。

总结:一个 Agent 操作系统#

把这 8 个子系统放在一起看,Claude Code 不是一个”调 API 的 CLI 工具”——它是一个Agent 操作系统

  • Ink 引擎 = 显示系统(GPU)
  • Tool 系统 = 系统调用接口
  • Hook 系统 = 中断处理
  • 权限管道 = 访问控制
  • CLAUDE.md = 配置管理
  • State Store = 内核状态
  • Coordinator = 进程调度器
  • Compaction = 内存管理(GC)

最值得学习的不是某个单点技术,而是这些子系统如何用几个统一的抽象粘在一起:React 管 UI、Hook 管扩展、Task 管执行、Store 管状态。每一层都有清晰的接口边界,但组合起来能支撑极其复杂的交互。

这大概就是”生产级”和”玩具级”agent 框架的差距——不在于 LLM 调用有多花哨,而在于围绕 LLM 的工程基础设施有多扎实。

Claude Code 源码里的工程课:8 个值得偷师的架构设计
https://blog.lishuyu.top/posts/claude-code-engineering-lessons/
作者
猫猫魔女
发布于
2026-03-31
许可协议
CC BY-NC-SA 4.0