语音识别及说话人分离模型(MOSS-Transcribe-Diarize)本地部署最佳实践

基于一次真实落地经历整理:在 Apple M2 Max(64GB 统一内存)上部署 OpenMOSS/MOSS-Transcribe-Diarize(0.9B 端到端音频转写 + 说话人分离),并构建 Web 应用。过程中解决了长音频(30 分钟 / 1–2 小时)在 MPS 上 OOM / 静默卡死的问题。

本文不重复项目能力说明,只讲”怎么把它跑稳、跑长”。能力研究与部署步骤见 README.md,Web 后端实现见 webapp/app.py


0. 一句话结论

不要在 Apple Silicon 上一次性推理超长音频。 端到端音频模型会把整段音频堆叠成一个巨型张量送编码器,自注意力矩阵大小 ∝ 音频长度²,几分钟可以、30 分钟必炸。正确做法是按 ≤300s 分块推理、块间重叠、时间戳偏移拼接、每块后释放 MPS 缓存


1. 部署:把”能跑”变成”稳定跑”

1.1 固定依赖版本,杜绝解析回溯

torch>=2.8 + 未固定版本的 pip install -e . 会让 uv 陷入 version-resolution 回溯死循环(解析到 2.13、多个 transformers 版本,半小时不收敛)。

# 固定 torch(macOS 轮子含 MPS 支持),避免 uv 回溯
uv pip install torch==2.11.0 torchaudio==2.11.0
# 固定 transformers,避免解析回溯
uv pip install -e . "transformers==5.10.2"

最佳实践:凡是带 C 扩展、版本敏感、且官方未锁版本的 ML 项目,先固定 torch / transformers / 项目本身三者的版本再装,能省下大量”为什么装了 40 分钟还没好”的排查时间。

1.2 HF 权重走镜像 + 新 CLI

官方 huggingface.co 在国内常被墙;旧 huggingface-cli 已废弃。

export HF_ENDPOINT=https://hf-mirror.com
hf download OpenMOSS-Team/MOSS-Transcribe-Diarize \
  --local-dir models/OpenMOSS-Team/MOSS-Transcribe-Diarize

1.3 找对 venv(多 venv 陷阱)

本项目克隆的仓库自带 .venvMOSS-Transcribe-Diarize/.venv),而项目根目录并没有 venv。启动脚本 run.sh 用的就是仓库内的那个。用错 venv 会报 No module named moss_transcribe_diarize / torch

最佳实践:启动任何脚本前,先用目标 python 验证依赖齐全:

MOSS-Transcribe-Diarize/.venv/bin/python -c \
  "import torch, fastapi, moss_transcribe_diarize; print('deps OK', torch.__version__)"

1.4 Apple Silicon 用 MPS + float32

本机无 CUDA,采用 transformers 直接推理 + MPS 加速。MPS 上统一用 float32 最稳(CUDA 才用 bf16 省显存)。设备选择交给 auto

import torch
if torch.backends.mps.is_available():
    device = "mps"   # Apple Silicon 自动走 Metal

2. Apple Silicon MPS 显存:长音频的真正敌人

2.1 MPS 不是”无限显存”

MPS 复用系统统一内存,但有硬上限(本机约 88GB,受 Metal 与系统约束)。OOM 报错长这样:

MPS backend out of memory (MPS allocated: 7.64 GiB, other allocations: 68.91 GiB, max allowed: 88.13 GiB).
Tried to allocate 33.92 GiB on private pool.
Use PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 to disable upper limit for memory allocations (may cause system failure).

不要PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 来”解除上限”——它只是把崩溃推迟到拖垮系统,治标不治本。

torch.mps.empty_cache() 只释放缓存池,不释放已持有的张量;它用来避免”多块累积”,但不能挽回单次超大分配。

2.2 端到端音频模型的隐藏陷阱:整段堆叠

MOSS-Transcribe-Diarize 内部按 30s 切片,但会把整段音频的所有切片堆叠成一个巨大张量一次性送 Whisper 编码器;解码器又对所有音频 pad token 做自注意力。于是:

  • 注意力矩阵 ∝ 音频长度²
  • 30 分钟 ≈ 22500 个音频 token → 注意力矩阵 22500×22500
  • 显存随音频长度平方级暴涨,轻易越过 MPS 上限

更早还会先出现 Audio features and audio tokens do not match: tokens: 0, features 33782 这类张量维度不匹配的报错(同样是”一次性塞太大”的副作用)。

2.3 症状分辨:OOM 报错 vs 静默卡死

现象原因排查难度
MPS backend out of memory ...单块/单次分配超 MPS 上限低(报错明确)
Audio features and audio tokens do not match: tokens: 0音频张量与 token 维度不匹配(仍是一口气太大)
Metal command buffer exited with error ... kIOGPUCommandBufferCallbackErrorSubmissionsIgnoredMetal 命令缓冲区对超长序列挂起
进度长时间不动、无报错、进程冻结同上,Metal 命令缓冲区静默挂起最高

最佳实践:超长单块(如 600s / 10 分钟)不会报错,而是永久卡死。这种”静默卡死”比 OOM 更难排查——必须靠进度监控 / 超时才能发现,绝不能靠”看起来没崩就当成功”。


3. 长音频最佳实践:分块推理(Chunked Inference)

3.1 分块策略

把整段音频切成 CHUNK_SECONDS(默认 300s)的块,逐块送模型:

chunk_samples = int(CHUNK_SECONDS * SR)
starts = list(range(0, total_samples, chunk_samples))
for i, start in enumerate(starts):
    # 非首块向前取 overlap 重叠,避免句首被截断
    seg_start = max(0, start - (overlap_samples if i > 0 else 0))
    seg_end   = min(total_samples, start + chunk_samples)
    arr = audio[seg_start:seg_end]
    # 写成临时 wav(见 4.1),调用库的单文件推理
    segments = generate_transcription(...)      # 本块结果
    offset = seg_start / SR                      # 本块在整段中的起点
    for s in segments:
        abs_start = s.start + offset             # 偏移为绝对时间戳
        if i > 0 and s.start < keep_from - 0.05: # 丢弃重叠区重复段
            continue
        all_segments.append({...绝对时间戳...})
    torch.mps.empty_cache()                      # 释放 MPS 缓存,避免累积

3.2 MPS 安全块长(实测)

单块长度结果
≤ 120–180s稳定(更保守,显存余量更大)
300s(默认)稳定,5 分钟单块约 170s 完成,无 OOM / 卡死
600s(10 分钟)卡死:Metal 命令缓冲区挂起,无报错,进程冻结

结论:默认 300s,遇到不稳定下调到 120–180s。可调环境变量 MTD_CHUNK_SECONDS(详见第 8 节)。

3.3 时间戳拼接与去重

  • 每块结果的时间戳是相对本块的,必须加 offset = seg_start / SR 偏移成整段绝对秒数。
  • 块间留 OVERLAP_SECONDS(默认 15s)重叠,避免句子在边界被截断;重叠区内的段落已由上一块覆盖,按时间丢弃避免重复。
  • 验证正确性的硬指标:拼接后所有段的 start 必须单调不回退(回退即去重/偏移逻辑有 bug)。

3.4 跨块说话人一致性

每块内部独立编号 S01/S02。若某块只出现 1 个说话人,其标签可能与相邻块错开。当前版本不保证跨块说话人身份一致。长会议如需跨段一致的说话人,需另接说话人聚类 / 声纹对齐——产品上要管理用户预期,不要承诺”全程同一标签”。


4. 库集成陷阱

4.1 numpy 数组不能参与布尔短路判断

库的 process_audio_info 内部用 item.get("audio") or item.get("audio_url") or ... 做短路判断。numpy 数组无法参与布尔运算,直接传数组会炸:

ValueError: The truth value of an array with more than one element is ambiguous

最佳实践:分块时把每块写成 wav 文件、传路径(用标准库 wave 写 16-bit PCM 单声道),复用库已验证的”单文件推理”路径,而不是传 numpy 数组。

4.2 优先走”单文件路径”推理

第三方模型库往往对”单文件路径”输入打磨得最充分。遇到奇怪的张量 / 类型错误时,先回到”传文件路径”这条最稳的路径,再考虑数组 / tensor 直传。


5. Web 应用架构:长任务异步化

5.1 job + 轮询模式

长音频推理耗时分钟级,绝不能同步等结果(会请求超时、前端挂起)。采用异步任务 + 轮询

  • POST /api/transcribe:立即接收文件、创建后台任务、马上返回 job_id
  • 后台线程跑分块推理
  • GET /api/job/{id}:轮询 status / progress(含 stagechunktotal
  • 前端每 1.5s 轮询,显示”正在转写第 cur/total 段” + 进度条

5.2 错误信息要带 traceback

后台线程的异常不会冒泡到 HTTP 请求。必须在 job 结果里存 error + traceback,否则排错时只能看到”任务失败”四个字。

except Exception as exc:
    import traceback as _tb
    job.update(status="error",
               error=f"{type(exc).__name__}: {exc}",
               traceback=_tb.format_exc())   # 关键:把堆栈带出来

5.3 临时文件与 safe-delete 守卫

WorkBuddy 沙箱的 safe-delete 守卫会拦截对”项目目录内文件”的删除(os.unlink 触发批量删除守卫)。最佳实践:上传临时目录固定放 OS 临时目录(如 /tmp/mtd_uploads),守卫对 OS tmp 走原生删除;清理代码包 try/except OSError 兜底,清理失败不应影响已完成的转写结果。

5.4 后台启动服务的正确姿势

  • 必须用 run_in_background: true 启动 uvicorn(或 nohup ... & / disown),不能用 & 在一个会结束的 Bash 调用里——调用结束进程会被杀,服务变”假死”(端口不通、health 无响应)。
  • exec 是 shell 内建命令,不能env ... exec python ...(会报 exec: No such file or directory)。直接 python -m uvicorn ... 即可。
  • zsh 下 env -u CODEBUDDY_** 会被当文件名通配 → no matches found 导致启动立即失败。需要”去掉某个环境变量”时改用具体变量名,或干脆省掉该参数(见 5.3,tmp 目录本就无需 unset)。

6. 测试与验证方法论

6.1 用合成音频做冒烟 + 压测

无需真实录音即可复现长音频问题:

# 用系统 say 合成两位说话人(验证 diarization)
say -v Alex    -o a.aiff "Hello, this is Alex ..."
say -v Victoria -o b.aiff "Hi, I am Victoria ..."
# ffmpeg 转 16k 单声道 wav
ffmpeg -y -i a.aiff -ar 16000 -ac 1 a.wav
ffmpeg -y -i b.aiff -ar 16000 -ac 1 b.wav

拼长音频做压测(无需等真实 1 小时录音):

import wave, numpy as np
src = np.concatenate([read_wav("a.wav"), read_wav("b.wav")])
long = np.tile(src, 150)          # 重复 150 次 ≈ 47 分钟
write_wav("long47min.wav", long)  # 比 30 分钟更严苛,可代表 1 小时级

6.2 时间戳单调性检查

prev_end = -1.0
bad = 0
for s in segments:
    if s["start"] < prev_end - 0.05:
        bad += 1
    prev_end = s["end"]
assert bad == 0   # 非单调即分块拼接/去重有 bug

7. 上线前检查清单

  • 依赖版本已固定(torch / transformers / 项目本身)
  • 权重来自镜像、CLI 用 hf download
  • 启动用的是装了依赖的那个 venv
  • CHUNK_SECONDS ≤ 300(MPS 安全),不稳定下调到 120–180
  • 分块后调用 torch.mps.empty_cache()
  • 传音频走文件路径,不传 numpy 数组
  • 长任务用 job + 轮询,错误信息带 traceback
  • 上传临时目录在 /tmp,清理包 OSError 兜底
  • 服务用 run_in_background 启动,health 探活通过
  • 冒烟测试:短音频双说话人分离正确、时间戳准确
  • 压测:≥30 分钟长音频完整跑完、无 OOM / 无静默卡死、时间戳单调

8. 关键环境变量速查

变量默认说明
MTD_CHUNK_SECONDS300每块时长(秒),MPS 上 ≤300 安全,600 会卡死
MTD_OVERLAP_SECONDS15块间重叠(秒),避免句首截断
MTD_DEVICEautoauto/mps/cpu/cuda(无 CUDA 用 mps)
MTD_MODEL_PATHmodels/OpenMOSS-Team/MOSS-Transcribe-Diarize模型权重目录
MTD_UPLOAD_DIR/tmp/mtd_uploads上传临时目录(OS tmp,避开门禁守卫)
MTD_HOST / MTD_PORT0.0.0.0 / 8000服务监听地址与端口
HF_ENDPOINThttps://hf-mirror.comHF 镜像(国内拉权重)
PYTORCH_MPS_HIGH_WATERMARK_RATIO不设置0.0 解除 MPS 上限,不推荐(会拖垮系统)

9. 最小可运行启动

cd webapp
MTD_CHUNK_SECONDS=300 MTD_OVERLAP_SECONDS=15 MTD_PORT=8000 MTD_DEVICE=auto \
  ../MOSS-Transcribe-Diarize/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8000
# 打开 http://localhost:8000

服务起来后先 GET /api/health 探活(模型懒加载,首次转写时才载入),再上传音频验证。