Harness Engineering
第5章 Tool Design:给 Agent 造趁手的兵器
第5章 Tool Design:给 Agent 造趁手的兵器
“Tools extend reasoning into action. A bad tool traps a smart model; a good tool amplifies a mediocre one.” —— 杨艺韬
本章要点
- Tool Design 不是写 API,是给概率推理引擎设计认知接口
- 粒度平衡:按意图拆分,不按技术实现拆分;40 个工具是工业界的黄金甜区
- 描述即接口:模型对工具的全部认知来自 description + schema
- 安全三分类:Read-only / Write / Destructive——对应不同的确认策略
- 幂等性是一级原则——设计工具时先问”能重试吗”
- 返回格式即成本:截断 + 告知截断是必须的
- 六大反模式:瑞士军刀 / 弱描述 / 原始大 blob / 无限制返回 / 隐式依赖 / 多态参数
5.1 Tool 的本质:让模型长出手脚
大语言模型天生只有一种能力——生成文本。无论它的推理能力多强,面对”帮我创建一个文件”这种请求,它能做的只是输出一段文字描述应该如何创建文件。要让模型真正”做事”,必须给它提供可调用的工具(Tool)。
在 Agent 系统中,一个 Tool 本质上由四部分组成:
interface Tool {
name: string // 工具名称,模型用它来选择调用哪个工具
description: string // 工具描述,模型理解工具能力的唯一依据
parameters: JSONSchema // 参数模式,定义输入的结构和约束
execute: (params) => Result // 执行逻辑,Harness 层负责实际运行
}
一个工具从定义到执行的完整生命周期:
sequenceDiagram
participant H as Harness
participant L as LLM
participant T as Tool
participant S as Sandbox
H->>L: 发送工具定义 (name + description + schema)
Note over L: 模型根据 description 选择工具
L->>H: 返回工具调用 (name + params)
H->>H: 参数校验 (JSON Schema)
H->>H: 权限检查
H->>H: 安全分级判定
alt 需要确认
H->>H: 请求用户确认
end
H->>S: 沙箱内执行工具
S-->>H: 原始结果
H->>H: 结果截断/摘要/格式化
H->>L: 反馈格式化结果
Note over L: 模型基于结果推断下一步
这四部分的设计质量,直接决定了 Agent 的行为质量。名字起得不好,模型选错工具;描述写得不清,模型用错场景;参数定义不严,模型传错数据;执行逻辑不健壮,系统崩在运行时。
一个常见的误解是把 Tool Design 等同于”写几个函数然后注册一下”。实际上,Tool Design 是 Harness Engineering 中最考验工程判断力的部分之一。你不是在给人类程序员设计 API,你是在给一个概率推理引擎设计交互界面——这两件事的设计约束完全不同。
人类 API 设计 vs AI Tool 设计
| 维度 | 人类 API | AI Tool |
|---|---|---|
| 文档作用 | 参考 | 决策依据 |
| 参数数量 | 可以多,文档补充 | 越少越好 |
| 嵌套深度 | 接受深层嵌套 | 扁平化 |
| 命名风格 | 技术导向 | 意图导向 |
| 错误信息 | 给人类看 | 给模型看,要能指导下一步 |
| 返回体积 | 按需取 | 强制截断+告知 |
| 重试行为 | 调用者决定 | 工具应天然幂等 |
| 副作用说明 | 可选 | 必须明确 |
5.2 粒度之争:一把瑞士军刀还是一整个工具箱
工具粒度是 Tool Design 的第一个关键决策。
太粗与太细的两端
太粗的工具——一个 do_everything(action, target, options) 包办一切——看起来简洁,实则灾难。模型需要在一个巨大的参数空间里做选择,action 和 options 之间的组合爆炸让描述无法覆盖所有用法。更糟糕的是,权限控制粒度也随之丧失:你没办法允许”读文件”但禁止”删文件”,因为它们是同一个工具的不同参数。
太细的工具——100 个微操作工具,read_line、read_char、move_cursor——则会淹没模型的选择能力。模型在选择工具时,需要把所有工具的名称和描述都放进上下文窗口。工具越多,上下文开销越大,选择准确率越低。
graph LR
subgraph Coarse["粗粒度 ❌"]
C1[do_everything]
C1 --> P1[action: read/write/delete/... ]
C1 --> P2[combinatoric param explosion]
end
subgraph Fine["细粒度 ❌"]
F1[read_line]
F2[read_char]
F3[move_cursor]
F4[100+ micro tools]
end
subgraph Balanced["平衡 ✅"]
B1[Read]
B2[Write]
B3[Edit]
B4[Grep]
B5[Glob]
B6[Bash 兜底]
B7[~40 total]
end
style Coarse fill:#fee2e2,stroke:#ef4444
style Fine fill:#fef3c7,stroke:#f59e0b
style Balanced fill:#dcfce7,stroke:#22c55e,stroke-width:2px
Claude Code 的 40 工具方案
Claude Code 的做法提供了一个值得参考的平衡点。它定义了约 40 多个工具,按功能域组织:
| 功能域 | 工具示例 | 设计思路 |
|---|---|---|
| 文件读取 | Read, Glob, Grep | 按搜索模式拆分,而非按文件类型 |
| 文件写入 | Write, Edit | 全量写入 vs 增量编辑,两种操作模式 |
| 系统交互 | Bash | 一个通用入口,覆盖所有命令行操作 |
| 网络访问 | WebFetch, WebSearch | 按意图拆分:获取内容 vs 搜索信息 |
| 笔记本 | NotebookEdit | 专用工具处理特殊格式 |
| 任务管理 | TaskCreate/Update/List | 显式状态管理 |
| Agent 编排 | Agent | 启动子 Agent |
三个经典设计决策
决策一:Read 和 Grep 为什么分开?
两者都能获取文件内容,但意图不同。Read 是”我知道要看哪个文件”,Grep 是”我不知道内容在哪个文件里”。按意图拆分工具,让模型更容易做出正确选择。
决策二:Bash 为什么是一个大工具?
命令行操作的可能性几乎无限,拆成 100 个工具不现实。Bash 作为一个”逃生舱口”存在——当专用工具覆盖不到时,模型可以退回到通用命令行。但 Claude Code 通过描述明确引导模型优先使用专用工具:在 Grep 工具的描述中写着”ALWAYS use Grep for search tasks. NEVER invoke grep or rg as a Bash command”。
决策三:Write 和 Edit 为什么分开?
全量覆盖和增量修改是两种截然不同的操作。Edit 只发送 diff,更安全、更高效,适合修改已有文件;Write 适合创建新文件或完全重写。分开设计让权限控制更精细,也让模型的意图表达更明确。
粒度设计的四条经验法则
- 按用户意图拆分工具,而非按技术实现拆分——模型以任务维度思考,工具应对齐这个维度
- 高频操作专用化——Read 的频率远高于 “打开 + 读取 + 关闭”,所以直接给一个 Read
- 低频操作走通用通道——罕见需求走 Bash,不值得专门设计工具
- 数量控制在 15-50 之间——少于 15 通常粒度过粗,多于 50 通常模型选择吃力
5.3 描述工程:Tool 的描述就是它的全部接口
对于人类开发者,一个函数的接口是它的类型签名。对于 AI 模型,一个 Tool 的接口是它的描述文本。模型不会读你的实现代码,它对工具的全部认知来自 name + description + parameters schema。
这意味着描述的质量直接决定了工具被正确使用的概率。来看一个对比:
差的描述
name: "search"
description: "Search for things"
模型看到这个描述,完全无法判断:搜什么?文件名还是文件内容?本地还是网络?返回什么格式?什么时候该用这个工具而不是其他工具?
好的描述
name: "Grep"
description: "A powerful search tool built on ripgrep.
- ALWAYS use Grep for search tasks. NEVER invoke grep or rg as a Bash command.
- Supports full regex syntax (e.g., 'log.*Error', 'function\\s+\\w+')
- Filter files with glob parameter (e.g., '*.js', '**/*.tsx')
- Output modes: 'content' shows matching lines, 'files_with_matches' shows only file paths
- Multiline matching: set multiline:true for cross-line patterns
- Use Agent tool for open-ended searches requiring multiple rounds"
好描述的五个特征
- 说明”是什么”:基于 ripgrep 的搜索工具,让模型理解能力边界
- 说明”何时用”和”何时不用”:明确告诉模型该用这个工具搜索,不要用 Bash 调 grep
- 给出使用示例:正则表达式的写法、glob 过滤的写法
- 说明关键参数的含义:output_mode 的各选项是什么意思
- 指向替代方案:复杂搜索用 Agent 工具
强指令词的魔力
Claude Code 的工具描述有一个显著特点:大量使用 MUST、NEVER、ALWAYS 等强指令词。
这不是随意的措辞选择。工具描述不是给人类看的帮助文档,而是给模型用的选择边界;边界越明确,模型越不需要在多个近似工具之间猜测。
| 描述写法 | 风险 | 更稳的写法 |
|---|---|---|
| ”can use Grep” | 只是说明能力,没有建立优先级 | ”Use Grep when searching file contents." |
| "should use Grep” | 有建议语气,但遇到 Bash 也可能摇摆 | ”Use Grep for file content search; Bash is for commands that have no dedicated tool." |
| "prefer using Grep" | "prefer” 不等于禁止替代路径 | ”ALWAYS use Grep for search; NEVER invoke grep through Bash." |
| "ALWAYS use Grep, NEVER invoke grep as Bash” | 边界清楚,但要避免和其他工具描述冲突 | 同时说明例外:只有用户明确要求 shell grep 时才用 Bash |
在描述中编码优先级
另一个重要实践是在描述中编码优先级关系。Read 工具的描述中包含这样的内容:
“Avoid using this tool to run find, grep, cat commands, unless explicitly instructed”
这在工具之间建立了清晰的优先级:有专用工具就用专用工具,Bash 是最后手段。
描述写作模板
一个实战检验过的工具描述模板:
[1. 一句话定位]: 这是什么工具,用在什么场景
[2. 强制使用/禁用规则(如果有)]:
- ALWAYS use X for ...
- NEVER use this tool for ...
[3. 关键能力点(3-5 条)]:
- 支持什么输入格式
- 支持什么输出格式
- 有什么特殊能力
[4. 参数说明(非显而易见的部分)]:
- 某参数的含义和何时使用
- 常见错误用法
[5. 替代方案]:
- 更高级场景用 X
- 更简单场景用 Y
[6. 示例(1-2 个)]:
- 典型调用
5.4 参数设计:JSON Schema 是你的契约
参数设计的核心原则是:让模型容易生成正确的参数,让 Harness 容易校验参数。
JSON Schema 是当前 Agent 生态中事实上的参数描述标准。OpenAI、Anthropic、Google 的 function calling 接口都基于它。一个好的参数 schema 应该:
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The absolute path to the file to read (must be absolute, not relative)"
},
"offset": {
"type": "number",
"description": "The line number to start reading from. Only provide if the file is too large to read at once"
},
"limit": {
"type": "number",
"description": "The number of lines to read. Only provide if the file is too large to read at once."
}
},
"required": ["file_path"]
}
五个关键设计原则
原则一:参数描述要包含约束
“must be absolute, not relative”——这种约束不写在描述里,模型就可能传一个相对路径进来。不要假设模型”应该知道”,它的行为完全由你提供的文本决定。
原则二:可选参数要说明何时提供
“Only provide if the file is too large to read at once”——这让模型知道正常情况下不需要传这个参数,避免了模型每次调用都费力填写所有字段。
原则三:避免深层嵌套
模型生成 JSON 时,嵌套越深,越容易遗漏必填字段、把字段放错层级,或者把字符串和对象混用。不要把这里写成某个固定错误率;不同模型、prompt 和 schema 都会改变结果。工程上更稳的规则是:能平铺就平铺,必须嵌套时给完整示例和 schema 校验错误回传。
一个三层嵌套的 options 对象远不如三个平铺的参数来得可靠:
// ❌ 深层嵌套(错误率高)
{
"options": {
"search": {
"pattern": "foo",
"flags": { "case_sensitive": false, "multiline": true }
}
}
}
// ✅ 扁平化(错误率低)
{
"pattern": "foo",
"case_sensitive": false,
"multiline": true
}
原则四:使用枚举约束值域
当参数只有几个合法值时,用 enum 而非 string:
{
"output_mode": {
"type": "string",
"enum": ["content", "files_with_matches", "count"],
"description": "Output mode: 'content' shows matching lines, 'files_with_matches' shows file paths, 'count' shows match counts"
}
}
枚举不仅帮助模型生成正确的值,也让 Harness 层可以在执行前做校验,把错误拦在最早的阶段。
原则五:字段名要可理解
❌ "tkn_lim" → 模型可能猜测,也可能失败
✅ "max_tokens" → 一目了然
❌ "mode" → 什么模式?
✅ "output_mode" → 输出模式
参数类型选择指南
| 需求 | 正确类型 | 陷阱 |
|---|---|---|
| 枚举值 | enum | 不要用 string |
| 布尔开关 | boolean | 不要用 string “true”/“false” |
| 整数 | integer | 不要用 number 允许 float |
| 路径 | string + 描述约束 | 没有原生类型 |
| 文件列表 | array of string | 不要用逗号分隔的 string |
| 键值对 | object 或 array of {key, value} | 后者更鲁棒 |
5.5 工具分类:读、写、毁
graph LR
subgraph Safe["🟢 只读 (自动执行)"]
R1[Read]
R2[Glob]
R3[Grep]
R4[WebSearch]
end
subgraph Caution["🟡 写入 (需谨慎)"]
W1[Write]
W2[Edit]
W3[Bash-safe]
end
subgraph Danger["🔴 破坏性 (必须确认)"]
D1[git push --force]
D2[rm -rf]
D3[Send Email/Payment]
D4[DROP TABLE]
end
Safe ---|"风险递增"| Caution
Caution ---|"风险递增"| Danger
style Safe fill:#dcfce7,stroke:#22c55e
style Caution fill:#fef3c7,stroke:#f59e0b
style Danger fill:#fee2e2,stroke:#ef4444
并非所有工具的风险等级相同。一个成熟的 Agent 系统必须对工具进行安全分类:
只读工具(Read-only)
特征:不修改任何状态。 示例:Read、Glob、Grep、WebSearch、List、Stat 策略:可以安全地自动执行,不需要人类确认。即使模型调错了,最大的损失也就是浪费了一些上下文空间。
写入工具(Write)
特征:修改系统状态但可逆(通过 git/备份可恢复) 示例:Write、Edit、Bash(大部分命令)、CreateFile 策略:需要谨慎对待——可能覆盖文件、创建不必要的内容。很多 Agent 系统在这个级别引入人类确认。
破坏性工具(Destructive)
特征:造成不可逆的后果
示例:git push --force、rm -rf、发送邮件、调用支付 API、DROP TABLE、发送短信
策略:必须有严格的保护机制。必须用户确认,并且确认要针对具体操作而非”同意所有破坏性操作”。
三层保护机制
flowchart TD
Call[工具调用请求] --> L1{只读?}
L1 -->|是| Exec[直接执行]
L1 -->|否| L2{可逆?}
L2 -->|是, 可逆| L3{当前权限模式?}
L3 -->|auto| Exec
L3 -->|默认| Confirm1[显示 diff 后确认]
L2 -->|否, 破坏性| Force[强制用户确认<br/>显示后果]
Force -->|批准| Exec
Force -->|拒绝| Block[❌ 阻止]
Confirm1 -->|批准| Exec
Confirm1 -->|拒绝| Block
style Exec fill:#dcfce7,stroke:#22c55e
style Block fill:#fee2e2,stroke:#ef4444
style Force fill:#fecaca,stroke:#dc2626
描述中编码安全规则
Claude Code 在系统提示中对此有明确的分层策略。它不只是把工具分类,而是在工具描述中直接编码安全规则:
// Bash 工具描述节选
"NEVER run destructive git commands (push --force, reset --hard, ...)
unless the user explicitly requests these actions."
"DO NOT push to the remote repository unless the user explicitly asks
you to do so"
"Never skip hooks (--no-verify) or bypass signing unless the user
has explicitly asked for it."
这种”在描述中编码安全规则”的做法比”在 Harness 层硬编码检查”更灵活。模型能够理解语境——比如用户说”帮我把这个分支强制推到远端”时,模型知道这是用户的明确授权。而纯硬编码的安全检查做不到这种语境理解。
当然,最高风险的操作不应该仅靠模型的”理解”来保护。对于真正危险的操作,Harness 层应该有独立于模型的硬性拦截。这是第 14 章”权限模型”的主题,这里只强调一点:工具的安全分类应该在设计阶段就确定,而不是事后补救。
5.6 幂等性与容错:为失败而设计
Agent 的执行环境充满不确定性。网络可能断开,文件可能被其他进程修改,命令可能超时。一个健壮的工具设计必须假设失败是常态。
幂等性是第一原则
幂等的工具可以安全地重试——执行一次和执行多次的效果相同。
Write 工具天然幂等(写同样的内容到同一个文件,结果不变),但 Bash 工具执行 echo "line" >> file.txt 就不幂等(每次追加一行)。
设计幂等工具的三个技巧
技巧一:用”设置状态”代替”改变状态”
❌ append_line(file, line) # 非幂等
✅ set_content(file, content) # 幂等
❌ increment_counter() # 非幂等
✅ set_counter(value) # 幂等
❌ add_label(ticket, label) # 可能产生重复
✅ set_labels(ticket, [label1, label2]) # 幂等
技巧二:用唯一标识做去重
如果工具创建资源,接受一个客户端 ID,重复调用不会创建重复资源:
{
"name": "create_ticket",
"parameters": {
"idempotency_key": {
"type": "string",
"description": "Unique key for this request. Same key = same result, no duplicate ticket."
}
}
}
技巧三:让读取操作成为验证手段
执行写入后返回新状态,让模型能验证操作是否成功:
// 工具执行后返回新状态
{
success: true,
new_state: { status: "published", id: 42, updated_at: "..." }
}
// 模型可以比较 new_state 和预期,判断是否需要重试
超时处理
超时处理至关重要。Claude Code 的 Bash 工具:
- 默认超时 120 秒
- 可配置最长 600 秒
- 超时时返回明确的错误信息:“Command timed out after 120000ms”
这比让模型无限期等待然后丢失上下文要好得多。
错误信息的设计
工具返回的错误信息不是给人看的——是给模型看的。模型需要从错误信息中判断:这是暂时性错误(值得重试)还是永久性错误(需要换方案)?错误的根因是什么?
❌ 差的错误信息:
"Error: operation failed"
⚠️ 中等的错误信息:
"Error: file not found"
✅ 好的错误信息:
"Error: file '/src/main.rs' not found. The path may be incorrect
or the file may have been deleted. Use Glob to search for the file."
好的错误信息不仅描述了什么出错了,还提示了模型下一步该怎么做。这是一种引导——通过错误回复来教模型如何恢复。
错误类型的分类编码
给错误加明确的类型标签,便于模型判断:
{
"success": false,
"error": {
"type": "NETWORK_TIMEOUT", // 建议重试
"message": "...",
"hint": "The request timed out. You may retry once."
}
}
{
"success": false,
"error": {
"type": "PERMISSION_DENIED", // 不应重试
"message": "...",
"hint": "You lack permissions. Ask the user for access."
}
}
{
"success": false,
"error": {
"type": "INVALID_INPUT", // 重试前需改参数
"message": "...",
"hint": "The file path must be absolute. Try /path/to/file instead of ./file."
}
}
5.7 返回格式:告诉模型它需要知道的,仅此而已
工具执行完毕后,返回结果会被塞进上下文窗口。这意味着返回格式的设计直接影响上下文效率。
结构化 vs 原始输出
大部分情况下,适度结构化的输出优于纯文本 dump。但”适度”是关键——模型并不需要机器可解析的严格 JSON,它需要的是人类可读的、信息密度高的文本。
Claude Code 的 Read 工具返回格式就是一个好例子:带行号的纯文本,类似 cat -n 的输出。不是 JSON 包裹的行数组,也不是没有行号的裸文本。行号让模型能精确引用位置(“第 42 行有个 bug”),纯文本避免了 JSON 转义带来的噪音。
1 use sqlx::MySqlPool;
2
3 use crate::error::AppError;
4
5 pub async fn upsert(
6 db: &MySqlPool,
7 ...
截断策略是必须的
一个 10 万行的文件不可能塞进上下文窗口。Read 工具默认只读 2000 行,Grep 默认限制 250 条结果。这种截断不是功能缺陷,而是设计选择——它迫使模型学会精确地指定自己需要什么。
截断时要让模型知道发生了截断:
[Showing 250 of 1,847 matches. Use offset parameter to see more.]
这行提示信息让模型知道还有更多结果,可以用分页参数继续获取。如果悄悄截断而不告知,模型会基于不完整的信息做出错误判断。
返回体积的经验预算
不同工具类型的合理返回体积:
| 工具 | 典型返回 | 硬上限 |
|---|---|---|
| Read(文件) | 1-2K tokens | 8K |
| Bash(命令) | < 1K | 4K |
| Grep(搜索) | 500-1500 | 4K |
| WebFetch | 2-3K | 10K |
| SQL 查询 | 1K | 4K |
| List 操作 | 500 | 2K |
超过这些数字就应该触发截断。
避免结构化过度
当 Bash 执行 ls -la 时,直接返回命令输出即可。不需要把每个文件解析成 JSON 对象——模型处理纯文本表格的能力完全够用,额外的 JSON 包装只会浪费 token:
❌ 过度结构化:
{
"files": [
{"name": "foo.txt", "size": 42, "mtime": "..."},
{"name": "bar.txt", "size": 123, "mtime": "..."}
]
}
// ~200 tokens
✅ 自然格式:
-rw-r--r-- 1 user staff 42 Apr 15 10:23 foo.txt
-rw-r--r-- 1 user staff 123 Apr 15 10:24 bar.txt
// ~80 tokens
5.8 横向对比:不同系统的 Tool Design 哲学
对比几个主流系统的工具设计,可以看到不同的工程哲学。
Claude Code
哲学:专用工具优先,通用工具兜底。
为常见操作(读文件、搜索、编辑)提供专用工具,用丰富的描述引导模型选择正确的工具,同时保留 Bash 作为通用后备。工具数量适中(约 40 个),描述极其详尽(单个工具描述可达数百字)。
特点:
- 描述详尽,包含反面案例
- 强指令词(ALWAYS/NEVER)
- 工具之间明确优先级
- 安全分级内嵌在描述
OpenAI Function Calling
哲学:开发者自定义。
不预设工具集,而是提供一套规范让开发者定义自己的 function schema。这种方式灵活性最高,但也意味着工具设计的质量完全取决于开发者。schema 要求是标准 JSON Schema,支持枚举、嵌套对象、必填/可选字段等完整特性。
特点:
- 灵活性最高
- 无内置工具
- 质量取决于开发者
- 标准 JSON Schema
LangChain Tools
哲学:框架封装。
把常见的外部服务(Google 搜索、Wikipedia、Calculator 等)封装成开箱即用的 Tool 类。优点是上手快,缺点是描述通常比较简略。LangChain 的 Tool 抽象还引入了 return_direct 等控制参数来影响 Agent 循环行为——这让工具定义和流程控制耦合在了一起,是一个见仁见智的设计决策。
特点:
- 开箱即用
- 描述通常简略
- 工具定义和流程控制耦合
- 适合快速原型
对比矩阵
| 维度 | Claude Code | OpenAI FC | LangChain |
|---|---|---|---|
| 内置工具 | ~40 个 | 0 | 数十到上百 |
| 描述长度 | 长(200-500 词) | 开发者决定 | 短(50-100 词) |
| 控制反转 | 无 | 无 | 有(return_direct) |
| 模型无关 | 主要 Claude | 主要 OpenAI | 多模型 |
| 适合场景 | 生产级 Agent | 自定义业务 | 快速原型 |
趋势:描述越来越重要
从这些对比中可以提炼出一个趋势:描述的信息密度越来越重要。
早期的 function calling 描述往往只有一两句话,现在 Claude Code 的工具描述动辄数百字、包含使用指南和反面示例。模型在工具选择上的表现和描述质量高度正相关——投入在描述上的每一个字都是值得的。
5.9 工具生命周期与演进
工具不是写一次就固定不变的——它会随着使用反馈不断演进。
四个演进阶段
graph LR
A[1. 初始设计<br/>满足基本需求] --> B[2. 使用反馈<br/>发现坑点]
B --> C[3. 描述调优<br/>通过描述消除误用]
C --> D[4. 接口迭代<br/>必要时修改 schema]
D -.-> B
style A fill:#dbeafe,stroke:#3b82f6
style B fill:#fef3c7,stroke:#f59e0b
style C fill:#dcfce7,stroke:#22c55e
style D fill:#fee2e2,stroke:#ef4444
可观测性:知道工具被怎么用
要演进工具,必须先知道它被怎么使用:
interface ToolUsageMetrics {
toolName: string
callCount: number // 调用次数
successRate: number // 成功率
avgLatency: number // 平均延迟
paramDistribution: { // 参数分布
[param: string]: { min, max, median, commonValues: string[] }
}
commonErrors: ErrorPattern[] // 常见错误模式
correlatedTools: { // 经常一起用的工具
[tool: string]: number
}
}
从 metrics 可以发现:
- 使用率低 → 描述不清或场景错误
- 错误率高 → 参数 schema 可能有问题
- 总是和工具 X 一起用 → 可能应该合并
- 某参数几乎总是传 null → 应该设默认值
工具版本化
工具升级时要有版本化策略:
// 旧工具保留一段时间
registerTool("search", searchV1, { deprecated: true, removeDate: "2026-07-01" })
registerTool("search_v2", searchV2)
// 描述中引导迁移
searchV1.description += "\n\n[DEPRECATED: Use search_v2 for better results]"
5.10 反模式清单
总结几个在实践中反复出现的 Tool Design 反模式:
反模式一:瑞士军刀工具
现象:一个工具通过 action 参数实现十几种功能。
问题:模型选对了工具还不够,还得选对 action、匹配对应的参数组合。调试噩梦,权限控制也无从下手。
对策:按意图拆分成独立工具。
反模式二:无描述或弱描述工具
现象:name: "process", description: "Process data"——这等于没说。
问题:模型只能靠名字猜,猜错了就是错误的工具调用。
对策:遵循 5.3 节的描述模板,至少 100 词。
反模式三:HTML/XML dump 返回
现象:工具返回完整的 HTML 页面或大段 XML。
问题:模型需要从几千行标签噪音中提取几个关键信息,浪费 token,降低准确率。
对策:在工具层做提取,只返回模型需要的数据。
反模式四:无限制返回
现象:工具执行数据库查询,返回 10 万条记录。没有分页,没有截断,直接把上下文窗口撑爆。
问题:一次调用就摧毁整个会话。
对策:每个可能返回大量数据的工具都必须有默认限制 + 截断告知。
反模式五:隐式状态依赖
现象:工具 A 必须在工具 B 之后调用才能工作,但这个依赖没有在任何描述中说明。
问题:模型不知道调用顺序的约束,随机排列工具调用,导致莫名其妙的失败。
对策:要么消除这种依赖,要么在描述中明确说明。Claude Code 的 Edit 工具描述里就明确写着”You must use your Read tool at least once before editing”。
反模式六:参数类型模糊
现象:一个参数既接受字符串又接受数组,行为还不一样。
问题:模型很难推断多态参数的正确用法。
对策:保持参数类型单一明确;如果确实需要多态,拆成两个参数或两个工具。
5.10.1 实测:Claude Code 的真实工具清单——40 个独立目录 + GrepTool 的 prompt 原文
§5.2 “Claude Code 的 40 工具方案” 表格用 7 个功能域示例工具——把 claude-code-main/src/tools/ 完整 40 个工具目录实测列出——
| 功能域 | 工具(实测目录名) | 数量 |
|---|---|---|
| 文件操作 | FileReadTool / FileWriteTool / FileEditTool / GlobTool / GrepTool / NotebookEditTool / LSPTool | 7 |
| 命令行执行 | BashTool / PowerShellTool / REPLTool | 3 |
| Agent 编排 | AgentTool / AskUserQuestionTool / BriefTool | 3 |
| Plan Mode | EnterPlanModeTool / ExitPlanModeTool | 2 |
| Worktree | EnterWorktreeTool / ExitWorktreeTool | 2 |
| MCP | MCPTool / McpAuthTool / ListMcpResourcesTool / ReadMcpResourceTool | 4 |
| 任务管理(异步 Task) | TaskCreateTool / TaskGetTool / TaskListTool / TaskOutputTool / TaskStopTool / TaskUpdateTool | 6 |
| 多终端协作(Team) | TeamCreateTool / TeamDeleteTool / SendMessageTool | 3 |
| Todo + Skill | TodoWriteTool / SkillTool | 2 |
| 网络 | WebFetchTool / WebSearchTool | 2 |
| 调度 / 时间 | ScheduleCronTool / SleepTool | 2 |
| 其他 | ConfigTool / ToolSearchTool / RemoteTriggerTool / SyntheticOutputTool | 4 |
| 合计 | — | 40 |
§5.2 “ALWAYS use Grep for search tasks” 原话实测——src/tools/GrepTool/prompt.ts:10——
// 实测 prompt.ts:10
- ALWAYS use ${GREP_TOOL_NAME} for search tasks.
NEVER invoke `grep` or `rg` as a ${BASH_TOOL_NAME} command.
The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
章节的”ALWAYS / NEVER”声明就是这一行——印证 §5.3 “强指令词的魔力” 是来自生产代码、不是文学修辞——ALWAYS + NEVER 是工程上有效的 prompt 工程原语。
两条值得记住的物理事实——
- 40 个工具但功能高度集中在 7 大类——文件 + 命令行 + Agent + 任务 4 类合计占 19/40 = 48%——印证 §5.2 “粒度设计的四条法则”——一个生产 Coding Agent 的工具集中在”文件 + Shell + 子任务” 三个核心场景;其余 21 个(MCP/Worktree/Plan/Schedule/Team 等)是”适配性扩展”——没有它们 Coding Agent 也能基本工作
- 36 个工具有 prompt.ts(
ls src/tools/*/prompt.ts | wc -l = 36)——4 个工具不需要专门 prompt(直接通过 description 表达)——印证 §5.3 “Tool 的描述就是它的全部接口”——36/40 = 90% 的工具需要专门的 prompt 文件——是产品级工具设计的真实工程比例
src/tools/ 行数实测 = ch01 §1.3.1 测得的 42309 行——平均每个工具 1057 行代码(含 prompt + 实现 + 测试 + 类型)——印证 ch02 §2.7.3 “生产级工具的工程量被严重低估”。
5.11 本章小结:工具设计的七条原则
Tool Design 的核心思想可以浓缩为一句话:
你不是在给程序员设计 API,你是在给语言模型设计认知接口。
模型通过文本描述理解工具能力,通过 JSON Schema 构造调用参数,通过返回文本理解执行结果。这整个链条都是文本驱动的。描述的每一个字、参数的每一个约束、返回的每一行输出,都在影响模型的决策质量。
七条核心原则
- 按意图拆分工具,保持粒度适中(15-50 个)
- 描述是接口,投入足够的精力写好描述
- 参数扁平化,用 Schema 约束值域
- 分级安全模型,区分读、写、毁三类操作
- 为失败而设计,保证幂等性和有意义的错误信息
- 控制返回体积,截断并告知模型
- 数据驱动演进,工具是活的,不是死的
记忆口诀
粒度按意图,描述见真章
参数要扁平,枚举配约束
只读可自动,写入需确认
幂等要保证,错误要会教
返回要精炼,截断要告知
指标要观测,工具要演进
下一章我们将讨论 Tool Orchestration——当 Agent 拥有了趁手的兵器之后,如何编排这些工具的调用顺序和组合策略。
延伸阅读:Tool 设计的”人机工程学”
Tool 是 Agent 和外部世界的接口——好的 Tool 设计、要兼顾”机器好用”和”人好维护”——这是 Agent 时代的”人机工程学”。机器好用——Tool 的描述、参数、返回值都要清晰、让 LLM 能理解和使用;人好维护——Tool 的实现、测试、文档要规范、让工程师能长期演化。这两个目标、有时统一、有时冲突。
冲突的典型场景——“为了 LLM 好懂、描述写得很详细”、但这让 Tool 定义文件变得冗长、维护成本上升;“为了人好维护、参数类型做得很抽象”、但这让 LLM 可能误解参数含义。好的 Tool 设计者、需要在两者之间找到平衡。Claude Code 的 Tool 体系、是这方面的典范——描述详尽但不啰嗦、参数明确但不僵硬——每个 Tool 的设计都体现了成熟的”人机工程”思维。《OpenClaw 源码》第 9 章、《LangChain 源码》第 14 章讨论的 Tool 设计、都有类似思考。
延伸阅读:Tool 粒度的 Goldilocks 原则
Tool 粒度设计、遵循”Goldilocks 原则”——不能太粗、也不能太细、要”刚刚好”。太粗——一个 Tool 做太多事情(比如”处理一切文件操作”)、LLM 容易误用、参数爆炸;太细——每个 Tool 只做一件小事(比如”读取文件第一行”、“读取文件第二行”、“读取文件第三行”)、LLM 需要调用太多次、效率低。“刚刚好”的粒度——每个 Tool 解决一个明确的业务场景(“读取文件”、“写入文件”、“列出目录”、“搜索文件”)、LLM 能准确选择、调用次数适中。
这种粒度感、和传统 API 设计的粒度问题类似——不是新问题、只是换了新场景。REST API 设计里、“每个资源一个 endpoint”还是”一个 endpoint 做多件事”、是 20 多年的老辩论、至今没定论。“RPC 粒度”、“微服务粒度”、“Tool 粒度”、“模块粒度”——本质上都是”把复杂度分布在哪些边界上”的问题。Goldilocks 原则告诉我们——太粗太细都不好、要找中间值——这个中间值往往要靠经验和迭代确定、没有公式可循。
延伸阅读:Tool 描述的”给 LLM 写作”艺术
Tool 描述是”给 LLM 的文档”——写法和给人类写文档有细微差异。给人类写文档——可以用缩写、可以有歧义(人能从上下文判断)、可以省略(常识不用重复)。给 LLM 写描述——要避免缩写(LLM 可能不懂)、要消除歧义(LLM 无法”问澄清问题”)、要显式写常识(LLM 的”常识”可能不可靠)。
这种”给 LLM 写作”的能力、是 Agent 时代的新技能。好的工具描述、不只是”字多”、而是”在合适的地方说清楚合适的事”——该举例时举例、该说边界时说边界、该区分时清晰区分。这种能力、没有速成方法——需要不断写 Tool、看 LLM 怎么用、根据 LLM 的错误反推描述应该怎么改。这是 AI 时代的”文档工程学”——一个全新的、正在形成的学科。
延伸阅读:Tool 安全分类的重要性
Tool 的安全分类——“只读 / 读写 / 危险”三级——不只是”给人看的标签”、更是”给系统看的元数据”。系统根据分类决定权限策略——只读 Tool 可以默认授权、读写 Tool 需要会话级授权、危险 Tool 强制逐次确认。没有明确分类、系统无法做精细权限控制。
这种”元数据驱动的安全”、在其他领域也有——HTTP 方法(GET/POST 暗示读/写语义)、SQL 权限(SELECT/INSERT/UPDATE/DELETE 对应不同权限)、Linux 文件权限(rwx 对应读/写/执行)——都是同一种思路。Tool 安全分类是这种思路在 Agent 领域的应用、也是行之有效的做法。《OpenClaw 源码》第 13 章、《Harness Engineering》第 14 章都深入讨论过类似的安全分级、值得对照阅读。
延伸阅读:Tool 幂等性的工程价值
幂等性(idempotency)——同一个 Tool 调用多次、结果相同、不产生副作用——是 Tool 设计的重要特性。幂等的 Tool(比如 GET、SELECT)、可以放心重试、可以放心缓存、可以放心并行;非幂等的 Tool(比如 INSERT、付款、发邮件)、必须谨慎处理、重试会导致问题。标注 Tool 的幂等性、让 Agent 系统知道”这个 Tool 能不能重试”——这对错误恢复至关重要。
幂等性设计来自分布式系统的经验——“在不可靠网络里、必须假设任何请求都可能被重复”——这是经过数十年血泪积累的教训。HTTP 的 GET / PUT / DELETE 被设计为幂等的、POST 不是;gRPC 有显式的 idempotency 标记;Kafka 有 idempotent producer——都是同一种工程智慧。Agent 系统把这种智慧迁移过来、让 Tool 调用也能享受”可靠重试”的红利。这再次证明——“好的工程思想跨领域通用”——这也是本书反复强调跨书关联阅读的深层原因。
延伸阅读:Tool 返回值的”精炼截断”艺术
Tool 返回值的设计有一对矛盾——“返回太多”会挤爆 LLM 的上下文窗口、“返回太少”会让 LLM 缺少必要信息。好的 Tool 返回值——“默认精炼、支持扩展”。比如”搜索文件”工具、默认返回前 20 个匹配、并告知”还有 150 个未显示”、支持用 offset 参数翻页。这种”分页 + 总数”的模式、让 LLM 能根据需要请求更多信息。
这种”精炼 + 可扩展”的设计、和 HTTP 分页、数据库 LIMIT/OFFSET、GraphQL pagination 是同一种思路。Agent 时代把这种思路应用到 Tool 返回值上、让”上下文窗口”这个新的稀缺资源得到妥善管理。好的 Agent 工程师、会把”节约 context”作为 Tool 设计的重要考量——因为每一个 token 都是钱、都是延迟、都是噪音。这种”节约意识”、是生产级 Agent 系统的标志——把有限的上下文窗口当成稀缺资源来运营。
延伸阅读:Tool 参数验证的前置与后置
Tool 参数验证、可以在两个层次做——前置(schema 层面、LLM 生成的参数是否符合格式)、后置(业务层面、参数值是否合法)。前置验证快、错误信息明确(缺字段、类型错误);后置验证慢、但能捕获业务约束(比如”日期必须是未来”、“金额必须为正”)。
好的 Tool 设计、两层都做。前置用 JSON Schema 或 Pydantic——自动化、成本低;后置用业务代码——人工写、但覆盖业务规则。缺少前置——会收到大量格式错误的请求、浪费资源;缺少后置——可能把非法参数执行掉、造成实际损害。两者结合、才能构筑健壮的 Tool 系统。《LangGraph 源码》第 8 章、《OpenClaw 源码》第 9 章都讨论过类似的”两层验证”思路。
延伸阅读:Tool 测试的特殊性
Tool 测试和普通函数测试略有不同——Tool 通常涉及外部资源(文件系统、网络、数据库)、副作用难以隔离。好的测试策略——“单元测试用 mock”(快速、可控)+ “集成测试用真实依赖”(慢、但真实)。两者结合、既能快速迭代(CI 主要跑单测)、又能保证真实场景正确(集成测试定期运行)。
一个常见陷阱——“测试过度依赖 mock”——mock 写得完美、但实际调用真实服务时出错。这是”测试用例过度设计”的反面——要么过度简化(不测真实集成)、要么过度复杂(mock 到和真实系统脱节)。好的测试、要在”速度”和”真实性”之间找平衡——《LangChain 源码》第 5 章讨论的 LLM 测试、同样涉及这个权衡——这是软件测试的永恒话题。
延伸阅读:Tool 版本管理
Tool 会随时间演化——参数会变、语义会调整、行为会优化——和任何活跃维护的 API 一样。如何管理这些演化?两种思路——“向后兼容”(保留老签名、新增可选参数、旧行为不变)、“版本号”(tool_v1, tool_v2、明确区分版本)。两者各有适用场景——简单场景用向后兼容(减少复杂度)、复杂场景用版本号(明确边界)。
糟糕的做法——“不告知地破坏性改动”——用户的 Agent 代码突然不工作、不知道原因、调试时还得去猜是不是 Tool 变了。这是”库维护者失职”的典型。好的 Tool 维护、要像好的 API 维护——遵循 semver、破坏性变更要提前通知、deprecation 要有过渡期、CHANGELOG 要写清楚——这些经验在 Tool 领域同样适用。
延伸阅读:Tool 性能优化的优先级
Tool 性能优化的优先级——“LLM 延迟 > Tool 执行 > Tool 返回传输”。LLM 延迟——一次 LLM 调用 1-5 秒、是系统最大瓶颈、应该最优先优化(减少调用次数、合并 prompt、使用更快模型)。Tool 执行——通常 10ms 到几秒、是第二瓶颈、值得优化但优先级次之。Tool 返回传输——通常毫秒级、很少成为瓶颈、除非返回值巨大。
把精力花在瓶颈上——这是系统优化的基本原则。很多新手工程师、花大量时间优化”Tool 执行速度”(从 100ms 降到 50ms)——结果用户体感没变、因为 LLM 延迟是主要瓶颈。要学会”先 profile 再优化”——用工具量化瓶颈、针对瓶颈下手、而不是凭感觉优化。《Vue 3 源码》第 6 章、《React 18 源码》第 10 章都强调过类似的”数据驱动优化”思路——靠数据说话、不靠直觉猜测。
延伸阅读:Tool 的可组合性
好的 Tool 集应该”正交可组合”——每个 Tool 做一件事、Tool 之间能自由组合解决复杂问题。这和 Unix 哲学的”做一件事、做好”一脉相承。反面例子——Tool 之间有隐性依赖(Tool A 必须在 Tool B 之前调用)、或者 Tool 功能重叠(Tool A 和 Tool B 部分职责相同)——都让组合变困难。
Unix 小工具(grep、sort、uniq、awk)能通过管道解决无数场景——因为它们正交可组合。Agent 的 Tool 集应该学这种思路——让 LLM 能像 shell 里用管道一样、自由组合 Tool。这种组合能力、让 Tool 集的表达力远超单个 Tool 的总和。
延伸阅读:Tool 和 Skill 的边界
Tool 和 Skill(见《OpenClaw 源码》第 16 章)有什么区别?Tool 是”单次可执行的函数”、输入参数、返回结果;Skill 是”多步指令的集合”、可能涉及多次 Tool 调用、带判断逻辑。类比——Tool 是 Unix 命令、Skill 是 shell 脚本。
两者的关系——Skill 可以调用 Tool、但 Tool 不会反过来调 Skill。清晰的边界让系统设计更干净——Tool 层专注”原子能力”、Skill 层专注”流程编排”。这种分层、让 Agent 系统能应对从简单到复杂的各种场景——简单场景只需 Tool、复杂场景用 Skill 编排多个 Tool——架构的灵活性让系统能 scale 到各种规模。
延伸阅读:Tool 的错误消息设计
Tool 错误消息的设计、需要”对 LLM 友好”——不是”对人类友好”。人类看错误消息、会从上下文推断;LLM 看错误消息、只能从字面理解。好的 Tool 错误消息——“明确错在哪”(字段名、具体值)、“告诉怎么修”(合法值范围、正确格式)、“避免隐晦缩写”(要写清楚、不要用”ERR_INVALID_INPUT” 这种代码)。
这种”为 LLM 优化的错误消息”、是 Agent 系统设计的新挑战。传统软件的错误消息、给人类看的;Agent 系统的错误消息、给 LLM 看的——写法完全不同。这也让”写错误消息”成为 Agent 工程师的专业能力——不是”顺手写写”、而是”认真设计、反复调试”——这是 AI 时代的新技术工种之一、需要大量实战经验积累。
延伸阅读:Tool 的可观测性
生产级 Tool 必须有可观测性——每次调用记录(时间、参数、结果、耗时)、错误率追踪、延迟分布监控。没有可观测性、Tool 出问题时你只能”猜”——猜是哪里错了、猜是什么参数导致的、猜是不是偶发。有了可观测性——数据告诉你一切。
Tool 可观测性的具体实现——OpenTelemetry 的 trace、Prometheus 的 metrics、ELK 的 log——都能用。重要的是”把 Tool 调用当成关键指标来管理”——设 SLA、设告警、定期分析趋势。这种”工程严肃性”、是生产级 Agent 系统区别于玩具项目的关键——区分”能 demo”和”能上生产”的就是这些看不见的基础设施。