Claude Code 的工具架构

@spandan_madan
英语4周前 · 2026年6月17日
552K
416
115
86
479

TL;DR

本次技术深度解析探讨了 Claude Code 的工具架构,详细介绍了其七阶段调度流水线、并发调度器以及支持复杂 AI Agent 操作的故障关闭(fail-closed)权限系统。

这是对 Claude Code 如何发现、调度和执行工具的深入剖析

引言

上一篇文章中,我们通过 Anthropic Harness 泄露的源代码逆向分析了其记忆系统。事实证明记忆系统出奇地简单——无非是 Markdown 文件、frontmatter 和提示工程。而工具系统则完全相反。它是整个代码库中工程化最深的子系统:43+ 个工具、一个流式执行管道、一个分层的权限系统、一个钩子框架、一个并发调度器——所有这些组合在一起,将一个无状态的语言模型变成了能够读取文件、运行 Shell 命令、搜索网络和生成子 Agent 的东西。

本文将以一个工具的生命周期为线索,从工具如何定义,到模型的工具调用如何分发,再到结果如何流回对话,进行全面的讲解。总体来说,该系统由四个层次组成:所有工具都实现的工具接口、组装工具池的注册中心、验证、权限检查和执行每个调用的分发管道,以及决定哪些任务并行运行的并发调度器

架构一览

Spandan Madan - inline image

工具接口

Claude Code 中的每个工具都实现相同的接口,定义在 Tool.ts 中。该类型是泛型的,有三个参数:Input(Zod 模式)、Output(结果类型)和 P(进度数据)。实际上,一个工具是一个包含约 30 个方法的对象,但只有少数几个对于理解系统至关重要。

核心结构如下:

text
1type Tool<Input, Output, P> = {
2 name: string
3 inputSchema: ZodType // 用于输入验证的 Zod 模式
4 call(input, context, canUseTool,
5 parentMessage, onProgress): Promise<ToolResult>
6
7 // 行为声明
8 isConcurrencySafe(input): boolean // 能否并行执行?
9 isReadOnly(input): boolean // 是否只读操作?
10 isDestructive(input): boolean // 是否破坏性操作?
11
12 // 权限与验证
13 checkPermissions(input, context): Promise<PermissionResult>
14 validateInput(input, context): Promise<ValidationResult>
15
16 // API 集成
17 description(input, options): Promise<string>
18 prompt(options): Promise<string> // 该工具的系统提示文本
19 mapToolResultToToolResultBlockParam(result, toolUseId): ToolResultBlockParam
20
21 // UI 渲染(React)
22 renderToolUseMessage(input, options): ReactNode
23 renderToolResultMessage(content, ...): ReactNode
24}

没有工具从头开始实现这个接口。一个工厂函数 buildTool() 会填充安全的默认值:

Spandan Madan - inline image

默认值故意设计得比较保守。如果一个工具作者忘记声明并发安全性,就会获得串行执行。如果一个工具作者忘记实现权限检查,就会使用默认的权限流程。系统会以安全的方式失败。

ToolResult 类型值得注意:

text
1type ToolResult<T> = {
2 data: T // 实际输出
3 newMessages?: Message[] // 可选的后续消息
4 contextModifier?: (ctx) => ToolUseContext // 为下一个工具修改上下文
5 mcpMeta?: { ... } // MCP 协议元数据
6}

contextModifier 很重要——它允许一个工具改变后续工具在同一回合中的执行上下文。这就是 EnterWorktree 等工具改变后续所有操作的工作目录的方式。关键的是,上下文修改器只允许用于非并发安全的工具。如果某个工具是并行运行的,它就不能修改共享状态。

工具注册中心

所有工具都在一个函数 getAllBaseTools() 中注册,该函数位于 tools.ts 中。它返回一个扁平数组。有些工具始终存在;其他工具则受限于功能标志、环境变量或平台检查。

始终可用的工具(16 个)

Spandan Madan - inline image

受功能标志控制的工具(约 27 个)

其余工具则根据条件包含。有些受环境变量限制(USER_TYPE=ant 用于 Anthropic 内部工具,如 configtungsten)。有些受功能标志限制(通过 Statsig 的 web_browsersleepmonitor)。有些是平台特定的(Windows 上的 powershell)。有些受组合条件限制——repl 工具需要同时满足 USER_TYPE=ant 和 REPL 功能标志。

受功能标志控制的工具完整列表

仅限 Ant:config、tungsten、suggest_background_pr、repl(同时需要 REPL 标志)

功能标志:web_browser、web_search、sleep、monitor、overflow_test、ctx_inspect、terminal_capture、list_peers、workflow、snip

Agent 触发器:cron_create、cron_delete、cron_list、remote_trigger

Kairos(主动 Agent):sleep、send_user_file、push_notification、subscribe_pr

多 Agent 集群:team_create、team_delete、send_message

Todo v2:task_create、task_get、task_update、task_list

环境:lsp(ENABLE_LSP_TOOL)、enter_worktree / exit_worktree(worktree 模式)、powershell(Windows)

工具发现:tool_search(当工具池较大时)

仅用于测试:testing_permission(NODE_ENV=test)

MCP 工具

除了内置工具之外,Claude Code 还支持 Model Context Protocol(MCP) 服务器——通过标准化协议暴露自己工具的外部进程。MCP 工具在运行时从连接的服务器动态注册,并包装在相同的 Tool 接口中。从分发管道的角度来看,MCP 工具与内置工具无法区分。

每个 MCP 工具都携带有关其来源服务器的元数据(mcpInfo: { serverName, toolName }),用于权限规则、错误处理和身份验证。当 MCP 工具因身份验证错误而失败时,系统会自动将服务器的状态更新为 needs-auth,并向用户展示该问题。

工具池组装

三个函数组装最终的工具集合:

  1. getAllBaseTools() —— 返回 43+ 个内置工具的原始列表,并应用功能门控
  2. getTools(permissionContext) —— 根据拒绝规则和 isEnabled() 进行过滤
  3. assembleToolPool(permissionContext, mcpTools) —— 合并内置工具和 MCP 工具

assembleToolPool() 中的合并策略是经过深思熟虑的:

内置工具排在前面,因此当名称冲突时,内置工具获胜。在每个分区内按字母顺序排序,使顺序在会话之间保持稳定,这对提示缓存很重要——工具数组是 API 请求的一部分,重新排序会破坏缓存。

assembleToolPool() 中的合并策略是经过深思熟虑的:

text
1// 对每个分区按字母顺序排序,合并,去重
2const byName = (a, b) => a.name.localeCompare(b.name)
3return uniqBy(
4 [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
5 'name',
6)

API 序列化

在工具到达 Claude API 之前,toolToAPISchema() 会将每个工具的 Zod 模式转换为 Anthropic API 的 JSON Schema 格式。

分发管道

当 Claude 响应时,其消息可能包含 tool_use 块——调用工具的结构化请求。分发管道通过七个阶段处理这些块。每个工具调用都会按顺序经过所有阶段。

阶段 1:提取

在主查询循环(query.ts)中,从助手消息中过滤出 tool_use 块:

text
1const msgToolUseBlocks = message.message.content.filter(
2 content => content.type === 'tool_use',
3) as ToolUseBlock[]

每个块都有一个 name、一个 input 对象和一个唯一的 id。id 至关重要——工具结果在发送回 API 时必须引用相同的 id,否则对话会中断。

阶段 2:输入验证

工具的 Zod 模式使用 safeParse() 验证原始输入——这是一个非抛出变体,要么返回有效数据,要么返回结构化的错误。如果验证失败,模型会收到一个带有模式提示的格式化错误消息,并且该工具的执行会停止。无效输入不会运行任何代码。

text
1const parsedInput = tool.inputSchema.safeParse(input)
2if (!parsedInput.success) {
3 let errorContent = formatZodValidationError(tool.name, parsedInput.error)
4 // 向模型返回错误,跳过执行
5}

在 Zod 验证之后,一些工具会运行第二次 validateInput() 检查,用于无法用模式表达的语义验证——例如,验证文件路径是绝对路径而不是相对路径。

阶段 3:工具前钩子

在权限检查之前,用户配置的钩子会执行。这些是在工具调用时触发的外部 Shell 命令或脚本。一个前工具钩子可以:

  • 允许工具调用,绕过交互式权限提示
  • 直接拒绝工具调用
  • 在执行前修改输入
  • 用错误消息阻止执行
  • 向用户提供额外的上下文

一个关键的不变量:钩子的 允许 不会绕过设置中的拒绝规则。源代码中有一个明确的注释关于这一点:“钩子‘允许’不能绕过 settings.json 中的拒绝/询问规则。”其意图是钩子可以打开门,但不能覆盖锁。

阶段 4:权限检查

权限系统是管道中最复杂的部分。它按顺序通过多个层次进行解析:

  1. 拒绝规则 —— 首先检查。如果任何拒绝规则匹配,执行立即停止。拒绝规则是最终的,不能被任何其他层覆盖。
  2. 询问规则 —— 如果匹配,则提示用户批准(除非 Bash 的沙箱自动允许适用)。
  3. 工具特定权限 —— 工具自己的 checkPermissions() 方法运行。例如,BashTool 会解析命令以检查子命令级别的规则。
  4. 安全检查 —— 对敏感路径(.git/.claude/、Shell 配置文件)的硬编码保护。这些是绕过免疫的——即使在完全绕过模式下,也需要交互式批准。
  5. 权限模式 —— 用户配置的模式决定了默认行为。
  6. 允许规则 —— 最后检查。如果允许规则匹配且没有触发拒绝/询问规则,则工具继续执行。

权限模式

default —— 始终提示用户“询问”决定。

acceptEdits —— 自动允许安全的文件操作(读取、编辑),提示其他所有操作。

bypassPermissions —— 自动允许除拒绝规则和安全检查之外的所有操作。

plan —— 先批准一个计划,然后按照之前的模式执行。

auto —— 使用 AI 分类器来决定是允许还是提示。

dontAsk —— 将所有“询问”决定转换为“拒绝”——从不提示,直接拒绝。

权限规则来自多个来源,按优先级顺序解析:policySettingslocalSettingsprojectSettingsuserSettingsflagSettingscliArgcommandsession。这使得组织策略能够覆盖用户偏好,CLI 参数能够覆盖两者。

阶段 5:执行

如果权限被授予,则调用工具的 call() 方法:

text
1const result = await tool.call(
2 callInput,
3 { ...toolUseContext, toolUseId: toolUseID },
4 canUseTool,
5 assistantMessage,
6 progress => onToolProgress({ toolUseID: progress.toolUseID, data: progress.data })
7)

五个参数: 验证后的输入、执行上下文(工作目录、中止控制器、应用状态)、权限回调(用于工具在中间需要请求额外权限)、父助手消息以及用于实时更新的进度回调。执行时长会全局跟踪。

一个细微的细节: 传递给 call() 的输入是模型的原始输入,而不是钩子和权限所看到的那种回填版本。这保持了对话转录的一致性——记录在对话中的工具调用完全匹配模型生成的内容。

阶段 6:工具后钩子

执行后,后工具钩子会触发。它们可以修改 MCP 工具输出、提供额外的上下文,或阻止对话继续。还有一个单独的 PostToolUseFailure 钩子仅在错误时触发,使外部系统有机会记录失败或建议补救措施。

阶段 7:结果映射

每个工具实现 mapToolResultToToolResultBlockParam() 方法,将其输出转换为 Anthropic API 的 ToolResultBlockParam 格式——一个 tool_result 块,包含 tool_use_id 引用和字符串或结构化内容。

如果结果超过大小阈值,它会被持久化到磁盘上的 sessionDir/tool-results/{toolUseId}.txt,并且一个带有文件引用的预览会被发送到 API。这可以防止大量输出(比如一个 10,000 行的文件读取、一个冗长的命令输出)使对话上下文膨胀。

并发调度器

当模型在单条消息中发出多个工具调用时,它们不会同时全部执行。调度器会根据并发安全性将它们分区成批次。

算法很简单。按顺序遍历工具调用。对于每一个,检查 isConcurrencySafe(input)。如果是安全的并且前一个批次也是安全的,则将其添加到批次中。否则,开始一个新批次。

text
1// 从 toolOrchestration.ts 简化而来
2for (const toolUse of toolUseMessages) {
3 const isSafe = tool.isConcurrencySafe(parsedInput)
4 if (isSafe && lastBatch.isConcurrencySafe) {
5 lastBatch.blocks.push(toolUse) // 合并到并发批次中
6 } else {
7 batches.push({ isConcurrencySafe: isSafe, blocks: [toolUse] })
8 }
9}

安全批次并行运行(上限为 10,可通过 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 配置)。不安全批次串行运行,一次一个工具。上下文修改器仅在批次之间应用,而不是在批次内部。

在实践中,这意味着像“读取这 5 个文件”这样的消息会产生一个并发批次,而“读取这个文件,然后编辑它”会产生两个串行批次。模型甚至可以在同一回合中触发两种模式——连续的只读调用会被批处理,而在第一个写入调用时批次就会中断。

流式执行器

还有第二条执行路径:StreamingToolExecutor。当启用流式传输时,工具会在模型仍在生成响应时就开始执行。当流中的每个 tool_use 块完成时,它会被立即排队执行,而不是等待完整的响应。

流式执行器使用相同的并发规则,但添加了一个行为:Bash 错误级联。如果 Bash 命令失败而兄弟工具正在并行运行,执行器会中止所有兄弟工具。其理由是,失败的 Bash 命令很可能使其他工具正在操作的上下文失效——继续执行它们会浪费时间,并可能导致令人困惑的错误。

text
1if (isErrorResult && tool.block.name === BASH_TOOL_NAME) {
2 this.hasErrored = true
3 this.siblingAbortController.abort('sibling_error')
4}

实际示例

为了具体说明,让我们追踪当模型决定读取文件时会发生什么。模型发出:

text
1{
2 "type": "tool_use",
3 "id": "toolu_01XYZ",
4 "name": "read",
5 "input": { "file_path": "/src/index.ts" }
6}

提取query.ts 从助手消息内容中过滤出这个块。

  1. 工具查找findToolByName(tools, "read") 找到 FileReadTool。
  2. 输入验证:Zod 将 { file_path: "/src/index.ts" }z.object({ file_path: z.string(), offset: z.number().optional(), limit: z.number().optional(), pages: z.string().optional() }) 进行解析。通过。
  3. 前工具钩子:任何用户配置的钩子触发。没有修改输入。
  4. 权限检查:FileReadTool 的 checkPermissions() 调用 checkReadPermissionForTool()。读取工具在大多数权限模式下通常是允许的。
  5. 执行FileReadTool.call() 读取文件,应用行号(cat -n 格式),特殊处理 PDF/图像/笔记本。
  6. 结果映射:文件内容成为一个 tool_result 块,引用 "toolu_01XYZ"
  7. 返回:结果被作为用户消息追加到对话中,并在下一次 API 调用中发送。

由于 FileReadTool 声明了 isConcurrencySafe: () => trueisReadOnly: () => true,如果模型在同一条消息中发出了五次读取调用,那么所有五次都会并行执行。

总结

工具系统是 Claude Code 的执行骨干。它将模型的意图——以结构化的 tool_use 块表示——转化为机器上的真实操作,每一步都包含验证、权限和并发控制。

该设计是分层的:一个保守的 buildTool() 工厂确保安全的默认值,一个受功能标志控制的注册中心控制可用的工具,一个七阶段的分发管道验证和权限检查每个调用,一个并发调度器在最大化并行性的同时保持正确性。流式执行器在此基础上增加了一项性能优化——工具在模型完成思考之前就开始运行。

与记忆系统(5 个路径、一个 Markdown 文件目录和提示工程)相比,工具系统是一个真正的运行时。这就像文件柜和操作系统之间的区别。

有趣之处

模型自身即是调度器

并发调度器是反应式的——它批处理模型发出的任何内容。但模型本身才是真正的调度器。系统提示告诉它“所有独立的工具调用都要并行执行”以及“使用单个 Bash 调用配合 && 来链接依赖的命令。”运行时信任这一点。如果模型发出了五次读取后跟一次写入,调度器会将读取并行化,将写入串行化。但顺序是由模型决定的。调度器在执行模型的计划,而不是制定自己的计划。

默认安全失败

最一致的设计原则:所有东西都默认安全失败。未知工具?报错。无效输入?报错。没有并发声明?串行执行。没有权限声明?询问用户。没有功能标志?工具不存在。对于一个主要用户可能是会幻觉工具名称或格式错误的输入的 AI 模型的系统来说,这很不寻常。系统旨在包容模型的错误,而不是迎合它们。

钩子作为扩展点

钩子系统——前工具、后工具和后失败——是主要的扩展点。它是组织如何执行策略(前钩子中的拒绝规则)、日志系统如何捕获工具使用(后钩子)以及 CI/CD 管道如何集成(失败钩子)的方式。重要的是,钩子只能加强限制,不能放松限制。一个钩子可以拒绝设置允许的工具,但不能允许设置拒绝的工具。

43 个工具,一个接口

也许最引人注目的是统一性。一个 bash 命令、一个 web_fetch、一个子 Agent 生成、一个 cron 作业创建、一个推送通知都实现了相同的 30 方法接口,都经过相同的七阶段管道,并遵循相同的权限系统。调度器中没有特例情况。复杂性在于单个工具的实现和权限规则中,而不是在路由中。

一键保存

使用 YouMind AI 深度阅读爆款文章

保存原文、追问细节、总结观点,并在一个 AI 工作空间里把爆款文章沉淀成可复用笔记。

了解 YouMind
写给创作者

把你的 Markdown 变成干净的 𝕏 文章

图片上传、表格、代码块,往 𝕏 上手动重排太痛苦。YouMind 把整篇 Markdown 一键转成干净、可直接发布的 𝕏 文章草稿。

试试 Markdown 转 𝕏

更多可拆解样本

近期爆款文章

探索更多爆款文章