PUBACK 已到,灯还没亮:Agent 设备指令何时算完成?

用户说"打开书房灯"。Agent 调用了工具,MQTT 客户端收到了 PUBACK,于是播报"已打开"。灯却没有亮。连接正常、调用无异常,为什么结果仍可能错?关键在于:协议收到消息、设备报告执行、设备状态符合预期,是三种不同的证据。 如果把第一种当作第三种,Agent 会比设备更早"成功"。
本文讨论一个具体问题:一次设备控制命令,何时可以向用户报告"完成"?以一盏支持开关状态上报的灯为例,给出一份可落地的命令证据链。例子是设计示意,不来自作者真实设备;没有实测时延、成功率或厂商实现承诺。

一个标准脚注,切断了"ACK 就是成功"
OASIS MQTT 5.0 标准 §4.3.2给出 QoS 1 的 PUBLISH/PUBACK 流程。流程图脚注指出,接收方发送 PUBACK 前不必完成应用消息的后续交付 。但第一步要看 PUBACK 的 Reason Code:0x00 表示当前接收方接受消息;0x10 表示接受消息却没有匹配的订阅者;0x80 及以上表示该次发布被拒绝或出错。若 Agent 工具将命令发给 Broker,即便拿到 0x00,也只能证明 Broker 接受了这一跳,不能证明订阅设备收到或完成开灯。0x10 更不能进入"设备正在执行"的叙事;错误码应向用户报告发送失败。即便设备客户端后来收到了命令,也还存在权限拒绝、参数不支持、继电器失败、离线重连后状态过期等业务问题。
MQTT 的 QoS 2 也不要被误读成"设备业务 exactly once"。标准保证的是 MQTT 消息传递流程中的相应语义;它不替应用定义"开灯"是否只执行一次、是否成功或是否被后续指令覆盖。PUBACK 的 Packet Identifier 是协议往返标识,不能直接当成长期业务命令 ID。把协议重试和业务重试混为一谈,会让一次超时变成两次操作。
因此,工具调用返回值至少要区分三类:publish_rejected、publish_accepted 与 device_completed。其中 publish_accepted 还要保留"没有匹配订阅者"的诊断结果;device_completed 必须由设备或能代表设备执行结果的业务系统另行给出。只有当前跳接受后,Agent 才适合说"当前跳已接受,设备结果待确认";不能说"灯已打开"。

期望状态与设备报告状态之间还有一道门
AWS IoT Device Shadow 官方文档把 desired 定义为应用请求的状态,reported 定义为设备报告的状态,两者差异形成 delta。该产品文档提供了一个直观例子:把 desired.power 写成 on 只说明平台保存了目标,reported.power 仍可能是 off。它没有替设备执行动作。
AWS IoT Shadow 数据流文档进一步描述:Shadow 接受更新后发出 delta;设备收到目标状态、执行变化,再将当前状态写回 reported。这说明"平台接受目标"和"设备报告当前值"是两个时刻。AWS 是产品实例,本文的设计原则不要求读者必须采用 AWS Shadow,也不把其主题名或版本规则误写成所有物模型平台的共同规范。
reported.power=on 仍有边界:它是设备或网关报告的状态,不一定是独立传感器证实的物理光照。对于普通家居灯,可信设备回报通常足够支撑"设备报告已打开";对于门锁、工业阀门等高后果动作,要按风险加独立传感、二次确认或人工检查。不能给所有设备统一一个"有 reported 就保证物理成功"的承诺。

同样是 on,先分清谁说的、什么时候说的
设想工具写入目标状态后,接口返回一个对象,其中同时包含 desired 与旧 reported。Agent 如果只在这个对象里搜索 on,就可能误把自己刚写进去的目标当成设备事实。解析层应该明确读取哪个状态域、来自哪个设备、哪次版本;给模型的摘要也标出"目标已保存"和"设备最新报告"各自的来源。这个场景是设计反例,没有来自真实账户的响应样本。
另一种情况是设备此前已经报告 on,本次用户又说开灯。若产品只要求灯处于打开状态,可以先基于可信且足够新鲜的状态解释"设备当前报告已经打开",未必需要再次下发命令。但若当前报告已很旧,就不能为了节省一次操作而把旧状态当作当前事实。何种时效可接受由产品需求与设备上报方式决定,文章不提供适用于所有设备的固定秒数。
版本只帮助排序,还需理解状态覆盖范围。某次整体文档版本更新可能来自无关属性,不能因此证明 power 在命令后被重新采样。记录属性级时间或命令相关回报更有解释力;平台若只提供整体版本,明确这层限制。对未提供命令关联的设备,状态达成与命令执行归因应继续分开。
还要防止多来源冲突。网关缓存报告 on,设备后续报告 off,应按来源可信度、版本与采样时间判断,不按哪条消息最后进入模型上下文判断。如果顺序证据不足,就保留两条事实并说明未能确认;不要把它们拼成一句听起来肯定的回答。用户需要的是可追溯的设备结果,不是模型选择一个最顺眼的字段。
让一条命令有自己的身份和终点
以下字段是作者建议的应用层契约示意,不是 MQTT 或 AWS 的原生报文:
json
{
"command_id": "cmd-demo-001",
"device_id": "lamp-study-demo",
"property": "power",
"target": "on",
"report_version_before": 40,
"expires_at": "2026-09-26T10:00:05Z"
}
发送前先检查操作者是否有权控制该设备、物模型是否允许 power=on、设备是否支持该能力。command_id 在应用层保持稳定;对同一次意图重试时先查询它的已知结果,不生成新 ID 盲发。非幂等动作尤其不能把"没有收到响应"当作"从未执行"。expires_at 控制旧命令的业务有效期,但设备/网关必须真的执行这一检查,字段本身不会阻止过期命令。
一条可审计时间线要记录命令受理、协议返回、设备执行与状态回报;协议返回还须区分成功、无订阅者和失败:
| 事件 | 能证实什么 | 不能证实什么 | 可对用户说什么 |
|---|---|---|---|
| 命令被工具受理 | 参数与权限已通过,系统准备发送 | Broker 或设备已接收 | "正在发送" |
MQTT PUBACK 为 0x00 |
当前接收方接受该发布 | 订阅设备已收到或执行 | "当前跳已接受,设备结果待确认" |
MQTT PUBACK 为 0x10 或错误码 |
0x10 是已接受但无匹配订阅者;错误码为拒绝/失败 |
设备执行中 | "没有匹配订阅者"或"发送失败",按真实原因区分 |
| 设备执行回报 | 设备/网关声明这条 command_id 的处理结果 |
最终状态仍保持目标值 | "设备报告执行成功,正在核对状态" |
| 新鲜状态回报符合目标 | 同一设备在命令后报告目标属性值 | 独立物理事实或永久保持 | "设备报告已打开" |
这里把"执行回报"和"状态回报"分开,是为了处理两种常见分歧:设备说命令成功,但回报仍是旧值;或者状态已是目标值,但这次命令其实没有执行(例如另一位用户先打开了灯)。如果产品只关心最终状态,后者也可能满足用户意图,但不能据此把这次命令的执行归功于 Agent。若动作带有副作用,必须按命令 ID 查执行结果。
AWS IoT Commands 的执行状态文档把设备报告的 IN_PROGRESS 与 SUCCEEDED 分开,说明厂商产品也显式保留"进行中"和"完成"的差别。但 SUCCEEDED 仍是设备提交的执行状态;是否需要再读属性或传感器,由业务风险决定。
状态回报还要做新鲜度与关联 判断。至少核对设备 ID、属性、回报时间或递增版本是否晚于命令前基线;若平台支持命令关联,应核对 command_id。仅看到缓存中的 power=on,不能证明当前命令完成;仅比较本地和服务端两个不同时钟的时间戳,也可能误判先后。可优先用同一状态源的版本序号判断先后,并记录状态源与采样时刻。AWS Shadow 的文档提供 version 和属性 timestamp,具体平台如何提供顺序证据须以自己的接口为准。

超时之后,最诚实的状态是"未知"
给 Agent 一个明确的状态机,比返回一个布尔 success 更稳妥:
text
validated → sent → accepted → executing → reported → state_confirmed
↘ rejected / failed / timed_out / unknown
这不是规定每个设备必须有全部六个回调,而是要求系统只报告自己有证据的层级。状态回报可能比执行回报先到;事件乱序时按命令 ID、设备状态版本与当前事实合并,不能仅按消息到达顺序回退。执行成功后又被另一个命令关闭,也不能永久保留"灯已打开"的用户界面。
超时并不等于执行失败:可能设备已动作但回报丢了。此时应先查询命令状态和设备当前状态;查不到就告诉用户"未能确认",并保留 command_id 供后续核对。重试前先判断操作是否幂等、原命令是否仍有效。对于"开灯"这类目标状态设置,重复写 on 通常比"切换灯状态"安全,但仍要处理离线积压与新旧指令竞争。对"开门一次""扣费一次"这样的动作,盲重发会产生更严重的副作用。
最小验收矩阵可覆盖五种场景:
| 场景 | 正确的 Agent 反馈 | 要留的证据 |
|---|---|---|
Broker 0x00 接受,设备离线 |
已发送,尚未确认 | PUBACK Reason Code、命令 ID、设备在线状态 |
Broker 返回 0x10 或失败码 |
未进入设备执行确认;报告无订阅者或发送失败 | PUBACK Reason Code、订阅关系诊断 |
| 设备回报失败 | 执行失败及原因 | 设备执行状态、错误码 |
| 设备回报成功,属性未更新 | 执行已报告,状态待核实 | 回报、旧/新属性值和版本 |
| 超时后状态变为目标值 | 当前设备报告已达到目标;本次执行归因仍待核实 | 超时记录、后续状态版本、命令查询结果 |
下面把这些分歧收成一个可运行的判定例:同一个 on,要分别回答"这条命令是什么结果"与"设备当前报告是什么状态"。
用一个单文件核对完成语义
把下面代码保存为 device_completion.py,用 Python 3.10 或更新版本运行 python3 device_completion.py。它只使用标准库,不需要账户、Broker、设备、网络或安装依赖;输入都是代码内构造的应用记录。运行时没有发送命令、等待真机、调用 AWS,也没有采集物理光照。这里验证的是归一化记录进入对话之前的判定逻辑。
示例保留两列结果,前列描述这条命令,后列描述可信状态源给出的 power。这样,错误命令的回报即使带着新鲜 on,也只能更新当前状态;它不能把本次命令变成成功。反过来,协议拒绝后查到 on 可以满足最终状态查询,却仍不能抹掉发送失败。对状态设置而言,这两种回答可以同时成立;带有一次性副作用的动作必须另查执行身份。
Command 和 Report 都是作者定义的简化数据结构。power_version 表示同一可信来源对 power 的采样版本,不是 AWS Shadow 整体文档 version;接真实平台时不能把无关属性引起的整体版本增加直接塞进此字段。power_sampled_at、sent_at、now 采用同一条本地逻辑时间轴;100、102 和新鲜度窗口 3 都是构造值,不是设备时延实测,也不是建议统一设置为三秒。wait_until 只控制本地等待,不能替设备落实前文 expires_at 的过期拒绝。
代码要求属性版本超过发送前基线、采样不早于发送且不晚于观察、样本仍在本例新鲜度窗口内。这个小例只支持一个可信的 power 来源;身份验证、授权、载荷解析、版本复位和多来源冲突由接入层处理。没有它们就不能把外部未经校验的数据当作这里的 Report。对真实设备"执行成功"的声明也需要验证来源,本地字段同名并不会赋予真实性。
python
"""Constructed application records only; no MQTT, AWS or physical device I/O."""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Command:
command_id: str = "cmd-demo-001"
device_id: str = "lamp-study-demo"
target: str = "on"
power_version_before: int = 40
sent_at: int = 100
wait_until: int = 105
@dataclass(frozen=True)
class Report:
device_id: str = "lamp-study-demo"
command_id: str | None = "cmd-demo-001"
execution: str = "SUCCEEDED"
power: str = "on"
power_version: int = 41
power_sampled_at: int = 102
def assess(cmd, reason, report, now, max_age=3):
# shortcut: trusted normalized records, one power source and one logical clock;
# add schema/auth/source checks before accepting external records.
if now < cmd.sent_at or max_age < 0:
raise ValueError("invalid observation time or freshness window")
same_device = report is not None and report.device_id == cmd.device_id
fresh = (same_device
and report.power_version > cmd.power_version_before
and cmd.sent_at <= report.power_sampled_at <= now
and now - report.power_sampled_at <= max_age)
state = report.power if fresh else "unknown"
if reason == 0x10:
return "accepted_no_subscribers", state
if reason in (0x80, 0x83, 0x87, 0x90, 0x91, 0x97, 0x99):
return "publish_rejected", state
if reason != 0x00:
return "invalid_puback_reason", state
correlated = same_device and report.command_id == cmd.command_id
if correlated and report.execution in ("FAILED", "REJECTED"):
return "device_failed", state
if correlated and report.execution == "SUCCEEDED":
if state == cmd.target:
return "state_confirmed", state
return "reported_pending_state", state
if now >= cmd.wait_until:
return "unknown_timeout", state
return "accepted_pending", state
def main():
cmd, good = Command(), Report()
cases = [
("ack_only", 0x00, None, 103, ("accepted_pending", "unknown")),
("no_subscribers", 0x10, None, 103, ("accepted_no_subscribers", "unknown")),
("publish_error", 0x87, None, 103, ("publish_rejected", "unknown")),
("invalid_reason", 0x01, good, 103, ("invalid_puback_reason", "on")),
("complete", 0x00, good, 103, ("state_confirmed", "on")),
("wrong_command", 0x00, replace(good, command_id="other"), 103,
("accepted_pending", "on")),
("wrong_device", 0x00, replace(good, device_id="other"), 103,
("accepted_pending", "unknown")),
("old_version", 0x00, replace(good, power_version=40), 103,
("reported_pending_state", "unknown")),
("old_sample", 0x00, replace(good, power_sampled_at=99), 103,
("reported_pending_state", "unknown")),
("future_sample", 0x00, replace(good, power_sampled_at=104), 103,
("reported_pending_state", "unknown")),
("stale_sample", 0x00, good, 106, ("reported_pending_state", "unknown")),
("target_mismatch", 0x00, replace(good, power="off"), 103,
("reported_pending_state", "off")),
("device_failure", 0x00, replace(good, execution="FAILED", power="off"), 103,
("device_failed", "off")),
("timeout", 0x00, None, 105, ("unknown_timeout", "unknown")),
("late_state_no_attribution", 0x00,
replace(good, command_id=None, power_sampled_at=106), 107,
("unknown_timeout", "on")),
("reject_with_current_state", 0x87, good, 103, ("publish_rejected", "on")),
]
for name, reason, report, now, expected in cases:
actual = assess(cmd, reason, report, now)
if actual != expected:
raise AssertionError(f"{name}: {actual} != {expected}")
print(f"{name}: {actual[0]} / {actual[1]}")
print(f"PASS {len(cases)} constructed cases")
if __name__ == "__main__":
main()
本次在 Python 3.11.6、macOS arm64 环境直接执行,退出码为 0,实际标准输出如下。代码逐案比较预期结果,任何差异会抛出 AssertionError 并以非零状态退出;这些预期来自本文的应用契约,不是 MQTT 或 AWS 服务的执行回执。
text
ack_only: accepted_pending / unknown
no_subscribers: accepted_no_subscribers / unknown
publish_error: publish_rejected / unknown
invalid_reason: invalid_puback_reason / on
complete: state_confirmed / on
wrong_command: accepted_pending / on
wrong_device: accepted_pending / unknown
old_version: reported_pending_state / unknown
old_sample: reported_pending_state / unknown
future_sample: reported_pending_state / unknown
stale_sample: reported_pending_state / unknown
target_mismatch: reported_pending_state / off
device_failure: device_failed / off
timeout: unknown_timeout / unknown
late_state_no_attribution: unknown_timeout / on
reject_with_current_state: publish_rejected / on
PASS 16 constructed cases
ack_only 与 complete 只差设备证据,却分别停在等待和状态确认;它们证明"同样收到 0x00"仍需后续业务回报。no_subscribers、publish_error 和 invalid_reason 分别走无订阅者、协议拒绝与不适用的原因码分支。示例显式列出 PUBACK 的允许错误码,不把未知数值当作成功;真实 MQTT 解码仍应交给符合标准的客户端。
old_version、old_sample、future_sample 与 stale_sample 分别挡住未超过基线的版本、命令前样本、未来采样与过旧样本。target_mismatch 则留下新鲜 off:它说明拿到当前状态也不等于达到目标。wrong_device 同时挡住执行与状态归属;wrong_command 可以保留本灯的新鲜 on,但不会把另一条命令的执行结果算在这条命令上。
timeout 在等待边界仍无回报,结果保持 unknown;late_state_no_attribution 后来得到新鲜 on,却没有本次命令的执行关联,仍保留未知执行结果。这不是要求晚到结果永久无效:接入后可以用新的关联证据更新命令记录,并保留原超时发生的历史。reject_with_current_state 则验证发送失败与当前已开可以并存,避免拿一个目标值洗掉错误路径。
这 16 个构造案例通过,只证明所列输入与预期之间的本地映射一致。它没有覆盖乱序事件合并、持久化、并发写入、版本重置和真实传输重试;也不能证明真 MQTT 交付、AWS 服务行为、设备执行成功或灯具实际发光。接入时先把可信回报归一成相同的最小字段,保留原报文来源,再用真实事件轨迹检查关联和新鲜度,最后按设备风险观察物理结果。

把用户反馈放在结果汇总之后
语音 Agent 可以先给一句简短的进度反馈,但不能在工具仅返回接受时提前完成播报。应用层可以维护命令查询与状态确认,模型只接收归一后的证据摘要:命令身份、已达到的层级、未知项和允许说出的结论。这样即使原始接口使用 success 字段,也能在进入对话前按真实含义拆开,避免字段名称主导用户承诺。
对正常家居开关,反馈无需念出协议码。可以说"请求已发出,正在等设备回报",随后说"设备报告已打开";没有匹配订阅者时说明"发送路径未找到接收设备",再进入诊断。底层日志保留 Reason Code,用户文案表达具体影响。既不让用户理解 MQTT 才能使用,也不在产品层抹掉证据差别。
超时后发现目标状态达成,可以更新当前状态,但保留原命令的超时记录。设想其他家庭成员手动开了灯,状态查询会得到 on,用户目标虽已满足,却不能把该动作归功于 Agent。历史执行记录与当前状态各回答一个问题,分开存才能解释这种并发变化。把后续确认写回历史也应附新证据和观察时间,而不是把旧超时静默改成从未失败。
验收时不仅模拟消息丢失,还可模拟顺序变化:先收到新状态,再收到旧执行回报;先得到拒绝,再收到一条没有关联的旧 on;命令完成后又出现另一条关灯命令。对每一种输入顺序,检查最终设备视图和这次命令记录是否都正确。状态机测试的目的在于挡住错误播报,不是证明真实灯具一定成功。
真实设备接入后,则要观察一次完整时间线:用户意图、命令生成、平台接受、设备动作、报告到达与最终反馈。若物理灯亮和设备报告存在明显分歧,先定位回报来源,不能只增加等待时间掩盖它。对于只回报软件寄存器而不检测灯具实际状态的设备,文案继续使用"设备报告"这一限定。
也应规定后续查询多久继续进行、何时停止。长期查询不是完成证据,停止查询也不代表动作不存在。到达产品定义的等待边界时,把命令标为未确认,并让后续主动查询能够找到同一个命令 ID;如果用户发出新的相反意图,按新意图处理,不让旧回报重新触发已过时的播报。
工具的 success 字段若仅表示 SDK 调用无异常,接入摘要应改为发送层结果;执行层附命令 ID,状态层附观察来源与新鲜度。模型使用这份摘要生成文案,应用层在播报前再次核对它仍对应当前用户意图。
设备只支持周期上报时,等待最新状态可能比正常协议响应长。此时可以及时给进度,随后补确认;不能为了语音体验快就把周期旧值当成本次回报。对于用户再次追问"到底开没开",优先查询当前事实,并解释这次执行是否仍未知,而不是照念第一次接受消息。
应用日志也应避免泄露不必要的用户语音与凭据。排障通常需要命令身份、目标属性、事件时间和结果来源,未必需要完整对话录音。保留能解释这条命令的最小证据,能让设备控制记录更容易查阅,也降低无关信息对后续判断的干扰。
决策边界
如果设备只提供 MQTT 订阅,没有执行结果和状态上报,就不能把 Agent 设计成可靠的"已完成"播报器;最多报告"命令已发送"。若设备提供可信的执行回报但没有状态属性,文案应停在"设备报告执行成功"。有新鲜 reported 时,可以说"设备报告已达到目标状态",并按风险决定是否追加独立物理确认。
这条边界的价值不是增加更多状态名,而是让 Agent 的每一句反馈都能追溯到一条证据:当前协议跳、该命令的执行回报,或设备在此后报告的目标状态。消息已经送达,和世界已经改变,中间始终隔着设备执行与状态观察。