用谷歌翻译把一句话来回翻译 20 次,再把最后生成的文字当作歌词唱出来,是最近短视频平台上很常见的创意挑战。外行看是恶搞,程序员的视角里却藏着一个很有意思的工程问题:机器翻译输出是稳定的吗?语义是怎么在多次往返中一点点丢掉的?如果把这项能力封装成一个脚本,我们要考虑哪些异常和限制?这篇文章就把这个实验拆成可执行的模块,从翻译源选型、20 轮循环、逐轮留痕,到相似度分析、TTS 音频生成完整跑一遍。
文章面向对 Python 脚本、机器翻译质量分析和多媒体小工具有兴趣的开发者。学完后,你不仅能用代码复现“歌词漂移 20 次”的效果,还能把同样的思路用到翻译质量评估、多语言数据清洗和文案走查上。
1. 把“翻译 20 次后唱出来”拆成可以执行的子问题
1.1 这个实验到底在测什么
“谷歌翻译 20 次《人是猫》”这类挑战,核心操作是:把一段中文文本交给机器翻译,翻译成英文,再翻译回中文,再翻译成英文,如此往复。每一次往返结束,文本都会因为翻译模型对上下文理解的差异而发生变化。若干轮之后,语义会偏离原文很远,甚至产生“一本正经胡说八道”的效果。
原作者最后“自己唱出来”,本质上是给漂移后的文本加了一层表现力。就算机器生成的文本语法没毛病,但它往往和真实歌词的节奏、音调、情绪已经完全不匹配了。正是这种不匹配,构成了创意的笑点。
从技术角度,这个项目可以拆成四件事:
- 低成本调用翻译服务,最好能切换不同翻译源。
- 用循环自动完成 N 次翻译,而不是手工复制粘贴。
- 把每一轮文本和状态保存下来,便于观察漂移过程。
- 把最终文本转成音频,或者生成对齐歌词卡,方便“唱出来”。
本文会用一个最小脚本完成前三点,用 TTS 工具完成第四点。整个项目不依赖复杂框架,适合作为周末小项目复现。
1.2 设计输出物
如果输入文本是“人是猫,猫是人,白天睡觉晚上跑”,一次完整运行结束后,应该得到三类输出:
| 输出物 | 格式 | 作用 |
|---|---|---|
| 逐轮翻译记录 | JSONL | 记录每一步的文本、语言方向、时间、长度 |
| 分析结果 | 终端表格 + PNG 图 | 观察语义相似度随轮次下降的趋势 |
| 最终音频 | MP3/WAV | 把第 20 轮文本朗读出来,供演唱练习使用 |
JSONL 里的每一步都是一行独立 JSON,即使程序中途崩溃,也不会把前面的记录全丢掉。这是处理长流程任务时非常有用的设计。
注意:不要一开始就盯着“最终结果多好笑”,先确认每一轮都成功落盘。翻译失败时,能定位到具体轮次,比重新跑 20 轮省时间得多。
2. 为什么越翻越走样:机器翻译的误差累积
2.1 翻译是生成不是替换
第一版机器翻译系统确实接近“查词典 + 规则调整”:把中文词替换成英文词,再按语法规则调整语序。这种系统对词序敏感的句子很容易翻错。
现在的神经机器翻译完全不同。它把源文编码成语义向量,再基于这个向量逐个生成目标语言 token。给定同一个源句,模型会计算每个目标词的概率分布。多数情况下模型会选择概率最高的词,但概率不是 1,句子中任何一个词的取舍都带有统计偏差。
一个词被换成近义词,后续句子结构就可能跟着调整。举例来说:
- 原文:人是猫,猫是人,白天睡觉晚上跑
- 某轮英文翻译可能是:People are cats, cats are people, sleeping by day and running at night
- 再翻回中文可能是:人是猫,猫是人,白天睡觉,晚上跑步
到这里还算稳定。但如果某一步模型把“人”理解为“人类”,把“猫”理解为“猫科动物”,后面就会沿着这个语义继续扩散。这不是一次性的错误,而是逐步累积的过程。
2.2 回译的误差不会自动消除
有人会想:既然中文翻英文会丢信息,那再翻回中文,不就把丢失的信息补回来了吗?
在机器翻译评测中,确实有一种方法叫“回译”,常用于数据增强:用低资源语言翻译出大量伪平行语料,再用它训练模型。但回译不是无损操作。它只能保证“源文的核心语义大概率还在”,无法保证词义、语序、隐喻、风格和韵律都保留。
原因是语言之间存在系统性差异:
- 中文经常省主语,英文必须补主语。
- 中文没有英语那种严格的时态变化,英文模型要选择用过去时还是现在时。
- 中文“他/她/它”发音相同,书面文本却不同;英文 all same he? no actually gender pronouns ambiguous in speech but textual. 多义词一旦被选中,另一个含义就被丢弃。
- 形容词、量词、语气词在不同语言里激活的语境不一样。
当这种不确定性连续叠加 20 次,浅层错误会逐渐变成深层次语义漂移。某些文本甚至会完全丢失原本指代对象,变成逻辑通顺但与原文无关的新文本。
2.3 用相似度把“走样”变成数字
手工观察 20 条记录太累,也不够客观。可以用文本相似度指标把“走样程度”量化。
常用指标包括:
| 指标 | 计算思路 | 优点 | 不足 |
|---|---|---|---|
| SequenceMatcher | 基于字符子序列匹配 | 实现简单、无需安装 NLP 模型 | 对同义词替换不敏感 |
| Levenshtein 距离 | 编辑距离:删除、插入、替换的次数 | 适合发现字符级变化 | 中文按字处理时语义价值有限 |
| Jaccard 相似度 | 分词后集合交集 / 并集 | 能反映关键词覆盖情况 | 无视词序 |
| BLEU | 计算候选文本和参考文本 n-gram 重合 | 机器翻译领域通用指标 | 需要参考译文,这里不一定有 |
对这个项目来说,difflib.SequenceMatcher已经足够看到趋势。如果想看关键词是否还保留,可以结合jieba分词后计算 Jaccard。后面第五章会给出具体实现。
3. 环境准备与翻译源选型
3.1 Python 依赖与目录
实验基于 Python 3.9 以上版本。新建一个目录,例如lyric_chain,在其中创建虚拟环境并安装依赖。
mkdir lyric_chain cd lyric_chain python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate依赖文件requirements.txt内容如下:
deep-translator>=1.11.4 requests>=2.31.0 jieba>=0.42.1 edge-tts>=6.1.10 matplotlib>=3.9.0安装命令:
pip install -r requirements.txt依赖说明:
deep-translator负责把多个翻译服务包装成统一调用方式。requests用于调用自建翻译服务时的 HTTP 请求。jieba用于中文分词,计算关键词保留率。edge-tts用于把最终文本合成为语音。matplotlib用于绘制逐轮相似度曲线。
版本号不必死守,安装时如果遇到依赖冲突,使用 pip 解析出的兼容版本即可。落地前确认 Python 环境和依赖版本,否则后面报错容易分不清是代码问题还是环境问题。
3.2 翻译源如何选
项目标题明确是“谷歌翻译”,所以默认翻译源自然选谷歌。但纯工程实现不能把鸡蛋放在一个篮子里:免费接口有请求频率限制,某些网络环境下未必稳定。比较好的做法是把“翻译源”抽象成一个接口,然后在配置里切换。
可选翻译源对比如下:
| 翻译源 | 调用方式 | 请求限制 | 适用场景 |
|---|---|---|---|
| Google 翻译免费接口 | deep-translator 的 GoogleTranslator | 有频率限制,不稳定 | 本地小规模实验、快速验证 |
| Google Cloud Translation API | 官方 REST/gRPC 接口 | 按项目配额和付费额度 | 生产级、需要稳定 SLA |
| LibreTranslate | 自建或第三方实例,POST /translate | 取决于部署实例 | 需要离线或私有化环境时 |
| 百度/有道云翻译 | 各云厂商 SDK/HTTP | 按账号额度 | 国内网络环境中比较常用 |
代码层面,不要写死某一个翻译源。定义一个基础类TranslatorBackend,每个翻译源实现一个子类,主流程只依赖父类接口。
下面代码是 Google 免费接口和 LibreTranslate 的实现示例。
# translator_factory.py from typing import Dict class TranslatorBackend: def translate(self, text: str, target: str) -> str: raise NotImplementedError class GoogleWebBackend(TranslatorBackend): """基于 deep-translator 的 Google 翻译封装,适合低频率小规模实验。""" def translate(self, text: str, target: str) -> str: from deep_translator import GoogleTranslator return GoogleTranslator(source="auto", target=target).translate(text) class LibreTranslateBackend(TranslatorBackend): """自建 LibreTranslate 或其他兼容实现。""" def __init__(self, base_url: str, api_key: str = ""): self.base_url = base_url.rstrip("/") self.api_key = api_key def translate(self, text: str, target: str) -> str: import requests payload = { "q": text, "source": "auto", "target": target, "format": "text", } headers = {} if self.api_key: headers["Authorization"] = "Bearer " + self.api_key resp = requests.post( self.base_url + "/translate", json=payload, headers=headers, timeout=20, ) resp.raise_for_status() return resp.json()["translatedText"] def build_backend(name: str) -> TranslatorBackend: if name == "google": return GoogleWebBackend() if name == "libre": return LibreTranslateBackend("http://127.0.0.1:5000") raise ValueError(f"unknown backend: {name}")这段代码的关键点是:主流程不需要关心底层是 Google 还是 LibreTranslate,只要调用translate(text, target)就能拿到结果。后续如果想换成官方云 API,只需要新增一个类,不改主流程。
3.3 连通性探针
翻译源选好后,先运行一个最小探针,确认服务可用。探针不需要翻译完整歌词,翻译一个短句即可。
import sys from translator_factory import build_backend backend = build_backend("google") text = "人是猫" try: result = backend.translate(text, "en") print("source:", text) print("target: en") print("result:", result) except Exception as exc: print("translation failed:", type(exc).__name__, exc) sys.exit(1)如果输出类似result: People are cats的内容,说明翻译链路是通的。这里出现 HTTP 429、403 或连接超时,都属于翻译源接入问题,应先解决接入,再继续后面的 20 轮循环。
4. 核心实现:20 轮翻译与逐轮留痕
4.1 语言方向设计
“翻译 20 次”有两种常见理解:
- 在中文和英文之间往返 20 次,每次调用都算一次翻译。
- 让文本在不同语言间依次接力,每切换一种语言算一次翻译。
为了方便复现两种效果,主脚本支持roundtrip和chain两种模式。roundtrip模式下语言序列固定为["en", "zh-CN"],奇数次调用翻译成英文,偶数次翻译回中文。chain模式下可自定义语言序列,未指定时使用["en", "ja", "fr", "de", "zh-CN"]。
4.2 主循环脚本
下面是完整主脚本run_chain.py,它负责三件事:
- 从命令行读取模式和轮数。
- 每次翻译前记录状态,翻译后立刻写入 JSONL。
- 请求之间加入间隔,规避频率限制。
# run_chain.py import argparse import json import time from datetime import datetime, timezone, timedelta from translator_factory import build_backend CST = timezone(timedelta(hours=8)) LOG_FILE = "records.jsonl" ROUNDTRIP_LANGS = ["en", "zh-CN"] CHAIN_LANGS = ["en", "ja", "fr", "de", "zh-CN"] def now_text() -> str: return datetime.now(CST).isoformat(timespec="seconds") def append_record(record: dict) -> None: with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") def target_lang(step: int, mode: str, seq: list[str]) -> str: if mode == "roundtrip": return seq[step % len(seq)] return seq[step % len(seq)] def main() -> None: parser = argparse.ArgumentParser(description="Run N-step machine translation chain.") parser.add_argument("--text", default="人是猫,猫是人,白天睡觉晚上跑") parser.add_argument("--rounds", type=int, default=20) parser.add_argument("--mode", choices=["roundtrip", "chain"], default="roundtrip") parser.add_argument("--interval", type=float, default=0.8) parser.add_argument("--backend", choices=["google", "libre"], default="google") args = parser.parse_args() seq = ROUNDTRIP_LANGS if args.mode == "roundtrip" else CHAIN_LANGS backend = build_backend(args.backend) text = args.text append_record({"step": 0, "direction": "source", "target": "", "text": text, "time": now_text()}) for step in range(1, args.rounds + 1): lang = target_lang(step - 1, args.mode, seq) try: translated = backend.translate(text, lang) except Exception as exc: print(f"[step {step}] translate to {lang} failed: {type(exc).__name__}: {exc}") break if not translated: print(f"[step {step}] empty result, stop") break append_record({ "step": step, "direction": f"step{step - 1}->{lang}", "target": lang, "text": translated, "time": now_text(), }) text = translated print(f"[step {step:>2}] target={lang:<6} len={len(translated):<5} text={translated}") if step < args.rounds: time.sleep(args.interval) if __name__ == "__main__": main()运行方式:
python run_chain.py --text "人是猫,猫是人,白天睡觉晚上跑" --rounds 20 --mode roundtrip --backend google参数说明表格:
| 参数 | 含义 | 默认值 | 建议 |
|---|---|---|---|
--text | 起始文本 | “人是猫,猫是人,白天睡觉晚上跑” | 换成你准备测试的歌词片段或原创文本 |
--rounds | 翻译调用次数 | 20 | 验证链路时先用 2 次 |
--mode | roundtrip 或 chain | roundtrip | 想看逐轮漂移用 roundtrip;想看跨语言接力用 chain |
--interval | 两次请求间隔秒数 | 0.8 | 免费接口建议不要小于 0.5 |
--backend | 翻译源 | 不稳定时切到其它实现 |
这段代码有意识地做了三个设计选择:
- 每次翻译完立刻写文件,而不是等 20 轮结束后一次性写。前面跑过再多轮,中途断掉也保留前 15 轮结果。
- 失败时不静默吞掉,而是打印错误并 break,保留已经完成的记录可继续分析,避免死循环刷请求。
- 间隔时间参数化,避免因为请求过密触发限流。
4.3 空结果和失败处理
免费翻译接口偶尔会返回空字符串。主脚本遇到空结果会停止,避免把空字符串传给下一轮翻译后产生""导致后续错误。
如果希望失败后重试而不是直接停,可以在 except 分支里加入指数退避。重试逻辑如下:
import time max_retries = 3 for attempt in range(1, max_retries + 1): try: translated = backend.translate(text, lang) break except Exception as exc: print(f"[step {step}] attempt {attempt} failed: {exc}") if attempt == max_retries: raise time.sleep(2 ** attempt)指数退避比固定等待更有效:它从 2 秒、4 秒、8 秒逐步加大间隔,给服务恢复留出时间,避免在限流窗口内反复请求加重问题。这是调用任何第三方接口都应该养成的习惯。
4.4 运行与预期输出
使用默认参数运行后,终端会输出类似下面的内容:
[step 1] target=en len=23 text=People are cats, cats are people... [step 2] target=zh-CN len=20 text=人是猫,猫是人... ... [step 20] target=zh-CN len=35 text=人类和猫科动物在昼夜节律上存在相似之处...需要强调:第 20 轮的具体内容不是固定不变的。翻译引擎模型更新、请求状态、不同账号环境都会影响结果。你不应该期待每次运行得到一模一样的文字,这正是这个实验里最大的变量。如果想复现一份“稳定的效果”,可以把某一轮的输出完整保存到本地,后续对比时以保存版本为准。
5. 实验记录分析和可视化
5.1 从 JSONL 里读回记录
前面的脚本生成了records.jsonl,下面用 Python 读取并计算每一轮相对原始文本的相似度。
# analyze.py import difflib import json import jieba def load_records(path="records.jsonl"): records = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: records.append(json.loads(line)) return records def char_similarity(a: str, b: str) -> float: return difflib.SequenceMatcher(None, a, b).ratio() def keyword_similarity(a: str, b: str) -> float: set_a = set(jieba.lcut(a)) set_b = set(jieba.lcut(b)) if not set_a: return 0.0 return len(set_a & set_b) / len(set_a | set_b) if __name__ == "__main__": records = load_records() if len(records) < 2: print("no enough records") raise SystemExit(1) base_text = records[0]["text"] print(f"{'step':<6}{'char_sim':<10}{'token_sim':<10}text") print("-" * 60) for index, record in enumerate(records): if index == 0: continue text = record["text"] cs = char_similarity(base_text, text) ts = keyword_similarity(base_text, text) short = text if len(text) <= 28 else text[:25] + "..." print(f"{record['step']:<6}{cs:<10.3f}{ts:<10.3f}{short}")char_similarity用的是字符级公共子序列比例,keyword_similarity则是分词后关键词集合的 Jaccard 值。两个指标一起看,可以判断:到底是文本顺序被打乱,还是关键词本身就丢了。
5.2 把漂移曲线画出来
如果只想快速看趋势,可以用 matplotlib 画相似度随轮次变化的折线图。
# plot_similarity.py import json import difflib import matplotlib.pyplot as plt def load_records(path="records.jsonl"): records = [] with open(path, "r", encoding="utf-8") as f: for line in f: if line.strip(): records.append(json.loads(line)) return records records = load_records() base_text = records[0]["text"] steps = [r["step"] for r in records[1:]] scores = [ difflib.SequenceMatcher(None, base_text, r["text"]).ratio() for r in records[1:] ] plt.figure(figsize=(8, 4)) plt.plot(steps, scores, marker="o") plt.xlabel("translation step") plt.ylabel("char similarity to source") plt.title("Semantic drift along translation chain") plt.grid(True, linestyle="--", alpha=0.6) plt.savefig("drift.png", dpi=200, bbox_inches="tight")正常情况下,相似度会整体波动,常见形态有两种:
- 呈现“平台期 + 阶梯下降”:前几轮还能大致还原,某一步出现关键近义词替换后,相似度骤然下降。
- 呈现“周期性锯齿”:因为文本在中英文之间往返,长度变化较大,字符级相似度自然涨落,但整体下移。
看到这两种形态都不需要奇怪。如果相似度长期保持在 1.0,很可能翻译源返回了原文,说明文本太短、翻译触发不了变化,或者接口返回逻辑有异常。此时应改用更长、更有歧义的文本再试。
6. 把最终文本转成能跟着唱的音频
6.1 用 TTS 生成参考诵读版
第 20 轮文本通常是可读的中文句子,但它不一定押韵,也没有旋律。先用 TTS 朗读一遍完整文本,可以作为后续演唱的“发音参照线”。
edge-tts是一个在线 TTS 命令行工具,使用示例:
edge-tts --voice zh-CN-XiaoxiaoNeural --file final_text.txt --write-media final_lyrics.mp3其中final_text.txt是上一小节从第 20 轮记录中提取的最终文本。也可以用 Python 调用:
import asyncio import edge_tts async def synthesise(text: str, output_path: str, voice: str = "zh-CN-XiaoxiaoNeural"): communicate = edge_tts.Communicate(text, voice) await communicate.save(output_path) final_text = "第20轮自动生成的最终歌词文本" asyncio.run(synthesise(final_text, "final_lyrics.mp3"))需要注意,edge-tts依赖在线语音服务,是否可用取决于运行环境的网络和服务策略。如果你的环境无法访问它,可以换成本地 TTS,例如pyttsx3,或者使用你所在环境可用的云厂商语音合成接口。
6.2 逐句生成“歌词卡”
“自己唱出来”最花时间的不是录音,而是确认每一句该怎么断、怎么对节奏。可以把 20 轮翻译产生的每一轮结果都保留成一句文本,生成“歌词卡”。
歌词卡本质上是一个简单的 JSON:
{ "title": "漂移练习曲", "source": "人是猫,猫是人,白天睡觉晚上跑", "final": "第20轮的最终文本", "sections": [ { "line": "第20轮文本第1句", "original_length": 6, "syllables": 6 }, { "line": "第20轮文本第2句", "original_length": 8, "syllables": 8 } ] }如果你按字符串标点拆分,再折算成汉字音节数,就能清楚知道每一句比原曲多几个字或少几个字。演唱练习时,多余的字可以弱读,不足的部分可以拖长尾音或者加入语气词。
6.3 生成节拍轨辅助练习
真正演唱时需要知道速度。可以用 FFmpeg 手工生成一个简单节拍轨,也可以直接在 Python 中调用pydub生成节拍音。
如果安装pydub且环境里有 FFmpeg,可以用下面的脚本生成一段节拍音:
from pydub import AudioSegment from pydub.generators import Sine bpm = 90 beat_seconds = 60.0 / bpm click_duration_ms = 40 click_freq = 880 click = Sine(click_freq).to_audio_segment(duration=click_duration_ms) beat = AudioSegment.silent(duration=int(beat_seconds * 1000)) track = AudioSegment.silent(duration=0) for _ in range(32): track += click + beat track.export("metronome.mp3", format="mp3")之后再把人声和节拍轨混音:
ffmpeg -i vocal.mp3 -i metronome.mp3 -filter_complex amix=inputs=2:duration=longest output.mp3混音不是必须的。练习时戴耳机听节拍轨,手机录音只录人声,更容易得到干净的音轨。
7. 排错清单:20 轮翻译链常见问题
7.1 高频错误快速定位
20 轮循环看似简单,实际跑起来会遇到不少问题。下面是高频现象和排查思路:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 第 1 轮就报 429 | 请求过于频繁或接口额度不足 | 查看异常状态码和响应体 | 增大--interval;换翻译源;等待一段时间再试 |
| 报 403 或连接失败 | 翻译服务在运行环境不可用 | 先运行连通性探针 | 按服务条款和环境限制接入可用端点;使用自建翻译服务 |
| 返回空字符串 | 接口异常或文本为空 | 打印repr(translated) | 停止本轮并重试;检查是否有过滤逻辑丢弃文本 |
| 连续 20 轮结果都不变 | 文本过短,触发不了语义变化 | 检查records.jsonl | 换成更长的歌词句或带歧义的句子 |
| 终端中文乱码 | 控制台编码不是 UTF-8 | locale检查系统编码 | Windows 下设置PYTHONIOENCODING=utf-8 |
| 中途断掉,重新执行又从第 1 轮开始 | 脚本没有续跑能力 | 查看日志起始 step | 从records.jsonl最后一行恢复文本,而不是从原始文本重跑 |
7.2 断点续跑思路
免费翻译接口的特点是随时可能抽风。如果跑到第 13 轮挂掉,再从第 1 轮开始不仅浪费请求,还可能因为流量变化得到完全不同的中间结果。
断点续跑的核心是:每次成功后,把“当前最新文本”和“当前轮次”写进状态文件。下次启动时先读取状态文件,如果有记录则从对应轮次继续。
import json from pathlib import Path STATE_FILE = Path("state.json") def save_state(step: int, text: str): STATE_FILE.write_text( json.dumps({"step":