阶段六:SSE AI 流式对话

📅 2026/7/19 15:12:49 👁️ 阅读次数 📝 编程学习
阶段六: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,判断用户是否登录。