在 AI 应用爆发的今天,前端工程师不再只是“切图仔”——我们也要和 LLM(大语言模型)打交道。本文将带你用 Vue 3 + Vite 快速搭建一个支持流式响应(Streaming)的 AI 对话 Demo,并深入理解其中的核心机制:响应式数据 + Fetch 流处理。
场景说明
我们要实现的功能很简单:
- 用户输入一个问题(如“讲一个喜羊羊和灰太狼的故事,20字”)
- 点击“提交”,调用 DeepSeek 的 Chat API
- 如果开启 Streaming 开关,AI 回复会逐字显示(像打字一样)
- 如果关闭,则等待完整响应后一次性显示
这正是 ChatGPT、Claude、DeepSeek 等主流 AI 产品的交互方式。
技术栈选择
-
框架:Vue 3(组合式 API +
<script setup>) -
构建工具:Vite(极速启动,无需配置)
-
核心能力:
ref:管理响应式状态(问题、回复内容、开关)fetch+response.body.getReader():处理流式响应TextDecoder:将二进制流解码为文本v-model:双向绑定表单与数据
注意:我们只用 ref,不引入 reactive、computed 等复杂概念,保持极简。
初始化项目
npm init vite
vue cd ai-demo
npm install
完整代码(App.vue)
先看最终成果,把以下代码复制到你的 src/App.vue 中即可运行(记得配置 .env.local):
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("")
const askLLM = async () => {
if (!question.value) {
console.log('question 不能为空');
return
}
content.value = '思考中...';
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [
{
role: 'user',
content: question.value
}
]
})
})
if(stream.value){
content.value="";
const reader=response.body?.getReader();
const decoder=new TextDecoder();
let done=false;
let buffer='';
while(!done){
const {value,done: doneReading}=await reader?.read()
done=doneReading;
const chunkValue=buffer+decoder.decode(value);
buffer='';
const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
for(const line of lines){
const incoming=line.slice(6);
if(incoming==='[DONE]'){
done=true;
break;
}
try{
const data=JSON.parse(incoming);
const delta=data.choices[0].delta.content;
if(delta){
content.value+=delta;
}
}catch(err){
buffer+=`data: ${incoming}`
console.log(buffer);
}
}
}else{
const data=await response.json();
console.log(data);
content.value=data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question"/>
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{content}}</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
</style>
同时,在项目根目录创建 .env.local 文件:
VITE_DEEPSEEK_API_KEY=你的 DeepSeek API 密钥
代码逐行解析
第一步:定义响应式状态
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("")
question:用户输入的问题,默认值便于测试。stream:布尔值,控制是否启用流式输出。content:AI 的回复内容,初始为空。
这三个变量都是通过 ref() 创建的响应式对象。在模板中使用 {{ content }} 时,Vue 会自动追踪其变化,并在 .value 改变时更新 DOM。
第二步:表单绑定与事件触发
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question"/>
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{content}}</div>
</div>
</div>
</div>
v-model="question"实现输入框与question的双向绑定。v-model="stream"将复选框状态同步到stream。@click="askLLM"在点击按钮时调用askLLM函数。
这是 Vue 最核心的声明式编程思想:你只描述“数据是什么”,不关心“如何更新 DOM” 。
第三步:调用 LLM 接口
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [
{
role: 'user',
content: question.value
}
]
})
})
请求体遵循 OpenAI 兼容格式:
model:指定使用deepseek-chat模型stream:决定是否开启流式响应messages:对话历史,这里只传用户当前消息
Vite 会在构建时将 import.meta.env.VITE_DEEPSEEK_API_KEY 替换为 .env.local 中的值。
第四步:处理流式响应(核心逻辑)
if(stream.value){
content.value="";
const reader=response.body?.getReader();
const decoder=new TextDecoder();
let done=false;
let buffer='';
while(!done){
const {value,done: doneReading}=await reader?.read()
done=doneReading;
const chunkValue=buffer+decoder.decode(value);
buffer='';
const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
for(const line of lines){
const incoming=line.slice(6);
if(incoming==='[DONE]'){
done=true;
break;
}
try{
const data=JSON.parse(incoming);
const delta=data.choices[0].delta.content;
if(delta){
content.value+=delta;
}
}catch(err){
buffer+=`data: ${incoming}`
console.log(buffer);
}
}
}
🔍 逐行详细解析
1. 判断是否启用流式模式
if(stream.value){
- 如果用户勾选了 “Streaming” 复选框(
stream.value === true),则进入流式处理逻辑。
2. 清空上一次的回复内容
content.value="";//把上次的生成清空
- 将
content响应式变量置为空字符串,确保新回复不会追加到旧内容后面。 - 注释明确说明这是“把上次的生成清空”,体现良好的用户体验设计。
3. 获取可读流(ReadableStream)
const reader=response.body?.getReader();
response.body是一个 ReadableStream 对象(前提是服务器支持流式响应)。- 调用
.getReader()返回一个 reader 实例,用于逐步读取流中的数据块(chunks)。 - 使用可选链
?.防止response.body为 null 时崩溃。
💡 DeepSeek 的 Chat Completions API 在
stream: true时会返回text/event-stream格式的响应体,符合 SSE(Server-Sent Events)规范。
4. 创建文本解码器
const decoder=new TextDecoder();
- 流中的每个 chunk 是 Uint8Array 类型的二进制数据。
TextDecoder是浏览器内置 API,用于将二进制数据解码为 UTF-8 字符串。- 默认使用 UTF-8 编码,无需额外配置。
5. 初始化控制变量
let done=false;//流是否结束 没有
let buffer='';
done:标记流是否已读取完毕,初始为false。buffer:用于暂存跨 chunk 的残缺数据(例如一个 JSON 被拆到两个网络包中)。
6. 循环读取流直到结束
while(!done){
- 只要流未结束(
done === false),就持续读取数据。
7. 读取下一个数据块
const {value,done: doneReading}=await reader?.read()
reader.read()返回一个 Promise,解析后得到{ value, done }。- 使用解构赋值,并将
done重命名为doneReading避免与外层done冲突。 value是当前 chunk 的二进制数据(Uint8Array)。doneReading为true表示服务器已发送完所有数据。
8. 更新流结束状态
done=doneReading;
- 将本次读取结果的
done状态同步到循环控制变量。
9. 解码并拼接缓冲区
const chunkValue=buffer+decoder.decode(value);//文本字符串
decoder.decode(value)将当前二进制 chunk 转为字符串。- 与上一轮残留的
buffer拼接,形成完整的文本片段chunkValue。 - 这一步是处理网络分帧导致的 JSON 截断问题的关键。
10. 清空缓冲区
buffer='';
- 已将
buffer内容合并到chunkValue,故清空以备下一轮使用。
11. 按行分割并过滤有效数据行
const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
- SSE 协议规定:每条消息以
data:开头,行末以\n结束。 split('\n')将整个 chunk 按换行符切分为多行。filter(...)仅保留以"data: "开头的行(排除空行或注释行)。
示例原始流数据:
从图上我们可以更清晰地观察到chunkValue的数据情况帮助理解,并且这也可以清晰地看出我们真正想要的数据在.choices[0].delta.content中
12. 遍历每一行有效数据
for(const line of lines){
- 对每一条
data: ...行进行处理。
13. 去除前缀 data:
const incoming=line.slice(6);//干掉数据标志
line形如"data: {...}",slice(6)去掉前 6 个字符(即"data: "),得到纯 payload。- 注释“干掉数据标志”生动表达了这一步的目的。
14. 检查流结束信号
if(incoming==='[DONE]'){
done=true;
break;
}
- DeepSeek(及 OpenAI 兼容 API)在流结束时会发送一行
data: [DONE]。 - 此时应立即终止循环,避免后续无效解析。
15. 尝试解析 JSON 并提取内容
try{
const data=JSON.parse(incoming);
const delta=data.choices[0].delta.content;
if(delta){
content.value+=delta;
}
}
JSON.parse(incoming)将字符串转为 JS 对象。- 从
data.choices[0].delta.content中提取新增的文本片段(token)。 - 若
delta存在(非 null/undefined),就追加到content.value。 - Vue 的响应式系统会自动更新模板中的
{{content}},实现“打字机”效果。
⚠️ 注意:某些 chunk 可能只包含
{"choices": [{"delta": {}}]}(无 content),此时delta为 undefined,不追加。
16. 处理 JSON 解析失败(关键容错)
catch(err){
// JSON.parse 解析失败
buffer+=`data: ${incoming}`
console.log(buffer);
}
-
为什么可能失败?
因为网络传输是分块的,一个完整的 JSON 可能被拆到两个 chunk 中。例如:- Chunk 1:
data: {"choices": [{"delta": {"content": "你好 - Chunk 2:
世界"}}]} - 第一块单独
JSON.parse会报错。
- Chunk 1:
-
如何解决?
将当前无法解析的incoming重新加上data:前缀,存入buffer,留到下一轮循环与新 chunk 合并后再试。 -
console.log(buffer)便于调试,观察残片累积情况。
这是流式解析中最容易出错但最关键的容错机制,你的代码已正确实现。
流式处理的核心要点
| 步骤 | 目的 | 技术点 |
|---|---|---|
| 获取 reader | 读取流式响应 | response.body.getReader() |
| 解码二进制 | 转为可读文本 | TextDecoder |
| 缓冲残片 | 处理跨 chunk JSON | buffer 变量 |
| 按行分割 | 提取有效消息 | split('\n') + filter |
| 去前缀解析 | 获取 token 内容 | slice(6) + JSON.parse |
| 实时更新 | 实现打字效果 | content.value += delta |
| 容错回退 | 防止解析中断 | try...catch + buffer 回填 |
总结
通过这个小项目,你掌握了:
- Vue 3 的
ref和v-model实现响应式表单 - 使用
fetch调用大模型 API - 利用
response.body.getReader()处理流式响应
前端 × AI 的时代已经到来,而你,刚刚完成了第一个作品。