从敲下回车到文字逐字出现:AI 流式输出的前端必修课

你有没有好奇过:为什么在 ChatGPT 或 DeepSeek 的对话框里敲下一个问题,回答是一个字一个字"蹦"出来的,而不是像普通网页请求那样转半天然后"啪"一下全部出现?

这背后藏着一个前端开发者在 AI 时代必须掌握的技术------流式输出(Streaming) 。今天我们不堆砌概念,而是从浏览器拿到数据的那一刻开始,一步步拆解"流"到底是怎么流起来的。

一、先搞懂一个问题:LLM 为什么"慢"

在理解流式输出之前,得先明白大语言模型(LLM)的"慢"从哪来。

LLM 生成回答的过程,本质上是一个逐 token 的自回归过程 。所谓 token,可以粗浅理解为"最小的文本单元"------可能是一个汉字、一个英文单词的一部分,甚至是一个标点符号。模型每生成一个 token,都要把它拼回已有的上下文,再预测下一个 token 是什么。这个过程涉及大量的矩阵运算,参数量越大、回答越长,耗时就越久

如果等模型把全部内容生成完再一次性返回,用户等待的时间就是 N 个 token 的推理时间总和 。而流式输出的思路极其简单:不用等全部生成完,生成一个 token 就给你一个 token

这就是流式输出的核心逻辑。

二、HTTP 协议层到底发生了什么

流式输出能跑起来,靠的是前后端之间的一个约定

客户端在请求体里带上 stream: true,相当于告诉服务器:"我不要等你全部做完再回复,你边做边给我就行。"服务端收到这个参数后,不会等到全部内容生成完毕才写入响应体,而是每推理出一个 token 就立刻写入

text

arduino 复制代码
// 请求体中的关键参数
{
  model: "deepseek-v4-flash",
  messages: [{ role: "user", content: "讲一个中国龙的故事" }],
  stream: true   // ← 就是这个开关
}

在 HTTP 协议层面,这依赖于 分块传输编码(Chunked Transfer Encoding) 。服务器不需要提前知道响应体的总长度,可以一边生成数据一边把数据块发给客户端。浏览器这边,则通过一套叫作 Web Streams API 的机制来接收这些陆续到达的数据块

三、浏览器端的武器:ReadableStream

当你用 fetch 发起一个 stream: true 的请求后,返回的 response 对象里藏着一个关键属性------response.body

javascript

php 复制代码
const response = await fetch(endpoint, {
  method: "POST",
  headers: { /* ... */ },
  body: JSON.stringify({
    model: "deepseek-v4-flash",
    messages: [{ role: "user", content: question.value }],
    stream: true
  })
});

console.log(response.body);  // ReadableStream 对象

response.body 是一个 ReadableStream 对象 。你可以把它理解成一根"水管"------服务器那边源源不断地往管子里送水(数据),浏览器这边负责从管子的另一头接水

拿到 ReadableStream 之后,我们要做两件事:

  1. 获取一个"读取器"(reader) ,用来从水管里一口一口"嘬"数据
  2. 循环读取,直到服务器说"数据发完了"

javascript

ini 复制代码
const reader = response.body.getReader();  // 拿到读取器[reference:13]
let done = false;

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  // value 就是这一口"嘬"到的数据
}

reader.read() 返回一个 Promise,resolve 之后得到一个对象,包含两个字段:

  • value:本次读取到的数据块,类型是 Uint8Array
  • done:布尔值,true 表示数据已经全部读完

四、Uint8Array 与 TextDecoder:二进制怎么变回文字

这里有一个关键问题:网络传输的底层是字节流reader.read() 拿到的 value 是一个 Uint8Array------也就是一堆 0 到 255 之间的数字组成的数组。

但我们要显示的是中文、英文、标点符号,不是数字。所以需要一个解码器,把这些字节还原成字符串。

javascript

scss 复制代码
const decoder = new TextDecoder();  // 创建一个 UTF-8 解码器[reference:16]

// 在循环里:
const chunk = decoder.decode(value);  // 把 Uint8Array 转成字符串
content.value += chunk;              // 拼到已有的内容后面

TextDecoder 支持流式解码 ------也就是说,如果一个字符的字节被拆到了两个数据块里(比如一个中文汉字在 UTF-8 下可能占 3 个字节),TextDecoder 能正确处理这种跨块的情况,不会出现乱码。

五、SSE 数据格式:一行一行解析

DeepSeek API 的流式响应遵循 SSE(Server-Sent Events) 格式。简单说,服务器每次发送一个数据块,格式是这样的:

text

css 复制代码
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"遥远"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"的"}}]}

data: [DONE]

每一行以 data: 开头,后面跟着一个 JSON 字符串。delta 字段里装着本次新增的内容(也就是新生成的那个 token)。最后一行是 data: [DONE],表示全部发送完毕。

所以在前端,我们不能直接把 decoder.decode(value) 的结果拼到页面上------需要先按行解析,提取出 delta.content,再拼进去。

javascript

ini 复制代码
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 jsonStr = line.replace("data: ", "");
    if (jsonStr === "[DONE]") {
      done = true;
      break;
    }
    try {
      const parsed = JSON.parse(jsonStr);
      const deltaContent = parsed.choices?.[0]?.delta?.content;
      if (deltaContent) {
        content.value += deltaContent;  // 逐字拼接到页面上
      }
    } catch (e) {
      // 忽略解析错误(可能是不完整的 JSON)
    }
  }
}

这里有一个容易被忽视的细节:网络数据块不保证按"行"对齐 。也就是说,一次 reader.read() 拿到的数据可能只包含半行,也可能包含好几行。所以我们用了一个 buffer 变量来缓存上一次没处理完的尾巴,等下一轮数据来了再拼起来一起解析。

六、在 Vue 3 里串起来

有了上面的基础,我们把它放进 Vue 3 的组件里。

vue

ini 复制代码
<script setup>
import { ref } from "vue";

const question = ref("讲一个中国龙的故事");
const content = ref("");
const useStream = 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: useStream.value
    })
  });

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

    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 jsonStr = line.replace("data: ", "");
        if (jsonStr === "[DONE]") {
          done = true;
          break;
        }
        try {
          const parsed = JSON.parse(jsonStr);
          const delta = parsed.choices?.[0]?.delta?.content;
          if (delta) content.value += delta;
        } catch (_) {}
      }
    }
  } else {
    // 非流式模式:一次性拿到全部结果
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }
};
</script>

这里用了 Vue 3 的 ref 来定义响应式数据content.value 每变化一次,页面上绑定了 {{ content }} 的地方就会自动更新------这就是 Vue 数据驱动视图 的核心机制

七、热更新:开发阶段的"局部刷新"

文章最后顺带提一下笔记里提到的热更新(Hot Reload)

在开发阶段,我们用的是 Vite 这样的构建工具。当我们修改了 App.vue 的代码,Vite 会检测到文件变化,然后只替换发生变化的那部分模块,而不是刷新整个页面。

这意味着什么?你在页面上输入了一段话、展开了一个菜单、滚动到了某个位置------这些页面状态不会因为代码修改而丢失。如果是传统的"保存→刷新页面"流程,所有状态都会重置,你就得重新操作一遍才能回到刚才的调试位置。

Vue/React 的响应式数据状态往往"密密麻麻"地分布在各个组件里,热更新让开发者能保持当前的调试上下文,效率提升非常明显。

总结

流式输出的完整链路可以概括为四步:

  1. 客户端 在请求中设置 stream: true,告诉服务器"我要流式数据"
  2. 服务端每生成一个 token 就通过 HTTP 分块传输发出来,格式为 SSE
  3. 浏览器 通过 response.body.getReader() 逐块读取数据
  4. 前端TextDecoder 解码、按行解析 SSE、提取 delta.content 后逐字渲染

这四个环节环环相扣,少一个都跑不起来。掌握了这条链路,你就掌握了 AI 时代前端与 LLM 交互的核心基本功。

相关推荐
数聚天成DeepSData1 小时前
企业知识库 RAG 数据准备与文档清洗:Dify、RAGFlow、扣子选型指南
开发语言·人工智能·机器学习·自然语言处理·sentinel·cocos2d
statistican_ABin1 小时前
2026 FIFA 世界杯比赛与球队数据探索性分析
人工智能·python·数据挖掘·数据分析
丘丘用户思思澪1 小时前
LangChain Agent 完全指南
人工智能·langchain
开源量化GO1 小时前
最新AI量化学习路径,从交易想法走到条件和动作
人工智能·python
科技发布1 小时前
2026户外广告数字化升级:传播易以AI技术重构全域户外品效营销体系
人工智能·重构
SNKXD_11 小时前
避开选型认知误区:2026年实测8款智能AI数字人平台总结筛选标准
大数据·人工智能·机器学习
未来和明天1 小时前
领嵌iLeadE-588边缘计算盒子16路AI视频分析、4路AHD、4路千兆网、4G/5G通讯、标准API接口
人工智能·5g·边缘计算
微硬创新2 小时前
耐达讯自动化PROFINET转DeviceNet网关:医药包装生产线的合规与效率双保障
服务器·人工智能·物联网·网络协议·自动化·信息与通信
俊哥V2 小时前
每日 AI 研究简报 · 2026-07-15
人工智能·ai