目标很朴素:把一个没有字幕的外语视频,变成一个能被播放器自动加载的字幕文件。 不重新配音,不把字幕压进视频,就是一个 .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

--n-cpu-moe 25

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. 句末标点 。!?!?…(含英文句点 .)→ 断

  2. 词间停顿 > 1 秒 → 断

  3. 单条超过 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,@echo off 直接失效

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

常用参数

参数

作用

--lang en

指定源语言,跳过自动检测,更快更准

--bilingual

输出双语字幕(原文 + 译文)

--glossary 表.txt

统一人名/术语,技术内容强烈建议加

--cue-max-chars 32

单条字幕字数上限,调小 = 断句更激进

--max-chars 18

单行最大字数,超出自动折行

--normalize dynaudnorm

声音忽大忽小 / 有耳语时开

--vad-threshold 0.3

小声语音识别不到时开

--concurrency 4

并发数,须与 server 的 --parallel 一致

术语表就是纯文本,每行一条:

# 每行一条「原文=译文」,# 开头是注释
load balancer=负载均衡
Jane Doe=简·多伊
throughput=吞吐量

跑之前三件事

  1. 关掉浏览器——最容易导致"卡在 loading"的原因

  2. 别同时跑另一个大模型实例——两个 21GB 模型 = 42GB,而你只有 32GB,系统会疯狂换页

  3. 首次加载 20 秒到 3 分钟,看起来"没反应"是正常的


十二、把这套东西变成可复现的交付包

最后一步是把这套东西连同踩过的坑一起,整理成一份能让别人从零复现的文档。

过程中顺手做了几件事:

① 把硬编码路径彻底剥离。 脚本内置默认值改为"基于脚本自身所在目录",新增一个 config.ini 作为唯一配置源:

命令行参数  >  config.ini  >  内置默认

这样整个文件夹复制到任何盘符都不用改一行代码。

② 写一键安装脚本。 建 venv、装依赖、装 CUDA 12 运行库、下 Whisper 模型、检查 ffmpeg 和 llama.cpp,可重复运行,已装步骤自动跳过。

③ 中文提示全部进 Python(就是第八节那个编码结论的落地)。

④ 自测覆盖到 63 项,包括:

  • 真实的端到端跑通(合成一段带语音的音频,走完整个流程)

  • 断点缓存命中验证(跑两次,确认第二次跳过已翻译内容)

  • 响度归一化真的生效(断言输出 RMS > 输入 3 倍)

  • 配置优先级、进程崩溃即时报错、日志落盘

⑤ 脱敏。 分享包逐文件机检,确保不含任何本机用户名、盘符路径、软件名和历史运行记录。

如果你也要做类似的东西,我的建议顺序

  1. 先写自测再写主程序——不然改一个参数就得靠人肉回归

  2. 日志必须落盘,永远不要 DEVNULL

  3. 配置文件和命令行分层,别把路径写死在代码里

  4. 把你的"实测数字"写进文档——比如"−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 + 更大内存)是数量级的提升。