Spring Boot + Spring AI 打造生产级 Java AI Agent 全栈实践

发布时间:2026/10/14 7:47:23
Spring Boot + Spring AI 打造生产级 Java AI Agent 全栈实践
做 Java 后端的同学们最近是不是有点焦虑看着身边同行用 Python 把 AI Agent 玩出花自己手头的 Spring Boot 项目似乎离智能很远。别急这段时间我一直在折腾一个项目Java 版 Personal Agent基于 Spring Boot Spring AI 全栈开源。项目完整地实现了从零搭建一个生产级个人助手不只是跑通 Demo而是把连接池、重试、持久化、流式输出这些后端的看家本领全跟 AI 应用结合了起来。这个项目做完之后我最大的感受是Java 生态做 AI Agent 完全可行而且 Spring AI 的抽象设计让这套系统的工程质量和可维护性比很多临时拼凑的 Python 脚本高出不少。这篇文章就把整个项目的设计思路、核心实现、生产化细节全榨干了摊开给你看从架构选型到代码实现从流式输出到并发控制再到我实际踩过的几个坑新手可以直接照着抄有点经验的朋友也可以对比一下自己的方案。1. 整体设计与思路拆解为什么是 Spring AIAgent 的骨架长什么样1.1 先说结论为什么用 Spring Boot Spring AI 而不是 LangChain很多 Java 开发者看到 AI 应用第一反应是那我直接调 HTTP 接口不就行了为什么要引入 Spring AI 这个框架这个问题我也纠结过。真正开始设计这个 Personal Agent 的时候我列了一下需求对话管理、工具调用、持久化记忆、多模型切换、流式输出、并发控制。如果用原生 HTTP 客户端去调大模型接口这些需求全都要自己造轮子而且每个模型厂商的接口格式还不一样后期维护是噩梦。Spring AI 的优势在于它做了几层非常关键的抽象。第一层是 ChatClient把不同厂商的 Chat API 统一成一个接口我换模型厂商只需要改配置业务代码一动不用动。第二层是 Tool Calling 机制它把 Java 方法的参数自动序列化成模型需要的 JSON Schema模型返回的工具调用参数又自动反序列化成 Java 对象类型安全这一块比 Python 系框架都要强。第三层是 Advisor 机制它类似于 Spring AOP 在 AI 链路上的应用可以在消息发送前、返回后插入各种处理逻辑。我这套架构选型其实还有一个很实际的理由项目要对接现有的业务系统比如日历、邮件、待办事项这些工具这些都是典型的 Java 服务。用 Spring Boot 可以非常自然地把这些能力注册成 Agent 的工具而 Spring AI 天然兼容这种 Spring 家族的配置方式和生命周期管理。如果是纯 Python 方案在 Java 体系里反而成了二等公民。1.2 Agent 的整体架构从用户对话到工具执行的完整链路先拿出一张我项目里的模块划分这是我在需求分析阶段就该想明白的模块职责关键组件Web 接入层HTTP/SSE 接口、会话管理Spring MVC、SSE EmitterAgent 编排层对话循环、工具决策、上下文组装ChatClient、ToolCall 管理器工具注册层暴露 Java 方法给模型调用Tool 注解、工具注册表记忆管理层会话历史、向量记忆、摘要记忆普通数据库、向量存储模型适配层对接多种大模型提供商Spring AI Model 接口可观测层链路日志、Token 统计、性能监控Micrometer、自定义 Filter处理一条用户消息的完整链路是这样的用户发送消息到 Web 层这个请求会被封装成一个 UserMessage 对象带上会话 ID。Agent 编排层拿到消息后会根据会话 ID 从记忆管理层拉取历史消息拼装成完整的消息列表然后调用 ChatClient。模型如果认为需要调用工具返回的响应里会带工具调用 ID 和参数 JSON。编排层解析之后根据工具名去注册表找到对应的 Java 方法反射调用并拿到结果再把结果作为工具响应消息回传给模型让模型继续推断这个循环一直到模型不再请求工具为止。这个设计本质上是把 Agent 的推理循环显式地做成了代码。一开始我想过用完全自动化的方式就是让模型自己决定什么时候结束但后来发现对于生产系统必须有一个可控的外层循环否则完全不可观测也容易出现死循环。所以我在编排层设置了一个最大迭代次数默认是 5 次超过就强制终止这个参数在下文的生产化细节会详细说。2. 核心细节解析与实操要点工具调用、上下文管理、流式输出逐个击破2.1 工具调用Function Calling的正确打开方式工具调用是整个 Agent 的灵魂也是新手最容易写崩的地方。Spring AI 里注册一个工具非常简单只需要在一个 Bean 方法上加上 Tool 注解Component public class TodoToolService { Tool(description 创建一条新的待办事项参数 title 是必填项dueDate 使用 yyyy-MM-dd 格式) public String createTodoItem(String title, ToolParam(yyyy-MM-dd) String dueDate) { TodoItem item todoService.add(title, dueDate); return 创建成功: item.getId(); } }就这么简单Spring AI 会在启动时扫描所有带 Tool 注解的方法把方法名、参数描述、格式要求都生成成 JSON Schema显式注册到模型接口的 tools 参数里。不过这里有一个非常关键的坑工具的描述信息决定了模型能不能正确调用它。我发现模型对工具的描述理解能力参差不齐写得太宽泛就会经常出现参数瞎传的情况。比如创建待办和创建一条待办事项title 为必填字符串dueDate 可选日期格式为 yyyy-MM-dd如果用户说明天要换算成具体日期效果完全不同。前一种模型经常把日期格式传错后一种基本很少出错。所以封装工具的时候我养成了一个习惯用一小段话把参数格式、边界条件、默认值处理都写清楚这比模型每次都能聪明地理解你的意图可靠得多。还有模型的 JSON 输出稳定性问题Spring AI 已经帮我解决了很大一部分它内部对工具调用的响应做了解析如果模型给出了非法 JSON会通过格式修正机制自动重新往模型里发一次提示。但在生产环境我仍然建议对这个过程做一个兜底捕获解析异常主动返回一条工具错误信息而不是让整个对话栈崩掉。2.2 上下文管理不是把所有历史都塞给模型做过几轮 Agent 对话的人都会发现直接把所有历史消息一股脑传给模型很快 token 就不够用了反应也越来越慢。我在这套项目里做了两层记忆管理。第一层是短窗口记忆每个会话维护最近的轮次列表但设置一个 max 值默认 10 轮。超过后最早的消息会被放进一个滚动摘要里由一个小模型定期生成摘要在下一次对话时把摘要作为系统提示词的一部分。这个做法比单纯截断消息友好很多保留了对早期信息的理解能力token 占用又很稳定。第二层是向量记忆把用户每条消息和 Agent 的关键回复做 Embedding存入向量数据库。当用户在一个新会话里问我之前跟你提过的那本技术书是什么时系统会先做一次向量检索把最相关的历史消息找出来拼装进当前上下文。这个跨会话的记忆能力是 Personal Agent 区别于普通客服机器人的核心功能。实现上我使用的是 Spring AI 自带的向量存储抽象它支持很多种实现我选的是内存型加上一个可选的本地持久化模式这样在开发环境不用额外起服务生产可以直接切到独立存储。Embedding 模型我也接了用的是开源模型。整套记忆管理的关键在于向量检索的结果要做一个相关度阈值过滤低于阈值的就直接忽略否则会引入大量无关历史干扰模型判断。2.3 流式输出的三次握手SSE 推送不能只开个接口用户问一个问题如果 Agent 要工具循环两三轮才给出最终结果那前端如果是同步等结果体验是极其糟糕的——可能用户在界面上看到请稍等要转好几秒钟。这个项目里我把流式输出做成了标配不只是聊天内容流式连工具调用过程也是流式的。Spring AI 的 ChatClient 支持.stream()方法返回一个 Flux 流。我在 Web 层用 Spring MVC 的 SseEmitter 接收这个流然后把事件推给前端。关键细节在于事件类型要区分好event: agent_start data: {sessionId: xxx} event: tool_call_start data: {toolName: query_calendar, args: {date:2026-05-01}} event: tool_call_end data: {toolName: query_calendar, result: ...} event: message_delta data: {content: 你好我是你的...} event: agent_end data: {status: done}前端只要能消费 SSE 协议就能逐段展示进度包括正在查询日历这种中间状态。对用户来说他看到的不是一只静态的加载转圈而是 Agent 实实在在思考的过程信任感和体验差非常多。整个链路的实现其实就是一个 EventPublisher 类在编排层发布事件SSE 控制器订阅事件推给客户端。这里要注意一个非常实际的生产问题SSE 连接不能无限制地挂着。我在服务端做了一个守护线程如果一个会话超过 60 秒没有空闲数据推送就关闭这个 SSE。因为模型在思考时不会发数据如果用户感觉卡住了前端需要能自动重连而不是等一个永远不回来的流。另外 SseEmitter 的异步线程要设置超时时间防止容器默认的 30 秒超时直接切掉连接。3. 生产级细节看起来是 AI 项目实际上是工程问题3.1 并发控制与会话隔离Agent 不能精神分裂Personal Agent 一旦对外服务首先面临的就是并发问题。同一个用户可能同时在手机和网页发起两个请求如果这两个请求都修改同一个会话的历史消息Agent 的记忆就会污染出现精神分裂。我的方案是引入会话级分布式锁锁的粒度是 sessionId。在进入 Agent 编排层之前先尝试获取会话锁拿不到就直接返回当前会话正在处理其他请求请稍候。Redis 实现天然支持过期时间我设置的过期时间是 30 秒超过就自动释放防止系统异常导致死锁。同时本地内存里还有一层更快的 ConcurrentHashMap 锁避免了所有请求都打 Redis 的开销。下面是核心的锁服务实现思路我给一个简单可用的版本public class SessionLockManager { private final StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX agent:lock:; private static final Duration LOCK_TTL Duration.ofSeconds(30); public boolean tryLock(String sessionId) { String key LOCK_PREFIX sessionId; Boolean success redisTemplate.opsForValue() .setIfAbsent(key, Thread.currentThread().getId() , LOCK_TTL); return Boolean.TRUE.equals(success); } public void unlock(String sessionId) { String key LOCK_PREFIX sessionId; redisTemplate.delete(key); } }这里还要处理一个隐藏问题模型推理有超时但超时发生时我们还在持有锁必须用 try-finally 释放。我把整个编排逻辑封装在一个方法里锁的释放放在 finally 块中就算抛出 Runtime 异常也能保证锁释放不会阻塞用户的后续会话。3.2 重试与熔断别瞎写AI 接口和数据库不是一个套路大模型接口和数据库接口的重试逻辑完全两回事。数据库挂了你重试可能就恢复了但模型接口返回 429限流或 5xx很多情况下是暂时的合理重试有效。但模型返回 400 这种参数错误重试一万次也是浪费钱。我在模型调用这一层做了一个全局的重试拦截器它根据异常类型和状态码分类处理。429 和 503 会触发指数退避重试初始 1 秒最多 4 次每次翻倍并且加上一个随机抖动防止多个请求同时重试再次把限流触发。业务级的 RuntimeException 不重试直接向上抛出让编排层决定是否要告诉用户系统繁忙。熔断这一块我用的是 Resilience4j集成非常轻量。熔断器的策略是在一个 30 秒的滑动窗口内如果模型调用的失败率超过 30%就打开熔断器后续 10 秒内的请求直接快速失败不再真正去打模型接口。10 秒后尝试放行少量请求探测恢复成功了就逐步关闭熔断。这里模型接口的调用的特点是高延迟通常 3-10 秒所以熔断器的时间窗口要设得比普通 HTTP 大很多否则会因为延迟高导致统计窗口一直处于打开状态。3.3 Token 计算与成本控制别到月底看账单才傻眼做一个 Personal Agent如果不关心 token 成本上线基本上就是烧钱。我在项目中实现了多级成本控制机制。首先在模型适配层每轮对话都会计算预估输入 token 和最大输出 token并实时检查当前会话累计消耗是否超限。我在配置里设置了用户日额度、会话额度两级限制一旦超限Agent 会直接提示今日对话额度已用完同时整轮请求会被快速拒绝。这个限制必须放在编排层的最前面不能等消息进了模型才拦截。为了准确统计 token我使用的是模型响应里的 usage 字段这个字段是真实消耗数不是估算。对于工具调用消耗的 token也都累加在会话的总账上。我做了个数据库表来记录每条消息的模型、输入输出 token 数和耗时每天结束跑一个汇总任务把这个数据推给后台的看板。3.4 可观测性Agent 的内部的每一步都要能追踪生产环境出了事故最绝望的事情就是日志打出来只有一行模型返回异常。你完全不知道是哪一轮工具调用出了问题也不知道当时用户问的是什么。所以从项目一开始我就给整套 Agent 链路打通了全链路追踪。核心做法是在消息进入 Agent 编排层时生成一个 traceId通过 SLF4J 的 MDC 机制把它塞进日志上下文整个链路的所有日志包括工具调用、模型请求、重试事件、熔断触发都会自动带上有同一个 traceId。配合日志平台我可以通过一个 traceId 拉出整次对话的完整生命周期日志。另外我在 Agent 编排层加了一个关键日志点格式类似[traceIdxxx, sessionxxx] [model_request] modelgpt-4o, promptTokens123, completionTokens45, totalTokens168, costEstimate0.003 [traceIdxxx, sessionxxx] [tool_call] toolquery_calendar, args{userId...}, result{count:3}一天结束从日志平台拉这些数据做聚合直接就能分析出哪个工具调用次数最多、哪个工具平均耗时最长、哪个模型成本占比最大。有了这些数据优化方向就非常清晰而不是拍脑袋。4. 完整实操从零搭建项目骨架到跑通首轮对话4.1 建项目与核心配置Spring Boot Spring AI项目基础是 Spring Boot 3.x 版本JDK 17。我建议用 Maven 管理依赖最关键的是在 pom.xml 引入 Spring AI BOMdependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement随后引入 starter-web 和 spring-ai-starter-model-openai。我用的模型接口是 OpenAI 兼容协议但实际接入的是国内的开放平台提供的模型服务因为这样响应速度快一些。配置方面在 application.yml 里设置spring: ai: openai: base-url: https://your-endpoint.example.com/v1 api-key: ${AI_API_KEY} chat: options: model: your-model-name temperature: 0.7 max-tokens: 2048这些配置本质上就是把模型厂商的信息从代码里剥离出去换供应商只改配置。我另外封装了一个 ModelConfig 类根据不同的配置文件可以灵活地在多个模型之间切换比如大模型负责推理、小模型负责摘要和 Embedding。4.2 核心代码逻辑ChatClient 的组装与 Agent 编排循环接下来是 Agent 服务的核心逻辑。在 Spring AI 里ChatClient 可以像构建器一样装配各种功能。我创建了一个 AgentService核心方法如下Service public class AgentService { private final ChatClient chatClient; private final ToolRegistry toolRegistry; public AgentService(ChatClient.Builder chatClientBuilder, ListToolService toolServices) { this.chatClient chatClientBuilder .defaultSystem(你是一个高效可靠的个人助理可以管理用户的待办、日程回答用户的问题。回答尽量简洁...) .defaultTools(toolServices.toArray()) .build(); } public FluxAgentEvent chat(String sessionId, String userMessage) { // 1. 获取会话锁 // 2. 加载历史消息 // 3. 组装 Prompt // 4. 调用 chatClient.stream() // 5. 通过事件流发布工具调用状态和消息增量 } }在 stream 的 Flux 里Spring AI 会把模型的流式响应拆分成一个个 Token 的 Flux我在 map 里把它转换成自定义的 AgentEvent然后通过 Sinks 类发布出去SSE 控制器订阅这个 Sinks 就能把实时结果推到前端。这里特别建议流式响应不要走 WebFlux 的全局方案因为 SSE 加 Sinks 的模式对接现有 Spring MVC 项目更平滑而且可以复用原有拦截器和过滤器链。4.3 工具的注册与执行反射调用之前的细节工具注册这一块Spring AI 自动扫描 Tool 注解的方法但生产上我加了一层校验。工具方法的入参必须是基本类型、String、或简单 POJO复杂嵌套对象容易在模型参数反序列化时出问题。如果工具需要多个参数我建议封装成一个 POJO 作为入参因为模型对 JSON 对象参数的生成比多个独立参数稳定得多。工具执行完了返回类型也用字符串最稳妥。Spring AI 在把工具结果回传给模型时需要的是字符串如果工具返回一个对象框架会序列化成 JSON 字符串这里如果没有经过严格测试可能会因为对象里有循环引用导致序列化异常。所以我所有工具方法统一返回 String内部自己负责格式化输出结果。4.4 会话记忆的持久化不重启就丢人对话记忆如果只放在内存的 ConcurrentHashMap 里一重启就全没了。我的实现中会话历史在每轮结束都完整写入数据库。表结构大概是session_id、message_roleuser/assistant/tool、content、message_time、token_usage。每次 Agent 编排层的循环结束都会批量插入这一轮产生的所有消息。查询的时候按时间升序拉出来组转成 Prompt 的消息列表。摘要记忆存在另一张表每次生成新摘要就覆盖旧摘要因为摘要只保留最近的状态。向量数据单独存到向量存储中。这三层数据的一致性要注意数据库会话历史的变更和向量数据写入必须在一个事务里完成否则会出现向量里有记录但原始消息没落库的不一致状态。5. 常见问题与排查技巧实录我踩过的那些坑5.1 模型幻觉导致工具参数名对不上怎么兜底我在最初测试时经常遇到模型返回了工具调用意图但参数名跟方法签名对不上。比如我定义的工具参数叫 userCode模型传了一个 user_id 进来。Spring AI 的解析会直接报错。实话讲这个问题目前没有完美解法模型对 JSON Schema 的理解还没有强到绝对稳定。我用的是多级兜底方案。第一级是框架自带的转换Spring AI 会把 JSON 值按类型转换第二级是我在代理层做字段名模糊匹配如果发现参数缺失但不影响调用就用默认值补上第三级如果核心参数缺失直接返回错误字符串给模型让它重新生成例如工具 query_user 需要参数 userCodeString你传的参数缺少该字段请重新调用。有了这个反馈机制下一次模型基本会纠正自己。5.2 Stream 中断导致前端卡死排查思路分享生产环境遇到过一个最棘手的 bug某个用户反馈对话框卡死了刷新也没用。排查下来是模型在生成到一半时断连基础服务返回了连接重置错误但 SSE 的异常处理没有触发导致前端一直开着等待却永远等不到下个事件。排查思路是先去链路日志看这个会话是否收到了模型服务的完成事件。日志显示没有说明模型侧确实中断了。然后检查异常捕获发现有一个自定义的全局异常处理器把 SSE 相关的异常都吞掉了导致 SseEmitter 一直不结束。修复方法是重写 SseEmitter 的错误处理任何异常发生时不仅要把异常事件推给前端还必须执行 emitter.complete()不给前端留挂死的可能。另外前端也做了 30 秒无消息自动重连的逻辑双保险才彻底解决这个问题。5.3 成本突增的排查向量记忆命中太泛有一段时间我观察每日 token 成本突然涨了 40%一开始以为是把生产模型换成了更大参数的版本查了配置发现没有。后来从日志统计里发现是向量记忆的检索结果过于宽泛每条消息都额外塞进了 2000 多 token 的历史记录。原因是我给的向量检索 topK 设置成了 5而且是固定值没有做相关度阈值过滤。修复方法很简单针对每个检索结果计算相似度得分低于 0.75 的结果直接丢弃。实测下来平均每条消息额外携带的历史记录从 2000 token 降到了 600 token 左右整体 token 成本下降了一大截。这是个典型的细节问题看起来不起眼但放大到生产流量上就是实实在在的钱。5.4 小技巧怎么用测试数据验证 Agent 多轮对话不跑偏最后分享一个测试小技巧。Agent 做多轮对话时最容易出现的问题是上下文污染。我有一套固定的验证用例每次代码改完都会跑一遍比如第一轮帮我安排明天的会议下午 3 点。第二轮改到后天。第三轮看看我这个月有哪些天没有会议。这三轮如果 Agent 都能正确处理说明会话级记忆和摘要记忆的基本链路是通的。我还专门写了一个集成测试基类启动完整的 Spring 容器去跑这几个用例每次 CI 都会执行。AI 应用的测试不应该只测工具方法本身一定要有端到端的多轮会话回归测试这是保证项目强壮性的底线。有些人觉得 Agent 项目就是调包真做下来才知道生产级的难度全在工程细节并发、成本、可观测性、容错兜底。我这个项目把这些细节全部开源出来了如果你也在用 Java 技术栈做个人助理或者智能客服方向直接参考这套架构至少能省掉两三个月的填坑时间。代码上的事多动手跑一遍比看任何文章都管用。