我的博客技术栈是 Next.js + Gin(Go),没有一行 Python。但当我决定给文章加朗读功能时,最终方案的核心却是一个 20 行的 Python 微服务——这篇文章记录这个"跨栈集成"的完整决策过程,以及三次教科书级的排坑。
一、选型:三条路的账
给 Web 文章配语音,摆在面前的路有三条:
| 方案 | 成本 | 音质 | 复杂度 |
|---|---|---|---|
| 浏览器 Web Speech API | 零 | 取决于访客设备,参差 | 十行代码 |
| 云厂商 TTS API | 按字符计费 | 优秀 | 中(key/计费防护) |
| Edge-TTS 自建 | 零 | 自然(微软神经音色) | 一个小容器 |
Web Speech API 的致命伤是"听感不可控"——你的文章在 Windows 上是自然女声,在某安卓机上是机械电音。对"通勤族边走边听"这个核心场景,音质的确定性就是体验本身。
Edge-TTS 的原理是调用微软 Edge 浏览器"大声朗读"功能的公开接口:无需 API key,音色是 Azure 神经语音同源(zh-CN-YunxiNeural 男声自然度相当能打)。它需要一个 Python 运行时——于是问题变成:Go 技术栈怎么优雅地引入这个能力?
我的答案:不是移植,是雇佣。把它当成一个说 Python 的外包员工,用 HTTP 协议对接,20 行 FastAPI 就是一个完整的部门。
二、架构:三层职责切干净
浏览器 ──▶ BFF 流式直通 ──▶ Gin /api/article/:id/tts ──▶ tts 微服务
├─ 读正文+清洗 Markdown ├─ 分句分块(~600字)
├─ 内容哈希磁盘缓存 ├─ 3路并发合成
└─ 命中缓存 7ms 返回 └─ MP3 字节拼接
职责切分的依据是信任边界:
tts 微服务只做一件事:文本进、音频出。它不碰数据库、不知道文章是什么,甚至不在公网暴露——只挂内网网络,请求必须带环境变量下发的 token。这意味着即使有人想拿它当免费公共 TTS 刷流量,连门都找不到。
Gin 层承担所有"脏活":从数据库取正文、把 Markdown 清洗成适合朗读的纯文本、按内容哈希做磁盘缓存、校验文章 id 合法性。特别注意最后一点——接口只接受数字 id,不接受任意文本。这保证了没人能把你的服务当成开放转写 API 用。
清洗规则值得单独说:朗读和阅读对文本的要求完全不同。代码块要变成句号停顿、图片链接语法要剥掉只剩 alt 描述、Markdown 标记符号全部去除、连续空行压成单换行。一段排版精美的技术文章,清洗后应该是流畅的"广播稿"。
三、坑一:403 与时钟 DRM
第一版服务起来后,合成请求直接 403:
WSServerHandshakeError 403: wss://speech.platform.bing.com/...&Sec-MS-GEC=...
Sec-MS-GEC 是微软给朗读接口加的防滥用校验:客户端按 UTC 时间窗口生成哈希随握手发送,服务端验签。我装的是 edge-tts 7.0.0——而微软后来调整过校验算法,旧库生成的签名不再被认可。
排查时先怀疑时钟漂移(这类校验最经典的死法),对比宿主机和容器的 UTC 时间:秒级一致,排除。然后查版本:最新 7.2.8 已适配新算法。升级,一次通过。
教训:依赖"逆向公开接口"的库,版本老化不是性能问题,是可用性问题。这类库的升级优先级应该视为安全补丁级。
四、坑二:长文之死与分块并发
短文本测试通过后,接入真实文章——直接 502。短文 28 字成功、整篇 3600 字失败,问题锁定在长度上。
edge-tts 对单次会话的内容量有隐性限制,超长文本会话中途被掐。解法是经典的分治:按句子边界切成约 600 字的块(每块约 1.5 分钟音频),信号量限制 3 路并发(防止触发微软连接数风控),各自独立合成,最后 MP3 字节直接拼接。
MP3 能这么拼是因为它是自同步帧格式:每帧都有帧头,字节流直接相连播放器就能正确解码。不需要重新编码,不需要 ffmpeg,b"".join(chunks) 一行搞定。
分块的额外红利是速度:六段并行,3600 字全文的合成时间从串行的近一分钟压到 10 秒——比很多人等一个普通接口还快。
五、坑三:NoAudioReceived 与三级容错
分块后仍然偶发失败,且复现出一个诡异模式:同一篇文章永远是特定的某一块失败,报 NoAudioReceived——微软对这段文本就是不吐音频,原因不明(疑似含特殊符号序列触发静默拒绝)。
既然单点失败无法根除,就把它变成系统可以吸收的扰动。合成函数加了三级容错:
async def synth_robust(text, voice, rate): for i in range(3): # 一级:指数退避重试 try: return await synth_once(text, voice, rate) except Exception: await asyncio.sleep(0.4 * (i + 1)) clean = sanitize(text) # 二级:净化非常规字符再试 if len(clean.strip()) >= 10: try: return await synth_once(clean, voice, rate) except Exception: pass return b"" # 三级:该段跳过留白
第三级的取舍值得说明:缺一句好过整篇失败。跳过的段落在音频里只是几秒空白,听众几乎无感;而因为一段符号导致整个朗读按钮报错,才是真正的体验灾难。当然要有底线——成功块占比低于 50% 直接判整体失败返回错误,不能交付一篇千疮百孔的音频。
六、边界感:免费资源要用得克制
Edge-TTS 处于灰色地带的免费——微软默许个人低量使用,但没有 SLA 承诺。用它就要拿出用免费资源的样子:
- 缓存优先:音频按
文章id + 内容哈希存盘,热门文章二次访问 7ms 返回,不重复消耗微软资源; - 并发自我约束:3 路信号量,不占满对方连接池;
- 失败降级:合成失败返回明确错误,前端显示"合成失败,点击重试",而不是让访客干等;
- 入口收敛:公网只能通过文章 id 触发,任意文本转写的口子从一开始就不存在。
免费的东西更要省着用——这不是道德姿态,是可持续性工程。
七、账本
| 项 | 数字 |
|---|---|
| 新增组件 | 1 个 20 行 Python 微服务 |
| 3600 字全文首次合成 | ~10 秒(6 块并行) |
| 缓存命中响应 | 7ms |
| 音频体积 | 约 3MB / 10 分钟 |
| 月成本 | ¥0 |
回看这次集成的完整路径:选型时用"雇佣外包员工"的思维绕开了技术栈壁垒;实现中三次排坑分别对应依赖老化、规模上限、外部不确定性三类经典故障;最后用缓存和限流给免费资源划出了可持续的边界。
多模态的第一步落地了:博客现在既能被读(RAG 问答),也能被听(TTS 朗读)。下一步也许是让两者打通——听完一段感兴趣,直接问 AI 助手文中细节。输入输出两端已经就位,中间只差一根线。