吴健
← 全部作品

CASE STUDY · WECHAT MINI PROGRAM

UtaNote

独立设计、开发并上线的「用日语歌学日语」微信小程序,覆盖 LLM 解析管线、自托管 TTS 与内容安全合规全链路。

LLM 管线TTS微信小程序云函数内容安全

UtaNote 是吴健独立设计、开发并上线的微信小程序,让用户通过日语歌曲学习日语。从产品设计、前端、云函数后端、LLM 管线、TTS 服务到内容安全合规,全链路由他一人负责;以个人主体通过微信审核上线,服务真实用户。

核心链路:用户粘贴歌词 → 云函数将歌词分批并发调用 LLM(DeepSeek)解析 → 产出逐词读音(furigana)、语法角色与整句翻译 → 自托管 VOICEVOX 引擎生成朗读音频(TTS)。

四个故事对应四类工程问题——正确性对齐、降级设计、成本控制、内容合规——均来自真实的线上事故与设计决策。

STORY 01

LLM 输出错位事故与重建校验——「宁缺勿错」不变量

问题

解析管线里每行歌词有两套分词:本地 Intl.Segmenter(快、免费、边界保守)和 LLM 返回的 tokens(附带读音与语法角色)。上线初期的实现按下标把 LLM 的读音、语法角色逐位贴到本地分词上。

真实线上事故:Intl.Segmenter 把「薄く」切成「薄|く」(把形容词连用形拆开了),而 LLM 按语言学上正确的方式切成一个词「薄く」。边界差一个位置,下标 zip 之后整句左移一位——「く」被标成「とうめい」(透明的读音)、「な」被标成「くちざわり」(口触り的读音)。错位读音混进 furigana,TTS 又把「透明」「口触り」各读了两遍。用户看到的是:注音是错的、朗读是重复的——对教日语的产品,这是致命错误。

根因:两个独立系统对同一文本的切分之间没有任何一致性保证,而旧代码假设它们逐位对齐。这不是实现层面的 bug,而是设计错误——假设本身就不成立。

方案

重建校验(reconstruction check):

  • 把 LLM tokens 的 text 拼接后与原句比对(忽略空白差异):拼得回去 → 整体采用 LLM 的边界、读音、角色;拼不回去 → 整句退回纯本地切分,并且不贴任何 LLM 数据。这是一条「全有或全无」的不变量。
  • 缺读音的内容词,用该词自身的文本推假名(wanakana,只对纯假名词有效)兜底——关键在于兜底数据来自 token 自己的 text,不再依赖位置对齐。
  • 客户端再加一道防线:TTS 发送前检测假名轨是否含汉字(含汉字说明某词读音丢失、已退回原文),检出即弃用整条假名轨,退回原文朗读。

结果

真实事故句「薄く透明な口触りで」被固化为回归测试,另补充空白差异、缺 token、空 text 等降级用例。修复部署后,新解析的错位问题归零。

设计权衡 Q&A

Q为什么不用编辑距离/LCS 做部分对齐,尽量多保留 LLM 数据?

部分对齐会产生「看起来对了、实际错位」的中间态,而在语言学习产品里,错误的读音比缺失的读音危害更大——用户会把错的学进去。「全有或全无」的不变量简单、可证明、可测试。教育产品的取舍是正确性优先于覆盖率。

Q为什么不在 prompt 里强制 LLM 按本地分词输出,从源头消除分歧?

prompt 里确实提供了本地分词作参考,但 LLM 不提供硬保证;而且这次事故里 LLM 的切分在语言学上反而是对的(「薄く」本来就是一个词)。正确做法不是强迫 LLM 迁就本地的错误切分,而是让校验器决定信谁。

Q服务端修好了,为什么客户端还要再防一道?

两个原因。其一是存量数据:已解析的歌存在用户设备上,服务端修复不追溯,重新导入之前老数据仍是错位的。其二是防御纵深:假名轨的生成路径未来还会演进,客户端检查的是最终不变量——送给 TTS 的读音轨不应含汉字——与上游实现解耦。

STORY 02

LLM 分批并发管线——槽位对齐的降级设计

问题

一首歌最多 40 行。单次请求把全部歌词塞给 DeepSeek 有三个问题:慢(长输出容易逼近 60 秒超时,微信云函数新函数默认超时更只有 3 秒)、脆(一次失败整首歌损失)、贵(重试代价是全量重跑)。

方案

管线拆成三层,全部提炼为纯函数并配单元测试:

  1. chunk:8 行一批(CHUNK_SIZE=8),平衡单批时延与请求数;
  2. runWithConcurrency:手写并发池,最多 4 批同时在飞(MAX_CONCURRENCY=4),返回 Promise.allSettled 形状、严格按输入顺序。不引入 p-limit 类库:云函数在意依赖体积,且约 30 行手写实现换来完全可控可测;
  3. mergeChunkResults:合并阶段的核心不变量——每个批次恰好贡献与该批行数相等的槽位。返回短了补 null,长了截掉多余,整批失败则全部填 null。null 槽位在组句阶段降级为本地基础版(分词+假名,无翻译)。

这样,任何一批的任何异常——失败、超发、短发——都不可能让其他批次的行发生错位。这与故事一是同一个设计哲学:对齐靠不变量保证,不靠运气。

可观测性与细节:

  • token 用量跨批累加,连同行数、来源、耗时记入解析日志;部分失败时标记 source='partial',并向前端透传人类可读的 warning。
  • 歌名生成只挂在第一批的 prompt 后缀上——每批都问浪费 token,中段歌词猜标题也不比开头准。

设计权衡 Q&A

Q失败的批次为什么不重试?

权衡的结果。重试会增加时延(用户正在等待),而失败最常见的原因是超时——重试大概率再次超时。降级到本地基础版保证「永远有东西可用」,用户可以自行选择重新导入。对照另一个功能 askLine(AI 唱法讲解:低频、单次、答案会被缓存复用)则选择重试一次,因为收益结构不同——权衡基于场景,而不是教条。

Q8 行/批和 4 并发这两个数怎么定的?

8 行/批让单批输出落在 DeepSeek 的舒适区(不逼近超时);4 并发意味着 40 行拆成 5 批、一轮多一点就能跑完,同时不触发上游限流。

QLLM 返回非法 JSON 怎么办?

双保险:请求侧用 response_format: json_object;解析侧 extractJSON 容忍 markdown 围栏与 prose 包裹。仍失败则该批 rejected → 全槽位填 null 降级,不炸整个请求。

STORY 03

TTS 成本工程——内容寻址双层缓存与双层额度

问题

朗读音频由自托管 VOICEVOX 生成(引擎跑在他的个人设备上,通过隧道暴露给云函数),语音合成是全链路最贵的操作。产品要求:用户重复播放不能重复付出合成成本。

方案

两层缓存,键的设计是核心:

  • 内容层 tts_cache:cacheKey = hash(text + voice + speaker + speed + pitch + intonation)。纯内容寻址——同一句话无论出现在哪首歌、哪个用户那里,音频只合成一次,全局共享。
  • 资产层 song_tts_assets:assetKey 由业务坐标构成(来源/歌曲/卡片/行/类型/语速)。资产命中直接返回;资产 miss 但内容层命中 → 只做一次 bind(把业务坐标指向已有音频),不合成、不消耗额度

请求处理顺序本身编码了成本不变量:查资产 → 查内容缓存 → 都 miss 才查额度 → 合成 → 上传 → 写缓存 → 最后才执行 commitUsage。额度检查放在缓存之后,意味着缓存命中永远免费;commitUsage 放在最后,意味着失败的合成不会扣用户额度。

额度双层:全局日限额保护自托管服务不被打爆,单用户日限额防滥用;普通歌词句与自定义文本的限额分开管理(自定义文本的滥用面更大,限额更紧)。所有限额通过环境变量可调,无需重新发版。

结果

核心不变量成立:播放与缓存命中永远零成本,只有真实合成消耗额度。

设计权衡 Q&A

Q两个用户同时 miss 同一句话,会发生什么?

会双份合成,后写入的缓存记录冗余,但正确性不受影响——内容寻址保证两份音频一致。不加锁是清醒的选择:云函数实例间无共享内存,用数据库做分布式锁的复杂度和失败面大于偶发一次冗余合成的成本,低频场景下最终一致就够了。

Q额度检查是 read-then-increment,不是原子的,会超卖吧?

会,并发窗口内可能超 1-2 个。但这是软限额(保护性质,非计费依据);_.inc 原子递增保证计数本身不丢,超卖量有界且无害。若换成付费额度就必须改用事务或条件更新——边界在哪里是清楚的。

Q为什么资产层不直接存音频,要多一层内容缓存?

复用率。同一首热门歌被多个用户导入时,句子文本是相同的——资产层是每用户/每歌的业务视图,内容层是全局去重的存储视图。分层之后,成本曲线跟着内容量走,而不是跟着用户量走。

STORY 04

个人主体的合规工程——内容安全纵深与 fail-closed

问题

UtaNote 以个人主体上线,UGC 有三个入口:粘贴歌词(解析)、自定义朗读文本(TTS)、AI 生成讲解(askLine)。任何一个入口流出违规内容,代价是整个主体账号。

方案

  • 所有 UGC 入口统一接入微信 msgSecCheck;超长文本按 2000 字分块逐块检查。
  • 判定 fail-closed:错误码 87014 → 按内容风险拒绝;其他非零错误码或接口异常 → 一律按「暂不可用」拒绝,不放行。理由是风险不对称:合规风险的下界是封号,可用性损失的下界只是用户重试一次——所以宁可误杀。
  • askLine 双向检查:用户输入的歌词行和 LLM 生成的讲解都要过检——生成式内容平台同样被追责,只查输入是常见漏洞。
  • 缓存命中不重查:安全检查在写入时完成;缓存键是内容寻址的,键不变则内容不变,写入时的检查结论持续有效。这让「命中免费」的成本不变量与合规要求互不冲突。
  • 另设 REVIEW_SAFE_MODE 开关,审核期间收敛高风险功能面。
  • 前置过滤looksJapanese 启发式(假名占比 ≥15%)在消耗 LLM 调用和每日解析额度之前就把非日语文本挡掉;客户端镜像同一逻辑做即时拒绝——省成本,也缩小内容风险面。

结果

个人主体小程序稳定通过微信审核并持续在线;判定与分块逻辑均提炼为纯函数并带单元测试。

设计权衡 Q&A

Qfail-closed 会不会把微信接口的抖动放大成功能不可用?

会,这是有意的取舍。缓解在于:检查只拦「生成」这一步,已生成内容(缓存命中)不受影响——接口抖动期间,老内容全部可用,只有新生成暂停。爆炸半径被缓存层天然限制住了。

QLLM 输出检查通过后才写缓存,那检查标准日后变严,老缓存怎么办?

诚实的回答:会存在存量豁免,与 TTS 引擎版本是同一类问题——需要离线重扫脚本。缓存文档里保存了 promptVersion 和原文,重扫在技术上可行,只是尚未到需要执行的阶段。

已知局限与后续方向

公开这些局限,是因为知道边界在哪里,和知道方案为什么成立同样重要。

  1. 对齐降级粒度偏粗(故事一):降级是整句级的,一个 token 异常就会丢弃全句的 LLM 数据。精细化方向是「前缀对齐」——从句首逐 token 匹配,匹配段采用、分歧点之后降级;但前提是先证明它不会重新引入错位中间态。
  2. 批参数未做系统化调优(故事二):8 行/批、4 并发来自工程判断加实测,未做正交实验;流量增长后值得系统化调参。
  3. cacheKey 不含引擎版本(故事三):VOICEVOX 升级后旧缓存不会自动失效,音色会新旧混杂;engineVersion 已记录在缓存文档中,升级时需离线迁移脚本按字段清洗。这是有意接受的取舍:引擎升级是低频运维事件,不值得为它复杂化 cacheKey。

开发工作流:人负责决策,AI 负责施工

UtaNote 深度使用 AI 辅助开发。吴健的分工方式是:他负责架构设计、接口约定和验收标准,AI(以 Claude Code 为主,多模型交叉审查)负责施工。正确性由三道关卡保证:

  1. 关键逻辑必须提炼成纯函数并配单元测试——解析、讲解、TTS 的核心 helpers 全部由此而来;
  2. 红测试不允许提交——测试不过,代码不进主干;
  3. 重要改动跑多模型互审——不同模型交叉审查彼此的产出。

上面每个故事里的设计决策——宁缺勿错的重建校验、槽位对齐的合并不变量、「命中永远免费」的额度顺序、fail-closed 的合规判定——都由他拍板,AI 负责把决策变成代码。这套工作流与高级工程师带团队同构:定义清楚约束与验收标准,把执行交出去,再用测试和评审收口。