大模型流式输出是怎么实现的?从 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: 第二条数据

每个事件可以包含 event、id、retry 和 data 等字段,事件之间使用空行分隔。

大模型兼容接口通常把 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 表示服务端已经发送结束标记。

完整读取并解析响应流

接下来把 ReadableStream、TextDecoder 和 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",而是一套可以稳定处理网络拆包、中文解码和增量渲染的数据流程。

相关推荐
博图光电4 分钟前
Libra 27105相关技术参数
人工智能·数码相机
IT_陈寒29 分钟前
SpringBoot自动配置差点让我加班到凌晨
前端·人工智能·后端
yumgpkpm42 分钟前
Acceldata ODP(Open Data Platform)3.3.6.4(RHEL9)保姆级完整安装手册
大数据·人工智能·hive·hadoop·kafka·hbase·cloudera
程序员cxuan1 小时前
腾讯又来一王炸,开源版 WorkBuddy 太夯了!
人工智能·后端·程序员
邓工说电1 小时前
智慧断路器安全吗?数据加密、离线保护与合规认证全解读
大数据·数据库·人工智能·智能断路器·炜晔科技
阿里云大数据AI技术1 小时前
Lance 数据检索怎么选,当然阿里云 Milvus 向量湖
人工智能
出海客1 小时前
跨境电商多语言客服知识库怎么建:资料结构、检索边界与人工升级
大数据·人工智能
xsd202411181 小时前
从自主导航到视觉读表:一台工业巡检机器人的全栈技术链路拆解
人工智能
袁哥大话安全1 小时前
巡隐WEBSHELL扫描软件
人工智能·安全·web
论文复现现场2 小时前
8卡4090能跑70B吗?Llama-2显存预算、QLoRA与通信瓶颈
人工智能·深度学习·分布式训练·llama·显存·qlora·算家云