Spring AI 工具调用超时控制与异步非阻塞执行
线上引入大模型智能客服与业务助手后,很多团队最先踩到的硬坑往往不是大模型本身的推理延迟,而是 Function Calling(工具调用)带来的级联阻塞。
在标准交互流程中,大模型判定需要调用外部工具,客户端收到tool_calls后在服务端反射执行本地@Bean函数。Spring AI 默认的函数调用通常工作在同步阻塞模式下。一旦底层依赖的 ERP 查询、风控验签或第三方物流接口出现抖动,执行耗时从预期的 200ms 飙升至数十秒,整个流式响应(SSE / WebSocket)线程直接卡死,Netty 或 Tomcat 工作线程被迅速打满,前端用户看到的就是光标停滞、连接超时断开。
要在大流量场景下稳定运行 AI Agent,必须将工具调用改造为支持精细化超时控制、线程池物理隔离以及响应式非阻塞的执行链路。
为什么默认工具调用会拖垮整个网关
在基于 Spring AI(例如 1.0.0+ 版本)构建多轮对话时,大模型在生成回复的过程中可能穿插一次或多次工具调用:
[用户输入] └──> [大模型推理 (Stream)] └──> [触发 Tool Call] └──> [Spring AI 反射调用本地 Function] (同步阻塞点) └──> [工具结果回传给大模型] └──> [大模型继续生成最终回复]默认实现中,工具函数的执行直接发生在处理大模型回调的当前线程上。如果你的接口使用的是 Spring WebFlux 响应式栈,这一阻塞会直接挂起 Netty EventLoop 线程,导致成百上千个并发连接同时冻结;如果是传统的 Spring MVC 线程池模型,慢调用也会在几秒内耗尽全部容器线程。
更严重的问题在于:大模型对工具返回的内容有强依赖。如果某个工具调用直接抛出未捕获的超时异常(如SocketTimeoutException),整个对话上下文就会直接中断,用户端报错 500,前序已经生成的 Token 全部作废。
核心设计:隔离线程池与显式超时降级
解决这一问题的核心架构是三层防护:
- 独立线程池隔离:工具执行绝不能占用 Web 容器线程或大模型响应式流的调度线程。
- 工具级独立超时控制:不同工具设置不同 SLA,实时查数据库设为 800ms,外部慢查询设为 3000ms。
- 语义级超时降级(Semantic Fallback):超时后不抛出系统级致命异常,而是构造符合大模型预期的 JSON 降级提示,让大模型在下一轮中向用户说明情况并尝试备选方案。
1. 线程池配置与上下文传递
工具执行线程池必须严格定义队列容量与拒绝策略,坚决避免使用CallerRunsPolicy(否则拒绝时依然会由调度线程同步执行):
package com.example.ai.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; import java.util.concurrent.ThreadPoolExecutor; @Configuration public class AiToolExecutorConfig { @Bean(name = "aiToolTaskExecutor") public Executor aiToolTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(16); executor.setMaxPoolSize(64); executor.setQueueCapacity(200); executor.setThreadNamePrefix("ai-tool-exec-"); // 拒绝策略:抛出异常,由外层统一捕获包装为降级响应,绝不回退给调用方线程 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(10); executor.initialize(); return executor; } }2. 异步超时工具包装器
通过自定义ToolCallback或对Function进行包装,引入 Java 8 / 9+ 的CompletableFuture.orTimeout实现非阻塞超时控制:
package com.example.ai.tool; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackWrapper; import java.time.Duration; import java.util.concurrent.*; import java.util.function.Function; public class AsyncTimeoutToolWrapper<I, O> implements Function<I, O> { private static final Logger log = LoggerFactory.getLogger(AsyncTimeoutToolWrapper.class); private final String toolName; private final Function<I, O> delegate; private final Duration timeout; private final Executor executor; private final Function<I, O> fallback; public AsyncTimeoutToolWrapper( String toolName, Function<I, O> delegate, Duration timeout, Executor executor, Function<I, O> fallback) { this.toolName = toolName; this.delegate = delegate; this.timeout = timeout; this.executor = executor; this.fallback = fallback; } @Override public O apply(I input) { CompletableFuture<O> future = CompletableFuture.supplyAsync(() -> { try { return delegate.apply(input); } catch (Exception e) { log.error("工具 [{}] 执行异常: {}", toolName, e.getMessage(), e); throw new CompletionException(e); } }, executor); try { // 设定硬超时时间 return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException te) { log.warn("工具 [{}] 执行超时(超过 {} ms),触发降级逻辑", toolName, timeout.toMillis()); future.cancel(true); return fallback.apply(input); } catch (RejectedExecutionException ree) { log.error("工具 [{}] 线程池已满,直接降级", toolName); return fallback.apply(input); } catch (Exception e) { log.error("工具 [{}] 发生未预期的调用失败", toolName, e); return fallback.apply(input); } } }3. 注册带降级能力的业务工具
以查询用户订单和库存为例,定义其请求、响应以及降级后的返回格式:
package com.example.ai.tool; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; import java.util.concurrent.Executor; @Configuration public class BusinessToolsConfig { public record OrderQueryRequest(String orderId, String userId) {} public record OrderQueryResponse(String status, String details, boolean isDegraded) {} @Bean public FunctionToolCallback<OrderQueryRequest, OrderQueryResponse> queryOrderStatusTool( Executor aiToolTaskExecutor, OrderService orderService) { // 真实业务逻辑 java.util.function.Function<OrderQueryRequest, OrderQueryResponse> realFunction = req -> { // 模拟远程 RPC 接口调用 return orderService.queryRemote(req.orderId(), req.userId()); }; // 语义降级逻辑:返回明确的业务提示,告知大模型工具暂时不可用 java.util.function.Function<OrderQueryRequest, OrderQueryResponse> fallbackFunction = req -> { return new OrderQueryResponse( "UNKNOWN_TIMEOUT", "订单系统查询响应超时,请向用户解释当前查询排队较多,并建议用户 1 分钟后重试,或提供人工客服转接入口。", true ); }; // 包装为 1.5 秒硬超时的异步函数 AsyncTimeoutToolWrapper<OrderQueryRequest, OrderQueryResponse> wrapper = new AsyncTimeoutToolWrapper<>( "queryOrderStatus", realFunction, Duration.ofMillis(1500), aiToolTaskExecutor, fallbackFunction ); return FunctionToolCallback.builder("queryOrderStatus", wrapper) .description("根据订单号和用户ID查询订单最新状态与物流轨迹") .inputType(OrderQueryRequest.class) .build(); } }响应式 WebFlux / SSE 全链路协同
在流式输出场景中,Spring AI 会将模型的逐字输出转换为Flux<ChatResponse>。当工具调用发生时,响应式流内部由底层 Client 协调工具回调。
为避免在响应式链路上产生阻塞等待,推荐结合 Project Reactor 的Mono.fromCallable().subscribeOn(Schedulers.fromExecutor(executor))做更深度的管道编排:
package com.example.ai.service; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.scheduler.Schedulers; import java.time.Duration; @Service public class ReactiveAgentService { private final ChatClient chatClient; public ReactiveAgentService(ChatClient.Builder chatClientBuilder) { this.chatClient = chatClientBuilder.build(); } public Flux<String> streamChatWithTools(String conversationId, String message) { return chatClient.prompt() .user(message) .tools("queryOrderStatus") // 声明挂载上述超时降级工具 .stream() .chatResponse() .map(response -> { // 提取模型内容或工具执行状态标记 var generation = response.getResult(); if (generation != null && generation.getOutput().getText() != null) { return generation.getOutput().getText(); } return ""; }) .timeout(Duration.ofSeconds(60)) // 全局单轮会话防护超时 .onErrorResume(e -> { // 发生全局异常时的兜底输出 return Flux.just("\n[系统提示:当前网络交互异常,请稍后重试]"); }); } }生产避坑与运维监控
MDC 链路日志与 TraceId 丢失:
在CompletableFuture.supplyAsync或响应式调度切换线程时,ThreadLocal中的链路追踪 ID(如 Zipkin/Sleuth/OpenTelemetry)会丢失。必须在任务提交前拷贝 MDC 上下文并在子线程中装载,或者使用定制的TaskDecorator:executor.setTaskDecorator(runnable -> { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { try { if (contextMap != null) MDC.setContextMap(contextMap); runnable.run(); } finally { MDC.clear(); } }; });工具降级提示词的规范性:
降级返回的 JSON 内容不能仅仅写{"error": "timeout"},因为小参数模型可能会将此错误直接复读给用户,或者陷入死循环重试。必须使用清晰的操作指导语句,例如:"调用超时,请不要重试该工具,直接告知用户由于系统维护暂时无法获取订单状态"。度量指标埋点(Micrometer):
对每一个挂载的工具,必须采集三项指标:ai.tool.invocation.count(调用次数)、ai.tool.execution.duration(耗时分布)以及ai.tool.timeout.count(超时率)。当超时率超过 5% 时,应触发运维告警,并考虑临时从 Agent 的 Tools 列表中动态下线该工具,防止整个 Agent 交互质量劣化。