2533 字
约 8 分钟
16
S22-X03 · Spring AI 的工具调用可靠性:重试、超时与失败回退
工具调用的可靠性问题,几乎都不是"重试不够多",而是没区分失败发生在四段链路里的哪一段:模型决策 → 参数生成 → 本地执行 → 结果回灌。模型选错了工具,重试本地执行毫无意义;本地执行超时了,在模型侧加重试只是把一次超时变成三次,成本翻倍、成功率不变。类名与方法签名按当前版本核对。
1. 四段链路与各自的失败形态
| 段 | 典型失败形态 | 观察点 | 控制手段 |
|---|---|---|---|
| 模型决策 | 选错工具、该调没调、反复调同一个 | tool_calls 列表 | 工具描述与数量、选择规则 |
| 参数生成 | 缺必填字段、类型不对、编造枚举、日期格式错 | 入参与 schema 比对 | schema 精度、枚举、示例 |
| 本地执行 | 抛异常、下游超时、被限流、缺权限 | 工具内异常与耗时 | 超时、独立线程池、兜底 |
| 结果回灌 | 过长被截断、字段多稀释重点、结果为空 | 回灌消息的 token 量 | 裁剪、聚合、压缩 |
只有第三段是"你的代码",但另外三段也要打点,否则排查只能靠猜。
2. @Tool 的 schema 生成与必填约束
Spring AI 从方法签名生成 JSON Schema,必填与否全看声明:
@Component
public class OrderTools {
private final OrderQueryService svc;
public OrderTools(OrderQueryService svc) { this.svc = svc; }
@Tool(name = "query_order", description = "按订单号查订单状态。仅在用户给出明确订单号时调用;"
+ "没有订单号先向用户索取,不要调用。")
public OrderBrief queryOrder(
@ToolParam(description = "订单号,形如 SO-20240101-0001") String orderNo,
@ToolParam(description = "是否含物流节点,默认 false", required = false) Boolean withLog) {
return svc.brief(orderNo, Boolean.TRUE.equals(withLog));
}
}
| Java 声明 | schema 效果 | 漏传风险 | 备注 |
|---|---|---|---|
| 基本类型 / 非空引用 | 进 required |
低 | 漏传在反序列化就报错 |
required = false 包装类型 |
不进 required |
高 | 方法内必须自己兜默认值 |
Optional<T> |
不进 required |
高 | 语义清楚,签名更长 |
| 枚举类型 | 生成取值集合 | 中(选错) | 比自由生成字符串强 |
String 表达日期 |
普通字符串 | 高(格式错) | description 里给示例 |
自由 Map |
无法约束 | 极高 | 换成明确的 record / DTO |
工具描述是控制模型决策最便宜的手段:把"什么时候不要调用"写进 description,胜过在上层堆重试。
3. 超时:模型侧超时 ≠ 本地执行超时
模型侧超时管"远端多久没回包",本地执行超时管"工具方法多久没返回"。两者都不设上限时,一个卡死的下游会拖住整轮对话。
spring:
ai:
retry: # 模型侧重试,只管远端往返
max-attempts: 2
backoff: { initial-interval: 1s, multiplier: 2, max-interval: 4s }
openai.chat.options.model: ${CHAT_MODEL:default-model} # 连接/读取超时键名按当前版本核对
task.execution.pool: { core-size: 8, max-size: 32, queue-capacity: 200 }
app.tools:
default-timeout: 3s # 单个工具方法的最长执行时间
round-hard-limit: 8s # 单轮工具执行的累计上限
| 层次 | 管什么 | 超时后表现 | 设太长的后果 |
|---|---|---|---|
| 连接超时 | TCP/TLS 建连 | 立刻失败,可重试 | 很小 |
| 读取超时(模型侧) | 远端多久没回包 | 整轮失败或重试 | 用户干等 |
| 工具执行超时 | 单个工具方法 | 该工具失败,可回灌错误文本 | 线程被占住 |
| 单轮累计上限 | 一轮所有工具调用 | 停止再调工具 | 成本不可控 |
| 网关 | 对外连接 | 客户端拿到超时 | 掩盖真实耗时 |
包超时必须用独立线程池,不能和 Web 请求线程池共用:
// pool 是独立线程池,不参与 Web 请求线程池
String call(Supplier<String> action, Duration d) {
try {
return CompletableFuture.supplyAsync(action, pool)
.orTimeout(d.toMillis(), TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
throw new ToolExecutionException("工具超时 " + d, e.getCause());
}
}
orTimeout 只让 join() 提前返回,不中断任务;底层 JDBC / HTTP 得靠各自客户端的超时真正掐断。
4. 重试:幂等与不幂等的分界线
| 工具类型 | 例子 | 可自动重试 | 策略 |
|---|---|---|---|
| 只读查询 | 查订单、查天气 | 可以 | 1–2 次,退避 200ms |
| 幂等写入 | 带幂等键的提交、UPSERT | 可以 | 复用同一幂等键 |
| 非幂等写入 | 转账、下单、发短信 | 不要 | 失败即回灌,交模型或人工 |
| 带副作用的通知 | 给用户发消息 | 不要 | 重复通知代价高于失败 |
判定写在工具自己的元数据上,别散落到调用方:
public enum ToolSemantics { READ_ONLY, IDEMPOTENT_WRITE, NON_IDEMPOTENT_WRITE }
public record ToolPolicy(int maxAttempts, Duration backoff) {
public static ToolPolicy of(ToolSemantics s) {
return switch (s) {
case READ_ONLY -> new ToolPolicy(2, Duration.ofMillis(200));
case IDEMPOTENT_WRITE -> new ToolPolicy(2, Duration.ofMillis(500));
case NON_IDEMPOTENT_WRITE -> new ToolPolicy(1, Duration.ZERO); // 不自动重试
};
}
public <T> T execute(Supplier<T> action, Predicate<Throwable> retryable) {
RuntimeException last = null;
for (int i = 1; i <= maxAttempts; i++) {
try {
return action.get();
} catch (RuntimeException e) {
last = e;
if (i == maxAttempts || !retryable.test(e)) break;
LockSupport.parkNanos(backoff.multipliedBy(i).toNanos());
}
}
throw last;
}
}
重试与成本是乘法:一轮 3 次往返 × 每次工具 2 次执行 = 最坏 6 倍。成本预算是给重试定上限的依据。
5. 失败回退三档
| 档位 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 一档:回灌错误自纠 | 把"缺少 orderNo"当工具结果回灌 | 参数不合 schema、值可枚举 | 多一次往返 |
| 二档:换工具 | 降级到备用实现(缓存、只读副本) | 下游抖动、主链路不可用 | 结果口径可能不一致 |
| 三档:固定降级回答 | 模板化答复 + 人工入口 | 重试预算已耗尽 | 体验下降,但不会静默出错 |
public String handleToolFailure(ToolCall call, Throwable err, int attemptsLeft) {
if (attemptsLeft > 0 && isSelfCorrectable(err)) { // 一档:自纠
return "TOOL_ERROR: " + err.getMessage() + "\n请修正参数后重试,或换工具,不要重复提交。";
}
if (fallbackTools.has(call.name())) { // 二档:换工具
return fallbackTools.invoke(call.name(), call.arguments());
}
return "AI_DEGRADED: 暂时无法完成该查询,请稍后再试或转人工。"; // 三档
}
三档的触发顺序必须固定,且每档单独计数,否则降级率上升时分不清是哪一档在兜。
6. 工具结果体积裁剪
- 字段白名单:只序列化模型需要的字段,别把整行 DTO 扔进去。
- 行数 / 字符上限:SQL 里
LIMIT N,文本里补"共 N 条,已省略 M 条"。 - 本地聚合与二段取数:日志堆栈先算计数或 Top-K;结果规模不定时先回摘要。
结果被截断时必须在文本里明说,否则模型会基于不完整数据给出确定结论。
7. 可观测性:入参、出参、耗时
@Aspect
@Component
public class ToolCallMetricsAspect {
private final MeterRegistry registry;
public ToolCallMetricsAspect(MeterRegistry registry) { this.registry = registry; }
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
String tool = pjp.getSignature().getName();
long t0 = System.nanoTime();
try {
Object r = pjp.proceed();
long cost = (System.nanoTime() - t0) / 1_000_000;
registry.timer("ai.tool.latency", "tool", tool, "outcome", "ok").record(cost, TimeUnit.MILLISECONDS);
log.info("tool_call tool={} args={} cost={}ms size={}", tool, safe(pjp.getArgs()), cost, sizeOf(r));
return r;
} catch (Throwable t) {
registry.counter("ai.tool.error", "tool", tool, "type", t.getClass().getSimpleName()).increment();
log.warn("tool_call tool={} error={}", tool, t.toString());
throw t;
}
} // safe() / sizeOf() 省略:日志只打白名单字段
}
打点四个维度:工具名、耗时、状态(ok / schema_error / timeout / downstream_error)、回灌体积;会话级再补"本轮调用次数"识别死循环。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 工具抛异常导致整轮失败 | 异常没在工具层捕获,被当致命错误冒泡 | 工具内 try/catch,转成错误文本 |
| 模型调不到工具 | 未注册、方法不可见、重名 | 查 callback 列表,重名只留一个 |
| 模型反复调同一个工具 | 回灌结果无结论字段,描述没写终止条件 | 补结论;描述写"查到即停" |
| 反序列化阶段就失败 | schema 与实际类型不符(long 收到 "12") |
用包装类型 + 显式 @ToolParam |
| 超时后线程仍被占用 | 用了 orTimeout 但底层无超时 |
给 JDBC / HTTP 客户端设超时 |
| 日志无入参 | 反射调用,切点没命中 | 改注解切点或包装入口 |
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 流程完全确定(固定三步) | 写 Service 编排 | 模型决策只增加不确定性 |
| 需严格事务的写操作 | 模型只收参数,落库服务端执行 | 工具调用不可事务化 |
| 高频只读查询 | 服务端先查缓存,必要时才暴露工具 | 省一次往返 |
| 工具数超过十来个 | 先分组,按意图路由到子集 | 选择空间大时决策质量下降 |
10. 自测
- 四段链路里,哪一段的失败适合"回灌错误文本",哪一段不适合?
@ToolParam(required = false)会让方法内哪种 bug 变得不可见?orTimeout已经让调用方提前返回了,为什么底层连接还可能一直开着?- 一个非幂等的转账工具,为什么不该纳入自动重试?
- 三档回退如果不给每一档单独打点,出问题时你会漏掉什么?
- 工具结果被截断时,为什么必须在回灌文本里显式说明?
S22-X03 · Spring AI 的工具调用可靠性:重试、超时与失败回退
https://www.clxhxhhr.top/posts/5735/ 评论
0 条
还没有评论,先写一条吧。