Spring AI多用户扩容的五个工程要点
文章梳理Spring AI应用扩容时的会话丢失、单请求工作量失控、连接重复创建与重复模型调用等问题。方案包括无状态服务、持久化会话、连接池、响应缓存和异步请求处理。
AI 深度解读
Agent 能陪一个人聊天,不代表能稳定服务一百个并发用户。这篇文章的核心判断是:扩容时先出问题的,往往是会话状态、HTTP 连接、线程和额度管理,Agent 的业务代码反而未必需要大改。
作者围绕 Spring AI 给出一套横向扩容方案。对做前端和全栈的读者,它最有价值的地方是解释了聊天接口背后的资源如何被耗尽。不过,几段示例与文中的效果描述并不完全一致,直接照抄还需要补上边界。
多加几个实例,为什么会把聊天记录弄丢
横向扩容,是让多个应用实例共同接请求。前提是任何实例都能处理同一位用户的下一条消息。
InMemoryChatMemory 把历史放在当前 JVM 中。用户第一条消息落到 Instance 1,第二条被负载均衡分给 Instance 2,后者便找不到前文。服务还活着,聊天上下文却断了。
作者把历史移到 Redis:引入 spring-boot-starter-data-redis,使用 spring.data.redis.host=${REDIS_HOST:localhost}、spring.data.redis.port=6379 和 spring.data.redis.password=${REDIS_PASSWORD:} 配置连接。
自定义的 RedisChatMemory 实现 ChatMemory,以 chat: 加 conversationId 作为键,用 Redis List 保存消息。add 追加数据并刷新 24 小时 TTL,get(conversationId, lastN) 读取末尾若干条,clear 删除会话,再通过 MessageChatMemoryAdvisor 接入 ChatClient。
无状态要求应用实例不独占会话状态,会话本身仍然需要保存。
这里还有两个缺口。读取 lastN 条并不会删除更早的记录;持续活跃的会话不断续期,存储仍可能增长。示例也只序列化角色和文本,反序列化仅处理 USER、ASSISTANT,其他角色返回 null。涉及工具调用时,消息能否完整恢复,原文没有交代。
连接池、缓存和异步,各自省掉什么
这三项优化处理的是不同成本。
连接池减少重复建立 TCP 连接和 TLS 握手的开销。作者先用 HttpClient.newBuilder() 配置 10 秒 connectTimeout、虚拟线程执行器,再通过 JdkClientHttpRequestFactory 接入 RestClient.Builder。连接超时限制的是建立连接的等待时间,并不等于整个模型调用的期限。
需要明确控制连接数量时,文章引入 httpclient5,给出下面的配置:
@Bean
public PoolingHttpClientConnectionManager connectionManager() {
PoolingHttpClientConnectionManager manager =
new PoolingHttpClientConnectionManager();
manager.setMaxTotal(200); // 所有路由的连接总数
manager.setDefaultMaxPerRoute(50); // 模型 API 端点的连接数
return manager;
}
要看清 setMaxTotal(200) 和 setDefaultMaxPerRoute(50) 的区别:如果请求主要发往同一个模型端点,不能把总连接数 200 当成该端点的可用连接数。原文也没有展示如何把这个管理器装配进实际使用的 HTTP 客户端,仅声明 Bean 尚不足以证明调用链已经使用它。
缓存省的是重复查询。作者通过 @EnableCaching、RedisCacheManager 和 GenericJackson2JsonRedisSerializer 配置 30 分钟 TTL,再给工具方法加上 @Cacheable。例如景点缓存使用 value = "attractions"、key = "#city",天气使用 value = "weather"、同样按城市取键;方法仍通过 @Tool、@ToolParam 暴露给 Agent。
但原文说,同一城市后续 100 次请求会同时避开模型和工具 API,这超出了代码能证明的范围。缓存工具返回值可以省掉工具 API 请求,不能据此断言模型调用也被省掉。 模型可能仍需理解问题、选择工具、组织回答。文章没有展示完整答案缓存。
异步处理则把等待模型的工作从 Servlet 请求线程转交给专用执行器:
@PostMapping("/chat")
public CompletableFuture<ResponseEntity<String>> chat(@RequestBody ChatRequest request) {
return CompletableFuture
.supplyAsync(() -> callAgent(request), agentExecutor)
.orTimeout(30, TimeUnit.SECONDS)
.thenApply(ResponseEntity::ok)
.exceptionally(e -> {
if (e.getCause() instanceof TimeoutException) {
return ResponseEntity.status(504)
.body("Request timed out.");
}
return ResponseEntity.status(500)
.body("Something went wrong.");
});
}
重点看 supplyAsync 和 orTimeout(30, TimeUnit.SECONDS):前者转移任务,后者限制 Future 等待时间,但不能保证底层 HTTP 请求也被取消。异常处理只检查 e.getCause(),超时能否按预期返回 504,也需要验证异常包装方式。
专用 ThreadPoolTaskExecutor 配置为核心线程 20、最大线程 50、队列容量 100,线程名前缀为 agent-。拒绝策略 CallerRunsPolicy 会让提交任务的线程执行工作,过载时仍可能把请求线程拖回等待中。
另一条路径是 Java 21+ 的 spring.threads.virtual.enabled=true。它降低阻塞等待占用平台线程的成本,但仍受 Spring Boot 版本和配置是否适用的约束,也不会增加模型配额或连接池容量。
限流和监控,决定服务会不会越忙越贵
作者用一个例子解释排队:模型调用耗时 5 秒,100 个并发用户争用 20 个请求线程,后来的请求就得等待。
为避免单个用户过多占用额度,文章使用 Bucket4j。给出的依赖是 com.github.vladimir-bukhtoyarov:bucket4j-core:8.10.1,令牌桶通过 Bandwidth.classic(10, Refill.greedy(10, Duration.ofMinutes(1))) 配置,每次 tryConsume(1),额度不足返回 429。作者建议从每用户每分钟 10 次请求起步。
问题是,示例把桶放在本地 ConcurrentHashMap。多实例各记各的账,无法形成统一的每用户限制,与前面要求共享状态的原则存在缺口。请求次数也不等于实际成本:一次请求可能触发多次工具或模型调用。
监控方面,文章推荐通过 TimedAspect、MeterRegistry 和 @Timed(value = "agent.call", percentiles = {0.5, 0.95, 0.99}) 观察延迟分位数。p50 为 2 秒、p99 为 30 秒只是说明性例子,用来提醒平均值会掩盖慢请求,并非作者公布的压测结果。
另外要看 agent.pool.active 与 agent.pool.size、工具缓存命中率,以及每次请求的 token 用量。作者认为稳定数据命中率低于 20% 应排查 TTL、缓存键复用和查询分布,但没有提供对照数据,不能把它当成通用合格线。
我的判断
这套方案的方向成立:先整理状态和资源边界,再增加实例。但文章提供的是工程建议和配置示例,没有给出并发压测、延迟改善或费用下降的实测结果。「便宜的性能收益」属于经验判断。
最需要修正的是缓存和限流。景点列表适合缓存,天气却有明确时效性;原文把天气放进统一的 30 分钟缓存,又建议不缓存时间敏感数据,两处需要协调。缓存键还得覆盖实际影响结果的条件,不能默认一个城市名足够。
替代方案也各有位置。黏性会话可以缓解跨实例丢上下文,却仍受实例重启和迁移影响;共享存储更适合独立扩缩容。虚拟线程适合降低阻塞线程成本,专用线程池方便隔离工作负载,两者都需要配合上游容量控制。
文章最后提出通过 Model Context Protocol(MCP)把工具接到外部系统,这是后续能力扩展方向,原文没有展开实现。Spring AI、Spring Boot 的具体版本同样没写清,示例 API 与依赖坐标应按项目版本核对。
能拿来做什么
- 给聊天服务增加第二个实例前,先验证同一
conversationId跨实例能否恢复完整历史,并检查工具消息、历史裁剪和会话过期行为。 - 对前端聊天入口,同时观察接口等待时间和后端模型调用时间;后端返回超时后,要确认上游请求是否仍在执行和消耗额度。
- 在 Spring AI 或 Node Agent 中,先缓存稳定、可共享的工具结果,分别统计工具 API 次数和模型调用次数,避免把两种节省混为一谈。
- 将每用户限流放到多实例共享的计数体系中,再结合实际工具调用次数、token 用量和 p95、p99 调整容量,不直接照搬每分钟 10 次或线程池参数。