10-设备控制指令-speak-gesture-light-wake-sleep
黒漂技术佬 · AI 伙伴(AI-Partner)系列 · 提醒设备与 MQTT 联动篇
一、让机器人"动起来"的最后一公里
前面几篇把上行讲透了:设备报状态、报心跳、报跌倒,云端收得明明白白。但陪伴机器人的灵魂在下行——用户说"给我讲个笑话",机器人得真的开口说;到点了该提醒吃药,它得挥手加播报。这一篇讲 AI 伙伴(AI-Partner)的指令下行全链路:从大模型决定调用工具,到指令飞进 MQTT 主题,到端侧执行。
二、入口:DeviceTool 的两个 @Tool
下行指令的入口不在 Controller(虽然也有 /api/devices/control 接口),更值得注意的是 Agent 工具。用户跟机器人聊天,大模型判断"这句话需要控制设备",就会调用 DeviceTool 里的方法:
// 项目源码:agent/tools/DeviceTool.java
@Component
@RequiredArgsConstructor
public class DeviceTool {
private final DeviceService deviceService;
@Tool("查询该用户绑定的陪伴机器人设备及其在线状态、电量。返回设备列表")
public String getDeviceStatus(@ToolMemoryId Long userId) {
return deviceService.listStatusText(userId);
}
@Tool("向指定陪伴机器人设备下发动作:speak(播报文本)、gesture(动作如 wave/nod/happy)、light(灯光开/关)、wake、sleep。返回下发结果")
public String controlDevice(String deviceCode,
String action,
String param,
@ToolMemoryId Long userId) {
return deviceService.control(userId, deviceCode, action, param);
}
}
注意 controlDevice 的 @Tool 描述原文:它把五种 action 的取值和含义直接写给了大模型——
| action | 含义 | param 示例 |
|---|---|---|
speak | 播报文本 | "该吃药啦" |
gesture | 肢体动作 | wave / nod / happy |
light | 灯光开关 | "on" / "off" |
wake | 唤醒 | 无 |
sleep | 休眠 | 无 |
这段描述就是大模型的"说明书":它决定调 controlDevice 时,会照着描述填 action=speak、param=讲个笑话的内容。工具描述写得越具体,模型填参数越靠谱——这不是注释,是提示词工程的一部分。@ToolMemoryId Long userId 保证工具内拿到的永远是当前对话用户的 ID,多用户隔离不靠模型自觉,靠框架注入。
三、DeviceService.control:校验、组装、发布
工具方法一行转发到 DeviceService.control,这才是干活的:
// 项目源码:service/DeviceService.java
/** 下发设备控制指令(MQTT server/xxx/cmd) */
public String control(Long userId, String deviceCode, String action, String param) {
Device device = deviceRepository.findByDeviceCode(deviceCode)
.orElseThrow(() -> new BusinessException("设备不存在:" + deviceCode));
if (device.getUserId() != null && !device.getUserId().equals(userId)) {
throw new BusinessException("无权控制该设备");
}
Map<String, Object> cmd = new HashMap<>();
cmd.put("action", action);
cmd.put("param", param == null ? "" : param);
cmd.put("ts", System.currentTimeMillis());
mqttClientManager.publish(TopicConstants.commandTopic(deviceCode), cmd);
return "已下发动作 " + action + " 到设备 " + deviceCode
+ (param == null || param.isBlank() ? "" : "(" + param + ")");
}
三步拆解:
- 校验:设备必须存在;如果设备已绑定用户,则当前
userId必须是绑定的那个——防止 A 用户控制 B 用户的机器人。注意userId == null(未绑定/待认领设备)时直接放行,这是当前实现的一个宽松点。 - 组装:payload 三件套
{action, param, ts},param为 null 时填空串(避免端侧判空),ts是毫秒时间戳(端侧可以做时效校验、去重排序)。 - 发布:目标主题由
TopicConstants.commandTopic(deviceCode)算出,即server/{deviceCode}/cmd——上一篇讲过的精确下行主题,只有这台设备自己订阅。
顺带一提:control 方法没有 @Transactional——它是纯读校验加 MQTT 发布,不写库,不需要事务。给这种方法加事务注解反而是噪音。
四、同一主题,两种报文
有意思的来了。发到 server/{code}/cmd 这个主题上的报文,其实有两种形态。除了上面的控制指令,提醒调度器(ReminderScheduler)到期触发时也往同一个主题发:
// 形态一:控制指令(DeviceService.control 下发)
{ "action": "speak", "param": "该吃药啦", "ts": 1760000000000 }
// 形态二:提醒播报(ReminderScheduler 下发)
{ "kind": "reminder", "title": "吃降压药", "content": "饭后服用", "type": "medication", "ts": 1760000000000 }
区别一目了然:控制指令用 action 字段开头,提醒报文用 kind 字段开头。端侧固件收到消息后,必须先判断是 kind 型还是 action 型,再走不同处理分支。这个设计能跑,但不算优雅——同一个主题承载两种协议,端侧解析逻辑被迫写 if-else。更规整的做法是统一信封:{ "kind": "cmd", "action": ... } 与 { "kind": "reminder", ... },或干脆拆成 server/{code}/cmd 与 server/{code}/reminder 两个主题。属于"能跑但建议重构"的债。
五、完整链路:模型决定 → 组装 → 端侧执行
把从用户一句话到机器人动起来串成全景图:
用户:"宝贝,挥挥手"
│
▼
ChatService.chat(userId, message)
│ 系统消息 = 人设 + 记忆 + 回复要求(PersonaProvider)
▼
大模型推理(LangChain4j Agent)
│ 看到工具描述:"向指定陪伴机器人设备下发动作:speak/gesture/light/wake/sleep"
│ 决定:调用 controlDevice,填参 deviceCode=?, action="gesture", param="wave"
▼
DeviceTool.controlDevice(deviceCode, action, param, userId)
│ @ToolMemoryId 注入当前用户
▼
DeviceService.control
│ ① 查设备 ② 校验归属 ③ 组装 {action:"gesture", param:"wave", ts}
▼
MqttClientManager.publish("server/A001/cmd", cmd)
│ JSON 序列化 → MQTT 发布(QoS1)
▼
Broker 转发 → 设备 A001(它只订阅自己的 server/A001/cmd)
│
▼
端侧解析 → 执行:舵机挥手 / TTS 播报 / 灯光亮起
链路上每一环都有明确的职责:模型管"要不要控制、控制什么",Service 管"能不能控制、怎么封装",MQTT 管"送到哪",端侧管"怎么执行"。任何一环失败,链条就断——所以下一步的可靠性问题必须摆上台面。
六、action 无枚举:隐患与收口方案
DeviceTool 描述里写了五种 action,但 DeviceService.control 没有任何校验——action 是任意字符串透传。模型填了 "sing"、"jump" 甚至 "rm_rf",都会原样组装进 payload 发给设备。隐患有三:
- 拼写漂移:模型可能填
"Speak"、"SPEAK",端侧不认识就静默忽略,用户体验是"机器人不理我"。 - 越权语义:
param同样自由透传,若端侧某 action 会解释 param 为文件路径或音量数值,恶意/异常值没有服务端拦截层。 - 端云协议失控:没有"合法 action 清单"这个 single source of truth,端侧实现与模型描述极易失配。
收口方案(改动很小,收益很大):
// 示意:action 白名单收口(非项目源码)
private static final Set<String> ALLOWED_ACTIONS =
Set.of("speak", "gesture", "light", "wake", "sleep");
if (!ALLOWED_ACTIONS.contains(action)) {
throw new BusinessException("不支持的动作:" + action);
}
再进一步可以按 action 校验 param 形态(speak 必须有非空文本、light 只认 on/off),并让 DeviceTool 的描述与这份白名单保持同步——描述即协议,协议即校验。
七、指令幂等:重发同一条会怎样
MQTT QoS 1 意味着"至少一次",重复是常态而非异常。设想:网络抖动,Paho 重发了 {action:"speak", param:"该吃药啦", ts:1760000000000}——机器人会播报两遍。对 speak 这种幂等友好的动作(说两遍顶多重听一遍),问题不大;但对 "light" 开关这种非幂等动作,重复投递可能导致"开-关-开"抖动。
这就是 payload 里 ts 字段真正该发挥的作用——端侧做去重:
// 示意:端侧基于 ts 的指令去重(非项目源码)
private long lastTs = 0;
void onCommand(String json) {
long ts = parseTs(json);
if (ts <= lastTs) { return; } // 老指令/重复指令,丢弃
lastTs = ts;
execute(json);
}
配套的还有回执设计——当前实现完全没有回执:云端发布完就返回"已下发动作 speak 到设备 A001",注意这句话的真实含义只是"我把消息丢给了 Broker",设备收没收到、执行成功与否,云端一概不知。改进方向是端侧收到指令后向上行主题发一条 {event:"cmd_ack", action:"speak", ts:...},云端凭回执更新指令状态(sent/acked/failed)。有了回执,"未连接时静默丢弃"和"设备离线假装成功"这两个问题才有被观测到的可能。
八、设备不在线时的降级策略
最后直面那个组合问题:用户让机器人说句话,但机器人恰好离线(断网/没电/心跳超时误判),会发生什么?
当前实现的真实行为链:control 校验的是数据库状态位,不看真实连接(也看不了)→ publish 时如果云端到 Broker 的连接断着,log.warn 丢弃,接口仍返回成功文案;如果连接是好的,消息发到 Broker,但设备不在线,QoS1 消息在 cleanSession=true 下不会被保留,设备重连后也收不到。两种情况用户感知都一样:机器人没反应,但系统认为一切正常。
分层降级建议(按产品优先级排序):
- 下发前判在线:校验环节加"心跳时间差"判定(上一篇的心跳巡检思路),离线直接返回"机器人目前不在线",让用户知情——这已经比静默失败强得多。
- 时效性评估:提醒类消息离线丢失后,重连时判断"已过期"就不再补发,宁可错过不可错乱;重要提醒(如吃药)改走多通道兜底(短信/家属 App 推送)。
- 回执+重试:指令未收到回执时按指数退避重试 N 次,仍失败则告警工单。
- 队列缓冲:对"唤醒后可执行"的指令(如 sleep 期间的留言),Broker 侧或云端保留到设备上线。
九、小结
| 要点 | 现状 | 建议 |
|---|---|---|
| 工具入口 | controlDevice 五种 action 写进 @Tool 描述 | 描述即协议,保持与白名单同步 |
| 校验 | 设备存在 + 归属校验,action 透传 | action 白名单 + param 形态校验 |
| 报文 | {action,param,ts} 与 {kind:"reminder",...} 两种形态共用一个主题 | 统一信封或拆主题 |
| 幂等 | QoS1 可能重复,端侧未去重 | 端侧按 ts 去重 |
| 回执 | 无 | 增加 cmd_ack 上行 |
| 离线降级 | 静默丢失,用户无感知 | 前置在线判定 + 多通道兜底 |
指令下行是陪伴机器人从"能聊"到"能动"的关键一步。协议收敛、幂等去重、在线降级这三件事做了,链路才算从 Demo 走向可靠。
合规提醒:本篇的指令链路直接作用于物理实体(语音、灯光、动作),面向老人与儿童时需格外谨慎——
speak的 param 是大模型生成的自由文本,端侧应保留内容安全过滤;light、gesture等动作不应包含惊吓性内容。儿童使用场景下,指令下发应有监护人可查的日志留痕,遵守未成年人个人信息保护与内容安全相关要求。

2033

被折叠的 条评论
为什么被折叠?



