工具不是给人看的 API 文档,而是给模型看的能力契约。同一段业务逻辑,描述写法不同,工具调用成功率可以差出一倍。这一章讲四件事:描述怎么写、参数怎么约束、错误怎么分类回喂、以及什么时候该上 MCP。
编辑部注 · 这一章的内容可以直接拿去改你手上的 Harness —— 它是投入产出比最高的一层。
先纠正一个常见误解:工具调用的失败,多数不是模型「不会用」,而是模型「不知道该怎么用」。你要给它的不是接口签名,而是判断依据——什么时候用这个工具、参数填什么范围、返回的结构长什么样、出错时代表什么。
一、工具是契约,不是接口
一个完整的工具契约包含七个要素。少任何一个都会以某种方式转化为线上问题。
| 要素 | 写什么 | 缺了会怎样 |
|---|---|---|
| 名称 | 动词开头、语义唯一、不与其它工具重名或近似 | 模型在多个相似工具间随机选择 |
| 用途描述 | 一句话说明「做什么」+ 一句话说明「什么时候该用 / 不该用」 | 模型在该用的场景想不到它 |
| 参数 schema | 类型、必填性、枚举取值、范围、格式范例 | 参数写错、格式不对、枚举乱填 |
| 返回值结构 | 字段名、类型、是否可能为空、可能的错误形态 | 模型误读结果,基于空值继续推理 |
| 幂等性标记 | 是否可安全重试 | 超时后盲目重试,产生重复副作用 |
| 副作用等级 | 只读 / 可逆写 / 不可逆 | 越权执行,出事无法追责 |
| 调用成本提示 | 是否昂贵、是否慢、是否有配额 | 模型反复调用昂贵接口,成本失控 |
二、错误分类:决定重试有没有意义
这是本章最有工程价值的部分。工具执行失败时,Harness 只有两种选择:抛出异常终止,或者把错误回喂让模型换策略。而回喂的内容决定了第二次尝试有没有意义。
| 错误类别 | 典型来源 | 该不该重试 | 回喂内容 |
|---|---|---|---|
| 参数错误 | 字段缺失、类型不符、枚举越界 | 必须改参数后重试 | 指出具体哪个字段错、合法取值是什么 |
| 格式错误 | JSON 解析失败、日期格式不对 | 改格式后重试 | 给出正确格式的示例 |
| 业务规则拒绝 | 不符合前置条件(如「订单已关闭」) | 换方案,不重试 | 说明被拒原因与可能的替代路径 |
| 权限不足 | 无权限访问该资源 | 重试无用 | 明确「重试无用」,建议上报或换目标 |
| 资源不存在 | 对象已删除、ID 拼错 | 换目标后重试 | 提示确认标识是否正确 |
| 超时 / 限流 | 下游慢、配额耗尽 | 可原样重试(退避) | 提示是瞬时问题,可稍后重试 |
| 上游 5xx | 依赖服务故障 | 有限次重试 | 标为系统故障,避免模型反复自我怀疑 |
| 结果为空 | 查询合法但无数据 | 不是错误 | 明确告知「查询成功但无结果」,防止模型以为工具坏了而反复重试 |
「查询成功但无结果」被当成错误处理,会引发一类非常隐蔽的成本浪费:模型看到「失败」,于是换关键词重试;再失败,再换;直到预算耗尽。日志里看到的是一连串工具调用(看起来很正常),实际全程都在做无用功。
修法:把「空结果」和「失败」在协议层就区分开——返回值里带上明确的状态字段(如 {"status":"ok","items":[]}),回喂时也明确告诉模型「查询成功,但确实没有匹配数据,请考虑条件是否过严或该数据不存在」。
三、让调用不可能出错:三层约束
按「结构的强制程度」从弱到强排列:
第一层,描述约束。 在描述里写清取值范围与范例。靠模型自觉,最弱但成本最低。
第二层,schema 约束。 用 JSON Schema 的 enum、minimum、maximum、pattern、required 把非法输入堵死。多数模型 API 在结构化输出模式下会直接保证符合 schema。
第三层,执行前校验。 无论前面怎么写,Handler 入口必须再校验一遍——因为模型可能根本不走结构化输出通道,或者你做了参数转换。
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)解决的是工具供给的标准化问题,不是工具设计的质量问题。用不用的判据很清楚:
| 场景 | 建议 | 理由 |
|---|---|---|
| 工具要跨团队 / 跨产品复用 | 用 MCP | 一次实现,多处接入,避免每个 Harness 各写一份适配层 |
| 需要第三方生态(数据库、SaaS、设计工具) | 用 MCP | 直接复用现成 server,省掉接入成本 |
| 工具只服务单一业务、调用链极短 | 直接用函数调用 | 引入 MCP 会多一层进程与协议开销,收益为负 |
| 工具需要极致低延迟(如实时补全) | 慎用 | 跨进程通信会引入额外延迟 |
| 工具需要访问宿主私有状态 | 视情况 | 进程隔离有时是优点(安全边界清晰),有时是障碍 |
但要记住:MCP 不解决工具描述写得差的问题。 一个描述糟糕的 MCP 工具,接入之后依然会被误用。协议标准化与契约质量是两件事。
把工具当接口写,模型会当接口用——参数填得对,语义完全错。把工具当契约写,模型才知道你的系统期望它做什么。本刊编辑部
五、自测
ok 与 empty 分开,是性价比极高的一处修复。六、小结
- 工具是契约:名称、用途、参数、返回、幂等性、副作用等级、成本提示,七项齐全。
- 描述要回答四个问题:做什么 / 何时用 / 参数怎么填 / 返回什么。
- 错误要分类:可重试、必须改参数、重试无用,三类处理方式完全不同。
- 空结果不是错误,必须在协议层与失败区分。
- 有副作用的操作要有幂等键,且键必须来自调用方。
- MCP 解决工具供给标准化,不解决工具设计质量。
下一章处理那个决定效果上限的层:模型到底能看到什么。
◇ 面试官会怎么问
共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
工具描述怎么写才算好? 高频
① 什么时候该用、什么时候不该用(最重要的,能显著减少误调用);
② 每个参数的业务含义与取值约束(枚举值、格式、范围,以及默认行为);
③ 典型调用示例(一到两个真实例子,最好包含常见错误写法);
④ 返回值结构(模型要能判断调用是否成功、结果里哪个字段才是它要的);
⑤ 失败与边界情况(什么输入会报错、报错长什么样、能不能重试)。
经验数据是:描述质量对调用准确率的影响,往往比换模型更大。所以我们把工具描述当一等公民维护,有版本、有评测。
追问链
- 那工具描述和 CLI 的 --help 是同一份吗?
- 怎么验证描述改好了?
模型调用工具失败了,你怎么处理?
① 参数错误(缺必填、类型不对、枚举越界)——可修复,把结构化错误回喂(哪个字段、错在哪里、合法取值集合是什么),允许重试 1–2 轮;
② 权限拒绝——不可自动修复,需要用户授权或换路径,应中断并说明;
③ 超时/限流——可重试,但要指数退避 + 上限,并考虑换通道;
④ 业务性错误(查无此人、条件不满足)——不该重试,这是有效信息,应该把"这个事实"告诉模型让它改变策略;
⑤ 工具自身 bug——不该让模型兜底,要告警并降级。
最容易犯的错是把"业务性错误"当异常重试,结果白白烧掉好几轮预算。
MCP 和直接写 Tools 有什么区别?什么时候用哪个? 高频
选型建议:
用 MCP——能力来自外部或第三方、需要生态复用、团队分工上由能力方自己维护、长尾能力多;
用内部 Tools——高频核心链路、需要强管控与极致性能、需要平台级统一配额与审计、参数要深度校验。
真实系统通常是两者并存:核心高频能力走内部工具,长尾与外部能力走 MCP,在模型看来它们是同一套工具抽象。
追问链
- MCP 的安全边界怎么处理?
- 两种工具在权限模型上要区别对待吗?
工具重试导致重复写库、重复发消息,怎么办?
① 只读查询——随便重试,无副作用;
② 幂等写——可以重试,依赖幂等键去重;
③ 非幂等副作用(发消息、转账、提交表单)——默认不自动重试,改由人工确认或状态查询后决定。
幂等键的做法是:在真正执行前,用「任务 ID + 工具名 + 参数规范化哈希」生成键,写入去重表(带 TTL),执行前先查、执行后标记。这样即便重试也只落地一次。
还要注意一个隐蔽问题:超时不代表没执行。超时后应该先查询执行状态,再决定重试,而不是直接重发。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。