25-架构模式总结
第 25 篇:架构模式总结 — 可迁移到你自己项目的设计模式
在过去 24 篇文章中,我们逐一拆解了 Claude Code 的每个子系统——从启动链路到对话循环,从工具系统到权限防线,从 Prompt Cache 到 MCP 协议。每篇都聚焦于”这个模块是怎么设计的“。
但工程师阅读源码的终极目的不是理解别人的代码,而是把好的设计用到自己的项目里。
本篇将切换视角:不再关注 Claude Code 特有的业务逻辑,而是提取那些跨项目可复用的架构模式。这 7 个模式覆盖了从编译期优化到运行时状态管理、从工具注册到安全防线的全栈设计决策。
模式 1:编译期 DCE — 同一份代码构建多版本
问题
你的产品需要从同一份代码库构建出多个版本——内部版/外部版、免费版/付费版、或者面向不同客户的定制版。传统做法是维护多个分支或在运行时用 if/else 判断,前者导致合并噩梦,后者让所有版本都包含所有代码。
Claude Code 的解法
利用 Bun bundler 的 feature() 函数实现编译期 Dead Code Elimination(DCE)。关键技巧是将 feature() 与 require()(而非静态 import)配合使用:
1 | // tools.ts:25-28 |
编译时,feature('PROACTIVE') 被替换为 true 或 false。当为 false 时,bundler 直接删除整个 require() 分支及其依赖的模块树。这实现了零成本的功能门控——外部版本的 bundle 中完全不存在内部功能的代码。
第二个维度是 process.env.USER_TYPE,它通过 --define 在构建时被替换为字符串常量:
1 | // tools.ts:16-19 |
这两层 Flag 各有分工:feature() 用于功能级门控(覆盖数十个功能区域),USER_TYPE 用于身份级门控。它们共同构成了一个编译期的”功能矩阵”。
必须遵守的约束
这个模式有一个容易被忽略的关键约束——不能使用顶层静态 import:
1 | // ❌ 错误:静态 import 会被模块系统无条件加载,DCE 无法生效 |
同时,为了保留 TypeScript 类型安全,使用 as typeof import(...) 模式:
1 | // tools.ts:63-65 |
另一个约束来自 QueryConfig(query/config.ts:12-14)——它刻意排除 feature() gate,只包含运行时 gate:
1 | // query/config.ts:8-14 |
如果把 feature() 的值抽取到配置对象中,bundler 就无法在 call site 进行常量折叠,DCE 失效。
迁移要点
- 适用场景:需要从同一代码库构建多个产品变体(SaaS 多租户、内部/外部版、平台差异化)
- 前提条件:使用支持编译期常量替换的 bundler(Bun、esbuild
--define、webpackDefinePlugin) - 核心原则:Flag 必须在 call site 内联,不能提升为变量;搭配
require()而非import
模式 2:极简 Store — 35 行代码桥接 React 与非 React
问题
在混合架构的应用中(UI 层用 React,核心逻辑不依赖 React),状态管理是一个典型痛点。用 Redux/Zustand 太重,用 React Context 又把非 React 代码绑死在框架上。
Claude Code 的解法
整个状态管理的核心只有 35 行代码(state/store.ts):
1 | // state/store.ts(完整代码) |
这个 Store 的精妙之处在于:
- 零依赖:不依赖 React 或任何框架,纯 TypeScript
Object.is相等性检查:与 React 的行为完全一致,避免无效渲染onChange回调:集中式副作用处理(权限同步、模型持久化、缓存清理等)subscribe返回取消函数:与useSyncExternalStore的接口契约完全匹配
桥接到 React 只需要一行 useSyncExternalStore:
1 | // state/AppState.tsx:27,57 |
React 组件通过 Context 拿到 Store 实例,用 useSyncExternalStore 订阅。非 React 代码(如 query.ts、工具执行逻辑)直接调用 store.getState() / store.setState()。两个世界共享同一个状态源,但互不耦合。
迁移要点
- 适用场景:React + 非 React 混合架构(CLI、Electron、SSR)、需要在纯逻辑层读写 UI 状态
- 核心技巧:Store 接口与
useSyncExternalStore天然兼容——getState+subscribe就是 React 18 要求的外部 Store 协议 - 扩展方向:通过
onChange回调实现中间件模式(日志、持久化、同步)
模式 3:工具注册表 — 单一来源 + 三层条件注册
问题
当你的系统需要管理 40+ 个可插拔的功能模块(工具、插件、处理器),如何确保注册逻辑集中可控,同时支持编译期、加载期、运行时三个层面的条件过滤?
Claude Code 的解法
工具注册采用”单一来源 + 三层漏斗“模式。所有工具在 tools.ts 的 getAllBaseTools() 中注册——这是唯一的注册入口:
1 | // tools.ts:193-251 |
三层漏斗的成本递增:
| 层级 | 时机 | 成本 | 示例 |
|---|---|---|---|
| 编译期 DCE | 构建时 | 零(代码不存在于 bundle) | feature('PROACTIVE') |
| 模块加载期 | 进程启动 | 极低(环境变量读取) | process.env.USER_TYPE === 'ant' |
| 运行时 | 每次调用 | 低(函数调用) | tool.isEnabled() |
在 getAllBaseTools() 之上,getTools() 还叠加了一层 deny 规则过滤 + isEnabled() 运行时检查:
1 | // tools.ts:271-327 |
最终,assembleToolPool() 将内置工具与 MCP 工具合并,按名称排序保证 Prompt Cache 稳定性:
1 | // tools.ts:345-367 |
Builder 模式:安全默认值
每个工具通过 buildTool() 构建,它在并发、读写、破坏性标注这些维度上提供了保守的安全默认值:
1 | // Tool.ts:757-769 |
新增一个工具时,你只需定义必要的方法,其余由默认值兜底。注意这里的”保守”是分层的:isConcurrencySafe 默认 false 意味着新工具默认串行执行——并发需要显式 opt-in;但 checkPermissions 默认 allow,因为工具级别的权限判定只是外层 7 步管线(见模式 7)的一个环节,真正的安全兜底由管线的 deny 规则、safety check 和模式级变换提供。
迁移要点
- 适用场景:任何需要管理多个可插拔模块的系统(API 路由、中间件、事件处理器、AI 工具)
- 核心原则:一个
getAll*()函数作为唯一注册入口,所有过滤逻辑在此之上叠加 - 安全哲学:
buildTool()的TOOL_DEFAULTS展示了分层保守——在并发/读写/破坏性标注上默认保守(false),权限判定则交给外层管线兜底
模式 4:Prompt 分段缓存 — static/dynamic boundary
问题
在调用 LLM API 时,System Prompt 每次请求都会被发送。对于包含大量工具描述、行为指引的复杂 prompt,这意味着巨大的 token 成本。如何在保持 prompt 动态性的同时最大化缓存命中率?
Claude Code 的解法
System Prompt 的缓存设计实际上是三层结构,而非简单的二分。边界由 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记(constants/prompts.ts:105-115):
- 静态段(boundary 之前):跨用户、跨请求不变的内容(介绍、编码规范、工具使用指引等),可以设置
scope: 'global'做最高级别缓存 - Memoized 动态段(boundary 之后,
systemPromptSection()):包含用户/会话特定信息(环境信息、MCP 指令、CLAUDE.md 内容等),不能走 global cache,但在 session 内只计算一次,缓存直到/clear或/compact - Volatile 段(boundary 之后,
DANGEROUS_uncachedSystemPromptSection()):每轮 API 调用都重新计算的内容,每次变化都会破坏 prompt cache
关键洞察是:boundary 之后 ≠ 每轮都变。大多数动态 section 使用 systemPromptSection() 注册,session 内只计算一次;只有极少数使用 DANGEROUS_uncachedSystemPromptSection() 才会每轮重算。
实现的核心是这对注册 API(constants/systemPromptSections.ts):
1 | // constants/systemPromptSections.ts:20-38 |
函数命名本身就是设计文档:DANGEROUS_uncached 前缀让开发者在写代码时就意识到”我正在做一个会破坏缓存的决定“。_reason 参数虽然不被运行时使用,但强制开发者记录理由,这是一种代码级的 ADR(Architecture Decision Record)。
解析逻辑同样简洁:
1 | // constants/systemPromptSections.ts:43-58 |
延迟构造:lazySchema
同样的”延迟到需要时才付出成本”理念也体现在工具 schema 的构造上:
1 | // utils/lazySchema.ts(完整代码,仅 8 行) |
每个工具的 Zod schema 可能很复杂,但在应用启动时并不需要。lazySchema 将构造推迟到第一次访问时,既节省启动时间,又保证后续访问的零成本。
迁移要点
- 适用场景:任何涉及 LLM API 调用的应用、需要管理复杂配置模板的系统
- 核心技巧:将配置/prompt 分为三层(global cache / session-memoized / per-turn volatile),而非简单的 static/dynamic 二分;session 内稳定的内容虽然不能走 global cache,但仍可在 org scope 内缓存
- API 设计:用命名约定(
DANGEROUS_*)在代码层面标记高风险操作,比注释更持久
模式 5:多层配置合并 — 6 层 Settings 的优先级链
问题
企业级应用需要支持多个配置来源:用户个人偏好、项目级配置、CI 环境变量、企业安全策略……如何设计一个清晰、可预测、可调试的配置合并系统?
Claude Code 的解法
配置系统采用 5 + 1 层架构,优先级从低到高(utils/settings/constants.ts:7-22):
1 | // utils/settings/constants.ts:7-22 |
加上 Plugin 基底层(非 SettingSource,通过 getPluginSettingsBase() 注入),共 6 层。数组顺序即合并优先级:后覆盖前。
合并使用 lodash 的 mergeWith 配合自定义合并器——数组拼接去重,标量覆盖:
1 | // 合并语义: |
Policy Settings 内部还有 4 层子优先级(first-source-wins):remote API → MDM → managed-settings.json + drop-in 目录 → HKCU。Drop-in 目录模式(managed-settings.d/*.json)借鉴了 systemd/sudoers 的约定——不同团队可以各自投放策略片段,无需协调编辑同一个文件。
安全边界
配置系统的一个关键设计是信任边界。对于高风险操作(如环境变量注入),projectSettings 和 localSettings 都不被信任。源码中 TRUSTED_SETTING_SOURCES 仅包含三种来源(utils/managedEnv.ts:105-109):
1 | // utils/managedEnv.ts:94-109 |
原因是 projectSettings 和 localSettings 都位于项目目录内,可以被任何有仓库写权限的人修改。如果允许它们在信任对话框之前设置 ANTHROPIC_BASE_URL 等环境变量,就等于打开了 RCE(Remote Code Execution)攻击面。对于项目级来源,只有 SAFE_ENV_VARS 白名单中的变量才会被应用。
1 | env 注入信任度:policySettings = flagSettings = userSettings > projectSettings = localSettings(仅白名单) |
变更检测
配置变更通过三路检测:
- chokidar 文件监听:本地文件变更实时响应
- 30 分钟 MDM 轮询:企业策略定期刷新
internalWrites.ts时间戳 Map:过滤自身写入产生的回声事件
还有一个精妙的细节:删除-重建的 grace period(1700ms)。某些编辑器保存文件时会先删除再创建,如果不处理这个时间差,就会误判为”配置被删除”。
迁移要点
- 适用场景:任何需要多层配置的应用(VS Code 扩展、DevOps 工具链、企业 SaaS)
- 核心原则:配置源用有序数组定义优先级,一目了然;合并逻辑与业务逻辑分离
- 安全考量:项目目录内的配置来源(project / local)对高风险操作(env 注入等)不可信,只允许白名单内的安全变量
- 容错设计:使用
.catch(undefined)式的前向兼容——未知字段不报错,只忽略
模式 6:Agent 隔离 — Context Clone + Shared Infrastructure
问题
在多 Agent 系统中,子 Agent 需要与父 Agent 隔离状态(避免互相干扰),但又需要共享基础设施(避免僵尸进程、避免重复创建资源)。如何在这两个矛盾的需求之间找到平衡?
Claude Code 的解法
createSubagentContext() 函数(utils/forkedAgent.ts:345-462)实现了一个”默认全隔离 + 显式 opt-in 共享“的模式:
1 | // utils/forkedAgent.ts:307-344(文档注释) |
具体的隔离/共享决策:
1 | // utils/forkedAgent.ts:376-461(核心逻辑) |
这个设计有几个值得注意的细节:
为什么 contentReplacementState 默认克隆而非新建? 因为 cache-sharing fork 会处理父线程的消息(包含父线程的 tool_use_id)。如果用全新的 state,子 Agent 会对这些 ID 做出不同的替换决策,导致序列化字节不同,prompt cache 失效。克隆保证了决策一致性。
为什么 localDenialTracking 在不共享 setAppState 时新建? 因为异步子 Agent 的 setAppState 是 no-op,denial 计数无法写入全局状态。没有本地跟踪,denial 熔断器永远不会触发,子 Agent 会无限重试被拒绝的操作。
迁移要点
- 适用场景:任何多 Agent/多线程/多租户系统需要状态隔离的场景
- 核心原则:默认隔离,共享需要显式声明——这与安全领域的”最小权限原则”一脉相承
- 基础设施穿透:进程/资源管理类的操作必须始终到达根级别,不能被隔离
- 类型驱动设计:
SubagentContextOverrides类型让每个共享选项都有文档注释,IDE 提示即文档
模式 7:安全防线 — Permission Rule Chain
问题
AI Agent 能执行任意代码(Bash 命令、文件编辑、网络请求),如何设计一个既不会被绕过、又不会让用户体验崩溃的权限系统?
Claude Code 的解法
权限判定采用多步决策管线(pipeline)。内层函数 hasPermissionsToUseToolInner()(utils/permissions/permissions.ts:1158-1318)定义了完整的决策流程,每一步都可以提前终止:
1 | 步骤 1a: 整工具 deny 规则 → 匹配则拒绝(最高优先级) |
这个管线的核心设计哲学有两层:
第一层:deny 优先。无论后续规则怎么配置,deny 规则永远第一个被检查(步骤 1a)。
第二层:bypass 不是万能的。步骤 1e-1g 定义了三类 bypass-immune 的 ask 决策——需要用户交互的工具、用户显式配置的内容级 ask 规则、以及敏感路径安全检查。这些 ask 即使在 bypassPermissions 模式下也必须弹出确认。这是一个容易被忽略但至关重要的安全边界:bypass 只跳过”没有明确规则命中”的默认 ask,不能覆盖显式的安全约束。
外层 hasPermissionsToUseTool()(utils/permissions/permissions.ts:503-955)根据当前权限模式对管线结果进行模式级变换:
1 | dontAsk 模式 → passthrough/ask 变为 deny(不能问用户就直接拒绝) |
熔断器:Denial Tracking
为了防止 Agent 在被拒绝后无限重试,系统实现了一个熔断器:
1 | // 连续 3 次拒绝 或 总计 20 次拒绝 → 回退到用户确认(CLI)或 abort(headless) |
这个机制在子 Agent 隔离上下文中尤为重要——正如模式 6 所述,异步子 Agent 需要 localDenialTracking 才能让熔断器正常工作。
多源规则系统
权限规则来自 8 种来源:5 种 Settings 来源 + cliArg + command + session。遍历顺序定义在 PERMISSION_RULE_SOURCES 中:
1 | // utils/permissions/permissions.ts:109-114 |
注意:这个数组的顺序是遍历顺序(搜索遍历),而非严格的优先级语义。在决策管线中,deny 和 allow 规则分开处理,deny 优先于 allow,同一行为内的规则按来源遍历匹配。
迁移要点
- 适用场景:任何需要细粒度权限控制的系统(API 网关、CI/CD pipeline、自动化工具)
- 核心原则:deny 优先 + bypass-immune 层——安全策略永远不会被”更宽松”的模式覆盖,显式的安全约束(用户配置的 ask 规则、敏感路径检查)即使在最宽松的 bypass 模式下也必须被尊重
- 模式分层:内层管线(规则匹配)与外层变换(模式适配)分离,同一套规则在不同模式下行为不同
- 熔断保护:对自动化系统尤为重要——防止 Agent 在死循环中消耗资源
7 个模式的全景关系
这 7 个模式并非孤立存在,它们在 Claude Code 中形成了一个协作网络:
1 | graph TD |
- 编译期 DCE(模式 1)决定了哪些工具代码存在于 bundle 中,直接影响工具注册表(模式 3)
- 工具注册表的排序策略直接服务于 Prompt 分段缓存(模式 4)的稳定性
- 多层配置(模式 5)驱动权限规则,权限规则通过安全防线(模式 7)控制工具执行
- 极简 Store(模式 2)在 Agent 隔离(模式 6)中被克隆/穿透,隔离上下文中的 denial tracking 又反馈给安全防线
写在最后
在这 25 篇文章中,我们从一个约 1900 个文件的真实 AI 产品中,看到了工程决策背后的权衡逻辑。Claude Code 的源码展示了一个核心理念:
好的架构不是追求”正确”的抽象,而是在矛盾的约束之间找到务实的平衡点。
- 编译期 DCE 在”代码可维护性”与”bundle 体积”之间平衡
- 极简 Store 在”框架无关性”与”React 集成便利性”之间平衡
- Agent 隔离在”状态安全”与”基础设施共享”之间平衡
- 安全防线在”用户体验流畅度”与”操作安全性”之间平衡
这些模式的价值不在于原创性——每一个单独拿出来都不算新奇。它们的价值在于在同一个生产系统中被验证了可以协同工作,并且在 1900 个文件、数百万次 API 调用的规模下证明了自己的可靠性。
希望这 7 个模式能成为你下一个项目的设计工具箱的一部分。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)