基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
AIHT
内容
精选全部 AI 动态热点榜AI 日报收藏
分类
模型行业编程产品技术观点开源
更多
Agent 接入
返回首页开发者导航
AIHT
精选 / 资讯详情
8.0
热点
AI SCORE
技术实践2026-10-23 22:30

Spring AI多用户扩容的五个工程要点

dev.to · AI#智能体#并发扩容#会话管理
Editor brief · 编辑速览

文章梳理Spring AI应用扩容时的会话丢失、单请求工作量失控、连接重复创建与重复模型调用等问题。方案包括无状态服务、持久化会话、连接池、响应缓存和异步请求处理。

文章思维导图
Knowledge map
拖拽缩放
AI deep read

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 次或线程池参数。
Original source

本文由 AI 通读原文后重新编写,观点部分为站内判断,细节以原文为准,来源 dev.to · AI,原文版权归原作者所有。

阅读原文
上一篇
工单分类上线,关键是何时交回人工
下一篇
用七项任务定义收敛 AI 系统需求

资讯速览

dev.to · AI · 2026-10-23 22:30

AI 评分 8.0 / 10

相关标签

#智能体#并发扩容#会话管理

继续阅读

  • 数千智能体并行分析脑细胞,流程代码开放
  • 工单分类上线,关键是何时交回人工
  • 用七项任务定义收敛 AI 系统需求
  • 备份应用密钥泄露波及数百租户
  • 模型替换评测:相关性更高仍可能落选