摘要
大模型调用通常具有较高延迟、响应时间波动大、连接持续时间长等特点。把同步调用简单改成异步,并不能自动解决高并发问题,反而可能因为线程堆积、连接耗尽和结果回写失控导致服务雪崩。
本文以 Spring Boot 和 Spring AI 为基础,介绍 Java AI 应用中的异步任务、流式输出、线程池、虚拟线程、超时、取消、背压、租户级并发控制和优雅降级,并给出一个可落地的接口设计示例。
一、背景与问题
一个普通的 AI 对话接口通常包含以下步骤:
- 接收用户请求并校验会话权限。
- 读取历史消息和业务上下文。
- 调用模型服务。
- 接收完整结果或流式结果。
- 保存消息、统计 Token,并返回给客户端。
如果整个链路使用同步方式处理,模型服务等待期间,请求线程会长期占用。并发量上升后,常见问题包括:
| 问题 | 典型表现 |
|---|---|
| 线程耗尽 | Tomcat 工作线程全部阻塞在模型调用 |
| 连接池耗尽 | HTTP 客户端无法建立新连接 |
| 队列无限增长 | 请求排队很久后同时超时 |
| 流式连接泄漏 | 客户端断开后服务端仍继续生成 |
| 重试放大流量 | 上游慢或限流时,重试请求再次堆积 |
| 单租户占满资源 | 某个应用的批量任务影响其他租户 |
因此,AI 应用的异步化目标不是"让所有请求都立即进入后台",而是建立可控制的并发边界:
text
请求接入
│
├─ 快速校验与限流
│
├─ 有界队列 / 信号量
│
├─ 模型调用与流式传输
│
└─ 超时、取消、降级、指标记录
二、核心概念
1. 异步不等于无限并发
异步执行只表示调用方不必同步等待结果。真正能承载多少并发,仍然取决于模型供应商限额、HTTP 连接池、CPU、内存、数据库和下游响应速度。
建议把并发拆成三层:
| 层级 | 控制内容 | 示例 |
|---|---|---|
| 接入层 | 请求是否允许进入系统 | 每秒请求数、排队长度 |
| 应用层 | 同时执行多少个模型任务 | 全局信号量、租户信号量 |
| 下游层 | 对单个模型或供应商的压力 | 每模型并发、Token 速率 |
2. 线程池与虚拟线程
传统平台线程适合 CPU 计算,但在等待网络 I/O 时会占用较多系统资源。虚拟线程可以降低大量阻塞 I/O 任务的线程成本,但它不会突破模型服务的限流,也不会替代队列、超时和连接池配置。
AI 服务可以使用虚拟线程简化阻塞式调用,也可以使用响应式流处理 SSE。两种方式都需要明确任务上限。
3. CompletableFuture、SSE 与消息队列
CompletableFuture:适合请求内的异步编排和并行调用。- SSE:适合把模型生成的增量内容持续推送给浏览器。
- 消息队列:适合报告生成、批量摘要等不要求立即返回的长任务。
交互式对话和离线任务不应共用同一个无限制线程池。
三、工作原理
1. 请求链路
一个生产级流式接口可以按以下顺序执行:
text
客户端
│
├─ 鉴权、幂等键、租户限流
│
├─ 获取全局与租户并发许可
│
├─ 创建 traceId 和消息记录
│
├─ 调用模型并读取增量结果
│ ├─ 写入 SSE
│ └─ 更新超时与取消状态
│
├─ 正常结束:保存完整回复与用量
└─ 异常结束:记录错误、释放许可、执行降级
2. 有界并发
可以使用 Semaphore 控制同时执行的任务数。与无界线程池相比,信号量能在进入模型调用前形成明确的资源闸门。
java
private final Semaphore globalPermits = new Semaphore(100);
public <T> T withPermit(Callable<T> task) throws Exception {
boolean acquired = globalPermits.tryAcquire(200, TimeUnit.MILLISECONDS);
if (!acquired) {
throw new TooManyRequestsException("AI service is busy");
}
try {
return task.call();
} finally {
globalPermits.release();
}
}
实际项目还应按租户、模型和任务类型拆分许可,避免一个大客户占满全部容量。
3. 超时与取消
超时至少分为三种:
- 排队超时:等待执行许可的时间。
- 连接超时:建立上游连接的时间。
- 响应超时:等待首个 Token 或等待完整结果的时间。
客户端断开 SSE 后,应取消下游订阅或中断任务,不能只停止写响应。否则模型仍可能继续消耗 Token。
四、实战示例
下面示例展示一个简化的 Spring Boot 服务。代码重点是边界控制,具体的模型配置应根据项目使用的 Spring AI 版本调整。
1. 配置有界执行器
java
@Configuration
public class AiExecutorConfig {
@Bean
public ThreadPoolTaskExecutor aiExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("ai-call-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
这里使用有界队列。队列满后不能继续接收无限任务,生产环境通常应返回明确的繁忙状态,或者把离线任务转入消息队列。
2. 异步完整响应
java
@RestController
@RequestMapping("/api/ai")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping("/chat")
public CompletableFuture<ChatResponse> chat(
@RequestHeader("X-Tenant-Id") String tenantId,
@RequestBody ChatRequest request) {
return chatService.chat(tenantId, request);
}
}
java
@Service
public class ChatService {
private final ChatClient chatClient;
private final Executor aiExecutor;
private final Semaphore permits = new Semaphore(100);
public ChatService(ChatClient chatClient,
@Qualifier("aiExecutor") Executor aiExecutor) {
this.chatClient = chatClient;
this.aiExecutor = aiExecutor;
}
@Async("aiExecutor")
public CompletableFuture<ChatResponse> chat(
String tenantId, ChatRequest request) {
return CompletableFuture.supplyAsync(() -> {
acquirePermit();
try {
String content = chatClient.prompt()
.user(request.message())
.call()
.content();
return new ChatResponse(content);
} finally {
permits.release();
}
}, aiExecutor).orTimeout(45, TimeUnit.SECONDS);
}
private void acquirePermit() {
if (!permits.tryAcquire()) {
throw new TooManyRequestsException("AI concurrency limit reached");
}
}
}
示例同时使用了 @Async 和 supplyAsync,真实项目不应重复套用异步调度。更清晰的做法是保留一种调度方式,并统一异常处理、上下文传递和指标记录。
3. 流式 SSE
java
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(
@RequestParam String message,
ServerHttpResponse response) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(text -> ServerSentEvent.builder(text).build())
.doOnCancel(() -> log.info("client cancelled AI stream"))
.timeout(Duration.ofSeconds(60))
.onErrorResume(TimeoutException.class, ex ->
Flux.just(ServerSentEvent.builder("服务繁忙,请稍后重试").build()));
}
流式接口需要配合反向代理的缓冲配置、心跳、最大连接数和断开检测。若使用 Servlet MVC,也可以采用 SseEmitter,但要确保异常和超时回调中释放资源。
4. 租户级限制
简单场景可以在本地维护租户信号量;集群场景则需要 Redis、网关限流或专门的配额服务。限制键可以由以下维度组成:
text
tenant:{tenantId}:ai_concurrency
tenant:{tenantId}:model:{modelName}:tokens
application:{appId}:requests_per_minute
五、常见问题与实践建议
1. 为什么线程池很大,吞吐量仍然上不去?
瓶颈可能在模型供应商限流、连接池、数据库写入或网络带宽。扩大线程池只会让更多任务同时等待,先观察排队时间、首 Token 延迟、连接池使用率和上游错误率。
2. 是否应该全部使用虚拟线程?
虚拟线程适合大量阻塞 I/O,但不适合用来掩盖无界并发。仍然需要设置并发许可、超时和下游客户端连接上限。CPU 密集型的文档切分、JSON 处理和重排序任务应单独使用平台线程池。
3. 客户端重试会不会造成重复扣费?
会。应为每次业务请求生成幂等键,保存请求状态,并区分"尚未发送""已发送但未确认""已完成"三种状态。对于不能安全重试的模型请求,不要仅因为网络超时就立即重发。
4. 如何设计降级?
可以按优先级降级:
- 降低上下文长度和最大输出 Token。
- 切换到成本更低或延迟更低的模型。
- 关闭非关键的工具调用。
- 对离线任务延迟处理。
- 返回可解释的繁忙提示。
降级策略要记录原因,不能静默改变业务结果。
六、进阶思考
1. 用背压保护服务
流式输出速度受客户端读取速度影响。生产环境应避免把所有增量内容无限堆在内存中,可以设置缓冲区上限、定期发送心跳,并在消费者长期不读取时取消上游请求。
2. 传播上下文
异步线程会改变执行上下文。traceId、租户 ID、用户 ID 和安全上下文不能依赖普通的 ThreadLocal 自动传播。应使用受控的任务装饰器或显式参数传递,且不要把完整 Prompt 放入日志上下文。
3. 资源预算
并发控制最好从"请求数"扩展到"资源量":
text
可用容量 = 模型并发上限
∩ HTTP 连接池上限
∩ 租户配额
∩ Token 预算
∩ 数据库写入能力
只有请求数限制时,一个超长上下文请求仍可能占用大量资源。
结论
Java AI 应用的异步化核心不是把同步代码包装成 CompletableFuture,而是围绕模型调用建立完整的资源边界。线程池要有界,队列要可控,流式连接要支持取消,超时要分层,租户要隔离,重试要考虑幂等和费用。
下一步可以继续引入 Redis 分布式限流、消息队列任务编排、OpenTelemetry 链路追踪,以及基于 Token 预算的动态调度。
参考资料
- Spring AI Reference:docs.spring.io/spring-ai/r...
- Spring Boot Reference Documentation:docs.spring.io/spring-boot...
- Java Concurrency Utilities:docs.oracle.com/en/java/jav...