语音 Agent 为何把格式符号读出声
LLM 输出的 Markdown、金额、日期和电话号码,直接交给 TTS 可能产生错误读法。文章指出应在合成前做文本规范化,并提醒逐条追加的正则规则会相互干扰。
AI 深度解读
语音 Agent 把「星号、星号」念出来,未必是模型差,也未必是语音合成出了错。作者遇到的问题在两者之间:LLM 输出适合阅读的文本,TTS 却需要适合朗读的句子。金额、日期、电话号码,都不能只靠去掉 Markdown 来处理。
这篇文章最有用的地方,是拆开了文本规范化里的规则冲突。作者还介绍了自己写的 speakable,但比工具更值得带走的,是处理顺序和听觉验收这两条经验。
为什么文字没错,电话里却念错了
原文的例子很直观:模型返回 **Invoice #42**: $1,234.56 due 2026-11-11.,放在聊天窗口里没有问题,到了电话里,星号、井号、日期中的横线却可能被逐个念出。
作者的解释是,LLM 接触了大量文档、Markdown、代码和文章,自然倾向于生成书面表达。TTS(语音合成)对这些表达的支持则不一致:有些能识别金额,有些能处理缩写,但作者认为,不能指望任何一家覆盖所有情况。更换供应商或版本后,读音也可能变化。
因此,合成前需要一个文本规范化阶段,把书面形式转换成可朗读形式。
这里的难点不只是清理符号。(555) 123-4567 应该按电话号码逐位读,不能当成几个大整数;Dr. Rao 里的 Dr. 是称谓;14:00 需要按时间理解。数字长得相似,承担的语义却不同。
年份也有自己的习惯:2026 可以读成「twenty twenty-six」,2005 是「two thousand five」,1905 则是「nineteen oh five」。一个通用的整数转英文函数,覆盖不了这些差别。
规则为什么必须有明确的执行顺序
最初的修补通常是 text = text.replace("*", "").replace("#", "")。它能消除部分杂音,却没有解释金额、日期和编号该怎么读。
继续补正则后,更麻烦的问题来了:规则会抢同一段文本。
作者提出,Markdown 清理要先执行,避免 **$5** 干扰后续金额匹配;日期要先于电话号码,否则 2026-11-11 可能被识别成带连字符的号码;邮箱和 URL 要先于修改句点的规则,否则 john.doe@example.com 可能被拆坏;金额要先于普通整数,否则 $1,234.56 可能只剩一个流畅的数字读法,货币单位却丢了。
规范化规则的执行顺序,就是这层模块的设计。
作者花了一个下午排查的时间正则,很能说明问题。下面这段代码试图识别时间和可选的上午、下午标记:
re.sub(r"\b(\d{1,2}):(\d{2})\s*([ap]\.?m\.?)?", say_time, text)
要看的是末尾的 \.?:处理 Call at 3:30 PM. 时,它会把句末句点一起吞掉。另一个问题是 \s* 放在可选组外,即使没匹配到 PM,也会消耗后面的空格,让 Departs at 14:00 sharp 出现单词粘连。
作者给出的修正同时收紧了结尾,并把空白移进可选组:
re.sub(r"\b(\d{1,2}):(\d{2})(?:\s*([ap]\.?m))?", say_time, text)
关键是 (?:\s*([ap]\.?m))? 这一段:只有匹配到上午、下午标记时,才一并消费前面的空白。这处修改解决的是原文列出的两个问题,不能据此认定时间解析已经覆盖全部写法。
电话号码也有边界陷阱。原文模式 r"(?<![\w$.])\+?\d[\d\-.\s()]{6,18}\d(?![\w])" 从首个数字开始匹配,处理 (555) 123-4567 时会留下开括号。作者指出,开括号也必须被包含在匹配范围内,但没有给出修正后的完整表达式。
speakable 能接在哪,边界又在哪
流式输出还有一类特殊输入:模型输出到一半,或达到输出上限,只留下 **Your total is。只匹配成对标记的清理规则拿它没办法,两个星号仍会进入 TTS。
作者的处理思路是,先执行成对规则,再清理残留标记。截断的回答已经不完整,不该再让格式符号增加听觉噪声。不过,原文没有说明如何跨流式分块保存状态,不能把清理残留标记直接等同于完整的流式解析方案。
作者将这层逻辑整理成了 speakable。安装命令是 pip install speakable,通过 from speakable import normalize 导入;在文中 LiveKit agent 的示例里,合成前调用 await tts.say(normalize(llm_response))。
按作者介绍,它支持 Markdown 和 emoji 清理、五种货币、ISO/美式/文字日期、12 和 24 小时制时间,以及电话、百分比、序数、单位、缩写、邮箱和 URL,还能选择逐字母朗读首字母缩略词。
作者称其核心零依赖,每条话语处理约 1.5 ms,并具有幂等性,也就是同一内容处理两次不会继续变化。项目采用 MIT 许可证。当前不支持非英语地区格式、印度 lakh/crore 数位分组、罗马数字和分数;原文也没有列出五种货币的具体名单。
我的判断
我认可把规范化独立成模块。它有清楚的输入输出,也有规则之间的依赖,适合单独维护和回归验证。相比在业务代码里不断追加 .replace(),明确的规则顺序更容易解释问题出在哪里。
但工具效果和性能,目前只能按作者自述理解。原文没有提供测试语料规模、运行环境,也没有给出不同 TTS 供应商的对照数据。约 1.5 ms 不能直接当作所有团队的性能承诺,「所有 TTS 都处理不全」也缺少逐家验证。
另一种常见做法,是在提示词里要求模型输出纯文本、使用口语表达。这能减少格式噪声,但具体金额、日期和号码仍需要稳定的处理规则。两种方法可以配合,原文没有做效果对比。
对国内团队,最大的限制是语言:作者明确说还不支持非英语地区格式,不能默认套到中文金额、中文日期或中英混读场景里。
能拿来做什么
- 如果正在做语音 Agent,就在 LLM 输出与 TTS 之间建立独立的规范化模块,并写清日期、电话、金额和普通数字的执行顺序。
- 用原文里的金额、年份、邮箱、括号电话号码和未闭合
**建立回归样例,同时检查转换结果与实际录音,尤其留意单位丢失、标点消失和单词粘连。 - 英文语音项目可以评估 speakable,重点验证业务常见格式与重复处理是否稳定;中文项目应先梳理自己的朗读规则,再判断需要实现多少语言相关逻辑。
- 更换 TTS 供应商或版本时,用同一批业务文本重新试听,把原本正确的读法是否发生变化纳入验收。