目标很朴素:把一个没有字幕的外语视频,变成一个能被播放器自动加载的字幕文件。 不重新配音,不把字幕压进视频,就是一个
.srt躺在旁边。折腾了几天,撞了十几个坑,其中有几个真的会让人怀疑人生。这里全记下来, 顺便给出可以直接抄的方案。
一、先说清楚一件事:这件事一个模型干不了
很多人以为现在的本地大模型能"直接看懂视频",但Qwen 是纯文本模型,它听不到声音。
所以这件事天然是两段式:
视频 ──ffmpeg抽音轨──> wav ──Whisper──> 原文台词 ──Qwen翻译──> 译文
│
└──> 回填时间轴 ──> .srt
这两段的性质完全不同:
ASR(Whisper) | 翻译(Qwen) | |
|---|---|---|
输入 | 音频波形 | 文本 |
瓶颈 | 显存带宽 / 批处理效率 | 逐 token 生成速度 |
并行性 | 天然可批处理 | 受限于显存里的 KV 缓存 |
质量瓶颈 | 听不清、切错句 | 术语不一致、语气不对 |
理解了这一点,后面的所有优化就都有地方下手了——别指望一招解决,得分别压这两段。
二、机器配置与预算
我的机器是一台看起来完全不够用的机器:
RTX 5060 Ti,8GB 显存(Blackwell / GDDR7)
32GB 系统内存
目标:2 小时的外语长视频
先说结论:能做,但必须精打细算。
时间账(2 小时视频)
第一段 ASR:2 小时音频 ÷ 25 倍实时 ≈ 5–8 分钟 第二段翻译:输出约 20K token ÷ 40 tok/s ≈ 纯生成 8 分钟,加上分块 prefill、重试,12–25 分钟
合计 35–40 分钟(区间 25–45 分钟)。
如果不做优化,把翻译换成 CPU,或者关思考模式没做,会轻松跑到 1.5–3 小时。
三、方案选型:MoE 模型 + 部分卸载
翻译这一段,我用的是 Qwen3.6-35B-A3B(35B 总参数、每次只激活约 3B 的 MoE 架构)的 Q4_K_M 量化版。
关键在于它是 MoE。MoE 的计算是稀疏的——每个 token 只激活极少数专家,所以:
把 MoE 专家张量放在 CPU 上跑,代价远小于把稠密模型放 CPU。
对应的 llama.cpp 参数是 --n-cpu-moe N(把前 N 层的专家留在 CPU/内存)。
实测这个方向和直觉完全相反——专家放 CPU 越多,整体往往越快:
配置 | 速度 |
|---|---|
不设该参数 | 8.7 tok/s |
| 40.9 tok/s |
同一个模型,差 4.7 倍。
原理也很直白:专家计算稀疏,CPU 扛得住;但注意力层和 KV 缓存是带宽敏感件,必须留在显存,否则每一步都要跨 PCIe 搬数据。
显存账本不只是"权重"
8GB 卡的显存是有限的,这几块都要算:
模型权重(GPU 上那部分)
+ mmproj 投影层 约 0.6–0.9 GB ← 如果你要跑多模态
+ GDN 递归状态 / slot 约 0.14 GB/槽
+ 计算缓冲 约 1 GB
──────────────────────────────
= 真正留给权重和 KV 的空间
Windows 上还要额外留 1.5GB 余量,否则驱动会走 System Memory Fallback 溢出到内存,速度直接掉一个数量级。
内存也要算账(这条最容易翻车)
32GB 内存、关掉浏览器之后,实测真正能留给模型的只有 约 18GB——日常后台(浏览器 / IM / 游戏平台)默认就吃掉 13.7GB。
跑模型前关掉浏览器,是最划算的一步。 没有之一。
四、ASR 选型:为什么不是 large-v3-turbo
large-v3 和 large-v3-turbo 的选择很明确:
large-v3:精度最高,2 小时约 5–8 分钟
large-v3-turbo(809M 参数蒸馏版):快 6–8 倍,WER 只差一点点
⚠️ turbo 不支持 translate 任务,但本场景只用
transcribe,所以没有影响
我最终选 large-v3,因为 5–8 分钟完全在预算内,没必要为了省 4 分钟牺牲精度。如果你有 10 小时以上的批量需求,turbo 是明确的答案。
一个绕不开的 ABI 坑
faster-whisper 底层的 CTranslate2 是 CUDA 12 构建,而我当时机器上装的是 CUDA 13(给 llama.cpp 用的)。这两个 ABI 不兼容,不能用 CUDA 13 的 DLL 顶替。
表现极具迷惑性:device="cuda" 加载能过,模型也"加载成功",但一推理就 RuntimeError。
解决办法是装匹配的运行库(只装进项目自己的 venv,不污染系统):
pip install nvidia-cublas-cu12 nvidia-cudnn-cu12
这里有个大坑:装完还是不行。
原因是我当时只做了:
os.add_dll_directory(r"...\nvidia\cublas\bin") # 无效!
os.add_dll_directory() 只对 ctypes 生效。而 CTranslate2 是在 C++ 层用裸 LoadLibrary 加载 DLL 的,对 Python 的 DLL 搜索路径增强完全无感知。
必须改环境变量:
os.environ["PATH"] = r"...\nvidia\cublas\bin" + os.pathsep + os.environ["PATH"]
os.environ["PATH"] = r"...\nvidia\cudnn\bin" + os.pathsep + os.environ["PATH"]
# add_dll_directory 也要一起加,但真正起作用的是上面这行
改完之后实测:12.4 秒音频,GPU 0.5 秒跑完,CPU 要 5.2 秒——差 10 倍。
五、最影响体验的一个决定:字幕粒度
这一段是我踩得最值的坑。
最初跑出来的效果是这样的:一条字幕 60 多个字、糊满屏幕下半部分。调小字号治标不治本,因为一条字幕的字数根本没变少。
根因在 Whisper 的分段策略
我抓了实际的转写输出看:
段 [0.00 - 24.98] ← 25.5 秒、8 句话,被合并成一个大段
Whisper 不是按句子切段的,它按语音片段切,遇到长句流会连续吐 token 直到判不出新句子为止。一段七八句话是常态。
"一段字幕对应一段语音"这个假设从根上就是错的。
解法:词级时间戳 + 按句重切
正确做法是开 word_timestamps=True,拿到每个词的精确时间,然后用词流重新切 cue,切分规则:
句末标点
。!?!?…(含英文句点.)→ 断词间停顿 > 1 秒 → 断
单条超过 48 字 → 强制断,优先在顿号/逗号处
改造后的实测效果:
[ 0.00 -> 1.50] about two years or so.
[ 1.68 -> 5.66] Then I started looking for a job, and I quit once.
[ 9.80 -> 12.60] Did you buy any big items during college?
[12.98 -> 14.14] Big items?
1 个大段 → 9 条 cue,一句一条,时间戳精确到词。
三个必须处理的细节
① 英文句点最容易漏。 我第一版只认中文标点,结果英文视频完全不断句。英文句点还必须做缩写防误伤——Mr. etc. Dr. 后面跟小写词时不能断。
② 静音段会引发幻觉。 必须开 VAD 把静音切掉,否则 Whisper 会在无人声处"想象"出一整段台词。
③ 别开双语字幕。 双语模式每条固定两行,是"字幕占满屏幕"的帮凶。需要原文对照时再用。
六、小声语音:真正的元凶不是模型
另一个实战问题:视频里有人小声说话,字幕直接丢了。
我做了个实验——用 TTS 合成一段音频,把其中几句分别衰减不同幅度,看能识别出几条(原始 7 句):
衰减量 | 相当于什么场景 | 现状识别率 |
|---|---|---|
−20 dB | 明显偏小 | 7/7 ✅ |
−30 dB | 很远、很轻地说话 | 7/7 ✅ |
−36 dB | 耳语级 | 3/7 ❌ 丢了 4 段 |
所以 large-v3 比想象中抗造,−30dB 以内都不用管。
但丢段的原因值得说清楚
我做了对照实验,把 VAD 阈值从默认 0.5 降到 0.3:
−36dB + VAD 0.5(默认)→ 3/7
−36dB + VAD 0.3 → 7/7 ← 4 段全部救回
元凶是语音活动检测器(VAD),不是 ASR 模型。
VAD 是能量-based 的判断机制,低能量段直接被判成"非语音",根本没轮到 Whisper 识别。这个事实很反直觉——很多人第一反应是"模型不够强",其实瓶颈在预处理。
另一个坑:dynaudnorm 默认参数是无效的
响度归一化理论上能解决,但 ffmpeg 的 dynaudnorm 默认参数几乎不干活:
输入 RMS 22 → 输出 RMS 25 (提升 1.14 倍,基本等于没动)
原因:它的统计窗口默认是 15.5 秒(500ms × 31)。你的音频里永远有大音量段落,窗口统计出来的增益对小声段接近 1。
必须调小窗口、放开增益上限:
ffmpeg -af "dynaudnorm=f=200:g=15:m=50:p=0.9" ...
调完之后小声段音量提升 27 倍,单独用这一招就能 7/7 全识别。
(顺便提醒:dynaudnorm 处理有个窗口填充期,测试它时音频至少要 5 秒,用 1 秒音频测会得到"无效"的错误结论——我就被这个骗过一次。)
最终策略
不要全局降 VAD 阈值——代价是音乐/噪声段更容易被误判成语音,产生幻觉字幕。
# 先按默认跑,发现哪段字幕缺失,再针对性重跑
pipeline video.mp4 --vad-threshold 0.3 --normalize dynaudnorm
配合断点缓存,重跑只翻变化的部分,不会全量重来。
七、翻译侧:把"思考模式"关掉是最值钱的一刀
这一条单独就能让翻译段从 20 分钟变成 1.5 小时(或者反过来)。
现在的主流推理框架默认给这类模型带 reasoning:每条字幕都先输出一段推理过程,再给译文。 字幕翻译是短文本任务,推理纯属浪费——输出 token 会膨胀 5–10 倍。
我实测确认关掉后的表现:
耗时 1.7s | 输出 24 tokens / 3 行 | reasoning_content: None
输出是干净的纯译文,一行不多:
你在大学期间买过什么大件物品吗?
大件物品?
我想应该是一台电视之类的东西。
⚠️ 这里有个版本陷阱
我最初用的是老写法:
--chat-template-kwargs "{\"enable_thinking\": false}"
但启动日志里悄悄飘过一行警告:
W Setting 'enable_thinking' via --chat-template-kwargs is deprecated.
Use --reasoning on / --reasoning off instead.
这个开关在较新版本上已经被静默废弃了。 你以为自己关了思考,其实一直开着——而且因为是"静默",不会有任何报错提示。
新版正确写法:
--reasoning off --no-reasoning-preserve
教训:推理框架的参数改名经常是 deprecate 而非报错。凡是"速度异常但功能正常"的情况,先去日志里翻 warning 行。
其他三个翻译侧调优
① 上下文从 64K 降到 8K–16K。 字幕是分块批译的,每块几百到一千多 token,64K 是巨大浪费。省下的 KV 显存让专家层能放回 GPU,decode 有望从 40 提到 55–70 tok/s。
⚠️ 这里和常规场景结论相反。你在 64K 场景测出"专家放 CPU 越多越快",直接套到字幕场景会适得其反——因为 KV 缓存突然成了新瓶颈。
② 开多槽并发。 单用户对话时多槽是白吃显存,但字幕逐条独立,是天然的批处理场景,--parallel 4 能让吞吐提升 1.5–2.5 倍。
③ 去掉格式开销。 不要收发带序号和时间码的 SRT,只发纯文本行、要求输出等量行,组装时按行号回填。输入输出各省 30% 以上。行数不匹配就重试那一块。
要不要用更小的模型?
老实说,字幕翻译用 8B 就够了——逐条短句翻译,上下文依赖只有前后几句。8B 全量上卡能跑 100+ tok/s,翻译段从 12 分钟压到 4 分钟。
但有两个例外:
技术教程 / 剧集(术语和人名密集)→ 建议保住大模型
如果要保质量又想省时间,可以两段式:8B 出初稿,大模型只做术语和人名的一致性修订
八、一个"能跑但出 bug"的经典陷阱:bat 编码
这一节跟 AI 无关,纯 Windows 坑,但杀伤力很大,值得单开一节。
我写完启动脚本后测试,中文 Windows 上报了一堆莫名其妙的错:
'-size' is not recognized as an internal or external command
'复用已在跑的翻译服务,不重新拉起' is not recognized ...
--chunk-size 的前缀 --chunk 被吞掉了,中文提示行被当成命令执行了。
机理
cmd 按 GBK(代码页 936) 解码多字节字符时,会吃掉后续字节——包括紧邻的引号和连字符,从而破坏命令的词法结构。
直觉上会以为 chcp 65001 能救。救不回来——文件解析发生在 chcp 生效之前,早就按 936 走完了。
我做了对照实验,四种编码全试
编码方案 | 结果 |
|---|---|
UTF-8 无 BOM | ❌ 词法被破坏 |
UTF-8 带 BOM | ❌ 本机 cmd 不认 BOM, |
GBK | ✅ 但只限中文 Windows |
纯 ASCII(一个中文都不放) | ✅ 跨环境可靠 |
最终架构
bat 里一个中文都不放,只做纯粹的 ASCII 启动器;所有中文提示、交互、帮助信息全部交给 Python 输出。
这不纯粹是为了绕编码——它顺带解决了三个问题:
跨语言 Windows 都能正常显示(Python 的输出编码自己会适配)
帮助信息可以写得很详细,不用担心 echo 的转义地狱
bat 只剩几行,永远不会因为改文案而搞坏语法
你现有的脚本里如果中文只在
echo行、没进if (...)块、也没挨着--xx参数,那它是"碰巧"能跑的。一旦中文进入条件块或紧邻参数,必炸。
九、另一个隐蔽陷阱:把日志丢进 DEVNULL
这个坑我自己踩了,坦白说很丢人。
最初的启动代码:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
效果是:llama-server 崩了 → 日志进黑洞 → 界面上和"正在加载"一模一样 → 然后傻等满 420 秒才报超时。
用户看到的现象就是:"一直卡在大模型加载。"
而实际上进程早就死了。
修法三个:
# 1. 日志落盘,出错时把最后 25 行打出来
log_file = open(args.server_log, "w", encoding="utf-8", errors="replace")
# 2. 进程一退出立刻报错,不白等
if proc.poll() is not None:
raise RuntimeError(f"服务已退出(code={proc.returncode}),日志尾部:\n{tail}")
# 3. 等待期间每 15 秒打心跳,显示已等秒数
log(f"等待服务就绪… 已等 {int(elapsed)}s")
判断口诀:卡住时打开任务管理器看有没有 llama-server.exe。
没有这个进程 → 它已经崩了,不是在加载 → 去看日志
有进程但内存/提交量在涨 → 它真的在加载,等着
好启动器的第一标准不是"跑得快",是出错时告诉你出了什么事。
十、最终架构
┌──────────────────────────────────────────────┐
│ 字幕流水线 │
└──────────────────────────────────────────────┘
视频文件
│
│ ffmpeg 抽音轨(16kHz 单声道 16bit)
│ + 可选:dynaudnorm 响度归一化
▼
audio.wav ─────────────┐
│
│ faster-whisper large-v3 (GPU, int8_float16)
│ · VAD 过滤静音
│ · word_timestamps=True ← 关键
▼
原始段落(可能几十秒一大段)
│
│ ★ 按句重切(句末标点 / 停顿 / 字数上限)
▼
cue 列表(一句一条,词级时间戳)
│
│ 断点缓存命中? ── 是 ──> 跳过翻译
│ ↓ 否
│ 分块(80 条/块)+ 并发 4 路
│ POST /v1/chat/completions
│ · reasoning off
│ · 纯文本行进,纯文本行出
▼
┌───────────────┐
│ 行数校验 │
│ 不匹配 → 重试 │──┐
│ 再失败 → 逐条 │ │
└───────┬───────┘ │
│ 匹配 │
▼ │
回填时间轴 ◀────────┘
│
▼
xxx.srt(UTF-8 with BOM)
│
▼
播放器自动加载(同目录同名)
输出侧的细节
编码用 UTF-8 with BOM——照顾 Windows 自带播放器
文件名和视频同名同目录,VLC / PotPlayer / MPV 会自动加载,不用手动挂
断点缓存写成一个隐藏 JSON,中途 Ctrl+C 不会白跑
十一、使用方式
日常:拖就完了
把 视频.mp4 拖到 跑字幕.bat 的图标上
Windows 拖拽只能传路径、没法带参数。需要指定语言、术语表时走命令行:
跑字幕.bat "D:\videos\lecture.mp4" --lang en --glossary 术语表.txt
常用参数
参数 | 作用 |
|---|---|
| 指定源语言,跳过自动检测,更快更准 |
| 输出双语字幕(原文 + 译文) |
| 统一人名/术语,技术内容强烈建议加 |
| 单条字幕字数上限,调小 = 断句更激进 |
| 单行最大字数,超出自动折行 |
| 声音忽大忽小 / 有耳语时开 |
| 小声语音识别不到时开 |
| 并发数,须与 server 的 |
术语表就是纯文本,每行一条:
# 每行一条「原文=译文」,# 开头是注释
load balancer=负载均衡
Jane Doe=简·多伊
throughput=吞吐量
跑之前三件事
关掉浏览器——最容易导致"卡在 loading"的原因
别同时跑另一个大模型实例——两个 21GB 模型 = 42GB,而你只有 32GB,系统会疯狂换页
首次加载 20 秒到 3 分钟,看起来"没反应"是正常的
十二、把这套东西变成可复现的交付包
最后一步是把这套东西连同踩过的坑一起,整理成一份能让别人从零复现的文档。
过程中顺手做了几件事:
① 把硬编码路径彻底剥离。 脚本内置默认值改为"基于脚本自身所在目录",新增一个 config.ini 作为唯一配置源:
命令行参数 > config.ini > 内置默认
这样整个文件夹复制到任何盘符都不用改一行代码。
② 写一键安装脚本。 建 venv、装依赖、装 CUDA 12 运行库、下 Whisper 模型、检查 ffmpeg 和 llama.cpp,可重复运行,已装步骤自动跳过。
③ 中文提示全部进 Python(就是第八节那个编码结论的落地)。
④ 自测覆盖到 63 项,包括:
真实的端到端跑通(合成一段带语音的音频,走完整个流程)
断点缓存命中验证(跑两次,确认第二次跳过已翻译内容)
响度归一化真的生效(断言输出 RMS > 输入 3 倍)
配置优先级、进程崩溃即时报错、日志落盘
⑤ 脱敏。 分享包逐文件机检,确保不含任何本机用户名、盘符路径、软件名和历史运行记录。
如果你也要做类似的东西,我的建议顺序
先写自测再写主程序——不然改一个参数就得靠人肉回归
日志必须落盘,永远不要 DEVNULL
配置文件和命令行分层,别把路径写死在代码里
把你的"实测数字"写进文档——比如"−30dB 以内不用担心",比"注意音量"有用一百倍
十三、几个可以带走的结论
① MoE 架构是低显存机器的福音。 稀疏激活让"把专家放 CPU"这个操作变得划算。同样的显存装不下的大模型,MoE 能跑;长上下文任务优先选可卸载的 MoE 架构。
② "哪个组件卡住"比"哪个组件弱"更重要。 小声语音丢失的元凶是 VAD 不是 Whisper;加载卡住的元凶是内存不是显存;翻译慢的元凶是 thinking 不是参数量。先定位瓶颈再优化。
③ 版本变更最危险的方式是"静默失效"。 参数改名不报错、编码问题报奇怪的错、日志被吞——这些都不像 bug,更像玄学。把日志落盘、把 warning 认真读,能省掉大量时间。
④ 静默优化不如显式开关。 VAD 阈值、归一化这些都有明确代价的开关,默认保持原行为,让用户按需开启。
⑤ 配置文件 + 命令行分层,是让工具能被分享的前提。 硬编码路径的脚本永远只能自己用。
附:整条流水线的耗时参考
阶段 | 2 小时视频 | 占比 |
|---|---|---|
抽音轨 | < 10 秒 | ~0% |
Whisper 转写(GPU,large-v3) | 5–8 分钟 | 22% |
Qwen 翻译(35B 部分卸载 + 并发 4) | 12–25 分钟 | 68% |
组装 SRT | 秒级 | ~0% |
合计 | 20–35 分钟 |
瓶颈非常明确地在翻译段。 所以要省时间,先优化翻译——关思考、降上下文、开并发、砍格式。
至于换模型还是换硬件:换模型最多再快一倍,换硬件(Linux + 更大内存)是数量级的提升。