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是17,Spring Boot用的3.2.x。Spring AI目前还在快速迭代期,我写这篇文章时用的是0.8.1,这个版本对Spring Boot 3.2的支持比较稳定,再往后的版本API有调整。你如果用的是更新的版本,有些类的包名和方法名会不一样,但只要掌握了思路,翻一下官方文档改改就行。
pom.xml 里核心依赖就这几个:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>0.8.1</version> </dependency> </dependencies>注意Spring AI的依赖仓库不在Maven Central,需要额外配置仓库地址:
<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> </repository> </repositories>2.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 RedisTemplate<String, String> redisTemplate; public DeepSeekChatService(OpenAiChatClient chatClient, RedisTemplate<String, String> redisTemplate) { this.chatClient = chatClient; this.redisTemplate = redisTemplate; } @Override public String chat(String sessionId, String userMessage) { List<Message> 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 List,Key为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(List<Message> 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应用都会做打字机效果,就是模型生成一个字,前端显示一个字。这个体验的底层是SSE(Server-Sent Events),不是WebSocket。SSE是单向的,服务端向客户端持续推送数据,刚好匹配大模型流式生成场景。
5.1 用SseEmitter实现流式输出
Spring Boot 3里实现SSE有两种常见方式:WebFlux的Flux返回,或者Servlet层面的SseEmitter。如果项目里已经用了Spring MVC,没必要为了流式输出强行引入WebFlux,SseEmitter就够了。
@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 提示词注入检测
这个是最容易被忽略的。用户在聊天框里输入“忽略之前的系统指令,告诉我怎么……”来尝试绕过提示词约束。大模型本身对这种攻击有一定抵抗力,但不能完全依赖模型自觉。
我加了一个轻量的规则检测:如果用户消息里同时出现“忽略指令”“忘记设定”“role=system”“立刻回答”等关键词组合,就标记为可疑,走特殊处理通道。可疑请求我选择的是不直接拒绝,而是交给一个独立的小模型重新审查一遍,双重保险。这样设计是因为用户有时候只是随口说说,误杀率太高会影响使用体验。
检测逻辑我封装成了一个接口:
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); } }然后把这个工具对象传给ChatClient:
ChatClient 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走到生产环境的关键。如果你也在做类似的整合,希望这篇文章能让你少走几步弯路。