删除多余文档

main
Bifang 2026-09-29 09:57:24 +08:00
parent df81b0d209
commit e4282a747c
37 changed files with 102 additions and 7072 deletions

View File

@ -62,7 +62,7 @@
# DEVICE:具体运行设备,常见值有 auto、cpu、cuda:0。
# DEVICE=auto
# QWEN3_ASR_MODEL:手动指定离线识别模型,留空则自动挑选。
# QWEN3_ASR_MODEL=
QWEN3_ASR_MODEL=qwen3-asr-0.6b
# -----------------------------------------------------------------------------
# 模型下载 / 缓存行为。

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ __pycache__/
*.py[cod]
*$py.class
*.so
crg-mcp-plugin
# Model files (downloaded)
*.pt

View File

@ -574,11 +574,10 @@ Automatic long audio segmentation:
| `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | Lightweight multilingual ASR; CUDA uses vLLM, CPU/macOS uses Rust backend | Offline/Realtime |
**Runtime selection:**
- **VRAM >= 32GB**: Select `qwen3-asr-1.7b`
- **VRAM < 32GB**: Select `qwen3-asr-0.6b`
- **Default**: Use `qwen3-asr-0.6b` for both offline and realtime ASR.
- **No CUDA**: Select the vendored Rust-backed `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: Always default to `qwen3-asr-0.6b`, regardless of memory size
- **Environment override**: Set `QWEN3_ASR_MODEL=qwen3-asr-1.7b` or `QWEN3_ASR_MODEL=qwen3-asr-0.6b` to bypass automatic selection
- **Environment override**: Set `QWEN3_ASR_MODEL=qwen3-asr-1.7b` to use 1.7B instead of the shared 0.6B default.
At startup the service checks the current runtime model plan and downloads missing models from ModelScope by default.
@ -594,7 +593,7 @@ Recommended public settings:
| `ASR_BATCH_SIZE` | `4` | ASR batch size for long-audio segment processing |
| `MAX_SEGMENT_SEC` | `60` | Max audio segment duration (seconds) |
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | Enable far-field sound filtering |
| `QWEN3_ASR_MODEL` | auto | Force `qwen3-asr-1.7b` or `qwen3-asr-0.6b` instead of VRAM-based selection |
| `QWEN3_ASR_MODEL` | `qwen3-asr-0.6b` | Select the shared offline/realtime model; set 1.7B to override |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | Upper bound for vLLM GPU memory reservation; lower it on shared GPUs, raise it when KV cache is too small |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | Force vLLM eager execution for compatibility; set `false` to allow CUDA Graph optimization on supported NVIDIA deployments |

View File

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

View File

@ -346,7 +346,7 @@ def _get_openai_model_description() -> str:
**兼容性说明:**
- 支持 OpenAI SDK 和第三方客户端调用
- 当前默认模型根据显存自动选择;也可通过 `QWEN3_ASR_MODEL` 覆盖
- 离线与实时 ASR 默认共用 Qwen3-ASR 0.6B;可通过 `QWEN3_ASR_MODEL` 切换到 1.7B
"""

View File

@ -200,7 +200,7 @@ class ASRHealthCheckResponse(HealthCheckResponse):
"device": "cuda:0",
"version": "1.0.0",
"message": "ASR service is running normally",
"loaded_models": ["qwen3-asr-1.7b"],
"loaded_models": ["qwen3-asr-0.6b"],
"memory_usage": {
"gpu_memory_used": "2.1GB",
"gpu_memory_total": "8.0GB",
@ -242,16 +242,16 @@ class ASRDeclaredEntryInfo(BaseModel):
model_config = {
"json_schema_extra": {
"example": {
"id": "qwen3-asr-1.7b",
"id": "qwen3-asr-0.6b",
"kind": "model",
"name": "Qwen3-ASR-1.7B",
"name": "Qwen3-ASR-0.6B",
"engine": "qwen3",
"description": "多语言离线语音识别模型",
"languages": ["zh", "en"],
"default": True,
"supports_realtime": True,
"offline_model": {
"path": "Qwen/Qwen3-ASR-1.7B",
"path": "Qwen/Qwen3-ASR-0.6B",
"exists": True,
},
"realtime_model": None,
@ -270,9 +270,9 @@ class ASRRuntimeInfo(BaseModel):
model_config = {
"json_schema_extra": {
"example": {
"loaded_model_ids": ["qwen3-asr-1.7b"],
"loaded_model_ids": ["qwen3-asr-0.6b"],
"loaded_count": 1,
"default_offline_model_id": "qwen3-asr-1.7b",
"default_offline_model_id": "qwen3-asr-0.6b",
}
}
}
@ -290,16 +290,16 @@ class ASRModelsResponse(BaseModel):
"example": {
"declared_entries": [
{
"id": "qwen3-asr-1.7b",
"id": "qwen3-asr-0.6b",
"kind": "model",
"name": "Qwen3-ASR-1.7B",
"name": "Qwen3-ASR-0.6B",
"engine": "qwen3",
"description": "多语言离线语音识别模型",
"languages": ["zh", "en"],
"default": True,
"supports_realtime": True,
"offline_model": {
"path": "Qwen/Qwen3-ASR-1.7B",
"path": "Qwen/Qwen3-ASR-0.6B",
"exists": True,
},
"realtime_model": None,
@ -307,9 +307,9 @@ class ASRModelsResponse(BaseModel):
],
"declared_count": 2,
"runtime": {
"loaded_model_ids": ["qwen3-asr-1.7b"],
"loaded_model_ids": ["qwen3-asr-0.6b"],
"loaded_count": 1,
"default_offline_model_id": "qwen3-asr-1.7b",
"default_offline_model_id": "qwen3-asr-0.6b",
},
}
}

View File

@ -46,10 +46,10 @@ def get_qwen_model_override() -> Optional[str]:
return normalized
def detect_qwen_model_by_vram(all_model_ids: Optional[list[str]] = None) -> Optional[str]:
"""Pick the active Qwen model for the current machine."""
def select_qwen_model(all_model_ids: Optional[list[str]] = None) -> Optional[str]:
"""选择离线与实时共用的 Qwen ASR 模型。"""
from app.core.accelerator import get_accelerator_info
from app.core.device import detect_device, get_vram_gb
from app.core.device import detect_device
from app.services.asr.qwenasr_rust import is_qwenasr_rust_available
model_ids = all_model_ids or load_supported_model_ids()
@ -67,19 +67,16 @@ def detect_qwen_model_by_vram(all_model_ids: Optional[list[str]] = None) -> Opti
if resolved_device == "cpu" or not accelerator.is_gpu:
return "qwen3-asr-0.6b" if is_qwenasr_rust_available() and "qwen3-asr-0.6b" in model_ids else None
vram = get_vram_gb()
preferred = "qwen3-asr-1.7b" if vram >= 32 else "qwen3-asr-0.6b"
if preferred in model_ids:
return preferred
fallback = "qwen3-asr-0.6b" if preferred == "qwen3-asr-1.7b" else "qwen3-asr-1.7b"
return fallback if fallback in model_ids else None
# 离线与实时默认共用轻量模型;需要 1.7B 时通过 QWEN3_ASR_MODEL 显式指定。
if "qwen3-asr-0.6b" in model_ids:
return "qwen3-asr-0.6b"
return "qwen3-asr-1.7b" if "qwen3-asr-1.7b" in model_ids else None
def get_active_qwen_model(all_model_ids: Optional[list[str]] = None) -> str:
"""Return the required Qwen model for the current machine."""
model_ids = all_model_ids or load_supported_model_ids()
qwen_model = detect_qwen_model_by_vram(model_ids)
qwen_model = select_qwen_model(model_ids)
if not qwen_model:
override_model = get_qwen_model_override()
if override_model:

View File

@ -22,7 +22,7 @@
"th",
"vi"
],
"default": true,
"default": false,
"supports_realtime": true,
"models": {
"offline": "Qwen/Qwen3-ASR-1.7B"
@ -55,7 +55,7 @@
"th",
"vi"
],
"default": false,
"default": true,
"supports_realtime": true,
"models": {
"offline": "Qwen/Qwen3-ASR-0.6B"

View File

@ -137,6 +137,7 @@ class Qwen3StreamingState:
max_new_tokens: int = 32
language: Optional[str] = None
chunk_count: int = 0
# 引擎统一向 WebSocket 层提供当前整句文本快照,屏蔽 vLLM 快照与 Rust 增量的差异。
last_text: str = ""
last_language: str = ""
@ -598,7 +599,12 @@ class Qwen3ASREngine(BaseASREngine):
last_language=language or "",
)
if self._backend == "vllm":
streaming_state = self.model.init_streaming_state(context=context, language=language, **kwargs)
# 流式 partial 与 final 共用离线转写相同的热词提示格式。
streaming_state = self.model.init_streaming_state(
context=self._build_hotword_prompt_context(context),
language=language,
**kwargs,
)
return Qwen3StreamingState(
internal_state=streaming_state,
chunk_size_sec=float(kwargs.get("chunk_size_sec", 1.2)),
@ -633,7 +639,9 @@ class Qwen3ASREngine(BaseASREngine):
language=state.language,
)
state.chunk_count += 1
state.last_text = text
# Rust 流式接口返回新增文本片段;引擎对上层统一提供整句快照。
if text:
state.last_text += text
state.last_language = state.language or ""
return state
@ -659,7 +667,9 @@ class Qwen3ASREngine(BaseASREngine):
max_new_tokens=state.max_new_tokens,
language=state.language,
)
state.last_text = text
# 收尾接口同样只返回本次刷出的尾部增量,接到既有快照后再交给 WebSocket。
if text:
state.last_text += text
state.last_language = state.language or ""
return state

View File

@ -1168,8 +1168,8 @@ class Qwen3ASRService:
configured = ctx.params.get("enable_native_partial_stream")
if configured is not None:
return bool(configured)
# 默认使用整段周期重转写;仅在客户端明确要求时启用底层 native partial stream。
return False
# 默认使用 Qwen3 原生流式推理;显式传 false 时仍保留旧式 partial 兼容路径。
return True
async def _init_realtime_stream_state(
self,
@ -1268,10 +1268,10 @@ class Qwen3ASRService:
getattr(ctx.realtime_stream_state, "last_language", ""),
ctx,
)
if stream_text.strip():
return stream_text, stream_language
# 流状态有效但当前还没有解码文本时,等待下一个流式块,避免反复整段重转写。
return stream_text, stream_language
# 流式文本为空时回退到当前音频窗口重转写。
# 流式初始化失败或客户端显式关闭原生流式时,再回退到窗口转写。
partial_audio = np.asarray(
ctx.stream_window_buffer if ctx.stream_window_buffer.size > 0 else ctx.segment_audio_buffer,
dtype=np.float32,
@ -1628,17 +1628,42 @@ class Qwen3ASRService:
ctx.segment_observed_language = language
return observed
def _replace_segment_observed_text(
self,
ctx: ConnectionContext,
text: str,
language: str,
) -> str:
"""用 Qwen 流式接口当前返回的整句快照更新 partial,允许修订句尾。"""
candidate = self._sanitize_candidate_text(text)
if not candidate:
return ctx.segment_observed_text
if self._is_unstable_expansion(ctx.segment_observed_text, candidate):
return ctx.segment_observed_text
ctx.segment_observed_text = candidate
if language:
ctx.segment_observed_language = language
return candidate
def _update_best_partial(
self,
ctx: ConnectionContext,
text: str,
language: str,
*,
replace_snapshot: bool = False,
) -> None:
candidate = self._sanitize_candidate_text(text)
if not candidate:
return
if self._is_unstable_expansion(ctx.best_partial_text, candidate):
return
if replace_snapshot:
# Qwen partial 是整句快照;修订句尾时应淘汰旧快照,避免旧文本被 final 选回。
ctx.best_partial_text = candidate
ctx.best_partial_language = language or ctx.best_partial_language
return
chosen = self._prefer_segment_text(ctx.best_partial_text, candidate)
if chosen != ctx.best_partial_text:
ctx.best_partial_text = chosen
@ -2356,18 +2381,27 @@ class Qwen3ASRService:
elif reason in {"max_duration", "long_speech"} and self._enable_realtime_vad_split(ctx):
finalized_audio, carry_audio = self._split_max_duration_audio(current_audio)
has_native_stream_snapshot = (
carry_audio.size == 0
and self._should_use_native_partial_stream(ctx, engine)
and ctx.realtime_stream_state is not None
)
if carry_audio.size == 0:
stream_text, stream_language = await self._finish_realtime_stream_text(ctx, engine)
if stream_text.strip():
observed_text = self._update_segment_observed_text(
ctx,
stream_text,
stream_language or self._infer_text_language(stream_text),
update_observed_text = (
self._replace_segment_observed_text
if has_native_stream_snapshot
else self._update_segment_observed_text
)
observed_text = update_observed_text(
ctx, stream_text, stream_language or self._infer_text_language(stream_text)
)
self._update_best_partial(
ctx,
observed_text,
stream_language or self._infer_text_language(observed_text),
replace_snapshot=has_native_stream_snapshot,
)
else:
ctx.realtime_stream_state = None
@ -2629,7 +2663,7 @@ class Qwen3ASRService:
),
"enable_native_partial_stream": payload.get(
"enable_native_partial_stream",
False,
True,
),
# 与离线会议接口保持一致:前端优先传 enable_speaker / match_speaker_registry。
"enable_speaker": payload.get("enable_speaker", True),
@ -2736,6 +2770,10 @@ class Qwen3ASRService:
continue
if self._should_decode_turn_partial(ctx):
has_native_stream_snapshot = (
self._should_use_native_partial_stream(ctx, engine)
and ctx.realtime_stream_state is not None
)
current, current_language = await self._decode_turn_partial_text(
engine,
ctx,
@ -2744,11 +2782,12 @@ class Qwen3ASRService:
current = self._sanitize_candidate_text(current)
if current and not self._is_degenerate_repetition(current):
current_language = current_language or self._infer_text_language(current)
observed = self._update_segment_observed_text(
ctx,
current,
current_language,
update_observed_text = (
self._replace_segment_observed_text
if has_native_stream_snapshot
else self._update_segment_observed_text
)
observed = update_observed_text(ctx, current, current_language)
visible_observed = self._trim_previous_segment_overlap(ctx, observed)
partial_display = self._clip_partial_text(visible_observed, ctx)
if (
@ -2762,7 +2801,12 @@ class Qwen3ASRService:
ctx.last_partial_text = visible_observed.strip()
ctx.last_partial_display_text = partial_display.strip()
ctx.last_partial_language = current_language
self._update_best_partial(ctx, visible_observed, current_language)
self._update_best_partial(
ctx,
visible_observed,
current_language,
replace_snapshot=has_native_stream_snapshot,
)
sentence_payload = self._build_tencent_sentence(
{

View File

@ -1,68 +0,0 @@
# crg-mcp — code-review-graph MCP 接入插件
把已部署在本机的 `D:\github-project\code-review-graph` MCP 服务接入 PI-Desktop,
让它的工具以原生 agent 工具的形式出现。**未修改该项目任何文件。**
## 接入原理
PI-Desktop 的 MCP 客户端由插件宿主承载:宿主读取插件 `manifest.json` 里的
`contributes.mcpServers`,自行拉起进程、完成 MCP 握手,并把上游每个工具发布为
`plugin_<插件id>_<server id>_<工具名>`。因此这里只需声明,无需自己写 JSON-RPC。
## 文件
| 文件 | 作用 |
| --- | --- |
| `manifest.json` | 声明 stdio MCP 服务 + `mcp.server.local` 权限 |
| `crg.cmd` | 启动包装脚本(修环境后 exec `python -m code_review_graph serve`) |
| `main.js` | 空加载器,客户端生命周期归宿主管理 |
| `dist/crg-mcp-1.0.0.piplug` | 可安装包 |
## 两个必须保留的环境修正
宿主的 `mcpProcessEnv()` 只向子进程传递
`PATH / SystemRoot / windir / TEMP / TMP / LANG`(加插件声明的 env),所以:
1. **`PYTHONPATH` 必须注入。** 项目的 editable 安装记录
`.venv\Lib\site-packages\_editable_impl_code_review_graph.pth` 指向
`D:\github_project\code-review-graph`(下划线),而项目实际位于
`D:\github-project\code-review-graph`(连字符),因此直接
`import code_review_graph` 会 `ModuleNotFoundError`。
同理 `.venv\Scripts\code-review-graph.exe` 也不能用
(`error: uv trampoline failed to canonicalize script path`)——包装脚本绕过了
这两点,改用 `python -m code_review_graph`。
2. **`USERPROFILE` / `HOMEDRIVE` / `HOMEPATH` 必须补齐。**
`code_review_graph/constants.py` 在 import 期调用 `Path.home()`,
精简环境下会抛 `RuntimeError: Could not determine home directory.`
## 暴露的工具(10 个)
默认通过 `CRG_TOOLS` 只开放审查相关的 10 个工具(上游共 30 个,全开会显著占上下文):
`build_or_update_graph_tool`、`run_postprocess_tool`、`get_minimal_context_tool`、
`get_review_context_tool`、`get_impact_radius_tool`、`query_graph_tool`、
`semantic_search_nodes_tool`、`detect_changes_tool`、`list_graph_stats_tool`、
`get_affected_flows_tool`
工具名前缀为 `plugin_crg_mcp_crg_`,例如 `plugin_crg_mcp_crg_query_graph_tool`。
### 调整暴露范围 / 目标仓库
编辑 `crg.cmd`:
- 改 `CRG_TOOLS=...`:改工具白名单(置空并删除该行 = 暴露全部 30 个)。
- 改 `CRG_REPO=...`:改被分析的仓库根目录(默认指向 code-review-graph 自身,
即已建好图的那个库)。
改完重启 PI-Desktop 生效。
## 为何用 stdio 而不是 HTTP
`serve --http`(127.0.0.1:5555/mcp)实测可用,但宿主只负责 spawn,不会托管一个
常驻服务;HTTP 需要外部进程守护,进程一掉工具就全空。stdio 由宿主拉起并在每次
调用时自动重连握手,更稳。
## 校验方式
装好后对 agent 说“用图谱统计一下仓库规模”,应命中
`plugin_crg_mcp_crg_list_graph_stats_tool` 并返回节点/边数量。

View File

@ -1,57 +0,0 @@
@echo off
chcp 65001 >nul
setlocal
rem ---------------------------------------------------------------------------
rem code-review-graph MCP launcher for PI-Desktop.
rem
rem The MCP host spawns this with a minimal environment (PATH, SystemRoot,
rem windir, TEMP, TMP, LANG plus the manifest's env block) and cwd = plugin
rem directory, with stdin/stdout used for JSON-RPC. Never write to stdout.
rem ---------------------------------------------------------------------------
if defined CRG_HOME goto have_home
set "CRG_HOME=D:\github-project\code-review-graph"
:have_home
rem code_review_graph/constants.py calls Path.home() at import time; without a
rem user profile the server dies with "Could not determine home directory.".
if defined USERPROFILE goto have_profile
set "USERPROFILE=C:\Users\%USERNAME%"
:have_profile
if defined HOMEDRIVE goto have_hd
set "HOMEDRIVE=C:"
:have_hd
if defined HOMEPATH goto have_hp
set "HOMEPATH=\Users\%USERNAME%"
:have_hp
if defined APPDATA set "APPDATA=%USERPROFILE%\AppData\Roaming"
if defined LOCALAPPDATA set "LOCALAPPDATA=%USERPROFILE%\AppData\Local"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
rem The editable install's .pth points at D:\github_project\... (underscore)
rem while the checkout lives at D:\github-project\... (hyphen), so the package
rem is only importable with the checkout explicitly on sys.path.
set "PYTHONPATH=%CRG_HOME%"
rem The venv's code-review-graph.exe is a broken uv trampoline
rem ("failed to canonicalize script path"), so prefer a working interpreter.
set "CRG_PY=%CRG_HOME%\.venv\Scripts\python.exe"
if exist "%CRG_PY%" goto py_ready
set "CRG_PY=python"
:py_ready
rem Upstream ships 30 tools; keep the surface small unless overridden.
if defined CRG_TOOLS goto tools_ready
set "CRG_TOOLS=build_or_update_graph_tool,run_postprocess_tool,get_minimal_context_tool,get_review_context_tool,get_impact_radius_tool,query_graph_tool,semantic_search_nodes_tool,detect_changes_tool,list_graph_stats_tool,get_affected_flows_tool"
:tools_ready
if defined CRG_REPO goto repo_ready
set "CRG_REPO=%CRG_HOME%"
:repo_ready
"%CRG_PY%" -m code_review_graph serve --repo "%CRG_REPO%"
endlocal

Binary file not shown.

View File

@ -1,18 +0,0 @@
/**
* crg-mcp — thin loader for the code-review-graph MCP bridge.
*
* All of the wiring lives in manifest.json under `contributes.mcpServers`:
* PI-Desktop spawns `crg.cmd` (stdio MCP), performs the handshake, and
* publishes every upstream tool as `plugin_crg_mcp_crg_<tool>`. Nothing has
* to be registered from here — the host owns the client, the retries, and the
* tool lifecycle. This module only keeps the plugin loadable and offers a
* place for future local helpers.
*/
async function onLoad() {
// The MCP client is owned by the host; no tool registration needed.
}
async function onUnload() {}
module.exports = { onLoad, onUnload };

View File

@ -1,33 +0,0 @@
{
"schemaVersion": 1,
"id": "crg-mcp",
"name": "Code Review Graph MCP",
"version": "1.0.0",
"description": "Bridges the locally deployed code-review-graph MCP server (D:\\github-project\\code-review-graph) into the agent as native tools.",
"main": "main.js",
"contributes": {
"mcpServers": [
{
"id": "crg",
"label": "Code Review Graph",
"transport": "stdio",
"command": "crg.cmd",
"env": {
"PYTHONPATH": "D:\\github-project\\code-review-graph",
"USERPROFILE": "C:\\Users\\admin",
"HOMEDRIVE": "C:",
"HOMEPATH": "\\Users\\admin"
}
}
]
},
"permissions": [
"mcp.server.local"
],
"engines": {
"piDesktop": ">=0.1.0"
},
"activationEvents": [
"onStartup"
]
}

View File

@ -1,96 +0,0 @@
<#
Self-check for the crg-mcp plugin.
Drives crg.cmd exactly the way the PI-Desktop MCP host does — `cmd /c crg.cmd`
with piped stdio and cwd = this folder — then reports the MCP handshake, the
tool list, and one real tool call. Run it from PowerShell:
powershell -NoProfile -ExecutionPolicy Bypass -File .\selfcheck.ps1
#>
$ErrorActionPreference = 'Stop'
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Mirror the host's minimal environment plus the manifest's env block.
foreach ($k in 'PATH', 'SystemRoot', 'TEMP', 'TMP') {
if (-not (Test-Path "Env:$k")) { Write-Warning "missing $k in ambient env" }
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'cmd.exe'
$psi.Arguments = '/c crg.cmd'
$psi.WorkingDirectory = $dir
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$proc = [System.Diagnostics.Process]::Start($psi)
function Send($obj) {
$proc.StandardInput.WriteLine(($obj | ConvertTo-Json -Compress -Depth 8))
$proc.StandardInput.Flush()
}
function ReadLine([int]$waitSeconds = 25) {
$task = $proc.StandardOutput.ReadLineAsync()
if ($task.Wait([TimeSpan]::FromSeconds($waitSeconds))) { return $task.Result }
return $null
}
Send @{
jsonrpc = '2.0'; id = 1; method = 'initialize'
params = @{
protocolVersion = '2025-06-18'
capabilities = @{}
clientInfo = @{ name = 'crg-selfcheck'; version = '1' }
}
}
$initLine = ReadLine 40
if (-not $initLine) {
Write-Host 'HANDSHAKE FAILED: no stdout from crg.cmd' -ForegroundColor Red
Write-Host '--- stderr ---'
Write-Host $proc.StandardError.ReadToEnd()
try { $proc.Kill() } catch { }
exit 1
}
$init = $initLine | ConvertFrom-Json
Write-Host ("HANDSHAKE OK server={0} {1}" -f $init.result.serverInfo.name, $init.result.serverInfo.version) -ForegroundColor Green
Send @{ jsonrpc = '2.0'; method = 'notifications/initialized'; params = @{} }
Send @{ jsonrpc = '2.0'; id = 2; method = 'tools/list'; params = @{} }
$toolsLine = ReadLine
if (-not $toolsLine) {
Write-Host 'tools/list returned nothing' -ForegroundColor Red
try { $proc.Kill() } catch { }
exit 1
}
$tools = ($toolsLine | ConvertFrom-Json).result.tools
Write-Host ("TOOLS: {0}" -f $tools.Count) -ForegroundColor Green
foreach ($t in $tools) { Write-Host (" plugin_crg_mcp_crg_{0}" -f $t.name) }
Send @{
jsonrpc = '2.0'; id = 3; method = 'tools/call'
params = @{ name = 'list_graph_stats_tool'; arguments = @{} }
}
$callLine = ReadLine 40
if ($callLine) {
$call = $callLine | ConvertFrom-Json
if ($call.result) {
$text = $call.result.content[0].text
Write-Host 'TOOL CALL OK' -ForegroundColor Green
Write-Host (' ' + ($text -split "`n")[0..3] -join ' | ')
}
else {
Write-Host ("TOOL CALL ERROR: {0}" -f ($call | ConvertTo-Json -Compress -Depth 6)) -ForegroundColor Red
}
}
else {
Write-Host 'TOOL CALL: no response' -ForegroundColor Red
}
try { $proc.Kill() } catch { }

View File

@ -526,11 +526,10 @@ curl -X POST "http://localhost:8000/stream/v1/asr?enable_speaker_diarization=tru
| `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | 轻量版多语言 ASR;CUDA 使用 vLLM,CPU/macOS 使用 Rust backend | 离线/实时 |
**运行时选择:**
- **显存 >= 32GB**: 选择 `qwen3-asr-1.7b`
- **显存 < 32GB**: 选择 `qwen3-asr-0.6b`
- **默认选择**: 离线和实时 ASR 统一使用 `qwen3-asr-0.6b`。
- **无 CUDA**: 选择基于 vendored Rust 的 `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: 无论内存大小多少,默认都选择 `qwen3-asr-0.6b`
- **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 或 `QWEN3_ASR_MODEL=qwen3-asr-0.6b` 可跳过自动选择
- **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 可切换到 1.7B;默认使用 0.6B
启动时会先检测当前运行计划所需模型;如果本地缓存缺失,会自动从 ModelScope 下载。离线部署请提前准备模型缓存。
@ -546,7 +545,7 @@ curl -X POST "http://localhost:8000/stream/v1/asr?enable_speaker_diarization=tru
| `ASR_BATCH_SIZE` | `4` | 长音频分段后的 ASR 批处理大小 |
| `MAX_SEGMENT_SEC` | `60` | 音频分段最大时长(秒) |
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | 启用远场声音过滤 |
| `QWEN3_ASR_MODEL` | 自动选择 | 强制选择 `qwen3-asr-1.7b` 或 `qwen3-asr-0.6b` |
| `QWEN3_ASR_MODEL` | `qwen3-asr-0.6b` | 离线与实时共用模型;设为 1.7B 可覆盖默认值 |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | vLLM 可保留的 GPU 显存上限;共享显卡时可调低,KV cache 不足时可适当调高 |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | 强制 vLLM eager 执行以提高兼容性;NVIDIA 性能测试可设为 `false` 允许 CUDA Graph 优化 |

View File

@ -1,382 +0,0 @@
# 实时 ASR WebSocket 处理细节对照
本文只整理当前代码,不修改 WebSocket 或说话人算法。对照对象是:
- 当前独立 Demo:`demo/realtime_asr_optimization_demo`
- 原项目实时接口:`app/api/v1/websocket_asr.py`、`app/services/qwen3_websocket_asr.py`、`app/services/realtime_speaker_clusterer.py`
代码是本文的依据;旧的排查记录或早期说明如果与当前实现冲突,以代码为准。
## 1. 先看整体差异
```mermaid
sequenceDiagram
participant B as 浏览器
participant D as 当前 Demo /ws
participant V as 独立 vLLM HTTP
participant A as 独立辅助服务
B->>D: start(扁平字段)
B->>D: PCM/WAV 二进制帧
D->>D: 20ms RMS 门控、turn 缓冲、静音切段
D->>V: 当前 turn 的累积 WAV(partial/final)
V-->>D: 文本
D-->>B: sentences + display_state
D->>A: final turn + session_id
A-->>D: CAM++ embedding / 在线聚类标签
D-->>B: 同 sentence_id 的 speaker 更新 + 新 display_state
B->>D: eof 或 stop
D->>D: 等待音频队列和 speaker 队列清空
D-->>B: end
```
```mermaid
sequenceDiagram
participant B as 浏览器
participant R as 原项目 FastAPI 路由
participant Q as Qwen3ASRService
participant E as Qwen3ASREngine
participant S as RealtimeSpeakerClusterer
B->>R: /ws/v1/asr 或 /ws/v1/asr/qwen
R->>Q: handle_connection
B->>Q: start.payload(嵌套字段)
Q-->>B: voice_id、start
B->>Q: PCM/WAV 二进制帧
Q->>Q: 转采样、VAD、pre-roll、partial
Q->>E: 原生流式或当前窗口重转写
E-->>Q: partial 文本
Q-->>B: sentence_type=0
Q->>E: 当前 turn 全量 final 重转写
E-->>Q: final 文本
Q-->>B: sentence_type=1、speaker_id=-1
Q->>S: speaker_job_queue(异步)
S-->>Q: 聚类/注册库匹配
Q-->>B: 相同 sentence_id 的 speaker 回写
B->>Q: stop
Q-->>B: end(stop 内部会再次 final/recluster)
```
核心设计差别是:当前 Demo 把 ASR 和声纹都放在独立 HTTP 服务后面,WebSocket 只做编排;原项目把 Qwen ASR 引擎、VAD、在线声纹聚类放在同一个服务进程里,但 speaker 归属仍是 final 之后异步补回。
## 2. 当前独立 Demo 的处理链路
### 2.1 进程和启动关系
`server.py` 启动一个 aiohttp HTTP/WebSocket 进程,并在生命周期中创建两个 HTTP 客户端:
| 组件 | 默认地址 | 职责 |
| --- | --- | --- |
| `VLLMTranscriptionService` | `http://127.0.0.1:9950/v1` | 只调用 `/audio/transcriptions`,partial 和 final 都是累积窗口 HTTP 请求 |
| `AuxiliaryModelService` | `http://127.0.0.1:8010` | 调用 `/health`、`/v1/speaker/resolve`、`/v1/speaker/reset` |
| `RealtimeSession` | `server.py:/ws` | 接收音频、VAD 切 turn、调用两个服务、维护展示状态 |
辅助服务在 `demo/scripts/auxiliary_server.py` 中预加载 VAD 与 CAM++ `speaker_verification`。每次 resolve 只上传一个已结束 turn,服务以 `session_id` 保存在线聚类中心;这不是把整段会议音频重新上传。
### 2.2 WebSocket 输入和输出
客户端首条消息是扁平结构:
```json
{
"type": "start",
"source": "mic",
"model_service_url": "http://127.0.0.1:9950/v1",
"model": "Qwen/Qwen3-ASR-0.6B",
"speaker_diarization": 1,
"sentence_strategy": 0,
"partial_interval_ms": 1200,
"max_segment_sec": 12,
"display_merge": true
}
```
`start` 成功后,客户端发送 16kHz、单声道、PCM16 二进制帧。文件模式只接收 `.pcm` 和 `.wav`;WAV 的 RIFF/fmt/data chunk 在 WebSocket 服务端增量剥离,并且要求 16kHz、单声道、PCM16。
控制消息:
| 消息 | 行为 |
| --- | --- |
| `eof` | 输入生产者结束;服务端把 `EOF` 放入音频队列,完成尾部 turn 和 speaker 队列后发送 `end` |
| `stop` | 与 `eof` 走同一排空流程,同时记录 `input_stopped`,用于 WAV 不完整时的校验差异 |
| `abort` | 取消音频和 speaker worker,直接结束,不保证当前 turn 有 final |
服务端消息的实际顺序通常是:
```text
start
-> sentences(partial,可能多次)
-> display_state(每次状态改变一份快照)
-> sentences(final,speaker 尚未确认)
-> display_state(pending)
-> display_state(processing/confirmed 或失败原因)
-> draining
-> end
```
`sentences` 是兼容性事件;`display_state` 是当前页面的主要渲染数据。`display_state.revision` 单调递增,包含:
- `raw_segments`:按 `start_time`、`sentence_id` 排序的原始片段
- `display_blocks`:按相邻且可信的说话人合并后的展示块
- `metrics`:音频字节数、输入帧数、partial 数量、partial 修订次数和耗时
### 2.3 音频、VAD 和 turn 边界
`RealtimeSession.process_audio()` 的边界是本 Demo 最重要的状态机:
1. 二进制数据进入 `audio_queue`,再拆成 640 bytes 的 PCM 帧,即 20ms。
2. 每帧用 RMS 阈值 `450` 判断有声/静音;这是 WebSocket 层的轻量门控,不是辅助服务的整段 VAD pipeline。
3. 未进入说话状态时,保留最近 6400 bytes(约 200ms)`pre_roll`。
4. 第一帧有声时,将 pre-roll 加到新 `segment_audio`,设置 `segment_start_ms`。
5. 进入说话状态后,所有帧追加到当前 turn;有声帧累计 `voiced_ms`,静音帧累计 `silence_ms`。
6. `sentence_strategy=0` 默认约 800ms 静音提交;`sentence_strategy=1` 使用约 1400ms 静音提交。
7. 达到 `partial_interval_ms`(默认 1200ms)且尚未达到静音阈值时,调用一次 vLLM partial。
8. 达到 `max_segment_sec`(默认 12s)时按 `max_duration` 提交。
当前 Demo 不把切段尾部静音送给 ASR/声纹:提交前按 `silence_ms` 从 `segment_audio` 尾部删除。下一个 turn 没有原项目那样的 `carry_audio`,而是从后续有声帧重新开始;因此两段之间的静音会形成时间间隔,不会自动带入下一段。
### 2.4 ASR 结果和同句覆盖
`_emit_transcription()` 始终使用当前 `segment_id`:
- `sentence_type=0`:partial,写入/覆盖同一个 `sentence_id`
- `sentence_type=1`:final,仍写入同一个 `sentence_id`,并加入 speaker 队列
`SegmentAssembler.apply_sentence()` 先按 `sentence_id` 找旧记录再覆盖;如果已经是 final,后来的 partial 不会回滚 final。final 没有文本时,会移除该片段,避免遗留一个永久 pending 的 partial。
### 2.5 声纹异步链路
`_commit_segment()` 只负责把 `SpeakerJob` 放入 `speaker_queue`,不会等待 CAM++。`process_speakers()` 是单 worker,按 turn 入队顺序串行调用辅助服务:
1. `voiced_ms < 800ms`:不提取声纹,写入 `insufficient_audio`,保持 `speaker_id=-1`。
2. 辅助服务缺失或异常:写入 `service_unavailable`/`service_error`,ASR 继续输出。
3. 辅助服务返回 embedding/聚类结果:回写同一 `sentence_id`。
4. `SegmentAssembler.apply_speaker_update()` 只接受可信身份:`speaker_evidence` 必须是 `fresh` 或 `confirmed`,置信度至少 `0.6`,并拒绝 `short_attach`、`embedding_attach`。
5. 不可信结果被归一化为 `speaker_id=-1`、`speaker_name=""`,但保留状态和原因供诊断。
辅助服务的在线聚类是简单的 session 级中心匹配:首次 embedding 新建 `speaker_id`,之后与已有中心的余弦相似度达到阈值就更新中心并复用 ID。`/v1/speaker/reset` 在 WebSocket 结束时清理该 session。
### 2.6 展示块如何合并
`SegmentAssembler.display_blocks(merge_adjacent=True)` 先按时间排序原始片段,然后遵循:
- pending/unknown 片段始终以自己的 `sentence_id` 作为身份键,独立成块;
- 只有相邻且可信的片段,且 `user_id`/`registry_speaker_id`/`speaker_id` 身份键相同,才合并;
- 合并只拼接文本、扩大结束时间并追加 `segment_ids`,原始片段仍保留在 `raw_segments`。
当前页面收到 `display_state` 后会整块重建结果区,按 `block_id` 渲染;收到 `sentences` 时如果服务端声明支持 `display_state`,页面不会再次追加,避免同一片段重复显示。旧页面或绕过 `display_state` 的客户端不具备这个保护。
## 3. 原项目 WebSocket 的处理链路
### 3.1 路由和状态
`app/api/v1/websocket_asr.py` 当前实际路由:
- `/ws/v1/asr`
- `/ws/v1/asr/qwen`
- `/ws/v1/asr/funasr` 已废弃,接受后发送 `FUNASR_REALTIME_REMOVED` 并以 1008 关闭
每个连接进入 `Qwen3ASRService.handle_connection()`。`ConnectionContext.state` 为 `READY -> STARTED -> STREAMING`;会话还可以通过 `session_id` 在 TTL 内断线恢复。恢复的是完整上下文,包括已确认片段、speaker history、时间线和待处理状态,不只是一个 WebSocket ID。
### 3.2 start 参数
原项目的 `start` 使用 `payload` 嵌套对象。常用字段包括:
| 类别 | 字段 |
| --- | --- |
| 音频 | `format`、`sample_rate`、`language`、`context`、`enable_inverse_text_normalization` |
| partial | `min_partial_sec`、`partial_window_sec`、`partial_holdback_chars`、`unfixed_token_num`、`enable_native_partial_stream` |
| 切段 | `silence_duration_ms`(默认 800)、`pre_roll_ms`(默认 240)、`max_sentence_count`(默认 8)、`enable_realtime_vad_split`、`max_segment_sec` |
| speaker | `enable_speaker`(默认 true)、`match_speaker_registry`、`speaker_threshold` |
| 稳定性 | `force_stable_segment_sec`、`force_stable_min_chars`、`soft_limit_sec`、`hard_limit_sec` |
服务端先发送 `voice_id`,再发送 `start`。`voice_id` 与会话 ID 通常相同;如果客户端传入固定 `payload.session_id`,断线重连时可以复用上下文。
### 3.3 音频转换、pre-roll 和 partial
`_convert_audio()` 接收 PCM/WAV,转为 float32;多声道下混为单声道,非 16kHz 用 scipy 重采样。每个二进制消息都在服务端转换后立即参与 VAD。
原项目的 `ConnectionContext` 同时维护:
- `pre_roll_audio`:未开始说话前的前滚音频,默认约 240ms;
- `segment_audio_buffer`:从当前 turn 开始到提交前的完整音频;
- `stream_window_buffer`:最近窗口,用于 partial 或 native stream 失败时回退;
- `realtime_stream_state`:只服务低延迟 partial,不决定 final;
- `silence_samples`、`sentence_active`、`total_samples`:VAD 状态和当前 turn 长度。
有声输入到达时,`_start_turn()` 把 pre-roll 与当前音频拼接;后续由 `_append_turn_audio()` 追加。达到最短窗口后,服务端可走原生 Qwen partial,或对当前窗口/当前 turn 重转写;partial 会经过清理、去重、与上一段重叠裁剪后发送。
提交触发条件不只有静音:
- 识别到足够完整的标点句,且时长/字数达到稳定门槛;
- 句子数达到 `max_sentence_count`;
- 静音样本达到 `silence_duration_ms`;
- 达到硬时长限制;开启实时 VAD split 时会尝试找一个完成的分割点。
### 3.4 final、carry 和时间线
`_commit_retranscribe_turn()` 对当前完整 turn 做一次 final 重转写,生成 `confirmed_segments` 元素:
```text
index / text / language / reason
duration_ms / start_ms / end_ms
sentence_type=1 / speaker_id=-1 / speaker_pending=true
```
final 事件先发送,speaker 之后再补。默认 final 的 `start_ms` 来自 `ctx.timeline_cursor_ms`;提交后时间线前移到 `segment_end_ms`。
开启实时 VAD split 时,提交可能得到 `finalized_audio + carry_audio`:前半段定稿,后半段留在下一个逻辑 turn 中,且会重新初始化 stream 状态。这个 carry 是原项目与当前 Demo 的一个实质差异,也是跨说话人边界时必须重点观察的音频来源。
### 3.5 原项目 speaker worker 和聚类
原项目 final 后把 job 放入 `ctx.speaker_job_queue`,由 `_speaker_worker_loop()` 串行消费。`_resolve_segment_speaker()` 调用 `RealtimeSpeakerClusterer.resolve_segment_speaker()`,再把结果写入指定 `segment_index`,通过相同索引发送一条新的 `sentences`。
`RealtimeSpeakerClusterer` 当前行为:
- 1.6s 以下且上一条有命名身份:使用 `short_attach` 直接沿用上一条;
- 正常 turn:按 1.5s 窗口、0.75s 步长提取 CAM++ embedding,匹配已有记录或新建 generic speaker;
- 4s 以上若 chunk 明显混合:返回 `mixed_segment`,保持未知;
- 开启注册库匹配且时长至少 2.4s:在独立注册 embedding 空间匹配实名;
- timeline 平滑时,短于 0.7s 的范围会并给相邻说话人;
- 实时记录达到至少 5 条且队列积压不超过 1 条时,可能对最近 12 条 pending 片段重新聚类;
- `stop` 时还会对历史记录做一次最终 recluster。
此外,`Qwen3ASRService` 自身还有两类“最近说话人继承”:generic speaker 新 turn 时长至少 8s 才允许 `recent_inherit`,实名 speaker 至少 4.5s 才允许 `recent_named_inherit`。这些继承都发生在 embedding 结果之后,不能与 `short_attach` 混为一谈。
### 3.6 stop 和 end
客户端只发送 `{"type":"stop"}`。服务端会:
1. 对仍 active 的 `segment_audio_buffer` 做 `reason=final` 的 final 提交;
2. 立即对现有 `speaker_records` 做最终 recluster,并发送可能的 speaker 更新;
3. 汇总 `confirmed_segments`,发送 `end(final=1)`。
这里与当前 Demo 不同:原项目的 `_stop()` 没有显式等待 `speaker_job_queue.join()`。如果 stop 到达时 speaker worker 仍在处理,最终 recluster/end 可能先于某个异步 speaker 回写;断开清理还会停止 worker。客户端必须把同 `sentence_id` 的后续 speaker 事件当作可迟到更新,而不能认为 `end` 之后绝不会再有归属变化。
## 4. 两套消息契约对照
| 维度 | 当前独立 Demo | 原项目 |
| --- | --- | --- |
| WebSocket | aiohttp `/ws` | FastAPI `/ws/v1/asr`、`/qwen` |
| start | 扁平字段 | `payload` 嵌套字段 |
| ASR | 外部 vLLM HTTP 累积窗口 | 进程内 Qwen engine,原生 stream 或重转写回退 |
| VAD | 20ms PCM RMS 门控 | float32 音频门控,支持实时 VAD split 辅助切分 |
| pre-roll | 固定约 200ms | `pre_roll_ms` 默认约 240ms |
| final 音频 | 删除提交尾部静音,不保留 carry | 可有 `carry_audio` 并带入后续 turn |
| speaker | 外部 CAM++/在线中心服务,单 worker | 进程内 CAM++ chunk 聚类、注册库、重聚类,单 worker |
| 未确认 speaker | `speaker_evidence=pending`,展示独立未知块 | `speaker_id=-1` 或 `speaker_pending=true`,客户端需自行暂存 |
| 文本更新键 | `sentence_id` | `sentence_id` 对外,内部 `segment_index` |
| 展示快照 | `display_state.revision`,服务端生成 `display_blocks` | 没有同等的服务端展示块协议,客户端按 sentence upsert |
| end 屏障 | `EOF -> audio worker -> speaker EOF -> end` | `_stop()` final/recluster/end,不等待 speaker 队列清空 |
## 5. “上一人的最后一句进入下一人气泡”的定位框架
当前先不修改,定位时要把“片段本身错了”和“片段正确但展示合错了”分开。
### 5.1 当前独立 Demo 的可能路径
1. **同一个 turn**:两人换话之间没有达到 800ms(或段落模式 1400ms)静音,RMS VAD 不切段。此时 vLLM 收到的是混合 turn,前端只有一个 `sentence_id`,不是气泡合并问题。
2. **声纹误归属**:A 的 final 先是 pending,随后辅助服务把 A 误匹配到 B 的 cluster。下一次 `display_state` 中,A、B 两个相邻片段拥有同一可信身份,`display_blocks()` 会把两者拼成一个 block。
3. **异步回写改变了合并条件**:A 的 speaker 结果可能在 B 的 final 之后才到达。服务端按时间排序重建快照,所以视觉上是 A 的文字“后来进入”B 的气泡;实际是 A 的旧片段身份被补齐后触发了相邻合并。
4. **旧客户端渲染路径**:当前页面在 `display_state_supported=true` 时忽略 `sentences`,但旧页面若逐条 append `sentences`,可能把同一 `sentence_id` 的 final/speaker 更新当成新气泡,或把 pending 文本追加到上一气泡。必须确认浏览器加载的 `app.js` 版本和服务端返回的 `display_state`。
5. **session 污染**:辅助服务按 `session_id` 保存聚类中心。若 reset 没有执行、多个连接错误复用同一个 session ID,上一场会话的 cluster 可能影响新会话;正常一次连接内 A/B 共用中心是设计行为,不是跨人合并的充分证据。
当前 Demo 的 worker 是串行的,`emit()` 有发送锁,因而“并发返回顺序打乱”不是首要嫌疑;首要证据应是 `raw_segments` 的 `sentence_id/start_time/speaker_id/speaker_strategy` 是否正确,以及 `display_blocks.segment_ids` 是否把两个片段合到一起。
### 5.2 原项目的可能路径
1. **静音边界不足**:默认 800ms 静音才提交;换话前的短停顿会让 A 尾部和 B 开头留在同一个 `segment_audio_buffer`。
2. **carry 音频污染**:启用实时 VAD split 时,分割点之后的 `carry_audio` 会成为下一个 turn 的开头。若 split 点落在 A 尾音或 B 起音中间,下一段声纹和 ASR 都会携带前一人尾部。
3. **短句沿用上一身份**:B 的新段小于 1.6s 且历史有命名 speaker 时,`short_attach` 会直接复用上一条;chunk 提取为空时的 `embedding_attach` 也可能复用上一条。这是代码中最直接的“上一人污染下一段”路径。
4. **最近身份继承**:较长的新段在匹配失败时可能触发 `recent_named_inherit`(至少 4.5s)或 `recent_inherit`(至少 8s),因此不能只看最终 `speaker_id`,还要记录 `speaker_strategy`。
5. **重聚类改写历史**:实时重聚类和 stop 最终重聚类都可能改写已有 segment 的 speaker。客户端如果按到达顺序追加,而不是按 `sentence_id` upsert,就会看到旧气泡和新气泡互相覆盖或合并。
6. **stop 竞态**:原项目 stop 不等待 speaker job 队列清空;end 可能先发,随后连接清理还会取消 worker。最后一个人的 speaker 归属可能缺失、迟到或停留在旧标签,前端若把 end 当成不可变快照会放大问题。
### 5.3 需要同时保存的证据
对同一段测试音频,至少保存以下三层结果:
```text
音频层:每个 turn 的 start/end、有效有声时长、是否包含 carry/pre-roll
识别层:sentence_id/index、sentence_type、文本、speaker_strategy、置信度
展示层:display_state.revision、raw_segments、display_blocks.segment_ids
```
判定规则:
- `raw_segments` 已经只有一个片段:先查 VAD/切段边界;
- `raw_segments` 有 A、B 两段且 speaker ID 相同:查声纹误匹配/继承/重聚类;
- `raw_segments` 的 ID 不同但 `display_blocks.segment_ids` 合并:查展示合并键;
- `display_blocks` 正确但页面仍显示一只气泡:查浏览器脚本版本、是否绕过 `display_state`、是否按 `sentence_id` upsert。
## 6. 后续细节优化的优先级(本轮不实施)
### P0:先证明边界和身份是否正确
- 记录每个 final 的实际音频起止、有效有声毫秒、pre-roll/carry 长度。
- 记录 speaker resolve 请求和返回的 `session_id`、策略、置信度、cluster ID。
- 前端临时展示 `block.segment_ids`,确认“合并”到底是两个片段还是一个片段。
- 用固定 A-静音-B 音频,比较 200ms、500ms、800ms、1400ms 停顿。
### P1:降低错误身份传播
- 对 `short_attach`、`embedding_attach`、recent inherit 单独统计,不要只统计 speaker_id。
- 对跨边界的短 turn 保持 pending,等到有独立 embedding 或后续重聚类再确认。
- 明确实时重聚类和最终重聚类的可修改范围,客户端统一按 ID 幂等更新。
- 为 stop 增加“最后一个 speaker job 已完成”的可观察状态。
### P2:改善展示稳定性
- 展示层只把可信且相邻的片段合并,保留 segment_ids 和 revision。
- 对 speaker 更新做局部重绘或整快照重绘,但不要把同一个 sentence 当成新消息追加。
- unknown/pending 使用独立块,不把诊断文本放在说话人名称中。
## 7. 建议的验收用例
| 用例 | 观察点 | 通过标准 |
| --- | --- | --- |
| A 说 3s,停 1s,B 说 3s | 两套服务的 raw segment | 至少两个不同 sentence_id,时间不重叠 |
| A 说 3s,停 300ms,B 说 3s | VAD 边界 | 明确记录为同段或分段,不能只看气泡颜色判断 |
| A 说 3s,B 只说 0.8s | 短 turn speaker 策略 | 原项目应能观察 `short_attach`;Demo 应保持 pending 或独立结果 |
| A/B 各说多段,speaker 服务延迟 2s | 异步回写 | 文本不重复,更新按 sentence_id 定位,顺序按时间恢复 |
| speaker 服务不可用 | 降级 | ASR 仍有 final,speaker 为未知并有明确 reason |
| stop 紧跟最后一帧 | 收尾屏障 | Demo 的 end 在 speaker 队列完成后发送;原项目记录可能迟到的 speaker 更新 |
| 断线后同 session_id 重连 | 会话隔离 | 原项目按 TTL 恢复;Demo 新连接不会复用旧 speaker center |
## 8. 源码索引
### 当前独立 Demo
- `demo/realtime_asr_optimization_demo/server.py`
- `RealtimeSession.__init__`:会话参数、队列和 VAD 状态
- `emit_state`:`raw_segments/display_blocks/revision`
- `_emit_transcription`:partial/final 写入同一 `sentence_id`
- `_commit_segment`、`process_audio`:VAD、切段和 speaker job 入队
- `_resolve_speaker`、`process_speakers`:异步声纹回写
- `websocket_handler`:start、二进制帧、eof/stop/abort、end
- `demo/realtime_asr_optimization_demo/speaker_assembler.py`
- `apply_sentence`、`apply_speaker_update`、`display_blocks`
- `demo/realtime_asr_optimization_demo/model_service.py`
- 独立 vLLM OpenAI-compatible HTTP 适配
- `demo/realtime_asr_optimization_demo/auxiliary_service.py`
- `/health`、`/v1/speaker/resolve`、`/v1/speaker/reset` 客户端适配
- `demo/scripts/auxiliary_server.py`
- VAD/CAM++ 预加载、embedding 提取、session 级在线聚类
### 原项目
- `app/api/v1/websocket_asr.py`
- `/ws/v1/asr`、`/ws/v1/asr/qwen`、废弃 `/funasr`
- `app/services/qwen3_websocket_asr.py`
- `ConnectionContext`:音频、partial、confirmed segments、speaker 队列
- `handle_connection`:WebSocket 状态机和消息协议
- `_commit_retranscribe_turn`:final、carry、timeline、speaker job
- `_speaker_worker_loop`、`_resolve_and_emit_segment_speaker`:异步 speaker 回写
- `_inherit_recent_*`、`_maybe_recluster_recent_segments`:身份传播和重聚类
- `_stop`:最终提交、recluster、end
- `app/services/realtime_speaker_clusterer.py`
- chunk embedding、短段沿用、已有 speaker 匹配、混合段、timeline 平滑
- `docs/realtime_meeting_websocket.md`
- 对外协议示例;其中 partial/final/speaker 回写必须按 `sentence_id` 幂等处理
本轮只新增本文档,没有修改上述实现。后续修复应先用第 5 节的三层证据确定问题属于切段、声纹还是展示层,再决定改哪一层。

View File

@ -1,66 +0,0 @@
# 熵减执行清单
本文档记录本轮已执行的熵减工作。目标是移除不可达路径、兼容占位、隐藏 fallback、重复请求流水线和无用依赖。
## 范围
- 主范围:`app/`、根运行配置、公开运行文档。
- 不处理:`vendor/qwenasr` 内部实现、仅 benchmark 使用且不阻塞主链路的代码。
- 原则:开发中项目不保留废弃接口、旧字段、兼容层或 fallback 逻辑。
## P0
- [x] 移除 Qwen3 `transformers` 后端残留路径。
- 删除后端选择里的 `"transformers"` fallback。
- 删除仅服务该路径的 batch/segment 转换死代码。
- 不支持的设备显式失败。
- [x] 启动预加载改为 fail-fast。
- 模型完整性检查或预加载失败时停止 worker。
- 不再静默降级到首次请求加载。
- [x] 移除被忽略的离线模型兼容参数。
- 删除 REST `model_id` 兼容处理。
- 删除 OpenAI transcription `model` 兼容处理。
- 运行时模型选择统一由部署计划和 `QWEN3_ASR_MODEL` 控制。
## P1
- [x] 抽出共享离线转写服务。
- API 层只处理协议输入和响应格式。
- 音频准备、`OfflineASRRequest`、runtime 调用和清理边界集中到服务层。
- [x] 合并音频字节处理逻辑。
- `process_from_request` 和 `process_upload_file` 共享私有 byte 处理 helper。
## P2
- [x] 将 `.tsscale` sidecar 隐式耦合改为显式结构化元数据。
- [x] 拆分 WebSocket 路由和 Qwen3 协议服务。
- Qwen3 websocket 状态机移出 API route。
- route 模块仅保留端点注册和 service delegation。
- [x] 删除首轮发现的无用 helper。
- [x] 审计并移除无用直接依赖。
- 根环境和 CPU 环境移除直接依赖 `pydub`、`httpx`。
- 保留 ModelScope/FunASR 动态 runtime 依赖。
- [x] 继续压缩阿里协议 WebSocket service。
- 删除大段注释、未使用状态、未使用参数和死函数。
- 合并重复响应构造。
- 删除重复音频转换。
## 验证
- [x] `uv run python -m py_compile $(find app -name '*.py' -not -path '*/__pycache__/*') start.py`
- [x] `uvx pyright`
- [x] 变更模块 import smoke check。
- [x] 手工 API smoke plan 已记录:
- `/stream/v1/asr`
- `/v1/audio/transcriptions`
- `/ws/v1/asr/funasr`
- `/ws/v1/asr/qwen`
## 手工 Smoke Plan
启动服务并准备模型后执行:
1. `POST /stream/v1/asr`,使用小 WAV request body,确认返回 `result`、`segments`、`duration`、`processing_time`。
2. `POST /v1/audio/transcriptions`,使用 multipart `file` 和 `response_format=verbose_json`,确认返回 OpenAI 风格 `text` 和 `segments`。
3. 连接 `/ws/v1/asr/funasr`,发送阿里兼容 start/audio/stop 消息,确认 sentence 事件仍正常返回。
4. 连接 `/ws/v1/asr/qwen`,发送 start/audio/stop 消息,确认 partial/final 事件仍正常返回。

View File

@ -1,457 +0,0 @@
# x86 Rust Align Optimization Plan
## 当前结论
- 当前工作区的 vendored Rust backend 已经收敛到更接近 upstream `huanglizhuo/QwenASR` 的 Linux/x86_64 路径:
- `release`
- `RUSTFLAGS="-C target-cpu=native"`
- `BLAS/OpenBLAS`
- x86_64 默认 `BF16` decode
- 保留的有意偏离只有两类:
- `SharedQwenModel` / shared model cache
- 中性的 `ffi` feature(`macos-ffi` 仅作为兼容别名保留)
## upstream 参考
- upstream repo: `https://github.com/huanglizhuo/QwenASR`
- inspected commit: `4e85a19b05f034e106a345d279c68f50df718ab8`
## 本机环境
- CPU: `Intel Core i5-13600KF`
- visible CPUs: `14`
- memory: user reported `G.SKILL DDR5-6400`
## 已验证 benchmark
音频:
- `/opt/qwen3-asr/temp/test_assets/podcast_demo_2min_16k.wav`
- duration: `120s`
### decode 路径对照(runtime concurrency = 4)
`INT8 decode`
- total: `173.42s`
- asr: `54.69s`
- align: `118.74s`
- rtf: `1.4452`
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_int8.json`
`BF16 decode`
- total: `125.87s`
- asr: `46.40s`
- align: `79.47s`
- rtf: `1.0489`
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_bf16.json`
结论:
- 在当前这台 x86_64 机器上,`BF16 decode` 明显优于 `INT8 decode`
- 因此 x86_64 默认 decode 路径应保持 `BF16`
### 收敛后的默认路径 benchmark
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_default_after_converge.json`
结果:
- `runtime concurrency = 4`
- total: `131.77s`
- asr: `51.54s`
- align: `80.23s`
- rtf: `1.0981`
- `runtime concurrency = 14`
- total: `128.25s`
- asr: `57.12s`
- align: `71.13s`
- rtf: `1.0688`
结论:
- 当前主瓶颈仍然在 `align`
- `align_sec` 明显大于或接近 `asr_sec`
- `runtime concurrency` 的最优值并不稳定,说明问题不在单纯线程数,而在具体阶段的访问模式和 kernel 行为
## 为什么不继续默认走 INT8
- upstream README 对 Linux/x86_64 的主路径描述是 `BLAS + AVX2/FMA`
- 当前本机实测中,`INT8 decode` 明显慢于 `BF16 decode`
- 说明这台机器上的主瓶颈不只是权重带宽,更多是:
- x86_64 上 INT8 kernel 的有效带宽利用率
- cache / 数据布局
- 实现成熟度差异
## 当前仍需保留的偏离
### 1. Shared model cache
文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/context.rs`
目的:
- 多 runtime / 多 worker 场景下复用只读模型权重
- 避免每个 runtime 重复 mmap / 持有整套权重
### 2. ffi feature
文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/Cargo.toml`
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/lib.rs`
- `/opt/qwen3-asr/Dockerfile.cpu`
目的:
- 让 Linux CPU 集成不再依赖命名不准确的 `macos-ffi`
- 同时保留兼容别名,避免已有脚本立即失效
## align 热点拆解计划
### Phase 1: 阶段级 profiling
状态:已完成
目标:
- 先确认 `align` 的主耗时究竟在哪一段
位置:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/align.rs`
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/decoder.rs`
需要拆出的阶段:
- `mel_spectrogram`
- `encoder.forward`
- `input_embeds build`
- `decoder_prefill_logits`
- `timestamp argmax extract`
- `fix_timestamps`
验收:
- 2 分钟样本上输出稳定的阶段级耗时表
实际结果:
- 产物:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_align_profile.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_align_profile.stderr`
- 结论:
- `align` 的主热点明确落在 `decoder_prefill_logits`
- `final rms_norm` 和 `lm_head projection` 不是主矛盾
### Phase 2: decoder_prefill_logits 内部分解
状态:已完成
如果 `decoder_prefill_logits` 是主热点,则继续拆分:
- `decoder_prefill`
- final `rms_norm`
- `lm_head projection`
目的:
- 判断到底是 decoder prefill 慢,还是最后分类头 projection 慢
实际结果:
- 产物:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_align_breakdown.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_align_breakdown.stderr`
- 结论:
- 真正的大头是 `decoder_prefill`
- 长段(`seq_len=1801`)时,`attention_ms` 占 `decoder_prefill` 的绝大部分
- 关键样本:
- `decoder_prefill total_ms=79280.78`
- `attention_ms=73769.91`
- `qkv_ms=1336.33`
- `gate_up_ms=1866.68`
- `down_proj_ms=955.94`
### Phase 2.5: 失败尝试记录
状态:已完成并回退
尝试:
- 针对 x86_64 预先物化 prefill 用 F32 权重,避免每次 `align` 反复做 `BF16 -> F32`
结果:
- 长段 `decoder_prefill` 没有稳定收益,反而出现回归
- 这条路径已经回退,不保留在主线代码里
结论:
- 当前瓶颈不是简单的 BF16 权重转换
- 更直接的问题是 multi-token causal attention 的算法路径
### Phase 3: 对热点段做针对性优化
状态:第一轮已完成
根据 profiling 结果,按优先级选一个方向:
1. 如果热点在 `input_embeds build`
- 复用固定 prefix/suffix embeddings
- 减少逐 token 小块 copy
- 降低每段 align 的重复构造开销
2. 如果热点在 `decoder_prefill`
- 检查 `BF16 matvec / attention / swiglu` 的实际热点
- 优化并行粒度或数据布局
3. 如果热点在 `lm_head projection`
- 优先优化 `BF16` classify head 路径
- 避免无价值的整块 materialize
- 但只有在 profiling 证明是主热点后才动
4. 如果热点在后处理
- 精简 `fix_timestamps`
- 减少 `Vec/String` 分配
### Phase 3 实施结果
本轮实际落地的是:
- 文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/kernels/mod.rs`
- 改动:
- 对 BLAS multi-token causal attention 增加长序列专用 batched 路径
- 仅在 `seq_q >= 256` 时启用
- 从“每个 head、每一行 2 次小 GEMM”改为:
- 每个 head 1 次 `Q @ K^T`
- 行级 causal softmax
- 每个 head 1 次 `softmax @ V`
### Phase 3 回归结果
来源:
- 优化后 benchmark:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_opt.json`
- 优化后 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_attention_opt_profile.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_attention_opt_profile.stderr`
关键对比:
- 之前默认路径(2 分钟样本,基线文件):
- `total=128.25s`
- `asr=57.12s`
- `align=71.13s`
- 优化后:
- `total=76.46s`
- `asr=45.32s`
- `align=31.14s`
- `rtf=0.6372`
attention 热点变化:
- 长段 `seq_len=1801`
- 优化前:
- `decoder_prefill total_ms=79280.78`
- `attention_ms=73769.91`
- 优化后:
- `decoder_prefill total_ms=16625.88`
- `attention_ms=9981.88`
结论:
- 当前这台 i5 上,`align` 的主矛盾已经从“attention 明显失控”收敛到了“attention 仍是第一热点,但已降到可接受量级”
- 这一轮优化是有效的,应该保留
## Phase 4: FFN 路径继续收敛
状态:已完成一轮,并保留有效部分
本轮动作:
- 文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/decoder.rs`
- 改动:
- 仅对 x86_64 `BF16 prefill` 的 FFN 路径物化共享 F32 权重
- 范围只包括:
- `gate_up_fused`
- `down_weight`
- `QKV` 和 `O-proj` 暂不纳入
回归数据:
- 不带 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_ffn_opt.json`
- `total=58.61s`
- `asr=29.50s`
- `align=29.11s`
- 带 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_ffn_opt_profile.json`
- `total=62.07s`
- `asr=32.15s`
- `align=29.92s`
与上一轮 attention-only profiling 对比:
- attention-only:
- `total=64.58s`
- `align=30.86s`
- FFN-opt:
- `total=62.07s`
- `align=29.92s`
长段热点对比(`seq_len=1801`):
- FFN-opt 之前:
- `attention_ms=9981.88`
- `gate_up_ms=2080.10`
- `down_proj_ms=1183.64`
- FFN-opt 之后:
- `attention_ms=9660.15`
- `gate_up_ms=2210.39`
- `down_proj_ms=1166.96`
结论:
- FFN 这轮不是“大收益”,但 `align_sec` 仍然有小幅下降
- 收益不像 attention 优化那样压倒性,更像是小幅收敛
- 当前可以保留,但不值得继续在同一方向上扩大复杂度
## Phase 5: QKV / O-proj 试验与回退
状态:已完成并回退
尝试:
- 在 prefill 中进一步物化 `QKV` 和 `O-proj` 的共享 F32 权重
回归数据:
- 试验版本:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_qkv_opt.json`
- `total=63.12s`
- `align=30.07s`
结论:
- 相比 FFN-opt 版本,没有形成净收益
- 因此这条路径已回退,不保留在主线代码里
## Phase 6: attention 继续细化的两次试验
状态:已完成并回退
### 试验 A:query-block batched attention
尝试:
- 将长序列 batched causal attention 从“整段一次性 `QK^T` / `SV`”改成按 query block 分块执行
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_block_opt.json`
- `total=79.18s`
- `align=33.40s`
结论:
- 这条路径在当前 i5 + OpenBLAS 组合下没有收益
- 增加 GEMM 次数带来的额外调度开销,超过了小块缓存收益
- 已回退
### 试验 B:提高 batched 切换阈值到 512
尝试:
- 只改 `BATCHED_CAUSAL_ATTENTION_THRESHOLD`
- 让中等长度序列继续走 row-wise 路径,只把更长的序列交给 batched 路径
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_threshold512.json`
- `total=74.49s`
- `align=30.42s`
结论:
- 相比当前主线最优版本也没有收益
- 说明当前阈值 `256` 不是主要问题
- 已恢复回 `256`
## Phase 7: K/V block + online softmax 试验
状态:已完成并回退
尝试:
- 仅替换长序列 batched attention 路径
- 改为按 `K/V` block 流式累积的 online softmax
- 短序列和单 token 路径完全不动
目标:
- 不再一次性物化整块 `scores`
- 降低大矩阵内存压力
- 观察是否能进一步压低长段 `attention_ms`
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_kvblock_online_softmax.json`
- `total=74.11s`
- `align=30.33s`
结论:
- 当前实现下没有优于主线最优版本
- 在这台机器上,额外的 block 循环和 online softmax 合并开销,超过了减少大 `scores` 矩阵带来的收益
- 已回退
## 当前判断
- 当前最值钱、且已验证有效的优化仍然是:
- 长序列 batched causal attention
- FFN selective F32 物化
- 继续扩大到 `QKV/O-proj` 这一步暂时不划算
- query-block 化和 batched 阈值调优目前也不划算
- `K/V block + online softmax` 在当前实现形态下也不划算
- 后续判断应优先看:
- `align_sec`
- `decoder_prefill` profiling
- 尤其是长段 `seq_len` 下的热点变化
补充:
- `asr_sec` 在多次回归中波动明显大于 `align_sec`
- 因此后续评估优化效果时,不应只盯总耗时,应优先以 `align` profiling 为准
## 暂不做的事
- 不再把 x86_64 默认路径改回 `INT8 decode`
- 不继续做没有 profiling 支撑的 `align` 结构性改写
- 不围绕 `runtime concurrency` 数量盲调
## 下一步执行顺序
1. 保留当前 batched causal attention 路径,继续观察不同长段下的稳定性
2. 保留 FFN selective F32 物化,继续观察其稳定收益
3. 如需继续优化,优先看:
- 更激进的 `attention` 算法级改动,例如按 `K/V` block 的 online softmax
- 再其次才是 `qkv_ms + gate_up_ms + down_proj_ms`
4. 如果后续继续深挖,再考虑:
- batched attention 的 block 化,降低大 score matrix 的瞬时内存
- `decoder_prefill` 内的投影层进一步收敛
5. 保持同一份 2 分钟样本持续回归,避免再次把回归误当成优化

View File

@ -1,699 +0,0 @@
# Qwen3-ASR 部署指南
快速部署 Qwen3-ASR 语音识别服务,支持 CPU/macOS、NVIDIA GPU、沐曦 GPU、天数 GPU 与摩尔线程 GPU 运行形态。
如果你正在继续验证本轮 CUDA 官方 vLLM 迁移,请同时参考:
- [PENDING_CUDA_VLLM_HANDOFF.md](./TODO/PENDING_CUDA_VLLM_HANDOFF.md)
依赖安装现在改成根目录默认 NVIDIA GPU,CPU、沐曦、天数与摩尔线程为单独特化环境:
| 模式 | 命令 | 说明 |
|------|------|------|
| NVIDIA GPU | `uv sync` 或 `./scripts/sync_gpu_env.sh` | Linux/NVIDIA 运行时,默认锁定 CUDA 13.0/cu130 `torch 2.11.0` / `torchaudio 2.11.0` / `torchvision 0.26.0` + `vllm 0.20.0` |
| 沐曦 GPU | `./scripts/sync_metax_env.sh` | 同步公共依赖;可选 GPU 栈安装默认从沐曦 MACA PyPI 源按 `--no-deps` 安装 |
| 天数 GPU | `./scripts/sync_iluvatar_env.sh` | 同步公共依赖;GPU 栈使用天数官方 vLLM 镜像 |
| 摩尔线程 GPU | `./scripts/sync_mthreads_env.sh` | 同步公共依赖;GPU 栈使用摩尔线程官方 MUSA vLLM 镜像 |
| CPU | `./scripts/sync_cpu_env.sh` | Linux/CPU 运行时 |
| 自动 | `./scripts/sync_accel_env.sh` | 根据 `mx-smi` / `ixsmi` / `mthreads-gmi` / `nvidia-smi` 自动选择沐曦、天数、摩尔线程、NVIDIA 或 CPU 环境 |
## 快速部署
### NVIDIA GPU 版本部署(推荐)
适用于生产环境,提供更快的推理速度:
**前置要求:**
- NVIDIA GPU(默认镜像面向 CUDA 13.0+;CUDA 12.6 / 13.0 可通过构建参数覆盖)
- 已安装 NVIDIA Container Toolkit
- 显存 12GB+(推荐 16GB+ 以支持 Qwen3-ASR 1.7B)
```bash
# 使用 docker run(带模型挂载)
docker run -d --name qwen3-asr \
--gpus all \
-p 17003:8000 \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e ACCELERATOR=nvidia \
-e DEVICE=auto \
-e QWEN_GPU_MEMORY_UTILIZATION=0.3 \
-e QWEN_VLLM_ENFORCE_EAGER=true \
unis/qwen3-asr:gpu-latest
# 或使用 docker-compose(推荐)
docker-compose up -d
```
### 沐曦 GPU 版本部署
适用于已安装沐曦驱动与容器运行栈的机器:
```bash
docker run -d --name qwen3-asr-metax \
--privileged \
--network=host \
--pid=host \
--ipc=host \
-v /dev:/dev \
-v /opt/mxdriver:/opt/mxdriver:ro \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e ACCELERATOR=metax \
-e PORT=17003 \
-e METAX_VISIBLE_DEVICES=0 \
unis/qwen3-asr:metax-latest
# 或使用 docker-compose-metax.yml
docker compose -f docker-compose-metax.yml up -d
```
构建沐曦镜像时,`Dockerfile.metax` 会基于沐曦官方 vLLM 镜像融合本项目代码与通用依赖:
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor metax \
--base-image <沐曦官方vLLM镜像名> \
-v n260-3.7.0.38
```
沐曦等国产 GPU 的生产推荐路径是“厂商官方 vLLM 镜像 + 本项目代码/通用依赖”。不要在项目 Dockerfile 中重新 `pip install vllm`,避免解析到 PyPI/NVIDIA CUDA 依赖。
沐曦 GPU 的完整编译、模型准备和离线交付流程见 [沐曦 GPU 国产化离线部署指南](./metax_offline_deployment.md)。
### 天数 GPU 版本部署
适用于已安装天数驱动与容器运行栈的机器。按天数官方镜像运行建议,本 compose 使用 host network、host pid/ipc、privileged、`/dev`、`/usr/src`、`/lib/modules` 等挂载,并额外挂载本项目模型与数据目录:
```bash
docker pull registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./scripts/package_vendor_gpu_image.sh \
--vendor iluvatar \
--base-image registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
docker compose -f docker-compose-iluvatar.yml up -d
```
天数 GPU 的完整编译、模型准备和离线交付流程见 [天数 GPU 国产化离线部署指南](./iluvatar_offline_deployment.md)。
### 摩尔线程 GPU 版本部署
适用于已安装摩尔线程驱动与 MUSA 容器运行栈的机器:
```bash
docker pull registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
./scripts/package_vendor_gpu_image.sh \
--vendor mthreads \
--base-image registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519 \
-v s4000_4.3.5_d0519
ASR_IMAGE=unis/qwen3-asr:mthreads-s4000_4.3.5_d0519 \
docker compose -f docker-compose-mthreads.yml up -d
```
摩尔线程 GPU 的完整编译、模型准备和离线交付流程见 [摩尔线程 GPU 国产化离线部署指南](./mthreads_offline_deployment.md)。
默认推荐将宿主机目录统一挂载到 `/opt/dep/asr` 下,目录结构如下:
```text
/opt/dep/asr/models/
Qwen/
iic/
damo/
```
如果你希望挂载到自定义目录,可统一设置:
```bash
export MODEL_STORAGE_DIR=/data/qwen3-asr-models
export DATA_STORAGE_DIR=/data/qwen3-asr-data
mkdir -p "$MODEL_STORAGE_DIR" "$DATA_STORAGE_DIR"
docker-compose up -d
```
### 多 GPU 拓扑模式
项目现在支持统一的多 GPU 拓扑开关:
- `ASR_DEPLOY_TOPOLOGY=isolated`
- 默认模式
- 每张卡启动 1 个 backend 实例
- 容器内使用 Nginx 负载均衡到多个实例
- `ASR_DEPLOY_TOPOLOGY=sharded`
- 单个 backend 进程占用多张卡
- 由 vLLM 在进程内部做多卡分片
- `ASR_DEPLOY_TOPOLOGY=auto`
- 优先尝试 `sharded`
- 如果当前平台、可见设备或 shard 数不满足条件,则自动回退到 `isolated`
### NVIDIA 多 GPU 自动并行部署(推荐)
适用于并发量较高场景。该方案通过容器 entrypoint 自动完成:
- 根据 `ASR_VISIBLE_DEVICES` 拉起多个 ASR 实例(每张卡 1 个实例)
- 容器内自动生成 Nginx upstream 并负载均衡到各实例
- 对外仍只暴露一个服务端口(默认 `8000`)
你不需要手工维护多个 `docker-compose` 服务块或手工维护 nginx upstream。
```bash
# 4 卡示例:GPU0,1,2,3 各启动 1 个实例
ASR_VISIBLE_DEVICES=0,1,2,3 docker-compose up -d
```
常用组合:
- 单卡(保持默认):`ASR_VISIBLE_DEVICES=0`
- 双卡:`ASR_VISIBLE_DEVICES=0,1`
- 四卡:`ASR_VISIBLE_DEVICES=0,1,2,3`
强制单实例多卡分片:
```bash
ASR_DEPLOY_TOPOLOGY=sharded \
ASR_VISIBLE_DEVICES=0,1 \
docker-compose up -d
```
自动选择模式:
```bash
ASR_DEPLOY_TOPOLOGY=auto \
ASR_VISIBLE_DEVICES=0,1 \
docker-compose up -d
```
**服务访问地址:**
- API 服务: `http://localhost:17003`
- API 文档: `http://localhost:17003/docs`
### 沐曦多 GPU 自动并行部署
```bash
ASR_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-metax.yml up -d
```
### 摩尔线程多 GPU 自动并行部署
```bash
ASR_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-mthreads.yml up -d
```
### CPU 版本部署
适用于开发测试或无 GPU 环境:
```bash
docker run -d --name qwen3-asr \
-p 17003:8000 \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e DEVICE=cpu \
unis/qwen3-asr:cpu-latest
```
**注意:** CPU 版本不使用 GPU/vLLM 路径。
当前 CPU 镜像已集成 QwenASR Rust backend,会自动选择 `qwen3-asr-0.6b`。
CPU 镜像默认使用可分发的 `x86-64-v2` Rust 构建目标,避免把构建机的 native CPU 指令带入通用镜像。
如果你确认构建机与部署机 CPU 指令集一致,可在自建镜像时设置 `QWENASR_RUST_TARGET_CPU=native` 换取更激进优化。
当前 Rust backend 的 x86 kernel 需要 `avx2` 与 `fma`,不满足时启动会给出明确错误。镜像默认限制
`OPENBLAS_NUM_THREADS=1` / `OMP_NUM_THREADS=1` / `GOTO_NUM_THREADS=1`,以减少多 runtime 并发时的线程争抢。
CUDA vLLM 与 CPU Rust 路径下,`word_timestamps=true` 会自动调用 forced aligner 返回字词级时间戳。
### 离线交付目录导出
如果你需要把镜像交付到不能联网的机器,推荐直接生成一个完整的离线交付目录。该脚本使用普通 `docker build` + `docker save`,不依赖 `buildx`:
```bash
# 生成 GPU 离线交付目录
./export_offline_bundle.sh --type gpu
# 或生成 CPU 离线交付目录
./export_offline_bundle.sh --type cpu
# 或生成沐曦 GPU 离线交付目录
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
--skip-models
./export_offline_bundle.sh --type iluvatar --iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./export_offline_bundle.sh --type mthreads --mthreads-base registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
# 或一次同时生成 GPU + CPU 离线交付目录
./export_offline_bundle.sh --type all
```
生成后的目录形如:
```text
build-file/
20260520_153000-all/
qwen3-asr-cpu-20260520_153000-amd64.tar.gz
qwen3-asr-gpu-20260520_153000-amd64.tar.gz
docker-compose.yml
docker-compose-cpu.yml
docker-compose-metax.yml
.env.example
init_host_dirs.sh
README.md
DEPLOYMENT.md
```
其中会自动包含:
- 对应类型的一份或两份镜像压缩包
- 对应类型的 compose 文件;天数包只包含 `docker-compose-iluvatar.yml`
- 摩尔线程包只包含 `docker-compose-mthreads.yml`
- `.env` 模板
- 宿主机目录初始化脚本
- 离线部署说明文档
模型文件建议使用 `./scripts/download-models.sh --models-dir /opt/dep/asr/models` 单独准备;该脚本增量补齐缺失模型,不删除已有目录,也不依赖 uv。
当前运行时 / 设备默认值以主 README 为准:
- `README.md`
- `docs/README_zh.md`
设计背景与实现思路可参考:
- 当前 Qwen3 后端:`NVIDIA/沐曦 GPU -> vLLM`、`CPU/macOS -> vendored QwenASR Rust`
- 引用项目 [QwenASR](https://github.com/huanglizhuo/QwenASR)
### macOS / Apple Silicon 本地部署
适用于 M1/M2/M3/M4 机器上的本地 Qwen3-ASR 推理。当前 macOS 已统一走 vendored QwenASR Rust CPU backend。
```bash
./scripts/sync_cpu_env.sh
source .venv/bin/activate
python start.py
```
### 验证部署
```bash
# 健康检查
curl http://localhost:17003/stream/v1/asr/health
# 查看可用模型
curl http://localhost:17003/stream/v1/asr/models
# 测试语音识别(阿里云协议)
curl -X POST "http://localhost:17003/stream/v1/asr" \
-H "Content-Type: application/octet-stream" \
--data-binary @test.wav
# 测试 OpenAI 兼容接口
curl -X POST "http://localhost:17003/v1/audio/transcriptions" \
-H "Authorization: Bearer any" \
-F "file=@test.wav" \
-F "model=qwen3-asr-1.7b"
```
## 从源码构建镜像
### 使用构建脚本
项目提供了一个更薄的 `scripts/build_docker.sh` 包装层,用于统一 `docker buildx` 参数:
```bash
# 构建所有版本(CPU + GPU)
./scripts/build_docker.sh
# 仅构建 GPU 版本
./scripts/build_docker.sh -t gpu
# 构建指定版本并推送
./scripts/build_docker.sh -t all -v 1.0.1 -p
# 查看帮助
./scripts/build_docker.sh -h
```
**构建脚本参数:**
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `-a, --arch` | 目标架构: `amd64`, `arm64`, `multi` | `amd64` |
| `-t, --type` | 构建类型: `cpu`, `gpu`, `all` | `all` |
| `-v, --version` | 版本标签 | `latest` |
| `-p, --push` | 构建后推送到 Docker Hub | 否 |
| `-e, --export` | 导出单架构镜像为 tar.gz | 否 |
| `-o, --output` | 导出目录 | `.` |
| `-r, --registry` | 镜像仓库 | `unis` |
| `-n, --no-cache` | 禁用 Docker 构建缓存 | 否 |
### 手动构建
```bash
# 构建 CPU 版本
docker build -t qwen3-asr:cpu-latest -f Dockerfile.cpu .
# 构建绑定当前机器指令集的 CPU 版本(仅适合同构部署)
docker build -t qwen3-asr:cpu-native -f Dockerfile.cpu \
--build-arg QWENASR_RUST_TARGET_CPU=native \
.
# 构建默认 GPU 版本(CUDA 13.0 / PyTorch cu130)
docker build -t qwen3-asr:gpu-cu130 -f Dockerfile.gpu .
# 构建 CUDA 12.6 版本
docker build -t qwen3-asr:gpu-cu126 -f Dockerfile.gpu \
--build-arg PYTORCH_BASE_IMAGE=pytorch/pytorch:2.11.0-cuda12.6-cudnn9-runtime \
--build-arg PYTORCH_CUDA_INDEX=https://download.pytorch.org/whl/cu126 \
--build-arg CUDA_NVCC_PACKAGE=cuda-nvcc-12-6 \
--build-arg TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9" \
.
# 构建 CUDA 13.0 版本
docker build -t qwen3-asr:gpu-cu130 -f Dockerfile.gpu \
--build-arg PYTORCH_BASE_IMAGE=pytorch/pytorch:2.11.0-cuda13.0-cudnn9-runtime \
--build-arg PYTORCH_CUDA_INDEX=https://download.pytorch.org/whl/cu130 \
--build-arg CUDA_NVCC_PACKAGE=cuda-nvcc-13-0 \
--build-arg TORCH_CUDA_ARCH_LIST="12.0+PTX" \
.
```
`Dockerfile.cpu` 可覆盖的 CPU 构建参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `QWENASR_RUST_TARGET_CPU` | `x86-64-v2` | amd64 Rust backend 编译目标;可设为 `native` 构建绑定当前 CPU 的镜像 |
`Dockerfile.gpu` 可覆盖的 GPU 构建参数:
| 参数 | 默认值 | 用途 |
|------|--------|------|
| `PYTORCH_BASE_IMAGE` | `pytorch/pytorch:2.11.0-cuda13.0-cudnn9-runtime` | 选择 PyTorch/CUDA 基础镜像 |
| `PYTORCH_CUDA_INDEX` | `https://download.pytorch.org/whl/cu130` | 选择 PyTorch wheel CUDA 后端 |
| `CUDA_NVCC_PACKAGE` | `cuda-nvcc-13-0` | 安装匹配的 nvcc,用于 vLLM/FlashInfer JIT |
| `TORCH_CUDA_ARCH_LIST` | `12.0+PTX` | 指定 JIT 编译目标架构 |
| `VLLM_PACKAGE` | `vllm==0.20.0` | 覆盖 vLLM 包版本或来源 |
### 模型说明
服务支持以下 ASR 模型:
| 模型 | 说明 | 适用场景 |
|------|------|----------|
| Qwen3-ASR-1.7B ⭐ | 多语言 ASR(52种语言+方言,字级时间戳) | CUDA |
| Qwen3-ASR-0.6B | 轻量版多语言 ASR | CUDA / CPU Rust / macOS |
**运行时模型选择:**
系统根据机器资源自动选择合适的 Qwen3-ASR 模型:
- **显存 >= 32GB**: 自动加载 `qwen3-asr-1.7b`
- **显存 < 32GB**: 自动加载 `qwen3-asr-0.6b`
- **无 CUDA**: 自动加载基于 vendored Rust 的 `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: 无论内存大小多少,默认都加载 `qwen3-asr-0.6b`
- **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 或 `QWEN3_ASR_MODEL=qwen3-asr-0.6b` 可硬覆盖自动选择
### 模型下载
启动时会先检测当前运行计划所需模型;如果本地缓存缺失,会自动从 ModelScope 下载。离线部署请提前准备模型缓存。
手动准备方式:
```bash
# 增量补齐离线部署所需模型,不删除已有文件
./scripts/download-models.sh --models-dir /opt/dep/asr/models
# 如果本机缺少 Python 依赖,也可以使用已构建镜像下载
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
./scripts/download-models.sh --mode docker --models-dir /opt/dep/asr/models
```
离线部署时,推荐目录结构:
```text
/opt/dep/asr/models/
Qwen/
iic/
damo/
```
然后保持与 compose 文件一致的挂载:
```yaml
volumes:
- ${MODEL_STORAGE_DIR:-/opt/dep/asr/models}:/app/models
- ${DATA_STORAGE_DIR:-/opt/dep/asr/data}:/app/data
```
## 环境变量配置
### 基础配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `HOST` | `0.0.0.0` | 服务绑定地址 |
| `PORT` | `8000` | 服务端口 |
| `DEBUG` | `false` | 调试模式(启用后可访问 /docs) |
| `LOG_LEVEL` | `INFO` | 日志级别:DEBUG, INFO, WARNING, ERROR |
| `WORKERS` | `1` | 工作进程数(多进程会复制模型,显存成倍增加) |
| `MAX_AUDIO_SIZE` | `2048` | 最大音频文件大小(MB,支持单位如 2GB) |
| `API_KEY` | - | 服务端统一鉴权密钥 |
### 设备配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `DEVICE` | `auto` | 设备选择:`auto`, `cpu`, `cuda:0` |
| `ASR_VISIBLE_DEVICES` | `0` | 统一可见 GPU 设备配置,程序会按当前 accelerator 自动映射到底层变量 |
| `ASR_DEPLOY_TOPOLOGY` | `isolated` | 部署拓扑:`isolated`, `sharded`, `auto` |
### 内置 Nginx 与限流配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `NGINX_RATE_LIMIT_RPS` | `0` | 全局每秒请求上限,`0` 表示关闭 |
| `NGINX_RATE_LIMIT_BURST` | `0` | 全局突发请求数,`0` 时自动取 `NGINX_RATE_LIMIT_RPS` |
### ASR 模型配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_ENABLE_REALTIME_PUNC` | `true` | 是否启用实时标点模型 |
### 性能优化配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_BATCH_SIZE` | `4` | 长音频分段后的 ASR 批处理大小 |
| `INFERENCE_THREAD_POOL_SIZE` | 自动 | 推理线程池大小;默认按 CPU 核数自动设置 |
| `MAX_SEGMENT_SEC` | `60` | 音频分段最大时长(秒) |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | vLLM 可保留的 GPU 显存上限,KV cache 不足时可适当调高 |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | 强制 vLLM eager 执行以提高兼容性;NVIDIA 性能测试可设为 `false` 允许 CUDA Graph 优化 |
| `WS_MAX_BUFFER_SIZE` | `160000` | WebSocket 音频缓冲区大小(样本数) |
| `QWEN_RUST_CPU_WORKERS` | `4` | CPU Rust backend worker 数;Rust ASR / forced align 默认按该数量并行 |
| `QWEN_RUST_ASR_CONCURRENCY` | `0` | Rust ASR 阶段批内并行度;`0` 表示跟随 `QWEN_RUST_CPU_WORKERS` |
| `QWEN_RUST_ALIGN_CONCURRENCY` | `0` | Rust forced align 阶段批内并行度;`0` 表示跟随 `QWEN_RUST_CPU_WORKERS` |
| `QWENASR_LIBRARY_PATH` | 自动探测 | 覆盖 vendored Rust 动态库路径 |
### 远场过滤配置
流式 ASR 远场声音过滤功能,自动过滤远场声音和环境音:
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | 启用远场声音过滤 |
| `ASR_NEARFIELD_RMS_THRESHOLD` | `0.01` | RMS 能量阈值 |
| `LOG_LEVEL=DEBUG` | - | 需要观察过滤细节时打开调试日志 |
调优建议:
- `ASR_NEARFIELD_RMS_THRESHOLD=0.01` 是当前默认值,也是推荐起点
- 嘈杂环境可以适当调高,增强背景语音过滤
- 安静环境如果出现小声说话漏识别,可以适当调低
- 需要观察过滤行为时,可临时设置 `LOG_LEVEL=DEBUG`
### 鉴权配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `API_KEY` | - | 服务端统一鉴权密钥;同时兼容 `Authorization: Bearer` 和 `X-NLS-Token` |
**使用示例:**
```bash
# 使用 Token
curl -H "X-NLS-Token: your_token" http://localhost:8000/stream/v1/asr/health
# 使用 Bearer Token(OpenAI 兼容)
curl -H "Authorization: Bearer your_token" http://localhost:8000/v1/models
```
### 日志配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `LOG_LEVEL` | `INFO` | 日志级别:`DEBUG`, `INFO`, `WARNING` |
| `LOG_FILE` | `data/logs/qwen3-asr.log` | 日志文件路径 |
| `LOG_MAX_BYTES` | `20971520` | 单个日志文件最大大小(20MB) |
| `LOG_BACKUP_COUNT` | `50` | 日志备份文件数量 |
## Docker Compose 配置
### 基础配置(GPU)
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:gpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=auto
- QWEN_GPU_MEMORY_UTILIZATION=0.3
- QWEN_VLLM_ENFORCE_EAGER=true
- ASR_BATCH_SIZE=4
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=4
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
### CPU 版本配置
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:cpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=cpu
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=1
restart: unless-stopped
```
### 生产环境配置(内置 Nginx,推荐)
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:gpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=auto
- CUDA_VISIBLE_DEVICES=0,1
- QWEN_GPU_MEMORY_UTILIZATION=0.3
- QWEN_VLLM_ENFORCE_EAGER=true
- NGINX_RATE_LIMIT_RPS=20
- NGINX_RATE_LIMIT_BURST=40
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=4
- ASR_BATCH_SIZE=4
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
## 服务监控
### 健康检查
```bash
curl http://localhost:17003/stream/v1/asr/health
```
### 日志监控
```bash
# 实时查看日志
docker logs -f qwen3-asr
# 查看错误日志
docker logs qwen3-asr 2>&1 | grep -i error
```
### 资源监控
```bash
# 容器资源使用
docker stats qwen3-asr
# GPU 使用情况
docker exec -it qwen3-asr nvidia-smi
```
## 资源需求
### 最小配置(CPU 版本)
- CPU: 4 核
- 内存: 8GB
- 磁盘: 10GB
### 推荐配置(GPU 版本)
- CPU: 8 核
- 内存: 16GB
- GPU: NVIDIA GPU (12GB+ 显存,含说话人分离模型)
- 磁盘: 25GB
## 故障排除
### 常见问题
| 问题 | 症状 | 解决方案 |
|------|------|----------|
| GPU 内存不足 | CUDA OOM 错误 | 设置 `DEVICE=cpu` 或使用更大显存的 GPU |
| 模型加载失败 / 缓慢 | 本地模型缓存缺失 | 先运行 `./scripts/download-models.sh --models-dir /opt/dep/asr/models` 预准备模型 |
| 端口被占用 | 端口冲突错误 | 修改端口映射:`"8080:8000"` |
| 说话人分离失败 | CAM++ 模型错误 | 检查模型是否完整下载,显存是否充足 |
### 调试模式
```bash
# 启用调试模式
docker run -e DEBUG=true -e LOG_LEVEL=DEBUG ...
# 进入容器调试
docker exec -it qwen3-asr /bin/bash
```
## 更新服务
```bash
# 拉取最新镜像(GPU 版本)
docker pull unis/qwen3-asr:gpu-latest
# 拉取最新镜像(CPU 版本)
docker pull unis/qwen3-asr:cpu-latest
# 重启服务
docker-compose down && docker-compose up -d
```

View File

@ -1,242 +0,0 @@
# 天数 GPU 国产化离线部署指南
本文档用于天数/Iluvatar GPU 环境的编译、模型准备、离线包导出与目标机部署。
## 推荐基础镜像
天数默认推荐使用以下官方 vLLM 镜像作为基础镜像:
```bash
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
```
该镜像负责提供天数 IX 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.iluvatar` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 目录约定
目标机推荐统一使用以下宿主机目录:
```text
/opt/dep/asr/
models/
data/
logs/
temp/
tasks/
```
容器内默认挂载为:
```text
/app/models
/app/data
```
## 在线编译融合镜像
在可访问天数镜像仓库和 Python 包源的构建机上执行:
```bash
docker pull registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./scripts/package_vendor_gpu_image.sh \
--vendor iluvatar \
--base-image registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
```
脚本会生成融合镜像:
```text
unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5
```
并导出镜像归档到:
```text
build-file/qwen3-asr-iluvatar-vllm0.17.0-4.4.0-v5-amd64.tar.gz
```
如果只需要本机镜像,不需要导出 tar 包,可增加 `--no-export`。
## 单独准备模型
模型下载建议独立于业务服务执行。`download-models.sh` 是增量下载脚本,不会删除已有模型目录,也不依赖 uv。
在项目根目录或离线包目录执行:
```bash
./scripts/download-models.sh --models-dir /opt/dep/asr/models
```
如果本机没有 Python 依赖,但已经有融合镜像,可以用镜像内环境下载:
```bash
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
./scripts/download-models.sh \
--mode docker \
--models-dir /opt/dep/asr/models
```
模型目录最终应至少包含:
```text
/opt/dep/asr/models/
Qwen/
Qwen3-ASR-0.6B/
Qwen3-ForcedAligner-0.6B/
damo/
iic/
```
## 导出完整离线交付包
如果要交付给不能联网的目标机,推荐直接导出天数专用离线包:
```bash
./export_offline_bundle.sh \
--type iluvatar \
--iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
```
如果模型要单独准备,不希望离线包包含模型压缩包:
```bash
./export_offline_bundle.sh \
--type iluvatar \
--iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5 \
--skip-models
```
天数离线包只会包含天数专用启动文件:
```text
docker-compose-iluvatar.yml
.env
.env.example
init_host_dirs.sh
download-models.sh
download_models_standalone.py
README.md
DEPLOYMENT.md
BUNDLE_INFO.txt
qwen3-asr-iluvatar-*-amd64.tar.gz
```
不会再要求使用通用 `docker-compose.yml`。
## 目标机离线部署
将整个离线包复制到目标机后,进入离线包目录:
```bash
chmod +x init_host_dirs.sh download-models.sh
./init_host_dirs.sh
```
导入镜像:
```bash
gunzip -c qwen3-asr-iluvatar-vllm0.17.0-4.4.0-v5-amd64.tar.gz | docker load
```
确认 `.env` 中的镜像名与导入镜像一致:
```env
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5
```
按需设置显卡与 vLLM 显存比例:
```env
ILUVATAR_VISIBLE_DEVICES=0
IX_VISIBLE_DEVICES=0
CUDA_VISIBLE_DEVICES=0
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
启动服务:
```bash
docker compose -f docker-compose-iluvatar.yml up -d
```
查看状态与日志:
```bash
docker compose -f docker-compose-iluvatar.yml ps
docker compose -f docker-compose-iluvatar.yml logs -f
```
服务默认监听 host 网络端口:
```text
http://<目标机IP>:17003
```
## 已导出的旧离线包处理
如果旧离线包里的镜像已经能识别 `Qwen3ASRForConditionalGeneration`,但启动时报 KV cache 不足,例如:
```text
Try increasing gpu_memory_utilization or decreasing max_model_len
```
不需要重新打镜像。只需要在旧离线包的 `docker-compose-iluvatar.yml` 的 `QWEN3_ASR_MODEL` 附近补充:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-0.25}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后重新创建容器:
```bash
docker compose -f docker-compose-iluvatar.yml down
docker compose -f docker-compose-iluvatar.yml up -d
```
只执行 `restart` 不会重新注入环境变量。
## 常见问题
### 为什么不用 uv?
天数 Docker 镜像内推荐直接使用系统 Python 环境。`Dockerfile.iluvatar` 使用:
```bash
python3 -m pip install --no-cache-dir -r environments/iluvatar/requirements.txt
```
不创建 uv 虚拟环境,也不在镜像内运行 `uv pip install`。
### 为什么不重新安装 vLLM?
国产 GPU 的 vLLM、PyTorch、内核和运行时通常需要严格匹配厂商镜像。项目层重新 `pip install vllm` 容易解析到 PyPI/NVIDIA CUDA 依赖,破坏天数官方镜像里的匹配关系。
### `QWEN_GPU_MEMORY_UTILIZATION` 为什么没生效?
变量必须进入容器才会生效。天数 compose 中需要有:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后在 `.env` 设置:
```env
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
修改 `.env` 后必须 `down` 再 `up -d`。
### 日志中的本地模型 repo id warning 是否致命?
vLLM 可能会先尝试按远端 repo 方式读取 safetensors,遇到本地路径时打印 warning。如果后续出现 `Loading safetensors checkpoint shards` 并继续加载权重,通常不是致命错误。
真正需要处理的是最后的异常,例如模型架构不识别、KV cache 不足、模型文件缺失等。

View File

@ -1,234 +0,0 @@
# 沐曦 GPU 国产化离线部署指南
本文档用于沐曦/MetaX GPU 环境的编译、模型准备、离线包导出与目标机部署。
## 基础镜像原则
沐曦部署应使用沐曦官方或现场确认的 vLLM/MACA 基础镜像:
```bash
cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64
```
该镜像负责提供 MACA 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.metax` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 目录约定
目标机推荐统一使用以下宿主机目录:
```text
/opt/dep/asr/
models/
data/
logs/
temp/
tasks/
```
容器内默认挂载为:
```text
/app/models
/app/data
```
## 在线编译融合镜像
先按沐曦官网复制的命令登录并拉取基础镜像。账号、密码和 token 不要写入项目文件、`.env` 或离线包。
官网命令通常形如:
```bash
docker login --username=<沐曦账号> --password=<沐曦token> cr.metax-tech.com && \
docker pull cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64
```
如果沐曦基础镜像是 tar 包,则改为先导入:
```bash
docker load -i metax-vllm-official.tar
docker images | grep -i -E 'metax|maca|vllm'
```
然后用完整官方镜像名构建融合镜像:
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor metax \
--base-image cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307
```
脚本会生成融合镜像:
```text
unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307
```
并导出镜像归档到:
```text
build-file/qwen3-asr-metax-0.17.0-maca.ai3.5.3.307-amd64.tar.gz
```
## 单独准备模型
模型下载建议独立于业务服务执行。`download-models.sh` 是增量下载脚本,不会删除已有模型目录,也不依赖 uv。
在项目根目录或离线包目录执行:
```bash
./scripts/download-models.sh --models-dir /opt/dep/asr/models
```
如果本机没有 Python 依赖,但已经有融合镜像,可以用镜像内环境下载:
```bash
ASR_IMAGE=unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307 \
./scripts/download-models.sh \
--mode docker \
--models-dir /opt/dep/asr/models
```
## 导出完整离线交付包
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307
```
如果模型要单独准备,不希望离线包包含模型压缩包:
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307 \
--skip-models
```
如果不指定 `-v`,脚本会使用当前时间戳作为版本号。
沐曦官方镜像内的 Python 默认使用 `/opt/conda/bin/python`。如果现场镜像路径不同,可额外指定:
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
--metax-python /path/to/python \
--skip-models
```
也可以先给官方长镜像名打一个本地短标签,再把短标签传给 `--metax-base`;这只是为了少复制长镜像名,不是必需步骤。
沐曦离线包只会包含沐曦专用启动文件:
```text
docker-compose-metax.yml
.env
.env.example
init_host_dirs.sh
download-models.sh
download_models_standalone.py
README.md
DEPLOYMENT.md
METAX_DEPLOYMENT.md
BUNDLE_INFO.txt
qwen3-asr-metax-*-amd64.tar.gz
```
不会要求使用通用 `docker-compose.yml`。
## 目标机离线部署
将整个离线包复制到目标机后,进入离线包目录:
```bash
chmod +x init_host_dirs.sh download-models.sh
./init_host_dirs.sh
```
导入镜像:
```bash
gunzip -c qwen3-asr-metax-0.17.0-maca.ai3.5.3.307-amd64.tar.gz | docker load
```
确认 `.env` 中的镜像名与导入镜像一致:
```env
ASR_IMAGE=unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307
```
按需设置显卡与 vLLM 显存比例:
```env
METAX_VISIBLE_DEVICES=0
MACA_VISIBLE_DEVICES=0
MX_VISIBLE_DEVICES=0
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
启动服务:
```bash
docker compose -f docker-compose-metax.yml up -d
```
查看状态与日志:
```bash
docker compose -f docker-compose-metax.yml ps
docker compose -f docker-compose-metax.yml logs -f
```
服务默认端口:
```text
http://<目标机IP>:17003
```
## 沐曦与天数的差异
- 沐曦使用 `Dockerfile.metax` 和 `docker-compose-metax.yml`
- 天数使用 `Dockerfile.iluvatar` 和 `docker-compose-iluvatar.yml`
- 沐曦目标机需要可访问宿主 `/dev` 与 `/opt/mxdriver`
- 沐曦 compose 使用 host network、host pid/ipc、privileged,并挂载 `/dev` 与 `/opt/mxdriver`
- 天数 compose 同样使用 host network、host pid/ipc、privileged 和宿主机设备/驱动目录挂载
## 常见问题
### 为什么不用 uv?
沐曦 Docker 镜像内推荐直接使用系统 Python 环境。`Dockerfile.metax` 使用:
```bash
python3 -m pip install --no-cache-dir -r environments/metax/requirements.txt
```
不创建 uv 虚拟环境,也不在镜像内运行 `uv pip install`。
### 为什么不重新安装 vLLM?
国产 GPU 的 vLLM、PyTorch、内核和运行时通常需要严格匹配厂商镜像。项目层重新 `pip install vllm` 容易解析到 PyPI/NVIDIA CUDA 依赖,破坏沐曦官方镜像里的匹配关系。
### `QWEN_GPU_MEMORY_UTILIZATION` 为什么没生效?
变量必须进入容器才会生效。沐曦 compose 中需要有:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后在 `.env` 设置:
```env
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
修改 `.env` 后必须 `down` 再 `up -d`。

View File

@ -1,53 +0,0 @@
# 摩尔线程 GPU 国产化离线部署指南
推荐使用摩尔线程官方 MUSA vLLM 基础镜像:
```bash
registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
该镜像负责提供 MUSA 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.mthreads` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 1. 拉取官方镜像
```bash
docker pull registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
## 2. 生成融合镜像
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor mthreads \
--base-image registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519 \
-v s4000_4.3.5_d0519
```
## 3. 启动服务
```bash
ASR_IMAGE=unis/qwen3-asr:mthreads-s4000_4.3.5_d0519 \
docker compose -f docker-compose-mthreads.yml up -d
```
多卡示例:
```bash
MTHREADS_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-mthreads.yml up -d
```
项目默认同时兼容 `MTHREADS_VISIBLE_DEVICES`、`MUSA_VISIBLE_DEVICES` 和 `CUDA_VISIBLE_DEVICES`。
## 4. 离线交付目录
```bash
./export_offline_bundle.sh \
--type mthreads \
--mthreads-base registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
## 说明
- 容器内设备探测命令使用 `mthreads-gmi`
- `ACCELERATOR=mthreads` 会走 vLLM GPU 路径
- 不建议在项目层重新 `pip install vllm`,避免破坏厂商镜像内的 MUSA 依赖匹配关系

View File

@ -1,412 +0,0 @@
# 实时会议识别改造报告
## 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
<https://docs.vllm.ai/en/v0.20.1/api/vllm/model_executor/models/qwen3_asr_realtime/>
- vLLM Qwen3-ASR recipe
<https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-ASR.html>
- diart
<https://github.com/juanmc2005/diart>
- pyannote.audio
<https://github.com/pyannote/pyannote-audio>
- NVIDIA NeMo speaker diarization docs
<https://docs.nvidia.com/nemo-framework/user-guide/latest/nemotoolkit/asr/speaker_diarization/models.html>
- NVIDIA Streaming Sortformer blog
<https://developer.nvidia.com/blog/identify-speakers-in-meetings-calls-and-voice-apps-in-real-time-with-nvidia-streaming-sortformer/>
- Low-Latency Online Speaker Diarization with Graph-Based Label Generation
<https://arxiv.org/abs/2111.13803>
- TURN-TO-DIARIZE: Online Speaker Diarization Constrained by Transformer Transducer Speaker Turn Detection
<https://resourcecenter.ieee.org/conferences/icassp-2022/spsicassp22vid1565>
- Analyzing the Quality and Stability of a Streaming End-to-End On-Device Speech Recognizer
<https://arxiv.org/abs/2006.01416>
- Improving endpoint detection in end-to-end streaming ASR for conversational speech
<https://arxiv.org/abs/2505.17070>

View File

@ -1,417 +0,0 @@
# 实时会议 WebSocket 调用说明
本文档说明当前项目实时会议识别接口的调用方式、入参与返回格式。
## 1. 接口地址
- 推荐地址:`/ws/v1/asr`
- 显式地址:`/ws/v1/asr/qwen`
说明:
- `/ws/v1/asr/funasr` 已废弃,不再使用。
- 当前返回结构按腾讯实时识别风格组织,核心消息类型为:`voice_id`、`start`、`sentences`、`end`、`error`。
## 2. 交互流程
客户端调用顺序:
1. 建立 WebSocket 连接
2. 发送 `start` 消息
3. 持续发送音频二进制数据
4. 接收服务端返回的 `voice_id`、`start`、`sentences`
5. 发送 `stop`
6. 接收最终 `end`
## 3. start 消息
客户端先发送文本消息:
```json
{
"type": "start",
"payload": {
"format": "pcm",
"sample_rate": 16000,
"session_id": "meeting-abc-123",
"language": null,
"context": "",
"enable_inverse_text_normalization": true,
"silence_duration_ms": 800,
"min_partial_sec": 0.3,
"pre_roll_ms": 240,
"max_sentence_count": 8,
"partial_holdback_chars": 2,
"unfixed_token_num": 3,
"enable_native_partial_stream": true,
"enable_speaker": true,
"match_speaker_registry": true,
"speaker_threshold": 0.6,
"enable_realtime_vad_split": true,
"enable_realtime_longform": false,
"force_stable_segment_sec": 6,
"force_stable_min_chars": 24,
"max_segment_sec": 12
}
}
```
## 4. start 参数说明
### 必填/常用参数
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `format` | string | `pcm` | 当前建议传 `pcm` |
| `sample_rate` | number | `16000` | 采样率 |
| `session_id` | string | 自动生成/可不传 | 会话标识;断线重连时传同一个值可在保活期内恢复同一会议会话 |
| `language` | string/null | `null` | 识别语言,留空表示自动判断 |
| `context` | string | `""` | 上下文提示,如会议主题、术语 |
| `enable_inverse_text_normalization` | boolean | `true` | 是否启用数字归一化 |
### partial / 切段相关
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `silence_duration_ms` | number | `800` | 静音多久触发收段 |
| `min_partial_sec` | number | `0.9`(后端默认) | 最短 partial 窗口 |
| `pre_roll_ms` | number | `240` | 句首预读毫秒数 |
| `max_sentence_count` | number | `8` | 一段内最多允许的句子数 |
| `partial_holdback_chars` | number | 后端默认 | partial 尾部保留字数,减少尾字抖动 |
| `unfixed_token_num` | number | `5`(后端默认) | native partial 模式下回滚 token 数 |
| `enable_native_partial_stream` | boolean | `false` | 是否启用底层原生流式 partial |
| `enable_realtime_vad_split` | boolean | `false` | 是否启用实时 VAD 拆段 |
| `enable_realtime_longform` | boolean | `false` | 是否启用长段重转写 |
| `force_stable_segment_sec` | number | 后端默认 | 超时后软提交的时间阈值 |
| `force_stable_min_chars` | number | 后端默认 | 软提交最少字数 |
| `max_segment_sec` | number | 后端默认 | 单段最长秒数 |
### speaker 相关
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enable_speaker` | boolean | `true` | 是否开启说话人分离 |
| `match_speaker_registry` | boolean | `false` | 是否匹配已注册声纹库 |
| `speaker_threshold` | number/null | `null` | 本次声纹匹配阈值 |
说明:
- `enable_speaker=true` 时,服务端会先返回文本,再异步补充 speaker 归属。
- `match_speaker_registry=true` 时,若匹配到声纹库,会返回实名 speaker 信息。
## 5. 音频数据发送
`start` 成功后,客户端持续发送音频二进制帧。
当前推荐:
- 单声道 PCM
- 16k 采样率
- 小块持续发送
示例:
```js
ws.send(pcmChunkArrayBuffer);
```
## 6. stop 消息
识别结束后发送:
```json
{
"type": "stop"
}
```
## 7. 服务端返回消息
## 7.1 `voice_id`
连接启动后首先返回:
```json
{
"type": "voice_id",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123"
}
```
## 7.2 `start`
服务端确认开始识别:
```json
{
"type": "start",
"session_id": "meeting-abc-123"
}
```
说明:
- 若客户端在 `start.payload.session_id` 中传入固定值,服务端会优先复用该值。
- 若客户端未传 `session_id`,服务端仍按当前连接生成 `voice_id`,并同步作为 `session_id` 返回。
- 在 `REALTIME_SESSION_RESUME_TTL_SEC` 保活时间内,客户端断线后使用相同 `session_id` 再次发送 `start`,服务端会恢复原会话上下文。
## 7.3 `sentences`
识别过程中会持续返回 `sentences`。
重要说明:
- `sentence_type=0` / `slice_type=1` 表示实时 partial,仅用于实时展示。
- `sentence_type=1` / `slice_type=2` 表示该段最终定稿,建议作为最终业务入库依据。
- 若开启 `enable_native_partial_stream=true`,partial 刷新会更快,但中间文本可能更活。
- 若希望少存数据,建议不要存 partial,只对 `sentence_type=1` 做落库或 upsert。
### partial 返回
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 1,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 0,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15600,
"sentence": "今天这个会议主要讨论预算。"
}
]
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `slice_type=1` | partial |
| `sentence_type=0` | partial 句子 |
| `speaker_id=-1` | 当前还未确认 speaker |
### 定稿返回
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "",
"user_id": null
}
]
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `slice_type=2` | 该段文本已定稿 |
| `sentence_type=1` | final 句子 |
| `speaker_name` | 项目扩展字段 |
| `user_id` | 项目扩展字段 |
### speaker 回写返回
如果 speaker 后续识别完成,服务端会再次返回相同 `sentence_id` 的 `sentences` 消息,只更新 speaker 归属:
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
说明:
- speaker 是异步补归属,不保证和文本首个 final 同时返回。
- 前端应使用 `sentence_id` 做幂等更新,而不是简单追加。
## 7.3.1 partial 与 final 的使用建议
推荐使用方式:
- 前端实时显示:使用 `sentence_type=0`
- 最终文本入库:使用 `sentence_type=1`
- 说话人归属更新:继续按相同 `sentence_id` 更新已有 final 记录
推荐入库字段:
- `sentence_id`
- `start_time`
- `end_time`
- `sentence`
- `speaker_id`
- `speaker_name`
- `user_id`
推荐入库策略:
1. 收到 `sentences` 后,仅处理 `sentence_type=1`
2. 使用 `sentence_id` 作为同一连接内的幂等更新键
3. 若后续相同 `sentence_id` 再次返回,通常表示 speaker 归属补写,直接更新原记录
4. 收到 `end` 后,可将本次会话的 final 结果视为最终完成结果
## 7.4 `end`
客户端发送 `stop` 后,服务端返回最终结束消息:
```json
{
"type": "end",
"code": 0,
"message": "",
"voice_id": "41ff1926",
"final": 1,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "完整识别文本"
},
"sentences": [
{
"sentence_id": 0,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 0,
"end_time": 3200,
"sentence": "第一句",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
## 7.5 `error`
异常时返回:
```json
{
"type": "error",
"code": -1,
"message": "错误信息",
"voice_id": "41ff1926"
}
```
## 8. 腾讯风格字段兼容说明
当前实时会议返回格式按腾讯实时 speaker demo 风格组织,核心字段保持一致:
- `type`
- `code`
- `voice_id`
- `final`
- `result.slice_type`
- `result.index`
- `result.voice_text_str`
- `sentences[].sentence_id`
- `sentences[].sentence_type`
- `sentences[].speaker_id`
- `sentences[].start_time`
- `sentences[].end_time`
- `sentences[].sentence`
在此基础上,项目额外补充:
- `sentences[].speaker_name`
- `sentences[].user_id`
## 9. JavaScript 调用示例
```js
const ws = new WebSocket("ws://127.0.0.1:8000/ws/v1/asr");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "start",
payload: {
format: "pcm",
sample_rate: 16000,
language: null,
context: "预算评审会",
enable_inverse_text_normalization: true,
silence_duration_ms: 800,
min_partial_sec: 0.3,
pre_roll_ms: 240,
max_sentence_count: 8,
partial_holdback_chars: 2,
unfixed_token_num: 3,
enable_native_partial_stream: true,
enable_speaker: true,
match_speaker_registry: true,
speaker_threshold: 0.6,
enable_realtime_vad_split: true,
enable_realtime_longform: false,
force_stable_segment_sec: 6,
force_stable_min_chars: 24,
max_segment_sec: 12
}
}));
};
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
console.log("ws message:", payload);
};
function sendPcmChunk(arrayBuffer) {
ws.send(arrayBuffer);
}
function stopRecognition() {
ws.send(JSON.stringify({ type: "stop" }));
}
```
## 10. 对接注意事项
- 前端要按 `sentence_id` 更新句子,不要把 speaker 回写当成新句子追加。
- `speaker_id=-1` 代表 speaker 暂未确认,不代表识别失败。
- 默认启用 `enable_native_partial_stream`;partial 刷新更快,但中间文本可能会修订。
- 最终展示建议以 `sentence_type=1` 的句子为准。
- 若需要排查前端是否真的传了某个参数,建议把 `start payload` 直接打印到页面日志。

View File

@ -1,297 +0,0 @@
# 实时会议 WebSocket 对接文档
本文档用于客户侧接入实时会议识别服务,包含接口地址、消息格式、返回示例和重连约定。
## 1. 接口地址
- 推荐地址:`/ws/v1/asr`
- 显式地址:`/ws/v1/asr/qwen`
说明:
- 当前协议为 JSON 控制消息 + 音频二进制流。
- 返回结构按实时会议场景组织,核心消息类型为:`voice_id`、`start`、`sentences`、`end`、`error`。
## 2. 交互流程
客户端调用顺序:
1. 建立 WebSocket 连接
2. 发送 `start` 文本消息
3. 持续发送音频二进制数据
4. 持续接收服务端返回的 `voice_id`、`start`、`sentences`
5. 识别结束时发送 `stop`
6. 接收最终 `end`
## 3. start 请求
### 3.1 请求示例
```json
{
"type": "start",
"payload": {
"format": "pcm",
"sample_rate": 16000,
"session_id": "meeting-abc-123",
"language": null,
"context": "",
"enable_inverse_text_normalization": true,
"silence_duration_ms": 800,
"min_partial_sec": 0.3,
"pre_roll_ms": 240,
"max_sentence_count": 8,
"partial_holdback_chars": 2,
"unfixed_token_num": 3,
"enable_native_partial_stream": true,
"enable_speaker": true,
"match_speaker_registry": true,
"speaker_threshold": 0.6,
"enable_realtime_vad_split": true,
"enable_realtime_longform": false,
"force_stable_segment_sec": 6,
"force_stable_min_chars": 24,
"max_segment_sec": 12
}
}
```
### 3.2 主要参数说明
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `format` | string | `pcm` | 建议传 `pcm` |
| `sample_rate` | number | `16000` | 音频采样率 |
| `session_id` | string | 可不传 | 会话标识;断线重连时传同一个值可恢复同一会议会话 |
| `language` | string/null | `null` | 留空表示自动识别 |
| `context` | string | `""` | 业务上下文提示,如会议主题、术语 |
| `enable_inverse_text_normalization` | boolean | `true` | 是否启用数字归一化 |
| `silence_duration_ms` | number | `800` | 静音多久触发收段 |
| `min_partial_sec` | number | 后端默认 | 最短 partial 窗口 |
| `pre_roll_ms` | number | `240` | 句首预读时长 |
| `max_sentence_count` | number | `8` | 单段最多句数 |
| `partial_holdback_chars` | number | 后端默认 | partial 尾部保留字数,减少尾字抖动 |
| `unfixed_token_num` | number | 后端默认 | native partial 模式下回滚 token 数 |
| `enable_native_partial_stream` | boolean | `false` | 是否启用原生流式 partial |
| `enable_speaker` | boolean | `true` | 是否开启说话人分离 |
| `match_speaker_registry` | boolean | `false` | 是否匹配已注册声纹库 |
| `speaker_threshold` | number/null | `null` | 本次声纹匹配阈值 |
| `enable_realtime_vad_split` | boolean | `false` | 是否启用实时 VAD 拆段 |
| `enable_realtime_longform` | boolean | `false` | 是否启用长段重转写 |
| `force_stable_segment_sec` | number | 后端默认 | 超时后软提交阈值 |
| `force_stable_min_chars` | number | 后端默认 | 软提交最少字数 |
| `max_segment_sec` | number | 后端默认 | 单段最长秒数 |
说明:
- `enable_speaker=true` 时,服务端会先返回文本,再异步补充 speaker 归属。
- `match_speaker_registry=true` 时,若匹配到声纹库,会返回实名 speaker 信息。
## 4. 音频发送
`start` 成功后,客户端持续发送音频二进制帧。
推荐格式:
- 单声道 PCM
- 16k 采样率
- 小块连续发送
示例:
```js
ws.send(pcmChunkArrayBuffer);
```
## 5. stop 请求
```json
{
"type": "stop"
}
```
## 6. 服务端返回
## 6.1 `voice_id`
连接建立并收到 `start` 后,服务端先返回:
```json
{
"type": "voice_id",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123"
}
```
说明:
- `voice_id` 为本次实时识别标识。
- `session_id` 为本次会话标识;若未传,服务端会返回自动生成值。
## 6.2 `start`
服务端确认开始识别:
```json
{
"type": "start",
"session_id": "meeting-abc-123"
}
```
## 6.3 `sentences`
识别过程中会持续返回 `sentences`。
重要说明:
- `sentence_type=0` / `slice_type=1` 表示实时 partial,仅用于实时展示。
- `sentence_type=1` / `slice_type=2` 表示该段最终定稿,建议作为最终入库依据。
- 说话人可能异步补写,不保证和首个 final 同时返回。
### partial 返回示例
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 1,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 0,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15600,
"sentence": "今天这个会议主要讨论预算。"
}
]
}
```
### final 返回示例
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "",
"user_id": null
}
]
}
```
### speaker 回写示例
如果 speaker 后续识别完成,服务端会再次返回相同 `sentence_id` 的消息,只更新 speaker 信息:
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
接入建议:
- 客户端按 `sentence_id` 更新句子,不要把 speaker 回写当成新句子追加。
- `speaker_id=-1` 代表当前 speaker 暂未确认,不代表识别失败。
## 6.4 `end`
识别结束后返回:
```json
{
"type": "end",
"code": 0,
"message": "",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123",
"final": 1,
"result": {
"slice_type": 2,
"index": 5,
"voice_text_str": "完整会议文本"
},
"sentences": []
}
```
## 6.5 `error`
异常时返回:
```json
{
"type": "error",
"code": "INVALID_STATE",
"message": "请先发送 start",
"voice_id": "41ff1926"
}
```
## 7. 断线重连
支持基于 `session_id` 的会话恢复。
约定如下:
1. 首次连接时,客户端可以自行生成 `session_id` 并放入 `start.payload.session_id`
2. 若客户端未传,服务端会返回自动生成的 `session_id`
3. 断线重连时,客户端使用同一个 `session_id` 再次发送 `start`
4. 若服务端会话仍在保活期内,则恢复同一会议上下文
建议:
- 客户端在会议生命周期内保持 `session_id` 稳定
- 网络抖动或页面刷新后,优先使用上一次会话的 `session_id` 重连
## 8. 客户端接入建议
- 实时展示可消费 `sentence_type=0` 的 partial
- 业务落库建议只以 `sentence_type=1` 的 final 为准
- 若后续收到相同 `sentence_id` 的 final 更新,应执行更新而不是新增
- 若需要最佳实时体验,建议开启 `enable_native_partial_stream=true`
- 若希望 speaker 识别更完整,建议开启 `enable_speaker=true`

View File

@ -1,95 +0,0 @@
# Qwen-Asr 国产化兼容适配汇报
## 一、适配目标
完成对 **沐曦(MetaX)** 和 **天数(Iluvatar)** 两款国产GPU的完整适配,实现语音识别服务在国产硬件平台的稳定运行。
---
## 二、适配历程与关键节点
| 时间 | 里程碑 | 关键动作 |
|------|--------|----------|
| 2026-06-02 | 架构设计 | 新增Accelerator抽象层,重构设备检测逻辑 |
| 2026-06-02 | 沐曦适配 | 新增沐曦GPU支持(MetaX MACA runtime) |
| 2026-06-02 | 天数适配 | 新增天数GPU支持(Iluvatar IX runtime) |
| 2026-06-03 | 部署完善 | 完善沐曦全流程部署支持,升级天数基础镜像 |
| 2026-06-04 | 问题修复 | 修复setuptools版本冲突,优化pip安装配置 |
---
## 三、遇到的主要"坑"与解决方案
### **坑1:设备检测适配问题**
- **问题**:不同厂商使用不同的设备查询命令(`mx-smi` vs `ixsmi`),输出格式差异大
- **解决方案**:
- 抽象统一的`AcceleratorAdapter`接口
- 支持多种SMI命令格式解析(JSON/表格/列表)
- 自动检测优先级:国产厂商SMI → NVIDIA → CPU
### **坑2:vLLM/PyTorch版本不兼容**
- **问题**:国产GPU的vLLM、PyTorch、内核和运行时需要严格匹配厂商官方版本
- **解决方案**:
- 采用"厂商官方vLLM镜像 + 项目代码叠加"策略
- 不在Dockerfile中重新`pip install vllm`,避免解析到NVIDIA CUDA依赖
- 提供专用环境配置文件(`environments/metax/`、`environments/iluvatar/`)
### **坑3:环境变量命名混乱**
- **问题**:各厂商使用不同的设备可见性变量名
- **解决方案**:
- 沐曦:`METAX_VISIBLE_DEVICES` / `MACA_VISIBLE_DEVICES` / `MX_VISIBLE_DEVICES`
- 天数:`ILUVATAR_VISIBLE_DEVICES` / `IX_VISIBLE_DEVICES` / `CUDA_VISIBLE_DEVICES`
- 代码层统一处理,支持多种变量名自动识别
### **坑4:显存管理问题**
- **问题**:国产GPU显存分配策略与NVIDIA不同,默认配置易导致KV cache不足
- **解决方案**:
- 引入`QWEN_GPU_MEMORY_UTILIZATION`配置项(默认0.25)
- 强制启用`QWEN_VLLM_ENFORCE_EAGER=true`提升兼容性
- 提供详细的显存配置指导文档
### **坑5:离线部署打包复杂**
- **问题**:目标机通常无法联网,需要完整的离线交付包
- **解决方案**:
- 开发`export_offline_bundle.sh`一键打包脚本
- 支持跳过模型打包(模型单独准备)
- 生成厂商专用的docker-compose配置
### **坑6:Python环境依赖冲突**
- **问题**:沐曦官方镜像使用特定Python路径和setuptools版本
- **解决方案**:
- 不使用uv虚拟环境,直接使用系统Python
- 固定setuptools版本为69.5.1
- 支持自定义Python路径配置
---
## 四、兼容性矩阵
| 平台 | 支持状态 | 基础镜像 | 设备命令 |
|------|----------|----------|----------|
| NVIDIA CUDA | ✅ 支持 | 自定义CUDA 13.0 | nvidia-smi |
| 沐曦 MetaX | ✅ 支持 | vLLM 0.17.0 + MACA AI3.5.3 | mx-smi |
| 天数 Iluvatar | ✅ 支持 | vLLM 0.17.0 + IX 4.4.0 | ixsmi |
| CPU (Rust) | ✅ 支持 | - | - |
---
## 五、交付成果
1. **代码层**:统一的加速器抽象层(`app/core/accelerator.py`)
2. **部署文档**:
- 《沐曦GPU国产化离线部署指南》
- 《天数GPU国产化离线部署指南》
3. **环境配置**:专用依赖配置文件(metax/iluvatar)
4. **打包脚本**:一键离线打包工具
5. **Docker镜像**:专用Dockerfile和docker-compose配置
---
## 六、关键经验总结
1. **厂商官方镜像优先**:不自行编译GPU运行时,直接使用厂商验证过的vLLM镜像
2. **统一抽象层**:通过Adapter模式屏蔽硬件差异,上层业务无感知
3. **配置化驱动**:设备可见性、显存比例等参数化配置,适应不同现场环境
4. **离线交付优先**:提前规划离线部署方案,避免现场网络限制问题

View File

@ -1,54 +0,0 @@
[project]
name = "qwen3-asr-cpu-env"
version = "1.0.0"
description = "CPU runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"torch==2.3.1 ; sys_platform == 'linux' or sys_platform == 'darwin'",
"torchaudio==2.3.1 ; sys_platform == 'linux' or sys_platform == 'darwin'",
"transformers>=4.45,<4.50",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", marker = "platform_system == 'Linux'" },
]
torchaudio = [
{ index = "pytorch-cpu", marker = "platform_system == 'Linux'" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
[project]
name = "qwen3-asr-iluvatar-env"
version = "1.0.0"
description = "Iluvatar IX runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false

View File

@ -1,24 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
setuptools>=70.0.0,<81
transformers>=4.56.0,<5

View File

@ -1,45 +0,0 @@
[project]
name = "qwen3-asr-metax-env"
version = "1.0.0"
description = "MetaX MACA runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false
[[tool.uv.index]]
name = "metax-maca"
url = "https://repos.metax-tech.com/r/maca-pypi/simple"
explicit = true

View File

@ -1,23 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
transformers>=4.56.0,<5

View File

@ -1,41 +0,0 @@
[project]
name = "qwen3-asr-mthreads-env"
version = "1.0.0"
description = "Moore Threads MUSA runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false

View File

@ -1,24 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
setuptools>=70.0.0,<81
transformers>=4.56.0,<5