工具不是给人看的 API 文档,而是给模型看的能力契约。同一段业务逻辑,描述写法不同,工具调用成功率可以差出一倍。这一章讲四件事:描述怎么写、参数怎么约束、错误怎么分类回喂、以及什么时候该上 MCP。
编辑部注 · 这一章的内容可以直接拿去改你手上的 Harness —— 它是投入产出比最高的一层。
先纠正一个常见误解:工具调用的失败,多数不是模型「不会用」,而是模型「不知道该怎么用」。你要给它的不是接口签名,而是判断依据——什么时候用这个工具、参数填什么范围、返回的结构长什么样、出错时代表什么。
一、工具是契约,不是接口
一个完整的工具契约包含七个要素。少任何一个都会以某种方式转化为线上问题——其中 工具描述(Tool Description) 与 参数 schema(Parameter Schema) 直接决定模型「会不会用、用得对不对」:
| 要素 | 写什么 | 缺了会怎样 |
|---|---|---|
| 名称 | 动词开头、语义唯一、不与其它工具重名或近似 | 模型在多个相似工具间随机选择 |
| 用途描述 | 一句话说明「做什么」+ 一句话说明「什么时候该用 / 不该用」 | 模型在该用的场景想不到它 |
| 参数 schema | 类型、必填性、枚举取值、范围、格式范例 | 参数写错、格式不对、枚举乱填 |
| 返回值结构 | 字段名、类型、是否可能为空、可能的错误形态 | 模型误读结果,基于空值继续推理 |
| 幂等性标记 | 是否可安全重试 | 超时后盲目重试,产生重复副作用 |
| 副作用等级 | 只读 / 可逆写 / 不可逆 | 越权执行,出事无法追责 |
| 调用成本提示 | 是否昂贵、是否慢、是否有配额 | 模型反复调用昂贵接口,成本失控 |
二、错误分类:决定重试有没有意义
这是本章最有工程价值的部分,核心是 错误分类(Error Taxonomy)。工具执行失败时,Harness 只有两种选择:抛出异常终止,或者把错误回喂让模型换策略。而回喂的内容决定了第二次尝试有没有意义——尤其要注意 空结果(Empty Result):查询成功但无数据,必须和「失败」区分开,否则模型会陷入换关键词空转。
| 错误类别 | 典型来源 | 该不该重试 | 回喂内容 |
|---|---|---|---|
| 参数错误 | 字段缺失、类型不符、枚举越界 | 必须改参数后重试 | 指出具体哪个字段错、合法取值是什么 |
| 格式错误 | JSON 解析失败、日期格式不对 | 改格式后重试 | 给出正确格式的示例 |
| 业务规则拒绝 | 不符合前置条件(如「订单已关闭」) | 换方案,不重试 | 说明被拒原因与可能的替代路径 |
| 权限不足 | 无权限访问该资源 | 重试无用 | 明确「重试无用」,建议上报或换目标 |
| 资源不存在 | 对象已删除、ID 拼错 | 换目标后重试 | 提示确认标识是否正确 |
| 超时 / 限流 | 下游慢、配额耗尽 | 可原样重试(退避) | 提示是瞬时问题,可稍后重试 |
| 上游 5xx | 依赖服务故障 | 有限次重试 | 标为系统故障,避免模型反复自我怀疑 |
| 结果为空 | 查询合法但无数据 | 不是错误 | 明确告知「查询成功但无结果」,防止模型以为工具坏了而反复重试 |
「查询成功但无结果」被当成错误处理,会引发一类非常隐蔽的成本浪费:模型看到「失败」,于是换关键词重试;再失败,再换;直到预算耗尽。日志里看到的是一连串工具调用(看起来很正常),实际全程都在做无用功。
修法:把「空结果」和「失败」在协议层就区分开——返回值里带上明确的状态字段(如 {"status":"ok","items":[]}),回喂时也明确告诉模型「查询成功,但确实没有匹配数据,请考虑条件是否过严或该数据不存在」。
三、让调用不可能出错:三层约束
按「结构的强制程度」从弱到强排列:
第一层,描述约束。 在描述里写清取值范围与范例。靠模型自觉,最弱但成本最低。
第二层,schema 约束。 用 JSON Schema 的 enum、minimum、maximum、pattern、required 把非法输入堵死,这本质上是在强制 结构化输出(Structured Output)。多数模型 API 在结构化输出模式下会直接保证符合 schema。
第三层,执行前校验(Pre-execution Validation)。 无论前面怎么写,Handler 入口必须再校验一遍——因为模型可能根本不走结构化输出通道,或者你做了参数转换。而「校验」的前提是「先解析出来」:模型常返回带 markdown 代码块包裹、尾随逗号甚至注释的 JSON,严格解析会直接抛异常,所以入口处常用宽容解析(Lenient Parsing)先把结构抽出来,再对内容做严格校验——「宽容解析 + 严格校验」的组合,比「严格解析 + 宽容校验」有效得多(后者在解析阶段就已崩,校验根本没机会跑)。对有副作用的工具,还要配合 幂等键(Idempotency Key) 在重试时去重。
from dataclasses import dataclass
from typing import Any, Callable
import json
@dataclass
class ToolResult:
"""统一返回协议:状态是显式的,绝不靠异常或空值暗示"""
status: str # ok | empty | bad_args | forbidden | not_found | timeout | upstream
data: Any = None
message: str = "" # 给模型看的一句话说明
retryable: bool = False
side_effect_done: bool = False # 副作用是否已经发生(决定能否安全重试)
cost: float = 0.0
def to_observation(self) -> str:
"""回喂给模型的字符串:状态 + 说明 + 精简数据"""
head = f"[{self.status.upper()}] {self.message}"
if self.data is None:
return head
body = json.dumps(self.data, ensure_ascii=False)
return f"{head}\n{body[:1800]}"
def search_articles(q: str, limit: int = 10) -> ToolResult:
# --- 第三层:执行前校验(不信任任何上游) ---
if not isinstance(q, str) or not q.strip():
return ToolResult("bad_args", message="参数 q 不能为空,请给出中文关键词。")
if not (1 <= limit <= 20):
return ToolResult("bad_args",
message=f"limit 必须在 1-20 之间,当前为 {limit}。",
data={"allowed": [1, 20]})
try:
rows = db_search(q, limit) # 真实查询
except TimeoutError:
return ToolResult("timeout", message="查询超时,这是瞬时问题,可原样重试。",
retryable=True)
except PermissionError:
return ToolResult("forbidden", message="当前身份无权访问该数据源,重试无用。")
# --- 空结果不是错误 ---
if not rows:
return ToolResult("empty",
message=f"查询成功,但没有匹配「{q}」的文章。"
f"建议换更宽的关键词,或确认该主题确实没有内容。",
data={"items": [], "hint": "可尝试同义词或去掉限定词"})
return ToolResult("ok", data={"items": rows[:limit], "total": len(rows)},
message=f"命中 {len(rows)} 条。",
cost=0.001)
# ---------------------------------------------------------------
# 有副作用的工具必须带幂等键
# ---------------------------------------------------------------
IDEMPOTENCY_STORE: dict[str, ToolResult] = {}
def send_notification(user_id: str, text: str, idempotency_key: str) -> ToolResult:
"""
不可逆操作的正确姿势:
1. 要求调用方提供幂等键
2. 同一个键重复调用直接返回上次结果,不重复执行
3. 返回值必须说明「副作用是否已经发生」
"""
if idempotency_key in IDEMPOTENCY_STORE:
prev = IDEMPOTENCY_STORE[idempotency_key]
return ToolResult("ok", data=prev.data,
message="该请求此前已成功执行过,本次未重复发送(幂等命中)。",
side_effect_done=True)
if not text.strip():
return ToolResult("bad_args", message="通知内容不能为空。")
resp = api_send(user_id, text) # 真实发送
res = ToolResult("ok", data={"message_id": resp["id"]},
message="已发送。", side_effect_done=True, cost=0.01)
IDEMPOTENCY_STORE[idempotency_key] = res
return res
def db_search(q, limit): raise NotImplementedError
def api_send(user_id, text): raise NotImplementedError如果 Harness 自己生成幂等键(比如用时间戳),那么「超时后重试」会生成一个新键,幂等保护就完全失效了。正确做法是:幂等键来自「这次操作代表什么意图」——例如任务 ID + 步骤序号,或者用户请求 ID。这样重试时键不变,才能真正挡住重复副作用。
面试里能把这一点讲清,基本可以确认你真的处理过线上幂等问题。
四、什么时候该用 MCP
MCP(Model Context Protocol)(Model Context Protocol)解决的是工具供给的标准化问题,不是工具设计的质量问题。用不用的判据很清楚——有 副作用等级(Side-effect Level) 的工具在调用前必须确认,而 调用成本提示(Cost Hint) 能阻止模型反复敲昂贵接口:
| 场景 | 建议 | 理由 |
|---|---|---|
| 工具要跨团队 / 跨产品复用 | 用 MCP | 一次实现,多处接入,避免每个 Harness 各写一份适配层 |
| 需要第三方生态(数据库、SaaS、设计工具) | 用 MCP | 直接复用现成 server,省掉接入成本 |
| 工具只服务单一业务、调用链极短 | 直接用函数调用 | 引入 MCP 会多一层进程与协议开销,收益为负 |
| 工具需要极致低延迟(如实时补全) | 慎用 | 跨进程通信会引入额外延迟 |
| 工具需要访问宿主私有状态 | 视情况 | 进程隔离有时是优点(安全边界清晰),有时是障碍 |
但要记住:MCP 不解决工具描述写得差的问题。 一个描述糟糕的 MCP 工具,接入之后依然会被误用。协议标准化与契约质量是两件事。
把工具当接口写,模型会当接口用——参数填得对,语义完全错。把工具当契约写,模型才知道你的系统期望它做什么。本刊编辑部
五、常见误区与追问
5.1 误区:工具描述是「文档」,写短一点省 token
错在哪:把描述当成占 token 的成本项,而不是决定成功率的输入。为什么自然:描述越长,每轮请求的常驻前缀越大,看着确实是在烧钱。判据:先算账再决定删不删——描述属于常驻前缀,前缀稳定时它被缓存复用,边际成本接近零;而描述含糊带来的每一次误调用,都要额外一轮「失败→回喂→重试」,那一轮的输入是完整上下文。所以「描述多写 300 token」和「多跑一轮 4k token」不是一个量级的取舍。要压就压真正冗余的部分,例如重复的业务背景、与调用无关的实现细节。
5.2 误区:把该写在 schema 里的约束写进系统提示词
错在哪:用概率性手段去实现确定性约束。为什么自然:改提示词看起来更快。判据:凡是能用 enum、minimum、maximum、required 表达的限制,都不该写进提示词——schema 约束下非法输入在结构上不可能出现,提示词约束下它只是变得不太可能。检查方法:把提示词里带数字或范围的句子抄出来,逐条问「这条能不能写成 schema 字段」;能改而没改的,就是留给线上事故的口子。提示词该留的是 schema 表达不了的东西:什么时候用、什么时候换别的。
5.3 误区:错误回喂就是把异常信息打印给模型
错在哪:把「让模型看到错误」当成「让模型能修正错误」。为什么自然:try/except 里直接拼一句 str(e) 只需要一行。判据:回喂内容必须回答三个问题——哪个字段错了、合法取值是什么、这次该不该重试。原始堆栈只能回答第一个,而且往往答不清:invalid literal 不会告诉模型 limit 应该落在 1–20。只回喂堆栈时,模型通常会原样重试 2–3 次才想到换参数,那是 2–3 轮完整上下文的成本;说清合法区间,第一次改参数就能过。
5.4 误区:有了重试就不需要幂等
错在哪:把「重试」和「重复执行」当成两件事,而不是同一件事的两种叫法。为什么自然:重试看起来只是再发一次请求,没人觉得危险。判据:最容易出事的是超时——服务端可能已经执行完、只是响应在路上丢了,此时重试就是第二次执行。挡住它的唯一办法是幂等键,而键必须由调用方按「操作意图」生成(任务 ID + 步骤序号),绝不能是时间戳或随机 UUID,否则每次重试都是新键,去重表永远命中不了。检查动作:把服务端的去重记录捞出来,看同一个业务对象有没有两条来自同一任务的记录。
5.5 误区:上了 MCP 就解决了工具接入的乱象
错在哪:把「供给标准化」当成「质量提升」。为什么自然:MCP 确实让同一份能力可以跨产品复用,看起来是全面的进步。判据:它解决的是「一次实现、N 处接入」的成本问题,一个字都没规定工具该怎样命名、描述怎么写、错误怎么分类。描述含糊的 MCP 工具接进来照样被误用,七要素一条都不能省。反过来也成立:如果这份能力只服务一个产品、调用链又极短,引入 MCP 只会多一层进程与协议开销,收益为负。所以判据是「它需不需要被第二个产品复用」,而不是「MCP 是不是更先进」。
六、自测
ok 与 empty 分开,是性价比极高的一处修复。七、小结
- 工具是契约:名称、用途、参数、返回、幂等性、副作用等级、成本提示,七项齐全。
- 描述要回答四个问题:做什么 / 何时用 / 参数怎么填 / 返回什么。
- 错误要分类:可重试、必须改参数、重试无用,三类处理方式完全不同。
- 空结果不是错误,必须在协议层与失败区分。
- 有副作用的操作要有幂等键,且键必须来自调用方。
- MCP 解决工具供给标准化,不解决工具设计质量。
下一章处理那个决定效果上限的层:模型到底能看到什么。
八、参考与延伸
工具这一层的理论文献很薄,但官方文档质量极高——因为各家模型厂都在教人怎么写工具。下面按「先看怎么写、再看协议、最后看怎么量」排好。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看怎么写(契约的四个层次)
- Anthropic · Writing Tools for Agents —— 目前最接近「工具描述实操手册」的一篇:命名怎么起、描述该写哪几段、返回结构怎么设计、错误怎么回喂,都给了改前改后的对照。把它和本章第一节的七要素表并排放着看,重合的部分可以直接照抄着改你自己的工具——这是本章所有延伸材料里唯一「当天就能用」的一份。
- Anthropic · Prompt Engineering Overview —— 标题像讲措辞,但有大半篇幅在讲工具定义、结构化输出这类确定性通道的正确用法。想彻底弄清「哪些约束该交给 schema、哪些只能靠提示词」,这一页把边界划得最清楚;本章第三层约束的设计标准就是从这里来的。
再看协议(工具怎么供给)
- Model Context Protocol 官方站 —— 规范、各语言 SDK、示例 server 都在这一站,读 Getting Started 一页足够建立全貌。本章第四节说「MCP 解决供给标准化、不解决设计质量」,读完规范你会确认这一点:它定义的是传输与方法,一个字都没规定描述该怎么写。
- OpenAI Cookbook —— 函数调用、结构化输出、并行工具调用都有可直接跑的 notebook。想验证「schema 约束到底能拦住多少错参数」,照它的结构化输出示例改一版你自己的工具,一个下午就能拿到自己的数据,而不必相信任何人的说法。
最后看怎么量(调用得对不对)
- τ-bench(sierra-research/tau-bench) —— 它的评分同时看「参数是否准确」和「是否在正确的时机调用了正确的工具」。这正好对应本章强调的那条区别:参数填对但语义用错,是最难被单测发现的失败形态;只统计工具调用成功率是看不出来的。
◇ 面试官会怎么问
共 7 条 · 其中 3 条高频 · 先自己答一遍,再展开对照
工具描述怎么写才算好? 高频
① 什么时候该用、什么时候不该用(最重要的,能显著减少误调用);
② 每个参数的业务含义与取值约束(枚举值、格式、范围,以及默认行为);
③ 典型调用示例(一到两个真实例子,最好包含常见错误写法);
④ 返回值结构(模型要能判断调用是否成功、结果里哪个字段才是它要的);
⑤ 失败与边界情况(什么输入会报错、报错长什么样、能不能重试)。
经验数据是:描述质量对调用准确率的影响,往往比换模型更大。所以我们把工具描述当一等公民维护,有版本、有评测。
追问链
- 那工具描述和 CLI 的 --help 是同一份吗?
- 怎么验证描述改好了?
模型调用工具失败了,你怎么处理?
① 参数错误(缺必填、类型不对、枚举越界)——可修复,把结构化错误回喂(哪个字段、错在哪里、合法取值集合是什么),允许重试 1–2 轮;
② 权限拒绝——不可自动修复,需要用户授权或换路径,应中断并说明;
③ 超时/限流——可重试,但要指数退避 + 上限,并考虑换通道;
④ 业务性错误(查无此人、条件不满足)——不该重试,这是有效信息,应该把"这个事实"告诉模型让它改变策略;
⑤ 工具自身 bug——不该让模型兜底,要告警并降级。
最容易犯的错是把"业务性错误"当异常重试,结果白白烧掉好几轮预算。
MCP 和直接写 Tools 有什么区别?什么时候用哪个? 高频
选型建议:
用 MCP——能力来自外部或第三方、需要生态复用、团队分工上由能力方自己维护、长尾能力多;
用内部 Tools——高频核心链路、需要强管控与极致性能、需要平台级统一配额与审计、参数要深度校验。
真实系统通常是两者并存:核心高频能力走内部工具,长尾与外部能力走 MCP,在模型看来它们是同一套工具抽象。
追问链
- MCP 的安全边界怎么处理?
- 两种工具在权限模型上要区别对待吗?
工具重试导致重复写库、重复发消息,怎么办?
① 只读查询——随便重试,无副作用;
② 幂等写——可以重试,依赖幂等键去重;
③ 非幂等副作用(发消息、转账、提交表单)——默认不自动重试,改由人工确认或状态查询后决定。
幂等键的做法是:在真正执行前,用「任务 ID + 工具名 + 参数规范化哈希」生成键,写入去重表(带 TTL),执行前先查、执行后标记。这样即便重试也只落地一次。
还要注意一个隐蔽问题:超时不代表没执行。超时后应该先查询执行状态,再决定重试,而不是直接重发。
同一个工具,A 版描述一句话,B 版四层齐全。团队决定「先上线,出问题再加提示词补」。这个决定错在哪,代价怎么算?
四层描述里至少有两层是可以变成结构强制的:枚举与范围写进 schema(
enum、minimum、maximum),非法输入在结构上不可能出现;「何时用、何时该改用别的」写进描述,消除相似工具之间的选择困难。用系统提示词补救有两个代价:① 它按概率生效,同一句约束在不同轮次、不同上下文长度下命中率不同;② 它占常驻 token 并且会动前缀——补丁每加一句,所有请求的头部都跟着变一次,前缀缓存的收益被吃掉一部分。
判据:任何准备「将来再加提示词」的信息,先问它能不能用 schema 表达;能,就不该留在提示词里。
追问链
- 哪些信息是 schema 表达不了、只能写在描述里的?
- 在很长的上下文里新增一条系统约束,你会怎么验证它真的生效了?
某个写操作超时了,重试 3 次之后发现数据被写了 4 次。问题出在哪几层? 高频
① 幂等键由 Harness 自己生成:如果键是时间戳或随机 UUID,每次重试都得到新键,服务端的去重表永远命中不了。键必须由调用方按「操作意图」生成——任务 ID + 步骤序号,或者用户请求 ID,这样重试时键不变。
② 超时没有被当成「可能已执行」:超时是最会骗人的错误,服务端可能已经执行完、只是响应在路上丢了。返回值里必须有
side_effect_done 这类字段,Harness 才知道「能不能安全重试」。验收方式:把进程杀在「请求发出后、响应返回前」,恢复执行后外部世界只能被改一次。另外,人工确认不能替代幂等——前者决定该不该做,后者决定会不会做两次。
追问链
- 幂等键的粒度按任务、按步骤还是按调用?各自的代价是什么?
- 如果下游服务不支持幂等键,你还有什么办法保证只执行一次?
团队要把 12 个内部工具接给 3 个不同的 Agent 产品使用。用 MCP 是对的还是错的,判断依据是什么?
12 个工具 × 3 个产品 = 36 份接入适配,而且上游接口每改一次就要改 36 处——这正是 MCP 要解决的问题:一次实现、多处接入,所以这个场景该用。
但要同时接受两个代价:① 多一层进程与协议开销,对实时补全这类极致低延迟的场景不合适;② MCP 不改善契约质量,描述含糊的工具接进来照样被误用,七要素一条都不能省。
反例:如果这份能力只服务一个产品、调用链又极短,引入 MCP 只会多一层开销,收益为负。所以决策问题只有一个:这份能力需不需要被第二个产品复用。
追问链
- MCP 工具与内部工具的权限模型要区别对待吗?
- 如果 3 个产品里只有一个对延迟敏感,你会怎么分层?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。