2639 字
约 8 分钟
15
S22-X13 · Agent 工具调用的参数校验与重试

Agent 出问题的姿势和传统程序很不一样:工具确实被调用了,参数也确实传了,只是参数缺了一个必填项、或者类型从字符串变成了对象。框架把异常抛给模型,模型看到一坨堆栈,于是用同样的参数再试一次——三次之后 token 烧完,问题还在原地。

工具调用的稳定性,一半靠契约写得够严,一半靠错误反馈得够具体。这篇拆的是这两半。


1. 四类失败,四个责任人

失败类型 典型表现 谁该修 修复手段
参数不合法 枚举外取值、格式不符 工具作者 + 提示词 收紧 schema,加字段描述与示例
必填缺失 少传 order_id 工具作者 schema 标 required,报错指明字段
类型不符 传了 "3" 而非 3 框架层 类型强转或 schema 校验拦截
工具执行失败 下游 500、超时 工具作者 返回结构化错误码,而非堆栈

关键认知:前两类是契约问题,不是模型的错。模型只能从 schema 和描述里推断该怎么填,schema 松、描述含糊,它就只能猜。把"模型乱填参数"当成模型能力问题,通常会连续改好几版提示词而无效。


2. 工具契约怎么写

{
  "name": "query_order",
  "description": "按订单号查询订单状态。仅在用户提供了订单号时调用;不要用手机号猜测订单号。",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string", "pattern": "^[A-Z]{2}\\d{10}$",
                   "description": "订单号,形如 AB1234567890"},
      "fields":   {"type": "array", "items": {"type": "string",
                   "enum": ["status", "amount", "created_at"]}}
    },
    "required": ["order_id"],
    "additionalProperties": false
  }
}
契约要素 作用 不写的后果
enum 把开放取值收成有限集 模型自造字段名,下游解析失败
pattern / 格式 提前拦住格式错误 错误流到业务层才暴露
description 说明何时调用、参数含义 模型选错工具或填错语义
required 明确必填边界 缺参靠运行时报错,浪费一轮
additionalProperties: false 禁止多余字段 拼写错误被静默忽略

字段描述要写判据而不是名词解释。"description": "订单号" 没有信息量;"仅当用户提供了形如 AB1234567890 的编号时使用" 才能约束模型的选择行为。


3. 校验要分两层

层 校验什么 失败返回 谁能改
框架层 schema 类型、必填、枚举、格式 结构化校验错误 模型(重填参数)
工具内业务校验 存在性、权限、状态机 业务错误码 + 提示 模型或用户

两层都要。只做 schema 校验会放过"订单号格式对但不存在";只做业务校验会让格式错误进到数据库查询里,白跑一次 IO。

def invoke(name, args):
    errs = schema_validate(TOOLS[name].schema, args)      # 第一层
    if errs:
        return ToolError(code="INVALID_ARGS", fields=errs)  # 字段级明细
    try:
        return TOOLS[name].run(args)                        # 第二层在 run 内部
    except BusinessError as e:
        return ToolError(code=e.code, message=e.user_message)

业务层的错误信息要面向模型可修正:{"code": "ORDER_NOT_FOUND", "hint": "该订单号不存在,请确认用户提供的是订单号而非运单号"} 比一段堆栈有用得多。


4. 把结构化错误回灌给模型

MAX_TOOL_RETRY = 2

def tool_loop(messages, call):
    for attempt in range(MAX_TOOL_RETRY + 1):
        result = invoke(call.name, call.args)
        if not isinstance(result, ToolError):
            return result
        messages.append({
            "role": "tool", "tool_call_id": call.id,
            "content": json.dumps({
                "error": result.code,
                "invalid_fields": result.fields,      # 精确到字段
                "expected": result.expected,          # 给出正确形态
                "retryable": result.retryable
            }, ensure_ascii=False)
        })
        # 让模型基于错误重填;超过次数则停止,不再烧 token
反馈内容 必须 理由
错误码 是 模型据此区分"重填"还是"放弃"
出错字段名 是 没有字段名,模型会整体重写
期望形态 是 给出示例值比说"格式错误"有效
是否可重试 是 不可重试的错误不该再消耗轮次
原始堆栈 否 噪声,且可能泄露内部结构

重试次数要有硬上限。没有上限的"让模型自己修"会形成死循环:同一参数反复被拒,每轮都完整计费。


5. 幂等与副作用

工具类型 示例 可重试 处理
只读 查询、检索 是 直接重试
幂等写 设置状态为某值 是 幂等键
非幂等写 下单、发消息、扣款 否 幂等键 + 去重表
def idempotent_write(key, fn):
    if seen(key):                       # 同一 key 直接返回上次结果
        return cache[key]
    with lock(key):
        if seen(key):
            return cache[key]
        out = fn()
        cache[key] = out
        return out

# key 由会话 + 工具 + 规范化参数生成,避免"重试=重复下单"
key = sha256(f"{session_id}:{tool}:{canonical_json(args)}")

Agent 场景下副作用尤其危险:模型不知道自己在重试。它只看到"上次工具返回了错误",于是在下一轮直接再调一次。如果工具是"发通知",用户会收到两条。


6. 超时、取消与去重

控制项 做法 缺失后果
单次工具超时 每个工具独立 timeout 一个慢工具拖住整轮对话
整轮预算 限制总时长与总步数 Agent 无限循环
取消传播 用户中断时取消在途调用 后台继续跑,继续计费
同轮去重 相同 (工具, 参数) 合并 并行调用重复执行
async def run_tool(call, budget):
    if budget.exhausted():
        raise StepLimit()
    key = (call.name, canonical_json(call.args))
    if key in inflight:                  # 同一轮内相同调用只跑一次
        return await inflight[key]
    inflight[key] = asyncio.create_task(
        asyncio.wait_for(TOOLS[call.name].run(call.args), timeout=call.timeout))
    return await inflight[key]

并行工具调用时,模型可能在同一轮里对同一工具给出两次几乎相同的参数。去重要基于规范化后的参数(键排序、去空白),否则 {"a":1,"b":2} 与 {"b":2,"a":1} 会被当成两次。


7. 模型选错工具

现象 根因 处理
该查订单却调了搜索 两个工具描述重叠 改描述,写清互斥边界
反复调用不存在的工具名 工具列表未随版本更新 校验工具名,返回可用列表
该一次调用却拆成多次 描述未说明可批量 描述里写明批量参数
该停止却继续调用 缺少终止条件说明 提示词里写明何时停止

选错工具优先改描述,而不是加重试。重试只能纠正"参数填错",纠正不了"选错对象"——模型在重试时会继续选同一个工具,因为它认为这个工具就是对的。

可用工具:query_order(查订单)、search_faq(查规则文档)
边界:问到"我的订单"用 query_order;问到"退款政策"用 search_faq。
两者都不适用时,直接回答并说明缺少信息,不要猜测调用。

8. 可观测

记录项 用途
工具名与参数(脱敏后) 复现问题、分析误选
校验是否被拒 + 拒绝字段 衡量契约质量
执行耗时与结果摘要 定位慢工具与失败率
重试轮次 识别"改不对"的死循环
是否有副作用执行 审计重复写入风险
log.info("tool_call", extra={
    "tool": call.name, "args_hash": args_hash,      # 不落原始敏感值
    "status": "ok|rejected|failed", "fields": err_fields,
    "attempt": attempt, "elapsed_ms": elapsed, "side_effect": TOOLS[call.name].writes,
})

args_hash 而不是原始参数:既能在日志里做关联,又不把用户数据写进日志。


9. 排错

现象 成因 处理
模型反复填错同一字段 schema 太松或描述含糊 加 enum/pattern,描述给示例值
模型看到堆栈后胡乱重试 错误里没给可修正信息 返回错误码 + 字段 + 期望形态
无限重试烧钱 没有重试上限 硬上限 + 不可重试错误直接终止
工具吞异常返回"成功" 内部 catch 后返回空结果 统一抛业务错误,禁止静默成功
同一轮重复执行写操作 无去重、无幂等键 规范化参数去重 + 幂等键
一个慢工具卡住整轮 无单工具超时 每工具独立 timeout + 整轮预算
用户中断后仍在调用 取消未传播 中断信号透传到工具层

最后一条值得展开:工具内部吞异常是 Agent 里最难查的一类问题。模型会以为调用成功,然后基于空结果继续推理,最终给出一个"逻辑正确但事实全错"的答案——比直接报错更难定位。


10. 边界

问题 本层能解决吗 应去哪一层
模型不理解业务概念 否 提示词与知识检索
工具本身下游不稳定 部分 服务端 SLA 与重试策略
多步规划不合理 否 提示词与流程编排
参数语义歧义(同一字段两种含义) 部分 拆成两个字段或两个工具
权限不足 部分 鉴权层与租户隔离

11. 自测

  1. 工具调用失败的四类原因,哪些属于契约问题而非模型问题?
  2. 为什么字段描述要写"判据"而不是名词解释?
  3. 框架层 schema 校验与工具内业务校验,各自拦住了什么?
  4. 回灌给模型的错误里,哪四项是必需的,堆栈为什么不要给?
  5. Agent 场景下为什么非幂等写操作尤其危险?
  6. 模型选错工具时,为什么优先改描述而不是加重试?
S22-X13 · Agent 工具调用的参数校验与重试
https://www.clxhxhhr.top/posts/5875/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。