# 从零开始,打造 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 对话消息数组,每项包含 rolecontentrole 有三种: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 是空对象,contentundefined------不要把它拼成 "undefined" 字符串
[DONE] 特殊标记 DeepSeek 在流彻底结束后发送的标志,不是 JSON,需要单独判断

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

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.bodyReadableStream<Uint8Array> 类型;getReader() 返回一个读取器并锁定该流------同一时间只能有一个 reader 在读取
new TextDecoder() 创建 UTF-8 解码器 默认编码就是 UTF-8。作用是把 Uint8Array(字节)→ JavaScript 字符串
await reader.read() 异步读取下一个数据块 返回 { value: Uint8Array, done: boolean }。数据还没到达时会 await 等待;流结束时 donetruevalueundefined
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" 条件渲染 streamfalse 时,这个元素不存在于 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) 创建一个响应式引用,初始值为 0ref() 内部返回 { 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 时代对"响应速度即体验"的最佳诠释。

相关推荐
Flynt1 小时前
TypeScript 5.8 让我少踩了一个大坑,顺手再聊聊 --erasableSyntaxOnly
前端·javascript·typescript
小杍随笔1 小时前
【 .npmrc 终极配置指南:统一管理 npm/pnpm 缓存与全局目录,告别磁盘混乱】
前端·缓存·npm
默_笙1 小时前
👍 手写一个 MCP 文件读取服务:原来 AI 能"读"我的文件,全靠这个协议
前端·javascript
饮茶三千1 小时前
基于 WangEditor 的富文本编辑器定制实践
前端·vue.js·设计模式
kyle~1 小时前
前端 --- Toast 轻量级弱反馈提示组件
前端
玉宇夕落1 小时前
Vue3 + 流式输出学习
前端
努力成为AK大王1 小时前
CSS 入门完整笔记
前端·css
微笑挖矿2 小时前
大模型流式渲染:为什么"逐字显示"远比你想的复杂
前端
weedsfly2 小时前
前端开发中的装饰器模式——给函数和组件“加壳”的艺术
前端·javascript·面试