我花了一个周末,把聊天机器人变成了3D老师
我花了一个周末,把聊天机器人变成了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 具象拟人交互形态,参数流给出了一条能跑通的路径。
更多推荐

所有评论(0)