Files
eaiadminandClaude Code e9c0c4007c fix(task): 删任务级联清掉 run 与产物,不再留孤儿逐字稿
DELETE /api/my/tasks/:id 原先只删 task_record。实测:删前 1/7/6,
删后 0/7/6 —— 运行记录与产物原样留着。任务列表里再也看不到,
也没有任何接口能按 task_id 找回,等于永久留在库里。而产物正文常常是
完整逐字稿(一次 26 分钟会议的录音内容),于是「删掉任务」并不等于
「删掉录音内容」;交付前按 DELIVERY.md 手工清单 C 项清测试数据时,
会留下一批谁也删不掉的逐字稿。

补上 TaskRunDAO.DeleteByTask + TaskArtifactDAO.DeleteByTask —— 两个 DAO
早就有了,weixin_public_account 的工作流重置一直在配对使用,缺的只是
这里没调。先子后父:中途失败任务还在,重试一次就干净;反过来先删父
再失败,子记录就再没人能找到了。task_id 外键只有这两张表,即完整级联。

验证(internal/api/my_task_delete_test.go,真实路由 + 真实鉴权中间件):
先 stash 掉 my_task.go 跑,孤儿断言如期红;改回全绿。另有越权用例钉住
「404 且一条都不许少」,以及旁观任务证明不误伤。条数用 3/2 而非 1,
计数走模型而非表名字符串(拼错表名 Count 为 0,而 0 正是期望值)。
详见 bugs_and_errors.md E15。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:16:39 +08:00

733 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```python
>>> [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 里发生的事。
两个放大伤害的细节:
1. `io.py` 没有 `from __future__ import annotations`,所以第 60 行的**注解在 import 时求值** ——
不是用到才炸,是 `import pyannote.audio` 这一行就炸。
2. 这决定了 **任何 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 那一代的组合:
```bash
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 一遍,别等跑数据。**
```bash
./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 的下载进度要看**临时目录体积的增长**:
```bash
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` 可在显存宽裕时调回):
```python
_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 = checkpoint`
- `core/pipeline.py:78` —— `if os.path.isfile(checkpoint_path): config_yml = checkpoint_path`
目录两条都不成立,于是落到 else 分支去当 repo id 校验,报出上面那句
「repo id 格式不对」——**它压根没打算读目录**。
**修法**:给到**文件**。管线给 `config.yaml`,子模型给 `pytorch_model.bin`:
```python
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)` 那一支):
```yaml
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 个**数据类**逐个放行:
```python
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 **区域**:工作方式(**我自己犯的错**)
**症状**:用户说「提交一下」(三个字)。我的做法是:
1. 通读 `git status`,107 个条目,逐条判断哪些属于本次 ASR 工作、哪些属于并行工作流;
2. 为了把「本次改动」单独拎出来,写了个 hunk 级挑选工具 `/tmp/pickhunks.py`
(`git diff` 切 hunk → 按序号挑选 → 输出可 `git apply --cached` 的补丁);
3. 把索引导出到临时目录,跑 `go build ./...` 自证「这一包能编译」;
4. 编译失败 → 补 stage 几个文件 → 再导出 → 再编译 …… **循环了 5 轮**;
5. 第 5 轮撞到 `package eai_agentplatform/backend/internal/search is not in std`,
发现锥已经伸到 playwright 和 `go.mod` 了;
6. 于是把索引清空,**弹了个选择框问用户「这次提交装哪些文件」**。
用户驳回,并说:「不做任何区分了,直接一包提交,这种事情以后不要干扰工作节奏,
现在只是在 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`):
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` 是同一个形状 —— 主服务现在
不写 `~`,但哪天有依赖要写,会以完全一样的姿势炸。
---
## E14 「卡在第 3 步」——三步说的不是同一步
**日期**:2026-09-26 **区域**:语音转写技能 / 失败恢复
**症状**:用户报「看起来在步骤 3 卡住了,没有完成工作」。界面实况:右栏
「语音转写 · 技能工作流 **4 / 6** 完成」,第 1–4 步全绿,第 5 步「整理段落与重点」
与第 6 步「提炼可交付纪要」都是「未开始」;聊天区最后一条停在
「已记下你确认的说话人身份,正在继续整理段落与生成纪要...」。
**根因**:**用户说的第 3 步、右栏的第 5 步、报错文案里的第 1 步,是三个不同的编号。**
- 真正断掉的是 `audio-transcribe:structure`(右栏第 5 步)。请求发出后 **46ms**
浏览器↔dev server 的连接断开,Gin 的 `c.Request.Context()` 随之取消,上游 LMUAI
回 499 `context canceled`,后端包成 400。
- **不是服务端取消**:全仓 grep `WithCancel|WithTimeout|cancel()` 在 `internal/ai`、
`internal/skills/api`、`internal/middleware`、`audio_transcribe` 中**零命中**;
前端全 `src/` grep `AbortController|CancelToken|signal` 同样零命中。
日志里也没有 401(排除跳登录页杀请求),全文只此 1 次 `context canceled`。
结论:一次客户端断连,不是模型/路由/代码的问题。
- 但**失败文案把它叫「第 1 步」** —— 尾部两步按**自己这一轮**从 1 数,
而右栏按整条链数。用户看到的第一个数字就对不上,于是有了「卡在步骤 3」这个说法。
- 更麻烦的是:断连之后**界面上没有任何续跑入口**。右栏那六个步骤是只读的,
没有 `@click`,任务永远停在 4/6。
**修法**(三处,都在前端):
1. `audioSkill.js` 导出 `STEP_NUMBERS`,报错文案与右栏共用同一份编号 —— 两处各写一遍
就是这次对不上号的成因。
2. 失败的那条助手消息上挂 `🔁 重试`(`SmartAssistantPage.vue`)。复用 `speakerConfirmBusy`
做并发闸,不新造一套。
3. `resumeAudioSkill` 收 `skipSteps`,由页面按 `task_run.action_key` 算出「哪几步跑过了」。
**判据必须是 run 而不是产物类型**:产物类型(`document` / `checklist`)别的技能也在用,
拿它判断会把别人的产物误认成自己的。
4. 待确认那条消息要用 `reactive()` 包。`messages` 是 `ref([])`,push 进去的对象在模板里
读时才被代理,而代码改的是**那个变量** —— 改普通对象只改了原始值,不触发重渲染,
用户会一直看到「正在继续整理...」。
**验证**(临时任务 100,用完即删):
- 用 CDP `Network.setBlockedURLs` 掐掉 `*/api/skills/audio/structure`,复现与线上同形的
断连 → 消息变失败态并挂出重试按钮。截图 `/tmp/shot_retry_a_failed.png`:红字
「第 5 步「整理段落与重点」失败:Network Error」,与右栏那个 5 是同一个。
- 脚本直接对后端补跑一次 `structure`(模拟「后端跑完了,只是响应没回来」)→ 点重试
→ 只跑 `minutes`,跑完 6/6(`/tmp/shot_retry_b_done.png`)。
- 落库核对:`document` 1 份、`checklist` 1 份,没有重复。
- **反证**(先证明断言会失败):再手工 POST 一次 `structure`,同名「结构化纪要」立刻变成
**2 份** —— 这就是 `skipSteps` 缺席时会发生的事。
**怎么早点发现**:
> **同一步在界面上有两个编号,迟早有一天会被当成两步。** 序号这种东西只有一份来源,
> 谁要显示都从那里取。
>
> 而**失败现场必须自带出路**。把「怎么继续」放在一个用户当前看不到、也点不动的地方
> (右栏只读列表),等于没放 —— 用户会做的是刷新,而刷新之后连失败的那条消息都没了。
---
## E15 删除任务只删了 `task_record`,run 与 artifact 全成孤儿
**日期**:2026-09-26 **区域**:后端(`internal/api/my_task.go`)
**症状**:清理验证夹具时走真实接口 `DELETE /api/my/tasks/100`,返回 200。
删前 `task_record` 1 条、`task_run` 7 条、`task_artifact` 6 条;删后
**0 / 7 / 6** —— 只有主记录没了,run 与产物原样留着。
**根因**:`DeleteMyTask`(`my_task.go:46`)只调 `taskRecordDAO.Delete(&task)`,没有级联。
**影响**:产物的 `content_text` 是**完整逐字稿**(真实会议录音,本次夹具那份 1550 字,
线上任务是 26278 字)。也就是说「删掉任务」并不等于「删掉录音内容」。
`DELIVERY.md` 第 3 节手工清单 C 项「清空测试数据」若按这个删法走,
库里会留下一批**没有任何入口能看到、也没人能删**的孤儿逐字稿。
**修法**:`DeleteMyTask` 补上 `TaskRunDAO.DeleteByTask` + `TaskArtifactDAO.DeleteByTask`
(两个 DAO 早就有了,`weixin_public_account` 的工作流重置一直在配对使用 —— 缺的只是
这里没调)。**顺序先子后父**:中途失败时任务还在,重试一次就干净;反过来先删父再失败,
那些子记录就再也没人能按 task_id 找到了。响应里回带清掉的条数。
`task_id` 外键只有 `task_run` 与 `task_artifact` 两张表(grep `internal/model` 确认),
所以这两步就是完整的级联。
**验证**:新增 `internal/api/my_task_delete_test.go`,走**真实路由 + 真实鉴权中间件**。
- **先证明会红**:把 `my_task.go` stash 掉再跑,孤儿断言如期失败
(「task_run 还剩 3 条孤儿」「task_artifact 还剩 2 条孤儿」)。
- 改回后全绿;另有一条越权用例钉住「返回 404 且**一条都不许少**」——
若哪天有人在权限判断之前就把级联删了,状态码照样 404,孤儿却已经产生。
- 条数故意用 3 / 2 而不是 1,免得「删了一条就以为删干净了」蒙混过关;另铺一条
「旁观任务」证明级联不会误伤别的任务。
- 计数走模型而不是表名字符串:表名是各模型 `TableName()` 写死的单数,拼错字符串
`Count` 出来是 0 —— 而 0 正是这里的期望值,断言会**对着一个不存在的表**全绿。
---
## 待沉淀(还没写进规则的)
- [ ] **装完必须导入自检**:带 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 是并发那条。
- [ ] **E14/E15 的通用形状**:**多步流程的「第 N 步」只能有一个编号来源**(E14);
**有从属数据的聚合根,删除必须级联,或者明确是软删**(E15)。
两条都属「界面/接口说了一件事,数据说另一件事」,候选合并成 P06 的一条。