diff --git a/docs/phase2-streaming.md b/docs/phase2-streaming.md index 13416eb..8584385 100644 --- a/docs/phase2-streaming.md +++ b/docs/phase2-streaming.md @@ -13,15 +13,16 @@ | 1–2s 就出字 | ✅ | ❌ 需 8s chunk | | 准确率 | ✅(2s 提交 + 8s 编码切片) | ✅(仅 chunk≈8s 时) | -同一段 62.6s 连续语音(不设段落上限): +同一段 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 | +| 配置 | 墙钟 | 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` 的每步耗时几乎不随总长变化;`incremental` 则线性增长。 ### 为什么 `append` 必须用大 chunk @@ -37,13 +38,11 @@ ### 追加式为什么便宜 -`append` 把每块音频当作一个新的对话轮追加到 KV 末尾: +`append` 把音频切成定长段,**每段独立转写后按顺序拼接**(对应 sglang 给 Qwen3-ASR 定的 segment 流式模式): -``` -[user: audio_1][assistant: text_1][user: audio_2][assistant: text_2]... -``` - -纯追加 —— **前缀完全不重算、旧音频不重编码、KV 永不回退**,所以单步成本与音频总长无关。 +- 不做任何前缀重算; +- 旧音频不重编码; +- KV 每段重置(成本与音频总长无关)。 而 `incremental` 每步都要把「音频尾部 + 到目前为止的全部文本前缀」按新的音频上下文重算一遍(因果注意力决定了:把新音频插到文本之前,文本之后的 KV 全部作废)。实测拆解显示这一步的代价是 O(N²): @@ -53,7 +52,23 @@ | 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**。 +「文本前缀 prefill」从 0.03s 涨到 1.53s,占末尾步耗时的一半。 + +**踩坑记录**:最初把各段实现成「接在上一轮之后的对话轮」(携带上文),结果模型会**抄上一轮的文本** —— 音频内容明显变了,它还是把上一轮的句子原样再吐一遍(实测:第 1 段是「英文尾 2.4s + 中文 5.6s」,却输出了整句英文)。改成每段独立转写后消失。所以这里的「追加」指的是**结果按顺序拼接**,不是「上下文一直累积」。 + +### 追加式的残留问题:段边界重复 + +各段互不重叠,所以边界处会把上一段的尾巴再写一遍,例如中文实测: + +``` ++ 不排除购房者可以在互联网上下单。不排除购房者可以在。 ++ 在互联网上下单,不排除购房者可以在互联网上下单。 ← "在互联网上下单," 是上一段的尾巴 +``` + +要消除它需要重叠窗口 + 去重,那就是 `incremental` 的做法(也因此贵)。所以: + +- **要字幕质量 → `incremental`(默认)**; +- **要省 CPU / 转写长音频 → `append`,chunk 取 5–8s**。 ## 结论 diff --git a/src/core/qwen3asr.cpp b/src/core/qwen3asr.cpp index 8783581..c97aecd 100644 --- a/src/core/qwen3asr.cpp +++ b/src/core/qwen3asr.cpp @@ -370,25 +370,19 @@ void Qwen3Asr::streamReset() d->turnCount = 0; } -// 追加式轮次:把每小段音频当作一个新的对话轮追加到 KV 末尾。 -// 序列形如 [user: audio_1][assistant: text_1][user: audio_2][assistant: text_2]... -// 好处是纯追加:不重算前缀、不重编码旧音频,单步成本与音频总长无关。 +// 追加式:把音频切成定长段,每段独立转写后按顺序拼接(sglang 的 segment 模式)。 +// 好处是纯追加/独立:不重算前缀、不重编码旧音频,单步成本与总长无关。 +// 注意:段长必须给足(约 8s),因为编码器需要它的 8s 注意力窗口。 bool Qwen3Asr::prepareTurn(std::string * error) { if (d->turnReady) return true; const std::string marker = mtmd_default_marker(); - // 第 1 轮是完整的 user/assistant 结构;后续轮接在上一轮文本之后开新一轮。 const std::string s1 = "<|im_start|>user\n" + marker + "<|im_end|>\n<|im_start|>assistant\n"; - const std::string sc = "<|im_end|>\n<|im_start|>user\n" + marker + - "<|im_end|>\n<|im_start|>assistant\n"; - std::vector tail1, tailC; + std::vector tail1; if (!d->splitPrompt(s1, d->firstHead, tail1, error)) return false; - if (!d->splitPrompt(sc, d->contHead, tailC, error)) return false; - - // 两种轮尾应当一致 d->tailTokens = tail1; llama_memory_clear(llama_get_memory(d->ctx), true); @@ -407,10 +401,20 @@ AsrStreamStep Qwen3Asr::streamAppendTurn(const float * pcm, size_t nSamples, int std::string err; if (!prepareTurn(&err)) { res.error = err; return res; } + // 每一轮都当作独立的一段来转写(清空 KV、只用第 1 轮的 prompt)。 + // + // 不能沿用上一轮的上下文:实测模型会"抄"上一轮的文本 —— 新一轮的音频 + // 哪怕内容完全不同,它也会把上一轮的句子原样再吐一遍。 + // 这与 sglang 给 Qwen3-ASR 定的 segment 流式模式一致: + // 把音频切成定长段分别独立转写,再按顺序拼接。 + llama_memory_clear(llama_get_memory(d->ctx), true); + d->nPastTurn = 0; + d->turnCount = 0; + const auto t0 = std::chrono::steady_clock::now(); - // 1) 轮首文本(纯追加,位置接在 KV 末尾) - const std::vector & head = (d->turnCount == 0) ? d->firstHead : d->contHead; + // 1) 轮首文本(全新的一段,从位置 0 开始) + const std::vector & head = d->firstHead; llama_pos p = d->nPastTurn; if (!d->decodeTokens(head.data(), (int32_t) head.size(), p, false)) { res.error = "解码轮首失败";