大模型流式输出实现:ReadableStream、Uint8Array与SSE
时间:2026-07-22 | 作者:318050 | 阅读:0传统 HTTP 接口,通常是等所有内容都生成完毕,再一次性把结果返回给客户端。
但大模型生成较长的文章时,用户可能面对几秒甚至更久的空白页面。即使模型内部正常工作,产品看起来却像“卡住了”。
流式输出改变了这个局面。模型每生成一小段内容,服务端就立即向客户端发送。页面再把这些增量内容持续追加到已有文本后面。
用户提交问题→ 大模型生成部分 Token→ 服务端发送一个数据事件→ 浏览器读取并解析→ Vue 更新页面→ 继续等待下一段内容
下面来看,用 Vue 3 和 Fetch API,具体怎么拆解浏览器读取并解析大模型返回的数据流。
stream: true 改变了什么?
非流式请求返回的是一个完整的 JSON 对象:
{"choices": [{"message": {"content": "完整回答"}}]}
前端只需要等待并解析:
const data = await response.json(); content.value = data.choices[0].message.content;
开启流式输出后,响应体就不再是一个可以直接 response.json() 的完整对象了。而是持续到达的数据流:
data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]
其中每个 delta.content 都是本次新增的内容。前端需要逐段读取并拼接。
ReadableStream 是浏览器里的数据管道
Fetch 收到响应后,response.body 实际上是一个 ReadableStream:
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() 相当于在数据管道上安装了一个读取器。每次调用:
const { value, done } = await reader.read();
会得到两个值:
value:本次读到的二进制数据done:数据流是否已经结束
如果暂时没有新数据,reader.read() 会等待。服务端继续发送后,Promise 才会完成。
Uint8Array 为什么不能直接显示?
reader.read() 返回的 value 通常是 Uint8Array。
它保存的是一组 0~255 的无符号整数,本质上是网络传输过来的原始字节:
Uint8Array(6) [228, 189, 160, 229, 165, 189]
浏览器无法直接把这些数字当成“你好”显示。因此需要使用 TextDecoder 解码:
const decoder = new TextDecoder('utf-8');
读取数据时使用流式解码:
const text = decoder.decode(value, { stream: true, });
UTF-8 中文字符通常由多个字节组成,而网络数据可以在任意字节位置拆分。
stream: true 会让解码器保留尚未完整的字节,等下一批数据到达后再继续解码,避免一个中文字符正好被拆在两次 read() 之间。
数据全部读取完成后,再调用一次:
buffer += decoder.decode();
把解码器内部剩余的字节刷新出来。
ReadableStream 和 SSE 不是一回事
这两个概念经常一起出现,但职责截然不同。
- ReadableStream:浏览器读取响应体的方式
- SSE:服务端组织文本事件的格式
SSE 是 Server-Sent Events 的缩写,常见格式如下:
data: 第一条数据 data: 第二条数据
每个事件可以包含 event、id、retry 和 data 等字段,事件之间使用空行分隔。
大模型兼容接口通常把 JSON 放进 data: 字段,并使用 [DONE] 表示结束。
一次 read() 不等于一次 SSE 事件
网络只负责传输字节,它不理解 JSON 和 SSE 的业务边界。
一次 reader.read() 可能拿到:
- 半条 SSE 事件
- 一条完整事件
- 多条完整事件
- 上一条的后半段和下一条的前半段
因此,不能假设每个 value 都能直接执行 JSON.parse()。
正确做法是准备一个 buffer:
新文本追加到 buffer→ 按 SSE 空行拆出完整事件→ 保留最后一段不完整内容→ 等待下一批数据继续拼接
封装一个 SSE 事件解析函数
先处理一条已经完整的 SSE 事件:
function parseSSEEvent(eventText, onDelta) { const payload = eventText.split(/rn/).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 事件边界连接起来:
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(/rnrn/); buffer = events.pop() ''; for (const eventText of events) { finished = parseSSEEvent(eventText, onDelta); if (finished) break; } } if (!finished && buffer.trim()) { parseSSEEvent(buffer, onDelta); } }
这里最关键的是:
buffer = events.pop() '';
按照空行拆分后,数组最后一项可能只是半条事件。它不能被丢弃,需要留到下一轮继续拼接。
在 Vue 中持续更新页面
Vue 使用响应式状态保存问题、回答和加载状态:
每当解析出新的 delta:
content.value += delta;
Vue 就会更新对应的页面内容,形成类似打字机的输出效果。
这里使用文本插值 {{ content }},而不是直接使用 v-html 渲染模型回答。避免把未经处理的模型输出当成 HTML 执行,这是一个重要的安全边界。
为什么聊天请求通常使用 Fetch,而不是 EventSource?
浏览器原生的 EventSource 也能接收 SSE,但它主要面向 GET 请求,并且不方便自定义请求体和请求头。
大模型聊天通常需要:
- 使用 POST 请求
- 发送 messages 或 prompt
- 携带业务参数
- 处理自定义错误响应
- 主动取消请求
因此,fetch() 配合 ReadableStream 更适合聊天场景。
SSE 和 WebSocket 怎么选?
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端向客户端持续发送 | 双向通信 |
| 协议基础 | HTTP | WebSocket |
| 数据格式 | 文本事件 | 文本或二进制 |
| 聊天生成 | 适合服务端持续返回回答 | 也能实现,但通常更复杂 |
大模型生成回答的核心数据方向是:
客户端提交一次问题 服务端持续返回内容
因此,SSE 已经能够满足大多数文本生成场景。只有当业务需要持续的双向实时通信时,才需要进一步考虑 WebSocket。
服务端需要提供什么?
本文重点是浏览器端解析,/api/stream 的 BFF 实现放在下一篇。
从客户端视角看,服务端至少需要:
接收前端问题→ 携带服务端保存的 API Key 请求大模型→ 开启 stream→ 保持流式响应→ 把 SSE 数据持续转发给浏览器
典型响应头包括:
Content-Type: text/event-stream; charset=utf-8 Cache-Control: no-cache Connection: keep-alive
API Key 保存在 BFF 服务端,不能使用 VITE_ 环境变量暴露给浏览器。
总结
大模型的“打字机效果”不是前端定时器模拟出来的,而是一条持续工作的数据管道:
LLM 生成 Token→ 服务端封装为 SSE 事件→ HTTP 响应流→ ReadableStream→ Uint8Array→ TextDecoder→ buffer 拼接完整事件→ JSON.parse→ Vue 响应式更新
其中有三个关键边界:
- 字节边界:使用
TextDecoder的流式模式处理 UTF-8 拆分 - 事件边界:使用
buffer保留未完成的 SSE 事件 - 安全边界:前端只请求 BFF,不保存大模型 API Key
理解这三个边界后,流式输出就不再只是“循环读取 response.body”,而是一套可以稳定处理网络拆包、中文解码和增量渲染的数据流程。
来源:整理自互联网
免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。
相关文章
更多-
- 纳睿雷达发布睿宸AI气象大模型与相控阵雷达
- 时间:2026-07-25
-
- 葡萄牙国家级大模型阿马利娅历时18个月正式亮相
- 时间:2026-07-25
-
- 阿里开源 Page Agent 实现大模型精准控制网页
- 时间:2026-07-25
-
- 阿里开源Page Agent让大模型读懂网页底层逻辑
- 时间:2026-07-25
-
- 大模型价格战下半场:推理价格持续降低
- 时间:2026-07-25
-
- 大模型评测自动化,回答质量回归测试成AI应用新标配
- 时间:2026-07-25
-
- 魔改P100显卡跑35B大模型提速88% 老卡再战三年
- 时间:2026-07-24
-
- 周鸿祎揭大模型幻觉:烧光一亿Token写周报
- 时间:2026-07-24
精选合集
更多大家都在玩
热门话题
大家都在看
更多-
- iOS 13.5.1电池续航差是电池耗电问题吗
- 时间:2026-07-25
-
- 苹果教育优惠开启 附购买攻略
- 时间:2026-07-25
-
- 苹果iOS 14 beta 2 测试版主要更新内容:除细节变化外修复多项Bug
- 时间:2026-07-25
-
- iOS 14 beta 2 是否解决内存占用过多问题?
- 时间:2026-07-25
-
- 受欢迎的奥特曼游戏有哪些
- 时间:2026-07-25
-
- iOS 14信息应用5大更新变化
- 时间:2026-07-25
-
- iOS 14正式版上线时间公布 官方全新介绍
- 时间:2026-07-25
-
- 最新苹果iOS 14 Beta 2版本更新内容全解析与升级教程
- 时间:2026-07-25
