最近在做一个跨平台的移动应用项目,需要集成一个智能客服功能。作为一个主要使用 Uniapp 的开发者,我一开始觉得这应该不难,但真正动手才发现,从选型到上线,每一步都有不少“坑”。今天就把我这次从零搭建到性能调优的实战经验整理出来,希望能帮到有同样需求的同学。

背景痛点:为什么移动端智能客服这么“难搞”?

在决定自己动手之前,我调研了一些市面上的方案,发现直接套用现成的客服 SDK 往往不太理想,尤其是在 Uniapp 这种需要兼顾多端的场景下。主要遇到了下面几个头疼的问题:

  1. 跨平台兼容性差:这是 Uniapp 开发的老大难了。H5、小程序、App(iOS/Android)对音频录制、播放、网络请求的支持各不相同。比如,H5 里用 Web Audio API 很顺手,但到了小程序里就得换 wx.createInnerAudioContext,原生 App 里又是另一套。AI 服务返回的富文本或结构化数据,在各端的渲染表现也可能不一致。
  2. 实时性要求高:用户可不想对着一个“呆滞”的机器人。从用户说完话到机器人回复,这个延迟必须控制在毫秒级,最好在 500ms 以内。这要求网络连接必须稳定高效,传统的 HTTP 轮询(Polling)根本不可行,必须上 WebSocket 长连接。
  3. 上下文保持与状态管理复杂:一个有用的对话不是一问一答就结束的。比如用户问“今天的天气怎么样?”,接着又问“那明天呢?”,AI 必须能理解“明天”指的是“明天的天气”。这需要在本地或服务端维护一个对话的上下文(Context),管理对话的状态(例如:等待输入、识别中、回复中、错误),逻辑变得相当复杂。

技术选型:主流云服务怎么选?

确定了要自己干之后,第一步就是选一个靠谱的 AI 后端服务。我主要对比了国内三大云厂商的方案:阿里云智能语音交互、腾讯云 TI 平台、百度 UNIT。我的评估维度主要是:集成成本QPS(每秒查询率)性能与 Uniapp 的契合度

  1. 阿里云智能语音交互
    • 优点:文档非常详细,功能齐全,从语音识别(ASR)到自然语言处理(NLP)再到语音合成(TTS),一条龙服务。提供了专门的 SDK,理论上集成方便。
    • 缺点:SDK 对原生 App 支持较好,但在 Uniapp 的 H5 或小程序环境中,可能需要较多的适配工作。免费额度后的按量计费,在对话量大的时候成本需要仔细核算。
  2. 腾讯云 TI 平台
    • 优点:和微信生态结合紧密,如果你的应用主要是小程序,那么腾讯云的通道和性能优化可能有天然优势。也提供了“智能对话”等预置场景,可以快速搭建。
    • 缺点:整体方案的定制化程度感觉不如阿里云高,如果想深度定制对话逻辑,可能需要更多开发工作。
  3. 百度 UNIT
    • 优点:在自然语言理解和对话系统方面积累很深,自定义技能和意图识别的工具链做得不错,适合需要高度定制对话流程的场景。
    • 缺点:在语音交互链路的整合上,可能需要自己串联 ASR 和 TTS 服务,稍微麻烦一些。

我的选择:考虑到项目对全平台的支持和功能的完整性,我最终选择了阿里云。虽然初期适配有点工作量,但其完整的语音交互链路和清晰的 API 文档,从长远看更可控。对于中小型应用,它们的免费套餐基本够用,压力测试下 QPS 表现也符合预期。

核心实现:打通任督二脉的三板斧

选好了服务,接下来就是如何在 Uniapp 里把它跑起来。核心要解决三个问题:通信、状态管理和网络稳定。

1. 使用 RenderJS 桥接原生能力

在 App 端,为了获得最好的语音录制性能,我们可能需要调用原生模块。但 Uniapp 的 Vue 逻辑层和原生渲染层是隔离的。这时,renderjs 就派上大用场了。它运行在视图层,可以直接操作 DOM 和 BOM,也能更高效地与原生插件通信。

我创建了一个 voice-engine.renderjs 文件,在里面封装了 WebView 向原生模块发送指令和接收回调的逻辑。这样,在 Vue 页面中,我只需要调用一个简单的方法,剩下的复杂通信就交给 renderjs 了。

// 在 pages/chat/index.vue 的 script 段中
export default {
  mounted() {
    // 初始化 renderjs 模块
    this.$ownerInstance.callMethod('initVoiceEngine');
  },
  methods: {
    // 这个函数会被 renderjs 调用
    onVoiceResult(text) {
      // 收到识别后的文本,提交给 AI
      this.sendMessage(text);
    }
  }
}
// voice-engine.renderjs
export default {
  mounted() {
    // 这里可以安全地使用 window、document 等
    console.log('RenderJS 模块加载');
    // 假设我们通过某种方式注册了一个全局回调,供原生代码调用
    window._onNativeVoiceResult = (result) => {
      // 将结果发送回 Vue 层
      this.$ownerInstance.callMethod('onVoiceResult', result);
    };
  },
  methods: {
    startRecording() {
      // 调用原生方法开始录音
      // 这里的具体调用方式取决于你使用的原生插件
      uni.sendNativeEvent('voice', { action: 'start' });
    },
    stopRecording() {
      uni.sendNativeEvent('voice', { action: 'stop' });
    }
  }
}

2. 基于 Vuex 的对话状态机

对话不是乱序的,它应该有个清晰的流程。我设计了一个简单的状态机,用 Vuex 来管理。

状态(State):

  • idle: 空闲,等待用户输入
  • listening: 正在录音
  • recognizing: 语音识别中
  • thinking: AI 思考中(网络请求)
  • speaking: 语音播报中
  • error: 发生错误

状态迁移图:

[空闲 Idle] -> 用户按下录音 -> [录音 Listening]
[录音 Listening] -> 用户松开 -> [识别 Recognizing]
[识别 Recognizing] -> 识别成功 -> [思考 Thinking] -> 请求AI
[思考 Thinking] -> 收到AI回复 -> [播报 Speaking]
[播报 Speaking] -> 播报结束 -> [空闲 Idle]
任何状态 -> 发生错误 -> [错误 Error] -> 用户确认 -> [空闲 Idle]

Vuex 的 store 大概长这样:

// store/modules/chat.js
const state = {
  status: 'idle', // 当前状态
  context: [], // 对话上下文数组,每条记录包含 role(user/assistant) 和 content
  errorMsg: null,
};

const mutations = {
  SET_STATUS(state, newStatus) {
    state.status = newStatus;
  },
  ADD_MESSAGE(state, message) {
    state.context.push(message);
    // 可选:限制上下文长度,防止内存无限增长
    if (state.context.length > 20) {
      state.context = state.context.slice(-20);
    }
  },
  SET_ERROR(state, msg) {
    state.status = 'error';
    state.errorMsg = msg;
  },
  CLEAR_ERROR(state) {
    state.errorMsg = null;
    state.status = 'idle';
  }
};

const actions = {
  async sendMessage({ commit, state }, userInput) {
    if (state.status !== 'idle') return; // 防止重复提交

    commit('ADD_MESSAGE', { role: 'user', content: userInput });
    commit('SET_STATUS', 'thinking');

    try {
      // 1. 将当前上下文(state.context)发送给AI服务
      const aiResponse = await callAIService(state.context);
      // 2. 存储AI回复
      commit('ADD_MESSAGE', { role: 'assistant', content: aiResponse });
      // 3. 触发语音合成(TTS)
      commit('SET_STATUS', 'speaking');
      await playTTS(aiResponse);
      // 4. 恢复空闲
      commit('SET_STATUS', 'idle');
    } catch (error) {
      commit('SET_ERROR', 'AI服务请求失败');
      console.error('AI请求错误:', error);
    }
  },
};

3. WebSocket 连接保活与断线重连

为了达到 <500ms 的响应目标,WebSocket 是必须的。但移动网络不稳定,必须做好保活和重连。

// utils/websocket-client.ts
type MessageHandler = (data: any) => void;

class WSClient {
  private ws: WebSocket | null = null;
  private url: string;
  private heartbeatInterval: number = 30000; // 30秒心跳
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  private reconnectDelay: number = 1000;
  private maxReconnectDelay: number = 30000;
  private handlers: Map<string, MessageHandler[]> = new Map();

  constructor(url: string) {
    this.url = url;
    this.connect();
  }

  private connect(): void {
    try {
      this.ws = new WebSocket(this.url);
      this.setupEventListeners();
    } catch (error) {
      console.error('WebSocket 连接创建失败:', error);
      this.scheduleReconnect();
    }
  }

  private setupEventListeners(): void {
    if (!this.ws) return;

    this.ws.onopen = () => {
      console.log('WebSocket 连接已建立');
      this.startHeartbeat();
      this.emit('connected', {});
    };

    this.ws.onmessage = (event: MessageEvent) => {
      try {
        const data = JSON.parse(event.data);
        // 处理服务器心跳回应
        if (data.type === 'pong') {
          return;
        }
        // 处理业务消息
        this.emit('message', data);
      } catch (e) {
        console.error('消息解析失败:', e);
      }
    };

    this.ws.onclose = (event) => {
      console.warn(`WebSocket 连接关闭,代码: ${event.code}`);
      this.stopHeartbeat();
      this.scheduleReconnect();
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket 错误:', error);
      this.ws?.close(); // 触发 onclose 进行重连
    };
  }

  private startHeartbeat(): void {
    this.stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.send({ type: 'ping' });
      }
    }, this.heartbeatInterval);
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  private scheduleReconnect(): void {
    setTimeout(() => {
      console.log('尝试重连 WebSocket...');
      this.connect();
      // 指数退避策略
      this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, this.maxReconnectDelay);
    }, this.reconnectDelay);
  }

  public send(data: object): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    } else {
      console.error('WebSocket 未连接,消息发送失败');
    }
  }

  public on(event: string, handler: MessageHandler): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event)!.push(handler);
  }

  private emit(event: string, data: any): void {
    const eventHandlers = this.handlers.get(event);
    if (eventHandlers) {
      eventHandlers.forEach(handler => handler(data));
    }
  }

  public close(): void {
    this.stopHeartbeat();
    this.ws?.close();
    this.handlers.clear();
  }
}

// 使用示例
export const chatWS = new WSClient('wss://your-ai-server.com/chat');

代码示例:一个可复用的对话组件

把上面的东西组合起来,封装成一个 ChatBox 组件。

<!-- components/chat-box.vue -->
<template>
  <view class="chat-container">
    <!-- 消息列表 -->
    <scroll-view scroll-y class="message-list">
      <view v-for="(msg, index) in messages" :key="index" :class="['message-bubble', msg.role]">
        {{ msg.content }}
      </view>
    </scroll-view>

    <!-- 输入区域 -->
    <view class="input-area">
      <input v-model="inputText" @confirm="sendText" placeholder="输入消息..." />
      <button @tap="toggleRecording" :class="{ recording: isRecording }">
        {{ isRecording ? '停止录音' : '按住说话' }}
      </button>
    </view>
  </view>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue';
import { useStore } from 'vuex';

interface ChatMessage {
  role: 'user' | 'assistant';
  content: string;
  timestamp: number;
}

export default defineComponent({
  name: 'ChatBox',
  setup() {
    const store = useStore();
    const inputText = ref('');
    const isRecording = ref(false);

    // 从 Vuex 获取消息和状态
    const messages = computed<ChatMessage[]>(() => store.state.chat.context);
    const chatStatus = computed(() => store.state.chat.status);

    const sendText = async (): Promise<void> => {
      const text = inputText.value.trim();
      if (!text || chatStatus.value !== 'idle') return;

      inputText.value = '';
      try {
        await store.dispatch('chat/sendMessage', text);
      } catch (error) {
        uni.showToast({ title: '发送失败', icon: 'none' });
      }
    };

    const toggleRecording = (): void => {
      if (isRecording.value) {
        // 停止录音
        uni.$emit('voice:stop');
        isRecording.value = false;
      } else {
        // 开始录音
        if (chatStatus.value !== 'idle') {
          uni.showToast({ title: '系统正忙', icon: 'none' });
          return;
        }
        uni.$emit('voice:start');
        isRecording.value = true;
      }
    };

    // 监听语音识别结果
    uni.$on('voice:result', (text: string) => {
      if (text) {
        store.dispatch('chat/sendMessage', text);
      }
      isRecording.value = false;
    });

    return {
      inputText,
      isRecording,
      messages,
      chatStatus,
      sendText,
      toggleRecording,
    };
  },
});
</script>
// utils/aliyun-ai.ts
import CryptoJS from 'crypto-js';

// 配置类型定义
interface AliyunConfig {
  accessKeyId: string;
  accessKeySecret: string;
  endpoint: string;
  namespace: string;
}

// 消息队列防抖
class MessageQueue {
  private queue: Array<() => Promise<void>> = [];
  private isProcessing: boolean = false;

  async enqueue(task: () => Promise<void>): Promise<void> {
    this.queue.push(task);
    if (!this.isProcessing) {
      await this.processQueue();
    }
  }

  private async processQueue(): Promise<void> {
    this.isProcessing = true;
    while (this.queue.length > 0) {
      const task = this.queue.shift();
      if (task) {
        try {
          await task();
        } catch (error) {
          console.error('队列任务执行失败:', error);
          // 可以根据错误类型决定是否重试或丢弃
        }
      }
      // 可选:添加微小延迟,避免洪水请求
      await new Promise(resolve => setTimeout(resolve, 50));
    }
    this.isProcessing = false;
  }
}

export class AliyunAIClient {
  private config: AliyunConfig;
  private messageQueue: MessageQueue;

  constructor(config: AliyunConfig) {
    this.config = config;
    this.messageQueue = new MessageQueue();
  }

  // 生成签名,用于 API 鉴权
  private generateSignature(text: string): string {
    const signStr = `text=${encodeURIComponent(text)}&timestamp=${Date.now()}`;
    return CryptoJS.HmacSHA256(signStr, this.config.accessKeySecret).toString(CryptoJS.enc.Hex);
  }

  // 发送消息到阿里云 NLP 服务(示例为 HTTP,实际可能用 WebSocket)
  async sendMessageAsync(userInput: string, sessionId: string): Promise<string> {
    return new Promise((resolve, reject) => {
      // 将请求任务加入队列,实现防抖/限流
      this.messageQueue.enqueue(async () => {
        try {
          const signature = this.generateSignature(userInput);
          const response = await uni.request({
            url: `${this.config.endpoint}/chat`,
            method: 'POST',
            header: {
              'Content-Type': 'application/json',
              'X-Signature': signature,
            },
            data: {
              text: userInput,
              session_id: sessionId,
              namespace: this.config.namespace,
            },
            timeout: 10000, // 10秒超时
          });

          const data = response.data as any;
          if (data.code === 0 && data.result) {
            resolve(data.result.text);
          } else {
            reject(new Error(`AI服务错误: ${data.message || '未知错误'}`));
          }
        } catch (error: any) {
          console.error('请求阿里云AI失败:', error);
          reject(new Error(`网络请求失败: ${error.errMsg || error.message}`));
        }
      });
    });
  }
}

性能优化:让体验更流畅

功能实现了,但体验要丝滑,还得优化。

1. 首屏加载时间优化

智能客服通常不是应用的主功能,所以不应该阻塞主包的加载。

  • 分包加载:将 AI 客服相关的组件、SDK、工具类全部打到一个独立的分包中。在用户真正点击进入客服页面时再加载。
    // pages.json
    {
      "subPackages": [
        {
          "root": "subpackage-chat",
          "pages": [
            {
              "path": "pages/chat/index",
              "style": { ... }
            }
          ]
        }
      ]
    }
    
  • 预初始化:在应用启动后,在空闲时间(例如 onLaunch 中设一个延时)预先建立 WebSocket 连接并进行鉴权。这样用户点击客服时,连接已经是就绪状态,能立刻开始对话。

2. 对话上下文压缩算法

随着对话轮次增加,每次都将全部历史上下文发给 AI,既浪费流量也增加延迟。我们可以进行压缩。

一个简单有效的方法是:只保留最近 N 轮对话(比如5轮),对于更早的对话,则提取其关键摘要。更高级的做法可以使用像 BERT (Bidirectional Encoder Representations from Transformers) 这样的句子编码模型,将历史对话编码成向量,然后只挑选与当前问题最相关的几条历史记录发送。

这里给出一个简单的摘要缓存思路:

// utils/context-compressor.js
class ContextCompressor {
  constructor(maxFullTurns = 5) {
    this.maxFullTurns = maxFullTurns; // 保留完整对话的最大轮数
    this.summaryCache = new Map(); // 对话摘要缓存
  }

  compress(context) {
    if (context.length <= this.maxFullTurns * 2) { // 乘以2因为包含user和assistant
      return context;
    }

    const recentContext = context.slice(-this.maxFullTurns * 2);
    const olderContext = context.slice(0, -this.maxFullTurns * 2);

    // 为更早的对话生成或获取摘要
    let summary = this.summaryCache.get('older');
    if (!summary) {
      summary = this.generateSummary(olderContext);
      this.summaryCache.set('older', summary);
    }

    // 将摘要作为一条“系统”消息插入到最近上下文之前
    return [
      { role: 'system', content: `Earlier conversation summary: ${summary}` },
      ...recentContext
    ];
  }

  // 简单的摘要生成方法(实际项目可用更复杂的NLP方法)
  generateSummary(context) {
    // 这里只是示例:提取每句话的前几个词
    return context.map(msg => {
      const words = msg.content.split(' ');
      return words.slice(0, 3).join(' ') + (words.length > 3 ? '...' : '');
    }).join('; ');
  }
}

避坑指南:生产环境高频问题

上线后,遇到了几个平台特有的问题,这里分享下解决方案。

  1. iOS 录音权限申请与描述

    • 问题:在 iOS 上,不仅要在 manifest.json 里配置麦克风权限,还必须向用户清晰说明用途,否则审核可能被拒。
    • 解决:在应用内,首次触发录音前,用自定义弹窗友好地说明“需要麦克风权限来实现语音客服功能”。调用 uni.authorize 时,确保用户已经知情。在 App Store 的隐私描述里也要写清楚。
  2. Android 后台语音唤醒限制

    • 问题:为了省电,Android 系统会限制后台应用的网络和麦克风访问。用户切到后台,WebSocket 可能断连,语音监听也会停止。
    • 解决
      • 使用 uni.onAppHideuni.onAppShow 监听应用前后台切换,在切到后台时发送一个“休眠”指令给服务器,暂停对话;切回前台时快速重连。
      • 对于需要后台持续收听“唤醒词”的场景(如“你好小助”),必须使用原生插件创建前台服务(Foreground Service),并给出常驻通知栏,这对用户体验影响较大,需谨慎使用。
  3. H5 端自动播放策略与音频上下文

    • 问题:Chrome 等浏览器禁止未经用户交互的自动音频播放。这意味着 AI 的语音回复(TTS)可能无法自动播出来。
    • 解决:在页面加载后,在用户第一次点击(如一个“开启语音”按钮)时,不仅申请麦克风权限,同时初始化一个空的 AudioContext 并播放一段静音音频。这个用户手势会解锁后续的自动播放权限。可以将这个逻辑封装成一个 unlockAudio() 函数,在应用启动后合适的时机调用。

延伸思考:不止于对话

基本的智能客服跑通了,但一个完整的客服系统还应该包括工单、转人工、知识库管理等功能。Uniapp 的 uni-cloud 云开发能力,为快速实现这些后端功能提供了可能。

你可以尝试:

  • uni-cloud 上搭建一个简单的云函数,当 AI 无法解决问题时,自动创建一张工单,并通知到你的管理后台。
  • 利用云数据库存储用户的对话历史,方便后续分析和客服人员查看。
  • 结合云存储,让客服可以发送图片、文件等。

这样一来,一个由“前端 Uniapp + AI 云服务 + 后端 uni-cloud”组成的轻量级、全栈智能客服解决方案就初具雏形了。

总结一下,在 Uniapp 里集成 AI 智能客服,核心在于处理好跨平台兼容实时通信复杂状态管理。通过合理的架构设计(如 RenderJS 桥接、Vuex 状态机)和稳定的网络策略(WebSocket 保活),完全可以构建出体验流畅的生产级应用。性能优化和平台特性适配是后期提升用户体验的关键。希望这篇笔记能为你节省一些摸索的时间。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐