企业级 AI 网关这类以 SSE 流式对话为核心的项目,优化重点不在某个接口的耗时,而在连接、传输、模型接入、存储四个层面的协同。我们的项目从四个方向下手:把线程模型从阻塞改为异步有界、给流式链路加心跳与断连检测、为多模型接入补超时与降级、把会话与计费数据落到 Redis 缓存。这四块优化做完,并发连接数提了约三倍,生成中断导致的无效消耗归零。下面按实施顺序拆解每块的具体做法。
一、线程与连接模型:有界队列替代默认线程池
SseEmitter 是异步接口,但 Spring Boot 默认用 Tomcat 线程池处理请求,长连接在生成期间占用着 Servlet 线程。并发对话一多,线程池先耗尽,新请求全部排队。问题不在连接本身,在任务执行器的队列策略。
我们把异步任务执行器替换为自定义实例,核心线程与最大线程按预期并发数设定,队列设上限,拒绝策略选 CallerRunsPolicy——线程池满时由调用线程继续执行,宁可让发起方慢一点,也不丢任务:
@Bean(name = "sseExecutor")
public Executor sseExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(64);
executor.setQueueCapacity(512);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("sse-");
return executor;
}
同时在 SseEmitter 的 onCompletion、onTimeout、onError 三个回调里统一做资源释放,用一个 AtomicBoolean 标记连接已结束,生成循环每轮检查该标记,避免连接断开后后台任务继续空转。这一步解决的是两个隐性成本:线程被无效任务占用、Token 被无效生成烧掉。
二、SSE 流式链路:心跳保活与断连感知
流式链路在代理层有两个常见坑:空闲连接被中间件超时切断、客户端关闭后服务端仍在生成。前者用心跳注释行解决,后者依赖断连检测。
心跳按 SSE 规范发送以冒号开头的注释行,客户端 EventSource 会自动忽略,只用于维持连接活性:
// 每 15 秒在生成循环间隙发送一次心跳
if (System.currentTimeMillis() - lastSend > 15_000) {
emitter.send(SseEmitter.event().comment("keep-alive"));
lastSend = System.currentTimeMillis();
}
断连检测在网关层分两步:前端关闭页面或刷新时,浏览器中断连接,后端在 onCompletion 里置位中断标志;模型调用侧再用超时与取消信号兜底。两者配合后,用户中途关掉页面的场景不再产生无效调用,计费从”按发起计”改成”按实际输出计”。
三、多模型接入层:超时、降级与路由分流
网关接入了多家大模型服务,接入层优化围绕三个问题展开:单家超时拖垮全局、模型不可用导致整链路失败、请求集中在某一家造成费用与性能倾斜。
- 为每家模型配置独立的连接超时与读超时,读超时略大于该模型的最长首包时间;
- 对模型调用做熔断计数:连续失败超过阈值即摘除该模型,进入降级名单,后续请求自动换路;
- 路由层按模型能力与成本分流,日常对话走通用模型,长文创作与深度推理走高规格模型,可按请求类型动态切换。
ai:
models:
chat:
provider: qwen
base-url: ${QWEN_BASE_URL}
connect-timeout: 5s
read-timeout: 60s
circuit-breaker:
failure-threshold: 5
half-open-after: 30s
reasoning:
provider: deepseek
base-url: ${DEEPSEEK_BASE_URL}
connect-timeout: 8s
read-timeout: 120s
模型切换对业务透明:上层只面对统一的 ChatClient 接口,具体走哪家由路由层决定,业务代码不感知降级过程。
四、数据与缓存层:会话热数据前置
网关的会话、上下文、计费流水都是热数据,直接落库会拖慢对话首包速度。我们把三块数据分层:
| 数据 | 存储 | 说明 |
|---|---|---|
| 会话上下文 | Redis | 每次对话读写都在内存完成,TTL 按会话时长设置 |
| Token 计费流水 | Redis + 异步落库 | 流式结束累计后异步刷入 MySQL,避免逐字写入 |
| 模型路由配置 | Redis + 本地缓存 | 配置变更双写,网关读本地缓存,秒级生效 |
上下文存 Redis 时按用户维度做键拆分,单条消息单独存取,追加消息只写增量,不用每次重写整个会话。这套改造把对话链路的数据库访问从”每轮多次”降到”结束一次”。
五、多阶段生成的编排优化
网关外的文章创作器项目做过多阶段生成:选题、大纲、正文、润色四步串联,每步都走 SSE 流式输出。最初的实现是同步串行,前一步不结束下一步不开始,首字延迟叠加后用户等待感明显。优化后改成阶段内流式、阶段间并行预热的编排:第一步选题流式输出时,后台已把提示词模板与检索上下文预取完毕;正文阶段使用流式分片写入缓存,前端展示与后端落库并行。四个阶段的状态统一交给一个编排器管理,任一阶段中断不重跑全程,只从断点阶段重试。这套编排让整篇文章的生成耗时缩短约 40%,同时保证了中途断网后可以续跑,而不是整篇重来。
五、优化效果的度量
优化的效果要靠指标说话,不能凭感觉。我们上线前后记录了三组数据:并发连接数从 40 提升到 120;超时导致的流中断率从 8% 降到 1% 以下;用户中途关闭页面的无效生成从持续到结束缩短到 3 秒内终止。前端同步做了路由懒加载与消息渲染节流,长文生成时页面滚动不再卡顿。整套优化遵循同一个原则:流式场景下,省连接就是省线程,省无效生成就是省钱。后续若并发再上一个量级,会考虑把网关迁移到 WebFlux 响应式栈,用背压机制从协议层面控制缓冲堆积。
常见问题(FAQ)
Q1:SseEmitter 和 WebFlux 怎么选?
SseEmitter 改动小、兼容现有 Servlet 栈;WebFlux 单线程事件循环抗更高并发,但重构成本高。
Q2:心跳间隔设多少合适?
15 到 30 秒为宜,大于代理层空闲超时阈值的一半,又不至于产生过多空包。
Q3:模型熔断后请求会失败吗?
不会。熔断摘除当前模型后自动降级到备选模型,对调用方透明,只影响首包延迟。