Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
II 版 · Harness 工程 第 03 章 Tools as Contracts
II · Harness 工程 03 / 22 进阶

Tool Use 与工具契约

Tools as Contracts
预计阅读 34 分钟
难度 进阶
关键词 Function Calling · 工具描述
本机状态 未读

工具不是给人看的 API 文档,而是给模型看的能力契约。同一段业务逻辑,描述写法不同,工具调用成功率可以差出一倍。这一章讲四件事:描述怎么写、参数怎么约束、错误怎么分类回喂、以及什么时候该上 MCP。

编辑部注 · 这一章的内容可以直接拿去改你手上的 Harness —— 它是投入产出比最高的一层。

先纠正一个常见误解:工具调用的失败,多数不是模型「不会用」,而是模型「不知道该怎么用」。你要给它的不是接口签名,而是判断依据——什么时候用这个工具、参数填什么范围、返回的结构长什么样、出错时代表什么。

一、工具是契约,不是接口

一个完整的工具契约包含七个要素。少任何一个都会以某种方式转化为线上问题。

要素写什么缺了会怎样
名称动词开头、语义唯一、不与其它工具重名或近似模型在多个相似工具间随机选择
用途描述一句话说明「做什么」+ 一句话说明「什么时候该用 / 不该用」模型在该用的场景想不到它
参数 schema类型、必填性、枚举取值、范围、格式范例参数写错、格式不对、枚举乱填
返回值结构字段名、类型、是否可能为空、可能的错误形态模型误读结果,基于空值继续推理
幂等性标记是否可安全重试超时后盲目重试,产生重复副作用
副作用等级只读 / 可逆写 / 不可逆越权执行,出事无法追责
调用成本提示是否昂贵、是否慢、是否有配额模型反复调用昂贵接口,成本失控
同一工具,两种命运 差别不在业务逻辑上,只在你给模型看的那几百个字符上。 写法 A 调用成功率低 { "name": "query_data", "description": "查询数据", "parameters": { "type": "object", "properties": {"q": {"type": "string"}} } } → 模型不知道 q 是自然语言还是 SQL,不知道返回值长什么样 写法 B 调用成功率高 { "name": "search_articles", "description": "按关键词检索已发布文章, 返回标题与摘要。适合先定位候选; 需要正文请用 fetch_article。", "parameters": { "q": {"type":"string","description":"中文关键词,   不要写 SQL;例:'客服 满意度'"}, "limit": {"type":"integer","minimum":1,"maximum":20} } B 比 A 多做的四件事(每一件都直接对应成功率提升) 1. 说清「什么时候用」+「什么时候用别的」→ 消除相似工具之间的选择困难 2. 参数给格式范例与反例 → 避免模型按自己理解填(如误填 SQL) 3. 用 minimum / maximum 约束范围 → 把「错参数」变成结构上不可能 4. 说明返回结构 → 模型知道下一步怎么用这批结果,减少无意义追问
图 1 工具描述的四个层次:做什么 → 什么时候用 → 参数怎么填 → 返回什么。多数团队只写了第一层,然后靠「加系统提示词」去补救第二到第四层——把该写在契约里的信息写进提示词,是典型的职责错位。

二、错误分类:决定重试有没有意义

这是本章最有工程价值的部分。工具执行失败时,Harness 只有两种选择:抛出异常终止,或者把错误回喂让模型换策略。而回喂的内容决定了第二次尝试有没有意义。

错误分类 → 处理策略(这张表建议直接抄进你的代码)
错误类别典型来源该不该重试回喂内容
参数错误字段缺失、类型不符、枚举越界必须改参数后重试指出具体哪个字段错、合法取值是什么
格式错误JSON 解析失败、日期格式不对改格式后重试给出正确格式的示例
业务规则拒绝不符合前置条件(如「订单已关闭」)换方案,不重试说明被拒原因与可能的替代路径
权限不足无权限访问该资源重试无用明确「重试无用」,建议上报或换目标
资源不存在对象已删除、ID 拼错换目标后重试提示确认标识是否正确
超时 / 限流下游慢、配额耗尽可原样重试(退避)提示是瞬时问题,可稍后重试
上游 5xx依赖服务故障有限次重试标为系统故障,避免模型反复自我怀疑
结果为空查询合法但无数据不是错误明确告知「查询成功但无结果」,防止模型以为工具坏了而反复重试
最后一行是隐形杀手

「查询成功但无结果」被当成错误处理,会引发一类非常隐蔽的成本浪费:模型看到「失败」,于是换关键词重试;再失败,再换;直到预算耗尽。日志里看到的是一连串工具调用(看起来很正常),实际全程都在做无用功。

修法:把「空结果」和「失败」在协议层就区分开——返回值里带上明确的状态字段(如 {"status":"ok","items":[]}),回喂时也明确告诉模型「查询成功,但确实没有匹配数据,请考虑条件是否过严或该数据不存在」。

三、让调用不可能出错:三层约束

按「结构的强制程度」从弱到强排列:

第一层,描述约束。 在描述里写清取值范围与范例。靠模型自觉,最弱但成本最低。

第二层,schema 约束。 用 JSON Schema 的 enumminimummaximumpatternrequired 把非法输入堵死。多数模型 API 在结构化输出模式下会直接保证符合 schema。

第三层,执行前校验。 无论前面怎么写,Handler 入口必须再校验一遍——因为模型可能根本不走结构化输出通道,或者你做了参数转换。

tool_contract.pypython
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 工具,接入之后依然会被误用。协议标准化与契约质量是两件事。

把工具当接口写,模型会当接口用——参数填得对,语义完全错。把工具当契约写,模型才知道你的系统期望它做什么。本刊编辑部

五、自测

本章自测第 2 题为高频考点
Q1提升工具调用成功率最有效的单项改动通常是?
B。重试与降温度都只是止损,换模型成本高昂且不可控。而描述补全是一次性投入、长期生效的改动——它直接消除了模型「不知道该填什么」的不确定性来源。这是 Harness 工程里投入产出比最高的优化之一。
Q2工具返回「查询成功但结果为空」,Harness 应该怎么处理?
D。把空结果误判为失败,会让模型进入「换关键词—再失败—再换」的空转循环,日志上看全是正常的工具调用,成本却被无声烧掉。协议层就把 okempty 分开,是性价比极高的一处修复。
Q3关于幂等键,下面哪个说法正确?
A。时间戳生成(B)是最常见的错法:每次重试都得到新键,幂等形同虚设。C 反了——读操作天然幂等,恰恰是写操作最需要幂等保护。D 混淆了两件事:幂等解决「重复执行」,人工确认解决「是否该执行」,两者不可互相替代。

六、小结

  • 工具是契约:名称、用途、参数、返回、幂等性、副作用等级、成本提示,七项齐全。
  • 描述要回答四个问题:做什么 / 何时用 / 参数怎么填 / 返回什么。
  • 错误要分类:可重试、必须改参数、重试无用,三类处理方式完全不同。
  • 空结果不是错误,必须在协议层与失败区分。
  • 有副作用的操作要有幂等键,且键必须来自调用方。
  • MCP 解决工具供给标准化,不解决工具设计质量

下一章处理那个决定效果上限的层:模型到底能看到什么。

面试官会怎么问

共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照

工具描述怎么写才算好? 高频
把工具描述当成给一个新同事的操作手册,而不是给人看的 API 文档。至少要包含五件事:
① 什么时候该用、什么时候不该用(最重要的,能显著减少误调用);
② 每个参数的业务含义与取值约束(枚举值、格式、范围,以及默认行为);
③ 典型调用示例(一到两个真实例子,最好包含常见错误写法);
④ 返回值结构(模型要能判断调用是否成功、结果里哪个字段才是它要的);
⑤ 失败与边界情况(什么输入会报错、报错长什么样、能不能重试)。
经验数据是:描述质量对调用准确率的影响,往往比换模型更大。所以我们把工具描述当一等公民维护,有版本、有评测。

追问链

  1. 那工具描述和 CLI 的 --help 是同一份吗?
  2. 怎么验证描述改好了?
模型调用工具失败了,你怎么处理?
先分类,不要统一 try-catch:
① 参数错误(缺必填、类型不对、枚举越界)——可修复,把结构化错误回喂(哪个字段、错在哪里、合法取值集合是什么),允许重试 1–2 轮;
② 权限拒绝——不可自动修复,需要用户授权或换路径,应中断并说明;
③ 超时/限流——可重试,但要指数退避 + 上限,并考虑换通道;
④ 业务性错误(查无此人、条件不满足)——不该重试,这是有效信息,应该把"这个事实"告诉模型让它改变策略;
⑤ 工具自身 bug——不该让模型兜底,要告警并降级。
最容易犯的错是把"业务性错误"当异常重试,结果白白烧掉好几轮预算。
加分点:"业务性错误不该重试"这一条能把有实战经验的人区分出来。
MCP 和直接写 Tools 有什么区别?什么时候用哪个? 高频
本质区别是解耦层次:直接写 Tools 是把能力硬编码进 Harness,调用方式、鉴权、协议都是平台自己的;MCP 把"能力暴露与调用"标准化成协议,新增一个上游只需要提供一个 MCP server,平台侧不用改代码
选型建议:
用 MCP——能力来自外部或第三方、需要生态复用、团队分工上由能力方自己维护、长尾能力多;
用内部 Tools——高频核心链路、需要强管控与极致性能、需要平台级统一配额与审计、参数要深度校验。
真实系统通常是两者并存:核心高频能力走内部工具,长尾与外部能力走 MCP,在模型看来它们是同一套工具抽象。

追问链

  1. MCP 的安全边界怎么处理?
  2. 两种工具在权限模型上要区别对待吗?
工具重试导致重复写库、重复发消息,怎么办?
根子上要先给工具分级,再定重试策略
① 只读查询——随便重试,无副作用;
② 幂等写——可以重试,依赖幂等键去重;
③ 非幂等副作用(发消息、转账、提交表单)——默认不自动重试,改由人工确认或状态查询后决定。
幂等键的做法是:在真正执行前,用「任务 ID + 工具名 + 参数规范化哈希」生成键,写入去重表(带 TTL),执行前先查、执行后标记。这样即便重试也只落地一次。
还要注意一个隐蔽问题:超时不代表没执行。超时后应该先查询执行状态,再决定重试,而不是直接重发。
加分点:"超时不代表没执行"是这一题的分水岭,很多人会漏掉。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes