首页 > 网页制作 >流式输出:AI产品首个关键体验

流式输出:AI产品首个关键体验

来源:互联网 2026-07-15 19:38:12

从零开始,打造 AI 产品的第一个关键体验——流式输出 一、问题:一次性返回,用户等不起 想象一个场景:你问 AI "讲一个关于中国龙的故事"。背后发生了什么? 服务器收到你的问题 LLM 开始推理(Transformer 逐层计算) 生成完整回答 把整段文字打包,一次性返回给你 问题恰恰就卡在中间

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


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

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

流式输出:AI产品首个关键体验

长期稳定更新的攒劲资源: >>>点此立即查看<<<

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

问题恰恰就卡在中间这两个环节。对于复杂问题,推理耗时可能长达数十秒。用户盯着一片空白的屏幕,不知道系统是在"工作中"还是"卡死了"——这就是传统一次性返回的痛点,也是所有AI产品开发者必须跨越的第一道坎。


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

流式输出的思路其实非常直观,甚至可以说是"偷懒"的智慧:

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

这里有个关键点需要理解:LLM 并不是一次性"想好"整段回答再输出的。它的生成过程本身就是逐 token 推理的——每生成一个 token(可以理解为"一个字或词"),就用它来预测下一个。流式输出不过是把这个过程实时暴露给用户看,本质上是"所见即所得"的另一种体现。

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

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

所以现在主流 AI Chatbot(ChatGPT、DeepSeek、Claude、文心一言……)都采用这种打字机式的流式输出——这是AI产品的第一个关键用户体验,也是衡量产品成熟度的基础指标之一。


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

流式输出不是"魔法",是实实在在的计算机网络技术,重点在于理解服务端和客户端怎么配合。

3.1 服务端约定

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

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

请求体 JSON 各字段解析:

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

3.2 客户端处理

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

 复制代码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,需要单独判断

3.3 浏览器中的实现

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

 复制代码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);
  }
}

整个流程可以概括为:

 复制代码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 类型;getReader() 返回一个读取器并锁定该流——同一时间只能有一个 reader 在读取
new TextDecoder()创建 UTF-8 解码器默认编码就是 UTF-8。作用是把 Uint8Array(字节)→ Ja vaScript 字符串
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

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

下面通过一个完整的 Demo 来串联所有知识,从零开始搭建一个可运行的流式对话界面。

4.1 技术栈

角色技术
构建工具Vite(脚手架,由 Node.js 驱动)
前端框架Vue 3(

4.4 运行

 复制代码npm run dev
# Vite 启动开发服务器,默认打开 

勾选 "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 会根据 的类型自动适配绑定行为
@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.contentresponse.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 文件由三个区块构成:

 复制代码

三个区块各司其职:

区块编译时运行时核心能力