Whisper 的代码和模型权重采用 MIT License 发布,可以在本地环境运行,不要求必须调用外部语音 API;Whisper large-v3 的公开模型卡覆盖 99 种语言,并支持语音识别、语言检测和语音到英语的翻译任务。
但如果真正把它放进会议系统,会很快发现:
Whisper解决的是"说了什么",而不是整个会议处理问题。
本文就从部署一个可用的 Whisper large-v3 服务开始,一步一步把它接到 VAD、时间戳、说话人处理和会议纪要链路中,看看一个 ASR 模型怎样真正变成企业可用的会议能力。
一、为什么会议场景依然适合本地部署 Whisper
很多语音识别需求现在都可以通过云 API 完成。
上传一个文件,等待几秒或者几分钟,接口返回文字,开发成本非常低。
但会议场景有几个比较特殊的问题。
首先是数据。
会议录音里可能出现项目进度、人员安排、预算、客户信息、技术方案甚至尚未公开的决策。是否允许把这类原始音频发送到外部服务,本身就是选型的一部分。
其次是调用量。
一段短语音可能只有几十秒,一场会议却可能持续一个小时甚至更长。当每天需要处理几十场、几百场会议时,ASR 已经不是一个偶尔调用的接口,而是持续运行的基础服务。
最后是可控性。
企业往往希望自己决定模型版本什么时候更新、音频保存多久、日志记录哪些字段、哪些机器可以访问 ASR 服务。
Whisper 的一个重要特点就在这里:官方代码和权重可以直接本地运行,而且采用 MIT License 发布。
部署完成后,可以形成这样一条完全内部化的链路:
text
会议麦克风 / 录音文件
↓
音频预处理
↓
Whisper large-v3
↓
转写文本 + 时间戳
↓
说话人处理
↓
会议摘要 / 决策 / 待办
↓
内部数据库与文件系统
从录音到文字,再到最终会议资料,都可以在自己的计算环境内完成。
二、第一步:不用原版推理,先换成 faster-whisper
直接使用 OpenAI 官方 whisper 包当然可以。
官方 Python 接口非常简单:
python
import whisper
model = whisper.load_model("large-v3")
result = model.transcribe("meeting.wav")
print(result["text"])
Whisper 官方实现会读取完整音频,并按照滑动的 30 秒窗口执行自回归转写。
对于工程部署,我们更常考虑 faster-whisper。
它基于 CTranslate2 实现 Whisper 推理,支持 GPU FP16、INT8、CPU INT8、批量推理、词级时间戳,同时集成了 Silero VAD。项目本身也支持直接加载 large-v3,或者提前把 Whisper 模型转换成 CTranslate2 格式后从本地目录加载。
先创建环境:
bash
conda create -n whisper-meeting python=3.10 -y
conda activate whisper-meeting
python -m pip install --upgrade pip
pip install faster-whisper
pip install fastapi uvicorn python-multipart
pip install requests soundfile
检查 GPU:
bash
nvidia-smi
确认 Python 环境:
bash
which python
python -V
pip show faster-whisper
安装 FFmpeg:
bash
sudo apt-get update
sudo apt-get install -y ffmpeg
到这里,基础环境已经准备完成。
三、先跑通最简单的 large-v3 推理
准备一段测试音频:
text
/data/test/meeting.wav
然后新建:
text
test_whisper.py
代码如下:
python
from faster_whisper import WhisperModel
MODEL_NAME = "large-v3"
AUDIO_PATH = "/data/test/meeting.wav"
model = WhisperModel(
MODEL_NAME,
device="cuda",
compute_type="float16",
)
segments, info = model.transcribe(
AUDIO_PATH,
beam_size=5,
language="zh",
)
print(
f"language={info.language}, "
f"probability={info.language_probability:.4f}"
)
for segment in segments:
print(
f"[{segment.start:.2f}s "
f"-> {segment.end:.2f}s] "
f"{segment.text}"
)
运行:
bash
python test_whisper.py
输出可能类似:
text
language=zh, probability=0.9976
[0.38s -> 4.72s] 今天先确认一下项目部署计划。
[5.13s -> 9.81s] 接口联调需要在周五之前完成。
[10.25s -> 14.61s] 测试环境的问题由运维组继续跟进。
这里有一个很容易忽略的细节:
faster-whisper 返回的 segments 是生成器,真正的转写会在你遍历它或者执行 list(segments) 时发生,而不是一定在调用 model.transcribe() 的那一刻全部完成。
因此,下面这种性能统计是有问题的:
python
start = time.time()
segments, info = model.transcribe(
AUDIO_PATH
)
print(time.time() - start)
更准确的方式应该是:
python
import time
start = time.perf_counter()
segments, info = model.transcribe(
AUDIO_PATH,
beam_size=5,
)
segments = list(segments)
elapsed = time.perf_counter() - start
print(f"总耗时:{elapsed:.3f}s")
否则你测出来的可能主要只是"创建生成器"的时间。
四、企业录音第一道坑:先统一音频格式
真实会议文件不会全部是标准 WAV。
你可能同时遇到:
text
.wav
.mp3
.m4a
.aac
.webm
.opus
甚至同样叫 WAV,内部也可能分别是:
text
16kHz
44.1kHz
48kHz
单声道
双声道
PCM
其他编码
在生产系统中,不建议把这些差异全部交给 ASR 模型处理。
我们一般先统一成:
text
16kHz
单声道
PCM WAV
FFmpeg 命令:
bash
ffmpeg -y \
-i meeting.m4a \
-ar 16000 \
-ac 1 \
-c:a pcm_s16le \
meeting_16k.wav
检查结果:
bash
ffprobe \
-v error \
-select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels \
-show_entries format=duration \
-of default=noprint_wrappers=1 \
meeting_16k.wav
输出:
text
codec_name=pcm_s16le
sample_rate=16000
channels=1
duration=5823.418000
可以再封装成 Python:
python
import subprocess
from pathlib import Path
def normalize_audio(
input_path: str,
output_dir: str = "/data/audio_normalized",
) -> str:
input_file = Path(input_path)
output_root = Path(output_dir)
output_root.mkdir(
parents=True,
exist_ok=True,
)
output_path = (
output_root /
f"{input_file.stem}.wav"
)
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(input_file),
"-ar",
"16000",
"-ac",
"1",
"-c:a",
"pcm_s16le",
str(output_path),
]
subprocess.run(
cmd,
check=True,
)
return str(output_path)
这样后面的 VAD、ASR 和说话人模块面对的都是统一输入。
五、长会议别把所有静音都送给模型
会议和普通语音数据有一个很大的区别:
空白时间很多。
一场两小时会议可能包含:
- 等人进入会议室;
- 翻 PPT;
- 临时查资料;
- 电话中断;
- 中场休息;
- 没有人说话的长时间静音。
这些内容没有必要全部进入 ASR。
faster-whisper 本身集成了 Silero VAD,可以在转写过程中去掉无语音片段,并允许调整静音参数。官方 README 中的默认策略相对保守,默认只移除超过 2 秒的静音。
开启方式:
python
segments, info = model.transcribe(
"/data/test/meeting.wav",
language="zh",
beam_size=5,
vad_filter=True,
)
还可以进一步调整:
python
segments, info = model.transcribe(
"/data/test/meeting.wav",
language="zh",
beam_size=5,
vad_filter=True,
vad_parameters={
"min_silence_duration_ms": 500,
},
)
会议场景不要一开始就把静音阈值调得特别激进。
因为一些真实发言可能是:
text
这个事情......
我觉得......
还是需要重新确认。
如果 VAD 参数过于敏感,短停顿可能导致语义被切得过碎。
更稳妥的做法是先用默认参数跑一批真实会议,再根据漏字情况调整。
六、时间戳比纯文本更重要
如果只是做录音转文字:
text
今天确认项目部署时间,接口联调周五之前完成。
已经足够。
但会议系统还需要解决一个非常实际的问题:
点击这句话以后,能不能直接跳回原音频?
因此最好从 ASR 阶段就保留:
text
开始时间
结束时间
文本
faster-whisper 默认已经提供 segment 时间范围,还可以通过 word_timestamps=True 获取词级时间戳。
python
segments, info = model.transcribe(
"/data/test/meeting.wav",
language="zh",
word_timestamps=True,
)
for segment in segments:
print(
segment.start,
segment.end,
segment.text,
)
for word in segment.words:
print(
word.start,
word.end,
word.word,
)
业务层不一定需要保存每个字的时间戳。
会议场景通常保存句级结构已经比较实用:
json
[
{
"start_ms": 380,
"end_ms": 4720,
"text": "今天先确认一下项目部署计划。"
},
{
"start_ms": 5130,
"end_ms": 9810,
"text": "接口联调需要在周五之前完成。"
}
]
以后无论是会议回听、关键词定位,还是"点击纪要跳转原声",都会依赖这组数据。
七、把 Whisper 从脚本变成一个真正的 ASR 服务
命令行脚本只能用于测试。
要接进会议系统,最好将 ASR 独立成 HTTP 服务。
新建:
text
whisper_server.py
代码:
python
import asyncio
import os
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from faster_whisper import WhisperModel
from fastapi import (
FastAPI,
File,
Form,
HTTPException,
UploadFile,
)
MODEL_PATH = os.getenv(
"WHISPER_MODEL_PATH",
"large-v3",
)
DEVICE = os.getenv(
"WHISPER_DEVICE",
"cuda",
)
COMPUTE_TYPE = os.getenv(
"WHISPER_COMPUTE_TYPE",
"float16",
)
model = WhisperModel(
MODEL_PATH,
device=DEVICE,
compute_type=COMPUTE_TYPE,
)
app = FastAPI(
title="Whisper Meeting ASR",
version="1.0.0",
)
# 先使用单请求锁,
# 等真实压测完成后再决定并发策略。
inference_lock = asyncio.Lock()
@app.get("/health")
def health():
return {
"status": "ok",
"model": MODEL_PATH,
"device": DEVICE,
"compute_type": COMPUTE_TYPE,
}
@app.post("/api/asr/transcribe")
async def transcribe(
audio_file: UploadFile = File(...),
language: str = Form(default="zh"),
):
request_id = uuid.uuid4().hex[:16]
suffix = (
Path(audio_file.filename or "")
.suffix
.lower()
)
if suffix not in {
".wav",
".mp3",
".m4a",
".aac",
".flac",
".ogg",
".opus",
".webm",
}:
raise HTTPException(
status_code=415,
detail="不支持的音频格式",
)
temp_dir = Path(
tempfile.mkdtemp(
prefix=f"whisper-{request_id}-"
)
)
input_path = (
temp_dir /
f"input{suffix}"
)
try:
with input_path.open("wb") as f:
while True:
chunk = await audio_file.read(
1024 * 1024
)
if not chunk:
break
f.write(chunk)
started_at = time.perf_counter()
async with inference_lock:
segments, info = (
await asyncio.to_thread(
model.transcribe,
str(input_path),
language=language,
beam_size=5,
vad_filter=True,
)
)
# 注意:segments是生成器
segments = list(segments)
process_sec = (
time.perf_counter()
- started_at
)
result_segments = []
for segment in segments:
result_segments.append(
{
"start_ms": int(
segment.start * 1000
),
"end_ms": int(
segment.end * 1000
),
"text": (
segment.text.strip()
),
}
)
text = "".join(
item["text"]
for item in result_segments
)
return {
"request_id": request_id,
"language": info.language,
"language_probability": (
info.language_probability
),
"text": text,
"segments": result_segments,
"process_sec": round(
process_sec,
3,
),
}
except Exception as exc:
print(
f"[ASR ERROR] "
f"request_id={request_id} "
f"type={type(exc).__name__} "
f"error={exc}"
)
raise HTTPException(
status_code=500,
detail="语音识别失败",
) from exc
finally:
shutil.rmtree(
temp_dir,
ignore_errors=True,
)
启动:
bash
export WHISPER_MODEL_PATH=large-v3
export WHISPER_DEVICE=cuda
export WHISPER_COMPUTE_TYPE=float16
python -m uvicorn \
whisper_server:app \
--host 127.0.0.1 \
--port 18140 \
--workers 1
检查健康状态:
bash
curl \
http://127.0.0.1:18140/health
上传会议录音:
bash
curl -X POST \
http://127.0.0.1:18140/api/asr/transcribe \
-F "audio_file=@meeting.wav" \
-F "language=zh"
到这一步,Whisper 已经从一个 Python Demo 变成了可以被业务系统调用的内部服务。
八、生产环境最好把模型也真正离线下来
如果直接写:
python
WhisperModel(
"large-v3",
device="cuda",
)
首次运行时,faster-whisper 会从 Hugging Face Hub 下载对应的 CTranslate2 模型。官方也支持将原始 Whisper 模型提前转换,然后直接从本地目录加载。
转换:
bash
pip install \
"transformers[torch]>=4.23"
ct2-transformers-converter \
--model openai/whisper-large-v3 \
--output_dir /data/models/whisper-large-v3-ct2 \
--copy_files tokenizer.json preprocessor_config.json \
--quantization float16
然后改成:
python
model = WhisperModel(
"/data/models/whisper-large-v3-ct2",
device="cuda",
compute_type="float16",
)
faster-whisper 官方文档也明确支持直接从本地转换后的模型目录加载。
生产部署时可以进一步生成哈希:
bash
tar -I 'zstd -T0' \
-cf whisper-large-v3-ct2.tar.zst \
-C /data/models \
whisper-large-v3-ct2
sha256sum \
whisper-large-v3-ct2.tar.zst \
> whisper-large-v3-ct2.tar.zst.sha256
目标机器校验:
bash
sha256sum -c \
whisper-large-v3-ct2.tar.zst.sha256
这比"第一次启动服务时再联网下载模型"更容易管理,也更适合隔离网络。
九、到这里还只是ASR:Whisper不知道"谁说的"
假设 Whisper 输出:
text
项目上线时间先定在下周三。
接口部分我这边负责。
那测试环境的问题由运维组继续处理。
对于字幕来说已经很好。
但对于会议纪要来说,还有一个问题:
分别是谁说的?
Whisper large-v3 的主要任务是 ASR 和语音翻译。官方模型卡也明确提醒,说话人分类、说话人分离等能力并不是 Whisper 本身经过充分评估的主要任务。
因此,会议系统更合理的做法是把它拆开:
text
Whisper
负责:
说了什么?
↓
CAM++ / Diarization
负责:
谁说的?
↓
大模型
负责:
哪些内容重要?
↓
会议系统
负责:
怎么保存、展示和继续使用?
经过说话人处理后,文本可以变成:
json
[
{
"speaker_id": "speaker_0",
"start_ms": 380,
"end_ms": 4720,
"text": "项目上线时间先定在下周三。"
},
{
"speaker_id": "speaker_1",
"start_ms": 5130,
"end_ms": 9810,
"text": "接口部分我这边负责。"
},
{
"speaker_id": "speaker_0",
"start_ms": 10250,
"end_ms": 14610,
"text": "那测试环境的问题由运维组继续处理。"
}
]
这时,转写数据才开始真正接近"会议数据"。
十、从"转写文本"到"会议纪要",还差最后一层
完成 ASR 和说话人处理以后,下一步才是大家真正关心的 AI 会议纪要。
在本文的集成链路中,可以将带时间戳和 speaker 信息的结果继续交给 熙瑾会悟 的上层会议处理模块。
整体数据流变成:
text
会议音频
↓
FFmpeg
↓
VAD
↓
Whisper large-v3
↓
时间戳文本
↓
说话人处理
↓
speaker + timestamp + text
↓
熙瑾会悟
↓
会议摘要
议题归纳
决策事项
待办事项
历史归档
ASR 输出可以整理成这样的请求:
python
import requests
MEETING_API = (
"http://127.0.0.1:18080"
"/api/meeting/process"
)
def submit_meeting(
meeting_id: str,
transcript: str,
segments: list[dict],
):
payload = {
"meeting_id": meeting_id,
"source": "whisper-large-v3",
"language": "zh",
"transcript": transcript,
"segments": segments,
"output": {
"summary": True,
"topics": True,
"decisions": True,
"todos": True,
},
}
response = requests.post(
MEETING_API,
json=payload,
timeout=300,
)
response.raise_for_status()
return response.json()
返回结构可以设计成:
json
{
"summary": "本次会议主要确认项目上线时间、接口负责人及测试环境安排。",
"topics": [
"项目上线时间",
"接口开发安排",
"测试环境准备"
],
"decisions": [
"项目暂定下周三上线"
],
"todos": [
{
"owner": "speaker_1",
"task": "完成接口相关工作"
},
{
"owner": "运维组",
"task": "处理测试环境问题"
}
]
}
这也是从 ASR 到 AI 会议助手最关键的一次变化:
Whisper 输出的是听写结果。
会议系统需要输出的是可以继续执行的信息。
十一、长会议真正需要解决的不是"模型能不能跑完"
Whisper 官方实现本身可以处理完整文件,并内部按照 30 秒滑动窗口完成长音频转写;Hugging Face 的 large-v3 实现也提供长音频处理方式。
但生产环境面对一两个小时的会议时,我们更关心的是:
text
服务中途挂了怎么办?
处理到第55分钟失败,要不要重新跑?
多个长会议同时上传怎么办?
显存不够时如何排队?
哪些片段已经完成?
原音频什么时候删除?
因此,工程上可以在 ASR 之外再加一层任务系统。
text
会议文件
↓
生成meeting_id
↓
任务队列
↓
音频预处理
↓
ASR
↓
保存segment结果
↓
说话人处理
↓
会议总结
数据库中保存任务状态:
json
{
"meeting_id": "M20260811001",
"status": "asr_processing",
"progress": 0.63,
"current_stage": "whisper",
"created_at": "2026-08-11 09:30:00"
}
处理完成:
json
{
"meeting_id": "M20260811001",
"status": "completed",
"progress": 1.0,
"current_stage": "finished"
}
这种设计比让一个 HTTP 请求一直等待两小时会议全部处理完成稳定得多。
十二、性能测试别只记录一句"比实时快多少"
做 ASR 性能测试时,经常看到:
text
一小时录音,5分钟转完。
这个数字没有错,但信息远远不够。
至少应该记录:
| 测试项 | 需要记录的内容 |
|---|---|
| 模型 | large-v3 / turbo |
| 推理框架 | OpenAI Whisper / faster-whisper |
| GPU | 实际型号 |
| compute_type | FP16 / INT8_FP16 / INT8 |
| beam_size | 1 / 5 等 |
| VAD | 开 / 关 |
| 音频时长 | 30秒 / 10分钟 / 1小时 |
| 音频场景 | 安静 / 会议室 / 远场 / 中英混合 |
| 处理耗时 | 秒 |
| RTF | 推理耗时 ÷ 音频时长 |
| 峰值显存 | MiB |
| 文本质量 | WER / CER / 人工复核 |
尤其是在比较不同 Whisper 实现时,参数必须尽量保持一致。
faster-whisper 官方特别提醒,OpenAI Whisper 与 faster-whisper 的默认 beam size 并不相同,性能比较时需要使用相似的解码配置,并尽量保证识别质量处于相近水平。
RTF 可以统一计算:
python
def calculate_rtf(
process_sec: float,
audio_sec: float,
) -> float:
if audio_sec <= 0:
return 0.0
return process_sec / audio_sec
例如:
text
音频时长:600秒
处理耗时:48秒
RTF = 48 / 600
= 0.08
RTF 小于 1,说明离线处理速度快于实时播放速度。
但快并不等于识别一定更好。
ASR测试最终仍然需要把速度、显存和文本质量放在一起看。
十三、几个真正值得注意的坑
1. 不要把 Whisper 当成说话人识别模型
Whisper 能生成转写和时间信息,但"谁说的"应该交给单独的 diarization 或声纹模块。官方模型卡也没有把 speaker diarization 作为经过充分验证的核心能力。
2. 不要把模型支持的语言数量等同于所有语言效果一致
large-v3 模型卡标记为 99 languages,但官方同时明确指出,不同语言、不同口音和不同训练资源条件下,识别效果并不均衡,低资源语言可能有更高错误率。
因此,真正上线前仍然应该用自己的会议录音测试。
3. Whisper会出现幻觉和重复
官方模型卡明确提醒,Whisper 在某些输入上可能生成音频中并未实际说出的文本,也可能出现重复文本现象。
会议系统因此最好增加:
text
VAD
重复检测
低置信结果检查
异常长文本检测
人工回听入口
而不是默认认为 ASR 输出一定正确。
4. 不要一上来就开多个 Uvicorn Worker
如果每个 Worker 都加载一份 large-v3:
bash
--workers 4
通常意味着同一张 GPU 上尝试加载多份模型。
对于单 GPU 服务,先从:
bash
--workers 1
开始,然后通过任务队列、批处理或者专门的推理调度策略优化吞吐,会更容易控制资源。
十四、本地部署的价值,最终不是一个"上传文件"页面
到这里回头看,会发现 Whisper large-v3 真正有价值的地方,并不是搭一个网页:
text
选择文件
→ 点击上传
→ 等待识别
→ 复制文本
真正进入企业场景之后,完整链路通常会变成:
text
会议采集
↓
录音文件管理
↓
音频标准化
↓
VAD
↓
Whisper large-v3
↓
时间戳
↓
说话人识别
↓
会议大模型
↓
摘要 / 决策 / 待办
↓
全文检索
↓
原音频回听
这时 Whisper 的角色反而变得非常明确:
它是一块 ASR 底座。
不是整个会议助手。
也正因为模型与业务之间保持了解耦,后续无论把 large-v3 换成 Turbo、Qwen ASR 或其他模型,会议系统本身都不需要重新设计。
十五、总结:Whisper解决"听懂",会议助手解决"用起来"
Whisper large-v3 已经把语音识别的使用门槛降得很低。
代码和模型权重可以本地使用;large-v3 提供多语言 ASR 能力;faster-whisper 又进一步补充了 CTranslate2 推理、VAD、时间戳和批处理等工程能力。
但从真实会议角度看,转写完成只是整个流程的开始。
会议真正需要留下来的不是:
"录音已经转换成了 18,000 字文本。"
而是:
谁提出了什么问题?
最终决定了什么?
哪些事情需要继续处理?
谁负责?
下个月还能不能快速找到这次讨论?
因此,在熙瑾会悟这样的本地会议处理链路中,Whisper 更适合被放在一个清晰的位置:负责将音频可靠地转换为带时间信息的文本,上层系统继续完成说话人组织、纪要生成、待办提取和资料归档。
从这个角度看,部署 Whisper large-v3 并不是在安装一个"会议助手"。
它更像是在搭建会议系统最重要的一层基础设施。
Whisper负责听懂会议。
后面的系统,负责让这些会议内容真正被使用起来。