Harness Engineering

第5章 Tool Design:给 Agent 造趁手的兵器

作者 杨艺韬 · 7,411 字

第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 设计

维度人类 APIAI 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 适合创建新文件或完全重写。分开设计让权限控制更精细,也让模型的意图表达更明确。

粒度设计的四条经验法则

  1. 按用户意图拆分工具,而非按技术实现拆分——模型以任务维度思考,工具应对齐这个维度
  2. 高频操作专用化——Read 的频率远高于 “打开 + 读取 + 关闭”,所以直接给一个 Read
  3. 低频操作走通用通道——罕见需求走 Bash,不值得专门设计工具
  4. 数量控制在 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"

好描述的五个特征

  1. 说明”是什么”:基于 ripgrep 的搜索工具,让模型理解能力边界
  2. 说明”何时用”和”何时不用”:明确告诉模型该用这个工具搜索,不要用 Bash 调 grep
  3. 给出使用示例:正则表达式的写法、glob 过滤的写法
  4. 说明关键参数的含义:output_mode 的各选项是什么意思
  5. 指向替代方案:复杂搜索用 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
键值对objectarray 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 --forcerm -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 tokens8K
Bash(命令)< 1K4K
Grep(搜索)500-15004K
WebFetch2-3K10K
SQL 查询1K4K
List 操作5002K

超过这些数字就应该触发截断。

避免结构化过度

当 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 CodeOpenAI FCLangChain
内置工具~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 / LSPTool7
命令行执行BashTool / PowerShellTool / REPLTool3
Agent 编排AgentTool / AskUserQuestionTool / BriefTool3
Plan ModeEnterPlanModeTool / ExitPlanModeTool2
WorktreeEnterWorktreeTool / ExitWorktreeTool2
MCPMCPTool / McpAuthTool / ListMcpResourcesTool / ReadMcpResourceTool4
任务管理(异步 Task)TaskCreateTool / TaskGetTool / TaskListTool / TaskOutputTool / TaskStopTool / TaskUpdateTool6
多终端协作(Team)TeamCreateTool / TeamDeleteTool / SendMessageTool3
Todo + SkillTodoWriteTool / SkillTool2
网络WebFetchTool / WebSearchTool2
调度 / 时间ScheduleCronTool / SleepTool2
其他ConfigTool / ToolSearchTool / RemoteTriggerTool / SyntheticOutputTool4
合计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 工程原语。

两条值得记住的物理事实——

  1. 40 个工具但功能高度集中在 7 大类——文件 + 命令行 + Agent + 任务 4 类合计占 19/40 = 48%——印证 §5.2 “粒度设计的四条法则”——一个生产 Coding Agent 的工具集中在”文件 + Shell + 子任务” 三个核心场景;其余 21 个(MCP/Worktree/Plan/Schedule/Team 等)是”适配性扩展”——没有它们 Coding Agent 也能基本工作
  2. 36 个工具有 prompt.tsls 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 构造调用参数,通过返回文本理解执行结果。这整个链条都是文本驱动的。描述的每一个字、参数的每一个约束、返回的每一行输出,都在影响模型的决策质量。

七条核心原则

  1. 按意图拆分工具,保持粒度适中(15-50 个)
  2. 描述是接口,投入足够的精力写好描述
  3. 参数扁平化,用 Schema 约束值域
  4. 分级安全模型,区分读、写、毁三类操作
  5. 为失败而设计,保证幂等性和有意义的错误信息
  6. 控制返回体积,截断并告知模型
  7. 数据驱动演进,工具是活的,不是死的

记忆口诀

粒度按意图,描述见真章
参数要扁平,枚举配约束
只读可自动,写入需确认
幂等要保证,错误要会教
返回要精炼,截断要告知
指标要观测,工具要演进

下一章我们将讨论 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”和”能上生产”的就是这些看不见的基础设施