TL;DR
- 工具调用失败不是异常分支,而是主流程的一部分:模型会编参数、网络会抖、下游会超时。
- 参数校验要在执行前做,用 JSON Schema 挡住模型幻觉出来的字段,比在下游报错后再补救便宜得多。
- 重试只对幂等操作安全;对写操作重试前,先确认你带了幂等键(idempotency key)。
- 把错误结构化回填给模型,让它自己决定换参数还是换工具,比在代码里硬编码所有分支更省事。
- 超时和重试预算要一起设:没有预算的重试会把一次慢请求放大成一次雪崩。
前置知识
你需要:
- 写过至少一次 function calling / tool use 的调用循环,知道
tools参数和tool_calls返回长什么样; - 能读懂 JSON Schema 的基本字段(
type、required、enum); - 熟悉你所用语言的
try/catch或等价错误处理。
你不需要:
- 不需要读过分布式系统教材——幂等和超时这里只讲 Agent 场景下够用的部分;
- 不需要自己实现重试框架,标准库或现成库就够;
- 不需要先搞懂 MCP 协议细节,本文的错误处理逻辑与具体工具协议无关。
零基础?先读小白故事线《如果 Agent 是新来的实习生》。
失败从哪里来:先分类,再处理
在写任何 try/catch 之前,先把失败按来源分四类。分类的意义在于:不同来源的失败,正确的应对方式完全不同,混在一起处理只会写出一个什么都兜、什么都兜不住的巨型异常块。
第一类:模型给的参数不合法。 模型生成 tool_calls 时,参数是它“写”出来的文本,不是类型安全的对象。它可能漏掉必填字段、把数字写成字符串、给枚举字段编一个不存在的值,甚至凭空造出一个 schema 里根本没有的字段。这类失败发生在你的代码里,执行前就能发现。
第二类:工具执行本身失败。 参数合法,但下游拒绝了:查订单返回 404、写数据库撞唯一键、第三方 API 返回 429。这类失败发生在你的代码之外,你只能观察和转述。
第三类:超时与网络抖动。 请求发出去了,但没在预期时间内回来。它和上一类的关键区别是:你不知道对方到底执行了没有。这个不确定性直接决定了重试是否安全。
第四类:模型对结果的处理失败。 工具成功返回了,但模型把结果理解错了,或者干脆忽略了结果继续瞎编。这类失败最难发现,因为它不抛异常。
前两类是本文重点,第三类决定重试策略,第四类靠回填格式和提示词缓解。
参数校验:在执行前挡住幻觉
最便宜的错误处理,是让错误根本没机会发生。工具执行前加一层校验,成本是几行代码,收益是把一整类下游报错变成一条清晰的反馈。
以 OpenAI 风格的 function calling 为例,工具的 parameters 本来就是一份 JSON Schema。同一份 schema,既交给模型当约束,也用来校验模型返回的参数——不要只把它当提示词用。
import { Validator } from "jsonschema";
const schema = {
type: "object",
properties: {
orderId: { type: "string", pattern: "^[A-Z]{2}-\\d{6}$" },
reason: { type: "string", enum: ["refund", "cancel", "status"] },
},
required: ["orderId", "reason"],
additionalProperties: false,
};
function validateArgs(rawArgs) {
const v = new Validator();
const result = v.validate(JSON.parse(rawArgs), schema);
if (!result.valid) {
return { ok: false, errors: result.errors.map((e) => e.stack) };
}
return { ok: true, value: JSON.parse(rawArgs) };
}
三个细节值得注意。其一,JSON.parse 本身可能抛异常——模型偶尔会返回截断的 JSON,这一步要包在 try 里。其二,additionalProperties: false 很关键:模型很爱多塞字段,宽松的 schema 会让这些字段一路流到下游。其三,校验失败时不要抛异常中断循环,而是把错误收集成结构化数据,走下一节的回填路径。
校验通过之后、真正执行之前,还有一层容易被忽略的检查:业务前置条件。schema 只能保证 orderId 格式对,保证不了这个订单存在、属于当前用户、状态允许退款。这类检查放在工具函数内部,失败时同样走回填。
重试与降级:先问幂等,再问次数
重试是最容易被滥用的手段。在决定重试之前,先回答一个问题:这个操作重复执行一次,结果会变吗?
幂等操作可以放心重试。 查询、读取、GET 类请求,重试 N 次和重试 1 次结果一样。这类操作配上指数退避(exponential backoff)就够了。
非幂等操作重试前必须带幂等键。 下单、支付、发消息这类操作,盲目重试可能造成重复扣款或重复发送。正确做法是让调用方生成一个唯一键随请求带上,下游用它去重。截至 2026-09-12,主流支付与云服务 API 普遍支持这种模式,具体字段名各家不同,以官方文档为准。
async function callWithRetry(fn, { maxAttempts = 3, baseDelayMs = 300 } = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return { ok: true, value: await fn() };
} catch (err) {
const retryable = err.status === 429 || err.status >= 500;
if (!retryable || attempt === maxAttempts) {
return { ok: false, error: err, attempts: attempt };
}
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
}
}
}
这段骨架里有两个决策点,比代码本身更重要。
哪些错误该重试? 经验规则:429(限流)和 5xx(服务端错误)值得重试,4xx 里的 400/401/403/404 重试多少次都一样,属于“重试也白搭”,应当直接失败并回填。把不可重试的错误反复重试,只会浪费你的重试预算,还拖慢整个循环。
重试失败之后怎么办? 这就是降级(fallback)。降级的选项按优先级大致是:换一个等价工具(比如主搜索 API 挂了换备用源)、返回缓存或默认值、把失败如实回填给模型让它换个思路。降级不等于静默吞掉错误——如果最终结果不可信,必须让模型和用户都知道。
把错误回填给模型:让它自己修
这是 Agent 相比传统程序最有意思的一点:错误信息本身可以是给模型的输入。传统程序里,异常要么被处理要么被抛出;在 Agent 里,异常还有第三个去处——转成一条 tool 角色的消息,塞回对话,让模型决定下一步。
关键在于回填的格式。一句 "Error: something went wrong" 对模型毫无价值,它只能瞎猜。有效的回填至少包含三部分:哪个参数错了、期望什么、你可以怎么改。
function toolErrorPayload(toolName, reason, hint) {
return JSON.stringify({
tool: toolName,
status: "error",
reason, // 例如 "missing required field: orderId"
hint, // 例如 "orderId 形如 AB-123456,请向用户确认"
retryable: false, // 明确告诉模型这次别重试
});
}
几个实践要点。明确 retryable 字段:模型看到一个可重试的错误会倾向于重试,看到不可重试的会更倾向于换参数或换工具,这个信号能显著减少无效循环。给出可操作的 hint:与其说“参数无效”,不如说“reason 只能是 refund / cancel / status 之一”。限制重试轮数:即使回填写得再好,也要在循环层面设一个上限(比如同一工具连续失败 3 次就停止),否则模型可能陷入“改一点、再错、再改一点”的死循环。
回填还有一个常被忽略的用途:记录。把每次失败的原始参数和错误一起写进日志,是后续调 prompt 和改 schema 的第一手材料。
超时与幂等:别让一次慢请求变成雪崩
超时和重试是一对必须一起设计的参数。没有超时的重试等于没有重试——如果单次调用可能挂 60 秒,三次重试最坏情况就是 180 秒,用户的等待时间被放大三倍,而成功率未必提高。
实践上分两层设预算。单次调用超时:给每个工具一个合理的上限,查询类可以短(比如 5 秒),生成类可以长(比如 60 秒)。整个工具调用的总预算:从 Agent 收到请求开始计时,超过总预算就停止重试,把当前状态回填给模型。这样最坏情况下的延迟是可预测的。
超时还牵出幂等问题。当一次调用超时,你面对的是一个未知状态:请求可能根本没到下游,也可能到了、执行了、只是响应没回来。对幂等操作,重试即可;对非幂等操作,重试前必须靠幂等键去重,否则一次超时可能变成两次扣款。所以幂等键的生成时机应该在首次调用之前,而不是在重试时——重试要复用同一个键。
最后,把超时和幂等一起写进工具的元数据里,而不是散落在各处:
const toolMeta = {
name: "create_order",
timeoutMs: 10_000,
idempotent: false, // 决定超时后能否自动重试
maxAttempts: 2,
};
这份元数据让重试逻辑可以统一处理所有工具,而不必为每个工具写一套 if-else。完整版的重试与幂等实现见官方文档,这里给的是足以启动理解的骨架。
截至 2026-09-12,各家模型厂商对 tool use 的错误回填格式没有强制统一标准,具体字段以你所用平台的官方文档为准。
延伸阅读
- OpenAI Function Calling 指南:讲清
tools参数、tool_calls返回结构与多轮工具循环的官方写法,是理解“错误回填”上下文的基础。 - JSON Schema 官方教程:参数校验所依赖的 schema 语法权威参考,重点看
required、enum、additionalProperties。 - Anthropic Tool use 文档:工具定义与
tool_result回填的官方说明,含错误结果如何传回模型的示例。 - Anthropic《Building effective agents》:从工程视角讨论 Agent 循环与失败处理的设计取舍,适合作为整体架构的延伸阅读。