语音合成失败一次,你的录音就没了:Hermes 修掉这个 P0

你让 Hermes 把一段会议纪要念成音频存下来,顺手在指令里补了一句“存到 meeting-notes.mp3”。那个文件名,是你上午刚录完的客户通话。几秒后工具报了个错,说 TTS provider 不可用。你点开那个文件——没了。不是内容被覆盖,是文件本身不在了。
这不是你在文件系统里手滑,而是 Hermes 一个被标为 P0 的 bug:一次失败的语音合成,会删除 output_path 指向的已有文件。10 月 9 日它已经被修掉(PR #135531,改动只有 5 行加一个测试文件)。这篇把机制、修复和你能做的防护讲清楚。
为什么一次失败会把你的文件删掉
要理解这件事,先看 text_to_speech 这个工具是怎么收尾的。
它支持长文本分块合成,所以每次调用都会维护一个集合 generated_artifacts:这次合成“碰过”的每一个路径都记在里面。等整个调用结束(无论成功还是失败),finally 里统一做清理——把集合里那些最终没有交付出去的文件 _remove_quietly() 掉。这个设计的初衷完全合理:分块合成失败了,那些 .chunk001.mp3、.chunk002.mp3 的半成品不该留在磁盘上。
问题出在记录的那一刻。修复前的代码是这样的:
chunk_path = base_path # 也就是 output_path 指向的路径
if len(chunks) > 1:
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
generated_artifacts.add(str(chunk_path)) # ← 用户自己的文件被登记成了"我生成的临时文件"
output_path 是模型可以自己选择的工具参数。当它指向一个已经存在的文件时,旧代码会把这个文件当成自己这次合成出来的产物登记进清理集合。于是:
- 合成成功 → 投递步骤把音频
os.replace()覆盖上去,你的文件被新音频替换(这是预期行为); - 合成失败 →
finally里的清理逻辑认领了这个路径,把它删掉。
失败的那条分支就是 P0 的来源:用户的东西从来没有参与过这次合成,却被清理逻辑算成了“半成品”。这个 bug 是在对插件目录做评审、检查 wyoming-voice-bridge 时被发现的。
修复:先写到隔壁,成功再换过去
新的 _synthesize_chunks 在写之前加了一次存在性判断:
if os.path.lexists(chunk_path):
# A file we didn't create: synthesize beside it so a failed attempt can neither truncate
# nor sweep it; on success the delivery step os.replace()s the audio over it (overwrite).
chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")
也就是说,目标路径已经存在时,合成会写到一个隐藏的兄弟文件 .meeting-notes.9f3c...mp3。接下来两条路径都变干净了:
- 失败:清理逻辑只会删掉那个兄弟文件。你原来的文件从头到尾没被打开、没被截断、没被 unlink。
- 成功:投递步骤照旧
os.replace()把音频换到目标路径上——覆盖语义和以前完全一致。
用一个“新路径”的时候(包括默认的时间戳缓存路径)走的是和以前一样的代码,失败时的半成品也照旧会被清理干净。
顺带一提,这个工具入口本来就有一批路径闸门,只是它们拦的是“不该写的地方”,而不是“不该覆盖的文件”:
..穿越组件会被拒绝(绝对路径允许,相对路径不能往上跳);- 含控制字符/换行分隔符的路径会被拒绝;
- 路径里带
MEDIA:指令会被拒绝(否则下游的媒体投递标签会被伪造); - 命中凭据/系统保护路径(
file_safety的is_write_denied/is_write_approval_required)会被拒绝。
不指定 output_path 时,默认落在 <audio cache>/tts_<timestamp>.<ext>:以时间戳命名,几乎不可能撞上你已有的文件。这也解释了为什么大多数人从来没见过这个 bug——撞上它需要“刚好指向一个已有文件”。
如果你已经踩过一次:能救回来吗
先说不好听的:被 unlink 的文件不走回收站,普通情况下救不回来。但如果你平时用上了这几样东西,损失可以很小:
- 检查点(checkpoints):Hermes 的检查点能在会话里回滚文件改动,
hermes checkpoints系列命令可以查看和恢复。注意它是为“Agent 改坏了文件”设计的,前提是改动发生在被追踪的工作区里。 /save与 session 导出:会话记录里通常留着当时的文本内容,重跑一次合成就能拿回音频本身(语音内容可重建,录音不可重建——这是关键区别)。- 把重要文件放在 git 工作区之外:类似的“文件被工具误伤”教训我们已经写过一个 —— 见 工作区脏数据导致文件丢失的那次事故。
- 需要了解检查点的完整用法,看 hermes checkpoints 命令参考。
怎么用才安全
一句话:不要用 output_path 指向你舍不得丢的文件。
- 想让它念一段文字给你,直接不给
output_path,用默认缓存路径; - 想归档音频,用一个新文件名(
summaries/2026-10-10-daily.mp3这类),而不是复用已有文件; - 如果确实要固定覆盖某个输出文件,明白“成功=覆盖、失败=原文保留”这个契约现在已经成立了,但你自己别在它旁边放唯一副本。
什么时候能拿到
这个修复合并于 2026-10-09,晚于 v0.21.6 的发布(10 月 8 日),所以它目前只在 origin/main 上,还没有进入任何正式 release。想立刻用上,可以 hermes update 跟进主线;否则等下一个版本发布后自然生效。
配套的回归测试也一并进了仓库(tests/tools/test_tts_failure_preserves_existing.py),用三行断言钉住了这个契约:provider 失败时已有文件必须存活且不残留半成品,新路径失败时不留垃圾,成功时依然覆盖。这个 bug 值得记住的一点是——工具会“清理自己的垃圾”,但前提是它能分清哪些是垃圾。把别人的文件登记成自己的临时产物,代价就是用户的录音。如果你在做插件或工具,这 5 行改动值得抄一遍。