📌 隶属于架构蓝图: 企业级 AI Agent 与本地 GPU 算力自动化 → L1: 确定性编排与业务状态机

为什么本地语音合成一直在念 “Percent”?语音管线里的 URL 编码与沙盒避坑记

· 6 分钟 · 排障手记 ttsdebuggingn8nurlencodepythonai

刚把阿里巴巴开源的 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 参数在网络与容器网关中的流动链路,整个过程如下:

  1. 客户端转码:当客户端通过 HTTP GET 发送带中文参数的请求时,所有非 ASCII 字符必须按照标准进行百分号编码(Percent-Encoding)。例如汉字 恭(UTF-8 编码为 0xE6 0x81 0xAD),在 URL Query 中会被编码为字符串 "%E6%81%AD"。
  2. n8n Webhook 接收:n8n 收到请求后,直接将原始 Query 参数挂载到数据上下文:
    {
      "query": {
        "text": "%E6%81%AD%E5%96%9C%EF%BC%81..."
      }
    }
  3. HTTP Request 节点转发:在最初的简易工作流中,下游转发给本地 GPU 服务器的 JSON Body 直接使用了变量插值:
    {
      "text": "={{ $json.query.text }}"
    }
  4. 模型收到原始转码文本:本地运行在 50000 端口的 Python FastAPI 服务直接收到了一个含有几十个 % 符号的纯 ASCII 字符串 "%E6%81%AD..."。

3. 为什么现代神经 TTS 不会自动转义?

在早期的传统规则式 TTS 中,如果传入奇怪的编码字符,系统往往会直接报错或丢弃非中英文字符。

但像 CosyVoice 2 或 GPT-SoVITS 这类新一代基于大语言模型架构(LLM + Flow Matching / Diffusion)的 TTS 系统,其文本前端包含复杂的文本规范化模块(Text Normalization)。为了让模型能够读懂各种数学符号、技术参数与代码片段,它的分词器将 % 显式映射为了发音为 “Percent” 的有效 Token。

因此,对 TTS 模型来说:


4. 根因剖析 2:N8N 沙盒表达式的隐形暗坑

发现问题后,第一反应通常是在 n8n 的 HTTP 节点表达式中直接加一句 JS 解码:

// 表面合理的写法:
{{ decodeURIComponent($json.query.text) }}

但这会立刻引发二次故障:

  1. POST 请求断流:如果调用方改用 POST JSON 发送请求,$json.query 会变成 undefined,直接报错 Cannot read property 'text' of undefined。
  2. ES 语法特性支持差异:在较老或某些特殊打包的 n8n 环境中,内嵌的 JavaScript 解释器对可选链操作符(如 $json.query?.text)的支持并不稳定,极易出现 Unexpected token '.' 语法错误。
  3. 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 企业自动化工作流,确保核心数据不出内网:

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts