第3章:工具系统 -- Agent 的双手

"If all you have is a hammer, everything looks like a nail." -- Abraham Maslow

学习目标: 阅读本章后,你将能够:

  • 掌握 Claude Code 45+ 工具的设计模式,理解五要素协议的设计哲学
  • 理解工具定义协议、注册机制、编排引擎的完整架构
  • 分析并发分区策略的调度原理和实际效果
  • 理解 StreamingToolExecutor 四阶段状态机的精妙设计
  • 评估延迟工具发现机制的工程价值

Maslow 的这句话用在 Agent 工具系统上再贴切不过。如果 Agent 只有一个 Bash 工具,所有任务都会变成 Shell 命令——读取文件用 cat,搜索代码用 grep,编辑文件用 sed。这虽然可行,但违反了"使用正确工具解决正确问题"的工程原则。Claude Code 的工具系统提供了 45+ 个专门化工具,每个工具针对特定的操作类型做了优化——这就好比为不同任务配备不同的专业工具,而不是用一把锤子解决所有问题。

3.1 工具定义协议

Claude Code 的每个工具都遵循一个统一的类型契约 -- Tool<Input, Output, Progress>。这个契约定义在工具类型核心模块中,是整个工具系统的基石。理解它,就理解了 Agent "双手"的解剖结构。

这个协议的设计哲学可以用"接口即架构"来概括:通过定义严格的类型接口,工具系统的所有架构约束——权限检查、并发控制、进度报告、UI 渲染——都被编译器强制执行。开发者无法"忘记"实现某个方法,因为类型检查器会立即报错。

核心类型:Tool、Tools、ToolDef、buildTool

Tool 类型是一个泛型接口,接受三个类型参数:

  • Input extends AnyObject:使用 Zod schema 定义的工具输入类型,确保每个工具的输入都是一个结构化对象。
  • Output:工具的输出类型,自由定义。
  • P extends ToolProgressData:工具的进度数据类型,用于流式反馈。

三个泛型参数的分离是一个深思熟虑的设计决策。如果将输入和输出类型合并为一个,工具的签名将变得更难阅读;如果省略进度类型,工具就无法在执行过程中提供实时反馈。三者分离使得每个关注点都有独立的类型空间,编译器可以分别检查。

每个工具必须实现的五要素如下:

flowchart TD
    subgraph protocol["Tool 五要素协议"]
        name["要素一:名称与别名<br/>唯一名称标识符 + 可选别名<br/>支持向后兼容"]
        schema["要素二:Zod Schema<br/>运行时验证 + API 通信<br/>类型安全屏障"]
        perm["要素三:权限模型<br/>validateInput → hasPermissions → checkPermissions<br/>三层分层检查"]
        exec["要素四:执行逻辑<br/>核心执行方法 + contextModifier<br/>上下文修改通道"]
        ui["要素五:UI 渲染<br/>六个渲染方法覆盖完整生命周期<br/>React 组件深度集成"]
    end

    name --> schema --> perm --> exec --> ui

    classDef element fill:#e8f4f8,stroke:#2196F3,stroke-width:2px,color:#1565C0
    class name,schema,perm,exec,ui element

要素一:名称与别名

每个工具拥有一个唯一的名称标识符,以及可选的别名用于向后兼容。当工具重命名时,旧名称可以通过别名继续匹配。工具查找函数同时检查主名称和别名。

别名机制的存在揭示了一个工程实践原则:在公开 API 中,重命名是"只增不减"的操作。 即使某个工具的名称不再准确(如从 SearchTool 重命名为 GrepTool),旧名称也必须通过别名保持可用,否则依赖旧名称的配置、脚本和用户习惯都会被打破。

要素二:Zod Schema

每个工具使用 Zod 定义其输入参数的 schema。Zod schema 承担了双重职责:

  1. 运行时验证:在工具执行之前,LLM 生成的参数经过 Zod 解析,确保类型和约束的正确性。这是"不要信任外部输入"原则的体现——LLM 的输出是不可控的,工具必须自我保护。
  2. API 通信:Zod schema 通过转换层生成 JSON Schema 发送给 API,让模型知道每个参数的含义和约束。这意味着 schema 定义就是工具的"使用说明书"——模型看到的参数描述来自 Zod schema 中的 describe() 调用。

交叉引用: Zod schema 的验证发生在第 4 章权限管线的第一阶段(validateInput),这是"安全边界内嵌"设计原则的具体体现。

要素三:权限模型

权限相关的三个方法构成了分层的权限检查管线:

  1. 第一层:输入验证(validateInput):在权限检查之前运行,用于拒绝无效输入。这是"数据合法性"检查,与权限无关。
  2. 第二层:权限检查(hasPermissionsToUseTool + checkPermissions):包含工具特定的权限逻辑。不同工具的权限检查粒度不同——Read 工具可能只检查路径是否在允许列表内,而 Bash 工具需要解析命令、评估风险等级。
  3. 第三层:运行时属性判断:影响工具的并发调度策略。例如 isConcurrencySafe() 标记工具是否可以并行执行。

三层分离的设计哲学是"关注点分离":数据验证不关心权限策略,权限策略不关心并发调度。每层只做一件事,但三层串联起来提供了完整的防护。

要素四:执行逻辑

这是工具的核心执行方法。它接收解析后的输入参数、工具使用上下文、权限检查函数、父消息引用和一个可选的进度回调。返回的结果携带输出数据和可选的上下文修改器。

上下文修改器(contextModifier)允许工具在执行后修改上下文(如更新文件缓存),这是工具影响后续行为的关键通道。例如,FileWriteTool 在写入文件后会通过 contextModifier 更新文件状态缓存,使得后续的 FileReadTool 能看到最新的文件内容。

要素五:UI 渲染

工具拥有丰富的渲染方法集合,覆盖了完整的 UI 生命周期:

  • renderToolUseMessage:工具调用开始时展示(如 "Reading src/foo.ts")
  • renderToolUseProgressMessage:工具执行中的进度展示
  • renderToolResultMessage:工具结果展示
  • renderToolUseRejectedMessage:权限被拒绝时的展示
  • renderToolUseErrorMessage:执行出错时的展示
  • renderGroupedToolUse:多个并行工具的分组展示

每个渲染方法都返回 React.ReactNode,使得工具系统与 React 渲染管线深度集成。这个设计选择意味着工具的 UI 表现可以像 React 组件一样灵活——进度条、颜色高亮、折叠面板、表格布局,都可以通过 React 组件实现。

这六个渲染方法的覆盖范围值得注意:它涵盖了工具调用的"生老病死"——从开始(renderToolUseMessage)到进行中(renderToolUseProgressMessage),到成功(renderToolResultMessage)、被拒(renderToolUseRejectedMessage)、出错(renderToolUseErrorMessage),以及并行执行的分组展示(renderGroupedToolUse)。这种完整的生命周期覆盖确保了用户在任何状态下都能看到清晰、有意义的 UI 反馈。

buildTool 工厂函数

buildTool 是创建工具的标准工厂函数。它接收一个部分工具定义,自动填充安全默认值。这些默认值遵循"fail-closed"原则:安全性相关的方法(如并发安全判断、只读判断)默认为 false,工具必须显式声明自己安全才能享受并发等优化。

这个设计哲学可以用一个类比来理解:在机场安检中,默认假设所有行李都需要检查(fail-closed),只有经过特殊认证的旅客(如外交官)才能走快速通道。如果反过来——默认放行,只在发现问题时才拦截(fail-open)——那么任何一个漏检都可能造成安全事故。

类型系统通过巧妙的类型计算,让开发者只需提供必要字段,而工厂函数的返回类型保证完整的工具接口。如果开发者在定义中提供了某个方法,类型系统会使用开发者提供的签名;如果省略了,则使用默认签名。这种"可选覆盖、安全默认"的模式在工程实践中非常有效——简单工具只需几行代码,复杂工具可以完全自定义。


3.2 工具注册与动态发现

getAllBaseTools() 完整工具清单

getAllBaseTools() 是所有内建工具的注册中心。它返回一个扁平数组,包含了 Claude Code 所有可用的工具。通过这个函数,我们可以统计出核心工具清单,并按功能分类:

类别 工具 职责 并发安全
执行 BashTool 运行 Shell 命令 否(副作用)
文件 FileReadTool, FileEditTool, FileWriteTool 读取、编辑、写入文件 Read 是,Edit/Write 否
搜索 GlobTool, GrepTool 文件名模式匹配、内容搜索
笔记本 NotebookEditTool Jupyter Notebook 编辑
网络 WebFetchTool, WebSearchTool 获取 URL 内容、网络搜索
智能 AgentTool 子智能体入口
任务 TodoWriteTool, TaskCreateTool 等 任务管理 视具体工具而定
规划 EnterPlanModeTool, ExitPlanModeV2Tool 计划模式切换
交互 AskUserQuestionTool 向用户提问 否(需要用户响应)
技能 SkillTool 调用 slash command 技能
配置 ConfigTool 修改配置
MCP ListMcpResourcesTool, ReadMcpResourceTool MCP 资源访问
工作树 EnterWorktreeTool, ExitWorktreeTool Git worktree 管理
通知 BriefTool 消息发送
搜索发现 ToolSearchTool 延迟工具发现

设计洞察: 注意"并发安全"列——超过一半的工具标记为并发不安全。这反映了一个深刻的工程现实:在 Agent 系统中,大多数操作都有副作用(修改文件、执行命令、更改状态),真正可以安全并行执行的操作(纯读取、纯搜索)是少数。并发分区算法(3.4 节)的核心挑战正是在这个约束下最大化并行度。

死代码消除在工具注册中的应用

Claude Code 的工具注册大量使用条件导入实现编译期死代码消除。当特定条件不满足时,对应工具的整个模块不会被包含在最终构建中。功能开关控制的工具也是如此。

功能开关来自构建工具链,在编译时由 bundler 评估。当功能开关关闭时,对应的工具实现代码都会被 tree-shaking 移除。这种模式确保了外部构建(面向第三方用户)不会包含内部工具的代码。

这个设计在安全层面有重要意义:如果内部工具(如 REPL 工具、调试工具)被包含在外部构建中,即使它们不可用,也可能泄露内部架构信息。死代码消除从源头上消除了这种信息泄漏的风险。

在工具注册函数中,条件注册使用展开运算符,根据运行环境和功能开关决定是否加入特定工具。

ToolSearchTool 延迟发现机制

当工具数量超过一定阈值时,Claude Code 启用延迟工具发现(deferred tool discovery)。核心思路是:不在初始系统提示中发送所有工具的完整 schema,而是只发送工具名称列表,让模型通过 ToolSearchTool 按需加载。

用一个类比来理解:传统方式像是把整本百科全书放在模型面前——即使大部分内容在当前对话中用不到。延迟发现则像是给模型一个目录索引——模型知道有哪些工具可用,只在需要时翻开对应的页面查看详细参数。

ToolSearchTool 的实现遵循标准的工厂函数模式。判断一个工具是否应该被延迟的逻辑是:显式标记为总是加载的工具不延迟、MCP 工具总是延迟、工具搜索工具自身不延迟。

这个机制的核心价值在于节省 prompt 空间:当 MCP 服务器注册了数十个工具时,全部发送给 API 会消耗大量 token。延迟发现让模型只在需要时加载工具的完整 schema,显著减少了初始 prompt 的大小。

最佳实践: 如果你正在构建自己的 Agent 系统并通过 MCP 协议接入外部工具,关注工具 schema 的 token 消耗。每个工具的 schema 包括名称、描述和参数定义,在工具数量达到 50+ 时可能消耗数千 token。延迟发现是一个有效的优化手段。

工具过滤管线

getAllBaseTools() 到最终发送给 API 的工具列表,经过多层过滤:

flowchart LR
    all["getAllBaseTools()<br/>所有内建工具"] --> mode["模式过滤<br/>简单/普通模式筛选"]
    mode --> deny["拒绝规则过滤<br/>移除 blanket deny 工具"]
    deny --> enabled["启用状态检查<br/>过滤未启用工具"]
    enabled --> pool["工具池组装<br/>合并内建 + MCP<br/>按名称排序去重"]
    pool --> api["发送给 API 的工具列表"]

    classDef filter fill:#e8f4f8,stroke:#2196F3,stroke-width:2px,color:#1565C0
    classDef result fill:#f0fdf4,stroke:#22c55e,stroke-width:2px,color:#166534
    class all,mode,deny,enabled,pool filter
    class api result
  1. 模式过滤:根据模式过滤工具。简单模式只保留 Bash、Read、Edit;普通模式排除特殊工具。这种模式化的工具过滤确保了在受限环境中 Agent 只能使用最基本的工具集。
  2. 拒绝规则过滤:移除被 blanket deny 规则匹配的工具。
  3. 启用状态检查:过滤掉未启用的工具。
  4. 工具池组装:合并内建工具与 MCP 工具,按名称排序去重。排序的目的是确保 prompt 缓存稳定性——工具顺序变化会导致缓存失效。

交叉引用: 模式过滤和拒绝规则过滤与第 4 章的权限管线紧密相关。工具过滤是权限系统的第一道防线(工具可见性过滤),确保模型甚至无法"看到"它不应该使用的工具。


3.3 核心工具深度解析

BashTool:命令执行的瑞士军刀

BashTool 是 Claude Code 最强大的工具之一,也是最复杂的。它不仅仅是简单的 Shell 执行器,而是集成了多层安全防护的执行环境。如果工具系统是 Agent 的双手,BashTool 就是其中最有力的那只手——也是最需要被约束的那只。

BashTool 在工具系统中的特殊地位体现在以下方面:

  • 错误传播:当 BashTool 执行失败时,会取消所有并行的 Bash 工具调用。这是因为 Bash 命令之间往往存在隐式依赖链(如 mkdir 失败后后续命令无意义)。这个设计体现了"快速失败"原则——与其让后续命令在一个已损坏的环境中继续执行并产生更多错误,不如立即停止整个批次。

  • 中断行为:BashTool 可以自定义用户中断时的行为。某些长时间运行的命令(如测试套件)可能选择阻塞而非取消。这个设计反映了对用户意图的精细理解:中断一个正在运行的 npm install 应该立即停止(用户改了主意),但中断一个测试套件可能只是用户想看当前进度(测试完成后结果仍然有价值)。

  • 语义分析:BashTool 会对命令进行 AST 解析和语义分析,判断命令是否为搜索/读取操作(isSearchOrReadCommand),用于 UI 折叠展示。这体现了"智能工具"的设计理念——工具不仅是被动执行命令的管道,还能理解命令的语义并做出相应的 UI 决策。

  • 沙盒集成:通过 --dangerouslyDisableSandbox 参数和沙盒配置,控制命令执行的安全边界。沙盒是 BashTool 的"安全网"——即使在 bypass 权限模式下,沙盒仍然可以限制命令的文件系统访问范围。

文件三件套:FileReadTool、FileEditTool、FileWriteTool

这三个工具构成了 Claude Code 文件操作的完整能力集。它们的分工反映了经典数据库操作的 CRUD 模式(Create/Read/Update),只是缺少了 Delete——这是一个有意为之的安全决策,因为"删除文件"是不可逆操作,通常通过 BashTool 的 rm 命令来实现,这会触发更严格的权限检查。

FileReadTool 负责读取文件内容。它维护了文件状态缓存,用于追踪哪些文件已被读取,避免重复注入内存附件。这个缓存机制是性能优化的关键——如果同一个文件被读取多次(在不同的工具调用轮次中),缓存确保只在第一次读取时触发实际的文件 I/O,后续读取直接使用缓存结果。

FileEditTool 负责精确编辑文件。它使用 old_string -> new_string 的精确替换模式,而非行号范围,确保编辑操作在文件变化时仍然正确。这个选择值得深入分析:

  • 为什么不用行号? 行号是脆弱的——如果在读取文件和编辑文件之间,另一个工具(或用户)修改了文件,行号可能已经偏移,导致编辑错误的位置。
  • 为什么用精确字符串匹配? 字符串匹配是幂等的——只要目标字符串存在于文件中,编辑就能正确定位。即使文件被部分修改,只要目标片段没有被触及,编辑就是安全的。

FileEditTool 的 isDestructive 方法会根据编辑内容判断是否为破坏性操作(如删除大量代码)。这种上下文感知的破坏性判断比简单的"写入即破坏性"标签更加精确。

FileWriteTool 负责创建或完全覆写文件。这是最"重"的文件操作,权限检查最为严格。FileWriteTool 与 FileEditTool 的区别在于作用范围——Edit 只修改文件中的特定片段,Write 可以完全覆盖文件内容。因此,Write 的权限检查标准更高。

三个工具都支持 contextModifier,在执行后更新文件状态缓存,使得后续的工具调用和内存附件注入能看到最新的文件状态。

最佳实践: 在设计自己的 Agent 工具时,遵循"最小权限"原则——优先使用 Edit 而非 Write,优先使用 Read 而非 Bash。这不仅是安全问题,也是效率问题:精确的 Edit 比 Write 整个文件更快,也更不容易出错。

搜索双雄:GlobTool 与 GrepTool

GlobTool 使用文件名模式匹配查找文件,底层使用 fast-glob 库。它返回匹配的文件路径列表,支持 ignore 模式和最大结果数限制。

GrepTool 使用正则表达式搜索文件内容,底层使用 ripgrep。它支持多种输出模式(文件名、内容行、计数),以及丰富的过滤选项(文件类型、glob 模式等)。

这两个工具的设计体现了"专门化优于通用化"的原则。虽然 BashTool 也可以通过 findgrep 命令实现类似功能,但专门的搜索工具有几个优势:

  1. 结构化输出:搜索工具返回结构化的结果列表,而非 Shell 命令的文本输出。模型可以更准确地解析结构化数据。
  2. 权限控制:搜索工具默认是只读的,权限检查更宽松。如果每次搜索都需要通过 BashTool,用户将面临更多的权限确认提示。
  3. 性能优化:专门的搜索工具可以针对特定场景优化(如限制结果数量、并行搜索),而 Shell 命令的优化空间有限。

值得注意的是,当 Ant 原生构建中嵌入了专用的快速搜索工具时,GlobTool 和 GrepTool 会被禁用,因为 Shell 中的 findgrep 命令已经被别名为这些快速工具,BashTool 可以直接使用它们。

AgentTool:子智能体入口

AgentTool 是 Claude Code 实现多智能体协作的核心工具。它允许主智能体生成子智能体来处理子任务。子智能体拥有独立的上下文窗口和工具集,执行完成后将结果返回给主智能体。

AgentTool 在工具系统中有几个特殊属性: - 它可能被标记为 alwaysLoad,确保在 ToolSearch 启用时仍然在第一轮可见。这是因为子智能体是处理复杂任务的关键能力,不应该被延迟发现机制隐藏。 - 子智能体通过 createSubagentContext 创建独立的 ToolUseContext,继承父上下文的部分状态(如权限规则)但拥有独立的消息列表。这种"继承但不共享"的模式确保了子智能体不会意外修改父智能体的状态。 - 子智能体的结果通过 TaskOutputTool 暴露给主智能体。

交叉引用: 子智能体的完整架构设计将在第三部分扩展篇中深入分析,包括上下文隔离策略、权限冒泡机制和结果传递协议。


3.4 工具编排引擎

工具编排引擎是工具系统的"指挥中心"——它决定了多个工具调用如何被调度、执行和结果收集。一个好的编排引擎需要在三个目标之间取得平衡:并行性(尽可能并行执行以提高速度)、安全性(避免并发执行导致的数据竞争)和顺序性(保证结果的产出顺序与请求顺序一致)。

runTools() 函数与并发分区

工具编排的核心逻辑在工具编排模块中。runTools 函数是一个异步生成器,负责调度一批工具调用的执行。

它的调度策略基于 并发分区(concurrency partitioning):

  1. 首先将所有工具调用按顺序划分为批次。
  2. 每个批次要么是一组连续的并发安全工具,要么是一个单独的非安全工具。
  3. 并发安全批次并行执行。
  4. 非安全批次串行执行。

分区算法的核心逻辑:遍历所有工具调用,检查每个工具的并发安全属性。如果当前工具安全且前一个批次也安全,则合并到同一批次;否则开启新批次。

这个算法可以用一个流水线来类比:想象一个工厂有多个工位。有些工序是独立的(如同时检查多个零件的质量),可以并行进行;有些工序必须严格按顺序(如先组装再测试),不能跳跃。并发分区算法就是自动识别哪些工序可以并行、哪些必须串行的调度器。

举例来说,如果模型请求了四个工具调用:[Read(a.ts), Read(b.ts), Bash(ls), Read(c.ts)],分区结果为:

flowchart LR
    subgraph input["输入序列"]
        direction LR
        t1["Read(a.ts)<br/>安全=是"] ~~~ t2["Read(b.ts)<br/>安全=是"] ~~~ t3["Bash(ls)<br/>安全=否"] ~~~ t4["Read(c.ts)<br/>安全=是"]
    end

    subgraph b1["Batch 1: 并发安全 = true — 并行执行"]
        direction LR
        b1a["Read(a.ts)"] ~~~ b1b["Read(b.ts)"]
    end

    subgraph b2["Batch 2: 并发安全 = false — 串行执行"]
        b2a["Bash(ls)"]
    end

    subgraph b3["Batch 3: 并发安全 = true — 并行执行"]
        b3a["Read(c.ts)"]
    end

    input --> b1 --> b2 --> b3

    classDef safe fill:#f0fdf4,stroke:#22c55e,stroke-width:2px,color:#166534
    classDef unsafe fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#991b1b
    class t1,t2,t4,b1,b1a,b1b,b3,b3a safe
    class t3,b2,b2a unsafe
flowchart LR
    subgraph timeline["执行时间线"]
        direction LR
        batch1["Batch 1: Read(a.ts) ‖ Read(b.ts)"]
        batch2["Batch 2: Bash(ls)"]
        batch3["Batch 3: Read(c.ts)"]
    end

    batch1 --> batch2 --> batch3

    note["注意:Batch 3 必须等 Batch 2 完成<br/>因为 Bash 可能有副作用"]

    classDef batch fill:#f0f7ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a5f
    class batch1,batch2,batch3 batch

并发执行的并发度上限由环境变量控制,默认为 10。

设计洞察: 为什么 Read(c.ts) 不能和 Bash(ls) 放在同一个批次?因为 Bash 命令可能有副作用——它可能创建新文件、修改文件内容或改变目录结构。如果在 Bash 执行的同时读取文件,Read 可能读到执行前的旧数据或执行后的新数据,导致不可预测的行为。串行执行确保了 Read(c.ts) 看到的是 Bash(ls) 执行完成后的确定状态。

StreamingToolExecutor 流式执行

StreamingToolExecutorrunTools 的增强版本,它不等待模型响应完全结束就开始执行工具,而是在流式接收到工具调用块时就立即启动执行。

这个设计的影响是显著的。假设模型在一个响应中请求了五个工具调用,每个工具执行需要 1 秒。传统模式下,模型生成完整响应需要 2 秒(流式输出时间),然后批量执行工具需要 5 秒,总计 7 秒。流式执行模式下,第一个工具在模型输出开始后约 0.4 秒(生成第一个 tool_use 块的时间)就开始执行,后续工具陆续启动,总计约 3 秒——速度提升超过 50%。

每个被追踪的工具拥有四阶段状态机:

stateDiagram-v2
    [*] --> queued : 工具入队
    queued --> executing : 执行条件满足
    executing --> completed : 执行完成
    completed --> yielded : 顺序轮到输出
    yielded --> [*] : 生命周期结束

    note_right of queued : 等待执行条件满足
    note_right of executing : 检查并发条件<br/>无工具在执行或全部并发安全时启动
    note_right of completed : 等待顺序轮到输出
    note_right of yielded : 结果已产出
  • queued:工具已入队,等待执行条件满足。
  • executing:正在执行。执行前会检查并发条件:只有当没有工具在执行,或所有执行中的工具都是并发安全时,才允许开始执行。
  • completed:执行完成,结果已收集。但尚未 yield 给上层(需要维持顺序)。
  • yielded:结果已产出,工具生命周期结束。

StreamingToolExecutor 的关键设计决策:

  1. 顺序保证:即使在流式执行中工具可以并行完成,结果的 yield 仍然保持与请求相同的顺序。结果收集函数在遍历工具列表时,遇到未完成的非安全工具就会停止,确保顺序约束不被违反。这是并行性与一致性之间的精妙平衡——允许并行执行以提高速度,但保证结果呈现的顺序性以简化上层处理逻辑。

  2. 错误传播:BashTool 执行失败会取消所有并行兄弟工具。非 Bash 工具的错误不会传播——因为读取/搜索操作通常是独立的。这个区分很重要:Bash 命令的失败通常意味着环境出了问题(如磁盘满了、网络断了),此时继续执行其他命令很可能也会失败。而文件读取或搜索操作的失败通常是局部的(如文件不存在、模式不匹配),不影响其他操作。

  3. 进度即时产出:工具执行中的进度消息绕过顺序约束,立即 yield 给上层。这使得 UI 可以实时显示工具执行进度,而不必等待前面的工具完成。这个设计体现了用户体验的优先级——进度消息是"提示性"的,不需要严格的顺序保证;而结果消息是"事实性"的,必须保持顺序。

  4. 丢弃机制:当流式回退发生时(模型切换到 fallback 模型),标记所有待执行和执行中的工具为废弃,避免过时的结果泄漏。这相当于一个"紧急刹车"——当模型决定改变策略时,所有基于旧策略的工具调用结果都应该被丢弃。

  5. 信号传播:每个工具执行使用独立的子取消控制器,形成层级化的取消信号链。兄弟工具的错误或用户的 Ctrl+C 都会通过信号传播到正确的工具。这种层级化的信号传播确保了取消操作的精确性——取消一个工具不会意外影响不相关的其他工具。

反模式警告: 如果你正在构建自己的工具编排系统,避免使用单一的 AbortController 来管理所有工具的取消。当工具 A 失败需要取消工具 B 时,不应该同时取消完全无关的工具 C。层级化的取消信号是正确的设计。

工具状态机在对话主循环中的集成

回到对话主循环,工具执行的状态与对话循环的状态紧密集成:

  1. 流式执行路径:当流式工具执行功能启用时,创建 StreamingToolExecutor。在流式接收工具调用块时,立即将工具加入执行队列,同时检查是否有已完成的结果可以立即 yield。这实现了"边接收边执行"的流水线模式。

  2. 批量执行路径:当流式执行不可用时,使用传统的批量执行函数在模型响应完全结束后批量执行所有工具。这是流式执行的后备方案,保证了在流式功能被禁用或出错时系统仍然可以正常工作。

  3. 上下文传播:工具执行后可能修改上下文(如文件缓存更新),这些修改传播回对话循环,影响后续工具的执行环境。上下文传播是"一致性"的关键——如果工具 A 写入了文件但缓存没有更新,后续的工具 B 可能基于旧缓存做出错误的决策。

交叉引用: 流式执行路径与第 2 章的对话主循环阶段三和阶段四紧密集成。对流式执行的理解需要结合对话主循环的整体流程图来把握。


实战练习

练习 1:实现一个自定义工具

使用 buildTool 工厂函数创建一个简单的工具。要求: - 定义 Zod schema,包含一个 path 字段(字符串类型) - 实现 call 方法,返回指定路径的文件信息 - 正确标记 isReadOnlyisConcurrencySafe - 实现 renderToolUseMessagerenderToolResultMessage

对比你的实现与 FileReadTool 的差异,理解 buildTool 默认值的作用。

思考题: 你的自定义工具的 isConcurrencySafe 应该标记为 true 还是 false?如果标记错误(只读工具标记为 false,或写入工具标记为 true),分别会导致什么问题?

练习 2:分析并发分区策略

给定以下工具调用序列:

[GlobTool(*.ts), GrepTool(pattern), BashTool(npm test), FileReadTool(a.ts), FileEditTool(a.ts), GlobTool(*.json)]

手动执行 partitionToolCalls 的逻辑,画出批次划分结果。然后思考:为什么 FileEditTool 和 GlobTool 不能放在同一个并发批次?

详细分析: 画出每个工具的安全标记,然后逐步模拟分区算法的决策过程。最终答案应该是:

Batch 1 (并发安全): [GlobTool(*.ts), GrepTool(pattern)]   -- 并行执行
Batch 2 (非安全):   [BashTool(npm test)]                    -- 串行执行
Batch 3 (非安全):   [FileReadTool(a.ts)]                    -- 串行执行(受 Batch 2 影响不能并入 Batch 1)
Batch 4 (非安全):   [FileEditTool(a.ts)]                    -- 串行执行
Batch 5 (并发安全): [GlobTool(*.json)]                      -- 可并行(但只有一个工具)

练习 3:追踪 StreamingToolExecutor 的生命周期

在 StreamingToolExecutor 的工具入队、工具执行和结果获取环节设置断点。发送一个触发多个并行工具调用的请求(如"搜索所有 TODO 注释并读取相关文件"),观察工具状态如何从 queued 到 executing 到 completed 再到 yielded,以及进度消息如何即时产出。

扩展观察: 在工具执行期间按 Ctrl+C,观察取消信号如何从用户传播到各个工具。注意层级化的取消控制器如何确保只有正在执行的工具被取消,而已经 completed 的工具结果不受影响。

练习 4:评估延迟发现的性能影响

如果你的环境接入了 MCP 服务器,观察以下两种情况下的 API 调用 token 消耗差异: - 禁用延迟发现(所有工具 schema 都在初始 prompt 中) - 启用延迟发现(只有工具名称列表在初始 prompt 中)

记录两种情况下的 input token 数量差异,计算延迟发现为你节省了多少 token。


关键要点

  1. 五要素协议是工具系统的 DNA:名称、Schema、权限、执行、渲染。每个工具都围绕这五个维度定义自己,而 buildTool 的默认值机制让简单工具只需关注核心逻辑。这个协议的设计哲学是"显式声明,安全默认"——工具必须主动声明自己是安全的,否则默认为不安全。

  2. 死代码消除保障构建安全:通过环境变量和功能开关的条件导入,确保内部工具不会泄漏到外部构建。这是 Agent 系统在多租户环境下的重要工程实践。结合第 1 章的"渐进式能力扩展"原则,死代码消除确保了不同产品形态可以共享同一份代码库。

  3. 并发分区是性能的关键isConcurrencySafe 的判断决定了工具能否并行执行。正确标记只读工具为并发安全,可以让 Agent 在单轮中同时执行多个搜索/读取操作,大幅减少响应时间。但错误标记的代价是数据竞争和不可预测的行为——这是一个需要谨慎权衡的设计决策。

  4. StreamingToolExecutor 是零等待的工具调度器:在模型还在生成 tool_use 块时就开始执行工具,通过四阶段状态机和顺序保证,在并行性和一致性之间取得平衡。它是"异步流式优先"设计原则在工具系统层面的完美体现。

  5. 工具系统的可扩展性来自类型契约Tool<Input, Output, Progress> 的泛型设计让每个工具有独立的类型空间,而 ToolUseContext 提供了统一的执行环境。添加新工具不需要修改编排引擎的代码——这正是第 1 章提到的"渐进式能力扩展"原则在工具层面的落地。

在下一章中,我们将深入 Agent 的安全护栏——权限管线。如果说工具系统给了 Agent 行动的能力,那么权限管线决定了 Agent 行动的边界。理解权限管线的设计,你将知道 Claude Code 是如何在"自主执行"和"安全保障"之间找到精确平衡的。