大模型流式输出是怎么实现的?从 ReadableStream、Uint8Array 到 SSE

普通 HTTP 接口通常会等所有内容生成完成,再一次性返回结果。

如果大模型生成一篇较长的文章,用户可能需要面对几秒甚至更久的空白页面。即使模型正在正常工作,产品看起来也像"卡住了"。

流式输出改变了响应方式:模型每生成一小段内容,服务端就立即向客户端发送,页面再把这些增量内容持续追加到已有文本后面。

text 复制代码
用户提交问题
→ 大模型生成部分 Token
→ 服务端发送一个数据事件
→ 浏览器读取并解析
→ Vue 更新页面
→ 继续等待下一段内容

本文使用 Vue 3 和 Fetch API,拆清浏览器如何读取并解析大模型返回的数据流。

stream: true 改变了什么?

非流式请求返回的是一个完整 JSON:

json 复制代码
{
  "choices": [
    {
      "message": {
        "content": "完整回答"
      }
    }
  ]
}

前端只需要等待并解析:

js 复制代码
const data = await response.json();
content.value = data.choices[0].message.content;

开启流式输出后,响应体不再是一个能够直接 response.json() 的完整对象,而是持续到达的数据流:

text 复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

其中每个 delta.content 都是本次新增的内容,前端需要逐段读取并拼接。

ReadableStream 是浏览器里的数据管道

Fetch 收到响应后,response.body 是一个 ReadableStream

js 复制代码
const response = await fetch('/api/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    prompt: question.value,
  }),
});

if (!response.ok) {
  throw new Error(`请求失败:${response.status}`);
}

if (!response.body) {
  throw new Error('当前浏览器不支持流式响应');
}

const reader = response.body.getReader();

这里请求的是自己的 /api/stream 接口。浏览器只提交问题,不保存大模型服务的 API Key。

getReader() 相当于在数据管道上安装了一个读取器。每次调用:

js 复制代码
const { value, done } = await reader.read();

会得到两个值:

text 复制代码
value:本次读到的二进制数据
done:数据流是否已经结束

如果暂时没有新数据,reader.read() 会等待;服务端继续发送后,Promise 才会完成。

Uint8Array 为什么不能直接显示?

reader.read() 返回的 value 通常是 Uint8Array

它保存的是一组 0~255 的无符号整数,本质上是网络传输过来的原始字节:

text 复制代码
Uint8Array(6) [228, 189, 160, 229, 165, 189]

浏览器无法直接把这些数字当成"你好"显示,因此需要使用 TextDecoder 解码:

js 复制代码
const decoder = new TextDecoder('utf-8');

读取数据时使用流式解码:

js 复制代码
const text = decoder.decode(value, {
  stream: true,
});

UTF-8 中文字符通常由多个字节组成,而网络数据可以在任意字节位置拆分。

stream: true 会让解码器保留尚未完整的字节,等下一批数据到达后再继续解码,避免一个中文字符正好被拆在两次 read() 之间。

数据全部读取完成后,再调用一次:

js 复制代码
buffer += decoder.decode();

把解码器内部剩余的字节刷新出来。

ReadableStream 和 SSE 不是一回事

这两个概念经常同时出现,但职责不同。

text 复制代码
ReadableStream:浏览器读取响应体的方式
SSE:服务端组织文本事件的格式

SSE 是 Server-Sent Events 的缩写,常见格式如下:

text 复制代码
data: 第一条数据

data: 第二条数据

每个事件可以包含 eventidretrydata 等字段,事件之间使用空行分隔。

大模型兼容接口通常把 JSON 放进 data: 字段,并使用 [DONE] 表示结束。

一次 read() 不等于一次 SSE 事件

网络只负责传输字节,不理解 JSON 和 SSE 的业务边界。

一次 reader.read() 可能拿到:

text 复制代码
半条 SSE 事件
一条完整事件
多条完整事件
上一条的后半段和下一条的前半段

因此,不能假设每个 value 都能直接执行 JSON.parse()

正确做法是准备一个 buffer

text 复制代码
新文本追加到 buffer
→ 按 SSE 空行拆出完整事件
→ 保留最后一段不完整内容
→ 等待下一批数据继续拼接

封装一个 SSE 事件解析函数

先处理一条已经完整的 SSE 事件:

js 复制代码
function parseSSEEvent(eventText, onDelta) {
  const payload = eventText
    .split(/\r?\n/)
    .filter(line => line.startsWith('data:'))
    .map(line => line.slice(5).trimStart())
    .join('\n');

  if (!payload) return false;

  if (payload === '[DONE]') {
    return true;
  }

  const data = JSON.parse(payload);
  const delta =
    data.choices?.[0]?.delta?.content;

  if (delta) {
    onDelta(delta);
  }

  return false;
}

这个函数完成三件事:

  • 提取事件中的 data: 内容
  • 识别 [DONE]
  • 从 JSON 中取得本次新增的 delta.content

返回 true 表示服务端已经发送结束标记。

完整读取并解析响应流

接下来把 ReadableStreamTextDecoder 和 SSE 事件边界连接起来:

js 复制代码
async function readSSEStream(response, onDelta) {
  if (!response.body) {
    throw new Error('响应体不是可读流');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';
  let finished = false;

  while (!finished) {
    const { value, done } = await reader.read();

    if (done) {
      buffer += decoder.decode();
      break;
    }

    buffer += decoder.decode(value, {
      stream: true,
    });

    const events = buffer.split(/\r?\n\r?\n/);
    buffer = events.pop() ?? '';

    for (const eventText of events) {
      finished = parseSSEEvent(
        eventText,
        onDelta,
      );

      if (finished) break;
    }
  }

  if (!finished && buffer.trim()) {
    parseSSEEvent(buffer, onDelta);
  }
}

这里最关键的是:

js 复制代码
buffer = events.pop() ?? '';

按照空行拆分后,数组最后一项可能只是半条事件。它不能被丢弃,需要留到下一轮继续拼接。

在 Vue 中持续更新页面

Vue 使用响应式状态保存问题、回答和加载状态:

vue 复制代码
<script setup>
import { ref } from 'vue';

const question = ref('写一篇英语作文');
const content = ref('');
const loading = ref(false);

async function submit() {
  if (!question.value || loading.value) return;

  content.value = '';
  loading.value = true;

  try {
    const response = await fetch('/api/stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        prompt: question.value,
      }),
    });

    if (!response.ok) {
      throw new Error(`请求失败:${response.status}`);
    }

    await readSSEStream(response, delta => {
      content.value += delta;
    });
  } catch (error) {
    content.value = error.message;
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <main class="container">
    <input v-model="question" />
    <button
      :disabled="loading"
      @click="submit"
    >
      {{ loading ? '生成中...' : '提交' }}
    </button>

    <div class="output">{{ content }}</div>
  </main>
</template>

<style scoped>
.output {
  margin-top: 16px;
  white-space: pre-wrap;
}
</style>

每当解析出新的 delta

js 复制代码
content.value += delta;

Vue 就会更新对应的页面内容,从而形成类似打字机的输出效果。

这里使用文本插值 {{ content }},而不是直接使用 v-html 渲染模型回答,避免把未经处理的模型输出当成 HTML 执行。

为什么聊天请求通常使用 Fetch,而不是 EventSource?

浏览器原生的 EventSource 也能接收 SSE,但它主要面向 GET 请求,并且不方便自定义请求体和请求头。

大模型聊天通常需要:

  • 使用 POST 请求
  • 发送 messages 或 prompt
  • 携带业务参数
  • 处理自定义错误响应
  • 主动取消请求

因此,fetch() 配合 ReadableStream 更适合聊天场景。

SSE 和 WebSocket 怎么选?

对比项 SSE WebSocket
通信方向 服务端向客户端持续发送 双向通信
协议基础 HTTP WebSocket
数据格式 文本事件 文本或二进制
聊天生成 适合服务端持续返回回答 也能实现,但通常更复杂

大模型生成回答的核心数据方向是:

text 复制代码
客户端提交一次问题
服务端持续返回内容

因此,SSE 已经能够满足大多数文本生成场景。只有当业务需要持续的双向实时通信时,才需要进一步考虑 WebSocket。

服务端需要提供什么?

本文重点是浏览器端解析,/api/stream 的 BFF 实现放在下一篇。

从客户端视角看,服务端至少需要:

text 复制代码
接收前端问题
→ 携带服务端保存的 API Key 请求大模型
→ 开启 stream
→ 保持流式响应
→ 把 SSE 数据持续转发给浏览器

典型响应头包括:

http 复制代码
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
Connection: keep-alive

API Key 保存在 BFF 服务端,不能使用 VITE_ 环境变量暴露给浏览器。

总结

大模型的"打字机效果"不是前端定时器模拟出来的,而是一条持续工作的数据管道:

text 复制代码
LLM 生成 Token
→ 服务端封装为 SSE 事件
→ HTTP 响应流
→ ReadableStream
→ Uint8Array
→ TextDecoder
→ buffer 拼接完整事件
→ JSON.parse
→ Vue 响应式更新

其中有三个关键边界:

  • 字节边界:使用 TextDecoder 的流式模式处理 UTF-8 拆分
  • 事件边界:使用 buffer 保留未完成的 SSE 事件
  • 安全边界:前端只请求 BFF,不保存大模型 API Key

理解这三个边界后,流式输出就不再只是"循环读取 response.body",而是一套可以稳定处理网络拆包、中文解码和增量渲染的数据流程。

相关推荐
何时梦醒8 小时前
⚛️ React 19 + TypeScript 深度学习笔记 —— 从组件化思维到 WebGPU 端侧 AI 落地
前端·javascript·人工智能
码农学院8 小时前
Neo4j知识图谱赋能跨境电商GEO:LLM实体识别与AI搜索引擎结构化数据输出实战
人工智能·知识图谱·neo4j
ZZZMMM.zip8 小时前
断舍离清单 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
用户938515635078 小时前
从 Vite 脚手架到 WebGPU 推理:手写一个 DeepSeek-R1 浏览器端大模型 Demo
javascript·人工智能·全栈
不如语冰8 小时前
AI大模型入门-参数的传递
数据结构·人工智能·pytorch·python
Jerry_Chenug8 小时前
MCP 入门到实战:把文档、接口和工具接入 Cursor
人工智能
Revolution619 小时前
一堆 if 把 Agent Loop 写乱了:Hooks 到底解决了什么?
人工智能
m沐沐9 小时前
【深度学习】深入理解长短期记忆网络 LSTM
人工智能·pytorch·深度学习·神经网络·lstm
xiancai_xianyu9 小时前
企业本体语义:设备保养与供应商评估,为什么需要统一的语义模型?
大数据·人工智能