阶段六:SSE AI 流式对话 从零构建带AI流式对话的Vue2全栈应用-CSDN博客本文是对这篇文章阶段六的讲解一、普通接口为什么不能产生打字机效果普通接口用户提问 ↓ 后端生成完整回答 ↓ 一次性返回整个回答 ↓ 页面一次性显示SSE用户提问 ↓ 后端保持连接 ↓ 发送第一个字 ↓ 发送第二个字 ↓ 发送第三个字 ↓ 直到结束这就是类似 ChatGPT 的流式输出。二、SSE 是什么SSE 全称Server-Sent Events意思是服务器持续向浏览器发送事件。它主要是单向通信服务器 → 浏览器浏览器使用new EventSource(url)连接服务器。三、SSE 后端响应头res.setHeader( Content-Type, text/event-stream; charsetutf-8 )告诉浏览器这是 SSE 数据而不是普通 JSON。res.setHeader( Cache-Control, no-cache, no-transform )禁止缓存和修改流式内容。res.setHeader( Connection, keep-alive )告诉网络连接不要立刻关闭。res.setHeader( X-Accel-Buffering, no )避免部分代理服务器把数据缓存起来等全部完成后才一次性返回。res.flushHeaders()立即发送响应头。不需要等待第一段正文。四、SSE 消息格式后端res.write( data: ${JSON.stringify(data)}\n\n )SSE 格式要求data: 数据内容每条消息后必须有两个换行。代码中的\n\n就是两个换行。发送sendEvent({ type: content, text: 你 })浏览器收到data: {type:content,text:你}五、为什么要定义不同的事件类型我们自己设计了三种消息{ type: start }表示开始生成。{ type: content, text: 你 }表示新生成了一段文字。{ type: end }表示生成结束。还可以有{ type: error, message: 模型调用失败 }表示错误。六、模拟逐字发送const characters Array.from(answer)把字符串变成字符数组。例如Array.from(你好)得到[你, 好]然后let currentIndex 0记录当前发送到第几个字符。setIntervalconst timer setInterval(() { // 每隔 70 毫秒执行一次 }, 70)每隔 70 毫秒发送一个字符。第一次你第二次好第三次继续。这就产生了打字机效果。发送完成if ( currentIndex characters.length ) { sendEvent({ type: content, text: characters[currentIndex] }) currentIndex 1 return }如果还有字符就发送当前字符。全部发送完成后sendEvent({ type: end }) clearInterval(timer) res.end()clearInterval停止定时器。res.end()关闭 HTTP 响应。七、用户停止生成前端执行this.eventSource.close()浏览器关闭 SSE 连接。后端监听req.on(close, () { clearInterval(timer) })连接关闭后停止继续生成。真实大模型时还应该同时取消上游模型请求避免继续产生费用。八、为什么 SSE 使用EventSource不用 AxiosAxios 更适合普通请求发送请求 等待完整响应 得到结果SSE 需要连接持续存在 不断接收新消息浏览器原生提供EventSource所以使用this.eventSource new EventSource(streamUrl)九、聊天页面中的数据data() { return { question: , messages: [], streamingContent: , isStreaming: false, eventSource: null, nextMessageId: 1 } }questionquestion: 保存输入框内容。模板el-input v-modelquestion /v-model是双向绑定。用户输入文字 → question 变化同时question 变化 → 输入框显示变化messagesmessages: []保存已经完成的历史消息。例如[ { id: 1, role: user, content: 你好 }, { id: 2, role: assistant, content: 你好请问有什么可以帮助你 } ]streamingContentstreamingContent: 保存当前还在生成中的 AI 回答。收到一个字this.streamingContent data.text例如原来你 新字符好 结果你好isStreamingisStreaming: false表示是否正在生成。false可以发送新问题 true正在生成显示停止按钮模板可以根据它切换按钮el-button v-ifisStreaming 停止生成 /el-button el-button v-else 发送 /el-buttoneventSourceeventSource: null保存当前 SSE 连接对象。有了它才能主动执行this.eventSource.close()十、发送问题流程sendQuestion() { const trimmedQuestion this.question.trim() if ( !trimmedQuestion || this.isStreaming ) { return } }先去掉空格。如果问题为空直接结束。如果正在生成也直接结束避免同时建立多个连接。保存用户消息this.messages.push({ id: this.nextMessageId, role: user, content: trimmedQuestion })向数组添加一条用户消息。Vue 发现数组变化后会自动更新页面中的v-formessage in messages清空输入框this.question 因为输入框使用v-modelquestion所以数据变空后输入框也自动清空。建立 SSEconst streamUrl createAiStreamUrl( trimmedQuestion ) this.eventSource new EventSource(streamUrl)最终地址类似/api/ai/stream?question请介绍一下SSE十一、onmessagethis.eventSource.onmessage event { const data JSON.parse(event.data) }每次服务器发送data: ...都会触发onmessage。event.data是字符串。例如{type:content,text:你}通过JSON.parse(event.data)转换成 JavaScript 对象。处理文字if (data.type content) { this.streamingContent data.text || return }每收到一个文字片段就追加到页面。处理结束if (data.type end) { this.finishStream() }finishStream()会把临时回答保存到 messages 关闭 SSE 恢复发送按钮十二、保存流式消息saveStreamingMessage() { if (!this.streamingContent) { return } this.messages.push({ id: this.nextMessageId, role: assistant, content: this.streamingContent }) this.nextMessageId 1 this.streamingContent }生成过程中回答在streamingContent生成完成后转移到messages然后把临时内容清空。十三、为什么要在beforeDestroy关闭连接beforeDestroy() { this.closeEventSource() }当用户离开聊天页面时组件准备销毁。如果不关闭 SSE用户已经进入别的页面 后端仍然继续发送 连接仍占用资源所以离开页面前必须关闭。十四、自动滚动scrollToBottom() { this.$nextTick(() { const container this.$refs.messageContainer container.scrollTop container.scrollHeight }) }$refs模板中div refmessageContainer脚本中可以通过this.$refs.messageContainer获得这个真实 HTML 元素。scrollHeight表示内容总高度。scrollTop表示当前滚动位置。设置scrollTop scrollHeight就会滚动到底部。$nextTickVue 修改数据后真实网页不是立即同步完成。所以先等待 Vue 更新 DOMthis.$nextTick(...)再滚动。十五、为什么阶段五 Cookie 对 SSE 很重要原生EventSource不方便添加自定义请求头。很难像 Axios 一样手动写Authorization: Bearer ...但是浏览器会自动给同源 EventSource 请求携带 Cookie。所以组合是阶段五HttpOnly Cookie 阶段六EventSource后端requireAuth可以正常读取 Cookie判断用户是否登录。