Files
live-caption/docs/phase2-streaming.md
T
blumia 6d64987d64 fix(stream): transcribe each append-mode chunk independently
The append mode used to feed chunks as successive conversation turns with
the context carried over. The model then copied the previous turn's text
verbatim instead of transcribing the new audio, so a continuous video
kept showing the same sentence forever. Transcribe each chunk
independently instead, matching sglang's segment streaming mode for
Qwen3-ASR; the fixed mode also stays flat at RTF 0.24 versus 0.57-1.08
for the incremental mode.

修复追加式的一个真 bug:原先把各段接成「携带上文的对话轮」,模型会抄上
一轮的文本 —— 连续视频里字幕永远重复同一句。改为每段独立转写(对应
sglang 给 Qwen3-ASR 的 segment 流式模式),内容随之正确变化;成本仍保持
平坦,RTF 0.24,而增量模式是 0.57~1.08。
2026-10-12 14:32:07 +08:00

8.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 chunk=2 segment=20 35.5s 0.57 1.09s 1.86s
incremental chunk=2 不限段长 67.7s 1.08 2.10s 4.30s
append chunk=5 18.1s 0.29 1.33s 1.56s
append chunk=8 15.2s 0.24 1.80s 1.91s

append 的每步耗时几乎不随总长变化;incremental 则线性增长。

为什么 append 必须用大 chunk

准确率对 chunk 长度极其敏感,因为编码器(AuT)用的是 8 秒注意力窗口(n_window_infer = 800 帧 @100Hz)。每块只用自己的长度去编码时:

chunk 中文实测结果
2s ❌ 不排除购房者可以在互。(每块都被截断,互联网上下单 全丢)
4s ⚠️ 不排除购房者可以在互联网上下单,不排除。(基本对,边界多一个词)
8s ✅ 与离线一致

所以「便宜/平坦」和「1–2s 出字」对 Qwen3-ASR 是互斥的:要准确就得给编码器 8s 上下文,给了就必然 8s 才出一次字。

追加式为什么便宜

append 把音频切成定长段,每段独立转写后按顺序拼接(对应 sglang 给 Qwen3-ASR 定的 segment 流式模式):

  • 不做任何前缀重算;
  • 旧音频不重编码;
  • 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,占末尾步耗时的一半。

踩坑记录:最初把各段实现成「接在上一轮之后的对话轮」(携带上文),结果模型会抄上一轮的文本 —— 音频内容明显变了,它还是把上一轮的句子原样再吐一遍(实测:第 1 段是「英文尾 2.4s + 中文 5.6s」,却输出了整句英文)。改成每段独立转写后消失。所以这里的「追加」指的是结果按顺序拼接,不是「上下文一直累积」。

追加式的残留问题:段边界重复

各段互不重叠,所以边界处会把上一段的尾巴再写一遍,例如中文实测:

+ 不排除购房者可以在互联网上下单。不排除购房者可以在。
+ 在互联网上下单,不排除购房者可以在互联网上下单。     ← "在互联网上下单," 是上一段的尾巴

要消除它需要重叠窗口 + 去重,那就是 incremental 的做法(也因此贵)。所以:

  • 要字幕质量 → incremental(默认);
  • 要省 CPU / 转写长音频 → append,chunk 取 5–8s。

结论

数据 结果
准确率 流式输出与离线转写逐字一致(中/英各自验证)
单步计算(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 分段。
  • 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
  • 边界标点偶有差异(分段处 —/, 之类),不影响内容。