阶段六:SSE AI 流式对话
从零构建带AI流式对话的Vue2全栈应用-CSDN博客本文是对这篇文章阶段六的讲解
一、普通接口为什么不能产生打字机效果
普通接口:
用户提问 ↓ 后端生成完整回答 ↓ 一次性返回整个回答 ↓ 页面一次性显示SSE:
用户提问 ↓ 后端保持连接 ↓ 发送第一个字 ↓ 发送第二个字 ↓ 发送第三个字 ↓ 直到结束这就是类似 ChatGPT 的流式输出。
二、SSE 是什么
SSE 全称:
Server-Sent Events意思是:
服务器持续向浏览器发送事件。
它主要是单向通信:
服务器 → 浏览器浏览器使用:
new EventSource(url)连接服务器。
三、SSE 后端响应头
res.setHeader( 'Content-Type', 'text/event-stream; charset=utf-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记录当前发送到第几个字符。
setInterval
const 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,不用 Axios
Axios 更适合普通请求:
发送请求 等待完整响应 得到结果SSE 需要:
连接持续存在 不断接收新消息浏览器原生提供:
EventSource所以使用:
this.eventSource = new EventSource(streamUrl)九、聊天页面中的数据
data() { return { question: '', messages: [], streamingContent: '', isStreaming: false, eventSource: null, nextMessageId: 1 } }question
question: ''保存输入框内容。
模板:
<el-input v-model="question" />v-model是双向绑定。
用户输入文字 → question 变化同时:
question 变化 → 输入框显示变化messages
messages: []保存已经完成的历史消息。
例如:
[ { id: 1, role: 'user', content: '你好' }, { id: 2, role: 'assistant', content: '你好,请问有什么可以帮助你?' } ]streamingContent
streamingContent: ''保存当前还在生成中的 AI 回答。
收到一个字:
this.streamingContent += data.text例如:
原来:你 新字符:好 结果:你好isStreaming
isStreaming: false表示是否正在生成。
false:可以发送新问题 true:正在生成,显示停止按钮模板可以根据它切换按钮:
<el-button v-if="isStreaming"> 停止生成 </el-button> <el-button v-else> 发送 </el-button>eventSource
eventSource: 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-for="message in messages"清空输入框
this.question = ''因为输入框使用:
v-model="question"所以数据变空后,输入框也自动清空。
建立 SSE
const streamUrl = createAiStreamUrl( trimmedQuestion ) this.eventSource = new EventSource(streamUrl)最终地址类似:
/api/ai/stream?question=请介绍一下SSE十一、onmessage
this.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 ref="messageContainer">脚本中可以通过:
this.$refs.messageContainer获得这个真实 HTML 元素。
scrollHeight
表示内容总高度。
scrollTop
表示当前滚动位置。
设置:
scrollTop = scrollHeight就会滚动到底部。
$nextTick
Vue 修改数据后,真实网页不是立即同步完成。
所以先等待 Vue 更新 DOM:
this.$nextTick(...)再滚动。
十五、为什么阶段五 Cookie 对 SSE 很重要
原生EventSource不方便添加自定义请求头。
很难像 Axios 一样手动写:
Authorization: Bearer ...但是浏览器会自动给同源 EventSource 请求携带 Cookie。
所以组合是:
阶段五:HttpOnly Cookie + 阶段六:EventSource后端requireAuth可以正常读取 Cookie,判断用户是否登录。