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

作者:袖梨 2026-07-17

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

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


1. 为什么需要流式?

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

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

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

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

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


2. 流和二进制

网络传输的本质是字节。

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

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

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


3. 核心代码逐行拆解

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

 复制代码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 发起请求

 复制代码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 流式读取

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

链路:

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

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

3.4 SSE 数据解析

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

SSE 格式:

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

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


4. 非流式分支

 复制代码} 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 长任务执行实时看到 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 状态定义

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

逐行拆解:

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

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


8.2 发起请求

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

逐行拆解:

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

8.3 流式读取

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

逐行拆解:

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

 复制代码Chunk 1 到达: "data: {"content":""}ndata: {"con"
                                   ↑ 在这里被截断
处理后: 
  - 完整行: "data: {"content":""}"  解析  "中" 追加到页面
  - 半行:   "data: {"con"  存入 bufferChunk 2 到达: "tent":""}n"
buffer + chunk2 = "data: {"content":""}n"
处理后:
  - 完整行: "data: {"content":""}"  解析  "国" 追加到页面

8.4 SSE 数据解析

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

逐行拆解:

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

SSE 协议三要素

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

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


8.5 非流式分支

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

逐行拆解:

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

8.6 整体数据流图

 复制代码用户点击提交
    │
    ▼
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
                │
                ▼
            页面一次性渲染全部回答

相关文章

精彩推荐