15 KiB
实时 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 当前输出方式
每次内部提交都会:
- 创建一条
confirmed_segments记录; - 使用该记录的
index作为sentence_id; - 立即发送
sentence_type=1的sentences事件; - 把所有记录用换行连接成
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
↓
相似度、聚类中心和重聚类结果被污染
影响包括:
- 继承链持续延长,错误实名被不断续写;
- 历史 embedding 被重复计数,匹配置信度可能虚高;
cluster_records_with_ranges会把复制的 chunk 作为真实样本参与聚类;- 后续重聚类可能把错误身份回写到更多 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.6s片段不继承上一位实名;- 没有新鲜声纹证据时保持 pending;
- 特征提取异常时保持 pending;
- 不复制上一条记录的 embedding;
- 只有达到确认阈值的独立特征才能更新身份缓存;
- 同一
sentence_id的更新覆盖原片段; - 相邻且身份可信度一致的物理 segment 才合并为 display block;
- A→B→A 保留时间顺序,不把非相邻发言重新拼接到一起;
- 前端展示使用 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 接口或带原始音频的端到端录音继续验证。