Harness Engineering
第5章 Tool Design:给 Agent 造趁手的兵器
本章要点
- Tool Design 不是写 API,是给概率推理引擎设计认知接口
- 粒度平衡:按意图拆分,不按技术实现拆分;以 Claude Code 为例,约 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 |
在描述中编码优先级
另一个重要实践是在描述中编码优先级关系。Bash 工具的描述中包含这样的内容(作者本地快照 src/tools/BashTool/prompt.ts:359):
"IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task."
这在工具之间建立了清晰的优先级:Bash 在自己的描述里就告诉模型——有专用工具就用专用工具,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(按命令动态分级)]
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、CreateFile 策略:需要谨慎对待——可能覆盖文件、创建不必要的内容。很多 Agent 系统在这个级别引入人类确认。
Bash 是动态分类的特例:Bash 既可以是只读查询(如
ls、grep),也可以是写入或破坏性操作(如rm -rf、git push --force)。它的风险等级应由命令解析器或沙箱策略在具体调用时判定,而不是固定归为写入层。
破坏性工具(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": {
"type": "object",
"properties": {
"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 秒
- 超时时返回明确的错误信息(作者本地快照
src/utils/ShellCommand.ts:325的文案为Command timed out after ${formatDuration(timeout)}——把毫秒格式化成人类可读的时长,而非裸毫秒数)
这比让模型无限期等待然后丢失上下文要好得多。
错误信息的设计
工具返回的错误信息不是给人看的——是给模型看的。模型需要从错误信息中判断:这是暂时性错误(值得重试)还是永久性错误(需要换方案)?错误的根因是什么?
❌ 差的错误信息:
"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.11 实测:Claude Code 的真实工具清单——40 个独立目录 + GrepTool 的 prompt 原文
来源说明
Claude Code 是 Anthropic 的闭源商业产品,其源码与目录结构并非公开可得。下文列出的工具目录名、prompt 片段与统计数据均来自作者本地源码快照,仅供理解生产级 Coding Agent 的工具组织方式,公开版本可能与此不同。读者请以 Anthropic 官方文档为准。
§5.2 "Claude Code 的 40 工具方案" 表格用 7 个功能域示例工具。下文把作者本地快照中观察到的 src/tools/ 目录结构列出——
| 功能域 | 工具(作者本地快照中观察到的目录名) | 数量 |
|---|---|---|
| 文件操作 | 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/ 行数 = 第1章《Agent 不等于大模型:Harness 的价值》§1.3.1 中作者本地快照测得的 42309 行——平均每个工具约 1057 行代码(含 prompt + 实现 + 测试 + 类型)——印证一个常被忽视的事实——生产级工具的工程量普遍被低估。
5.12 本章小结:工具设计的七条原则
Tool Design 的核心思想可以浓缩为一句话:
你不是在给程序员设计 API,你是在给语言模型设计认知接口。
模型通过文本描述理解工具能力,通过 JSON Schema 构造调用参数,通过返回文本理解执行结果。这整个链条都是文本驱动的。描述的每一个字、参数的每一个约束、返回的每一行输出,都在影响模型的决策质量。
七条核心原则
- 按意图拆分工具,保持粒度适中(15-50 个)
- 描述是接口,投入足够的精力写好描述
- 参数扁平化,用 Schema 约束值域
- 分级安全模型,区分读、写、毁三类操作
- 为失败而设计,保证幂等性和有意义的错误信息
- 控制返回体积,截断并告知模型
- 数据驱动演进,工具是活的,不是死的
记忆口诀
粒度按意图,描述见真章
参数要扁平,枚举配约束
只读可自动,写入需确认
幂等要保证,错误要会教
返回要精炼,截断要告知
指标要观测,工具要演进
下一章我们将讨论第 6 章《工具编排》——当 Agent 拥有了趁手的兵器之后,如何编排这些工具的调用顺序和组合策略。