给模型接了二十个工具,第一周就开始乱选:查订单的时候调了退款,问库存的时候调了创建工单。加提示词没用:工具本身就是提示词,而且比提示词更硬。
这篇谈三件事:粒度怎么定、副作用怎么分级、失败怎么表达。
1. 粒度与选择能力
工具数量上升,选择质量下降,这是结构性的:
| 工具规模 | 典型表现 | 应对 |
|---|---|---|
| 个位数 | 选择基本稳定,描述能写清 | 保持,别急着拆 |
| 十几个 | 语义重叠的开始互相误选 | 合并同义工具,先减后加 |
| 几十个 | 需要分组或分层路由 | 先粗后细,或加意图分类前置 |
真正决定误选率的是语义重叠,不是数量。两个工具都能"查订单",选哪个都对也都不对。拆分前先问:参数差异大,还是语义差异大? 参数差异大(按 id vs 按手机号)值得拆;语义差异大但参数一样(查订单 vs 查退款)该合并成一个带 type 枚举的工具。
2. 先粗后细的收敛方式
起步不要设计全套工具,先给两三个粗工具,然后从失败日志里长出来:
第一版(3 个)
search(kind, query) # kind: order|refund|ticket
execute(action, target, args) # action: cancel|refund|update
观察两周后拆分的判据:
· 某 kind 的调用占比 > 40%,且参数总是同几个字段 → 值得独立成专用工具
· 某 action 被误用(该 cancel 却调了 refund)→ 拆开,各写"何时不用"
粗工具选择空间小,但参数退化为自由文本;细工具反过来。拆分由"参数能否收敛成枚举"驱动,不由"看起来更清晰"驱动。
3. 工具描述怎么写
描述比参数 schema 更重要,因为模型是先按描述选工具,再按 schema 填参数。描述写不好,schema 再严也没机会生效。
| 要素 | 写什么 | 反例 |
|---|---|---|
| 做什么 | 一句话、动词开头、说明作用对象 | "订单相关操作" |
| 何时用 | 触发条件的同义说法(覆盖用户可能的措辞) | 只写"查询订单" |
| 何时不用 | 明确排除,并指向正确工具 | 缺失,于是被拿去做退款 |
| 返回什么 | 字段清单与量级(几条、多大) | "返回结果" |
{
"name": "search_orders",
"description": "按条件检索订单,只读。用户问订单状态、金额、物流、下单时间时使用。不要用于退款、取消或改单——那些用 execute_order_action。返回最多 20 条,每条含 order_id、status、amount;无结果返回空数组。",
"parameters": {
"type": "object",
"properties": {
"phone": { "type": "string", "description": "用户手机号,11 位" },
"status": { "type": "string", "enum": ["paid", "shipped", "done", "cancelled"] },
"limit": { "type": "integer", "default": 10, "maximum": 20 }
},
"required": ["phone"]
}
}
三段结构:做什么(只读/写)→ 何时用 → 何时不用并指向替代工具。第三段最常省略,也最能降误选。
4. 参数设计的约束
| 约束 | 理由 | 做法 |
|---|---|---|
| 必填项尽量少 | 每多一个必填,多一次追问或一次编造 | 能从上下文或会话推断的就不要必填 |
| 枚举优先于自由文本 | 自由文本是编造的入口 | status 用 enum,别用 string |
| 不要让模型做计算 | 日期区间、金额换算、单位转换都会算错 | 传 "last_7_days",服务端换算 |
| 不让模型生成 ID | 长 ID 几乎必错 | 先 search 拿 id,再 execute |
| 加硬上限 | 模型会给出 limit: 1000 |
maximum、maxLength 写在 schema 里 |
服务端拒绝后,模型常换个更大的值重试。约束写进 schema 是省一轮对话,不只省一次校验。
5. 副作用分三级
| 等级 | 例子 | 策略 |
|---|---|---|
| 只读 | 查询、搜索、读取配置 | 直接执行,不加确认 |
| 幂等写 | 打标签、更新备注、设置偏好 | 幂等键 + 静默执行,可回滚 |
| 不可逆写 | 下单、退款、删除、发消息给外部 | 预览 + 明确确认 + 幂等键 |
不可逆写的标准形态是"先预览再执行",拆成两个工具:
# 1) 预览:只计算、不落库,返回将被改变的内容
preview = execute_order_action(action="refund", order_id="A-1", dry_run=True)
# → will_refund / order_status_after / reversible
# 2) 执行:需要带上前一步返回的 token,防止模型跳步
execute_order_action(action="refund", order_id="A-1", confirm_token=preview["token"])
confirm_token 的作用是强制顺序:拿不到 token 就跳不过预览。
6. 失败语义
失败信息是模型下一步的输入。丢个堆栈回去,它只会重试或道歉。
| 失败类型 | 可重试 | 返回给模型的内容 |
|---|---|---|
| 参数缺失/非法 | 否 | 哪个字段、期望什么格式、示例值 |
| 前置条件不满足 | 否 | 缺什么、用哪个工具补齐 |
| 临时故障(超时/5xx) | 是(有限次) | "可重试一次" + 建议退避 |
| 权限不足 | 否 | 哪个权限、谁能授权 |
| 空结果 | 不算失败 | 空数组 + "换条件再试"的提示 |
{
"ok": false,
"code": "PRECONDITION_MISSING",
"retryable": false,
"message": "order_id 不存在或不属于当前用户",
"next": "先用 search_orders 按手机号查到 order_id,再调用本工具",
"example": { "phone": "138****0000" }
}
三个字段缺一不可:retryable 决定要不要重试,next 决定重试什么,example 降低二次填错的概率。
空结果不要返回错误:报错会让模型换工具重试,正确动作往往是放宽条件。
7. 返回值体积
工具返回会直接进上下文。一次返回 200KB JSON,后面几轮都没法思考。
| 策略 | 做法 | 代价 |
|---|---|---|
| 上限截断 | 单条结果 maxLength、列表 limit |
长尾信息丢失 |
| 分页游标 | 返回 next_cursor,让模型决定要不要翻 |
多一轮调用 |
| 摘要 + 明细 | 列表给摘要字段,详情另开工具 | 两个工具,但上下文省一半 |
| 字段白名单 | 只返回模型决策需要的字段 | 需要按场景裁剪 |
推荐组合:列表返摘要 + 游标,详情按 id 取单条。一次解决体积和"在缺失字段上瞎猜"。
8. 依赖与顺序约束
| 约束类型 | 表达位置 | 例子 |
|---|---|---|
| 前置工具 | 描述里写"调用前必须已有 X" | execute_order_action 需要 order_id |
| 顺序不可跳 | 用 token/句柄强制 | 预览返回的 confirm_token |
| 互斥 | 描述里写"与 X 二选一" | 取消 vs 退款 |
只靠描述约束顺序并不可靠,模型会跳步。能靠数据结构强制的,别靠文字。
9. 幂等键与去重
模型重试、超时重发、多轮重复确认都会造成重复写。幂等键要覆盖"同一次意图":
key = sha256(json.dumps({
"tool": "execute_order_action",
"args": normalize(args), # 排序键、去空白、统一大小写
"scope": conversation_id, # 同一会话内视为同一次意图
}, sort_keys=True))
三个设计点:normalize 必须稳定(否则键序不同就算两次);scope 用会话而非用户(同一用户在不同会话里确实可能下两单);键存储带 TTL,与业务可撤销窗口对齐。
第二次调用要返回第一次的结果,而不是报"重复请求"——报错会让模型以为失败并再试一次。
10. 观测与权限
| 观测项 | 用途 | 注意 |
|---|---|---|
| 入参(脱敏 + hash) | 复现与聚类 | 敏感字段不入日志 |
| 耗时 | 区分模型慢还是工具慢 | 与推理耗时分开计 |
| 结果分类 | success / empty / denied / error | 混算就看不出问题 |
| 权限维度 | 做法 | 适用 |
|---|---|---|
| 按用户 | 工具内取当前身份,不信任模型传的 user_id | 默认,不可省 |
| 按工具 | 不同 agent 拿到不同工具集 | 子 agent 只给最小集 |
| 按会话 | 临时授权,会话结束失效 | 危险操作二次确认 |
永远不要让模型传身份参数。 user_id 出现在 schema 里,就等于把越权交给提示词去防。身份从会话上下文取,工具自己拼。
11. 测试
| 测试类型 | 内容 | 通过标准 |
|---|---|---|
| 离线回放 | 录制真实调用序列重放到新版本 | 选择与参数一致率不低于基线 |
| 边界输入 | 空参数、超长文本、注入式参数 | 不崩溃、不越权、错误可读 |
| 拒绝路径 | 权限不足、用户拒绝确认、超时 | 停在正确状态,无半成品写入 |
| 重复调用 | 同参数连发两次 | 只产生一次副作用 |
回放用例能跑在 CI 里,改描述不必先上线观察:
def test_refund_requires_preview(cassette):
out = run_agent("帮我把 A-1 退款", tools=[preview, execute], replay=cassette)
assert out.tool_calls[0].name == "execute_order_action"
assert out.tool_calls[0].args.get("dry_run") is True
12. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 模型不调用工具,直接编答案 | 描述里的措辞与用户说法不匹配 | 把用户常见说法写进"何时用" |
| 反复调同一工具 | 返回值没告诉它"够了" | 加 is_complete 或明确"最多 20 条" |
| 参数总填错 | 必填过多、缺枚举、要它算日期 | 减必填、加 enum、传相对语义 |
| 同一单被下两次 | 无幂等键,或键随重试变化 | 键要含规范化参数 + 会话 |
13. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 只有两三个工具 | 不建体系,写清描述即可 | 设计成本高于误选成本 |
| 纯只读工具集 | 可放宽确认与幂等 | 最坏结果是查错,不是写错 |
| 强一致写操作 | 不交给模型决定 | 模型只提议,人执行 |
| 工具即内部 API 直通 | 先包一层再暴露 | API 参数不是给模型看的 |
| 高频固定流程 | 用工作流固化,不用工具自由组合 | 组合空间是错误来源 |
一句话:工具设计的目标不是"能力齐全",是"错误可预测"。 一个模型永远用不错的工具集,比一个什么都能做的工具集更值钱。
14. 自测
- 工具误选主要由数量还是语义重叠造成?拆分应该由什么驱动?
- 工具描述的四段结构里,哪一段最常被省略,为什么它最能降误选?
- 为什么"不要让模型计算日期区间",正确的传参形态是什么?
- 不可逆写为什么拆成"预览 + 执行"两个工具,
confirm_token的真正作用是什么? - 空结果为什么不应该返回错误?
- 幂等键里的
scope为什么用会话而不是用户?