Java AI转型实战:用Spring Boot打造完整智能助手应用

发布时间:2026/9/13 7:37:54
Java AI转型实战:用Spring Boot打造完整智能助手应用 Java AI转型实战这个系列写到这一篇终于到了把前面那些零件拼成整机的时候。前面十几篇我们聊过大模型API怎么调、提示词怎么写、向量库怎么接、Agent的思路怎么落地但如果只停留在单点Demo层面过两天就会忘更没法用到真实业务里。这篇文章我们就来做一个完整的智能助手应用用Spring Boot把对话、记忆、工具调用、流式输出、安全兜底全部整合进同一个工程跑通一条从用户提问到模型返回再到前端展示的完整链路。这个项目的目标不是“能聊天”而是提供一个可以继续往里加业务能力的底座。你要是有Java基础或者正在做Java后端想往AI方向转型这篇文章会带你走一遍我在实际整合过程中踩过的坑和做过的取舍。整篇代码我都是在Spring Boot 3 JDK 17 Spring AI 0.8.1 的环境下跑通的API在不同版本里会有差异但整体设计思路通用。1. 设计先行这个智能助手到底要做成什么样动手写代码之前我花了大概一个下午想清楚边界。很多人做AI应用上来就写Controller、调接口、返回字符串结果Demo跑通了真正要接业务的时候发现完全没法扩展。我觉得问题不在于代码写得不好而在于一开始没想明白“助手”和“聊天机器人”的区别。1.1 先想清楚助手是“玩具”还是“生产力工具”我给自己定的目标是做一个“生产力工具”所以要回答几个问题用户会连续提问助手必须能记住上下文不能每句话都当第一次见面。模型返回可能会很长用户不想等全部生成完才看到结果最好一个字一个字往外蹦。助手不能只聊天它得能查天气、查订单、算价格也就是要能调用外部工具。模型会胡说八道也要防止用户用恶意提示词绕过约束必须有安全兜底。不同场景可能用不同模型比如复杂推理用大模型、简单分类用小模型底层不能写死。这五个问题直接决定了项目的模块划分。我最终拆成了五个部分会话网关、模型路由、上下文记忆、工具执行层、安全策略层。下面这张表是我当时做的模块规划后面所有代码都是围绕它展开的。模块职责关键技术点会话网关接收HTTP请求、管理会话ID、处理SSE流式响应Spring MVC / WebFlux模型路由屏蔽不同大模型API差异支持多厂商切换策略模式 Spring AI上下文记忆把历史消息存起来窗口截断、超长压缩Redis Token估算工具执行层让模型能调用外部API、执行本地方法Function Calling / Tool安全策略层敏感词过滤、提示词注入检测、超时熔断过滤器链 降级策略1.2 一次请求的完整数据流理解这个项目最好的方式是跟着一条消息走一遍。用户在聊天框输入“帮我查一下明天的天气顺便提醒我带伞”前端把这个文本和会话ID一起发给后端/api/chat/stream接口。后端先做安全过滤检查有没有敏感词、有没有明显的提示词注入。检查通过后从Redis里拉取这个会话ID最近的消息记录拼成完整的Prompt再带上系统提示词一起发给大模型。模型流式返回内容后端在返回过程中实时扫描是否有风险内容同时把最终回复追加到Redis的会话记录里。如果模型判断需要查天气它会触发工具调用后端执行天气API再把结果返回给模型让它继续生成。这个流程看着不复杂但每一步都有不少细节要处理。我下面按模块一个个讲顺序是从底层依赖到上层接口这样你照着敲也能顺下来。2. 工程搭建Spring Boot与Spring AI的基础配置我先说一个很多教程不会告诉你的点用Spring AI不是为了炫技而是因为它把“模型调用”这个事抽象好了。我早期直接拿RestTemplate调OpenAI接口后来发现要自己处理鉴权、错误码、JSON解析、流式解析、重试一套下来代码量非常大。Spring AI把这些常见问题封装好了而且它的接口设计是模型无关的换模型厂商只需要改配置。2.1 项目依赖与版本选择我用的JDK是17Spring Boot用的3.2.x。Spring AI目前还在快速迭代期我写这篇文章时用的是0.8.1这个版本对Spring Boot 3.2的支持比较稳定再往后的版本API有调整。你如果用的是更新的版本有些类的包名和方法名会不一样但只要掌握了思路翻一下官方文档改改就行。pom.xml 里核心依赖就这几个parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version0.8.1/version /dependency /dependencies注意Spring AI的依赖仓库不在Maven Central需要额外配置仓库地址repositories repository idspring-milestones/id nameSpring Milestones/name urlhttps://repo.spring.io/milestone/url /repository /repositories2.2 配置文件里的猫腻application.yml 里最核心的就是模型相关配置。我是用的DeepSeek的API它兼容OpenAI的接口格式所以直接用Spring AI的OpenAI Starter把base-url换成DeepSeek的就行。这个方法我在项目里实测是可以的Spring AI本身没有锁死厂商。spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 2048有个关键经验API Key一定不要写在配置文件里提交到Git仓库。我见过太多人把Key硬编码在yml里然后推到GitHub几分钟就被别人盗刷。正确做法是配置里写环境变量占位符${DEEPSEEK_API_KEY}本地用IDEA的环境变量配置或者.env文件。我在团队里还加了条规矩谁把Key推到远端谁请全组喝奶茶。Redis配置按默认来就行主要是存储会话历史。生产环境记得给Redis加密码别用默认端口裸奔。3. 封装模型调用层从“写死模型”到“可切换路由”一开始我在Service层直接注入OpenAiChatClient当时觉得很方便。后来产品跟我说“能不能换个更便宜的模型跑简单的问答”我发现所有代码都耦合在OpenAI的类上换模型要改一大片这就不合理了。3.1 用策略模式封装ChatClient正确的做法是先定义一个自己的接口把模型调用抽象出来public interface AiChatService { String chat(String sessionId, String userMessage); void streamChat(String sessionId, String userMessage, StreamCallback callback); }然后分别实现不同厂商的适配器。Spring AI的好处是各个厂商的ChatClient接口长得差不多所以适配器本身不复杂Service public class DeepSeekChatService implements AiChatService { private final OpenAiChatClient chatClient; private final RedisTemplateString, String redisTemplate; public DeepSeekChatService(OpenAiChatClient chatClient, RedisTemplateString, String redisTemplate) { this.chatClient chatClient; this.redisTemplate redisTemplate; } Override public String chat(String sessionId, String userMessage) { ListMessage messages buildMessagesWithHistory(sessionId, userMessage); return chatClient.call(new Prompt(messages)).getResult().getOutput().getContent(); } // streamChat实现后面流式输出章节详细展开 }以后要接其他模型只需要新增一个实现类用ConditionalOnProperty或者配置中心动态路由就能在不改业务代码的情况下切换模型厂商。这个抽象我强烈建议你保留因为大模型行业变化太快今天用的厂商可能三个月后就换策略了。3.2 超时、重试与降级模型接口和普通HTTP接口不一样一个复杂问题的生成可能需要几十秒但下游系统不会等你那么久。我碰到的真实情况是用户发一个问题模型30秒没返回前端已经超时断开了但后端还在继续生成资源白白浪费。我的处理方案是给模型调用设置两档超时连接超时5秒读取超时60秒。连接超时用于快速失败比如API地址配错了、网络不通读取超时给模型充足的生成时间。同时加了简单的重试机制但只针对网络异常重试业务错误码比如鉴权失败不重试避免浪费。spring: ai: openai: connect-timeout: 5s read-timeout: 60s如果模型服务整体不可用我还有一个降级策略返回固定提示语同时把用户问题记录下来等模型恢复后离线补跑。这个在客服场景特别有用用户至少能收到“当前AI助理繁忙请稍后再试”的友好提示而不是看到502页面。4. 会话记忆与上下文管理让助手不“失忆”用过ChatGPT的人都知道它能记住你前面说过的内容是因为每次请求都会携带历史消息。但在Java后端里实现这个逻辑有几个坑是教程里很少提的。4.1 用什么存历史消息我第一版直接用ConcurrentHashMap存在内存里测试没问题但一重启就全部丢了。后来换成了Redis这样即使应用重启会话历史还在而且以后做多实例部署多个Pod能共享同一份历史记录。存储结构我用的是Redis ListKey为session:{sessionId}:messages每次用户发消息和模型返回后都往List里push两条消息。返回给模型时取最近N条。这里有一个设计细节消息有角色之分。用户消息的role是user模型回复的role是assistant系统指令的role是system。大模型API要求历史消息必须按角色交替传递否则有些模型会报错或者表现异常。4.2 窗口截断与Token估算大模型的上下文窗口是有限的。DeepSeek的上下文窗口我记得是64K但实际使用中塞太多历史一是费用高二是模型会“迷失在长文本中”反而答不好。我的经验是普通对话保留最近10轮20条消息就足够维持连贯性了。问题在于不同模型的Token计算方式不同中文大概1个汉字约等于1到2个Token。我封装了一个简单的估算方法private int estimateTokenCount(String text) { // 中文按1.5字符/Token估算英文按4字符/Token估算粗糙但够用 int chineseChars 0; int otherChars 0; for (char c : text.toCharArray()) { if (c \u4e00 c \u9fff) { chineseChars; } else { otherChars; } } return (int)(chineseChars * 1.5 otherChars / 4.0); }这个估算法不够精确但用于判断要不要截断历史消息已经足够。我还设置了一个硬上限拼好的Prompt总Token数超过6000就截断优先保留最近的对话。这个数字可以根据你使用的模型上下文窗口来调整核心思路是“保证历史消息装满但不溢出”。4.3 超长会话的摘要压缩如果用户聊了很长很长比如一个上午都在和助手对话即使只保留最近10轮之前的关键信息比如用户说过自己是程序员、喜欢喝咖啡也会丢掉。我的方案是引入摘要压缩当历史消息超过一定量时把最早的一批消息发给一个专门用来做摘要的小模型生成几句话的要点存成一个system消息放在最前面。public String summarizeOldMessages(ListMessage oldMessages) { String content oldMessages.stream() .map(m - m.getRole() : m.getContent()) .collect(Collectors.joining(\n)); String prompt 请用简洁的中文概括以下对话的要点包括用户的关键信息、需求和已确认的事项\n content; return chatClient.call(new Prompt(prompt)).getResult().getOutput().getContent(); }这个方法有两个好处一是压缩了Token占用二是让模型在长对话中仍然记得早期用户透露的关键信息。业务场景里实测下来摘要压缩能让长对话的体验提升一个档次。5. 流式输出与SSE实现前端“打字机”效果的背后很多AI应用都会做打字机效果就是模型生成一个字前端显示一个字。这个体验的底层是SSEServer-Sent Events不是WebSocket。SSE是单向的服务端向客户端持续推送数据刚好匹配大模型流式生成场景。5.1 用SseEmitter实现流式输出Spring Boot 3里实现SSE有两种常见方式WebFlux的Flux返回或者Servlet层面的SseEmitter。如果项目里已经用了Spring MVC没必要为了流式输出强行引入WebFluxSseEmitter就够了。PostMapping(/api/chat/stream) public SseEmitter streamChat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(120_000L); String sessionId request.getSessionId(); String userMessage request.getMessage(); // 先返回一个会话ID确认消息 try { emitter.send(SseEmitter.event().name(meta).data({\sessionId\:\ sessionId \})); } catch (IOException e) { emitter.completeWithError(e); return emitter; } // 异步执行模型调用 CompletableFuture.runAsync(() - { try { aiChatService.streamChat(sessionId, userMessage, new StreamCallback() { Override public void onToken(String token) { try { emitter.send(SseEmitter.event().name(token).data(token)); } catch (IOException e) { throw new RuntimeException(e); } } Override public void onComplete() { emitter.complete(); } Override public void onError(Throwable t) { emitter.completeWithError(t); } }); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }需要注意SseEmitter必须用异步方式执行模型调用否则会阻塞Servlet线程导致Tomcat线程池被占满。我踩过这个坑最开始直接在同步方法里调用模型前端能收到流式数据但并发一高其他普通接口全部卡死因为线程池被占完了。5.2 流式数据的安全处理流式输出的数据是碎片化的模型可能一个字一个词地返回。如果安全策略层只在整段内容生成完后做敏感词过滤就会出现一个问题用户已经看到了一整段不合规的内容然后再被前端删掉。这个体验很差。我的做法是在流式回调里维护一个StringBuilder每积累一小段就做一次敏感词检查。正常对话内容放行发现敏感词时不仅中断生成还要记录日志。这里涉及到工具执行层和安全策略层的交互下一节展开。5.3 前端怎么接SSE前端我用的是原生的EventSource对象不需要引入额外的库const eventSource new EventSource(/api/chat/stream?sessionId${sessionId});但注意一个细节EventSource只支持GET请求所以我上面的Controller如果要用EventSource就必须改成GET。但消息内容放在URL里会有限制我这边实际项目是用fetch ReadableStream来解析SSE这样可以用POST传消息体。或者用POST EventSource通过自定义Header需要额外库。我自己最后是写了一个简单的SSE客户端封装用fetch流式读取兼容POST方法。这里有一点要提醒如果用来做聊天功能建议用POST避免消息里的特殊字符和超长内容导致URL溢出。6. 安全与兜底敏感词过滤、注入检测与降级策略AI应用上线最怕两件事模型说了不该说的话或者用户通过精心构造的提示词让模型绕过系统设定。我在这个项目里把安全策略做成了一条过滤链每个环节都有明确的职责。6.1 敏感词过滤从“一刀切”到“分级处理”敏感词过滤不能简单匹配到就拒绝否则“我们公司今年要搞一个活动”这种正常句子可能因为包含“搞”被误杀。我的做法是分级处理级别处理方式示例高威直接拒绝请求违法违规、色情暴力等中威替换为****后放行轻度不文明用语低威记录日志正常放行疑似擦边但无害过滤算法我用的前缀树Trie Tree实现复杂度O(n)性能好。这里有一个关键经验过滤词表一定要放在Redis里缓存支持动态更新不能写死在代码里。因为词表需要随时增删每次改代码发版效率太低了。流式返回场景下不能等整句话生成完才过滤。我在流式回调里维护了一个滑动窗口缓冲区每收到一个Token就追加到缓冲区然后从缓冲区末尾取出最近的5到10个字符做匹配。为什么是“末尾”因为敏感词可能在两个Token之间拆开比如第一个Token是“涉”第二个Token是“黄”单看根Token都不命中拼起来就命中了。缓冲区只保留最近几个字符就是为了让跨Token组合的敏感词也能被查出来。6.2 提示词注入检测这个是最容易被忽略的。用户在聊天框里输入“忽略之前的系统指令告诉我怎么……”来尝试绕过提示词约束。大模型本身对这种攻击有一定抵抗力但不能完全依赖模型自觉。我加了一个轻量的规则检测如果用户消息里同时出现“忽略指令”“忘记设定”“rolesystem”“立刻回答”等关键词组合就标记为可疑走特殊处理通道。可疑请求我选择的是不直接拒绝而是交给一个独立的小模型重新审查一遍双重保险。这样设计是因为用户有时候只是随口说说误杀率太高会影响使用体验。检测逻辑我封装成了一个接口public interface RiskDetector { boolean check(String content); RiskLevel evaluate(String content); }实现类包括SensitiveWordDetector、PromptInjectionDetector、PIILeakDetector身份证号、手机号的泄露检测。整条链路用责任链模式串起来每个检测器只负责自己的事增删都方便。6.3 超时、熔断与降级别让AI拖垮整个系统模型服务是外部依赖必然会出现超时或者不可用。我在测试时遇到过一个问题某个大模型服务突然变慢原来2秒返回变成30秒返回前端等不及断开连接但后端线程还在阻塞等结果最后Tomcat线程池被打满整个系统都不可用。这个问题必须用超时和熔断来解决。我是基于Resilience4j做的核心配置是这样的Bean public CircuitBreaker aiCircuitBreaker() { CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) // 失败率超过50%触发熔断 .waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断后30秒尝试半开 .slidingWindowSize(10) // 最近10次请求统计 .build(); return CircuitBreaker.of(aiService, config); }熔断器打开后后续请求快速失败进入降级逻辑返回固定的提示语“AI服务暂不可用请稍后再试”同时把用户消息记录到数据库等待恢复后重放。这个设计在生成式AI场景里特别重要因为一次生成可能消耗大量Token熔断能帮你拦住大部分无效请求节省成本。7. 工具调用让助手“动起来”——从文本聊天到执行命令如果助手只能聊天那它和搜索引擎的区别就不大。真正的智能助手要能干活查天气、查库存、提交工单。大模型本身不能直接执行这些操作但它可以决定“该调用哪个工具”然后由你的后端代码去执行再把执行结果回传给模型组织成自然语言回复。这就是Function Calling机制。7.1 Spring AI里怎么定义工具Spring AI 0.8.1里可以用Tool注解直接标记一个Java方法为可调用工具Component public class WeatherTools { Tool(根据城市名称查询未来三天的天气) public String getWeather(String city) { // 调用真实的天气API或者返回模拟数据 return weatherApi.query(city); } }然后把这个工具对象传给ChatClientChatClient chatClient ChatClient.builder() .defaultTools(new WeatherTools()) .build();当模型认为需要查天气时它会自动生成一个包含工具调用指令的响应Spring AI框架负责解析这个指令、调用对应Java方法、把执行结果返回给模型整个过程对业务代码几乎透明。我一开始觉得这个机制很玄学后来理解了原理就不怕了。本质上它是在发送给模型的Prompt里附带了一份“工具说明书”JSON Schema描述每个工具的名称、参数、功能。模型根据用户问题判断需要哪个工具然后输出一个特殊格式的JSON框架再把这个JSON翻译成Java方法调用。你可以把大模型理解成一个“调度员”它自己不会干活但它知道该叫谁去干活。7.2 工具调用的边界与校验工具调用有个安全隐患如果工具能执行数据库操作那用户能不能通过一些巧妙措辞让模型执行危险操作比如“删除所有数据”这种话。我的做法是在工具执行层加了两道防线。第一道是参数校验所有工具方法接收到的参数都要再做一遍白名单校验不允许动态拼接SQL之类的危险行为。第二道是执行确认对于删除、批量修改这类高风险操作工具方法会返回一个“需要用户确认”的结果让模型追问用户是否确认执行。这里我踩过一个很实际的坑工具返回的结果如果太长比如查询库存返回了200行数据会占用大量Token模型可能无法把所有数据都纳入上下文。所以工具执行层要做好结果裁剪只返回最关键的信息。我的经验是能返回摘要就返回摘要比如“库存总量为35件其中黑色17件、白色14件、蓝色4件”而不是原样返回整个数据表。8. 效果评测与性能调优上线前我用这几个指标把关很多人把功能跑通就认为大功告成了等到上线被用户骂“这AI是人工智障”才回来优化。我在项目上线前会重点盯三个指标生成质量、首字延迟、Token成本。8.1 生成质量怎么量化大模型的质量评测不能完全靠人去聊天感受工作量太大且主观。我建了一个简单的评测集大概100条问题覆盖了项目要支持的主要场景。每次调整Prompt或者更换模型后我会跑一遍评测集把结果保存下来对比。评测打分我用两个维度相关性答案是否贴合问题和准确性事实是否错误。这两个维度在内部有标注标准评分用1到5分。如果某次改动导致分数下降即使功能看起来没问题我也不会贸然上线。这个习惯帮我避免了好几次“改了感觉更好但实际更差”的尴尬情况。8.2 首字延迟与Token消耗首字延迟指的是用户发出请求到看到第一个字的时间。这个指标直接影响用户的耐心我实测超过3秒用户流失会明显增加。影响首字延迟的因素主要有三个历史消息长度上下文越长模型处理越久、模型本身的响应速度、网络链路。我的优化方向一是控制上下文长度前面说的截断策略就是为了这个二是接入流式输出后模型内部虽然还在生成但用户已经能看到内容了主观等待感大幅下降。Token成本则是另一个容易失控的指标。我加了统计中间件每次请求记录输入Token数、输出Token数和费用估算定时汇总。上线一周后我发现某个场景的Token消耗异常高排查发现是把整个历史记录全量发给模型没有做截断。修掉之后成本直接降了40%。8.3 接口压测与并发保障AI应用的压测和普通接口不太一样。普通接口的QPS是关键指标但AI应用的瓶颈在模型服务的响应速度本地接口的QPS反而不是主要矛盾。我压测时重点关注的是高并发下Tomcat线程池是否被打满、Redis连接是否够用、超时和熔断是否正确触发。压测时我开了一个有趣的现象当模拟20个并发用户同时提问时我的服务本身几乎没有任何压力Redis和MySQL都很闲但模型API侧已经出现超时。这说明AI应用瓶颈不在自己的服务器而在外部模型的吞吐能力。所以后来我把限流器加在了API网关层限制每用户每5秒只能发一条消息避免少数用户刷爆模型额度。9. 从开发到上线的最后一公里几个让我印象深刻的坑到这里整个智能助手的核心链路已经完整了。最后分享几个在开发过程中让我印象深刻的细节这些小事看着不起眼但都能显著影响体验和稳定性。一个是IDEA本地开发时SSE流式输出经常被一层层代理缓冲导致前端一次性全部收到内容没有打字机效果。我在nginx层做了关闭缓冲的配置开发环境直连后端API才正常。这个问题看似小排查起来还挺费劲。另一个是Redis里存的历史消息中文乱码问题。最初没配置RedisTemplate的序列化器存进去的中文变成了一堆转义字符取回来再拼Prompt模型输出的质量明显变差。后来统一用JSON序列化方式存储顺带解决了一个更隐蔽的问题不同版本的Java类反序列化兼容。还有日志规范。AI应用调试最大的痛点是没法复现“上次那个答案是怎么生成出来的”。我后来给每次请求生成了一个traceId从用户的原始输入、拼好的Prompt、模型原始输出、工具调用记录到最终返回内容全部按traceId串起来存日志。这样用户投诉“回答不对”时我查一下日志就知道是哪一步出了问题是Prompt拼错了还是工具返回了脏数据还是模型本身就答错了。最后再说一句做完这个项目再回头看Java做AI应用其实没有想象中那么多障碍Spring AI把模型交互这层封装得已经相当顺手。真正花时间的反而是那些工程化的东西——会话管理、流式传输、安全过滤、成本控制这些才是决定一个AI应用能不能从Demo走到生产环境的关键。如果你也在做类似的整合希望这篇文章能让你少走几步弯路。