你有没有好奇过:问 ChatGPT 一个问题,它的回答是"一个字一个字往外蹦"的,而不是等几十秒一次性甩出一大段。这背后到底藏着什么前端技术?今天我们就从 0 到 1 拆解它。
写在前面
我们正处在一个特殊的时代节点------Agent 开发时代。AI 正越来越像人,一步步走向 AGI。作为前端工程师,新的命题出现了:
如何把工作拆分?把 AI 擅长的交给 Agent,我们负责审核;不擅长的我们接管。
举个小例子:项目工程初始化,完全没必要从 0 手写一个 Vue 项目(src/App.vue、index.html 一堆文件),直接到 GitHub 拉一个模板项目,交给 Agent 去搭就行。
而要跟大模型打交道,流式输出(Streaming) 就是绕不开的第一课。这篇文章带你彻底搞懂它。
一、开发态的底气:Vite 热更新
在聊流式输出之前,先说一个开发阶段的好东西------热更新(Hot Reload),它来自 Vite。
传统开发模式下,每次改完代码都要刷新浏览器 才能看到效果。但刷新页面有个致命问题:页面状态会丢失。Vue / React 里密密麻麻的数据状态(表单填了一半、列表滚到中间、弹窗打开了......)全没了,得重新来一遍。
热更新做的是另一件事:
文件修改 → 局部刷新(只替换改动的模块,保留页面状态)
有了它,改代码时页面状态稳稳保留,开发体验丝滑得不行。这是现代前端工程化的基础,也是我们能愉快调试流式输出代码的前提。
二、流式输出的本质:二进制流 + SSE
2.1 服务器返回的是什么?
一句话:stream 返回的就是二进制流。
具体来说,它是一个 Uint8Array------无符号 8 位整数数组,每个数在 0-255 之间。这就是网络世界里最原始的"数据形状":一个个字节。
javascript
Uint8Array[228, 184, 173, ...] // 这些十进制数就是字节
字节不是字符,一个中文字(如"中")在 UTF-8 里占 3 个字节,所以必须安排编码 才能还原成我们能读的字符串。这就是 TextDecoder 的活儿。
2.2 ReadableStream:一根水管
浏览器原生提供了 ReadableStream 对象,可以把它想象成一根水管:
- 服务器那头开水龙头,数据一滴一滴流过来
- 浏览器这头可以一边接一边处理,不用等整桶水装满
这就是为什么大模型能"边生成边显示"------模型每算出一个 token(最小的语义单元),就立刻顺着管子推给浏览器,用户即时看到。
2.3 SSE:数据长什么样?
流式输出用的是 SSE(Server-Sent Events) 格式。服务器推过来的原始文本长这样:
css
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
规则就三条:
- 每条消息以
data:开头 - 消息之间用空行(两个
\n)分隔 - 最后一条固定是
data: [DONE],表示流结束
注意流式里内容字段叫 delta (增量),不是非流式里的 message。
三、实战:Vue 3 调用 DeepSeek 实现流式对话
3.1 技术栈一览
| 技术 | 角色 |
|---|---|
Vue 3 <script setup> |
组合式 API,逻辑组织更聚合 |
ref |
创建响应式数据,数据变页面自动更新 |
fetch |
浏览器原生 HTTP 客户端,无需 axios |
ReadableStream + getReader() |
逐块读取响应体 |
TextDecoder |
字节流 → 字符串 |
| SSE 按行解析 | 提取每一段增量内容 |
3.2 数据状态设计
用三个 ref 搞定整个页面的状态:
js
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事') // 用户输入
const content = ref('') // LLM 回答内容
const stream = ref(true) // 是否流式
为什么用 ref? 普通变量改了 Vue 不知道,页面不会变。
ref()把值包装成响应式引用对象(RefImpl),xxx.value一改,页面上绑定它的地方会局部热更新,不用碰 DOM。
3.3 发请求:fetch + DeepSeek API
js
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
})
几个要点:
- API Key 从哪来?
import.meta.env.VITE_DEEPSEEK_API_KEY是 Vite 的环境变量注入。项目根目录放一个.env文件,写VITE_DEEPSEEK_API_KEY=sk-xxx。变量名必须以VITE_开头,Vite 才会暴露给前端,既安全又方便多环境切换。 Authorization: Bearer xxx是 REST API 标准认证格式(Bearer + 空格 + Token)。JSON.stringify必不可少------HTTP 只能传字符串/二进制,传不了 JS 对象。messages遵循 OpenAI 格式:每条消息有role(system/user/assistant)和content。
四、流式核心:边接边显示
这是全文最关键的部分。分三步走。
第一步:拿到读取器(吸管)
js
const reader = response.body?.getReader()
response.body 是 ReadableStream,不能直接读,必须通过 getReader() 拿一个"读取器"来一口一口地"嘬"。?. 是可选链,防止 body 为 null 时报错。
第二步:准备工具
js
const decoder = new TextDecoder() // 字节 → 字符串
let done = false // 流是否读完的开关
let buffer = '' // 缓存半截 JSON
buffer 为什么必须存在? 这是 SSE 解析的核心坑。一个 chunk 末尾可能是
data: {"conten,下一个 chunk 开头才是t":"你好"}。如果本轮解析失败就丢掉,内容直接缺失。必须把"半截"存进 buffer,下一轮拼上再解析。
第三步:循环读取 + 解析 + 追加
js
content.value = '' // 清空"思考中"
while (!done) {
// 嘬一口数据,没嘬到就 await 等着
const { value, done: doneReading } = await reader.read()
done = doneReading
// 字节 → 字符串,拼上上一轮的半截
const chunkValue = buffer + decoder.decode(value)
buffer = ''
// SSE 按行拆分,只留 data: 开头的
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data:'))
for (const line of lines) {
// 去掉 "data: " 前缀
const jsonStr = line.replace(/^data:\s*/, '')
// 流结束标记
if (jsonStr === '[DONE]') {
done = true
break
}
try {
const data = JSON.parse(jsonStr)
// 流式取 delta.content,逐字追加
const delta = data.choices[0].delta?.content || ''
content.value += delta // 响应式自动更新页面
} catch {
// JSON 解析失败 = 半截,塞回 buffer 等下一轮
buffer = jsonStr
}
}
}
关键点逐个说:
await reader.read()会在这里暂停等待,直到下一块数据到达。这就是"边生成边显示"的底层机制------模型不吐数据,代码就停在这儿等。done: doneReading是解构时重命名,避免和外层done变量冲突。content.value += delta每次追加一个字,Vue 响应式系统自动更新{{ content }}绑定的节点,页面就"一个字一个字蹦出来"。- try/catch 兜底半截 JSON:解析失败不报错,存进 buffer 等下轮拼上再试,保证内容不丢。
非流式分支对比
js
} else {
const data = await response.json()
content.value = data.choices[0].message.content
}
非流式时服务器等全部算完一次性返回完整 JSON,注意字段是 message 不是 delta。
五、页面:双向绑定让数据流起来
vue
<template>
<div class="container">
<div>
<input v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
</div>
</div>
</template>
v-model:双向绑定。输入框打字 →question.value自动变;JS 里改question.value→ 输入框也跟着变。复选框的v-model绑定的是布尔值。@click="update":点击触发请求函数。{{ content }}:插值语法,content一变就自动重渲染。
整条数据链路:
css
用户输入 → v-model → question.value → fetch 请求 → 流式读取 → content.value += delta → {{ content }} 自动更新
全程不碰 DOM,改数据就行------这就是 Vue 数据驱动视图的魅力。
六、样式:Flex 弹性布局一统江湖
css
.container {
display: flex;
flex-direction: column; /* 主轴纵向 */
align-items: start; /* 交叉轴左对齐 */
justify-content: start; /* 主轴顶对齐 */
height: 100vh; /* 占满视口高度 */
font-size: 0.85rem; /* rem 相对 html,移动端友好 */
}
Flexbox 是现代布局首选,比 float/position 简单太多。100vh 让容器撑满屏幕,rem 做移动端等比例适配。
总结:一图看全流程
javascript
用户点提交
↓
fetch POST → DeepSeek API(stream: true)
↓
response.body → getReader() 拿吸管
↓
while 循环:
await reader.read() ← 等数据
decoder.decode() ← 字节转字符
split + filter ← 拆 SSE 行
JSON.parse ← 取 delta.content
content.value += delta ← 追加,页面自动更新
↓
data: [DONE] → 退出循环
一句话记住: 流式输出 = ReadableStream 逐块读 + TextDecoder 解码 + SSE 按行解析 + ref 响应式追加。掌握这条链路,你就拿住了大模型时代前端的入场券。
Agent 时代已来,热更新让开发丝滑,流式输出让交互即时。这些不是炫技,而是新时代前端的日常。下一站,我们继续探索如何用 LangChain 把大模型的能力变成真正可用的 Agent。