第三节:Tool 的一生 —— 从定义到执行的完整生命周期

张开发
2026/4/18 6:06:58 15 分钟阅读

分享文章

第三节:Tool 的一生 —— 从定义到执行的完整生命周期
核心命题上一节我们理解了核心循环其中最关键的一步是执行工具。但执行工具远不只是调个函数这么简单——工具怎么定义怎么告诉 LLM 有哪些工具可用LLM 返回的工具调用怎么解析和验证执行前要不要检查权限执行后怎么把结果格式化回去这条完整的链路是你构建任何 Agent 都必须想清楚的第一个问题。一、为什么要深入理解 Tool 的生命周期1.1 Tool 是 Agent 与世界的接口回忆上一节的核心循环while (true) { response await callLLM(messages, tools) if (response.stop_reason end_turn) break result await executeTool(response.tool_use) // ← 本节聚焦这一行 messages.push(result) }executeTool这一行看起来简单但在 Claude Code 中它背后是一条8 步流水线涉及 3 个核心文件、超过 2500 行代码。为什么要这么复杂因为工具调用是 Agent 与外部世界交互的唯一通道——LLM 不能直接读文件、执行命令、搜索网页它必须通过工具间接操作。这意味着工具系统必须解决三个核心问题安全性LLM 是非确定性的它可能生成危险的工具调用删除系统文件、执行恶意命令健壮性LLM 可能生成格式错误的参数、调用不存在的工具、在工具执行中超时可观测性用户需要看到 Agent 在做什么开发者需要调试和监控1.2 路线图我们将追踪一个工具从被定义到执行完成的完整旅程二、Tool 类型定义 —— 793 行代码里的设计哲学2.1 泛型结构ToolInput, Output, PClaude Code 的工具类型使用了三个泛型参数// Tool.ts 第362-695行 export type Tool Input extends AnyObject AnyObject, // 输入参数的 Zod Schema Output unknown, // 输出结果的类型 P extends ToolProgressData ToolProgressData, // 进度事件的类型 { name: string aliases?: string[] call(...): PromiseToolResultOutput description(...): Promisestring inputSchema: Input checkPermissions(...): PromisePermissionResult validateInput?(...): PromiseValidationResult isReadOnly(input): boolean isConcurrencySafe(input): boolean isEnabled(): boolean // ...还有 30 个方法和属性 }三个泛型参数的含义泛型参数类型约束作用示例Inputextends AnyObject(Zod Schema)约束工具的输入参数类型z.object({ file_path: z.string() })Output unknown约束工具的返回结果类型{ content: string; path: string }Pextends ToolProgressData约束进度事件的类型BashProgress,MCPProgress这种设计使得每个工具的输入输出都是类型安全的——编译时就能发现参数错误而不是等到运行时 LLM 生成了错误参数才报错。2.2 核心方法逐一解析让我们按重要程度逐一分析 Tool 接口的关键方法call()—— 核心执行逻辑// Tool.ts 第362-695行 export type Tool Input extends AnyObject AnyObject, // 输入参数的 Zod Schema Output unknown, // 输出结果的类型 P extends ToolProgressData ToolProgressData, // 进度事件的类型 { name: string aliases?: string[] call(...): PromiseToolResultOutput description(...): Promisestring inputSchema: Input checkPermissions(...): PromisePermissionResult validateInput?(...): PromiseValidationResult isReadOnly(input): boolean isConcurrencySafe(input): boolean isEnabled(): boolean // ...还有 30 个方法和属性 }注意返回类型ToolResultOutputexport type ToolResultT { data: T // 工具的实际输出 newMessages?: (UserMessage | ...)[] // 附加消息可选 contextModifier?: (context: ToolUseContext) ToolUseContext // 上下文修改器 mcpMeta?: { ... } // MCP 协议元数据 }contextModifier是一个精妙的设计——工具可以在执行后修改上下文。例如当用户执行cd /other/directory时Bash 工具可以通过contextModifier更新工作目录。description()—— 动态描述description( input: z.inferInput, options: { isNonInteractiveSession: boolean toolPermissionContext: ToolPermissionContext tools: Tools }, ): Promisestring注意这不是一个静态字符串而是一个异步函数。描述可以根据当前权限模式、可用工具列表、是否交互式会话等条件动态生成。例如文件编辑工具在plan模式下可能会在描述中强调当前仅允许只读操作。inputSchema—— Zod Schema 验证readonly inputSchema: Input // Zod Schema readonly inputJSONSchema?: ToolInputJSONSchema // 可选的 JSON Schema 格式每个工具的输入通过Zod进行验证。为什么选 Zod 而不是 JSON SchemaTypeScript 类型推断z.infertypeof schema自动产生 TS 类型组合性Zod Schema 可以用.extend()、.pick()等方法组合运行时验证schema.safeParse(input)提供精确的错误信息JSON Schema 导出需要时可以转换为 JSON Schema发送给 LLM APIcheckPermissions()—— 权限守门员checkPermissions( input: z.inferInput, context: ToolUseContext, ): PromisePermissionResult返回三种可能的行为type PermissionResult | { behavior: allow; updatedInput: any; ... } // 允许 | { behavior: deny; message: string; ... } // 拒绝 | { behavior: ask; message?: string; ... } // 需要用户确认这是工具级别的权限检查在通用权限系统之上增加了工具特有的逻辑。第 8 节会深入权限系统的完整设计。isReadOnly()和isConcurrencySafe()—— 调度标记isReadOnly(input: z.inferInput): boolean isConcurrencySafe(input: z.inferInput): boolean这两个方法接收具体输入作为参数——同一个工具不同的输入可能有不同的只读性和并发安全性。例如 BashToolls -la→isReadOnly true,isConcurrencySafe truerm -rf /→isReadOnly false,isConcurrencySafe false这些标记直接影响第 2 节中提到的工具调度策略isReadOnly影响plan模式下的工具过滤isConcurrencySafe影响toolOrchestration.ts的分区策略渲染方法族 —— 终端 UIrenderToolUseMessage(input, options): React.ReactNode // 渲染正在执行... renderToolResultMessage?(content, progress, options): React.ReactNode // 渲染执行结果 renderToolUseProgressMessage?(progress, options): React.ReactNode // 渲染进度条 renderToolUseRejectedMessage?(input, options): React.ReactNode // 渲染被拒绝的提示 renderToolUseErrorMessage?(result, options): React.ReactNode // 渲染错误信息 renderGroupedToolUse?(toolUses, options): React.ReactNode // 渲染分组的并行工具Claude Code 的终端 UI 是用 React Ink 构建的第 12 节会详细说明每个工具都可以定制自己的渲染方式。这使得用户看到的不是原始 JSON而是格式化的、高亮的、可折叠的信息。2.3 Tool 接口的设计原则通过分析完整的 Tool 类型我们可以提炼出几个设计原则原则 1输入驱动的行为差异化几乎所有行为方法都接收input参数isReadOnly(input),isConcurrencySafe(input),isDestructive(input)。这意味着同一个工具在不同输入下可以有不同的行为特征——这比整个工具要么只读要么不只读更精细。原则 2关注点分离到极致关注点对应方法核心逻辑call()输入约束inputSchema,validateInput()安全控制checkPermissions(),isReadOnly(),isDestructive()调度优化isConcurrencySafe()用户界面render*()方法族可观测性description(),userFacingName(),getActivityDescription()LLM 交互prompt(),toAutoClassifierInput()搜索发现searchHint,shouldDefer,alwaysLoad原则 3安全默认 显式声明这一点在buildTool()中体现得最为明显。三、buildTool()工厂函数 —— 约定优于配置3.1 默认值设计每个工具都通过buildTool()工厂函数构建// Tool.ts 第757-792行 const TOOL_DEFAULTS { isEnabled: () true, // 默认启用 isConcurrencySafe: (_input?) false, // 默认不可并行 isReadOnly: (_input?) false, // 默认可写 isDestructive: (_input?) false, // 默认非破坏性 checkPermissions: (input, _ctx?) // 默认允许 Promise.resolve({ behavior: allow, updatedInput: input }), toAutoClassifierInput: (_input?) , // 默认不参与安全分类 userFacingName: (_input?) , // 默认空名称 } ​ export function buildToolD extends AnyToolDef(def: D): BuiltToolD { return { ...TOOL_DEFAULTS, userFacingName: () def.name, ...def, } as BuiltToolD }注意默认值的选择属性默认值设计理由isEnabledtrue工具注册就是启用禁用需显式声明isConcurrencySafefalse安全第一——假设不安全安全的需显式声明isReadOnlyfalse安全第一——假设有写操作isDestructivefalse大部分工具非破坏性checkPermissionsallow委托给通用权限系统为什么isConcurrencySafe默认为false如果默认为true开发者忘记标注一个有副作用的工具会导致它被并行执行可能产生竞态条件。而默认为false的最坏情况只是少了一些性能优化——宁可慢一点不能错。这是fail-closed的安全理念。3.2ToolDef与BuiltToolD的类型魔法buildTool使用了一个精妙的类型推断机制// ToolDef可以省略有默认值的方法 export type ToolDefI, O, P OmitToolI, O, P, DefaultableToolKeys PartialPickToolI, O, P, DefaultableToolKeys ​ // BuiltToolD保证所有方法都存在 type BuiltToolD OmitD, DefaultableToolKeys { [K in DefaultableToolKeys]-?: K extends keyof D ? undefined extends D[K] ? ToolDefaults[K] : D[K] : ToolDefaults[K] }翻译成白话ToolDef你定义工具时可以省略isEnabled、isConcurrencySafe等有默认值的方法BuiltTool经过buildTool()后所有方法都保证存在调用者不需要?.()判空这样做的好处工具开发者只需要关注核心逻辑call()、inputSchema其他方法有合理的默认值。四、工具执行的完整链路8 步流水线4.1 全景图当 LLM 返回一个tool_useblock 时从解析到最终结果要经过以下 8 步4.2 步骤详解步骤 1查找工具// toolExecution.ts 第343-356行 let tool findToolByName(toolUseContext.options.tools, toolName) ​ // 如果找不到尝试通过 alias 回退 if (!tool) { const fallbackTool findToolByName(getAllBaseTools(), toolName) if (fallbackTool fallbackTool.aliases?.includes(toolName)) { tool fallbackTool } }aliases机制支持工具重命名的向后兼容。例如KillShell被重命名为TaskStop老的 transcript 中仍然有KillShell调用通过 alias 可以正确路由。// Tool.ts 第348-353行 export function toolMatchesName( tool: { name: string; aliases?: string[] }, name: string, ): boolean { return tool.name name || (tool.aliases?.includes(name) ?? false) }步骤 2Zod Schema 验证// toolExecution.ts 第615-680行 const parsedInput tool.inputSchema.safeParse(input) if (!parsedInput.success) { let errorContent formatZodValidationError(tool.name, parsedInput.error) // 如果是延迟加载的工具且 schema 未发送 const schemaHint buildSchemaNotSentHint(tool, toolUseContext.messages, toolUseContext.options.tools) if (schemaHint) { errorContent schemaHint } return [{ message: createUserMessage({ content: [{ type: tool_result, content: tool_use_errorInputValidationError: ${errorContent}/tool_use_error, is_error: true, tool_use_id: toolUseID }], }) }] }这里有一个巧妙的细节buildSchemaNotSentHint。当使用 ToolSearch延迟加载功能时如果 LLM 尝试调用一个 schema 未发送给它的工具验证会失败因为 LLM 不知道正确的参数格式。此时错误消息会提示 LLM先用 ToolSearch 加载这个工具的 schema再重试。// toolExecution.ts 第578-597行 export function buildSchemaNotSentHint(tool, messages, tools): string | null { if (!isToolSearchEnabledOptimistic()) return null if (!isDeferredTool(tool)) return null const discovered extractDiscoveredToolNames(messages) if (discovered.has(tool.name)) return null return This tools schema was not sent to the API... Load the tool first: call ToolSearch with query select:${tool.name}, then retry. }步骤 3业务验证// toolExecution.ts 第682-733行 const isValidCall await tool.validateInput?.(parsedInput.data, toolUseContext) if (isValidCall?.result false) { return [{ message: createUserMessage({ content: [{ type: tool_result, content: tool_use_error${isValidCall.message}/tool_use_error, ... }] }) }] }与步骤 2 的 Schema 验证不同业务验证检查的是语义而非语法。例如FileEdit 工具检查文件是否存在Bash 工具检查是否在允许的目录下MCP 工具检查服务器是否已连接步骤 4PreToolUse Hooks// toolExecution.ts 第800-862行 for await (const result of runPreToolUseHooks( toolUseContext, tool, processedInput, toolUseID, messageId, ... )) { switch (result.type) { case message: // 进度消息或附件消息 break case hookPermissionResult: // Hook 做了权限决策 hookPermissionResult result.hookPermissionResult break case hookUpdatedInput: // Hook 修改了输入参数 processedInput result.updatedInput break case preventContinuation: // Hook 要求阻止后续循环 shouldPreventContinuation true break case stop: // Hook 阻止了此次工具执行 return resultingMessages } }PreToolUse Hook 是一个拦截器——它可以修改工具输入例如自动补全文件路径做出权限决策例如自定义的安全策略完全阻止工具执行例如危险操作的人工审核注入额外的上下文信息步骤 5权限检查// toolExecution.ts 第920-932行 const resolved await resolveHookPermissionDecision( hookPermissionResult, // 来自 PreToolUse Hook 的决策 tool, // 工具定义 processedInput, // 处理后的输入 toolUseContext, // 执行上下文 canUseTool, // 权限检查函数 assistantMessage, // 触发消息 toolUseID, // 工具调用 ID ) const permissionDecision resolved.decision权限检查的优先级Hook 的权限决策如果有工具自身的checkPermissions()通用权限系统的canUseTool()如果权限被拒绝if (permissionDecision.behavior ! allow) { // 记录遥测 // 生成拒绝消息 // 可能触发 PermissionDenied hooks允许重试 return resultingMessages }步骤 6tool.call() 执行// toolExecution.ts 第1206-1222行 const result await tool.call( callInput, { ...toolUseContext, toolUseId: toolUseID, userModified: permissionDecision.userModified ?? false, }, canUseTool, assistantMessage, progress { onToolProgress({ toolUseID: progress.toolUseID, data: progress.data, }) }, )注意userModified标记——如果用户在权限审核时修改了工具输入例如改变了 Bash 命令这个标记会传递给工具的call()方法。步骤 7PostToolUse Hooks// toolExecution.ts 第1483-1531行 for await (const hookResult of runPostToolUseHooks( toolUseContext, tool, toolUseID, messageId, processedInput, toolOutput, ... )) { if (updatedMCPToolOutput in hookResult) { if (isMcpTool(tool)) { toolOutput hookResult.updatedMCPToolOutput // MCP 工具的输出可以被 Hook 修改 } } else { resultingMessages.push(hookResult) } }PostToolUse Hook 可以修改 MCP 工具的输出数据清洗、脱敏注入审计日志触发副作用更新外部状态步骤 8结果封装// toolExecution.ts 第1292-1296行 const mappedToolResultBlock tool.mapToolResultToToolResultBlockParam( result.data, toolUseID, )将工具的输出转换为 Anthropic API 期望的ToolResultBlockParam格式然后包装成UserMessage推入消息历史。五、ToolUseContext—— 工具的执行环境5.1 为什么工具需要上下文一个简单的函数调用不需要上下文——给参数返结果。但 Agent 的工具不是简单函数文件操作需要知道当前工作目录权限检查需要知道当前权限模式进度报告需要知道谁在监听子 Agent 需要知道父 Agent 是谁中断处理需要知道AbortController这些信息统一封装在ToolUseContext中。5.2 ToolUseContext 的完整结构// Tool.ts 第158-300行 export type ToolUseContext { // --- 基础配置 --- options: { commands: Command[] // 可用的斜杠命令 debug: boolean // 调试模式 mainLoopModel: string // 当前使用的模型 tools: Tools // 当前可用的工具列表 verbose: boolean // 详细模式 thinkingConfig: ThinkingConfig // 思维模式配置 mcpClients: MCPServerConnection[] // MCP 客户端连接 isNonInteractiveSession: boolean // 是否无头模式 agentDefinitions: AgentDefinitionsResult // Agent 定义 maxBudgetUsd?: number // USD 预算上限 refreshTools?: () Tools // 工具列表刷新回调 } // --- 控制器 --- abortController: AbortController // 中断控制器 // --- 状态访问 --- readFileState: FileStateCache // 文件读取缓存 getAppState(): AppState // 获取应用状态 setAppState(f: (prev) AppState): void // 更新应用状态 // --- UI 交互 --- setToolJSX?: SetToolJSXFn // 设置工具的 JSX 渲染 addNotification?: (notif) void // 添加通知 appendSystemMessage?: (msg) void // 追加系统消息 sendOSNotification?: (opts) void // 发送 OS 级通知 // --- 身份信息 --- agentId?: AgentId // 子 Agent ID主线程为 undefined agentType?: string // 子 Agent 类型名称 // --- 跟踪信息 --- messages: Message[] // 当前消息历史 queryTracking?: QueryChainTracking // 查询链追踪 toolDecisions?: Mapstring, {...} // 工具决策记录 // --- 文件操作限制 --- fileReadingLimits?: { maxTokens?: number; maxSizeBytes?: number } globLimits?: { maxResults?: number } }5.3 ToolUseContext 的设计亮点亮点 1options子对象是只读语义的基础配置放在options里与可变的状态分离。这使得工具调用期间的配置稳定不会被其他并发工具修改。亮点 2状态通过函数访问getAppState()和setAppState()是函数不是直接的状态引用。这意味着每次调用都能拿到最新状态而非快照状态更新通过纯函数prev newState避免竞态子 Agent 可以有自己的状态覆盖setAppState可以是 no-op亮点 3abortController贯穿始终每个工具都可以检查abortController.signal.aborted来响应中断。这使得即使在长时间执行的工具如大文件搜索、长命令执行中用户按 CtrlC 也能立即响应。六、ToolResult的结果封装6.1 从工具输出到 API 格式工具的返回不能直接发回给 LLM API——需要经过格式转换// 工具返回的原始数据 ToolResultOutput { data: { content: file contents..., path: /src/query.ts } } ​ // 转换为 API 格式 ToolResultBlockParam { type: tool_result, tool_use_id: toolu_xxx, content: tool_resultfile contents.../tool_result, is_error: false }每个工具需要实现mapToolResultToToolResultBlockParam()方法来完成这个转换。6.2 大结果的处理当工具输出过大时超过maxResultSizeChars结果会被持久化到磁盘Claude 只会看到一个预览和文件路径// 如果结果超过阈值 const toolResultBlock await processToolResultBlock(tool, result.data, toolUseID) // 内部会检查大小必要时写入临时文件 // Claude 看到[Tool result too large, saved to /tmp/xxx.txt, showing first 1000 chars]...但有一个例外——FileReadTool的maxResultSizeChars设为Infinity// 为什么 FileRead 不持久化大结果 // 因为持久化后 Claude 会再次调用 FileRead 去读那个临时文件 // → 又产生大结果 → 又持久化 → 又 FileRead → 无限循环 maxResultSizeChars: Infinity // Set to Infinity for tools whose output must never be persisted6.3 错误结果的标准格式所有工具错误都用tool_use_error标签包装{ type: tool_result, content: tool_use_errorError: File not found: /nonexistent.ts/tool_use_error, is_error: true, tool_use_id: toolUseID, }tool_use_error标签使得 LLM 能明确识别这是一个错误而不是把错误信息当成正常输出。LLM 看到这个标签后通常会尝试修正操作或向用户解释问题。七、工具执行中的backfillObservableInput机制这是一个容易被忽视但非常精妙的设计// Tool.ts 第474-481行 backfillObservableInput?(input: Recordstring, unknown): void这个方法的作用在工具的输入被观察者Hook、权限系统、SDK 流、transcript看到之前向输入中添加派生字段。例如文件工具有一个file_path字段LLM 可能给出相对路径./src/query.ts。backfillObservableInput会添加一个展开后的绝对路径expanded_path: /workspace/src/query.ts这样 Hook 就能基于完整路径做决策。但关键约束是原始的input永远不会被修改因为 API 的 prompt cache 依赖输入的字节级一致性。所以backfillObservableInput只操作克隆且只用于观察者不用于call()。// toolExecution.ts 第783-793行 let callInput processedInput // 保存原始输入给 call() const backfilledClone tool.backfillObservableInput processedInput ? ({ ...processedInput }) : null if (backfilledClone) { tool.backfillObservableInput!(backfilledClone) processedInput backfilledClone // 观察者看到的是带有派生字段的版本 } // callInput 仍然指向原始输入传给 tool.call()八、辅助类型和工具函数8.1Tools类型export type Tools readonly Tool[]注意readonly——工具列表是不可变的。这使得在多处传递工具列表时不需要担心被意外修改。修改工具列表需要通过refreshTools()回调创建新数组。8.2findToolByName()和toolMatchesName()export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find(t toolMatchesName(t, name)) } ​ export function toolMatchesName( tool: { name: string; aliases?: string[] }, name: string, ): boolean { return tool.name name || (tool.aliases?.includes(name) ?? false) }看似简单但 alias 机制解决了一个实际问题当工具被重命名时已有的 transcript会话记录中还是旧名字。通过 alias旧 transcript 可以正确路由到新工具。8.3ValidationResult类型export type ValidationResult | { result: true } | { result: false; message: string; errorCode: number }errorCode的设计允许不同的错误被分类处理——例如某些错误码可能触发重试而另一些意味着永远不要再试。九、完整的工具执行时序图十、知识图谱本节与前后节的联系十一、小结核心概念回顾Tool 泛型接口ToolInput, Output, P使用 Zod 进行类型安全的输入验证buildTool() 工厂安全默认isConcurrencySafefalse, isReadOnlyfalse约定优于配置8 步执行流水线查找 → Schema验证 → 业务验证 → PreHook → 权限 → 执行 → PostHook → 封装ToolUseContext工具执行的完整环境包含配置、状态、控制器、身份信息backfillObservableInput观察者与执行者看到不同版本的输入保证 cache 一致性ToolResult 封装大结果持久化、错误标准格式、contextModifier 机制

更多文章