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