Java AI 应用的异步化与高并发设计

摘要

大模型调用通常具有较高延迟、响应时间波动大、连接持续时间长等特点。把同步调用简单改成异步,并不能自动解决高并发问题,反而可能因为线程堆积、连接耗尽和结果回写失控导致服务雪崩。

本文以 Spring Boot 和 Spring AI 为基础,介绍 Java AI 应用中的异步任务、流式输出、线程池、虚拟线程、超时、取消、背压、租户级并发控制和优雅降级,并给出一个可落地的接口设计示例。

一、背景与问题

一个普通的 AI 对话接口通常包含以下步骤:

  1. 接收用户请求并校验会话权限。
  2. 读取历史消息和业务上下文。
  3. 调用模型服务。
  4. 接收完整结果或流式结果。
  5. 保存消息、统计 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. 如何设计降级?

可以按优先级降级:

  1. 降低上下文长度和最大输出 Token。
  2. 切换到成本更低或延迟更低的模型。
  3. 关闭非关键的工具调用。
  4. 对离线任务延迟处理。
  5. 返回可解释的繁忙提示。

降级策略要记录原因,不能静默改变业务结果。

六、进阶思考

1. 用背压保护服务

流式输出速度受客户端读取速度影响。生产环境应避免把所有增量内容无限堆在内存中,可以设置缓冲区上限、定期发送心跳,并在消费者长期不读取时取消上游请求。

2. 传播上下文

异步线程会改变执行上下文。traceId、租户 ID、用户 ID 和安全上下文不能依赖普通的 ThreadLocal 自动传播。应使用受控的任务装饰器或显式参数传递,且不要把完整 Prompt 放入日志上下文。

3. 资源预算

并发控制最好从"请求数"扩展到"资源量":

text 复制代码
可用容量 = 模型并发上限
           ∩ HTTP 连接池上限
           ∩ 租户配额
           ∩ Token 预算
           ∩ 数据库写入能力

只有请求数限制时,一个超长上下文请求仍可能占用大量资源。

结论

Java AI 应用的异步化核心不是把同步代码包装成 CompletableFuture,而是围绕模型调用建立完整的资源边界。线程池要有界,队列要可控,流式连接要支持取消,超时要分层,租户要隔离,重试要考虑幂等和费用。

下一步可以继续引入 Redis 分布式限流、消息队列任务编排、OpenTelemetry 链路追踪,以及基于 Token 预算的动态调度。

参考资料

  1. Spring AI Reference:docs.spring.io/spring-ai/r...
  2. Spring Boot Reference Documentation:docs.spring.io/spring-boot...
  3. Java Concurrency Utilities:docs.oracle.com/en/java/jav...
相关推荐
落魄实习生1 小时前
Agent Scope Java 2.x 系列【2】 ReActAgent
java·开发语言
硅基手札1 小时前
【risc-v专栏 06b】内存架构深挖:PMA / ePMP / Cache / CMO / MMU 细节 / RVWMO 形式化
架构·risc-v
艸肅1 小时前
Ubuntu 安装 JDK (含手动安装)
java·ubuntu·jdk
m0_734172422 小时前
Python字典计数怎样处理首次出现的键
java
凤山老林2 小时前
Spring Boot 集成 iText 7 实现动态 PDF 生成与电子签章:合同、报表场景实战
spring boot·后端·pdf·itext7·电子签章
孙启超2 小时前
【AI开发之Rust】第 19 课:UniFFI 导出核心能力
开发语言·后端·rust
卓怡学长2 小时前
w204基于springbo旅行社签证代理服务系统
java·spring boot·spring·intellij-idea
泡海椒2 小时前
趋势分析报表:jquick-pdf折线图PDF生成实战
java·大数据·运维·服务器·前端·pdf
CAD开发小慧2 小时前
MFC工业软件换上4K屏后界面模糊?BCGControlBar Pro如何适配高DPI与多屏
架构