做 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 | 更新内部状态 | 自动决策 |
| 子 agent | no-op(隔离) | 上报给父处理 |
| 测试 | mock | mock |
同一个 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,部分类型定义有简化。