
如果你做过字幕翻译大概见过这种场景一部 90 分钟的视频字幕文件拆开后是上千条时间轴每一条只有短短十几个词却要求译者在几秒内理解上下文、保持人名语气一致还不能超时。用 LLM 来做这件事听起来像是一行 API 就能“极速通关”的任务。真正动手之后才发现所谓 speedrun速通根本不是把翻译跑快而是把一条容易翻车的工作流跑稳。“Lost in translation” 这个标题有两层意思。一层是说字幕信息本身零散机器很容易在翻译中迷失另一层是说当我们把一个看上去简单的问题丢给 LLM 时真正丢失的往往是格式、时间约束和术语一致性。如果只追求“调用大模型把字幕文本替换成中文”产出的多半是没法直接用的半成品。本文会基于一套可复制的 Python 工作流讲清楚 LLM 字幕翻译的完整链路、验证方法和常见坑最后给出工程化建议。读完你应该能回答三个问题字幕翻译为什么不是逐行调用 API 就行一个可靠的字幕翻译脚本应该包含哪些模块翻译完成后怎么验证“能播、能懂、不丢信息”。1. 这篇文章真正要解决的问题字幕翻译这个需求最近两年正在被重新定义。传统做法是人工翻译成本高、周期长一部电影光初译就要好几天。传统机器翻译MT虽然快但字幕是典型的短文本上下文极度缺失人名的前后一致性和口语化表达往往一塌糊涂。大模型出现之后很多人的第一反应是把字幕逐行发给 LLM让模型做翻译不就完了真实项目里这个思路会撞上四个问题格式问题。SRT 和 ASS 不是简单的文本文件里面有序号、时间轴、标签、换行符。LLM 一旦开始“自由发挥”帮你合并句子、补全内容原文件的结构就毁了。时间问题。字幕时间轴对应音频翻译后句子变长观众会在下一句出现前读不完。字幕翻译的及格线不是“翻得对”而是“读得完”。上下文问题。单条字幕只有十几个词翻译时不知道前后文术语和语气很难保持一致。成本问题。上千条字幕如果逐条请求接口费用、限流、失败重试都是工程问题不是“调用一下 API”能解决的。所以这篇文章真正要讲清楚的是LLM 字幕翻译的瓶颈不在模型翻译能力而在工程化能力。时间轴、格式、上下文、术语一致性和自动校验这些才是决定项目成败的部分。适合阅读本文的读者包括给视频课程、播客、自媒体做中文字幕的制作者想在自己项目里接入“批量翻译流水线”的后端开发者正在选型字幕翻译工具想知道自研和现成方案边界的产品或技术负责人。本文不涉及实时字幕翻译那需要流式语音识别链路也不讨论如何完美翻译诗歌和双关语重点是一套可落地的离线字幕批处理方案。2. 字幕翻译的核心概念与三条隐性约束2.1 字幕文件的基本结构最常见的字幕格式是 SRT结构非常简单1 00:00:01,000 -- 00:00:04,000 Hey, have you seen Hedwig today? 2 00:00:04,500 -- 00:00:07,500 Not yet. Shes been flying around the castle all morning.每一段字幕由三部分组成序号、时间轴、文本内容。时间轴的单位是毫秒级的时间戳它决定字幕何时出现、何时消失。翻译时这一切都必须原样保留。进阶格式 ASS/SSA 更复杂文本里可以带样式标签和定位信息Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\pos(100,200)}Hey, have you seen Hedwig today?其中的{\pos(...)}是控制字幕位置的标签。如果翻译脚本直接把整行文本丢给 LLM模型很可能把标签当普通文字处理或者把整段结构破坏掉。这就是我在标题里说的“翻译中迷失”的典型场景译文对了文件废了。2.2 LLM 较传统机器翻译的优势传统机器翻译如早期的统计翻译、通用神经翻译 API在处理字幕时有两个明显劣势上下文零感知同一部剧里的人名、梗、双关语在前后字幕中往往译法不同过于“忠实”遇到口语、俚语和省略句时容易翻译得生硬。LLM 的提示词能力让它可以理解“这是一段字幕字幕要简洁、口语化、保持时间窗口可读”还能通过术语表和上下文拼接规避一致性问题。这是 LLM 字幕翻译能成立的根本原因。三种方案对比维度纯人工传统机器翻译LLM速度慢快较快上下文理解强弱较强口语/俚语处理强较差较强术语一致性靠人工术语表差需提示词约束格式保持手动需后处理需后处理主要瓶颈人力成本质量成本 校验从这张表能看出一个判断LLM 真正替代的不是整个翻译工种而是那个“能看懂上下文、又会口语化表达”的初译环节。剩下的格式处理、时间校验、术语管理仍然是工程问题。2.3 三条隐性约束字幕翻译和普通文本翻译有三个本质区别做工程时必须时刻记住第一时间轴不可变。字幕时间轴绑定音频翻译不能改变出现和消失的时间。这意味着译文必须能塞进原时间窗口。中文阅读速度上限通常在每秒 13 到 16 字之间一条 3 秒的字幕译文最好不超过 45 字否则观众根本来不及看。第二上下文来自批量。单条字幕太短独立翻译会丢失前后关系。把连续 5 到 10 条字幕放在同一个请求里翻译模型就能感知对话的连续性。这是字幕翻译工程里成本最低、效果最明显的优化手段。第三格式必须回填。模型只能翻译“可见文本”标签、时间轴、换行符必须由代码在翻译后重新拼装回去。谁把这一步省了谁就会在用户反馈里收获大量“格式错乱”的 bug。3. 环境准备与前置条件3.1 运行环境与依赖本文示例使用 Python 3.10 及以上版本依赖库如下# requirements.txt pysubs21.6.1 openai1.30.0 python-dotenv1.0.0安装命令pip install -r requirements.txtpysubs2负责解析和保存 SRT、ASS/SSA 字幕它比手写正则解析可靠得多能把时间轴、样式、标签结构化地读进内存。openai库兼容大部分 OpenAI 协议的服务后面会说明怎么切换到本地模型。3.2 模型与接口选择翻译质量和服务成本由模型决定但工作流本身和模型解耦。只要模型服务商提供 OpenAI 兼容的/v1/chat/completions接口代码基本不用改。建议选择云端闭源模型质量稳定适合业务要求高的场景注意数据出域合规问题本地部署模型字幕文本通常涉及版权内容如果项目不方便走外部 API可以用 vLLM、Ollama 等方案在本地启动兼容接口成本可控且数据不出域。无论选哪种都建议在提示词中要求模型按“序号|译文”的格式输出不要用 JSON 格式。原因是字幕行数以千计JSON 输出在解析失败时更难定位按行分隔的纯文本格式更容易做兜底和审计。3.3 输入文件准备准备一份 SRT 或 ASS 字幕文件本文示例命名为input.srt。如果字幕嵌在视频里可以用 ffmpeg 提取# 查看视频中有哪些字幕流 ffprobe -v error \ -show_entries streamindex,codec_name:stream_tagslanguage \ -select_streams s -of json input.mkv # 提取第 0 条字幕流为 srt ffmpeg -i input.mkv -map 0:s:0 -c:s srt output.srt需要说明的是字幕翻译只应针对你有权处理的视频和字幕内容请遵守文件来源的版权和平台条款不要在未经授权的情况下批量翻译并二次分发。4. 字幕翻译工作流拆解先看整体流程图再逐个模块解释。这里的每一步都对应后面代码里的一个函数。SRT/ASS 文件 │ ▼ 解析字幕保留时间轴、标签、样式 │ ▼ 抽取可见文本去掉 i、{\pos(...)} 等标签 │ ▼ 按批次翻译每批 5~10 条保持上下文 │ ▼ 解析模型返回逐行校验“序号|译文” │ ▼ 回填并保留标签译文写回原字幕结构 │ ▼ 时长与可读性校验 │ ▼ 输出新 SRT/ASS 审计日志4.1 第 1 步解析字幕并保留时间轴用pysubs2.load()读取文件后内存里是一个SSAFile对象里面每个元素是Subtitle包含start、end、text等字段。解析阶段最重要的事情是不要修改 start 和 end。时间轴是字幕的骨架整个翻译过程只允许替换text字段。4.2 第 2 步抽取可见文本很多字幕文本里混着标签比如 SRT 的i.../iASS 的{\pos(...)}、{\an8}。翻译时需要用正则把这些标签识别出来只把可见文本交给模型。这一步的价值是避免模型“帮忙”翻译{\pos(100,200)}这种指令型标签。4.3 第 3 步分批翻译保持上下文字幕是碎片化文本单条翻译容易丢掉指代关系和语气。把连续若干条合并成一个请求模型能看到完整对话。但批次也不能无限大太大的批次会浪费 token也更容易触发模型的格式漂移比如合并行、跳行、追加解释。经验阈值是每批 5 到 10 条后面在最佳实践里再详细讨论。4.4 第 4 步译文回填模型返回“序号|译文”后代码必须把译文重新拼回带标签的原文结构。回填有两个原则一是保留标签位置二是缺失的行用原文兜底绝不让字幕“变少”。4.5 第 5 步可读性与时长校验这是最容易偷懒、也最容易翻车的一步。译文写完不是终点要逐条估算阅读耗时是否超过原时间窗口。超时的字幕要么人工调整要么在提示词里限制译文长度。自动化校验的意义在于上千条字幕里哪怕只有 5% 超时人工也看不完。5. 完整示例代码实现下面给出一套可直接运行的示例工程包含 4 个文件依赖文件、环境变量文件、主翻译脚本、校验脚本。5.1 依赖与配置文件先创建requirements.txt内容见 3.1 节。再创建.env文件# .env LLM_API_KEYsk-xxxxx LLM_BASE_URLhttps://api.openai.com/v1 LLM_MODELgpt-4o-mini如果使用本地兼容接口把LLM_BASE_URL改成类似http://localhost:8000/v1的地址即可。5.2 主翻译脚本 translate_subs.py# translate_subs.py import json import os import re import sys import time from pathlib import Path import pysubs2 from dotenv import load_dotenv from openai import OpenAI load_dotenv() API_KEY os.getenv(LLM_API_KEY, ).strip() BASE_URL os.getenv(LLM_BASE_URL, ).strip() MODEL os.getenv(LLM_MODEL, ).strip() BATCH_SIZE 8 # 每批翻译的字幕条数 SOURCE_LANG en TARGET_LANG zh-CN MAX_RETRIES 3 RETRY_BASE_SLEEP 2 TAG_RE re.compile(r[^]|\{[^}]*\}) GLOSSARY 术语表 - Hedwig: 海德薇 - Transfiguration: 变形术 SYSTEM_PROMPT f你是一名专业字幕翻译把字幕从 {SOURCE_LANG} 翻译成 {TARGET_LANG}。 硬性要求 1. 用户会提交多行“序号|原文”请严格按行翻译逐行输出“序号|译文”。 2. 不得合并、拆分、增删行不得输出解释、注释或多余内容。 3. 口语、俚语按语境自然翻译说话人标签、音效词如 [Laughter]、(music)保留原文。 4. 专有名词优先按术语表翻译没有术语表的采用通用译法并保持一致。 5. 译文长度控制在原文可读时间内不写冗长解释。 def split_visible(raw: str): 把带标签的字幕文本拆成 [(segment, is_visible)]保留标签位置。 parts [] last 0 for m in TAG_RE.finditer(raw): if m.start() last: parts.append((raw[last:m.start()], True)) parts.append((m.group(), False)) last m.end() if last len(raw): parts.append((raw[last:], True)) return parts def visible_text(raw: str) - str: 抽取出真正要翻译的可见文本。 vis [seg for seg, flag in split_visible(raw) if flag] text .join(seg.strip() for seg in vis if seg.strip()) return text.replace(\\N, ).replace(\\n, ).strip() def merge_translation(raw: str, translated: str) - str: 把译文写回原字幕文本保留原有标签的位置。 本实现适用于整行被标签包裹的常见场景多标签混排时建议按片段粒度改造。 parts split_visible(raw) if not any(flag for _, flag in parts): return raw replaced False out [] for seg, is_visible in parts: if is_visible: if not replaced and seg.strip(): out.append(translated) replaced True else: out.append() else: out.append(seg) return .join(out) def build_user_prompt(lines: list[str]) - str: numbered \n.join(f{i 1}|{text} for i, text in enumerate(lines)) if GLOSSARY.strip(): return f{GLOSSARY.strip()}\n\n待翻译字幕\n{numbered} return f待翻译字幕\n{numbered} def parse_response(content: str, expected: int): 解析模型返回的“序号|译文”文本缺失的序号返回 None。 table {} for raw_line in content.splitlines(): line raw_line.strip() if | not in line: continue idx_text, _, value line.partition(|) try: idx int(idx_text.strip()) except ValueError: continue if 1 idx expected: table[idx] value.strip() return [table.get(i) for i in range(1, expected 1)] def log_audit(entry: dict): 把每次请求的关键信息写入审计日志便于事后回溯和人工抽检。 with open(audit.jsonl, a, encodingutf-8) as f: f.write(json.dumps(entry, ensure_asciiFalse) \n) def translate_one_batch(client, lines: list[str]) - list[str]: user_prompt build_user_prompt(lines) last_error None for attempt in range(1, MAX_RETRIES 1): try: resp client.chat.completions.create( modelMODEL, messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_prompt}, ], temperature0.2, ) content resp.choices[0].message.content or results parse_response(content, len(lines)) if not any(results): raise ValueError(返回内容中没有可解析的“序号|译文”行) final_results [t if t else src for t, src in zip(results, lines)] log_audit({ ts: time.time(), model: MODEL, user_prompt: user_prompt, raw_response: content, results: final_results, }) return final_results except Exception as exc: # 网络、限流、格式异常统一走重试 last_error exc print(f[batch] 第 {attempt}/{MAX_RETRIES} 次失败{exc}, filesys.stderr) if attempt MAX_RETRIES: time.sleep(RETRY_BASE_SLEEP * attempt) raise RuntimeError(f批次重试耗尽最后错误{last_error}) def main(): if len(sys.argv) 3: print(用法python translate_subs.py 输入字幕.srt 输出字幕.srt) sys.exit(1) if not API_KEY: print(错误未配置 LLM_API_KEY请检查 .env 文件, filesys.stderr) sys.exit(2) client_kwargs {api_key: API_KEY} if BASE_URL: client_kwargs[base_url] BASE_URL client OpenAI(**client_kwargs) input_path Path(sys.argv[1]) output_path Path(sys.argv[2]) subs pysubs2.load(str(input_path), encodingutf-8-sig) print(f共解析到 {len(subs)} 条字幕开始翻译……) batch_originals [] batch_indexes [] finished 0 def flush(): nonlocal finished if not batch_originals: return translated translate_one_batch(client, batch_originals) for idx, text in zip(batch_indexes, translated): sub subs[idx] merged merge_translation(sub.text, text) if merged ! sub.text: sub.text merged finished 1 print(f进度{finished}/{len(subs)}) batch_originals.clear() batch_indexes.clear() for i, sub in enumerate(subs): text visible_text(sub.text) if not text: continue batch_originals.append(text) batch_indexes.append(i) if len(batch_originals) BATCH_SIZE: flush() flush() subs.save(str(output_path)) print(f完成结果已保存到{output_path}) if __name__ __main__: main()代码的关键逻辑有以下几点split_visible和merge_translation解决了格式保真问题。翻译只发生在可见文本上标签留在原位置模型再怎么发挥也不会破坏字幕文件结构。parse_response用“序号|译文”格式解析模型输出缺失的行返回None翻译时用原文兜底保证行数永远不变。translate_one_batch内置重试和审计日志。每次请求的提示词、原始返回、最终结果都写入audit.jsonl这是事后排错和人工抽检的基础。temperature0.2是字幕翻译的合理经验值足够稳定同时保留一定的口语化表达能力。5.3 时长校验脚本 verify_subs.py# verify_subs.py import sys import pysubs2 from translate_subs import visible_text def estimate_seconds(text: str, lang: str) - float: 估算观众读完该字幕需要多少秒。 中文按 15 字/秒上限英文按 3.5 词/秒阈值可按项目调整。 if lang zh: return len(text) / 15.0 return len(text.split()) / 3.5 def millis_to_time(ms: int) - str: cs (ms // 10) % 100 s (ms // 1000) % 60 m (ms // 60000) % 60 h ms // 3600000 return f{h:02d}:{m:02d}:{s:02d},{cs:02d} def main(): if len(sys.argv) 2: print(用法python verify_subs.py 已翻译字幕.srt [zh|en]) sys.exit(1) path sys.argv[1] lang sys.argv[2] if len(sys.argv) 2 else zh threshold float(sys.argv[3]) if len(sys.argv) 3 else 1.1 subs pysubs2.load(path, encodingutf-8-sig) problems [] for sub in subs: text visible_text(sub.text) if not text: continue need estimate_seconds(text, lang) actual (sub.end - sub.start) / 1000.0 if need actual * threshold: problems.append((sub.start, sub.end, need, actual, text[:40])) if problems: print(f发现 {len(problems)} 条可能超时的字幕) for start, end, need, actual, preview in problems[:20]: print(f {millis_to_time(start)} - {millis_to_time(end)} f需 {need:.2f}s / 实 {actual:.2f}s | {preview}) sys.exit(1) print(校验通过所有字幕译文都在原时间窗内。) if __name__ __main__: main()校验脚本能自动找出“字幕出现 2 秒、中文却有 50 个字”这种肉眼难查的问题。第一次跑脚本你大概率会惊讶于 LLM 在“控制译文长度”这件事上有多不可靠——这正是需要程序兜底的原因。5.4 从视频中提取字幕如果原始字幕嵌在 MKV/MP4 里先用前面 3.3 节的 ffmpeg 命令导出再进入翻译流程。导出后建议先用播放器快速看一眼第 1 条到第 20 条字幕确认没有乱码或错位再开始翻译。字幕文件一旦时间轴错位后续所有校验都会建立在错误的数据上。6. 运行结果与效果验证6.1 运行命令按顺序执行# 第一步翻译 python translate_subs.py input.srt output_zh.srt # 第二步时长校验 python verify_subs.py output_zh.srt zh # 第三步查看审计日志人工抽检 python -m json.tool audit.jsonl | head -506.2 预期输出output_zh.srt的前几行应该类似1 00:00:01,000 -- 00:00:04,000 嘿你今天见到海德薇了吗 2 00:00:04,500 -- 00:00:07,500 还没有。她整个上午都在城堡上空飞来飞去。判断成功有三个标准时间轴和原文件完全一致逐字节对比 start 和 end 都不变字幕条数和原文件一致没有多行、少行、合并行文件能用播放器正常加载标签没有残留成明文。6.3 翻译质量的验证方法格式校验通过之后还要验证翻译质量。这里推荐两类方法。第一类抽样人工检查。从审计日志里随机抽 20 到 50 条看人名术语是否一致、口语是否自然、有没有漏译。字幕文件的特殊性在于观众是一句一句看下去的前后语境非常重要单看一条觉得没问题的译文放到上下文里可能很突兀。第二类回译抽检。对随机抽取的译文做中译英观察是否丢信息。比如把下面这 3 条交给模型1|嘿你今天见到海德薇了吗 2|还没有。她整个上午都在城堡上空飞来飞去。如果回译结果变成 “Have you seen Hedwig?” 且丢失了“今天”“上午”等限定信息说明模型在做字幕翻译时可能过度精简要回头检查提示词里“不写冗长解释”的约束是否过强。回译不是精确指标但能快速暴露系统性丢信息的问题。如果verify_subs.py报出大量超时字幕第一步先看是“少数高长度字幕”还是“整体普遍超时”。前者人工改掉几十条即可后者需要去调整提示词明确写“中文译文长度不超过原文的 80%、单条字幕不超过 20 字”之类的硬性上限。7. 常见问题与排查思路这里整理了我在类似字幕翻译工程里见到的高频问题按“现象 → 原因 → 排查 → 解决”的路径给出建议。问题现象可能原因排查方式解决方案返回行数与输入不一致模型合并或拆分句子看audit.jsonl中raw_response提示词强制“逐行输出序号|译文”代码用原文兜底缺失行译文超时观众来不及读译文过长模型忽略长度约束运行verify_subs.py提示词加硬长度上限超时条目人工删减人名前后不一致单条字幕独立翻译缺少上下文在输出文件里搜索关键词注入术语表增大批次让模型看到更完整对话ASS 标签丢失斜体/定位没了回填逻辑只用了译文文本对比 input.ass 与输出的标签结构使用merge_translation式回填保留标签接口报 429 限流请求频率过高查看错误日志和audit.jsonl的批次信息指数退避重试降低并发增大批次音效和说话人标签被翻译提示词没说明这部分要保留搜索[、(关键词提示词明确写“说话人标签、音效词保留原文”译文出现幻觉内容模型过度补充语境回译抽检temperature 调低提示词强调“只翻译不补写”这里最容易踩坑的是第一个问题。很多 LLM 看到“序号|原文”的列表后会主动“好心”地给句子加上主语、补充省略成分导致行数对不上。所以代码里无论如何都要保留“缺失行用原文兜底”的逻辑它保证的是字幕文件的完整性优先于单行质量。8. 最佳实践与工程建议跑通最小示例只是第一步真正放到生产环境还需要补齐下面这些工程细节。8.1 术语表与专有名词字幕里最影响观感的是人名、专有名词和梗的一致性。建议维护一个GLOSSARY常量用“原文: 译文”的形式维护。更进一步可以在每次翻译前把上一批的最后一句作为“仅看上下文、不要翻译”的种子拼进提示词让批次之间的衔接更自然。这个做法的成本几乎为零但能显著减少下一批第一句的突兀感。8.2 上下文、批次与成本批次大小的选择本质是在“上下文连续性”和“成本、稳定性”之间做权衡。批次太小模型看不到上下文批次太大token 浪费且格式漂移概率上升。建议从 8 到 10 条开始测试用同一段字幕对比输出质量。成本控制有几个实用技巧对完全相同或几乎相同的字幕行做缓存避免重复请求先让便宜模型初译再用强模型只修订质量不行的行而不是全程用贵模型只在视频内容变更时重新翻译对应片段不要把整份字幕反复全量翻译。8.3 安全、合规与数据边界字幕文件往往包含完整的视频对白涉及版权和隐私。如果字幕源受版权保护你应该只在自己有权处理的范围内使用这套工作流。调用外部模型时要确认服务商的数据处理条款如果内容敏感优先选择本地部署模型把数据留在自己的环境里。脚本不要把 API Key 写死在代码里使用.env管理并确保.env不进入版本仓库。8.4 团队协作与审计字幕项目通常是多人协作的。audit.jsonl不应该被当成临时调试文件而应该作为正式产物保留包含时间、模型、提示词、原始返回和最终结果。这样当出现“这译文是谁生成的、用的哪个提示词”这类问题时可以准确回溯。发布前建议至少安排一个人做完整抽查。这里推荐“分段负责制”每人负责 10 到 15 分钟时长的字幕用播放器按真实速度看一遍重点检查超时、错译和术语不一致。机器负责把明显问题过滤掉人负责处理机器判断不了的细微质量问题这是目前性价比最高的字幕质检流程。9. 总结与后续学习方向回到标题里的 “speedrun”。用 LLM 做字幕翻译真正能加速的是“初译 基础质检”这一大段重复劳动而无法省略的是格式保真、时间校验、术语管理和人工抽查。一套可用的字幕翻译脚本最少要包含解析、抽文本、分批翻译、回填、校验五个模块缺一个都会在真实字幕文件上翻车。下一步比较值得深入的方向有三个。其一是双语字幕生成把原文和译文同时在画面中展示这对时间轴的约束更强但成品对语言学习者更有价值。其二是术语库自动化从项目历史字幕里自动抽取高频人名和专有名词动态生成术语表。其三是接回本地推理服务在版权和数据边界敏感的项目里用本地模型跑通同一套工作流成本更低也更可控。最后给你的建议是先用一份 20 条左右的小字幕跑通全流程确认格式和时间轴没有破坏再扩展到完整视频。字幕翻译的工程能力不是看它能调多少次 API而是看它能在多少次失败、多少条异常数据面前仍然输出一个“能播、能懂、不丢信息”的文件。