从0到1手撕流式输出:Vue3 + Vite 实现 LLM 流式响应全解析

你有没有想过,为什么 ChatGPT 能一个字一个字地蹦出来,而不是让你盯着转圈等半天? 这背后究竟是魔法还是工程?本文带你从零手撕一个流式输出 Demo,搞懂 SSE、二进制流、JSON 截断处理的底层原理。

一、Agent 时代:学会把活分给 AI

在 Agent 开发时代,AI 越来越像人,正在走向 AGI。但我们要清醒地认识到:AI 不是万能的,学会分工才是关键

1.1 工作拆分原则

交给 Agent 的活 我们自己接管的活
项目工程初始化 核心业务逻辑
重复模板代码 架构决策
文档生成 最终审核

为什么不自己写 src/App.vueindex.html 没有必要从 0 开始写一个 Vue 项目,直接到 GitHub 拉取一个模板项目,把时间花在更有价值的事情上。


二、热更新:Vite 给开发者的礼物

2.1 什么是热更新(Hot Reload)

开发阶段,我们希望修改代码后不用刷新浏览器就能看到变化,这就是热更新。

复制代码
文件修改 → 局部刷新(不丢失页面状态)

2.2 为什么需要它?

如果每次修改都整页刷新,Vue/React 中那些密密麻麻的数据状态就全丢了------用户输入的内容、表单进度、弹窗状态全部归零。热更新只替换变更的模块,保留当前页面状态,开发体验直接拉满。

这个功能来自 Vite,基于 ES Module 实现,是现代前端开发的利器。


三、流式输出的本质:二进制流

3.1 Stream 是什么?

stream 返回的本质是二进制流 ,在 JavaScript 中就是 Uint8Array

js 复制代码
// 0-255 之间的无符号整数数组
Unit8Array[十进制数, ...]

3.2 编码与解码

字符串和二进制流之间的转换靠 TextEncoder / TextDecoder

js 复制代码
const encoder = new TextEncoder();
// 字符串 → Uint8Array 二进制
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); // 你好

原理:你好 ↔ 编码数字(如 128、129)↔ 二进制,0-255 每个数字对应一个字节。


四、Server 流式输出:SSE 协议全解析

4.1 后端返回的数据流长什么样?

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

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

data: [DONE]

几个关键特征:

  • 二进制文本:本质是一串文本流
  • \n 换行符 区分每个 data: 数据块,一行结束
  • data: {} 是 JSON 格式文本,结构和 completion 类似
  • data: [DONE] 标识流结束

4.2 为什么用换行分隔?

兼顾响应速度传输效率

  • LLM 生成 token 时返回 JSON,格式短、解析快
  • 一次性发送多少行 data: 不确定,可能一行,也可能两三行(取决于 LLM 计算速度)

4.3 核心难点:JSON 截断问题

⚠️ 数据包有大小限制,当 JSON 数据超过缓冲区大小时,会被截断!

css 复制代码
// 实际场景:一个完整的 JSON 可能被拆成两半
第1个 chunk: data: {"choices":[{"delta":{"content":"你
第2个 chunk: 好"}}]}

如果直接 split('\n') 然后 JSON.parse()极有可能失败

4.4 解决方案:buffer 缓冲区机制

js 复制代码
let buffer = '';  // 存上一次 JSON.parse 失败的不完整数据

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

  // 关键:把上一轮的 buffer 拼到这一轮前面
  const chunkValue = buffer + decoder.decode(value);
  buffer = '';  // 拼完后清空 buffer

  const lines = chunkValue.split('\n')
    .filter(line => line.startsWith('data:'));

  for (const line of lines) {
    const incoming = line.slice(6);  // 去掉 "data: " 头(6个字符)
    if (incoming === '[DONE]') { done = true; break; }

    try {
      const data = JSON.parse(incoming);
      const delta = data.choices[0].delta.content;
      if (delta) content.value += delta;
    } catch (err) {
      // JSON 不完整,存回 buffer,下一轮接着拼
      buffer = `data: ${incoming}`;
    }
  }
}

核心思想:解析失败不扔掉,存到 buffer 里,等下一轮数据到来时拼接后再解析。


五、完整实战:Vue3 调用 DeepSeek 流式 API

5.1 核心代码

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

const question = ref('讲一个中国龙的故事');
const content = ref('');
const stream = ref(true);

const update = async () => {
  if (!question.value) return;
  content.value = '思考中...';

  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value  // 开启流式输出
    })
  });

  if (stream.value) {
    content.value = '';
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';

    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      done = doneReading;
      const chunkValue = buffer + decoder.decode(value);
      buffer = '';

      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data:'));

      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === '[DONE]') { done = true; break; }
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) content.value += delta;
        } catch (err) {
          buffer = `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }
}
</script>

5.2 关键概念速查

概念 含义
response.body 服务器响应体,是一个 ReadableStream 二进制流
getReader() 获取读取器,像"水管子嘬一口",有数据就 resolve,没数据就等
delta 偏移量/增量,每次返回的一小块 token
[DONE] 流结束标识

5.3 两种结束方式

  1. done: truereader.read() 返回时设置
  2. data: [DONE]:服务端单独发送一条结束文本流

六、总结

流式输出的核心就三件事:

  1. 二进制流Uint8Array + TextEncoder/Decoder 编解码
  2. SSE 协议data: 开头 + \n 分行 + [DONE] 结束
  3. buffer 机制:JSON 截断时暂存不完整数据,下一轮拼接再解析

理解了这三点,你就能手撕任何 LLM 的流式响应了。

💡 动手试试:把代码跑起来,输入一个问题,看着 AI 一个字一个字蹦出来的瞬间,你会真正理解"流式"的魅力。

相关推荐
光影少年1 小时前
react navite跨端项目的痛点、踩过哪些坑、怎么解决
前端·react native·react.js
默_笙1 小时前
🧀 用户访问一个网站到底经历了什么?全栈部署的"城市观光"指南
前端·javascript
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_trichart_natal_composite_transit2接口指南
前端·php
是立不是利2 小时前
CSS 架构——在混乱中建立秩序
前端·html
两只羊ovo2 小时前
让 Agent 接上“万能接口”:LangChain + MCP 实战,工具不再锁死在项目里
前端
妙码生花2 小时前
使用git更新ai-go-admin框架
前端·人工智能·git·golang·typescript·php
YUJIANYUE2 小时前
查立得万用查分电脑版(web环境+查询系统免安装单文件一键运行包)
前端·jvm
天道kabuto2 小时前
Vite 项目报错 @rollup/rollup-win32-x64-msvc 的解决与版本锁定
vite
小聪7082 小时前
基于node实现一个轻量化web引擎:elpis-core
前端