
简介这是一套基于Spring Boot与Vue 2Element UI构建的AI大模型集成问答助手源码面向需要快速接入多家大模型平台的后端开发或AI应用学习者。项目覆盖OpenAI/ChatGPT、讯飞星火、文心一言、Ollama等文本对话模型并实现流式响应同时集成Midjourney AI绘图能力可用于搭建多模型统一调用入口、对比各平台回复效果或作为智能客服、知识问答类系统的二次开发底座。压缩包共611个文件以580个Java源码文件为主辅以XML/yml/properties配置、SQL初始化脚本及Dockerfile约566KB结构上可按业务服务、绘图任务、会话记录、权限管理等模块查阅便于理解多模型适配与异步任务流转。已有1062人学习下载适合具备一定Java基础、希望掌握大模型平台鉴权、流式输出与多模型管理实践的读者。作者也提供账号与环境问题交流能帮助减少落地踩坑。1. Spring Boot Vue2 智能问答助手流式响应是及格线而不是加分项判断一个智能问答助手是不是生产级第一个问题不是模型选谁而是用户点了发送之后页面多久出现第一个字。一个正常人类阅读速度大约是每秒 4 到 6 个汉字如果模型生成 200 个字需要 8 秒非流式接口会让用户面对一个旋转的 loading 长达 8 秒流失率比想象中高得多。这就是为什么标题里特意把流式响应写出来——它不是可选的优化项而是这类产品的体验底线。这个项目的技术骨架是 Spring Boot 负责网关和业务逻辑Vue2 负责界面与交互通过 Server-Sent EventsSSE把大模型平台的文本流逐字推给浏览器实现打字机效果。它把集成多家 AI 大模型平台抽象成后端一个可替换的适配层前端不关心你接的是哪家 API只看统一的 SSE 流。适合正在做企业知识库问答、客服机器人、演示项目或者手里有一个需要快速接入大模型的 Spring Boot 存量系统的人。2. Spring Boot 集成大模型网关用 WebClient 把 SSE 流接进来大模型平台提供的 SDK 虽然开箱即用但一个智能问答助手通常要对接多个平台比如按场景切换模型、按成本路由请求这时候 SDK 反倒成了耦合源。常见做法是后端统一封装一个ChatClient接口内部用 Spring WebClient 以流式方式调用各家 HTTP API对外再以 SseEmitter 或 Flux 暴露给前端。2.1 为什么用 WebClient 而不是 RestTemplateRestTemplate 是同步阻塞模型调用大模型接口时线程要一直等到整个响应体返回平均 3 到 10 秒的生成时间意味着每个对话请求独占一个 Tomcat 线程压测时线程池很快被打满。WebClient 基于 Reactor 的异步非阻塞模型在等待网络响应的过程中释放线程同样的内存能让系统扛住高得多的并发。另一个关键点是各家大模型平台的流式接口都基于 SSE 格式返回WebClient 的retrieve().bodyToFlux(String.class)天然支持按行消费配合dataBuffer可以拿到原始字节流逐段解析。RestTemplate 也能做但需要手动处理 InputStream 的阻塞读取响应体一长就容易把线程钉死在 I/O 上。2.2 一个适配多家平台的流式网关核心代码public interface ChatClient { /** * 发起流式对话 * param messages 对话消息列表含历史 * param model 模型名例如 deepseek-chat / qwen-plus * param consumer 流式回调每收到一段文本就触发一次 */ void streamChat(ListChatMessage messages, String model, ConsumerString consumer); } Component public class OpenAiCompatibleClient implements ChatClient { private final WebClient webClient; public OpenAiCompatibleClient(Value(${ai.base-url}) String baseUrl, Value(${ai.api-key}) String apiKey) { this.webClient WebClient.builder() .baseUrl(baseUrl) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer apiKey) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } Override public void streamChat(ListChatMessage messages, String model, ConsumerString consumer) { MapString, Object requestBody Map.of( model, model, messages, messages, stream, true ); webClient.post() .uri(/chat/completions) .bodyValue(requestBody) .retrieve() .bodyToFlux(String.class) .doOnNext(rawLine - { if (!rawLine.startsWith(data:)) return; String data rawLine.substring(5).trim(); if ([DONE].equals(data)) return; // 用 JsonNode 解析 delta.content 字段 String delta parseDelta(data); if (delta ! null !delta.isEmpty()) { consumer.accept(delta); } }) .onErrorResume(e - { consumer.accept(【服务异常】 e.getMessage()); return Flux.empty(); }) .subscribe(); } }这段代码做了三件事Post 到标准的/chat/completions接口开启stream: true然后对返回的每一行做 SSE 解析。bodyToFlux(String.class)会把 HTTP 响应体按行切分成字符串流每一行是一条data: {...}或data: [DONE]。[DONE]是 OpenAI 兼容协议的结束标记各家平台在兼容模式下都会返回它。parseDelta内部用 Jackson 读取choices[0].delta.content这个字段在流式模式下是增量文本累加起来就是完整回答。这里的ConsumerString是同步回调实际项目中可以把它接到 SseEmitter 上实现实时推送。需要注意Flux是异步的回调触发时所在的线程不是请求线程任何涉及请求上下文的操作比如从 ThreadLocal 取用户信息都不能放在这里必须先取出再传入。2.3 对接多平台时的关键配置参数参数典型值说明base-urlhttps://api.deepseek.com/v1兼容 OpenAI 协议的平台路径基本一致api-key写在环境变量或配置中心不要硬编码SSE 请求头会被前端看到吗不会因为最终对外接口由你后端转发modeldeepseek-chat/gpt-4o-mini不同模型有不同 token 计价和速度按业务场景动态传max-tokens2048限制单次回答长度防止模型发散temperature0.7 知识问答 / 0.2 代码生成值越高越随机代码场景压低timeout30s 或更长大模型生成长文本超 60 秒并不罕见千万别用默认 5 秒多平台适配的重点不是每家 API 长什么样而是它们几乎都提供了 OpenAI 兼容模式。DeepSeek、通义千问、智谱、Moonshot 等平台都有/v1/chat/completions的兼容端点参数结构几乎一致。少数不兼容的比如某些平台的messages格式有额外字段你只需要再写一个ChatClient实现类在streamChat内部把它们转成自己的格式上层业务不用改。3. Vue2 消费流式响应fetch 读 SSE 与打字机效果的完整实现后端把流接进来了前端这一侧才是真正决定体验的地方。Vue2 项目里最常见的误区是有人把 EventSource 当作唯一选择但 EventSource 不支持自定义请求头而且断了会自动重连调试时非常痛苦。我一般用 fetch 的ReadableStream手动处理既能加 Token 鉴权又能主动控制中断时机。3.1 SSE 协议的最小格式SSE 本质上就是一段 HTTP 响应Content-Type 是text/event-stream格式约定是每个事件以data:开头事件之间用空行分隔流结束时发送data: [DONE]。一个典型的响应体长这样data: {choices:[{delta:{content:你好}}]} data: {choices:[{delta:{content:我是}}]} data: [DONE]每两行之间有一个空行fetch拿到的数据是一个大字符串需要按空行或换行符拆解。很多平台实际返回时每行带\n\n前端要做的是把data:前缀去掉剩下的是 JSON 字符串再取choices[0].delta.content。3.2 为什么不用 EventSource 而是 fetchEventSource 有两个硬伤。第一它只能用 GET 请求Token 放在 URL 的 query 参数上URL 会出现在网关日志、Nginx access log 里对生产环境来说这是泄露风险。第二EventSource 默认启用自动重连服务端异常断开后会反复请求同一个接口如果后端没做幂等会重复触发大模型计费。fetch ReadableStream 的优势在于POST 请求可以带 JSON bodyToken 通过 Authorization 请求头传中断通过调用AbortController.abort()立刻生效而且拿到原始流之后你可以自己决定怎么解析、怎么更新 Vue 的 data。3.3 Vue2 组件里的流式输出与中断控制// ChatPanel.vue async handleSend() { this.loading true; this.answer ; // 清空上一次回答 const controller new AbortController(); this.abortController controller; // 存起来给取消按钮用 try { const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: this.history.slice(-10), // 最多带最近 10 条历史 }), signal: controller.signal, }); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按空行切出完整的 SSE 事件 const events buffer.split(\n\n); buffer events.pop(); // 最后一段可能不完整留到下次 for (const event of events) { const line event.trim(); if (!line.startsWith(data:)) continue; const data line.substring(5).trim(); if (data [DONE]) return; try { const json JSON.parse(data); const delta json.choices?.[0]?.delta?.content || ; this.answer delta; // Vue2 的响应式系统会触发视图更新 this.scrollToBottom(); } catch (e) { console.warn(解析失败, e); } } } } catch (err) { if (err.name AbortError) { this.answer \n[已停止生成]; } else { this.answer \n[网络异常]; } } finally { this.loading false; } },关键细节有两个。第一个是TextDecoder必须在循环外用{ stream: true }方式实例化因为大模型返回的 UTF-8 字节流可能把一个中文字符拆成两段跨 chunk 传输stream: true模式会在内部缓存未完成的字节等下一个 chunk 拼齐再输出避免乱码。第二个是buffer的切分逻辑\n\n是一个 SSE 事件的终止标记切完最后一个元素要放回 buffer因为网络包边界不一定是事件边界。打字机效果不需要额外写定时器——this.answer delta每执行一次Vue2 的响应式系统触发一次 DOM 更新浏览器自然会逐字渲染。如果发现一次涌入太多内容可以在this.answer delta前面加一个this.$nextTick批量处理但大多数场景逐字追加就够了。另外记得在组件销毁时调用this.abortController.abort()否则组件切走之后请求还在跑更新一个不存在的 Vue 实例会报警告。4. 多轮会话与并发控制把智能问答助手调成可用状态流式链路通了之后最容易被低估的是上下文管理。大模型的 API 是无状态的你每次请求传什么 messages 它就基于什么回答。如果前端每次只传当前这一句模型就没有上文问答助手会变成人工智障。4.1 上下文管理的两种存储方式第一种是前端维护并回传项目叫history组装成 messages 发给后端。这种做法的优点是实现简单后端不用存任何会话状态天然适合水平扩展缺点是历史消息体积膨胀快而且前端可以被绕过安全性要求高的场景不合适。第二种是后端按 sessionId 存 Redis每次请求由后端去取历史拼接前端只传 sessionId 和当前问题。这是生产项目的常见选择推荐直接上 Redis。RestController public class ChatController { private final StringRedisTemplate redisTemplate; private final ChatClient chatClient; PostMapping(value /api/chat/stream, produces text/event-stream;charsetutf-8) public SseEmitter stream(RequestParam String sessionId, RequestBody UserMessage msg) { SseEmitter emitter new SseEmitter(60_000L); String historyKey chat:history: sessionId; // 从 Redis 取最近 20 条历史 ListChatMessage messages loadMessages(historyKey); messages.add(new ChatMessage(user, msg.getContent())); chatClient.streamChat(messages, msg.getModel(), delta - { try { emitter.send(SseEmitter.event().data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); return emitter; } }SseEmitter 的构造参数是超时时间60 秒比较稳妥。前端每次拿到完整回答后会把 user 消息和 assistant 消息一起发给后端保存到 Redis下次请求时重新组装。注意生产环境的 token 窗口是有限的一般做法是保留最近 N 条轮次超出部分丢弃。4.2 并发控制与限流需要盯住的参数表限流维度手段说明用户级并发每个用户同时只允许 1 个流式请求防止用户狂点按钮触发多条计费后端线程池自定义 WebClient 的maxConnections默认连接池很容易在并发高时打满网关限流Nginxlimit_req按 IP 每秒 5 次写死到配置里模型 QPS每家平台的 QPS 配额不一样超额会返回 429需要重试这里是用户最容易踩坑的地方在 Controller 里直接用new SseEmitter()默认是永不超时但 Nginx 的proxy_read_timeout默认 60 秒就会断掉空闲连接。而大模型生成过程中每几秒就会有一个data:数据包这不是空闲连接所以通常不会断。真正需要关注的是 Nginx 的proxy_buffering默认值是 on会导致 Nginx 攒着数据一次性发给前端SSE 变成一次性的渣男响应。必须显式设置location /api/chat/ { proxy_pass http://backend-server; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_http_version 1.1; }proxy_buffering off是那一行哪怕后端处理得再快前端也收不到增量数据的原因。如果你用了 Nginx 做反向代理这个配置漏掉前端表现就是转圈 5 秒然后一次性出现全文流式效果瞬间失效。4.3 多轮上下文还牵扯内容安全各家大模型平台都有内置的内容审核但企业场景下通常还要做一层自己的敏感词过滤。一个轻量做法是在后端维护一套 DFA 词表对用户的输入先做拦截对模型的输出做脱敏。流式输出天然适合做这个因为每个 delta 只有几个字你可以把增量拼接起来到句号或换行时做一次匹配命中后再 flush 给前端。注意不要把用户输入直接透传给模型记得在上面代码的messages.add之前做一次filterContent()。5. 流式链路验收与排错从 curl 到前端逐段定位最后这部分直接给一套可复制的验证方法你按顺序做能定位 95% 的流式问题。5.1 用 curl 验证后端 SSE 是否正常curl -N -X POST http://localhost:8080/api/chat/stream \ -H Content-Type: application/json \ -d {content:用一句话介绍你自己} \ --max-time 30关键在-N参数关闭 curl 的缓冲让输出边收到边打印。如果这里能看到逐字滚出内容说明后端到浏览器这一步链路是通的问题在前端解析。如果半天没输出最后一次性出来说明你的代理层开了缓冲回看 4.2 的proxy_buffering。如果直接报超时用curl -v看响应头里有没有Content-Type: text/event-stream没有就是 Controller 的produces写错了。5.2 前端三个典型故障的快速判定乱码检查按钮浏览器把响应当成application/json用 UTF-8 解了多半是后端没有显式声明charsetutf-8导致 Nginx 走了默认的 ISO-8859-1。修复方式是让后端响应头带上Content-Type: text/event-stream;charsetutf-8比前端任何TextDecoder(utf-8)都前置。一次涌入而非逐字显示先在浏览器 DevTools 的 Network 面板看 Response如果浏览器一次性显示全部响应体是浏览器层面无法解决的问题出在后端没有真正开启流式检查stream: true是否传进了请求体或者后端在SseEmitter.send之前先做了join拼接。点停止生成没用看 Network 里请求是否真的 cancel 了。AbortController.abort()对 fetch 生效但如果你的请求经过了 Service Worker 或被某些 HTTP 库包装过比如 axios中断会被吞掉。axios 在浏览器端不支持流式响应必须原生 fetch这一点在 Vue2 项目里尤其要提前和团队对齐避免有人顺手把 fetch 换成 axios 导致整个流式白屏。5.3 后端的超时与中断参数速查表环节参数推荐值症状Tomcat 连接server.tomcat.connection-timeout20000点击发送后 20 秒才报错SseEmitter 超时new SseEmitter(60000L)60000回答超过 1 分钟被截断Nginx 读后端proxy_read_timeout300s超过 1 分钟断流WebClient 全局reactor.netty.http.client.connectTimeout30000连接建立阶段超时前端 fetchAbortSignal.timeout(120000)120000前端主动放弃 2 分钟无响应的请求在对接多家大模型平台时还要留意不同平台的超时策略差异。有的平台首字延迟高达 5 秒超过了某些 HTTP 客户端的响应头超时导致流刚建立就被切断。常见兜底做法是维护一个独立的firstTokenTimeout或者把建连和读流的超时拆开——建连 10 秒读流 120 秒。这样首字慢不会杀掉整个流长文本生成也不会被误砍。最后把这三套验证步骤固定成脚本每次换新的平台接入时跑一遍能省下大量来回对日志的时间。本文还有配套的精品资源点击获取