AI交互命令
约 9546 字大约 32 分钟
2026-03-02
本文定义$thing/*/ai接入。按业务选择语音、通话、摄像头或媒体功能,请从AI与音视频接入开始;使用signaling/message的会话字段和时序在该栏目独立定义,不与本文会话混用。
联犀平台支持通过 MQTT 协议进行设备端 AI 交互,包括纯文字对话、实时语音对话、多模态输入(文字+图片/视频/文件)等多种交互方式。语音控制与文本事件走 MQTT,双向 Opus 音频走 UDP;设备可在创建会话时声明支持的交互模式,由平台选择本次模式。
扩展协议:
- UDP 音频通道协议详细规范
- 联犀内置小智 AI 功能(含拍照识图接入)
- 表情/情绪下发协议(本文档第 11 节)
语音对话支持两条并存的云端处理链路,由平台按 Agent 配置和设备能力选择:
- 流式 ASR + LLM + TTS 方案:Agent 配置
voicePipeline=cascade,会话选择interactionMode=halfDuplex。设备按轮次发送audioStart → UDP 音频 → audioStop,平台依次完成语音识别、模型回复和语音合成。 - Realtime 端到端语音模型:Agent 配置
voicePipeline=realtime,会话选择interactionMode=fullDuplex。设备启动后持续上传音频,回复播放期间也能收音和插话;audioStop表示暂停输入。
voicePipeline 是平台侧配置,不由设备指定。设备通过 sessionCreate.supportedInteractionModes 报告能力,平台通过 sessionCreated.data.interactionMode 确定本次模式。两条链路共用联犀 MQTT 消息信封和 UDP 音频包格式,具体时序见第 8.2 节。
1. 协议概述
AI 交互使用独立的 MQTT Topic 类型 ai,与物模型的 property/event/action 类型分离,语义更清晰。
MQTT Topic
上行(设备 → 云端): $thing/up/ai/{ProductID}/{DeviceName}
下行(云端 → 设备): $thing/down/ai/{ProductID}/{DeviceName}通用消息信封
所有 AI 协议消息使用统一的 JSON 信封格式:
{
"msgToken": "msg-001",
"method": "sessionCreate",
"params": {
"modalities": ["text"]
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| msgToken | string | 消息令牌,用于请求-响应匹配 |
| method | string | 方法名(小驼峰命名) |
| params | object | 请求参数(上行消息使用) |
| data | object | 响应数据(下行消息使用) |
| code | int | 响应码,200 表示成功(下行消息使用) |
2. Agent-Device 绑定机制
设备进行 AI 交互时,会自动关联到对应的智能体(Agent)和数字分身(Clone)。
2.1 AgentGroup 用途分类
AgentGroup 通过 purpose 字段区分用途,不同 purpose 适用于不同的 AI 交互入口:
| Purpose | 适用场景 | 触发路径 |
|---|---|---|
user | 用户网页/APP 对话 | HTTP/api/v1/ai/user/completions |
device | 平台预留(当前未启用) | — |
default | 通用默认 | — |
platform | 平台级配置 | — |
设备 MQTT AI 交互不通过 AgentGroup 路由。设备发起
sessionCreate时,云端直接使用绑定在设备上的 Clone(见 2.3 节),Clone 已包含对应的 Agent 信息,无需实时查找 AgentGroup。
2.2 产品绑定默认 Agent
通过在产品(Product)上配置 defaultAgentID,该产品下的所有设备在首次绑定时会自动创建对应的 Clone:
配置步骤:
- 在控制台创建 AgentGroup,设置
purpose=device - 在该 Group 下创建 Agent,配置系统 Prompt 和 LLM 参数
- 创建产品时设置
defaultAgentID,或在产品详情页更新绑定关系
2.3 设备 Clone 自动创建
设备首次被用户绑定时(调用 deviceInfo/bind API),系统会自动执行以下逻辑:
- 检查设备是否已有 Clone(
dm_device_info.clone_id) - 若无且产品有
defaultAgentID,自动调用 aisvr 的 Clone RPC 创建 Clone - Clone 代码格式:
device_{productID}_{deviceName}(全局唯一) - Clone 绑定到设备,记录
cloneID
Product.defaultAgentID → 设备首次绑定 → 自动创建 Clone → dm_device_info.clone_idClone 的作用:
- 每个设备拥有独立的 Clone,AI 记忆完全隔离
- Clone 继承 Agent 的 Prompt 和配置,但记忆独立
- 不同设备间的 AI 对话历史、偏好设置互不干扰
产品未配置
defaultAgentID时: 绑定设备时不会自动创建 Clone(clone_id保持为空)。此时设备发起sessionCreate会返回错误(invalidInput,提示设备未绑定 Agent)。如需 AI 交互,须先在产品上配置defaultAgentID并重新绑定设备,或通过管理接口为设备手动关联 Clone。
2.4 完整绑定流程
3. 上行方法(设备 → 云端)
| Method | 说明 |
|---|---|
sessionCreate | 创建 AI 会话 |
sessionClose | 关闭 AI 会话 |
audioStart | 启动音频流 |
audioStop | 流式 ASR + LLM + TTS 方案结束本轮输入;全双工模式暂停输入 |
respCancel | 取消当前响应(打断) |
inputSend | 发送多模态输入 |
打断语义补充:
当前服务端不是“检测到有声就一定立刻打断”。实际运行时已区分:
vad:VAD 达到打断门槛后停止 TTSasr_first_text:ASR 首个有效文本到达后停止 TTS / LLMasr_final_text:ASR 最终文本到达后停止 TTS / LLM同时服务端存在一个短暂的播放保护窗,用于减少 TTS 外放回采导致的误打断。
3.1 sessionCreate - 创建会话
请求示例(语音对话):
{
"msgToken": "msg-001",
"method": "sessionCreate",
"params": {
"modalities": ["text", "audio"],
"audioParams": {
"format": "opus",
"sampleRate": 24000,
"channels": 1,
"frameDuration": 60
}
}
}请求示例(纯文字对话):
{
"msgToken": "msg-001",
"method": "sessionCreate",
"params": {
"modalities": ["text"]
}
}参数说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| modalities | []string | 是 | 请求模态:text(纯文字)、audio(语音) |
| audioParams | object | 否 | 音频参数(包含 audio 模态时需要) |
| audioParams.format | string | 否 | 音频格式:opus(推荐)、pcm、wav、mp3 |
| audioParams.sampleRate | int | 否 | 采样率:8000 / 16000 / 24000 / 48000(Hz),当前现网默认 24000 |
| audioParams.channels | int | 否 | 声道数:1(单声道,推荐)、2(立体声) |
| audioParams.frameDuration | int | 否 | 帧时长(ms):20 / 40 / 60 |
| transport | string | 否 | 音频传输方式:udp(默认);全双工当前仅支持 UDP |
| instructions | string | 否 | 本次会话的临时系统 Prompt(覆盖 Agent 默认的 systemPrompt,仅本次有效) |
| supportedInteractionModes | []string | 否 | 设备支持的语音交互模式:halfDuplex、fullDuplex;不传表示旧设备,显式空数组表示无支持模式 |
| aecMode | string | 否 | 设备回声处理能力:device 表示设备侧 AEC,none 表示无;全双工当前要求 device |
纯文字对话时
modalities设为["text"],无需audioParams。会话时长受平台令牌有效期、所选模型的空闲与最长会话限制约束,不应把固定时长写入设备固件。以
sessionClosed或错误通知为准,关闭后重新创建会话。
全双工请求示例:
{
"msgToken": "msg-duplex-001",
"method": "sessionCreate",
"params": {
"transport": "udp",
"modalities": ["text", "audio"],
"supportedInteractionModes": ["halfDuplex", "fullDuplex"],
"aecMode": "device",
"audioParams": {"format": "opus", "sampleRate": 16000, "channels": 1, "frameDuration": 20}
}
}设备只报告自身能力,不指定豆包或其他云端模型,也不通过 audioStart 选择交互模式。平台基于 Agent 已启用的语音能力、所选云端链路、交互策略和设备能力协商:流式 ASR + LLM + TTS 方案对应 halfDuplex,当前豆包 Realtime 端到端语音模型对应 fullDuplex;无公共模式时创建失败,不静默切换链路。未上报模式的旧设备在流式方案配置下保持原行为;若 Agent 被配置为仅支持 Realtime 端到端语音模型,旧设备创建会话会失败,须升级设备或调整 Agent 配置。仅声明 fullDuplex 而未声明设备侧 AEC 的设备,当前不能进入全双工模式;缺省或未知的 aecMode 也不视为已具备 AEC。设备声明 AEC 不等于硬件回声消除效果已经验收。
3.2 inputSend - 发送多模态输入
请求示例(纯文字):
{
"msgToken": "msg-002",
"method": "inputSend",
"params": {
"contents": [
{"type": "text", "text": "你好,请介绍一下自己"}
]
}
}请求示例(文字+图片):
{
"msgToken": "msg-003",
"method": "inputSend",
"params": {
"contents": [
{"type": "text", "text": "这张图片里有什么?"},
{"type": "image_url", "imageUrl": "https://oss.example.com/photo.jpg"}
]
}
}请求示例(文字+图片+音频文件):
{
"msgToken": "msg-004",
"method": "inputSend",
"params": {
"contents": [
{"type": "text", "text": "请结合图片和语音说明现场情况"},
{"type": "image_url", "imageUrl": "https://oss.example.com/camera.jpg"},
{"type": "audio", "audioData": "BASE64_ENCODED_AUDIO", "audioFmt": "wav"}
],
"modalities": ["audio"]
}
}参数说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| contents | []ContentPart | 是 | 多模态内容列表(见第 5 节) |
| modalities | []string | 否 | 期望输出模态(不填则使用会话默认值) |
补充说明:
contents当前已验证支持:textimage_urlaudio
audio类型通过audioData + audioFmt传输音频文件内容- 当
modalities=["audio"]且会话已绑定 UDP 音频通道时,服务端会在多模态文本路径下也触发 TTS 下行,而不再局限于纯语音识别路径
3.3 其他上行方法
audioStart、audioStop、sessionClose 可以不带 params;respCancel 也可不带 params,沿用取消当前回复的原有语义:
{
"msgToken": "msg-004",
"method": "audioStart"
}{
"msgToken": "msg-005",
"method": "audioStop"
}{
"msgToken": "msg-006",
"method": "respCancel"
}{
"msgToken": "msg-007",
"method": "sessionClose"
}流式 ASR + LLM + TTS 方案的 audioStart 可选扩展参数:
{
"msgToken": "msg-004",
"method": "audioStart",
"params": {
"mode": "auto",
"realtime_mode": "asr_first_text",
"auto_end": false,
"silence_duration_ms": 500,
"max_idle_duration_ms": 0
}
}若不传以上字段,将使用服务端默认值。全双工模式在 sessionCreate 时协商,audioStart.params.mode 和 realtime_mode 不负责选择全双工。
全双工定向取消示例:
{"msgToken":"msg-006","method":"respCancel","params":{"respId":"resp_001"}}respId 使用设备在 respCreated / respAudioStart 中看到的回复 ID,不是云端模型内部 ID。设备应先停止本地播放,再发送带目标的取消;旧目标的迟到取消不会误停新回复。不带 respId 仍取消当前回复,但没有跨回复迟到保护。取消播报不会撤销已经执行的设备控制动作,也不会停止全双工麦克风上行。
| Method | 触发时机 |
|---|---|
audioStart | 流式 ASR + LLM + TTS 方案开始本轮采集;全双工模式首次启动或暂停后恢复持续收音 |
audioStop | 流式 ASR + LLM + TTS 方案结束本轮输入;全双工模式暂停输入,不表示一句话结束,也不关闭会话 |
respCancel | 用户打断 AI 回复(如再次按下按钮),触发 TTS 中止 |
sessionClose | 对话结束,释放会话资源 |
以下
audioStop/ VAD 对照仅适用于流式 ASR + LLM + TTS 方案:
手动 audioStopVAD 自动触发 触发方式 设备主动发送 服务端检测到语音静音边界后自动触发 设备是否需要发包 是,发送 audioStop否,无需设备操作 设备 UDP 发包何时停止 发送 audioStop后可停止发包设备可持续发包(静音帧),VAD 会忽略静音 等待时间 服务端等待 ASR 最终结果 ≤1.5s 服务端收到静音边界后直接处理,无需等待
audioStop后的会话状态: 流式 ASR + LLM + TTS 方案下只结束本轮输入,不关闭 session;全双工模式下暂停输入,当前回复仍可继续下行,再次audioStart恢复同一会话的上行。
respCancel后的会话状态: 设备收到audioSpeechInterrupted后,会话仍然活跃,无需重新sessionCreate。流式 ASR + LLM + TTS 方案可再发audioStart或inputSend;全双工模式保持持续收音,无需为下一句话重复audioStart。流式 ASR + LLM + TTS 方案的口语退出: 当用户在语音里明确说“再见”“结束对话”“退出对话”等时,服务端可先播报告别语,再主动下发
sessionClosed。全双工设备如需确定结束会话,应主动发送sessionClose;收到sessionClosed后如需继续交互,必须重新sessionCreate。
3.4 现网调试建议
当设备侧语音链路异常时,推荐按下面的顺序排查:
- 先跑独立 ASR 能力
- 只验证
respSttDone - 排除 TTS / MCP / session 生命周期干扰
- 只验证
- 再跑独立 TTS 能力
- 只验证
respAudioStart/respAudioDone
- 只验证
- 最后再跑完整 workflow
- 多轮语音
- 设备控制
- 偏好记忆
如果需要对照当前仓库稳定测试基线,可参考:
backend/things/tools/devicesim/integration_test.gobackend/things/test/testdata/README.mdshell/devicesim-oneclick-test.sh
4. 下行方法(云端 → 设备)
| Method | 说明 |
|---|---|
sessionCreated | 会话已创建,返回连接凭证 |
sessionClosed | 会话已关闭(响应 sessionClose 或会话超时) |
audioStarted | audioStart 请求已被接纳;按 msgToken 关联 |
respCreated | 新一轮 AI 响应开始 |
respSttDelta | STT 增量识别结果(流式) |
respSttDone | STT 最终识别结果 |
respTextDelta | LLM 增量文本输出(流式) |
respTextDone | LLM 完整文本输出 |
audioSpeechStarted | 回复音频开始发送(非设备播放回执) |
audioSpeechStopped | 回复音频已发送完(非设备播放回执) |
audioSpeechInterrupted | 旧回复被打断(主动取消或全双工自然插话) |
respAudioStart | 回复音频流开始(UDP 下推前通知) |
respAudioDone | 回复音频流结束 |
respMedia | 媒体内容(图片/视频等) |
respEmotion | 表情/情绪下发(LLM 回复含 emoji 时触发) |
error | 错误通知 |
4.1 sessionCreated / sessionClosed
sessionCreated 响应 sessionCreate,成功时 code=200,包含会话参数和 UDP 连接信息(如适用)。失败时可能仍以 sessionCreated 携带非 200 错误码;设备不得据此启动 UDP 或发送 audioStart。
sessionClosed 可在以下情况下下发:
- 设备主动发送
sessionClose,服务端释放会话后回应 - 会话达到当前链路配置的空闲或最长时长限制,服务端自动关闭
- 流式 ASR + LLM + TTS 方案中,用户明确表达“结束对话/再见”,服务端播报告别语后主动关闭
{"method": "sessionClosed", "code": 200}设备收到 sessionClosed 后,会话已完全释放,如需继续对话须重新发送 sessionCreate。
sessionCreated - 会话创建响应
响应示例(语音模式):
{
"method": "sessionCreated",
"msgToken": "msg-001",
"code": 200,
"data": {
"sessionId": "sess-abc123",
"transport": "udp",
"modalities": ["text", "audio"],
"audioParams": {
"format": "opus",
"sampleRate": 24000,
"channels": 1,
"frameDuration": 60
},
"udp": {
"server": "udp.example.invalid",
"port": 6789,
"key": "30313233343536373839616263646566",
"nonce": "01000000a1b2c3d46612345600000000"
},
"uploadUrl": "/api/v1/things/device/edge/upload-file",
"supportedModalities": ["text", "audio", "image"]
}
}响应示例(已选定全双工,节选):
{
"method": "sessionCreated",
"msgToken": "msg-duplex-001",
"code": 200,
"data": {
"sessionId": "sess-duplex-001",
"transport": "udp",
"modalities": ["text", "audio"],
"supportedInteractionModes": ["fullDuplex"],
"interactionMode": "fullDuplex",
"audioParams": {"format": "opus", "sampleRate": 16000, "channels": 1, "frameDuration": 20},
"udp": {"server": "udp.example.com", "port": 6789, "key": "<session-key-hex>", "nonce": "<session-nonce-hex>"}
}
}supportedInteractionModes 是当前平台链路与设备的公共模式;设备应以唯一的 interactionMode 执行本会话。示例中的 UDP 凭据是占位符,实际以回包为准。缺少 interactionMode 的旧服务端响应不能让新设备自行启用全双工;若设备仍支持原轮流模式,可按原模式运行。sessionCreated 不保证上游模型此刻已完成音频启动,启动失败仍可能通过后续错误消息报告。
响应示例(纯文字模式):
{
"method": "sessionCreated",
"msgToken": "msg-001",
"code": 200,
"data": {
"sessionId": "sess-xyz789",
"modalities": ["text"],
"uploadUrl": "/api/v1/things/device/edge/upload-file"
}
}data 字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| sessionId | string | 会话 ID,后续所有关联消息的标识 |
| transport | string | 实际使用的音频传输方式;全双工当前为 udp |
| modalities | []string | 本次会话支持的模态列表 |
| audioParams | object | 服务端实际使用的音频参数(可能与请求有所调整) |
| audioParams.format | string | 音频格式 |
| audioParams.sampleRate | int | 采样率(Hz) |
| audioParams.channels | int | 声道数 |
| audioParams.frameDuration | int | 帧时长(ms) |
| udp | object | UDP 连接信息(仅语音模式返回) |
| udp.server | string | UDP 服务器地址(IP 或域名) |
| udp.port | int | UDP 端口号 |
| udp.key | string | AES-CTR 加密密钥(hex 编码,16 字节,对应 32 个十六进制字符) |
| udp.nonce | string | UDP nonce 模板(hex 编码,16 字节,对应 32 个十六进制字符) |
| uploadUrl | string | 文件上传端点,用于多模态输入的文件预上传 |
| supportedModalities | []string | 服务端报告的模态列表;不等于当前会话已启用所有模态 |
| supportedInteractionModes | []string | 当前云端链路与设备共同支持的交互模式(新协商会话返回) |
| interactionMode | string | 平台为本会话选定的 halfDuplex 或 fullDuplex(新协商会话返回) |
udp.key和udp.nonce用于 AES-CTR 加密音频数据包,详见 UDP 音频通道。
4.2 respCreated - 响应开始
每次 AI 开始生成新一轮响应时下发:
{
"method": "respCreated",
"data": {
"respId": "resp_001"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID,用于关联后续的 delta/done 消息 |
4.3 respTextDelta / respTextDone - 流式文本输出
LLM 回复通过 respTextDelta 逐 token 流式下发,最后通过 respTextDone 发送完整文本:
{
"method": "respTextDelta",
"data": {
"respId": "resp_001",
"contentIndex": 0,
"delta": "你好"
}
}{
"method": "respTextDelta",
"data": {
"respId": "resp_001",
"contentIndex": 0,
"delta": ",我是"
}
}{
"method": "respTextDelta",
"data": {
"respId": "resp_001",
"contentIndex": 0,
"delta": "AI助手"
}
}{
"method": "respTextDone",
"data": {
"respId": "resp_001",
"contentIndex": 0,
"text": "你好,我是AI助手"
}
}respTextDelta 字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID(与 respCreated 对应) |
| contentIndex | int | 内容索引(多模态响应时区分不同内容块) |
| delta | string | 本次增量文本片段 |
respTextDone 字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID |
| contentIndex | int | 内容索引 |
| text | string | 完整的文本内容(所有 delta 的拼接结果) |
| contents | []ContentPart | 多模态内容(可选,LLM 同时输出图片等时使用) |
4.4 respSttDelta / respSttDone - 语音识别结果
{
"method": "respSttDelta",
"data": {
"respId": "resp_001",
"delta": "今天天"
}
}{
"method": "respSttDelta",
"data": {
"respId": "resp_001",
"delta": "今天天气"
}
}{
"method": "respSttDone",
"data": {
"respId": "resp_001",
"text": "今天天气怎么样"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID |
| delta | string | 增量识别文本(实时更新,可能不稳定) |
| text | string | 最终识别文本(稳定结果,仅 Done 消息有) |
4.5 audioSpeechStarted / Stopped / Interrupted - 回复音频状态
{
"method": "audioSpeechStarted"
}{
"method": "audioSpeechStopped"
}{
"method": "audioSpeechInterrupted"
}流式 ASR + LLM + TTS 方案中,这三个消息通常不带 data;全双工模式中可以携带 data.respId,用于标识对应回复:
{"method":"audioSpeechInterrupted","data":{"respId":"resp_001"}}audioSpeechStarted:回复音频开始从 UDP 下推,设备可开始播放audioSpeechStopped:该回复的音频已从服务端发送完;不证明设备扬声器已播完。全双工设备保持监听audioSpeechInterrupted:旧回复已失效;设备应清空待播队列并停止旧回复播放,全双工上行继续
与 4.6 节
respAudioStart/respAudioDone的区别:
audioSpeech*(4.5)respAudio*(4.6)语义层级 播放器状态通知 响应级别音频边界 携带 respId 流式 ASR + LLM + TTS 方案通常不带;全双工模式可带 是 用途 驱动设备播放器行为(开始播/停止播/立即打断) 追踪某次 AI 响应的音频流范围,与 respCreated对应流式 ASR + LLM + TTS 方案的典型消息发送顺序:
audioSpeechStarted ← 通知设备"开始播放" respAudioStart(respId) ← UDP 音频帧即将开始推送 [UDP 音频帧持续下推] respAudioDone(respId) ← UDP 最后一帧已推送 audioSpeechStopped ← 服务端已发送完该回复音频全双工模式实际先发送
respAudioStart(respId),再发送audioSpeechStarted(respId);结束时发送respAudioDone(respId),再发送audioSpeechStopped(respId)。这些都是云端发送事件,并非设备播放完成回执。设备通常用audioSpeech*驱动播放器状态,以respAudio*识别回复音频边界。
4.6 respAudioStart / respAudioDone - 回复音频流
{
"method": "respAudioStart",
"data": {
"respId": "resp_001"
}
}{
"method": "respAudioDone",
"data": {
"respId": "resp_001"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID |
respAudioStart 在音频帧下推前发送;全双工模式中它先于 audioSpeechStarted,respAudioDone 则先于 audioSpeechStopped。MQTT 控制事件与 UDP 音频包分属不同通道,不保证跨通道严格到达顺序;设备应对已建会话内的短暂先到音频做有界缓冲。
4.7 respMedia - 媒体内容
{
"method": "respMedia",
"data": {
"respId": "resp_001",
"mediaType": "image",
"url": "https://oss.example.com/ai-gen-image.png",
"mimeType": "image/png",
"desc": "根据您的描述生成的图片"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID |
| mediaType | string | 媒体类型:image、video、file |
| url | string | 媒体资源 URL |
| mimeType | string | MIME 类型(可选) |
| desc | string | 媒体描述(可选) |
4.8 error - 错误通知
{
"method": "error",
"data": {
"code": "invalidInput",
"message": "modalities 字段缺失"
}
}{
"method": "error",
"data": {
"code": "sessionExpired",
"message": "会话已过期,请重新创建",
"respId": "resp_001"
}
}{
"method": "error",
"data": {
"code": "modelTimeout",
"message": "LLM 响应超时"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| code | string | 错误码(见第 7 节) |
| message | string | 可读的错误描述 |
| respId | string | 关联的响应 ID(可选,错误发生在特定响应时携带) |
5. ContentPart 多模态内容类型
inputSend 的 contents 数组中每个元素为一个 ContentPart:
| type | 说明 | 关键字段 | 示例 |
|---|---|---|---|
text | 纯文本 | text | {"type":"text","text":"你好"} |
image_url | 图片 URL | imageUrl | {"type":"image_url","imageUrl":"https://..."} |
audio | 音频数据 | audioData, audioFmt | {"type":"audio","audioData":"base64...","audioFmt":"wav"} |
video_url | 视频 URL | videoUrl | {"type":"video_url","videoUrl":"https://..."} |
file_url | 文件 URL | fileUrl, fileMime | {"type":"file_url","fileUrl":"https://...","fileMime":"application/pdf"} |
完整字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | 内容类型(必填) |
| text | string | 文本内容(type=text 时使用) |
| imageUrl | string | 图片 URL(type=image_url 时使用) |
| audioData | string | 音频数据(Base64 编码,type=audio 时使用) |
| audioFmt | string | 音频格式(type=audio 时使用,如 wav、mp3) |
| videoUrl | string | 视频 URL(type=video_url 时使用) |
| fileUrl | string | 文件 URL(type=file_url 时使用) |
| fileMime | string | 文件 MIME 类型(type=file_url 时使用) |
| fileName | string | 文件名(可选,便于展示) |
文件预上传
设备端可通过以下接口上传文件(图片/视频/文档),获取 URL 后在 inputSend 中引用。
完整接口规范参见:设备文件上传
- URL:
/api/v1/things/device/edge/upload-file - Method:
POST - Content-Type:
multipart/form-data - 认证:Basic Auth,使用 MQTT 格式生成的账号密码
curl --location --request POST 'http://{host}/api/v1/things/device/edge/upload-file' \
--header 'Authorization: Basic {base64(mqtt_username:mqtt_password)}' \
--form 'file=@"/path/to/photo.jpg"'响应示例:
{
"code": 200,
"msg": "success",
"data": {
"filePath": "edge/{productID}/{deviceName}/250225/125609/photo.jpg",
"fileUri": "https://xxx.oss.com/edge/{productID}/{deviceName}/250225/125609/photo.jpg"
}
}上传成功后,将 data.fileUri 作为 imageUrl 或 fileUrl 填入 inputSend 的 contents 中。
禁止上传 html、php、jsp 等危险文件类型。MQTT 账号密码格式参见 MQTT 认证。
6. UDP 音频通道
当 sessionCreate 的 transport 为 udp(默认)时,语音数据通过 UDP 传输而非 MQTT,以降低延迟。
UDP 通道建立流程:
- 设备发送
sessionCreate(含transport: "udp") - 云端返回
sessionCreated,其中udp字段包含连接信息 - 设备建立 UDP Socket,记录
server、port、key、nonce - 设备发送
audioStart,开始上传加密音频帧 - 云端通过同一 UDP 通道下推回复音频帧
选定 fullDuplex 时,设备在下行播放期间继续从同一通道上传麦克风音频;UDP 包头、加密方式和 MQTT Topic 均不因交互模式改变。平台内部会将设备 Opus 音频适配为所选模型需要的格式,设备无需发送供应商私有事件。
UDP 音频包需使用 AES-CTR 加密,完整的包格式、加密规范和音频编码要求,请参见:
7. AI 物模型联动
在 AI 对话过程中,如果用户请求控制设备(如"打开客厅的灯"、"调低温度到 22 度"),AI 服务会通过物模型 Action 下行来执行设备控制,而不是直接操作硬件。
7.1 联动原理
用户语音/文字
↓
AI 解析意图 + 调用 MCP 工具
↓
AI 服务通过 MCP 执行设备工具
↓
dmsvr 通过物模型 action 通道下推到设备
↓
设备执行动作,通过 actionReply 上报结果
↓
AI 服务接收结果,生成最终回复文本及音频(如适用)全双工语音路径由当前实时模型发起授权范围内的工具调用,平台通过既有 MCP 执行器完成设备操作并把结果送回模型继续播报。设备仍只需实现自己的物模型 action/actionReply;上传音频与等待设备回执可并行。工具超时或连接中断时结果可能未知,不能将语音打断视为设备动作已撤销。
7.2 设备侧 Topic
AI 工具产生的设备控制最终使用现有物模型 action/actionReply 协议;全双工只改变语音收发方式,不改变设备动作回执:
| 方向 | Topic | 作用 |
|---|---|---|
| 云端 → 设备 | $thing/down/ai/{ProductID}/{DeviceName} | AI 回复文本与音频状态事件 |
| 云端 → 设备 | $thing/down/action/{ProductID}/{DeviceName} | 物模型 action 命令 |
| 设备 → 云端 | $thing/up/action/{ProductID}/{DeviceName} | 物模型 actionReply 结果 |
设备通过标准物模型 action Topic 接收命令,执行完毕后通过 $thing/up/action/{ProductID}/{DeviceName} 上报 actionReply,并原样带回下发的 msgToken。设备控制已执行后,打断语音播报不会自动撤销动作;失败或超时也不能播报为“执行成功”。
7.3 设备端处理建议
设备端实现时,需要同时订阅以下两个 Topic:
$thing/down/ai/{ProductID}/{DeviceName}— AI 协议消息(会话管理、文本、TTS 状态)$thing/down/action/{ProductID}/{DeviceName}— 物模型行为下行(AI 发起的设备控制)
物模型协议详情参见:物模型协议文档
8. 交互流程
8.1 纯文字对话
8.2 语音对话:流式 ASR + LLM + TTS 与 Realtime 端到端模型
8.2.1 流式 ASR + LLM + TTS 方案(halfDuplex)
audioStop 优雅停止路径:设备发送
audioStop后,服务端会等待 ASR 输出最终识别文本(最多 1.5 秒),再触发 LLM → TTS 完整回复流程。若等待超时仍无识别结果(如输入为静音),会话静默退出本轮响应,不发respSttDone/respTextDone。VAD 自动触发路径:服务端检测到语音静音边界时自动结束本轮输入,设备无需发送
audioStop。此后设备可持续向 UDP 通道发送静音帧(服务端会忽略),也可停止发包。VAD 触发后的 LLM → TTS 流程与手动audioStop完全相同。
8.2.2 Realtime 端到端语音模型(fullDuplex)与播放中插话
全双工设备在 sessionCreate 上报支持模式和设备 AEC,经 sessionCreated.data.interactionMode=fullDuplex 确认后,发送一次 audioStart。audioStarted 表示启动请求已被接纳;若后续模型连接或处理失败,仍须处理下行 error。设备在回复播放期间继续上传经过自身 AEC 处理的音频,不按每句话发送 audioStop;语音边界由所选实时模型处理,不要求设备本地 VAD 硬切句。
图中事件为典型示例,并非每轮都包含文本增量或固定的跨通道到达顺序。自然插话或带目标 respCancel 均可能使旧回复失效;收到 audioSpeechInterrupted 后,设备应停止旧回复播放并清空队列,不能停止上行。UDP 包本身不带 respId,弱网下旧包可能迟到;全双工设备应按会话序号丢弃重复或迟到包,并对 MQTT 控制与 UDP 音频先后到达做有界处理。软件模拟链路已验证这些行为,不代表硬件 AEC、扬声器回灌或现场听感已验收。
用户需要暂停收音时发送 audioStop,此时当前回复仍可继续下行;再发 audioStart 恢复同一会话的上行。结束对话使用 sessionClose,不能用 audioStop 代替。当前全双工只支持 UDP,不支持在同一语音会话中通过 inputSend 混入图片、视频或文件;此类内容应使用支持的非全双工会话。
8.3 多模态输入(文字+图片)
8.4 用户打断 AI 回复
上图展示主动取消;全双工播放中用户直接说话也可触发自然插话,无需先发送 respCancel。主动取消旧回复后,设备不得把已执行的 action 当作被撤销。
8.5 AI 联动设备控制
8.6 完整设备 AI 交互生命周期
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 产品创建 │────>│ Agent 绑定 │────>│ 设备绑定 │────>│ 自动创建 │
│ Product │ │ Product. │ │ DeviceInfo │ │ Clone │
│ │ │ defaultAgent│ │ .bind │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ AI 响应 │<────│ LLM/TTS │<────│ aicenter │<────│ MQTT │
│ MQTT 下行 │ │ 处理 │ │ RPC 调用 │ │ sessionCreate│
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘9. 错误码
| 错误码 | 说明 |
|---|---|
invalidInput | 无效输入(参数缺失、格式错误) |
modelOverloaded | 模型过载 |
modelTimeout | 模型超时 |
sessionExpired | 会话已过期(需重新 sessionCreate) |
quotaExceeded | 配额超限 |
internalError | 内部错误 |
unsupportedModality | 不支持的模态类型 |
错误处理建议:
| 错误码 | 设备端处理方式 |
|---|---|
invalidInput | 检查参数格式后重试 |
sessionExpired | 重新发送sessionCreate 创建新会话 |
modelOverloaded | 等待 1-3 秒后重试 |
modelTimeout | 等待 3-5 秒后重试,或通知用户 |
quotaExceeded | 停止请求,等待配额恢复 |
internalError | 等待后重试,多次失败则上报 |
10. Go 代码示例
以下示例来自 backend/things/test/mqtt_ai_realdev_test.go 的集成测试,展示完整的设备端 AI 交互流程。
10.1 MQTT 连接(设备认证)
import (
"gitee.com/unitedrhino/things/share/domain/deviceAuth"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// 使用 HMAC-SHA256 生成设备认证信息
clientID, userName, pwd := deviceAuth.GenSecretDeviceInfo(
deviceAuth.HmacSha256, productID, deviceName, deviceSecret)
opts := mqtt.NewClientOptions()
opts.AddBroker("tcp://your-server:1883")
opts.SetClientID(clientID)
opts.SetUsername(userName)
opts.SetPassword(pwd)
opts.SetAutoReconnect(false)
opts.SetConnectTimeout(10 * time.Second)
mc := mqtt.NewClient(opts)
token := mc.Connect()
token.WaitTimeout(10 * time.Second)
defer mc.Disconnect(500)10.2 订阅下行消息
type AiMsg struct {
MsgToken string `json:"msgToken"`
Method string `json:"method"`
Code int `json:"code,omitempty"`
Params any `json:"params,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
msgCh := make(chan AiMsg, 30)
downTopic := fmt.Sprintf("$thing/down/ai/%s/%s", productID, deviceName)
mc.Subscribe(downTopic, 1, func(_ mqtt.Client, m mqtt.Message) {
var msg AiMsg
json.Unmarshal(m.Payload(), &msg)
msgCh <- msg
})10.3 文字对话完整流程
upTopic := fmt.Sprintf("$thing/up/ai/%s/%s", productID, deviceName)
// 发布上行消息的辅助函数
publish := func(method string, params any) {
msg := AiMsg{
MsgToken: fmt.Sprintf("msg-%d", time.Now().UnixNano()),
Method: method,
Params: params,
}
payload, _ := json.Marshal(msg)
mc.Publish(upTopic, 1, false, payload).Wait()
}
// 等待指定 method 的下行消息(跳过中间消息如 respTextDelta)
waitMethod := func(ctx context.Context, method string) (AiMsg, error) {
for {
select {
case msg := <-msgCh:
if msg.Method == method {
return msg, nil
}
case <-ctx.Done():
return AiMsg{}, fmt.Errorf("等待 %s 超时", method)
}
}
}
// Step 1: 创建会话(纯文字模式)
publish("sessionCreate", map[string]any{
"modalities": []string{"text"},
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
msg, _ := waitMethod(ctx, "sessionCreated")
sessionID := msg.Data["sessionId"].(string)
fmt.Println("会话已创建:", sessionID)
// Step 2: 发送文字输入
time.Sleep(500 * time.Millisecond)
publish("inputSend", map[string]any{
"contents": []map[string]any{
{"type": "text", "text": "物联网是什么?"},
},
})
// Step 3: 等待 AI 回复(respTextDone 包含完整文本)
ctx2, cancel2 := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel2()
resp, _ := waitMethod(ctx2, "respTextDone")
aiText := resp.Data["text"].(string)
fmt.Println("AI 回复:", aiText)
// 示例输出:物联网(Internet of Things,简称IoT),是把世间万物通过互联网连接起来...
// Step 4: 关闭会话
publish("sessionClose", nil)
ctx3, cancel3 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel3()
waitMethod(ctx3, "sessionClosed")10.4 流式 ASR + LLM + TTS 语音对话(UDP 通道)
// Step 1: 创建含音频的会话
publish("sessionCreate", map[string]any{
"modalities": []string{"text", "audio"},
"transport": "udp",
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
msg, _ := waitMethod(ctx, "sessionCreated")
// Step 2: 提取 UDP 参数
udpInfo := msg.Data["udp"].(map[string]any)
udpServer := udpInfo["server"].(string) // 服务端地址(由服务端配置决定)
udpPort := int(udpInfo["port"].(float64)) // UDP 端口,默认 8884
udpKey := udpInfo["key"].(string) // hex AES 密钥
udpNonce := udpInfo["nonce"].(string) // hex nonce 模板
fmt.Printf("UDP 连接信息: %s:%d\n", udpServer, udpPort)
// Step 3: 建立 UDP 连接并发送音频帧(使用 udp_audio.go 中的 UdpAudioClient)
// 参见 backend/things/test/udp_audio.go
udpClient, _ := NewTestUDPClient(udpServer, udpPort, udpKey, udpNonce)
defer udpClient.Close()
// 通知服务端开始接收
publish("audioStart", nil)
// 发送 Opus 音频帧(此处以静音帧为例)
udpClient.SendSilenceFrames(20, 60) // 20帧 × 60ms = 1.2秒
publish("audioStop", nil)
// Step 4: 发送文字输入或等待 VAD 触发
publish("inputSend", map[string]any{
"contents": []map[string]any{
{"type": "text", "text": "测试语音通道,请确认收到"},
},
})
ctx2, cancel2 := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel2()
resp, _ := waitMethod(ctx2, "respTextDone")
fmt.Println("AI 回复:", resp.Data["text"])完整可运行代码:
backend/things/test/mqtt_ai_realdev_test.gocd backend/things TEST_BASE_URL=http://your-server:7777 TEST_MQTT_BROKER=tcp://your-server:1883 \ go test -v -run "TestMQTTRealDevTextChat|TestMQTTRealDevVoiceChat" ./test/... -timeout 180s
10.5 前置条件:产品/设备创建与 Agent 绑定
Go 端调用 HTTP API 创建产品、设备并绑定 Agent 的完整流程:
// 1. 创建 Agent(返回 agentID)
agentResp := httpPost("/api/v1/ai/agent/info/create", map[string]any{
"name": "我的设备智能体",
"capabilities": []string{"text", "voice"},
})
agentID := agentResp["id"].(float64)
// 2. 创建产品(绑定 defaultAgentID)
// bindLevel 说明:
// 1 = 强绑定(需要设备在线且未被其他用户绑定)
// 2 = 中绑定(无需设备在线,但不允许重复绑定)
// 3 = 弱绑定(无需设备在线,允许重复绑定,适合家庭/多用户场景)
httpPost("/api/v1/things/product/info/create", map[string]any{
"productID": "my_product_001",
"productName": "智能设备",
"deviceType": 1, // 直连设备
"authMode": 1, // 密钥认证
"defaultAgentId": agentID,
"bindLevel": 3, // 弱绑定
})
// 3. 创建设备
httpPost("/api/v1/things/device/info/create", map[string]any{
"productID": "my_product_001",
"deviceName": "device_001",
"projectID": "2",
})
// 4. 绑定设备(触发自动创建 Clone,设备获得专属 AI 记忆)
httpPost("/api/v1/things/device/info/bind", map[string]any{
"device": map[string]any{
"productID": "my_product_001",
"deviceName": "device_001",
},
})
// 5. 获取设备 Secret(用于 MQTT 认证)
infoResp := httpPost("/api/v1/things/device/info/get-one", map[string]any{
"productID": "my_product_001",
"deviceName": "device_001",
})
deviceSecret := infoResp["secret"].(string)
cloneID := infoResp["cloneID"].(string) // cloneID 以字符串形式返回(避免 JS 精度丢失)
// cloneID 非空且不为 "0" 表示 Clone 已自动创建,设备拥有独立的 AI 记忆11. 表情/情绪下发
版本状态:已上线。表情/情绪下发能力默认开启,当前不引入产品级配置开关,也不在
sessionCreate中进行能力协商。
当 LLM 回复内容包含 emoji 白名单内的表情时,云端会在第一个 respTextDelta 之前向设备下发 respEmotion,设备端可据此切换屏幕表情动画。
11.1 表情决定与下发流程
- Prompt 注入:服务端在 Agent 的系统提示词末尾追加 emoji 使用规则,要求 LLM 在需要表达情绪时,在回复开头使用一个白名单内的 emoji;
- LLM 输出:LLM 在生成回复时,根据提示词在回复开头自然输出一个 emoji(也可能不输出);
- 服务端扫描:服务端在收到本轮第一个非空且有效的文本增量时,在文本清理(去除 Markdown、控制字符等)之前扫描原始文本,从左到右查找第一个命中白名单的 emoji;
- 映射与下发:命中后,将 emoji 映射为
emotion名称,立即通过respEmotion下发给设备; - TTS 联动:若当前 TTS provider 支持
emotion参数(如豆包 TTS),服务端会同步调整当前回复的 TTSemotion,使语音情绪与屏幕表情保持一致。
11.2 触发规则
- 每轮 AI 回复最多触发一次
respEmotion; - 只在识别到有效 emoji 时下发,未命中时不下发(联犀不采用默认 happy 方案);
- 扫描在文本清理前进行,避免 emoji 被误删。
11.3 emoji 白名单与 emotion 映射
| emoji | emotion |
|---|---|
| 😶 | neutral |
| 🙂 | happy |
| 😆 | laughing |
| 😂 | funny |
| 😔 | sad |
| 😠 | angry |
| 😭 | crying |
| 😍 | loving |
| 😳 | embarrassed |
| 😲 | surprised |
| 😱 | shocked |
| 🤔 | thinking |
| 😉 | winking |
| 😎 | cool |
| 😌 | relaxed |
| 🤤 | delicious |
| 😘 | kissy |
| 😏 | confident |
| 😴 | sleepy |
| 😜 | silly |
| 🙄 | confused |
设备端只应处理白名单内的 emoji;收到白名单外 emoji 时应忽略或按默认表情处理。
11.4 消息格式
{
"method": "respEmotion",
"data": {
"respId": "resp_001",
"emoji": "😂",
"emotion": "funny"
}
}字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| respId | string | 响应 ID,与本轮respCreated 对应 |
| emoji | string | 原始 emoji 字符 |
| emotion | string | 映射后的情绪名称,设备端可据此播放预设表情动画 |
11.5 下发时序
respCreated(respId=resp_001)
respEmotion(respId=resp_001, emoji="😂", emotion="funny")
respTextDelta(respId=resp_001, delta="😂 哈哈")
respTextDelta(respId=resp_001, delta=",这个太好笑了")
respTextDone(respId=resp_001, text="😂 哈哈,这个太好笑了")
respAudioStart(respId=resp_001)
[UDP 音频帧]
respAudioDone(respId=resp_001)11.6 TTS 情绪联动
若当前 TTS provider 支持 emotion 参数(如豆包 TTS),服务端在识别到有效 emoji 时会同步调整 TTS emotion,使语音情绪与屏幕表情保持一致。映射关系如下:
| 屏幕 emotion | 豆包 TTS emotion |
|---|---|
| happy | happy |
| sad | sad |
| angry | angry |
| surprised | excited |
| neutral | neutral |
| 其他 | happy |
11.7 设备端处理建议
- 订阅
$thing/down/ai/{ProductID}/{DeviceName}并解析method=respEmotion; - 收到
respEmotion后立即切换表情动画,不要等待respTextDelta; respEmotion只影响表情状态,不影响文本和音频的正常处理;- 未收到
respEmotion时,设备端如需默认表情请本地兜底(如保持上一轮表情或回到 neutral)。
12. 版本说明
联犀 AI 交互协议使用小驼峰命名的方法(sessionCreate、inputSend、audioStart 等),通过独立的 ai 类型 Topic 传输,与物模型的 property/event/action 通道完全分离。
更新日志
2026/10/17 03:27
查看所有更新日志
d04e1-docs(AI与音视频): 按公共主线与场景分支整合接入手册于d94c1-Merge #21 into master from docs/xiaozhi-ai-feature于7e927-文档站全面重构:目录重排、首页重写、案例独立成站、应用市场下线于3bc7e-docs: 同步设备接入文档更新与万物互联系列博客于f7d9b-docs(AI交互协议): 补充表情/情绪下发章节于66485-docs: 更新设备接入文档中的MQTT地址与后台地址于4a596-删除定价页项目数量限制显示于4ef2c-docs(ai交互): 同步语音退出意图会话结束语义于09d68-docs(ai): sync interaction and udp protocol notes于2e886-docs: refresh ai interaction notes于e70e5-fix(ai-interaction): 删除 agentId 参数、响应码 0→200、kind 字段改小驼峰于8d78e-docs(ai): 补充 AI 交互协议中不明确的字段和行为说明于a7760-docs(ai): 补充完善 AI 交互协议文档于69dce-docs: 新增设备接入-联犀协议-AI交互文档于
