大模型的回答是怎么一个字一个字蹦出来的?前端流式输出解析

你有没有好奇过:问 ChatGPT 一个问题,它的回答是"一个字一个字往外蹦"的,而不是等几十秒一次性甩出一大段。这背后到底藏着什么前端技术?今天我们就从 0 到 1 拆解它。

写在前面

我们正处在一个特殊的时代节点------Agent 开发时代。AI 正越来越像人,一步步走向 AGI。作为前端工程师,新的命题出现了:

如何把工作拆分?把 AI 擅长的交给 Agent,我们负责审核;不擅长的我们接管。

举个小例子:项目工程初始化,完全没必要从 0 手写一个 Vue 项目(src/App.vueindex.html 一堆文件),直接到 GitHub 拉一个模板项目,交给 Agent 去搭就行。

而要跟大模型打交道,流式输出(Streaming) 就是绕不开的第一课。这篇文章带你彻底搞懂它。


一、开发态的底气:Vite 热更新

在聊流式输出之前,先说一个开发阶段的好东西------热更新(Hot Reload),它来自 Vite。

传统开发模式下,每次改完代码都要刷新浏览器 才能看到效果。但刷新页面有个致命问题:页面状态会丢失。Vue / React 里密密麻麻的数据状态(表单填了一半、列表滚到中间、弹窗打开了......)全没了,得重新来一遍。

热更新做的是另一件事:

复制代码
文件修改 → 局部刷新(只替换改动的模块,保留页面状态)

有了它,改代码时页面状态稳稳保留,开发体验丝滑得不行。这是现代前端工程化的基础,也是我们能愉快调试流式输出代码的前提。


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

2.1 服务器返回的是什么?

一句话:stream 返回的就是二进制流

具体来说,它是一个 Uint8Array------无符号 8 位整数数组,每个数在 0-255 之间。这就是网络世界里最原始的"数据形状":一个个字节。

javascript 复制代码
Uint8Array[228, 184, 173, ...]   // 这些十进制数就是字节

字节不是字符,一个中文字(如"中")在 UTF-8 里占 3 个字节,所以必须安排编码 才能还原成我们能读的字符串。这就是 TextDecoder 的活儿。

2.2 ReadableStream:一根水管

浏览器原生提供了 ReadableStream 对象,可以把它想象成一根水管

  • 服务器那头开水龙头,数据一滴一滴流过来
  • 浏览器这头可以一边接一边处理,不用等整桶水装满

这就是为什么大模型能"边生成边显示"------模型每算出一个 token(最小的语义单元),就立刻顺着管子推给浏览器,用户即时看到。

2.3 SSE:数据长什么样?

流式输出用的是 SSE(Server-Sent Events) 格式。服务器推过来的原始文本长这样:

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

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

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

data: [DONE]

规则就三条:

  1. 每条消息以 data: 开头
  2. 消息之间用空行(两个 \n)分隔
  3. 最后一条固定是 data: [DONE],表示流结束

注意流式里内容字段叫 delta (增量),不是非流式里的 message


三、实战:Vue 3 调用 DeepSeek 实现流式对话

3.1 技术栈一览

技术 角色
Vue 3 <script setup> 组合式 API,逻辑组织更聚合
ref 创建响应式数据,数据变页面自动更新
fetch 浏览器原生 HTTP 客户端,无需 axios
ReadableStream + getReader() 逐块读取响应体
TextDecoder 字节流 → 字符串
SSE 按行解析 提取每一段增量内容

3.2 数据状态设计

用三个 ref 搞定整个页面的状态:

js 复制代码
import { ref } from 'vue'

const question = ref('讲一个中国龙的故事')  // 用户输入
const content = ref('')                     // LLM 回答内容
const stream = ref(true)                    // 是否流式

为什么用 ref? 普通变量改了 Vue 不知道,页面不会变。ref() 把值包装成响应式引用对象(RefImpl),xxx.value 一改,页面上绑定它的地方会局部热更新,不用碰 DOM。

3.3 发请求:fetch + DeepSeek API

js 复制代码
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
  'Content-Type': 'application/json',
  Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question.value }],
    stream: stream.value
  })
})

几个要点:

  • API Key 从哪来? import.meta.env.VITE_DEEPSEEK_API_KEY 是 Vite 的环境变量注入。项目根目录放一个 .env 文件,写 VITE_DEEPSEEK_API_KEY=sk-xxx。变量名必须以 VITE_ 开头,Vite 才会暴露给前端,既安全又方便多环境切换。
  • Authorization: Bearer xxx 是 REST API 标准认证格式(Bearer + 空格 + Token)。
  • JSON.stringify 必不可少------HTTP 只能传字符串/二进制,传不了 JS 对象。
  • messages 遵循 OpenAI 格式:每条消息有 rolesystem/user/assistant)和 content

四、流式核心:边接边显示

这是全文最关键的部分。分三步走。

第一步:拿到读取器(吸管)

js 复制代码
const reader = response.body?.getReader()

response.bodyReadableStream,不能直接读,必须通过 getReader() 拿一个"读取器"来一口一口地"嘬"。?. 是可选链,防止 body 为 null 时报错。

第二步:准备工具

js 复制代码
const decoder = new TextDecoder()  // 字节 → 字符串
let done = false                   // 流是否读完的开关
let buffer = ''                    // 缓存半截 JSON

buffer 为什么必须存在? 这是 SSE 解析的核心坑。一个 chunk 末尾可能是 data: {"conten,下一个 chunk 开头才是 t":"你好"}。如果本轮解析失败就丢掉,内容直接缺失。必须把"半截"存进 buffer,下一轮拼上再解析。

第三步:循环读取 + 解析 + 追加

js 复制代码
content.value = ''  // 清空"思考中"

while (!done) {
  // 嘬一口数据,没嘬到就 await 等着
  const { value, done: doneReading } = await reader.read()
  done = doneReading

  // 字节 → 字符串,拼上上一轮的半截
  const chunkValue = buffer + decoder.decode(value)
  buffer = ''

  // SSE 按行拆分,只留 data: 开头的
  const lines = chunkValue.split('\n')
    .filter(line => line.startsWith('data:'))

  for (const line of lines) {
    // 去掉 "data: " 前缀
    const jsonStr = line.replace(/^data:\s*/, '')

    // 流结束标记
    if (jsonStr === '[DONE]') {
      done = true
      break
    }

    try {
      const data = JSON.parse(jsonStr)
      // 流式取 delta.content,逐字追加
      const delta = data.choices[0].delta?.content || ''
      content.value += delta  // 响应式自动更新页面
    } catch {
      // JSON 解析失败 = 半截,塞回 buffer 等下一轮
      buffer = jsonStr
    }
  }
}

关键点逐个说:

  1. await reader.read() 会在这里暂停等待,直到下一块数据到达。这就是"边生成边显示"的底层机制------模型不吐数据,代码就停在这儿等。
  2. done: doneReading 是解构时重命名,避免和外层 done 变量冲突。
  3. content.value += delta 每次追加一个字,Vue 响应式系统自动更新 {{ content }} 绑定的节点,页面就"一个字一个字蹦出来"。
  4. try/catch 兜底半截 JSON:解析失败不报错,存进 buffer 等下轮拼上再试,保证内容不丢。

非流式分支对比

js 复制代码
} else {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

非流式时服务器等全部算完一次性返回完整 JSON,注意字段是 message 不是 delta


五、页面:双向绑定让数据流起来

vue 复制代码
<template>
  <div class="container">
    <div>
      <input v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>
  • v-model :双向绑定。输入框打字 → question.value 自动变;JS 里改 question.value → 输入框也跟着变。复选框的 v-model 绑定的是布尔值。
  • @click="update":点击触发请求函数。
  • {{ content }} :插值语法,content 一变就自动重渲染。

整条数据链路:

css 复制代码
用户输入 → v-model → question.value → fetch 请求 → 流式读取 → content.value += delta → {{ content }} 自动更新

全程不碰 DOM,改数据就行------这就是 Vue 数据驱动视图的魅力。


六、样式:Flex 弹性布局一统江湖

css 复制代码
.container {
  display: flex;
  flex-direction: column;   /* 主轴纵向 */
  align-items: start;       /* 交叉轴左对齐 */
  justify-content: start;   /* 主轴顶对齐 */
  height: 100vh;            /* 占满视口高度 */
  font-size: 0.85rem;       /* rem 相对 html,移动端友好 */
}

Flexbox 是现代布局首选,比 float/position 简单太多。100vh 让容器撑满屏幕,rem 做移动端等比例适配。


总结:一图看全流程

javascript 复制代码
用户点提交
   ↓
fetch POST → DeepSeek API(stream: true)
   ↓
response.body → getReader() 拿吸管
   ↓
while 循环:
  await reader.read()  ← 等数据
  decoder.decode()     ← 字节转字符
  split + filter       ← 拆 SSE 行
  JSON.parse           ← 取 delta.content
  content.value += delta ← 追加,页面自动更新
   ↓
data: [DONE] → 退出循环

一句话记住: 流式输出 = ReadableStream 逐块读 + TextDecoder 解码 + SSE 按行解析 + ref 响应式追加。掌握这条链路,你就拿住了大模型时代前端的入场券。


Agent 时代已来,热更新让开发丝滑,流式输出让交互即时。这些不是炫技,而是新时代前端的日常。下一站,我们继续探索如何用 LangChain 把大模型的能力变成真正可用的 Agent。

相关推荐
计算机魔术师15 分钟前
Anthropic 复盘 Claude 模型越权访问真实系统事件并改进对齐与安全措施
前端
掘金者阿豪22 分钟前
Seedance 2.0/2.5 虚拟素材能跨 Key 共用吗?一次讲清 Asset ID、账号隔离与 SaaS 素材架构
前端·后端
用户500937683903932 分钟前
热敏小票 Web 打印:我在 58/80mm 上折腾的两周
前端·vue.js
XGM39 分钟前
一个前端新手的"顿悟"时刻:DOM树、渲染原理和JS动态渲染
前端
简单风1 小时前
别再让 Agent 光会表演:能过编译、过 Schema、过检查的,才叫真正落地
agent
东方小月1 小时前
一篇文章带你深入拆解Skill的本质与工程实现,让你不再滥用Skill
前端·人工智能·后端
阿里云云原生1 小时前
评估:从黄金指标到 Rubric丨AgentLoop 数据飞轮实践(三)
agent
xy34531 小时前
Axure9.0 中继器遮罩的核心应用场景(精准适配列表交互)
前端·ui·html·原型·产品设计·axure9.0
天道kabuto2 小时前
记一次 UnoCSS 样式离奇失效的踩坑复盘
前端