← 返回全部文章

Claude Code 的工具系统是怎么跑起来的

Claude Code 能读文件、跑命令、调用 MCP、开子任务,靠的是一套工具接口、注册表、权限管线和并发调度。这里用小白能懂的方式拆开看。

来源链接: The Tool Architecture of Claude Code

Claude Code 看起来像一个会写代码的聊天工具,但它真正能干活,靠的不是聊天本身。

模型只会生成文本。它要读文件、改代码、跑 shell 、查网页、调用 MCP 、派子 Agent,必须通过一套工具系统。可以把它理解成一个运行时:模型提出“我要调用哪个工具、传什么参数”,运行时负责检查、授权、执行,再把结果塞回对话。

这套架构大致分四层:工具接口、工具注册表、调度执行管线、并发调度器。

先看整体:模型不直接碰电脑

Claude Code 的执行链路可以这样理解:

  1. 模型生成 tool_use 结构,说明要调用哪个工具。
  2. 系统根据工具名找到对应工具。
  3. 用 Zod schema 检查参数是否合法。
  4. 跑 hook 和权限规则。
  5. 通过后执行工具。
  6. 把工具结果转成 tool_result,回传给模型。

这里先把区别拆清楚:模型负责“决定做什么”,工具运行时负责“能不能做、怎么做、做完怎么回传”。

每个工具都长一个样

Claude Code 里的工具很多,但它们都实现同一套接口。文件读取、 Bash 、网页搜索、 MCP 工具、子 Agent,本质上都要遵守同一个形状。

核心接口大概是这样:

type Tool<Input, Output, P> = {
  name: string
  inputSchema: ZodType           // Zod schema for input validation
  call(input, context, canUseTool,
       parentMessage, onProgress): Promise<ToolResult>

  // Behavior declarations
  isConcurrencySafe(input): boolean   // Can run in parallel?
  isReadOnly(input): boolean          // Read-only operation?
  isDestructive(input): boolean       // Destructive action?

  // Permission and validation
  checkPermissions(input, context): Promise<PermissionResult>
  validateInput(input, context): Promise<ValidationResult>

  // API integration
  description(input, options): Promise<string>
  prompt(options): Promise<string>     // System prompt text for this tool
  mapToolResultToToolResultBlockParam(result, toolUseId): ToolResultBlockParam

  // UI rendering (React)
  renderToolUseMessage(input, options): ReactNode
  renderToolResultMessage(content, ...): ReactNode
}

这段代码里,最关键的是几组字段。

name 用来让模型指定工具。inputSchema 用来检查输入。call() 是真正执行工具的地方。isConcurrencySafe()isReadOnly()isDestructive() 会影响能不能并发、是否需要更严格的权限。checkPermissions() 决定这次调用是否放行。

工具不是每个都从零写一遍。系统用 buildTool() 给工具补默认行为。这个设计很保守:没有声明并发安全,就按不能并发处理;没有明确权限逻辑,就走默认权限流程。简单说,默认不冒险。

工具结果也有固定形状:

type ToolResult<T> = {
  data: T                    // The actual output
  newMessages?: Message[]    // Optional follow-up messages
  contextModifier?: (ctx) => ToolUseContext  // Mutate context for next tool
  mcpMeta?: { ... }          // MCP protocol metadata
}

这里的 contextModifier 很重要。它允许某些工具修改后续工具的执行上下文,比如切换工作目录。为了避免并发时互相踩状态,只有非并发安全工具才允许改共享上下文。

工具注册表:哪些能力能被模型看到

工具定义好之后,还要被放进工具池。 Claude Code 里有一个 getAllBaseTools(),会返回基础工具列表。有些工具一直可用,有些工具要看功能开关、环境变量和平台。

源材料里提到的常驻工具有 16 个,功能开关工具大约 27 个。

一些工具示例:

Ant-only: config, tungsten, suggest_background_pr, repl (also needs REPL flag)
Feature flags: web_browser, web_search, sleep, monitor, overflow_test, ctx_inspect, terminal_capture, list_peers, workflow, snip
Agent triggers: cron_create, cron_delete, cron_list, remote_trigger
Kairos (proactive agent): sleep, send_user_file, push_notification, subscribe_pr
Multi-agent swarms: team_create, team_delete, send_message
Todo v2: task_create, task_get, task_update, task_list
Environment: lsp (ENABLE_LSP_TOOL), enter_worktree / exit_worktree (worktree mode), powershell (Windows)
Tool discovery: tool_search (when tool pool is large)
Test-only: testing_permission (NODE_ENV=test)

这说明 Claude Code 的工具池不是固定死的。它会根据运行环境动态变化。 Windows 下可能有 PowerShell,打开某个 feature flag 后才有某些工具,连接 MCP 后又会多出外部工具。

MCP 工具为什么能接进来

MCP 的价值就在这里。外部 MCP server 可以暴露自己的工具,Claude Code 把这些工具包成同样的 Tool 接口。

运行时看来,MCP 工具和内置工具没有本质区别:都要注册、校验、过权限、执行、返回结果。 MCP 工具只是多带一层来源信息,比如:

mcpInfo: { serverName, toolName }

这层信息会用于权限规则、错误处理和认证。如果某个 MCP 工具因为认证失败调用不了,系统可以把 server 状态标记成 needs-auth,再提示用户处理。

工具池怎么合并

最终工具池由三步组成:

  1. getAllBaseTools():拿到带 feature gate 的内置工具。
  2. getTools(permissionContext):按 deny rules 和 isEnabled() 过滤。
  3. assembleToolPool(permissionContext, mcpTools):把内置工具和 MCP 工具合并。

合并时有一个细节:内置工具优先。如果 MCP 工具和内置工具重名,内置工具胜出。每一组内部按字母排序,最后去重。这样做还能保护 prompt cache,因为工具数组顺序也是 API 请求的一部分,顺序乱掉会影响缓存命中。

// Sort each partition alphabetically, concat, deduplicate
const byName = (a, b) => a.name.localeCompare(b.name)
return uniqBy(
  [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
  'name',
)

在发给 Claude API 之前,toolToAPISchema() 还会把工具的 Zod schema 转成 Anthropic API 使用的 JSON Schema 。模型看到的是结构化工具描述,不是 TypeScript 对象本身。

一次工具调用要过七道关

当 Claude 的回复里出现 tool_use block,Claude Code 会把它拿出来,走一条调度管线。每个工具调用都要按顺序经过这些阶段。

第一步是提取。主循环会从 assistant message 里筛出工具调用:

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

每个 block 有三件东西:工具名、输入对象、唯一 ID 。 ID 很关键,因为工具结果必须引用同一个 ID,否则对话结构就断了。

第二步是输入校验。 Zod 会检查模型传进来的参数是否符合 schema 。

const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  let errorContent = formatZodValidationError(tool.name, parsedInput.error)
  // Return error to model, skip execution
}

safeParse() 不会直接抛异常,而是返回成功或失败。失败时,系统会把格式化后的错误告诉模型,工具本身不会执行。这一点很重要:模型参数乱写,真实代码不会被触发。

有些工具还会跑第二层 validateInput()。 schema 只能检查类型,语义检查要靠工具自己做。比如文件路径必须是绝对路径,不能只靠 z.string() 判断。

第三步是 pre-tool hook 。 hook 是外部脚本或命令,可以在工具执行前介入。它可以允许、拒绝、修改输入、阻止执行,或者给用户补充上下文。

但 hook 有一个边界:hook 的 allow 不能绕过 settings.json 里的 deny / ask 规则。也就是说,hook 可以把门关得更严,不能把已经上锁的门打开。

第四步是权限检查。权限系统会按顺序看这些层:

  1. Deny rules:先检查,命中就立即停止。
  2. Ask rules:命中就请求用户确认。
  3. Tool-specific permissions:工具自己的权限逻辑,比如 Bash 会按子命令检查。
  4. Safety checks:保护 .git/.claude/、 shell 配置等敏感路径。
  5. Permission mode:看用户当前模式怎么设。
  6. Allow rules:最后才看 allow 。

权限模式大概可以这样理解:

default:遇到 ask 就提示用户。
acceptEdits:安全文件操作自动允许,其他仍然提示。
bypassPermissions:大部分都自动允许,但 deny rules 和安全检查仍然有效。
plan:先批准计划,再按之前的模式执行。
auto:用 AI 分类器决定放行还是提示。
dontAsk:所有 ask 都变成 deny,不提示,直接拒绝。

权限规则也有来源优先级:组织策略、本地设置、项目设置、用户设置、 flag 、 CLI 参数、命令、 session 。企业策略可以压过个人设置,CLI 参数又可以覆盖一部分上下文。

第五步才是执行。权限通过后,工具的 call() 被调用:

const result = await tool.call(
  callInput,
  { ...toolUseContext, toolUseId: toolUseID },
  canUseTool,
  assistantMessage,
  progress => onToolProgress({ toolUseID: progress.toolUseID, data: progress.data })
)

这里传了五类东西:校验后的输入、执行上下文、权限回调、父级 assistant message 、进度回调。进度回调让界面能实时显示工具执行到哪里。

一个很细的设计:传给 call() 的 input,仍然是模型原始输入,而不是 hook 和权限检查过程中补过的版本。这样可以保持 transcript 一致,记录下来的工具调用和模型实际生成的内容一致。

第六步是 post-tool hook 。工具执行后,外部系统还能修改 MCP 输出、补充上下文,或者阻止对话继续。失败时还有单独的 PostToolUseFailure hook,方便日志系统或 CI/CD 给出补救建议。

第七步是结果映射。每个工具都会把自己的结果转成 Anthropic API 能接收的 tool_result block,并带上刚才的 tool_use_id

如果结果太大,比如读了一个 10000 行文件,系统会把完整结果写到磁盘:

sessionDir/tool-results/{toolUseId}.txt

再把预览和文件引用发回 API,避免上下文被大输出撑爆。

并发调度:哪些能一起跑,哪些必须排队

模型一次可能发出多个工具调用。比如让它读 5 个文件,它可能同时生成 5 个 read call 。运行时不会简单全部并发,而是按工具声明的并发安全性分批。

简化逻辑如下:

// Simplified from toolOrchestration.ts
for (const toolUse of toolUseMessages) {
  const isSafe = tool.isConcurrencySafe(parsedInput)
  if (isSafe && lastBatch.isConcurrencySafe) {
    lastBatch.blocks.push(toolUse)    // Merge into concurrent batch
  } else {
    batches.push({ isConcurrencySafe: isSafe, blocks: [toolUse] })
  }
}

安全批次会并发执行,上限可以通过环境变量控制:

CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY

不安全批次串行执行,一次只跑一个。上下文修改也只会在批次之间应用,不会在同一个并发批次里乱改共享状态。

实际效果是:

读 5 个文件:可以并发。
先读一个文件,再编辑它:读和写要分成两个批次。
多个 read 后面接一个 write:read 批量跑,write 单独排队。

还有一条 streaming 执行路径。开启 streaming 时,模型还没完整生成完回复,工具调用一旦在流里完成,就可以立刻排队执行。这样能减少等待时间。

streaming executor 还加了 Bash 错误级联:如果一个 Bash 命令失败,而它旁边还有并行工具在跑,系统会中止这些兄弟任务。

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

这个设计很实用。 Bash 失败通常意味着环境状态已经不对,继续跑旁边任务可能只会制造更多噪音。

走一遍真实例子:读一个文件

假设模型想读 /src/index.ts,它会生成一个工具调用:

{
  "type": "tool_use",
  "id": "toolu_01XYZ",
  "name": "read",
  "input": { "file_path": "/src/index.ts" }
}

系统接下来会做这些事:

  1. 从 assistant message 里提取 tool_use
  2. findToolByName(tools, "read") 找到 FileReadTool 。
  3. 用 Zod 检查 { file_path: "/src/index.ts" }
  4. 执行 pre-tool hooks 。
  5. 跑 FileReadTool 的权限检查。
  6. 调用 FileReadTool.call() 读取文件,并按行号格式处理。
  7. 把文件内容映射成 tool_result,引用 toolu_01XYZ
  8. 把结果作为下一轮上下文发回模型。

因为 FileReadTool 通常是只读、并发安全的,如果同一条消息里有 5 个文件读取请求,它们可以一起跑。

这套设计有什么启发

第一,Claude Code 的工具系统是“失败默认关闭”。未知工具、非法输入、没声明并发安全、没权限声明,都会走更保守的路径。模型可能幻觉工具名,也可能传错参数,运行时的职责就是把这些错误挡住。

第二,模型其实也是调度器。运行时可以判断哪些调用能并发,但调用顺序还是模型决定的。系统提示会鼓励模型把独立工具调用并行发出,把依赖关系用单个 Bash 或顺序调用表达出来。运行时是在执行模型的计划,不是在替模型做全局规划。

第三,hook 是企业化扩展点。公司可以通过 pre-hook 做策略控制,通过 post-hook 做审计日志,通过 failure hook 接 CI/CD 或告警。 hook 能收紧权限,不能绕过 deny 规则,这个边界很关键。

第四,43+ 个工具共用一个接口。 Bash 、 web_fetch 、 MCP 、子 Agent 、 cron 、推送通知,看起来差别很大,但调度器眼里都是同一种对象。复杂度留在工具实现和权限规则里,路由层保持统一。

理解这套架构后,再看各种 Coding Agent,就不会只盯着“模型聪不聪明”。真正决定它能不能稳定干活的,是工具接口是否统一、权限是否保守、并发是否可控、错误结果能不能正确回到上下文里。

Claude Code 强的地方,不只是会写代码。它把一个 stateless 的语言模型,接到了一个带权限、工具、调度和状态回传的运行时上。

来源:https://x.com/spandan_madan/status/2067320100911493454