feat(stream): add incremental streaming decode with 8s slice reuse
Add native streaming ASR: 8s-aligned audio slices are cached in the KV cache, only new audio is encoded, and the previous text is fed back as an assistant prefix with token rollback. Text matches offline results. Streaming keeps each step within the 2s budget for segments up to 20s. 新增原生流式解码:8s 对齐的音频切片存入 KV 复用,只编码新音频,并把 上一轮文本作为 assistant 前缀回喂(带 token 回滚)。转写结果与离线 一致;20s 以内的段落每步计算都在 2s 预算内。
This commit is contained in:
1 parent
18dbc9c8d3
commit
21d68e2294
8 files changed
+678
-34
No files matched your search
+71
-32
@@ -1,52 +1,91 @@
|
||||
# Phase 2 计划:原生流式解码
|
||||
# Phase 2:原生流式解码
|
||||
|
||||
目标:把「窗口重转写 + 事后取最长公共前缀」换成模型官方的流式解码 —— **前缀回滚(prefix rollback)**,消除 Phase 1 里「窗口在词中间被切断时模型自己编结尾」的问题。
|
||||
状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。
|
||||
|
||||
实现方式:**在 libllama / libmtmd 上自己实现**(只保留一个推理运行时,复用已有的 CMake、模型管理、采集与解析代码)。
|
||||
参考实现:`antirez/qwen-asr`(纯 C,已实现 `--stream` 的前缀回滚与滑窗),其 MODEL.md 逐行记录了官方 Python 实现 `qwen_asr/inference/qwen3_asr.py` 的流式算法,用作对齐参数的对照。
|
||||
## 结论
|
||||
|
||||
## 为什么不需要 vLLM
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
|
||||
| 单步计算 | 最慢 1.83s(预算 2.0s,提交粒度 2s) |
|
||||
| 整体 RTF | 约 0.66 |
|
||||
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
|
||||
|
||||
官方文档写「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**:
|
||||
## 算法
|
||||
|
||||
官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**(`qwen_asr/inference/qwen3_asr.py`,`antirez/qwen-asr` 的 MODEL.md 有逐行记录):
|
||||
|
||||
1. 音频按 2s 累积;
|
||||
2. 每次触发时把**已累积的全部音频重新过一遍编码器**(O(n²),被 2s 节奏限制住);
|
||||
3. 解码器 prompt 里带上**上一轮转写文本(去掉最后 5 个 token)**作为 assistant 前缀,从那里继续生成;
|
||||
2. 把音频(连同已有的历史)过编码器;
|
||||
3. 解码器 prompt 里带上**上一轮文本减去最后 `unfixed_token_num` 个 token** 作为 assistant 前缀,从那里继续生成;
|
||||
4. 前 `unfixed_chunk_num` 个 chunk 不带前缀(冷启动);
|
||||
5. 输出 = 前缀 + 新生成部分。
|
||||
|
||||
## 参数(官方代码默认值)
|
||||
参数取代码默认值:`chunk_size_sec=2.0`、`unfixed_token_num=5`、`unfixed_chunk_num=2`。
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `chunk_size_sec` | 2.0 s | 音频累积粒度 |
|
||||
| `unfixed_token_num` | 5 | 前缀回滚:丢掉最后 5 个 token 再续写 |
|
||||
| `unfixed_chunk_num` | 2 | 冷启动不开前缀;论文写 4,取保守值 |
|
||||
| 滑窗上限 | ~16 s | 丢弃「文本已提交」对应的音频,把成本从 O(n²) 压到 O(n·窗口) |
|
||||
## 关键发现:编码必须按 8 秒切片对齐
|
||||
|
||||
## 与 Phase 1 原型的差距
|
||||
这是决定准确率的核心。编码器(AuT)按 `n_window_infer = 800` 帧(8s @100Hz)做窗口注意力,并按 100 帧一组做独立卷积。**把音频切得太碎去编码会明显掉准确率**:
|
||||
|
||||
| 已有 | 缺 |
|
||||
| 编码粒度 | 结果 |
|
||||
|---|---|
|
||||
| 2s 节奏的窗口重转写 | 把「上一轮文本 − 最后 5 token」拼进 assistant 前缀 |
|
||||
| mtmd tokenize + eval 的完整路径 | 按 **token** 而非字符回滚(需跟踪生成的 token 序列) |
|
||||
| 语言识别、输出解析、稳定前缀显示 | 滑窗:丢弃已提交文本对应的音频 |
|
||||
| monitor 采集 + 重采样 | 用「已提交 token 数」定义稳定边界,替换事后取交集 |
|
||||
| 2s | ❌ `dinner, or dinner,` / `boiled bruised potatoes` / `thick, thick` |
|
||||
| 4s | ⚠️ 一处错(`browned bruised`、`thick, black`) |
|
||||
| **8s** | ✅ 与离线逐字一致 |
|
||||
|
||||
> 注意:Phase 1 的「字符最长公共前缀」是错的替代品,官方的定义是「丢最后 5 个 token」。
|
||||
所以实现上**按 8s 对齐切片**:排满 8s 的切片固化进 KV 永久复用;尾部不满 8s 的部分每步重编码(临时的,下一步丢弃)。
|
||||
|
||||
## 顺带简化:去掉 VAD
|
||||
## 实现:增量 KV 复用
|
||||
|
||||
官方明确不使用 VAD(模型自己处理静音;只有非流式长音频才用「在切点 ±5s 内找 100ms 最低能量点」切段)。流式模式下可以整个删掉能量 VAD,少一个需要调参的组件。
|
||||
与「每步重编码整个窗口」(早期做法)不同:
|
||||
|
||||
## Spike(先验证,再改产品代码)
|
||||
- 新音频只编码一次;
|
||||
- 已解码的音频留在 KV cache 里,不再重编码、也不再重新 prefill;
|
||||
- 每步只做:`llama_memory_seq_rm` 回退到音频末尾 → 追加新音频(`mtmd_helper_eval_chunk_single`,它内部处理 M-RoPE 与分批)→ 解码音频之后的 prompt 文本与前缀 → 贪心生成。
|
||||
|
||||
1. **效果**:同一段音频,对比「离线转写」与「带前缀回喂的流式」输出,确认流式结果连贯、不再编结尾。
|
||||
2. **延迟**:实测 2s 节奏下的单步耗时,重点看窗口涨到 16s 时是否仍跟得上(每步预算 < 2s)。
|
||||
3. 以上两条通过后,才把它落进 `Qwen3Asr`,并补一个流式模式的实机测试脚本。
|
||||
因此单步成本与音频总长无关,只与**当前段的长度**有关。
|
||||
|
||||
## 风险
|
||||
对比(62.6s 英文音频,chunk=2s):
|
||||
|
||||
- **成本**:每步重编码累积音频,窗口越大越慢;若 16s 窗口跟不上,需改为「复用 KV cache + 只编码新 chunk」,并用 `llama_memory_seq_rm` 做回滚。
|
||||
- **前缀注入方式**:需要确认把文本前缀拼在 chat template 的 assistant 头之后,mtmd 能否正确切分(预期:`[text]<media>[text]` 三段,前缀属于第三段)。
|
||||
- **吞吐**:前缀越长,每步 prefill 越贵;滑窗是必须的,不是优化项。
|
||||
| 做法 | 墙钟 | 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
|
||||
# 流式转写(增量 + 8s 切片 + 2s 提交)
|
||||
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
|
||||
--stream-file audio_16k_mono.wav [--stream-chunk 2] [--stream-segment 30]
|
||||
|
||||
# 对比用:旧的"重编码整个窗口"实现
|
||||
./build/live-caption-cli ... --stream-mode window --stream-window 4
|
||||
```
|
||||
|
||||
相关参数:`--stream-chunk`(提交粒度)、`--stream-window`(仅 window 模式)、`--stream-segment`(单段上限)、`--unfixed-tokens`、`--unfixed-chunks`。
|
||||
|
||||
## 遗留
|
||||
|
||||
- 实时链路接入时用**静音检测**代替固定 `--stream-segment` 分段。
|
||||
- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
|
||||
- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。
|
||||
Reference in new issue
Block a user