test/REALTIME_ASR_TROUBLESHOOTIN...

311 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 实时 ASR 当前问题排查记录
更新时间:2026-09-07
排查范围:实时 ASR、原生流式 partial、说话人识别、声纹姓名匹配、WebSocket 输出和前端展示。
## 1. 当前结论
目前发现的问题分布在三个层次:
```text
识别层 原生 partial 默认关闭,非原生路径会反复重识别窗口
说话人层 短片段无条件继承上一位实名,并可能把复制的 embedding 写回记录池
输出层 内部物理切段直接作为前端展示单元,导致同一说话人被拆成多行
```
其中,“不同的人进入已经确定的说话人气泡”最明确的根因是说话人层的短段快路径:小于 1.6 秒的片段在提取新特征之前直接继承上一位已命名说话人。WebSocket 本身负责传递这些结果,但错误身份是在上游状态机中产生的。
“同一说话人被切成几行”主要是输出层问题。silence 和 max duration 可以结束一次内部识别片段,但不应直接决定前端展示换行。
## 2. 当前实时 WebSocket 链路
入口位于 [app/api/v1/websocket_asr.py](app/api/v1/websocket_asr.py)。`/ws/v1/asr` 和 `/ws/v1/asr/qwen` 最终都进入 [Qwen3ASRService.handle_connection](app/services/qwen3_websocket_asr.py#L2538)。
主流程如下:
```text
客户端 start
↓
初始化 ConnectionContext 和 streaming state
↓
接收二进制音频
↓
RMS/peak 判断是否有声音,维护 pre-roll 和当前 turn
↓
partial 解码并向前端发送 sentence_type=0
↓
silence / max_duration / sentence_limit / stop 触发内部提交
↓
最终重识别,创建 confirmed segment
↓
异步 speaker worker 处理说话人
↓
通过相同 sentence_id 发送 speaker update
↓
stop 时发送最终 sentences 和 end
```
图谱分析显示 `handle_connection` 是当前实时链路的关键汇聚点;说话人解析又连接到 `RealtimeSpeakerClusterer.resolve_segment_speaker`、声纹注册服务和历史记录重聚类。因此直接修改主服务的影响范围较大,本次验证优先放在独立 demo 中。
## 3. 问题一:内部切段直接变成前端展示行
### 3.1 当前切段触发器
| 触发器 | 当前条件 | 当前作用 | 对展示的实际影响 |
|---|---|---|---|
| `silence` | 连续静音默认 `800ms` | 主力结束当前 turn | 直接产生一个 confirmed segment |
| `max_duration` | 缓冲默认最多 `12s` | 防止单段无限增长 | 长讲话被硬切成多个 segment |
| `sentence_limit` | 段内达到默认 `8` 句 | 兜底限制 | 可能提前提交 |
| `complete_sentence` | 标点结尾、时长达到阈值且字数足够 | 长独讲兜底 | 正常短句通常到不了该条件 |
| `final` | 客户端发送 `stop` | 提交最后一段 | 输出最终结果 |
相关逻辑位于 [qwen3_websocket_asr.py](app/services/qwen3_websocket_asr.py#L2538)、[_should_commit_complete_sentence](app/services/qwen3_websocket_asr.py#L1100) 和 [_commit_retranscribe_turn](app/services/qwen3_websocket_asr.py#L2333)。
### 3.2 当前输出方式
每次内部提交都会:
1. 创建一条 `confirmed_segments` 记录;
2. 使用该记录的 `index` 作为 `sentence_id`;
3. 立即发送 `sentence_type=1` 的 `sentences` 事件;
4. 把所有记录用换行连接成 `full_text`。
代码中 `full_text` 使用 `"\\n".join(...)`,位置在 [_commit_retranscribe_turn](app/services/qwen3_websocket_asr.py#L2464) 和 [_stop](app/services/qwen3_websocket_asr.py#L2916)。因此这里的 `sentence_id` 实际上是“物理切段编号”,不一定是语义完整句子编号。
项目协议文档明确区分了 `sentence_type=0` 的 partial 和 `sentence_type=1` 的 final,但没有把“内部 segment”和“前端 display block”分开,见 [realtime_meeting_websocket.md](docs/realtime_meeting_websocket.md#L162)。
前端可以根据相同 `sentence_id` 更新原记录,但不会把不同 `sentence_id` 且说话人相同的记录合并。因此停顿、12 秒硬切和前端换行目前形成了直接关系。
另外,图谱显示 [_should_force_stable_segment](app/services/qwen3_websocket_asr.py#L2051) 当前没有调用方,它不能实际改变实时切段;`complete_sentence` 主要是长段兜底。
## 4. 问题二:原生流式 partial 与输出合并不是同一层
当前配置中 `REALTIME_STREAM_CHUNK_SEC` 默认约为 `1.2s`,`REALTIME_PARTIAL_WINDOW_SEC` 默认约为 `8s`,原生 partial 开关默认关闭,相关默认值在 [app/core/config.py](app/core/config.py#L103)。
非原生路径会维护窗口并调用 `_transcribe_audio_text` 重识别;原生路径则通过:
```text
create_stream → push_stream/feed_stream → finish_stream
```
对应 [qwen3_engine.py](app/services/asr/qwen3_engine.py#L570) 和 [qwen3_websocket_asr.py](app/services/qwen3_websocket_asr.py#L1162)。
原生 partial 影响的是:
- partial 首次出现的延迟;
- 每次增量识别的计算量;
- 流式状态维护方式;
- 文本回滚和未固定 token 的处理。
它不应决定:
- 是否因为停顿产生前端新行;
- 哪些物理 segment 合并成一个说话人气泡;
- 是否把某个 speaker 姓名写入展示结果。
所以两个优化需要同时做,但必须保持两个独立状态:ASR streaming state 和 display aggregation state。
## 5. 问题三:短音频无条件继承实名
### 5.1 已确认的代码路径
在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L27) 中,`_FAST_ATTACH_MAX_SEC = 1.6`。
`resolve_segment_speaker` 的入口逻辑是:
```text
duration_sec < 1.6s
且 speaker_records 非空
且上一条记录存在实名身份
↓
直接复制上一条记录的 speaker_id / speaker_name / user_id / registry_speaker_id
speaker_confidence = 0.0
speaker_strategy = "short_attach"
```
该分支在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L505),早于 `extract_chunk_embeddings`。因此短段没有经过新的声纹特征验证。
### 5.2 为什么实名会直接通过稳定性判断
[_is_stable_speaker_info](app/services/qwen3_websocket_asr.py#L613) 首先检查 `registry_speaker_id` 或 `user_id`。短段继承时这两个字段被完整复制,所以即使 `speaker_confidence=0.0`,仍会被判断为 stable。
随后 [_resolve_segment_speaker](app/services/qwen3_websocket_asr.py#L2156) 会把该身份写入最终 segment,并由 [_emit_speaker_update](app/services/qwen3_websocket_asr.py#L498) 通过相同 `sentence_id` 推送给客户端。
这解释了为什么匿名 `SpeakerNN` 的继承可能被拦截,而带 `user_id` 或注册声纹 ID 的实名继承可以直接进入前端。
### 5.3 为什么短段是高风险场景
“嗯”“对”“好的”“可以”等短插话通常不到 1.6 秒,恰好是多人会议中最容易发生换人的场景。当前快路径把上一位实名当作新片段身份,产生的结果就是:新说话人的短句进入上一位实名气泡。
## 6. 问题四:特征提取失败分支与实际路径
代码中确实存在 `embedding_attach` 分支:特征提取后如果 `current_chunks` 为空,则尝试继承上一位实名,位置在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L521)。
但当前 `extract_chunk_embeddings` 在没有 chunk 时会回退为整段单 chunk,因此正常情况下很难返回空列表;如果模型真正抛异常,异常会向上传递,最终由 [_resolve_and_emit_segment_speaker](app/services/qwen3_websocket_asr.py#L2220) 捕获,当前片段保持 pending。
准确结论是:
- 模型抛异常:当前片段通常保持 `speaker_id=-1`,不会走继承;
- chunk 为空:代码意图是继承实名,但该分支近乎不可达;
- 模型不抛异常但产生垃圾 embedding:仍需单独检查零向量、NaN 和相似度边界;
- 当前最确定、最直接的错误来源是 `<1.6s` 的 `short_attach`。
## 7. 问题五:继承 embedding 造成污染链
短段继承返回时还会复制上一条记录的 `_embedding`、`_chunk_embeddings` 和 `_chunks`。下游 [_record_segment_speaker](app/services/qwen3_websocket_asr.py#L644) 只要发现 `_embedding` 非空,就会把它作为正常 speaker record 保存。
污染链如下:
```text
上一位实名离场
↓
新人的短段直接复制实名和 embedding
↓
复制结果成为新的 last_record
↓
后续短段继续继承这条记录
↓
历史匹配池重复出现同一个 embedding
↓
相似度、聚类中心和重聚类结果被污染
```
影响包括:
1. 继承链持续延长,错误实名被不断续写;
2. 历史 embedding 被重复计数,匹配置信度可能虚高;
3. `cluster_records_with_ranges` 会把复制的 chunk 作为真实样本参与聚类;
4. 后续重聚类可能把错误身份回写到更多 segment。
## 8. 问题六:speaker update 是异步的
实时最终段先写入 `confirmed_segments`,speaker worker 再异步处理,处理完成后通过相同 `sentence_id` 发送更新,相关位置是 [_speaker_worker_loop](app/services/qwen3_websocket_asr.py#L442) 和 [_emit_speaker_update](app/services/qwen3_websocket_asr.py#L498)。
当前协议允许首次 final 没有姓名,后续再补姓名,这一点在 [realtime_meeting_websocket.md](docs/realtime_meeting_websocket.md#L240) 有说明。
这里有两个风险:
- 前端如果把每次事件当成追加消息,会出现重复行;
- 上游异步继承结果如果带着错误实名,前端的幂等更新会把错误身份稳定显示出来。
因此上层 WebSocket 需要维护 segment 状态表,按 `sentence_id` 覆盖更新,然后根据完整状态重新生成 display block 快照。
## 9. 需要同时实施的两层优化
### 9.1 识别层:原生流式 partial
目标是让同一轮语音持续使用一个 streaming state,通过增量音频推进识别,减少窗口重复重识别。
demo 应透传并记录:
- `enable_native_partial_stream`;
- partial 产生时间;
- 每次 partial 的文本长度和修订次数;
- 首次 partial 延迟;
- final 延迟;
- 原服务返回的 chunk 或 segment 时间范围。
### 9.2 说话人与输出层
目标是把“身份确认”和“展示合并”分开:
1. `<1.6s` 片段不继承上一位实名;
2. 没有新鲜声纹证据时保持 pending;
3. 特征提取异常时保持 pending;
4. 不复制上一条记录的 embedding;
5. 只有达到确认阈值的独立特征才能更新身份缓存;
6. 同一 `sentence_id` 的更新覆盖原片段;
7. 相邻且身份可信度一致的物理 segment 才合并为 display block;
8. A→B→A 保留时间顺序,不把非相邻发言重新拼接到一起;
9. 前端展示使用 display block,入库和诊断仍保留 raw segment。
推荐的状态关系是:
```text
raw segment
├─ ASR text state:partial / final
├─ speaker evidence:pending / fresh / confirmed
├─ speaker identity:cluster / registry / user
└─ display block:按时间和可信身份重新生成
```
## 10. 服务器接口边界
已验证服务器 `10.100.53.199:8000` 可访问,原实时 WebSocket 地址为:
```text
ws://10.100.53.199:8000/ws/v1/asr/qwen
```
当前公开接口包括:
- `/ws/v1/asr/qwen`:原实时 WebSocket,已经包含主服务内部的 ASR、切段和 speaker 逻辑;
- `/v1/audio/transcriptions`:整段音频转写;
- `/api/v1/speakers/identify`:通过文件来源识别注册说话人;
- `/api/v1/speakers`:声纹注册和人员管理。
当前公开接口没有返回实时聚类所需的原始 embedding,也没有把 [Qwen3ASREngine](app/services/asr/qwen3_engine.py#L570) 的 `create_stream`、`feed_stream`、`finish_stream` 暴露为独立远程模型 RPC。
所以存在一个边界:
- 上层 demo WebSocket 可以独立重写事件顺序、切段提交策略、pending 保护和展示合并;
- 如果要在 demo 中完整重建原项目的声纹聚类,服务器还需要提供 embedding 接口或模型 RPC;
- 只依赖当前 `/ws/v1/asr/qwen` 返回字段,无法重新计算已被原服务错误归类的长段身份。
## 11. 当前独立 demo 状态
独立验证项目位于 [realtime_asr_optimization_demo](realtime_asr_optimization_demo)。
当前结构:
```text
浏览器
↓ ws://127.0.0.1:8082/ws
demo 上层 WebSocket
↓ model_service.py 远程模型服务适配层
↓ ws://10.100.53.199:8000/ws/v1/asr/qwen
已部署模型服务
```
当前 demo 已具备:
- 原生 partial 参数透传;
- 同一 `sentence_id` 覆盖更新;
- raw event 与 display block 分离;
- 同一说话人的相邻片段合并;
- `<1.6s` 实名结果降级为 pending;
- 已命名身份不接受只有弱 cluster id 的片段加入;
- embedding 字段不进入 demo 状态池。
- 模型服务与 WebSocket 解耦;demo 不启动或加载模型;
- 模型服务地址可通过页面或 `--model-service-url` 配置。
对应实现见 [server.py](realtime_asr_optimization_demo/server.py)、[model_service.py](realtime_asr_optimization_demo/model_service.py)、[speaker_assembler.py](realtime_asr_optimization_demo/speaker_assembler.py) 和 [static/app.js](realtime_asr_optimization_demo/static/app.js)。
这个 demo 当前主要验证上层协议、状态覆盖和展示保护。后续把模型服务部署到新服务器时,只需替换模型服务地址;若新模型服务协议不同,则替换 `model_service.py`,不改变 WebSocket 编排层。若要验证“demo 自己完成声纹特征提取、聚类和按需姓名匹配”,仍需服务器提供远程 embedding/model RPC。
## 12. 建议验证用例
| 用例 | 关注结果 |
|---|---|
| 单人连续讲话,中间停顿 800ms 以上 | 前端仍属于同一个 display block |
| A 讲话后,B 说“嗯/好的” | B 的短段保持 pending,不进入 A 的实名块 |
| A→B→A | 保持三个时间顺序块 |
| 同一 `sentence_id` 先 partial 后 final | 文本覆盖,不重复追加 |
| final 先返回,speaker update 后返回 | 原 block 更新并重新归并 |
| 声纹模型异常 | 片段保持 pending,不继承上一位实名 |
| 连续多个短插话 | 不复制上一条 embedding,不形成继承链 |
| 关闭 native partial | 只影响识别延迟和资源,不改变展示合并规则 |
| 关闭 display merge | 可以看到原始物理 segment,用于对照 |
## 13. 当前未确认项
以下问题不能仅靠现有公开 WebSocket 字段确认:
- 原服务是否存在未写入 OpenAPI 的内部 embedding/model RPC;
- 实际使用的 realtime speaker 模型是否会输出零向量或 NaN;
- 不同真实说话人被分配相同稳定 cluster id 的比例;
- speaker worker 完成时间与 `end` 事件之间是否存在竞态;
- 远程 Docker 是否还映射了独立的模型后端端口。
这些项目需要通过服务器日志、embedding 接口或带原始音频的端到端录音继续验证。