流式输出:Vue 3 + DeepSeek API 实战

前端与 LLM 交互时,流式输出(Streaming) 是决定用户体验的关键技术。本文通过一个 Vue 3 实战 Demo,逐行拆解流式输出的完整链路------从点击按钮到文字逐字呈现。


1. 为什么需要流式?

传统的 API 调用是"请求-等待-一次返回"模式:

复制代码
用户提问 → 等待 10 秒 → 整段回答一次性出现

GPT 类模型是 token-by-token 生成的。后端每生成一个 token 就可以立刻送出,前端也能立刻渲染。这就是 SSE(Server-Sent Events)风格的流式响应

erlang 复制代码
用户提问 → "中" → "中国" → "中国龙" → "中国龙是" → ...

效果:首字延迟从 10 秒降到 1 秒以内,用户感知速度大幅提升。


2. 流和二进制

网络传输的本质是字节。

css 复制代码
Uint8Array [十进制数, ...]  ←  0-255 之间的无符号整数数组

服务器返回的 Response Body 就是一个二进制流fetch 拿到的是 ReadableStream 对象------它是一个"水管",数据从服务端流向浏览器,每到达一小块就触发一次读取。

理解这一点,是理解流式编程的基础。


3. 核心代码逐行拆解

3.1 状态定义(响应式基础)

js 复制代码
const question = ref('讲一个关于中国龙的故事');
const content = ref('');
const stream = ref(true);

Vue 3 的 Composition API 把相关逻辑组织在一起,而不是像选项式 API 那样按 data / methods / computed 拆分。ref() 创建响应式对象------值变了,页面绑定的地方就局部热更新,不会整页刷新导致状态丢失。这正是 HMR(Hot Module Replacement)在开发中的价值:你改了代码,页面状态不丢。

3.2 发起请求

js 复制代码
const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question.value }],
    stream: stream.value    // ← 关键参数
  })
});

stream: true,DeepSeek(兼容 OpenAI 协议)返回的不是一个完整 JSON,而是一个 Streamable 对象------数据分块到达。

3.3 流式读取

js 复制代码
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (!done) {
  const { value, done: doneReading } = await reader?.read();
  done = doneReading;
  const chunkValue = buffer + decoder.decode(value);
  // 处理 chunkValue...
}

链路:

scss 复制代码
Response.body(ReadableStream)
  → .getReader() 获取读取器
  → .read() 逐块读取 Uint8Array
  → TextDecoder 将二进制解码为文本
  → 拼接 buffer,解析 SSE 格式

buffer 是核心缓冲变量。网络分包不按行切------可能某个 data: {...} JSON 被切成两半到达。buffer 把上次剩余的半行拼到本次 chunk 前面,保证每次处理的都是完整行。

3.4 SSE 数据解析

js 复制代码
const lines = chunkValue.split('\n').filter(
  (line) => line.startsWith('data: ')
);

SSE 格式:

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

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

data: [DONE]

按换行分割,过滤出 data: 开头的行,解析 JSON 取 delta.content,累加到 content.value------页面逐字渲染。


4. 非流式分支

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

stream: false 时,直接 response.json() 获取完整数据,choices[0].message.content 就是完整回答。此时清空 buffer(不过在这个分支里 buffer 其实没有用到------它只是确保状态清洁)。


5. Agent 视角下的流式应用

Agent 越来越像人,走向 AGI。如何将工作拆分:AI 擅长的交给 Agent,我们审核;不擅长的我们接管。

从这个角度再看流式,意义就更深了:

场景 流式的作用
Agent 长任务执行 实时看到 Agent 的思考过程,不是干等
多步骤工作流 每步结果即时反馈,方便中途干预
人机协作 AI 输出 → 人类审核 → 继续,流式让审核零等待
项目初始化 Agent 生成脚手架时,逐文件产出,所见即所得

Agent 不需要一次性给出完美答案。它和流式一样------一步一步来,边输出边修正,人随时接管。


6. 关键要点

  1. stream: true 告诉 API 逐 token 返回,前端用 ReadableStream 接收
  2. buffer 处理网络分包------半行拼接是 SSE 解析的稳定性保证
  3. TextDecoder 是二进制到文本的桥梁
  4. Composition API + 响应式 让数据流到 UI 的映射自然、局部更新
  5. 流式 = 更好的体验:从"等 10 秒看结果"变成"1 秒出首字,越读越多"

7. 一段话总结

流式输出的本质是把"等全部做好了再给你"变成"做一个字就给你一个字"。技术上靠的是 ReadableStream + SSE 协议;体验上让 AI 的思考过程透明、可感知;架构上为人机协作留出了空间------人可以在任何时刻介入、纠偏、接管。这就是流式的力量。


8. 代码逐行解析

8.1 状态定义

js 复制代码
const question = ref('讲一个关于中国龙的故事');
const content = ref('');
const stream = ref(true);

逐行拆解:

代码 解析
1 const question = ref('讲一个关于中国龙的故事') ref() 是 Vue 3 响应式 API 的核心。它接收一个初始值,返回一个 RefImpl 响应式对象 。这个对象只有一个属性 .value,读 question.value 取值,写 question.value = xxx 赋值。Vue 的依赖追踪系统会自动收集所有读取过 .value 的组件/计算属性,当值变化时通知它们局部更新。这里初始值是一句提示词,意味着页面加载后输入框会预填这句话。
2 const content = ref('') 空字符串初始值。这个变量将承载 LLM 返回的全部文本。流式模式下它会被不断追加内容------每次追加都会触发视图更新,产生"逐字冒出"的视觉效果。不是整个 DOM 重新渲染,而是 {{ content }} 所在的文本节点局部修改
3 const stream = ref(true) 布尔值,默认 true。绑定到页面的 checkbox 上(v-model="stream"),控制请求体中的 stream 字段。用户勾选/取消时,stream.value 自动切换,下次点击提交就走对应的分支逻辑。

关键设计思想 :Composition API 让你把"这个组件要用到什么数据"和"这些数据如何变化"写在相邻位置,而不是分散在 data()computedmethods 三个孤岛上。当组件变复杂时,你可以把一组相关逻辑拆成独立的 useXxx() 函数------这就是 Vue 3 的逻辑聚合优势。


8.2 发起请求

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

逐行拆解:

代码 解析
1 const response = await fetch(endpoint, { await 会暂停 update 函数的执行,等待 fetch 返回的 Promise 完成。注意这是异步非阻塞 的------JS 主线程不会卡死,UI 仍然可以交互。变量名 response 表示这是一个 HTTP 响应对象,此时响应头已到达,但响应体(body)可能还在传输中------这就是后面流式读取的前提。
2 method: 'POST' DeepSeek Chat Completions API 要求 POST。GET 请求也可以用于 SSE,但 LLM 接口参数复杂(messages 数组可能很长),POST 更合适。
3 headers 来自上方定义的 { 'Content-Type': 'application/json', Authorization: 'Bearer ...' }Content-Type 告诉服务器"我发的是 JSON";Authorization 通过环境变量 VITE_DEEPSEEK_API_KEY 注入------不把密钥写死在代码里,由 Vite 在构建时替换
4 body: JSON.stringify({ fetchbody 必须是字符串或二进制。JSON.stringify 把 JS 对象序列化为 JSON 文本。
5 model: 'deepseek-v4-flash' 指定模型。v4-flash 是 DeepSeek 的快思考模型,延迟低、速度快------非常适合流式场景,用户能更快看到首字。如果用 v4-proreasoner,首 token 延迟会更长,但回答更深思熟虑。
6 messages: [{ role: 'user', content: question.value }] Chat Completions 的标准消息格式。role 有三种:system(系统指令)、user(用户消息)、assistant(AI 回复)。这里只传了一条用户消息。.value 是关键------你拿到的是 ref 包裹的值,而不是 ref 对象本身。
7 stream: stream.value 来自 checkbox 绑定的 stream ref。true → 服务端返回 SSE 流;false → 一次性返回完整 JSON。一个布尔值决定了前后端整个交互模式

8.3 流式读取

js 复制代码
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (!done) {
  const { value, done: doneReading } = await reader?.read();
  done = doneReading;
  const chunkValue = buffer + decoder.decode(value);
  // 处理 chunkValue...
}

逐行拆解:

代码 解析
1 const reader = response.body?.getReader() response.body 是一个 ReadableStream 对象------它在 fetch 拿到响应头时就已经可用,但数据还在陆续传输中。?. 是可选链操作符:如果 bodynullundefined,整个表达式返回 undefined 而不抛错。getReader() 返回一个 ReadableStreamDefaultReader ,它是流式读取的"遥控器"------每次调用 .read() 从流中取一个 chunk。注意一个流只能有一个 reader,拿到 reader 后就不能再用 response.json() 等方式读取了。
2 const decoder = new TextDecoder() TextDecoder 是浏览器内置的 Web API,用于把二进制(Uint8Array)解码为字符串。默认使用 UTF-8 编码(正好是 HTTP 响应的标准编码)。创建一次、多次使用------不需要每读一个 chunk 就 new 一个。
3 let buffer = '' 这是流式编程中最关键的变量 。网络传输不给"行"的保证------TCP 分包可能把一个 JSON 行切成任意位置。比如一个 chunk 结尾是 "content":"中,下一个 chunk 开头是 国"}。buffer 的作用是:把每次读取前残留的"半行"暂存下来,下一次读取时拼接到前面。没有它,你的 SSE 解析会频繁出现 JSON parse error。
4 while (!done) { 循环条件 !done------只要流还没读完就继续。done 的初始值是 let done = false(在 while 之前声明)。当 reader 读到流末尾时,doneReading 会变成 true,赋给 done,下次进入循环条件为 false,退出。
5 const { value, done: doneReading } = await reader?.read() reader.read() 返回一个 Promise,resolve 为一个对象 { value: Uint8Array, done: boolean }。用解构语法同时取出两个字段,并把 done 重命名为 doneReading(避免和外层 done 变量名冲突)。value 是当前 chunk 的二进制数据------可能是几十字节到几 KB 不等,由网络缓冲决定,不固定。await 意味着这里是等待的------如果流暂时没有新数据,这行会阻塞(异步等待 I/O),不占 CPU。
6 done = doneReading 把本轮的 done 状态同步到外层变量,供下一轮 while 条件判断。
7 const chunkValue = buffer + decoder.decode(value) decoder.decode(value) 把 Uint8Array 转为字符串。然后把 buffer 拼到前面 ------buffer 里存放的是上一次解析剩余的半行数据(见下文 SSE 解析中的处理)。拼接后 chunkValue 可能包含多行完整的 SSE 数据。
8 // 处理 chunkValue... 这个注释占位代表后续的 SSE 解析逻辑。实际应该在这里调用 split('\n') + startsWith('data: ') 过滤,然后逐行 JSON.parse 取出 delta.content,追加到 content.value。同时,最后一行(可能不完整)要存回 buffer,供下一次循环拼接。

buffer 的完整工作流程

swift 复制代码
Chunk 1 到达: "data: {\"content\":\"中\"}\ndata: {\"con"
                                   ↑ 在这里被截断
处理后: 
  - 完整行: "data: {\"content\":\"中\"}" → 解析 → "中" 追加到页面
  - 半行:   "data: {\"con" → 存入 buffer

Chunk 2 到达: "tent\":\"国\"}\n"
buffer + chunk2 = "data: {\"content\":\"国\"}\n"
处理后:
  - 完整行: "data: {\"content\":\"国\"}" → 解析 → "国" 追加到页面

8.4 SSE 数据解析

js 复制代码
const lines = chunkValue.split('\n').filter(
  (line) => line.startsWith('data: ')
);

逐行拆解:

步骤 代码 解析
1 chunkValue.split('\n') 按换行符把文本块拆成数组。SSE 协议规定每条消息占一行或多行,以 \n 分隔。比如 "data: {...}\n\ndata: {...}\n" 拆成 ["data: {...}", "", "data: {...}", ""]
2 .filter(...) 过滤数组,只保留满足条件的元素。
3 line.startsWith('data: ') SSE 标准格式:数据行以 data: 开头(5 个字符,末尾有空格)。其它行如空行、注释行(: 开头)、event 行都会被滤掉。注意 startsWith 是大小写敏感的 ------如果服务端发了 Data: (大写 D)就会漏掉。
4 const lines = ... 最终 lines 是一个字符串数组,每个元素形如 "data: {\"choices\":[...]}"。接下来要做的就是把 "data: " 前缀去掉(line.slice(6)),然后 JSON.parse,取 choices[0].delta.content,拼到 content.value

SSE 协议三要素

  • data: 前缀标记数据行
  • 空行(\n\n)分隔不同的消息
  • [DONE] 或类似标记表示流结束

一个隐藏的坑 :如果某一行的 JSON 恰好也被网络从中切断(例如一个 chunk 结尾是 "delta":{"conten),SSE 解析本身无感------它在 \n 层面处理。真正暴露问题的是下游的 JSON.parse,它会因为不完整的 JSON 字符串而抛异常。这就是 buffer 存在的根本原因 ------buffer 在 SSE 解析和 JSON 解析之间起缓冲作用,确保喂给 JSON.parse 的永远是完整的一行 JSON。


8.5 非流式分支

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

逐行拆解:

代码 解析
1 } else { 对应上方 if (stream.value) 的 else 分支。当用户取消勾选 "Streaming" checkbox 时走这里。
2 const data = await response.json() response.json()fetch 的快捷方法------它会读取整个 response body(等待流结束),然后一次性解析为 JSON 对象 。这是非流式模式:必须等所有数据传输完才能拿到结果。注意 response.json()getReader() 互斥------一个 response 只能读一次,所以这个分支不会执行上面的 reader 逻辑。
3 content.value = data.choices[0].message.content data 是完整的 Chat Completions 响应对象。结构:{ choices: [{ message: { content: "..." } }] }choices[0] 取第一个候选回答,.message.content 是完整文本。非流式下 .messagecontent;流式下则是 .delta.content------同一套 API,不同模式返回结构不同(一个显著的差异点)。
4 buffer = '' 清空 buffer。在这个分支中 buffer 可能未被使用,但放入这行是防御性编程------确保如果用户先走流式、再走非流式,上次残留的 buffer 数据不会意外污染后续请求。

8.6 整体数据流图

scss 复制代码
用户点击提交
    │
    ▼
question.value 作为 messages[0].content
    │
    ▼
fetch(endpoint, { stream: stream.value })
    │
    ├── stream = true ──────────────────────────────────────┐
    │                                                        │
    │   response.body.getReader()                            │
    │       │                                                │
    │       ▼                                                │
    │   while(!done) {                                       │
    │       reader.read() → Uint8Array                       │
    │           │                                            │
    │           ▼                                            │
    │       TextDecoder → 字符串 chunk                       │
    │           │                                            │
    │           ▼                                            │
    │       buffer + chunk → 完整文本                        │
    │           │                                            │
    │           ▼                                            │
    │       split('\n') → 按行拆分                           │
    │           │                                            │
    │           ▼                                            │
    │       filter(line => startsWith('data: '))             │
    │           │                                            │
    │           ▼                                            │
    │       取最后完整行存入 buffer,其余解析                  │
    │           │                                            │
    │           ▼                                            │
    │       JSON.parse → delta.content                       │
    │           │                                            │
    │           ▼                                            │
    │       content.value += delta.content                   │
    │           │                                            │
    │           ▼                                            │
    │       页面局部更新,新字出现                             │
    │   }                                                    │
    │                                                        │
    └── stream = false ─────────────────────────────────────┘
                │
                ▼
            response.json() → 一次性获取全部
                │
                ▼
            content.value = data.choices[0].message.content
                │
                ▼
            页面一次性渲染全部回答
相关推荐
先吃饱再说1 小时前
流式输出完全指南:让 AI 对话像打字机一样流畅
javascript·vue.js
小小编程路5 小时前
C++ 块作用域详解:从基础到实战
前端·javascript·vue.js
虚惊一场5 小时前
MarkView:纯前端 Markdown 实时预览工具的架构设计
前端·javascript·vue.js
早期的虫儿有鸟吃6 小时前
Vue3
前端·javascript·vue.js
早期的虫儿有鸟吃6 小时前
vue2Day06----路由
前端·javascript·vue.js
阿黎梨梨7 小时前
从敲下回车到文字逐字出现:AI 流式输出的前端必修课
vue.js·人工智能
小林ixn7 小时前
Vue3接入DeepSeek流式输出,把“二进制碎片”和“局部热更新”扒个精光
前端·vue.js·人工智能
Cobyte8 小时前
编译解析器的基础——状态模式与状态机的原理
前端·javascript·vue.js
Listen·Rain9 小时前
Vue3中computed详解
前端·javascript·vue.js