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:
@@ -21,6 +21,10 @@
|
||||
| E07 | 2026-09-26 | 本地 ASR 加载 | pyannote 的 `from_pretrained` **只认文件不认目录**,给目录被当成 HF repo id |
|
||||
| E08 | 2026-09-26 | 本地 ASR 加载 | torch≥2.6 把 `weights_only` 默认翻成 True,pyannote 旧权重直接拒载 |
|
||||
| E09 | 2026-09-26 | 工作方式 | 一句「提交一下」被我做成依赖锥测量 + hunk 挑拣工具,最后反问用户提交范围 |
|
||||
| E10 | 2026-09-26 | 本地 ASR 显存 | pyannote 管线挪上 CUDA 后永久缓存、从不释放,服务只能转写**第一次** |
|
||||
| E11 | 2026-09-26 | 验证方法 | 靠「连跑三次都成功」下结论 —— 其实三次全是缓存命中,压根没碰 GPU |
|
||||
| E12 | 2026-09-26 | 本地 ASR 服务化 | 转写堵住事件循环,探活超时 → 本地被判不健康 → **静默退云端,音频出网** |
|
||||
| E13 | 2026-09-26 | 本地 ASR 服务化 | 服务账号的 `~` 不可写,pyannote 写不了 `~/.pyannote/database.yml`,每次转写都 500 |
|
||||
|
||||
---
|
||||
|
||||
@@ -412,6 +416,210 @@ internal/ai/llm.go 的 ctx 签名变更
|
||||
|
||||
---
|
||||
|
||||
## E10 pyannote 管线占着显存不放,本地转写只能成功第一次
|
||||
|
||||
**日期**:2026-09-26 **区域**:本地 ASR 显存(原型遗留给常驻服务的坑)
|
||||
|
||||
**症状**:把本地 ASR 从「跑一次看结果」接成常驻服务后,**第一次转写正常,之后每一次都失败**:
|
||||
|
||||
```
|
||||
[22:30:32] 合并完成:18 段,1 个说话人,正文 480 字 ← 第一次,成功
|
||||
[22:35:15] 加载 faster-whisper large-v3(当前空闲显存 0.25 GiB)
|
||||
[22:35:18] int8_float16 加载失败:CUDA failed with error out of memory
|
||||
[22:35:21] int8 加载失败:CUDA failed with error out of memory
|
||||
```
|
||||
|
||||
`nvidia-smi` 显示 `./venv/bin/python` 稳稳占着 2646 MiB 不还。
|
||||
|
||||
**根因**:`asr_core.py` 里两个模型的显存抢占**只做了单向**。
|
||||
|
||||
- `_FW_CACHE["m"]`(whisper)有释放函数 `_free_fw()`;
|
||||
- `_DIA_CACHE["p"]`(pyannote 管线)**没有**。`_pipeline()` 里 `pipe.to(torch.device("cuda"))`
|
||||
之后就再也没人碰过它,`_DIA_CACHE` 是模块级字典,进程活着它就不走。
|
||||
|
||||
而 `_free_fw()` 的唯一调用点在 `run()` 里,条件是 `if "diarize" in want` ——
|
||||
注释写着「转写和分离是先后关系,没有并存的理由,所以分离前先放掉前一个」。
|
||||
这个理由是对的,但**只覆盖了一个方向**:分离前放掉 whisper。反向没人管 ——
|
||||
第二次转写要加载 whisper 时,pyannote 还在卡上。
|
||||
|
||||
所以第一次跑完(转写 → 放 whisper → 加载 pyannote → 留在卡上),
|
||||
第二次就无路可走。8G 卡被 llama-server 常态占掉约 5G,余量本来就只够一个模型。
|
||||
|
||||
**修法**(`asr_core.py`):
|
||||
|
||||
1. 新增 `_free_pipe()`,与 `_free_fw()` 对称:pop 出 `_DIA_CACHE["p"]` → `gc.collect()`
|
||||
→ `torch.cuda.empty_cache()`。
|
||||
2. **把互斥放进加载器自己**,而不是留给调用方记:
|
||||
- `_fw_model()` 在阶梯循环**之前**调 `_free_pipe()`;
|
||||
- `_pipeline()` 在加载之前调 `_free_fw()`。
|
||||
3. 删掉 `run()` 里那句 `if "diarize" in want: _free_fw()` —— 现在多余,而且正是它
|
||||
教坏了结构:腾显存成了「调用方要记住的规矩」,只要有一条路径绕开就 OOM。
|
||||
|
||||
放在阶梯循环之前是必须的:放后面的话 `int8_float16` / `int8` 两档会各白撞一次 OOM。
|
||||
|
||||
修完日志每次都是干净的循环,空闲显存回到 2.42 GiB 而不是塌到 229 MiB:
|
||||
|
||||
```
|
||||
已释放 pyannote 显存,现空闲 2.42 GiB → 加载 faster-whisper large-v3
|
||||
已释放 whisper 显存,现空闲 2.42 GiB → (加载 pyannote)
|
||||
```
|
||||
|
||||
**代价(如实记)**:两个模型现在每次都互相驱逐,也就是**每次请求都要重新加载模型**,
|
||||
单次多花几秒。这是 8G 卡上仅剩 2.4G 可用显存的必然结果,不是可以调优掉的。
|
||||
换更大显存的交付机时,这条约束可以放宽(改成「装不下才驱逐」),但**没在那种机器上验过,
|
||||
不要提前改** —— 与 `asr.env` 里不加分配器开关是同一条理由。
|
||||
|
||||
**怎么早点发现**:**接成服务之后,用「两个内容不同的输入」连跑两次。**
|
||||
原型阶段的验证模式是「跑一遍、看结果对不对」,这个模式下「跑第二次」是不存在的动作,
|
||||
所以单次能过就以为没问题。服务化会引入一类原型阶段根本不存在的失败:**状态残留**。
|
||||
凡是「加载了就往缓存里一放」的模块级字典,都要问一句:谁负责把它拿出来。
|
||||
|
||||
---
|
||||
|
||||
## E11 「连跑三次都成功」其实是三次缓存命中
|
||||
|
||||
**日期**:2026-09-26 **区域**:验证方法(**我自己犯的错**)
|
||||
|
||||
**症状**:修完 E10 后我验证,连打三次本地转写,三次都返回 200,据此向用户报告
|
||||
「修复成立」。实际上**三次都走的是 `out/` 里的缓存**(`whisper_raw.json` /
|
||||
`diarization.json` 按音频内容哈希落盘),一秒钟的活都没干。
|
||||
|
||||
揭穿它的是显存:三次的 `nvidia-smi` 只动了 300 MiB(whisper 常驻),
|
||||
pyannote 压根没加载过。而 E10 要验的正是 pyannote 那条路径。
|
||||
|
||||
**根因**:两件事同时让我误判,而且它们**都会让验证看起来通过**:
|
||||
|
||||
1. **`asr_core.py` 按内容哈希缓存各阶段结果**。同样的输入第二次不重算 ——
|
||||
这是我要的功能,但它让「重复同一动作」变成了无效验证。
|
||||
2. **我用的测试载荷本身走不到出问题的那条路径**。当然后端自带的 audio 通路测试
|
||||
发的是 **1 秒静音 WAV**,它连说话人分离都不会触发 —— 拿它验显存问题,
|
||||
等于用体温计测血压。
|
||||
|
||||
两次误判的形状一样:**我以为在验 A,实际验的是 B,而 B 从来没坏过。**
|
||||
|
||||
**修法**:验证要让「出问题的那条路径」真的被执行。
|
||||
|
||||
- 判断依据不是「返回码是 200」,而是**副作用真的发生了**:
|
||||
看 `nvidia-smi` 有没有动、看日志里有没有出现该出现的行(`已释放 pyannote 显存`)。
|
||||
- 输入要**两两不同**(内容不同 → 哈希不同 → 不吃缓存),且要包含**走分离**的路径。
|
||||
这次是切三段不同的音频(`-ss 300 / 700 / 1100`)连跑。
|
||||
- 决定性的一次是**第三个**文件:此时 pyannote 已驻留,它必须先被请走才能加载 whisper ——
|
||||
这才复现了 E10 的场景。
|
||||
|
||||
**怎么早点发现**:
|
||||
|
||||
> **当一个验证「全都通过了」,先问一句:它们真的跑了吗?**
|
||||
> 找那个**不该这么快**的信号 —— 2ms、1ms 的「转写」就是它。
|
||||
|
||||
具体到本仓库:任何带缓存的链路(`out/` 下的 `whisper_raw.json`、`diarization.json`),
|
||||
连续两次同样的请求**不构成两次验证**。要验第二次,就换输入。
|
||||
|
||||
---
|
||||
|
||||
## E12 转写堵住事件循环,本地路由被误判「不健康」,音频静默出网
|
||||
|
||||
**日期**:2026-09-26 **区域**:本地 ASR 服务化(**装成服务才出现,原型上永远不会遇到**)
|
||||
|
||||
**症状**:`serve.py` 装成 systemd 服务后,后台「AI 路由 → 语音」里本地那条
|
||||
`healthy=false`,`last_error` 是
|
||||
|
||||
```
|
||||
服务不可达: Get "http://127.0.0.1:8090/v1/models": context deadline exceeded
|
||||
```
|
||||
|
||||
而同时手工 `curl` 同一个地址是 **HTTP 200,1.7 毫秒**。于是 `audio_route_auto`
|
||||
解析到 `audio_route_siliconflow_diarize` —— **下一个任务的录音会被送到公网 ASR**。
|
||||
整个过程中平台不报任何错,界面只显示「本地不可用」,用户完全不知道自己失去了
|
||||
「音频不出本机」。
|
||||
|
||||
**根因**:`serve.py` 的端点写成 `async def transcriptions(...)`,里面**直接**调
|
||||
分钟级的阻塞函数 `asr_core.run(...)`。uvicorn 只有一个事件循环 —— 这一行把循环
|
||||
占死,期间 `/v1/models` 一个字都回不了(连 FastAPI 丢给线程池的 `def` 端点也一样,
|
||||
因为请求要先由事件循环受理)。
|
||||
|
||||
关键在**「不健康」是假的**:本地服务没坏,它只是**正忙**。而健康探测分不清
|
||||
「忙」和「死」,两者都表现为「20 秒内没有任何响应头」,于是判死。
|
||||
|
||||
原型机上为什么没暴露:原型是手工起在前台,跑一次转写就占满那一次会话;
|
||||
而平台侧那 30 分钟一次的探测,撞上正忙的概率被「反正没人同时用」掩盖了。
|
||||
|
||||
**修法**:把阻塞调用丢出事件循环(`serve.py`)
|
||||
|
||||
```python
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
...
|
||||
result = await run_in_threadpool(asr_core.run, src, work, language or "zh", n)
|
||||
```
|
||||
|
||||
转写本身仍然串行(`asr_core` 里已有 `_LOCK`),这里只是把「等 GPU」从事件循环里
|
||||
挪出去,好让探活和排队中的请求还能被受理。
|
||||
|
||||
**怎么证明修好了**(不是「探了一次是 200」):要**在忙窗口里**探。
|
||||
|
||||
这次的证据链是三样东西对齐时间戳:
|
||||
|
||||
1. 服务日志给出忙窗口:`23:26:43 开始说话人分离 … 23:28:13 合并完成`,
|
||||
紧接着 `23:28:13 → 23:29:23` 是第二段的转写窗口;
|
||||
2. 探测结果打时间戳:5 次采样落在 `23:28:11 / 23:28:41 / 23:29:01 / 23:29:22 / 23:29:45`
|
||||
—— **全部在忙窗口内**,全部 `healthy=true`,延迟 1–2 毫秒;
|
||||
3. 修之前同样的探活在忙窗口里是 `20018ms` 超时。
|
||||
|
||||
**怎么早点发现**:
|
||||
|
||||
> **「服务不可达」和「服务正忙」在探测上长得一模一样 —— 只要探测走的是同一个
|
||||
> 单线程入口,忙就会被读成死。**
|
||||
>
|
||||
> 给一个「会把进程占满」的服务的健康端点,先问一句:**它在主活跑着的时候还能应答吗?**
|
||||
> 验的时候必须在忙的**当中**探,忙完再探等于没探。
|
||||
|
||||
具体到本仓库:`internal/config/route_health.go` 那个 20 秒超时对 audio 是**没有余量**的
|
||||
(chat 探测发的是 4 token 的小请求,audio 探测虽然只 GET `/v1/models`,但撞上忙窗口
|
||||
就得靠对端把循环让出来)。所以这条不只是 ASR 一侧的事。
|
||||
|
||||
---
|
||||
|
||||
## E13 服务账号的家目录不可写,pyannote 起步就 PermissionError
|
||||
|
||||
**日期**:2026-09-26 **区域**:本地 ASR 服务化(同上,装了服务才出现)
|
||||
|
||||
**症状**:装成 systemd 服务后,每次转写都以 500 收场:
|
||||
|
||||
```
|
||||
本地 ASR 失败:PermissionError: [Errno 13] Permission denied: '/home/eai_agentplatform/.pyannote/database.yml'
|
||||
```
|
||||
|
||||
手工起原型时同样的代码、同样的音频,一切正常。
|
||||
|
||||
**根因**:两件事在服务化时同时发生,原型上一个都不成立:
|
||||
|
||||
1. unit 里 `ProtectHome=true`,`/home` 整个不可访问;
|
||||
2. 服务账号是 `useradd -r` 建的系统账号,家目录 `/home/eai_agentplatform` **根本不存在**。
|
||||
|
||||
pyannote 起步时要写 `~/.pyannote/database.yml`,于是必失败。报错落在说话人分离
|
||||
那一段,看起来像**模型坏了**,其实是**没地方写配置**。
|
||||
|
||||
**修法**:给服务一个可写的家目录 —— `deploy/asr.env` 里
|
||||
|
||||
```
|
||||
HOME=/opt/eai_agentplatform-asr/cache
|
||||
```
|
||||
|
||||
指到 `cache/` 是因为 unit 的 `ReadWritePaths` 只放开了这一个目录;顺带让
|
||||
torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
|
||||
|
||||
**怎么早点发现**:
|
||||
|
||||
> **凡是「以某个账号跑」的服务,`~` 就得当成一个真实依赖来验**,
|
||||
> 不能假定它存在、也不能假定它可写 —— 尤其是 `useradd -r` 建的系统账号
|
||||
> (家目录常常压根不建)和开了 `ProtectHome` 的 unit。
|
||||
>
|
||||
> 症状的迷惑性在于:报错点在**第三个库**(pyannote),而病灶在**运行环境**(HOME)。
|
||||
|
||||
具体到本仓库:`deploy/eai_agentplatform.service` 是同一个形状 —— 主服务现在
|
||||
不写 `~`,但哪天有依赖要写,会以完全一样的姿势炸。
|
||||
|
||||
---
|
||||
|
||||
## 待沉淀(还没写进规则的)
|
||||
|
||||
- [ ] **装完必须导入自检**:带 C 扩展的包(torch / torchaudio / ctranslate2)装完立刻 import 一次(见 E01)。
|
||||
@@ -421,3 +629,8 @@ internal/ai/llm.go 的 ctx 签名变更
|
||||
候选升格为 P06 的一条(「旧权重在新 torch 上要先放行 unpickle 白名单」)。
|
||||
- [ ] **E06 的通用形状**:GPU 是共享资源,跑之前先 `nvidia-smi` 看**别人**占了多少,
|
||||
再决定自己的精度档位 —— 候选升格为 P06 的一条。
|
||||
- [ ] **E12/E13 的通用形状**:**「手工跑得通」不等于「装成服务跑得通」** ——
|
||||
服务化会同时改掉三件事:运行账号(家目录、权限)、资源隔离(ProtectHome /
|
||||
ProtectSystem / ReadOnlyPaths)、并发模型(前台独占 vs 后台多请求)。
|
||||
凡是「原型上验过」的东西,装成服务后必须**照着这三条重验一遍**。
|
||||
这次的 E13 是账号那条,E12 是并发那条。
|
||||
|
||||
Reference in New Issue
Block a user