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. 自测
- 工具调用失败的四类原因,哪些属于契约问题而非模型问题?
- 为什么字段描述要写"判据"而不是名词解释?
- 框架层 schema 校验与工具内业务校验,各自拦住了什么?
- 回灌给模型的错误里,哪四项是必需的,堆栈为什么不要给?
- Agent 场景下为什么非幂等写操作尤其危险?
- 模型选错工具时,为什么优先改描述而不是加重试?