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. 自测

  1. 四段链路里,哪一段的失败适合"回灌错误文本",哪一段不适合?
  2. @ToolParam(required = false) 会让方法内哪种 bug 变得不可见?
  3. orTimeout 已经让调用方提前返回了,为什么底层连接还可能一直开着?
  4. 一个非幂等的转账工具,为什么不该纳入自动重试?
  5. 三档回退如果不给每一档单独打点,出问题时你会漏掉什么?
  6. 工具结果被截断时,为什么必须在回灌文本里显式说明?
S22-X03 · Spring AI 的工具调用可靠性:重试、超时与失败回退
https://www.clxhxhhr.top/posts/5735/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。