为什么本地语音合成一直在念 “Percent”?语音管线里的 URL 编码与沙盒避坑记
刚把阿里巴巴开源的 CosyVoice 2 模型部署到局域网并挂载上群晖 NAS 的 n8n 自动化工作流,在控制台用 Python 脚本直调合成一切正常。然而,当我们第一次尝试通过 n8n 的 Webhook 端点发起 HTTP GET 测试时,音箱里传出来的却是一段令人窒息的“外星密码”。
原本传入的中文台词是:“恭喜!本地 CosyVoice 2 模型通过群晖 N8N 联动测试成功!”
但生成的音频却以极其严肃认真的语调,一口气连念了十几秒:
“Percent E 6, percent 81, percent 81, percent B 9, percent A 7, percent 9… percent…”
模型没有崩溃,甚至声线极为清晰、咬字极其标准,但它在不断疯狂重复 “Percent”。
这是一起非常典型、但在语音合成自动化链路中极容易踩中的“文本前端(Text Normalization)灾难”。本文记录这起 Bug 的全链路成因与双层防御方案。
1. 现象复现:无限的 “Percent” 循环
我们在终端使用 curl 模拟普通外部请求调用 n8n 的 Webhook:
curl -G "http://192.168.1.1:5678/webhook/ltts" \
--data-urlencode "text=恭喜!本地模型测试成功!" \
--data-urlencode "voice=child" \
-o test.wav
接口返回状态码 HTTP 200,并成功下载了一段 2.4MB 的标准 WAV 音频。但当我们将音频丢进本地的语音识别(STT)模型逆向转写核对内容时,转写文本原形毕露:
{
"text": "percent E 6 percent 81 percent 81 percent B 9 percent A 7 percent..."
}
模型并没有出现自回归幻觉,它只是在“百分之百忠实”地读出它所收到的字符串。
2. 根因剖析 1:被透传的 URL 百分号编码
追溯 HTTP 参数在网络与容器网关中的流动链路,整个过程如下:
- 客户端转码:当客户端通过 HTTP GET 发送带中文参数的请求时,所有非 ASCII 字符必须按照标准进行百分号编码(Percent-Encoding)。例如汉字
恭(UTF-8 编码为0xE6 0x81 0xAD),在 URL Query 中会被编码为字符串"%E6%81%AD"。 - n8n Webhook 接收:n8n 收到请求后,直接将原始 Query 参数挂载到数据上下文:
{ "query": { "text": "%E6%81%AD%E5%96%9C%EF%BC%81..." } } - HTTP Request 节点转发:在最初的简易工作流中,下游转发给本地 GPU 服务器的 JSON Body 直接使用了变量插值:
{ "text": "={{ $json.query.text }}" } - 模型收到原始转码文本:本地运行在 50000 端口的 Python FastAPI 服务直接收到了一个含有几十个
%符号的纯 ASCII 字符串"%E6%81%AD..."。
3. 为什么现代神经 TTS 不会自动转义?
在早期的传统规则式 TTS 中,如果传入奇怪的编码字符,系统往往会直接报错或丢弃非中英文字符。
但像 CosyVoice 2 或 GPT-SoVITS 这类新一代基于大语言模型架构(LLM + Flow Matching / Diffusion)的 TTS 系统,其文本前端包含复杂的文本规范化模块(Text Normalization)。为了让模型能够读懂各种数学符号、技术参数与代码片段,它的分词器将 % 显式映射为了发音为 “Percent” 的有效 Token。
因此,对 TTS 模型来说:
- 它根本不知道上游传入的是经过 URL 编码的汉字。
- 它只看到文本里写着一个个真实的百分号、大写字母 E 和数字 6。
- 于是,它严格按照规范将一段 15 个字的中文句子,当成了包含几十个数学符号和英文序列的混排公式,认认真真地读出了每一个 “Percent”。
4. 根因剖析 2:N8N 沙盒表达式的隐形暗坑
发现问题后,第一反应通常是在 n8n 的 HTTP 节点表达式中直接加一句 JS 解码:
// 表面合理的写法:
{{ decodeURIComponent($json.query.text) }}
但这会立刻引发二次故障:
- POST 请求断流:如果调用方改用 POST JSON 发送请求,
$json.query会变成undefined,直接报错Cannot read property 'text' of undefined。 - ES 语法特性支持差异:在较老或某些特殊打包的 n8n 环境中,内嵌的 JavaScript 解释器对可选链操作符(如
$json.query?.text)的支持并不稳定,极易出现Unexpected token '.'语法错误。 - URIError 崩溃风险:如果用户传入的文本本身包含合法的独立百分号(例如“降价 50%”),未经包装的原生
decodeURIComponent("50%")会直接抛出URIError: URI malformed,导致整个工作流执行中断。
5. 双层防御方案
为了让整条语音合成链路彻底免受各类编码问题的困扰,我们在编排层与模型层同时设置了兜底防线。
第一道防线:N8N Code 节点独立预清洗
在 Webhook 节点之后、HTTP 转发节点之前,插入一个纯 JavaScript Code 节点,统一承接 GET 与 POST 数据并做容错解码:
const item = $input.first().json;
const query = item.query || {};
const body = item.body || {};
let text = query.text || body.text || '你好,本地语音测试。';
let voice = query.voice || body.voice || 'default';
// 仅在文本包含百分号时尝试解码,并在异常时优雅回退
if (typeof text === 'string' && text.indexOf('%') !== -1) {
try {
text = decodeURIComponent(text.replace(/\+/g, ' '));
} catch (err) {
// 包含非法百分号组合时保持原文,避免流程崩溃
}
}
return [{
json: {
text: text.trim(),
voice: String(voice).trim()
}
}];
第二道防线:后端 Python 服务幂等兜底
在本地运行的 FastAPI 接口实现中(webui.py),对传入的 text 参数增加一层无条件但幂等的 urllib.parse.unquote:
import urllib.parse
@app.post("/tts")
async def api_tts(request: Request):
body = await request.json()
raw_text = body.get("text", "").strip()
# 无论上游是否已经解码,在此做一次幂等安全解码
text = urllib.parse.unquote(raw_text).strip()
voice = body.get("voice", "default")
# 送入推理管线...
unquote 是幂等的:如果文字已经是纯中文,处理后原样返回;如果有任何遗漏的 URL 编码,会被彻底还原为真实汉字,绝不把 % 留给下层的分词与发音模块。
6. 验证与总结
在部署好双层防护后,我们重新使用同样的带 URL 编码命令请求接口:
curl -G "http://192.168.1.1:5678/webhook/ltts" \
--data-urlencode "text=恭喜!本地模型通过 N8N 联动测试成功!" \
--data-urlencode "voice=child" \
-o test_clean.wav
重新将生成的音频送入语音识别转写:
{
"text": "恭喜!本地模型通过N8N联动测试成功!"
}
所有的 “Percent” 杂音彻底消失,取而代之的是清脆甜美的正常女童声。
工程经验: 在搭建涉及“网络网关 → 自动化编排器 → AI 神经推理模型”的多层系统时,绝不能假设到达 API 边界的数据格式天然符合预期。在网关层做安全清洗、在模型接口层做幂等兜底,两行轻量代码就能彻底杜绝 AI 模型在半夜把网络请求头念给用户听的尴尬事故。
需要搭建高可用本地 AI 语音管线或业务自动化?
我为企业和开发者定制并交付基于私有 GPU 算力的高保真语音合成(TTS)、本地语音识别(ASR)与 n8n 企业自动化工作流,确保核心数据不出内网:
- WhatsApp:+60 12-797 2969
- Email:[email protected]
- 网站:hoelee.com