Claude Code 架构设计深度解析

0 阅读13分钟

做 AI开发有一种特别的煎熬:技术演进比项目进度快。刚把LangChain 的用法摸透,LangGraph 出来了;agent方案刚在项目里落地,MCP 又带来了新的一层;多 agent协作的设计刚稳定下来,发现业界已经在讨论更复杂的swarm 模式。重构完了就过时。

Claude Code 源码泄漏这件事,我花了两天认真读完了——读之前以为是套了API 的 CLI 工具,读完之后改变了这个判断。 源码泄漏之后,大部分分析停在技术实现这一层:循环怎么写的、上下文怎么压缩的、BashTool 有多复杂、里面藏了什么彩蛋。这些都有意思,但还有一层更值得说——这套系统的架构哲学,在工具系统怎么设计、agent怎么协作、权限怎么治理这些问题上,走了一条和主流框架根本不同的路

说真的,不看这个直接开干也行,就是等工具加到三十个、多环境部署开始出问题的那天,你会想起来今天没有认真读这篇文章。


先说和主流框架的根本分歧

在看具体设计之前,先说一个更根本的问题:Claude Code 和主流 agent 框架,在设计哲学上到底哪里不一样。

添加图片注释,不超过 140 字(可选)

一句话总结:主流框架的做法是框架了解工具,Claude Code 的做法是工具向框架声明自己。 前者的问题是框架越来越胖,工具越多越难维护;后者的好处是框架保持薄,工具带着自己的知识走。

这不只是实现风格的差异,是控制流向的根本不同——这也是后面每一个设计点背后真正的逻辑。


一、工具是行为契约,不是函数

添加图片注释,不超过 140 字(可选)

大多数 agent 框架里,工具本质上是一个带描述的函数:

@tool
def read_file(path: str) -> str:
    """Read a file from disk"""
    ...

框架拿到的信息:名字、描述、参数 schema、返回值。关于这个工具的其他一切——能不能并发执行、会不会修改磁盘、结果多大算合理、出错了怎么显示——框架全都不知道。这些判断要么散落在框架代码里靠 hardcode 工具名特殊处理,要么干脆不处理。

Claude Code 的 src/Tool.ts 是整个源码里我读得最仔细的文件,将近 800 行。光是接口里的方法就三四十个:

type Tool = {
  // 并发语义:框架据此决定能否并行执行
  isConcurrencySafe(input): boolean

  // 安全语义
  isReadOnly(input): boolean
  isDestructive?(input): boolean

  // 中断语义:用户打断时,取消还是继续跑完
  interruptBehavior?(): 'cancel' | 'block'

  // 渲染契约:结果怎么显示,工具自己负责
  renderToolResultMessage?(content, progress, options): React.ReactNode

  // 大小契约:超过这个尺寸,结果自动持久化到磁盘
  maxResultSizeChars: number

  // 供安全分类器使用的摘要输入
  toAutoClassifierInput(input): unknown
}

工具通过 buildTool() 工厂构建,强制应用 fail-closed 默认值:

const TOOL_DEFAULTS = {
  isConcurrencySafe: () => false,  // 默认假定不能并发
  isReadOnly: () => false,          // 默认假定会写数据
  isDestructive: () => false,
}

这里有个细节值得注意:全部默认向安全方向失败。工具要想被并发执行,必须主动声明安全;工具要想跳过某些权限检查,必须主动声明只读。不是默认信任,是默认不信任。

这么设计的结果是:query loop 不需要了解任何具体工具,就能做出正确的并发决策、权限决策、中断决策。新增一个工具,框架代码一行不改。工具告诉框架它是什么,框架不需要知道它做什么。

这是控制反转在工具系统上的真正落地。常见的情况是工具越加越多,框架里的特殊判断也越来越多,最后变成一个知晓一切的上帝对象。这套设计里这件事不会发生——增加工具的知识不会流向框架,只会留在工具自己身上。

演进方向:随着 MCP 工具生态扩张,接入的第三方工具会越来越多。谁来承担”了解工具”的责任是个真问题。框架承担注定走向上帝对象,工具自己声明是唯一可持续的路。这不是某一个系统的选择,迟早是整个生态绕不过去的问题。


二、ToolUseContext:Context 的形状决定运行时环境

添加图片注释,不超过 140 字(可选)

所有工具执行时只接收一个参数 ToolUseContext,里面有工具需要的一切:

type ToolUseContext = {
  getAppState(): AppState
  setAppState(f: prev => newState): void
  readFileState: FileStateCache
  abortController: AbortController
  messages: Message[]
  agentId?: AgentId    // 有值说明当前在子 agent 里
  // ...
}

这本身不稀奇,依赖注入很常见。有意思的是子 agent 怎么处理:

const subContext = createSubagentContext({
  setAppState: () => {},             // no-op:子 agent 不能直接改父状态
  setAppStateForTasks: parentSetFn,  // 基础设施注册是例外,可以穿透
  readFileState: cloneFileStateCache(...),
})

子 agent 拿到的 Context,setAppState 是个空函数。工具代码本身没有任何变化,但在子 agent 里执行时,所有状态更新都被静默丢弃。Context 的形状决定了行为:

运行环境setAppState权限处理
主 REPL真实更新 UI弹窗等待用户
SDK/headless更新内部状态自动决策
子 agentno-op(隔离)上报给父处理
测试mockmock

同一个 BashTool.call() 在四种环境下都能正确运行,不需要任何条件判断。能做到这一点,原因是状态更新从一开始就没有用全局变量,而是通过 Context 传入。这个决策要在设计早期做,等全局状态遍布各处再想改就难了。

演进方向:agent 系统从单一 CLI 扩展到 Web、Mobile、SDK 多端是迟早的事。同一套工具在不同运行时环境下的行为一致性,到时候会成为标配需求。这套 Context 注入的模式,现在看着是工程细节,后面会是多端一致性的基础。


三、权限系统与能力系统正交

添加图片注释,不超过 140 字(可选)

agent 框架的权限管理,常见的退化形式是两种:一是权限逻辑散落在每个工具里,二是框架里一个巨大的 switch-case。

Claude Code 把权限做成了完全独立的正交维度。

规则是多层叠加的:

1:全局模式
  default | plan | acceptEdits | bypassPermissions | dontAsk | auto

层2:规则来源(有明确优先级)
  policySettings > cliArg > userSettings > projectSettings > session

层3:per-tool per-pattern 规则
  alwaysAllow("Bash(git *)")
  alwaysDeny("Bash(rm -rf *)")
  alwaysAsk("Write(*)")

每个工具实现自己的模式匹配逻辑:

preparePermissionMatcher(input: { command: string }) {
  return async (pattern: string) => matchesGlob(input.command, pattern)
}

框架用规则库里的 pattern 调用这个方法做匹配,不需要知道 BashTool 的命令语法。权限系统不了解工具,工具也不了解权限系统,两者通过这个接口松散地耦合在一起。

结果是:加一个新工具不需要动权限代码,调整权限策略不需要动工具代码。同一个工具在不同团队、不同项目下有完全不同的权限配置,底层代码完全复用。这在企业部署场景下比较实际——不同业务线对 Bash 工具的授权范围差距可能很大,但没人想为此维护多个工具版本。

演进方向:agent 从个人工具变成企业基础设施,权限治理的复杂度会成倍增长——多租户、审计日志、合规策略。能在早期把权限和能力分开的团队,比到时候再拆要省很多麻烦。这个正交性不只是设计上好看,是有实际预见性的。


四、Async Generator 贯穿整个调用链

添加图片注释,不超过 140 字(可选)

这个设计不如前面几个显眼,但工程上很精妙。

整个调用链是嵌套的 async generator:

QueryEngine.submitMessage()   →  AsyncGenerator<SDKMessage>
  yield* query()              →  AsyncGenerator<Message | StreamEvent>
    yield* queryLoop()        →  AsyncGenerator<...>
      claude API streaming    →  AsyncGenerator<StreamEvent>
      yield* runTools()       →  AsyncGenerator<ToolResult>

同一个生产链,三种消费方式:

// REPL:实时渲染每条消息
for await (const msg of query(params)) {
  renderToTerminal(msg)
}

// SDK:转换格式后向外暴露
for await (const msg of engine.submitMessage(prompt)) {
  yield normalizeForExternal(msg)
}

// Headless:只取最终结果
for await (const msg of query(params)) {
  if (msg.type === 'result') return msg.text
}

用 Event Emitter 做这件事,多消费者需要显式广播,错误需要在每层 catch 之后再 emit,消费顺序也容易出问题。Generator 的消费是线性的,背压天然存在——消费者不取,生产者不推进。API 调用、工具执行、渲染的步调自动对齐,不需要额外的流量控制代码。


五、递归 Agent:子 Agent 就是同一个 query()

多 agent 框架通常把”主 agent”和”子 agent”设计成两套东西——不同的类、不同的执行路径、不同的工具集,带来大量重复代码和不一致的行为边界。

Claude Code 里子 agent 的核心执行代码:

async function* runAgent(params) {
  const subContext = createSubagentContext(...)

  yield* query({          // 和主循环完全相同的函数
    messages: agentMessages,
    systemPrompt: agentSystemPrompt,
    toolUseContext: subContext,
  })
}

AgentTool 是一个工具,它的 call() 调用 query(),而 query() 可以再次触发 AgentTool,形成任意深度的递归。深度通过 queryTracking.depth 追踪防止无限嵌套。

直接的好处:给主 agent 加任何新能力,子 agent 自动获得,不需要单独维护”子 agent 版本”。更重要的是行为一致性——子 agent 的 token 预算管理、错误恢复、流式传输全走同一段代码,不会出现主 agent 能处理某种边界情况而子 agent 不能的问题。

这有点像 Unix fork——子进程不是一个功能受限的”子进程模式”,就是进程本身。

演进方向:多 agent 系统的复杂度不应该靠堆协调协议来解决。递归组合是一条更干净的路——不是用不同类型的 agent 协作,而是用单一执行原语在不同上下文里递归。复杂度不是加出来的,是靠正确的抽象消掉的。这代表了一种值得关注的架构方向。


六、多 Agent 后端:接口先行,实现可替换

添加图片注释,不超过 140 字(可选)

Claude Code 的多 agent 协作支持三种执行后端:in-process(同进程,最快)、tmux(独立窗格,真正的进程隔离)、iTerm2(原生分割窗格)。三种方式底层机制差异很大,但对上层代码透明。

做法是先定义接口:

type TeammateExecutor = {
  spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult>
  sendMessage(agentId: string, message: TeammateMessage): Promise<void>
  terminate(agentId: string, reason?: string): Promise<boolean>
  kill(agentId: string): Promise<boolean>
}

运行时根据当前环境探测选择后端:在 tmux 里就用 TmuxBackend,在 iTerm2 里就用 ITermBackend,都不满足就 fallback 到 InProcessBackend。创建子 agent 的业务代码不知道也不需要知道底层是哪种。

大多数 agent 框架里执行机制是预设的,LangGraph 的 agent 就跑在 Python 进程里,不可替换。等到需要支持多执行环境时,往往发现执行逻辑和业务逻辑已经耦合得无法剥离。这种边界要在设计早期划,越晚越贵。

演进方向:随着 agent 从单机扩展到分布式、从本地扩展到云端,执行环境的多样性只会增加。这种后端抽象的模式,在 agent 系统里目前还不是惯例,但早晚会成为规范设计。提前把这层边界划清楚的系统,后面支持新的运行环境时会省非常多工作量。


七、编译时特性隔离

添加图片注释,不超过 140 字(可选)

Claude Code 同时有开源版本和内部版本,内部有大量未发布的功能。处理这个问题用的是 bun:bundle 的 feature() 函数,这是一个编译时宏,不是运行时判断:

import { feature } from 'bun:bundle'

const reactiveCompact = feature('REACTIVE_COMPACT')
  ? require('./services/compact/reactiveCompact.js')
  : null

// 连权限模式的枚举也受 feature 控制
export const INTERNAL_PERMISSION_MODES = [
  ...EXTERNAL_PERMISSION_MODES,
  ...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
]

feature() 在构建时被替换成 true 或 false,false 的分支被物理删除。不是”运行时不执行”,是从产物里彻底消失,连字符串字面量都不剩。

用运行时 feature flag 做不到这点——条件两侧的代码都会被打包进去,字符串还在,反编译就能看到。内部服务地址、未发布功能名称、实验性 API 端点,需要物理消失才算真正隔离。

这种模式在游戏引擎的多平台构建里很常见,开源包和内部包共享同一套源码,通过构建参数控制,不需要维护两个 fork。在 agent 框架里,这个级别的工程严谨性确实少见。


八、DeepImmutable 状态树

添加图片注释,不超过 140 字(可选)

应用状态用 DeepImmutable 包裹,TypeScript 层面禁止任何直接赋值,所有更新通过函数式 reducer:

type AppState = DeepImmutable<{
  settings: SettingsJson
  toolPermissionContext: ToolPermissionContext
  mainLoopModel: ModelSetting
  // ...
}>

setAppState(prev => ({
  ...prev,
  toolPermissionContext: { ...prev.toolPermissionContext, ... }
}))

这和第二节的 Context 设计直接相关。子 agent 的 setAppState 是 no-op 之所以在类型上合法,是因为状态更新的接口是 (prev => newState) => void——这个函数签名接受任何实现,包括什么都不做的实现。如果状态是可直接赋值的引用,隔离就变成了引用管理问题,复杂度完全不同。

不可变状态还让会话恢复(--resume)和推测执行(speculation)的实现简单得多——任意时刻的 AppState 就是完整快照,不需要额外的快照机制。


读完整套源码,最让我印象深刻的不是某个具体技术,而是这套设计里有一件事在反复发生:把关于工具的知识,从框架里挖出来,还给工具本身。

框架不知道哪些工具可以并发,工具自己声明。框架不知道怎么匹配权限规则,工具自己提供匹配器。框架不知道结果怎么显示,工具自己实现渲染。

这样做的结果是:60 多个工具在没有中心协调的情况下,行为一致,扩展成本低。工具数量少的时候感觉不出来,工具一多,框架里每多一个 if-else 就多一份维护负担。这套设计里没有这种 if-else——不是因为没有判断,是因为判断的责任在工具自己身上。

现在回头看市面上的 agent 框架,大多数还停留在”工具就是函数、框架做调度”的思路上。这没什么问题,系统规模小的时候工作得很好。问题出在规模变大之后——工具超过三十个、需要支持多种运行环境、权限需要精细治理的时候,设计早期没有做出的那些决策会开始还债,而且利滚利。

Claude Code 源码给的答案是:这些问题不是等到大了再解决,而是在设计最早期就把知识的归属问题想清楚。工具的知识留在工具身上,框架只做执行基础设施,能力和权限正交,执行环境可插拔。这不是某一个具体的技术选型,是一套一致的哲学,每一个设计点都在同一个方向上用力。

能不能直接搬到你的系统里,要看具体情况。但这个方向是对的——而且这条路,主流框架迟早也得走过来。


基于 claude-code-source-code-main v2.1.88,部分类型定义有简化。