前端与 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. 关键要点
stream: true告诉 API 逐 token 返回,前端用ReadableStream接收- buffer 处理网络分包------半行拼接是 SSE 解析的稳定性保证
- TextDecoder 是二进制到文本的桥梁
- Composition API + 响应式 让数据流到 UI 的映射自然、局部更新
- 流式 = 更好的体验:从"等 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()、computed、methods 三个孤岛上。当组件变复杂时,你可以把一组相关逻辑拆成独立的 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({ |
fetch 的 body 必须是字符串或二进制。JSON.stringify 把 JS 对象序列化为 JSON 文本。 |
| 5 | model: 'deepseek-v4-flash' |
指定模型。v4-flash 是 DeepSeek 的快思考模型,延迟低、速度快------非常适合流式场景,用户能更快看到首字。如果用 v4-pro 或 reasoner,首 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 拿到响应头时就已经可用,但数据还在陆续传输中。?. 是可选链操作符:如果 body 为 null 或 undefined,整个表达式返回 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 是完整文本。非流式下 .message 有 content;流式下则是 .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
│
▼
页面一次性渲染全部回答