简介:面向毕业设计选题与Spring AI技术实践,这份基于Spring Boot 3.2.0和Vue 3的AI聊天应用源码,集成了DeepSeek、SiliconFlow、Gemini等多种大模型接口,适合需要快速搭建AI对话产品原型的高校学生与Java开发者。项目以前后端分离方式组织,后端采用Spring Boot与WebFlux响应式编程,实现聊天接口的流式输出;前端基于Vue 3完成实时聊天界面、响应式布局、Markdown渲染等交互能力,代码结构清晰,便于二次扩展。压缩包共20个文件,以Java源码为主(11个java),辅以Vue/JavaScript逻辑(3个js)、项目配置(yml)、前端样式(css)、页面结构(html)及说明文档(md)等,包体仅48KB,轻量易读,适合直接导入IDE学习与运行。已有182人学习下载,对于想掌握Spring AI多模型接入、WebFlux流式响应或完成相关毕设课题的读者,这份源码提供了完整的落地参考。 这段时间我一直在搞一个基于 Spring Boot 和 Vue 3 的 AI 聊天应用,源码从头到尾完整落地了一版,接入的模型包括 DeepSeek、SiliconFlow 和 Gemini。这个项目拿来当毕业设计,定位非常讨巧:它不碰自训练模型、不需要 GPU,核心是把三家大模型 API 通过统一适配层接进自己的业务系统里,同时把全栈里最常考的几块技术——Spring Boot、Vue 3、流式传输、异步消息队列——全部串起来。说白了,这是一个既能在答辩时讲清楚,又能在简历上写实的项目。
这篇文章我打算把整个项目从选题思路、技术选型、后端适配层设计,到前端流式渲染、Redis Stream 消息队列落地,再到部署时踩过的坑,一次说透。适合正在发愁毕设选题的同学,也适合想快速走一遍 Java + Vue 全栈 AI 应用的朋友参考。
1. 选题思路与技术栈选型
1.1 为什么是 Spring Boot + Vue 3 + AI 这个组合
作为毕业设计,项目的技术选型不能太保守,也不能冒进到把自己卡死。Spring Boot + Vue 3 是一个经历过大量生产环境验证的组合,资料多、答疑方便、生态成熟,这个底座几乎不会出大问题。真正让这个项目“跳出来”的点是 AI 接入:现在主流大模型都提供 HTTP API,不需要本地部署模型、不需要显卡,一台普通笔记本就能跑完整套开发调试流程。
选这个组合还有一层现实的考虑——就业导向。后端高频面试题里的 REST API 设计、接口鉴权、SSE 长连接、Redis 使用、事务处理,前端高频的响应式状态管理、组件通信、异步请求处理,在这个项目里全部都能找到对应的落地点。比起单纯做一个增删改查管理系统,这个项目的技术覆盖面明显更有竞争力。
另外从答辩演示角度考虑,AI 聊天应用的效果非常直观。现场输入一句话,模型逐字返回,配合前端打字机效果,评委一眼就能明白项目在做什么。这种“看得见”的成果,比花大力气做后台管理、报表统计要省事得多,也更容易留下好印象。
1.2 多模型接入:DeepSeek、SiliconFlow 与 Gemini 的差异
一开始我也纠结过,一个毕设项目接一个模型不就够了吗?后来实际做下来发现,接多个模型并不是“炫技”,而是有非常实际的价值。
第一,容灾与成本。不同厂商的 API 时有波动,有的模型在高峰期响应慢,有的模型在特定类型的问题上效果差。多个模型可以互相备份,也可以按场景切换——日常闲聊用便宜快速的模型,复杂推理用更聪明的模型。第二,这是项目里一个天然的亮点模块。在系统架构层面,多模型接入意味着必须设计一个“适配层”,把不同厂商的协议差异挡在业务外面。这一点在简历和答辩里都可以作为核心设计点来讲。
这三家的接入方式差异相当明显:
- DeepSeek 走的是 OpenAI 兼容协议,API 路径是
/chat/completions,请求体里带messages数组,流式响应里内容在choices[0].delta.content这个位置。 - SiliconFlow 作为一个模型聚合平台,它提供的也是 OpenAI 兼容格式,但 base URL 不一样,模型名非常多,相当于“一个 API 换几十种开源模型”。
- Gemini 的协议完全不同,走的是
generateContent,请求体结构是contents数组,流式返回里的文本在candidates[0].content.parts中。
这也就意味着,如果直接把 HTTP 调用逻辑写在业务代码里,每换一个模型就要改一遍 Service。而通过定义一个统一的ChatProvider接口,把每个模型封装成实现类,业务层只依赖接口,这就是适配层模式的核心价值。
2. 后端架构设计与核心实现
2.1 统一模型接口层与协议适配
后端这块我先搭了一个非常薄的模型层,核心是一个接口:
public interface ChatProvider { // 服务提供方名称,如 deepseek / siliconflow / gemini String providerName(); // 非流式调用,返回完整文本 String chat(ChatRequest request); // 流式调用,通过 sink 逐段往外推 void streamChat(ChatRequest request, StreamSink sink); }ChatRequest里封装了用户消息、历史消息列表、模型名、temperature、max_tokens 这些公共参数。每个模型一个实现类,比如DeepSeekChatProvider、SiliconFlowChatProvider、GeminiChatProvider,各自负责把公共参数翻译成对应厂商的请求结构,再解析返回结果。
这里有一个非常关键的差异点:解析流式响应。OpenAI 兼容接口的流式 chunk 长这样:
data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]Gemini 则长这样:
data: {"candidates":[{"content":{"parts":[{"text":"你"}]}}]}如果不做适配层,这些解析逻辑会散落到业务代码各处分叉,非常难看。做了统一接口之后,前端根本不关心后端到底接的哪家模型,它只收到一种统一格式的流式返回,业务代码也完全不需要感知模型差异。这个设计在答辩时可以专门画一张图:请求进来 -> 路由到指定 Provider -> 协议转换 -> 流式返回,逻辑非常清晰。
在实现时有一个细节要注意:调用外部 API 一定要设置连接超时和读取超时。我用的是RestClient,连接超时给 5 秒,读取超时给 60 秒。如果不设读取超时,一旦上游模型迟迟不返回,请求线程会一直挂着,连接池很快就会被耗尽。
2.2 流式对话:SSE 推送与异步线程池
AI 聊天的核心体验就是“打字机效果”,这要求后端把模型返回的内容边收边推给前端。Spring Boot 里做这件事最直接的方式是 SSE(Server-Sent Events),用SseEmitter。
需要注意的是,SseEmitter默认会占用一个 Tomcat 工作线程。如果直接在 Controller 方法里同步调用上游 API,等到模型全部返回完才结束,那么每个对话请求会长时间占用一个线程。并发一高 Tomcat 线程池很快就会打满。所以正确的做法是:把耗时操作丢给业务线程池执行,Controller 方法直接返回SseEmitter,让 Tomcat 线程立刻释放。
大致结构是这样的:
@PostMapping("/chat/stream") public SseEmitter stream(@RequestBody ChatRequest request) { SseEmitter emitter = new SseEmitter(120_000L); chatService.streamChat(request, emitter); return emitter; }emitter.send()可以发送文本片段,结束的时候调用emitter.complete(),异常时调用emitter.completeWithError(e)。这里有一个我踩过坑的点:前端断开了连接时,SseEmitter会抛出IOException,这个异常一定要捕获,并且确保调用emitter.complete()释放资源,否则这个 emitter 会一直挂在内存里,直到超时。
还有超时时间不能随便设。太短则长文本还没推完通道就关了,太长则异常情况下资源释放慢。我设置的是 120 秒,基本能覆盖大多数模型生成长回答的时间。
2.3 Redis Stream 异步落库与消费
聊天记录必须落库,这是毕设的基本要求。但这里有一个问题:如果每聊一句话就同步去写一次数据库,会拖慢对话响应;如果把消息处理和模型调用串在一起,用户要等数据库写完才能看到首字返回。解决思路是把“对话”和“记录”解耦,通过 Redis Stream 做异步队列。
这也是搜索热词里“spring boot redis stream 如何拉取队列消息”对应的部分。我在项目里实践下来,核心逻辑分三段:
首先是写入端。每次会话结束后,把消息记录封装成ChatRecord,通过redisTemplate.opsForStream().add()写入指定 key:
ObjectRecord<String, ChatRecord> record = StreamRecords.newRecord() .ofObject(chatRecord) .withStreamKey("chat:record:stream"); redisTemplate.opsForStream().add(record);然后是消费端。Redis Stream 的消费比 List 的brpop先进之处在于支持消费者组,可以多个消费者并行消费,且每条消息只会被组内一个消费者拿到。配置一个StreamMessageListenerContainer,监听同一个 key:
StreamMessageListenerContainer<String, ObjectRecord<String, ChatRecord>> container = StreamMessageListenerContainer.create( redisConnectionFactory, StreamMessageListenerContainerOptions.builder() .pollTimeout(Duration.ofSeconds(1)) .targetType(ChatRecord.class) .build()); container.receive( Consumer.from("chat-record-group", "consumer-1"), message -> { ChatRecord record = message.getData(); chatRecordMapper.insert(record); // 处理完成后确认消息 redisTemplate.opsForStream().acknowledge("chat:record:stream", "chat-record-group", message.getId()); }); container.start();这里最关键的坑是acknowledge。Redis Stream 不会自动删除已投递消息,如果消费者处理失败后没有确认,消息会一直停留在 Pending 列表里,再次被投递。如果不ack,消息会累积,最终变成“幽灵消息”。我在最开始没写ack,导致启动一段时间后消息堆积,数据库里重复数据一大片。所以完整流程一定是:投递 -> 处理 -> ack,三步缺一不可。
另外要注意,消费者组的创建需要提前执行,否则receive会直接报NOGROUP错误。可以在启动时加一个初始化方法,提前执行XGROUP CREATE chat:record:stream chat-record-group 0 MKSTREAM。
3. 前端 Vue 3 交互层实战
3.1 消息列表与流式解析
前端是 Vue 3 + Vite,核心交互就是一个多会话聊天页面。这里最麻烦的部分是流式响应的解析。
后端走的是 SSE,但前端我没有用EventSource,原因是EventSource只支持 GET 请求,而我们的对话接口需要 POST body 传参数。所以采用了fetch+ReadableStream的方式手动解析。
核心代码大概是这样:
const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // 手动按行切分 SSE 格式 const lines = chunk.split('\n').filter(line => line.startsWith('data:')); for (const line of lines) { const data = line.replace('data:', '').trim(); if (data === '[DONE]') continue; const json = JSON.parse(data); currentMessage.value += json.content; } }这段代码有一个经典的大坑:chunk的分隔不一定是按行对齐的。TCP 分片、Nginx 转发、后端写入缓冲都有可能导致一次reader.read()返回的内容横跨多条 SSE 事件,或者一条事件被拆成两半。直接按\n切分然后解析 JSON,大概率会在切到一半的时候报JSON.parse错误。
我的处理方案是维护一个buffer变量:每次拿到新 chunk 先拼到 buffer,再按换行符切分;最后一段如果凑不齐一整行,留在 buffer 里等下一次。这样解析就稳定了。这个细节虽然小,但没经验的人真的要调很久。
3.2 会话、模型切换与参数控制
Vue 3 的逻辑组织我用了 Composition API,整个聊天状态收敛到一个useChatStore里,维护会话列表、当前会话消息、当前模型和参数设置。
一个比较难处理的设计点是:切换模型时上下文怎么处理。模型之间不能共享多轮上下文,因为不同模型的提示词格式和上下文长度都不一样。我的策略是:每个会话绑定一个模型,新建会话时可以选择模型,创建之后不允许切换;如果用户想用另一个模型,就新建会话。这样实现简单,也避免混用导致的上下文错乱。
参数面板我做了三个控制项:temperature、top_p、max_tokens。这里有一个不同模型的兼容性问题——Gemini 对 temperature 的取值范围有自己的一套约束,而 OpenAI 兼容接口的模型对 max_tokens 的命名是max_tokens,Gemini 则是maxOutputTokens。同样是前端传一个参数,后端适配层要做一次映射。这也是为什么前端只需要一个统一结构,后端去处理差异。
前端还有一个小功能值得做:历史会话本地持久化。我用 localStorage 保存会话列表和最近 50 条消息,刷新页面不丢。这个功能实现成本极低,但演示的时候很加分——起码证明你考虑到了用户体验。
4. 部署、运行与常见问题排查
4.1 开发环境从命令行把项目跑起来
这个项目在本地跑起来的流程很简单,但我见过太多同学卡在第一步。后端是标准的 Maven 项目,命令行直接执行:
mvn spring-boot:run -Dspring-boot.run.profiles=devdev profile 里配置本地 Redis 连接、各模型 API Key、密钥等信息。注意这些配置不要写死在application.yml里,用环境变量覆盖,避免源码上传时把 API Key 泄露。
前端是 Vite 工程,跑起来也简单:
npm install npm run dev但有个关键配置必须写对:Vite 的 dev server 代理。前端页面跑在 5173,后端接口在 8080,如果不配代理,前端每个请求都会跨域。在vite.config.ts里加一段:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, },这样前端请求/api/chat/stream时会自动转发到后端,不会触发浏览器 CORS。把代理配好,整个开发过程就顺畅很多。
4.2 生产环境部署与 Tomcat 注意事项
如果毕设要求部署到服务器,常见做法有两种。
一种是前后端分开部署:后端打成 jar 包,java -jar app.jar运行;前端把 dist 目录扔给 Nginx 托管,Nginx 配置/api反向代理到后端。这里有一个大坑:Nginx 默认会缓冲 SSE 响应,导致前端收到的一大坨数据在缓冲区里攒着,流式效果完全丢失。必须关闭缓冲:
location /api/ { proxy_pass http://localhost:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; }proxy_read_timeout也要调大,默认 60 秒内如果后端没有输出数据,Nginx 会断开连接。模型生成长文时可能超过这个时间。
另一种是把前端打包后放到 Spring Boot 的static目录下,一个 jar 包全搞定,适合演示环境。但这种做法要注意 Vue Router 的 history 模式刷新会 404,需要加一个 controller 把未知路径转发到index.html。方便起见,演示环境我通常直接改用 hash 模式路由,省掉这个麻烦。
4.3 高频报错与排查方向
项目跑完,我把高频问题整理成了一张表,基本覆盖了从联调到部署的各个阶段:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 前端调接口报 401 | 请求未携带登录 token | 检查拦截器放行/api/chat/**或补齐 Authorization 头 |
| 200 但内容为空 | 模型请求格式不对,远端返回了空 choices | 用 Postman 直接打上游 API,对比请求体差异 |
| 流式内容一直不输出,最后一次性出现 | Nginx 缓冲了 SSE | 关闭proxy_buffering |
| JSON parse error on chunk | SSE 事件被拆包 | 用 buffer 拼接,按完整行解析 |
| Redis 连接拒绝 | 本地 Redis 未启动 | 先redis-cli ping,再查配置路径 |
| Gemini 返回 400 Bad Request | contents 结构或 participant role 不对 | 仔细看 Gemini API 文档,system 指令要用systemInstruction字段 |
| 模型返回超长截断 | max_tokens 设置太小 | 调大或在前端提示用户 |
还有一个实用技巧:开发阶段不要每个问题都调真实模型接口,既烧钱又慢。我把每个模型的响应 JSON 保存成固定的测试文件,后端提供一个 mock provider,开发联调时切换到 mock 模式。前端不管后面接的是真模型还是假模型,都能正常开发。这个思路在团队协作时特别实用,其他人不申请 API Key 也能把前端页面完全调通。
写在最后
我个人在源码落地过程中最深的体会是:这个项目真正的难点并不是“调用一个模型”,而是把多个不同协议的模型统一进一套业务体系,再通过流式和异步队列把交互体验做好。先跑通一个模型的最小闭环,再逐步加多模型、加历史会话、加 Redis Stream 落库,这个节奏会让整个开发过程清晰可控。
最后再分享一个小的扩展方向:如果想让这个项目再往上走一个档次,可以尝试接入 Spring AI——它本身就是 Spring 官方对 AI 应用编程模型的抽象,能够更规范地管理模型调用、提示词模板和结构化输出。把 Spring Boot + Vue 3 的底座留着,把适配层替换成 Spring AI,或者在这个基础上加一个 RAG 知识库,都可以让项目从“毕设完成度”直接升级到“求职作品级别”。结构搭好了,后面想怎么长,完全取决于你自己的方向。
本文还有配套的精品资源,点击获取