# 从零开始,打造 AI 产品的第一个关键体验——流式输出

从零开始,打造 AI 产品的第一个关键体验------流式输出

为什么 ChatGPT、DeepSeek 的回答像打字机一样逐字出现?这篇文章带你从"用户体感"到"代码实现",彻底搞懂流式输出(Streaming)。


一、问题:一次性返回,用户等不起

想象一个场景:你问 AI "讲一个关于中国龙的故事"。背后发生了什么?

  1. 服务器收到你的问题
  2. LLM 开始推理(Transformer 逐层计算)
  3. 生成完整回答
  4. 把整段文字打包,一次性返回给你

问题出在第 2 步和第 3 步。对于复杂问题,推理耗时可能长达数十秒。用户盯着一片空白的屏幕,不知道系统是在"工作中"还是"卡死了"------这就是传统一次性返回的痛点。

核心矛盾:问题越复杂 → 推理越久 → 等待越长 → 体验越差。


二、解法:流式输出,生成一个字就推送一个字

流式输出的思路非常直观:

ini 复制代码
传统方式: [推理全部完成] → [一次性返回整段文字]
流式方式: 推→理→中→的→每→一→个→token→实→时→展→示

LLM 并不是一次性"想好"整段回答再输出的。它的生成过程本身就是逐 token 推理 的------每生成一个 token(可以理解为"一个字或词"),就用它来预测下一个。流式输出不过是把这个过程实时暴露给用户看。

这样一来,用户体验完全不同:

  • ✅ 立刻看到第一个字出现,知道"系统在干活了"
  • ✅ 像聊天一样自然------对方一边想一边说,而不是沉默半分钟然后丢给你一大段
  • ✅ 等得不耐烦时可以提前判断内容是否符合预期,不符合直接打断

这就是为什么所有主流 AI Chatbot(ChatGPT、DeepSeek、Claude、文心一言......)都采用打字机式的流式输出------这是 AI 产品的第一个关键用户体验。


三、底层原理:从协议层理解流式传输

流式输出不是"魔法",是实实在在的计算机网络技术。

3.1 服务端约定

调用 LLM API 时,请求体中有一个关键参数:

json 复制代码
{
  "model": "deepseek-v4-flash",
  "messages": [...],
  "stream": true   // 👈 这一个布尔值,决定了"流式"还是"非流式"
}
  • stream: false(默认):服务器生成完整回答后,一次性返回 JSON。
  • stream: true:服务器每生成一个 token,就立即以 SSE(Server-Sent Events) 格式推送一段数据。

请求体 JSON 各字段解析:

字段 类型 必填 含义
model string 是 模型标识符。deepseek-v4-flash 是速度优先模型,此外还有 deepseek-v4(通用)、deepseek-r1(推理增强)。不同模型的能力、速度和计费各不相同
messages array 是 对话消息数组,每项包含 role 和 content。role 有三种:system(系统指令,定义 AI 行为边界)、user(用户发言)、assistant(AI 过往回复)。LLM API 是无状态的------每次请求都要把完整对话历史传过去,服务器不会帮你记上下文
stream boolean 否 核心参数。默认 false。设为 true 后,HTTP 响应头 Content-Type 变成 text/event-stream,响应体从一次性的 JSON 对象变成持续的 SSE 数据流

3.2 客户端处理

客户端收到的是一个个数据块(chunk),而不是完整的 JSON。每个 chunk 长这样:

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

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

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

...

客户端需要做的事情很简单:不断拼接这些 delta content,更新到 UI 上。

SSE 数据块逐行拆解:

上面的 4 个 chunk,逐行来看:

行 关键字段 含义
第 1 行 delta.content: "中" 第一个 token,AI 生成的第一个字。finish_reason: null 表示还没结束
第 2 行 delta.content: "国" 第二个 token。每条 SSE 消息之间用空行隔开
第 3 行 delta.content: "龙" 第三个 token。客户端把这些 delta 逐个拼起来就得到了完整的回答
最后 delta: {} + finish_reason: "stop" 生成完毕的信号。注意此时 delta 是空对象,content 为 undefined------不要把它拼成 "undefined" 字符串
[DONE] 特殊标记 DeepSeek 在流彻底结束后发送的标志,不是 JSON,需要单独判断

核心区分 :非流式返回里内容是 choices[0].message.content(完整),流式返回里内容是 choices[0].delta.content(增量)。message 和 delta 一字之差,取错路径就拿不到数据。

3.3 浏览器中的实现

在现代浏览器中,我们使用 fetch API + ReadableStream 来实现流式读取:

javascript 复制代码
const response = await fetch(endpoint, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${API_KEY}`
  },
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question }],
    stream: true   // 开启流式
  })
});

// response.body 是一个 ReadableStream(可读流)
const reader = response.body.getReader();   // 获取读取器
const decoder = new TextDecoder();           // 二进制 → 文本解码器

let done = false;
while (!done) {
  const { value, done: isDone } = await reader.read();
  done = isDone;
  if (value) {
    const text = decoder.decode(value, { stream: true });
    // 解析 SSE 格式,提取 delta.content,拼接到 UI
    content.value += extractContent(text);
  }
}

整个流程可以概括为:

scss 复制代码
HTTP Response Body (二进制流)
    → ReadableStream.getReader()
    → 逐块读取 (while loop)
    → TextDecoder 解码(二进制 → 文本)
    → 解析 SSE data 字段
    → 提取 choices[0].delta.content
    → 拼接到前端界面

代码逐段解析:

代码段 做了什么 关键细节
fetch(endpoint, { method: 'POST', ... }) 向 DeepSeek API 发起 POST 请求 method: 'POST' 是因为要发送 JSON 请求体;POST 在 HTTPS 下是加密的,API Key 不会暴露在 URL 里
headers: { Authorization: 'Bearer ...' } 携带 API Key 进行身份认证 Bearer 是 HTTP 标准的令牌传递方式------"持有此令牌者即有权访问"。模板字符串 Bearer ${KEY} 拼接出完整的认证头
body: JSON.stringify({...}) 把 JS 对象序列化为 JSON 字符串 stream: true 是这个调用里最重要的一行,它告诉服务器"请逐 token 推送"
response.body.getReader() 获取可读流的读取器 response.body 是 ReadableStream<Uint8Array> 类型;getReader() 返回一个读取器并锁定该流------同一时间只能有一个 reader 在读取
new TextDecoder() 创建 UTF-8 解码器 默认编码就是 UTF-8。作用是把 Uint8Array(字节)→ JavaScript 字符串
await reader.read() 异步读取下一个数据块 返回 { value: Uint8Array, done: boolean }。数据还没到达时会 await 等待;流结束时 done 为 true,value 为 undefined
decoder.decode(value, { stream: true }) 流式解码 { stream: true } 是关键------它告诉解码器"后面还有数据",避免把一个多字节字符(如中文 UTF-8 3 字节)从中间截断导致乱码 �
content.value += extractContent(text) 解析 SSE 并追加到界面 每收到一块数据就立即显示,用户看到的就是打字机效果。Vue 的响应式系统会自动把 content 的变化同步到 DOM

{ stream: true } 为什么不能省略? 中文字符在 UTF-8 中占 3 个字节,网络传输按字节切块时,一个字可能恰好被劈成两半(块 1 含前 2 字节,块 2 含最后 1 字节)。不加 { stream: true },解码器会把不完整的字节直接输出为乱码 ��;加上后,解码器内部维护一个缓冲区,暂存不完整字节,等下一块到了拼接完整再输出。


四、前端实战:Vite + Vue 3 + DeepSeek API

下面通过一个完整的 Demo 来串联所有知识。

4.1 技术栈

角色 技术
构建工具 Vite(脚手架,由 Node.js 驱动)
前端框架 Vue 3(<script setup> 语法糖)
LLM API DeepSeek Chat Completions

4.2 项目结构

ruby 复制代码
stream-demo/
├── index.html          # 入口 HTML,<div id="app"> 是 Vue 挂载点
├── vite.config.js      # Vite 配置,引入 @vitejs/plugin-vue
├── package.json        # 依赖:vue 3 + vite 8
├── .env.local          # VITE_DEEPSEEK_API_KEY=sk-xxx(Vite 自动读取)
└── src/
    ├── main.js         # 创建 Vue 应用,挂载到 #app
    ├── App.vue         # 根组件:输入框 + 流式开关 + 内容展示
    └── style.css       # 全局样式

关键细节 :Vite 会自动读取 .env.local 中以 VITE_ 为前缀的环境变量,在代码中通过 import.meta.env.VITE_DEEPSEEK_API_KEY 访问。这样 API Key 就不会被提交到 Git 仓库。

项目结构逐文件职责:

文件 角色 关键点
index.html 浏览器入口 Vite 以 HTML 为入口(不是 JS)。<div id="app"> 是 Vue 的挂载点,<script type="module" src="/src/main.js"> 让浏览器以 ES Module 方式加载
vite.config.js 构建配置 @vitejs/plugin-vue 插件让 Vite 能解析 .vue 文件。Vite 本身只认 .js/.ts,处理 .vue 全靠这个插件
package.json 依赖声明 vue 是运行时框架,vite 和 @vitejs/plugin-vue 是开发时工具。"type": "module" 让 Node.js 也使用 ES Module 模式
.env.local 密钥存储 .local 后缀表示本机独有,应加入 .gitignore。只有 VITE_ 前缀的变量才会暴露给浏览器端代码------这是 Vite 的安全机制
src/main.js JS 入口 调用 createApp(App).mount('#app') 启动 Vue 应用。import './style.css' 注入全局样式
src/App.vue 根组件 包含全部 UI 和业务逻辑:输入框、流式开关、API 调用、流式读取

4.3 核心代码:App.vue

vue 复制代码
<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input type="text" class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const question = ref('讲一个关于中国龙的故事')
const stream = ref(false)
const content = ref('')

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

    while (!done) {
      const { value, done: isDone } = await reader.read()
      done = isDone
      if (value) {
        const text = decoder.decode(value, { stream: true })
        // 逐块解析 SSE 并拼接
        content.value += extractContent(text)
      }
    }
  } else {
    // ===== 非流式输出 =====
    const data = await response.json()
    content.value = data.choices[0].message.content
  }
}
</script>

4.4 运行

bash 复制代码
npm run dev
# Vite 启动开发服务器,默认打开 http://localhost:5173

勾选 "Streaming" 复选框,点击提交------你会看到回答像打字机一样逐字出现。

App.vue 逐层解析:

Template(模板层)------每条指令的含义:

代码 Vue 指令 做了什么
{{ content }} 插值表达式 把 content 这个响应式变量的值实时渲染到页面。content 每变一次,这里自动更新------不需要手动操作 DOM
v-model="question" 双向绑定 等价于 :value="question" + @input="question = $event.target.value"。数据变→视图变,用户输入→数据变,双向同步
v-model="stream" 双向绑定(checkbox) 勾选 → stream.value = true,取消 → stream.value = false。Vue 会根据 <input> 的类型自动适配绑定行为
@click="update" 事件监听 @ 是 v-on: 的缩写。点击按钮触发 update 函数
v-if="stream" 条件渲染 当 stream 为 false 时,这个元素不存在于 DOM 中 (不是隐藏,是移除)。为 true 时才动态创建并插入

Script(逻辑层)------核心流程分步走:

步骤 代码 解析
定义状态 ref(...) 三个响应式变量:question(用户输入)、stream(流式开关)、content(AI 回复内容)。ref() 返回 { value: ... } 结构------模板中自动解包,脚本中必须 .value
输入校验 if (!question.value) return 空字符串是 falsy 值,防止发送空请求浪费 API 额度
加载态 content.value = '思考中....' 给用户即时反馈------清空旧结果的同时告知"请求已发出"。没有这行,点完按钮界面毫无反应,用户会以为没点上
请求头 Authorization: 'Bearer ...' HTTP Bearer Token 认证。import.meta.env.VITE_DEEPSEEK_API_KEY 是 Vite 编译时从 .env.local 注入的值
流式分支 if (stream.value) 两条路径二选一。流式路径:getReader() → while 循环 → decoder.decode() → 拼接。非流式路径:response.json() 一行搞定
非流式 data.choices[0].message.content response.json() 等全部数据收完才返回。注意取的是 message.content(完整内容),和流式的 delta.content(增量)不同
流式 response.body?.getReader() ?. 是可选链------body 为 null 时返回 undefined 而不报错。getReader() 锁定流,之后其他人无法读取
流式循环 while (!done) { await reader.read() } 核心循环。reader.read() 返回 { value: Uint8Array, done: boolean }。done 为 true 时流结束,value 为 undefined
流式解码 decoder.decode(value, { stream: true }) 二进制→字符串。{ stream: true } 防止中文字符被截断。解码器暂存不完整字节,等下一块到了再拼
流式拼接 content.value += extractContent(text) 解析 SSE 的 data: 行,提取 delta.content,追加到 content。Vue 自动更新 {{ content }},用户看到打字机效果

五、Vue 基础速览------理解组件化开发

如果你是前端新手,这里快速过一下 Demo 中涉及的 Vue 核心概念。

5.1 什么是 .vue 文件?

.vue 文件又叫单文件组件(SFC,Single File Component) 。它是 Vue 生态中"构成页面的最小单位"------不再是零散的 HTML 标签,而是一个封装好的、可复用的业务单元。

Facebook 的网页由一万多个组件组成,国内大厂的页面也是成百上千个组件拼出来的。组件化的好处:

  • 封装:HTML + CSS + JS 打包在一起,职责清晰
  • 复用:写一次,到处用
  • 维护:改一个组件不影响其他组件

5.2 三部分结构

每个 .vue 文件由三个区块构成:

vue 复制代码
<template>
  <!-- 模板:动态 HTML,可以绑定数据,数据变了页面自动更新 -->
</template>

<script setup>
  // 脚本:JS 逻辑,使用 ref() 创建响应式数据
</script>

<style>
  /* 样式:这个组件的专属 CSS */
</style>

三个区块各司其职:

区块 编译时 运行时 核心能力
<template> Vue 编译器把模板编译成 render() 函数(虚拟 DOM) 数据变化 → 虚拟 DOM diff → 最小化更新真实 DOM 插值 {{ }}、指令 v-xxx、事件 @click
<script setup> Vite + @vitejs/plugin-vue 编译为 JS 模块 ref() 创建的变量变化时自动触发模板重渲染 响应式数据、计算属性、生命周期钩子
<style> 提取到 <head> 的 <style> 标签中 CSS 选择器匹配组件元素 加 scoped 属性后自动添加 data-v-xxx 哈希,样式隔离不外泄

5.3 响应式数据

这是 Vue 最核心的概念------你不需要手动操作 DOM。

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

const count = ref(0)   // 创建一个响应式数据

// 修改数据
count.value = 1
// 页面上所有用到 {{ count }} 的地方自动更新------不需要 document.querySelector + innerHTML

在模板中直接使用:

html 复制代码
<div>{{ count }}</div>
<button @click="count++">+1</button>

ref() 代码逐行解析:

代码 说明
import { ref } from 'vue' ref 是 Vue 3 的核心函数,从 vue 包中按需导入。Vue 3 的 Composition API 采用函数式设计,需要什么就 import 什么,不像 Vue 2 的全局 this.$data
const count = ref(0) 创建一个响应式引用,初始值为 0。ref() 内部返回 { value: 0 } 形式的对象。Vue 用 getter/setter 拦截 .value 的读写,从而追踪"谁在用这个数据"以及"数据什么时候变了"
count.value = 1 修改值。在 <script> 中必须写 .value ------count 是 Ref 对象,count.value 才是真正的值。如果直接写 count = 1,会把整个响应式对象替换成普通数字,破坏响应式
{{ count }} 模板中使用。Vue 编译器会自动解包 Ref 对象------你写 {{ count }},它自动取 count.value。不需要在模板里写 {{ count.value }}
@click="count++" 模板中自动解包同样适用于表达式------count++ 实际执行的是 count.value++。Vue 检测到 .value 变化后,自动触发依赖此数据的 DOM 更新
响应式链路 ref(0) 创建 → 模板渲染时读取 .value(Vue 记录为"副作用")→ .value 改变 → Vue 通知所有依赖 → 局部 DOM 更新(仅 {{ count }} 所在节点,不是整个页面刷新)

5.4 数据双向绑定

html 复制代码
<input v-model="question" />

v-model 是 Vue 的"双向绑定"指令。它本质上是一个语法糖,等价于下面这个展开形式:

html 复制代码
<!-- v-model 写法(简写) -->
<input v-model="question" />

<!-- 等价展开(手动实现) -->
<input
  :value="question"
  @input="question = $event.target.value"
/>

拆解 v-model 的双向绑定机制:

方向 对应展开部分 触发时机 做了什么
数据 → 视图 :value="question" question.value 被修改时 Vue 自动把新的 question.value 写入 input 的 value 属性,输入框内容自动刷新
视图 → 数据 @input="question = $event.target.value" 用户在输入框中输入字符时 浏览器触发 input 事件,$event.target.value 是用户刚输入的内容,赋值给 question.value 后 Vue 的响应式系统接管后续更新
checkbox 适配 v-model 自动判断 勾选/取消时 <input type="checkbox"> 上 v-model 绑定布尔值:勾选 → true,取消 → false。Vue 会根据 <input> 的 type 自动选择绑定策略

为什么不需要手动 DOM 操作? 传统写法是 document.querySelector('input').value 取值 + innerHTML 写值。v-model 让你只需要关心数据(question),DOM 的读写由 Vue 自动完成。数据和界面始终保持一致------这就是"数据驱动视图"的核心思想。


六、流式 vs 非流式:一张表总结

维度 非流式 (stream: false) 流式 (stream: true)
响应方式 生成完一次性返回 JSON 逐 token 实时推送 SSE
用户体感 长时间空白等待 打字机式逐字出现
前端实现 response.json() 直接解析 ReadableStream + reader 逐块读取
适用场景 短回答、批量处理 长回答、对话交互
复杂度 简单 需要处理 SSE 解析、缓冲、错误重连

七、最后

流式输出不是花哨的"炫技",而是 AI 产品用户体验的基石。它解决了"用户在等待中焦虑"这一根本问题------就像在真实对话中,对方一边思考一边说,远比沉默 30 秒然后念出一大段更自然。

作为前端工程师,理解并实现流式输出是你进入 AI 应用开发的第一课。背后的技术并不复杂:stream: true 一个参数,ReadableStream 一个循环,把握好 SSE 的解析逻辑,一个体验良好的 AI Chatbot 就成型了。

技术的终点永远是用户感受。流式输出,就是 AI 时代对"响应速度即体验"的最佳诠释。

相关推荐
wangruofeng1 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng1 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈4 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技4 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户47949283569155 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马7 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng7 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding8 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js
鶴哥只手遮天9 小时前
《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
前端·electron
FungLeo11 小时前
成为全栈·Vue 管理后台篇·分类树与标签管理:层级数据如何落到管理界面
前端·javascript·vue.js·分类树·naive ui·标签管理·前端数据结构