资讯动态

Spring AI流式对话接入Vue3:SSE解析与前端实践

发布时间:2026/9/9 4:57:39 来源:尧图企业网站定制
把 Spring AI 的流式对话接到 Vue3 前端是我个人项目中印象最深的一段。后端用 SSE 把大模型的 token 一个接一个地吐出来前端要做的不是等数据全部到达再一次渲染而是一边接收一边把文字推进 DOM用户才能看到类似“打字机”的效果。听起来不复杂真正动手后你会发现里面全是细节浏览器对流式事件的解析、中文乱码、连接中断、重复点击、组件之间怎么传递对话状态、Markdown 代码块怎么优雅展示。这篇文章是 Spring-AI 系列第 04 章的实战记录核心就是 Vue3 端完整实现一个流式对话页面技术选型、SSE 解析、消息管理、组件拆分、Markdown 渲染以及我在实际对接里踩过的坑和排查思路。不管你是刚接触 Spring AI还是已经写好了后端接口正准备补前端这篇都可以当作一份可以照着改的参考。1. 项目概述与整体设计思路1.1 流式对话的技术选型为什么是 SSE 而不是 WebSocket做流式对话可选方案无非三种轮询、SSE、WebSocket。轮询是效率最低的一种前端定时去拉最新结果大模型的生成过程是流式的轮询要么间隔太短造成大量无效请求要么间隔太长让用户觉得界面卡顿基本可以排除。WebSocket 看起来最灵活双向通信、低延迟很多实时应用都会选它。但对话场景里其实有一个关键特征核心方向是人问 AI 答AI 向用户单向推送文本流用户发送消息是低频事件。用 WebSocket 来解决单向流式传输等于把一个不需要双向通信的场景强行架到双工协议上连接管理、心跳、重连、跨域、鉴权都要额外处理复杂度被明显放大。SSEServer-Sent Events是专门为服务端向客户端单向推送设计的协议本质就是一个普通 HTTP 请求服务端返回Content-Type: text/event-stream的响应连接保持打开数据按约定格式持续下发。它的优势非常实在不引入新协议和普通接口一样部署、一样走鉴权、一样可以被网关代理浏览器原生支持前端代码量也很小。Spring AI 的流式接口设计也正好与 SSE 对齐后端返回Flux后本质就是逐行下发数据块。所以这个项目的流式传输方案选型就是 SSE不是 WebSocket。1.2 前端技术栈方案Vue3 Vite TypeScript fetch前端技术栈我选了 Vue3 Vite TypeScript。Vite 负责开发服务器和构建它内置了非常顺手的/api代理配置开发阶段可以优雅地规避跨域问题TypeScript 强类型约束在这个场景里很有价值因为对话消息、流式数据块都有固定结构类型写对了组件传参就不容易散架。有一点值得单独说就是请求模块选型。很多同学第一反应是 axios因为它封装好、拦截器好用。但流式对话场景里 axios 处理 SSE 并不顺手axios 默认在收到完整响应后才让 Promise 进入 then虽然它底层 XHR 有onprogress事件可以读到增量responseText但需要额外适配而且流式数据的结构化解析还是得靠手工等于绕了一大圈又回到原点。fetch 自带response.body的流式读取能力配合ReadableStream.getReader()每一块数据到达都可以立刻处理代码直观依赖更少。所以本项目核心请求全部走 fetch非流式接口上保留 axios 作为补充两者并行互不干扰。1.3 前端目录结构与组件职责划分流式对话页面功能集中但组件职责必须有边界不然写到最后就是 props 满天飞。我按功能拆成了五块views/ChatPage.vue对话页主容器持有消息列表并调用请求逻辑components/ChatMessageList.vue渲染消息列表处理滚动逻辑components/ChatMessageItem.vue单条消息渲染区分用户和助手消息结构components/ChatInput.vue底部输入区负责发送与停止按钮的状态管理components/MarkdownRenderer.vue助手消息的 Markdown 渲染组件内部接 highlight.js 着色职责划分原则是每一层只做一件事。ChatPage 管状态和发送逻辑MessageList 只管渲染和滚动到底部MessageItem 只负责结构和部分交互Input 负责文本框、快捷键和按钮禁用MarkdownRenderer 被 MessageItem 引用专注做文本到 HTML 的转换。1.4 为什么把消息状态往上提我让 ChatPage 持有messages数组而不是把消息增删逻辑散落在子组件里。从实际流程看发送一条用户消息后要立刻追加用户气泡同时追加一条空的助手占位气泡后续每读到一段增量都要定位到这条助手气泡并追加文本。这个流程天然跨组件输入框触发 send 后消息列表要马上更新并滚动消息项要感知 loading 和停止状态的切换。如果消息状态分散在多个组件里状态同步会非常痛苦。在 Vue3 里最省心的做法是 ChatPage 中维护一个useChat组合式函数对外暴露messages、sendMessage、stop、loading再通过 props 把messages传给 MessageList把loading传给 Input 控制按钮禁用通过 emits 把 send、stop 事件上抛到 ChatPage。这套单向数据流清晰、可调试也符合 Vue3 Composition API 的推荐用法。2. 核心细节解析与关键依赖2.1 SSE 数据格式解析data 字段与结束标记SSE 协议的原始数据长什么样搞清楚这个前端代码才不会写错。一条标准的 SSE 消息可以是多行结构每个字段形如field: value最常见的字段是data。多个data字段会被浏览器按换行合并成一条消息但在我们对接的 Spring AI 场景里一般后端每行输出一个data:每次携带一个 JSON 字符串。Spring AI 的流式响应数据格式一般类似这样data:{messageId:f1f7-9a1c, content:你,finishReason:null} data:{messageId:f1f7-9a1c, content:好,finishReason:null} data:{messageId:f1f7-9a1c, content:很高兴,finishReason:null} data:{messageId:f1f7-9a1c, content:认识你,finishReason:null} data:{messageId:f1f7-9a1c, content:,finishReason:STOP}所以前端解析要做的事情可以拆成三步按行切分、找data:前缀、JSON.parse 解析。再从 payload 里取content字段累积到当前助手消息里。如果看到finishReason为 STOP或者后端发了约定的[DONE]标记就该结束流程了。这里必须提醒一个新手最容易踩的坑不要把流式响应当成一次性 JSON 处理。流式接口返回的是text/event-stream不是application/json如果用res.json()去解析一定会卡住或者直接报错。正确姿势是拿到response.body用getReader()逐块读取。2.2 ReadableStream 读取与中文乱码问题fetch 拿到的response.body是ReadableStream实例通过getReader()拿到 reader然后反复调用reader.read()。每次read()返回一段Uint8Array字节数组字节转字符串必须用TextDecoder。代码写起来不难坑在细节。如果直接new TextDecoder().decode(value)而不传{ stream: true }遇到一个 UTF-8 字符正好被拆在两个 chunk 里时解码就会出错表现为末尾时不时出现乱码字符。正确写法是decode(value, { stream: true })这个参数告诉解码器“这次没结束后面还有数据先把无法完整解码的部分缓存起来”。全部读完后再调一次decoder.decode()把剩余数据处理掉确保尾部没有遗漏。还有一个细节需要自己维护一个 buffer 字符串。因为 Reader chunk 的切分位置和 SSE 数据行的边界不一定重合很可能一个data:的 JSON 被拆在两个 chunk 里。正确做法是每次 decode 出来的字符串先拼进 buffer然后按\n拆行最后一行先不处理等下一次数据到达再拼接解析。2.3 关键依赖与 Vite 基础配置项目里我把依赖控制到很小。运行时依赖只有vue、marked、highlight.js。marked 负责 Markdown 转 HTMLhighlight.js 负责代码块着色。开发依赖就是vite、vitejs/plugin-vue、typescript、vue-tsc。创建项目用 Vite 官方模板即可一条命令就能起来。需要注意 Node.js 版本最好在 18 以上Windows 的 PowerShell 对--template参数偶发解析问题所以建议直接把参数用引号包起来再执行。vite.config.ts里重点配置两件事路径别名和开发代理。import { defineConfig } from vite import vue from vitejs/plugin-vue import { fileURLToPath, URL } from node:url export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这个配置解决两类问题一是 import 语句可以用/components这种路径表达不用写一长串../../二是开发阶段把/api开头的请求转发到后端服务前端代码里直接用相对路径/api/chat/stream发请求浏览器不再有跨域问题。线上环境怎么办一般由 Nginx 统一代理/api到 Spring Boot 应用前端代码不需要变化。所以我的 fetch 请求都习惯以/api开头而不是写死http://localhost:8080。2.4 TypeScript 消息模型定义流式对话的数据结构并不复杂但建议一开始就用 TypeScript 定清楚。// src/types/chat.ts export interface ChatMessage { id: string role: user | assistant content: string createTime: number status?: pending | streaming | done | error } export interface StreamChunk { messageId?: string content?: string finishReason?: string | null }status字段是我后来加的它对外层渲染很有用可以区分“正在生成的助手消息”和“已经结束的回复”这样复制按钮、停止按钮的可用状态就很好判断了。3. 实操过程与核心环节实现3.1 后端 SSE 接口的对接约定前端实现前需要先确认后端接口长什么样。Spring AI 的流式接口一般按text/event-stream返回核心是Flux类型的流。一个典型控制器代码如下这是常见的实践方式PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString chatStream(RequestBody ChatRequest request) { return chatClient.prompt() .messages(request.messages()) .stream() .content() .map(content - ServerSentEvent.builder(content).build()); }这个接口的产出就是上面提到的逐行data:格式。前端对接时请求方法为 POST请求体是整个对话历史 messagesContent-Type 是application/json响应 Content-Type 是text/event-stream。这个响应头记下来后面排查 CORS 和 Nginx 缓冲问题时非常关键。3.2 流式请求核心模块网络层完整代码网络层是整个项目的心脏我封装在src/utils/sse.ts里。它的功能包括构造请求、读取流、按 SSE 协议解析、逐块回调、支持中止并在请求完成和出错时回调收尾方法。// src/utils/sse.ts export interface SSECallback { onData: (text: string) void onDone?: () void onError?: (error: Error) void } export async function streamChat( messages: { role: string; content: string }[], callbacks: SSECallback, signal?: AbortSignal ): Promisevoid { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal }) if (!response.ok) { throw new Error(请求失败HTTP ${response.status}) } const reader response.body?.getReader() if (!reader) { throw new Error(当前浏览器不支持流式响应读取) } const decoder new TextDecoder(utf-8) let buffer try { while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const lines buffer.split(\n) buffer lines.pop() ?? for (const line of lines) { const trimmed line.trim() if (!trimmed.startsWith(data:)) continue const payload trimmed.slice(5).trim() if (!payload) continue if (payload [DONE]) { callbacks.onDone?.() return } try { const chunk JSON.parse(payload) as StreamChunk if (chunk.content) { callbacks.onData(chunk.content) } if (chunk.finishReason) { callbacks.onDone?.() return } } catch { // 单条数据解析失败不影响整体流程 } } } callbacks.onDone?.() } finally { reader.releaseLock() } }有几个点需要重点解释。第一URL 用的是相对路径/api/chat/stream开发环境交给 Vite 代理生产环境交给 Nginx 代理。只要前后端约定好路径前缀这套代码不用改。第二signal参数直接传给 fetch用于 AbortController 取消。注意取消请求会触发 fetch 抛AbortError调用方要区分是用户主动停止还是真实网络错误。第三解析到finishReason为真时主动结束。Spring AI 常见的finishReason值有 STOP、LENGTH、TOOL_CALLS。对纯对话来说遇到 STOP 就结束遇到 TOOL_CALLS 说明模型还要调工具复杂场景需要后端把工具结果一并回传前端可以先按普通文本处理。第四while循环只有在reader.read()返回done或中途抛错时才退出。这个条件由服务端关闭连接或客户端AbortController.abort()控制。如果服务端一直没有结束标记也没有关闭连接页面会一直挂着。所以后端一定要在回复结束后 close 流这个点要在联调最开始就确认清楚。3.3 消息状态管理useChat 组合式函数接下来是消息状态层。把请求逻辑和消息列表逻辑统一放在 useChat 组合式函数里。// src/composables/useChat.ts import { ref } from vue import { streamChat } from /utils/sse import type { ChatMessage } from /types/chat export function useChat() { const messages refChatMessage[]([]) const loading ref(false) let controller: AbortController | null null function appendUserMessage(content: string) { messages.value.push({ id: generateId(), role: user, content, createTime: Date.now(), status: done }) } function appendAssistantPlaceholder() { const msg: ChatMessage { id: generateId(), role: assistant, content: , createTime: Date.now(), status: streaming } messages.value.push(msg) return msg } async function sendMessage(content: string) { const text content.trim() if (!text || loading.value) return appendUserMessage(text) const assistantMsg appendAssistantPlaceholder() const history messages.value .filter((m) m.id ! assistantMsg.id) .map((m) ({ role: m.role, content: m.content })) loading.value true controller new AbortController() try { await streamChat( history, { onData: (delta) { assistantMsg.content delta }, onDone: () { assistantMsg.status done } }, controller.signal ) } catch (error: any) { if (error.name AbortError) { assistantMsg.status done } else { assistantMsg.status error assistantMsg.content \n\n 请求出错了${error.message} } } finally { loading.value false controller null } } function stop() { controller?.abort() } function reset() { messages.value [] } return { messages, loading, sendMessage, stop, reset } } function generateId() { return msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)} }这段代码的核心设计意图是一次对话点击拆成三个动作追加用户消息、追加占位助手消息、发起网络流读取。助手占位消息的对象引用赋值给assistantMsg流式onData每来一个 delta就把内容追加到assistantMsg.content。因为messages.value里存的是同一个对象引用Vue3 的响应式系统会立刻感知到content变化并刷新 DOM这就是打字机效果的本质。这里没有特殊的动画库本质就是每次数据更新驱动视图重渲染。停止按钮的实现依赖 AbortController 的abort()。停止后网络请求会以AbortError结束代码里把assistantMsg.status置为done避免页面还显示“生成中”状态。3.4 页面组件的实现与事件流页面层用 ChatPage 串起来。整体模板不复杂核心逻辑都在组合式函数里template div classchat-page ChatMessageList :messagesmessages / ChatInput :loadingloading sendhandleSend stophandleStop / /div /template script setup langts import ChatMessageList from /components/ChatMessageList.vue import ChatInput from /components/ChatInput.vue import { useChat } from /composables/useChat const { messages, loading, sendMessage, stop } useChat() function handleSend(text: string) { sendMessage(text) } function handleStop() { stop() } /scriptChatMessageList 里用v-for渲染 messages并监听消息变化自动滚到底部import { nextTick, ref, watch } from vue import type { ChatMessage } from /types/chat const props defineProps{ messages: ChatMessage[] }() const listRef refHTMLElement() watch( () props.messages.map((m) m.content).join(), async () { await nextTick() const el listRef.value if (el) { el.scrollTop el.scrollHeight } } )这里有个优化点监听内容是messages.map((m) m.content).join()只关心 content 变化。如果直接把整个 messages 数组作为 watch 源每次 push 新消息也会触发逻辑上问题不大但通过字符串拼接能确保流式追加大段文本时watcher 触发频率与文本追加频率一致滚动会更顺滑。如果消息太长导致频繁重排建议再加一个简单防抖降低滚动触发频率。ChatInput 的实现关键点是 Enter 发送、Shift Enter 换行以及 loading 时把发送按钮切换成停止按钮template div classchat-input textarea v-modeldraft rows3 placeholder输入消息Enter 发送Shift Enter 换行 keydownhandleKeydown / button v-if!loading :disabled!draft.trim() clickhandleSend发送/button button v-else classdanger clickhandleStop停止/button /div /template script setup langts import { ref } from vue const emit defineEmits{ (e: send, text: string): void (e: stop): void }() const props defineProps{ loading: boolean }() const draft ref() function handleKeydown(e: KeyboardEvent) { if (e.key Enter !e.shiftKey) { e.preventDefault() handleSend() } } function handleSend() { const text draft.value.trim() if (!text || props.loading) return emit(send, text) draft.value } function handleStop() { emit(stop) } /script关于父子组件交互我的经验是尽量少用 provide/inject 这种隐式通信对话页就一层父子关系props 下传、emits 上抛足够清晰。如果后续页面复杂了需要多个页面共享对话状态再考虑 pinia 也不迟。3.5 助手消息的 Markdown 渲染大模型输出基本都是 Markdown直接展示纯文本会很难看。我用 marked highlight.js 做了两件事把 Markdown 转成 HTML给代码块加高亮。注意 marked 新版本已经移除了 renderer 的highlight配置项推荐用自定义 renderer 实现// src/utils/markdown.ts import { marked, type Tokens } from marked import hljs from highlight.js import highlight.js/styles/github-dark.css const renderer new marked.Renderer() renderer.code ({ text, lang }: Tokens.Code) { const language hljs.getLanguage(lang || ) ? lang as string : plaintext const highlighted hljs.highlight(text, { language }).value return pre classhljscode${highlighted}/code/pre } marked.setOptions({ renderer, breaks: true }) export function renderMarkdown(content: string): string { return marked.parse(content || ) as string }MarkdownRenderer.vue 内部通过 computed 把 content 转成 HTML再用v-html输出。需要提醒的是v-html有 XSS 风险。模型输出一般可控但如果你的应用允许用户上传统一格式内容或者接入了外部知识库建议额外用 DOMPurify 做白名单过滤成本很低安全收益却很明显。4. 常见问题与排查技巧实录4.1 高频问题速查表我整理了一份对接过程中容易翻车的问题速查表先看问题再对方案问题现象可能原因解决方案页面一直等待很久才统一出结果后端没有设置text/event-stream或 Nginx 代理未关闭缓冲检查接口的 produces 类型Nginx 增加proxy_buffering off中文偶尔乱码TextDecoder 没有传{ stream: true }decode(value, { stream: true })流到一半连接断开没有报错网络超时、代理空闲断开、后端异常抛错前端做错误提示或重连后端确认异常捕获请求报 403 或跨域后端 CORS 未放行相关请求头和响应头在 CORS 配置中允许事件流响应头开发环境直接用 Vite 代理重复点击发送消息顺序错乱没有阻止 loading 中的二次发送sendMessage开头判断if (loading) return停止按钮无效AbortController 的 signal 没有传给 fetch把controller.signal放入 fetch 配置Nginx 部署后首页正常流式数据不刷新Nginx 默认缓冲了 SSE 响应设置proxy_buffering off、proxy_cache off收到 JSON 解析报错数据流里混入了[DONE]或注释行显式判断[DONE]标记非 data 行直接跳过4.2 开发阶段的调试技巧SSE 请求挂在浏览器 Network 面板里可能一挂就是几十秒看起来像“失败”其实数据一直在流动。我常用的验证方式有三个。第一看到请求状态始终是 pending 是正常的。SSE 请求要等服务端断开连接才会变成完成状态所以不要因为 pending 就认为出问题先看 Response 区域Chrome 会展示 streaming 过程中收到的原始内容。第二用 curl 直接敲后端接口能看到一行行data:下来的数据这个方式对快速定位“到底有没有数据流”非常有效。后端有日志但前端没反应问题基本在解析层前端显示不出内容但接口有数据问题就在渲染层。第三不要忽略控制台。SSE 连接中断时浏览器经常会有net::ERR_INCOMPLETE_CHUNKED_ENCODING之类的提示这个错误描述虽然看着吓人但大多数时候只是连接被中断对照速查表逐个排查就可以了。4.3 流中断与重连机制大模型生成时间可能超过普通 HTTP 超时时间或者代理因为长时间无数据转发而主动断开连接。一个实用的思路是给 fetch 设置首个字节超时保护比如超过 60 秒还没有收到第一个字节就自动中止并提示用户。这样可以避免用户对着空白页面干等。如果首个 token 已经收到说明链路是通的后续就不应该再限制总时长。更完善的方案是断线自动重连但自动重连要小心重复发送消息。我的经验是不直接自动重发整个请求而是设计一个“续传上下文”机制流式过程意外中断后把已接收的文本和当前消息历史保存在界面上提供一个“继续生成”按钮让用户决定是否补充后端基于上下文继续。这个功能成本略高适合做产品级体验。个人项目里做好错误提示和手动重发通常就够用了。4.4 性能与体验优化经验聊了很多轮之后消息列表会越来越长如果每条消息都重新解析 Markdown 并高亮代码DOM 更新会明显变慢。我做两个优化。第一个是长对话折叠。超过一定条数后把前面的消息区域折叠起来用户想看再展开或者用虚拟滚动只渲染可视区域。这个优化对长对话效果立竿见影。第二个是局部渲染优化。当某条消息内容很长时每次流式追加都重新渲染整条消息的 HTML 并不划算。我的做法是已经完成的消息用 memo 缓存渲染结果只有正在流式输出的那一条实时更新。代码上就是在 MessageItem 里加一个cachedHtml字段内容变化时如果当前不是 streaming 状态就不重新解析。另外流式滚动还有个细节当用户正在往上翻看历史消息时被强制吸底会很难受。我加了一个滚动判断最近滚动位置距离底部超过 100px 就不再自动滚下去等用户手动回到接近底部的位置再恢复自动滚动。这个改动成本很低体验提升却很明显。4.5 Spring AI 对接中的特有坑最后提几个对接 Spring AI 时容易遇到的细节。Spring AI 的流式接口有时会在流结束后输出一个data: [DONE]的结束标记。如果解析代码没有判断[DONE]JSON.parse 就会直接报错但通常不影响主流程。我的建议是显式判断[DONE]把它当作正常结束。还有finishReason的语义。除 STOP 外还有 LENGTH 和 TOOL_CALLS。前端至少要认识 STOP其他值建议统一走onDone收尾但不要把 LENGTH 当成网络异常它只是命中长度限制用户其实拿到了部分结果直接展示再补一句提示即可。再一个就是 CORS。很多同学在开发环境没有跨域问题因为走了 Vite 代理但把前端部署到独立域名后SSE 请求跨域时可能报“该响应没有正确的 Access-Control-Allow-Origin”。SSE 本身也是普通 HTTP 响应后端 CORS 配置同样要管理并放行对应请求头。排查思路先看浏览器 Console确认为 CORS 拦截就改后端否则再查代理配置。5. 写在最后给同路人的几条经验整个 Spring AI 流式对话前端项目做下来我的体会是后端把 token 流出来只是第一步前端把这股流稳定、流畅、可中断地呈现在用户面前才是对话产品体验的关键。这一章反复强调的 fetch ReadableStream、TextDecoder、AbortController就是 Vue3 端的核心三角把这三个点吃透其他都是锦上添花。最后分享一个小技巧联调流式接口时不要边写边调先准备一个最小的测试页只放一个按钮和一个 pre 标签把原始流式数据逐行打印出来亲眼确认数据长什么样、什么时候结束再开始做组件和样式。我两次踩到解析问题都是靠这个最小测试页快速定位的。流式对话的坑大多不在框架而在对协议细节的理解先回到底层看真实数据流问题都会清晰很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价