上一篇我们聊了 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 里大量重复字符和样式。CharPool 和 StylePool 给每个唯一字符串分配一个数字 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 复杂度已经不是简单的”打印文本”了。
// 两阶段事件分发,和浏览器 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() 工厂构建:
// 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.ts 有 4200+ 行,实现了一个覆盖 27 种事件的 hook 系统。这不是 React hooks——是 Claude Code 的生命周期钩子,让用户在几乎每个关键节点插入自定义逻辑。
5 种 hook 执行方式:
| 类型 | 运行方式 | 典型场景 |
|---|---|---|
| Command | 启动 shell 进程,stdin 传 JSON,exit code 控制结果 | 代码格式化、lint |
| Prompt | 调用 LLM 评估 | 复杂条件判断 |
| Agent | 完整的 agent 循环 | 多步验证 |
| HTTP | POST JSON 到外部服务 | Webhook 通知 |
| Callback | 内存中的 TypeScript 函数 | SDK 内部快速路径 |
exit code 语义设计得很巧妙:
- 0 = 成功(stdout 静默,除非开了 transcript 模式)
- 2 = 阻断错误(stderr 立即展示给模型,阻止工具执行)
- 其他 = 非阻断错误(stderr 只展示给用户,工具继续执行)
为什么是 2 而不是 1?因为 1 太常见了——很多程序的通用错误都返回 1。用 2 作为”明确的阻断信号”减少误判。
hook 还支持 async 和 asyncRewake 模式:
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.jsonprojectSettings → .claude/settings.jsonlocalSettings → .claude/settings.local.jsonflagSettings → CLI 参数session → 最高优先级(当次会话中的临时规则)Auto 模式特别有意思——它用一个 YOLO Classifier 做两阶段评估:
- Fast 阶段:快速判断,够置信就直接决策
- 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:790export 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> XMLWorker 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 的工程基础设施有多扎实。