feat(asr): 降级兜底——分离路由全挂时退到无分离路由,只交付逐字稿

回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由
试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 2 步
产物(capability_degraded / capability_zh),第 3 步与第 5/6 步据此判为不可用。

- transcribe.go:Result 加 CapabilityDegraded,判据 chain[0] 能分离而实际这条
  不能;与 HasSpeakers 分开记(后者可能是「配了却没输出」那种异常)
- audio_handlers.go:闸门从「读路由声明的能力」改成「读稿子里实际有没有标签」
  (audioTranscriptSpeakerKeys),与第 3 步共用同一句 SpeakerKeysOf;本次没分离
  → 哨兵错误 errAudioSpeakersUnavailableThisRun,不再放行去写一份看不出残缺的纪要
- 闸门**不看** capability_degraded:改动前落库的老产物没有这个字段,看它就 fail-open
- 第 1 步「转写要求」提前把降级的后果说清;第 2 步产物带完整措辞与「⚠」日志
- 前端两处(SpecialistPanel.vue / audioSkill.js)判断顺序改为先读实际结果
  has_speakers、再退回声明 speakers —— 顺序反了会在降级那一次照旧显示第 3 步
- ai_config.json:补 3 条云端无分离路由与各自的回退链

验证:三处变异(产物 key 拼错、标记写死 true、闸门 fail-closed)都验过会红;
新增两个用例文件走真实 gin 路由 + 真实鉴权中间件,断言拦下来的**理由**而不只是
「拦下来了」;go test ./... 全绿、gofmt 干净、前端构建通过。

同期把本地 ASR 装成 systemd 常驻服务(deploy/install_asr_local.sh 七步全过,
开机自启,实测 26.7 分钟录音 → 3.1 分钟)。装的过程挖出两个只在服务化时才暴露的坑:

- E12 转写堵住事件循环 → 探活超时 → 本地被判不健康 → auto 静默退云端、音频出网,
  全程没有任何报错。修法 run_in_threadpool(deploy/asr/serve.py)
- E13 服务账号的 ~ 不可写,pyannote 写不了 ~/.pyannote/database.yml,每次转写 500。
  修法 asr.env 加 HOME=<cache 目录>(该目录在 unit 的 ReadWritePaths 里)

顺带收口一处交付缺口:服务源码原先只有 ~/asr-poc 一份,而 DELIVERY.md 的清理计划
要 rm -rf 它 —— 那会让唯一副本变成 /opt 下 root 所有、不在任何版本库里的文件。
现在 deploy/asr/ 是唯一事实源,装机脚本与文档同步改。

已知偏离 / 未做(记在案):
- 界面那句「本次没有说话人分离,后续步骤不可用」只验到后端接口层,没有造出真实
  降级场景渲染出来看过
- deploy/asr/ 的引入改变了装机来源:原型目录 $SRC_DIR 从此只提供 venv 与模型,
  服务代码一律从仓库取

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 23:36:00 +08:00
co-authored by Claude Code
parent bc30bfce28
commit b4c8ea38d1
19 changed files with 2002 additions and 151 deletions
@@ -64,6 +64,9 @@
/opt/eai_agentplatform-asr/ # 本地语音转写(独立进程,独立单元)
├── serve.py # OpenAI 兼容 HTTP 端点(/v1/audio/transcriptions、/v1/models)
├── asr_core.py # 转写 + 说话人分离
│ # ↑ 这两个的**事实源在仓库** deploy/asr/,装机时由
│ # install_asr_local.sh 装过来;直接改 /opt 下那份
│ # 会在下次装机时被覆盖,且没人 review 得到
├── asr.env # 环境变量(精度档位、离线开关)
├── venv/ # Python 环境(约 9.4 GB)
├── models/ # 模型权重(约 4.1 GB,实体目录)
@@ -105,10 +108,14 @@ sudo systemctl enable --now eai_agentplatform
curl http://127.0.0.1:8080/api/health # {"status":"ok",...}
# 2.5 本地语音转写服务(ASR)
# 整个 2.5 已经写成脚本,它会先打印计划再执行(--dry-run 只看计划):
sudo bash deploy/install_asr_local.sh
#
# 它做的事(等价的手工命令如下,仅供理解,**别照着敲**):
# 环境本身随原型机整盘克隆过来,这一步只是把它装到交付位置 + 起服务。
# venv 与模型都很大(9.4G + 4.1G),用 mv 而不是 cp —— 两台机器上各留一份没意义。
sudo mkdir -p /opt/eai_agentplatform-asr
sudo cp -a ~/asr-poc/{serve.py,asr_core.py} /opt/eai_agentplatform-asr/
sudo install -m 0644 deploy/asr/{serve.py,asr_core.py} /opt/eai_agentplatform-asr/
sudo mv ~/asr-poc/venv /opt/eai_agentplatform-asr/venv
sudo cp -aL ~/asr-poc/models /opt/eai_agentplatform-asr/models # -L 解开软链,客户机上没有仓库可指
sudo install -m 0644 deploy/asr.env /opt/eai_agentplatform-asr/asr.env
@@ -0,0 +1,80 @@
# 本地语音转写服务(eai_agentplatform-asr)
> 这份文件是给**运维/交付工程师**看的,装完机器后遇到「转写失败」时先读它。
> 服务的 systemd 单元里 `Documentation=` 指向本文件。
---
## 它是什么
一个只监听回环地址的 Python 服务,提供 OpenAI 兼容的转写端点:
| 端点 | 用途 |
|---|---|
| `POST /v1/audio/transcriptions` | 转写(含说话人分离) |
| `GET /v1/models` | 健康探测用的探针端点 |
- **监听**:`127.0.0.1:8090` —— 只回环,不在局域网上暴露
- **模型**:faster-whisper large-v3 + pyannote 3.1
- **为什么要它**:把客户会议录音的转写留在本机。平台默认语音路由指向它,
音频不出本机;它不可用时平台才回退云端,并在界面上标注「音频已离开本机」。
---
## 怎么确认它是好的
```bash
systemctl status eai_agentplatform-asr # active (running)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/v1/models # 期望 200
journalctl -u eai_agentplatform-asr -n 50 --no-pager
```
平台侧从**后台 → AI 路由 → 语音**看 `本地` 那条的 `healthy` 是否为真;
或直接打后端接口:
```bash
curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:8080/api/ai/routes/audio
```
---
## 它挂了会怎样(重要)
**不会报错给用户,而是静默回退云端。** 这正是需要有人盯着它的原因:
1. `audio_route_auto` 探测到本地不健康 → 改选 `fallback_routes` 里的云端路由;
2. 转写照常成功,但**录音已经出了本机**;
3. 界面在转写结果上标注「音频已离开本机」——这是唯一的用户可见信号。
所以:**如果这台机器的定位是「音频不许出本机」,本服务必须一直 active。**
单元里 `Restart=always` 就是这个意思;不要改成 `on-failure`。
---
## 排障顺序
| 症状 | 先看这里 |
|---|---|
| 服务起不来 | `journalctl -u eai_agentplatform-asr -n 100` |
| CUDA OOM | 显存被别的进程占了(本机 llama-server 常态占约 5G,卡总共 8G)。见 `bugs_and_errors.md` E06 |
| 加载模型挂住不报错 | 是否有分支想回 `huggingface.co`。本机到那边不通,`HF_HUB_OFFLINE=1` / `TRANSFORMERS_OFFLINE=1` 会让它立刻失败而不是挂起 |
| 平台显示本地不健康但服务是好的 | 端口对不上。端口写在 unit 的 `ExecStart --port` 与后端 `config/ai_config.json` 的 `audio_route_local_whisper.base_url` 两处,改一处没用 |
| 平台显示本地不健康,且刚重启过服务 | 平台每 30 分钟探一次。后台点一次保存/重载会立刻重探(`POST /api/ai/reload`) |
---
## 装 / 卸
装:`sudo bash deploy/install_asr_local.sh`(见 `DELIVERY.md` 第 2.5 节)
卸(会删掉 13.5 GB 的 venv 与模型,先确认目标机不再需要本地转写):
```bash
sudo systemctl disable --now eai_agentplatform-asr
sudo rm -rf /opt/eai_agentplatform-asr
sudo rm -f /etc/systemd/system/eai_agentplatform-asr.service
sudo systemctl daemon-reload
```
卸完记得把后台的**默认语音路由**切到云端,否则 `audio_route_auto` 会一直
在一条永远不健康的本地路由上打转,每次都白等一轮探测超时。
@@ -16,6 +16,19 @@ ASR_COMPUTE_TYPE=int8_float16
# /opt/eai_agentplatform-asr/models(软链或实体目录都行,见 DELIVERY.md 第 1 节)。
# 把模型放到别处就得改 asr_core.py,不如把目录放对。
# 家目录指到 cache/,**必须有这一行**。
#
# 原型机上服务以 eaiadmin 跑,~ 可写,所以从没暴露过;装成服务后两件事同时变:
# unit 里 ProtectHome=true 挡住 /home,而系统账号 eai_agentplatform 的家目录
# (/home/eai_agentplatform)压根不存在。pyannote 起步时要写
# ~/.pyannote/database.yml,于是每次转写都以 PermissionError 收场 ——
# 而报错发生在说话人分离那一段,看起来像模型坏了,其实是没地方写配置。
#
# 指到 cache/ 是因为 unit 的 ReadWritePaths 只放开了这一个目录;
# 顺带让 torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
# 换目录要连 unit 的 ReadWritePaths 一起改。
HOME=/opt/eai_agentplatform-asr/cache
# 离线开关。权重全部是本地文件(faster-whisper 直接读目录、pyannote 的
# config.yaml 已改写成本地文件路径),正常情况下一个网络请求都不发。
# 这两个开关是安全网:万一哪条分支想回 huggingface.co,本机到那边是**不通**的,
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""本地 ASR 核心:faster-whisper large-v3 + pyannote 3.1 说话人分离。
产物刻意做成**后端那个形状**(internal/skills/packages/audio_transcribe/transcribe.go:164-173):
{"duration": 秒, "text": "全文", "segments": [{"speaker","start","end","text"}], "usage": {...}}
所以只要外面套一个 OpenAI 兼容的 HTTP 端点(serve.py),Go 侧一个字都不用改。
三个阶段的代价差很多(wav 几秒 / 转写几分钟 / 分离一两分钟),所以每阶段都往
out_dir 落缓存,重跑读缓存。调分离参数时不该让 large-v3 再跑一遍。
"""
from __future__ import annotations
import gc
import hashlib
import json
import os
import subprocess
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
MODELS = ROOT / "models"
SEG_DIR = MODELS / "pyannote/segmentation-3.0"
EMB_DIR = MODELS / "pyannote/wespeaker-voxceleb-resnet34-LM"
DIA_DIR = MODELS / "pyannote/speaker-diarization-3.1"
FW_DIR = MODELS / "Systran/faster-whisper-large-v3"
# faster-whisper / pyannote 都不是线程安全的,而且显存只有 8G,
# 并发跑两个 large-v3 必爆。整个进程串行化 —— 转写本来就是分钟级的长活,
# 并发几个请求也不会更快。
_LOCK = threading.Lock()
_FW_CACHE = {}
_DIA_CACHE = {}
def log(msg: str):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# ── 阶段 1:转 wav ────────────────────────────────────────────────
def ensure_wav(src: Path, out_dir: Path) -> Path:
dst = out_dir / "audio16k.wav"
if dst.exists() and dst.stat().st_size > 0:
return dst
out_dir.mkdir(parents=True, exist_ok=True)
log(f"ffmpeg 转 16k 单声道:{src.name}")
subprocess.run(
# -vn 丢掉 mp3 里那个 mjpeg 封面流。不丢的话 pyannote 读音频时会被它绊到,
# 报出来的错跟「音频格式不对」一模一样,很难看出是封面。
["ffmpeg", "-y", "-v", "error", "-i", str(src), "-vn",
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(dst)],
check=True,
)
log(f"wav 就绪:{dst.stat().st_size/1048576:.1f} MB")
return dst
# ── 阶段 2:转写 ──────────────────────────────────────────────────
# 本机显存只有 8 GiB,而 llama-server(仓库 README 里 8080/8081 那两个常驻服务)
# 常年占着约 5 GiB,留给我们的实际不到 2.6 GiB —— large-v3 的 float16 权重
# 本身就要 3 GB,一加载就 `CUDA failed with error out of memory`。
# 所以默认用 int8_float16(权重压到一半左右,实测 2.5 GiB 空闲下装得下),
# 并用 ASR_COMPUTE_TYPE 留一个旋钮:显存宽裕时可以调回 float16 换精度。
_COMPUTE_LADDER = [
os.environ.get("ASR_COMPUTE_TYPE", "int8_float16"),
"int8", # 再小一档
]
def _fw_model():
if "m" not in _FW_CACHE:
import torch
from faster_whisper import WhisperModel
# 先请走 pyannote:它上一次跑完还占着约 2.6G,不清掉这里必 OOM。
# 放在阶梯循环**之前**,否则 int8_float16 / int8 两档会白撞两次。
_free_pipe()
last = None
for ct in dict.fromkeys(_COMPUTE_LADDER): # 去重且保序
free = torch.cuda.mem_get_info()[0] / 2**30
log(f"加载 faster-whisper large-v3(本地目录,不走网络,"
f"compute_type={ct},当前空闲显存 {free:.2f} GiB)")
try:
_FW_CACHE["m"] = WhisperModel(
str(FW_DIR), device="cuda", compute_type=ct, num_workers=1,
)
if ct != _COMPUTE_LADDER[0]:
log(f"注意:{_COMPUTE_LADDER[0]} 装不下,已退到 {ct}(精度略降)")
break
except Exception as e:
last = e
log(f"{ct} 加载失败:{type(e).__name__}: {e}")
else:
# 一档都装不下就别再试了 —— 报出显存实况,比抛一句 CUDA OOM 有用。
holders = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_memory,process_name",
"--format=csv,noheader"],
capture_output=True, text=True).stdout.strip()
raise RuntimeError(
f"faster-whisper large-v3 在本机显存里装不下(试过 "
f"{list(dict.fromkeys(_COMPUTE_LADDER))})。\n"
f"当前占显存的进程:\n{holders}\n"
f"处置:腾出显存,或把 ASR_COMPUTE_TYPE 调得更小。"
) from last
return _FW_CACHE["m"]
def _free_fw():
"""把 whisper 请出显存。
本机只剩两三百 MB 余量,whisper 和 pyannote 同时驻留必炸 ——
转写和分离是先后关系,没有并存的理由,所以加载另一个之前先放掉它。
"""
if _FW_CACHE.pop("m", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 whisper 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def _free_pipe():
"""把 pyannote 管线请出显存。
这是 `_free_fw` 的对称面,缺了它服务只能转写**一次**:
显存只有 8G,llama-server 常态占掉约 5G。第一次请求转写完会加载 pyannote
并把它挪上 CUDA,然后一直缓存在 `_DIA_CACHE` 里 —— 原先没有任何地方释放它。
于是第二次请求去加载 whisper 时,空闲显存只剩零点几 G,int8_float16 和 int8
两档接连 OOM,用户看到的是「第一次能转,之后每次都失败」。
原型机是跑一次看一次结果,暴露不出来;接成常驻服务后这是必现的。
"""
if _DIA_CACHE.pop("p", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 pyannote 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def transcribe(wav: Path, out_dir: Path, language: str = "zh") -> dict:
cache = out_dir / "whisper_raw.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
model = _fw_model()
t0 = time.time()
segments, info = model.transcribe(
str(wav),
language=language or None,
beam_size=5,
vad_filter=True, # 长会议里静音很多,先切掉
vad_parameters={"min_silence_duration_ms": 500},
word_timestamps=True, # 按时间把说话人贴到词上要用
condition_on_previous_text=False, # 长音频上它会放大幻觉,必须关
)
log(f"音频 {info.duration/60:.1f} 分钟,语言 {info.language}"
f"(置信度 {info.language_probability:.2f})")
out = []
for i, seg in enumerate(segments):
out.append({
"start": seg.start, "end": seg.end, "text": seg.text.strip(),
"words": [{"word": w.word, "start": w.start, "end": w.end}
for w in (seg.words or [])
if w.start is not None and w.end is not None],
})
if (i + 1) % 100 == 0:
log(f" 已转写 {i+1} 段,进度 {seg.end/60:.1f}/{info.duration/60:.1f} 分钟")
took = time.time() - t0
log(f"转写完成:{len(out)} 段,{took/60:.1f} 分钟({info.duration/took:.1f}x 实时)")
result = {"duration": info.duration, "language": info.language, "segments": out}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# ── 阶段 3:说话人分离 ────────────────────────────────────────────
def _ckpt(repo_dir: Path) -> dict:
"""把本地模型目录说成 pyannote 认得的形式。
pyannote 的 `Model.from_pretrained(x)` 只认**文件**:`os.path.isfile(x)` 不成立
就当成 HF repo id 去联网(core/model.py:588)。传目录会在 huggingface_hub 的
`validate_repo_id` 上炸成 HFValidationError,看着像「路径写错了」,其实是
「它压根没打算读目录」。传 dict 走的是 `Model.from_pretrained(**dict)` 那一支
(pipelines/utils/getter.py:81),checkpoint 指到 .bin 就通了。
不给 hparams_file:权重里自带 PL 的 hparams,而 repo 里那份 config.yaml 是
模型结构配置、没有 `task:` 段,塞进去只会换来 `Missing key setup`。
"""
return {"checkpoint": str(repo_dir / "pytorch_model.bin")}
def _allow_torch_load():
"""放行 pyannote 权重里那几个类,否则 torch>=2.6 一律拒绝加载。
torch 2.6 起 `torch.load` 的 `weights_only` 默认从 False 翻成 True,
pickle 里没在白名单上的全局符号直接拒载。pyannote 3.x 的 .bin 里存着 4 个
数据类(TorchVersion / Specifications / Problem / Resolution),于是
`Pipeline.from_pretrained` 会抛一大段「Weights only load failed」。
这里只放行这 4 个**数据类**,不碰 `weights_only=False` —— 后者等于把
反序列化变成任意代码执行。权重是我们自己下的、有 SHA256SUMS 对过,
但没必要为此把整扇门打开。
"""
import torch
from pyannote.audio.core.task import Problem, Resolution, Specifications
torch.serialization.add_safe_globals(
[torch.torch_version.TorchVersion, Specifications, Problem, Resolution])
def _pipeline():
"""加载 pyannote 管线(带缓存)。
读的是**加工件**里的 config.yaml,它由 `build_ready.sh` 从 raw 生成,
已把两个 HF repo id 改写成 raw 里的本地路径 —— 照原样读会去连
huggingface.co,而本机不通。除那两行外其余参数(含那两个阈值)逐字未改。
路线 B 是兜底:万一 pyannote 升级后不认这份 3.1 配置,就手工组装,阈值照抄
config.yaml。两条路都失败就原样抛出 —— 绝不吞掉错误退化成「没有说话人」,
那种失败从结果上跟「音频里真的只有一个人」长得一模一样,最难查。
"""
if "p" in _DIA_CACHE:
return _DIA_CACHE["p"]
# 与 _fw_model 里的 _free_pipe 对称:两个模型在 8G 卡上不能并存。
# 有了这一步,`run()` 里那句「转写完先放掉 whisper」就多余了 ——
# 谁都不用记住「先放谁」,那是加载器自己的事。
_free_fw()
_allow_torch_load()
import pyannote.audio as pa
from pyannote.audio import Model, Pipeline
log(f"pyannote {pa.__version__}")
try:
log("路线 A:读本地 config.yaml 加载管线")
# 必须给到 config.yaml 这个**文件**:给的目录会被当成 repo id(见 _ckpt)。
pipe = Pipeline.from_pretrained(str(DIA_DIR / "config.yaml"))
log("路线 A 成功")
except Exception as e:
log(f"路线 A 失败:{type(e).__name__}: {e}")
log("路线 B:手工组装(两个子模型直接吃本地 .bin)")
from pyannote.audio.pipelines import SpeakerDiarization
pipe = SpeakerDiarization(
segmentation=Model.from_pretrained(**_ckpt(SEG_DIR)),
embedding=Model.from_pretrained(**_ckpt(EMB_DIR)),
clustering="AgglomerativeClustering",
)
# 两个阈值照抄 speaker-diarization-3.1/config.yaml,不自己调。
pipe.instantiate({
"clustering": {"method": "centroid", "min_cluster_size": 12,
"threshold": 0.7045654963945799},
"segmentation": {"min_duration_off": 0.0},
})
log("路线 B 成功")
try:
import torch
pipe.to(torch.device("cuda"))
log("管线已挪到 GPU")
except Exception as e:
log(f"挪 GPU 失败,用 CPU:{e}")
_DIA_CACHE["p"] = pipe
return pipe
def diarize(wav: Path, out_dir: Path, num_speakers: int | None = None) -> dict:
salt = f".n{num_speakers}" if num_speakers else ""
cache = out_dir / f"diarization{salt}.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
pipe = _pipeline()
t0 = time.time()
kw = {"num_speakers": num_speakers} if num_speakers else {}
log("开始说话人分离…")
ann = pipe(str(wav), **kw)
turns = [{"start": float(t.start), "end": float(t.end), "speaker": str(lab)}
for t, _, lab in ann.itertracks(yield_label=True)]
speakers = sorted({t["speaker"] for t in turns})
log(f"分离完成:{len(turns)} 个轮次,{len(speakers)} 个说话人 {speakers},"
f"{(time.time()-t0)/60:.1f} 分钟")
result = {"turns": turns, "speakers": speakers}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# 短于这个字数的碎片不单独成段。理由见 _absorb_stray_groups。
_MIN_GROUP_CHARS = 4
def _absorb_stray_groups(groups: list) -> list:
"""把一闪而过的「说话人碎片」并回邻居。
说话人边界跟词边界对不齐时,会出现「记」/「住这个要」/「求」这种被切碎的段 ——
实测一份 26 分钟录音里 693 段中有一大批是这么来的,读起来像乱码。
这不是分离算错了,是**切点太细**:pyannote 的边界落在词中间,
而按词投票只能整词归属,于是边界两侧各留下半截。
处置:字数不到 _MIN_GROUP_CHARS 的组不单独成段,并进相邻的组
(优先并给前一组;段首没有前一组就并给后一组)。
并进去等于承认「这一两个词归属存疑」,比切出半截字更接近事实,
也比硬判给某一方诚实。
"""
if len(groups) <= 1:
return groups
def chars(g) -> int:
return len("".join(w["word"] for _, w in g).strip())
out: list = []
for g in groups:
if out and chars(g) < _MIN_GROUP_CHARS:
out[-1].extend(g) # 并给前一组
else:
out.append(list(g))
# 首组可能自己就是碎片(上面没有前一组可并),回头并给后来变成首组的那一组。
# 注意要先 pop 再取 out[0]:pop 之后原 out[1] 才在索引 0 上。
if len(out) > 1 and chars(out[0]) < _MIN_GROUP_CHARS:
first = out.pop(0)
out[0][:0] = first
return out
# ── 阶段 4:拼装 ──────────────────────────────────────────────────
def assign_speakers(transcript: dict, diar: dict) -> dict:
"""把说话人贴到 whisper 段上。
按**词**投票而不是整段取最大重叠:中文会议里一个 whisper 段跨两个人很常见,
按整段会把少数那个人的话一起判给多数,云端 diarize 模型不会犯这个错,
我们自己拼装就得自己处理掉。
"""
turns = diar["turns"]
def spk_of(a: float, b: float) -> str:
best, best_ov = None, 0.0
for t in turns:
ov = min(b, t["end"]) - max(a, t["start"])
if ov > best_ov:
best, best_ov = t, ov
if best is not None:
return best["speaker"]
if not turns:
return ""
# 词落在两轮之间的缝里:归给最近的一轮,不留空。
# 留空会让这一段没有 speaker,前端会当成「这次没做分离」。
mid = (a + b) / 2
return min(turns, key=lambda t: min(abs(mid - t["start"]),
abs(mid - t["end"])))["speaker"]
segments = []
for seg in transcript["segments"]:
words = seg.get("words") or []
if not words:
segments.append({"speaker": spk_of(seg["start"], seg["end"]),
"start": seg["start"], "end": seg["end"],
"text": seg["text"]})
continue
labelled = [(spk_of(w["start"], w["end"]), w) for w in words]
groups, cur = [], [labelled[0]]
for spk, w in labelled[1:]:
if spk == cur[0][0]:
cur.append((spk, w))
else:
groups.append(cur)
cur = [(spk, w)]
groups.append(cur)
groups = _absorb_stray_groups(groups)
for grp in groups:
text = "".join(w["word"] for _, w in grp).strip()
if not text:
continue
segments.append({"speaker": grp[0][0],
"start": round(grp[0][1]["start"], 3),
"end": round(grp[-1][1]["end"], 3),
"text": text})
text = "\n".join(f"说话人{s['speaker']}:{s['text']}" for s in segments)
speakers = sorted({s["speaker"] for s in segments if s["speaker"]},
key=lambda x: int(x) if x.isdigit() else 0)
return {
"duration": transcript["duration"],
"language": transcript.get("language", ""),
"text": text,
"segments": segments,
"speakers": speakers,
}
def run(audio: Path, out_dir: Path, language: str = "zh",
num_speakers: int | None = None, stages: str = "wav,transcribe,diarize,merge") -> dict:
"""完整跑一遍,返回后端要的形状。out_dir 里落各阶段缓存。"""
out_dir.mkdir(parents=True, exist_ok=True)
want = {s.strip() for s in stages.split(",") if s.strip()}
t0 = time.time()
with _LOCK:
wav = out_dir / "audio16k.wav"
if "wav" in want:
wav = ensure_wav(audio, out_dir)
transcript = transcribe(wav, out_dir, language) if "transcribe" in want else \
json.loads((out_dir / "whisper_raw.json").read_text())
# 这里曾经有一句 `if "diarize" in want: _free_fw()`。
# 现已移进 `_pipeline()`:腾显存是加载方的事,不是调用方要记住的规矩 ——
# 放在调用方时,只要有一条路径绕开它就会 OOM(`_fw_model` 那侧原来就是这样)。
diar = diarize(wav, out_dir, num_speakers) if "diarize" in want else \
json.loads((out_dir / "diarization.json").read_text())
merged = assign_speakers(transcript, diar)
merged["model"] = "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1(本地)"
merged["elapsed_seconds"] = round(time.time() - t0, 1)
(out_dir / "asr_result.json").write_text(
json.dumps(merged, ensure_ascii=False, indent=1))
(out_dir / "transcript.txt").write_text(merged["text"])
log(f"合并完成:{len(merged['segments'])} 段,{len(merged['speakers'])} 个说话人,"
f"正文 {len(merged['text'])} 字,总耗时 {merged['elapsed_seconds']/60:.1f} 分钟")
return merged
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""把本地 ASR 包成一个 OpenAI 兼容的 /v1/audio/transcriptions 端点。
存在的意义:后端 internal/skills/packages/audio_transcribe/transcribe.go 是按
OpenAI 兼容协议写的(multipart 发 model/language/file,读回
{duration,text,segments:[{speaker,start,end,text}]})。只要本地服务说同一套协议,
**Go 侧一行都不用改**,改的是 config/ai_config.json 里那条 audio_routes 的 base_url。
用法:
./venv/bin/python serve.py --port 8090
然后 config/ai_config.json 加:
"audio_route_local_whisperx": {
"provider": "local_asr", "base_url": "http://127.0.0.1:8090/v1",
"endpoint": "/audio/transcriptions", "model": "large-v3",
"supports_speakers": true, "timeout_seconds": 3600
}
⚠️ 光改 base_url 还不够:transcribe.go:85 在 APIKey 为空时直接报错,而 key 只从
ProviderSecretKey[provider] 映射来。所以还得往 internal/config/json_loader.go 的
ProviderSecretKey 加一行 "local_asr": "LOCAL_ASR_API_KEY",并在 ai_secrets.json
里塞个占位值(本地服务不校验它,但 Go 侧要求非空)。
curl -s -X POST http://127.0.0.1:8090/v1/audio/transcriptions \
-H "Authorization: Bearer local" -F file=@x.mp3 -F model=large-v3 -F language=zh
"""
import argparse
import hashlib
import json
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import asr_core # noqa: E402
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from starlette.concurrency import run_in_threadpool # noqa: E402
import uvicorn # noqa: E402
CACHE = asr_core.ROOT / "cache"
CACHE.mkdir(exist_ok=True)
app = FastAPI(title="本地 ASR(OpenAI 兼容)", version="0.1.0")
STARTED_AT = time.time()
@app.get("/health")
def health():
return {
"status": "ok",
"uptime_seconds": round(time.time() - STARTED_AT, 1),
"model": "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1",
"models_dir": str(asr_core.MODELS),
}
@app.get("/v1/models")
def models():
# 后端不查这个端点,但 OpenAI 兼容服务一般都有,留着方便人工确认。
return {"object": "list",
"data": [{"id": "large-v3", "object": "model", "owned_by": "local"}]}
@app.post("/v1/audio/transcriptions")
async def transcriptions(
file: UploadFile = File(...),
model: str = Form("large-v3"),
language: str = Form(""),
response_format: str = Form("json"),
num_speakers: str = Form(""),
authorization: str = Header(default=""),
):
# 后端会带 Authorization: Bearer <key>。这里只要求「有」,不校验具体值 ——
# 本地服务绑 127.0.0.1,本来就不对外。
if not authorization.strip():
raise HTTPException(401, "缺少 Authorization 头")
raw = await file.read()
if not raw:
raise HTTPException(400, "上传的音频是空的")
digest = hashlib.sha256(raw).hexdigest()
work = CACHE / digest
cached = work / "asr_result.json"
# 按内容哈希缓存:同一份音频重发(比如后端重试、或我复跑)不该再烧一次 GPU。
if cached.exists() and cached.stat().st_size > 0:
asr_core.log(f"命中缓存 {digest[:12]},直接返回")
return JSONResponse(_to_openai(json.loads(cached.read_text()), model))
work.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "audio.mp3").suffix or ".mp3"
src = work / f"source{suffix}"
if not src.exists():
src.write_bytes(raw)
n = int(num_speakers) if num_speakers.strip().isdigit() else None
asr_core.log(f"新请求 {digest[:12]}:{file.filename} "
f"({len(raw)/1048576:.1f} MB, language={language or 'auto'})")
try:
# 丢到工作线程去跑,**不能在事件循环里直接调**。
#
# asr_core.run 是分钟级的阻塞调用(26 分钟音频实测 3 分钟),
# 而 uvicorn 只有一个事件循环:直接调的话,这段时间里 /v1/models
# 一个字都回不了。平台的健康探测正是打 /v1/models 的,于是
# 「正在转写」被读成「本地服务挂了」→ audio_route_auto 解到云端路由 →
# 下一个任务的音频就出公网了。整个过程没有任何报错。
#
# 转写本身仍然是串行的(asr_core 里的 _LOCK),这里只是把「等 GPU」
# 从事件循环里挪出去,让探活和排队中的请求还能被受理。
result = await run_in_threadpool(asr_core.run, src, work, language or "zh", n)
except Exception as e:
# 不吞异常:ASR 失败必须让调用方看见 5xx,不能返回一个空稿当成功。
# 后端那边对「空文本」也是按失败处理的(transcribe.go:203)。
asr_core.log(f"失败:{type(e).__name__}: {e}")
raise HTTPException(500, f"本地 ASR 失败:{type(e).__name__}: {e}")
return JSONResponse(_to_openai(result, model))
def _to_openai(result: dict, model: str) -> dict:
"""转成 OpenAI 兼容响应。
`usage` 里带 duration 是照着 SiliconFlow 的 diarize 模型来的 —— 后端
ai/credits.go 按这个记用量。本地跑不要钱,但字段留着,免得下游解析时
因为缺字段而报错。
"""
return {
"text": result["text"],
"duration": result["duration"],
"language": result.get("language", ""),
"model": model,
"segments": result["segments"],
"usage": {"type": "duration", "seconds": result["duration"]},
# 非标准字段,方便人工核对;后端只读上面那几个,多给的会被忽略。
"_local": {"speakers": result.get("speakers", []),
"elapsed_seconds": result.get("elapsed_seconds")},
}
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8090)
args = ap.parse_args()
asr_core.log(f"本地 ASR 服务启动于 http://{args.host}:{args.port}")
asr_core.log(f"模型目录 {asr_core.MODELS}")
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
#
# 把原型机上的本地语音转写(~/asr-poc)安装成 systemd 常驻服务。
#
# 做的事就是 DELIVERY.md 第 2.5 节,只是写成了可重复执行的脚本:
# 建账号 → 搬 venv/模型到 /opt/eai_agentplatform-asr → 装 unit 与 env →
# enable --now → 自检。
#
# 用法:
# sudo bash deploy/install_asr_local.sh # 先打印计划,再执行
# sudo bash deploy/install_asr_local.sh --dry-run # 只看计划
#
# 幂等:重复执行不会重搬 venv/模型(那两项是按「目标不存在才做」判断的)。
#
set -euo pipefail
SRC_DIR="${ASR_SRC_DIR:-$(getent passwd "${SUDO_USER:-root}" | cut -d: -f6)/asr-poc}"
DST_DIR=/opt/eai_agentplatform-asr
SVC_USER=eai_agentplatform
UNIT=/etc/systemd/system/eai_agentplatform-asr.service
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
say() { printf ' %s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
run() {
if [[ "$DRY_RUN" -eq 1 ]]; then
printf ' [dry-run] %s\n' "$*"
else
"$@"
fi
}
die() { printf '\n[FATAL] %s\n' "$*" >&2; exit 1; }
# ── 0. 前置检查 ────────────────────────────────────────────────
step "0/7 前置检查"
[[ "$(id -u)" -eq 0 ]] || die "需要 root:sudo bash $0"
say "root ✓"
# 服务代码(serve.py / asr_core.py)从**仓库**取,不从 $SRC_DIR 取。
#
# 原先是从原型目录 cp 的,那是错的:DELIVERY.md 的清理计划里明确写着装完要
# `rm -rf ~/asr-poc`,也就是这服务的唯一副本会变成 /opt 下那份 root 所有、
# 不在任何版本库里的文件 —— 改动没法 review、丢了没法重建。
# 现在仓库里的 deploy/asr/ 是唯一事实源,$SRC_DIR 只负责 venv 与模型(那两样
# 太大,天生不该进仓库)。
for f in serve.py asr_core.py; do
[[ -f "$DEPLOY_DIR/asr/$f" ]] || die "缺少 $DEPLOY_DIR/asr/$f"
done
say "服务代码 $DEPLOY_DIR/asr ✓"
# 只有 venv 与模型还需要原型目录。
[[ -d "$SRC_DIR" ]] || die "找不到原型目录 $SRC_DIR —— 用 ASR_SRC_DIR=... 指定"
say "原型目录 $SRC_DIR ✓"
# venv 只在「还没装过」时才是必需的
if [[ ! -d "$DST_DIR/venv" ]]; then
[[ -x "$SRC_DIR/venv/bin/python" ]] || die "$SRC_DIR/venv/bin/python 不可执行,venv 不完整"
say "venv ✓"
else
say "venv 已就位,本次不搬(幂等)"
fi
# 模型:原型上是软链指向仓库,交付机上必须是实体目录
if [[ ! -d "$DST_DIR/models" ]]; then
[[ -e "$SRC_DIR/models" ]] || die "$SRC_DIR/models 不存在"
if [[ -L "$SRC_DIR/models" ]]; then
say "模型是软链($(readlink "$SRC_DIR/models"))—— 会解开拷成实体目录"
fi
need_kb=$(( $(du -sL "$SRC_DIR/models" | cut -f1) / 1024 + 1024 ))
free_kb=$(df -Pk "$(dirname "$DST_DIR")" | awk 'NR==2{print $4}')
(( free_kb > need_kb )) || die "磁盘不够:需要约 ${need_kb}MB,可用 ${free_kb}MB"
say "磁盘余量 ${free_kb}MB ≥ 需要 ${need_kb}MB ✓"
else
say "模型已就位,本次不拷(幂等)"
fi
for f in asr.env eai_agentplatform-asr.service OFFLINE.md; do
[[ -f "$DEPLOY_DIR/$f" ]] || die "缺少交付件 $DEPLOY_DIR/$f"
done
say "交付件齐备 ✓"
# 8090 上如果不是本服务在听,先让开,否则新服务起不来。
#
# 只认「cmdline 里含 serve.py」的那一个 PID —— 不做按名字的模糊匹配。
# 这台机器上还跑着别的常驻服务(10231/10232 的 dev server、llama-server),
# 模糊匹配会误杀它们。
port_pid() {
command -v ss >/dev/null 2>&1 || return 1
ss -ltnp "( sport = :8090 )" 2>/dev/null \
| sed -n '2,$p' | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2
}
STALE_PID="$(port_pid || true)"
if [[ -n "${STALE_PID:-}" ]]; then
if systemctl is-active --quiet eai_agentplatform-asr 2>/dev/null; then
say "8090 是本服务的上一实例,稍后 restart 接管"
elif tr '\0' ' ' < "/proc/$STALE_PID/cmdline" 2>/dev/null | grep -q 'serve\.py'; then
say "8090 上跑着原型机的 serve.py(PID $STALE_PID),安装时会让它退出"
run kill "$STALE_PID"
sleep 2
run kill -9 "$STALE_PID" 2>/dev/null || true
else
say "8090 被一个**不是**本服务的进程占用:"
ps -o pid=,args= -p "$STALE_PID" 2>/dev/null | sed 's/^/ /'
die "请先手动停掉它,本服务才能绑定 8090"
fi
fi
# ── 1. 账号 ────────────────────────────────────────────────────
step "1/7 服务账号 $SVC_USER"
if id "$SVC_USER" >/dev/null 2>&1; then
say "已存在,跳过"
else
run useradd -r -s /usr/sbin/nologin "$SVC_USER"
say "已创建(系统账号,不可登录)"
fi
# ── 2. 目录与代码 ──────────────────────────────────────────────
step "2/7 目录与代码 → $DST_DIR"
run mkdir -p "$DST_DIR"
run install -m 0644 "$DEPLOY_DIR/asr/serve.py" "$DEPLOY_DIR/asr/asr_core.py" "$DST_DIR/"
run install -m 0644 "$DEPLOY_DIR/OFFLINE.md" "$DST_DIR/OFFLINE.md"
say "serve.py / asr_core.py / OFFLINE.md"
# ── 3. venv ───────────────────────────────────────────────────
step "3/7 venv(约 9.4 GB)"
if [[ -d "$DST_DIR/venv" ]]; then
say "已就位,跳过"
else
# 用 mv 不用 cp:同一个文件系统上是重命名,瞬时且不占额外空间。
# 两台机器上各留一份 venv 没有意义,DELIVERY.md 也是这么写的。
run systemctl stop eai_agentplatform-asr 2>/dev/null || true
run mv "$SRC_DIR/venv" "$DST_DIR/venv"
say "已从 $SRC_DIR/venv 移入(原型目录将不再能独立运行)"
fi
# ── 4. 模型 ───────────────────────────────────────────────────
step "4/7 模型(约 4.1 GB)"
if [[ -d "$DST_DIR/models" ]]; then
say "已就位,跳过"
else
# -L 解开软链:原型机上 models 指向仓库的 external_download/,
# 客户机上没有仓库可指,必须落成实体目录。
run cp -aL "$SRC_DIR/models" "$DST_DIR/models"
say "已拷成实体目录"
fi
# ── 5. 权限 ───────────────────────────────────────────────────
step "5/7 权限与 cache 目录"
run chown -R "$SVC_USER:$SVC_USER" "$DST_DIR"
run mkdir -p "$DST_DIR/cache"
run chown "$SVC_USER:$SVC_USER" "$DST_DIR/cache"
# 只读是刻意的:ProtectSystem=strict 下服务只能写 cache/,
# venv 与模型保持只读,跑起来之后谁也别想改。
run chmod -R a-w "$DST_DIR/venv" "$DST_DIR/models"
run chmod u+w "$DST_DIR/cache"
say "venv/模型 只读;cache/ 可写"
# ── 6. 单元与 env ─────────────────────────────────────────────
step "6/7 systemd 单元与 env"
run install -m 0644 "$DEPLOY_DIR/asr.env" "$DST_DIR/asr.env"
run install -m 0644 "$DEPLOY_DIR/eai_agentplatform-asr.service" "$UNIT"
run systemctl daemon-reload
say "已安装 $UNIT"
# ── 7. 启动与自检 ─────────────────────────────────────────────
step "7/7 启动与自检"
if [[ "$DRY_RUN" -eq 1 ]]; then
say "[dry-run] 到此为止,未做任何改动"
exit 0
fi
systemctl enable --now eai_agentplatform-asr >/dev/null
printf ' [.] 等待 8090 就绪(首次加载模型要几十秒)'
ok=0
for _ in $(seq 1 90); do
if curl -fsS -m 2 -o /dev/null http://127.0.0.1:8090/v1/models 2>/dev/null; then
ok=1; break
fi
printf '.'; sleep 2
done
printf '\n'
if [[ "$ok" -eq 1 ]]; then
echo " [OK] 本地语音转写服务就绪 (127.0.0.1:8090)"
else
echo " [FAIL] 90 次探测都没起来。看日志:"
echo " journalctl -u eai_agentplatform-asr -n 80 --no-pager"
echo " —— 若报 CUDA OOM,多半是显存被 llama-server 占了,见 bugs_and_errors.md E06"
exit 1
fi
cat <<'EOF'
下一步(不是必须,但建议做):
1) 确认平台侧认到了:后台 → AI 路由 → 语音,看「本地」那条 healthy 是否为真。
平台每 30 分钟探一次,想立刻重探就打一次 POST /api/ai/reload。
2) 确认「默认语音路由」指向本地 —— 这才是「优先本地」的开关。
EOF