# 实时会议识别改造报告
## 1. 背景
当前 `Qwen-Asr` 的实时会议链路,已经暴露出 3 类系统性问题:
1. 长句在 `max_duration` 或静音点附近容易被截坏,出现残句、半句、尾词漂移。
2. `partial` 与 `final segment` 责任混杂,静音、串音、背景音、测试语种会直接污染最终结果。
3. 说话人识别仍以“句级单 embedding + 会话聚类”为主,面对插话、背景音、儿童声音、英语/泰语测试时容易抖动。
这些问题并不是单个阈值导致的,而是实时链路的职责分层不清晰。
## 2. 外部方案调研结论
本次调研覆盖了:
- vLLM / Qwen3-ASR 官方实时文档
- `diart`
- `pyannote.audio`
- NVIDIA NeMo `Streaming Sortformer`
- 在线 speaker diarization / label matching 论文
- streaming ASR partial stability / endpointing 论文
### 2.1 vLLM/Qwen3-ASR 的边界
vLLM 的 `Qwen3-ASR realtime` 更像“流式推理后端”,它负责:
- 累积音频
- 到固定块长后执行流式推理
- 提供 `flush/finish`
它不负责:
- 会议场景 endpointing
- speaker diarization
- partial 稳定化
- 说话人标签稳定跟踪
结论:
`vLLM 只能做实时 ASR 的第一层,不是完整会议链路的解决方案。`
### 2.2 开源实时 speaker diarization 的主流范式
#### A. `diart` 范式
特征:
- rolling buffer
- overlap-aware segmentation
- incremental clustering
- cannot-link constraints
- 低延迟滚动更新
优点:
- 工程上相对容易接入
- 很适合作为现有系统的 speaker 旁路
#### B. `Streaming Sortformer` 范式
特征:
- 真正 streaming diarization
- chunk-based processing
- speaker cache / AOSC
- 跨 chunk 标签稳定
优点:
- 更像现代工业方案
- 标签稳定性更强
缺点:
- 接入成本高于 `diart`
### 2.3 论文结论
#### 在线 diarization 的关键问题不是“分离”,而是“标签一致性”
主流论文强调:
- 在线 diarization 必须解决 label matching
- 否则 `Speaker01/02/03` 会在不同 chunk 间乱跳
#### streaming ASR 的关键问题不是“能不能出字”,而是“partial 稳定性”
论文结论:
- partial 会被不断修正
- partial 不应直接当 final 使用
- 需要单独设计稳定策略
#### endpointing 对长句质量至关重要
论文与开源实现都说明:
- 只靠静音阈值不够
- 最好使用 VAD/SAD 或模型辅助 endpointing
- `max_duration` 只能是兜底机制,不应成为主要切句方式
## 3. 当前项目存在的核心架构问题
### 3.1 `partial` 和 `final` 混线
当前链路中:
- `partial` 的输出直接影响最终句子定稿
- 静音前后、噪声和串音有机会直接污染最终句子
这会带来:
- 静音幻觉
- 末尾漂移
- 错误残句
### 3.2 断句主要靠“能量阈值 + 最长时长”
当前主逻辑仍然偏向:
- `self._has_voice(audio)` 做粗能量判定
- `silence_samples` 达阈值就截断
- `12s max_duration` 强行保底
这会导致:
- 长句被硬切
- 断句点不自然
- 下一段拿到的是残尾巴
### 3.3 speaker 仍然是“句级单 embedding”
当前 speaker 的主要依据是:
- 当前句或其裁剪后的音频
- 提一个 embedding
- 与会话内 slot 聚类
这对干净单人句子有效,但对会议场景不够:
- 一句里可能混多人
- 背景音会污染 embedding
- 不同 chunk 之间缺少真正的 diarization cache
### 3.4 registry match 介入过早
当前一旦句级聚类通过阈值,就可能直接映射实名。
这会带来:
- 错误聚类被直接升级为错实名
- 后续纠正空间变小
## 4. 推荐的目标架构
建议将实时会议链路拆成 4 层:
### Layer A: Streaming ASR Partial
职责:
- 面向 UI 输出中间文本
- 只作为“参考内容”
- 不入库
- 不参与 speaker
- 不做实名映射
要求:
- 可以允许回退、修正
- 需要稳定策略,但不要求和 final 完全一致
### Layer B: Endpointing / Final Segment Flush
职责:
- 决定“什么时候一句真正结束”
- 产出 final segment
推荐方式:
- `pending_buffer`
- 周期性 VAD/SAD 检查
- 只刷出“已完成”的语音片段
- 最后一段继续留 buffer,等待更多上下文
说明:
- `max_duration` 仍保留
- 但只作为异常保底
### Layer C: Online Speaker Tracking
职责:
- 产出稳定 `Speaker01/02/03/...`
- 不直接输出实名
推荐路线:
- 低成本版:`rolling window diarization + incremental clustering`
- 强化版:`streaming diarization + speaker cache`
必要能力:
- label matching
- speaker cache
- cluster centroid 更新
- recent speaker continuity
### Layer D: Speaker Registry Match
职责:
- 把稳定的 slot 映射成实名
原则:
- 先有稳定 `Speaker01/02/03`
- 再做 registry match
- 不要在每个短句上直接实名匹配
## 5. 对当前代码库的具体落地建议
### 5.1 保留 `Qwen3ASREngine` 作为流式 ASR 后端
文件:
- `app/services/asr/qwen3_engine.py`
建议:
- 继续让它只负责 `init_streaming_state / streaming_transcribe / finish_streaming_transcribe`
- 不再让它承担句边界和 speaker 逻辑
### 5.2 重构 `qwen3_websocket_asr.py`
文件:
- `app/services/qwen3_websocket_asr.py`
建议把它拆成以下内部组件:
1. `PartialStreamController`
- 负责把有声 chunk 喂给流式 ASR
- 维护 partial 状态
2. `RealtimeEndpointController`
- 维护 `pending_buffer`
- 周期性 VAD flush
- 产出 finalized audio span
3. `RealtimeSpeakerController`
- 对 finalized span 做 speaker tracking
- 维护 `Speaker01/02/...`
4. `RegistryMatchController`
- 在 slot 稳定后映射实名
### 5.3 speaker tracker 升级方向
当前文件:
- `app/services/realtime_speaker_tracker.py`
建议保留它,但升级为:
- slot centroid
- slot history
- recent speaker cache
- explicit label matching step
- “未知 slot” 与 “实名 slot” 分层
不建议继续只用:
- `assign(embedding)` 的一次性聚类结果
### 5.4 引入真正的 VAD completed-segment flush
当前项目最应该借鉴老项目的部分就是:
- `pending_buffer`
- `flush_completed_segments`
- 只把已完成语音刷成 final
建议新建模块,例如:
- `app/services/realtime_endpointing.py`
它负责:
- 累积 PCM
- 每隔固定步长跑 VAD
- 判断哪些片段已完成
- 返回 `finalized_audio` 与 `remaining_audio`
### 5.5 `max_duration` 的正确角色
建议保留,但只作为:
- buffer 过长
- 长时间不出句
- VAD 失效
时的保底。
不建议让它继续承担:
- 常规切句
- 主要 final 机制
## 6. 推荐改造路线
### 阶段 1:先把文本链路拆干净
目标:
- `partial` 只显示
- `final segment` 只由 endpointing 决定
工作项:
- 引入 `pending_buffer + VAD flush`
- 保留现有 websocket 接口字段不变
- `max_duration` 降级为兜底机制
### 阶段 2:speaker 从句级 embedding 升级为在线 tracking
目标:
- 稳定 `Speaker01/02/03`
工作项:
- 引入 rolling window diarization 或 speaker turn 检测
- 增加 label matching
- 增加 speaker cache
### 阶段 3:实名映射后移
目标:
- 先稳定 slot
- 再实名
工作项:
- registry match 不再逐句触发
- 改成基于 slot centroid 或稳定窗口触发
### 阶段 4:partial 稳定策略
目标:
- UI 不再频繁抖动
工作项:
- prefix commit
- suffix freeze
- partial stability score
## 7. 不建议继续做的事
以下方向收益很低,且会继续增加系统复杂度:
- 继续堆更多 `if suspicious`
- 继续调 `speaker_threshold`
- 继续靠 `max_duration` 修长句
- 继续用句级单 embedding 解决多人会议 speaker
- 继续让 `partial` 直接影响 final
## 8. 结论
当前项目最需要的不是继续补条件分支,而是把实时会议能力拆成明确的四层:
1. `streaming partial`
2. `endpointing/final flush`
3. `online diarization / speaker tracking`
4. `registry match`
其中:
- `vLLM` 属于第 1 层
- 不是第 2、3、4 层的替代品
如果后续继续在当前单文件链路上修修补补,复杂度会继续升高,稳定性仍然不可控。
如果按本报告做分层重构,问题会从“靠猜修 bug”变成“按职责逐层验证”。
## 9. 参考资料
- vLLM realtime Qwen3-ASR model docs
- vLLM Qwen3-ASR recipe
- diart
- pyannote.audio
- NVIDIA NeMo speaker diarization docs
- NVIDIA Streaming Sortformer blog
- Low-Latency Online Speaker Diarization with Graph-Based Label Generation
- TURN-TO-DIARIZE: Online Speaker Diarization Constrained by Transformer Transducer Speaker Turn Detection
- Analyzing the Quality and Stability of a Streaming End-to-End On-Device Speech Recognizer
- Improving endpoint detection in end-to-end streaming ASR for conversational speech