摘要

2025到2026年,国产大模型的日子过得相当热闹。Qwen3系列在多项基准上冲到了第一梯队,DeepSeek用极低的训练成本打出了惊人的性价比,各家厂商你追我赶,榜单纪录周周刷新。

作为一名日常用国产模型写代码、做项目的开发者,我能真切感受到能力的进步。但有一个场景始终让我觉得"差点意思"——企业展厅里的AI讲解员。

去年帮一个城市展厅项目做技术方案评估。客户的需求很简单:大厅里有一块大屏,上面站一个数字人,参观者来了可以跟它互动,问它关于城市历史、规划、经济数据的问题。数字人要能像真人讲解员一样,带着表情和语气给你介绍,不是冷冰冰的文字弹窗。

我当时评估了一圈方案,发现一个尴尬的事实:国产大模型很强,但它们都缺了"最后一公里"——从文本输出到具身表达的跃迁。

Qwen能生成一篇精彩的城市介绍,但它不会"说"出来。就算你加了TTS让它发出声音,它也没有表情、没有手势、没有眼神交流。更实际的问题是,大部分云端数字人方案把3D渲染放在服务器上,用视频流推到客户端——一个展厅大屏持续运行8小时,带宽和算力成本让甲方当场打退堂鼓。

这篇文章是一篇开发者实验手记,记录我如何借助 Claude Code,用国产 LLM + 魔珐星云 SDK 搭建企业级展厅讲解系统。它关注的不是单纯写代码,而是如何把一个大模型问答原型快速推进为可上线的终端交互成品。

Claude Code 加速了开发,但还需要终端表达层

Qwen能思考,DeepSeek能推理,但它们都不会"说话"——我的意思是,像真人一样,带着表情、手势和语气的那种"说话"。

从“能生成代码”到“能完成接待”的距离

借助 Claude Code,展厅讲解系统的 Vue 组件逻辑、基础页面、接口代理、模型调用和数据结构可以很快完成。今天的国产大模型也已经能理解展厅资料、总结产品卖点、回答专业问题。

Plaintext 听到问题 → 理解意图 → 组织回答 → 用语音表达 → 配合表情和手势 → 观察听众反应

但如果最终交付形态仍然只是一段文字,站在展厅里的访客面对的就不是讲解员,而是一个需要自己阅读的查询框。

人类能力

对应AI技术

国产方案现状

听到问题

ASR/文本输入

✅ 成熟

理解意图

LLM推理

✅ Qwen/DeepSeek已很强

组织回答

LLM生成

✅ 内容质量高

用语音表达

TTS

✅ CosyVoice等可用

配合表情和手势

3D数字人驱动

❌ 短板

观察听众反应

多模态感知

❌ 缺失

把这条链路映射到 AI 技术栈:

Claude Code 负责快速搭建工程和业务流程,国产大模型负责认知和推理,知识库负责事实支撑,TTS 负责声音输出,而魔珐星云补齐的是终端成品最关键的一层:面向用户的具身表达和实时交互能力。

这也是具身交互智能的关键。Claude Code 可以让开发者快速完成展厅 Agent 的代码和页面,但接入魔珐星云后,Agent 才能绑定数字人具象形态,进入展厅讲解、门店服务、咨询导览等终端场景。

现有方案的两个死结

在找方案的过程中,我测试了市面上几种主流做法:

拼接路线(LLM + TTS + 3D 引擎各自独立)

这是最常见的做法:找一个 LLM API 生成文本,接一个 TTS 转语音,再找一个 3D 引擎做数字人渲染,用脚本把它们串起来。

问题在于:Claude Code 可以很快把这些模块串成 Demo,但线下现场无法容忍明显等待。LLM、TTS、渲染和网络传输逐层叠加后,访客问了一个问题,要盯着“正在思考”的数字人发呆,接待节奏直接断掉。

更麻烦的是,这三个系统之间没有联动:模型不知道自己要被具身表达,语音不知道哪里该强调,渲染不知道什么时候该做表情和动作。最终效果像一个会念稿的屏幕,而不是能服务现场的 Agent。

云端视频流路线同样有成本和并发压力。多展厅、多终端部署时,每一路视频都在消耗带宽和云端算力,很难形成低成本、可复制的落地方案。

实验设计:国产LLM + 魔珐星云

了解到这些痛点后,我把目光投向了魔珐星云。吸引我的不是它的数字人有多逼真(当然这也很重要),而是它的架构设计思路——自研参数流架构搭配 AI 端渲和解算。,跟视频流完全不是一条路线。

一句话搞懂参数流

云端视频流方案会完成全帧画面生成、编码后向终端传输完整画面内容;而自研参数流架构仅由云端下发语音、动画类轻量化参数,终端依托AI 端渲和解算能力自主生成 3D 数字人画面。

区别在哪?带宽。视频流每秒需要Mbps级的数据量,参数流只需要Kbps级——差了两个数量级。对于一个需要8小时不间断运行的展厅大屏来说,这个差异直接决定了项目在经济上是否可行。

依托AI 端渲和解算的技术特性,还有一大优势:画面画质不会因网络传输出现降级。视频流受编码压缩影响,低带宽时画面模糊;参数流是本地GPU实时渲染,始终是原始画质。

我的实验目标

用一句话概括:验证国产LLM(Qwen/DeepSeek)+ 魔珐星云参数流架构,能否搭建一个经济可行的企业级展厅AI讲解系统。

技术栈选型:

层级

选型

理由

LLM

Qwen3-VL(ModelScope)

国产多模态,支持图片输入

向量检索

Qwen3-Embedding-8B

同生态,4096维语义检索

数字人

魔珐星云XmovAvatar SDK

自研参数流架构 + AI 端渲和解算

数据可视化

ECharts

展厅数据图表

前端

Vue 3 + TypeScript

企业级项目选型

全栈国产化,没有依赖任何海外服务。这不是刻意为之,而是因为在这个场景下国产方案确实够用了。

搭建过程:几个关键节点的记录

在开始动手之前,先介绍一下本次实验的核心平台——魔珐星云具身智能数字人开放平台。这个平台提供了从数字人创建、驱动到部署的全套能力,我们要用的XmovAvatar SDK就来自这里。

魔珐星云官网:点我跳转

登录官网后,在开发者控制台中创建应用即可拿到 App ID 和 App Secret,这是调用SDK的凭证。平台同时提供了完整的开发文档和接入指南,整体上手体验比较顺畅。

下面按时间线记录搭建过程中的几个关键节点。代码部分我会给出核心片段,完整的Demo在文末。

节点一:让数字人"站"到屏幕上

第一步是把星云SDK接入项目。出乎意料地简单——加载一个CDN脚本,调用七八个API就能控制数字人的全部行为。

核心初始化逻辑(Vue 3 Composition API风格):

TypeScript
// avatarService.ts 核心封装
import { ref } from 'vue'

export const avatarState = ref('offline')
export const voiceState = ref<'idle' | 'speaking'>('idle')

let sdk: any = null

export async function connectAvatar(config: {
  containerId: string
  appId: string
  appSecret: string
}) {
  // 星云SDK通过CDN全局加载
  sdk = new (window as any).XmovAvatar({
    containerId: config.containerId,
    appId: config.appId,
    appSecret: config.appSecret,
    gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',

    // 数字人状态回调
    onStateChange: (state: string) => {
      avatarState.value = state
    },

    // 语音播放回调 —— 这是我们控制讲解节奏的关键
    onVoiceStateChange: (status: 'start' | 'end') => {
      voiceState.value = status === 'start' ? 'speaking' : 'idle'
    },

    // 网络质量回调 —— 展厅场景下监控网络状态很重要
    onNetworkInfo: (info: { rtt: number; downlink: number }) => {
      console.log(`延迟: ${info.rtt}ms, 下行: ${info.downlink}MB/s`)
    },

    onMessage: (msg: { code: number; message: string }) => {
      if (msg.code >= 40000) console.error('SDK错误:', msg)
    }
  })

  // 初始化:下载资源 + 建立WebSocket
  await sdk.init({
    onDownloadProgress: (progress: number) => {
      console.log(`资源加载: ${progress}%`)
    }
  })

  await sdk.idle()
}

// 让数字人说话(支持流式)
export async function speak(text: string, isStart = true, isEnd = true) {
  if (!sdk) return
  await sdk.speak(text, isStart, isEnd)
}

// 状态切换
export const listen = () => sdk?.listen()
export const think = () => sdk?.think()
export const interactiveIdle = () => sdk?.interactiveidle()
export const destroy = () => { sdk?.destroy(); sdk = null }

这段代码做了一件事:把星云SDK的状态机封装成Vue响应式变量。数字人的每个状态变化都会触发UI更新——讲解时显示"讲解中",空闲时显示"请提问"。

有个细节值得记录:onVoiceStateChange 回调是控制讲解节奏的核心。数字人说完一段话后触发 end 事件,我们才去拉取下一段内容或展示下一个展品。这个"语音驱动的UI同步"模式在后面的展品切换中起了关键作用。

节点二:让LLM"会讲解"

普通的LLM对话和"讲解"是两回事。聊天可以随意,但讲解需要结构清晰、用词口语化、适合语音播报。

我花了不少时间调system prompt,最终总结出几个关键规则:

TypeScript
// prompts.ts
export const GUIDE_SYSTEM_PROMPT = `你是一位名叫"小星"的城市展厅智能讲解员。

## 讲解规则
1. 用口语化的表达,像面对面聊天一样自然
2. 每段回答控制在3-5句话,适合语音播报
3. 不要使用Markdown格式(加粗、标题、代码块等)
4. 不要使用emoji
5. 数字要说清楚单位,比如"一千两百亿"而不是"1200亿"
6. 回答结构:先概括,再展开2-3个要点,最后小结

## 知识领域
你是这座城市的讲解专家,了解城市历史、文化、经济发展、科技产业、城市规划等内容。

## 交互原则
- 如果参观者问的问题不在你的知识范围内,诚实地说明
- 可以主动推荐相关的展品或话题
- 保持热情、专业的讲解风格`

还有一个容易被忽视的环节:LLM输出的文本需要为语音合成做预处理。LLM喜欢输出Markdown格式的文本(加粗、标题、列表),但TTS遇到这些符号会读出"*号"或"井号"。我写了一个文本清洗函数:

TypeScript
// textOptimizer.ts
export function optimizeForSpeech(raw: string): string {
  return raw
    .replace(/#{1,6}\s/g, '')        // 移除Markdown标题
    .replace(/\*\*(.*?)\*\*/g, '$1')  // 移除加粗
    .replace(/\*(.*?)\*/g, '$1')      // 移除斜体
    .replace(/```[\s\S]*?```/g, '')   // 移除代码块
    .replace(/`([^`]+)`/g, '$1')      // 移除行内代码
    .replace(/[🌍🏙️🏗️📊💡🎯]/g, '') // 移除emoji
    .replace(/\s+/g, ' ')             // 压缩空白
    .replace(/\n{2,}/g, '。')         // 段落分隔换为句号
    .trim()
}

这个函数在LLM输出到星云SDK之间起作用,确保数字人"说"出来的内容干净流畅。

节点三:让讲解员"有知识"

展厅讲解员需要了解大量展品信息。单纯靠LLM的预训练知识不够——展厅有特定的展品内容、数据、介绍文本,这些需要通过检索增强生成(RAG)来提供。

我用Qwen3-Embedding-8B把展品内容向量化,存入本地索引。参观者提问时,先语义检索相关展品,把检索结果作为上下文喂给LLM:

TypeScript
// 简化的语义检索流程
async function searchExhibits(query: string): Promise<ContentItem[]> {
  // 1. 把用户问题向量化
  const queryVector = await embedText(query)

  // 2. 与展品库做余弦相似度匹配
  const results = contentLibrary
    .map(item => ({
      item,
      score: cosineSimilarity(queryVector, item.embedding)
    }))
    .filter(r => r.score > 0.4)  // 40%相似度阈值
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)                  // 取最相关的3条

  return results.map(r => r.item)
}

// 构建带RAG上下文的对话消息
function buildMessages(query: string, exhibits: ContentItem[]) {
  const context = exhibits
    .map(e => `【${e.title}】${e.content}`)
    .join('\n')

  return [
    { role: 'system', content: GUIDE_SYSTEM_PROMPT + `\n\n参考展品信息:\n${context}` },
    ...chatHistory,
    { role: 'user', content: query }
  ]
}

这个模式在展厅场景中效果很好。参观者问"这个城市的GDP是多少",系统先检索到"经济发展"相关的展品内容,LLM基于这些内容生成回答,数字人再用语音讲出来。整个链路端到端运行。

节点四:让展厅"有数据"

展厅大屏不能只有数字人,还得有数据可视化。我用ECharts做了几个数据看板——人口趋势、GDP增长、产业结构、科技创新指数、城市综合评分。

这部分跟星云SDK没有直接关系,但跟整体场景体验密切相关。在完整项目中,当数字人讲到"经济发展"主题时,大屏自动切换到GDP数据看板;讲到"科技创新"时,切换到创新指数雷达图。数字人的讲解和数据图表形成联动,参观者边听边看,信息获取效率远高于纯文字展板。

一个可运行的极简Demo

上面是完整项目中的关键节点。下面给一个最小可运行版本——一个大屏展厅讲解Demo,数字人站在左边,右边是展品列表和数据展示。点击展品,数字人自动讲解。

代码保存为HTML文件,通过本地服务器(npx serve .)打开即可体验。我在开发中主要使用 Claude Code 辅助Vue组件逻辑编写,DeepSeek 用于测试LLM讲解效果,Qwen3-Embedding 用于展品语义检索。

HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>城市展厅AI讲解员 - 魔珐星云Demo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, sans-serif;
      background: #0c1222;
      color: #e2e8f0;
      display: flex;
      height: 100vh;
      overflow: hidden;
    }

    /* 左侧:数字人 */
    .avatar-area {
      width: 420px;
      height: 100%;
      flex-shrink: 0;
      position: relative;
      background: #0f172a;
      border-right: 1px solid #1e293b;
    }
    #avatar-container {
      width: 100%;
      height: 100%;
    }
    .avatar-status {
      position: absolute;
      bottom: 12px;
      left: 50%;
      transform: translateX(-50%);
      background: rgba(15,23,42,0.85);
      padding: 6px 16px;
      border-radius: 20px;
      font-size: 12px;
      color: #94a3b8;
      backdrop-filter: blur(8px);
    }

    /* 中间:展品与数据 */
    .content-area {
      flex: 1;
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    .content-header {
      padding: 16px 24px;
      font-size: 18px;
      font-weight: 600;
      color: #22d3ee;
      border-bottom: 1px solid #1e293b;
    }
    .exhibit-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 12px;
      padding: 16px 24px;
    }
    .exhibit-card {
      background: #1e293b;
      border: 1px solid #334155;
      border-radius: 10px;
      padding: 16px;
      cursor: pointer;
      transition: all 0.25s;
    }
    .exhibit-card:hover {
      border-color: #22d3ee;
      transform: translateY(-2px);
      box-shadow: 0 4px 20px rgba(34,211,238,0.15);
    }
    .exhibit-card.active {
      border-color: #22d3ee;
      background: #0f2937;
    }
    .exhibit-card .tag {
      font-size: 10px;
      color: #22d3ee;
      text-transform: uppercase;
      margin-bottom: 6px;
    }
    .exhibit-card h3 {
      font-size: 14px;
      margin-bottom: 6px;
    }
    .exhibit-card p {
      font-size: 12px;
      color: #94a3b8;
      line-height: 1.5;
    }
    .data-panel {
      flex: 1;
      margin: 0 24px 16px;
      background: #1e293b;
      border-radius: 10px;
      padding: 20px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #64748b;
      font-size: 14px;
    }

    /* 右侧:对话 */
    .chat-area {
      width: 340px;
      flex-shrink: 0;
      display: flex;
      flex-direction: column;
      border-left: 1px solid #1e293b;
    }
    .chat-header {
      padding: 14px 18px;
      font-size: 14px;
      color: #94a3b8;
      border-bottom: 1px solid #1e293b;
    }
    .chat-messages {
      flex: 1;
      overflow-y: auto;
      padding: 14px 18px;
    }
    .msg {
      margin-bottom: 10px;
      padding: 10px 14px;
      border-radius: 10px;
      font-size: 13px;
      line-height: 1.6;
    }
    .msg.user {
      background: #1e3a5f;
      margin-left: 30px;
    }
    .msg.guide {
      background: #1e293b;
      margin-right: 30px;
    }
    .msg.system {
      background: transparent;
      color: #64748b;
      font-size: 12px;
      text-align: center;
    }
    .chat-input {
      display: flex;
      gap: 6px;
      padding: 12px 14px;
      border-top: 1px solid #1e293b;
    }
    .chat-input input {
      flex: 1;
      padding: 8px 12px;
      border: 1px solid #334155;
      border-radius: 6px;
      background: #0f172a;
      color: #e2e8f0;
      font-size: 13px;
    }
    .chat-input button {
      padding: 8px 14px;
      border: none;
      border-radius: 6px;
      background: #0e7490;
      color: white;
      cursor: pointer;
      font-size: 13px;
    }

    /* 配置浮层 */
    .config-overlay {
      position: fixed;
      top: 0; left: 0; right: 0; bottom: 0;
      background: rgba(0,0,0,0.7);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 999;
    }
    .config-box {
      background: #1e293b;
      border-radius: 12px;
      padding: 28px;
      width: 400px;
    }
    .config-box h2 {
      font-size: 16px;
      color: #22d3ee;
      margin-bottom: 18px;
    }
    .config-box label {
      display: block;
      font-size: 12px;
      color: #94a3b8;
      margin-bottom: 4px;
      margin-top: 12px;
    }
    .config-box input {
      width: 100%;
      padding: 8px 12px;
      border: 1px solid #334155;
      border-radius: 6px;
      background: #0f172a;
      color: #e2e8f0;
      font-size: 13px;
    }
    .config-box button {
      margin-top: 20px;
      width: 100%;
      padding: 10px;
      border: none;
      border-radius: 8px;
      background: #0e7490;
      color: white;
      font-size: 14px;
      cursor: pointer;
    }
    .hidden { display: none; }
  </style>
</head>
<body>

  <!-- 配置浮层 -->
  <div class="config-overlay" id="configOverlay">
    <div class="config-box">
      <h2>展厅讲解员配置</h2>
      <label>星云 App ID</label>
      <input id="cfg-appid" />
      <label>星云 App Secret</label>
      <input id="cfg-secret" type="password" />
      <label>DeepSeek API Key(或Qwen)</label>
      <input id="cfg-llmkey" type="password" />
      <button onclick="startUp()">连接并启动</button>
    </div>
  </div>

  <!-- 主界面 -->
  <div class="avatar-area">
    <div id="avatar-container"></div>
    <div class="avatar-status" id="avatarStatus">未连接</div>
  </div>

  <div class="content-area">
    <div class="content-header">城市展厅 · 智能讲解</div>
    <div class="exhibit-grid" id="exhibitGrid"></div>
    <div class="data-panel" id="dataPanel">点击展品卡片,讲解员将为您介绍</div>
  </div>

  <div class="chat-area">
    <div class="chat-header">自由提问</div>
    <div class="chat-messages" id="chatMessages"></div>
    <div class="chat-input">
      <input id="chatInput" placeholder="问我关于这座城市的问题..."
             onkeydown="if(event.key==='Enter')sendChat()" />
      <button onclick="sendChat()">提问</button>
    </div>
  </div>

  <!-- 展品数据 -->
  <script>
    const EXHIBITS = [
      {
        id: 'history', tag: '历史文化', title: '千年古城',
        summary: '这座城市有着超过两千年的建城史,从古代商埠到现代化都市。',
        content: '这座城市始建于秦朝,距今已有两千两百多年历史。古代因地处水陆要冲,逐渐发展为繁华商埠。唐宋时期达到鼎盛,成为全国重要的经济文化中心。如今保留了大量历史遗迹和非物质文化遗产,是了解中华文明演进的重要窗口。'
      },
      {
        id: 'economy', tag: '经济发展', title: '产业集群',
        summary: '信息技术、高端制造、生物医药三大产业集群协同发展。',
        content: '全市已形成以信息技术为核心、高端制造业为支撑、生物医药为新兴增长点的产业格局。信息技术产业占比达到35%,高新技术企业超过8000家。数字经济核心产业增加值占GDP比重超过28%,位居全国前列。'
      },
      {
        id: 'tech', tag: '科技创新', title: '科技高地',
        summary: '国家重点实验室12个,年专利申请量突破5万件。',
        content: '全市拥有高等院校40余所、国家重点实验室12个、国家工程技术研究中心20个。每年专利申请量突破5万件,技术合同成交额超过1000亿元。在人工智能、量子计算、生物医药等前沿领域取得了一批具有国际影响力的原创成果。'
      },
      {
        id: 'culture', tag: '文化底蕴', title: '人文荟萃',
        summary: '世界文化遗产2处,国家级非遗项目15项。',
        content: '拥有世界文化遗产2处、全国重点文物保护单位30余处。国家级非物质文化遗产代表性项目15项。每年举办国际文化节、艺术双年展等大型文化活动,吸引海内外游客超过8000万人次。'
      },
      {
        id: 'planning', tag: '城市规划', title: '未来蓝图',
        summary: '新一轮城市总体规划:打造具有全球影响力的创新城市。',
        content: '新一轮城市总体规划描绘了到2035年的发展蓝图:城市常住人口控制在1300万以内,建设用地总规模控制在1200平方公里以内。重点打造"一核三极"创新空间格局,建设5条城市发展走廊,形成"多中心、网络化、组团式"的空间结构。'
      },
      {
        id: 'ecology', tag: '生态宜居', title: '绿水青山',
        summary: '城市绿化覆盖率达45%,空气质量优良天数超过300天。',
        content: '始终坚持生态优先、绿色发展理念。城市绿化覆盖率达45%,人均公园绿地面积超过15平方米。空气质量优良天数每年超过300天,集中式饮用水水源地水质达标率100%。已建成城市绿道超过2000公里,形成"推窗见绿、出门入园"的宜居环境。'
      }
    ];

    let activeExhibit = null;
  </script>

  <!-- 加载星云SDK -->
  <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>

  <script>
    let sdk = null;
    let llmKey = '';
    const chatHistory = [];

    // ============ 启动流程 ============

    async function startUp() {
      const appId = document.getElementById('cfg-appid').value;
      const appSecret = document.getElementById('cfg-secret').value;
      llmKey = document.getElementById('cfg-llmkey').value;

      if (!appId || !appSecret || !llmKey) {
        alert('请填写所有密钥');
        return;
      }

      // 隐藏配置浮层
      document.getElementById('configOverlay').classList.add('hidden');

      // 渲染展品列表
      renderExhibits();

      // 连接数字人
      await connectAvatar(appId, appSecret);
    }

    // ============ 数字人连接 ============

    async function connectAvatar(appId, appSecret) {
      setAvatarStatus('正在连接...');

      sdk = new XmovAvatar({
        containerId: '#avatar-container',
        appId, appSecret,
        gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
        onStateChange: (s) => setAvatarStatus(s),
        onVoiceStateChange: (status) => {
          if (status === 'end') {
            setAvatarStatus('讲解完毕');
          }
        },
        onMessage: (msg) => {
          if (msg.code >= 40000) console.error('SDK:', msg)
        }
      });

      try {
        await sdk.init({
          onDownloadProgress: (p) => setAvatarStatus('加载中 ' + p + '%')
        });
        await sdk.idle();
        setAvatarStatus('就绪');

        // 自动欢迎语
        await sdk.speak('您好,我是智能讲解员小星,欢迎来到城市展厅。请点击感兴趣的展品,我将为您详细讲解。', true, true);
      } catch (e) {
        setAvatarStatus('连接失败');
      }
    }

    // ============ 展品交互 ============

    function renderExhibits() {
      const grid = document.getElementById('exhibitGrid');
      grid.innerHTML = EXHIBITS.map(ex => `
        <div class="exhibit-card" id="card-${ex.id}" onclick="onExhibitClick('${ex.id}')">
          <div class="tag">${ex.tag}</div>
          <h3>${ex.title}</h3>
          <p>${ex.summary}</p>
        </div>
      `).join('');
    }

    async function onExhibitClick(exhibitId) {
      if (!sdk) return;

      // 高亮选中卡片
      document.querySelectorAll('.exhibit-card').forEach(
        c => c.classList.remove('active')
      );
      document.getElementById('card-' + exhibitId).classList.add('active');

      const exhibit = EXHIBITS.find(e => e.id === exhibitId);
      if (!exhibit) return;
      activeExhibit = exhibit;

      // 更新数据面板
      document.getElementById('dataPanel').textContent = exhibit.content;

      // 让数字人讲解
      setAvatarStatus('正在讲解...');
      await sdk.speak(exhibit.content, true, true);
    }

    // ============ 自由提问 ============

    async function sendChat() {
      const input = document.getElementById('chatInput');
      const question = input.value.trim();
      if (!question || !sdk) return;
      input.value = '';

      addChatMsg('user', question);

      // 构建上下文:当前展品 + 对话历史
      const context = activeExhibit
        ? `当前展品【${activeExhibit.title}】:${activeExhibit.summary}`
        : '参观者尚未选择展品';

      chatHistory.push({ role: 'user', content: question });

      await sdk.think();

      try {
        // 调用DeepSeek(也支持替换为Qwen的ModelScope API)
        const resp = await fetch('https://api.deepseek.com/chat/completions', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + llmKey
          },
          body: JSON.stringify({
            model: 'deepseek-chat',
            messages: [
              {
                role: 'system',
                content: `你是一位城市展厅讲解员,名叫小星。回答简洁口语化,3-5句话。
不要使用Markdown格式和emoji。适合语音播报。
展品上下文:${context}`
              },
              ...chatHistory.slice(-6)
            ],
            stream: true
          })
        });

        // 流式读取 + 驱动数字人
        const reader = resp.body.getReader();
        const decoder = new TextDecoder();
        let fullText = '';
        let buffer = '';
        let isFirst = true;

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value, { stream: true });
          for (const line of chunk.split('\n')) {
            if (!line.startsWith('data:')) continue;
            const d = line.slice(5).trim();
            if (d === '[DONE]') continue;
            try {
              const parsed = JSON.parse(d);
              const c = parsed.choices?.[0]?.delta?.content || '';
              if (!c) continue;
              fullText += c;
              buffer += c;
              if (isFirst || buffer.length >= 25) {
                await sdk.speak(buffer, isFirst, false);
                isFirst = false;
                buffer = '';
              }
            } catch (_) {}
          }
        }

        if (buffer) await sdk.speak(buffer, false, true);
        else await sdk.speak('', false, true);

        chatHistory.push({ role: 'assistant', content: fullText });
        addChatMsg('guide', fullText);

      } catch (e) {
        addChatMsg('system', '生成失败: ' + e.message);
        await sdk.interactiveidle();
      }
    }

    // ============ 工具函数 ============

    function setAvatarStatus(text) {
      document.getElementById('avatarStatus').textContent = text;
    }

    function addChatMsg(role, text) {
      const container = document.getElementById('chatMessages');
      const div = document.createElement('div');
      div.className = 'msg ' + role;
      div.textContent = text;
      container.appendChild(div);
      container.scrollTop = container.scrollHeight;
    }

    window.addEventListener('beforeunload', () => sdk?.destroy());
  </script>
</body>
</html>

这段代码跟前面两篇文章的Demo有一个本质区别:交互模式不是纯对话,而是"展品浏览+讲解+自由提问"的混合模式。 用户可以点击展品卡片让数字人讲解,也可以自由提问。这两种模式共存于同一个界面,更贴近真实展厅场景。

几个意料之外的技术发现

开发过程中有几个"原来如此"的时刻,记录下来。

发现一:参数流的成本优势比我预想的更大

我做过一个粗略的成本测算。假设一个展厅大屏每天运行8小时、每月30天:

方案

带宽/路

月带宽成本估算

服务端渲染

视频流720p

~3 Mbps

数千元级

需要(GPU开销大)

参数流

~80 Kbps

几十元级

不需要(端侧渲染)

差距是两个数量级。对于需要多屏部署的场景(比如一个城市有5个展厅),参数流的经济优势更加明显。这直接回答了甲方最关心的问题:"这个方案贵不贵?"

发现二:国产LLM在"口语化表达"上需要额外调教

Qwen和DeepSeek生成正式文本的能力很强,但要做口语化讲解,默认输出并不理想。它倾向于输出带有Markdown格式的"书面语",比如用"GDP增长"这样的加粗标记,或者在回答中列举"第一、第二、第三"。

这些在文字阅读时没问题,但由数字人"说"出来就很不自然。我的解决方案是双管齐下:system prompt中明确要求口语化+禁用Markdown,再加上前面提到的文本清洗函数做兜底。

发现三:端侧渲染对展厅硬件要求并不高

星云SDK支持RK3588跑1080p。RK3588的开发板价格在几百元级别。这意味着展厅大屏背后不需要一台高性能服务器或GPU——一块百元级的芯片板子就够了。从部署成本角度看,这让"给每块屏幕配一个AI具身智能体"变得经济可行。

关于"国产化AI闭环"的一点思考

做完这个项目,我对"国产化AI闭环"有了更具体的理解。

之前的国产化讨论主要集中在芯片、操作系统、数据库这些基础设施层面。在AI领域,讨论也多聚焦于LLM本身——模型是不是国产的,参数量够不够大,榜单分数高不高。

但这次项目让我意识到:AI的国产化不只是模型的国产化,更是技术栈的国产化。

一个完整的AI应用需要的不是只有LLM:

Plaintext 用户输入 → ASR(语音识别) → LLM(推理) → TTS(语音合成) → 数字人表达(渲染) → 用户感知

在"数字人表达"这一环,之前确实没有成熟的国产方案。你的国产LLM再强,到了"让数字人像真人一样说话"这一步,还是得依赖海外的3D引擎或者高成本的云端渲染服务。

魔珐星云的参数流+端侧渲染架构,在这个位置上补上了一块拼图。加上国产LLM(Qwen/DeepSeek)、国产向量模型(Qwen3-Embedding)、国产语音合成(CosyVoice等),整个链路可以完全不依赖海外技术栈。这在信创项目、政府展厅、国企数字化等场景中有非常现实的意义。

更具体地说,对于一个信创项目来说:

  • LLM层:Qwen3-VL / DeepSeek-V3,国产模型,能力已达第一梯队

  • Embedding层:Qwen3-Embedding-8B,4096维语义检索

  • 具身表达层:魔珐星云,参数流+端侧渲染,端到端≤500ms

  • 硬件层:RK3588等国产芯片即可部署

从模型到表达,从软件到硬件,全栈国产化闭环。

写在最后

做这个项目的初衷很简单:需要一个展厅讲解数字人,我不想用高成本的视频流方案,也不想拼凑一堆单点技术。

魔珐星云给我的感觉是:它在正确的地方做了正确的事。参数流架构解决了视频流的天生缺陷(带宽、延迟、成本),端侧渲染让部署门槛降到了百元级芯片,SDK的设计简洁到不需要啃文档就能上手。配合国产LLM,整个方案在技术上和经济上都可行。

如果要说有什么遗憾,那就是目前SDK的SSML动作库还有扩展空间——我希望能有更多适配展厅场景的动作(比如指向某处、展示手势),这会让讲解更自然。不过这属于"更好"的范畴,不影响"能用"。

最后回到文章开头的问题:国产大模型还缺什么?

答案是缺一层"具身表达"。LLM能思考,但不会"表演"——这个Gap不是靠更大的模型参数能补上的,需要的是架构层面的创新。参数流+端侧渲染,我认为是一条可行且已被验证的路径。

如果你的团队也在做信创项目或者企业级AI应用,值得花一个下午试试这套组合——国产LLM + 魔珐星云,也许会打开一些新的可能性。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐