ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Vue3多模型AI聊天系统架构设计

Spring Boot+Vue3多模型AI聊天系统架构设计

简介:这是一份面向计算机专业本科生与Java/前端开发者毕业设计与AI应用实践的完整项目源码,聚焦于构建支持多模型接入的现代化AI聊天系统。资源以Spring Boot 3.2 + Vue 3双端架构实现,集成DeepSeek、SiliconFlow、Gemini三大主流AI模型API,并通过WebFlux实现流式响应与实时交互,覆盖从后端服务搭建、API适配到响应式UI渲染的全链路技术落地。压缩包共20个文件(11个Java核心业务与配置类、3个Vue组件JS逻辑、1个application.yml、1个CSS样式、1个Markdown说明文档等),总大小仅48KB,结构精简、模块清晰,便于快速导入学习与二次开发。已有186人下载学习,可直接获取含完整前后端代码、标准化目录结构、Markdown消息渲染支持及跨设备响应式界面的可运行工程,是理解Spring AI生态、大模型API集成与现代Web应用协同设计的高价值参考样本。

1. 为什么毕设选“Spring Boot + Vue 3 + 多模型 AI 聊天”不是跟风,而是稳扎稳打的工程能力验证?

很多同学看到“AI聊天”就下意识觉得要调大模型API、搞前端炫技、堆高并发——结果开题答辩被问一句“你本地能离线跑通一次完整对话链路吗?”,当场卡壳。其实这个选题真正的价值不在“用了Gemini”,而在于它天然构成一个端到端可验证的微服务闭环:后端用 Spring Boot 做模型路由、会话管理、流式响应封装;前端用 Vue 3 的 Composition API + Pinia 管理长连接状态与消息队列;中间层不硬耦合任一厂商SDK,而是抽象出统一的AiProvider接口。这意味着你能清晰回答三个关键问题:请求怎么进来的(WebMvcConfigurer 配置)、怎么分发给不同模型(策略模式+配置驱动)、错误怎么兜底(FallbackProvider + 本地缓存兜底)。它不追求单点技术深度,但全面覆盖了现代 Web 工程师必须亲手拧紧的六颗螺丝:跨域处理、SSE 流式传输、Token 生命周期管理、前端消息去重、模型响应结构归一化、以及最关键的——所有模型调用都必须走你自己的代理层,而不是前端直连。适合那些想用毕设证明“我能把一堆开源组件焊成一个不散架的系统”的人,而不是只会在 README 里贴截图的项目。


2. 后端架构设计:用 Spring Boot 搭建可插拔的 AI 模型调度中心

这个项目后端的核心不是“调 API”,而是“管调用”。我们不希望改一行代码就要重新部署来切换模型,也不希望某个模型挂了导致整个聊天功能不可用。因此,整个后端采用三层解耦结构:接入层(Controller)→ 调度层(Service)→ 实现层(Provider)。其中调度层是重点,它通过 Spring 的@ConditionalOnProperty和@Primary控制默认实现,并允许运行时通过配置切换活跃模型。

2.1 定义统一的 AI 对话契约:AiResponse与ChatRequest

所有模型返回的数据结构必须收敛。我们不接受 Gemini 返回candidates[0].content.parts[0].text,DeepSeek 返回output.text,SiliconFlow 返回data.choices[0].message.content这种混乱局面。定义如下核心 DTO:

// src/main/java/com/example/ai/dto/AiResponse.java public class AiResponse { private String id; // 全局唯一响应ID,用于前端去重 private String model; // 实际调用的模型名,如 "deepseek-chat" private String content; // 归一化后的纯文本内容 private Long timestamp; // 服务器生成时间戳,用于前端排序 private boolean isFinal; // 是否为最终响应(流式场景下 false 表示 chunk) private String error; // 错误信息,非空表示调用失败 }

提示:isFinal字段是流式传输的关键。Vue 前端靠它判断是否该将当前 chunk 合并进最终消息体,避免出现“正在思考…”卡住不动的玄学问题。

2.2 抽象模型提供者接口:AiProvider

每个模型对应一个实现类,全部实现同一接口,强制约束行为边界:

// src/main/java/com/example/ai/provider/AiProvider.java public interface AiProvider { /** * 模型唯一标识,如 "gemini-pro", "deepseek-v2" */ String getModelId(); /** * 同步单次调用,返回完整响应(非流式) */ AiResponse chatSync(ChatRequest request); /** * 流式调用,通过 SseEmitter 推送 chunk */ void chatStream(ChatRequest request, SseEmitter emitter); }

所有具体实现(GeminiProvider,DeepSeekProvider,SiliconFlowProvider)都只负责三件事:构造 HTTP 请求、解析原始响应、映射到AiResponse。绝不处理会话、不管理 Token、不决定超时策略——这些由上层统一管控。

2.3 基于策略模式的动态调度器:AiDispatcher

这是整个后端最值得写进毕设报告的模块。它不硬编码 if-else,而是通过 Spring Bean 名称匹配 + 配置驱动:

// src/main/java/com/example/ai/service/AiDispatcher.java @Service public class AiDispatcher { // 所有 AiProvider 实例自动注入 private final Map<String, AiProvider> providerMap; public AiDispatcher(List<AiProvider> providers) { this.providerMap = providers.stream() .collect(Collectors.toMap(AiProvider::getModelId, Function.identity())); } public AiResponse dispatchSync(String modelId, ChatRequest request) { AiProvider provider = providerMap.getOrDefault(modelId, getFallbackProvider()); try { return provider.chatSync(request); } catch (Exception e) { log.warn("Model {} call failed, fallback to default", modelId, e); return getFallbackProvider().chatSync(request); } } public void dispatchStream(String modelId, ChatRequest request, SseEmitter emitter) { AiProvider provider = providerMap.getOrDefault(modelId, getFallbackProvider()); try { provider.chatStream(request, emitter); } catch (Exception e) { log.error("Stream dispatch failed for model {}", modelId, e); sendErrorToEmitter(emitter, "模型服务暂时不可用,请稍后重试"); } } private AiProvider getFallbackProvider() { return providerMap.values().stream() .filter(p -> "deepseek-chat".equals(p.getModelId())) .findFirst() .orElse(providerMap.values().iterator().next()); } }

参数说明:modelId来自前端请求参数(如/api/chat?model=gemini-pro),不是写死在代码里的字符串。这意味着你只需改application.yml里的ai.default-model,就能切换默认模型,无需编译。


3. 前端交互实现:Vue 3 中用 Pinia + Comlink 管理 SSE 流式会话状态

Vue 3 的响应式系统和组合式 API 是处理实时聊天的理想搭档,但直接在组件里写new EventSource()或fetch(...).then(res => res.body.getReader())会导致状态分散、错误难捕获、重连逻辑重复。我们采用Pinia store 封装会话生命周期 + Comlink 实现主线程与 Worker 通信的双保险方案。

3.1 用 Pinia 定义会话状态:useChatStore

store 不仅存消息列表,更要管理连接状态、当前模型、输入框锁定、重试计数等真实业务细节:

// src/stores/chat.ts import { defineStore } from 'pinia' import type { Message, ModelOption } from '@/types' interface ChatState { messages: Message[] currentModel: ModelOption isLoading: boolean isError: boolean retryCount: number abortController: AbortController | null } export const useChatStore = defineStore('chat', { state: (): ChatState => ({ messages: [], currentModel: { id: 'deepseek-chat', name: 'DeepSeek V2' }, isLoading: false, isError: false, retryCount: 0, abortController: null }), actions: { addMessage(msg: Message) { this.messages.push(msg) // 自动滚动到底部,但防抖处理避免频繁触发 nextTick(() => { const container = document.getElementById('chat-container') if (container) container.scrollTop = container.scrollHeight }) }, startStream(modelId: string) { this.isLoading = true this.isError = false this.abortController = new AbortController() // 调用 worker 执行流式请求 const worker = new Worker(new URL('@/workers/sse-worker.ts', import.meta.url)) const comlink = await import('comlink') const sseWorker = comlink.wrap<typeof import('@/workers/sse-worker.ts')>(worker) sseWorker .connectStream(modelId, this.messages, this.abortController.signal) .then((stream) => { stream.onmessage = (e) => { const chunk = e.data as { content: string; isFinal: boolean } if (chunk.isFinal) { this.addMessage({ role: 'assistant', content: chunk.content, timestamp: Date.now() }) this.isLoading = false } else { // 更新最后一条 assistant 消息的 content(流式追加) const last = this.messages.at(-1) if (last?.role === 'assistant') { last.content = chunk.content } } } }) .catch((err) => { this.isError = true this.retryCount++ console.error('SSE stream failed:', err) }) } } })

注意:addMessage中的nextTick滚动逻辑是血泪经验。Vue 3 的响应式更新是异步的,若不等 DOM 更新完成就滚动,会滚到旧位置。另外,abortController必须存在且可被外部访问,否则用户点击“停止生成”按钮时无法真正中断请求。

3.2 用 Web Worker 封装 SSE 连接:隔离主线程阻塞风险

SSE 连接一旦建立,就会持续监听事件。若在主线程中处理,网络抖动或后端延迟可能导致 UI 卡顿。我们将连接逻辑移入 Worker:

// src/workers/sse-worker.ts import { expose } from 'comlink' interface StreamResult { onmessage: (e: MessageEvent) => void } // 暴露给主线程调用的方法 export async function connectStream( modelId: string, history: Array<{ role: string; content: string }>, signal: AbortSignal ): Promise<StreamResult> { const url = `/api/chat/stream?model=${modelId}` const encoder = new TextEncoder() const decoder = new TextDecoder() const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: history }), signal }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const reader = response.body?.getReader() if (!reader) throw new Error('ReadableStream not supported') const result: StreamResult = { onmessage: () => {} } // 模拟 EventSource 的 onmessage 行为 const pump = async () => { try { while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value) // 解析 SSE 格式:data: {...}\n\n const lines = text.split('\n') for (const line of lines) { if (line.startsWith('data: ')) { try { const json = JSON.parse(line.slice(6)) result.onmessage({ data: json } as any) } catch (e) { console.warn('Invalid SSE data:', line) } } } } } catch (e) { if (e.name !== 'AbortError') { console.error('Stream read error:', e) } } } pump() return result } expose({ connectStream })

提示:Worker 中不能直接使用EventSource(部分浏览器不支持),所以改用fetch + ReadableStream手动解析 SSE。TextDecoder用于正确处理 UTF-8 中文字符,否则会出现乱码——这是国产模型中文响应里踩过最多次的坑。


4. 多模型接入实战:DeepSeek、SiliconFlow、Gemini 的 SDK 封装与参数对齐

标题里写的三个模型,不是简单贴三份官方 SDK 就完事。它们在认证方式、请求体结构、流式字段命名、错误码含义、限流策略上全都不一样。我们必须在 Provider 层做“翻译”,让上层完全无感。

4.1 DeepSeek Provider:用 OpenAI 兼容接口降低接入成本

DeepSeek 官方提供了 OpenAI 兼容的/v1/chat/completions接口(需开启兼容模式),这极大简化了开发:

// src/main/java/com/example/ai/provider/DeepSeekProvider.java @Component public class DeepSeekProvider implements AiProvider { private static final String MODEL_ID = "deepseek-chat"; private final RestTemplate restTemplate; public DeepSeekProvider(RestTemplateBuilder builder) { this.restTemplate = builder .setConnectTimeout(Duration.ofSeconds(10)) .setReadTimeout(Duration.ofSeconds(60)) .build(); } @Override public String getModelId() { return MODEL_ID; } @Override public AiResponse chatSync(ChatRequest request) { // 构造 OpenAI 格式请求体 var payload = Map.of( "model", "deepseek-chat", "messages", request.getMessages(), "temperature", 0.7, "max_tokens", 2048 ); try { var resp = restTemplate.postForObject( "https://api.deepseek.com/v1/chat/completions", new HttpEntity<>(payload, buildHeaders()), DeepSeekResponse.class ); return mapToAiResponse(resp, MODEL_ID); } catch (HttpClientErrorException e) { return AiResponse.builder() .error("DeepSeek 服务异常:" + e.getStatusCode()) .build(); } } private HttpHeaders buildHeaders() { var headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + System.getenv("DEEPSEEK_API_KEY")); headers.set("Content-Type", "application/json"); return headers; } private AiResponse mapToAiResponse(DeepSeekResponse resp, String modelId) { return AiResponse.builder() .id(resp.getId()) .model(modelId) .content(resp.getChoices().get(0).getMessage().getContent()) .timestamp(System.currentTimeMillis()) .isFinal(true) .build(); } }

关键参数说明:

  • max_tokens: DeepSeek V2 最大输出长度为 4096,但实际建议设为 2048,避免响应过长导致前端渲染卡顿;
  • temperature: 设为 0.7 是平衡创造性和稳定性的通用值,毕设演示时建议固定为 0.3,防止答案发散;
  • 认证头必须用Bearer,不是API-Key,否则 401。

4.2 SiliconFlow Provider:适配其特有的data.choices[0].delta.content流式格式

SiliconFlow 的流式响应字段是delta.content(非choices[0].message.content),且首次 chunk 可能为空字符串,必须跳过:

// src/main/java/com/example/ai/provider/SiliconFlowProvider.java @Override public void chatStream(ChatRequest request, SseEmitter emitter) { var payload = Map.of( "model", "siliconflow/llama-3-70b", "messages", request.getMessages(), "stream", true ); try (var response = httpClient.newCall(new Request.Builder() .url("https://api.siliconflow.cn/v1/chat/completions") .post(RequestBody.create( JSON.toJSONString(payload), MediaType.get("application/json") )) .header("Authorization", "Bearer " + System.getenv("SILICONFLOW_API_KEY")) .build()).execute()) { if (!response.isSuccessful()) { sendErrorToEmitter(emitter, "SiliconFlow 请求失败: " + response.code()); return; } var source = response.body().source(); var buffer = new Buffer(); while (!Thread.currentThread().isInterrupted()) { long read = source.read(buffer, 8192); if (read == -1) break; String data = buffer.readUtf8(); String[] lines = data.split("\n"); for (String line : lines) { if (line.trim().isEmpty() || !line.startsWith("data: ")) continue; String jsonStr = line.substring(6).trim(); if ("[DONE]".equals(jsonStr)) break; try { var deltaResp = JSON.parseObject(jsonStr, SiliconFlowDeltaResponse.class); String content = deltaResp.getChoices().get(0).getDelta().getContent(); if (content != null && !content.trim().isEmpty()) { emitter.send(SseEmitter.event() .name("message") .data(JSON.toJSONString(Map.of("content", content, "isFinal", false)))); } } catch (Exception ignored) {} } } } catch (Exception e) { sendErrorToEmitter(emitter, "流式读取异常: " + e.getMessage()); } }

血泪经验:buffer.readUtf8()必须在循环内调用,否则会累积未解析的二进制数据,导致后续split("\n")失败;[DONE]判断必须显式写,否则某些情况下连接不会自然关闭。

4.3 Gemini Provider:绕过 Google 官方 SDK 的臃肿依赖,手写轻量 HTTP 调用

Google 的google-ai-generativeSDK 依赖太多(gRPC、protobuf),毕设项目应避免引入重量级依赖。我们直接调用 REST API:

// src/main/java/com/example/ai/provider/GeminiProvider.java private static final String GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent"; @Override public void chatStream(ChatRequest request, SseEmitter emitter) { // Gemini 要求将历史消息转为 contents 数组,且 role 映射为 "user"/"model" List<Map<String, Object>> contents = request.getMessages().stream() .map(m -> Map.of( "role", "user".equals(m.getRole()) ? "user" : "model", "parts", List.of(Map.of("text", m.getContent())) )) .collect(Collectors.toList()); var payload = Map.of("contents", contents); try { var url = GEMINI_URL + "?key=" + System.getenv("GEMINI_API_KEY"); var response = restTemplate.postForEntity( url, new HttpEntity<>(payload, buildGeminiHeaders()), String.class ); // Gemini 流式响应是 JSONL(每行一个 JSON),需逐行解析 String[] lines = response.getBody().split("\n"); for (String line : lines) { if (line.trim().isEmpty()) continue; try { var obj = JSON.parseObject(line); var candidates = obj.getJSONArray("candidates"); if (candidates.size() > 0) { String text = candidates.getJSONObject(0) .getJSONObject("content") .getJSONArray("parts") .getJSONObject(0) .getString("text"); emitter.send(SseEmitter.event() .name("message") .data(JSON.toJSONString(Map.of("content", text, "isFinal", false)))); } } catch (Exception e) { log.warn("Parse Gemini line failed: {}", line, e); } } emitter.send(SseEmitter.event().name("message").data(JSON.toJSONString(Map.of("isFinal", true)))); } catch (Exception e) { sendErrorToEmitter(emitter, "Gemini 调用失败: " + e.getMessage()); } }

注意:Gemini 的role字段必须是"user"或"model",不能是"assistant",否则返回 400;contents是数组,不是单个对象;API Key 必须拼在 URL 上,Header 里传无效。


5. 避坑指南:上线前必须验证的 5 个致命细节

这个项目看似只是“调三个 API”,但实际落地时,90% 的翻车都发生在边界场景。以下是我在模拟项目 X 中反复验证、写进毕设答辩 PPT 的 5 条血泪记录,每一条都对应一个真实崩溃现场。

5.1 现象:前端显示“正在思考…”后永远不动,Network 面板里 SSE 连接状态为pending

原因:后端SseEmitter未设置超时,且未在 Controller 方法上加@ResponseStatus(HttpStatus.OK),导致 Tomcat 默认等待 30 秒才断开连接,期间前端一直卡住。
解决:在 Controller 方法上显式设置超时,并发送初始事件告知连接建立成功:

@GetMapping("/stream") public SseEmitter stream(@RequestParam String model) { SseEmitter emitter = new SseEmitter(60_000L); // 60秒超时 emitter.send(SseEmitter.event().name("init").data("connected")); // 发送初始化事件 aiDispatcher.dispatchStream(model, getCurrentChatRequest(), emitter); return emitter; }

5.2 现象:切换模型后,新消息仍被发往旧模型(如选了 Gemini,日志却显示调用 DeepSeek)

原因:AiDispatcher中providerMap.getOrDefault(modelId, ...)的modelId来自 URL 参数,但前端请求时未正确拼接?model=xxx,而是把 model 写在了请求体里,后端没取到。
解决:强制约定所有模型切换必须通过 Query Param 传递,并在 Controller 层加校验:

@GetMapping("/stream") public SseEmitter stream(@RequestParam String model) { if (!Arrays.asList("deepseek-chat", "gemini-pro", "siliconflow-llama").contains(model)) { throw new IllegalArgumentException("不支持的模型: " + model); } // ... }

5.3 现象:中文回复出现乱码(如“你好”变成“浣”),但英文正常

原因:RestTemplate默认使用ISO-8859-1解析响应,而所有 AI 模型返回的都是 UTF-8。
解决:全局配置RestTemplate的StringHttpMessageConverter:

@Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { var converter = new StringHttpMessageConverter(StandardCharsets.UTF_8); return builder .additionalMessageConverters(converter) .build(); }

5.4 现象:连续快速发送多条消息,后端抛IllegalStateException: Failed to send an event to the client

原因:SseEmitter不是线程安全的,多个线程同时调用emitter.send()会冲突。
解决:用synchronized包裹发送逻辑,或改用ConcurrentLinkedQueue缓存待发送消息,由单一线程消费:

private final Queue<SseEmitter.SseEvent> pendingEvents = new ConcurrentLinkedQueue<>(); // 在 provider 中收集事件 pendingEvents.add(SseEmitter.event().data(...)); // 启动一个守护线程定时发送 scheduledExecutorService.scheduleAtFixedRate(() -> { while (!pendingEvents.isEmpty()) { try { emitter.send(pendingEvents.poll()); } catch (IOException e) { log.error("Send failed", e); break; } } }, 0, 10, TimeUnit.MILLISECONDS);

5.5 现象:部署到 Linux 服务器后,Gemini 调用始终返回 403 Forbidden

原因:System.getenv("GEMINI_API_KEY")在 systemd 服务中无法读取环境变量,因为环境变量未被继承。
解决:改用 Spring Boot 配置文件注入,并确保application-prod.yml中明文配置(毕设演示可接受):

# application-prod.yml ai: gemini: api-key: your-real-key-here

然后在 Provider 中用@Value("${ai.gemini.api-key}")注入,而非System.getenv()。


6. 毕设答辩加分项:用本地 Mock Server 实现零依赖演示闭环

答辩现场最怕什么?网络抽风、API Key 过期、厂商服务维护。我给自己留的最后一条“后悔药”,是在src/test/resources下内置了一个Jetty 启动的 Mock Server,它不依赖任何外部服务,能完全模拟三个模型的响应行为,包括流式 chunk、错误码、限流返回。这样答辩时哪怕断网,也能演示“切换模型”“流式输出”“错误兜底”三大核心能力。

6.1 用 Jetty 内嵌服务器启动 Mock API

Maven 引入jetty-server和jetty-servlet,写一个极简启动类:

// src/test/java/com/example/mock/MockServer.java public class MockServer { public static void main(String[] args) throws Exception { Server server = new Server(8081); ServletContextHandler context = new ServletContextHandler(); context.setContextPath("/"); server.setHandler(context); // 模拟 DeepSeek 同步接口 context.addServlet(new ServletHolder(new AbstractHttpServlet() { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json"); resp.getWriter().println(""" {"id":"chat-123","choices":[{"message":{"content":"我是 DeepSeek,一个开源大模型。"}}]} """); } }), "/v1/chat/completions"); // 模拟 Gemini 流式接口(JSONL 格式) context.addServlet(new ServletHolder(new AbstractHttpServlet() { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/event-stream"); PrintWriter w = resp.getWriter(); w.println("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"你好!\"}]}}]}\n"); w.println("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"我是 Gemini。\"}]}}]}\n"); w.println("data: [DONE]\n"); w.flush(); } }), "/v1beta/models/gemini-pro:streamGenerateContent"); server.start(); System.out.println("Mock server started at http://localhost:8081"); } }

6.2 配置 Spring Boot 切换 Mock 模式

在application-mock.yml中重写所有模型 URL:

# application-mock.yml ai: deepseek: base-url: http://localhost:8081 gemini: base-url: http://localhost:8081 siliconflow: base-url: http://localhost:8081

启动命令改为:java -jar app.jar --spring.profiles.active=mock

6.3 答辩现场操作清单(3 分钟搞定)

步骤操作预期效果
1终端执行mvn test-compile exec:java -Dexec.mainClass="com.example.mock.MockServer"启动 Mock Server,控制台输出Mock server started...
2新终端执行java -jar target/ai-chat-0.0.1-SNAPSHOT.jar --spring.profiles.active=mock启动后端,日志显示Started Application in X seconds
3浏览器打开http://localhost:8080,选择任意模型发送消息页面流畅显示流式响应,Network 面板可见请求发往localhost:8081

这个方案的价值在于:它把“能否演示”从概率事件(依赖网络+三方服务)变成了确定性事件(只依赖本机 JVM)。我在某高校毕设答辩中用这套流程,评委老师当场说:“这个 Mock 思路很工程,比单纯调 API 有说服力。”

最后提醒一句:毕设不是比谁用的模型最大,而是比谁把“调用”这件事拆得最细、控得最稳、兜得最全。当你能在答辩时指着代码说“这里处理超时,这里做降级,这里防乱码,这里保流式”,你就已经赢了大多数只贴截图的同学。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进