Vue3+AI智能客服系统前端实战:从零搭建到生产环境部署
最近在做一个智能客服系统的前端项目,用上了 Vue 3 和 TypeScript,还集成了 AI 服务。整个过程下来,感觉和以前用 Vue 2 做复杂交互应用很不一样,尤其是处理多轮对话状态、实时通信这些场景。今天就把我的搭建思路和踩过的一些坑整理一下,希望能给想尝试类似项目的朋友一些参考。
1. 项目背景与核心挑战
智能客服前端听起来就是个聊天界面,但真做起来,会发现不少技术难点。最头疼的主要是两点:
多轮对话的状态维护:用户和 AI 的对话不是一问一答就结束的。比如用户先问“你们的退货政策是什么?”,客服 AI 回答后,用户可能接着问“那运费谁承担?”。AI 需要理解这个“那”指的是上一轮的“退货”,这就需要前端把对话的上下文(Context)完整、有序地维护好,并在每次请求时准确传递给后端。
AI 响应延迟与异步处理:调用大语言模型的 API,响应时间不稳定,有时快有时慢。前端需要处理这种不确定性,比如在等待时显示“思考中...”的加载状态,还要处理可能发生的网络超时或服务错误,给出友好的降级提示(如“客服正在忙,请稍后再试”),而不是直接白屏或报错。

2. 为什么选择 Vue 3?
之前也考虑过 React 或其他框架,但最终选择了 Vue 3,主要是看中了它在复杂交互场景下的几个优势:
- 组合式 API (Composition API):这是最大的亮点。对于智能客服这种逻辑复杂的应用,传统的 Options API 会把逻辑分散在
data、methods、computed等各个选项中,维护起来很费劲。而 Composition API 允许我们把“对话管理”、“网络请求”、“AI 逻辑”这些功能,封装成一个个独立的、可复用的自定义 Hook(在 Vue 里叫composable),代码组织清晰多了。 - 更好的 TypeScript 支持:Vue 3 从头到尾用 TypeScript 重写,类型推断非常完善。这对于需要严格定义“消息对象”、“对话状态”、“API 响应格式”的项目来说,能极大减少低级错误,提升开发体验。
- 响应式系统升级:Vue 3 的响应式系统更高效、更灵活。在处理实时更新的对话列表、在线状态等数据时,性能表现更好。
当然,Vue 3 的学习曲线对于 Vue 2 用户来说确实存在,但一旦熟悉了 Composition API 的思维方式,开发效率会高很多。
3. 核心实现步骤拆解
下面我分模块讲讲具体是怎么做的。
3.1 使用 Pinia 管理全局对话状态
状态管理我用的是 Pinia,它比 Vuex 更简单,也完美支持 Composition API。我定义了一个 useConversationStore 来管理所有对话相关的状态。
// stores/conversation.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
// 定义类型
export interface Message {
id: string;
content: string;
sender: 'user' | 'ai';
timestamp: number;
status: 'sending' | 'success' | 'error';
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
}
export const useConversationStore = defineStore('conversation', () => {
// 状态
const currentConversationId = ref<string>('');
const conversations = ref<Conversation[]>([]);
const isLoading = ref<boolean>(false);
// Getter
const currentConversation = computed(() => {
return conversations.value.find(c => c.id === currentConversationId.value) || null;
});
const currentMessages = computed(() => {
return currentConversation.value?.messages || [];
});
// Action
const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
const conv = currentConversation.value;
if (!conv) return;
const newMsg: Message = {
...message,
id: `msg_${Date.now()}`,
timestamp: Date.now(),
};
conv.messages.push(newMsg);
};
const updateMessageStatus = (messageId: string, status: Message['status']) => {
const conv = currentConversation.value;
if (!conv) return;
const msg = conv.messages.find(m => m.id === messageId);
if (msg) {
msg.status = status;
}
};
// 初始化或切换对话
const switchConversation = (convId?: string) => {
if (!convId) {
// 创建新对话
const newConv: Conversation = {
id: `conv_${Date.now()}`,
title: '新对话',
messages: [],
createdAt: Date.now(),
};
conversations.value.unshift(newConv); // 新对话放在最前面
currentConversationId.value = newConv.id;
} else {
currentConversationId.value = convId;
}
isLoading.value = false;
};
return {
currentConversationId,
conversations,
isLoading,
currentConversation,
currentMessages,
addMessage,
updateMessageStatus,
switchConversation,
};
});
这样,在任何组件里,我都可以方便地获取和修改对话状态,逻辑非常清晰。
3.2 WebSocket 连接封装与重试机制
为了实时接收 AI 的流式响应(比如一个字一个字地返回),我选择了 WebSocket。封装了一个带自动重连和心跳检测的 Hook。
// composables/useWebSocket.ts
import { ref, onUnmounted } from 'vue';
interface UseWebSocketOptions {
url: string;
onMessage: (event: MessageEvent) => void;
onOpen?: () => void;
onClose?: () => void;
onError?: (event: Event) => void;
reconnectAttempts?: number;
reconnectInterval?: number;
}
export function useWebSocket(options: UseWebSocketOptions) {
const socket = ref<WebSocket | null>(null);
const isConnected = ref(false);
const reconnectCount = ref(0);
const maxReconnect = options.reconnectAttempts || 5;
const reconnectDelay = options.reconnectInterval || 3000;
let heartbeatTimer: number | null = null;
const connect = () => {
try {
const ws = new WebSocket(options.url);
socket.value = ws;
ws.onopen = () => {
console.log('WebSocket connected');
isConnected.value = true;
reconnectCount.value = 0;
options.onOpen?.();
startHeartbeat();
};
ws.onmessage = options.onMessage;
ws.onclose = (event) => {
console.log('WebSocket disconnected', event.code, event.reason);
isConnected.value = false;
stopHeartbeat();
options.onClose?.();
// 非正常关闭,尝试重连
if (event.code !== 1000 && reconnectCount.value < maxReconnect) {
setTimeout(() => {
reconnectCount.value += 1;
console.log(`Attempting to reconnect... (${reconnectCount.value}/${maxReconnect})`);
connect();
}, reconnectDelay);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
isConnected.value = false;
options.onError?.(error);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
}
};
const send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (socket.value && isConnected.value) {
socket.value.send(data);
} else {
console.error('WebSocket is not connected');
// 可以在这里将消息加入队列,等连接恢复后发送
}
};
const disconnect = () => {
if (socket.value) {
socket.value.close(1000, 'Normal closure');
socket.value = null;
}
stopHeartbeat();
};
const startHeartbeat = () => {
heartbeatTimer = window.setInterval(() => {
if (socket.value && isConnected.value) {
socket.value.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // 每30秒发送一次心跳
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
onUnmounted(() => {
disconnect();
});
return {
socket,
isConnected,
connect,
send,
disconnect,
};
}
3.3 AI 服务调用与防抖降级处理
调用 AI 接口是核心。我封装了一个 useAIChat 的 composable,集成了防抖、错误处理和降级逻辑。
// composables/useAIChat.ts
import { ref } from 'vue';
import { useConversationStore } from '@/stores/conversation';
import { useWebSocket } from './useWebSocket';
interface AIChatOptions {
apiEndpoint: string;
wsEndpoint: string;
maxRetries?: number;
}
export function useAIChat(options: AIChatOptions) {
const conversationStore = useConversationStore();
const isThinking = ref(false);
const error = ref<string | null>(null);
let abortController: AbortController | null = null;
// 防抖发送消息
const sendMessageToAI = async (userInput: string) => {
// 1. 清除旧错误,设置加载状态
error.value = null;
isThinking.value = true;
// 2. 在状态管理中添加用户消息
conversationStore.addMessage({
content: userInput,
sender: 'user',
status: 'success',
});
// 3. 为本次请求创建 AI 消息占位符(用于流式更新)
const aiMessageId = `ai_msg_${Date.now()}`;
conversationStore.addMessage({
id: aiMessageId,
content: '', // 初始为空,通过 WebSocket 流式填充
sender: 'ai',
status: 'sending',
});
// 4. 准备对话上下文
const contextMessages = conversationStore.currentMessages
.slice(-10) // 只发送最近10条作为上下文,防止 token 超限
.map(msg => ({ role: msg.sender, content: msg.content }));
const requestPayload = {
messages: contextMessages,
stream: true, // 启用流式响应
};
try {
// 方案A: 使用 WebSocket 进行流式对话(主方案)
const { send, isConnected } = useWebSocket({
url: options.wsEndpoint,
onMessage: (event) => {
const data = JSON.parse(event.data);
if (data.type === 'chunk') {
// 流式更新 AI 消息内容
updateAIMessageContent(aiMessageId, data.content);
} else if (data.type === 'done') {
conversationStore.updateMessageStatus(aiMessageId, 'success');
isThinking.value = false;
}
},
});
if (isConnected.value) {
send(JSON.stringify(requestPayload));
} else {
// 方案B: WebSocket 未就绪,降级为普通 HTTP 请求
await fetchAIChatFallback(requestPayload, aiMessageId);
}
} catch (err) {
handleAIChatError(err, aiMessageId);
}
};
// 降级方案:普通 HTTP POST 请求
const fetchAIChatFallback = async (payload: any, aiMessageId: string) => {
abortController = new AbortController();
try {
const response = await fetch(options.apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, stream: false }), // 降级为非流式
signal: abortController.signal,
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
updateAIMessageContent(aiMessageId, result.reply);
conversationStore.updateMessageStatus(aiMessageId, 'success');
} catch (err) {
handleAIChatError(err, aiMessageId);
} finally {
isThinking.value = false;
abortController = null;
}
};
// 更新 AI 消息内容(用于流式拼接)
const updateAIMessageContent = (messageId: string, chunk: string) => {
const conv = conversationStore.currentConversation;
if (!conv) return;
const msg = conv.messages.find(m => m.id === messageId);
if (msg && msg.sender === 'ai') {
msg.content += chunk;
}
};
// 统一错误处理
const handleAIChatError = (err: any, aiMessageId: string) => {
console.error('AI chat error:', err);
conversationStore.updateMessageStatus(aiMessageId, 'error');
error.value = err.message || 'AI 服务暂时不可用,请稍后重试。';
isThinking.value = false;
// 可以在这里触发监控告警
};
// 取消请求
const cancelRequest = () => {
if (abortController) {
abortController.abort();
isThinking.value = false;
error.value = '请求已取消';
}
};
return {
sendMessageToAI,
isThinking,
error,
cancelRequest,
};
}
4. 性能优化:虚拟列表渲染对话记录
当对话历史很长时,渲染所有消息节点会非常消耗性能。我引入了虚拟列表技术,只渲染可视区域内的消息。
我使用了 vue-virtual-scroller 这个库,它非常适合聊天场景。核心思路是计算滚动位置,动态显示和隐藏消息项。
<!-- components/MessageList.vue -->
<template>
<RecycleScroller
class="message-scroller"
:items="formattedMessages"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<MessageBubble :message="item" />
</RecycleScroller>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import MessageBubble from './MessageBubble.vue';
import { useConversationStore } from '@/stores/conversation';
const conversationStore = useConversationStore();
// 对消息进行预处理,方便虚拟列表计算高度
const formattedMessages = computed(() => {
return conversationStore.currentMessages.map(msg => ({
...msg,
// 可以根据内容长度估算高度,这里简化处理
estimatedHeight: Math.max(60, Math.ceil(msg.content.length / 50) * 20),
}));
});
</script>
<style scoped>
.message-scroller {
height: 500px;
overflow-y: auto;
}
</style>
5. 安全考虑:XSS 防护与敏感词过滤
用户输入和 AI 返回的内容都可能存在风险,必须处理。
-
XSS 防护:
- 渲染时转义:Vue 的模板语法
{{ }}默认会对内容进行 HTML 转义,这是第一道防线。 - 谨慎使用
v-html:除非绝对必要且内容可信,否则避免使用v-html指令。如果 AI 返回了富文本(比如包含简单排版),必须在后端或前端使用一个安全的 HTML 净化库(如DOMPurify)进行处理后再渲染。
import DOMPurify from 'dompurify'; // ... const sanitizedHtml = DOMPurify.sanitize(aiResponseContent); - 渲染时转义:Vue 的模板语法
-
敏感词过滤:
- 虽然主要过滤责任在后端,但前端也可以做一层基础防护,避免明显违规内容直接显示或发送。可以维护一个轻量级的敏感词库,在用户发送和 AI 回复显示时进行匹配和替换(如替换为
***)。 - 更常见的做法是,在前端发送消息前,调用一个内容安全校验接口,由后端返回是否合规的结果。
- 虽然主要过滤责任在后端,但前端也可以做一层基础防护,避免明显违规内容直接显示或发送。可以维护一个轻量级的敏感词库,在用户发送和 AI 回复显示时进行匹配和替换(如替换为
6. 生产环境部署检查清单
项目开发完,要上线了,下面这些点得再核对一遍:
-
构建与发布:
npm run build检查是否有错误或警告。- 确认
vue.config.js或vite.config.ts中的publicPath是否正确(尤其是使用 CDN 时)。 - 开启 Gzip/Brotli 压缩(通常在 Web 服务器如 Nginx 配置)。
-
CDN 与静态资源:
- 将构建出的
dist目录下的静态文件(JS、CSS、图片)上传至 CDN。 - 确保 CDN 域名已正确配置,并且在项目中通过环境变量引用。
- 为静态资源设置合适的缓存策略(如长期缓存),并配置版本哈希以解决更新问题。
- 将构建出的
-
API 与 WebSocket 配置:
- 将开发环境的后端地址替换为生产环境地址。务必使用环境变量管理,不要写死在代码里。
- 检查 WebSocket 连接地址 (
wss://) 是否正确,并确认生产环境防火墙/负载均衡器支持 WebSocket 协议。 - 配置 API 请求的超时时间、重试策略。
-
监控与埋点:
- 性能监控:记录页面加载时间、接口响应时间、WebSocket 连接成功率/延迟。
- 错误监控:全局捕获并上报 JS 错误、未处理的 Promise 拒绝、API 调用失败、WebSocket 异常断开等信息。可以使用 Sentry 或自建监控服务。
- 业务埋点:记录用户关键行为,如“发送消息次数”、“会话时长”、“AI 响应超时次数”、“用户满意度评价(如果有)”。这些数据对优化产品至关重要。
-
安全复查:
- 确保没有将敏感密钥(如 API Key)硬编码在前端代码中。
- 验证 CSP (Content Security Policy) 头部的配置是否正确,防止 XSS。
- 确认 HTTPS 已正确部署,特别是 WebSocket (
WSS)。
-
用户体验兜底:
- 再次测试网络离线、服务端错误等异常情况下的 UI 提示是否友好。
- 确认加载状态、骨架屏等过渡效果正常工作。
写在最后
从零搭建这个 Vue 3 + AI 的智能客服前端,整个过程就像搭积木,用 Composition API 把功能模块一块块拼起来,再用 Pinia 管理好它们之间的状态流动。最大的体会是,面对 AI 集成这种异步性、不确定性很强的需求,前端的健壮性设计(错误处理、降级、重试)和状态管理清晰度变得异常重要。
现在项目已经稳定运行了一段时间,这套架构也经受住了考验。希望这篇笔记里提到的思路和代码片段,能帮你少走些弯路。如果你在实现过程中遇到其他问题,欢迎一起交流。
更多推荐



所有评论(0)