工具调用(Function Calling)失败时,大多数框架的默认行为是让异常沿调用栈传播——在我们的流式架构里,这意味着整条 SSE 流被打断:用户看到推理中途崩掉,模型连"补救"的机会都没有。
一个工具充足的 Agent 系统,工具失败应该是常态输入,而不是异常路径:MCP Server 会宕机、搜索 API 会超时、模型会生成畸形参数。本文介绍我们的降级协议:执行隔离 + 限时 + 三态结构化错误回喂。核心认知只有一句话——
回喂给 LLM 的工具错误文本不是日志,是 prompt 的一部分。模型会读它,并根据它决定下一步。
1. 为什么不能裸抛异常
我们的主 Agent 单轮 = 一次流式 LLM 调用,工具循环由框架在流内部驱动(call tool → 喂结果 → 继续生成)。工具回调抛异常的连锁反应:
工具抛异常 → 框架终止 chatResponse() Flux → 流进入 onError
→ 已 committed 的 SSE 响应上无法再优雅收尾 → 用户看到"推理过程出错"
→ 本轮已生成的内容、已完成的其他工具调用全部浪费
而如果把错误作为工具结果文本返回给模型,模型会带着这个信息继续推理:修正参数重试、换用其他工具、或者如实告知用户"暂时查不到"。后者才是 Agent 应有的韧性。
2. 三态错误协议
笼统的"工具调用失败"对模型没有行动指导意义。我们按模型应采取的恢复策略把错误分三态,文本前缀显式标注:
| 态 | 触发 | 回喂文本形态 | 期望模型行为 |
|---|---|---|---|
| 【参数错误】 | 参数 JSON 解析/类型转换失败 | 【参数错误】工具「x」的参数不合法(原因)。请修正参数格式后重新调用,所有字符串值必须加双引号。 |
立即修正参数重试(模型自纠成功率很高) |
| 【工具超时】 | 执行限时熔断、网络/读取超时 | 【工具超时】工具「x」执行超过 N 秒仍未返回,已中止本次调用。可稍后重试,或改用其他方式完成任务。 |
降频重试或换路径,不要立刻连环重试 |
| 【暂不可用】 | 其他一切(连接拒绝、5xx、NPE) | 【暂不可用】工具「x」调用失败:原因。可修正参数后重试,或改用其他方式完成任务。 |
换工具/换思路/告知用户 |
分类沿异常因果链判定(参数错误看 Jackson 转换特征、超时看 SocketTimeoutException/HttpTimeoutException),根因消息截断到 300 字符避免污染上下文。
每种态的文本都以"可执行的下一步建议"结尾——这不是写给运维看的,是写给模型看的。错误文本的措辞质量直接决定模型的恢复质量,值得像写 prompt 一样打磨。
3. 执行隔离与单工具限时
降级的前提是工具不能无限挂起——挂起的工具不打断流,但会冻结这一轮推理(模型在等工具结果)。给每个工具回调包一层限时壳:
private static final ExecutorService TOOL_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor(); // 每任务一个虚拟线程
public String call(String toolInput) {
return callWithTimeout(() -> delegate.call(toolInput), toolInput);
}
private String callWithTimeout(Supplier<String> action, String toolInput) {
var future = CompletableFuture.supplyAsync(action, TOOL_EXECUTOR);
try {
return future.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return "【工具超时】工具「" + toolName() + "」执行超过 " + timeoutSeconds
+ " 秒仍未返回,已中止本次调用。可稍后重试,或改用其他方式完成任务。";
} catch (Exception e) {
return classify(unwrap(e)); // → 三态文本
}
}
设计决策:
- 虚拟线程而非平台线程池:工具调用以 IO 阻塞为主,虚拟线程无池化上限问题;超时
cancel(true)中断即可,不产生线程泄漏焦虑; - 限时按工具类型分级:普通工具 60s,媒体生成(图/视频)天然分钟级,单独放宽到 300s——统一一个值会要么误杀慢工具、要么放任挂起;
- 限时层在容错壳里,不在各工具内部:对 MCP/内置/自定义三类工具统一生效,新增工具自动获得保护。
4. 超时治理的统一收口
工具层限时只是最后一道。外部调用散落各处时,超时值会各自为政(这次治理前我们有两处调用是零超时)。收口为一个配置块:
app:
resilience:
llm: # LLM 网关
connect-timeout-seconds: 10
read-timeout-seconds: 300 # 流式读空闲看门狗,必须 ≫ 正常 token 间隔
mcp: # MCP Server JSON-RPC
connect-timeout-seconds: 5
read-timeout-seconds: 30
tool: # 工具执行限时(上文)
default-timeout-seconds: 60
media-timeout-seconds: 300
identity: # 内部身份服务(仅幂等调用做有限退避重试)
timeout-seconds: 5
max-retries: 1
retry-backoff-ms: 1000
两个容易配错的点:
- 流式读超时是空闲看门狗,不是总时长限制:它度量"两个数据块之间的最大间隔"。LLM 流式的 token 间隔通常在毫秒级,但长思考可能出现数十秒的安静期——取 300s 既能兜住网关挂起,又绝不误杀正常流;
- 重试只给幂等调用:身份查询、健康检查可以指数退避重试;工具执行(可能有副作用,如发消息、生成计费媒体)不重试,失败交给三态协议让模型决策。
5. 效果与验证
治理后的行为矩阵(均有测试钉住):
| 场景 | 旧行为 | 新行为 |
|---|---|---|
| MCP Server 宕机 | 连接异常打断 SSE 流 | 【暂不可用】回喂,模型换路径或如实告知 |
| 工具挂起不返回 | 本轮推理冻结 | 限时熔断,【工具超时】回喂 |
| 模型生成畸形参数 | Jackson 异常打断流 | 【参数错误】回喂,模型自纠重试 |
| LLM 网关挂起 | 读循环无限阻塞(虚拟线程泄漏) | 读空闲超时,sink.error 优雅收尾 |
| 同一工具同参数重复调用 | 重复执行(浪费配额) | 按指纹去重,返回"已调用过,跳过" |
一句话总结:把工具失败从"异常"重新定义为"模型可读的输入"——用三态文本告诉模型发生了什么、能怎么办;用隔离和限时保证失败有界。Agent 的韧性不在框架里,在这些文本和边界里。