手把手教你用魔珐星云搭一个会说话的3D AI老师,DeepSeek做大脑,Node.js保安全

当所有Agent都在卷"脑子",我开始思考——如果AI有一副身体,交互会变成什么样,


## 当Agent拥有了"身体"

最近半年,AI圈最热闹的事是各家都在做Agent——能自己思考、自己调用工具、自己干活的AI。市面上也出了不少相关产品,但用了一圈下来,我总觉得差点意思。

在这里插入图片描述

差点什么?差一个"人味儿",总是冷冰冰的一个对话框,让人没有安全感,总之很官方。

你看现在大部分Agent的交互方式:你在对话框里打字,它回你一段文字,最多配张图。整个过程像在跟一个客服窗口聊天——它能回答你的问题,但你感受不到对面坐着一个"人"。

在这里插入图片描述

而我们人类真实的沟通是什么样的?是面对面的——你会看到对方的表情、听到他说话的语气、注意他讲到重点时的手势。这些"非语言信息"其实占了沟通量的60%以上。

这就是为什么数字人这两年突然又火起来了。从企业展厅里的虚拟讲解员,到直播间里的AI主播,再到银行大堂的智能客服——几乎所有"有屏"的地方,都开始出现数字人的身影。原因不复杂:当AI能"长出一张脸、能开口说话、能做表情动作"的时候,它跟用户之间的距离就一下子被拉近了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

所以当我接触到魔珐星云这个平台的时候,第一反应是——这个方向对了。它给AI Agent提供了一副3D的"身体",不仅能思考,还能像真人一样自然地表达和交互。这篇文章就是我这次实战的记录:踩过哪些坑、怎么搭起来、跑起来什么效果,以及一些个人感受。希望能给同样在探索Agent交互形态的同行一点参考,希望也能和大家多多交流。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一个很优秀的点:有很多形象可以我们自己选择。

## 一、为什么是数字人?

说白了,文字Agent的天花板不是算法,是"人"。人天然更喜欢跟"人"打交道,而不是跟"对话框"打交道。

数字人把 AI 拟人化之后,至少带来三个变化:

- 注意力更集中:有人盯着你说话,不容易走神

- 信息密度更高:表情、手势、语气都是信息通道

- 情感连接更强:用户会更愿意信任一个"看着像人"的 AI

这就是为什么 2025 年开始,几乎所有带屏的产品(手机助手、智能音箱、车机、柜台大屏)都在加数字人能力。

## 二、实战:搭建一个多场景具身AI老师

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

### 2.1 场景设计

做了一个"多场景具身AI老师":左侧 3D 数字人,右侧场景卡片,可切换数学/物理/英语/编程/语文 5 个学科,每个学科有 4 个快捷问题,一键对话。

需求:

1. 场景化:用户进来就能选,不用裸聊天框

2. 快捷交互:每场景预置 4 个典型问题

3. 多角色扮演:后端动态切换 system prompt

4. 密钥安全:appId/appSecret/LLM_KEY 全部存服务端 `.env`

5. 流式输出:DeepSeek 一边生成,数字人一边说

### 2.2 架构设计

┌─────────── Node.js 代理层 (:8080) ────────────┐
│                                              │
│  .env 文件 → dotenv 加载密钥                  │
│  ├─ GET  /         → 字符串替换渲染 HTML      │
│  ├─ POST /api/chat → DeepSeek SSE 代理转发   │
│  └─ GET  /api/health → 配置校验              │
│                                              │
└──────┬──────────────────────────┬───────────┘
       │ 模板注入 appId/appSecret │ 转发 LLM 请求
       ▼                          ▼
┌────────────── 前端 ───────────┐ ┌─── 外部服务 ───┐
│ 加载星云SDK (<script> CDN)    │ │ 数字人服务     │
│ 渲染场景卡片 (5个学科)        │ │ TTS+动画参数流   │
│ SDK直连云服务 (WebSocket)     │ ├─────────────────┤
│ fetch('/api/chat') 走代理     │ │ DeepSeek API    │
│ 状态机驱动 (speak/listen等)   │ │ 流式推理 (SSE)  │
└───────────────────────────────┘ └─────────────────┘

### 2.3 项目结构

xingyun-demo/
├── .env                  # ← 所有密钥存这里
├── .env.example          # 配置模板
├── server.js             # Node.js 代理服务器
├── templates/
│   └── index.html        # 前端页面(占位符模板)
├── package.json
└── node_modules/

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里的我将示例代码提供给大家,大家也可以根据官方Skill以及Trea.ai去生成。

Gitee代码地址:https://gitee.com/xiaotietou/xingyun-08-01.git,大家可以去下载立马就能体验哦,代码都是完整的。
最近官方刚发布了小程序,大家可以在 微信->搜索小程序-> 魔珐星云 就可以预览咯。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

### 2.4 极简Demo代码

> 这是 Node.js 代理架构的 Demo。`appId`、`appSecret`、LLM API Key 全部存在 `.env`,由服务端注入到 HTML,前端源码零明文。

这里大家可一定要注意,千万不要明文暴露密钥,带来更大的隐患

#### 第一步:`server.js` — Node.js 代理层

const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const https = require('https');
require('dotenv').config();

const XINGYUN_APP_ID = process.env.XINGYUN_APP_ID || '';
const XINGYUN_APP_SECRET = process.env.XINGYUN_APP_SECRET || '';
const LLM_API_KEY = process.env.LLM_API_KEY || '';
const LLM_API_BASE = process.env.LLM_API_BASE || 'https://api.deepseek.com/v1/chat/completions';
const LLM_MODEL = process.env.LLM_MODEL || 'deepseek-chat';
const PORT = parseInt(process.env.PORT || '8080', 10);

function checkConfig() {
  const missing = [];
  if (!XINGYUN_APP_ID || XINGYUN_APP_ID.startsWith('your_')) missing.push('XINGYUN_APP_ID');
  if (!XINGYUN_APP_SECRET || XINGYUN_APP_SECRET.startsWith('your_')) missing.push('XINGYUN_APP_SECRET');
  if (!LLM_API_KEY || LLM_API_KEY.startsWith('your_')) missing.push('LLM_API_KEY');
  return missing;
}

const app = express();
app.use(cors());
app.use(express.json());

// 首页:模板注入凭证
app.get('/', (req, res) => {
  let html = fs.readFileSync(path.join(__dirname, 'templates', 'index.html'), 'utf-8');
  const missing = checkConfig();
  const llmOk = !missing.includes('LLM_API_KEY');

  html = html.replace(/__APP_ID__/g, XINGYUN_APP_ID);
  html = html.replace(/__APP_SECRET__/g, XINGYUN_APP_SECRET);
  html = html.replace(/__GATEWAY_SERVER__/g, process.env.XINGYUN_GATEWAY_SERVER || '');
  html = html.replace(/__LLM_CONFIGURED__/g, llmOk ? 'true' : 'false');
  html = html.replace('__MISSING_CONFIG_JSON__', JSON.stringify(missing));

  res.type('html').send(html);
});

// 5 个学科的 system prompt
const ROLE_PROMPTS = {
  math: '你是一位亲切耐心的中学数学老师。简洁易懂,回答200字以内。',
  physics: '你是一位风趣的物理老师。用生活例子解释,回答200字以内。',
  english: '你是一位英语外教。中英双语教学,回答200字以内。',
  coding: '你是一位编程导师。通俗讲解代码逻辑,回答200字以内。',
  literature: '你是一位语文老师。旁征博引讲解古诗词,回答200字以内。',
  default: '你是一位AI助手。回答200字以内。',
};

// LLM 代理:SSE 流式透传
app.post('/api/chat', (req, res) => {
  if (!LLM_API_KEY || LLM_API_KEY.startsWith('your_')) {
    return res.status(503).json({ error: 'LLM_API_KEY 未配置' });
  }
  const { message, role = 'default' } = req.body || {};
  if (!message) return res.status(400).json({ error: 'message 为必填' });

  const messages = [
    { role: 'system', content: ROLE_PROMPTS[role] || ROLE_PROMPTS.default },
    { role: 'user', content: message },
  ];

  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  const url = new URL(LLM_API_BASE);
  const proxyReq = https.request({
    hostname: url.hostname, port: 443, path: url.pathname, method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LLM_API_KEY}` },
    timeout: 60000,
  }, proxyRes => {
    proxyRes.on('data', chunk => res.write(chunk));
    proxyRes.on('end', () => res.end());
    proxyRes.on('error', err => { res.write(`data: ${JSON.stringify({error: err.message})}\n\n`); res.end(); });
  });
  proxyReq.on('error', err => { res.write(`data: ${JSON.stringify({error: err.message})}\n\n`); res.end(); });
  proxyReq.write(JSON.stringify({ model: LLM_MODEL, messages, stream: true }));
  proxyReq.end();
});

app.listen(PORT, () => console.log(`http://localhost:${PORT}`));

#### 第二步:`templates/index.html` — 关键片段

<!-- 凭证由服务端注入 -->
<script>
  var APP_ID = "__APP_ID__";
  var APP_SECRET = "__APP_SECRET__";
  var LLM_CONFIGURED = __LLM_CONFIGURED__;
</script>
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>

创建数字人

sdk = new XmovAvatar({
  containerId: '#avatar-container',
  appId: APP_ID, appSecret: APP_SECRET, gatewayServer: GATEWAY_SVR,
  onStateChange: s => console.log('[SDK]', s),
  onVoiceStateChange: s => { if (s === 'end') sdk.interactiveidle(); },
  onMessage: m => { if (m.code >= 40000) addMessage('system', 'SDK错误: ' + m.message); }
});
await sdk.init({ onDownloadProgress: p => setStatus('loading', '加载 ' + p + '%') });
await sdk.idle();

在这里插入图片描述

LLM 调用走代理 + 数字人说话

我们这里配置的是DeepSeek-V4-Pro,大家也可以对接其他优秀的AI公司接口。

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: question, role: currentRole })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let full = '', buf = '', isFirst = true;
const msgDiv = addMessage('assistant', '');

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  for (const line of decoder.decode(value, { stream: true }).split('\n')) {
    if (!line.startsWith('data:')) continue;
    const data = line.slice(5).trim();
    if (data === '[DONE]') continue;
    try {
      const c = JSON.parse(data).choices?.[0]?.delta?.content;
      if (!c) continue;
      full += c; buf += c; msgDiv.textContent = full;
      // 首个 token 或累积 30 字/遇到标点就驱动说话
      if (isFirst || buf.length >= 30 || /[。!?;\n]/.test(buf)) {
        await sdk.speak(buf, isFirst, false);
        isFirst = false; buf = '';
      }
    } catch (e) {}
  }
}
if (buf) await sdk.speak(buf, false, true);

在这里插入图片描述

#### 第三步:`.env`

XINGYUN_APP_ID=你的真实AppID
XINGYUN_APP_SECRET=你的真实AppSecret
LLM_API_KEY=sk-你的真实Key
LLM_API_BASE=https://api.deepseek.com/v1/chat/completions
LLM_MODEL=deepseek-chat
PORT=8080

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里我们单独拆分的文件去集中管理密钥,

针对于XINGYUN_APP_ID,XINGYUN_APP_SECRET我们去https://xingyun3d.com/ 官网,创建应用
在这里插入图片描述
在这里插入图片描述

流程很简单,如果大家觉得体验很好,前期测试可以点击福利中心先使用我的邀请码进行1000积分领取,足够大家进行测试。

邀请码:JMWAGFUI8Z

在这里插入图片描述

#### 启动

npm install
npx nodemon server.js

打开 http://localhost:8080,选场景 → 连接数字人 → 对话。nodemon 会监听 templates/.env/server.js,改任何文件自动重启。

### 4.5 代码解析:9个关键设计点

#### (1)状态机驱动的交互节奏

星云 SDK 核心是 5 状态流转:

idle(待机) → listen(倾听) → think(思考) → speak(说话) → interactiveidle(互动待机)

不是简单切动画,是 SDK 内部统一管理的连贯行为。idle 自然呼吸、listen 前倾、think 皱眉、speak 配合手势。onVoiceStateChange('end') 时调用 interactiveidle() 让数字人回到互动待机态,准备下一轮。

#### (2)Node.js 代理架构:密钥安全的核心

之前的文章版本把 appId/appSecret/LLM Key 全硬编码在前端 JS 中——右键查看源代码就能拿走。

新架构:

- 密钥分层:`.env` 存机密,`.env.example` 提交 Git,`.env` 加入 `.gitignore`

- 服务端注入:服务端读 `.env` → `APP_ID` 字符串替换 → 返回真实 HTML

- LLM 代理:前端不直连 DeepSeek,调用 `/api/chat`,Node.js 转发时附加 `Authorization: Bearer {LLM_API_KEY}`

- 配置校验:`checkConfig()` 启动时检查,未配置页面顶部显示黄色警告

> 安全边界:星云 SDK 是客户端 JS 库,`appId`/`appSecret` 仍会在浏览器内存中存在。生产环境建议服务端生成临时 Session Token(需星云配合),或把数字人渲染放在受控设备上。

#### (3)多场景角色切换

后端 ROLE_PROMPTS 映射表 + 前端 currentRole 状态:

在这里插入图片描述

// 后端:5 个学科 → 5 套人设
const ROLE_PROMPTS = { math: '...', physics: '...', english: '...', ... };

// 前端:调用时把 role 一起传过去
fetch('/api/chat', { body: JSON.stringify({ message, role: currentRole }) });

在这里插入图片描述

新增场景只需加一行 prompt + 4 个问题,零侵入。

#### (4)流式驱动:LLM 和数字人的节奏同步

链路:DeepSeek → SSE → Node.js /api/chat → SSE 透传 → 浏览器 fetch → 分块 sdk.speak()

Node.js 代理对 SSE 零修改透传,不增加延迟。前端策略:

  • 第一个 token 到达 → speak(text, true, false) 立即开始驱动

  • 后续累积 30 字或遇到 。!?;\n 标点 → speak(chunk, false, false) 追加

  • LLM 生成完 → speak('', false, true) 标记结束

"说话"和"生成"并行,用户几乎无感。

#### (5)生命周期管理:别忘了 destroy()

页面关闭不调 sdk.destroy(),WebSocket 不释放,连续刷新几次就触发并发限制(错误码 10005)。beforeunload 里必须调用:

window.addEventListener('beforeunload', () => {
  if (sdk) { try { sdk.destroy(); } catch (e) {} }
});

#### (6)UI 状态机:`setStatus()` 与 `addMessage()`

把"数字人连接状态"和"对话气泡"做成统一的小函数,避免 DOM 操作散落各处:

function setStatus(state, text) {
  $('status-dot').className = 'status-dot ' + state;  // online/loading/error
  $('status-text').textContent = text;
}

function addMessage(role, text) {
  const div = document.createElement('div');
  div.className = 'message ' + role;  // user/assistant/system
  div.textContent = text;
  $('messages').appendChild(div);
  $('messages').scrollTop = $('messages').scrollHeight;
  return div;
}

这样在 connect()disconnect()sendMessage() 任何地方都能用一行代码更新 UI,不容易出错。

#### (7)错误码分级处理

星云 SDK 错误码分段很清晰:10000-19999 是用户态(密钥错、参数错),40000+ 是 SDK 内部错误。`onMessage` 回调里我做了简单分级:

onMessage: m => {
  if (m.code >= 40000) addMessage('system', 'SDK错误: ' + m.message);
  // 10000 段错误一般是密钥或网关问题,让用户去查 .env
}

不要把所有错误都静默吞掉——用户看到"连接失败"却不知道为啥,体验直接崩。

#### (8)场景卡片的动态渲染

切换场景时,快捷问题列表要跟着变。用一个 map 维护所有场景的问题,切换时清空容器重新渲染:

function switchRole(role) {
  currentRole = role;
  // 更新左侧标签的 active 态
  document.querySelectorAll('.scene-tag').forEach(t => {
    t.classList.toggle('active', t.dataset.role === role);
  });
  renderQuickQuestions();  // 重新渲染快捷问题
}

function renderQuickQuestions() {
  const box = $('quick-questions');
  box.innerHTML = '';
  (QUICK_QUESTIONS[currentRole] || []).forEach(q => {
    const b = document.createElement('button');
    b.className = 'quick-btn';
    b.textContent = q;
    b.onclick = () => { $('user-input').value = q; sendMessage(); };
    box.appendChild(b);
  });
}

这样"切换学科=改数据",不用动 HTML 结构。

#### (9)nodemon 自动重启 + 路径配置

开发期用 npx nodemon --watch templates --watch .env --watch server.js server.js,改任何文件自动重启,不用手动 Ctrl+C。

坑点提醒:H 盘如果有写入权限问题(Windows 系统盘保护),建议把项目复制到 C 盘临时目录运行,源码仍放在 H 盘维护。

### 4.6 从单场景到多场景的产品化思路

早期 Demo 只有"数学老师",对话框空空如也。改成多场景后体验完全不同:

- 打开页面 → 5 个学科卡片(数学/物理/英语/编程/语文)整齐排列

在这里插入图片描述

- 点"数学老师" → 快捷问题变成「勾股定理证明」「一元二次方程」等

- 点"连接数字人" → 数字人加载后待机,呼吸/眨眼自然循环

- 点任意快捷问题 → 数字人开始说话,DeepSeek 流式文字逐字显示,口型表情实时跟随

- 切换场景 → 数字人保持连接,system prompt 立即切换,再次提问人设就变了(选英语会用中英双语回答)

"开箱即用"的设计是把 Demo 变成可交付产品的关键。

## 三、SDK 与落地要点

### SDK 集成

<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
API用途
new XmovAvatar(config)创建实例
sdk.init(options)初始化(下载资源 + 建 WebSocket)
sdk.speak(ssml, isStart, isEnd)驱动说话,支持流式和 SSML 动作
sdk.listen() / sdk.think() / sdk.idle()状态切换
sdk.interactiveidle()互动待机(用于打断)
sdk.destroy()释放资源,必须在 beforeunload 调用

### 落地要点

1. 协议要求:本地 `localhost:8080` 自动满足 SDK 要求,生产环境需 HTTPS

2. 密钥安全:`.env` 存机密,`.env.example` 提交 Git,`.env` 加入 `.gitignore`

3. 开发效率:`nodemon` 监听 `templates/.env/server.js`,改任何文件自动重启

4. 场景扩展:在 `ROLE_PROMPTS` 加一行 + `QUICK_QUESTIONS` 加 4 个问题即可

## 四、总结

参数流+端侧渲染的架构是范式转换,不是优化。当数字人能在 500ms 内实时回应、表情自然变化、手势跟内容配合,"具身交互智能"和"文字聊天"的体验鸿沟就出现了——这是交互形态的质变

星云 SDK 对开发者很友好:核心 API 七八个,状态机语义清晰,配合 AI Coding 工具和官方 Skill 文件,从零到可运行 Demo 可以压缩到 30 分钟,大家去使用我的GiteeAI教育示例,运行就可以预览,会更节省大家的调研时间。

教育只是其中一个场景。金融客服、医疗咨询、文旅讲解、智能座舱——任何一块屏幕,都可能因为具身交互智能数字人而升级为一个 AI 具身交互智能体。这不是遥远的未来,是现在就能落地的能力

最后特别想提一下国产化闭环:魔珐星云 + DeepSeek/Qwen,从 LLM 推理到语音合成到 3D 具身表达,全部国产技术栈走通,在各种场景下意义重大。

用一个词概括我的整体体验:具身****交互智能。AI 不只是"能回答问题",更能"像一个真实的存在一样和你对话"。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传


相关资源

Logo

电影级数字人,免显卡端渲染SDK,十行代码即可调用,工业级demo免费开源下载!

更多推荐