test/REALTIME_ASR_TROUBLESHOOTIN...

15 KiB
Raw Blame History

实时 ASR 当前问题排查记录

更新时间:2026-09-07
排查范围:实时 ASR、原生流式 partial、说话人识别、声纹姓名匹配、WebSocket 输出和前端展示。

1. 当前结论

目前发现的问题分布在三个层次:

识别层       原生 partial 默认关闭,非原生路径会反复重识别窗口
说话人层     短片段无条件继承上一位实名,并可能把复制的 embedding 写回记录池
输出层       内部物理切段直接作为前端展示单元,导致同一说话人被拆成多行

其中,“不同的人进入已经确定的说话人气泡”最明确的根因是说话人层的短段快路径:小于 1.6 秒的片段在提取新特征之前直接继承上一位已命名说话人。WebSocket 本身负责传递这些结果,但错误身份是在上游状态机中产生的。

“同一说话人被切成几行”主要是输出层问题。silence 和 max duration 可以结束一次内部识别片段,但不应直接决定前端展示换行。

2. 当前实时 WebSocket 链路

入口位于 app/api/v1/websocket_asr.py。/ws/v1/asr 和 /ws/v1/asr/qwen 最终都进入 Qwen3ASRService.handle_connection。

主流程如下:

客户端 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、_should_commit_complete_sentence 和 _commit_retranscribe_turn。

3.2 当前输出方式

每次内部提交都会:

  1. 创建一条 confirmed_segments 记录;
  2. 使用该记录的 index 作为 sentence_id;
  3. 立即发送 sentence_type=1 的 sentences 事件;
  4. 把所有记录用换行连接成 full_text。

代码中 full_text 使用 "\\n".join(...),位置在 _commit_retranscribe_turn 和 _stop。因此这里的 sentence_id 实际上是“物理切段编号”,不一定是语义完整句子编号。

项目协议文档明确区分了 sentence_type=0 的 partial 和 sentence_type=1 的 final,但没有把“内部 segment”和“前端 display block”分开,见 realtime_meeting_websocket.md。

前端可以根据相同 sentence_id 更新原记录,但不会把不同 sentence_id 且说话人相同的记录合并。因此停顿、12 秒硬切和前端换行目前形成了直接关系。

另外,图谱显示 _should_force_stable_segment 当前没有调用方,它不能实际改变实时切段;complete_sentence 主要是长段兜底。

4. 问题二:原生流式 partial 与输出合并不是同一层

当前配置中 REALTIME_STREAM_CHUNK_SEC 默认约为 1.2s,REALTIME_PARTIAL_WINDOW_SEC 默认约为 8s,原生 partial 开关默认关闭,相关默认值在 app/core/config.py。

非原生路径会维护窗口并调用 _transcribe_audio_text 重识别;原生路径则通过:

create_stream → push_stream/feed_stream → finish_stream

对应 qwen3_engine.py 和 qwen3_websocket_asr.py。

原生 partial 影响的是:

  • partial 首次出现的延迟;
  • 每次增量识别的计算量;
  • 流式状态维护方式;
  • 文本回滚和未固定 token 的处理。

它不应决定:

  • 是否因为停顿产生前端新行;
  • 哪些物理 segment 合并成一个说话人气泡;
  • 是否把某个 speaker 姓名写入展示结果。

所以两个优化需要同时做,但必须保持两个独立状态:ASR streaming state 和 display aggregation state。

5. 问题三:短音频无条件继承实名

5.1 已确认的代码路径

在 realtime_speaker_clusterer.py 中,_FAST_ATTACH_MAX_SEC = 1.6。

resolve_segment_speaker 的入口逻辑是:

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,早于 extract_chunk_embeddings。因此短段没有经过新的声纹特征验证。

5.2 为什么实名会直接通过稳定性判断

_is_stable_speaker_info 首先检查 registry_speaker_id 或 user_id。短段继承时这两个字段被完整复制,所以即使 speaker_confidence=0.0,仍会被判断为 stable。

随后 _resolve_segment_speaker 会把该身份写入最终 segment,并由 _emit_speaker_update 通过相同 sentence_id 推送给客户端。

这解释了为什么匿名 SpeakerNN 的继承可能被拦截,而带 user_id 或注册声纹 ID 的实名继承可以直接进入前端。

5.3 为什么短段是高风险场景

“嗯”“对”“好的”“可以”等短插话通常不到 1.6 秒,恰好是多人会议中最容易发生换人的场景。当前快路径把上一位实名当作新片段身份,产生的结果就是:新说话人的短句进入上一位实名气泡。

6. 问题四:特征提取失败分支与实际路径

代码中确实存在 embedding_attach 分支:特征提取后如果 current_chunks 为空,则尝试继承上一位实名,位置在 realtime_speaker_clusterer.py。

但当前 extract_chunk_embeddings 在没有 chunk 时会回退为整段单 chunk,因此正常情况下很难返回空列表;如果模型真正抛异常,异常会向上传递,最终由 _resolve_and_emit_segment_speaker 捕获,当前片段保持 pending。

准确结论是:

  • 模型抛异常:当前片段通常保持 speaker_id=-1,不会走继承;
  • chunk 为空:代码意图是继承实名,但该分支近乎不可达;
  • 模型不抛异常但产生垃圾 embedding:仍需单独检查零向量、NaN 和相似度边界;
  • 当前最确定、最直接的错误来源是 <1.6s 的 short_attach。

7. 问题五:继承 embedding 造成污染链

短段继承返回时还会复制上一条记录的 _embedding、_chunk_embeddings 和 _chunks。下游 _record_segment_speaker 只要发现 _embedding 非空,就会把它作为正常 speaker record 保存。

污染链如下:

上一位实名离场
  ↓
新人的短段直接复制实名和 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 和 _emit_speaker_update。

当前协议允许首次 final 没有姓名,后续再补姓名,这一点在 realtime_meeting_websocket.md 有说明。

这里有两个风险:

  • 前端如果把每次事件当成追加消息,会出现重复行;
  • 上游异步继承结果如果带着错误实名,前端的幂等更新会把错误身份稳定显示出来。

因此上层 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。

推荐的状态关系是:

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 地址为:

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 的 create_stream、feed_stream、finish_stream 暴露为独立远程模型 RPC。

所以存在一个边界:

  • 上层 demo WebSocket 可以独立重写事件顺序、切段提交策略、pending 保护和展示合并;
  • 如果要在 demo 中完整重建原项目的声纹聚类,服务器还需要提供 embedding 接口或模型 RPC;
  • 只依赖当前 /ws/v1/asr/qwen 返回字段,无法重新计算已被原服务错误归类的长段身份。

11. 当前独立 demo 状态

独立验证项目位于 realtime_asr_optimization_demo。

当前结构:

浏览器
  ↓ 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、model_service.py、speaker_assembler.py 和 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 接口或带原始音频的端到端录音继续验证。