17 KiB
口语训练视频服务部署指南
本文部署以下两个进程:
oral-trainer-api:视频上传、管理后台、边界查询、Range 视频播放和朗读评分。- GPU 转写服务(二选一,只在服务器内网监听):
whisper/:faster-whisper(Speaches),推荐,部署最简单,见第 2A 节。MOSS-Transcribe-Diarize:带说话人分离,见第 2 节。
推荐 Ubuntu 22.04/24.04、Python 3.12、FFmpeg、NVIDIA GPU 和已安装的 NVIDIA 驱动。MOSS 0.9B 的实际显存占用受推理框架、并发和音频长度影响,生产环境建议从 16 GB 以上显存开始验证。
1. 目录与端口
本文假设代码位于 /opt/oral-trainer:
/opt/oral-trainer/
sentence_api/
data/
v/ 上传的视频
work/ 临时转码文件
attempts/ 可选的学生录音
oral_trainer.sqlite3
端口规划:
| 服务 | 监听地址 | 用途 |
|---|---|---|
| MOSS | 127.0.0.1:8001 |
内部语音转写 |
| Whisper | 127.0.0.1:9000 |
内部语音转写(Whisper 方案,见第 2A 节) |
| FastAPI | 127.0.0.1:8000 |
内部应用服务 |
| Nginx | 0.0.0.0:80 |
对内 HTTP 回源(HTTPS 由总出口终结) |
不要把 MOSS/Whisper 的转写端口直接暴露到公网。
2A. 部署 Whisper(推荐)
基于 faster-whisper(Speaches)的 OpenAI 兼容转写服务,接口与 MOSS 相同
(POST /v1/audio/transcriptions,支持 verbose_json),因此接入 sentence_api
时应用代码无需改动。部署文件在仓库的 whisper/ 目录。
前置条件:Docker、NVIDIA 驱动(≥ 535,支持 CUDA 12.x)和 NVIDIA Container Toolkit。 镜像自带 CUDA 12.6 运行时,宿主机不需要再装 CUDA。
sudo mkdir -p /opt/whisper
sudo chown "$USER":"$USER" /opt/whisper
cp -a whisper/. /opt/whisper/ # 用 whisper/. 而不是 whisper/*,否则不会复制 .env.example 等隐藏文件
cd /opt/whisper
cp .env.example .env # 按需修改 WHISPER_MODEL
docker compose up -d
docker compose logs -f whisper # 首次启动下载模型(约 3 GB)
验证:
/opt/whisper/verify.sh
注册为 systemd 服务(可选,模板位于 whisper/systemd/):
sudo cp whisper/systemd/whisper-transcribe.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now whisper-transcribe
sudo journalctl -u whisper-transcribe -f
接入 API 时,把 .env 改为:
MOSS_TRANSCRIBE_URL=http://127.0.0.1:9000/v1/audio/transcriptions
MOSS_MODEL=Systran/faster-whisper-large-v3
变量名沿用 MOSS_* 前缀,实际指向 Whisper 即可。模型选型与常见问题见
whisper/README.md。
2. 部署 MOSS
官方模型为 OpenMOSS-Team/MOSS-Transcribe-Diarize。CUDA 12 环境可使用官方当前说明中的 vLLM 构建:
sudo apt update
sudo apt install -y ffmpeg git curl
curl -LsSf https://astral.sh/uv/install.sh | sh
sudo mkdir -p /opt/moss-transcribe
sudo chown "$USER":"$USER" /opt/moss-transcribe
cd /opt/moss-transcribe
uv venv --python 3.12 .venv
. .venv/bin/activate
uv pip install -U vllm \
--torch-backend=auto \
--extra-index-url https://wheels.vllm.ai/68b4a1d582818e67adc903bf1b8fc5a5447da2fa/cu129
启动模型:
. /opt/moss-transcribe/.venv/bin/activate
vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize \
--host 127.0.0.1 \
--port 8001 \
--trust-remote-code
生产环境可把它注册为 systemd 服务:
# /etc/systemd/system/moss-transcribe.service
[Unit]
Description=MOSS Transcribe Service
After=network-online.target
[Service]
Type=simple
User=oraltrainer
WorkingDirectory=/opt/moss-transcribe
ExecStart=/opt/moss-transcribe/.venv/bin/vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize --host 127.0.0.1 --port 8001 --trust-remote-code
Restart=on-failure
RestartSec=10
TimeoutStartSec=1800
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now moss-transcribe
sudo journalctl -u moss-transcribe -f
模型第一次启动会下载权重。CUDA 13 服务器也可以按照 MOSS 官方仓库说明改用 SGLang Omni。推理框架的安装地址可能随上游版本更新,正式部署前应对照 MOSS 官方仓库 的 Quickstart。
验证:
curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
-F model=OpenMOSS-Team/MOSS-Transcribe-Diarize \
-F file=@/path/to/test.wav \
-F response_format=verbose_json \
-F temperature=0
响应应包含 text;开启 verbose_json 后应包含带 start、end、text 的 segments。
断句规则
sentence_api 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积,
只有碰到句号(.)或问号(?)才算一句结束;Whisper 在没有标点处断开的
segment 会自动合并成同一句。若转写服务支持词级时间戳
(timestamp_granularities[]=word,Whisper/Speaches 支持),句子的结束时间用
标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。
每句结束时间默认再向后顺延 300ms(MOSS_END_PADDING_MS,可在 200–500 之间调整),
避免句子末尾发音被切掉;顺延不会越过下一句的开头。
3. 部署 API
Docker Compose 方式
cd /opt/oral-trainer/sentence_api
cp .env.example .env
openssl rand -hex 32
分别生成两个随机值,填入 .env 的 ADMIN_API_KEY 和 CLIENT_API_KEY,并确认:
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
若使用 Whisper 方案(第 2A 节),把 MOSS_TRANSCRIBE_URL 改为
http://127.0.0.1:9000/v1/audio/transcriptions,并把 MOSS_MODEL 设为
Systran/faster-whisper-large-v3。
启动:
docker compose up -d --build
docker compose logs -f oral-trainer-api
Compose 配置使用 Linux 的 host network,使容器可以访问只监听本机的
转写服务(MOSS 127.0.0.1:8001 或 Whisper 127.0.0.1:9000),同时 API 也只监听 127.0.0.1:8000。如果在
Docker Desktop 上做本地测试,可移除 network_mode: host,恢复端口映射,并把
转写服务地址改为 host.docker.internal。
检查:
curl http://127.0.0.1:8000/healthz
moss_configured 应为 true。这里只表示地址已配置,实际连通性会在上传视频或评分时验证。
不使用 Docker
cd /opt/oral-trainer
python3.12 -m venv .venv-api
. .venv-api/bin/activate
python -m pip install -r sentence_api/requirements.txt
export ORAL_TRAINER_DATA_DIR=/opt/oral-trainer-data
export PUBLIC_BASE_URL=https://videoservice.d1kt.cn
export ADMIN_API_KEY='替换为随机密钥'
export MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
python -m uvicorn sentence_api.main:app \
--host 127.0.0.1 --port 8000 --workers 1 \
--proxy-headers --forwarded-allow-ips='*'
不使用 Docker 时可创建 API 的 systemd 服务:
# /etc/systemd/system/oral-trainer-api.service
[Unit]
Description=Oral Trainer Video API
After=network-online.target moss-transcribe.service
[Service]
Type=simple
User=oraltrainer
WorkingDirectory=/opt/oral-trainer
EnvironmentFile=/opt/oral-trainer/sentence_api/.env
ExecStart=/opt/oral-trainer/.venv-api/bin/python -m uvicorn sentence_api.main:app --host 127.0.0.1 --port 8000 --workers 1 --proxy-headers --forwarded-allow-ips=*
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now oral-trainer-api
sudo journalctl -u oral-trainer-api -f
当前上传后的处理任务运行在 API 进程中,因此只能使用一个 Uvicorn worker。需要多机或多 worker 时,应把 VideoProcessor.process 迁移到 Celery、RQ 或其他持久化任务队列。
3A. 内网直连(可选)
默认 FastAPI 只监听 127.0.0.1:8000,由 nginx 对外提供访问。如果只想在可信内网
(例如 192.168.0.124 这台虚拟机)直接上传,有两种方式:
方式一(推荐,不改后端):nginx 已监听 0.0.0.0:80,直接访问
http://192.168.0.124/admin 或在浏览器上传即可,上传地址为
http://192.168.0.124/api/v1/admin/videos/raw。
方式二(把 API 绑定到所有网卡):在 sentence_api/.env 增加一行:
ORAL_TRAINER_BIND_HOST=0.0.0.0
然后重建容器:
cd /opt/oral-trainer/sentence_api
docker compose up -d
ss -tlnp | grep 8000 # 应显示 0.0.0.0:8000
之后即可访问 http://192.168.0.124:8000/healthz 和内网上传接口。
安全提醒:0.0.0.0 会把 API 暴露给整个内网(管理接口受 X-Admin-Key 保护,但公开的
视频列表和 /admin 页面没有密钥)。仅建议在可信内网使用;公网部署务必保持
127.0.0.1 + nginx,或用防火墙限制来源 IP。
4. 配置 Nginx(HTTP 回源)
本机 Nginx 只监听 80 端口提供 HTTP,不在本机终结 HTTPS。总出口(线上边缘网关)
在 https://videoservice.d1kt.cn 终结 TLS 证书后,把请求以 HTTP 转发到本机
内网 IP 的 80 端口。因此本机不需要证书,也不需要安装 certbot。
sudo cp sentence_api/nginx.conf.example /etc/nginx/sites-available/oral-trainer
sudo ln -s /etc/nginx/sites-available/oral-trainer /etc/nginx/sites-enabled/oral-trainer
sudo nginx -t
sudo systemctl reload nginx
配置文件中的关键项:
client_max_body_size 12g:允许超过 1 GB 的视频。proxy_request_buffering off:上传时直接流向 FastAPI,避免 Nginx 再完整缓存一份。proxy_force_ranges on:保留 Android 随机拖动播放所需的 HTTP Range。proxy_read_timeout 7200s:允许长视频处理和慢速上传。X-Forwarded-Proto:透传边缘网关标记的原始协议(https),让 API 在需要时能识别 用户实际走的 HTTPS;若网关不设置该头,可把配置改回$scheme。
本地验证(走本机 Nginx):
curl -H 'Host: videoservice.d1kt.cn' http://127.0.0.1/healthz
公网验证(经总出口映射,需先在网关配置好 https://videoservice.d1kt.cn
到本机内网 IP:80 的映射):
curl https://videoservice.d1kt.cn/healthz
证书的申请与续期都在总出口完成,本机无需任何证书配置。
管理后台地址:
https://videoservice.d1kt.cn/admin
后台的管理密钥保存在当前浏览器的 localStorage,接口请求通过 X-Admin-Key 发送。
CLIENT_API_KEY 应通过 Android 构建配置注入 OralTrainerSdkConfig.assessmentApiKey,
不要硬编码进公开代码仓库。SDK 会在评分请求中发送 X-Client-Key。公开视频列表和播放
接口仍可交给 CDN 缓存,GPU 评分接口则受到密钥保护。
4A. 如果要在服务器本机终结 HTTPS(可选)
若总出口不做 TLS 终结,而是由服务器本机 nginx 提供 HTTPS,443 的 server 块必须
包含与 80 块相同的上传相关指令,否则大文件上传会被掐断:
server {
listen 443 ssl;
server_name videoservice.d1kt.cn;
ssl_certificate /etc/letsencrypt/live/videoservice.d1kt.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/videoservice.d1kt.cn/privkey.pem;
client_max_body_size 12g;
client_body_timeout 7200s;
send_timeout 7200s;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_read_timeout 7200s;
proxy_send_timeout 7200s;
}
location ~ "^/api/v1/videos/[0-9a-fA-F]{64}/content$" {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_force_ranges on;
proxy_buffering off;
}
}
签发证书并应用:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d videoservice.d1kt.cn
4B. 上传中断(ClientDisconnect)排查
后台日志出现 starlette.requests.ClientDisconnect 表示浏览器到后端之间某处的连接在
请求体传完之前被断开。先确认实际生效的 nginx 配置:
sudo nginx -T | grep -E "client_max_body_size|client_body_timeout|proxy_request_buffering"
最常见的原因是新加的 HTTPS server 块漏掉了上传指令:nginx 默认
client_max_body_size 只有 1 MB,超过会直接返回 413 并断开连接,后端表现为
ClientDisconnect。其他常见原因:
- 总出口(网关/CDN)有更小的请求体大小或更短的上传超时,需在网关侧放行。
client_body_timeout(默认 60s)过短:网络慢时上传暂停过久会被掐断,按上文设 为 7200s。- 浏览器/客户端主动取消或网络中断。
绕过网关在本机回源验证(能通则问题在网关):
curl -X PUT -H 'Host: videoservice.d1kt.cn' -H "X-Admin-Key: $ADMIN_KEY" \
--data-binary @large.mp4 \
'http://127.0.0.1/api/v1/admin/videos/raw?filename=large.mp4' \
-o /dev/null -w '%{http_code}\n'
API 已对 ClientDisconnect 做优雅处理:连接中断时返回 400 并记录一条 WARNING,
不再产生 500 堆栈日志。
5. 上传与处理流程
后台上传后,服务会:
- 管理后台通过 raw body 流式写入临时文件并同步计算 SHA-256,不会先在系统临时目录保留完整副本。兼容的 multipart 接口仍以 4 MB 分块复制。
- 保存到
data/v/{sha256}.{extension}。 - 使用 FFmpeg 提取 16 kHz 单声道 WAV。
- 请求转写服务(MOSS 或 Whisper)的
verbose_json接口。 - 以转写时间戳生成句子边界,并计算每句原音有效语音时长。
- 把视频和句子写入 SQLite,状态变为
ready。
查询处理状态:
curl https://videoservice.d1kt.cn/api/v1/videos
curl https://videoservice.d1kt.cn/api/v1/videos/{sha256}
如果转写服务未配置,上传仍会使用原来的静音检测生成边界,但句子文本为空,无法开始测验。配置好转写服务后可在后台点击“重新处理”。
6. 视频随机播放
移动端播放地址为:
GET /api/v1/videos/{sha256}/content
服务支持 HTTP Range,Media3/ExoPlayer 可以随机 seek,并继续使用 SDK 现有的本地缓存。第一阶段不必强制改成 HLS。
上传前建议把 MP4 处理成 H.264/AAC 并把 moov 元数据移动到文件头:
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4
如果原始编码不被 Android 广泛支持,再转码:
ffmpeg -i input.mkv \
-c:v libx264 -preset medium -crf 22 \
-c:a aac -b:a 128k -movflags +faststart output.mp4
当并发量明显增长或网络波动较大时,再增加 HLS/DASH 转码和对象存储/CDN。
7. 朗读评分接口
curl -X POST \
https://videoservice.d1kt.cn/api/v1/videos/{sha256}/sentences/0/assessments \
-H 'X-Client-Key: 替换为客户端密钥' \
-F audio=@student.wav \
-F language=en
当前 asr-fluency-v1 评分为:
总分 = 内容分 * 80% + 流畅度 * 20%
流畅度 = 有效语音时长分 * 35% + 停顿分 * 40% + 语速分 * 25%
总分不低于 ASSESSMENT_PASS_SCORE(默认 70)即通过。pronunciation_score 和 prosody_score 目前返回 null,避免把 ASR 内容匹配误报为音素发音质量。
服务只向转写服务发送语言提示,不发送标准句子作为 prompt 或热词,避免标准答案诱导识别结果。
8. 运维与备份
需要持久化备份:
sentence_api/data/v/
sentence_api/data/oral_trainer.sqlite3
work/ 可以清理。KEEP_ATTEMPT_AUDIO=false 时学生录音在评分完成后自动删除;设为 true 时还需备份和定期清理 attempts/,并在产品隐私政策中说明保存期限。
SQLite 在线备份示例:
sqlite3 sentence_api/data/oral_trainer.sqlite3 \
".backup '/backup/oral_trainer-$(date +%F).sqlite3'"
升级前先备份数据库和 data/v/。API 重启期间被中断的视频会保留 processing 状态,可从后台执行“重新处理”。
9. 更新与回滚
日常代码更新、镜像升级与回滚流程见 UPDATE.md。