Vue 3 如何接住大模型的流式回答:从 ReadableStream 到可靠的 SSE 解析
调用大模型接口时,如果一直等到整段回答生成完毕再显示,页面可能十几秒没有变化。更自然的体验是:模型生成一点,页面就追加一点,像聊天软件正在打字。
这件事看起来只是一个 while 循环,真正容易出错的地方却在网络分块:浏览器每次读到的 Uint8Array,并不保证刚好是一条完整消息。一段 JSON 可能被拆成两次到达,也可能几条消息挤在同一个分块里。若直接按换行切割并执行 JSON.parse(),代码在网络顺畅时似乎可用,换个环境就可能随机报错或漏字。
下面用 Vue 3 和 Vite 实现一个可以切换"流式/非流式"模式的聊天页面,并把二进制解码、SSE 消息边界、响应式更新和错误处理逐层讲清楚。
先约定接口与项目结构
前端向同源地址 POST /api/chat 发送 JSON:
json
{
"message": "讲一个中国龙的故事",
"stream": true
}
当 stream 为 false 时,后端返回普通 JSON:
json
{
"content": "很久以前......"
}
当 stream 为 true 时,响应类型是 text/event-stream,内容类似:
text
data: {"content":"很久"}
data: {"content":"以前"}
data: [DONE]
这种格式叫 SSE(Server-Sent Events)。每个事件由一个空行结束,data: 表示事件的数据字段,[DONE] 是这个接口约定的结束标记。SSE 是文本协议,但文本经过网络传输后,浏览器读到的仍是字节。
示例的文件结构如下:
text
stream-demo/
├─ src/
│ ├─ App.vue
│ ├─ main.js
│ └─ style.css
├─ index.html
├─ package.json
└─ vite.config.js
创建一个 Vue 项目后安装依赖并启动:
bash
npm install
npm run dev
开发阶段可让 Vite 把 /api 转发到本地后端,避免浏览器的跨域问题:
javascript
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})
大模型的 API Key 应由后端读取,不能写进 VITE_* 环境变量。Vite 会把这类变量打进前端产物,任何打开开发者工具的人都能看到。后端代理的职责是保存密钥、校验用户输入,并把上游响应转发给浏览器。具体厂商的请求字段可能不同,但不影响本文的前端数据流。
字符串为什么会变成 Uint8Array
先在浏览器控制台运行一个最小例子:
javascript
const encoder = new TextEncoder()
const bytes = encoder.encode('hello')
console.log(bytes)
// Uint8Array(5) [104, 101, 108, 108, 111]
const decoder = new TextDecoder()
console.log(decoder.decode(bytes))
// hello
Uint8Array 是无符号 8 位整数数组,每个元素的范围是 0~255。它是字节的容器,并不等于"一个元素对应一个字符":中文等字符在 UTF-8 中通常占多个字节。
fetch() 返回的 Response 对象中,response.body 是 ReadableStream。调用 getReader() 得到读取器,再调用 reader.read() 会返回一个 Promise。Promise 完成后的值是:
javascript
{
value: Uint8Array | undefined,
done: boolean
}
value 是本次到达的字节,done 表示整个响应流是否关闭。因为网络数据不会立刻到齐,read() 必须异步等待;这也是读取函数需要声明为 async、调用处需要使用 await 的原因。等待期间 JavaScript 主线程并没有被冻结,浏览器仍然可以绘制页面、处理点击和执行其他任务。
最难的不是读取,而是识别消息边界
假设服务端发送两条事件:
text
data: {"content":"你"}
data: {"content":"好"}
网络层完全可能这样分块:
text
第 1 块:data: {"content":"你"}\n\ndata: {"con
第 2 块:tent":"好"}\n\n
因此,"一个 chunk 等于一行"或"一个 chunk 等于一个 JSON"都是错误假设。可靠做法是维护字符串缓冲区:
- 把新解码的文本追加到缓冲区。
- 只取出已经出现
\n\n的完整事件。 - 将最后一段不完整内容留到下一轮。
- 数据流结束后,再检查剩余内容。
解码本身也存在边界问题。一个中文字符的 UTF-8 字节可能被拆到两个 chunk 中,所以处理中间分块时要调用 decoder.decode(value, { stream: true })。stream: true 会让解码器保留末尾不完整的字符字节。流结束后再执行一次不带参数的 decoder.decode(),把内部残留刷新出来。
下面把协议解析单独封装成函数:
javascript
async function readSSE(response, onContent) {
if (!response.body) {
throw new Error('当前浏览器没有提供可读取的响应流')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
function consumeEvent(rawEvent) {
const data = rawEvent
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (!data) return false
if (data === '[DONE]') return true
const payload = JSON.parse(data)
if (typeof payload.content === 'string') {
onContent(payload.content)
}
return false
}
try {
while (true) {
const { value, done } = await reader.read()
buffer += done
? decoder.decode()
: decoder.decode(value, { stream: true })
// 同时兼容 CRLF(\r\n)和 LF(\n)换行。
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() ?? ''
for (const event of events) {
if (consumeEvent(event)) {
await reader.cancel()
return
}
}
if (done) {
if (buffer.trim()) consumeEvent(buffer)
return
}
}
} finally {
reader.releaseLock()
}
}
onContent 是调用方传入的回调函数。解析器只负责找到完整事件和解析 JSON,不关心 Vue 页面;每得到一段文本,它就把文本交给回调。这样协议处理与界面更新彼此独立,也更容易测试。
注意 events.pop():split() 后的最后一项可能还没有遇到空行,不能立刻解析。它被放回 buffer,等待下一个 chunk 补齐。这比捕获 JSON.parse() 异常后猜测"是不是被截断了"更可靠,因为 JSON 解析失败也可能真的是服务端格式错误,不应该一律吞掉。
在 Vue 中把流转换成页面状态
Vue 3 的 ref() 会创建响应式引用。在 JavaScript 中读取或修改它要使用 .value;模板会自动解包,所以模板里直接写 content 即可。
完整的 src/App.vue 如下:
vue
<script setup>
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事')
const content = ref('')
const useStream = ref(true)
const loading = ref(false)
const errorMessage = ref('')
async function readSSE(response, onContent) {
if (!response.body) throw new Error('响应体不可读取')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
function consumeEvent(rawEvent) {
const data = rawEvent
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (!data) return false
if (data === '[DONE]') return true
const payload = JSON.parse(data)
if (typeof payload.content === 'string') {
onContent(payload.content)
}
return false
}
try {
while (true) {
const { value, done } = await reader.read()
buffer += done
? decoder.decode()
: decoder.decode(value, { stream: true })
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() ?? ''
for (const event of events) {
if (consumeEvent(event)) {
await reader.cancel()
return
}
}
if (done) {
if (buffer.trim()) consumeEvent(buffer)
return
}
}
} finally {
reader.releaseLock()
}
}
async function submitQuestion() {
const message = question.value.trim()
if (!message || loading.value) return
loading.value = true
errorMessage.value = ''
content.value = useStream.value ? '' : '正在生成回答......'
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
stream: useStream.value,
}),
})
if (!response.ok) {
const detail = await response.text()
throw new Error(`请求失败(${response.status}):${detail}`)
}
if (useStream.value) {
await readSSE(response, (delta) => {
content.value += delta
})
} else {
const data = await response.json()
if (typeof data.content !== 'string') {
throw new Error('响应中缺少 content 字段')
}
content.value = data.content
}
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : '发生未知错误'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="container">
<h1>流式聊天演示</h1>
<form class="controls" @submit.prevent="submitQuestion">
<input v-model="question" aria-label="问题" placeholder="请输入问题" />
<button :disabled="loading">
{{ loading ? '生成中...' : '提交' }}
</button>
<label>
<input v-model="useStream" type="checkbox" />
流式输出
</label>
</form>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<section class="output" aria-live="polite">{{ content }}</section>
</main>
</template>
@submit.prevent 会在表单提交时调用 submitQuestion,同时阻止浏览器刷新页面。v-model 在文本框上绑定字符串,在复选框上绑定布尔值。按钮在请求期间禁用,避免连续点击产生多条并发请求并把内容混在一起。
流式分支中的回调接收 delta。这里的 delta 表示本次新增的文本片段,因此使用 content.value += delta 追加;非流式分支拿到的是完整答案,所以直接赋值。
样式只需保证输出保留换行:
css
/* src/style.css */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
}
.container {
max-width: 760px;
margin: 40px auto;
padding: 0 20px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.controls > input {
flex: 1;
min-width: 240px;
padding: 8px;
}
.output {
min-height: 240px;
margin-top: 18px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
white-space: pre-wrap;
}
.error {
color: #c62828;
}
入口文件负责创建 Vue 应用并挂载到 HTML 中的 #app:
javascript
// src/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
一次请求从点击到显示经历了什么
把完整执行顺序串起来,许多异步代码就不再神秘:
- 浏览器加载
main.js,创建 Vue 应用,并把App.vue挂载到#app。 ref()创建问题、回答、模式和加载状态,模板订阅这些状态。- 用户提交表单,Vue 调用
submitQuestion();函数从question.value读取并清理输入。 fetch()把对象通过JSON.stringify()转成 JSON 字符串,发送给/api/chat。调用返回 Promise,await得到Response。- 程序先检查
response.ok。HTTP 404、401、500 等状态不会让fetch()自动抛错,所以这一步不能省略。 - 非流式模式调用
response.json(),等待完整响应并读取content。 - 流式模式从
response.body创建 reader。每次reader.read()等待一批新字节。 TextDecoder按 UTF-8 增量解码,文本被追加到buffer。- 程序按空行提取完整 SSE 事件;不完整的尾部继续留在缓冲区。
- 完整事件中的 JSON 被解析,
onContent回调把新增文本追加到content.value,Vue 随即更新输出区域。 - 收到
[DONE]后主动取消剩余读取;底层流自然关闭时则直接结束。随后finally释放 reader 的锁,并恢复按钮状态。 - 任一步抛出异常都会进入
catch,错误信息显示在页面上;外层finally仍会执行。
一个 Response 的响应体通常只能消费一次。因此不能先执行 response.json(),再对同一个响应调用 response.body.getReader();代码必须先根据模式选择其中一种读取方式。
常见错误与排查
JSON 偶尔出现 Unexpected end of JSON input
典型现象是大部分请求正常,偶尔报错:
text
SyntaxError: Unexpected end of JSON input
原因通常不是模型返回了错误 JSON,而是代码把网络 chunk 当成完整消息。排查时打印每次解码后的文本,往往会看到 JSON 从中间被切断。正确做法是按 SSE 的空行边界缓存,只有完整事件才能交给 JSON.parse()。
中文偶尔变成替换字符
如果每个 chunk 都直接 decoder.decode(value),多字节字符恰好跨越边界时可能显示为 �。中间分块必须使用:
javascript
decoder.decode(value, { stream: true })
流结束时再用 decoder.decode() 刷新剩余字节。
服务端返回 401,页面却只报 JSON 解析失败
fetch() 遇到 HTTP 错误状态仍会正常返回 Response。如果直接把 401 的文本错误页当 JSON 解析,真正原因就被遮住了。应先检查:
javascript
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
然后依次确认后端是否加载了环境变量、Authorization 格式是否符合所用服务的要求,以及密钥是否有效。不要把密钥移到前端来"解决"401。
开发环境请求 /api/chat 得到 404
检查三件事:后端是否监听 3000 端口,Vite 的 server.proxy 是否配置了 /api,修改 vite.config.js 后是否重启了开发服务器。生产环境不会自动使用 Vite 开发代理,还需要在部署平台或反向代理中配置同样的转发规则。
页面直到最后才一次性显示
前端循环正确也不代表链路一定实时。后端若先把上游响应完整读入内存,或者反向代理启用了响应缓冲,浏览器仍只能最后一次收到全部内容。应确认后端是边读边转发,并检查压缩、中间件和代理的缓冲配置。
从演示代码走向真实应用
这个版本已经解决了分块边界与基本错误处理,但生产应用还应继续补齐几个能力。
最实用的是取消请求。用户离开页面或点击"停止生成"时,可以通过 AbortController 把 signal 传给 fetch(),再调用 controller.abort(),避免服务器继续生成无用内容。
其次是限制输入长度、请求频率和并发数。前端限制用于改善体验,真正的安全限制必须放在后端,因为浏览器代码可以被绕过。后端还应设置请求超时,记录上游状态码,但不要把密钥或完整敏感输入写入日志。
如果需要支持不同供应商,不要让 Vue 组件直接理解各家 choices[0].delta.content 等结构。后端可以把上游格式统一转换为 { "content": "新增文本" },前端解析器便能保持稳定。协议适配集中在一处,也便于升级模型或更换服务。
最后,可以为 readSSE() 写单元测试,主动构造"一个事件拆成两块""多个事件合成一块""中文字符跨块""CRLF 换行"和"非法 JSON"等输入。流式程序最容易出问题的恰恰是边界,测试时故意制造边界,比反复手工点击更有效。
总结
大模型流式输出的关键不在于不断执行 read(),而在于分清三个层次:网络层给出任意大小的字节块,TextDecoder 把字节增量转换为文本,SSE 解析器再从文本中识别完整事件。只有完成这三步,JSON 才有稳定的边界。
Vue 在这里负责的事情反而很简单:每拿到一段新增文本,就更新响应式状态。把流协议、接口请求和页面状态各自放在清晰的位置后,代码不仅能"打字式"显示回答,也能正确面对慢网络、跨块中文、HTTP 错误和不完整消息。