从 “干等回复” 到 “丝滑打字”:Vue3 实现 LLM 流式输出的趣味学习笔记
作为前端开发,谁没幻想过自己搭一个 “迷你 ChatGPT”?看着 AI 回复像打字机一样逐字蹦出,那丝滑的体验可比干等一大段文字爽多了!今天咱们就借着一个 Vue3 小 demo,从项目基建到流式输出核心逻辑,一步步解锁这个 “黑科技”,全程人话教学,新手也能轻松拿捏。
一、基建先行:用 Vite 给 Vue3 安个 “家”
想搞前端项目,第一步总得先搭个架子 —— 总不能像早年写原生 JS 那样,直接在 HTML 里塞<script>标签吧?现在最火的脚手架非Vite莫属,比老大哥 Webpack 快了可不是一星半点,堪称前端基建的 “闪电侠”。
1.1 项目初始化三步曲
打开终端,敲下这行命令就能开启新世界的大门:
npm init vite
这句命令的意思很直白:“npm 啊,帮我初始化一个 Vite 项目”。执行后 Vite 会贴心地给你几个选项,咱们直接选Vue3和JavaScript(先别碰 TS,一步一步来),回车确认后,一个干净的 Vue3 项目模板就生成了。
生成的项目里,src/目录是咱们的 “主战场”,所有业务代码都得在这儿安家。而 Vue 项目的灵魂,就是.vue后缀的单文件组件,咱们可以把它比作 “前端界的三层汉堡”,结构清晰又实用。
1.2 Vue 单文件组件的 “三明治结构”
每个.vue文件都逃不开这三层结构,少了哪层都没那味儿:
<script>层:负责 “搞逻辑”—— 定义变量、写函数、处理数据,是组件的 “大脑”。<template>层:负责 “搞界面”—— 写 HTML 结构,展示数据,是组件的 “脸”。<style>层:负责 “搞颜值”—— 写 CSS 样式,让界面好看,是组件的 “衣服”。
咱们 demo 里的App.vue就是根组件,整个项目的界面和逻辑都从这儿开始,相当于咱们迷你 ChatGPT 的 “总控制台”。
二、Vue3 核心魔法:响应式数据解放双手
早年写原生 JS 做交互,那叫一个 “费劲”:要监听按钮点击事件,要手动找 DOM 节点,要修改 DOM 内容,代码写得像 “机械指令”。比如想实现一个计数器,原生 JS 得这么写:
// 原生JS的“苦力活”
let count = 0;
const btn = document.querySelector('#btn');
const span = document.querySelector('#count');
btn.onclick = function() {
count++;
span.innerText = count;
}
而 Vue3 的响应式数据直接把咱们从 DOM 操作里解放出来,让咱们专心搞业务逻辑,不用再当 “DOM 搬运工”。
2.1 ref:让变量拥有 “自动更新界面” 的超能力
Vue3 里最常用的响应式 API 就是ref,它能把普通变量变成 “响应式对象”—— 只要变量的值变了,用到它的界面会自动更新,这波操作堪称 “魔法”。
在 demo 里,咱们定义了三个响应式变量,各司其职:
import { ref } from 'vue'
// 绑定输入框的提问内容
const question = ref('讲一个喜洋洋和灰太狼的故事,40字')
// 控制是否开启流式输出的开关
const stream = ref(true)
// 存储AI的回复内容
const content = ref("")
这里要注意一个小细节:在<script>里操作ref变量的值,得用.value(比如content.value = '思考中...'),但在<template>里不用,直接写{{content}}就行,Vue 会自动帮咱们处理。
2.2 v-model:表单和数据的 “双向绑定 CP”
demo 里的输入框用了v-model="question",这就是 Vue 的双向绑定—— 输入框里的文字变了,question的值会跟着变;反过来question的值变了,输入框也会同步更新。
比如你在输入框里删掉 “200 字” 改成 “100 字”,question.value就会自动变成 “讲一个喜洋洋和灰太狼的故事,100 字”,不用手动写监听事件,主打一个 “默契联动”。
三、核心玩法:对接 LLM 接口实现流式输出
咱们这个 demo 的终极目标,是让 AI 回复像打字机一样逐字显示,而不是等半天一次性弹出大段文字。这就用到了流式响应技术,核心是 HTML5 的ReadableStreamAPI 和 LLM 接口的stream参数。
3.1 接口请求的 “准备工作”
要调用 DeepSeek 的 LLM 接口,得先把请求的 “三件套” 准备好:请求地址、请求头、请求体。
// 请求地址(接口的“家门牌号”)
const endpoint = 'https://api.deepseek.com/chat/completions';
// 请求头(相当于“身份凭证”和“数据格式说明”)
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
// 请求体(给AI的“指令”)
const body = JSON.stringify({
model: 'deepseek-chat',
stream: stream.value, // 关键!开启流式输出的开关
messages: [{ role: 'user', content: question.value }]
})
这里有个保命知识点:API 密钥(VITE_DEEPSEEK_API_KEY)绝对不能直接写在代码里!咱们要把它存在.env文件里,用import.meta.env来读取,不然密钥 “裸奔” 在代码里,分分钟被黑客偷走,到时候钱包都得遭殃。
3.2 普通响应 vs 流式响应:天差地别的体验
如果把stream设为false,就是普通响应 —— 前端得等 AI 把所有内容都生成完,接口才会一次性返回数据,体验就像 “等外卖,得等全部做好才能拿到”:
// 非流式响应:一次性拿到所有内容
const data = await response.json();
content.value = data.choices[0].message.content;
而把stream设为true,就是流式响应 ——AI 每生成一个字或一句话,就会立刻给前端发一个 “数据块”,前端边收边渲染,体验就像 “外卖小哥分批发货,拿到一份吃一份”,这就是咱们要的 “打字机效果”。
3.3 流式响应的 “硬核解析逻辑”
流式响应的核心是处理接口返回的二进制数据流,这部分代码看着复杂,其实拆解开就三步:“读数据→转文本→拼内容”。
第一步:获取数据流读取器
接口返回的response.body是一个可读流,咱们得先拿到它的 “读取器”,才能一点点把数据读出来:
const reader = response.body?.getReader();
第二步:二进制转成可读文本
流里的数据是二进制格式,咱们得用TextDecoder把它转成能看懂的字符串:
const decoder = new TextDecoder();
// 每次读取的数据转成字符串
const chunkValue = buffer + decoder.decode(value);
第三步:处理 SSE 格式的数据块
LLM 的流式响应是 SSE(服务器发送事件) 格式,每一行都是data: 数据的样子,还有可能出现不完整的 JSON 块,这时候就得靠buffer来 “兜底”:
let buffer = ''; // 缓存不完整的数据流
const Lines = chunkValue.split('\n').filter(Line => Line.startsWith('data: '));
for (const Line of Lines) {
const incoming = Line.slice(6); // 去掉前缀“data: ”
if(incoming === '[DONE]') break; // 数据流结束的标志
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if(delta) content.value += delta; // 逐字拼接内容
} catch(err) {
// 遇到不完整的JSON,先存到buffer里下次再拼
buffer += `data: ${incoming}`;
}
}
这里的buffer就像 “快递暂存点”—— 如果某次拿到的 JSON 不完整(比如只拿到一半),就先存到buffer里,等下一次拿到数据再拼接,保证 JSON 能正常解析,不会因为数据残缺报错。
当 AI 生成完所有内容,会返回data: [DONE],咱们看到这个标志就知道可以停止读取数据流了,整个 “打字机” 过程也就结束了。
四、给功能 “穿衣服”:模板和样式的小讲究
逻辑写好了,还得给界面整点 “颜值”,不然光秃秃的输入框和文字可拿不出手。
4.1 模板布局:简单实用就好
demo 的<template>结构很清晰,分 “输入区” 和 “输出区”:
- 输入区放了输入框和提交按钮,按钮绑定
@click="askLLM",点击就触发接口请求。 - 输出区加了个复选框绑定
stream,用来切换 “流式输出” 和 “普通输出”,再用{{content}}展示 AI 的回复。
这里的@click是 Vue 的事件绑定,相当于原生 JS 的onclick,但写法更简洁,不用手动找 DOM 节点绑定事件。
4.2 scoped 样式:样式隔离的 “防打架神器”
<style>标签加了scoped属性,这是 Vue 的 “样式隔离” 魔法 —— 这个组件里的样式只会作用于当前组件,不会影响其他组件。比如你给.container设了flex布局,其他组件里的.container不会跟着变,避免了 “样式污染” 的尴尬。
demo 里还做了基础的布局优化,比如让容器占满视口高度、输入框设固定宽度、输出区留足够高度,保证界面看着舒服不拥挤。
五、踩坑指南:这些坑我替你先踩了
新手写流式输出,很容易栽进这些 “坑” 里,提前避坑少走弯路:
- API 密钥配置错误:如果报错 “401 未授权”,大概率是
.env文件里的密钥写错了,或者没重启 Vite(改了环境变量要重启项目才生效)。 - 跨域问题:本地开发时可能遇到跨域报错,这时候可以用 Vite 的代理配置,把请求转发到接口地址,避免浏览器的跨域限制。
- JSON 解析失败:如果数据流处理逻辑没写好,容易因为 JSON 残缺报错,记得用好
buffer做兜底。 - 忘记清空旧内容:每次发起新请求前,要把
content.value清空,不然新回复会和旧回复混在一起,看着乱糟糟的。
六、总结与拓展:从 demo 到实战的进阶路
咱们这个小 demo 虽然简单,但已经涵盖了 Vue3 的核心知识点(响应式数据、事件绑定、双向绑定)和流式输出的核心逻辑(数据流读取、SSE 解析)。在此基础上,还能拓展更多实用功能:
- 加个loading 状态:请求过程中按钮置灰,避免用户重复点击。
- 加错误提示:接口请求失败时,给用户弹个提示,而不是默默报错。
- 加历史记录:把每次的提问和回复存起来,支持查看历史对话。
- 优化样式:给输出区加个气泡样式,让界面更像 ChatGPT。
最后唠两句
Vue3 的响应式数据让咱们摆脱了 DOM 操作的束缚,而流式输出技术则给用户带来了更丝滑的交互体验。把这两者结合起来,就能做出各种有趣的 AI 交互应用。只要你吃透了这个 demo 的逻辑,再去搞更复杂的前端 AI 应用,就会发现 “原来这么简单”!