2978 字
约 9 分钟
15
S21-A06 · Agent 工具设计:粒度、副作用与失败语义

给模型接了二十个工具,第一周就开始乱选:查订单的时候调了退款,问库存的时候调了创建工单。加提示词没用:工具本身就是提示词,而且比提示词更硬。

这篇谈三件事:粒度怎么定、副作用怎么分级、失败怎么表达。


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. 自测

  1. 工具误选主要由数量还是语义重叠造成?拆分应该由什么驱动?
  2. 工具描述的四段结构里,哪一段最常被省略,为什么它最能降误选?
  3. 为什么"不要让模型计算日期区间",正确的传参形态是什么?
  4. 不可逆写为什么拆成"预览 + 执行"两个工具,confirm_token 的真正作用是什么?
  5. 空结果为什么不应该返回错误?
  6. 幂等键里的 scope 为什么用会话而不是用户?
S21-A06 · Agent 工具设计:粒度、副作用与失败语义
https://www.clxhxhhr.top/posts/6119/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。