做会议语音识别时,一个很容易被忽略的问题是:
ASR识别出了文字,不等于文字已经和原始录音精确对齐。
比如一场一个小时的会议,ASR输出:
text
接口联调周五之前完成。
业务真正需要的可能是:
text
Speaker 2
00:36:12.420 - 00:36:14.760
接口联调周五之前完成。
用户点击纪要里的这句话,播放器能够直接跳到:
text
36分12.420秒
开始播放。
如果只是做一份 TXT 转写稿,这个能力并不重要。
但当会议系统开始支持:
text
逐字稿回听
关键词定位
发言内容复核
会议证据追溯
字幕高亮
纪要跳转原声
时间戳精度就变成了一个独立的工程问题。
Qwen3-ASR 当前提供了单独的 Qwen3-ForcedAligner-0.6B。它的输入不是单纯音频,而是:
text
音频
+
已经确定的文本
然后返回字词与原始音频之间的时间对应关系。官方 qwen-asr 示例直接通过 Qwen3ForcedAligner.from_pretrained() 加载模型,并调用 align() 完成文本---语音对齐。
这次不只讨论 API 怎么调用,而是从一台新服务器开始,把它整理成一个可以长期运行的内部服务。
最终链路如下:
text
会议录音
↓
音频标准化
↓
Qwen3-ASR / 其他ASR
↓
转写文本
↓
音频片段 + 已知文本
↓
Qwen3-ForcedAligner
↓
精确时间戳
↓
会议业务系统
↓
字幕 / 回听 / 检索 / 纪要
一、为什么Forced Aligner最好独立部署?
最简单的做法当然是:
python
ASR模型
+
ForcedAligner
+
会议业务
全部写在一个 Python 服务里。
Demo 阶段没有问题。
但实际运行以后,会出现几个麻烦。
第一,ASR和Forced Aligner的生命周期不完全一致。
ASR负责:
text
Audio → Text
Forced Aligner负责:
text
Audio + Text → Timestamp
也就是说,Forced Aligner甚至不要求和ASR使用同一个模型。
今天可以:
text
Qwen3-ASR
↓
Qwen3-ForcedAligner
以后也可能是:
text
Whisper
↓
文本
↓
Qwen3-ForcedAligner
因此我们最后把它拆成一个独立能力:
text
asr-service
↓
transcript
↓
aligner-service
↓
timestamps
熙瑾会悟上层只接收统一后的:
json
{
"text": "接口联调周五之前完成。",
"start_ms": 2172420,
"end_ms": 2174760
}
至于底层文本是谁识别的,对业务层并不重要。
二、部署前先确认宿主机环境
正式装 Python 以前,先看机器。
bash
nvidia-smi
确认至少能够看到:
text
Driver Version
CUDA Version
GPU Name
Memory Usage
然后检查 Docker:
bash
docker version
如果准备使用 NVIDIA GPU 容器,还应该先验证:
bash
docker run --rm \
--gpus all \
nvidia/cuda:12.8.0-base-ubuntu22.04 \
nvidia-smi
能够在容器里正常看到 GPU,再继续做模型镜像。
这一点比直接进入 Python 环境安装包重要得多。
否则后面出现:
text
torch.cuda.is_available() == False
很容易在:
text
PyTorch
CUDA Toolkit
NVIDIA Driver
Docker
NVIDIA Container Runtime
之间反复排查。
三、不要让生产服务器第一次启动时自己下载模型
官方 qwen-asr 支持直接写:
python
Qwen3ForcedAligner.from_pretrained(
"Qwen/Qwen3-ForcedAligner-0.6B"
)
这样运行时会自动寻找和下载模型。
开发阶段很方便。
正式部署不建议这样做。
Qwen官方README已经提供了两种提前下载模型的方法,并明确把 ModelScope 作为中国大陆用户的一种推荐下载路径。
例如:
bash
mkdir -p /data/models
cd /data/models
使用 ModelScope:
bash
pip install -U modelscope
modelscope download \
--model Qwen/Qwen3-ForcedAligner-0.6B \
--local_dir ./Qwen3-ForcedAligner-0.6B
或者 Hugging Face:
bash
pip install -U "huggingface_hub[cli]"
huggingface-cli download \
Qwen/Qwen3-ForcedAligner-0.6B \
--local-dir \
./Qwen3-ForcedAligner-0.6B
当前 Hugging Face 模型仓库显示完整仓库约 1.84GB,并采用 Apache-2.0 License。
下载完成:
bash
du -sh \
/data/models/Qwen3-ForcedAligner-0.6B
再看文件:
bash
find \
/data/models/Qwen3-ForcedAligner-0.6B \
-maxdepth 1 \
-type f \
-printf '%f\n'
四、模型下载完第一件事:保存SHA256
很多项目会把模型下载下来以后直接复制到其他机器。
半年以后再问:
现在A服务器和B服务器是不是同一版?
没人说得清。
所以模型第一次确定以后,我更习惯直接生成哈希:
bash
cd \
/data/models/Qwen3-ForcedAligner-0.6B
find . \
-type f \
! -name SHA256SUMS \
-print0 \
| sort -z \
| xargs -0 sha256sum \
> SHA256SUMS
以后验证:
bash
sha256sum -c \
SHA256SUMS
理想结果:
text
./config.json: OK
./model.safetensors: OK
./tokenizer_config.json: OK
...
正式交付时,建议至少保留:
text
模型名称
模型目录
下载日期
Git Commit / Revision
SHA256
qwen-asr版本
PyTorch版本
CUDA环境
镜像Tag
这比简单写:
text
使用Qwen3-ForcedAligner-0.6B
有用得多。
五、先规划好服务器目录,不要全部塞进容器
这里可以提前把目录固定下来:
text
/data/qwen3-aligner/
├── app/
│ ├── server.py
│ └── requirements.txt
│
├── models/
│ └── Qwen3-ForcedAligner-0.6B/
│
├── tmp/
│
├── logs/
│
├── docker/
│ └── Dockerfile
│
└── docker-compose.yml
我的原则是:
text
代码
→ 镜像
模型
→ 宿主机只读挂载
临时音频
→ 独立tmp目录
日志
→ 独立挂载
业务数据
→ 不写进容器层
这样升级镜像:
bash
docker rm
docker pull
docker run
不会影响模型。
更重要的是,不会因为一次:
bash
docker commit
把几GB甚至几十GB模型权重又复制到一个新镜像里。
六、先用原生Python确认模型可以运行
Qwen官方推荐使用独立环境,目前README给出的快速环境示例采用 Python 3.12,并通过:
bash
pip install -U qwen-asr
安装 Transformers 后端;FlashAttention 2 是可选优化,并要求硬件和 dtype 条件兼容。
创建环境:
bash
conda create \
-n qwen-aligner \
python=3.12 \
-y
conda activate qwen-aligner
安装:
bash
python -m pip install \
--upgrade pip
pip install -U qwen-asr
然后先不要写服务。
直接测试模型:
python
import torch
from qwen_asr import (
Qwen3ForcedAligner,
)
MODEL_PATH = (
"/data/models/"
"Qwen3-ForcedAligner-0.6B"
)
model = (
Qwen3ForcedAligner
.from_pretrained(
MODEL_PATH,
dtype=torch.bfloat16,
device_map="cuda:0",
)
)
result = model.align(
audio="/data/test/test.wav",
text="接口联调需要在周五之前完成。",
language="Chinese",
)
for item in result[0]:
print(
item.text,
item.start_time,
item.end_time,
)
官方目前的 Forced Aligner 示例也是以 bfloat16 + cuda:0 加载,并通过 align(audio, text, language) 返回对齐项。
可能得到:
text
接口 0.42 0.87
联调 0.88 1.31
需要 1.46 1.72
在 1.73 1.85
周五 1.98 2.39
之前 2.40 2.74
完成 2.75 3.16
到这里才说明:
text
驱动
PyTorch
模型
音频解码
Qwen3ForcedAligner
这几层最基本的链路是通的。
七、ForcedAligner有个很重要的限制:别把两小时会议直接扔进去
这个地方非常值得单独写。
当前 qwen-asr 源码中:
python
MAX_FORCE_ALIGN_INPUT_SECONDS = 180
也就是 Forced Aligner 单次输入上限被设置为 180秒;同一份工具代码里,ASR 的输入上限则是 1200 秒。
所以这条链路不应该是:
text
2小时会议.wav
+
2小时转写全文
↓
ForcedAligner
而应该是:
text
完整会议
↓
ASR / VAD已有分段
↓
20~60秒语音块
↓
每块对应文本
↓
ForcedAligner
↓
恢复全局时间轴
假设:
text
Segment 17
原始会议位置:
2150.0s - 2180.0s
Forced Aligner返回:
text
12.42s - 14.76s
那么真正会议时间应该是:
text
2162.42s - 2164.76s
代码很简单:
python
def restore_timestamp(
chunk_start: float,
local_start: float,
local_end: float,
):
return {
"start": (
chunk_start
+ local_start
),
"end": (
chunk_start
+ local_end
),
}
这一层一定要保留。
否则最终得到的是:
text
每个小文件自己的0秒
根本无法用于整场会议回听。
八、把模型封装成独立FastAPI服务
模型验证以后,再服务化。
server.py:
python
import asyncio
import os
import shutil
import tempfile
import uuid
from pathlib import Path
import torch
from fastapi import (
FastAPI,
File,
Form,
HTTPException,
UploadFile,
)
from qwen_asr import (
Qwen3ForcedAligner,
)
MODEL_PATH = os.getenv(
"MODEL_PATH",
"/models/"
"Qwen3-ForcedAligner-0.6B",
)
TMP_ROOT = Path(
os.getenv(
"TMP_ROOT",
"/app/tmp",
)
)
TMP_ROOT.mkdir(
parents=True,
exist_ok=True,
)
app = FastAPI(
title="Qwen3 Forced Aligner",
version="1.0.0",
)
aligner = (
Qwen3ForcedAligner
.from_pretrained(
MODEL_PATH,
dtype=torch.bfloat16,
device_map="cuda:0",
)
)
gpu_lock = asyncio.Lock()
健康检查:
python
@app.get("/health")
def health():
return {
"status": "ok",
"model":
"Qwen3-ForcedAligner-0.6B",
"cuda":
torch.cuda.is_available(),
}
真正接口:
python
@app.post("/api/v1/align")
async def align(
audio: UploadFile = File(...),
text: str = Form(...),
language: str = Form("Chinese"),
):
if not text.strip():
raise HTTPException(
status_code=400,
detail="text不能为空",
)
request_id = (
uuid.uuid4()
.hex[:16]
)
suffix = (
Path(
audio.filename
or "audio.wav"
)
.suffix
.lower()
)
if suffix not in {
".wav",
".mp3",
".flac",
".m4a",
".ogg",
}:
raise HTTPException(
status_code=415,
detail="不支持的音频格式",
)
work_dir = Path(
tempfile.mkdtemp(
prefix=(
f"{request_id}-"
),
dir=TMP_ROOT,
)
)
input_path = (
work_dir
/ f"input{suffix}"
)
try:
with input_path.open(
"wb"
) as f:
while True:
chunk = await audio.read(
1024 * 1024
)
if not chunk:
break
f.write(chunk)
async with gpu_lock:
results = (
await asyncio.to_thread(
aligner.align,
audio=str(
input_path
),
text=text,
language=language,
)
)
items = []
for item in results[0]:
items.append(
{
"text":
item.text,
"start":
item.start_time,
"end":
item.end_time,
}
)
return {
"request_id":
request_id,
"language":
language,
"items":
items,
}
except Exception as exc:
print(
f"[ALIGN ERROR] "
f"id={request_id} "
f"type="
f"{type(exc).__name__} "
f"error={exc}"
)
raise HTTPException(
status_code=500,
detail="对齐失败",
) from exc
finally:
shutil.rmtree(
work_dir,
ignore_errors=True,
)
这里有三个特意做的设计。
第一,模型只加载一次。
第二,临时文件:
text
请求结束
→ 无论成功失败
→ finally删除
第三,先用一个:
python
gpu_lock
限制单模型并发。
不是说 Forced Aligner 一定只能处理一个请求,而是刚开始部署时,比一上来并发八个请求把显存打爆更容易控制。
等完成真实压测以后,再决定批处理和并发量。
九、服务启动不要直接写python server.py
正式运行:
bash
uvicorn \
server:app \
--host 0.0.0.0 \
--port 18220 \
--workers 1
这里同样建议:
text
workers = 1
原因和ASR服务类似。
如果:
bash
--workers 4
每个 Worker 都可能单独:
text
加载一份1.84GB模型仓库对应权重
+
占用一份GPU资源
对于 GPU 模型服务,并不能简单套用普通 Web 服务:
text
CPU多
→ Worker多
的逻辑。
并发应该优先放在模型内部调度、批处理或者任务队列层解决。
十、Dockerfile:把环境固定下来
原生环境验证完成以后再打镜像。
Qwen3-ASR 官方仓库本身提供 CUDA 12.8 / Ubuntu 22.04 的 Dockerfile 示例,其中安装了 FFmpeg、libsndfile、Python 及 qwen-asr 等运行依赖。
我们可以沿用这个思路,做一个只服务 Forced Aligner 的精简镜像:
dockerfile
FROM nvidia/cuda:12.8.0-devel-ubuntu22.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
python3 \
python3-pip \
python3-dev \
ffmpeg \
libsndfile1 \
curl \
ca-certificates \
&& rm -rf \
/var/lib/apt/lists/*
RUN python3 -m pip install \
--no-cache-dir \
--upgrade \
pip setuptools wheel
RUN python3 -m pip install \
--no-cache-dir \
qwen-asr \
fastapi \
"uvicorn[standard]" \
python-multipart
WORKDIR /app
COPY server.py \
/app/server.py
RUN mkdir -p \
/app/tmp \
/app/logs
ENV MODEL_PATH=\
/models/Qwen3-ForcedAligner-0.6B
ENV TMP_ROOT=/app/tmp
EXPOSE 18220
HEALTHCHECK \
--interval=30s \
--timeout=5s \
--start-period=60s \
--retries=3 \
CMD curl -fsS \
http://127.0.0.1:18220/health \
|| exit 1
CMD [
"python3",
"-m",
"uvicorn",
"server:app",
"--host",
"0.0.0.0",
"--port",
"18220",
"--workers",
"1"
]
这里模型没有:
dockerfile
COPY models ...
进去。
这是故意的。
镜像负责:
text
运行环境
+
业务代码
模型继续由宿主机:
text
read-only mount
提供。
十一、构建镜像时给版本号,不要永远latest
构建:
bash
docker build \
-t \
qwen3-forced-aligner:v1.0.0 \
.
确认:
bash
docker images \
qwen3-forced-aligner
不建议生产服务器永远:
text
latest
因为出现问题以后:
text
昨天那个latest
和:
text
今天这个latest
可能已经不是同一个镜像。
至少保留:
text
v1.0.0
v1.0.1
v1.1.0
必要时再额外:
text
stable
指向当前生产版本。
十二、docker run时把GPU、模型和临时目录全部写清楚
启动:
bash
docker run -d \
--name qwen3-aligner \
--restart unless-stopped \
--gpus '"device=0"' \
--shm-size=2g \
-p \
127.0.0.1:18220:18220 \
-v \
/data/models/Qwen3-ForcedAligner-0.6B:\
/models/Qwen3-ForcedAligner-0.6B:ro \
-v \
/data/qwen3-aligner/tmp:/app/tmp \
-v \
/data/qwen3-aligner/logs:/app/logs \
-e \
MODEL_PATH=/models/Qwen3-ForcedAligner-0.6B \
qwen3-forced-aligner:v1.0.0
这里几个参数都不是装饰。
text
--gpus device=0
固定使用哪张GPU。
text
--restart unless-stopped
机器重启后自动恢复。
模型:
text
:ro
只读挂载,避免容器意外修改权重。
端口:
text
127.0.0.1:18220
只监听宿主机本地。
如果会悟后端和 aligner 部署在同一个 Docker Network 中,则甚至可以不把端口暴露到宿主机外部,让两个容器直接通过服务名通信。
十三、启动以后别急着接业务,先做5项检查
查看容器:
bash
docker ps \
--filter \
name=qwen3-aligner
健康状态:
bash
docker inspect \
qwen3-aligner \
--format \
'{{json .State.Health}}'
看日志:
bash
docker logs \
--tail 100 \
qwen3-aligner
确认GPU:
bash
docker exec \
qwen3-aligner \
nvidia-smi
检查模型挂载:
bash
docker exec \
qwen3-aligner \
ls -lh \
/models/Qwen3-ForcedAligner-0.6B
最后:
bash
curl \
http://127.0.0.1:18220/health
返回:
json
{
"status": "ok",
"model": "Qwen3-ForcedAligner-0.6B",
"cuda": true
}
这时候才算服务真正起来。
十四、再用curl跑一次真实接口
准备:
text
/data/test/segment.wav
执行:
bash
curl \
-X POST \
http://127.0.0.1:18220/api/v1/align \
-F \
"audio=@/data/test/segment.wav" \
-F \
"text=接口联调需要在周五之前完成。" \
-F \
"language=Chinese"
返回:
json
{
"request_id": "38da128c260645ea",
"language": "Chinese",
"items": [
{
"text": "接口",
"start": 0.42,
"end": 0.87
},
{
"text": "联调",
"start": 0.88,
"end": 1.31
},
{
"text": "周五",
"start": 1.98,
"end": 2.39
}
]
}
这才是真正需要保存的数据。
十五、接入会议系统时,不要把字级时间戳全部塞进主表
Forced Aligner可能产生非常细的时间粒度。
如果整场会议:
text
2万字
每个字都作为一条关系型数据库记录保存:
text
20,000 rows / meeting
并不一定合理。
更常见的做法是保留:
json
{
"segment_id": 168,
"speaker": "speaker_2",
"start_ms": 2172420,
"end_ms": 2174760,
"text": "接口联调需要在周五之前完成。",
"words": [
{
"text": "接口",
"start_ms": 2172420,
"end_ms": 2172870
}
]
}
其中:
text
segment级时间
用于普通:
text
搜索
跳转
回听
详细:
text
words
可以放 JSON 字段或者单独文件。
这样数据库不会因为字级时间戳膨胀得太快。
十六、真正接入会悟时,只增加一个Alignment Adapter
在熙瑾会悟的业务层里,我们没有让其他模块直接依赖:
text
Qwen3ForcedAligner
这个 Python 类。
而是只增加一个接口适配层:
python
import requests
ALIGNER_API = (
"http://qwen3-aligner:18220"
"/api/v1/align"
)
def align_segment(
audio_path: str,
text: str,
):
with open(
audio_path,
"rb",
) as f:
response = requests.post(
ALIGNER_API,
files={
"audio":
f,
},
data={
"text":
text,
"language":
"Chinese",
},
timeout=120,
)
response.raise_for_status()
return response.json()
业务层只认识:
text
align_segment()
而不认识:
text
Qwen3ForcedAligner
CUDA
Transformers
bfloat16
以后 Forced Aligner 更换,真正改的是 adapter 后面,而不是整个会议系统。
十七、临时音频目录一定要设置兜底清理
接口里的:
python
finally:
shutil.rmtree(...)
只能处理正常 Python 生命周期。
如果服务器发生:
text
kill -9
容器OOM
宿主机断电
GPU Driver异常
临时文件仍然可能留下。
因此宿主机最好额外做定期清理:
bash
find \
/data/qwen3-aligner/tmp \
-type f \
-mmin +180 \
-delete
如果目录里还会产生空文件夹:
bash
find \
/data/qwen3-aligner/tmp \
-type d \
-empty \
-delete
可以放进:
text
systemd timer
或者 cron。
这样系统跑半年以后,tmp 不会悄悄堆几十GB会议录音。
十八、日志同样不能输出会议正文
最简单的调试:
python
print(text)
上线以后最好删掉。
否则:
text
完整会议内容
可能进入:
text
docker logs
journald
ELK
日志备份
更加合适的是:
python
print(
f"request_id={request_id} "
f"chars={len(text)} "
f"result_count={len(items)}"
)
排查问题真正需要的是:
text
请求ID
处理时长
音频时长
模型版本
错误类型
显存
状态码
而不是会议原文。
对于私有化会议系统,这属于很容易被遗漏的数据边界问题。
十九、怎么验证它真的可以断网运行?
本地模型路径已经改成:
text
/models/Qwen3-ForcedAligner-0.6B
还不够。
最好真正验证一次。
先启动一个完全没有外部网络的容器:
bash
docker run -dit \
--name aligner-offline-test \
--network none \
--gpus '"device=0"' \
-v \
/data/models/Qwen3-ForcedAligner-0.6B:\
/models/Qwen3-ForcedAligner-0.6B:ro \
qwen3-forced-aligner:v1.0.0
然后在容器内部:
bash
docker exec \
aligner-offline-test \
curl \
http://127.0.0.1:18220/health
再准备一个测试文件放入挂载目录,从容器内部完成一次 align。
如果:
text
没有网络
+
模型正常加载
+
接口正常返回
才可以确认运行阶段不依赖外部模型下载。
这类验证比文档里写一句:
支持完全离线部署
更可靠。
二十、升级模型时不要覆盖旧目录
以后如果 Forced Aligner 更新,不建议:
text
/data/models/Qwen3-ForcedAligner-0.6B
直接原地覆盖。
更好的目录:
text
/data/models/
├── Qwen3-ForcedAligner-0.6B-r1/
└── Qwen3-ForcedAligner-0.6B-r2/
生产配置:
text
MODEL_PATH=
/models/Qwen3-ForcedAligner-0.6B-r2
如果新版出现问题:
text
停止容器
↓
MODEL_PATH切回r1
↓
启动旧镜像
几十秒就可以回滚。
同样,镜像也保留:
text
qwen3-forced-aligner:v1.0.0
qwen3-forced-aligner:v1.1.0
不要:
text
docker rmi旧版
之后才发现新版本在长会议上有问题。
二十一、生产验收我会额外加一张表
与其只写一句:
模型部署成功。
不如直接记录:
| 检查项 | 验收内容 |
|---|---|
| 模型加载 | 重启后可正常加载 |
| GPU指定 | 只使用目标GPU |
| 模型路径 | 本地只读挂载 |
| 外网依赖 | 断网仍可运行 |
| 中文对齐 | 时间戳正常 |
| 长音频 | 按片段处理并恢复全局时间 |
| 临时文件 | 成功/失败均可清理 |
| 异常请求 | 不导致服务退出 |
| 容器恢复 | 主机重启自动启动 |
| 日志 | 不记录完整会议正文 |
| 版本追溯 | 模型和镜像版本可查询 |
| 回滚 | 旧模型、旧镜像可恢复 |
再额外准备:
text
10秒短语音
60秒普通发言
多人会议片段
中英文混合
远场录音
低信噪比
长会议分段
逐项跑一次。
这比单独测一段官方 Demo 音频更接近真实交付。
二十二、总结:模型能跑和服务能交付,中间差了很多东西
Qwen3-ForcedAligner-0.6B 本身的调用其实非常简单:
python
model.align(
audio=audio,
text=text,
language="Chinese",
)
真正花时间的,反而是它周围这些东西:
text
模型离线下载
版本固定
SHA256
目录规划
环境隔离
Docker封装
GPU绑定
健康检查
长音频切片
全局时间恢复
临时文件清理
日志脱敏
离线验证
模型升级
快速回滚
这些内容看起来和"模型算法"关系不大,却决定了一个 AI 模块究竟只能停留在 Demo,还是能长期运行在真实系统里。
在熙瑾会悟的会议链路中,Forced Aligner 最终承担的也只是一个很明确的职责:
text
已经知道说了什么
↓
准确找到它什么时候说
然后上层再把这个时间信息用于:
text
逐字稿
点击回听
搜索定位
纪要复核
会议资料追溯
从产品界面上看,它可能只是用户点击一句话以后,播放器准确跳到了对应位置。
但从后台看,为了让这一跳稳定工作,后面其实是一套完整的模型部署、时间轴管理和服务化工程。
这也是会议 AI 做到后面越来越明显的一件事:
真正决定产品稳定性的,往往已经不只是模型本身,而是模型怎么被部署、管理和接进业务系统。