开源自托管 Agent 项目 Hermes Agent 的语音链路与代价
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
用嘴指挥 Agent 从来不是一个功能,而是四条互不相关的链路被迫串在一起的结果;这个项目值得读的地方也不是”它支持语音”,而是它把每一块的失败面都摊在了配置项和代码注释里。 这里说的是 Nous Research 开源的常驻自托管 Agent 项目 hermes-agent(MIT 许可,仓库地址 https://github.com/NousResearch/hermes-agent),不是同名的 Hermes 开源模型系列,也不是任何同名商标或第三方同名库。它会常驻在你自己的机器上、开终端执行命令、连你的聊天软件账号、往磁盘写文件,语音只是它众多入口中的一个——但恰好是暴露工程细节最多的那个。
站内 Agent 工具设计 谈的是通用方法论,pi 的工具层 拆的是另一个项目的分层,Agent 失败分类 给的是排障时的归类框架;这篇不重复它们,只做一件事——把这个具体项目的语音链路落到具体文件、具体配置项、具体代价上。
一、四块拼图:谁负责什么,在哪个文件
先把”语音”这个词拆开。在这个仓库里它至少是四件独立的事:麦克风上有个东西一直在听你有没有喊它(唤醒词);喊了之后有个东西负责录一段并判断你什么时候说完(语音模式);录完有个东西把音频变成字(转写);回答生成出来又有个东西把字变成声音并放出来(合成与播放)。它们各有各的 provider、各有各的配置块、各有各的失败方式,任何一块没配好,整条链路的表现都是”它听见了但什么也没发生”。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 唤醒词监听 | 常驻听一个短语,命中后回调;不碰上下文 | tools/wake_word.py | 想免手动开始一次对话时 |
| 唤醒词模型 | 随仓库带的 hey hermes 模型文件 | tools/wakewords/hey_hermes.onnx(同目录另有 .tflite) | 想换短语或换自训练模型时 |
| 录音与端点判定 | 采集、静音自动停、抢话打断、幻听过滤 | tools/voice_mode.py | 抱怨”它总是提前截断我”时 |
| 转写 | 音频转文本,多 provider 分发 | tools/transcription_tools.py | 想改成本机跑还是走云端时 |
| 合成与播放 | 文本转音频、容器修复、系统播放器兜底 | tools/tts_tool.py | 语音回复没声或格式不对时 |
| 流式分句与流式 provider | 把模型的 token 增量切成句子,边生成边说 | tools/tts_streaming.py、说明在 docs/streaming-tts.md | 觉得”回答完才开口”太慢时 |
| 播报前的文本清洗 | 统一去掉思考块、markdown、emoji | tools/tts_text_normalize.py | 它把星号和链接念出来时 |
| 平台侧流式音频 | 把增量音频交给聊天平台适配器 | gateway/streaming_tts_consumer.py | 在 Telegram 之类平台上要边生成边发时 |
这张表本身就是一个判断依据:如果你只想要”命令行里用嘴问一句”,你要动的只有中间三行;唤醒词和平台流式那两行是完全可以不开的。
二、唤醒词:一个始终在跑、且必须独占麦克风的东西
tools/wake_word.py 的定位写得很直白:一个轻量的常驻热词监听,命中后调用回调,检测全程在本机完成,在你真正对 Agent 说命令之前没有音频离开机器。默认是关的(wake_word.enabled 默认 false),交互式会话里用 /wake on 打开、/wake status 看状态、/wake off 关掉,而这个开关本身就是配置——切换会写回 ~/.hermes/config.yaml,下次启动仍然生效。
它给了三个引擎,取向完全不同:
openwakeword:默认,本机 ONNX 模型,不要密钥。仓库直接带了 hey hermes 的模型文件,所以开箱就有一个短语可用;换成内置名(hey_jarvis、alexa等)或自己的.onnx也支持。sherpa:本机、免密钥,而且是开放词汇——wake_word.phrase里写什么短语,运行时就用 BPE 拿模型词表把它切成 token 序列去匹配,不需要训练步骤。代价是首次要下载一个小的英文流式模型,缓存在 Hermes 家目录下的cache/wakewords。porcupine:Picovoice 的引擎,需要PORCUPINE_ACCESS_KEY,支持内置关键词和自定义.ppn。
三个引擎的灵敏度语义原本是打架的,仓库的处理方式值得抄:sensitivity 统一约定成”越高越严”,openwakeword 里它就是每帧分数阈值,sherpa 里映射到关键词阈值,而 Porcupine 官方的 sensitivities 恰好是反的(越高越松),于是代码里直接 1.0 - sensitivity 倒过来,保证一个配置项在三个后端下含义一致。这是”多 provider 抽象”里最容易被偷懒跳过、也最容易让用户困惑的一环。
另一处硬工程是 confirmation_frames。openWakeWord 一次只给一个约 80 毫秒的帧打分,背景闲聊里蹦出一个音素就可能把单帧顶过阈值;真正念出短语时分数会连续几帧维持在高位。所以默认要求连续 3 帧过阈值才触发,可调范围被夹在 1 到 10 之间——1 就退回”见一帧就开火”的老行为,调高的代价是几十毫秒延迟。这是对付误触发的主要旋钮,比一味调高阈值更对症。
平台差异也没被藏起来。resolve_inference_framework() 里写明 openWakeWord 的 ONNX 后端在 macOS ARM64 上分数近乎为零(共享的 embedding 那一级是坏的,前端和分类头都正常),表现是”检测器起来了、麦克风也在工作、任何短语都永远过不了阈值”。所以那个平台默认走 tflite,而且即使用户显式钉了 onnx,这一种组合也会被强制改回 tflite 并打一次告警——宁可违背显式配置,也不给用户一只永远听不见的耳朵。为了让上游硬编码的 tflite_runtime 导入能成功,ensure_tflite_runtime() 还在进程内把它别名到另一个 wheel,不往 site-packages 里写任何东西。
真正决定它能不能和别的功能共存的,是麦克风所有权。WakeWordDetector 明确注明”同一设备上开两条输入流跨平台不可靠”,所以语音回合占用麦克风时调用方要 pause(),空闲后再 resume();引擎对象跨 pause/resume 保活,只有音频流和读取线程在循环。恢复时必须调 engine.reset() 清掉滚动特征缓冲,否则暂停前录到的那句”hey hermes”会在恢复瞬间再次命中,变成唤醒→语音→恢复→又唤醒的自激。再加一层:整机一把文件锁(Hermes 根目录下 runtime/wake-word.lock,Windows 用 msvcrt、其它平台用 fcntl),抢不到就抛 WakeWordInUse——命令行、终端界面、桌面端谁先拿到谁听。
还有个很实在的诊断:流开着但每帧都接近全零,说明麦克风是”哑”的(权限没给、选错设备)。它数满约十秒的近零帧就把状态标成静音,audio_is_silent() 让状态面板能显示”在听,但麦克风似乎没声音”,而不是一片健康的绿。
最后是它主动划的边界:check_wake_word_requirements() 会同时检查转写和合成是否就绪,两头缺一就拒绝武装并给出指引。理由写在注释里——唤醒之后没有转写,每句话都死在转写环节;没有合成,回答是静音的,整个免手动流程毫无意义。
三、听清楚:录音、端点判定、抢话与幻听过滤
tools/voice_mode.py 是这条链路里代码最”脏”的一块,因为它直面硬件。采集参数是 16 kHz 单声道 16 位——注释直接写了这是 Whisper 的原生采样率。命令行里 /voice on 打开语音模式,Ctrl+B 切换按键录音。
端点判定不是简单的”低于阈值就停”。AudioRecorder 的回调里维护了一组状态:先要连续超过阈值一小段时间才确认”这是人在说话”,说话过程中短暂的音量下坠有容忍窗口(音节之间本来就会掉下去),确认说话后又要连续静音若干秒才自动停;如果压根没检测到人声,则等一段时间后放弃。此外 voice.max_recording_seconds 提供一个总时长硬顶,让一个不停说话的人也会被截断。停止时还有两道丢弃逻辑:太短的录音直接扔,以及用整段的峰值 RMS(不是平均值,平均会被尾部静音稀释)判断这段到底有没有人声。
流本身是复用的。_ensure_stream() 注释写明输入流创建一次后就一直保活,两次录音之间回调只是把音频丢掉——因为在 macOS 上关掉再开 InputStream 会无限期挂住。这类”不是我想这么写、是平台逼我这么写”的注释,在这个文件里到处都是。
转写这一层交给 tools/transcription_tools.py,本机 faster-whisper 是默认且免密钥(首次会自动下载模型),另外还有几家云端 provider,各要各自的密钥;配置驱动,并且同样支持用户自声明的命令型 provider 和插件型 provider。涉及云端 provider 的具体规则各家不同且会调整,以官方最新说明为准。
转写完还有两道过滤特别值得单独看。第一道是幻听过滤:仓库里维护了一张 Whisper 在静音/近静音音频上常吐的短语表(英文的”thank you""thanks for watching”这类,也包括俄语、日语、意大利语的字幕客套话),再加一条重复模式的正则,命中就当作空转写返回。第二道是停止短语:voice.stop_phrases 默认只有 stop,判定刻意做得很严——整句在小写并剥掉首尾标点后必须完全等于配置里的某一条,所以”stop the docker container”仍然会正常送给 Agent。两道过滤的顺序也是有讲究的:停止短语先判,因为像 bye、okay 这类词同时落在幻听表里,先过滤就会导致用户说了停止词却毫无反应。
抢话(barge-in)是这个文件里最精巧的一段。full_duplex_listen() 从提交问题一直听到这一轮结束,按 30 毫秒一块,用一个”是否正在出声”的回调把过程分成两相:生成相里房间是安静的,用前若干块标定噪声地板,触发线是地板乘一个倍数;播放相里安静基线被锁住、绝不拿扬声器串音去重新标定,触发线再往上夹一个下限,并且播放刚起来的一小段窗口内抑制触发,避开起播瞬态。命中判定用滑动窗口里的多数票而不是严格连续计数,这样词内的能量下坠不会把进度清零。命中后从一段回溯缓冲开始录——所以你的第一个音节不会丢——直到你安静足够久为止。想现场调,HERMES_VOICE_DEBUG=1 会把每个决策点打到标准错误。
顺带一提,“正在出声”这个信号是由播放路径用引用计数维护的(播放开始加一、结束减一),因为按回合计的 TTS 完成事件在整轮里都是”忙”,哪怕管线其实正在静静等文本。同一个信号还被用来压住”思考音”——Agent 可能思考或跑工具好几分钟,全程零音频听起来像死了,所以有一个默认开启、可用 voice.thinking_sound 关掉的低音量水泡音在填这段空白,真的有语音在播时自动跳过。
四、说回来:合成的三层退路与流式的代价
tools/tts_tool.py 是这四块里 provider 最多的一块:内置了 Edge TTS(默认、免密钥)、几家云端服务,以及几个纯本机模型。默认值的选法有个态度值得注意——_get_provider() 的注释写着”有推理凭据不等于同意付费生成语音”,所以哪怕你已经配了某家的密钥,只要没显式设 tts.provider,它仍然停在免费的 Edge 上。
在内置名单之外,它给了两条扩展缝:插件注册的 provider,以及用户自己在配置里声明的命令型 provider。后者的形态是这样(摘自模块内的说明):
tts:
provider: piper-en
providers:
piper-en:
type: command
command: "piper -m ~/model.onnx -f {output_path} < {input_path}"
output_format: wav
Hermes 把待朗读文本写到临时 UTF-8 文件,按占位符渲染命令并执行,再去读命令写出的音频文件。占位符支持输入路径、输出路径、格式、音色、模型、速率这几类,且渲染时会按占位符所处的 shell 引号上下文(裸、单引号、双引号)分别转义,所以带空格的路径不会炸。分发顺序被写死并且在两个地方各校验一遍:内置名永远优先(用户配一个叫 openai 的命令型 provider 也盖不掉真的 OpenAI 处理器),命令型优先于同名插件。
这条缝也是全篇最需要你自己掂量的地方:它就是在你机器上执行你写的 shell 命令。仓库的处理是子进程环境默认被洗掉 Hermes 的密钥,只有你在 env_passthrough 里点名的变量才从父进程拷回来;超时后按进程树清理(Windows 走 taskkill /F /T,其它平台递归终止再升级到 kill)。这些都是必要的,但它改变不了”这条路径的信任边界等于你写进配置的那行命令”这个事实。
输出格式这一层的坑最多。几个聊天平台只有 Ogg/Opus 才渲染成原生语音气泡,而 Edge 只出 MP3、某些本机引擎只出 WAV、某些兼容服务器会直接忽略你请求的 opus 格式。结果就是一个后缀写着 .ogg、内容却是 MP3 的文件,在平台上表现为一个 0 秒的坏气泡。仓库没有为每个 provider 单独打补丁,而是合成完统一嗅探魔数:容器和后缀不符就用 ffmpeg 原地转成真正的 Ogg/Opus,转不了就改名成它真实的后缀——宁可给一个诚实的文件,也不给一个坏气泡。
流式是另一条路。按 docs/streaming-tts.md 的说法,管线是四段:模型吐文本增量、SentenceChunker 累积增量并在完整句子处冲刷(连跨增量被切开的思考块也一起剥掉)、流式 provider 把每句变成裸 PCM 块、音频汇要么是本机输出流要么是平台适配器的写入缝。默认它用你已经配好的那个 provider 去流式——只有当这个 provider 真有分块接口时才流,不会为了流式偷偷换掉你的音色;想覆盖就设 tts.streaming.provider,写 auto 则按一条固定优先级走到第一个凭据能解析出来的那家:
tts:
provider: gemini
streaming:
provider: gemini # or "auto"
四家流式后端的传输方式并不统一:两家走分块 HTTP,一家走 SSE,一家走 WebSocket,但对上层是同一个接口。没有分块接口的 provider 也不是就退回”整段合成”——它们走的是按句同步合成再播放的路径,所以默认的 Edge 也能做到”第一句就开口”,只是时间到第一声音频比真流式长。
代价在这里:按句朗读意味着朗读顺序和文本生成顺序绑死,一旦某句合成失败,代码只是记一条告警然后继续下一句,你听到的是一段被跳过的回答。流式实现里还有一段”跳过重复句”的逻辑——模型偶尔会把同一句重复吐出来,去重是为了不让耳朵听两遍,但它同时意味着回答里合法的重复句也会被吞掉一次。平台侧同理:一轮流式音频顺利结束,这一轮的整段语音回复会被抑制以免放两遍;而如果流式在还没有任何声音出来之前就失败,才会退回整段文件那条老路。
五、边界与代价:它明确不管什么
它不替你解决麦克风。 detect_audio_environment() 对 SSH 会话、容器、WSL 这三种场景各写了一段判定:先看有没有可达的声音服务器(相关环境变量或套接字),有就只记一条提示放行,没有才判成硬失败并给出指引(把 PulseAudio/PipeWire 的套接字挂进来、把相应环境变量指过去)。但它只是检测和劝告,声音服务器不会自己长出来。WSL 那段还多一层:即使没有转发,只要系统自带的播放路径还能用,也只降级成提示——因为那条路只覆盖播放,录音仍然缺声音服务器。桌面权限同理:macOS 上”流开着全是静音”的标准解释就是没给麦克风权限,代码只能提示你去系统设置里点。
唤醒词不做说话人识别。 它判定的是”这段音频里有没有这个短语”,谁说的一律算。屋里任何人、电视里任何一句巧合的音,达到阈值就能开一次会话——而这个会话背后是一个能开终端、能写磁盘的 Agent。仓库给的对策是提高阈值和连续帧确认,这是概率上的削减,不是权限上的隔离。真正的权限边界得靠别的机制来划,参见 Agent 权限设计。
它放弃了”一个麦克风多处共用”。 整机一把锁、pause/resume 手动交接,是明确的取舍:换来的是行为可预测,代价是你不能一边让桌面端听唤醒词一边让命令行录音。
它不承诺检测的语音留在本机。 请分清两件事:唤醒词检测确实全程在本机,但唤醒之后那句命令是否离开机器,取决于你的转写 provider 选的是本机模型还是云端服务;朗读同理。想让整条链路不出网,转写和合成两端都必须选本机 provider——这也是它把这两块设计成配置驱动的实际价值所在。相关的数据面风险,可以对着 AI 工具的数据安全风险 那份清单过一遍。
它不管音色训练和识别调参。 音色是 provider 的事,模型是你自己下的;仓库这一层只负责分发、截断、格式修复和播放兜底。
朗读文本是被改写过的。 所有播报路径共用一个清洗器,思考块、markdown、emoji 会被剥掉,单位和符号会被展开,换行会被压成句读。这对耳朵是必要的,但意味着你听到的和屏幕上的不是一份东西——排查”它为什么没念那段”时,先去看清洗器而不是模型。
六、上手与避坑清单
先把两端配齐再开唤醒词。 会踩是因为唤醒词看起来是个独立开关,直觉上先打开再说;实际上转写或合成任一端没配好,武装检查会直接拒绝,你会得到一条不知所云的提示。怎么避:先跑通 /voice on 下的一次问答,再去 /wake on。
先确认麦克风真的在出声,再怀疑短语。 会踩是因为”检测器起来了”和”麦克风有声音”是两件事,权限没给、选错设备的表现都是”喊了没反应”,和阈值太高一模一样。怎么避:先看状态里的静音标记;确认是设备问题就把 wake_word.input_device 设成 PortAudio 的设备序号或一段唯一的设备名,而不是去调灵敏度。
误触发先动连续帧确认,别先动阈值。 会踩是因为多数人对”灵敏度”的第一反应就是往上拧,而单帧阈值拧太高会连你自己念清楚的短语也一起挡掉。怎么避:把 confirmation_frames 从默认值往上加一到两级,代价只是几十毫秒延迟,对付背景人声比拧阈值对症。
别在 macOS ARM64 上钉 ONNX。 会踩是因为它看起来是个”选个后端”的无害配置,实际那个组合下分数永远过不了阈值。怎么避:把推理后端留空走自动判断;仓库现在会强制改回 tflite 并告警一次,但你早年钉下的配置未必碰得到这条修正。
Telegram 之类平台上先装 ffmpeg。 会踩是因为免费默认的 Edge 只出 MP3,而这些平台的原生语音气泡要 Ogg/Opus。怎么避:装上 ffmpeg;不装的话你会拿到一个后缀被改成真实格式的普通附件,不是气泡——这是它诚实降级的表现,不是 bug。
命令型 provider 当成”给 Agent 递了一把 shell”来审。 会踩是因为配置文件里一行 command 看着和其它配置项没差别,实际每次朗读都会执行它。怎么避:这行命令自己写、别贴来源不明的,密钥靠点名放行而不是整体继承,并且明确它的超时行为;日常这类常驻组件的巡检口径可以参考 Agent 日常运维。
觉得”它总打断我”和”它从不打断我”,看的是同一段代码的两端。 会踩是因为端点判定的容忍窗口、静音时长、总时长硬顶是三个独立参数,症状却互相像。怎么避:先开调试输出,把每块的 RMS、地板、触发线、窗口计数打出来,再决定动哪一个——这一步比猜参数快得多。
要不要在自己机器上开这条链路,我给三个自检问题:唤醒之后开的那个会话,能做的事你能接受屋里任何人(包括电视)以概率触发吗?你的转写和合成 provider,是本机的还是出网的,你说得清吗?你能接受语音回复是被清洗器改写过、可能跳过失败句和重复句的版本吗?三个都能点头,再往下走。
接着往哪读,按你的痛点选:想弄清”喊了没反应”,读 tools/wake_word.py 里的武装检查和静音检测两段;想弄清”总被提前截断”,读 tools/voice_mode.py 里录音回调那一大段状态机;想弄清”回答完才开口”,先读 docs/streaming-tts.md 那四段架构说明再回到 tools/tts_streaming.py;想加一个自己的流式后端,那份文档里”新增流式 provider”一节直接给了五步,测试放在 tests/tools/test_tts_streaming.py。唤醒词那块的行为约定也有对应测试可以对照读,在 tests/tools/test_wake_word.py。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的并行派活机制 和 开源项目 Hermes Agent 里的四处成本旋钮各拦哪类失控。