一个父亲的烦恼

我家女儿今年上小学三年级,数学成绩一直不太稳定。我和老婆工作都忙,晚上回到家经常已经八九点了,辅导作业这事儿就变得特别拧巴——孩子等着你教,你已经累得不想说话了。

试过不少办法。买过学习机,孩子用两天就拿来刷视频了;下过AI辅导App,文字聊天那种,孩子看两眼就没兴趣了;请过大学生家教,一周来两次,但平时有问题没人答。

后来我想,孩子为什么对AI辅导App没兴趣?因为那个App没有"人味儿"。一个冰冷的对话框,连个表情都没有,小孩子天然没有代入感。

如果AI能有一个3D的、会说话的、能做出表情和动作的"身体"呢?

这个念头一出来,我就停不下来了。花了一个周末的时间,我用魔珐星云具身驱动SDK + DeepSeek大模型,给女儿搭了一个具身交互智能体家庭教师。女儿第一次看到"她"的时候,眼睛都亮了——那种反应,是任何文字聊天App都给不了的。

今天把整个过程写下来,给同样有辅导焦虑的家长们一个参考。

---

技术选型的思考过程

说实话,一开始我并不是直接选的魔珐星云。我调研了好几个方案:

方案一:Unity + 对口型插件。我之前有Unity基础,理论上可以用Unity做一个3D角色,再接一个TTS做对口型。但实际操作下来发现,口型同步效果很假,动作完全是预设的,而且整个项目太重了,不适合快速验证。

方案二:2D数字人方案。市面上有一些2D数字人,用一张照片生成。效果还行但总觉得不够"真实",而且2D的局限太大,没有空间感和互动感。

方案三:魔珐星云。在一个技术群里看到有人推荐,说是端侧渲染、不需要GPU、SDK几行代码就能跑通。我半信半疑地试了一下,结果——真香。

选魔珐星云的核心原因有三个:

第一,基于KA 语义动作库实现 AI 驱动动作生成,可以根据输出语义实时生成匹配手势,讲到加法会做出比划手势,夸奖时做出竖大拇指的动作,语义和动作联动,自然度远超传统预制动画方案。

第二,端侧渲染。我家里的旧笔记本就能跑,不需要花钱租GPU服务器。对于一个个人项目来说,这点至关重要。

第三,接入足够简单。一个JS文件引入,几行配置代码,前端小白也能上手。

大模型方面选了DeepSeek,主要考虑是它的中文理解和推理能力很强,辅导数学需要一定的逻辑推理能力,DeepSeek在这块表现不错,而且API价格便宜,我主要用的是DeepSeek-V4-Pro 正式版,Agent能力提升了很多。

---

从零开始:我的搭建全过程

第一步:注册和配置

魔珐星云的注册流程比较简单,官网(https://xingyun3d.com)注册账号后进入控制台。

我随后进入具身交互应用配置页面,平台提供了非常多的人设资源,我花了不少时间挑选,能看出平台在具身交互智能体人设方面做了大量积累。

给女儿用的家庭教师,形象很重要。太严肃了孩子有距离感,太活泼了又不像老师。最后选了一个扎马尾辫、穿粉色衬衫的年轻女性形象,看起来亲切又有知性。

音色选了一个活泼的故事姐姐音色,语速调到了0.9——比正常稍快一点点,因为小孩子注意力容易分散,语速太慢她会走神。

表演风格选了"活泼亲切",动作幅度稍微大一些,这样更能吸引孩子的注意力。

第二步:设计教师角色的System Prompt

这一步我觉得是整个项目里最关键的环节。大模型的能力决定了回答质量,但System Prompt决定了大模型"以什么身份、用什么方式"回答。

我反复改了好几版,最终定稿如下:

const systemPrompt = {
  role: "system",
  content: `你是一位温柔有耐心的家庭教师,名叫"小知姐姐"。你正在辅导一名小学三年级的学生。

教学原则:
1. 不要直接给出答案!要用启发式提问引导孩子自己思考
2. 如果孩子做错了,先肯定TA的思考过程,再温柔地指出问题
3. 用生活中的例子来解释抽象概念,比如用分苹果解释除法
4. 语言要简单易懂,避免使用超过小学三年级水平的词汇
5. 每次回复不超过4句话,保持孩子的注意力
6. 适当使用鼓励性语言,如"你真棒"、"再想想看"
7. 如果孩子表示累了或者不想学了,要理解并适当鼓励休息

特别注意:
- 孩子可能输入错别字或表达不清楚,你要耐心理解TA的意思
- 不要一次性讲太多知识点,一次只解决一个问题
- 如果问题超出小学三年级范围,告诉孩子"这个问题有点超纲了,可以问问老师哦"`
};

这个Prompt的灵魂在于**"不直接给答案"**。我看过太多AI辅导工具都是直接甩答案,孩子抄完就完事了,根本没学到东西。小知姐姐会用提问的方式引导孩子自己推导,这才是真正的辅导。

第三步:核心代码实现

整个项目的代码结构我设计成了这样的交互流程:

孩子输入问题 → 数字人"思考" → DeepSeek生成启发式回复 → 数字人"说话"+做动作

关键代码拆解如下。

数字人初始化:

const sdk = new XmovAvatar({
  containerId: "#avatar-container",
  appId: "YOUR_APP_ID",
  appSecret: "YOUR_APP_SECRET",
  gatewayServer: "https://nebula-agent.xingyun3d.com/user/v1/ttsa/session",
  onStateChange(state) {
    // 状态变化时更新UI
    // idle → 待机, listen → 倾听, think → 思考, speak → 说话
    updateTeacherStatus(state);
  }
});

await sdk.init({
  onDownloadProgress(progress) {
    // 显示加载进度
    showProgress(progress);
  }
});

这里有个细节:我在onStateChange回调里做了UI状态同步。当数字人进入think状态时,界面上会显示"小知姐姐正在思考..."的提示,让孩子知道AI正在处理她的问题,而不是卡住了。

DeepSeek对话接口:

async function askTeacher(question) {
  // 数字人进入思考状态
  sdk.think();

  messages.push({ role: "user", content: question });

  const response = await fetch("https://api.deepseek.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: messages,
      temperature: 0.8,  // 稍微高一点的temperature让回答更有创意
      max_tokens: 300    // 控制回复长度,避免太长孩子听不进去
    })
  });

  const data = await response.json();
  const reply = data.choices[0].message.content;
  
  messages.push({ role: "assistant", content: reply });
  return reply;
}

temperature设成了0.8,比健康顾问场景高一点。因为教育场景需要一些创意——用不同的例子来解释同一个概念,让回答不那么千篇一律。

驱动数字人说话:

async function handleUserInput(text) {
  // 1. 界面显示用户消息
  appendMessage('user', text);
  
  // 2. 调用DeepSeek获取回复
  const reply = await askTeacher(text);
  
  // 3. 界面显示AI回复
  appendMessage('ai', reply);
  
  // 4. 驱动数字人说出回复
  sdk.speak(reply, true, true);
}

整体逻辑就是这样的线性流程。魔珐星云SDK的API设计得很直觉,think()speak() 就完成了从思考到说话的自然过渡。

第四步:完整HTML代码

下面是完整的可运行代码,替换App ID、App Secret和DeepSeek API Key后保存为.html文件,双击即可在浏览器中打开

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>小知姐姐 · AI家庭教师</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%);
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    .header {
      width: 100%;
      background: rgba(255,255,255,0.9);
      padding: 14px 24px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.06);
      text-align: center;
    }
    .header h1 {
      color: #e91e63;
      font-size: 22px;
    }
    .header span { font-size: 14px; }
    .header .subtitle { color: #888; font-size: 13px; margin-top: 4px; }
    .main {
      display: flex;
      gap: 16px;
      width: 100%;
      max-width: 1100px;
      padding: 16px;
      flex: 1;
    }
    .avatar-box {
      flex: 1;
      background: #fff;
      border-radius: 20px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.06);
      overflow: hidden;
      position: relative;
      min-height: 520px;
    }
    #avatar-container { width: 100%; height: 100%; }
    .loading-mask {
      position: absolute;
      inset: 0;
      background: rgba(255,255,255,0.96);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      z-index: 10;
    }
    .loading-mask.hide { display: none; }
    .spinner {
      width: 50px; height: 50px;
      border: 5px solid #ffe0b2;
      border-top-color: #ff9800;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    .loading-tip { margin-top: 16px; color: #e91e63; font-size: 14px; font-weight: 500; }
    .status-tag {
      position: absolute;
      top: 12px; right: 12px;
      padding: 6px 14px;
      border-radius: 20px;
      font-size: 12px;
      font-weight: 500;
      z-index: 5;
      transition: all 0.3s;
    }
    .status-tag.idle { background: #e8f5e9; color: #2e7d32; }
    .status-tag.think { background: #fff3e0; color: #e65100; }
    .status-tag.speak { background: #e3f2fd; color: #1565c0; }
    .status-tag.listen { background: #f3e5f5; color: #6a1b9a; }
    .chat-box {
      width: 360px;
      background: #fff;
      border-radius: 20px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.06);
      display: flex;
      flex-direction: column;
    }
    .chat-title {
      padding: 14px 18px;
      border-bottom: 1px solid #f0f0f0;
      font-weight: 600;
      color: #e91e63;
      font-size: 15px;
    }
    .chat-list {
      flex: 1;
      overflow-y: auto;
      padding: 14px;
      max-height: 420px;
    }
    .msg {
      margin-bottom: 10px;
      max-width: 88%;
      padding: 10px 14px;
      border-radius: 14px;
      font-size: 14px;
      line-height: 1.6;
    }
    .msg.kid {
      background: #fff8e1;
      margin-left: auto;
      text-align: right;
      border: 1px solid #ffe082;
    }
    .msg.teacher {
      background: #fce4ec;
      border-left: 3px solid #e91e63;
    }
    .msg .name {
      font-size: 11px;
      color: #999;
      margin-bottom: 3px;
    }
    .input-bar {
      padding: 10px 14px;
      border-top: 1px solid #f0f0f0;
      display: flex;
      gap: 8px;
    }
    .input-bar input {
      flex: 1;
      padding: 10px 14px;
      border: 2px solid #ffcdd2;
      border-radius: 12px;
      font-size: 14px;
      outline: none;
      transition: border-color 0.3s;
    }
    .input-bar input:focus { border-color: #e91e63; }
    .input-bar button {
      padding: 10px 18px;
      background: #e91e63;
      color: #fff;
      border: none;
      border-radius: 12px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
      transition: all 0.3s;
    }
    .input-bar button:hover { background: #c2185b; transform: scale(1.05); }
    .input-bar button:disabled { background: #ccc; cursor: not-allowed; transform: none; }
    .topics {
      padding: 8px 14px;
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
    }
    .topics button {
      padding: 5px 12px;
      background: #fff3e0;
      border: 1px solid #ffcc80;
      border-radius: 16px;
      font-size: 12px;
      color: #e65100;
      cursor: pointer;
      transition: all 0.2s;
    }
    .topics button:hover { background: #ffe0b2; }
    .emoji-decor { font-size: 20px; }
  </style>
</head>
<body>
  <div class="header">
    <h1><span class="emoji-decor">📚</span> 小知姐姐 · AI家庭教师</h1>
    <div class="subtitle">魔珐星云 × DeepSeek | 陪你一起学,越学越快乐</div>
  </div>

  <div class="main">
    <div class="avatar-box">
      <div class="status-tag idle" id="status-tag">准备中...</div>
      <div id="avatar-container"></div>
      <div class="loading-mask" id="loading-mask">
        <div class="spinner"></div>
        <div class="loading-tip" id="loading-tip">小知姐姐正在准备中...</div>
      </div>
    </div>

    <div class="chat-box">
      <div class="chat-title">💬 和小知姐姐聊天</div>
      <div class="chat-list" id="chat-list">
        <div class="msg teacher">
          <div class="name">小知姐姐</div>
          嗨~我是小知姐姐!今天有什么不会的题吗?或者有什么想学的呀?一起开始吧!
        </div>
      </div>
      <div class="topics">
        <button onclick="quickAsk('24 × 5 = ?')">乘法计算</button>
        <button onclick="quickAsk('什么是分数?')">什么是分数?</button>
        <button onclick="quickAsk('一道应用题不会做')">应用题不会</button>
        <button onclick="quickAsk('我不想做作业了')">不想写作业</button>
      </div>
      <div class="input-bar">
        <input type="text" id="kid-input" placeholder="输入你的问题..." 
               onkeypress="if(event.key==='Enter') sendToTeacher()">
        <button id="ask-btn" onclick="sendToTeacher()">问姐姐</button>
      </div>
    </div>
  </div>

  <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
  <script>
    // ========== 配置 ==========
    const CONFIG = {
      APP_ID: "YOUR_APP_ID",
      APP_SECRET: "YOUR_APP_SECRET",
      DEEPSEEK_API_KEY: "YOUR_DEEPSEEK_API_KEY",
      GATEWAY: "https://nebula-agent.xingyun3d.com/user/v1/ttsa/session"
    };

    const SYSTEM_PROMPT = {
      role: "system",
      content: `你是一位温柔有耐心的家庭教师,名叫"小知姐姐"。你正在辅导一名小学三年级的学生。

教学原则:
1. 不要直接给出答案!要用启发式提问引导孩子自己思考
2. 如果孩子做错了,先肯定TA的思考过程,再温柔地指出问题
3. 用生活中的例子来解释抽象概念,比如用分苹果解释除法
4. 语言要简单易懂,避免使用超过小学三年级水平的词汇
5. 每次回复不超过4句话,保持孩子的注意力
6. 适当使用鼓励性语言,如"你真棒"、"再想想看"
7. 如果孩子表示累了或者不想学了,要理解并适当鼓励休息

特别注意:
- 孩子可能输入错别字或表达不清楚,你要耐心理解TA的意思
- 不要一次性讲太多知识点,一次只解决一个问题
- 如果问题超出小学三年级范围,告诉孩子"这个问题有点超纲了,可以问问老师哦"`
    };

    // ========== 状态 ==========
    let sdk = null;
    let chatHistory = [SYSTEM_PROMPT];
    let isThinking = false;

    // ========== 初始化 ==========
    async function initTeacher() {
      try {
        sdk = new XmovAvatar({
          containerId: "#avatar-container",
          appId: CONFIG.APP_ID,
          appSecret: CONFIG.APP_SECRET,
          gatewayServer: CONFIG.GATEWAY,
          onStateChange(state) {
            updateStatus(state);
          }
        });

        await sdk.init({
          onDownloadProgress(p) {
            document.getElementById('loading-tip').textContent = 
              `小知姐姐正在准备中... ${p}%`;
          }
        });

        document.getElementById('loading-mask').classList.add('hide');
        document.getElementById('status-tag').textContent = '待机中';

        // 打招呼
        const hi = "嗨~我是小知姐姐!今天有什么不会的题吗?或者有什么想学的呀?一起开始吧!";
        appendMsg('teacher', hi);
        sdk.speak(hi, true, true);

      } catch(e) {
        console.error('初始化失败:', e);
        document.getElementById('loading-tip').textContent = 
          '加载失败啦,请检查配置是否正确~';
      }
    }

    // ========== 状态更新 ==========
    function updateStatus(state) {
      const tag = document.getElementById('status-tag');
      const map = {
        'idle': { text: '待机中', cls: 'idle' },
        'listen': { text: '倾听中', cls: 'listen' },
        'think': { text: '思考中', cls: 'think' },
        'speak': { text: '讲解中', cls: 'speak' }
      };
      const info = map[state] || { text: state, cls: 'idle' };
      tag.textContent = info.text;
      tag.className = `status-tag ${info.cls}`;
    }

    // ========== DeepSeek对话 ==========
    async function askDeepSeek(question) {
      chatHistory.push({ role: "user", content: question });

      const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${CONFIG.DEEPSEEK_API_KEY}`
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: chatHistory,
          temperature: 0.8,
          max_tokens: 300
        })
      });

      const data = await res.json();
      const reply = data.choices[0].message.content;
      chatHistory.push({ role: "assistant", content: reply });
      return reply;
    }

    // ========== 发送消息 ==========
    async function sendToTeacher() {
      const input = document.getElementById('kid-input');
      const text = input.value.trim();
      if (!text || isThinking) return;

      isThinking = true;
      document.getElementById('ask-btn').disabled = true;
      input.value = '';

      appendMsg('kid', text);
      sdk.think();

      try {
        const reply = await askDeepSeek(text);
        appendMsg('teacher', reply);
        sdk.speak(reply, true, true);
      } catch(e) {
        console.error('请求出错:', e);
        appendMsg('teacher', '哎呀,小知姐姐刚才走神了,再问一次好不好?');
        sdk.idle();
      } finally {
        isThinking = false;
        document.getElementById('ask-btn').disabled = false;
      }
    }

    // ========== 快捷提问 ==========
    function quickAsk(text) {
      document.getElementById('kid-input').value = text;
      sendToTeacher();
    }

    // ========== 追加消息 ==========
    function appendMsg(role, content) {
      const list = document.getElementById('chat-list');
      const div = document.createElement('div');
      div.className = `msg ${role}`;
      const name = role === 'kid' ? '我' : '小知姐姐';
      div.innerHTML = `<div class="name">${name}</div>${content}`;
      list.appendChild(div);
      list.scrollTop = list.scrollHeight;
    }

    // ========== 启动 ==========
    window.addEventListener('load', initTeacher);
  </script>
</body>
</html>

---

开发中踩的坑

整个过程虽然顺利,但也踩了几个坑,记录一下给后来者避雷。

坑一:System Prompt太宽泛

第一版Prompt我写的是"你是一位老师",结果DeepSeek经常用大学教授的口吻回答,满屏专业术语,三年级孩子根本看不懂。后来加了"小学三年级"的明确约束,又加了"用生活中的例子解释抽象概念"这条规则,效果才好起来。

教训:System Prompt一定要明确场景和受众,越具体越好。

坑二:max_tokens设太大了

一开始我设了1000,DeepSeek经常回复一大段话。数字人念半天念不完,孩子早就不听了。后来调到300,控制在4句话以内,刚刚好。

教训:数字人说话和文字聊天不一样,太长了用户根本听不下去,一定要控制回复长度。

坑三:忘记管理对话历史

最初我每次请求都重新构造messages数组,导致小知姐姐没有"记忆"——孩子刚才问的乘法,转头问除法她就忘了前面的上下文。后来改成全局chatHistory数组,每次请求都带上完整历史,这才实现了连贯的多轮对话。

但这里也有个隐患:对话历史太长会导致Token消耗增加。我的做法是超过20轮就截断前面的历史,只保留System Prompt和最近10轮对话。

坑四:localhost限制

魔珐星云SDK在本地调试时,需要通过http://localhosthttp://127.0.0.1访问。如果你直接双击打开HTML文件(file://协议),SDK可能无法正常工作。解决方案是用VS Code的Live Server插件,或者用python -m http.server起一个本地服务器。

---

女儿的反应

搭好之后我喊女儿过来看。她坐到电脑前,看到一个扎马尾辫的大姐姐出现在屏幕上,对她微笑着说"嗨~我是小知姐姐"的时候,她先是愣了一下,然后特别兴奋地喊:"爸爸她好像真的在跟我说话!"

然后她就迫不及待地开始问问题了。让我意外的是,她问的第一个问题不是数学题,而是"小知姐姐,你知道有什么好玩的吗"——小知姐姐回了一句"我们先学完今天的知识,然后我再告诉你好玩的事好不好?"把孩子引导回学习上了,这个表现比我预期的好太多。

后来她做数学作业遇到不会的题,会主动去问小知姐姐。小知姐姐不会直接告诉她答案,而是问"你觉得第一步应该先算什么呢?"——引导她自己推导。说实话,这种启发式教学的方式,比我这个急性子家长强多了。

用了两周下来,孩子的数学成绩提高了8分。当然不全是数字人的功劳,但它确实让孩子从"害怕做数学"变成了"愿意做数学",这个心态转变才是最重要的。

---

一些反思

这个项目让我对"具身交互智能"有了更深的理解。

之前我觉得3D数字人真正的智能在大模型那里。但实际用下来我发现,"身体"本身就是智能的一部分。一个会微笑、会做手势的数字人老师,和一个纯文字对话框,对孩子注意力的影响是完全不同的。女儿会盯着小知姐姐的脸看,会在小知姐姐竖大拇指的时候开心地笑——这种情感连接,是纯文字永远给不了的。

魔珐星云的端侧渲染也让我很满意。家里的旧笔记本跑起来毫无压力,不需要额外的硬件投入。对于一个家庭项目来说,这点太重要了。端到端约 500ms 响应在实际体验中确实感受明显——数字人不会出现"卡一下再开口"的停顿感,整个交互非常流畅。

如果你也是家长,或者对教育类AI感兴趣,真的建议试试。不需要编程基础,照着上面的代码改改配置就能跑。给孩子一个会说话的AI老师,也许就能改变TA对学习的态度。

技术的意义不在于多炫酷,而在于能不能让生活变得更好一点点。至少在我家,这个数字人老师做到了。

相关资源

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐