序言
本文将以一个完整的 Vue 3 + Vite 项目为例,深入浅出地讲透 LLM(大语言模型)流式输出的前端实现原理。从二进制的本质到 SSE 协议,从 ReadableStream API 到不完整 JSON 的缓冲拼接,一文打尽。
一、项目概览
这是一个极简但五脏俱全的 LLM 流式对话 Demo。技术栈如下:
| 层 | 技术选型 |
|---|---|
| 框架 | Vue 3(Composition API) |
| 构建工具 | Vite 5 |
| LLM 服务 | DeepSeek API(deepseek-v4-flash 模型) |
| 流式传输 | SSE(Server-Sent Events)协议 + Fetch ReadableStream |
核心功能:用户在输入框提问,前端通过 fetch 调用 DeepSeek 的 Chat Completions API,以流式模式 (stream: true)实时逐字渲染 AI 回复。
二、为什么 LLM 需要流式输出?
2.1 用户体验的「体感延迟」
一个典型的大模型生成 500 个 token 大约需要 5-15 秒。如果采用非流式(同步)模式,用户需要等服务器生成完所有 token 后,才能一次性看到完整回复------这 5-15 秒里用户面对的是一个空白页面,焦虑感可想而知。
而流式(streaming)模式 下,模型每生成一个或几个 token,就立刻推送到前端。用户看到文字像打字机一样逐字出现,体感延迟从「等 10 秒」变成了「立刻开始看到结果」。
2.2 技术层面的差异
rust
非流式模式:
用户请求 -> 服务器生成全部 token -> 一次性返回 JSON -> 前端渲染
流式模式:
用户请求 -> 服务器边生成边推送 -> 前端边接收边渲染
^--- 持续的数据流 ---^
2.3 「打字机效果」的心理学
这不仅仅是技术炫技。研究表明,逐字出现的文本能够让用户保持注意力,并且让用户感觉到系统正在"思考"------这比一个转圈圈的 loading 动画更能让人接受等待。这也是 ChatGPT、Claude 等所有主流 AI 产品都采用流式输出的原因。
三、SSE 协议:LLM 流式传输的事实标准
3.1 什么是 SSE?
SSE(Server-Sent Events)是一种基于 HTTP 的服务器向客户端单向推送技术。在 LLM 场景中,服务器用 SSE 格式将 token 逐个推送到浏览器。
3.2 SSE 数据格式
DeepSeek API(兼容 OpenAI 格式)的流式响应体长这样:
css
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}
data: [DONE]
每条消息的结构为:
javascript
data: <JSON 字符串>\n\n
data:是 SSE 协议的消息前缀JSON字符串是一段完整或增量的响应数据delta.content字段包含模型本次生成的增量文本[DONE]是一个特殊的哨兵值,表示流结束
3.3 为什么要用换行符 \n 分割?
这是一个精妙的设计权衡:
- 粒度适中:以行为单位发送数据,兼顾了响应速度(不必等大段文本)和传输效率(不必每个字符都发一个 HTTP chunk)
- LLM 生成节奏匹配 :模型生成 token 的速度不恒定,有时快有时慢。服务端生成几个 token 就拼成一条
data:消息发出,客户端按行解析 - 容错性好:如果某一行 JSON 损坏,不影响其他行
四、二进制流的本质:从 0 和 1 到字符串
4.1 Uint8Array:无符号 8 位整数数组
在 JavaScript 中,网络传输的原始数据是二进制字节流 ,表示为 Uint8Array------一个每个元素都在 0~255 之间的无符号整数数组。
css
Uint8Array [228, 189, 160, 229, 165, 189] ← 这代表什么?
每个数字是一个字节(Byte),8 个二进制位,范围 0~255。一段文本在网络中传输时,实际传输的是这些字节,而不是人类可读的字符。
4.2 编码与解码:TextEncoder 和 TextDecoder
字符 ↔ 数字(字节)的映射关系,就是字符编码。
javascript
// 编码:字符串 → 字节数组
const encoder = new TextEncoder();
const bytes = encoder.encode("你好");
console.log(bytes); // Uint8Array(6) [228, 189, 160, 229, 165, 189]
// 解码:字节数组 → 字符串
const decoder = new TextDecoder();
const str = decoder.decode(bytes);
console.log(str); // "你好"
UTF-8 是互联网上最主流的编码方式。对于 ASCII 字符(英文、数字、标点),UTF-8 用 1 个字节表示;对于中文等字符,UTF-8 用 3 个字节表示。"你好" 两个中文字符就占了 6 个字节。
4.3 为什么前端需要手动解码?
当我们用 fetch 发起请求并拿到 response.body 时,得到的是一个 ReadableStream 对象-----一个原始的二进制流 。浏览器不知道这些字节是 UTF-8 编码的文本还是图片数据,需要我们用 TextDecoder 显式告诉它如何解码。
五、ReadableStream API:浏览器端的「水管」
5.1 核心概念
ReadableStream 是 WHATWG Streams 标准的一部分,可以把它想象成一根水管:
- 服务器是水龙头,不断往水管里注水(字节数据)
- 前端是一个读取器(Reader),不断从水管中"嘬一口"
- 每次
reader.read()返回{ value, done }:value是这次读到的Uint8Array,done表示流是否结束
5.2 核心代码拆解
javascript
// 1. 获取 response 的 body(ReadableStream)
const reader = response.body?.getReader();
// 2. 创建解码器(UTF-8 → 字符串)
const decoder = new TextDecoder();
let done = false;
let buffer = ''; // 用于缓存不完整的 JSON 行
// 3. 循环读取
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// 将二进制块解码为字符串
const chunkValue = buffer + decoder.decode(value);
buffer = '';
// 按行分割,过滤出 data: 开头的行
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6); // 去掉 "data: " 前缀
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta; // 追加到 Vue 响应式变量
}
} catch (err) {
// JSON 不完整?放入 buffer,等下一轮拼接
buffer = `data: ${incoming}`;
}
}
}
让我们逐段分析。
六、最棘手的问题:JSON 截断与缓冲拼接
6.1 问题场景
SSE 消息以换行符分隔,但网络传输的物理分块(chunk)不一定和逻辑行对齐。一个 chunk 可能在 JSON 中间断开:
css
Chunk 1: data: {"choices":[{"delta":{"content":"你好世
Chunk 2: 界"}}]}\n\ndata: {"choices":...
第一个 chunk 的 JSON 是不完整的------JSON.parse() 会抛出异常。
6.2 缓冲区(Buffer)模式
解决思路是维护一个 buffer 变量:
- 如果当前 chunk 的最后一行 JSON 解析失败 → 将其保存到
buffer - 下一个 chunk 到达时,将
buffer和新的 chunk 拼接后再处理 - 拼接后通常能得到完整的 JSON
javascript
// 关键逻辑:
const chunkValue = buffer + decoder.decode(value); // 拼接上一轮的残余
buffer = ''; // 清空
// ... 处理每一行 ...
} catch (err) {
// 解析失败 → 这一行不完整,留到下一轮
buffer = `data: ${incoming}`; // 保留 data: 前缀
}
6.3 图解 Buffer 模式
swift
┌─────────────────────────────────────────────┐
│ Buffer 流程图 │
├─────────────────────────────────────────────┤
│ │
│ Chunk 1 到达 │
│ ┌──────────────────────┐ │
│ │ data: {"content":"你 │ ← JSON 不完整 │
│ └──────────┬───────────┘ │
│ │ JSON.parse 失败 │
│ ▼ │
│ 存入 buffer = "data: {\"content\":\"你" │
│ │
│ Chunk 2 到达 │
│ ┌──────────────────────┐ │
│ │ 好"}\n\ndata: [DONE] │ │
│ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ 拼接: buffer + chunk2 │
│ = "data: {\"content\":\"你好\"}\n\n..." │
│ │ │
│ ▼ │
│ JSON.parse 成功! ✓ │
│ │
└─────────────────────────────────────────────┘
这个 buffer 模式是流式 JSON 解析的经典方案,在几乎所有 LLM 流式客户端中都能看到它的影子。
七、Vue 3 Composition API:响应式驱动的 UI 更新
7.1 为什么是 Composition API?
本项目的 App.vue 使用 Vue 3 的 <script setup> 语法(Composition API),而非 Vue 2 的 Options API。
javascript
import { ref } from 'vue';
const question = ref('讲cs职业选手niko的故事');
const content = ref('');
const stream = ref(true);
Composition API 的优势在于将相关逻辑组织在一起 。本例中所有与流式请求相关的逻辑(请求、解析、状态更新)都在 update 函数中,而不是分散在 methods、data、computed 等多个选项中。
7.2 ref 的响应式魔法
javascript
const content = ref('');
// 在流式循环中:
content.value += delta; // 每次追加,页面自动局部更新
ref() 创建了一个响应式引用 。当 content.value 发生变化时,Vue 的响应式系统会自动追踪并触发模板中绑定了 content 的 DOM 更新。这让我们可以专注于数据逻辑,而不用手动操作 DOM。
7.3 非流式模式的回退
javascript
if (stream.value) {
// 流式处理...
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
项目通过一个 checkbox 控制 stream 变量,用户可以切换流式和非流式模式。在非流式模式下,直接调用 response.json() 一次性解析整个响应体。这是一个优雅的降级方案。
八、Vite:开发体验的质变
8.1 从 Webpack 到 Vite
本项目的构建工具是 Vite 5,配置极其简洁:
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()]
})
8.2 Vite 的核心优势:ESM + esbuild
Vite 在开发阶段利用浏览器原生 ES Module 支持,实现了按需编译:
- 冷启动快:不像 Webpack 需要先打包整个应用,Vite 只在浏览器请求某个模块时才编译它
- 热更新(HMR)极快:修改文件后,Vite 只重新编译被修改的模块,并推送增量更新到浏览器
这一点对开发体验影响巨大。在传统的 Webpack 项目中,修改一个组件可能需要等 2~5 秒才能看到刷新;而 Vite 通常在毫秒级别完成热更新。
8.3 热更新 vs 全量刷新
传统刷新:
修改文件 → 全量重新编译 → 浏览器刷新 → 页面状态丢失
Vite HMR:
修改文件 → 增量编译变更模块 → 浏览器局部替换 → 页面状态保留
对于本项目的 LLM 对话场景,HMR 意味着:你正在调试流式解析逻辑,修改代码后页面不会刷新,当前的对话状态和 content 变量值都保留着------这大大提升了调试效率。
九、项目完整数据流
将上述所有技术串联起来,整个项目的数据流如下:
scss
┌──────────────────────────────────────────────────────────────────┐
│ 完整数据流 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ① 用户输入问题 │
│ │ │
│ ▼ │
│ ② Vue 响应式变量 question.value 更新 │
│ │ │
│ ▼ │
│ ③ update() 函数触发 │
│ │ │
│ ▼ │
│ ④ fetch() 发送 POST 请求到 DeepSeek API │
│ body: { model: "deepseek-v4-flash", stream: true, ... } │
│ │ │
│ ▼ │
│ ⑤ DeepSeek 服务器开始流式返回 │
│ SSE 格式字节流 → HTTP Response │
│ │ │
│ ▼ │
│ ⑥ response.body.getReader() 获取读取器 │
│ │ │
│ ▼ │
│ ⑦ while 循环逐块读取 Uint8Array │
│ │ │
│ ▼ │
│ ⑧ TextDecoder 解码:Uint8Array → 字符串 │
│ │ │
│ ▼ │
│ ⑨ 拼接 buffer(处理上轮残余) │
│ │ │
│ ▼ │
│ ⑩ 按 \n 分割,过滤 data: 行 │
│ │ │
│ ▼ │
│ ⑪ JSON.parse 解析每个 data chunk │
│ ├─ 成功 → 提取 delta.content → content.value += delta │
│ └─ 失败 → 存入 buffer,等待下一轮拼接 │
│ │ │
│ ▼ │
│ ⑫ Vue 响应式系统检测 content.value 变化 │
│ │ │
│ ▼ │
│ ⑬ 模板中 {{ content }} 对应的 DOM 局部更新 │
│ │ │
│ ▼ │
│ ⑭ 循环直到 reader.read() 返回 done: true 或收到 [DONE] │
│ │
└──────────────────────────────────────────────────────────────────┘
十、关键技术点深度剖析
10.1 response.body?.getReader() 的可选链
javascript
const reader = response.body?.getReader();
这里使用了可选链操作符 ?.。为什么?因为 response.body 在某些老旧浏览器中可能为 null。虽然现代浏览器都支持 ReadableStream,但加上这个检查是良好的防御性编程习惯。
10.2 chunkValue.split('\n') 为什么能处理多个换行?
LLM 服务端可能一次发送多行 data: 消息(取决于它生成 token 的速度和网络缓冲策略)。split('\n') 会将多行拆开,然后 filter(line => line.startsWith('data: ')) 过滤出有效的数据行。空行(只有 \n 的)会被自动忽略。
10.3 [DONE] 的两种到达方式
流结束有两种可能:
reader.read()返回done: true:服务器关闭了连接- 收到
data: [DONE]消息:服务器主动发送结束哨兵
代码中对两种情况都做了处理,确保不会遗漏。
10.4 为什么 buffer 要保留 data: 前缀?
javascript
buffer = `data: ${incoming}`;
因为后续的 split('\n').filter(line => line.startsWith('data: ')) 需要识别 data: 前缀。如果不恢复这个前缀,拼接后的数据会被过滤器丢弃。
十一、代码亮点与值得学习的设计
11.1 变量命名清晰
chunkValue(块的值)、incoming(传入的数据)、delta(增量内容)------每个变量名都准确描述了它的含义和来源阶段。
11.2 注释密度适当
代码中的注释恰到好处地解释了关键概念("水管子,嘬一口"、"llm 服务器 ReadableStream"等),既不过于啰嗦,也不会让人看不懂。
11.3 组合优于分散
所有流式处理逻辑(解码、分割、解析、缓冲、渲染)都在一个 update 函数内,而不是分散在多个 methods 中。这体现了 Composition API 的设计哲学:将相关逻辑放在一起。
十二、主流 AI 产品的流式实现对比
为了拓宽视野,我们来对比一下各主流 AI 产品的流式实现思路:
| 产品 | 流式协议 | 特点 |
|---|---|---|
| ChatGPT (OpenAI) | SSE | data: {"choices":[{"delta":{"content":"..."}}]} |
| Claude (Anthropic) | SSE | 同样使用 SSE,delta 结构略有差异 |
| DeepSeek | SSE | 完全兼容 OpenAI 格式 |
| Gemini (Google) | SSE | 与 OpenAI 格式类似 |
| Ollama (本地) | NDJSON | 每行一个完整 JSON(非增量 delta,而是累积文本) |
可以看到,SSE 是 LLM 流式传输的行业标准。掌握了本文的内容,你就有了与任何 LLM API 对接的能力。
十三、总结
| 主题 | 核心要点 |
|---|---|
| 流式输出 | LLM 边生成边推送 token,用户体验从「等结果」变为「看结果」 |
| SSE 协议 | data: <JSON>\n\n 格式,[DONE] 标记结束 |
| 二进制流 | 网络传输的是 Uint8Array(0~255 的字节),需要 TextDecoder 解码 |
| ReadableStream | 浏览器的流式读取 API,reader.read() 逐块获取数据 |
| Buffer 模式 | 处理 JSON 跨 chunk 截断的经典方案 |
| Vue 3 Composition API | ref 驱动响应式更新,逻辑按关注点聚合 |
| Vite HMR | 开发阶段毫秒级热更新,保留页面状态 |
这个不到 120 行的 Vue 单文件组件,浓缩了 LLM 流式对话前端的核心技术。它麻雀虽小,五脏俱全------从底层的二进制编解码到上层的响应式 UI 更新,每一层都在正确的抽象级别上被处理。
如果你正在做一个 AI 对话产品,或者想深入理解 LLM 流式输出的前端实现,这个 Demo 是一个极好的起点。理解了它,你就理解了 ChatGPT、Claude 等所有主流 AI 产品的核心技术脉络。
本文配套代码位于:stream-demo 项目。运行方式:
npm install && npm run dev,别忘了在.env.local中配置你的 DeepSeek API Key。
关于作者:一个热爱把复杂技术讲清楚的前端开发者。关注 AI 工程化、前端架构和开发者体验。