魔珐星云 | 给语音智能体写一套状态机:按钮什么时候该亮?
具身交互智能体开发笔记 · 第 02 篇
页面上只有四个按钮:发送、开始收音、停止收音、打断表达。
最开始,这件事很容易做:点哪个按钮,就调用哪个方法。
麻烦往往出在第二步。初始化还没完成,发送按钮先亮了;智能体明明还在说话,页面却显示"空闲";用户想让它停下来,点完以后才发现,只是关掉了麦克风。
链路还在跑,界面却已经跟不上了。沿着代码往回找,经常会发现:几个含义不同的"完成",最后都改了同一个变量。
上一篇聊过回调、播报和打断的区别。这一篇就把它们落到页面上:什么时候能发送,什么时候该收音,想打断时,按钮又该听谁的?
先把 isBusy 放到一边
假设页面只有一个变量:
js
let isBusy = false;
开始识别时设成 true,请求完成时改回 false。听起来很合理,直到出现这个场景:
智能体正在说话,麦克风也正在收音。
它到底忙不忙?
对于"开始收音",它已经在收音,不能再启动一次;对于"停止收音",这个按钮应该可用;对于"打断表达",用户也应该能点。
写到这里,isBusy 就有点为难了。它只能回答"忙不忙",却回答不了"正在忙什么,还能做什么"。
先把状态拆开,按钮的逻辑反而会简单下来。

这一篇继续用魔珐星云的 XingyunAvatarAgent 来写。魔珐星云做的是端到端具身交互智能,感知、智脑和多模态表达可以通过这套 SDK 接进 Web 页面。对于想做接待助手、屏幕交互应用的人来说,这样更容易先把交互跑起来,再往里面加自己的业务。我们今天要补的,就是页面上这几个看似不起眼的按钮。
想边看边试,可以先打开官方的 SDK 教学与 Demo。代码、执行入口和日志都在里面,填好自己的 App ID、App Secret,完成初始化,就能顺着"表达""感知&大脑"里的示例试下去。特别值得留意的是:点下按钮以后,日志里的状态什么时候变了,画面又什么时候才真正结束。
还没有账号的话,注册魔珐星云时可以填邀请码 JSUAY3IS96 ,领取 1000 积分。有兴趣跟着动手的朋友,可以顺手用上。
再看这三条状态线,各自管的事情就清楚了:
| 状态来源 | 页面需要知道什么 | 本文会用到的示例值 |
|---|---|---|
| Agent | 当前能否发起交互 | running、reconnecting |
| ASR | 麦克风与识别处于什么阶段 | idle、requesting-permission、starting、listening、stopping、failed |
| Conversation | 对话最近进入了哪个阶段 | asking、speaking、completed、interrupted |
先记住表里这几个常见值就够了。asking 要多留个心眼:开始 ASR 时也可能收到它,仅凭这个名字,还不能断定模型已经在生成回答。
页面自己也有事要记。比如初始化 Promise 还没返回,或者用户刚点了开始收音,浏览器正在等授权。这些过程归应用自己管理,和 SDK 回传的状态分开存。
一份状态快照,别让回调各改各的按钮
先给这些状态找个统一的落脚点:
js
function initialState() {
return {
phase: "empty", // 应用生命周期,非 SDK 状态
agent: "idle", // SDK Agent 状态
asr: "idle", // SDK ASR 状态
conversation: "idle", // 初始占位值,后续记录 SDK 回调
pending: null, // 应用正在提交的命令
error: ""
};
}
let state = initialState();
phase 记页面走到了哪一步:有没有实例,初始化是否完成,是不是正在清理。pending 记刚点下去的命令,比如"开始收音"还在等浏览器授权。
命令还没返回,与一轮对话还没结束,是两件事。
await agent.ask(text) 返回了,后面还可能有一长段话要说。模型发来文字的 done,也不等于声音和动作已经结束。这里如果抢先写入 completed,页面就会比智能体提前"下班"。
接下来,把所有状态变化收进 reduce。给它一个旧快照和一个新事件,让它算出下一份快照。先把最常走的几条路径写出来:
js
function reduce(state, event) {
switch (event.type) {
case "INIT_BEGIN":
return { ...initialState(), phase: "initializing" };
case "INIT_OK":
return { ...state, phase: "active" };
case "SDK_AGENT":
return { ...state, agent: event.value };
case "SDK_ASR":
return { ...state, asr: event.value };
case "SDK_CONVERSATION":
return { ...state, conversation: event.value };
case "COMMAND_BEGIN":
return { ...state, pending: event.name, error: "" };
case "COMMAND_END":
return { ...state, pending: null };
case "ERROR":
return { ...state, error: event.message };
default:
return state;
}
}
INIT_OK、SDK_AGENT 是应用自己起的事件名。以后翻日志,一眼就能分清:这次是初始化走完了,还是 SDK 又报来一条状态。
更省心的是,回调从此只管更新快照。button.disabled = ... 不必散落在三个回调里,按钮也不会因为某条消息晚到一步,就被改回错误的样子。
按钮是否可用,交给一个函数
有了这份快照,四个按钮就可以从同一个地方拿答案:
js
export function controls(s) {
const ready = s.phase === "active" && s.agent === "running";
const free = s.pending === null;
const micIdle = ["idle", "failed"].includes(s.asr);
return {
connect: s.phase === "empty",
ask: ready && free && micIdle,
startASR: ready && free && micIdle,
stopASR: ready && free && s.asr === "listening",
interrupt: ready,
destroy: ["active", "init-failed"].includes(s.phase) && free
};
}
ready 同时检查两件事:应用已经等到初始化完成,SDK 当前也处于 running。
这个"双重检查"专门挡住一种时序:running 回调已经到了,init() 的 Promise 却还没返回。SDK 报告了进展,应用自己的初始化流程还得等它走完。
剩下几个按钮,我在这里做了这样的取舍:
- 收音期间,暂停手动文本发送,减少两个输入入口同时提交的机会。
- ASR 处于
idle或failed时,才允许尝试开始收音;授权、启动和停止的过程中不重复启动。 - 本例只在
listening时开放"停止收音",不实现等待授权过程中的取消操作。 - "打断表达"只要求整体可交互,不受麦克风命令的
pending阻塞。
最后一条是有意留的。浏览器还在等麦克风授权,用户也可能突然想让智能体停下来。这个时候,不能连"打断"一起锁掉。
所以这里只要整体还能交互,就保留打断入口,不靠最近一条对话回调去猜服务端是否还有内容待播。没有正在表达的内容时,点击它也可能听不出变化。
"发送"亮起来,则只说明它过了眼前这几道检查,并不代表上一轮已经结束。如果你的产品必须严格一问一答,还要加一层轮次调度。按钮能不能点,和下一轮该不该开始,得各自想清楚。

算完以后,再一次性更新界面:
js
function render(state, enabled) {
for (const [name, canUse] of Object.entries(enabled)) {
document.getElementById(name).disabled = !canUse;
}
document.getElementById("error").textContent = state.error;
}
function dispatch(event) {
state = reduce(state, event);
render(state, controls(state));
}
这里用原生 DOM 把关系写得直白一点。换成 React 或 Vue,前面的状态和判断函数照样能用,渲染交给框架就好。
SDK 回调只报告事实
现在回头接 SDK,回调里反而没多少事情要做了:收到什么,就记下什么。
js
agentCallbacks: {
onAgentStateChange(value) {
dispatch({ type: "SDK_AGENT", value });
},
onASRStateChange(value) {
dispatch({ type: "SDK_ASR", value });
},
onConversationChange(event) {
dispatch({ type: "SDK_CONVERSATION", value: event.state });
},
onError(error) {
dispatch({ type: "ERROR", message: error.message });
}
}
报错时也别急着把整个页面判成"坏了"。一次模型请求失败,不等于 Agent 已经掉线。错误信息先展示出来,Agent 和 ASR 是否变了,继续看它们各自的回调。
至于初始化,就老老实实等 await 走完,再记下完成:
js
await agent.init();
dispatch({ type: "SDK_AGENT", value: agent.getAgentState() });
dispatch({ type: "SDK_ASR", value: agent.getASRState() });
dispatch({ type: "INIT_OK" });
结束时再读一遍 SDK 当前状态,页面就能从一个对齐的快照开始工作。
后面遇到断线,Agent 进入 reconnecting,ready 自然变成 false,按钮随之变灰。等 SDK 恢复到 running,再重新算一遍。这里让 SDK 处理自动重连,页面负责告诉用户"现在还在恢复",别再叠一个定时器不停创建新实例。
点击以后,还要再检查一次
按钮禁用了,不等于调用入口就安全了。表单提交、快捷键和其他业务代码,都可能绕过按钮的点击事件。
所以,真正调用 SDK 的地方,还得看一眼当前快照:
js
async function command(name, invoke) {
// UI 禁用只是提示;真正执行前仍要检查一次。
if (!agent || !controls(state)[name]) return false;
dispatch({ type: "COMMAND_BEGIN", name });
try {
await invoke(agent);
return true;
} catch (error) {
report(error);
return false;
} finally {
dispatch({ type: "COMMAND_END" });
}
}
把 agent、state 和 dispatch 放在同一个控制器里,三个入口就都能经过这道检查:
js
await command("ask", current => current.ask(text));
await command("startASR", current => current.startASR());
await command("stopASR", current => current.stopASR());
COMMAND_BEGIN 在调用 SDK 前同步发生。即使短时间又进来一次点击,也会先看见 pending,不会重复启动收音。
finally 只负责解除命令锁 ,不会把对话状态改为空闲。后续是否进入 speaking、是否来到 completed,继续等待对话回调。
打断走独立入口,检查 controls(state).interrupt 后调用 agent.interrupt("speak"),并捕获可能的异常。它不共用麦克风命令锁,也不顺手调用 stopASR()。
到这里,"停止收音"和"打断表达"终于从界面到代码都分开了:一个控制语音输入,一个控制当前交互的表达。用户想做哪个动作,就触发哪个入口。
别只测"顺着点一遍"
连上以后,发一句话,再关掉页面,通常看不出这里的问题。稍微给事件顺序添点乱,才知道前面的判断有没有漏。

这一步先不用对着麦克风反复说话。用模拟回调把事件一条条送进去,看按钮是不是按预期变化;这一层过了,再用真实应用检查收音和播报。
| 输入序列或场景 | 应当看到的结果 |
|---|---|
running 已到,init() 仍未完成 |
"发送"继续禁用 |
初始化完成,Agent 为 running,ASR 为 idle |
可发送,也可开始收音 |
ASR 为 listening,对话为 speaking |
可停止收音,也可打断表达 |
Agent 变成 reconnecting |
交互按钮禁用,页面保留各条状态 |
ask() 返回,或收到模型文字 done |
不擅自写入对话 completed |
| 某个命令抛出异常 | 显示错误,并释放命令锁 |
| 初始化失败,但实例尚未释放 | 不允许再叠加创建一个实例 |
第一个场景尤其适合写成断言。用一个暂不完成的 init Promise,先触发 running 回调:
js
const connecting = controller.connect(options);
callbacks.onAgentStateChange("running");
assert.equal(controls(controller.getState()).ask, false);
resolveInit(); // 测试桩:现在才让 init() 完成
await connecting;
assert.equal(controls(controller.getState()).ask, true);
resolveInit 来自测试桩,用来控制模拟的 init() 什么时候完成。故意把 Promise 拖后一步,就是要看看:页面会不会一听见 running,就忘了自己还在初始化。
清理实例时也一样,得等它真的清理成功,再回到可以重新连接的 empty。清理报错就先留住引用,别在 finally 里一把清空,转头又创建一个新实例。这部分还有不少细节,后面聊失败重试时再拆开。
用户看见的,就是这几个按钮
做到这里,再回头看开头那四个按钮,事情就清楚多了。
智能体一边说、一边听,"停止收音"和"打断表达"可以同时亮着。网络正在恢复,页面知道该等一等。ask() 返回了,界面也不会急着宣布"本轮结束"。
用户未必关心这些回调叫什么,但一定会在意:我现在能点什么,点完有没有用。
把状态放回同一份快照,后面再加字幕、流式回复或业务卡片,也就有了一个能接着往下写的地方。
下一篇,我们处理另一个一眼就能看见的问题:语音字幕为什么会越识别越重复?
接口参考