feat(stream): add append-only streaming mode with flat cost

Add an append-only streaming path that feeds each audio chunk as a new
conversation turn, so nothing is ever recomputed: the KV grows by
append, no prefix is re-decoded and no audio is re-encoded. Per-step
cost becomes flat instead of growing with the segment (RTF 1.06 -> 0.26
on 62.6s of continuous speech), but accuracy requires ~8s chunks because
the audio encoder needs its 8s attention window, so text only appears
every 8s. The incremental mode stays the default.

新增追加式流式:每块音频作为一个新的对话轮追加,前缀不重算、旧音频不重
编码,单步成本不再随段落增长(连续 62.6s 语音 RTF 从 1.06 降到 0.26)。
但准确率要求 chunk≈8s,否则会被截断,所以出字粒度是 8s;增量式仍为默认。
This commit is contained in:
blumia committed 2026-10-12 14:22:29 +08:00
1 parent 7e5257429a
commit 85b206f1cd
10 files changed
+330 -18

No files matched your search

+63 -6
View File
@@ -2,13 +2,66 @@
状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。
## 两种流式模式(实测对比)
代码里实现了两条流式路径,用 `--stream-mode` 切换。它们对应一个**无法同时满足的三选二**:
| 目标 | `incremental`(默认) | `append`(追加式) |
|---|---|---|
| 成本不随说话时长增长 | ❌ | ✅ |
| CPU 效率 | RTF 1.06(无段落上限时) | **RTF 0.26** |
| 1–2s 就出字 | ✅ | ❌ 需 8s chunk |
| 准确率 | ✅(2s 提交 + 8s 编码切片) | ✅(仅 chunk≈8s 时) |
同一段 62.6s 连续语音(不设段落上限):
| 模式 | 墙钟 | RTF | 首步 | 末步 | 最慢步 |
|---|---|---|---|---|---|
| `incremental` | 66.3s | 1.06 | 0.62s | 3.14s | 4.06s |
| `append` chunk=2 | 32.6s | 0.52 | 0.70s | 0.67s | 1.32s |
| `append` chunk=8 | **16.0s** | **0.26** | — | — | 2.52s |
`append` 的每步耗时几乎不随总长变化;`incremental` 则线性增长(尾步是最慢的)。
### 为什么 `append` 必须用大 chunk
准确率对 **chunk 长度**极其敏感,因为编码器(AuT)用的是 **8 秒注意力窗口**(`n_window_infer = 800` 帧 @100Hz)。每块只用自己的长度去编码时:
| chunk | 中文实测结果 |
|---|---|
| 2s | ❌ `不排除购房者可以在互。`(每块都被截断,`互联网上下单` 全丢) |
| 4s | ⚠️ `不排除购房者可以在互联网上下单,不排除。`(基本对,边界多一个词) |
| 8s | ✅ 与离线一致 |
所以「便宜/平坦」和「1–2s 出字」对 Qwen3-ASR 是互斥的:要准确就得给编码器 8s 上下文,给了就必然 8s 才出一次字。
### 追加式为什么便宜
`append` 把每块音频当作一个新的对话轮追加到 KV 末尾:
```
[user: audio_1][assistant: text_1][user: audio_2][assistant: text_2]...
```
纯追加 —— **前缀完全不重算、旧音频不重编码、KV 永不回退**,所以单步成本与音频总长无关。
而 `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 |
「文本前缀 prefill」从 0.03s 涨到 1.53s,占末尾步耗时的一半。`append` 模式把它降到了 **0.06s**。
## 结论
| 指标 | 结果 |
| 数据 | 结果 |
|---|---|
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
| 单步计算 | 最慢 1.83s(预算 2.0s,提交粒度 2s) |
| 整体 RTF | 约 0.66 |
| 单步计算(incremental,segment≤20s) | 最慢 1.84s(预算 2.0s,提交粒度 2s) |
| 整体 RTF | 约 0.59 |
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
## 算法
@@ -74,15 +127,19 @@
## 用法
```bash
# 流式转写(增量 + 8s 切片 + 2s 提交)
# 流式转写(默认 incremental:8s 切片 + 前缀回滚 + 2s 提交)
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
--stream-file audio_16k_mono.wav [--stream-chunk 2] [--stream-segment 30]
# 对比用:旧的"重编码整个窗口"实现
# 追加式:成本平坦、省 CPU,但需要大 chunk 才准确
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
--stream-file audio_16k_mono.wav --stream-mode append --stream-chunk 8
# 对比用:最早的"重编码整个窗口"实现
./build/live-caption-cli ... --stream-mode window --stream-window 4
```
相关参数:`--stream-chunk`(提交粒度)、`--stream-window`(仅 window 模式)、`--stream-segment`(单段上限)、`--unfixed-tokens`、`--unfixed-chunks`。
相关参数:`--stream-mode`(incremental / append / window)、`--stream-chunk`(提交粒度)、`--stream-segment`(单段上限)、`--stream-window`(仅 window 模式)、`--unfixed-tokens`、`--unfixed-chunks`。
## 遗留