2026-10-12 13:01:26 +08:00
|
|
|
|
# Phase 2:原生流式解码
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 14:22:29 +08:00
|
|
|
|
## 两种流式模式(实测对比)
|
|
|
|
|
|
|
|
|
|
|
|
代码里实现了两条流式路径,用 `--stream-mode` 切换。它们对应一个**无法同时满足的三选二**:
|
|
|
|
|
|
|
|
|
|
|
|
| 目标 | `incremental`(默认) | `append`(追加式) |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 成本不随说话时长增长 | ❌ | ✅ |
|
|
|
|
|
|
| CPU 效率 | RTF 1.06(无段落上限时) | **RTF 0.26** |
|
|
|
|
|
|
| 1–2s 就出字 | ✅ | ❌ 需 8s chunk |
|
|
|
|
|
|
| 准确率 | ✅(2s 提交 + 8s 编码切片) | ✅(仅 chunk≈8s 时) |
|
|
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
同一段 62.6s 连续语音:
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
| 配置 | 墙钟 | RTF | 平均步 | 最慢步 |
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
| `incremental` chunk=2 segment=20 | 35.5s | 0.57 | 1.09s | 1.86s |
|
|
|
|
|
|
| `incremental` chunk=2 不限段长 | 67.7s | 1.08 | 2.10s | 4.30s |
|
|
|
|
|
|
| `append` chunk=5 | 18.1s | 0.29 | 1.33s | 1.56s |
|
|
|
|
|
|
| `append` chunk=8 | **15.2s** | **0.24** | 1.80s | 1.91s |
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
`append` 的每步耗时几乎不随总长变化;`incremental` 则线性增长。
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
### 为什么 `append` 必须用大 chunk
|
|
|
|
|
|
|
|
|
|
|
|
准确率对 **chunk 长度**极其敏感,因为编码器(AuT)用的是 **8 秒注意力窗口**(`n_window_infer = 800` 帧 @100Hz)。每块只用自己的长度去编码时:
|
|
|
|
|
|
|
|
|
|
|
|
| chunk | 中文实测结果 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 2s | ❌ `不排除购房者可以在互。`(每块都被截断,`互联网上下单` 全丢) |
|
|
|
|
|
|
| 4s | ⚠️ `不排除购房者可以在互联网上下单,不排除。`(基本对,边界多一个词) |
|
|
|
|
|
|
| 8s | ✅ 与离线一致 |
|
|
|
|
|
|
|
|
|
|
|
|
所以「便宜/平坦」和「1–2s 出字」对 Qwen3-ASR 是互斥的:要准确就得给编码器 8s 上下文,给了就必然 8s 才出一次字。
|
|
|
|
|
|
|
|
|
|
|
|
### 追加式为什么便宜
|
|
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
`append` 把音频切成定长段,**每段独立转写后按顺序拼接**(对应 sglang 给 Qwen3-ASR 定的 segment 流式模式):
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
- 不做任何前缀重算;
|
|
|
|
|
|
- 旧音频不重编码;
|
|
|
|
|
|
- KV 每段重置(成本与音频总长无关)。
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
而 `incremental` 每步都要把「音频尾部 + 到目前为止的全部文本前缀」按新的音频上下文重算一遍(因果注意力决定了:把新音频插到文本之前,文本之后的 KV 全部作废)。实测拆解显示这一步的代价是 O(N²):
|
|
|
|
|
|
|
|
|
|
|
|
| 累计音频 | 音频(编码+写KV) | 文本前缀 prefill | 生成 | 合计 |
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
| 2.0s | 0.35s | 0.03s | 0.19s | 0.58s |
|
|
|
|
|
|
| 46.0s | 1.23s | 1.20s | 0.38s | 2.81s |
|
|
|
|
|
|
| 62.6s | 1.46s | **1.53s** | 0.16s | 3.15s |
|
|
|
|
|
|
|
2026-10-12 14:32:07 +08:00
|
|
|
|
「文本前缀 prefill」从 0.03s 涨到 1.53s,占末尾步耗时的一半。
|
|
|
|
|
|
|
|
|
|
|
|
**踩坑记录**:最初把各段实现成「接在上一轮之后的对话轮」(携带上文),结果模型会**抄上一轮的文本** —— 音频内容明显变了,它还是把上一轮的句子原样再吐一遍(实测:第 1 段是「英文尾 2.4s + 中文 5.6s」,却输出了整句英文)。改成每段独立转写后消失。所以这里的「追加」指的是**结果按顺序拼接**,不是「上下文一直累积」。
|
|
|
|
|
|
|
|
|
|
|
|
### 追加式的残留问题:段边界重复
|
|
|
|
|
|
|
|
|
|
|
|
各段互不重叠,所以边界处会把上一段的尾巴再写一遍,例如中文实测:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
+ 不排除购房者可以在互联网上下单。不排除购房者可以在。
|
|
|
|
|
|
+ 在互联网上下单,不排除购房者可以在互联网上下单。 ← "在互联网上下单," 是上一段的尾巴
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
要消除它需要重叠窗口 + 去重,那就是 `incremental` 的做法(也因此贵)。所以:
|
|
|
|
|
|
|
|
|
|
|
|
- **要字幕质量 → `incremental`(默认)**;
|
|
|
|
|
|
- **要省 CPU / 转写长音频 → `append`,chunk 取 5–8s**。
|
2026-10-12 14:22:29 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
## 结论
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 14:22:29 +08:00
|
|
|
|
| 数据 | 结果 |
|
2026-10-12 13:01:26 +08:00
|
|
|
|
|---|---|
|
|
|
|
|
|
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
|
2026-10-12 14:22:29 +08:00
|
|
|
|
| 单步计算(incremental,segment≤20s) | 最慢 1.84s(预算 2.0s,提交粒度 2s) |
|
|
|
|
|
|
| 整体 RTF | 约 0.59 |
|
2026-10-12 13:01:26 +08:00
|
|
|
|
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
## 算法
|
|
|
|
|
|
|
|
|
|
|
|
官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**(`qwen_asr/inference/qwen3_asr.py`,`antirez/qwen-asr` 的 MODEL.md 有逐行记录):
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
|
|
|
|
|
1. 音频按 2s 累积;
|
2026-10-12 13:01:26 +08:00
|
|
|
|
2. 把音频(连同已有的历史)过编码器;
|
|
|
|
|
|
3. 解码器 prompt 里带上**上一轮文本减去最后 `unfixed_token_num` 个 token** 作为 assistant 前缀,从那里继续生成;
|
2026-10-12 11:40:59 +08:00
|
|
|
|
4. 前 `unfixed_chunk_num` 个 chunk 不带前缀(冷启动);
|
|
|
|
|
|
5. 输出 = 前缀 + 新生成部分。
|
|
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
参数取代码默认值:`chunk_size_sec=2.0`、`unfixed_token_num=5`、`unfixed_chunk_num=2`。
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
## 关键发现:编码必须按 8 秒切片对齐
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
这是决定准确率的核心。编码器(AuT)按 `n_window_infer = 800` 帧(8s @100Hz)做窗口注意力,并按 100 帧一组做独立卷积。**把音频切得太碎去编码会明显掉准确率**:
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
| 编码粒度 | 结果 |
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|---|---|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
| 2s | ❌ `dinner, or dinner,` / `boiled bruised potatoes` / `thick, thick` |
|
|
|
|
|
|
| 4s | ⚠️ 一处错(`browned bruised`、`thick, black`) |
|
|
|
|
|
|
| **8s** | ✅ 与离线逐字一致 |
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
所以实现上**按 8s 对齐切片**:排满 8s 的切片固化进 KV 永久复用;尾部不满 8s 的部分每步重编码(临时的,下一步丢弃)。
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
## 实现:增量 KV 复用
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
与「每步重编码整个窗口」(早期做法)不同:
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
- 新音频只编码一次;
|
|
|
|
|
|
- 已解码的音频留在 KV cache 里,不再重编码、也不再重新 prefill;
|
|
|
|
|
|
- 每步只做:`llama_memory_seq_rm` 回退到音频末尾 → 追加新音频(`mtmd_helper_eval_chunk_single`,它内部处理 M-RoPE 与分批)→ 解码音频之后的 prompt 文本与前缀 → 贪心生成。
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
因此单步成本与音频总长无关,只与**当前段的长度**有关。
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
对比(62.6s 英文音频,chunk=2s):
|
2026-10-12 11:40:59 +08:00
|
|
|
|
|
2026-10-12 13:01:26 +08:00
|
|
|
|
| 做法 | 墙钟 | RTF | 最慢单步 |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| 每步重编码整个窗口,window=4s | 32.9s | 0.53 | 1.39s(但准确率在窗口滑动时抖动) |
|
|
|
|
|
|
| 每步重编码整个窗口,window=16s | 89.0s | 1.42 | 3.93s ❌ |
|
|
|
|
|
|
| **增量 KV 复用 + 8s 切片** | 41.2s | 0.66 | **1.89s ✅** |
|
|
|
|
|
|
|
|
|
|
|
|
## 单段时长上限
|
|
|
|
|
|
|
|
|
|
|
|
解码器对 KV 的注意力随总长增长,所以**段不能无限长**:
|
|
|
|
|
|
|
|
|
|
|
|
| 单段上限 | RTF | 最慢单步 | 超预算步数 |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| 20s | 0.66 | 1.89s | 0/32 ✅ |
|
|
|
|
|
|
| 30s | 0.63 | 2.23s | 1/32 ⚠️ |
|
|
|
|
|
|
| 不限 | 1.04 | 3.62s | 15/32 ❌ |
|
|
|
|
|
|
|
|
|
|
|
|
真实字幕场景里句子之间本来就有静音,应该由**静音检测**分段(Phase 3 接入实时链路时做)。当前用 `--stream-segment`(默认 30s)作为兜底上界,超过即提交并重置上下文,历史正文会保留并继续拼接。
|
|
|
|
|
|
|
|
|
|
|
|
## 过程中修掉的问题
|
|
|
|
|
|
|
|
|
|
|
|
1. **拼接错**:`Result::text` 只是新生成的部分,忘了接回前缀 —— 看起来像"算法不工作",其实只是显示拼接错。
|
|
|
|
|
|
2. **缝隙缺空格**:`parseAsrOutput` 的 `trim()` 吃掉了续写开头那个空格(`potatoesand`)。改用未 trim 的 `raw` 拼接。
|
|
|
|
|
|
3. **分段后重复**:多段拼接时 `parseAsrOutput` 用 `rfind` 定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。
|
|
|
|
|
|
|
|
|
|
|
|
## 用法
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-10-12 14:22:29 +08:00
|
|
|
|
# 流式转写(默认 incremental:8s 切片 + 前缀回滚 + 2s 提交)
|
2026-10-12 13:01:26 +08:00
|
|
|
|
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
|
|
|
|
|
|
--stream-file audio_16k_mono.wav [--stream-chunk 2] [--stream-segment 30]
|
|
|
|
|
|
|
2026-10-12 14:22:29 +08:00
|
|
|
|
# 追加式:成本平坦、省 CPU,但需要大 chunk 才准确
|
|
|
|
|
|
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
|
|
|
|
|
|
--stream-file audio_16k_mono.wav --stream-mode append --stream-chunk 8
|
|
|
|
|
|
|
|
|
|
|
|
# 对比用:最早的"重编码整个窗口"实现
|
2026-10-12 13:01:26 +08:00
|
|
|
|
./build/live-caption-cli ... --stream-mode window --stream-window 4
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-10-12 14:22:29 +08:00
|
|
|
|
相关参数:`--stream-mode`(incremental / append / window)、`--stream-chunk`(提交粒度)、`--stream-segment`(单段上限)、`--stream-window`(仅 window 模式)、`--unfixed-tokens`、`--unfixed-chunks`。
|
2026-10-12 13:01:26 +08:00
|
|
|
|
|
|
|
|
|
|
## 遗留
|
|
|
|
|
|
|
|
|
|
|
|
- 实时链路接入时用**静音检测**代替固定 `--stream-segment` 分段。
|
|
|
|
|
|
- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
|
|
|
|
|
|
- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。
|