MultiNet 只听到一次命令,为什么连续触发?

0 阅读22分钟

MultiNet 只听到一次命令,为什么连续触发?

“TELL ME A JOKE”只说了一次,设备却像连续收到了命令。ESP-SR issue #216 的报告者在 ESP32-S3 上贴出了应用循环:detect() 返回 mn_state 后,程序没有用它判断是否产生新的识别事件,而是直接取 get_results(),只看 command_id[0] == 1 就执行动作。

这能确认应用分发条件少了一道状态守卫;不能确认 MultiNet 每帧都重新识别,也不能确认旧结果字段被库缓存。要定位重复,应分开记录当帧识别状态、结果字段与应用动作。本文分析公开材料,没有板卡复现或 issue 的已确认修复。

一次口令与重复动作之间的未知状态示意

公开循环:结果 ID 被当成每帧的新命令

报告者的简化代码相当于下面的调用顺序:

esp_mn_state_t mn_state = multinet->detect(model_data_hn, frame);
esp_mn_results_t *result = multinet->get_results(model_data_hn);
if (result->command_id[0] == 1) {
    dispatch_command();
    multinet->clean(model_data_hn);
}

上面只保留与本文有关的分支;dispatch_command() 是本文为说明动作而起的伪函数,并非 issue 原码。原报告的关键缺口是:取得了 mn_state,却没有用它守卫动作分发。相同的 command_id 字段值是否代表一次新命中,必须结合当帧状态判断。报告者在将音频重采样为 16 kHz、单声道的读取循环中调用 detect()。报告还提到 ESP-IDF 5.4.4/6.0.2、ESP-SR 2.0.0/2.4.3 以及 0.1 阈值;公开材料没有逐帧状态、音频输入和守卫改动前后对照,不能把阈值或版本定为根因。

公开 issue 调用顺序与可证实的缺口

一块结果数据,不自动拥有“又发生一次”的含义

结果字段回答内容,当帧状态回答本次是否命中。若只比较表里仍写着“开灯”,就可能每帧重发;这是应用条件的构造反例,不证明库会保存或清空哪些字段。

日志里“command_id=1”也应注明来自哪一次 detect。若打印发生在每次 get_results 后,却没有状态和帧号,读者无法区分新事件、历史结果、诊断读取或重复业务回调。为同一轮关联状态、结果与动作,能够把一个表面上连续的命令串拆为可以核对的序列。无需添加复杂事件总线,先用现有帧号与动作日志即可。

应用缺口可以确认,库根因仍不能代替证明

公开循环少了状态条件,是源码可见事实。重复究竟全部来自该缺口,则需要守卫前后对照。假设补上守卫后只执行一次,支持原分发路径参与了重复;假设仍多次执行,则继续看多次 DETECTED 或动作层重试。两种结果都不能从原码提前决定。文章要求修正明确的消费条件,同时保留对照,而不把候选改动称为已验证修复。

官方状态契约:守卫的是动作,不是所有结果读取

乐鑫 MultiNet 文档把 DETECTING 定义为尚未识别目标词,把 DETECTED 定义为已识别、可取结果,TIMEOUT 则退出等待下次唤醒。因此命令动作应由当帧 DETECTED 且有效候选命中触发。

这里不能写成“非 DETECTED 绝不能调用 get_results()”。ESP-Skainet 官方英文示例在 TIMEOUT 分支也读取结果字符串作日志,但没有据此执行命令。当前 ESP32-S3 接口头文件列出 num、command_id、phrase_id、prob 等字段;实际项目仍须对照锁定版本。 本轮 2026-10-13 复核 issue 仍为 Open、没有后续评论;接口字段固定到 ESP-SR 提交 e2fc6ce01e6d,英文示例固定到 ESP-Skainet 提交 97885d425d95,均不替报告者旧版本定义跨版本布局。

官方状态含义与示例中的动作边界

诊断读取和业务执行需要不同的判定

TIMEOUT 时读取结果字符串可以帮助调试窗口里发生过什么,但诊断内容不会自动成为一次可执行命令。应用应明确哪个分支允许动作,其他分支如何记录。否则一个为了排障增加的打印或结果读取,可能顺手复用公共回调并触发业务,这就把观察行为变成执行行为。这里讨论的是调用者组织方式,官方示例仅作为二者可分开的参照。

结果有效性还包括候选数量和目标映射。即使当帧 DETECTED,也应先确认结果指针和数量符合当前接口,再访问候选。不能因为命中状态就忽略边界,也不能固定读取 command_id[0] 而不看有没有候选。当前头文件中的字段帮助建立检查方向,实际大小与所有权仍按锁定版本核对,本文不补写跨版本布局保证。

单次模式与连续模式,定义不同的退出关系

如果业务只需要一次命令,就要按实际单次模式或应用窗口设计在处理结果后退出;如果业务需要连续控制,则下一次独立命中可以再次执行。不能把“同一个 ID 又出现”自动当重复错误,因为用户确实可能再次说同一命令。是否为新事件,要结合当帧状态、输入和窗口判断,而非只比较上一条 ID 是否相等。

反过来,连续模式允许再次识别,不等于一条结果可以在每个 DETECTING 帧不断分发。业务要把合法的下一次命中与同一次结果的重复消费分开。简单的“相同 ID 永不再执行”会误伤用户连续说两次“下一首”之类的合理交互;简单的定时冷却则可能暂时压住现象,却没有解释事件来源。本文不把这些限制当默认补丁。

只改分发守卫,保留对照

第一轮保留模型、阈值与 clean 位置,只补齐消费条件和对应模式的退出边界:当帧为 DETECTED 时核验候选再分发;TIMEOUT 结束识别窗口、等待再次唤醒;单次模式在 DETECTED 后结束,即使候选无效也不能继续送帧。连续模式才按锁定版本合同等待后续命中。2026-10-25 核对官方单次/连续模式说明,退出规则与此一致;结果字段仍按前文固定头文件核对。下面片段位于现有逐帧循环内,single_command_mode 由当前业务模式提供;读取完整新帧、结果布局、退出后清理与再次唤醒的接线均未实现,不是可直接编译的 SDK 补丁:

/* 位于已有逐帧循环内;每轮由原采集路径提供新的完整 frame。 */
bool should_exit = false;
esp_mn_state_t state = multinet->detect(model, frame);
log_frame(frame_no, state);
if (state == ESP_MN_STATE_TIMEOUT) {
    should_exit = true;  /* 可另读诊断结果,但不分发动作。 */
} else if (state == ESP_MN_STATE_DETECTED) {
    esp_mn_results_t *result = multinet->get_results(model);
    if (result && result->num > 0 && result->command_id[0] == target_id) {
        log_action(frame_no, action_id);
        dispatch_command_once(target_id);
    }
    should_exit = single_command_mode;  /* 与候选是否有效无关。 */
}
if (should_exit) {
    log_window_end(frame_no, state);
    break;  /* 退出当前送帧循环;由外层恢复等待下一次唤醒。 */
}

模式退出是两组共同前提,不能把新增守卫与改变识别模式的效果混算。若旧实现也缺退出边界,先按锁定版本补齐并保存独立构建,再以相同模式和窗口退出方式比较分发守卫。

复制检查退出边界:无动作也可能必须结束窗口

把下面代码保存为 window_exit_checks.py,使用 Python 3.6 或更新版本运行 python3 window_exit_checks.py。每条输入只用三种声明状态和 (候选数, 首候选ID),不模拟 SDK 结果结构、采集或清理。输入结束的 INPUT_END 仅表示这份有限列表已用完,不表示产品窗口正常结束。例子验证上面分支的控制流,不能证明设备已经停帧或恢复唤醒。

"""Finite author state/result records; no SDK, audio or device execution."""
def consume_window(frames, single):
    actions, processed = [], 0
    for state, result in frames:
        processed += 1
        if state == "TIMEOUT":
            return actions, processed, "TIMEOUT"
        if state == "DETECTED":
            if result and result[0] > 0 and result[1] == 1:
                actions.append(processed)
            if single:
                return actions, processed, "SINGLE_DETECTED"
    return actions, processed, "INPUT_END"


def main():
    hit = ("DETECTED", (1, 1))
    timeout = ("TIMEOUT", (1, 1))
    cases = [
        ("timeout_stale_continuous", [timeout], False, ([], 1, "TIMEOUT")),
        ("timeout_null_single", [("TIMEOUT", None)], True, ([], 1, "TIMEOUT")),
        ("single_valid", [hit], True, ([1], 1, "SINGLE_DETECTED")),
        ("single_null", [("DETECTED", None)], True, ([], 1, "SINGLE_DETECTED")),
        ("single_zero_candidates", [("DETECTED", (0, 1))], True,
         ([], 1, "SINGLE_DETECTED")),
        ("single_unknown_id", [("DETECTED", (1, 99))], True,
         ([], 1, "SINGLE_DETECTED")),
        ("continuous_two_hits", [hit, hit, timeout], False, ([1, 2], 3, "TIMEOUT")),
        ("continuous_null_then_timeout", [("DETECTED", None), timeout], False,
         ([], 2, "TIMEOUT")),
        ("detecting_stale_then_timeout", [("DETECTING", (1, 1)), timeout], True,
         ([], 2, "TIMEOUT")),
        ("single_invalid_stops_before_hit", [("DETECTED", (0, 1)), hit], True,
         ([], 1, "SINGLE_DETECTED")),
        ("timeout_stops_before_hit", [timeout, hit], False, ([], 1, "TIMEOUT")),
        ("single_valid_stops_before_next", [hit, hit], True,
         ([1], 1, "SINGLE_DETECTED")),
    ]
    for name, frames, single, expected in cases:
        actual = consume_window(frames, single)
        assert actual == expected, (name, actual, expected)
        print(f"CONSTRUCTED {name}: actions={actual[0]} processed={actual[1]} exit={actual[2]}")
    print("PASS 12 finite window-exit cases; no MultiNet/audio/device result")


if __name__ == "__main__":
    main()

本轮实际执行退出码为 0、stderr 为空,stdout 如下;十二个固定反例包含超时后的候选、单次模式无候选/未知 ID、退出后仍有输入,以及连续模式两次合法命中:

CONSTRUCTED timeout_stale_continuous: actions=[] processed=1 exit=TIMEOUT
CONSTRUCTED timeout_null_single: actions=[] processed=1 exit=TIMEOUT
CONSTRUCTED single_valid: actions=[1] processed=1 exit=SINGLE_DETECTED
CONSTRUCTED single_null: actions=[] processed=1 exit=SINGLE_DETECTED
CONSTRUCTED single_zero_candidates: actions=[] processed=1 exit=SINGLE_DETECTED
CONSTRUCTED single_unknown_id: actions=[] processed=1 exit=SINGLE_DETECTED
CONSTRUCTED continuous_two_hits: actions=[1, 2] processed=3 exit=TIMEOUT
CONSTRUCTED continuous_null_then_timeout: actions=[] processed=2 exit=TIMEOUT
CONSTRUCTED detecting_stale_then_timeout: actions=[] processed=2 exit=TIMEOUT
CONSTRUCTED single_invalid_stops_before_hit: actions=[] processed=1 exit=SINGLE_DETECTED
CONSTRUCTED timeout_stops_before_hit: actions=[] processed=1 exit=TIMEOUT
CONSTRUCTED single_valid_stops_before_next: actions=[1] processed=1 exit=SINGLE_DETECTED
PASS 12 finite window-exit cases; no MultiNet/audio/device result

单次模式的零候选之后故意放一条有效命中,处理数仍为 1;超时之后的命中也未消费。相反,连续模式保留两次命中,到 TIMEOUT 才结束。这些构造结果证明这个教学循环的分支与停止位置,不是 MultiNet、clean 行为或端到端唯一执行的证据。

同时改阈值、clean() 时机和模型版本,会让结果无法归因。这个守卫应切断非命中帧重复消费结果字段的路径;若模型仍连续返回 DETECTED,还得继续查输入、模式和阈值。报告者调用了 clean(),但未公开它在各帧对结果字段的影响,所以不能断言它清不掉缓存,更不能把守卫称为已验证修复。

当帧状态守卫应用分发的诊断示意

最小修改的范围,正好覆盖已看见的缺口

固定输入、模型、词表、阈值和 clean 位置,只改分发条件;提前停输入、清更多状态或换模型都要另列实验,不能把动作减少全归给守卫。

伪代码中的 once 表达作者希望一次有效事件只分发一次,并不说明调用一个叫 once 的函数就有了幂等。实际工程若将动作放入队列,仍需要确认一个事件是否入队一次、回调是否重入、发送是否重试。本文没有实现目标队列,所以不会宣称伪代码保证端到端唯一执行。它首先约束识别循环内的消费条件。

输入帧、识别事件与动作流水号分别有什么用

帧号识别输入位置,识别状态标记当次 detect 的返回,动作流水号追踪应用执行。假设同一个帧号对应两个动作流水号,就优先查看应用重复分发;假设不同帧各有 DETECTED,则优先看模型输入与模式;假设一个流水号在设备端执行两次,则需要查看发送与重试语义。这些标识服务不同边界,不应把设备动作次数反算成模型命中次数。

如果项目已有轮次 ID 和命令 ID,可在已有记录上增加关联,不另建一套标识体系。每条动作记录至少知道来自哪个轮次、哪个命中帧或事件,以及处理到了发送还是实际执行。缺少设备最终状态时,结论就停在发送层。识别循环修正,无法替下游通信或硬件回执提供不存在的证据。

清理动作放在哪里,要由当前模式解释

clean 调用可能影响后续识别状态,因而其执行前后需要保留状态记录。本文既不宣布 clean 清不掉旧结果,也不宣布它一定删除所有候选。目标版本的合同与实验决定这些关系。诊断时先固定现有位置,避免把清理和分发两个变化混为一体;定位到模式边界之后,再评估是否调整。

业务动作若耗时很长,同步执行还可能改变下一帧的消费节奏。重复问题和背压问题可以同时存在,但应分别记录。第一轮只验证事件条件,第二轮有证据显示动作阻塞时再评估异步处理。不能为解决重复而默认新增多个队列,因为新的队列同样需要容量、取消和所有权处理。

用一个单文件把三种重复拆成真实计数

把下面代码保存为 dispatch_guard.py,用 Python 3.10 或更新版本运行 python3 dispatch_guard.py。代码只依赖标准库,手工构造当帧状态和结果,不读取音频、不运行 MultiNet、不发送设备指令。held_field 刻意让不同帧持有相同结果字段;这是用来验证调用者条件的输入,不是观察到 SDK 缓存了结果。

以下比较器只隔离分发守卫的计数职责,不模拟窗口退出;它可把多个窗口已记录的帧放入对照,不能据此继续向已结束的真实窗口送帧。Frame.state 是当次 detect 的返回,Result.state/num/ids 是读到的结果域;两个域保留不同身份。field_only 只按 ID 计数,是原缺口的安全简化模型;它避免 Python 空对象访问,不能模拟原 C 代码潜在的越界或空指针故障。guarded 在当帧 DETECTED 之外还核对结果状态、非空候选与数量范围。数量上限 5 对应本轮核对头文件,不当作所有未来版本的保证。

"""Constructed state/result records; no MultiNet, audio or device execution."""
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Result:
    state: str = "DETECTED"
    num: int = 1
    ids: tuple = (1,)


@dataclass(frozen=True)
class Frame:
    seq: int
    state: str
    result: Result | None


def dispatches(frames, guarded):
    if any(b.seq <= a.seq for a, b in zip(frames, frames[1:])):
        raise ValueError("frame records must be strictly ordered")
    actions = []
    for f in frames:
        if f.state not in ("DETECTING", "DETECTED", "TIMEOUT"):
            raise ValueError("unknown current state")
        r = f.result
        if r is None or not r.ids:
            continue
        if guarded and (f.state != "DETECTED" or r.state != "DETECTED"
                        or not 0 < r.num <= 5 or len(r.ids) < r.num):
            continue
        if r.ids[0] == 1:
            actions.append((f.seq, 1))
    return actions


def main():
    # shortcut: explicit result values are test inputs, not SDK cache behavior;
    # use the real state, result and action trace for a target-version diagnosis.
    result = Result()
    held = [Frame(1, "DETECTED", result), Frame(2, "DETECTING", result),
            Frame(3, "TIMEOUT", result)]
    repeated = [Frame(i, "DETECTED", result) for i in (1, 2, 3)]
    two_hits = [Frame(1, "DETECTED", result), Frame(2, "DETECTING", result),
                Frame(3, "DETECTED", result)]
    cases = [("held_field", held, (3, 1)),
             ("consecutive_detected", repeated, (3, 3)),
             ("two_recorded_hits", two_hits, (3, 2)),
             ("zero_candidates", [Frame(1, "DETECTED", replace(result, num=0))], (1, 0)),
             ("too_many_candidates", [Frame(1, "DETECTED", replace(result, num=6))], (1, 0)),
             ("missing_result", [Frame(1, "DETECTED", None)], (0, 0)),
             ("missing_ids", [Frame(1, "DETECTED", replace(result, ids=()))], (0, 0)),
             ("unknown_id", [Frame(1, "DETECTED", replace(result, ids=(99,)))], (0, 0)),
             ("result_state_mismatch", [Frame(1, "DETECTED", replace(result, state="TIMEOUT"))], (1, 0))]
    for name, frames, expected in cases:
        old, guarded = dispatches(frames, False), dispatches(frames, True)
        assert (len(old), len(guarded)) == expected
        states = sum(f.state == "DETECTED" for f in frames)
        print(f"CONSTRUCTED {name}: DETECTED={states}, field_only={len(old)}, "
              f"guarded={len(guarded)}, guarded_sources={guarded}")
    one_event = dispatches(held, True)
    copied_downstream = one_event + one_event
    assert len(one_event) == 1 and len(copied_downstream) == 2
    print("CONSTRUCTED downstream_copy: guarded=1, business_dispatches=2; inspect action layer")
    try:
        dispatches([held[0], held[0]], True)
    except ValueError:
        print("CONSTRUCTED duplicate_frame_record: rejected")
    else:
        raise AssertionError("duplicate frame records accepted")
    print("PASS 11 guard/sequence checks; no SDK root-cause or exactly-once proof")


if __name__ == "__main__":
    main()

实际在 Python 3.11.6、macOS arm64 环境执行,退出码为 0;真实 stdout 如下。9 个输入场景、1 个下游复制反例和1个重复帧记录反例,共11项检查。

CONSTRUCTED held_field: DETECTED=1, field_only=3, guarded=1, guarded_sources=[(1, 1)]
CONSTRUCTED consecutive_detected: DETECTED=3, field_only=3, guarded=3, guarded_sources=[(1, 1), (2, 1), (3, 1)]
CONSTRUCTED two_recorded_hits: DETECTED=2, field_only=3, guarded=2, guarded_sources=[(1, 1), (3, 1)]
CONSTRUCTED zero_candidates: DETECTED=1, field_only=1, guarded=0, guarded_sources=[]
CONSTRUCTED too_many_candidates: DETECTED=1, field_only=1, guarded=0, guarded_sources=[]
CONSTRUCTED missing_result: DETECTED=1, field_only=0, guarded=0, guarded_sources=[]
CONSTRUCTED missing_ids: DETECTED=1, field_only=0, guarded=0, guarded_sources=[]
CONSTRUCTED unknown_id: DETECTED=1, field_only=0, guarded=0, guarded_sources=[]
CONSTRUCTED result_state_mismatch: DETECTED=1, field_only=1, guarded=0, guarded_sources=[]
CONSTRUCTED downstream_copy: guarded=1, business_dispatches=2; inspect action layer
CONSTRUCTED duplicate_frame_record: rejected
PASS 11 guard/sequence checks; no SDK root-cause or exactly-once proof

held_field 有一次 DETECTED,结果 ID 在后续 DETECTING/TIMEOUT 仍相同,字段条件计出3次、守卫计出1次。这证明在所构造序列中守卫过滤了非命中帧,不能证明 issue 的实际帧恰好如此。consecutive_detected 则有3个当前命中状态,守卫仍分发3次;它过滤条件,不解释模型为何连续命中,也不提供去重窗口。

two_recorded_hits 在帧1和帧3分别命中,守卫留下两条来源。若现场音频证明用户确实两次说相同命令,这两条可能都是合理动作,不能按 ID 相同永久抑制。代码只知道状态记录,无法从中推出用户真的说了两遍;输入留样仍是下一层证据。

zero_candidates、too_many_candidates、missing_result、missing_ids、unknown_id 与 result_state_mismatch 分别演示候选与映射的边界。它们是人为构造的不完整或冲突记录,不宣称库实际会返回这种坏结构。数量为0时,即使某字段仍含目标ID,也不应据它分发;结果域与当帧状态冲突则先核记录来源及版本,不能只挑有利的一个。

downstream_copy 在守卫产出的单事件之后,故意把业务分发列表复制成两条,展示守卫无法替后续队列/回调保证唯一执行。duplicate_frame_record 拒绝同一帧被重复入表,防止观察工具自身把事件数翻倍;实际重入和跨任务合并需要目标应用另验,不是这个函数已修复。

11项通过的证据仅限构造计数与拒绝条件。记录中的状态、结果和业务次数不来自真实库,不能据此判定 clean 的行为、旧结果保存方式、阈值根因或识别准确率。现场先在不发真实动作的模式下保留同一轮的输入范围和三类日志,再按下面三组结果分流;后续连续 DETECTED 与真实重复说话需要音频和目标版本共同确认。

一次口令加静音,把重复归到正确一层

固定实际依赖版本、模型、词表、阈值和输入,送一次口令后接静音。每帧留 frame_no、时间、mn_state;命中帧再留候选数、命令 ID、概率和动作流水号。旧实现与加守卫后的对照只改这个条件。这里是实验设计,不是 issue #216 已公布的测试结果。

对照前后分别留存实际构建并用于设备测试的 ELF、SHA-256、固件提交及当次配置身份,将每份状态/结果/动作日志绑定到对应构建。守卫修改允许生成不同 ELF,不能强求前后字节相同;应保持模型、词表、阈值、输入和其他构建条件一致,并明确唯一的分发条件改动。同一依赖版本不等于同一实际构建,不能拿版本号替代产物身份。

输入音频与个人语音样本如需公开,先确认授权并完成隐私处理;正文只需展示脱敏日志与结构化结果,不要求公开原始人声。交给维护者的输入材料也按其授权范围处理。上面的 Python 示例仅含构造状态和结果记录,没有采集或使用真实语音。

同轮记录下一步
一次 DETECTED,其后 DETECTING,旧实现多次动作优先检查结果字段被应用重复当作新事件;守卫应能切断这条分发路径。
多次 DETECTED,每次伴随动作检查输入是否重放、连续识别模式、阈值和版本。
只有一次 DETECTED,守卫后仍多次动作查动作队列、回调重入或命令重试。
状态或输入记录缺失保持待判,不把动作次数直接当成模型命中次数。

逐帧记录与三路分流

这篇的可带走产物是状态—结果—动作逐帧对照。要向维护者报告“MultiNet 重复识别”,先证明同一受控输入下确有多次独立 DETECTED;否则先修正应用消费条件,再保留完整时序。公开 issue 尚不能给出最终根因。

可复查的输入必须真的只包含一次口令

测试文件标成 once,不足以证明只有一次有效刺激。录音可能带回声,回放程序也可能循环,应用可能重复送入同一缓存。应保存实际送给 detect 的帧范围,注明回放方式和静音来源。这里的静音用于观察口令之后的状态,不替代真实背景声测试;若目标设备会自播,后续还应覆盖播放进入麦克风的场景。

假设一次口令后出现两段相似输入,而两次都 DETECTED,模型可能确实收到了重复刺激。此时状态守卫按契约允许两次分发,根因需要向上游追。假设输入只一次、状态也只一次,业务执行却多次,则根因需要向下游追。先确认实际输入,使三个分流表有可以核对的基础,避免测试者根据预期文件内容忽略现场帧重复。

第一组:一次命中,后续状态没有再次命中

对应前面的 held_field 构造结果,非命中帧不再分发,并不证明目标板卡内部缓存。现场仍保存状态与动作关联;播报被取消或设备失败时,听到的回应次数不等于分发次数。无候选、未知 ID、超时及结果状态不一致均在代码中单列,防止守卫只在正常样本上成立。

第二组:状态真正多次返回 DETECTED

对应 consecutive_detected,守卫仍允许每次命中进入动作层,不会自动去重相同 ID。继续查输入、连续模式和参数;调整阈值另起一轮,同时看合法重复口令是否漏掉。给维护者保留同一输入及帧序列、合法词表、模型与实际依赖版本,不能用“高阈值后少动作”代替内部原因证明。

第三组:识别只一次,动作层仍重复

动作队列可能被不同回调消费,网络层可能按业务规则重试,设备端也可能重复处理一个请求。这里列的是需要核查的假设,不指认某个协议或库有缺陷。按动作流水号向后追踪每次入队、发送和执行,找到第一次数量增加的位置,再修该边界。不要再调模型,因为模型事件数已经有一条独立证据。

如果设备动作具有不可简单重复的含义,比如切换状态或前进一项,更需要明确请求与回执关系。重复开灯可能看起来无害,重复切换却可能将灯关回去;测试不能只选效果幂等的动作来隐藏分发次数。可以先用无副作用的计数记录定位,实际动作回归再按产品约定进行。本文没有对任何真实设备发送命令。

把原故障和合法重复说话都纳入回归

现场候选修改后,先重跑一次口令加静音,再测两次独立的相同口令、两次不同口令、超时后再唤醒和用户取消。第一项复现原问题,第二项防止过度去重,其余项覆盖窗口与轮次恢复。每种交互记录输入、当帧状态、结果和动作,不用一个整体成功率合并不同分支。

可以先不执行设备动作,只累计有效事件与拟分发次数,避免设备播报或状态变化反馈到麦克风。恢复真实动作后,再检查回声、耗时与重试;前一阶段通过不能继承成设备链路通过。若没有捕获重复,保留输入、时长和模式,继续找可重现的轮次退出或背景声条件;不能写成所有条件已经解决。

结论精确到本机记录支持的层次:非命中帧不再重复分发,或模型仍多次命中需继续核查,或下游动作数量增加。每个候选结论回到具体命中帧、候选和动作流水号,找到数量第一次增多的位置;没有对应就保持待判。本文没有作者板卡实测或 issue #216 的修复验证,跨版本和长期行为仍需目标证据。