Vue 3 如何接住大模型的流式回答:从 ReadableStream 到可靠的 SSE 解析

Vue 3 如何接住大模型的流式回答:从 ReadableStream 到可靠的 SSE 解析

调用大模型接口时,如果一直等到整段回答生成完毕再显示,页面可能十几秒没有变化。更自然的体验是:模型生成一点,页面就追加一点,像聊天软件正在打字。

这件事看起来只是一个 while 循环,真正容易出错的地方却在网络分块:浏览器每次读到的 Uint8Array,并不保证刚好是一条完整消息。一段 JSON 可能被拆成两次到达,也可能几条消息挤在同一个分块里。若直接按换行切割并执行 JSON.parse(),代码在网络顺畅时似乎可用,换个环境就可能随机报错或漏字。

下面用 Vue 3 和 Vite 实现一个可以切换"流式/非流式"模式的聊天页面,并把二进制解码、SSE 消息边界、响应式更新和错误处理逐层讲清楚。

先约定接口与项目结构

前端向同源地址 POST /api/chat 发送 JSON:

json 复制代码
{
  "message": "讲一个中国龙的故事",
  "stream": true
}

streamfalse 时,后端返回普通 JSON:

json 复制代码
{
  "content": "很久以前......"
}

streamtrue 时,响应类型是 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.bodyReadableStream。调用 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"都是错误假设。可靠做法是维护字符串缓冲区:

  1. 把新解码的文本追加到缓冲区。
  2. 只取出已经出现 \n\n 的完整事件。
  3. 将最后一段不完整内容留到下一轮。
  4. 数据流结束后,再检查剩余内容。

解码本身也存在边界问题。一个中文字符的 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')

一次请求从点击到显示经历了什么

把完整执行顺序串起来,许多异步代码就不再神秘:

  1. 浏览器加载 main.js,创建 Vue 应用,并把 App.vue 挂载到 #app
  2. ref() 创建问题、回答、模式和加载状态,模板订阅这些状态。
  3. 用户提交表单,Vue 调用 submitQuestion();函数从 question.value 读取并清理输入。
  4. fetch() 把对象通过 JSON.stringify() 转成 JSON 字符串,发送给 /api/chat。调用返回 Promise,await 得到 Response
  5. 程序先检查 response.ok。HTTP 404、401、500 等状态不会让 fetch() 自动抛错,所以这一步不能省略。
  6. 非流式模式调用 response.json(),等待完整响应并读取 content
  7. 流式模式从 response.body 创建 reader。每次 reader.read() 等待一批新字节。
  8. TextDecoder 按 UTF-8 增量解码,文本被追加到 buffer
  9. 程序按空行提取完整 SSE 事件;不完整的尾部继续留在缓冲区。
  10. 完整事件中的 JSON 被解析,onContent 回调把新增文本追加到 content.value,Vue 随即更新输出区域。
  11. 收到 [DONE] 后主动取消剩余读取;底层流自然关闭时则直接结束。随后 finally 释放 reader 的锁,并恢复按钮状态。
  12. 任一步抛出异常都会进入 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 开发代理,还需要在部署平台或反向代理中配置同样的转发规则。

页面直到最后才一次性显示

前端循环正确也不代表链路一定实时。后端若先把上游响应完整读入内存,或者反向代理启用了响应缓冲,浏览器仍只能最后一次收到全部内容。应确认后端是边读边转发,并检查压缩、中间件和代理的缓冲配置。

从演示代码走向真实应用

这个版本已经解决了分块边界与基本错误处理,但生产应用还应继续补齐几个能力。

最实用的是取消请求。用户离开页面或点击"停止生成"时,可以通过 AbortControllersignal 传给 fetch(),再调用 controller.abort(),避免服务器继续生成无用内容。

其次是限制输入长度、请求频率和并发数。前端限制用于改善体验,真正的安全限制必须放在后端,因为浏览器代码可以被绕过。后端还应设置请求超时,记录上游状态码,但不要把密钥或完整敏感输入写入日志。

如果需要支持不同供应商,不要让 Vue 组件直接理解各家 choices[0].delta.content 等结构。后端可以把上游格式统一转换为 { "content": "新增文本" },前端解析器便能保持稳定。协议适配集中在一处,也便于升级模型或更换服务。

最后,可以为 readSSE() 写单元测试,主动构造"一个事件拆成两块""多个事件合成一块""中文字符跨块""CRLF 换行"和"非法 JSON"等输入。流式程序最容易出问题的恰恰是边界,测试时故意制造边界,比反复手工点击更有效。

总结

大模型流式输出的关键不在于不断执行 read(),而在于分清三个层次:网络层给出任意大小的字节块,TextDecoder 把字节增量转换为文本,SSE 解析器再从文本中识别完整事件。只有完成这三步,JSON 才有稳定的边界。

Vue 在这里负责的事情反而很简单:每拿到一段新增文本,就更新响应式状态。把流协议、接口请求和页面状态各自放在清晰的位置后,代码不仅能"打字式"显示回答,也能正确面对慢网络、跨块中文、HTTP 错误和不完整消息。

相关推荐
蓝银草同学19 小时前
Stream 数据统计实战:求和、平均值、分组汇总(AI 辅助学习 Java 8)
java·前端·后端
Dontla19 小时前
Hero Section(首屏大图区 / 英雄区)介绍(Web网页落地页Landing Page最顶部的区域)
前端
rain_sxr19 小时前
大模型端侧推理的前端落地:WebGPU 与 ONNX Runtime 的浏览器部署
人工智能
大模型任我行19 小时前
腾讯:预测散度掩码提升LLM强化学习
人工智能·语言模型·自然语言处理·论文笔记
程序员-李俞19 小时前
向量引擎接入自研 API 中转网关:鉴权、限流、熔断和审计日志复盘
服务器·人工智能·大模型·api·ai编程·ai api
雪碧聊技术19 小时前
软件定义三维近存AI芯片发布——国产算力走出“不依赖先进制程”的独特路线
人工智能
夜瞬19 小时前
内生可解释性:从黑盒深度模型到可理解、可干预的智能系统
人工智能·python
额恩6619 小时前
阶段一:Vue 2 单页应用基础
人工智能·深度学习·机器学习