当Vue遇上AI:我在项目中接入大模型的完整实践

举报
码事漫谈 发表于 2026/08/24 23:22:44 2026/08/24
【摘要】 从焦虑到落地,聊聊前端开发者如何拥抱AI时代 写在前面去年这个时候,我还在担心AI会不会让前端失业。今年这个时候,我已经在三个项目中成功接入了大模型能力。回头看,最大的感悟是:AI不是来取代我们的,而是给我们发了一把新武器。这篇文章不讲空话,直接从我的实际项目出发,分享Vue应用中接入AI的完整方案、踩过的坑和沉淀下来的最佳实践。 为什么是Vue + AI?Vue的响应式系统和组合式API,...

从焦虑到落地,聊聊前端开发者如何拥抱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 前端只需调接口

写在最后:一点心得

经过这几个月的实践,我最大的体会是:

  1. 不要被"AI原生应用"这个词吓到。本质上还是前端工程,只是数据来源从数据库变成了模型接口。

  2. 用户体验是关键。流式输出、加载状态、错误重试、中断生成——这些细节决定了一个AI应用是"能用"还是"好用"。

  3. 关注成本。每次调用都有成本,前端可以做很多优化(缓存、上下文压缩、按需调用)来降低成本。

  4. 保持学习但不要焦虑。AI技术迭代很快,但前端的核心能力(交互设计、性能优化、工程化)依然稀缺且重要。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。