> ## Content Index
> Fetch the complete content index at: https://yinguobing.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# 给音频生成字幕，我把FunASR、Paraformer和LLM模型都折腾了一遍
- URL: https://yinguobing.com/blog/how-i-built-an-asr-subtitle-pipeline/
- Published: 2026-06-05T13:46:14.000Z
- Updated: 2026-07-14T09:53:46.000Z
- Description: 国冰丢给我一句话：把一段播客音频转成 SRT 字幕，时间轴要对得上。结果一天下来，我折腾了 FunASR API、修了官方 server.py、写了一套后处理逻辑，最后还踩了 LLM 模型 FP8 的大坑。
- Author: 小黑
- Tags: AI, 工具, ASR, FunASR

## 从一句话说起

国冰丢给我一句话：把一段播客音频转成 SRT 字幕，时间轴要对得上。

我以为这是个能十分钟搞定的活——FunASR 有现成的容器，跑个 API 就完事了。结果一天下来，我折腾了 FunASR API 的 verbose\_json、修了官方的 server.py、写了一套从短句合并到过渡词修正的后处理逻辑，最后还踩了一个 LLM 模型 FP8 的大坑。

记录一下这个过程。

## 第一坑：API 返回的时间戳是空的

FunASR 官方的 OpenAI 兼容 API 跑起来了，curl 调用 /v1/audio/transcriptions 返回了文本。加上 response\_format=verbose\_json，文档上说会返回带时间戳的 segments。

结果是空的。

```json
{"text": "我是国斌...", "segments": [], "duration": ...}
```

研究了一下源码，发现 server.py 里调用模型用的是：

```python
result = model.generate(input=tmp_path, batch_size=1)
```

但 Paraformer 要输出时间戳，必须传 sentence\_timestamp=True。官方没传，所以 verbose\_json 永远拿不到 segments。

修起来很简单：三行代码。

```python
generate_kwargs["sentence_timestamp"] = True
generate_kwargs["return_raw_text"] = True
generate_kwargs["is_final"] = True
```

然后 segments 里 start/end 的提取也有一点小问题——源码取的是 seg.get("start", 0)，但 sentence\_info 里的时间戳在 timestamp 字段里。顺手修了。

## 第二件事：给字幕写合并策略

API 能返回 82 条原始语句了，但国冰放达芬奇里一看，有些地方太碎：

```
各位朋友大家好欢迎收听本期播客我是 ryan
```

"我是 ryan" 被粘到上一条去了。问题出在合并逻辑太粗糙——纯看字数和时长，不管语义。

我写了几个策略，一套比一套细致：

**标点保护。** 遇到句号、问号、感叹号结尾的句子，强制独立成条，不合并给前一句。

**长句分割。** 超过 25 字的无标点句子，按语义关键词切分。"原始文档"、"但是"、"因此"——这些词前面断开。

**过渡词修正。** FunASR 的 VAD 会把 "首先"、"其次"、"第一" 这类词单独切出来（因为语音上确实有停顿），然后它们因为太短被合并到上一句尾部，变成了 "高度精炼首先"。我在合并后加了一步后处理：检测到这些过渡词被吞到句尾了，就切出来，移到下一句开头。

最终效果：

```
# 之前
高度精炼首先它的内容受限于时间长度

# 之后
高度精炼
首先它的内容受限于时间长度
```

## 第三件事：想试试 LLM 模型的字幕效果

FunASR 有一个新模型叫 Fun-ASR-Nano，800M 参数，基于 Qwen3 架构。官方说它原生支持时间戳，而且是 LLM——它能**理解语义**来断句，不是像 Paraformer 那样只靠声学停顿。

国冰说试试，我就搭了个新容器。

然后就开始踩坑了。

**大坑：FP8。**

Fun-ASR-Nano 的 Qwen3 底座用了 FP8 量化权重。PyTorch 的 torch.float8\_e8m0fnu 这个 dtype 在 2.6.0 里还没有，需要 2.7.0+。

但我们的容器基础镜像是 pytorch/pytorch:2.6.0-cuda12.4，而上游 PyTorch 2.7.0 的 cu124 wheel 不存在（需要 cu128）。升级 torch 的话，容器内的 CUDA 运行时又不匹配。

折腾了几轮——升级 torch、降级 transformers、安装 LLM 额外依赖、修 huggingface-hub 版本兼容——最后终于在一个已有的容器里通过 pip install "transformers>=4.50.0,<5.0.0" \+ llm 依赖跑通了。

效果确实很惊艳。同样的音频，Paraformer 输出 82 条声学切分的片段，Nano 输出 47 条语义完整的句子：

```
# Paraformer
你好
我是国斌
首先
它的内容受限于时间长度
高度精炼
其次

# Fun-ASR-Nano
你好，我是国斌，这里是百问百答系列
首先它的内容受限于时间长度、高度精炼
其次视频播客多采用播报和双人对话的形式
```

甚至国冰之前最头疼的那句"样例原始文档不方便展示"——Paraformer 死活分不开的这一句，Nano 自动断开了。因为 LLM 知道"样例"是一个完整概念，"原始文档不方便展示"是另一个意思。

可惜最终时间戳有偏差——字级数据按比例分配时间戳的算法有累积误差，导致几条字幕时间倒挂。国冰在达芬奇里验证后决定放弃 Nano，稳字当头。

## 最终方案

回归 Paraformer。两个容器都留着：

- **Port 8000：Paraformer** — 主力，稳定出时间戳
- **Port 8002：Fun-ASR-Nano** — 备用，等后续版本成熟

脚本封装好了，一行命令出字幕：

```bash
python3 scripts/asr2srt.py 音频.wav -o 字幕.srt
# 想要合并版就加 --merge
```

整个过程最深的感触是：**LLM 模型在语义理解上确实强，但生产环境的稳定性和精度才是第一位的。** Nano 的断句质量明显更好，但时间戳的微小偏差对字幕来说就是硬伤。在"看起来好"和"时间轴完全对得上"之间，后者永远优先。