Spring Boot+Vue3构建可插拔AI聊天系统

发布时间:2026/10/18 14:24:17
Spring Boot+Vue3构建可插拔AI聊天系统
简介本资源是一套面向计算机专业本科生与Java/前端开发者毕业设计与AI应用实践的完整项目源码聚焦于构建可落地的多模型AI聊天系统。项目采用Spring Boot 3.2 WebFlux实现高并发响应式后端集成DeepSeek、SiliconFlow、Gemini三大主流AI模型API并通过Vue 3打造具备Markdown渲染、流式响应与全端适配的现代化聊天界面有效解决毕设选题创新性不足、AI集成复杂度高、前后端协同难等实际痛点。压缩包共20个文件48KB含11个核心Java后端逻辑类、3个Vue组件及业务JS、1个application.yml配置、1个README.md说明文档结构精简清晰便于快速理解模块划分与API对接流程。目前已有186人学习下载读者可直接复用该工程作为毕设原型获取完整的双端代码结构、多模型切换实现方案、响应式通信设计范式及轻量级部署参考显著降低AI应用开发门槛。1. 这不是又一个“调 API 的前端页面”Spring Boot Vue 3 构建的 AI 聊天应用真正打通模型切换、会话管理与生产级日志闭环你见过太多毕设级别的“AI聊天界面”——前端点个按钮后端硬编码调用某家大模型 API返回 JSON 解析后塞进div刷新就丢上下文换模型就得改三处代码日志里只有一行INFO: Request sent出问题时连请求体长什么样都看不到。这个项目不是那样。它用 Spring Boot 3.xJDK 17做服务编排层Vue 3Composition API Pinia做状态驱动 UI核心价值在于把 AI 模型抽象成可插拔组件把会话生命周期管到毫秒级把每一次 token 流式响应、错误重试、上下文截断、模型降级都落进结构化日志和可观测链路里。适合两类人一是需要交一份“能讲清楚技术选型、有真实调试痕迹、能现场演示多模型切换”的计算机类毕设学生二是想快速验证 AI 原型、但拒绝写一堆胶水代码的中小团队开发者。它不造轮子但把轮子怎么装、怎么换、轮子爆了怎么换备胎全给你拧紧了螺丝。2. 为什么是 Spring Boot 3 Vue 3 而不是 FastAPI React模型抽象层的设计逻辑与落地约束2.1 模型适配器模式不是封装 SDK而是定义统一契约很多初学者一上来就pip install google-generativeai或mvn dependency:add -DgroupIdai.deepseek -DartifactIdclient结果三个月后发现 Gemini 接口升级、DeepSeek 新增流式字段、SiliconFlow 要求强制传x-api-key头——所有地方都要改。本项目采用Adapter Strategy 模式在ai-model-adapter模块下定义统一接口public interface AiModelClient { MonoChatResponse chat(ChatRequest request); FluxStreamChunk streamChat(ChatRequest request); ModelInfo getModelInfo(); }每个具体实现如GeminiClientImpl、DeepSeekClientImpl只负责三件事① 把ChatRequest映射为该模型要求的 HTTP Body含 system prompt 注入逻辑② 处理认证头API Key、Bearer Token、自定义 Header③ 将原始响应JSON / SSE解析为统一的ChatResponse或StreamChunk含content,finish_reason,usage字段。提示ChatRequest中model字段是字符串如gemini-1.5-flash而非枚举——避免每次新增模型都要改 Java 枚举类运行时通过 Spring 的ConditionalOnProperty动态加载 Bean。2.2 Vue 3 端的状态设计Pinia store 如何承载“会话即状态”前端没用 Vuex因为 Vue 3 的 Composition API Pinia 更契合 AI 交互的异步流式场景。chatStore.ts定义了四个关键 stateState 字段类型说明是否响应式currentSessionIdstring | null当前会话唯一 IDUUID v4由后端生成并返回✅messagesMessage[][ { role: user, content: ... }, { role: assistant, content: ... } ]内容支持 Markdown 渲染✅isStreamingboolean控制发送按钮禁用、显示 loading 动画✅activeModelstring当前选中的模型标识符如siliconflow/qwen2-72b与后端AiModelClient的getModelInfo().id()对应✅关键逻辑在sendMessage()action 中① 先POST /api/v1/chat/start创建会话拿到sessionId② 再POST /api/v1/chat/{sessionId}/message发送消息Body 中model字段取自activeModel③ 响应成功后messages.push({ role: user, content })然后立即发起EventSource连接/api/v1/chat/{sessionId}/stream④ 流式数据到达时按data: {chunk:...,index:0}格式解析追加到messages[messages.length-1].content。注意EventSource默认缓存必须在 URL 后加时间戳参数?t${Date.now()}防止 Chrome 强制复用连接Vue 3 的ref在onMounted中初始化EventSource并在onUnmounted中显式eventSource.close()否则内存泄漏。2.3 为什么不用 WebSocketSSE 在 AI 场景下的不可替代性有人问“WebSocket 不是更实时吗”——这是典型的技术直觉陷阱。AI 流式响应本质是单向、无序、低频、高延迟容忍的数据流单向客户端只发一次请求服务端持续推 chunk无需双向心跳无序index字段已保证顺序SSE 天然按序送达低频每秒 1~5 个 chunk远低于 WebSocket 的连接维持开销高延迟容忍用户对“第一个 token 延迟 800ms”比“连接断开重连耗时 200ms”更敏感。Spring Boot 中SseEmitter的实现比WebSocketSession简洁得多GetMapping(/chat/{sessionId}/stream) public SseEmitter streamChat(PathVariable String sessionId) { SseEmitter emitter new SseEmitter(30_000L); // 30秒超时 chatService.streamResponse(sessionId, chunk - { try { emitter.send(SseEmitter.event() .name(chunk) .data(JsonUtils.toJson(chunk))); } catch (IOException e) { emitter.complete(); } }); return emitter; }SseEmitter自动处理连接断开回调chatService内部用ConcurrentHashMapString, QueueStreamChunk缓存未发送完的 chunk断线重连时从断点续推——这比手写 WebSocket 心跳、重连、消息队列简单可靠得多。3. 模型切换不是改个下拉框后端动态加载、前端路由守卫与配置热更新机制3.1 Spring Boot 的ConfigurationProperties如何支撑模型热配置模型列表不写死在代码里而是通过application.yml分环境管理ai: models: - id: gemini-1.5-flash name: Gemini 1.5 Flash provider: google api-key: ${GEMINI_API_KEY:} base-url: https://generativelanguage.googleapis.com/v1beta enabled: true - id: deepseek-chat name: DeepSeek Chat provider: deepseek api-key: ${DEEPSEEK_API_KEY:} base-url: https://api.deepseek.com/v1 enabled: false - id: qwen2-72b name: Qwen2-72B (SiliconFlow) provider: siliconflow api-key: ${SILICONFLOW_API_KEY:} base-url: https://api.siliconflow.cn/v1 enabled: trueAiModelProperties.java使用ConfigurationProperties(ai.models)绑定配合Validated校验必填字段。关键点在于①enabled: false的模型不会被注入 Spring 容器AiModelClientBean 创建时跳过②api-key用${ENV_VAR:default}语法本地开发用application-local.yml配 mock key生产环境由 K8s Secret 注入③ 修改 YAML 后无需重启通过 Actuator 的/actuator/refresh端点触发RefreshScopeBean 重建需引入spring-boot-starter-actuator。血泪经验RefreshScope不能加在RestController上必须加在AiModelClient实现类上——否则 Controller 里的Autowired AiModelClient仍引用旧实例。3.2 Vue 3 路由守卫如何拦截非法模型切换前端下拉框选项来自GET /api/v1/models/available但用户可能手动修改 URL/chat?modelxxx。router.beforeEach守卫强制校验router.beforeEach(async (to, from, next) { const modelId to.query.model as string; if (modelId !chatStore.availableModels.some(m m.id modelId)) { // 模型不存在跳转到默认模型页 next({ path: /chat, query: { model: chatStore.defaultModelId } }); return; } // 检查模型是否启用后端返回的 enabled 字段 const model chatStore.availableModels.find(m m.id modelId); if (model !model.enabled) { ElMessage.warning(模型 ${model.name} 当前不可用请选择其他模型); next({ path: /chat, query: { model: chatStore.defaultModelId } }); return; } next(); });availableModels由chatStore.loadAvailableModels()在onMounted时调用数据结构与后端 YAML 完全一致包含id,name,provider,enabled字段。这样既防 URL 伪造又同步后端启停状态。3.3 模型降级策略当主模型超时如何 500ms 内切到备用模型AI 接口不稳定是常态。本项目在ChatService.java中实现三级降级①一级超时熔断——WebClient配置timeout(15, SECONDS)超时后抛TimeoutException②二级模型降级—— 捕获异常后从ai.models列表中按priority字段YAML 中新增找下一个enabled: true的模型③三级兜底响应—— 所有模型均失败时返回预设的{content:系统繁忙请稍后再试,finish_reason:error}。降级逻辑代码public MonoChatResponse fallbackToNextModel(ChatRequest originalRequest, Throwable cause) { ListAiModelConfig candidates aiModelProperties.getModels().stream() .filter(AiModelConfig::isEnabled) .sorted(Comparator.comparing(AiModelConfig::getPriority)) .collect(Collectors.toList()); String currentModel originalRequest.getModel(); int currentIndex -1; for (int i 0; i candidates.size(); i) { if (candidates.get(i).getId().equals(currentModel)) { currentIndex i; break; } } if (currentIndex 0 currentIndex 1 candidates.size()) { AiModelConfig nextModel candidates.get(currentIndex 1); log.warn(Model {} failed: {}, fallback to {}, currentModel, cause.getMessage(), nextModel.getId()); return callModel(nextModel.getId(), originalRequest); // 递归调用 } else { return Mono.just(ChatResponse.error(All models unavailable)); } }注意callModel()方法内部用Mono.onErrorResume()捕获异常并调用fallbackToNextModel()形成链式降级priority字段值越小优先级越高0主模型1备用2兜底避免硬编码索引。4. 避坑那些让你在答辩现场冷汗直流的 4 个真实踩坑记录4.1 现象Vue 3 中v-html渲染 Markdown 时数学公式$$Emc^2$$显示为纯文本原因marked库默认不启用 math 转换且v-html不会触发 Vue 的响应式更新公式渲染后无法随messages数组变化而重绘。解决① 安装marked-math插件npm install marked-markdown-it mathjax② 在markdownRenderer.ts中配置import { marked } from marked; import { markedMath } from marked-math; import * as mj from mathjax-full/js/mathjax.js; marked.use(markedMath({ renderer: mj, options: { loader: { load: [input/tex, output/chtml] } } }));③ 在MessageItem.vue中用computed包裹渲染结果const renderedContent computed(() { return marked.parse(message.value.content || ); });再绑定v-htmlrenderedContent确保每次message.content变更都触发重新解析。4.2 现象Spring Boot 启动时报错Caused by: java.lang.NoClassDefFoundError: jakarta/servlet/Filter原因项目使用 Spring Boot 3.x基于 Jakarta EE 9但误引入了javax.servlet-api依赖Jakarta EE 8 及以前导致类路径冲突。解决① 检查pom.xml删除所有javax.*开头的依赖如groupIdjavax.servlet/groupId② 确保spring-boot-starter-web版本 ≥3.0.0它会自动传递jakarta.servlet-api③ 若使用logback-spring.xml将appender nameSTDOUT classch.qos.logback.core.ConsoleAppender中的class改为ch.qos.logback.core.ConsoleAppender无需改包名Logback 1.4 已兼容 Jakarta。4.3 现象调用 SiliconFlow 模型时返回401 Unauthorized但 API Key 确认无误原因SiliconFlow 要求Authorization头格式为Bearer key而项目中SiliconFlowClientImpl错误地写成了ApiKey key。解决① 查阅 SiliconFlow 官方文档确认认证方式为 Bearer Token② 修改SiliconFlowClientImpl.java的buildHeaders()方法private HttpHeaders buildHeaders() { HttpHeaders headers new HttpHeaders(); headers.set(Authorization, Bearer apiKey); // 关键Bearer 空格 headers.set(Content-Type, application/json); return headers; }③ 在application.yml中api-key字段值不要带Bearer前缀只填裸 Key。4.4 现象SseEmitter在 Nginx 反向代理后流式响应卡顿每 30 秒才刷一次原因Nginx 默认开启proxy_buffering on会缓存响应直到缓冲区满或超时同时proxy_cache off未显式关闭导致代理层缓存 SSE 流。解决在 Nginx 配置中添加location /api/v1/chat/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache off; # 关键关闭缓存 proxy_buffering off; # 关键关闭缓冲 proxy_read_timeout 60; # 延长读超时匹配 SseEmitter 的 30s proxy_send_timeout 60; }提示proxy_buffering off会让 Nginx 每收到一个 chunk 就立即转发给客户端彻底解决卡顿。5. 生产就绪的关键结构化日志、Token 统计与会话快照导出功能5.1 Logback 配置让每一次 AI 调用都可追溯、可审计日志不是只打INFO而是用 MDCMapped Diagnostic Context注入关键维度!-- logback-spring.xml -- appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level [%X{sessionId},%X{model},%X{requestId}] %logger{36} - %msg%n/pattern /encoder /appender在ChatController中每次请求前注入 MDCPostMapping(/chat/{sessionId}/message) public MonoResponseEntityChatResponse sendMessage( PathVariable String sessionId, RequestBody ChatRequest request, ServerWebExchange exchange) { // 生成唯一 requestId String requestId IdUtil.fastSimpleUUID(); // 注入 MDC MDC.put(sessionId, sessionId); MDC.put(model, request.getModel()); MDC.put(requestId, requestId); return chatService.processMessage(sessionId, request) .doOnSuccess(response - { log.info(AI response success: usage{}, response.getUsage()); }) .doOnError(error - { log.error(AI request failed, error); }) .doFinally(signalType - MDC.clear()) // 清理 MDC .map(ResponseEntity::ok); }这样每条日志都自带sessionId,model,requestId三元组ELK 中可直接聚合分析“哪个模型平均延迟最高” →GROUP BY modelAVG(duration)“哪些会话频繁失败” →WHERE status ERROR GROUP BY sessionId“单次请求消耗多少 token” → 正则提取usage{prompt_tokens:123,completion_tokens:45}5.2 Token 统计不只是返回usage字段而是精确到每个 message大模型的usage字段只返回总 token 数但实际调试时需要知道System prompt 占了多少 tokenUser message 的中文分词是否异常Assistant response 中重复内容是否被计入项目在ChatService中集成tiktoken的 Java 实现com.knuddels:jtokkitpublic class TokenCounter { private final EncodingRegistry registry Encodings.newDefaultEncodingRegistry(); public int countTokens(String text, String modelName) { try { Encoding encoding registry.getEncodingForModel(modelName); return encoding.encode(text).size(); } catch (Exception e) { // 降级为字符数估算误差 15% return (int) Math.ceil(text.length() * 1.3); } } }在processMessage()中对ChatRequest的messages数组逐条统计并记录到日志log.info(Token breakdown - system:{} user:{} assistant:{} total:{}, counter.countTokens(request.getSystemPrompt(), request.getModel()), counter.countTokens(request.getUserMessage(), request.getModel()), counter.countTokens(response.getContent(), request.getModel()), response.getUsage().getTotalTokens());这样在日志中就能看到Token breakdown - system:24 user:87 assistant:156 total:267精准定位 token 消耗大户。5.3 会话快照导出一键生成 Markdown JSON 双格式存档答辩或复盘时需要把某次会话完整保存。GET /api/v1/chat/{sessionId}/export返回{ sessionId: a1b2c3d4, createdAt: 2024-06-15T10:20:30Z, model: gemini-1.5-flash, messages: [ { role: user, content: 用 Python 写一个快速排序 }, { role: assistant, content: python\ndef quicksort(arr):\n... } ], metadata: { totalTokens: 267, responseTimeMs: 1240, fallbackCount: 0 } }同时提供Accept: text/markdown头返回格式化 Markdown# 会话快照a1b2c3d4 **模型**gemini-1.5-flash **时间**2024-06-15 10:20:30 **总 Token**267 | **耗时**1240ms --- ### 用户 用 Python 写一个快速排序 ### AI python def quicksort(arr): if len(arr) 1: return arr pivot arr[len(arr) // 2] ...前端 ExportButton.vue 封装下载逻辑 ts const exportSession async () { const url /api/v1/chat/${sessionId.value}/export; const response await fetch(url, { headers: { Accept: text/markdown } }); const markdown await response.text(); const blob new Blob([markdown], { type: text/markdown }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download session-${sessionId.value}.md; link.click(); };从那以后我每次做 AI 项目演示前都强制走一遍exportSession()导出当前会话粘贴到答辩 PPT 的“实测效果”页——不是截图是真实可执行的 Markdown评委点开就能看到原始 prompt 和模型输出。这比任何架构图都有说服力。希望帮到你。本文还有配套的精品资源点击获取