Files
live-caption/docs/phase2-streaming.md
T
blumia 21d68e2294 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 预算内。
2026-10-12 13:01:26 +08:00

92 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2:原生流式解码
状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。
## 结论
| 指标 | 结果 |
|---|---|
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
| 单步计算 | 最慢 1.83s(预算 2.0s,提交粒度 2s) |
| 整体 RTF | 约 0.66 |
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
## 算法
官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**(`qwen_asr/inference/qwen3_asr.py`,`antirez/qwen-asr` 的 MODEL.md 有逐行记录):
1. 音频按 2s 累积;
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`。
## 关键发现:编码必须按 8 秒切片对齐
这是决定准确率的核心。编码器(AuT)按 `n_window_infer = 800` 帧(8s @100Hz)做窗口注意力,并按 100 帧一组做独立卷积。**把音频切得太碎去编码会明显掉准确率**:
| 编码粒度 | 结果 |
|---|---|
| 2s | ❌ `dinner, or dinner,` / `boiled bruised potatoes` / `thick, thick` |
| 4s | ⚠️ 一处错(`browned bruised`、`thick, black`) |
| **8s** | ✅ 与离线逐字一致 |
所以实现上**按 8s 对齐切片**:排满 8s 的切片固化进 KV 永久复用;尾部不满 8s 的部分每步重编码(临时的,下一步丢弃)。
## 实现:增量 KV 复用
与「每步重编码整个窗口」(早期做法)不同:
- 新音频只编码一次;
- 已解码的音频留在 KV cache 里,不再重编码、也不再重新 prefill;
- 每步只做:`llama_memory_seq_rm` 回退到音频末尾 → 追加新音频(`mtmd_helper_eval_chunk_single`,它内部处理 M-RoPE 与分批)→ 解码音频之后的 prompt 文本与前缀 → 贪心生成。
因此单步成本与音频总长无关,只与**当前段的长度**有关。
对比(62.6s 英文音频,chunk=2s):
| 做法 | 墙钟 | 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` 分段。
- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。