喊一声它要沉默两秒:本地语音不再每次重新加载模型


你对着麦克风说「嘿,帮我查一下今天的日程」,然后等。一秒,两秒,第三秒它才开口——它听见你了,只是还在把语音模型加载进内存。更让人难受的是:这件事每次唤醒都会发生一遍。你甚至没法抱怨它“启动慢”,因为它是每次都“重新启动”。

本地 TTS(Piper、KittenTTS 这类跑在自己机器上的语音引擎)为了省内存是懒加载的:用到才载入。这个设计本身没问题,问题出在卸载的时机上。v0.21.5 里的一个新配置项把这件事修正了,同一批还让桌面端的语音循环真正开始读你的配置。这篇讲清每个开关。

一、问题不在加载慢,在卸载太快

先看原来的逻辑:每次你打开“朗读回复”或者开始一段语音会话,就相当于对语音引擎持有一次租约(lease);关掉时释放。当最后一个租约释放、持有者计数归零,代码会立刻卸载模型。

那段“唤醒词式”的对话是这样的:你喊一声,它进入语音会话——这中间有一秒多的预热加载(Piper 的声音模型是几十 MB,KittenTTS 最多约 80MB)——你说一句话,对话结束,租约释放。于是模型在加载完几毫秒之后就被驱逐了。下一次唤醒,从零再来一遍。

还有一个更隐蔽的变体:释放发生在加载过程中的时候(CLI/TUI 里切换语音的 acquire 和 release 跑在不同线程上),卸载会看到一个空缓存,于是模型留在内存里,但持有者是零——既占着内存,又没被复用。

二、tts.keep_warm_seconds:卸载等一等

新的配置项是 tts.keep_warm_seconds,默认 60:

tts:
  keep_warm_seconds: 60    # 最后一次关闭后再保留这么久(0 = 立即卸载)

语义很直白:最后一次释放后,卸载会被安排在一个可取消的定时器上等这么久。在这个窗口内,任何一次重新开启(重新 acquire)都会取消这次卸载,直接复用已经加载的模型。具体有三个细节值得知道:

  • 按调用读取配置,所以每个 profile 自己的 config.yaml 生效,不是全局写死。
  • **用一个代数计数器(generation counter)**保证一个已经醒来的定时器变成空操作——不会出现“定时器醒了、又已经有人重新持有”的竞态把模型卸掉。
  • 一次没有持有者的预热结束后,窗口会重新开始计时——也就是说你刚预热完立刻关掉,仍然是按 60 秒算。

配置默认值在 hermes_cli/config_defaults.py 里,说明写进了 website/docs/user-guide/features/tts.md 的租约小节和 configuration.md 的 TTS 块。想验证自己拿到的值:

hermes config get tts.keep_warm_seconds    # 60
hermes config set tts.keep_warm_seconds 0  # 想省内存就设 0,行为回到"立即卸载"

设成 0 是给“内存紧张、不在乎每次重载”的人留的退路。反过来,如果你的语音交互很密集,可以调大——代价是一段时间内那几十 MB 常驻内存。

三、租约本身是跨界面的,换句话说它能防“互相踩”

这一点是设计里比较容易忽略但很实用的部分:每个开关持有一份租约,只有跨所有界面的最后一个租约释放时模型才会卸载。所以你在桌面端一个窗口里关掉朗读,不会把另一个窗口正在进行的语音对话的引擎抽走。

同样的租约机制也覆盖用户自定义的 provider:命令行型 provider 会执行它可选的 warm_command / release_command,Python 插件型 provider 会收到 warm() / release()。也就是说你自建的语音服务同样可以按开关预加载和卸载,而不是自己想办法挂钩。桌面端调用的是 POST /api/audio/tts-lease,请求体是 {"lease": "<name>", "active": true} 或 false,其他前端可以直接用同一个端点。

另外一个小而有用的配置:tts.streaming.min_len(默认 20)决定多长的首句才被单独朗读;更短的开场白会跟下一句一起读。默认 20 适合英语,中日韩的语音配置一般用 6 左右。

四、桌面端的口头停止,现在读你的配置

症状:你按提示说“отбой”想结束语音对话,结果这句话被当成 prompt 发给了 agent。

原因:判断口头停止的 isVoiceStopCommand() 只匹配一份写死的英文列表,而界面上提示你的那句话来自 voice.stop_phrases。俄语用户看到提示说“说 отбой 结束”,说出来的词却根本不在匹配表里。

修法(PR #120751):匹配器改为读取和提示同一份 voice.stop_phrases,并且沿用后端的语义(tools/voice_mode_transcript.is_voice_stop_phrase):

voice:
  stop_phrases: ["stop"]   # 整句话必须等于其中一个短语才结束;[] 表示禁用口头停止
  • 整句话必须等于某个短语(忽略大小写和标点),不是包含;
  • 写成裸字符串会被当成一个短语;
  • [] 表示禁用口头停止;
  • 值格式不合法时回落到默认值。

还有一个必须注意的兼容细节:/api/config 会把 DEFAULT_CONFIG 合并进来,所以默认安装永远会报出 stop_phrases: ["stop"]。如果实现只做“读到什么用什么”,那么每一个桌面端用户都会失去 goodbye、never mind、cancel 这些额外英文短语——这次的实现会把加载到的列表和 /api/config/defaults 做比较,避免这个回归。

五、喊话打断播放,用你的阈值

症状:你戴着蓝牙耳机,在它朗读回复的时候开口打断——它不理会。

原因:触发“打断播放”的阈值写死为 PLAYBACK_MIN_TRIGGER_LEVEL = 0.14,这个值高于蓝牙 HFP 耳机在播放时的说话电平,所以你怎么说都够不到那条线。

修法(PR #120751):改用已有的 voice.barge_in_threshold_multiplier(默认 3.0)——CLI/TUI 的全双工监听器一直在读这个键,只是桌面端没用它。没有新增配置项,也没有环境变量。 配置里的注释写得很清楚:播放前先标定安静房间的底噪,说话触发的门槛是“底噪 × 这个倍数”。

六、朗读不再念占位词,表格改念表头

症状:用中文(或其他非英文)语音时,读到代码块的位置会念出英文的 “code block omitted”,读到链接的位置念 “link”;遇到 Markdown 表格则整块消失,没有任何提示。

修法(PR #120752):

  • 代码块和 URL 完全静默——任何语言都不再念占位词,代码在屏幕上照常可见;
  • MEDIA: 文件链接令牌不会进语音(桌面端清洗器和后端规范化器 tools/tts_text_normalize.py 都改了,后者同时支撑 gateway、CLI 语音和桌面的 WS/POST 转发);行内令牌紧跟的标点会保留,所以 “see MEDIA:/x.py. Then” 的句号还在;
  • 行尾冒号会闭合:当“这里有三个:“后面跟着的代码块被移除时,冒号会被改成句号;前面是数字的冒号(比如 3:2)在后端不会被动;
  • 表格改念表头行——它会读 “Item, Value, Notes.” 然后跳过数据行。听的人知道这里有一张表、比的是什么,又不用一个单元格一个单元格地听下去。表头全为空的表格保持静默。

七、逐句朗读:还在 main 上

还有一条相关但是不在 v0.21.5 里的修复,值得单独说明,因为它解决的是“长回答开头几秒的沉默”:

用 Edge(或其他非流式)TTS 时,桌面端语音模式以前会等整段回复生成完再一次性合成,所以长回答前面会有好几秒没声音。文档承诺的是逐句朗读,但之前的修复只覆盖了流式 provider,同步回退路径(awaitFallbackSpeech)仍然在等完整回复。现在这条路径维护一个句子队列,每切出一句就立刻播放(PR #121685 / issue #79314);同时被封存的中间气泡(message.interim)会在每个轮询周期被冲刷,它的最后一句在工具执行期间就被念出来,而不是等工具跑完。

这条 PR 合入 main 的时间是 2026 年 9 月 24 日下午(UTC 约 17:12),晚于 v0.21.5 在当天上午 10:09 UTC 的发布,所以目前只在 main 上。

八、功能键语音快捷键可以重新绑定(这条在 v0.21.5 里)

一键口述变成了一个未绑定的 composer.dictate 键盘动作,和语音对话分开;它复用了桌面端既有的按键捕获路径(F 键、Caps Lock、Delete、Space 以及组合键)。过时的桌面端 voice.record_key 字段被移除,并注明该配置只对 CLI/TUI 有效。Electron 侧加了仲裁:被指派给渲染进程的 F12 能收到按键,没被指派的 F12 保留 DevTools 行为(PR #120928)。

同一个窗口里,macOS 上的功能键工作流也修好了:TUI 解析器保留 F1–F12 终端序列(包括带修饰键的协议路径),并且给 TUI 和经典 CLI 的实时工作坞加了 Ctrl+R 作为 F7 的可移植备选(PR #120666)。

发布状态一览

能力 PR 状态
tts.keep_warm_seconds 保活 #121080 在 v0.21.5 里
口头停止读 voice.stop_phrases #120751 在 v0.21.5 里
打断阈值用 voice.barge_in_threshold_multiplier #120751 在 v0.21.5 里
朗读不再念占位词、表格念表头 #120752 在 v0.21.5 里
功能键口述可重绑 #120928 在 v0.21.5 里
macOS F1–F12 与 Ctrl+R 备选 #120666 在 v0.21.5 里
Edge/同步 TTS 逐句朗读 #121685 仅在 main

你可以做的自查

hermes config get tts.keep_warm_seconds                 # 60
hermes config get voice.stop_phrases                    # - stop
hermes config get voice.barge_in_threshold_multiplier   # 3.0

然后做一次真实测试:喊一次唤醒词,说完一句结束对话,隔几秒再喊一次——第二次的反应应该明显更快(模型还在内存里)。

小结

  • 本地语音的痛点是卸载太早,不是加载太慢:唤醒式对话在预热结束几毫秒后就释放租约。
  • tts.keep_warm_seconds(默认 60)让卸载等一等,窗口内重新开启会取消卸载;0 回到立即卸载。
  • 口头停止和打断阈值现在都读你的配置,不再用写死的英文列表和硬编码电平。
  • 朗读不再念占位词,表格改念表头;逐句朗读的修复还在 main 上,没进 v0.21.5。

这一批属于 v0.21.5 那个“没有发布说明”的补丁窗口,完整清单见站内的 v0.21.5 发版页 和 v0.21.5 窗口自查指南。