最近后台私信里好几个人都在问同一个名字:hermes-agent。有问它和智能音箱有什么区别的,有问能不能白嫖一套离线语音控制的,还有直接把GitHub链接丢过来让帮忙看看怎么用的。我一开始以为是某个蹭热度的前端脚手架,仔细翻完源码和协议文档之后发现,这东西其实比大部分语音助手方案都更有意思——它不是一个聊天机器人,也不是一个云端技能,而是一整套把语音理解、意图分发和设备控制拆开的“代理层”。如果你正在折腾本地化智能家居、想在不开云服务的前提下让音箱自己干活,那hermes-agent值得你花一个周末仔细研究。
先说结论:hermes-agent本身不负责“听懂”,它负责的是听懂之后那句“去做”。它将语音助手常见的识别(ASR)、语义解析(NLU)、对话管理和设备控制逻辑彻底解耦,用一套基于MQTT的Hermes消息协议把这些模块串起来。每个模块负责自己的那一摊,agent则像一个调度员,在收到“开灯”“开空调”“明天几点下雨”这类解析结果之后,根据预设意图去调用对应服务,再通过语音合成把结果回给用户。整套链路跑在本地局域网里,延迟可控、没有云端账单、数据不出门,改造空间也大,非常适合嵌入式开发者和智能家居玩家。
1. 项目定位与核心设计思路拆解
1.1 为什么需要一个“代理”而不是直接用语音识别SDK
先想一个实际问题:你对着麦克风说了一句“帮我把客厅灯调暗一点”,这句话要经过几道工序才能变成真实世界里的那一次PWM调光?首先是语音转文字,然后要做意图识别——知道“调暗”是一个动作、“客厅灯”是一个目标设备,然后才轮到设备侧执行。前两步是AI问题,最后一步是工程问题,如果硬把所有逻辑塞进一个进程,比如在唤醒词引擎里直接写一套设备控制代码,那后面每加一个设备、每调整一次说法都要重新编译、重新部署,而且任何一个环节崩了整套语音服务就全瘫了。hermes-agent的思路就是把这条链路上的每个节点做成独立模块,模块之间只通过消息通信,谁挂了就只重启谁,互不拖累。
这里还涉及一个很重要的取舍:设备控制逻辑为什么不让云端的技能平台去做?因为智能家居的控制对象在局域网里,云端技能要拿到设备控制权,要么设备主动连云端,要么在路由器上做端口映射,两条路都有安全隐患和网络依赖性。hermes-agent从一开始就把控制逻辑放在本地的agent进程里,云端只做语音识别和语义解析(甚至你也可以把这部分也全部本地化),真正下发指令的这一步永远留在家里。用行话说,这是“边缘计算”思路的极简实践。
1.2 事件驱动架构:Hermes协议解决的是消息传递问题
在动手写代码之前,必须先理解Hermes协议在整个项目里的位置。Hermes协议本质上是一组定义好的MQTT主题(Topic)和消息格式,相当于给语音助手的各个模块之间统一了“语言”。为什么选MQTT而不是HTTP?因为语音交互天然是异步的:用户说完话,识别可能耗时几百毫秒,意图解析又要几百毫秒,在这个过程中对话还在继续,用户可能补充信息或者改口,HTTP这种请求-响应模式在这种长链路上非常别扭,而MQTT的发布-订阅模型天然契合事件流。
打个比方,Hermes协议就像是整个语音助手团队的“内部聊天群”:ASR模块在群里喊了一声“我转出来一段文字”,NLU模块听到了就把文字解析成意图,再喊一声“用户想关灯”,agent听到这个意图就去把灯关掉,然后喊一声“事情办完了”。所有的模块都不需要知道对方在哪儿、用的是什么语言写的,只要遵守群里的发言格式就行。这种松耦合的架构对开发者极其友好,你可以用Python写agent,用C++写唤醒词引擎,用任意语言实现TTS,只要大家发的MQTT消息格式一致,就能协同工作。
1.3 从整体架构看hermes-agent在哪个位置
为了讲清楚,我把典型的Hermes语音链路画成一个表格,每个环节都有对应的MQTT主题和职责。
| 层级 | 模块 | 典型Topic | 职责 |
|---|---|---|---|
| 语音输入 | 唤醒词引擎 | hermes/hotword/detected | 检测到唤醒词后触发会话 |
| 语音输入 | ASR语音识别 | hermes/asr/textCaptured | 把音频转成文字 |
| 语义理解 | NLU意图解析 | hermes/nlu/intentParsed | 把文字解析为意图+槽位 |
| 业务执行 | Agent代理 | hermes/intent/ | 接收意图,调用设备或服务 |
| 对话管理 | 对话管理器 | hermes/dialogueManager/sessionStarted | 维护会话状态,控制流程 |
| 语音输出 | TTS语音合成 | hermes/tts/say | 把文本转成语音并播放 |
hermes-agent在整个链路里处于“业务执行”这一层,但它并不是一个被动的执行者。它需要监听意图消息、维护对话上下文、调用外部API或设备协议,再决定是直接结束会话还是追问用户。这也正是agent这个名字的含义:它是代表用户去执行动作的“代理”。
2. 核心机制拆解:意图、槽位、会话闭环
2.1 意图与槽位:让机器知道你到底想干什么
任何一个语音助手项目,最先要搞明白的就是“意图”和“槽位”这两个概念。意图就是用户这句话背后的目的,比如“turnOnLight”是开灯,“queryWeather”是查天气;槽位则是完成这个目的需要的关键信息,比如“开灯”需要知道是哪个灯,“查天气”需要知道是哪个城市、哪一天。Hermes协议对这两者的定义非常清晰,一条意图消息的JSON结构大致长这样:
{ "sessionId": "a1b2c3", "intent": { "intentName": "home:turnOnLight", "confidenceScore": 0.98 }, "slots": [ { "slotName": "room", "value": { "kind": "Custom", "value": "客厅" }, "rawValue": "客厅" } ], "siteId": "bedroom", "input": "打开客厅的灯" }这里最容易踩坑的是槽位的值不一定是你期望的字符串。比如用户说“打开老妈房间的灯”,如果NLU没把“老妈房间”这个叫法映射到具体的实体ID,槽位里可能只有一段原始文本,你的agent还是不知道要操作哪个设备。所以成熟的实现里,NLU配置阶段会有一张“同义词表”,把“主卧”“爸妈房间”“大房间”全部映射到同一个实体上。这个动作越早做,后续agent处理逻辑就越简单。
2.2 会话生命周期:一次对话是怎么从开始到结束的
和HTTP那种“一来一回”的短连接不同,语音对话是有生命周期的。Hermes协议里用“session”这个概念来表示一次完整的交互:从用户说唤醒词开始,到任务完成或者用户主动退出结束。会话的状态流转大致是这样的:
- 用户说唤醒词,hotword模块发布消息,对话管理器创建一个新session。
- ASR把后续语音转成文字,发布textCaptured。
- NLU解析文字,发布intentParsed。
- agent收到意图,决定是执行操作后立刻关闭会话,还是继续追问缺失的槽位。
- 最后agent发布endSession,对话管理器清理会话状态。
这个流程最精妙的地方在于:agent是可以通过“继续会话”来反向控制对话流程的。比如用户只说了一句“开灯”,没说哪里的灯,agent无法确定目标设备,就可以发布一条continueSession,让对话管理器通过TTS问用户“请问开哪里的灯?”。用户回答后再走一轮ASR→NLU→intent,直到槽位齐全、任务完成。这种能力让agent不再是一个单向的指令接收器,而是真正具备多轮对话能力的交互体。
2.3 Agent之间怎么协作:多个agent同时监听
实际项目里你不会只写一个agent文件,而是倾向于按业务领域拆分成多个agent:一个管灯光、一个管空调、一个管闹钟。Hermes协议的意图Topic是分层的,不同agent只需订阅自己关心的那部分。比如:
- 灯控agent订阅 hermes/intent/home:turnOnLight 和 hermes/intent/home:turnOffLight
- 天气agent订阅 hermes/intent/weather:query
- 闹钟agent订阅 hermes/intent/device:setTimer
这样做的好处很明显:业务边界清晰、单个agent崩溃不影响其他agent、新增功能只需要加一个新进程而不需要改动旧的。代码仓库的目录结构我也建议按agent拆分,每个agent一个文件夹,自带配置文件和依赖声明,这样后续维护起来不会变成一锅粥。
3. 手把手实操:从安装到第一个可用agent
3.1 环境准备与依赖安装
我实操时的环境是树莓派4B(4GB版本)加一块USB麦克风阵列,系统用的是Debian默认的Python 3.9。如果你手里只有一台普通电脑,也可以用虚拟麦克风模拟音频输入,不影响测试agent逻辑。
MQTT broker是整个系统的心脏,推荐用mosquitto,轻量、稳定、配置少。在Debian系系统上安装只需要一条命令:
sudo apt install mosquitto mosquitto-clients然后确认它跑起来了:
systemctl status mosquitto如果用Docker部署,更干净的做法是起一个持续运行的容器:
docker run -d --name mosquitto -p 1883:1883 eclipse-mosquitto:2.0接下来是hermes-agent本身。它提供了官方Python客户端库,直接通过pip安装:
pip install hermes-agent注意这个包名不要和某个JS框架搞混。安装完成后,可以从仓库的示例目录里拷贝一份基础模板,或者直接用接下来我给的这段代码。
3.2 用15行代码写一个最小可用的灯控agent
真正写一个agent比我想象中简单得多,核心逻辑就是:连接MQTT、订阅意图Topic、执行动作、结束会话。下面这段代码我实测可用,它监听一个叫home:turnOnLight的意图,收到后打印日志、模拟开灯操作、然后语音回复用户:
import json import paho.mqtt.client as mqtt BROKER_HOST = "127.0.0.1" BROKER_PORT = 1883 INTENT_TOPIC = "hermes/intent/home:turnOnLight" def on_intent(client, userdata, msg): payload = json.loads(msg.payload.decode()) intent = payload.get("intent", {}).get("intentName") slots = payload.get("slots", []) room = None for slot in slots: if slot["slotName"] == "room": room = slot["value"]["value"] print(f"[agent] 收到意图: {intent}, 房间: {room}") # 这里写真实设备控制逻辑,比如通过GPIO或MQTT电源控制 # if room == "客厅": # turn_on_light("living_room") # 执行成功,结束对话并TTS回复 client.publish("hermes/dialogueManager/endSession", json.dumps({ "sessionId": payload.get("sessionId"), "text": f"好的,已为您打开{room}的灯" })) def main(): client = mqtt.Client() client.on_message = on_intent client.connect(BROKER_HOST, BROKER_PORT) client.subscribe(INTENT_TOPIC) client.loop_forever() if __name__ == "__main__": main()这段代码有几个容易被忽略的细节。第一,endSession里的sessionId必须原样带回,因为对话管理器是靠它识别会话的,如果漏掉或写错,TTS消息会无法关联到当前会话,表现为“识别到了意图但设备没反应、也没有语音反馈”。第二,slots数组不一定按你期望的顺序排列,所以要用slotName去匹配,而不是固定取第一个元素。第三,如果你的agent逻辑执行时间比较长,比如要调用一个慢速API,建议先回一句“正在处理”再慢慢干活。
3.3 多轮追问:让agent补齐缺失槽位
前面提到,如果用户说“开灯”没说哪个房间,agent可以选择继续追问。在Hermes协议里,这叫做continueSession。我改造一下上面的代码,让它具备槽位补齐能力:
def on_intent(client, userdata, msg): payload = json.loads(msg.payload.decode()) session_id = payload.get("sessionId") slots = {s["slotName"]: s["value"]["value"] for s in payload.get("slots", [])} if "room" not in slots: client.publish("hermes/dialogueManager/continueSession", json.dumps({ "sessionId": session_id, "text": "请问要打开哪里的灯?", "intentFilter": ["home:turnOnLight", "home:turnOffLight"] })) return room = slots["room"] print(f"执行开灯操作: {room}") client.publish("hermes/dialogueManager/endSession", json.dumps({ "sessionId": session_id, "text": f"好的,已打开{room}的灯" }))这里的关键参数是intentFilter,它限制了下一轮用户说话时必须解析成哪些意图,如果用户回答“卧室”,NLU会继续解析为home:turnOnLight意图,只是这次会带上room槽位;如果用户回答“帮我看看天气”,intentFilter会让系统忽略这个请求,或者重新提示。这个机制看起来简单,但实际体验上非常关键——没有它,多轮对话很容易跑偏。
3.4 集成测试方法:消息模拟与端到端验证
写完agent后,不需要急着接麦克风,直接用mosquitto_pub发一条模拟意图消息就能测试:
mosquitto_pub -t hermes/intent/home:turnOnLight -m '{ "sessionId": "test", "intent": {"intentName": "home:turnOnLight", "confidenceScore": 1.0}, "slots": [{"slotName": "room", "value": {"kind": "Custom", "value": "客厅"}, "rawValue": "客厅"}], "siteId": "test" }'如果一切正常,你的agent终端会打印出日志,同时你在另一个监听endSession的终端窗口里能看到回包。监听命令是:
mosquitto_sub -t 'hermes/dialogueManager/#' -v这种“分层验证”的方法我强烈建议养成习惯:先用模拟消息验证agent逻辑,再接真实ASR;出了问题时能用mosquitto_sub看消息流,比盲猜代码高效得多。
4. 实际应用场景与系统集成经验
4.1 场景一:全本地离线语音控光
我把自己卧室的灯控系统接到hermes-agent上之后,最大的感受是:响应速度完全是本地级别的。整个链路由Rhasspy做唤醒和ASR,NLU在本地解析,然后通过MQTT发布意图,agent收到后直接调用灯控模块的HTTP接口。从说出“开灯”到灯亮,体感延迟大概在0.5到0.8秒之间,比很多依赖云端的智能音箱还要跟手。中间断了外网也不影响,因为没有任何一个环节需要访问公网服务。
这个场景的架构对你的网络有个硬性要求:MQTT broker的地址必须是局域网内固定IP,或者使用mDNS主机名,这样才能让音箱、树莓派和各设备在同一个网络域里稳定通信。而且建议broker开启简单的用户名密码认证,避免局域网里任何设备都能乱发消息。
4.2 场景二:把agent能力扩展到私有API服务
有一次我想让语音助手查一下家里的NAS剩余空间,原本以为要写一堆复杂的NAS接口适配,结果发现agent本质上只是一个消息转发器,完全可以把任意HTTP API包一层语音接口。我在agent里加了一个diskUsageHandle函数,收到意图后调用NAS的本地API,把返回的JSON拼成一句人话,交给TTS播报出来。整个过程不到50行代码,不需要任何智能音箱平台审核。
这个思路放大之后就是把agent当成一个“语音网关”,以后你想控制的设备只要能被本地或私有API驱动,就都能接入语音控制。局域网里的监控、下载机、打印服务器、甚至开发板的LED灯珠,都可以用同一套机制暴露成意图,交给hermes-agent统一调度。自由度远远高于封闭的智能音箱生态。
4.3 场景三:离线闹钟与日程提醒
闹钟类agent是我觉得Hermes协议里最优雅的用法之一。它不需要麦克风连续在线,只需要在后台监听定时任务,到点后主动发布TTS消息播报“该喝水了”“八点了”。Hermes协议里TTS消息不一定要在会话内发布,你可以在任意时刻用hermes/tts/say主题说一句话,所有连接在同一siteId下的扬声器都会播放音频。
这个场景暴露了一个规划上的细节:siteId字段的存在意味着一个Hermes系统可以管理多个房间、多台音箱,agent在选择播放设备时要声明siteId。比如“在主卧播报天气”和“在客厅播报闹钟”用的是不同siteId。代码层面就是多传一个字段,但架构上必须早早就想清楚设备分组,不然后期加房间会很痛苦。
4.4 项目代码组织建议
如果你和我一样会同时维护好几个agent,建议每个agent做成独立Python文件,用同一个MQTT连接配置,放在一个monitor脚本里统一拉起。这样有三个好处:输出日志可以带agent名字前缀,排查问题方便;单个agent更新时可以单独重启不影响其他功能;后续想迁移到Docker或者systemd多进程管理都很容易。我目前在树莓派上就是用一个systemd service跑一个launcher脚本,里面的子任务各自独立,最坏情况下某个agent崩了会被自动拉起,主服务毫发无损。
5. 常见问题与排查技巧实录
5.1 意图收到了但设备就是不动作
这个问题出现频率最高,而且绝大多数不是hermes-agent的锅,而是槽位匹配不一致。比如NLU解析出的room值是rawValue,而你的真实设备表里写的是roomId,两者对不上,agent自然找不到设备。我的排查习惯是先在agent回调里打印完整的JSON payload,看看实际收到的槽位值长什么样,再对着日志调映射表。另外注意,有些NLU框架返回的slot value是个嵌套对象,不是简单字符串,处理时要多取一层。
5.2 多轮对话卡住,TTS不回话
如果你用continueSession之后,用户回答了问题但agent没反应,优先检查intentFilter。举个例子,第一轮用户说的是“开灯”,你让NLU继续解析turnOnLight意图,但TTS追问的时候intentFilter写成了“turnOffLight”,那用户说“卧室”就永远走不进你的回调,表面上看就是“卡住了”。解决方法是把intentFilter放宽,至少把上一轮相关的意图都放进去,同时也观察mosquitto_sub日志,确认NLU到底发布了哪条消息。
5.3 ASR结果一直乱飘,agent频繁误触发
本地语音识别最头疼的就是误识别。你先用mosquitto_sub监听hermes/asr/textCaptured看看实际转写的文本,多半会发现用户没在说话,但ASR把环境噪音转成了一句话。这种情况要从声学层面解决:调整麦克风增益、开启语音活动检测(VAD)、提高NLU的置信度阈值。hermes-agent这层能做的事情有限,我建议在NLU配置里把confidenceScore低于0.5的意图直接丢弃,先挡住一部分误触发。
5.4 调试MQTT消息的实用命令行
最后分享几个我天天用的MQTT调试命令。要看所有Hermes主题的消息:
mosquitto_sub -h 127.0.0.1 -t 'hermes/#' -v只看某个类别的消息,比如只看TTS播报内容:
mosquitto_sub -h 127.0.0.1 -t 'hermes/tts/#' -v发布一条自定义意图来测试agent:
mosquitto_pub -h 127.0.0.1 -t 'hermes/intent/test:ping' -m '{"sessionId":"1","intent":{"intentName":"test:ping"},"slots":[]}'这组命令看似简单,但在排查“消息到底有没有到”“是NLU的问题还是agent的问题”时,比从头翻日志高效一个数量级。
5.5 梳理一份避坑清单
根据我这一个多月的实际使用,整理了这份避坑清单,每一条都是真金白银换来的教训:
- 所有MQTT payload必须用UTF-8编码,不要把bytes直接拿去json.loads,否则中文槽位会乱码。
- agent里写的所有日志,建议带上sessionId和意图名,多个用户同时说话时能区分上下文。
- TTS回复别用聋哑式的短句,至少要带上用户提到的槽位信息,比如“已打开客厅的灯”,这是体验的底线。
- Hermes的session有超时机制,agent逻辑执行超过几十秒还没回应,对话管理器会主动关闭会话,如果要做长任务,先回复“正在处理”再接异步结果。
- 不要在agent里直接调用阻塞式的设备驱动,尤其是那些带睡眠重试的库,会卡住整个消息循环,最好丢进线程池或消息队列。
6. 后续还能怎么玩
如果你对hermes-agent这套机制的印象还停留在“接个灯、开个空调”这种level,说明它的潜力还没被挖出来。我最近在实验的是把agent和家里的监控告警打通,让它在某个服务挂掉的时候主动发消息到音箱,用语音提醒我家里的网络异常。逻辑很简单:一个脚本每秒轮询几个内外网服务的连通性,一旦发现异常就通过hermes/tts/say播报,全程不需要任何云端中转。另一个方向是结合情景模式,让agent根据时间、人物和传感器上下文自动调整策略,比如“工作日早上8点半自动把客厅灯调亮”,这个用定时器在agent里就能实现。
我个人的体会是,像hermes-agent这样的项目最难得的地方在于它把“语音交互”从云端拉回了本地,把“智能”的裁判权还给了开发者。当你发现一次开灯操作背后有这么多环节协同工作,而且每个环节都能亲手调试和替换的时候,那种可控感和成就感,不是用几百块买一个智能音箱能比的。最后再分享一个小技巧:刚开始接触别追求功能多,先把agent最小循环跑通——唤醒、识别、解析、执行、回复,每一步用mosquitto_sub确认消息到了哪里,走通之后你再往上加东西,会发现一切都顺理成章。