从零搭建 LLM 流式对话前端:一文读懂 SSE、二进制流与 Vue 3 响应式

序言

本文将以一个完整的 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 编码与解码:TextEncoderTextDecoder

字符 ↔ 数字(字节)的映射关系,就是字符编码

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 是这次读到的 Uint8Arraydone 表示流是否结束

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 变量:

  1. 如果当前 chunk 的最后一行 JSON 解析失败 → 将其保存到 buffer
  2. 下一个 chunk 到达时,将 buffer 和新的 chunk 拼接后再处理
  3. 拼接后通常能得到完整的 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 函数中,而不是分散在 methodsdatacomputed 等多个选项中。

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] 的两种到达方式

流结束有两种可能:

  1. reader.read() 返回 done: true:服务器关闭了连接
  2. 收到 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 工程化、前端架构和开发者体验。

相关推荐
没落英雄1 小时前
7. 从零开始搭建一个 AI Agent —— UI 卡片渲染系统
前端·人工智能·架构
前端缘梦1 小时前
LangGraph 核心特性技术详解:流式输出、持久化、记忆体系与中断实战。
前端·人工智能·程序员
张龙6871 小时前
前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
前端·javascript
渣波1 小时前
从零手写 Vite Mock 流式 AI 对话,搞懂 LLM 的“打字机”特效
前端·javascript
温柔过期啦1 小时前
网站改了标题但是搜索引擎却没改?怎么样才能快速更新?
前端·搜索引擎·百度
cxxcode1 小时前
JavaScript 模块化:从模块规范到打包原理
前端·webpack
驰子1051 小时前
不重写、不换框架:让十年 jQuery 老项目写出 Vue 手感的渐进式改造
前端
月弦笙音1 小时前
# SSE 连接可靠性方案
前端
weedsfly1 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试