在这里插入图片描述

固执开发者的不服气

我学了3-4年后端,去年开始转向 Agent 开发。做过基于 RAG 的问答机器人,也给聊天机器人接过各种大模型,功能都能跑通,但每次给非技术背景的朋友演示,对方的反应都很一致:哦,一个聊天框。

这话不好反驳。任凭你背后是多先进的模型、多精巧的工具调用链,用户看到的就是一个输入框加一串气泡。放在网页里当客服还算合适,可一旦想把它搬进门店、展厅或前台,问题就藏不住了:很少有人愿意把一块门店大屏当成传统客服窗口,长时间站在那里输入文字。用户习惯面对面自然沟通、中途随时插话;而一个只能输出文字的 Agent,缺失整套具身交互智能,明显缺少进入这类场景所需的表达与交互能力。

我后来逐渐意识到,模型能力和交互体验其实是两件事。大模型可以把答案组织得很好,但当它进入门店这类线下空间,用户还会在意它有没有及时回应、说话时能不能被打断、等待过程中有没有反馈。行业里所说的具身交互智能,解决的正是这一层问题:AI 不再只把结果写进聊天框,而是通过可见的形象、声音、表情和动作与人交流,并对人的新输入及时作出反应。

这次我拿魔珐星云做了个实验:为纯文本 Agent 补齐完整具身交互智能,给一个门店导购 Agent 装上 3D 身体,让它站在"店里"接待顾客。数字人播报过程中,顾客提交新问题,它会停止当前讲解,转而处理新的一轮。整个 Demo 用 Claude Code 辅助开发,大模型用的是智谱 GLM-4-flash,前后大概一天时间。这次原型通过文字输入和快捷问题触发交互,没有接入麦克风或语音识别,因此它验证的是“播报中提交新问题并即时打断”,而不是真正的语音抢话。下面把实现过程和几个关键取舍拆开讲清楚。

这个 Demo 真正难在哪

一个能开口说话的数字人导购,链路上至少要串四段:大模型生成回答文本、TTS 把文本转成语音、口型和表情要跟语音对上、身体动作还要自然并能实时切换状态。这四段通常来自不同技术栈,如果采用非流式串行处理,或者依赖云端完成视频渲染后再回传,多个环节的等待时间会累加。最终可能出现用户问完问题后,数字人仍要等待数秒才开始表达的情况;在线下服务场景里,这种停顿很容易破坏交流节奏。

另一个难点是打断。真实的导购对话里,顾客经常听到一半就提出新问题:"等等,那个多少钱?"部分整段合成、整段播放的方案在处理中途打断时,需要停止音频、清理播放或渲染队列,再重新进入回答链路;如果产品没有设计好打断机制,用户就只能等待上一段内容播放结束,交流会显得生硬。

魔珐星云处理的是表达与交互这一层。它更准确的定位是具身交互智能开放平台,大模型负责理解与生成,Agent 负责工具调用和流程调度,魔珐星云则通过参数流、AI 端渲与端侧解算,把语音、表情、口型和动作组织成可实时驱动的表达链路,让接入的 Agent 拥有可见、可听、可实时打断的终端交互载体。相较于先在云端渲染完整视频再传回终端,这种方案有助于减少持续传输视频带来的等待和带宽压力。真实项目能够承载多少并发、成本能降到什么程度,仍然取决于账号方案、终端性能、网络和业务配置,不能只凭一个本地 Demo 下结论。

在我的开发环境中,数字人资源已经加载完成后,从调用 speak() 到浏览器音频触发 play 事件,单次观察值为 564 毫秒。它只反映星云表达链路的首响,不包含大模型推理、工具查询和答案生成时间,不能当作完整 Agent 问答耗时。表达层正在进行的播报,则可以通过 SDK 的 interrupt() 停止。

把等待时间拆开看,会更容易判断优化应该落在哪一层。顾客提交问题以后,GLM 先判断是否需要调用工具;如果需要,后端查询商品或库存,再把工具结果交还给模型组织答案。文本准备完成后,才轮到星云 SDK 接管表达。前面测到的 564 毫秒只覆盖最后一段,也就是从调用 speak() 到浏览器开始播放音频。它不能证明整个 Agent 在半秒内完成回答,只能说明在这一次观察中,答案准备好以后,表达层没有再等待几秒才开始出声。

放到这个 Demo 里,各部分的边界很具体:GLM 负责理解问题和生成回答,Agent 负责工具调用与对话状态,星云 SDK 负责把最终文本变成能被看到、听到并随时停止的表达。把三者接上线并不算复杂,难点是保证它们始终处在同一轮对话中,不让旧请求、旧答案和当前播报互相打架。

动手:门店导购小星

第一步:在平台上创建应用

魔珐星云平台注册后进应用管理,创建一个驱动应用。起名"门店导购小星",选角色形象和音色,预览模式选横屏(门店大屏是横的)。创建完成后,在"接入 SDK"中可以取得 App ID 和 App Secret,供本地 Demo 初始化 SDK 使用。

在这里插入图片描述

在这里插入图片描述

第二步:最小可运行版本

这次使用的 JS SDK 可以直接通过 script 标签接入,没有绑定特定前端框架。先给一个用于本地体验的最小版本:把下面这段存成 index.html,填上自己的 appId 和 appSecret,再通过本地静态服务打开(SDK 要求 localhost 或 https 环境)。浏览器端初始化配置对客户端可见,因此这种写法只适合本地 Demo,不能把真实凭证随代码公开或原样部署到公网;正式项目需要按官方方案处理鉴权、域名白名单、权限和额度限制:

<!doctype html>
<html>
<body>
  <div style="width:540px;height:960px">
    <div id="avatar"></div>
  </div>
  <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
  <script>
    const avatar = new XmovAvatar({
      containerId: '#avatar',
      appId: '你的AppID',
      appSecret: '你的AppSecret',
      gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
      onMessage: (msg) => console.log('SDK消息', msg),
      onVoiceStateChange: (s) => console.log('语音状态', s)
    })

    avatar.init({
      onDownloadProgress: (p) => console.log('资源加载', p)
    }).then(() => {
      // 浏览器自动播放策略:首次发声要在一次用户点击之后
      document.body.addEventListener('pointerdown', () => {
        avatar.speak('欢迎光临星云数码体验店,我是导购小星。', true, true)
      }, { once: true })
    })
  </script>
</body>
</html>

运行后,一个 3D 数字人会出现在页面里。点击页面,她开始说欢迎语,口型、表情和语音同步呈现,待机时也有自然的小动作。我之前做校园助手项目时接过一次星云,这次少走了一些弯路。从创建应用到数字人成功播报欢迎语,大约用了二十分钟。

speak 方法的第一个参数是文本,也支持用 SSML 标记指定动作。后两个布尔值是 is_start 和 is_end,可用于承接大模型的流式输出:第一段传 true/false,中间传 false/false,最后一段传 false/true,数字人就能随着文本分段持续播报。

这次 Demo 为了让 Function Calling 的工具循环保持简单,没有启用大模型增量流式输出:后端会先等待 GLM 完成工具调用并生成完整回答,再把整段文本交给数字人播报。因此下文展示的是 SDK 的流式接入能力,而当前原型实际采用的是完整回答一次播报。后续如果要进一步缩短完整问答的首响时间,可以在工具调用结束后接入模型流式输出,并按句号等边界分段调用 speak()

第三步:接上大脑

导购不能只会说欢迎语,得能根据业务数据回答问题。我给它接了智谱 GLM-4-flash。后端使用兼容 OpenAI Chat Completions 消息结构的 Function Calling:模型判断什么时候查询商品、库存和门店政策,工具返回数据后,再由模型组织成适合口头表达的回答。为了专注验证 Agent 与数字人的交互链路,这次没有接真实门店 ERP 或库存系统,而是在后端准备了一份本地 products.json 模拟商品、库存和 FAQ 数据;真实项目中可以把相同的工具接口替换成业务系统 API。图里的商品工具并不会直接调用星云 SDK,实际顺序是 Agent 调用工具、取得结果并生成答案,再把最终文本交给 SDK:

在这里插入图片描述

后端是一个约两百行的 Express 服务,核心是三个工具定义和一个工具执行循环:

const tools = [
  { type: 'function', function: {
      name: 'search_products',
      description: '按关键词或品类搜索店内商品,返回名称、价格、库存、卖点、优惠',
      parameters: { type: 'object', properties: {
        keyword: { type: 'string', description: '商品关键词或品类' },
        maxPrice: { type: 'number', description: '预算上限(元),可选' }
      }, required: ['keyword'] } } },
  { type: 'function', function: {
      name: 'check_stock',
      description: '查询本地 Demo 数据中指定商品的模拟库存和到货信息',
      parameters: { type: 'object', properties: {
        name: { type: 'string', description: '商品名称(可模糊)' }
      }, required: ['name'] } } },
  { type: 'function', function: {
      name: 'store_faq',
      description: '查询门店政策:退换货、营业时间、会员权益等',
      parameters: { type: 'object', properties: {
        topic: { type: 'string' }
      }, required: ['topic'] } } }
]

System prompt 里有一条专门为"具身"写的规则:输出纯文本,不要 Markdown、不要列表符号,因为这些内容最终会被直接念出来。给聊天框写提示词和给一张嘴写提示词,确实不是一回事。

屏幕上的回答可以回看,也可以用标题、列表和加粗帮助扫读;语音说出口以后,信息却是一句句经过的,听到后面时,用户很可能已经忘了前面。因此我在 Prompt 里把回答限制在三句话以内。实际调试几轮后,我也更倾向于让回答先说型号、价格和有没有货,再补续航或优惠,而不是把数据库字段从头念到尾。给数字人准备回答时,除了内容正确,还得考虑这句话是否适合被人听见。

前端拿到模型的回答文本后,交给数字人播报:

avatar.interactiveidle()          // 两次 speak 之间切一次状态
avatar.speak(answerText, true, true)

这里的 interactiveidle() 只负责两次 speak() 之间的状态切换,不会终止正在进行的播报。需要停止当前语音时,使用的是 interrupt()

第四步:提交新问题,随时打断

顾客发来新问题时,第一步是停止正在进行的播报;如果上一轮模型请求还没结束,还要同步取消请求,并用请求编号拦住已经来不及取消的旧响应。下面是从实际项目中压缩出来的核心控制逻辑:

let speaking = false
let chatController = null
let activeRequestId = 0

async function ask(question) {
  if (!question.trim()) return

  // 1. 停止已经开始的数字人播报
  if (speaking) {
    avatar.interrupt()
    speaking = false
  }

  // 2. 取消还在等待模型或工具结果的上一轮请求
  chatController?.abort()

  // 3. 为本轮请求生成唯一编号
  const requestId = ++activeRequestId
  const controller = new AbortController()
  chatController = controller

  try {
    const resp = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: history }),
      signal: controller.signal
    })

    // 项目实际通过 SSE 逐段读取结果;每次更新界面或播报前都要检查编号
    const answerText = await readAnswerFromSSE(resp, requestId)
    if (requestId !== activeRequestId) return

    avatar.speak(answerText, true, true)
  } catch (error) {
    if (error.name !== 'AbortError') throw error
  }
}

这里的 readAnswerFromSSE() 负责解析服务端流式事件,并在处理每个分片前再次比较 requestIdactiveRequestId。因此,即使旧请求已经进入返回阶段、来不及被浏览器真正取消,它的结果也只能被丢弃,不能重新写入聊天记录,更不能再次触发数字人播报。

在我的设备上,开发调试时单次观察到 interrupt() 调用与音频停止事件之间约为 2 毫秒,主观感受就是"话音戛然而止"。这不是严格的性能基准,只代表本次设备、浏览器和运行状态下的观察结果。提交新问题后,当前播报立即停止,Agent 转而处理并回答新的问题;相比必须等待上一段内容播放完毕,这种交互节奏自然得多。

新问题可能出现在两个时刻:上一轮还在调用工具、等待模型回答,或者答案已经生成、数字人正在播报。前一种情况需要取消后台请求,后一种情况需要停止当前音频。取消也可能不够及时,旧响应如果随后返回,不能再更新界面或触发播报。这个问题只靠一个 SDK 方法解决不了。

我的处理分了四层:先调用 interrupt() 停止当前音频;再用 AbortController 取消尚未结束的模型请求;同时递增请求编号,让已经来不及取消的旧响应在返回后也无法更新界面或触发 speak();如果上一轮用户消息还没有得到回答,就从对话历史中移除,避免下一次请求带着一条悬空消息继续推理。前一层解决“别再说了”,后面三层解决“别让旧答案回来抢话”。这部分代码不复杂,却比数字人的外观更直接地决定了对话是否自然。

延迟数据的测法很简单:在调用 speak() 前记下时间戳,并先注册音频播放监听,避免音频很快开始时错过 play 事件:

const t0 = performance.now()
const onAudioPlay = (event) => {
  if (event.target.tagName !== 'AUDIO') return
  document.removeEventListener('play', onAudioPlay, true)
  console.log('表达链路首响', Math.round(performance.now() - t0), 'ms')  // 本次单次观察值 564ms
}

document.addEventListener('play', onAudioPlay, true)
avatar.speak(text, true, true)

这段测量从文本已经准备好、即将调用 speak() 时计时,因此衡量的是数字人表达链路,不是从顾客提问到 Agent 完成回答的全链路延迟。它不包含模型推理、工具查询和答案生成;不同网络、设备、文本长度和资源加载状态也都会影响结果。

为了让 Function Calling 循环保持简单,我暂时没有启用模型增量流式输出。当前 Demo 虽然使用了 SSE,但它只负责把工具查询状态和最终答案送到前端,并不是模型逐 Token 流式生成,因此必须等 GLM 生成完整答案以后,数字人才开始说话。下一步如果要压缩顾客感受到的等待时间,我会在工具调用结束后开启模型流式输出,按句号或较稳定的语义边界切分文本,再通过 is_startis_end 分段喂给 speak()。这里还要处理播报队列:切得太碎,语气会断;切得太长,又失去了流式首响的意义。它不是把 stream: true 打开就结束了。

效果

最终的 Demo 长这样:左边是站在门店场景里的导购小星,右边是对话面板。问"预算一千以内推荐一款耳机",界面上闪过一行"正在查询 search_products",随即她开口:"推荐灵眸 X1 无线降噪耳机,本周立减 100 元,续航长达 36 小时……"中途你随时可以发新问题,她立刻停下来处理新的。

在这里插入图片描述

调试时,我通过暴露在页面上的 SDK 实例调用 showDebugInfo() 打开调试面板。里面能看到会话 ID、当前帧、下发音频的帧区间和解码耗时,排查表达链路的问题基本靠它:

在这里插入图片描述

踩过的三个坑

除了顺利跑通的部分,下面三个问题也都是我在开发过程中实际遇到的。

第一个:本地 Demo 的 WebSocket 一直连不上,控制台反复报 socket 连接失败。查了半天发现是星云平台的应用调试页还开着。那个页面会自动连接房间,占掉当前应用的并发名额。关掉平台调试页,本地立刻就通了。在我当时的账号和应用配置下,平台调试页与本地 Demo 只能留一个;其他账号能同时开启多少路,应以实际套餐和应用配置为准。

第二个:浏览器自动播放策略。页面加载后直接调 speak,音频数据其实已经到了(调试面板里能看到下发音频),但就是不出声,因为 Chrome 不允许没有用户手势的页面播放音频。解法是把第一次发声挪到用户第一次点击之后,上面最小版本里的 pointerdown 就是干这个的。

这里还遇到一个更隐蔽的状态问题:SDK 的 onVoiceStateChange 跟随参数流帧变化,在我的测试中,它比实际音频播放状态慢了大约三秒。如果直接拿这个回调控制“讲解中”徽标和打断按钮,数字人已经开口,界面却还显示待机;声音已经停了,按钮又可能迟迟不消失。最后我改成监听页面动态音频元素的 playpause 事件来更新 UI,把 SDK 回调保留为结束状态的辅助判断。一个看起来很小的状态偏差,放到面对面交互里会特别明显,因为用户会用界面反馈判断系统到底有没有听见。

第三个:模型幻觉被工具层兜住的一课。商品库里的名字是"光影 14 轻薄本",中间有空格,顾客问"光影14还有货吗",查询函数匹配失败返回了"未找到",结果模型顺嘴编了一句"您可以看看其他型号的相机"。店里根本没有相机。修复分两层:匹配函数做空格归一化,工具的失败返回里明确写上"请如实告知顾客没有查到,不要编造其它商品"。

这件事让我意识到,数字人并不会自动提高答案的可靠性,反而可能放大错误的影响。同一句错误信息出现在聊天框里,用户或许会把它当成模型生成的文本;当它由一个有声音、有口型、有表情的导购说出来时,更容易被理解成确定的业务答复。因此价格、库存和门店政策这类可验证事实,应该尽量收口到工具层,但工具也不是一道万能保险。真实系统还需要校验参数、区分“没有商品”“库存为零”“数据源超时”等失败类型,并限制模型只能复述工具实际返回的业务字段。对门店来说,坦率地说“暂时查不到”,远比一本正经地推荐不存在的商品可靠。

从开发者视角看

做完这个原型,我最直接的感受是:开发者已经可以在不处理图形学、TTS 模型和口型对齐的情况下,先把一条具身交互链路跑起来。SDK 通过 script 标签接入,speak()interrupt() 覆盖了这次验证所需的核心表达能力,我的主要精力反而花在商品数据、提示词、工具调用和对话状态上。这些部分也更接近门店导购真正的业务差异。

如果真的把它放进门店,我最先补的不会是更多角色动作,而是交互状态的一致性。顾客需要知道数字人此刻是在等待输入、查询商品、组织回答、正在播报,还是遇到了错误;否则几秒钟的无声等待就会被理解成系统卡住。Agent 内部也要维护对应状态,让界面提示、数字人动作和后台请求保持一致。这次 Demo 已经处理了待机、思考、播报、打断和部分异常,但长时间运行还需要更完整的会话清理、工具超时提示、网络断开恢复和新顾客到来后的上下文重置。

我也特意给几个失败场景留了退路:星云初始化失败时保留纯文字对话,模型请求超过 30 秒会被终止,工具循环有最大轮数,新问题到来时取消旧请求。这些处理还谈不上生产级,但能避免某一个环节出错后整个页面彻底失去响应。真正落地还要继续补充真实业务数据、鉴权与限流、内容安全、隐私提示和长时间稳定性测试。

这次我使用的是智谱 GLM-4-flash。如果换成其他模型,只有在对方兼容相同的 Chat Completions、toolstool_calls 消息结构时,才可能主要调整 API Key、接口地址和模型名称;不同厂商的工具调用格式、错误响应和流式协议仍可能需要适配。模型和表达层相互解耦,确实降低了已有文本 Agent 增加数字人表达能力的改造成本,但打断、状态、自动播放、迟到响应和凭证治理仍要一起处理。这次验证的是一个面向门店大屏设计的横屏浏览器原型,并不等于已经在真实门店终端完成部署测试。

回到开头那个不服气。这次演示我不再只给朋友看聊天框了,我把笔记本横过来放在桌上,让他直接向小星提问。他连续三次在播报过程中提交新问题,小星每次都停下当前讲解,再处理新的问题。讨论的重点也从“这又是一个聊天框”变成了:如果接上语音输入和真实库存,这东西能不能真的摆进门店。

这次魔珐星云Demo 给我留下最深印象的,是 Agent 走出聊天框后暴露出的那些细节。声音要及时开始,用户改变主意时要立即停下,旧答案不能突然回来抢话,查询失败也不能让数字人一本正经地胡说。答案能生成只是第一步,具身交互智能真正考验的是:AI 能否进入终端,把话说对、说及时,并让用户愿意继续交流。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐