311 lines
15 KiB
Markdown
311 lines
15 KiB
Markdown
# 实时 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 接口或带原始音频的端到端录音继续验证。
|