Harness Engineering

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

作者 杨艺韬 · 7,744 字

本章要点

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

粒度设计的四条经验法则

  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

在描述中编码优先级

另一个重要实践是在描述中编码优先级关系。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
键值对 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(按命令动态分级)]
    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 既可以是只读查询(如 lsgrep),也可以是写入或破坏性操作(如 rm -rfgit push --force)。它的风险等级应由命令解析器或沙箱策略在具体调用时判定,而不是固定归为写入层。

破坏性工具(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": {
    "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 工程原语。

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

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

七条核心原则

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

记忆口诀

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

下一章我们将讨论第 6 章《工具编排》——当 Agent 拥有了趁手的兵器之后,如何编排这些工具的调用顺序和组合策略。