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>
733 lines
38 KiB
Markdown
733 lines
38 KiB
Markdown
# 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 的一条。
|