当Vue遇上AI:我在项目中接入大模型的完整实践
从焦虑到落地,聊聊前端开发者如何拥抱AI时代
写在前面
去年这个时候,我还在担心AI会不会让前端失业。今年这个时候,我已经在三个项目中成功接入了大模型能力。回头看,最大的感悟是:AI不是来取代我们的,而是给我们发了一把新武器。
这篇文章不讲空话,直接从我的实际项目出发,分享Vue应用中接入AI的完整方案、踩过的坑和沉淀下来的最佳实践。
为什么是Vue + AI?
Vue的响应式系统和组合式API,天然适合处理AI应用的典型场景:
- 流式输出的实时渲染
- 对话状态的精细管理
- 加载、错误、重试等UI状态机
如果你用的是Vue 3,那更是如虎添翼。
实战一:从零搭建AI对话组件
1. 先看最终效果
我们要实现这样一个聊天界面:
- 用户输入问题
- AI流式输出回答(逐字显示)
- 支持停止生成
- 对话历史本地保存
2. 核心代码实现
这是我最满意的useChat组合式函数:
// composables/useChat.ts
import { ref, shallowRef } from 'vue'
import type { Ref } from 'vue'
export function useChat(apiUrl: string) {
const messages: Ref<Array<{ role: 'user' | 'assistant', content: string }>> = ref([])
const currentResponse = ref('')
const isLoading = ref(false)
const isStreaming = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | null = null
const sendMessage = async (userInput: string) => {
if (!userInput.trim() || isLoading.value) return
// 添加用户消息
messages.value.push({ role: 'user', content: userInput })
currentResponse.value = ''
error.value = null
isLoading.value = true
isStreaming.value = true
abortController = new AbortController()
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.value,
stream: true
}),
signal: abortController.signal
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const reader = response.body?.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader!.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content || ''
currentResponse.value += content
} catch (e) {
// 忽略解析错误
}
}
}
// 流式结束,保存完整消息
if (currentResponse.value) {
messages.value.push({ role: 'assistant', content: currentResponse.value })
}
} catch (err: any) {
if (err.name === 'AbortError') {
// 用户主动停止
if (currentResponse.value) {
messages.value.push({ role: 'assistant', content: currentResponse.value + ' [已停止]' })
}
} else {
error.value = err.message
console.error('Chat error:', err)
}
} finally {
isLoading.value = false
isStreaming.value = false
abortController = null
}
}
const stopGeneration = () => {
if (abortController) {
abortController.abort()
}
}
const clearHistory = () => {
messages.value = []
currentResponse.value = ''
error.value = null
}
return {
messages,
currentResponse,
isLoading,
isStreaming,
error,
sendMessage,
stopGeneration,
clearHistory
}
}
3. Vue组件中的使用
<template>
<div class="chat-container">
<!-- 消息列表 -->
<div class="messages" ref="messagesContainer">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]">
<div class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="content" v-html="renderMarkdown(msg.content)"></div>
</div>
<!-- 流式输出中的消息 -->
<div v-if="isStreaming && currentResponse" class="message assistant streaming">
<div class="avatar">🤖</div>
<div class="content" v-html="renderMarkdown(currentResponse)">
<span class="cursor-blink">|</span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<textarea
v-model="inputText"
@keydown.ctrl.enter="handleSend"
:disabled="isLoading"
placeholder="输入你的问题,Ctrl+Enter发送"
rows="3"
/>
<div class="actions">
<button @click="handleSend" :disabled="!inputText.trim() || isLoading">
{{ isLoading ? '生成中...' : '发送' }}
</button>
<button v-if="isStreaming" @click="stopGeneration" class="stop-btn">
停止
</button>
<button @click="clearHistory" class="clear-btn">
清空对话
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { useChat } from '@/composables/useChat'
import { renderMarkdown } from '@/utils/markdown'
const inputText = ref('')
const messagesContainer = ref<HTMLElement>()
const {
messages,
currentResponse,
isLoading,
isStreaming,
sendMessage,
stopGeneration,
clearHistory
} = useChat('/api/chat/stream')
const handleSend = async () => {
if (!inputText.value.trim() || isLoading.value) return
const text = inputText.value
inputText.value = ''
await sendMessage(text)
// 滚动到底部
await nextTick()
scrollToBottom()
}
// 监听消息变化,自动滚动
watch([messages, currentResponse], () => {
nextTick(scrollToBottom)
}, { deep: true })
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
</script>
实战二:AI智能搜索(RAG方案)
纯对话不够,我们还需要让AI能回答私有数据的问题。这是我在公司内部知识库项目中的方案。
架构图
用户提问 → 向量检索(相似度匹配)→ 召回相关文档片段
→ 组装Prompt → 大模型生成 → 流式返回 + 引用来源
前端实现要点
// composables/useRAGSearch.ts
export function useRAGSearch() {
const searchResults = ref<Array<{ content: string, source: string, score: number }>>([])
const answer = ref('')
const isSearching = ref(false)
const askWithContext = async (question: string) => {
isSearching.value = true
searchResults.value = []
answer.value = ''
try {
// 第一步:向量检索(获取相关文档)
const searchResp = await fetch('/api/search', {
method: 'POST',
body: JSON.stringify({ query: question, topK: 5 })
})
const { results } = await searchResp.json()
searchResults.value = results
// 第二步:基于上下文生成回答(流式)
const chatResp = await fetch('/api/chat/withContext', {
method: 'POST',
body: JSON.stringify({
question,
context: results.map(r => r.content).join('\n\n')
})
})
// ... 流式处理同上面的useChat
} finally {
isSearching.value = false
}
}
return { searchResults, answer, isSearching, askWithContext }
}
界面中显示引用来源是关键:
<template>
<div v-if="searchResults.length" class="sources">
<h4>📚 参考来源</h4>
<div v-for="(item, idx) in searchResults" :key="idx" class="source-item">
<span class="badge">相关度 {{ (item.score * 100).toFixed(0) }}%</span>
<span class="source-name">{{ item.source }}</span>
</div>
</div>
</template>
实战三:AI驱动的UI组件
除了聊天,AI还能让传统UI变得更智能。分享几个我落地的小功能:
智能表单填充
用户输入一句话,AI自动填充整个表单:
<script setup>
const handleAIComplete = async (instruction: string) => {
const result = await fetch('/api/ai/form-fill', {
method: 'POST',
body: JSON.stringify({
instruction,
formSchema: formFields.value // 传入表单结构
})
}).then(r => r.json())
// 自动填充表单
Object.assign(formData.value, result.filledData)
}
</script>
内容智能摘要
为长文章生成摘要和关键词:
const generateSummary = async (content: string) => {
const response = await fetch('/api/ai/summarize', {
method: 'POST',
body: JSON.stringify({ content, maxLength: 200 })
})
const { summary, keywords, readingTime } = await response.json()
return { summary, keywords, readingTime }
}
性能优化:我踩过的三个大坑
坑1:流式输出导致页面卡顿
问题:每个chunk都触发Vue更新,频繁re-render导致掉帧。
解决:使用shallowRef + 防抖更新
// ❌ 原来的做法
const currentResponse = ref('') // 每次更新都触发依赖收集
// ✅ 改进后
const currentResponse = shallowRef('') // 只监听顶层的引用变化
// 在流式更新时,用批量更新策略
let buffer = ''
let updateTimer: number | null = null
const appendChunk = (text: string) => {
buffer += text
if (updateTimer) return
updateTimer = window.requestAnimationFrame(() => {
currentResponse.value = buffer // 一帧只更新一次
updateTimer = null
})
}
坑2:长对话上下文爆炸
问题:对话越长,token消耗越大,响应越慢。
解决:滑动窗口策略
const MAX_HISTORY = 10 // 只保留最近10轮对话
const trimHistory = () => {
if (messages.value.length > MAX_HISTORY * 2) {
// 保留系统提示 + 最近N轮
const systemMsg = messages.value[0]?.role === 'system' ? messages.value[0] : null
const recent = messages.value.slice(-MAX_HISTORY * 2)
messages.value = systemMsg ? [systemMsg, ...recent] : recent
}
}
坑3:用户快速连续点击
问题:快速点击发送导致多个请求并发,状态错乱。
解决:防抖 + 请求锁
const sendMessage = throttle(async (text: string) => {
if (isProcessing.value) return
isProcessing.value = true
try {
await doSend(text)
} finally {
isProcessing.value = false
}
}, 300, { trailing: false })
安全:不能忽视的一环
前端接入AI,安全是第一位的。我的原则:
1. API Key永不暴露在前端
// ❌ 绝对不要这样做
const API_KEY = 'sk-xxx123456' // 前端代码里硬编码
// ✅ 通过后端代理
const response = await fetch('/api/ai/chat', { // 后端转发
method: 'POST',
body: JSON.stringify({ messages })
})
2. 输入过滤
// 防止Prompt注入
const sanitizeInput = (input: string) => {
// 移除危险的控制字符
return input.replace(/[\x00-\x1F\x7F]/g, '')
.slice(0, 2000) // 限制长度
}
3. 敏感内容脱敏
发送给AI的数据要先经过脱敏处理:
const maskSensitiveData = (text: string) => {
return text
.replace(/\d{17}[\dXx]/, '***') // 身份证
.replace(/1[3-9]\d{9}/, '***') // 手机号
}
我的工具链推荐
| 用途 | 工具 | 说明 |
|---|---|---|
| API代理 | Nitro / Express | 后端转发,隐藏密钥 |
| 流式传输 | fetch + ReadableStream | 原生方案,兼容性好 |
| Markdown渲染 | marked + highlight.js | 代码高亮必备 |
| 状态管理 | Pinia | 管理对话会话状态 |
| 本地存储 | localStorage + IndexedDB | 保存历史记录 |
| 向量检索 | 后端提供API | 前端只需调接口 |
写在最后:一点心得
经过这几个月的实践,我最大的体会是:
-
不要被"AI原生应用"这个词吓到。本质上还是前端工程,只是数据来源从数据库变成了模型接口。
-
用户体验是关键。流式输出、加载状态、错误重试、中断生成——这些细节决定了一个AI应用是"能用"还是"好用"。
-
关注成本。每次调用都有成本,前端可以做很多优化(缓存、上下文压缩、按需调用)来降低成本。
-
保持学习但不要焦虑。AI技术迭代很快,但前端的核心能力(交互设计、性能优化、工程化)依然稀缺且重要。
- 点赞
- 收藏
- 关注作者
评论(0)