第四章:工具系统——Agent 的双手
Agent 循环是大脑,工具系统就是双手。LLM 只能"想",是工具让它能"做"。
本章深入Tool.ts、tools.ts、toolExecution.ts、StreamingToolExecutor.ts等核心文件,
拆解工具从定义、注册、发现、权限检查、执行到结果回传的完整生命周期。
目录
- 4.1 工具系统是什么?
- 4.2 核心文件关系图
- 4.3 Tool 类型定义:一个工具长什么样?
- 4.4 buildTool:安全默认值工厂
- 4.5 工具注册表:tools.ts
- 4.6 工具目录结构:微模块化
- 4.7 工具执行管线:从 LLM 输出到工具结果
- 4.8 StreamingToolExecutor:边流式边执行
- 4.9 工具编排的备选方案:toolOrchestration.ts
- 4.10 并发控制:哪些工具可以并行?
- 4.11 错误级联:Bash 错误取消兄弟工具
- 4.12 中断行为:用户新消息时怎么办?
- 4.13 权限系统概览
- 4.14 工具钩子:Pre/Post 扩展点
- 4.15 MCP 工具:动态扩展边界
- 4.16 ToolSearch:延迟加载大量工具
- 4.17 子 Agent 的工具限制
- 4.18 工具结果处理:大结果持久化
- 4.19 工具分类体系
- 4.20 典型工具剖析:FileReadTool vs BashTool
- 4.21 关键工程思想总结
4.1 工具系统是什么?
在 Agent 架构中,LLM 本身只能生成文本。要让它真正与外部世界交互——读文件、执行命令、搜索代码——就需要工具(Tools)。
生成 tool_use 块"] --> ToolSys["工具系统"] ToolSys --> Read["Read
读文件"] ToolSys --> Edit["Edit
编辑文件"] ToolSys --> Bash["Bash
执行命令"] ToolSys --> Grep["Grep
搜索内容"] ToolSys --> Agent["Agent
启动子Agent"] ToolSys --> MCP["MCP 工具
外部扩展"] Read --> Result["工具结果
返回给 LLM"] Edit --> Result Bash --> Result Grep --> Result Agent --> Result MCP --> Result
Claude Code 的工具系统有三个关键设计原则:
| 原则 | 说明 |
|---|---|
| 工具即数据 | 每个工具不是一个"类",而是一个符合 Tool 接口的普通对象 |
| 安全默认 | 默认假设工具不可并发、会写入、需要权限检查(fail-closed) |
| 统一接口 | 内置工具和 MCP 外部工具共用同一个 Tool 接口,对 LLM 一视同仁 |
4.2 核心文件关系图
★ Tool 类型定义
buildTool() 工厂"] ToolsTs["tools.ts
工具注册表
getAllBaseTools()"] end subgraph Implementation["实现层 (tools/)"] FR["FileReadTool/"] FE["FileEditTool/"] FW["FileWriteTool/"] BT["BashTool/
★ 最复杂 (640KB)"] GT["GrepTool/"] GL["GlobTool/"] AT["AgentTool/
子 Agent 生成"] WF["WebFetchTool/"] SK["SkillTool/"] More["... 40+ 工具"] end subgraph Execution["执行层 (services/tools/)"] TE["toolExecution.ts
runToolUse()
checkPermissionsAndCallTool()"] STE["StreamingToolExecutor.ts
★ 流式并发执行器"] TO["toolOrchestration.ts
备选批量执行器"] TH["toolHooks.ts
Pre/Post 工具钩子"] end subgraph External["外部扩展"] MCPTool["MCPTool/
MCP 工具模板"] MCPClient["services/mcp/client.ts
动态工具发现"] end ToolTs --> ToolsTs ToolsTs --> TE Implementation --> TE TE --> STE TE --> TO TH --> TE MCPClient --> MCPTool MCPTool --> ToolsTs
4.3 Tool 类型定义:一个工具长什么样?
Tool 类型定义在 src/Tool.ts:362,是一个泛型类型 Tool<Input, Output, P>,约 330 行。可以按职责分为四组:
核心身份
1 | // src/Tool.ts:362 |
生命周期方法
1 | { |
分类方法
1 | { |
渲染方法
1 | { |
设计洞察:一个
Tool对象同时承担了"执行引擎"和"UI 组件"两重角色。这避免了把渲染逻辑分散到其他地方,但也让每个工具文件变得较大。
4.4 buildTool:安全默认值工厂
buildTool() 是所有工具的"出生证",定义在 src/Tool.ts:757。它接收一个 ToolDef(可以省略部分方法),补齐安全默认值后返回完整的 Tool 对象。
(可省略部分方法)"] --> BuildTool["buildTool()"] BuildTool --> CompleteTool["完整的 Tool 对象"] subgraph Defaults["安全默认值 (fail-closed)"] D1["isEnabled → true"] D2["isConcurrencySafe → false
(假设不安全)"] D3["isReadOnly → false
(假设会写入)"] D4["isDestructive → false"] D5["checkPermissions → allow
(交给通用权限系统)"] D6["toAutoClassifierInput → ''
(跳过分类器)"] D7["userFacingName → name"] end BuildTool -.-> Defaults
关键设计:fail-closed 默认值
1 | // src/Tool.ts:757 |
为什么选择 fail-closed?
- 如果一个新工具忘记实现
isConcurrencySafe,它会被当作不可并发处理 → 串行执行,宁可慢不可错 - 如果忘记实现
isReadOnly,它会被当作写入工具处理 → 需要权限确认 - 这避免了"新工具上线后并发执行导致数据损坏"的风险
4.5 工具注册表:tools.ts
src/tools.ts 是工具的"户口本",约 390 行。它管理工具的注册、过滤和组装:
三层工具获取
所有可能的工具(40+个)
受 feature flag 和环境变量控制"] GT["getTools(permContext)
过滤后的可用工具
移除 deny 规则匹配的工具
移除 isEnabled()=false 的工具"] ATP["assembleToolPool(permContext, mcpTools)
最终工具池
合并内置工具 + MCP 工具
按名称去重(内置优先)
分区排序保证缓存稳定"] GAB --> GT --> ATP
getAllBaseTools():全量工具清单
1 | // src/tools.ts:193 |
注意工具的加载方式:
| 加载方式 | 示例 | 原因 |
|---|---|---|
| 静态 import | BashTool, FileReadTool |
核心工具,始终需要 |
| 条件 require() | REPLTool, SleepTool |
通过 feature() 门控,构建时可裁剪 |
| 懒加载 getter | getTeamCreateTool() |
打破循环依赖 |
| 环境变量控制 | ConfigTool (USER_TYPE === 'ant') |
仅内部用户可用 |
assembleToolPool():合并 + 排序
1 | // src/tools.ts:345 |
为什么分区排序很重要? API 服务端会在内置工具和 MCP 工具之间放置缓存断点。如果排序不稳定(MCP 工具插入到内置工具中间),每次 MCP 工具变化都会导致所有缓存失效。
4.6 工具目录结构:微模块化
每个工具是 src/tools/ 下的一个独立目录:
简单工具:FileReadTool(5 个文件,~70KB)
1 | tools/FileReadTool/ |
复杂工具:BashTool(20 个文件,~640KB)
1 | tools/BashTool/ |
对比启示:BashTool 的代码量是 FileReadTool 的 9 倍,因为它需要分析任意 Shell 命令的安全性。这说明"执行任意命令"的工具在 Agent 系统中是安全挑战最大的部分。
4.7 工具执行管线:从 LLM 输出到工具结果
当 LLM 返回一个 tool_use 块时,它经历以下 8 步处理:
{name, id, input}"] --> A A["① 查找工具
findToolByName()"] --> B B["② Zod Schema 校验
inputSchema.safeParse(input)"] --> C C["③ 自定义校验
tool.validateInput()"] --> D D["④ PreToolUse 钩子
runPreToolUseHooks()"] --> E E["⑤ 权限检查
checkPermissions → canUseTool"] --> F F["⑥ 执行工具
tool.call(args, context, ...)"] --> G G["⑦ PostToolUse 钩子
runPostToolUseHooks()"] --> H H["⑧ 结果映射
mapToolResultToToolResultBlockParam()"] B -- "校验失败" --> Err1["返回 InputValidationError"] C -- "校验失败" --> Err2["返回自定义错误"] D -- "钩子拒绝" --> Err3["返回钩子拒绝消息"] E -- "权限拒绝" --> Err4["返回权限拒绝消息"] style A fill:#e1f5fe style F fill:#fff3e0 style H fill:#e8f5e9
核心函数:checkPermissionsAndCallTool()
这是 src/services/tools/toolExecution.ts:599 中约 700 行的函数,是工具执行的"大脑":
1 | async function checkPermissionsAndCallTool( |
一个精妙的细节:第 ③ 步的"投机启动 Bash 安全分类器"。当检测到是 Bash 工具调用时,立即在后台启动安全分类,这样它可以与钩子执行和权限对话框并行,而不是串行等待。
4.8 StreamingToolExecutor:边流式边执行
StreamingToolExecutor(src/services/tools/StreamingToolExecutor.ts,531 行)是工具执行的主引擎。它的核心创新是:LLM 还在流式输出时,已完成解析的工具就开始执行了。
状态机
并发条件满足 executing --> completed : tool.call() 完成 completed --> yielded : getCompletedResults()
按原始顺序交付 queued --> completed : 已被中止
(sibling_error/
user_interrupted) executing --> completed : 执行中被中止 note right of queued : 等待并发条件 note right of executing : tool 正在运行 note right of completed : 结果已就绪 note right of yielded : 已交付给 query()
核心类结构
1 | // src/services/tools/StreamingToolExecutor.ts:40 |
与 query.ts 的集成时序
关键设计:结果按原始顺序交付
即使 Tool B 比 Tool A 先完成,结果也会按 LLM 输出的顺序交付:
1 | // src/services/tools/StreamingToolExecutor.ts:412 |
4.9 工具编排的备选方案:toolOrchestration.ts
当流式工具执行被禁用时,系统回退到 src/services/tools/toolOrchestration.ts(189 行)的批量执行器:
按并发安全性分批"] Partition --> Batch1["批次 1: [Read, Grep, Glob]
并发安全 → 并行执行"] Partition --> Batch2["批次 2: [Edit]
非并发 → 串行执行"] Partition --> Batch3["批次 3: [Read, Read]
并发安全 → 并行执行"] Batch1 --> Run1["runToolsConcurrently()
Promise.race, 最大并发 10"] Batch2 --> Run2["runToolsSerially()
逐个执行"] Batch3 --> Run3["runToolsConcurrently()"]
分批规则(partitionToolCalls):
1 | // src/services/tools/toolOrchestration.ts:91 |
最大并发数通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 控制,默认 10。
4.10 并发控制:哪些工具可以并行?
特殊情况:BashTool 的动态判断
BashTool 的 isConcurrencySafe 不是固定的,而是根据命令内容动态判断:
git status、ls -la、cat file.txt→ 可并发(只读命令)npm install、rm -rf、git commit→ 不可并发(写入命令)
判断逻辑在 readOnlyValidation.ts(68KB)中,通过解析 Shell 命令的 AST 实现。
StreamingToolExecutor 的并发判定
1 | // src/services/tools/StreamingToolExecutor.ts:129 |
4.11 错误级联:Bash 错误取消兄弟工具
当多个工具并行执行时,如果一个 Bash 工具出错,会触发兄弟取消:
siblingAbortController.abort() STE--xBashB: 收到 abort 信号,取消 Note over STE: BashB 收到合成错误:
"Cancelled: parallel tool call
Bash(mkdir /tmp/a…) errored" Read-->>STE: 正常完成 Note over Read: Read 是非 Bash 工具
不会被 Bash 错误级联取消
为什么只有 Bash 错误会级联?
1 | // src/services/tools/StreamingToolExecutor.ts:359 |
Bash 命令常有隐式依赖链(mkdir 失败 → 后续 cd 无意义),而 Read、WebFetch 等工具是独立的,一个失败不影响其他。
4.12 中断行为:用户新消息时怎么办?
当工具正在执行时用户提交了新消息,每个工具可以声明自己的中断行为:
| 行为 | 含义 | 默认值 |
|---|---|---|
'cancel' |
停止工具,丢弃结果,接收合成错误消息 | - |
'block' |
继续运行,用户新消息排队等待 | 是(默认) |
1 | // src/services/tools/StreamingToolExecutor.ts:210 |
4.13 权限系统概览
权限系统是工具执行管线中的"安全闸门",详细内容将在后续章节深入。这里先给出整体架构:
getDenyRuleForTool()"] -- 匹配 --> Deny["拒绝"] Step1 -- 不匹配 --> Step2 Step2["② 全局 Ask 规则
getAskRuleForTool()"] -- 匹配 --> Ask["询问用户"] Step2 -- 不匹配 --> Step3 Step3["③ 工具特定权限
tool.checkPermissions()"] --> Step4 Step4["④ 规则匹配
checkRuleBasedPermissions()"] --> Step5 Step5["⑤ 权限模式判断"] Step5 -- "bypassPermissions" --> Allow["放行"] Step5 -- "plan" --> PlanCheck{"只读工具?"} PlanCheck -- "是" --> Allow PlanCheck -- "否" --> Ask Step5 -- "default" --> Ask Step5 -- "auto" --> Classifier["AI 安全分类器"] Classifier --> Allow Classifier --> Ask
五种权限模式:
| 模式 | 行为 | 典型场景 |
|---|---|---|
default |
危险操作询问用户 | 日常使用 |
plan |
只读自动通过,写入需确认 | 规划阶段 |
acceptEdits |
文件编辑自动通过,Shell 仍询问 | 信任文件修改 |
bypassPermissions |
全部放行 | 完全信任(CI/自动化) |
auto |
AI 分类器自动判断 | 实验性功能 |
4.14 工具钩子:Pre/Post 扩展点
工具钩子(src/services/tools/toolHooks.ts)提供了三个扩展点:
───────────
· 允许/拒绝/询问
· 修改输入
· 添加上下文
· 阻止执行"] --> Exec["tool.call()
执行工具"] Exec --> Post["PostToolUse 钩子
───────────
· 阻止后续循环
· 修改 MCP 输出
· 添加上下文"] Exec -- "失败" --> PostFail["PostToolUseFailure 钩子
───────────
· 记录失败信息
· 清理副作用"]
钩子与权限的交互规则(resolveHookPermissionDecision):
1 | 钩子说 allow + 规则说 deny → deny 胜出(安全优先) |
这个"deny 总是胜出"的设计确保了:即使第三方钩子试图绕过安全规则,系统也能保持安全。
4.15 MCP 工具:动态扩展边界
MCP(Model Context Protocol)让 Claude Code 可以动态接入外部工具服务:
services/mcp/client.ts"] Template["MCPTool 模板
tools/MCPTool/MCPTool.ts"] Fetch["fetchToolsForClient()
发现并创建工具"] Pool["assembleToolPool()
合并到工具池"] end S1 --> Client S2 --> Client S3 --> Client Client --> Fetch Fetch --> Template Template --> Pool Pool --> LLM["传给 LLM 选择"]
MCP 工具的创建过程
1 | // src/services/mcp/client.ts (fetchToolsForClient) |
命名规则:mcp__<服务器名>__<工具名>,例如 mcp__github__create_pr、mcp__jira__list_issues。
4.16 ToolSearch:延迟加载大量工具
当工具数量很多(内置 + MCP 可能超过 50 个)时,把所有工具的完整 schema 放入提示词会浪费 token。ToolSearch 实现了延迟加载:
只看到核心工具 + ToolSearch"] Start --> Need{"需要特定工具?"} Need -- "是" --> Search["调用 ToolSearch
query: 'select:NotebookEdit'
或关键词搜索"] Search --> Load["加载完整 schema
加入 discovered 集合"] Load --> Use["下一轮可以使用该工具"] Need -- "否" --> Direct["直接用已加载的工具"]
延迟加载的工具标记了 shouldDefer: true。当 LLM 试图直接调用一个延迟工具(未先通过 ToolSearch 发现),系统会返回一个友好的错误提示:
1 | This tool's schema was not sent to the API — it was not in the discovered-tool set. |
4.17 子 Agent 的工具限制
当 AgentTool 启动子 Agent 时,子 Agent 不能使用所有工具:
+ SendMessage
+ SyntheticOutput"] end Main --> SubAgent Main --> AsyncAgent Main --> Coordinator
三种工具限制集:
1 | // src/constants/tools.ts |
设计原因:
- 禁用
Agent防止递归无限创建子 Agent - 禁用
AskUserQuestion因为子 Agent 无法与用户交互 - 禁用
ExitPlanMode、EnterPlanMode因为计划模式是主线程的抽象
4.18 工具结果处理:大结果持久化
工具的输出可能非常大(比如 cat 一个大文件)。为了避免撑爆上下文窗口,系统有大结果持久化机制:
maxResultSizeChars?"} Check -- "否" --> Direct["直接放入对话消息"] Check -- "是" --> Persist["持久化到磁盘文件"] Persist --> Preview["返回预览 + 文件路径
给 LLM 看摘要"]
每个工具可以设置不同的 maxResultSizeChars:
| 工具 | maxResultSizeChars | 说明 |
|---|---|---|
| FileReadTool | Infinity |
永不持久化(自身已有行数限制) |
| BashTool | 100_000 |
Shell 输出可能很大 |
| GrepTool | 100_000 |
搜索结果可能很多 |
| 一般工具 | 100_000 |
默认值 |
FileReadTool 设为 Infinity 有特殊原因:如果持久化 Read 的结果到文件,LLM 可能会试图 Read 那个持久化文件,形成 Read → file → Read 循环。
4.19 工具分类体系
Claude Code 从多个维度对工具进行分类,每个维度影响不同的行为:
isConcurrencySafe()"] Tool --> D2["读写性
isReadOnly()"] Tool --> D3["破坏性
isDestructive()"] Tool --> D4["搜索/读取分类
isSearchOrReadCommand()"] Tool --> D5["加载策略
shouldDefer / alwaysLoad"] Tool --> D6["中断行为
interruptBehavior()"] D1 --> E1["影响:并行 vs 串行执行"] D2 --> E2["影响:权限检查严格程度"] D3 --> E3["影响:MCP 工具标注"] D4 --> E4["影响:UI 折叠/分组显示"] D5 --> E5["影响:是否需要 ToolSearch"] D6 --> E6["影响:用户中断时的处理"]
搜索/读取分类(UI 折叠)
1 | isSearchOrReadCommand?(input): { |
这个分类决定了 UI 中工具调用的折叠显示行为——连续的多个搜索/读取操作可以合并为一个紧凑的条目,避免刷屏。
4.20 典型工具剖析:FileReadTool vs BashTool
FileReadTool:简洁的只读工具
1 | // src/tools/FileReadTool/FileReadTool.ts(简化) |
FileReadTool 的特点:简单、安全、可并发。
BashTool:最复杂的工具
BashTool(160KB)的复杂性来自它需要处理任意 Shell 命令:
1 | BashTool 的内部分层: |
BashTool 的 isConcurrencySafe 是动态的:
1 | isConcurrencySafe(input) { |
4.21 关键工程思想总结
思想 1:工具即数据(Tool as Data)
工具不是类实例,而是符合接口的普通对象。这让工具的注册、过滤、排序、序列化都很简单:
1 | // 过滤只读工具 |
思想 2:Fail-Closed 默认值
新工具忘记声明并发安全性时,系统假设它不安全。宁可降低性能(串行执行),不冒数据风险。
思想 3:投机并行(Speculative Parallelism)
1 | Bash 工具调用到达 |
安全分类器的启动不等待前序步骤,而是投机并行运行,减少用户等待时间。
思想 4:有选择的错误级联
只有 Bash 错误取消兄弟工具,因为 Bash 命令常有隐式依赖链。而 Read、Grep 等独立工具的错误不影响其他。这是对领域知识的编码——不是所有错误都一样严重。
思想 5:分区排序保证缓存稳定
工具列表的排序策略(内置工具连续排列在前,MCP 工具在后)不是为了好看,而是为了 LLM 提示词缓存的命中率。如果排序不稳定,每次 MCP 配置变化都会让所有用户的缓存失效。
思想 6:延迟加载减少 Token 消耗
ToolSearch 机制让 LLM 首轮只看到核心工具,需要时再加载其他工具。这在工具数量很多时(40+ 内置 + N 个 MCP)显著节省了提示词的 token 消耗。
思想 7:渲染与执行同源
每个工具自带渲染方法(renderToolUseMessage、renderToolResultMessage 等)。这避免了"在一个地方加工具,在另一个地方加 UI"的分裂,但也意味着每个工具需要同时理解 React 和业务逻辑。
思想 8:钩子不可绕过安全规则
PreToolUse 钩子可以 allow/deny/ask,但 deny 规则总是胜出。即使第三方插件的钩子返回 allow,如果安全规则说 deny,最终结果还是 deny。安全策略是不可商量的。
思想 9:统一接口消除内外差异
内置工具和 MCP 工具共用同一个 Tool 接口。对 LLM 来说,Read 和 mcp__github__create_pr 没有本质区别——都是可以调用的工具。这种统一让工具池的管理、排序、权限检查都只需一套逻辑。
思想 10:微模块化组织
每个工具是独立的目录,内部按职责拆分文件(定义、权限、安全、UI、提示词)。这让不同的工具可以独立发展,一个工具的修改不影响其他。BashTool 可以有 20 个文件 640KB,FileReadTool 只需 5 个文件 70KB,各取所需。
下一章预告:第五章将深入权限与安全系统——Claude Code 如何在"让 Agent 自由行动"和"保护用户安全"之间找到平衡。
