ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Rokid Glasses AIUI开发实战:从零构建“今天吃什么”语音技能

Rokid Glasses AIUI开发实战:从零构建“今天吃什么”语音技能 最近在折腾 Rokid Glasses 的 AIUI 开发顺手把一个一直困扰我的问题做成了小技能“今天吃什么”。这大概是智能眼镜上最容易让身边人产生共鸣的场景了开饭前盯着冰箱发呆不如直接问一句眼镜“今天吃什么”它给你一个能落地的答案。我从建工程、配意图、写推荐逻辑到真机调试完整走了一遍从 0 到 1 的流程今天把它全部摊开来讲包括踩过的坑和最后留下的经验。这篇教程适合两类人一是刚拿到 Rokid Glasses 开发机、想用 AIUI 快速做点东西的朋友二是对语音交互感兴趣但不知道意图、槽位、兜底策略这些概念怎么落地到实际项目里的开发者。我会用“今天吃什么”这个最小可运行的项目把 AIUI 技能开发的关键环节串起来你照着做基本能做出自己的第一个语音技能而不只是看文档空转。1. 开工之前先把 Rokid Glasses AIUI 开发的基础设施理清楚1.1 眼镜端 AIUI 到底是什么AIUI 在 Rokid Glasses 上不是简单挂个语音助手它是一整套从唤醒、语音识别、语义理解到语音合成的交互链路。你在眼镜上说的每一句话会经过麦克风采集、前端降噪、云端 ASR语音转文字、NLU意图理解最后回到眼镜端渲染成一句话回复或者一张交互卡片。眼镜端的特殊之处在于用户不能像手机一样低头盯着屏幕操作也没有触屏键盘。语音成了主交互通道卡片反而成了辅助反馈。这意味着很多在手机语音助手上的设计习惯要打碎重来对话必须短、反馈必须快、出错必须能自然补救。AIUI 把这套链路封装成了可复用的“技能”机制开发者只需要把自己的领域逻辑做成一个技能挂上去就能被语音唤起。我刚开始容易把 AIUI 理解成“给眼镜加一个大模型对话框”后来才发现真正好用的小技能不需要大模型那么泛反而要精确。意图粒度越清晰用户体验越干脆。“今天吃什么”这种高频、低决策成本的小场景特别适合用来练手。1.2 开发工具链与账号准备先把工具备齐我用的开发环境是 macOSWindows 端流程也类似。去 Rokid 开发者中心注册账号申请 Glasses 设备开发者权限。个人开发者一般都能通过审核时间不长。下载安装 Rokid Studio这是包含工程管理、模拟器、日志查看器的 IDE基于 VS Code 改的上手没有负担。安装 Rokid Glasses CLI 工具初始化工程和真机调试都靠它。macOS 上直接brew install rokid-glasses-cli就行。电脑上装好 ADB因为 Glasses 通过 Type-C 连接电脑调试时本质还是一台 Android 设备。装完在终端跑一句确认环境rokid-glasses doctor这个命令会检查 CLI 版本、环境变量、ADB 是否可用还会提醒你登录开发者账号。我第一次跑的时候卡在没登录CLI 提示访问一个本地端口完成 OAuth 授权浏览器里登录一次就好。这一步走完开发环境基本就绪。有一点要提前说真机调试比模拟器重要得多。模拟器能验证界面和基础逻辑但麦克风收音、唤醒、降噪这些只有真机上才有意义。我的习惯是“模拟器里写代码真机上调对话”。1.3 建一个“今天吃什么”的最小工程命令行执行rokid-glasses create today-eat脚手架会生成一个最简 AIUI 技能工程目录结构大概是这样today-eat/ ├── manifest.json # 技能配置名称、意图、槽位、权限 ├── main.js # 技能入口逻辑 ├── res/ │ ├── card_layout.json # 结果卡片的渲染定义 │ └── tts/ └── node_modules/manifest.json是核心技能怎么被唤醒、能听懂哪些话全在这里定义。main.js则是技能被触发后的处理逻辑可以在里面调用本地资源也可以发 HTTP 请求到云端。先跑一个什么都不干的最小版本确认真机链路是通的。把main.js改成const aiui require(rokid/aiui); aiui.onIntent(query_food, async (session) { await aiui.tts(开饭时间到我先给你想一个); session.end(); });然后编译部署到眼镜上rokid-glasses build rokid-glasses deploy --device对着眼镜说“今天吃什么”如果它回你“开饭时间到我先给你想一个”恭喜第一条 AIUI 链路已经跑通了。后面所有复杂功能都是在基础上加东西而已。2. “今天吃什么”技能设计意图、槽位与兜底策略2.1 对话流程设计与意图划分很多新手做语音技能容易一上来就写代码写完了才发现 NLU 理解得乱七八糟。我现在的做法是先在纸上把用户可能说的话全部列出来再归类成意图。意图就是用户“想干什么”槽位就是“完成这件事还需要什么信息”。拿今天吃什么来举例用户其实会说很多种话但归到底就四类直接问吃什么不需要额外信息。说不吃什么带有明确的食材偏好作为排除条件。对当前推荐不满意要求重新推荐。想知道这道菜具体怎么做。对应的意图我就切成了query_food、dislike_food、change_food、want_cook。再加上一个fallback兜底意图专门接听不懂的话。一个容易被忽略的关键点dislike_food不能当成独立的“一轮对话”来处理它应该是一个“上下文修正”。因为用户说“我不吃香菜”时往往是在上一轮推荐结果出来之后说的。如果技能无状态这轮就断了。我在一开始设计流程图时就明确dislike_food只更新本轮会话的排除清单然后重新执行推荐所以整个技能要有一个短时记忆的会话对象。2.2 定义技能配置文件意图、槽位与兜底打开manifest.json把定义的意图写进去。Rokid Glasses 的 NLU 支持“一句模板 槽位变量”的训练方式也可以直接上传语料。我的做法是先写模板再补几十条真实口语语料准确率会好很多。意图配置如下表意图触发例句槽位query_food今天吃什么 / 午饭吃啥 / 给我推荐个菜无dislike_food我不吃/不要/讨厌 食材ingredientchange_food换一个 / 不想吃这个 / 再来一个无want_cook这个怎么做 / 告诉我做法 / 步骤是什么无fallback其他无法识别的话无对应的manifest.json片段{ skill: { name: today_eat, title: 今天吃什么, intents: [ { id: query_food, templates: [ 今天吃什么, 午饭吃什么, 晚饭吃啥, 给我推荐个菜 ] }, { id: dislike_food, templates: [ 我不吃{ingredient}, 不要{ingredient}, 讨厌{ingredient} ], slots: [ { name: ingredient, type: sys.food } ] }, { id: change_food, templates: [ 换一个, 不想吃这个, 再来一个 ] }, { id: want_cook, templates: [ 这个怎么做, 告诉我做法, 步骤是什么 ] } ], fallback: { replies: [ 没听清你的意思可以说“换一个”或者“不吃某种菜”, 我还不太明白要不直接说“今天吃什么” ] } } }槽位的类型我用了sys.food这是平台内置的食材实体不用自己造词表。如果你想支持“我不吃猪肝”这种自定义食材可以去实体管理里加自定义词条但相信我先用内置的后面不够再扩。2.3 为什么我把推荐逻辑放在云端而不是眼镜端设计时我反复犹豫过推荐菜单这个逻辑到底放本地还是放云端后来还是放了云端理由有三个。第一菜单数据要变。今天想吃清淡的明天想吃重口味的如果写死在眼镜里每次更新菜单都要重新发版。云端改个 JSON 就完事。第二排除食材的过滤规则我不想用一套死逻辑打在设备端云端可以做更复杂的偏好分析。第三眼镜端的职责是尽量轻渲染卡片和说话已经占了大部分资源把推荐这种低频计算挪出去整机发热和耗电都好一些。作为折中我在眼镜端维护了一个 10 条菜的本地兜底列表云端接口挂了就随机挑一条顶上。这套“云端为主、本地兜底”的结构在后续真机测试里救了我好几次。3. 从 0 到 1 实现核心推荐逻辑与语音回复3.1 云端推荐接口的快速实现云端服务我用了最简单的 Node.js Express部署在边缘函数平台上。接口暴露一个GET /api/recommend接收exclude参数传的是排除食材列表返回一道随机的菜品。核心逻辑不长直接贴出来。const express require(express); const app express(); const dishes [ { name: 番茄炒蛋, tags: [鸡蛋, 番茄], side: 紫菜蛋花汤, cookTime: 15 }, { name: 清蒸鲈鱼, tags: [鱼], side: 白灼菜心, cookTime: 25 }, { name: 土豆炖牛腩, tags: [土豆, 牛肉], side: 凉拌黄瓜, cookTime: 40 }, { name: 麻婆豆腐, tags: [豆腐, 猪肉末], side: 米饭, cookTime: 20 }, { name: 蒜蓉西兰花, tags: [西兰花], side: 菌菇汤, cookTime: 12 }, { name: 红烧茄子, tags: [茄子], side: 番茄蛋汤, cookTime: 20 }, { name: 白切鸡, tags: [鸡], side: 姜葱蘸料, cookTime: 30 }, { name: 虾仁滑蛋, tags: [虾, 鸡蛋], side: 炒时蔬, cookTime: 15 } ]; app.get(/api/recommend, (req, res) { const exclude req.query.exclude ? req.query.exclude.split(,).map(s s.trim()) : []; const candidates dishes.filter(d !d.tags.some(tag exclude.includes(tag)) ); if (candidates.length 0) { return res.json({ code: 0, message: 没有合适的菜了, dish: null }); } const dish candidates[Math.floor(Math.random() * candidates.length)]; res.json({ code: 0, message: ok, dish: { ...dish, reason: pickReason(dish) } }); }); function pickReason(dish) { const reasons [ 做起来只要${dish.cookTime}分钟很快, ${dish.tags.join(、)}搭配着很下饭, 今天这个天气吃正合适 ]; return reasons[Math.floor(Math.random() * reasons.length)]; } app.listen(3000);这个接口的关键其实就是“过滤 随机”。但别小看这两步用户说“不吃香菜”如果你过滤不干净推荐出来还是带香菜这一轮信任就崩了。所以我特意把tags设计成数组任何命中排除项的直接淘汰。响应结构我固定成了{ code, message, dish }这样眼镜端和云端之间的协议边界很清楚。以后要加“我想吃点辣的”这种正向口味也只要加一个prefer参数进去就行。3.2 眼镜端调用推荐服务接下来回到main.js把对话逻辑接上云端。这一步的核心是处理异步请求同时保证会话状态能跨意图存活。const aiui require(rokid/aiui); const fetch require(rokid/fetch); const CLOUD_API https://your-edge-domain/api; // 管理会话级状态排除食材列表、当前菜品 const sessionState { excludeList: [], currentDish: null }; aiui.onIntent(query_food, async (session) { sessionState.excludeList []; const dish await recommendDish(); sessionState.currentDish dish; await aiui.tts(今天吃${dish.name}怎么样${dish.reason}); await aiui.render(dish_card, buildCardPayload(dish)); session.end(); }); aiui.onIntent(dislike_food, async (session, slots) { const ingredient slots.ingredient; if (!sessionState.excludeList.includes(ingredient)) { sessionState.excludeList.push(ingredient); } const dish await recommendDish(); sessionState.currentDish dish; await aiui.tts(好不吃${ingredient}。那试试${dish.name}吧); await aiui.render(dish_card, buildCardPayload(dish)); session.end(); }); async function recommendDish() { const exclude sessionState.excludeList.join(,); try { const resp await fetch(${CLOUD_API}/recommend?exclude${encodeURIComponent(exclude)}) .timeout(8000) .then(r r.json()); if (resp.dish) { return resp.dish; } return getLocalFallback(exclude); } catch (e) { return getLocalFallback(exclude); } } function getLocalFallback(exclude) { const localDishes [西红柿炒蛋, 清炒土豆丝, 可乐鸡翅]; const filtered localDishes.filter(d !exclude.includes(d)); return { name: filtered[0] || 白粥, side: 咸鸭蛋, cookTime: 10 }; }这里有几个细节想多说一句。fetch.timeout(8000)是我加上去的超时控制。边缘函数偶尔冷启动会慢不能让用户对着眼镜干等 20 秒。8 秒是底线超过就降级到本地兜底。sessionState是模块级变量在当前技能进程里存活。对于“今天吃什么”这种轻量技能不需要持久化到文件内存里存一轮就够了。还有一点dislike_food里我给用户回话时把“不吃 X”这个信息回显了出去这是语音交互里很重要的原则让用户知道你真的听懂了他刚才的约束。如果不回显用户会以为系统没听懂然后重复说一遍体验很差。3.3 语音回复模板与 TTS 控制TTS 回复不是简单把文字拼出来要考虑听感。我试过直接把推荐接口的 JSON 字段拼成句子结果“番茄炒蛋做起来只要 15 分钟很快”这种长句语音合成出来像在背书。后来我总结了几条模板规则菜名放最前面用户第一时间接收到核心信息。理由跟一句就够不要堆三条理由听着累。每个逗号自然产生停顿注意句子节奏。单条回复控制在 60 到 90 字以内太长用户记不住。我在枚举菜谱时顺手补了一个reason字段其实就是为了让 TTS 模板有变化。不然每次都是“今天吃 XX 怎么样”听三天就腻了。另外一个经验不要在 TTS 里读卡片上已经展示的信息。比如卡片上已经显示了菜名和配菜TTS 里就不要再重复“配一份紫菜蛋花汤”一句话带过即可。语音和卡片信息要错开别互相抢注意力。3.4 异常兜底与超时处理AIUI 技能最容易翻车的地方就是“话接不上”。用户说了一句模板之外的话NLU 没识别出来如果技能直接不回复体验极其断裂。所以平台提供了fallback但我的经验是兜底话术也得带上下文不能每次都背同一句。aiui.onFallback(async (session, text) { const hasContext sessionState.currentDish ! null; if (hasContext) { await aiui.tts(你可以说“换一个”或者告诉我你不吃什么食材); } else { await aiui.tts(没太明白试试直接说“今天吃什么”); } session.end(); });这里怎么判断有没有上下文也推敲了很久。光靠“当前菜品是否为空”其实不够因为用户第一次问完吃到一半可能隔了很久才说“换一个”。严格来说要加时间戳超过 5 分钟就重置上下文。我在代码里加了一个lastActiveAt每次收到意图就更新兜底前先判断现在是 5 分钟之内的“对话中”还是已经“开新话题”。异常兜底不是用来敷衍用户而是保证对话能优雅地绕回主线。实际使用中至少有一半的话是 NLU 没识别出来的做好兜底等于把用户体验的下限托住了。4. 卡片交互让结果“看得见”4.1 卡片布局与信息层级语音回复告诉用户“吃什么”卡片则负责把这道菜的关键参数一屏展示清楚。Rokid Glasses 的屏幕面积很小卡片设计必须和信息层级一起规划。我定义的dish_card卡片长这样{ type: card, title: 番茄炒蛋, subtitle: 酸酸甜甜很下饭, badges: [15分钟, 清淡, 下饭], actionButton: { primary: 换一个, secondary: 看做法 } }设计阶段我想过把卡片做得很丰富比如放菜谱图片、分数、热量表。后来在眼镜上一看发现完全不行——本来屏幕就小字再多一点直接糊成一片。最后我砍到只剩三个层级菜名、一句话评价、标签位。标签最多放三个超过就换行而换行在眼镜上特别占地。我一直跟自己做约定卡片上的字不超过十行单个标签不超过五个汉字。用户瞟一眼能看懂就达到目的了。4.2 按钮交互“换一个”与“看做法”眼镜端没有鼠标交互靠触控板和实体按键。卡片上的按钮其实是通过“语音点击”和“手势确认”两种方式触发的。用户在卡片态下说“换一个”会直接走change_food意图。如果用手势点按则触发卡片的actionButton回调。aiui.onCardAction(dish_card, async (action, session) { if (action 换一个) { const dish await recommendDish(); sessionState.currentDish dish; await aiui.tts(好换一个${dish.name}怎么样); await aiui.render(dish_card, buildCardPayload(dish)); } else if (action 看做法) { await aiui.tts(简单先把${sessionState.currentDish.tags.join(、)}备齐下锅炒熟全程${sessionState.currentDish.cookTime}分钟); await aiui.render(cook_card, buildCookPayload(sessionState.currentDish)); } session.end(); });第一次做的时候我把“看做法”做成弹长文本结果在眼镜上读起来非常痛苦。后来改成 TTS 说重点步骤卡片只显示步骤编号和调料清单文字量立刻少了很多。记住这句话眼镜卡片是给用户扫一眼的不是给用户读的。4.3 适配眼镜端显示的注意事项在适配过程中我整理了几个硬性约束项目建议值原因正文字号不小于 24px低于这个值在眼镜上容易发虚行间距1.4 倍过密会视觉粘连单屏行数不超过 10 行多了会被视线裁切按钮数量最多 2 个多了误触率飙升对比度文字与背景色差要明显户外光线强灰底灰字直接看不见最大的坑是安全区。眼镜显示区域四周有个不可用的弧面区域直接贴边放内容会被裁掉。我第一版卡片标题提得太靠上真机上右边缺了一块。后来在card_layout.json里加了全局边距所有元素统一往内缩 16px问题才解决。另外尽量避免横向滚动的设计。用户的头部不会为了看完一行字而左右转这是眼镜端和手机端最本质的区别。5. 真机调试与常见问题排查5.1 连接眼镜与查看日志真机调试我建议全程用 Type-C 连着电脑。连接后先用 ADB 确认设备adb devices看到设备后用 CLI 部署技能rokid-glasses deploy --device查看运行日志是排查问题的主要手段adb logcat -s AIUI这个过滤标签会把 ASR 识别文本、NLU 解析结果、TTS 合成状态都打出来。我经常干的一件事是对着眼镜说一句测试话术然后立刻回看日志里的 NLU 输出确认平台把我的话理解成了哪个意图、抽出了哪些槽位。如果你想单独看某个意图的处理耗时可以在代码里打console.time和console.timeEndCLI 会把日志同步回传这个在调延迟的时候特别好用。5.2 我踩过的三个坑调试过程中最大的三个问题我整理成了表格应该是不少人会遇到的。现象可能原因解决方式说“今天吃什么”老触发 fallback工程部署后 NLU 模型没同步在开放平台重新训练并发布技能再redeploy第一次推荐特别慢后面就快了边缘函数冷启动云端加防冷启动策略或本地先缓存一次响应TTS 播报时听不清菜名模板里连续同音字太多在菜名前后加停顿词比如“试试番茄炒蛋”卡片按钮点击没反应卡片的type写成了普通卡片改成type: card并注册onCardAction第一个坑是最隐蔽的。我在开放平台上改了意图模板但没有点“重新训练”结果眼镜上还是老模型在跑。后来养成习惯每次改完manifest.json一定去平台点一次训练再重新部署缺一不可。第二个坑的冷启动问题我的临时解法是把本季度的常用菜单缓存到眼镜本地每天凌晨更新一次。云端挂了也不怕用户至少能拿到菜品列表只不过推荐理由会少一个“新意”。5.3 更远的优化方向技能做完后我认真复盘了一遍觉得这个项目还能往三个方向扩展也分享给你当延伸作业一是加入正向口味偏好比如“今天想吃辣的”NLU 层加taste槽位云端按辣度过滤推荐准确度会更有惊喜感。二是做食材库存管理用户对眼镜说“冰箱里有鸡蛋和番茄”技能记住库存推荐时直接按库存出菜那就真成了“今天吃什么”的完全体。三是把决策时间缩短。现在的推荐是纯随机没有记忆用户可能连续两次听到同一道菜。后端可以把推荐记录写到缓存里短时间内避免重复体验会自然很多。我自己的体会是做“今天吃什么”这类的 AIUI 技能真正难的从来不是写代码而是“理解人的对话习惯”。你愿意多花时间打磨意图边界、设计合理的兜底策略体验就会明显比同类技能高出一截。最后再分享一个小技巧所有 TTS 话术里的菜名尽量用“菜名 语气词”结尾比如“番茄炒蛋怎么样”比“推荐番茄炒蛋”听起来自然得多。这个小细节用户说不出来哪里好但就是会觉得你的技能更“懂人”。
返回列表