MOSS-Transcribe-Diarize 0.9B 私有化部署实战:一个模型完成会议转写、说话人识别与时间戳

以前搭建一套多人会议语音处理系统,往往需要部署三个相互独立的模型。

ASR 负责语音转文字,Speaker Diarization 负责判断谁在什么时候发言,Forced Aligner 负责把文本重新对齐到原始音频。

一套典型链路是:

会议录音
   ↓
Qwen3-ASR / Whisper
   ↓
转写文本
   ↓
Sortformer / pyannote
   ↓
说话人时间轴
   ↓
ForcedAligner
   ↓
文本、说话人和时间戳融合
   ↓
会议纪要

这套架构已经能够解决大多数会议处理需求,但真正部署以后,会发现麻烦往往不在单个模型,而在模型之间的数据衔接。

ASR 和 Diarization 对语音边界的判断未必一致;说话人可能在一句话中途发生切换;长会议切片以后,不同片段的 Speaker ID 也可能发生重排。

如果再考虑三个模型各自的 Python 环境、GPU 显存、推理框架和接口协议,一套会议语音链路很容易变成多个独立服务。

2026 年 7 月,OpenMOSS 开源了 MOSS-Transcribe-Diarize 0.9B,提供了一种不同的思路:让模型直接从音频中联合生成转写文本、说话人标签和时间戳。

它不再要求先完成 ASR,再额外调用一个 Speaker Diarization 模型来组织发言关系。

这次我们重点研究三个问题:

  • 一个 0.9B 模型究竟能替代原来多少模块?
  • 如何把它部署成可以长期运行的内网语音服务?
  • 原有会议系统需要怎样调整接口,才能接收这种联合建模结果?

整篇从 Ubuntu 服务器开始,逐步完成模型离线下载、Python 推理、结构化输出解析、FastAPI、Docker、长音频处理以及业务集成。

一、MOSS-Transcribe-Diarize 和普通 ASR 有什么不同?

传统 ASR 的输出通常是:

{
  "text": "接口联调预计周五完成。"
}

经过时间对齐以后,可以得到:

{
  "start": 12.32,
  "end": 15.48,
  "text": "接口联调预计周五完成。"
}

再加入说话人模块,最终才能变成:

{
  "speaker": "speaker_1",
  "start": 12.32,
  "end": 15.48,
  "text": "接口联调预计周五完成。"
}

MOSS-Transcribe-Diarize 的区别,是直接生成类似这样的文本序列:

[0.48][S01]今天先确认一下项目进度。[3.20]
[3.84][S02]接口联调预计周五完成。[7.12]
[7.56][S01]好的,测试环境也同步准备。[11.30]

这里包含三种信息:

字段 作用
[0.48] 当前发言开始时间,单位为秒
[S01] 匿名说话人标签
正文 识别出的语音文本
[3.20] 当前发言结束时间

与传统分模块架构相比,它最大的变化是文本和说话人标签由同一个模型生成,能够减少外部 ASR 与 Diarization 的结果匹配工作。

根据 OpenMOSS 官方模型说明,0.9B 版本支持 50 多种语言、热词提示以及最长 90 分钟的单次音频处理。模型权重采用 Apache-2.0 License。

不过需要区分两个概念。

MOSS 输出的 [S01]、[S02] 是相对于当前录音的匿名说话人编号,并不代表模型已经识别出某个具体人员的真实身份。

另外,模型生成的是带时间范围的发言片段,不等同于专用 Forced Aligner 输出的逐字级精确时间戳。

如果业务需要逐字字幕、高精度时间定位或真实姓名绑定,仍然可能需要后续模块。

二、先看模型架构:0.9B 为什么能同时处理这几种任务?

MOSS-Transcribe-Diarize 并不是简单把 Whisper 和声纹聚类打包到一个 Python 项目里。

它采用的是音频编码器与文本解码器相结合的架构。

根据官方公开的模型结构,主要包括:

组件 技术结构
音频编码器 Whisper-Medium 配置
文本主干 Qwen3-0.6B 风格因果解码器
输入音频 16kHz
音频特征 80维 Mel 特征
音频处理 30秒特征分块
音频文本桥接 时间维度合并与 MLP
输出 文本、Speaker 标签和时间戳

可以简单理解成:

会议音频
   ↓
Whisper Audio Encoder
   ↓
Audio Features
   ↓
Temporal Merge + MLP
   ↓
Qwen-style Decoder
   ↓
[start][speaker]text[end]

音频编码器负责提取语音特征,文本解码器则根据这些音频信息生成包含结构化标记的结果。

这里还有一个值得注意的细节。

模型虽然只有 0.9B 参数,但并不意味着 0.9B 模型就一定能够在任意显卡上轻松处理 90 分钟会议。

长音频产生的音频特征、注意力中间结果和生成 token,同样会影响显存占用。

因此我们仍然需要根据实际 GPU 配置设计长音频处理和并发策略。

三、部署前准备:先固定 Python、CUDA 和项目版本

先检查宿主机。

nvidia-smi

确认 GPU、驱动版本以及当前显存占用。

python3 --version
docker --version

OpenMOSS 官方项目采用 Python 3.12 和 Transformers 5.x 进行验证,因此新建环境时尽量不要直接复用已有的 Whisper 或 Qwen3-ASR 虚拟环境。

创建独立目录:

mkdir -p /data/moss-meeting

cd /data/moss-meeting

mkdir -p models logs tmp tests

建议目录结构:

/data/moss-meeting/
├── app/
│   ├── server.py
│   └── benchmark.py
│
├── source/
│   └── MOSS-Transcribe-Diarize/
│
├── models/
│   └── MOSS-Transcribe-Diarize/
│
├── tests/
│   └── meeting.wav
│
├── tmp/
├── logs/
└── docker/
    └── Dockerfile

将代码、模型权重、临时音频和日志分开存放。

这种目录安排看起来很普通,但等到后面 Docker 镜像升级、模型版本切换、客户环境迁移时,会省掉很多麻烦。

四、安装官方项目与推理环境

先安装 FFmpeg:

sudo apt-get update

sudo apt-get install -y \
    ffmpeg \
    libsndfile1 \
    git

创建环境:

conda create \
    -n moss-meeting \
    python=3.12 \
    -y

conda activate moss-meeting

安装 uv:

python -m pip install -U uv

拉取官方项目:

cd /data/moss-meeting/source

git clone \
    https://github.com/OpenMOSS/MOSS-Transcribe-Diarize.git

cd MOSS-Transcribe-Diarize

为了后续复现,记录当前源码提交:

git rev-parse HEAD

按照官方环境方式安装:

uv pip install -e ".[torch-runtime]" \
    --torch-backend=auto

这会安装项目依赖和 PyTorch 运行环境。

对于已经固定 CUDA、驱动和 PyTorch 的生产机器,也可以先安装经过验证的 PyTorch 版本,再安装项目自身。

安装以后检查:

python - <<'PY'
import torch
import transformers
import moss_transcribe_diarize

print("torch:", torch.__version__)
print("transformers:", transformers.__version__)
print("cuda:", torch.cuda.is_available())

if torch.cuda.is_available():
    print("gpu:", torch.cuda.get_device_name(0))
PY

如果能够正确导入模块,并且 torch.cuda.is_available() 返回 True,就可以继续。

不过需要注意:PyTorch 能看到 GPU,并不代表 MOSS 模型一定能在当前显卡上完整运行。

模型加载和实际推理仍然需要单独验证。

五、正式部署不要让模型在首次启动时自动下载

开发阶段可以直接通过 Hugging Face 模型名称加载:

model_id = "OpenMOSS-Team/MOSS-Transcribe-Diarize"

首次调用时,Transformers 会从模型仓库下载权重及对应配置。

但企业内网部署不能依赖这种方式。

更适合的流程是:

联网准备机器
    ↓
下载模型
    ↓
锁定版本
    ↓
检查文件完整性
    ↓
打包传输
    ↓
目标内网服务器
    ↓
从本地路径加载

安装下载工具:

python -m pip install -U huggingface_hub

下载:

hf download \
  OpenMOSS-Team/MOSS-Transcribe-Diarize \
  --local-dir \
  /data/moss-meeting/models/MOSS-Transcribe-Diarize

模型下载完成后检查:

du -sh \
  /data/moss-meeting/models/MOSS-Transcribe-Diarize

再生成完整性校验文件:

cd /data/moss-meeting/models/MOSS-Transcribe-Diarize

find . -type f \
  ! -name SHA256SUMS \
  -print0 \
  | sort -z \
  | xargs -0 sha256sum \
  > SHA256SUMS

之后可以使用:

sha256sum -c SHA256SUMS

验证复制后的文件是否一致。

MOSS 使用自定义 Transformers 模型代码,加载时需要 trust_remote_code=True。因此正式交付前不应只检查权重,还应固定并审查模型仓库对应的 Python 实现。

六、先跑通最小推理,不要急着封装 FastAPI

完成环境安装后,先准备一段普通话会议录音。

ffmpeg -y \
  -i original_meeting.m4a \
  -ar 16000 \
  -ac 1 \
  -c:a pcm_s16le \
  /data/moss-meeting/tests/meeting.wav

这里统一为:

16kHz
Mono
PCM WAV

然后新建 test_moss.py:

import torch

from transformers import (
    AutoModelForCausalLM,
    AutoProcessor,
)

from moss_transcribe_diarize import (
    parse_transcript,
)

from moss_transcribe_diarize.inference_utils import (
    build_transcription_messages,
    generate_transcription,
    resolve_device,
)


MODEL_PATH = (
    "/data/moss-meeting/models/"
    "MOSS-Transcribe-Diarize"
)

AUDIO_PATH = (
    "/data/moss-meeting/tests/meeting.wav"
)


device = resolve_device("auto")

dtype = (
    torch.bfloat16
    if device.type == "cuda"
    else torch.float32
)


model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
    dtype="auto",
    attn_implementation="sdpa",
).to(dtype=dtype).to(device).eval()


processor = AutoProcessor.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
)


messages = build_transcription_messages(
    AUDIO_PATH
)


result = generate_transcription(
    model,
    processor,
    messages,
    max_new_tokens=8192,
    do_sample=False,
    device=device,
    dtype=dtype,
)


print("Raw Output:")
print(result["text"])


print("\nParsed Segments:")

for segment in parse_transcript(
    result["text"]
):
    print(
        segment.start,
        segment.end,
        segment.speaker,
        segment.text,
    )

这里使用的是官方提供的 generate_transcription() 和 parse_transcript(),而不是自己拼接 Transformers 的音频 Prompt。

执行:

python test_moss.py

正常情况下,模型首先产生带时间戳和 Speaker 标签的文本序列,然后通过解析器转换为多个结构化 Segment。

一段会议的输出格式可以类似:

[1.24][S01]今天先确认一下项目上线计划。[5.82]
[6.12][S02]接口联调周五之前可以完成。[10.36]
[11.04][S01]那测试环境同步准备。[14.27]

注意,这里展示的是输出结构示例,并不是固定的识别结果。

实际说话人数量、分段长度和内容由输入音频决定。

七、为什么不建议自己用正则表达式解析模型输出?

看到输出格式以后,很容易想到:

import re

然后自行匹配:

[start][speaker]text[end]

这种方法可以快速制作 Demo,但对于生产环境并不稳妥。

原因是模型生成的是一条连续序列。

其中可能存在:

多个片段连续拼接

文本中的数字

额外声学事件标记

不完整时间戳

异常空文本

输出被max_new_tokens截断

因此官方已经提供的解析器应该优先使用。

我们可以在官方 parse_transcript() 外面再增加一层业务数据转换:

from moss_transcribe_diarize import (
    parse_transcript,
)


def normalize_segments(raw_text: str):
    result = []

    for item in parse_transcript(raw_text):

        start_ms = round(
            float(item.start) * 1000
        )

        end_ms = round(
            float(item.end) * 1000
        )

        text = item.text.strip()

        if end_ms <= start_ms:
            continue

        if not text:
            continue

        result.append({
            "speaker_id": str(item.speaker),
            "start_ms": start_ms,
            "end_ms": end_ms,
            "text": text,
        })

    return result

最终:

[
  {
    "speaker_id": "S01",
    "start_ms": 1240,
    "end_ms": 5820,
    "text": "今天先确认一下项目上线计划。"
  },
  {
    "speaker_id": "S02",
    "start_ms": 6120,
    "end_ms": 10360,
    "text": "接口联调周五之前可以完成。"
  }
]

这一步看起来只是格式转换,却是新模型接入已有会议系统时最重要的适配层。

在熙瑾会悟的接入设计中,可以保留原有会议数据协议,让 MOSS 的联合识别结果经过 Adapter 转换后直接进入逐字稿、会议回听和纪要处理链路。

这样业务层不需要知道底层究竟采用了三个独立模型,还是一个联合模型。

八、企业会议里最实用的一个能力:热词提示

通用 ASR 经常遇到一个问题:

普通文字识别没有太大问题,但一到企业名称、产品型号、技术缩写,就容易出现错误。

比如:

Prometheus
Grafana
昇腾910B
CUDA
Kubernetes

这些词在日常语音中出现频率不高,但在技术会议里很常见。

MOSS 支持通过 Prompt 提供热词提示。

官方默认的结构化转写指令可以扩展为:

prompt = """
请将音频转写为文本,每一段需以起始时间戳
和说话人编号([S01]、[S02]、[S03]…)
开头,正文为对应的语音内容,
并在段末标注结束时间戳。

热词提示:
Prometheus, Grafana, 昇腾910B,
Kubernetes, CUDA
"""

通过官方 build_transcription_messages() 的 Prompt 参数构造对应消息,即可把热词提示纳入模型输入。

但需要注意:

热词提示不等于硬约束词典。

它是在生成时提供上下文线索,并不保证指定专业术语一定正确。

因此企业术语场景仍然建议保留自己的测试集,单独统计专业词命中率。

九、生产服务化:把模型加载和会议请求分开

模型推理跑通以后,下一步是将其封装成内部 HTTP 服务。

这里最不建议的写法是:

@app.post("/transcribe")
def transcribe(...):

    model = AutoModelForCausalLM.from_pretrained(
        MODEL_PATH
    )

每个请求都重新加载一次模型。

正确思路应该是:

服务启动
   ↓
加载模型
   ↓
GPU常驻
   ↓
等待请求
   ↓
处理音频
   ↓
返回结构化结果
   ↓
等待下一次请求

新建 server.py。

首先初始化:

import os
import asyncio
import shutil
import tempfile
import time
from pathlib import Path

import torch

from fastapi import (
    FastAPI,
    File,
    HTTPException,
    UploadFile,
)

from transformers import (
    AutoModelForCausalLM,
    AutoProcessor,
)

from moss_transcribe_diarize import (
    parse_transcript,
)

from moss_transcribe_diarize.inference_utils import (
    build_transcription_messages,
    generate_transcription,
    resolve_device,
)


MODEL_PATH = os.getenv(
    "MODEL_PATH",
    "/models/MOSS-Transcribe-Diarize",
)

TMP_ROOT = Path(
    os.getenv("TMP_ROOT", "/app/tmp")
)

TMP_ROOT.mkdir(
    parents=True,
    exist_ok=True,
)

device = resolve_device("auto")

dtype = (
    torch.bfloat16
    if device.type == "cuda"
    else torch.float32
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
    dtype="auto",
    attn_implementation="sdpa",
).to(dtype=dtype).to(device).eval()

processor = AutoProcessor.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
)

app = FastAPI(
    title="MOSS Meeting ASR",
    version="1.0.0",
)

gpu_lock = asyncio.Lock()

这里先采用单模型、单请求推理锁。

它不是最终吞吐最高的方案,但适合在第一轮部署时排除多请求显存竞争问题。

增加推理函数:

def run_inference(audio_path: str):

    messages = build_transcription_messages(
        audio_path
    )

    result = generate_transcription(
        model,
        processor,
        messages,
        max_new_tokens=8192,
        do_sample=False,
        device=device,
        dtype=dtype,
    )

    segments = []

    for item in parse_transcript(
        result["text"]
    ):
        segments.append({
            "speaker_id": str(item.speaker),
            "start_ms": int(item.start * 1000),
            "end_ms": int(item.end * 1000),
            "text": item.text.strip(),
        })

    return {
        "raw_text": result["text"],
        "segments": segments,
    }

再增加音频标准化:

import subprocess


def normalize_audio(
    input_path: str,
    output_path: str,
):
    command = [
        "ffmpeg",
        "-hide_banner",
        "-loglevel", "error",
        "-y",
        "-i", input_path,
        "-ar", "16000",
        "-ac", "1",
        "-c:a", "pcm_s16le",
        output_path,
    ]

    subprocess.run(
        command,
        check=True,
        timeout=300,
    )

HTTP 接口:

@app.get("/health")
def health():
    return {
        "status": "ok",
        "model": "MOSS-Transcribe-Diarize",
        "device": str(device),
    }


@app.post("/api/v1/transcribe")
async def transcribe(
    audio: UploadFile = File(...),
):

    work_dir = Path(
        tempfile.mkdtemp(
            dir=TMP_ROOT
        )
    )

    input_path = work_dir / "input_audio"
    normalized_path = work_dir / "audio.wav"

    try:
        with input_path.open("wb") as f:

            while True:
                chunk = await audio.read(
                    1024 * 1024
                )

                if not chunk:
                    break

                f.write(chunk)

        await asyncio.to_thread(
            normalize_audio,
            str(input_path),
            str(normalized_path),
        )

        started = time.perf_counter()

        async with gpu_lock:
            result = await asyncio.to_thread(
                run_inference,
                str(normalized_path),
            )

        cost = (
            time.perf_counter() - started
        )

        return {
            "process_sec": round(cost, 3),
            **result,
        }

    except Exception as exc:
        print(
            "MOSS_ERROR",
            type(exc).__name__,
            str(exc),
        )

        raise HTTPException(
            status_code=500,
            detail="会议转写失败",
        ) from exc

    finally:
        shutil.rmtree(
            work_dir,
            ignore_errors=True,
        )

这段代码适合作为服务化起点。正式对外提供上传接口时,还需要由网关限制文件大小、认证访问来源,并对音频时长进行检查,避免异常请求无限占用 GPU。

启动:

uvicorn server:app \
  --host 127.0.0.1 \
  --port 18230 \
  --workers 1

请求:

curl -X POST \
  http://127.0.0.1:18230/api/v1/transcribe \
  -F "audio=@meeting.wav"

返回的核心内容:

{
  "process_sec": 12.38,
  "segments": [
    {
      "speaker_id": "S01",
      "start_ms": 1240,
      "end_ms": 5820,
      "text": "今天先确认一下项目上线计划。"
    }
  ]
}

这里的处理耗时仅用于展示接口字段,不代表特定硬件的实测速度。

十、Docker部署:模型权重与运行镜像分离

完成 Python 验证以后,再考虑 Docker。

对私有化部署来说,我更建议:

Docker Image
   ↓
Python运行环境
模型推理代码
API服务

Model Directory
   ↓
MOSS权重
Tokenizer
Processor
自定义模型代码

Config
   ↓
端口
GPU
模型路径
临时目录

不要把所有东西都直接打进一个不断更新的大镜像。

尤其是模型权重,不建议在每次业务代码更新时重复复制。

可以先使用 Python 3.12 基础镜像构建运行环境。

Dockerfile:

FROM python:3.12-slim

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
    && apt-get install -y \
       --no-install-recommends \
       ffmpeg \
       libsndfile1 \
       ca-certificates \
    && rm -rf /var/lib/apt/lists/*

RUN python -m pip install \
    --no-cache-dir \
    --index-url \
    https://download.pytorch.org/whl/cu128 \
    torch torchaudio

COPY MOSS-Transcribe-Diarize \
    /opt/moss-source

RUN python -m pip install \
    --no-cache-dir \
    /opt/moss-source

WORKDIR /app

COPY server.py /app/server.py

RUN mkdir -p /app/tmp

ENV MODEL_PATH=/models/MOSS-Transcribe-Diarize
ENV TMP_ROOT=/app/tmp

EXPOSE 18230

CMD ["python", "-m", "uvicorn", \
     "server:app", \
     "--host", "0.0.0.0", \
     "--port", "18230", \
     "--workers", "1"]

这份 Dockerfile 使用 CUDA 12.8 的 PyTorch wheel 路线,目标机器需要匹配的 NVIDIA 驱动及 Container Toolkit。实际交付时应固定 PyTorch、Transformers 和项目源码版本,而不是长期使用浮动依赖。

构建:

docker build \
  -t moss-meeting:v1.0.0 \
  .

启动:

docker run -d \
  --name moss-meeting \
  --restart unless-stopped \
  --gpus '"device=0"' \
  --shm-size=4g \
  -p 127.0.0.1:18230:18230 \
  -v /data/moss-meeting/models/MOSS-Transcribe-Diarize:/models/MOSS-Transcribe-Diarize:ro \
  -v /data/moss-meeting/tmp:/app/tmp \
  -e MODEL_PATH=/models/MOSS-Transcribe-Diarize \
  -e HF_HUB_OFFLINE=1 \
  -e TRANSFORMERS_OFFLINE=1 \
  moss-meeting:v1.0.0

这里有三个关键细节。

第一个是:

--gpus device=0

固定目标 GPU。

第二个是模型目录:

:ro

只读挂载,防止运行过程意外修改模型。

第三个是:

HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1

要求 Hugging Face 相关组件在运行阶段优先使用本地资源,避免启动时重新访问远程模型仓库。

十一、为什么官方还推荐 SGLang Omni 和 vLLM?

上面的 Transformers 路线适合验证模型能力和封装基础服务。

但真正需要多任务并发时,单 Python 服务并不是唯一选择。

MOSS 官方目前提供三种主要推理方向:

路径 更适合的用途 特点
Transformers 研发验证、定制处理 接口灵活,方便调试
vLLM GPU服务化 支持统一转写接口与推理调度
SGLang Omni 长音频、高吞吐场景 官方推荐的优化服务路线

SGLang Omni 当前主要面向 CUDA 13 环境,提供兼容 OpenAI 的 /v1/audio/transcriptions 接口。

在已经安装并验证 SGLang Omni 的环境中,可以使用:

sgl-omni serve \
  --model-path \
  OpenMOSS-Team/MOSS-Transcribe-Diarize \
  --port 8000 \
  --max-running-requests 16 \
  --cuda-graph-max-bs 16 \
  --mem-fraction-static 0.80

如果要获得解析后的 Speaker Segments,可以请求:

curl -X POST \
  http://127.0.0.1:8000/v1/audio/transcriptions \
  -F model=OpenMOSS-Team/MOSS-Transcribe-Diarize \
  -F file=@meeting.wav \
  -F response_format=verbose_json

这里的 verbose_json 比普通 json 更适合会议系统,因为它可以直接返回结构化分段,而不是只返回原始转写文本。

对于 CUDA 12 环境,可以考虑官方文档中的 vLLM 路线。

但需要注意:普通 vLLM 稳定版不一定包含 MOSS-Transcribe-Diarize 的模型注册。

官方提供了特定的 vLLM nightly 构建,因此正式部署前应按照仓库 README 固定支持该模型的运行时版本。

这也是为什么实际迁移时,我更建议先验证 Transformers 路径,再切换推理后端。

这样可以把问题分成两类:

模型本身是否正确

以及:

服务框架是否正确

不会把两者混在一起排查。

十二、长会议处理:支持90分钟不等于所有环境都能一次跑完

MOSS 官方介绍了最长 90 分钟的单次音频处理能力。

但对于生产环境,这个数字仍然需要结合显存、生成长度和服务并发一起考虑。

例如一段一个小时的会议,除了输入音频特征以外,还会产生大量转写 token:

音频
   ↓
音频特征
   ↓
文本解码
   ↓
时间戳
Speaker标签
转写文字

如果仍然使用:

max_new_tokens=2048

就可能导致长会议转写没有生成完整。

官方 SGLang Omni 文档也建议对长音频增大 max_new_tokens,甚至给出了 65536 的设置示例。

不过这个参数同样会影响显存和生成开销,不应该无条件调到最大。

我更建议按三个阶段验证:

音频长度 验证目标
1~3分钟 API与输出格式正确
10~20分钟 常规会议显存、速度
60~90分钟 长音频稳定性和完整性

对于资源有限的服务器,仍然可以设置业务侧切片。

例如每段 10 分钟,保留适当重叠区域。

def build_chunks(
    duration_sec: float,
    chunk_sec: float = 600,
    overlap_sec: float = 10,
):
    chunks = []
    start = 0.0

    while start < duration_sec:

        end = min(
            start + chunk_sec,
            duration_sec,
        )

        chunks.append({
            "start": start,
            "end": end,
        })

        if end >= duration_sec:
            break

        start = end - overlap_sec

    return chunks

但联合模型同样存在跨切片 Speaker ID 问题。

第一段中的:

S01 = 张工

不代表第二段:

S01 = 张工

如果把音频拆开独立推理,匿名 Speaker 标签就需要重新建立跨片段映射。

因此对 MOSS 来说,一次性处理完整会议除了减少接口调用,还有保持说话人上下文一致性的价值。

是否切片,需要结合显存、长会议错误率和 Speaker 连续性进行选择。

十三、怎么衡量新模型是否真的比原来的三模型链路更好?

这是本文最值得做的实验。

以前的架构:

Qwen3-ASR
+
Sortformer
+
ForcedAligner

现在的架构:

MOSS-Transcribe-Diarize

不能只比较:

用了几个模型

还要比较识别质量和工程开销。

建议测试至少包括:

指标 三模型链路 MOSS联合模型
CER 单独测量 单独测量
cpCER 单独测量 单独测量
DER 单独测量 单独测量
RTF 整链路计算 整链路计算
峰值显存 三模块峰值及调度方式 单模型峰值
服务数量 多个模型服务 一个联合推理服务
Speaker一致性 依赖融合与跨段处理 需要检验联合输出
时间戳精度 可使用ForcedAligner 模型直接生成段级时间
长会议失败率 单独统计 单独统计
运维复杂度 多套模型依赖 单套核心模型依赖

这里建议重点关注三个指标。

CER(字符错误率)衡量文本识别质量。

cpCER(最小排列拼接字符错误率)综合考察不同说话人对应的转写内容,比较适合研究带 Speaker 标签的转写任务。

DER(说话人分离错误率)则单独衡量说话人时间轴,包括漏检、误检和说话人混淆。

cpCER 与 DER 并不是同一个指标,不能只测 cpCER 就认定说话人时间轴已经足够准确。

OpenMOSS 官方在 AISHELL-4 测试中公布了 0.9B 模型的 CER 为 14.84%、cpCER 为 15.83%,在 Alimeeting 上也提供了对应测试结果。

这些是官方公开测试集的数据,不能直接当成企业实际会议的准确率。

更有价值的做法,是用自己的中文多人会议录音跑一轮 A/B 测试。

性能测试建议

测试前先固定:

GPU型号
驱动版本
CUDA环境
模型Revision
PyTorch版本
推理框架版本
音频格式
测试样本
输出长度

随后计算:

def calculate_rtf(
    process_sec: float,
    audio_sec: float,
):
    if audio_sec <= 0:
        raise ValueError(
            "audio_sec must be positive"
        )

    return process_sec / audio_sec

例如一段 600 秒音频,处理耗时 48 秒:

RTF = 48 / 600
    = 0.08

表示离线处理速度快于实时播放速度。

需要特别注意,三模型链路的耗时应该统计完整处理过程,而不是只拿 Qwen3-ASR 的推理耗时与 MOSS 比较。

否则结果会明显偏向原有架构。

十四、接入熙瑾会悟时,最值得保留的是统一Adapter

从模型选型角度看,MOSS 最吸引人的地方是降低多模型融合复杂度。

但从会议产品角度看,更值得保留的反而是统一接口。

不管底层采用:

Whisper
+
Speaker模型
+
ForcedAligner

还是:

MOSS-Transcribe-Diarize

上层都应该得到相同的数据结构:

{
  "meeting_id": "M20261022001",
  "segments": [
    {
      "speaker_id": "S01",
      "start_ms": 1240,
      "end_ms": 5820,
      "text": "今天先确认一下项目上线计划。"
    }
  ]
}

这样会议业务层才能继续复用:

逐字稿展示
    ↓
Speaker标签
    ↓
点击文字回听
    ↓
会议摘要
    ↓
决策与待办提取
    ↓
归档与全文检索

对于熙瑾会悟的模型接入设计,可以让旧的多模块链路与新的 MOSS Adapter 并存。

上线前先使用同一批会议进行双路推理:

同一份会议音频
       │
       ├── 原ASR+Speaker链路
       │          ↓
       │       结构化文本A
       │
       └── MOSS联合模型
                  ↓
               结构化文本B

通过统一的数据协议比较两个结果。

这样迁移不是一次性替换所有旧组件,而是先完成验证、再进行切换。

如果新模型在某些方言、特殊多人场景或者长会议上出现问题,仍然能够回退到原来的处理路径。

十五、真正部署时,最容易忽略的几个问题

1. Speaker标签不是人员姓名

S01 和 S02 只是匿名标签。

如果业务要求展示张工、李工或者王经理,仍然需要人工绑定、独立声纹身份识别等方式。

Diarization 和 Speaker Identification 不能混为一谈。

2. 输出格式正确不等于结果一定完整

模型可能因为生成长度限制而提前结束。

因此除了检查 HTTP 200,还应该检查:

是否有有效Segment

最后一个Segment结束时间

原始音频总时长

是否存在异常时间戳

是否出现大量重复文本

特别是长会议,不能因为接口正常返回就认为完整识别了全部内容。

3. 多人重叠说话仍然要单独验收

联合建模减少了模型间融合步骤,但并不意味着多人同时说话的问题已经消失。

仍然需要测试:

两人抢话
多人快速轮换
远场拾音
同声线人员
中英文混说
嘈杂会议室

4. 不要把模型权重体积当成显存需求

0.9B 参数只是模型规模。

实际显存还与音频长度、注意力实现、生成长度、并发数和推理框架有关。

长会议尤其需要实际监控:

nvidia-smi

以及服务日志中的:

CUDA out of memory

5. 模型版本与运行时版本应当一起固定

MOSS 使用自定义 Transformers 代码。

因此不能只记录:

MOSS 0.9B

而应该同时记录:

模型Revision
代码Commit
Transformers版本
PyTorch版本
推理后端版本
Docker镜像Tag

对于内网环境,版本可追溯往往比频繁追新版本更重要。

十六、总结:多模型会议链路,开始出现新的工程选择

过去我们习惯把一套会议语音系统拆成:

ASR
+
Diarization
+
Forced Alignment

这种分模块结构具有很强的可控性,各个模块可以独立优化,也方便针对特殊语言和业务场景进行调节。

MOSS-Transcribe-Diarize 则提供了另一种思路。

会议音频
   ↓
MOSS-Transcribe-Diarize
   ↓
文本 + Speaker + 时间戳
   ↓
会议业务系统

它把原来需要多个模型配合完成的任务放到了同一条生成链路中,减少了独立 Speaker 服务和时间轴融合的工作。

对于熙瑾会悟这样的完整会议系统,这种技术变化带来的价值不只是少部署一个模型,而是可以重新考虑语音处理层的架构边界:哪些任务仍然需要独立服务,哪些能力已经可以由联合模型一次完成。

当然,联合建模也不是所有场景的最终答案。

如果业务需要非常精细的逐字时间戳、已注册人员实名识别、复杂重叠语音处理或者特定方言优化,分模块架构仍然可能更加合适。

真正值得做的不是立即删除原来的 ASR、Sortformer 和 ForcedAligner,而是让新模型通过统一接口进入现有系统,再用真实会议数据决定是否替换。

从多个模型拼接到一个模型联合输出,减少的是模型之间的衔接复杂度;真正决定能否进入生产环境的,仍然是识别质量、说话人一致性、长会议稳定性以及完整的部署管理能力。

posted @ 2026-10-22 18:34  AI助力语音识别  阅读(4)  评论(0)    收藏  举报