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;增量式仍为默认。
7.1 KiB
Phase 2:原生流式解码
状态:完成。准确率与离线转写一致,单步计算稳定在 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。
结论
| 数据 | 结果 |
|---|---|
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
| 单步计算(incremental,segment≤20s) | 最慢 1.84s(预算 2.0s,提交粒度 2s) |
| 整体 RTF | 约 0.59 |
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
算法
官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套解码策略(qwen_asr/inference/qwen3_asr.py,antirez/qwen-asr 的 MODEL.md 有逐行记录):
- 音频按 2s 累积;
- 把音频(连同已有的历史)过编码器;
- 解码器 prompt 里带上上一轮文本减去最后
unfixed_token_num个 token 作为 assistant 前缀,从那里继续生成; - 前
unfixed_chunk_num个 chunk 不带前缀(冷启动); - 输出 = 前缀 + 新生成部分。
参数取代码默认值: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)作为兜底上界,超过即提交并重置上下文,历史正文会保留并继续拼接。
过程中修掉的问题
- 拼接错:
Result::text只是新生成的部分,忘了接回前缀 —— 看起来像"算法不工作",其实只是显示拼接错。 - 缝隙缺空格:
parseAsrOutput的trim()吃掉了续写开头那个空格(potatoesand)。改用未 trim 的raw拼接。 - 分段后重复:多段拼接时
parseAsrOutput用rfind定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。
用法
# 流式转写(默认 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-mode(incremental / append / window)、--stream-chunk(提交粒度)、--stream-segment(单段上限)、--stream-window(仅 window 模式)、--unfixed-tokens、--unfixed-chunks。
遗留
- 实时链路接入时用静音检测代替固定
--stream-segment分段。 - 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
- 边界标点偶有差异(分段处
—/,之类),不影响内容。