简介:面向树莓派与嵌入式开发者、Python初学者与智能家居DIY爱好者,这套源码给出了一套可实际运行的语音控制家居系统实现。系统以树莓派为控制终端,用Python整合图灵机器人、和风天气与百度人工智能平台,结合爬虫与嵌入式技术,用户说出唤醒词“依米”后即可用语音指令驱动设备;功能覆盖智能门禁、智能聊天、生活指南与待办事项提醒,测试中表现出稳定的交互效果、多样的需求满足能力和实用的家居功能。压缩包共107个文件、约1.67MB,主体为33个py源码与14个pyc编译文件,另含wav/mp3语音提示音、txt说明与配置文件以及版本管理相关文件,目录结构清晰,便于按模块检索查阅。已有2371人学习下载。读者可获得语音唤醒与识别调用、机器人对话接入、天气数据爬取及门禁提醒逻辑的完整实现思路,既可直接部署到树莓派,也可作为课程设计、毕业设计的技术参考,对照源码能快速理解各平台API对接方式与模块间协作关系。
1. 需求先立住:为什么智能家居语音控制要用 Python 从一条指令的全链路倒推
做 Python 智能家居语音控制,最常见的起手式是打开教程挑一个语音库,跑通「说一句、屏幕打一行字」的 demo 就以为成了,然后卡在第二步——识别出来的文本怎么变成「把客厅灯调到百分之四十」这种带设备、带动作、带参数的指令,执行完状态怎么回传,播报和拾音怎么不打架。这三件事没在设计阶段定死,后面只能一层一层打补丁凑一套勉强能跑的处理逻辑,答辩现场一紧张就露馅。
这套方案要解决的就是「一条语音指令从进麦克风到设备状态改变」的完整闭环,适合两类人:一类是做毕设或课设,需要一个结构完整、能演示、能被追问设计取舍的系统;另一类是想把手上那个语音 demo 改造成能长期在本地跑的控制服务。设计顺序刻意反着来——先定指令集和设备状态模型,再回头选识别方案,因为指令集决定了后面每一个模块的边界,反着做往往会发现选好的引擎给不出你需要的槽位。
2. 总体架构设计:四层链路与六个核心模块的职责划分
2.1 分层结构:从拾音到设备执行的四层切分
我一般把整条链路切成四层,自下而上依次是设备层、调度层、理解层、交互层。设备层只关心「某个 device_id 执行某个 action 之后状态变成什么」;调度层负责把结构化指令路由到正确设备并做参数校验;理解层把一段自然语言文本变成结构化指令;交互层负责拾音、播报和界面刷新。依赖方向严格单向向下,上层只能调用下层的公开接口,不允许跨层直接摸对方内部字段。
这样切的好处很直接:语音识别引擎是整个项目里最容易更换的部件,今天用离线引擎,明天导师说识别率不够想换在线接口,只要理解层的输入仍然是「一段文本」,换引擎就不用动调度层和设备层一行代码。反过来,如果一开始把识别结果直接塞进设备控制函数里,换引擎等于重写半个项目,这就是典型的返工坑。
四层的职责和变化频率可以用一张表固定下来,写在设计文档第一章,答辩时直接照着讲:
| 层次 | 核心职责 | 输入 | 输出 | 变化频率 |
|---|---|---|---|---|
| 交互层 | 拾音、唤醒、播报、界面刷新 | 麦克风音频流、系统事件 | 文本、播报内容、UI 状态 | 高 |
| 理解层 | 语音转文本、意图与槽位解析 | 文本 | 结构化指令对象 | 中 |
| 调度层 | 指令校验、路由、状态汇总 | 结构化指令 | 执行结果与回执 | 低 |
| 设备层 | 设备抽象、动作执行、状态保存 | action + params | 新状态、提示语 | 低 |
注意「变化频率」这一列:越往下越稳定,越往上越爱改。所以单元测试的重点应该压在下两层,因为它们是全项目的地基,改一次就要全量回归。
2.2 六个核心模块与目录结构
落到代码上,四层拆成六个包加两个公共模块。目录规划清楚,后面加设备、加意图都是往固定位置塞文件,不会出现「功能加了但不知道放哪」的情况。
smart_home_voice/ ├── main.py # 入口:装配各模块、启动线程 ├── config/ │ ├── devices.yaml # 设备清单:id、房间、类型、初始状态 │ └── intents.yaml # 意图词典:动作词、设备别名、单位 ├── audio/ │ ├── recorder.py # 拾音、分帧、端点检测 │ └── wakeword.py # 唤醒词判定,决定何时开始正式识别 ├── asr/ │ ├── base.py # 识别引擎抽象接口 │ ├── offline_engine.py # 本地离线识别实现 │ └── online_engine.py # 在线接口实现(可选,作对比实验) ├── nlu/ │ ├── parser.py # 意图解析主流程 │ └── dictionary.py # 同义词、设备别名、数字归一化 ├── core/ │ ├── dispatcher.py # 指令执行器:校验、路由、回执 │ ├── registry.py # 设备注册表,按 device_id 索引 │ └── state_store.py # 状态持久化与开机恢复 ├── devices/ │ ├── base.py # 设备抽象基类 │ ├── light.py # 灯:开关、亮度、色温 │ ├── curtain.py # 窗帘:开合百分比 │ └── sensor.py # 只读传感器,演示「不可控设备」的处理 ├── feedback/ │ └── tts.py # 播报队列与互斥 └── logs/ └── runtime.log # 每条指令的原始文本、解析结果、执行结果六个核心模块就是audio、asr、nlu、core、devices、feedback。config单独拎出来是为了让「加一台设备」变成改配置而不是改代码——这一点在答辩时很加分,因为它说明你想过可维护性。logs目录必须存在,语音项目出问题时最怕的就是没办法重放现场,把原始识别文本和最终执行结果一起落盘,等于给系统装了个黑匣子。
asr包里刻意写了三个文件而不是一个:base.py定义接口,两个实现文件做对比实验。毕设里经常需要「离线方案与在线方案的识别准确率对比」这种章节,接口统一之后,同一批测试音频跑两遍就能出数据,不需要维护两份调用代码。
2.3 指令协议与设备状态模型
理解层输出、调度层消费的那个对象,是整个系统的契约,必须在动手写代码前定死。我用一个固定结构的字典,字段不多但每个都有明确用途:
{ "intent": "set_brightness", "device_id": "living_room_light", "slots": { "value": 40, "unit": "percent" }, "raw_text": "把客厅的灯调暗一点 调到四十", "confidence": 0.92 }intent取值限定在一个枚举里,比如turn_on、turn_off、set_brightness、set_position、query_state,不允许自由发挥,否则调度层没法写分支。device_id用英文标识而不是中文名,中文只作为别名存在于词典中,这样设备重命名、加同义词都不会影响核心逻辑。slots只放这个意图需要的参数,raw_text保留原话用于日志回溯,confidence留给置信度阈值做兜底——低于阈值的指令不执行,直接播报「没听清,请再说一遍」,比乱执行安全得多。
设备状态模型同理,每台设备一份快照,字段固定:
| 字段 | 类型 | 说明 |
|---|---|---|
| device_id | string | 全局唯一,与配置一致 |
| room | string | 房间标识,用于「客厅的灯全关」这类批量指令 |
| type | string | light / curtain / sensor,决定支持哪些 action |
| online | bool | 设备是否可达,模拟设备恒为 true |
| state | object | 类型相关,灯是 power + brightness,窗帘是 position |
表里online这个字段经常被忽略,但在真实场景里很重要:批量关灯时某台设备掉线,正确做法是跳过它并汇总提示「客厅灯已关,阳台灯离线未执行」,而不是整条指令报错。状态模型里预留这个字段,后面加批量指令时就不用改结构。
扩展一台新设备的完整动作只有三步:在devices.yaml里加一条记录,在devices/下继承基类实现apply方法,在intents.yaml的设备别名表里加中文叫法。三步之外不需要动调度层,这就是分层带来的实际收益。
3. 语音链路实现:唤醒词、识别与意图解析的三段式落地
3.1 拾音与唤醒:把常听改成按需听
一直开着麦克风做完整识别是最省事的写法,也是最容易翻车的写法:CPU 占用高、误触发多、播报时把自己的声音录进去。正常的做法是两级结构——常听只做轻量的唤醒判定,确认唤醒后再进入完整识别。
音频参数我固定用 16kHz 采样率、单声道、16bit 位深、每帧 30 毫秒。这三个数字不是随便定的:16kHz 覆盖了人声的主要频段,单声道省一半带宽,30 毫秒一帧刚好对应 480 个采样点,方便按帧算能量做端点检测。参数写在配置里,不要散落在各处。
import audioop import pyaudio CHUNK = 480 # 30ms @ 16kHz FORMAT = pyaudio.paInt16 CHANNELS = 1 RATE = 16000 SILENCE_RMS = 500 # 静音阈值,按实际环境噪声标定 MAX_SILENCE_FRAMES = 20 # 连续 600ms 静音就判为说完 def record_utterance(pa, threshold=SILENCE_RMS): """录一句话,靠能量做端点检测,返回 16bit PCM 字节串""" stream = pa.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK) frames, silence, started = [], 0, False while True: data = stream.read(CHUNK, exception_on_overflow=False) rms = audioop.rms(data, 2) # 当前帧音量 if rms > threshold: started = True silence = 0 frames.append(data) elif started: silence += 1 frames.append(data) # 尾巴也要,避免吞字 if silence >= MAX_SILENCE_FRAMES: break stream.stop_stream() stream.close() return b"".join(frames)SILENCE_RMS必须现场标定,不能抄。做法是让房间安静下来跑一百帧,取平均 RMS 再乘个 1.5 到 2 的系数。标高了说话录不进去,标低了风扇声都能触发。MAX_SILENCE_FRAMES决定尾巴长度,取 20 帧即 600 毫秒,太短会把「百分之……四十」中间的停顿切断,太长则用户说完还要干等。exception_on_overflow=False这行是血泪经验,缓冲区溢出时抛异常会直接中断录制线程。
唤醒词有两种实现路径:关键词表匹配和独立的小模型。前者把唤醒词也走一遍识别,命中即唤醒,实现成本低但延迟高、误唤醒率一般;后者用专门的轻量唤醒引擎,常听功耗低。毕设里如果只是演示,关键词表足够;要写「唤醒率与误唤醒率」的对比实验,就得上小模型,两条路都能在audio/wakeword.py里做成可切换的策略。
3.2 识别引擎适配:在线、离线与混合的取舍
识别引擎的选择不该在写代码时定,而该在测完一轮之后定。我通常先写接口,再拿同一批音频跑不同实现:
from abc import ABC, abstractmethod class BaseASR(ABC): @abstractmethod def recognize(self, pcm_bytes: bytes, timeout: float = 8.0) -> str: """输入 16kHz 单声道 PCM,返回识别文本,失败返回空串""" ... def preprocess(self, pcm_bytes: bytes) -> bytes: """统一做音量归一化,避免不同引擎对音量敏感度不一致""" return pcm_bytes接口只有recognize一个方法,返回值约定为文本或空串,不抛异常给上层。这个约定的价值在于:调度层永远不需要判断「识别引擎是否可用」,空串就当作没听清处理。timeout参数对在线实现是必须的,网络抖动时卡住十秒会让整个交互体验崩掉;离线实现可以忽略它。
离线方案的优点是延迟稳定、不依赖网络、答辩现场断网也能演示,代价是模型体积和生僻词识别率;在线方案识别率高但引入了不确定性,演示时网络一抖就尴尬。实务里我倾向「离线为主、在线可选」,把在线实现作为对比实验的对照组写进论文,而不是主链路。
识别失败的重试策略也要定:空串连续出现两次就播报提示并回到唤醒等待,不要陷入「你说一遍我识别一遍」的死循环。
3.3 意图解析:规则引擎 + 词典的落地写法
语音文本进到意图解析这一步,面对的是一堆口语变体:「把客厅灯打开」「客厅的灯开一下」「开下客厅灯」。规则引擎配同义词词典完全够用,上模型反而增加不可解释性,答辩时也不好讲。
思路是三步:先做文本归一化(去空格、数字转阿拉伯数字、去除语气词),再按设备别名定位device_id,最后按动作词和数值模式定intent和slots。
import re from config.dictionary import DEVICE_ALIAS, ACTION_WORDS, CN_NUM def normalize(text: str) -> str: text = re.sub(r"[\s,。!?、]", "", text) for cn, num in CN_NUM.items(): # 四十 -> 40,一点 -> 1 text = text.replace(cn, str(num)) return text def parse_intent(text: str, threshold: float = 0.6) -> dict | None: norm = normalize(text) # 1) 找设备:按别名长度倒序匹配,避免「灯」覆盖「客厅灯」 device_id, hit = None, "" for alias in sorted(DEVICE_ALIAS, key=len, reverse=True): if alias in norm: device_id, hit = DEVICE_ALIAS[alias], alias break if not device_id: return None rest = norm.replace(hit, "") # 2) 找数值槽位:百分比、数字、开合度 m = re.search(r"(\d{1,3})", rest) value = int(m.group(1)) if m else None # 3) 定意图:先匹配带参数的,再匹配纯开关 if value is not None and ("亮度" in rest or "调" in rest): intent, slots = "set_brightness", {"value": value, "unit": "percent"} elif value is not None and ("窗帘" in rest or "开合" in rest): intent, slots = "set_position", {"value": value, "unit": "percent"} elif any(w in rest for w in ACTION_WORDS["off"]): intent, slots = "turn_off", {} elif any(w in rest for w in ACTION_WORDS["on"]): intent, slots = "turn_on", {} elif any(w in rest for w in ACTION_WORDS["query"]): intent, slots = "query_state", {} else: return None return {"intent": intent, "device_id": device_id, "slots": slots, "raw_text": text, "confidence": 0.9 if value is not None else 0.75}关键在第一步的匹配顺序:设备别名必须按长度倒序,否则「客厅灯」会先被「灯」命中,结果所有灯都解析成同一台。这是个非常典型的翻车点,写完记得拿「主卧灯」和「灯」两个词互相验证一遍。
confidence这里是按规则硬编的权重,参数型指令给了 0.9、纯开关给 0.75,虽然粗糙但足够做阈值过滤。要做得更细,可以统计每条规则在测试集上的命中准确率反填,论文里也好写。
ACTION_WORDS词典的结构大致是:{"on": ["打开", "开启", "开一下"], "off": ["关闭", "关掉", "关上"], "query": ["状态", "开着吗", "多少"]}。词典放在config/intents.yaml里,加词不用改代码,测试同学也能自己补。
3.4 播报与自激规避:让 TTS 和麦克风不打架
播报看起来最简单,实际上最容易出玄学问题:系统播报「已为您打开客厅灯」,麦克风把这句录进去,识别成一条新指令,于是循环播报。规避手段是加一把互斥锁加一个静音窗口。
import threading, time class Speaker: def __init__(self, engine, cooldown=0.6): self.engine = engine self.cooldown = cooldown self._lock = threading.Lock() self._busy = False @property def busy(self): return self._busy def speak(self, text: str): with self._lock: # 串行播报,避免两句话叠在一起 self._busy = True try: self.engine.say(text) self.engine.runAndWait() finally: time.sleep(self.cooldown) # 尾音余响,等声卡缓冲排空 self._busy = False拾音线程在每次开始录制前检查speaker.busy,为真就直接跳过这一轮。cooldown默认 0.6 秒,作用是等声卡缓冲区里的尾音放完——很多人只加锁不加冷却,播报结束的瞬间仍然会录到半句回音。
播报内容也要设计,不能把执行结果原样念出来。成功时说「客厅灯已打开,亮度百分之四十」,失败时说「亮度需要在 1 到 100 之间」,两条都简短,控制在 15 个字内。长句播报会拉长麦克风静默时间,用户体感上会觉得系统反应慢。
4. 设备控制与状态管理:设备抽象层与指令执行器
4.1 设备抽象:一个基类屏蔽真实与模拟设备
毕设演示大多没有真实硬件,全靠模拟设备。模拟设备和真实设备如果走两套代码,等真接上硬件那天就要重写。正确做法是定义一个基类,把「执行动作并返回新状态」作为唯一契约,模拟实现和真实实现都继承它。
from abc import ABC, abstractmethod class BaseDevice(ABC): def __init__(self, device_id: str, room: str, name: str): self.device_id = device_id self.room = room self.name = name self.online = True self.state: dict = {} @abstractmethod def apply(self, action: str, params: dict): """执行动作,返回 (成功与否, 新状态, 播报文案)""" ... def snapshot(self) -> dict: return {"device_id": self.device_id, "room": self.room, "name": self.name, "online": self.online, "state": dict(self.state)}class LightDevice(BaseDevice): def __init__(self, device_id, room, name): super().__init__(device_id, room, name) self.state = {"power": "off", "brightness": 100} def apply(self, action, params): if action == "turn_on": self.state["power"] = "on" return True, self.state, f"{self.room}的{self.name}已打开" if action == "turn_off": self.state["power"] = "off" return True, self.state, f"{self.room}的{self.name}已关闭" if action == "set_brightness": value = params.get("value") if not isinstance(value, int) or not 1 <= value <= 100: return False, self.state, "亮度需要在1到100之间" self.state["power"] = "on" self.state["brightness"] = value return True, self.state, f"亮度已调到百分之{value}" return False, self.state, "这个设备不支持该操作"返回值三元组的设计是有讲究的:第一个值给调度层做分支,第二个值给状态存储做落盘,第三个值直接丢给播报模块。三个模块各取所需,谁都不用再去读设备内部字段。
参数校验放在设备的apply里而不是调度层,因为不同设备的合法范围不同——灯是 1 到 100,窗帘是 0 到 100,传感器根本不接受写操作。把校验下沉到设备,调度层就能保持无状态。真接硬件时,只需要在apply里把改state换成发串口指令或调协议库,上层完全无感。
4.2 指令执行器:校验、执行、回执三步走
调度层的核心是执行器,它拿一条结构化指令,走完查表、校验、执行、记录四件事。
class Dispatcher: def __init__(self, registry, store, logger): self.registry = registry # device_id -> BaseDevice self.store = store # 状态持久化 self.logger = logger def dispatch(self, cmd: dict): if not cmd or cmd.get("confidence", 0) < 0.6: return False, "没听清,请再说一遍" device = self.registry.get(cmd["device_id"]) # 1) 查设备 if device is None: return False, "没有找到这个设备" if not device.online: return False, f"{device.name}当前离线" ok, state, msg = device.apply(cmd["intent"], cmd.get("slots", {})) # 2) 执行 self.logger.info("cmd=%s ok=%s state=%s", cmd, ok, state) # 3) 记录 if ok: self.store.save(device.device_id, state) # 4) 落盘 return ok, msg这里有一个容易被追问的设计点:为什么置信度判断放在执行器而不是解析器?因为置信度的语义是「这条指令值不值得执行」,属于业务决策,而解析器只负责「文本能解析出什么」。分开之后,调整阈值不影响解析逻辑,做 A/B 也方便。
批量指令(「把客厅的灯全关」)可以在这里加一个分支:按room过滤注册表,逐台执行,最后汇总成一句播报。汇总时把失败项单独列出来,别让一条失败淹没在成功里。
4.3 状态持久化与开机恢复
状态存内存就够演示,但断电重启后界面全灭,答辩时演示到一半重启会很尴尬。用 SQLite 落一份,开机加载时覆盖默认状态即可。
CREATE TABLE IF NOT EXISTS device_state ( device_id TEXT PRIMARY KEY, state_json TEXT NOT NULL, updated_at INTEGER NOT NULL ); -- 保存用 upsert,避免先查后插的竞态 INSERT INTO device_state (device_id, state_json, updated_at) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(device_id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at; -- 开机恢复:只取最近 24 小时内变更过的记录 SELECT device_id, state_json FROM device_state WHERE updated_at > strftime('%s','now') - 86400;state_json直接存 JSON 字符串而不是拆字段,是为了让不同设备类型的异构状态(灯有亮度、窗帘有开合度)共用一张表,加设备类型时不用改表结构。updated_at用来做过期判断,超过一天的状态大概率已经和设备实际不符,宁可回落到配置文件里的默认值,也别把一个陈旧状态当真的显示出来。
写入频率要注意,别每来一帧就写一次盘。执行器里只在ok为真时写,且同一设备 200 毫秒内的连续变更可以合并,正常使用根本不会触发这个上限。
4.4 线程模型:三条线程加一个队列
整个系统至少三条线程:拾音线程、识别与解析线程、界面/播报线程。最省事也最危险的做法是让拾音回调里直接调识别,识别一次几百毫秒,期间缓冲区溢出,直接丢音。
我的做法是拾音线程只负责往queue.Queue(maxsize=8)里塞完整的一句话音频,识别线程从队列取,解析、调度、播报全部在这条线程里串行完成。队列长度设 8 是留一点缓冲,超过就丢弃最旧的一条并记日志,防止用户连说十句把内存撑爆。
界面刷新必须回到主线程,用信号槽或者after定时器都行,别在识别线程里直接改控件,这个错误在带界面的项目里几乎必踩一次。
5. 避坑与排查:语音项目里最容易翻车的五个点
项目能跑通和能稳跑,中间隔着一堆只在长时间运行或现场演示时才冒出来的问题。下面五条是我在类似系统里反复遇到的,按「现象 → 原因 → 解决」写清楚,排查时照着对。
5.1 能录到音,但识别结果永远是空串
现象是日志里音频帧数很正常,RMS 也显示有声音,但识别接口一直返回空。原因通常有三个:采样率没对齐,录音是 44.1kHz 却按 16kHz 喂给引擎;位深不匹配,录的是浮点却当 16bit 整型传;或者音频没做归一化,音量太低,引擎的静音检测直接把整段判成噪声丢掉。
解决的顺序是先用工具把录到的音频存成 wav 文件,手动播放听一遍,确认内容和音质没问题,再用ffprobe之类的工具核对实际的采样率和声道数。参数对齐之后如果还是空,就在preprocess里做一次音量归一化,把峰值拉到 0.7 左右再送识别。这一步不要省,实测对识别率的影响比换引擎还大。
5.2 没人说话也频繁自己唤醒
现象是系统在安静环境里隔一段时间就自己触发一次,日志里能看到一堆不成句的识别文本。原因一般是唤醒阈值定得太低,把空调风声、键盘声判成了有效语音;另一种是唤醒词本身太短,比如只有两个音节,识别引擎的误命中率天然就高。
解决方法是先标定噪声底:静音采集 100 帧算平均 RMS,把阈值设成均值的 1.8 到 2 倍,别用绝对值。然后加一层双重确认——唤醒后必须解析出合法意图才继续,解析失败就静默回到等待态,不做任何播报。唤醒词尽量选三到四个音节,且避免和常用指令词重叠。
5.3 指令识别对了,但设备没反应
现象很迷惑:日志里明明打印了「客厅灯已打开」这样的播报文案,界面上的灯还是关着。原因一般出在device_id对不上——词典里配的是living_room_light,配置文件里写成了livingroom_light,注册表查不到,执行器返回失败,但播报文案是拼出来的默认话术,看起来像成功了。
解决办法是把「查不到设备」这条分支的提示语写死成明确的失败文案,不要复用成功模板;同时在启动时做一次交叉校验,遍历intents.yaml里的所有device_id,检查是否都能在设备注册表里找到,找不到就直接在启动日志里报错退出。这个校验十行代码,能挡掉后面几天的排查。
5.4 播报之后系统开始自己跟自己说话
现象是连续触发好几轮指令,而且方向完全随机。原因就是播报的声音被麦克风录进去当成了新指令,尤其是播报内容里本身就带「打开」「关闭」这些动作词的时候。
解决分三层:播报期间拾音线程直接跳过;播报结束加 0.5 到 0.8 秒冷却,等声卡缓冲排空;播报文案避免使用和指令词完全一致的表述,比如把「打开客厅灯」改成「客厅灯已打开」。三层里前两层是必须的,第三层是锦上添花。有条件的话用带回声消除的采集设备,能根治。
5.5 跑两三个小时后卡死或者爆内存
现象是刚启动一切正常,挂机一段时间后响应越来越慢,最后界面无响应。原因通常有两个:录音流对象用完没关,句柄越积越多;日志里把每一帧音频都打出来,文件涨到几个 G 之后 IO 拖垮整个进程。
解决办法是把音频流的打开和关闭收进try/finally,确保异常路径也会释放;日志按天切割,音频数据只在 DEBUG 级别输出,默认级别只记指令和结果。另外队列一定要设上限,我在 4.4 里写maxsize=8就是这个原因——无界队列在长期运行下就是内存泄漏的温床。
6. 验收与答辩:用一份可复现的测试用例表把系统讲清楚
演示能跑不代表系统可用,答辩被追问「你怎么证明它稳定」的时候,拿不出一张用例表是很被动的。我的习惯是在开发阶段就同步维护一份测试用例表,每条用例包含输入语音、期望意图、期望设备状态和判定方式,跑完打勾。这张表既是验收依据,也是论文里的实验数据来源。
| 编号 | 输入语音 | 期望意图 | 期望设备状态 | 判定方式 |
|---|---|---|---|---|
| T01 | 打开客厅灯 | turn_on | 客厅灯 power=on | 状态表比对 |
| T02 | 把客厅灯调暗到百分之四十 | set_brightness | brightness=40 | 状态值精确匹配 |
| T03 | 客厅灯亮度调到一百五 | 不执行 | 状态不变 | 播报含范围提示 |
| T04 | 关闭主卧灯 | turn_off | 主卧灯 power=off | 其余设备状态不变 |
| T05 | 灯打开 | turn_on | 命中默认房间设备 | 检查 device_id 解析 |
| T06 | 窗帘拉开一半 | set_position | position=50 | 槽位值匹配 |
| T07 | 客厅温度多少 | query_state | 状态不变 | 播报含数值 |
| T08 | 今天天气怎么样 | 不执行 | 状态不变 | 播报未知指令 |
| T09 | 静音环境 30 秒 | 无触发 | 状态不变 | 无识别日志 |
| T10 | 连续三条不同指令 | 依次执行 | 三条状态均变更 | 日志顺序与状态一致 |
T03、T05、T08 这三条是专门设计来打脸的:越界参数、设备名歧义、域外指令,正好覆盖前面提到的三个高频翻车点。T09 测误唤醒,挂机跑十分钟统计触发次数。T10 测连续交互,重点看队列有没有丢指令。这份表跑一遍大概十五分钟,交付前必走。
答辩环节的技巧在于「先讲约束再讲能力」。开口别说系统多智能,先说清楚它支持哪五类意图、覆盖哪三种设备、识别在什么噪声条件下测的,把边界划出来,评委的提问就会落在你准备好的范围内。然后现场演示只跑 T01、T02、T05 三条,这三条最稳且覆盖了完整链路。剩下的用例表直接放 PPT 里,一页讲完。被问到识别率,就说测试集多少条、准确率多少、错的那几条错在哪类口音或噪声上——承认边界的回答永远比吹满分更可信。
从那以后我每次做语音相关的项目,都会在写第一行代码之前先把这份用例表列出来,跑通一次全绿才敢交给别人演示。希望帮到你。
本文还有配套的精品资源,点击获取