回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由 试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 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>
31 KiB
bugs and errors
用途:记录开发过程中真实发生过的错误与坑。 每条必须能回答四件事:症状 → 根因 → 怎么修 → 怎么早点发现。
不写流水账,不写「今天很顺利」。这份文件的价值只在于:下一个踩同一个坑的人, 能在五分钟内认出自己遇到的是同一件事,而不是花两小时重新推导一遍。
关联
TOP_CODING_RULES.md(G02 错误不得静默、G04 完成判定靠事实)。
索引
| # | 日期 | 区域 | 一句话 |
|---|---|---|---|
| E01 | 2026-09-26 | 本地 ASR 依赖 | torchaudio>=2.9 删了 pyannote 3.x 要的三个 API,装完 import 才炸 |
| E02 | 2026-09-26 | 模型下载 | HuggingFace 不通 + pyannote repo gated,hf-mirror 403,只有魔搭能下 |
| E03 | 2026-09-26 | 模型下载 | jonatasgrosman/…-chinese-zh-cn 没有 model.safetensors,只有 .bin |
| E04 | 2026-09-26 | 依赖安装 | whisperx 的依赖树(lightning/optuna/opentelemetry)解析十几分钟不收敛 |
| E05 | 2026-09-26 | 进度测量 | /proc/PID/io 的 read_bytes 不算 socket 读,据此误判「下载卡死」 |
| E06 | 2026-09-26 | 本地 ASR 显存 | 8G 卡被 llama-server 占了 5G,large-v3 的 float16 一加载就 CUDA OOM |
| 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 |
E01 torchaudio>=2.9 删了 pyannote 3.x 要的 API
日期:2026-09-26 区域:~/asr-poc 本地 ASR 环境 影响:整个本地 ASR 跑不起来
症状
装完一切正常(Successfully installed … 无任何警告),第一次导入就硬失败:
$ ./venv/bin/python -c "import pyannote.audio"
File ".../pyannote/audio/__init__.py", line 29, in <module>
from .core.inference import Inference
File ".../pyannote/audio/core/inference.py", line 35, in <module>
from pyannote.audio.core.io import AudioFile
File ".../pyannote/audio/core/io.py", line 60, in <module>
) -> torchaudio.AudioMetaData:
AttributeError: module 'torchaudio' has no attribute 'AudioMetaData'
当时的版本:torch 2.14.0 / torchaudio 2.11.0 / pyannote.audio 3.4.0。
根因
pyannote.audio 3.x 有三处依赖 torchaudio 的 backend-dispatch I/O API:
| 位置 | 用的是 |
|---|---|
pyannote/audio/core/io.py:60 |
torchaudio.AudioMetaData 作返回注解 |
pyannote/audio/core/io.py:81 |
torchaudio.list_audio_backends() |
pyannote/audio/core/io.py:85 |
torchaudio.info(...) |
而 torchaudio 从 2.9 起把这套 API 整体移除了(I/O 交给 torchcodec),只留下 load。
本机实测 torchaudio 2.11.0:
>>> [n for n in ('load','info','list_audio_backends','AudioMetaData') if hasattr(torchaudio,n)]
['load']
修复后拿到了 torchaudio 官方的一手佐证 —— 降到 2.8.0 之后 import pyannote.audio, 它自己把这条弃用警告打了出来,等于承认了根因:
UserWarning: torchaudio._backend.list_audio_backends has been deprecated. This deprecation
is part of a large refactoring effort to transition TorchAudio into a maintenance phase.
The decoding and encoding capabilities of PyTorch for both audio and video are being
consolidated into TorchCodec. ... It will be removed from the 2.9 release.
「It will be removed from the 2.9 release」—— 正是我们装的 2.11 里发生的事。
两个放大伤害的细节:
io.py没有from __future__ import annotations,所以第 60 行的注解在 import 时求值 —— 不是用到才炸,是import pyannote.audio这一行就炸。- 这决定了 任何 pyannote.audio 3.x 都没救,不是某个小版本的问题。 要么降 torchaudio,要么整个换到 pyannote 4。
为什么会踩
- 最初是裸装:
pip install torch torchaudio faster-whisper "pyannote.audio>=3.1,<4" …—— torch/torchaudio 没钉版本,拿到当时最新的 2.14.0 / 2.11.0。 - pip 全程没有任何警告,
Successfully installed一长串看着完全正常。 - 讽刺的是:正因为绕开 whisperx 手工装(见 E04)才丢掉了版本约束 ——
而 whisperx 自己钉的是
torch~=2.8.0,等于生态早就知道该用 2.8。
修法
钉到 pyannote 3.4 那一代的组合:
pip install --index-url https://mirrors.aliyun.com/pypi/simple \
"torch==2.8.0" "torchaudio==2.8.0"
没走的那条路:升到 pyannote.audio 4.0.7(它已不再依赖 torchaudio)。
pip install --dry-run 的结果:
Would install … opentelemetry-api/-sdk/-proto/-exporter-*(10 个)
pyannoteai-sdk-0.4.0 torchcodec-0.16.0 safetensors-0.8.0
pyannote-core-6.0.1 pyannote-database-6.1.1 pyannote-metrics-4.1
否决理由:多出十几个包,其中 pyannoteai-sdk 是厂商云 SDK、opentelemetry 是遥测上报 ——
与「100% 本地化部署」的原则相抵;且 4.x 吃不吃 version: 3.1.0 的 config 没验证过,
正好是当初选 pyannote 3.x 时就想避开的那个未知数。
怎么早点发现
装完立刻 import 一遍,别等跑数据。
./venv/bin/python -c "import torch, faster_whisper, pyannote.audio; print(torch.__version__, pyannote.audio.__version__)"
一秒的事,能省掉 4.2GB venv + 4.1GB pip 缓存 + 约 2.5GB 重下的往返。
已固化成 ~/asr-poc/selfcheck.sh,装完/重建后跑一次。
通用教训:GPU 生态里「装成功」和「能用」是两件事 ——
pip install 只证明包落盘了,不证明 ABI/API 对得上。
凡是带 C 扩展的包(torch / torchaudio / ctranslate2 / torchcodec),装完必须真 import 一次。
E02 HuggingFace 不通 + gated,只有魔搭能下
日期:2026-09-26 区域:模型下载
症状:huggingface.co 上 pyannote/segmentation-3.0 等 repo 是 gated(需登录并接受条款),
本机连 huggingface.co 都连不上(curl 返回 000);退而用 hf-mirror.com 代理,一律 403。
根因:gated 是账号态的授权,代理站点没有你的 HF 账号,自然过不了。
修法:改用 modelscope.cn(魔搭) —— 它把这些 gated repo 整个镜像到了官方命名空间
(路径就是 pyannote/segmentation-3.0),且在魔搭上不 gated。
同一文件同一时刻的实测:
| 站点 | 结果 |
|---|---|
huggingface.co |
000(不通) |
hf-mirror.com |
403 |
www.modelscope.cn |
206(分片续传正常) |
注意:gated 不等于许可变更(许可仍是 MIT / CC-BY-4.0), 但镜像确实绕过了「在 HF 上点接受条款」那一步,交付前值得再确认一次(关联 G19.10 / P06.11)。
怎么早点发现:探测顺序应该是「官方 → 官方镜像 → 国内镜像」,而不是「官方失败就放弃」。 且要用具体文件去探,不是探首页 —— 首页 200 不代表文件可下(hf-mirror 就是首页通、文件 403)。
E03 这个中文对齐 repo 没有 model.safetensors
日期:2026-09-26 区域:模型下载
症状:按惯例去取 model.safetensors → 404。
根因:jonatasgrosman/wav2vec2-large-xlsr-53-chinese-zh-cn
只发布了 pytorch_model.bin,没有 safetensors 版本。
修法:下 pytorch_model.bin。已写进 download_models.sh 的注释与下载清单,
免得下一个人照 safetensors 的惯例又踩一次。
怎么早点发现:下之前先列 repo 文件清单
(魔搭 …/repo/files?Revision=master),别按别的 repo 的文件名去猜。
E04 whisperx 的依赖树解析不收敛
日期:2026-09-26 区域:依赖安装
症状:pip install whisperx 跑了 16 分钟以上仍在 Resolving dependencies,
没有失败、没有输出进度,看起来像卡死。
根因:whisperx 3.8.6 依赖 pyannote-audio>=4.0.0 与 torch~=2.8.0,
连带拖进 lightning / optuna / opentelemetry / aiohttp 一大棵树,回溯空间极大。
修法:放弃 whisperx,只装实际用到的那几个,并逐条钉版本:
torch==2.8.0 torchaudio==2.8.0 faster-whisper pyannote.audio>=3.1,<4
fastapi uvicorn python-multipart
说话人分离用 pyannote 直接做,不需要 whisperx 那层封装。
怎么早点发现:pip install 长时间无输出就是危险信号。此时应看
/tmp/pip-unpack-*/ 目录是否在长(见 E05),而不是凭感觉判断「卡死了」。
E05 /proc/PID/io 的 read_bytes 不算 socket 读
日期:2026-09-26 区域:进度测量(我自己犯的错)
症状:pip 下载中,我读 /proc/<pid>/io 的 read_bytes 得到 0 KB/s,
据此向用户报告「下载卡死了」,并切了一次源。
根因:read_bytes 统计的是块设备 I/O,即真正落到磁盘的字节。
pip 是边下边写临时文件,套接字收包不计入该字段 —— 这个数字在下载期间本来就接近 0,
它不是一个「下载进度」指标。
修法:量 pip 的下载进度要看临时目录体积的增长:
du -sb /tmp/pip-unpack-*/ # 隔几秒看两次,差值才是真实速度
后果:因这一次误判,我多发了一次无谓的切换(源其实没问题)。 结论是「源慢」而不是「源死」,两者的处置完全不同。
怎么早点发现:用指标前先确认这个指标定义的是什么。 一个恒为 0 的读数,先怀疑指标选错了,再怀疑被测对象。
E06 8G 显存被 llama-server 占掉 5G,large-v3 装不下
日期:2026-09-26 区域:本地 ASR(~/asr-poc)
症状:selfcheck.sh 全绿、模型文件也都在位,一跑就炸:
File "/home/eaiadmin/asr-poc/asr_core.py", line 71, in _fw_model
_FW_CACHE["m"] = WhisperModel(str(FW_DIR), device="cuda", compute_type="float16", ...)
File ".../faster_whisper/transcribe.py", line 689, in __init__
self.model = ctranslate2.models.Whisper(...)
RuntimeError: CUDA failed with error out of memory
根因:不是代码问题,是卡上真的没地方了。nvidia-smi 实测:
memory.total 8192 MiB, memory.used 5106 MiB, memory.free 2681 MiB
3418, /app/llama-server, 4778 MiB
3449, /app/llama-server, 232 MiB
那 5 GiB 是仓库 README 里 8080/8081 那两个常驻 llama.cpp 服务占的 —— 属于用户的服务,不是我们这次起的。而 large-v3 的 float16 权重本身就要 3 GB。
修法:不动别人的进程,改自己的取用方式(默认 int8_float16,实测 2.5 GiB 空闲下装得下,
质量损失可接受;ASR_COMPUTE_TYPE=float16 可在显存宽裕时调回):
_COMPUTE_LADDER = [os.environ.get("ASR_COMPUTE_TYPE", "int8_float16"), "int8"]
连带的第二个坑:whisper 和 pyannote 不能同时在卡上。
只剩两三百 MB 余量,两个都驻留必炸 —— 而这两步本来就是先后关系。
所以转写完立刻 _free_fw()(gc.collect() + torch.cuda.empty_cache())再上 pyannote。
实测释放后空闲回到 2.50 GiB,分离正常。
怎么早点发现:报「CUDA out of memory」时第一件事是 nvidia-smi 看谁占着,
而不是去调自己的 batch size。本机只有 8G 且常年被别的服务占着,
「模型本身装不下」这种情况会反复出现,值得一开始就把 mem_get_info() 打进日志
(现在加载前会打一行 当前空闲显存 x.xx GiB)。
E07 pyannote 的 from_pretrained 只认文件,不认目录
日期:2026-09-26 区域:本地 ASR 加载
症状:把 HF repo id 换成本地绝对路径(一个目录)之后,加载报的错看着像路径写错了, 其实完全不是那个意思:
HFValidationError: Repo id must be in the form 'repo_name' or 'namespace/repo_name':
'/home/.../models/pyannote/segmentation-3.0'. Use `repo_type` argument if needed.
根因:pyannote 判「这是本地路径还是 HF repo id」只看它是不是一个文件:
core/model.py:588——if os.path.isfile(checkpoint): path_for_pl = checkpointcore/pipeline.py:78——if os.path.isfile(checkpoint_path): config_yml = checkpoint_path
目录两条都不成立,于是落到 else 分支去当 repo id 校验,报出上面那句 「repo id 格式不对」——它压根没打算读目录。
修法:给到文件。管线给 config.yaml,子模型给 pytorch_model.bin:
Pipeline.from_pretrained(str(DIA_DIR / "config.yaml")) # 管线:config 文件
Model.from_pretrained(checkpoint=str(SEG_DIR / "pytorch_model.bin")) # 子模型:权重文件
config.yaml 里那两项也得跟着变成 dict 形式(getter.py:81 走
Model.from_pretrained(**dict) 那一支):
segmentation:
checkpoint: <dir>/pytorch_model.bin
而且不能给 hparams_file —— 权重里自带 pytorch-lightning 的 hparams,
repo 里那份 config.yaml 是模型结构配置、没有 task: 段,塞进去只会换来
ConfigAttributeError: Missing key setup / full_key: task.setup。
怎么早点发现:路径参数报「格式不对」,先去看那个函数的判定条件是什么,
别顺着报错字面去改路径写法(我第一反应是路径要加引号/要相对路径,全错)。
另外:Pipeline.from_pretrained 有两个同名参数路径,管线和模型各判各的文件类型,
改了一处不代表另一处也通了 —— 这次就是改完管线还在模型那处炸。
E08 torch≥2.6 的 weights_only 默认值翻了个面
日期:2026-09-26 区域:本地 ASR 加载
症状:路径问题解决后,加载 pyannote 权重时换了一个完全不同的错:
_pickle.UnpicklingError: Weights only load failed. ...
(1) In PyTorch 2.6, we changed the default value of the `weights_only` argument
in `torch.load` from `False` to `True`. ...
WeightsUnpickler error: Unsupported global: GLOBAL torch.torch_version.TorchVersion
was not an allowed global by default.
根因:torch 2.6 起 torch.load 的 weights_only 默认由 False 翻成 True,
pickle 里没在白名单上的全局符号一律拒载。pyannote 3.4 的 .bin 里存着 4 个数据类,
逐个不在默认白名单里 —— 而且它是一次只报一个,放行一个再报下一个。
修法:不要图省事用 weights_only=False(那等于把反序列化变成任意代码执行),
而是把这 4 个数据类逐个放行:
torch.serialization.add_safe_globals([
torch.torch_version.TorchVersion,
pyannote.audio.core.task.Specifications,
pyannote.audio.core.task.Problem,
pyannote.audio.core.task.Resolution,
])
放行的全是数据类、没有可调用对象;权重是我们自己下的、过了 SHA256SUMS, 不存在「不可信来源」的问题。
怎么早点发现:这 4 个是实测枚举出来的(捕获异常 → 正则抠出 GLOBAL x.y.Z →
import 回来 → 再放行 → 重试,直到加载成功),不是猜的。下次遇到同类拒载,
可以用同一招把清单跑出来,别一个个手试。
遗留疑问:这 4 个是这一次加载路径上遇到的;换模型/换 pyannote 版本可能还要加。
所以 selfcheck.sh 里那条「模型文件在位」之外,首次加载必须真跑一次 diarize ——
只 import 不加载是查不出来的(import 一路都是好的)。
E09 一句「提交一下」,我做成了一次依赖锥测量
日期:2026-09-26 区域:工作方式(我自己犯的错)
症状:用户说「提交一下」(三个字)。我的做法是:
- 通读
git status,107 个条目,逐条判断哪些属于本次 ASR 工作、哪些属于并行工作流; - 为了把「本次改动」单独拎出来,写了个 hunk 级挑选工具
/tmp/pickhunks.py(git diff切 hunk → 按序号挑选 → 输出可git apply --cached的补丁); - 把索引导出到临时目录,跑
go build ./...自证「这一包能编译」; - 编译失败 → 补 stage 几个文件 → 再导出 → 再编译 …… 循环了 5 轮;
- 第 5 轮撞到
package eai_agentplatform/backend/internal/search is not in std, 发现锥已经伸到 playwright 和go.mod了; - 于是把索引清空,弹了个选择框问用户「这次提交装哪些文件」。
用户驳回,并说:「不做任何区分了,直接一包提交,这种事情以后不要干扰工作节奏, 现在只是在 dev 的早期」。
根因:我把 G14 的「dev 阶段 git 是后台事务,不是讨论焦点」读成了 「提交粒度值得花成本」——恰恰读反了。G14 的原意是 git 不该占戏份, 而我花了整个回合的注意力在 git 上,最后还把决定权推回给用户。
顺带暴露一个事实判断错误:我以为「ASR 改动」是一个可分离的集合。它不是。
这次实测出来的依赖锥(留着有用,不是猜测,是逐轮编译失败逼出来的):
internal/ai/llm.go 的 ctx 签名变更
→ 12 个调用点必须同时改
→ chat_message.go 的 ctx 改动与「编排重写 callChatModel」**处在同一个 hunk 里**(无法干净拆分)
→ internal/ai/agent.go、web_search_tool.go、general_assistant/orchestrate.go、persistence.go
→ internal/search/(playwright)→ go.mod / go.sum
结论:ASR / LLM 调用层 / 编排 Agent / 联网搜索 在编译上是同一个单元 (只有网盘是可分离的)。任何「按工作流拆分」的方案都会产出编不过的中间态。
修法:git add -A 一包提交,提交正文里逐条列出混合了哪些并行线。
已写进 TOP_CODING_RULES.md G14.5 的例外条款(V1.5)。
怎么早点发现:一条比「阶段判断」更好操作的判据 ——
如果为了让这次提交能编译,你得先测量一遍依赖锥,那这些改动本来就是一个单元,不要拆。
实操上的三个信号,出现任一个就该直接 git add -A 收工:
- 开始写脚本/工具来辅助这次提交(为一次提交造工具,成本已经超过收益);
- 索引导出后编译失败,且补文件补到第 3 个还没收敛;
- 发现自己准备问用户「这次提交装哪些文件」 —— 用户要的是提交完成,不是参与分类。
边界(别过度矫正):这不是「以后永远一包提交」。进入交付 / 需要回滚定位 / 多人协作 任一场景,就恢复按事拆分;而且粒度可以放宽,如实披露不能放宽 —— 混合提交必须在正文里写明混了什么。
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):
- 新增
_free_pipe(),与_free_fw()对称:pop 出_DIA_CACHE["p"]→gc.collect()→torch.cuda.empty_cache()。 - 把互斥放进加载器自己,而不是留给调用方记:
_fw_model()在阶梯循环之前调_free_pipe();_pipeline()在加载之前调_free_fw()。
- 删掉
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 那条路径。
根因:两件事同时让我误判,而且它们都会让验证看起来通过:
asr_core.py按内容哈希缓存各阶段结果。同样的输入第二次不重算 —— 这是我要的功能,但它让「重复同一动作」变成了无效验证。- 我用的测试载荷本身走不到出问题的那条路径。当然后端自带的 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)
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」):要在忙窗口里探。
这次的证据链是三样东西对齐时间戳:
- 服务日志给出忙窗口:
23:26:43 开始说话人分离 … 23:28:13 合并完成, 紧接着23:28:13 → 23:29:23是第二段的转写窗口; - 探测结果打时间戳:5 次采样落在
23:28:11 / 23:28:41 / 23:29:01 / 23:29:22 / 23:29:45—— 全部在忙窗口内,全部healthy=true,延迟 1–2 毫秒; - 修之前同样的探活在忙窗口里是
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'
手工起原型时同样的代码、同样的音频,一切正常。
根因:两件事在服务化时同时发生,原型上一个都不成立:
- unit 里
ProtectHome=true,/home整个不可访问; - 服务账号是
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)。
是否升格为
TOP_CODING_RULES.md的一条(放在 G19 依赖那节,或新开一条「依赖装完必须自检」)待定。 - E08 的通用形状:
torch.load(weights_only=True)是 2.6 之后的默认行为, 凡是加载 2.6 之前产出的.bin/.ckpt都会撞上,且与具体项目无关 —— 候选升格为 P06 的一条(「旧权重在新 torch 上要先放行 unpickle 白名单」)。 - E06 的通用形状:GPU 是共享资源,跑之前先
nvidia-smi看别人占了多少, 再决定自己的精度档位 —— 候选升格为 P06 的一条。 - E12/E13 的通用形状:「手工跑得通」不等于「装成服务跑得通」 —— 服务化会同时改掉三件事:运行账号(家目录、权限)、资源隔离(ProtectHome / ProtectSystem / ReadOnlyPaths)、并发模型(前台独占 vs 后台多请求)。 凡是「原型上验过」的东西,装成服务后必须照着这三条重验一遍。 这次的 E13 是账号那条,E12 是并发那条。