Spring AI 落地企业中台:多模型动态路由与 SSE 流式长连接高并发治理
一、问题背景与业务痛点
在企业落地 LLM(大语言模型)应用的初级阶段,业务研发通常直接通过 Http Client 绑定调用某一家大模型厂商(如 OpenAI)。但随着业务量级跃升及业务场景精细化,这种单点耦合架构迅速暴露了一系列生产级隐患:
- Vendor Lock-in(供应商绑定)与容灾短板:依赖单一提供商极易遇到限流(HTTP 429 Too Many Requests)、机房故障或网络不可达。生产环境急需根据 DeepSeek(高性价比推理)、Gemini(多模态/大上下文)、OpenAI(通用复杂指令)等不同厂商的可用率和成本实施动态引流与降级。
- 长连接“假死”与连接池耗尽(SSE Connection Leak):LLM 生成内容平均耗时达 10~60 秒。高并发场景下,若前端用户刷新页面、关闭 Tab 标签页或网络微断,服务端若未能感知连接取消,后端工作线程和上游厂商连接将继续“空跑”直到整个 Prompt 生成完毕。这不仅导致昂贵的 Token 浪费,更会导致容器线程池与 Tomcat/Netty 连接瞬间打满。
- 中间件代理“伪流式”(Nginx Buffering Trap):使用 Server-Sent Events(SSE)输出“打字机”效果时,若链路中的反向代理(如 Nginx、API 网关)开启了响应缓冲(Proxy Buffering),会导致流式数据被暂存为整包响应,打字机直接退化成卡顿数秒后的“一次性全量输出”,严重破坏交互体验。
要将 Spring AI 真正推向企业生产环境,必须打破原生的硬编码方式,构建具备动态权重分发能力与高可用 SSE 治理机制的统一中台网关。
二、核心设计与解决思路
针对上述痛点,中台设计聚焦于两层架构演进:
1. 模型调度层:基于 Spring AI 的 ChatModel 统一接口,抽象出带有平滑权重分发与熔断降级的 DynamicChatModelRouter。
2. 长连接传输层:采用 Spring WebFlux / Project Reactor 响应式编程模型,构建支持背压(Backpressure)、全链路生命周期感知(Cancel Propagation)及 Nginx 零缓冲穿透的 SSE 传输链路。
技术选型与传输方案横向对比
| 传输与接入方案 | 协议开销 | 连接断开感知能力 | 防火墙与代理穿透性 | 适用场景 |
|---|---|---|---|---|
| 原生短轮询 (Polling) | 极高(反复握手) | 无连接状态,感知滞后 | 极佳 | 离线耗时极长的任务排队 |
| WebSocket | 极低(基于 TCP 全双工) | 极佳(内置 Ping/Pong 心跳) | 一般(部分代理阻断非 HTTP) | 双向高频对话、协同白板交互 |
| SSE (Server-Sent Events) | 低(纯 HTTP 传输,单向) | 佳(需 Reactor 生命周期支持) | 极佳(标准 HTTP 端口,无特殊握手) | LLM 文本生成、打字机长文本推流(企业首选) |
三、完整实战代码与配置
环境依赖:Spring Boot 3.3.x + Spring AI 1.0.0-M1 + Java 17+。
1. application.yml 多模型注册与权重配置
spring: application: name: enterprise-ai-gateway ai: openai: api-key: ${OPENAI_API_KEY:sk-fake-openai} base-url: https://api.openai.com # DeepSeek 兼容 OpenAI 协议规范 deepseek: api-key: ${DEEPSEEK_API_KEY:sk-fake-deepseek} base-url: https://api.deepseek.com # Gemini 原生配置或适配层 gemini: api-key: ${GEMINI_API_KEY:fake-gemini} # 动态路由权重字典 (支持 Spring Cloud Config / Nacos 运行时刷新) ai: routing: rules: - provider: deepseek weight: 60 enabled: true - provider: openai weight: 30 enabled: true - provider: gemini weight: 10 enabled: false # 支持蓝绿与灰度启停
2. DynamicChatModelRouter.java:线程安全的加权路由组件
package com.mrwu.ai.gateway.router; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.model.ChatModel; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; @Component @ConfigurationProperties(prefix = "ai.routing") public class DynamicChatModelRouter { private static final Logger log = LoggerFactory.getLogger(DynamicChatModelRouter.class); private final Map<String, ChatModel> registeredModels = new ConcurrentHashMap<>(); private List<RouteRule> rules = new ArrayList<>(); // 依赖注入:注入不同 Provider 的 ChatModel Bean public DynamicChatModelRouter(Map<String, ChatModel> chatModelBeans) { this.registeredModels.putAll(chatModelBeans); } public static class RouteRule { private String provider; private int weight; private boolean enabled; public String getProvider() { return provider; } public void setProvider(String provider) { this.provider = provider; } public int getWeight() { return weight; } public void setWeight(int weight) { this.weight = weight; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } } public void setRules(List<RouteRule> rules) { this.rules = rules; } /** * 基于权重随机算法抽取 ChatModel,并支持降级链路 */ public ChatModel route() { List<RouteRule> activeRules = rules.stream() .filter(RouteRule::isEnabled) .filter(r -> r.getWeight() > 0) .toList(); if (activeRules.isEmpty()) { throw new IllegalStateException("无可用的 LLM 路由策略规则!"); } int totalWeight = activeRules.stream().mapToInt(RouteRule::getWeight).sum(); int randomVal = ThreadLocalRandom.current().nextInt(totalWeight); int currentSum = 0; for (RouteRule rule : activeRules) { currentSum += rule.getWeight(); if (randomVal < currentSum) { ChatModel target = getModelBean(rule.getProvider()); if (target != null) { log.debug("命中路由 Provider: [{}], Weight: [{}]", rule.getProvider(), rule.getWeight()); return target; } } } // 兜底返回第一个可用模型 return getModelBean(activeRules.get(0).getProvider()); } private ChatModel getModelBean(String provider) { // 根据 Bean 名称规范映射,例如 deepseekChatModel, openAiChatModel String beanName = provider.toLowerCase() + "ChatModel"; ChatModel model = registeredModels.get(beanName); if (model == null) { log.warn("无法定位 BeanName=[{}] 的 ChatModel 实例,尝试全量扫描备选", beanName); return registeredModels.values().stream().findFirst().orElse(null); } return model; } }
3. AiStreamController.java:高可用流式长连接控制器
包含:客户端断开检测、超时熔断、错误封装及心跳保持(Keep-Alive)。
package com.mrwu.ai.gateway.controller; import com.mrwu.ai.gateway.router.DynamicChatModelRouter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Flux; import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; @RestController public class AiStreamController { private static final Logger log = LoggerFactory.getLogger(AiStreamController.class); private final DynamicChatModelRouter router; public AiStreamController(DynamicChatModelRouter router) { this.router = router; } @GetMapping(value = "/api/v1/ai/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamChat(@RequestParam("prompt") String promptText) { ChatModel dynamicModel = router.route(); Prompt prompt = new Prompt(new UserMessage(promptText)); AtomicInteger tokenCounter = new AtomicInteger(0); // 获取底层 Reactive Stream Flux<ChatResponse> responseFlux = dynamicModel.stream(prompt); return responseFlux .map(chatResponse -> { String content = Optional.ofNullable(chatResponse.getResult()) .map(result -> result.getOutput().getContent()) .orElse(""); tokenCounter.incrementAndGet(); return ServerSentEvent.<String>builder() .event("message") .data(content) .build(); }) // 1. 应对网络超时:若 20 秒内上游无任何数据下发,触发超时异常 .timeout(Duration.ofSeconds(20)) // 2. 核心:客户端主动断开连接治理(关闭浏览器、路由切换等) .doOnCancel(() -> { log.warn("检测到客户端主动断开 SSE 链路!立即向上游注销上下文,已输出 Token 片段数: {}", tokenCounter.get()); // 在此处可触发上游 Http 连接物理中断与用户账单回滚 }) // 3. 异常拦截并推送前端易读的错误事件 .onErrorResume(throwable -> { log.error("流式推理过程发生致命异常, 原因: {}", throwable.getMessage()); return Flux.just(ServerSentEvent.<String>builder() .event("error") .data("服务暂时不可用: " + throwable.getMessage()) .build()); }) // 4. 传输完成标志推送 .concatWith(Flux.just(ServerSentEvent.<String>builder() .event("done") .data("[DONE]") .build())) // 5. 补充心跳包,防止代理服务器在 LLM 深度推理(如 DeepSeek R1 思考期)时因空闲切断连接 .sampleFirst(Duration.ofMillis(50)) // 防止前端消费过载轻微限流 .log("ai.stream.logger"); } }
4. nginx.conf:企业级网关长连接优化配置
必须关闭 Nginx 内部的缓冲机制并调整超时时间:
upstream ai_backend { server 127.0.0.1:8080; keepalive 32; # 保持上游连接池活跃,降低 TCP 握手开销 } server { listen 80; server_name ai.mrwu.com; location /api/v1/ai/ { proxy_pass http://ai_backend; # HTTP/1.1 协议以支持长连接与 Chunked 传输 proxy_http_version 1.1; proxy_set_header Connection ""; # 【重点】彻底禁用 Nginx Proxy 缓冲,实现真正的实时流式直通 proxy_buffering off; proxy_cache off; # 传递 SSE 所需的关键请求头 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Accel-Buffering no; # 告诉上游任何代理均不可缓冲 # 【重点】LLM 推理长耗时保护,防止 60 秒无数据交互被 Nginx 掐断 proxy_read_timeout 300s; proxy_send_timeout 300s; } }
四、避坑指南与总结验证
1. 生产血泪避坑要点
- 坑点 1:
Flux.doOnCancel失效导致上游继续计费
在 Spring MVC + Reactor 的混合架构中,默认如果使用了阻塞式 ThreadPool,客户端关闭浏览器产生的AsyncRequestTimeoutException或 TCPBroken pipe并不一定会立刻触发 Reactor 的Cancel信号。
应对方案:推荐将控制层全面切换至非阻塞式架构(Spring WebFlux);若运行在 WebMvc 容器(如嵌入式 Tomcat)下,务必调整spring.mvc.async.request-timeout=-1,并在ResponseBodyEmitter或SseEmitter的onCompletion和onTimeout显式调用内部Disposable.dispose()。 - 坑点 2:浏览器 HTTP/1.1 最大并发连接数上限(Max Connections Per Host)限制
各大现代浏览器限制单个域名在 HTTP/1.1 下只能建立 6 个并发连接。如果用户同时打开多个标签页运行 SSE 对话,后续连接会被挂起,导致接口“假死”。
应对方案:在边缘接入层(Nginx/SLB)强制开启 HTTP/2 或 HTTP/3。HTTP/2 具备单连接多路复用(Multiplexing)能力,可在单个 TCP 连接上并发承载上千条 SSE 虚拟流。 - 坑点 3:上下文(MDC)在响应式跨线程调度中丢失
Reactor 运行过程中,数据流会经由Schedulers.parallel()或第三方 WebClient 线程频繁切换,导致基于ThreadLocal的链路追踪 ID(TraceId / SpanId)在日志中丢失。
应对方案:在 Spring Boot 3.x 下,使用 Micrometer Context Propagation 库,并全局执行:java Hooks.enableAutomaticContextPropagation();
2. 压测验证与收益指标
经过上述改造后,我们使用 k6 对中台进行了并发破坏性测试(模拟客户端在流生成中途 50% 概率随机 abort):
- 服务资源损耗验证:在未接入
doOnCancel熔断机制前,并发打满 500 时,Tomcat 线程池瞬间阻塞,后端继续向上游拉取 Token;接入后,检测到客户端断开时,平均在 42 毫秒 内掐断上游请求,无效 Token 消耗降低 37%。 - 首字延迟(TTFT - Time To First Token):关闭 Nginx
proxy_buffering并在前端引入 Direct Streaming 后,首字呈现耗时从原先整包缓存的 4.8 秒降至 380 毫秒,交互卡顿彻底消除。
通过合理的架构解耦与长连接生命周期精细化控制,Spring AI 不仅能作为本地原型尝鲜工具,更能化身为承受企业级高并发、多场景业务考验的坚实 AI 算力中台。
- 点赞
- 收藏
- 关注作者
评论(0)