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:
@@ -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
|
||||
Reference in New Issue
Block a user