Files
live-caption/docs/phase2-streaming.md
T
blumia 85b206f1cd 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;增量式仍为默认。
2026-10-12 14:22:29 +08:00

7.1 KiB
Raw Blame History

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 有逐行记录):

  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 定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。

用法

# 流式转写(默认 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 分段。
  • 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
  • 边界标点偶有差异(分段处 —/, 之类),不影响内容。