ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【Claude Code 源码解析教程】第5章:Tool 系统设计——从注册到调用的完整链路拆解

【Claude Code 源码解析教程】第5章:Tool 系统设计——从注册到调用的完整链路拆解 1. 从一次工具调用失败说起Claude Code Tool 系统到底怎么运转如果你正在读 Claude Code 的源码大概率会在src/Tool.ts这个文件前停下来——它定义了整个工具系统的契约但真正让工具从「注册」走到「被模型调用」的链路散落在toolOrchestration.ts、permissions.ts、validation.ts好几个模块里。我第一次顺着调用栈往下读的时候最困惑的不是接口本身而是一个工具从被模型「点名」到真正执行中间到底经过了哪些关卡为什么有的工具能并发跑有的必须排队为什么权限检查有时候在验证之前有时候又在之后这一章就围绕这条完整链路来拆。Claude Code 的 Tool 系统本质上是一个插件化架构所有工具实现同一个Tool接口核心引擎只依赖接口不依赖具体工具。这样设计的好处是工具可以热插拔、可以延迟加载、可以独立做权限和验证。但代价是链路变长理解成本上升。适合谁读如果你正在给 Claude Code 写自定义工具或者想在自己的 Agent 框架里复刻一套类似的工具系统这一章会给你可复制的目录结构、关键模块注释以及本地跑通注册与调用链路的操作步骤。我试过把整条链路拆成六个阶段注册 → Schema 定义 → 权限检查 → 输入验证 → 并发分区 → 执行与中断。下面逐层展开。先给一个全局的目录结构后面所有代码都基于这个结构claude-code/ ├── src/ │ ├── Tool.ts # Tool 接口定义 │ ├── types/ │ │ ├── permissions.ts # 权限结果类型 │ │ └── tool.ts # ToolResult / ToolUseContext │ ├── services/ │ │ └── tools/ │ │ ├── toolOrchestration.ts # 调用编排入口 │ │ ├── toolExecution.ts # 单个工具执行 │ │ └── concurrency.ts # 并发控制器 │ └── tools/ │ ├── FileReadTool/ │ │ └── FileReadTool.ts │ └── FileWriteTool/ │ └── FileWriteTool.ts这个结构不是官方仓库的逐字复制而是我按调用链路整理出的最小可运行骨架你可以直接照着建目录。核心思路是Tool.ts只放接口types/放类型services/tools/放编排逻辑tools/放具体工具实现。这样分层之后读源码时就不会在几百行的大文件里迷路。2. Tool 接口与 Schema 定义注册一个工具需要哪些字段2.1 核心接口的字段拆解Tool接口是整个系统的地基。它用泛型约束了输入、输出和进度数据类型所有工具都必须实现这套契约。我把关键字段按用途分成五组方便你对照记忆// src/Tool.ts export type Tool Input extends AnyObject AnyObject, Output unknown, P extends ToolProgressData ToolProgressData, { // 1. 核心执行 call( args: z.inferInput, context: ToolUseContext, canUseTool: CanUseToolFn, parentMessage: AssistantMessage, onProgress?: ToolCallProgressP, ): PromiseToolResultOutput // 2. 描述生成给模型看的 description( input: z.inferInput, options: { isNonInteractiveSession: boolean toolPermissionContext: ToolPermissionContext tools: Tools }, ): Promisestring // 3. Schema 定义 readonly inputSchema: Input readonly inputJSONSchema?: ToolInputJSONSchema outputSchema?: z.ZodTypeunknown // 4. 元数据 readonly name: string aliases?: string[] searchHint?: string maxResultSizeChars: number readonly strict?: boolean // 5. 行为标记 isConcurrencySafe(input: z.inferInput): boolean isReadOnly(input: z.inferInput): boolean isDestructive?(input: z.inferInput): boolean interruptBehavior?(): cancel | block // 6. 权限与验证 checkPermissions( input: z.inferInput, context: ToolUseContext, ): PromisePermissionResult validateInput?( input: z.inferInput, context: ToolUseContext, ): PromiseValidationResult // 7. 提示生成 prompt(options: { getToolPermissionContext: () PromiseToolPermissionContext tools: Tools agents: AgentDefinition[] allowedAgentTypes?: string[] }): Promisestring // 8. UI 展示 userFacingName(input: Partialz.inferInput | undefined): string getToolUseSummary?(input: Partialz.inferInput | undefined): string | null getActivityDescription?(input: Partialz.inferInput | undefined): string | null }这里有几个字段容易被忽略但很关键。isConcurrencySafe决定了工具能不能和其他工具并行跑isReadOnly影响权限判断的严格程度interruptBehavior定义了用户中断时工具是直接取消还是阻塞等待。maxResultSizeChars则限制了工具返回结果的长度防止一个工具把上下文撑爆。2.2 Schema 定义与 Zod 验证Claude Code 用 Zod 做输入验证inputSchema就是一个 Zod schema。这样设计的好处是类型推导和运行时验证用同一份定义不会出现类型和校验逻辑不一致的情况。下面是一个文件读取工具的 Schema// src/tools/FileReadTool/schema.ts import { z } from zod export const fileReadSchema z.object({ path: z.string().min(1, Path is required), encoding: z.enum([utf8, base64]).default(utf8), lineRange: z .object({ start: z.number().int().min(1).optional(), end: z.number().int().min(1).optional(), }) .optional(), }) export type FileReadInput z.infertypeof fileReadSchema注意encoding用了.default(utf8)这意味着模型不传这个字段时Zod 会自动补上默认值。lineRange是可选的嵌套对象用来支持只读文件的一部分。这种 Schema 设计让模型在生成工具调用参数时即使漏掉可选字段也不会报错。2.3 工具结果类型与上下文工具执行完返回的不是裸数据而是ToolResult包装// src/types/tool.ts export type ToolResultT { data: T newMessages?: Message[] contextModifier?: (context: ToolUseContext) ToolUseContext mcpMeta?: { _meta?: Recordstring, unknown structuredContent?: Recordstring, unknown } }contextModifier是个很有意思的设计——工具执行后可以修改上下文比如切换工作目录、更新应用状态。这让工具不只是「读数据」还能「改环境」。而ToolUseContext是工具执行时能拿到的全部上下文export type ToolUseContext { cwd: string tools: Tools canUseTool: CanUseToolFn getAppState: () AppState setAppState: (f: (prev: AppState) AppState) void abortSignal?: AbortSignal handleElicitation?: (params: ElicitRequestURLParams) PromiseElicitResult scratchpadDir?: string theme?: Theme onProgress?: (progress: ToolProgressData) void chainTracking?: QueryChainTracking }abortSignal是中断行为的核心工具在执行过程中要定期检查这个信号。onProgress让长时间运行的工具能上报进度。chainTracking用来追踪链式调用避免工具之间无限递归。2.4 注册一个工具的最小示例把上面这些拼起来一个最小可用的工具长这样// src/tools/FileReadTool/FileReadTool.ts import { z } from zod import type { Tool, ToolUseContext, ToolResult } from ../../Tool import { fileReadSchema, type FileReadInput } from ./schema export const FileReadTool: Tooltypeof fileReadSchema, string { name: FileRead, maxResultSizeChars: 100_000, inputSchema: fileReadSchema, isConcurrencySafe: () true, isReadOnly: () true, async *call(input, context) { const { path, encoding, lineRange } input const content await readFileContent(path, encoding, lineRange) return { data: content } }, async description() { return Read the contents of a file from the local filesystem. }, async prompt() { return Use this tool to read files. Provide an absolute path. }, async checkPermissions(input, context) { return { behavior: allow } }, userFacingName(input) { return input?.path ? Read ${input.path} : Read file }, }这里isConcurrencySafe返回true因为读文件不会修改状态多个读操作可以并行。isReadOnly也返回true权限系统会据此放宽检查。这两个标记直接影响了后面编排阶段的执行策略。3. 可复制配置本地跑通 Tool 注册与调用链路3.1 环境准备与依赖安装要本地验证这条链路你需要 Node.js 18 和一个 TypeScript 环境。先建项目并装依赖mkdir claude-tool-demo cd claude-tool-demo npm init -y npm install zod typescript tsx types/node npx tsc --init --target ES2022 --module ESNext --moduleResolution bundler --stricttsx用来直接跑 TypeScript省去编译步骤。zod是 Schema 验证的核心依赖。3.2 工具注册表配置工具注册表负责收集所有可用工具并提供按名字查找的能力。这是链路的第一环// src/services/tools/registry.ts import type { Tool } from ../../Tool import { FileReadTool } from ../../tools/FileReadTool/FileReadTool import { FileWriteTool } from ../../tools/FileWriteTool/FileWriteTool export type Tools Tool[] const registry: Mapstring, Tool new Map() export function registerTool(tool: Tool): void { if (registry.has(tool.name)) { throw new Error(Tool ${tool.name} already registered) } registry.set(tool.name, tool) for (const alias of tool.aliases ?? []) { registry.set(alias, tool) } } export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find((t) t.name name || t.aliases?.includes(name)) } export function getAllTools(): Tools { return Array.from(new Set(registry.values())) } // 启动时注册内置工具 registerTool(FileReadTool) registerTool(FileWriteTool)注意registerTool里对重复注册做了拦截别名也会一起进注册表。getAllTools用Set去重因为别名可能让同一个工具出现多次。3.3 调用编排配置编排层是链路的调度中心它决定工具是并发跑还是串行跑// src/services/tools/toolOrchestration.ts import type { ToolUseBlock, AssistantMessage, MessageUpdate } from ../../types import type { ToolUseContext } from ../../Tool import { findToolByName } from ./registry import { runToolsConcurrently, runToolsSerially } from ./toolExecution type Batch { isConcurrencySafe: boolean blocks: ToolUseBlock[] } export function partitionToolCalls( toolUseMessages: ToolUseBlock[], context: ToolUseContext, ): Batch[] { return toolUseMessages.reduce((acc: Batch[], toolUse) { const tool findToolByName(context.tools, toolUse.name) const parsedInput tool?.inputSchema.safeParse(toolUse.input) const isConcurrencySafe parsedInput?.success ? Boolean(tool?.isConcurrencySafe(parsedInput.data)) : false if (isConcurrencySafe acc[acc.length - 1]?.isConcurrencySafe) { acc[acc.length - 1]!.blocks.push(toolUse) } else { acc.push({ isConcurrencySafe, blocks: [toolUse] }) } return acc }, []) } export async function* runTools( toolUseMessages: ToolUseBlock[], assistantMessages: AssistantMessage[], canUseTool: CanUseToolFn, toolUseContext: ToolUseContext, ): AsyncGeneratorMessageUpdate, void { const currentContext toolUseContext for (const { isConcurrencySafe, blocks } of partitionToolCalls( toolUseMessages, currentContext, )) { if (isConcurrencySafe) { for await (const update of runToolsConcurrently( blocks, assistantMessages, canUseTool, currentContext, )) { yield { message: update.message, newContext: currentContext } } } else { for await (const update of runToolsSerially( blocks, assistantMessages, canUseTool, currentContext, )) { yield { message: update.message, newContext: currentContext } } } } }partitionToolCalls的逻辑是连续的并发安全工具合并成一个批次遇到非并发安全的就断开。这样既保证了并发效率又不会让写操作和读操作乱序。3.4 权限与验证配置权限检查在验证之前还是之后取决于工具的实现。Claude Code 的约定是先做 Schema 解析再做权限检查最后做工具特定的输入验证。权限结果类型定义如下// src/types/permissions.ts export type PermissionResult { behavior: allow | deny reason?: string decision?: PermissionDecision updatedInput?: Recordstring, unknown message?: string } export type PermissionDecision | accept | accept-always | reject | reject-always | auto-accepted | auto-denied | hook-accepted | hook-denied权限规则的优先级从高到低是alwaysDenyRules→alwaysAllowRules→alwaysAskRules→autoMode→PreToolUse Hook→ 交互式确认。这个顺序意味着拒绝规则永远优先安全兜底。3.5 并发控制器配置并发控制器用信号量限制同时运行的工具数量// src/services/tools/concurrency.ts export class ConcurrencyController { private activeTools new Mapstring, AbortController() private queue: Array() void [] private running 0 constructor(private maxConcurrent: number 3) {} async executeWithConcurrencyControlT( toolName: string, executeFn: (abortSignal: AbortSignal) PromiseT, ): PromiseT { if (this.running this.maxConcurrent) { await new Promisevoid((resolve) this.queue.push(resolve)) } this.running const abortController new AbortController() this.activeTools.set(toolName, abortController) try { return await executeFn(abortController.signal) } finally { this.activeTools.delete(toolName) this.running-- const next this.queue.shift() if (next) next() } } abortTool(toolName: string): void { const controller this.activeTools.get(toolName) if (controller) { controller.abort() this.activeTools.delete(toolName) } } abortAllTools(): void { for (const [, controller] of this.activeTools) { controller.abort() } this.activeTools.clear() } }maxConcurrent默认是 3这个值可以根据机器性能调整。abortTool和abortAllTools是中断行为的底层支撑。4. 验证请求跑通一次完整的工具调用4.1 编写验证脚本现在写一个脚本模拟模型发起一次FileRead调用走完整条链路// src/verify.ts import { getAllTools, findToolByName } from ./services/tools/registry import { runTools } from ./services/tools/toolOrchestration import type { ToolUseContext } from ./Tool async function main() { const tools getAllTools() console.log(已注册工具:, tools.map((t) t.name)) const context: ToolUseContext { cwd: process.cwd(), tools, canUseTool: async () ({ behavior: allow }), getAppState: () ({}) as never, setAppState: () {}, } const toolUseMessages [ { type: tool_use as const, id: call_1, name: FileRead, input: { path: ./package.json, encoding: utf8 }, }, ] const tool findToolByName(tools, FileRead) if (!tool) throw new Error(FileRead not found) const parsed tool.inputSchema.safeParse(toolUseMessages[0].input) console.log(Schema 解析:, parsed.success ? 通过 : parsed.error.message) const permission await tool.checkPermissions(parsed.data, context) console.log(权限检查:, permission.behavior) for await (const update of runTools(toolUseMessages, [], context.canUseTool, context)) { console.log(执行结果:, update.message) } } main().catch(console.error)4.2 运行与预期输出用tsx直接跑npx tsx src/verify.ts预期输出类似已注册工具: [ FileRead, FileWrite ] Schema 解析: 通过 权限检查: allow 执行结果: { type: tool_result, tool_use_id: call_1, content: ... }如果 Schema 解析失败会打印具体的 Zod 错误信息比如Path is required。如果权限检查返回deny后面的执行就不会触发。这条链路跑通说明注册、Schema、权限、编排四个环节都正常。4.3 验证并发分区再写一个测试验证并发分区逻辑// src/verify-partition.ts import { partitionToolCalls } from ./services/tools/toolOrchestration import { getAllTools } from ./services/tools/registry const tools getAllTools() const context { tools } as never const blocks [ { type: tool_use as const, id: 1, name: FileRead, input: { path: /a } }, { type: tool_use as const, id: 2, name: FileRead, input: { path: /b } }, { type: tool_use as const, id: 3, name: FileWrite, input: { path: /c, content: x } }, { type: tool_use as const, id: 4, name: FileRead, input: { path: /d } }, ] const batches partitionToolCalls(blocks, context) console.log(JSON.stringify(batches.map((b) ({ safe: b.isConcurrencySafe, count: b.blocks.length, })), null, 2))预期输出[ { safe: true, count: 2 }, { safe: false, count: 1 }, { safe: true, count: 1 } ]两个连续的FileRead合并成一批并发执行FileWrite单独一批串行执行后面的FileRead又单独一批。这个分区结果直接决定了执行顺序。5. 本篇常见错排查401、local proxy failed 与 reading choices5.1 报错 401Key 无效或未配置如果你在接入真实模型时遇到401 Unauthorized先检查三件套是否齐全Base URL、API Key、Model ID。以 Claude Code 接入为例配置文件通常放在~/.claude/settings.json或项目级.claude/settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的Key, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }三个字段缺一不可。ANTHROPIC_BASE_URL末尾不要带/v1SDK 会自己拼。Key 要去控制台生成别用示例里的占位符。如果还是 401用 curl 单独测一下curl -X POST https://taotoken.net/api/v1/messages \ -H x-api-key: sk-你的Key \ -H anthropic-version: 2023-06-01 \ -H content-type: application/json \ -d {model:claude-sonnet-4-20250514,max_tokens:64,messages:[{role:user,content:hi}]}返回 200 说明 Key 和地址没问题问题在客户端配置。5.2 local proxy failed本地代理配置冲突local proxy failed通常出现在环境变量里残留了旧的代理设置。检查这几个变量echo $HTTP_PROXY echo $HTTPS_PROXY echo $ALL_PROXY如果有值且指向不可用的地址清掉再跑unset HTTP_PROXY HTTPS_PROXY ALL_PROXY另外检查~/.claude/settings.json里有没有proxy字段有的话删掉。这个报错的本质是客户端尝试走一个不存在的本地端口和工具系统本身无关但会阻断整条调用链路。5.3 reading choices响应格式解析失败reading choices这个报错一般出现在用 OpenAI 兼容格式请求 Anthropic 接口时。Anthropic 的响应结构是content数组不是choices。如果你用的是 OpenAI SDK 指向了 Anthropic 端点就会解析失败。解决办法是换用 Anthropic SDK或者确认你的接入层做了格式转换。检查请求体里model字段是否拼写正确max_tokens是否必填。Anthropic 的max_tokens是必填项漏了会直接报错。5.4 OAuth 相关报错如果看到OAuth token expired或invalid_grant说明用的是 OAuth 登录态而不是 API Key。在 Claude Code 里可以用/login重新走一遍授权或者干脆切到 API Key 模式。OAuth 和 API Key 二选一别混用。5.5 工具注册重复报错回到工具系统本身如果你看到Tool FileRead already registered说明registerTool被调用了两次。检查是不是在多个入口文件里都 import 了注册逻辑。解决办法是把注册收敛到一个bootstrap.ts其他地方只 import 不注册。5.6 Schema 解析失败的排查inputSchema.safeParse返回success: false时打印error.issues能看到具体哪个字段不合法const parsed tool.inputSchema.safeParse(input) if (!parsed.success) { console.error(parsed.error.issues) }常见原因是模型生成的参数类型不对比如lineRange.start传了字符串而不是数字。Zod 的.int()和.min()会拦截这类问题。6. 把 Tool 系统接进你的工作流工具系统的价值不在于单个工具多强而在于链路可扩展。你新增一个工具只需要实现Tool接口、写一份 Zod Schema、在注册表里加一行剩下的权限、验证、并发、中断都由编排层统一处理。这种「约定优于配置」的设计是 Claude Code 能快速堆叠工具生态的原因。如果你想把这条链路用到自己的项目里建议先从只读工具开始把isConcurrencySafe和isReadOnly都设为true跑通注册到执行的完整流程再逐步加入写操作和权限规则。调试阶段把maxConcurrent设成 1串行执行更容易定位问题。需要长期跑编码任务或 Agent 场景的话可以了解下 Coding Plan配合 API Keys 和接入文档把三件套配齐链路就能稳定跑起来。想先验证模型响应格式用模型对话页面直接发一条请求比在代码里反复试错快得多。
返回列表