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.infer<Input>, context: ToolUseContext, canUseTool: CanUseToolFn, parentMessage: AssistantMessage, onProgress?: ToolCallProgress<P>, ): Promise<ToolResult<Output>> // 2. 描述生成(给模型看的) description( input: z.infer<Input>, options: { isNonInteractiveSession: boolean toolPermissionContext: ToolPermissionContext tools: Tools }, ): Promise<string> // 3. Schema 定义 readonly inputSchema: Input readonly inputJSONSchema?: ToolInputJSONSchema outputSchema?: z.ZodType<unknown> // 4. 元数据 readonly name: string aliases?: string[] searchHint?: string maxResultSizeChars: number readonly strict?: boolean // 5. 行为标记 isConcurrencySafe(input: z.infer<Input>): boolean isReadOnly(input: z.infer<Input>): boolean isDestructive?(input: z.infer<Input>): boolean interruptBehavior?(): 'cancel' | 'block' // 6. 权限与验证 checkPermissions( input: z.infer<Input>, context: ToolUseContext, ): Promise<PermissionResult> validateInput?( input: z.infer<Input>, context: ToolUseContext, ): Promise<ValidationResult> // 7. 提示生成 prompt(options: { getToolPermissionContext: () => Promise<ToolPermissionContext> tools: Tools agents: AgentDefinition[] allowedAgentTypes?: string[] }): Promise<string> // 8. UI 展示 userFacingName(input: Partial<z.infer<Input>> | undefined): string getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null getActivityDescription?(input: Partial<z.infer<Input>> | 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.infer<typeof fileReadSchema>注意encoding用了.default('utf8'),这意味着模型不传这个字段时,Zod 会自动补上默认值。lineRange是可选的嵌套对象,用来支持只读文件的一部分。这种 Schema 设计让模型在生成工具调用参数时,即使漏掉可选字段也不会报错。
2.3 工具结果类型与上下文
工具执行完返回的不是裸数据,而是ToolResult包装:
// src/types/tool.ts export type ToolResult<T> = { data: T newMessages?: Message[] contextModifier?: (context: ToolUseContext) => ToolUseContext mcpMeta?: { _meta?: Record<string, unknown> structuredContent?: Record<string, 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) => Promise<ElicitResult> 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: Tool<typeof 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: Map<string, 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, ): AsyncGenerator<MessageUpdate, 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?: Record<string, 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 Map<string, AbortController>() private queue: Array<() => void> = [] private running = 0 constructor(private maxConcurrent: number = 3) {} async executeWithConcurrencyControl<T>( toolName: string, executeFn: (abortSignal: AbortSignal) => Promise<T>, ): Promise<T> { if (this.running >= this.maxConcurrent) { await new Promise<void>((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 choices
5.1 报错 401:Key 无效或未配置
如果你在接入真实模型时遇到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末尾不要带/v1,SDK 会自己拼。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 和接入文档把三件套配齐,链路就能稳定跑起来。想先验证模型响应格式,用模型对话页面直接发一条请求,比在代码里反复试错快得多。