端到端 SDK 实战,三段代码做一个会听、会说、能被打断的网页具身交互智能体
端到端 SDK 实战,三段代码做一个会听、会说、能被打断的网页具身交互智能体
一、ASR + LLM + TTS 拼接方案的三个坑
老板一句话,官网能不能有个"真人感"的接待
上个月开周会,老板指着官网首页说,每天进来的人不少,看两眼就走了,能不能放个前台在这儿,有人来就打个招呼,问什么答什么。
我当场答应了。脑子里的方案很快就有了,语音识别把话转成文字,大模型想答案,语音合成念出来,再找个形象对上口型。三样都有现成的云服务,接口文档也齐,我给自己留了三天。
三天后,我拼出了一个"会说话的木头人"
三天后它能用了。你问一句,它停一两秒,开口回答。
看着别扭的地方有三处。
| 现象 | 原因 |
|---|---|
| 每次开口前都愣一下 | ASR 转完才交给 LLM,LLM 写完才交给 TTS,三段等待叠在一起 |
| 嘴在动,脸是僵的 | 口型跟着音频走,表情和动作另外配,两边互相不知道 |
| 语气一直是一个调 | TTS 只拿到一段文字,分不出这句是在道歉还是在介绍产品 |
客户插了句嘴,它还在自顾自地念
给客户演示那天出了岔子。它在介绍公司的产品线,讲得挺长,客户听到一半问了一句,你们支持私有化部署吗。
它没停,把整段介绍念完才去处理那句问题。
我回去翻代码,问题出在整条流程里根本没有"被打断"这回事。TTS 在播的时候,ASR 那头就算收到了声音,也没有地方通知播放停下来。我试着让麦克风一直开着,结果它把自己喇叭里的声音也当成了用户在说话。
后来我推倒重做,做完以后它就待在官网页面的一侧,访客边看网页边跟它说话,下面这段官方演示和我想要的样子几乎一样。

二、端到端具身交互智能,从串行流程到持续交互循环
每一环都在等上一环
我原来的方案画出来是一条直线。
用户说话 → ASR → LLM → TTS → 屏幕播放
ASR、LLM、TTS 单拿出来都很成熟,我用的几家识别准、回答好、声音也自然。毛病出在它们排队干活,后一环必须等前一环交卷,谁也看不见别人当下在做什么。
人和人聊天的时候,这几件事是一起进行的。
真人聊天是怎么做到边说边听的
想一下你跟公司前台说话。她说着话,耳朵一直开着,你一开口她就停下来听。她的语气、表情和手势跟那句话一起出来,没有先说完再补动作。你皱一下眉,她可能马上换个说法。
听、想、说、动同时在跑,还会跟着你的反应改。
把整段交互当成一个循环
后来读魔珐星云的资料,发现他们讲的端到端就是在做这件事。官方对星云的定义是这样一句话。
魔珐星云是一套端到端具身交互智能平台,让 AI 通过屏幕和机器人进入真实世界,成为能够感知、理解、表达并行动的智能体。
星云围绕一次完整的人机交互去设计整个系统。识别、大脑、语音这些模块都在,它们共享同一套会话状态和时间线,由同一个运行时调度,简单拼接给不了这一点。官方把这套运转方式叫 Continuous Interaction Loop,也就是持续交互循环。

持续感知 → 持续理解 → 持续决策 → 持续表达与行动 → 持续反馈 → 回到感知
用官方的大白话讲,AI 一边和你交流,一边继续听、继续看、继续判断接下来该怎么回应。
撑起这个循环的是五项能力,放到我的 AI 前台里,各管一摊。

| 能力 | 在 AI 前台里管什么 |
|---|---|
| 多模态感知 | 听清访客的话,分出哪句是访客、哪句是自己的回声,判断是不是有效插话 |
| 专属智脑 | 知道自己是这家公司的前台,知道公司的产品和接待规则 |
| 多模态表达 | 语音、口型、表情、眼神、手势一起生成 |
| AI 端渲 | 形象在浏览器本地渲染,不用云端推视频流 |
| 数字与物理行动 | 在屏幕上把答案说出来、演出来,以后进了机器人还能转身带路 |
把五项串起来的是 Real-time Interaction Runtime,事件流转、会话状态、音画同步、打断和断线重连都归它管。我那条直线缺的就是它。
三、三段代码跑通端到端流程,init、ask() 和 startASR()
重做的时候我只给自己定了一条,不再写胶水代码。
先拿钥匙
- 去[魔珐星云官网]注册,注册时填兑换码
JSUAY3IS96。 - 进控制台创建应用,配好形象、身份和知识。官方的接入顺序是创建应用、无代码效果预览、SDK 接入三步,第二步不用写代码就能先跟它聊几句看效果。
- 记下
appId和appSecret。
第一段代码,让它站上页面
原生 HTML 用 CDN 引入,文件名里带 _e2e 的才是端到端版本。
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar_e2e@latest.js"></script>
项目用 Vue 或 React 的话走 npm。
npm install @xmov/avatar@latest
import XingyunAvatarAgent from "@xmov/avatar/agent";
然后创建实例,初始化。
const agent = new XingyunAvatarAgent({
containerId: "#agent-avatar",
appId: "your-app-id",
appSecret: "your-app-secret",
gatewayServer: "https://nebula-agent.xingyun3d.com/user/v1/ttsa_v2/session",
onMessage(err) { console.error("渲染错误", err.code, err.message); },
agentCallbacks: {
onError(err) { console.error("Agent 错误", err.domain, err.code); },
},
});
try {
await agent.init({ onDownloadProgress: (p) => console.log(p + "%") });
} catch (e) {
await agent.destroy("init_failed"); // 初始化失败就销毁,别留半个实例
throw e;
}
这段有两处我差点写错。
网关地址的路径是 ttsa_v2。旧版渲染 SDK 用的是 ttsa,照着旧教程抄很容易少两个字符,报错信息还不太好认。
错误回调要写两个。顶层的 onMessage 收渲染层的错,agentCallbacks.onError 收识别、大脑、网络和配额的错。只写一个,另一半的报错就没人接。
init() 成功返回,实例就进入了 running 状态,可以开始对话。
第二段代码,一句 ask() 走完听、想、说
以前我要拿到文字、调大模型、拼 SSML、调 TTS、再喂给形象播放。现在只写一行。
agent.ask("你好,请用一句话欢迎来访的客人");
文字交给它的大脑,生成回答,再用语音、口型、表情和动作一起说出来。

ask() 返回只说明请求发出去了,它说没说完要看回调。
agentCallbacks: {
onConversationChange({ state }) {
// 取值 idle | asking | speaking | completed | failed
if (state === "completed") console.log("这一轮说完了");
},
},
它怎么知道该回答什么,靠的是星云的专属智脑。
这里我踩过一个坑。第一次跑的时候我没在控制台填身份,就让它介绍一下自己。它报出了底层大模型的名字,接着现编了一家做企业软件的公司,产品线讲得有模有样,可那些产品根本不存在。
所以上线前一定要先把身份配好,XX 公司前台,代表谁,服务谁,再把公司介绍和常见问题传上去。专属智脑管的就是这件事,让它知道自己是谁、掌握哪些知识、该按什么规则说话。已经有自己大模型的团队也可以换上去,这一节最后会讲。
第三段代码,打开麦克风
文字能聊了,接着让它能听。
const agent = new XingyunAvatarAgent({
// 前面的参数不变
features: { auto_send_asr_to_llm: true }, // 识别完自动交给大脑
});
micButton.onclick = () => agent.startASR(); // 放在用户点击里
stopButton.onclick = () => agent.stopASR();
startASR() 会申请麦克风权限。浏览器只允许在用户点击这类手势里申请,页面一加载就调用会被拦下,所以要绑在按钮上。
auto_send_asr_to_llm 打开以后,用户一说完,识别结果自动进大脑,回答自动播出来。我原来手写的那一串识别、请求模型、合成语音的代码,到这里整段删掉了。
模块可以换成自己的
写到这里我担心过一件事,全用一家的服务,以后想换怎么办。
翻了资料,星云的组件是可插拔的。
| 模块 | 可选 |
|---|---|
| ASR | 魔珐自研大模型 ASR 或小模型 ASR,也可以用火山引擎、腾讯云 |
| 大脑 | 魔珐自研大脑,带 RAG、FAQ 库、自研大模型和可视化编排;也能接任意 OpenAI 协议的大模型、客户自建大脑,或 Dify、Coze 上搭好的工作流 |
| TTS | 魔珐自研,也可以用微软 Azure、字节豆包、MiniMax |
| 机器人运控 | 魔珐运控,或机器人本体厂商自带的运控 |
想换成自己的大模型,构造参数里加一段 brain_config。
brain_config: {
provider: "openai-compatible",
base_url: "YOUR_SERVICE_BASE_URL",
api_key: "YOUR_API_KEY",
model: "YOUR_MODEL_NAME",
},
换了模块,打断和状态同步照样能用。官方的解释是各模块之间有统一的接口契约和状态管理,替换以后事件流转、会话状态、时间同步和异常处理都保持一致。
api_key 写在前端只适合本地测试,生产环境要放到平台上配置。
四、全双工打断与 agentCallbacks 状态回调
我故意在它说到一半时开口
Demo 跑通以后,我先把那天的场面重演了一遍。让它介绍产品线,说到一半,我开口问支持私有化部署吗。
处理插话的代码是这样写的。
let speaking = false;
agentCallbacks: {
onSpeakStateChange({ state }) {
speaking = state === "speak_start"; // speak_start、speak_end、speak_error
},
onASRResult({ text, isFinal }) {
if (text && speaking) agent.interrupt(); // 我开口时它还在说,先让它停
},
},
interrupt() 没有参数,返回打断耗时,单位毫秒。它只停播报,麦克风照样开着,所以我后面那句话会被完整听完,再进入新一轮回答。
这回它停下来了。

上面这段是我实测录下来的。我先让它慢慢介绍公司的产品,说到一半,在输入框里插了一句"等一下,先介绍一下你自己"。Demo 的发送按钮里也写了一句 if (speaking) agent.interrupt(),所以文字插话和语音插话走的是同一个 interrupt()。它停掉了那段长介绍,转去回答我的新问题。
官方文档里还有一种做法,放一个打断按钮,点击时调 agent.interrupt(),接着调 agent.listen() 切到聆听姿态。enableClientInterrupt 默认就是 true,不用另外设置。
它怎么分清是我,还是它自己的回声
麦克风一直开着,喇叭里它自己的声音也会被收进来。我原来的方案就卡在这里。
星云把这件事放在多模态感知里处理。官方资料列出的语音感知能力有算法降噪、AEC 抗回声、本体声音抑制、VAD、ASR、远场语音,以及 Double Talk 和 Barge-in 智能打断。
一次有效打断背后要连着做完几步。
判断真实用户声音 → 去除本体回声 → 区分噪声、咳嗽、附和声与真实插话
→ 检测有效打断 → 停止当前表达 → 持续接收用户完整输入 → 进入新一轮交互
在 SDK 里开回声消除,两个开关都要写上。
features: {
auto_send_asr_to_llm: true,
speech_frontend: { enabled: true, enable_aec: true },
},
文档专门提醒过,只写 enable_aec: true 不够,语音前处理的 enabled 也得打开。我第一遍就漏了这个。
官方把这叫持续感知。落到代码里,它说话的时候耳朵也一直开着,还能分出哪句是你说的。
把它的状态打印出来看
调试的时候我把所有回调都打到页面上,持续交互循环怎么转,看日志一目了然。
let reply = "";
agentCallbacks: {
onAgentStateChange: (s) => log("[Agent]", s),
onASRStateChange: (s) => log("[麦克风]", s),
onASRResult: ({ text, isFinal }) => log(isFinal ? "[说完]" : "[正在说]", text),
onLLMResponse: (r) => {
if (r.event === "chunk") reply += r.text || ""; // 回复一段段流过来
if (r.event === "done") { log("[大脑]", reply); reply = ""; }
},
onSpeakStateChange: ({ state }) => log("[播报]", state),
onConversationChange: ({ state }) => log("[一轮]", state),
onError: (e) => log("[错误]", e.domain, e.code),
},
onLLMResponse 这里我第一版写错了。我以为 done 事件会带上完整回复,结果实测打出来是空的。大模型的回答是按 chunk 一段段流过来的,要自己拼起来,到 done 再一次性用。
下面是那次插话测试的真实日志,中间省掉了大段回复文字。
[Agent] running
[一轮对话] asking
[播报] speak_start
[一轮对话] speaking ← 正在长篇介绍产品
[一轮对话] interrupted ← 我插话了
[一轮对话] asking
[播报] speak_end ← 旧播报收尾
[一轮对话] completed
[大脑回复] 您好呀…… ← 新问题的回答
[播报] speak_start
[一轮对话] speaking
[播报] speak_end
[一轮对话] completed
顺序很能说明问题。对话状态先变成 interrupted,马上进入新一轮 asking,旧播报的 speak_end 晚一步才到。写业务逻辑时要注意,判断"它还在不在说话"用播报状态,判断"这一轮算不算完"用对话状态,两个别混着用。

onASRResult 会先给出几条 isFinal: false 的中间结果,最后一条才是 isFinal: true。用户话还没说完,我在中间结果阶段就能决定要不要打断。
五、上线前的三个坑,容器高度、appSecret 和异常兜底
它在页面上隐身了
第一次跑,控制台没报错,资源进度走到了 100%,页面上什么都没有。
原因是容器 div 没给高度,画布跟着缩成了 0。
/* 容器必须有明确宽高 */
#agent-avatar { width: 405px; height: 720px; }
官方的踩坑记录里也写了这一条。同一类的坑还有两个。页面要跑在 HTTPS 或 localhost 上,直接双击打开 html 文件不行。有些编辑器自带的预览窗口禁了网络请求,会渲染失败,换外部浏览器打开就好。
钥匙贴在了门上
Demo 里 appSecret 直接写在前端代码里,本地调试可以,上线不行,任何人按 F12 就能看到。
上线前我会做三件事。大模型、TTS 这些供应商密钥放到星云平台上配置,不写进浏览器代码。页面加访问控制和频率限制,免得被人拿去刷积分。私有化部署的项目,用官方提供的 e2eServer 加 authToken 这组参数,接自己的鉴权。
三道兜底
| 情况 | 处理 |
|---|---|
| 初始化失败 | catch 里调 agent.destroy("init_failed"),页面提示稍后再试 |
| 对话失败 | onConversationChange 收到 failed 或 onError 报错时,让它说一句刚才没听清能再说一遍吗,然后回到聆听 |
| 用户离开页面 | 调 agent.destroy(),只删 DOM 不会释放麦克风 |
| 想在页面上显示回复文字 | 在 chunk 事件里自己拼接,done 事件的 text 实测为空 |
onError 里的 domain 很好用,能直接看出是哪一类问题。permission 是没给麦克风权限,network 是网络,asr、brain、ttsa 分别对应识别、大脑和播报,quota 是积分用完了,sdk 是 SDK 自身。不同类别给不同提示,用户就不会对着一句"出错了"发呆。
完整 Demo 是一个约 150 行的 HTML 文件,放在文末。填上自己的 appId 和 appSecret,用 npx serve 在 localhost 起一下就能跑。
六、一个智能体,多种身体,从网页到大屏和机器人
它现在住在网页里,以后能搬家
做完以后回头看,少写的那些代码倒在其次。我原来以为给 AI 一个身体,就是把大模型的回答搬到屏幕上。现在的理解是,它得一直感知、理解、表达和行动,人在变,它也跟着变。端到端具身交互智能处理的就是这个。
还有一件事我没想到,这个前台不用一直待在网页里。
星云的说法是,同一个智能体,可以拥有不同的身体。身份、知识、记忆、任务和业务能力都能复用,换了身体,能力还会多出来。

| 身体 | 多出来的能力 |
|---|---|
| Web、PC、手机、Pad | 随开随用 |
| AI 屏幕,比如大屏和自助终端 | 摄像头看到有人走近,主动打招呼 |
| 移动 AI 屏幕 | 移动、导航、带路 |
| 人形机器人 | 转身、靠近、上肢动作 |
从网页搬到公司前台的一块屏幕上,它就能接待真正走进门的访客。下面这段是官方素材里的办公楼前台,访客说想拜访市场部,它在屏幕上接待、指路。

再往前一步,就是人形机器人。前台的活还是那些,机器人还能转身、走动、用手势指路。

形象在终端本地渲染,这是 AI 端渲,不用云端推视频流。官网给的数据是百元级入门芯片就能流畅运行,从提问到回答在 1200ms 以内。公司前台那块现成的屏幕,以后也能直接用上它。官方有一句话概括这件事,智能体持续存在,身体不断升级。
下一篇,把旧项目的胶水代码删掉
手上有用旧版渲染 SDK XmovAvatar 写的项目、自己接了 ASR 和大模型的,下一篇我带着迁移到端到端 SDK,看看能删掉多少行。
附录 完整 Demo
单文件 index.html,原生 HTML 加 CDN,把配置区换成自己的凭证就能跑。
npx serve . # 任意静态服务器都行,确保是 localhost
# 浏览器打开提示的地址,点"连接"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 前台 · 魔珐星云端到端 SDK Demo</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #0f1222; color: #e8e8f0; }
.wrap { display: flex; gap: 24px; padding: 24px; height: 100vh; box-sizing: border-box; }
/* 关键:容器必须有明确宽高,否则画布会折叠成 0 */
#agent-avatar { width: 405px; height: 720px; flex: none; border-radius: 12px; background: #1a1e36; }
.panel { flex: 1; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.row { display: flex; gap: 8px; }
input { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #3a3f66; background: #151933; color: inherit; }
button { padding: 10px 14px; border-radius: 8px; border: 0; background: #5b6cff; color: #fff; cursor: pointer; }
button:disabled { opacity: .4; cursor: not-allowed; }
#log { flex: 1; overflow: auto; background: #151933; border-radius: 8px; padding: 12px; font: 12px/1.6 monospace; white-space: pre-wrap; }
#status { font-size: 13px; color: #9aa0c8; }
</style>
</head>
<body>
<div class="wrap">
<div id="agent-avatar"></div>
<div class="panel">
<div id="status">未连接</div>
<div class="row">
<button id="btnConnect">连接</button>
<button id="btnMic" disabled>开麦</button>
<button id="btnStop" disabled>打断</button>
<button id="btnDestroy" disabled>销毁</button>
</div>
<div class="row">
<input id="txt" placeholder="输入问题,回车发送" disabled />
<button id="btnAsk" disabled>发送</button>
</div>
<div id="log"></div>
</div>
</div>
<!-- 端到端 SDK:注意文件名带 _e2e,全局对象是 XingyunAvatarAgent -->
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar_e2e@latest.js"></script>
<script>
// ====== 配置区:换成你在星云控制台创建应用后拿到的凭证 ======
// 注意:appSecret 写在前端只适合本地 Demo,上线请看文章第五节
const APP_ID = "your-app-id";
const APP_SECRET = "your-app-secret";
// ===========================================================
const $ = (id) => document.getElementById(id);
const log = (...a) => { $("log").textContent += a.join(" ") + "\n"; $("log").scrollTop = 1e9; };
const setStatus = (s) => ($("status").textContent = s);
let agent = null;
let speaking = false; // 智能体当前是否在说话
let micOn = false;
let reply = ""; // 拼接大模型流式回复
function setReady(ready) {
["btnMic", "btnStop", "btnDestroy", "txt", "btnAsk"].forEach((id) => ($(id).disabled = !ready));
$("btnConnect").disabled = ready;
}
async function connect() {
setStatus("连接中…");
agent = new window.XingyunAvatarAgent({
containerId: "#agent-avatar",
appId: APP_ID,
appSecret: APP_SECRET,
gatewayServer: "https://nebula-agent.xingyun3d.com/user/v1/ttsa_v2/session",
// 识别完自动交给大脑;同时打开语音前处理 + 回声消除
features: {
auto_send_asr_to_llm: true,
speech_frontend: { enabled: true, enable_aec: true },
},
// 渲染层错误(数字错误码)
onMessage(err) { log("[渲染错误]", err.code, err.message); },
agentCallbacks: {
onAgentStateChange(state) { log("[Agent]", state); },
onASRStateChange(state) { log("[麦克风]", state); },
onASRResult({ text, isFinal }) {
if (!text) return;
log(isFinal ? "[我说完了]" : "[我正在说]", text);
// 我插嘴了,而它还在说:先让它闭嘴
if (speaking) agent.interrupt();
},
onLLMResponse(res) {
// 文字在 chunk 里流式到达,done 时 text 可能为空,自己拼
if (res.event === "chunk") reply += res.text || "";
if (res.event === "done") { log("[大脑回复]", reply); reply = ""; }
},
onSpeakStateChange({ state }) {
speaking = state === "speak_start";
log("[播报]", state);
},
onConversationChange({ state }) {
log("[一轮对话]", state);
if (state === "completed") setStatus("已就绪,等你说话");
if (state === "speaking") setStatus("它在说…");
},
// Agent 层错误(字符串错误码 + domain)
onError(err) { log("[Agent错误]", err.domain, err.code, err.message); },
},
});
try {
await agent.init({
onDownloadProgress(p) { setStatus("资源加载 " + p.toFixed(0) + "%"); },
});
setStatus("已就绪,等你说话");
setReady(true);
agent.ask("你好,请用一句话欢迎来访的客人");
} catch (e) {
log("[初始化失败]", e && e.message);
await agent.destroy("init_failed").catch(() => {});
agent = null;
setStatus("连接失败,看日志");
}
}
$("btnConnect").onclick = connect;
$("btnAsk").onclick = () => {
const t = $("txt").value.trim();
if (!t || !agent) return;
if (speaking) agent.interrupt();
agent.ask(t);
$("txt").value = "";
};
$("txt").onkeydown = (e) => e.key === "Enter" && $("btnAsk").onclick();
// 麦克风必须在用户点击里打开(浏览器权限策略)
$("btnMic").onclick = async () => {
if (!agent) return;
if (micOn) { await agent.stopASR(); micOn = false; $("btnMic").textContent = "开麦"; }
else { await agent.startASR(); micOn = true; $("btnMic").textContent = "关麦"; }
};
$("btnStop").onclick = () => {
if (!agent) return;
const ms = agent.interrupt();
log("[手动打断] 耗时", ms, "ms");
agent.listen();
};
$("btnDestroy").onclick = async () => {
if (!agent) return;
await agent.destroy("user"); // 只删 DOM 不会释放麦克风,必须 destroy
agent = null; micOn = false; speaking = false;
setReady(false); $("btnMic").textContent = "开麦";
setStatus("已销毁");
};
</script>
</body>
</html>
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)