我花了一个周末,把聊天机器人变成了3D老师

手搓一个教育陪读具身智能体,聊聊 Agent 为什么需要一个"身体"

写在前面

上周末闲着没事,翻出之前做的一个教育问答机器人——就是那种最朴素的实现:前端一个输入框,后端接个大模型,流式输出文本。跑是能跑,但怎么看怎么像我在 2023 年写的 demo。

这两年 Agent 圈子变化很大,MCP、Function Calling、Multi-Agent 这些概念层出不穷。但有一个事情我一直觉得别扭:不管 Agent 后面有多复杂的推理链路、多少个工具节点,到了用户面前,还是只有一个聊天窗口。

这周偶然看到魔珐星云的具身驱动 SDK,突然想试一件事——能不能把我那个"丑陋的聊天框"变成一个真正能说话、能做动作的3D数字人老师?本文记录了整个折腾过程。

手机端可以直接体验交互效果:

一、从一个问题开始:Agent 缺了什么?

先别急着写代码,想清楚一件事。

我那个教育问答机器人,RAG 接了,Function Calling 也接了,多轮对话记忆也做了。从"能力"角度来说没毛病。但给几个朋友试用后,反馈出奇地一致:

  • “感觉就是在跟搜索引擎说话”

  • “等它回复的时候不知道在干嘛”

  • “回答挺准的,但总觉得冷冰冰的”

第一条好理解——纯文本输出,信息密度太低。面对面交流时,语言本身只传递大约 7% 的信息量,语气和肢体占了 93%。纯文本等于把信息通道砍到只剩零头。

第二条更关键。Agent 调工具、查知识库、多步推理——这些内部过程对用户来说是黑盒。用户发完消息就盯着光标闪,不知道 Agent 在思考还是在卡死。在纯文本界面里你只能加个"正在思考..."的 toast,但那和真人对话中"对方皱眉想了一下"的体感完全不是一回事。

第三条其实是一二两条的叠加效果——没有表情、没有语气、没有肢体反馈,整个交互就是"你问,我答"的机械循环。

所以问题不是"Agent 不够聪明",而是"Agent 没法把自己做的事’演’出来"。

1.2 技术栈选择

本项目采用以下技术方案:

- 前端框架:Vue3(组合式API)

- 构建工具:Vite

- 样式方案:CSS3(Flexbox布局、渐变背景、动画效果)

- 数字人服务:星云数字人SDK

- AI对话服务:大语言模型流式接口

1.3 项目结构

├── src/
│   ├── components/
│   │   └── CustomerService.vue    # 主组件
│   ├── services/
│   │   ├── xingyun.service.js     # 数字人服务
│   │   └── llm.service.js         # AI对话服务
│   ├── styles/
│   │   └── main.css               # 全局样式
│   ├── App.vue                     # 根组件
│   └── main.ts                     # 入口文件
├── public/                        # 静态资源
├── index.html                      # HTML模板
├── vite.config.ts                 # Vite配置
└── package.json                    # 项目依赖

二、环境配置与项目初始化

2.1 创建Vue3项目

npm create vite@latest diy-creative-avatar -- --template vue-ts
cd diy-creative-avatar
npm install

2.2 安装项目依赖

npm install vue-router@4 pinia axios

2.3 配置路径别名

在`vite.config.ts`中配置路径别名,简化导入路径:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

三、全局样式设计

3.1 自定义滚动条样式

::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: rgba(26, 71, 42, 0.5);
  border-radius: 4px;
}

::-webkit-scrollbar-thumb {
  background: linear-gradient(135deg, #4ade80, #22c55e);
  border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
  background: linear-gradient(135deg, #22c55e, #16a34a);
}

四、核心组件开发

4.1 HTML模板结构

组件采用经典的三区域布局设计:

<template>
  <div class="diy-container">
    <!-- 顶部导航栏 -->
    <header class="header">
      <div class="logo-area">
        <div class="logo-icon">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
          </svg>
        </div>
        <div class="logo-text">
          <h1>DIY创意达人</h1>
          <p class="subtitle">手工教学 · 趣味创意数字人</p>
        </div>
      </div>
      <div class="header-controls">
        <div class="guide-status" :class="{ active: isConnected }">
          <span class="status-dot"></span>
          <span class="status-text">{{ isConnected ? '导师在线' : '系统就绪' }}</span>
        </div>
      </div>
    </header>

    <!-- 主内容区域 -->
    <div class="main-content">
      <!-- 左侧数字人展示区 -->
      <div class="avatar-section">
        <div class="avatar-container">
          <div :id="containerId" class="avatar-render-area"></div>
          <div v-if="!isConnected" class="loading-overlay">
            <div class="loader-ring"></div>
            <p class="loading-text">创意导师准备中...</p>
            <div class="progress-bar">
              <div class="progress-fill" :style="{ width: loadProgress + '%' }"></div>
            </div>
          </div>
        </div>

        <!-- 创作档案面板 -->
        <div class="status-panel">
          <div class="panel-header">
            <span class="panel-title">创作档案</span>
          </div>
          <div class="status-grid">
            <div class="status-item">
              <span class="item-label">创作类型</span>
              <span class="item-value">{{ craftType || '待选择' }}</span>
            </div>
            <div class="status-item">
              <span class="item-label">难度</span>
              <span class="item-value">{{ difficultyLevel || '未选择' }}</span>
            </div>
          </div>
        </div>
      </div>

      <!-- 右侧交互区 -->
      <div class="interaction-section">
        <!-- 标签页切换 -->
        <div class="tab-bar">
          <button
            v-for="tab in tabs"
            :key="tab.id"
            :class="['tab-btn', { active: activeTab === tab.id }]"
            @click="activeTab = tab.id"
          >
            <span class="tab-icon">{{ tab.icon }}</span>
            <span class="tab-label">{{ tab.label }}</span>
          </button>
        </div>

        <!-- 对话记录区 -->
        <div class="chat-history" ref="chatContainer">
          <div class="history-header">
            <span>创作对话</span>
            <span class="record-count">共 {{ chatHistory.length }} 条</span>
          </div>
          <div
            v-for="(message, index) in chatHistory"
            :key="index"
            :class="['message', message.type]"
          >
            <div class="message-header">
              <span class="message-sender">{{ message.sender }}</span>
              <span class="message-tag" v-if="message.tag">{{ message.tag }}</span>
            </div>
            <div class="message-content">{{ message.content }}</div>
            <div class="message-meta">
              <span class="message-time">{{ message.time }}</span>
            </div>
          </div>
        </div>

        <!-- 输入控制区 -->
        <div class="input-controls">
          <div class="craft-input" v-if="!craftStarted">
            <div class="input-row-grid">
              <div class="input-group">
                <label>手工类型</label>
                <select v-model="craftType" class="select-input">
                  <option value="">请选择</option>
                  <option value="paper">折纸艺术</option>
                  <option value="clay">黏土创作</option>
                  <option value="recycle">废旧改造</option>
                  <option value="origami">复杂折纸</option>
                  <option value="handmade">综合手工</option>
                </select>
              </div>
              <div class="input-group">
                <label>难度等级</label>
                <select v-model="difficultyLevel" class="select-input">
                  <option value="">请选择</option>
                  <option value="beginner">入门级</option>
                  <option value="intermediate">进阶级</option>
                  <option value="advanced">熟练级</option>
                  <option value="expert">大师级</option>
                </select>
              </div>
            </div>
          </div>

          <div class="quick-actions" v-if="isConnected">
            <button
              v-for="action in quickActions"
              :key="action.id"
              :class="['action-btn', action.class]"
              @click="handleQuickAction(action)"
            >
              <span class="btn-icon">{{ action.icon }}</span>
              <span class="btn-text">{{ action.label }}</span>
            </button>
          </div>

          <div class="message-input-area" v-if="craftStarted">
            <div class="input-row">
              <textarea
                v-model="userInput"
                placeholder="请输入您想学习的手工技巧..."
                @keyup.enter="sendMessage"
                :disabled="!isConnected || isProcessing"
              ></textarea>
            </div>
            <div class="input-toolbar">
              <div class="toolbar-left">
                <select v-model="selectedCategory" class="expression-select">
                  <option value="">选择教学类别</option>
                  <option value="tutorial">教程演示</option>
                  <option value="tips">技巧分享</option>
                  <option value="material">材料讲解</option>
                </select>
              </div>
              <div class="toolbar-right">
                <button
                  class="send-btn"
                  @click="sendMessage"
                  :disabled="!isConnected || !userInput.trim()"
                >
                  <span>发送问题</span>
                </button>
              </div>
            </div>
          </div>

          <div class="start-craft" v-if="!craftStarted && isConnected">
            <button class="start-btn" @click="startCraft" :disabled="!craftType">
              <span>开始创作</span>
            </button>
          </div>
        </div>
      </div>
    </div>

    <!-- 底部状态栏 -->
    <footer class="footer">
      <div class="footer-left">
        <span>趣味教学 · 创意激发 · 变废为宝</span>
      </div>
      <div class="footer-center">
        <span>DIY创意达人 v2.0</span>
      </div>
      <div class="footer-right">
        <span :class="{ connected: isConnected }">
          {{ isConnected ? '● 已连接服务' : '○ 未连接' }}
        </span>
      </div>
    </footer>
  </div>
</template>

4.2 核心业务逻辑

// 初始化数字人SDK
const initAvatar = async () => {
  try {
    const config = {
      appId: 'YOUR_APP_ID',
      appSecret: 'YOUR_APP_SECRET',

      onProgress: (progress) => {
        loadProgress.value = progress
      },

      onStateChange: (state) => {
        currentState.value = state
        if (state === 'idle' || state === 'speak') {
          isConnected.value = true
        }
      },

      onSubtitle: (content) => {
        addMessage('avatar', content)
      },

      onSubtitleEnd: () => {
        isProcessing.value = false
      },

      onError: (error) => {
        console.error('SDK错误:', error)
        addMessage('system', `系统提示: 连接错误 - ${error.message}`)
      }
    }

    await XingYunService.initSDK(config)
    isConnected.value = true
    addMessage('system', 'DIY创意达人已就绪,请选择您感兴趣的手工类型')
  } catch (error) {
    console.error('初始化失败:', error)
    addMessage('system', '系统初始化失败,请刷新页面重试')
  }
}

// 开始创作
const startCraft = () => {
  if (!craftType.value) return

  craftStarted.value = true

  const craftTypeNames = {
    'paper': '折纸艺术',
    'clay': '黏土创作',
    'recycle': '废旧改造',
    'origami': '复杂折纸',
    'handmade': '综合手工'
  }

  addMessage('system', `创作开始 - 类型: ${craftTypeNames[craftType.value]}`)
  addMessage('avatar', `您好!我是您的DIY创意导师。很高兴和您一起探索${craftTypeNames[craftType.value]}的乐趣!`)

  XingYunService.speak(`您好!我是您的DIY创意导师。很高兴和您一起探索${craftTypeNames[craftType.value]}的乐趣!`, true, true)
}

// 处理快捷操作
const handleQuickAction = (action) => {
  switch (action.id) {
    case 'tutorial':
      showTutorial()
      break
    case 'material':
      showMaterialList()
      break
    case 'inspiration':
      showInspiration()
      break
    case 'tips':
      showTips()
      break
  }
}

// 发送消息
const sendMessage = () => {
  if (!userInput.value.trim() || !isConnected.value) return

  isProcessing.value = true
  const text = userInput.value
  addMessage('user', text)

  LLMService.sendMessageStream(text)
    .then((aiReply) => {
      XingYunService.speak(aiReply, true, true)
    })
    .catch((error) => {
      console.error('获取AI回复失败:', error)
      addMessage('system', '服务暂时不可用,请稍后重试')
    })

  userInput.value = ''
  selectedCategory.value = ''
}

// 添加消息到历史记录
const addMessage = (type, content, tag = '') => {
  const now = new Date()
  const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`

  const senderNames = {
    avatar: '创意导师',
    user: '您',
    system: '系统'
  }

  chatHistory.value.push({
    type,
    content,
    time,
    sender: senderNames[type],
    tag
  })

  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight
    }
  })
}

// 获取教程内容
const showTutorial = () => {
  if (!craftType.value) {
    addMessage('system', '请先选择手工类型')
    return
  }

  const tutorials = getTutorialForType(craftType.value, difficultyLevel.value)
  addMessage('avatar', tutorials, '图文教程')
  XingYunService.speak(tutorials, true, true)
}

const getTutorialForType = (type, difficulty) => {
  const tutorials = {
    'paper': '折纸入门教程 - 纸飞机:\n\n1. 准备一张正方形彩纸\n2. 对角折叠成三角形\n3. 展开,留下中心折痕\n4. 两边向中心线折叠\n5. 再次对折,完成!',
    'clay': '超轻黏土入门 - 小章鱼:\n\n1. 准备红、橙两色黏土\n2. 搓一个大圆球做脑袋\n3. 搓8个小圆球做触手\n4. 用工具画眼睛\n5. 晾干后完成!',
    'recycle': '废旧改造 - 瓶盖装饰画:\n\n1. 收集各色瓶盖\n2. 准备硬纸板底板\n3. 规划图案布局\n4. 用热胶枪粘贴\n5. 装饰完成!',
  }

  return tutorials[type] || '欢迎来到创意手工的世界!'
}

五、样式实现

5.1 动画效果

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

@keyframes slideIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.message {
  margin-bottom: 16px;
  animation: slideIn 0.3s ease;
}

.loader-ring {
  width: 60px;
  height: 60px;
  border: 3px solid rgba(74, 222, 128, 0.2);
  border-top-color: #4ade80;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.status-dot {
  background: #86efac;
}

.guide-status.active .status-dot {
  background: #4ade80;
  box-shadow: 0 0 10px rgba(74, 222, 128, 0.6);
  animation: pulse 2s infinite;
}

在这里插入图片描述
在这里插入图片描述

六、数字人服务封装

数字人获取

6.1 服务接口设计

在`services/xingyun.service.js`中封装数字人SDK的调用:

ervice.instance = new XingYunService()
    }
    return XingYunService.instance
  }

  async initSDK(config: any): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        this.config = config

        // 初始化SDK配置
        window.xingyun.init({
          appId: config.appId,
          appSecret: config.appSecret,
          onProgress: config.onProgress,
          onStateChange: config.onStateChange,
          onSubtitle: config.onSubtitle,
          onSubtitleEnd: config.onSubtitleEnd,
          onError: config.onError,
        }).then(() => {
          resolve()
        }).catch(reject)
      } catch (error) {
        reject(error)
      }
    })
  }

  speak(text: string, withAction: boolean = true, withEmotion: boolean = true) {
    if (this.sdk) {
      this.sdk.speak(text, { withAction, withEmotion })
    }
  }

  speakWithAction(text: string, action: string) {
    if (this.sdk) {
      this.sdk.speak(text, { action })
    }
  }

  disconnect() {
    if (this.sdk) {
      this.sdk.disconnect()
    }
  }

  getSupportedActions() {
    return ['Hello', 'Goodbye', 'Agree', 'Disagree', 'Think', 'Explain']
  }
}

export default XingYunService.getInstance()

6.2 AI对话服务封装

// services/llm.service.js
class LLMService {
  private static instance: LLMService

  static getInstance() {
    if (!LLMService.instance) {
      LLMService.instance = new LLMService()
    }
    return LLMService.instance
  }

  async sendMessageStream(message: string): Promise<string> {
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message }),
      })

      const data = await response.json()
      return data.reply
    } catch (error) {
      console.error('LLM调用失败:', error)
      throw error
    }
  }
}

export default LLMService.getInstance()

七、项目部署

7.1 构建生产版本

npm run build

构建完成后,生成的文件位于`dist`目录。

7.2 部署配置

对于SPA应用,需要配置服务器将所有请求重定向到`index.html`。在`vite.config.ts`中添加:

export default defineConfig({
  // ... 其他配置
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})

八、总结与拓展

最后的感受

这个项目从动手到跑通,大概一个周末。trae帮我搭了 Vue3 的组件框架,Claude 3.5 Sonnet 辅助写了样式和交互逻辑。星云 SDK 的 API 设计确实简洁——实例化、初始化、speak,三个核心步骤。

几个体感上的差异:

延迟。500ms 的端到端响应,在教育场景里的体感是"问完就说",不是"问完等会儿再说"。这个差异不亲自试很难感受到——就像从机械键盘换到薄膜键盘,数据上都是"按下去打字",但手感完全不一样。

动作的温度。没有 KA 动作的数字人,像个会说话的蜡像。加上 KA 之后,点头鼓励、思考时皱眉、开场时挥手——这些肢体语言在教育场景里不是装饰,学生能明确感知到"老师在认真回应我"。

硬件成本。RK3588 开发板 200 块钱出头就能跑 1080P 渲染。学校批量部署的话,终端硬件费几乎可以忽略。这个在视频流方案里不可能做到。

全兼容。同一套代码,换个容器就能在不同终端跑。教育场景要覆盖课堂大屏、平板、手机,全兼容省了大量适配工作。

当然也有不足:情感感知目前靠 prompt 工程,不是端到端的情感计算;多 Agent 切换需要 destroy+重建,有短暂加载;本地开发环境必须 localhost。

总的来说,如果你在做 Agent 相关的东西,且场景涉及面对面交互,星云的参数流方案值得试一试。它解决的不是一个功能问题,而是"Agent 怎么把自己做的事’演’给用户看"的问题。纯文本 Agent 缺的那具 3D 具象拟人交互形态,参数流给出了一条能跑通的路径。

Logo

纵情码海钱塘涌,杭州开发者创新动! 属于杭州的开发者社区!致力于为杭州地区的开发者提供学习、合作和成长的机会;同时也为企业交流招聘提供舞台!

更多推荐