kitt-stt · dev-report · 2026-09-02-switchable-wake-provider

可切換喚醒 Provider:STT / Manual

第一版保留獨立的 features.enable_kws feature gate,並新增 typed wake.provider;保留既有 Web UI manual override、bypass、exit、timeout 與 EOT authority,不引入 iGo、DSpotter、VID 或聲學音訊介面。
狀態:實作與驗收完成 分支 feat/switchable-wake-provider 離線 562 passed / 1 skipped live:STT 39 passed / 4 manual skipped · manual 4 passed

🎛️本輪 · Provider switch 實作與驗收

結論

features.enable_kws=false 會完全略過 Wake policy; features.enable_kws=true + wake.provider=stt 維持 transcript wake; features.enable_kws=true + wake.provider=manual 只停用 automatic wake,仍保留 Web UI Active/Standby、bypass、exit 與共享 timeout。 預設仍是 stt,既有部署不改設定即可維持原行為。

562
offline passed
39
default STT live passed
4
manual live passed
0
Ruff / scoped Pyright errors
4
new SW contracts

Plan 與範圍

設計與施工依據: 2026-09-02-switchable-wake-provider-design.md2026-09-02-switchable-wake-provider.md。 本輪只交付 stt / manual;未來 iGo / DSpotter acoustic authority、generation/sample clock 與 One-shot 音訊裁切仍屬後續 cycle。

features.enable_kwswake.providerRuntime 行為
false合法值但 runtime 忽略普通 STT/EOT passthrough;Web UI frame 不改 STT gating
truestt既有 transcript wake + bypass
truemanual無 automatic wake;保留 Web UI override、bypass、exit、timeout

Architecture 與 frame flow

Control path Web UI/LM 手動切換 Active/Standby Speech path ASR final 的 automatic wake、bypass、exit 與 EOT runtime input InterruptionFrame agent_active / agent_standby feature gate features.enable_kws? false bypass wake policy Frame passthrough 不改 STT Wake state true KWSStreamManager Update room state Active / Standby shared idle timeout output Pipeline 照常繼續 沒有 Wake state side effect output Active / Standby frame 同步 kitt-core / Web UI runtime input SenseVoice ASR final final transcript feature gate features.enable_kws? false output Ordinary STT/EOT transcript passthrough true automatic wake source wake.provider? stt Automatic wake match match/strip final transcript manual Skip automatic wake 不移除 wake-like transcript common policy + output bypass / exit / current state emit / drop · EOT wait / commit / cancel Owner:KittSttService 兩條 lane 相互獨立;provider 不控制 manual frame。
圖 1 · 雙 Lane Runtime Flow:Control path 與 Speech path 各自由上往下;wake.provider 只出現在 Speech path 的 automatic wake 分支。
架構圖解

閱讀方式。左右兩條 lane 都只由上往下:左邊是 Web UI/LM control frame,右邊是 ASR final transcript。兩條路徑不互相切換,也不共用箭頭。

Control path。features.enable_kws=false 時 frame 照常 passthrough,但不改 STT Wake state;值為 true 時才更新 room-wide Active/Standby、shared timeout,並送出 state frame 同步 kitt-core/Web UI。

Speech path。features.enable_kws=false 時直接走普通 STT/EOT;值為 true 時,wake.provider=stt 執行 automatic wake match/strip,manual 則跳過。兩個 provider 最後都進入共用的 bypass、exit、state 與 EOT emission policy。

目前與未來的界線。本版 provider 是 enum 加上 KittSttService 內的條件分支,尚未建立 vendor adapter interface。未來 iGo/DSpotter 應以獨立 acoustic detector 產生標準化 wake event,再交給同一個 Wake arbitration;Web UI manual control 不納入 provider hierarchy。

Frame / input方向觸發條件動作Owner
InterruptionFrame(agent_active)Web UI → STTstt / manualroom-wide Active,啟動 shared timeoutKittSttService.process_frame
InterruptionFrame(agent_standby)Web UI / LM → STTstt / manualroom-wide Standby,丟棄 pending turnKittSttService.process_frame
ASR final transcriptinternalstt可做 automatic wake match / One-shot strip_finalize_utterance_impl
ASR final transcriptinternalmanual不做 automatic wake match;bypass 仍執行_finalize_utterance_impl
任何 active/standby framepassthroughfeatures.enable_kws=false不改 Wake state 或 STT/EOT gatingSTTService base path

本輪 acceptance 與 Feature-first traceability

PRD FeatureSW_idBehaviourExecutable evidenceResult
STT_EXTRASW-W-14STT / manual automatic wake provider switchtest_wake_provider_policy.py · test_wake_provider_manual.py✓ PASS
STT_EXTRASW-B-12manual 只停 automatic wake,保留 exact/prefix bypasstest_wake_provider_policy.py · test_wake_provider_manual.py✓ PASS
STT_EXTRASW-UI-07features.enable_kws=true 時,Web UI override 對所有 provider 共用;false 時不做 STT gatingtest_wake_provider_policy.py · test_btn_control.py✓ PASS
STT_EXTRASW-CFG-03獨立 feature gate、typed provider、狀態頁與 startup logtest_wake_provider_config.py · test_status_page_settings.py✓ PASS
GateCommand / environmentResult
Offline full.venv/bin/pytest -q(sandbox 外,native/thread 可用)562 passed, 1 skipped
Default STT live full./tests/docker-test.sh --build · RTX 4090 / CUDA39 passed, 4 manual skipped
Manual live targetedWAKE_PROVIDER=manual LIVE_TEST_TARGET=... ./tests/docker-test.sh --build4 passed
StaticRuff + scoped Pyright + bash -n + diff check0 errors
累積基線

以下內容承接 2026-08-31 report,保留歷輪 Feature ledger、coverage checklist、完整 STT stack SVG 與 frame-flow 圖;本輪差異與最新真值以上方區塊為準。

🔖累積基線(2026-08-31)· 代號說明

本報告引用四個代號家族。讀者手上只有這一份檔案,所以每一個都在這裡定義。 本輪只採用 #3;其餘代號出現,是因為它們被評估過並判定不做(理由在各自的小節), 或是從前幾輪的累積表承接下來的。

家族是什麼本報告用到的成員
#n 面向 A 候選——只有在「用 offline 模型定期重解整段」這個策略下才存在的優化手段。 編號沿用 2026-08-18-multiuser-concurrency §6 的清單 #3 prep 增量化(本輪採用,並延伸到 final)· #4 滑動窗口+保留前綴(不做)· #5 final 重用最後一次 interim(不做)· #6 提高 interim 間隔地板(不做,§3.1)
Mn 面向 B 候選——SW 流程設計,換掉模型之後還算數的手段。同樣沿用上一輪編號 M1 interim 重解碼移出音訊 frame 路徑(上一輪已上線)· M5 跨座位批次解碼(本輪判定不做)· M4 interim 間隔 × 人數 · M6 背壓感知跳過 · M7 全域併發上限(後三者皆不做,§3.1 凍結時間判準)
AnBn 驗收條件 ID。字母只在「哪一輪」的脈絡下唯一——每一輪都從 A1 重新編號, 所以看到這類代號要先確定它屬於哪一輪 2026-08-31 baseline cycle(定義在 docs/dev-specs/2026-08-31-aspect-a-model-inference-design.md §2): A1 前綴定錯率 · A2 文字差異率 · A3 fork 成本評估 · B1B6 #3 的實作驗收(B6=final 沿用 stream)。
承接前輪(出現在附錄 C 的累積覆蓋表):C1C6 上一輪多人量測工具的驗收 · GAP-BGAP-C 上一輪留下的兩個缺口
SW-area-nn STT 的行為契約 ID,由 docs/requirements/kitt-stt-sw-spec.md 擁有、跨輪穩定,不在單輪報告內重新定義。附錄 B 的累積 ledger 依它編排 2026-08-31 baseline cycle 沒有新增 SW_id——#3 是純延遲優化、輸出文字逐位元相同, 不改變任何行為契約,依使用者裁定其測試檔登記在 SW 規格的「無 SW_id 者」note。 表中各條為歷輪承接
三個會讀錯的碰撞

圖 A1 不是驗收條件 A1——附錄 A 的架構圖編號是 圖 A1圖 A2, 與本輪的驗收 A1(前綴定錯率)無關。
附錄 B 裡的 B1B22026-08-14 那一輪的驗收 ID (interim 只套 s2t()/final 套完整 itn(),測試在 test_batch_asr_manager_itn_split), 不是本輪的 B1B2(binding/逐 bit 等價)。同理 A3A5test_audio_cfg 那一列是 2026-07-29 那輪的編號。
#4(候選編號)與 F_4(PRD Feature 編號)沒有關係——後者只出現在附錄 C 的覆蓋表,一律帶底線。

🎯1 · 目標與承接

上一輪(2026-08-18-multiuser-concurrency)把 SW 流程設計(面向 B)優化到接近極限——M1 背景解碼把共用 frame 路徑佔用率從 65–78% 壓到 2–4%——並把八個「換掉模型之後還算不算數」的候選整理成清單,判給面向 A(模型推理能力)交下一輪。本輪要回答的就是那張清單:哪個先做、代價多少、做完有沒有達到預期

面向它問的問題怎麼量狀態
A · 模型推理能力模型解一次要多久?成本怎麼隨句長與人數變化?能不能少算?逐段計時的 prepdecode、截斷 CER、前綴定錯率本輪
B · SW 流程設計工作被放在哪條路徑上?多少被序列化?佇列在哪形成?逐段計時、time to final、更新節奏上一輪已收斂(四個純 B 候選全部處理完)

沿用前輪的 SW_id(累積 ledger,只增不減)

本段承接 2026-09-01 當時的最新累積表;依 CLAUDE.md §6 第 2 點與 SW-DOC-02,須涵蓋歷輪聯集的所有 SW_id。下列各條不是本輪驗證的—— 本輪未觸及其行為,故沿用前輪結果,並指向規格與其測試;本輪自己的追溯在上方各表。

SW_id行為(摘自 SW 規格)測試
SW-W-02非喚醒下說無關長語音,需立即 smart-turn stoptest_standby.py
SW-WM-01喚醒詞比對與剝除:latin 大小寫無關、分隔字元先剝除、喚醒詞後可直接接指令剝除喚醒詞後指令完整保留tests/test_wake_matcher.py
SW-WM-02alias 只能買回實測誤聽,不得擴大誤觸發:全中文 alias 與 canonical 距離 ≤1 字;普通語句不得誤觸發tests/test_wake_matcher.py
SW-WM-03退出詞 alias 必須錨定到已設定的退出詞:alias 群組的 canonical 不在 exit_words 內則整組忽略test_exit_alias_groups_name_a_configured_exit_word
SW-TURN-01一個 turn 內只有第一個 VAD segment 是 turn head;其餘為續段,不跑喚醒/免喚醒偵測tests/test_turn_arbitration.py
SW-TURN-02續段的送出權依 bypass 種類授權:prefix 授予(SW-B-03 需要),exact 不授予SW-B-07 的語意)tests/test_turn_arbitration.py
SW-TURN-03新 turn head 清除上一個 turn 的指令送出權,續段則保留tests/test_turn_arbitration.py
SW-ITN-01final 的 ITN:中文數字正規化 + 繁簡轉換tests/test_text_converter.py
SW-ITN-02interim 與 final 的 ITN 刻意不同:interim 只做 s2t(),final 做完整 itn();故兩者在數字上可以不同,這是設計不是 bugtests/test_batch_asr_manager_itn_split.py
SW-PERF-02反應式 interim 重解碼排程的設定解析:間隔由上一次解碼延遲 × 安全係數決定,並受下限約束tests/test_audio_cfg.py
SW-OPS-01模型解析:get_local_model_path 維持純函式(不觸發下載);ensure_local_model_path 只在 bundle 真的缺失時 pull、且只 pull 自己那個 .dvc;失敗訊息必須指名該跑的指令tests/test_model_manager.py
SW-OPS-02出貨模型的五處必須一致config-basic.yamlmodel.keyutils/model_manager.py 註冊表、三個 Dockerfile*COPYdeploy.shMODEL_DVC_FILES、**.dockerigntests/test_augment.py
SW-FT-01訓練語料建構:目標詞以 config-basic.yaml 為單一事實來源;句型展開不污染標籤;音色池與配額可重現;AISHELL-3 全程只當評估、永不進訓練tests/test_finetune_corpus.py
SW-FT-02量測與判定:MODEL/PRODUCT 雙軸(別名不計入模型能力)、三軸判定須同時成立、checkpoint 選擇規則(A4 失敗者不得被選中、全 NO-GO 時不得回傳贏家)tests/test_finetune_eval.py
SW-FT-03LoRA 接線:不可達目標必須拋錯而非靜默不啟用;可訓練集合非空且只含 lora;merge 後鍵集合=base 且權重確實改變tests/test_finetune_lora.py
SW-FT-04波形增強:SNR 精度、訓練/評估池不相交、確定性、不削波;預設 AUG_RATIO=0(乾淨語料)tests/test_augment.py
SW-FT-05論文基準計分不得與 A4 計分共用程式碼;中英文正規化分流、論文目標值釘住tests/test_paper_bench.py
SW-FT-06模型量化匯出須保留 metadata 與 fp32 graph I/O,量化參數只允許存在一份,且 finetune 匯出預設產生 INT8tests/test_quantize_bundle.py
SW-FT-07量測後端可切換 ONNX;互斥參數須拒絕,付費語料快取缺失時不得發出請求tests/test_eval_onnx_backend.py
SW-FT-08量化運算子的目標 provider 指派可在無語料環境重測,且 provider 未載入與運算子回退必須分開報告tests/test_op_support.py
SW-DOC-01新增測試檔必須在本規格留下紀錄:掛在某個 SW_id 之下,或明示「刻意不給 ID」與理由。反向亦然——本規格不得引用已不存在的測試檔(重新命名或刪除後留下的空指向,比未覆蓋更危險,因為它看起來有覆蓋)。§8/§9 兩區與其 ID 前綴不得被靜默移除tests/test_spec_traceability.py
SW-DOC-02cycle 報告的累積 ledger 只增不減:最新一份報告必須涵蓋歷輪聯集的所有 SW_id(不只是對前一輪比對——那會讓某個 ID 消失一輪後就永遠消失,因為下一次比的是兩份都已缺它的報告)。報告檔名須與 docs/dev-specs/ 的 cycle stem 對應tests/test_report_integrity.py`
SW-DOC-03報告使用的技術名詞與自創說法必須在名詞表定義,並說明本輪的重要性tests/test_report_glossary.py
SW-DOC-04每張圖須有 aria-label;數據圖須有寫出結論的 captiontests/test_report_charts.py
SW-DOC-05報告的章節交叉引用必須指向實際存在的章節tests/test_report_crossrefs.py
SW-DOC-06報告 ID 對照表必須逐項定義引用的每個 A/D/E/F/R IDtests/test_report_ids.py
SW-EOT-01 STT 是唯一 EOT authority:每個 VAD stop 以語尾模型評分 turn tail(跨 segment、最後 8 秒), COMPLETE 立即結束 turn,其餘依 P(incomplete) 等待,期間有新語音則延續同一 turn tests/test_eot_state.py
SW-EOT-02 KWS 事件(standby 無匹配/離開詞/agent_standby/ASR 失敗/連線結束)一律丟棄 turn, 優先於強制停止與模型判定;被丟棄的 turn 永不進 LLM tests/test_eot_kws_precedence.py
SW-EOT-03 語尾模型的音訊前處理必須與訓練時逐位元相同(fbank 80 → LFR 7/6 → CMVN,裁到最後 8 秒) tests/turnsense/test_frontend.py
SW-EOT-04 語尾模型只在 CUDA 上執行:provider 未生效時伺服器拒絕啟動而非降級到 CPU;行程內單例、推論序列化 tests/turnsense/test_runtime.py
SW-OPS-03 Per-turn debug log 檢索端點(GET <base>/debug/logs):features.enable_debug_log_endpoint 關閉時回 404;開啟時依 WS 握手的 ?trace_id= 取回該連線自己的 log,不含其他連線的內容 tests/test_debug_log_store.pytests/functional/live/test_debug_logs.py
SW-OPS-04 狀態頁設定介面:EOT 等待秒數以滑桿呈現,其上下界與步進即 clamp() 實際夾制的範圍; 整頁只有一個儲存動作,任一欄位驗證失敗則全部不送出。上界必須低於 kitt-core 的 user_turn_stop_timeout,否則該 fallback 會搶在語尾判定之前結束 turn tests/test_wake_config.py
SW-OPS-05live harness 的映像新鮮度檢查必須涵蓋 Dockerfile 所有 COPY 路徑,避免對舊映像產生 false passtests/test_augment.py
SW-OPS-06provider=trt 不得靜默降級成 CUDA;EP 註冊或 runtime 驗證失敗時必須拒絕啟動tests/test_trt_provider_guard.py

🧪2 · 實驗方法

§2.1–2.4 沿用上一輪、內容未改(重繪內嵌而非放連結,見 docs/spec.md 附錄 A 的規範);§2.5 是格點表裡「逾時」的判準。

這節寫下本報告每個數字是怎麼產生的,包含失敗過的做法。它們不是流程細節:本輪已經因為 違反其中幾條而撤回過結論(單次量測就下判斷、比較基準沒對齊、聚合把效應抹掉),而下面的 「量測缺陷」小節裡有三個缺陷,若沒被抓到,整份報告會建立在系統性偏差的數字上。

本節多處提到「組態」。兩個組態(SW 整體優化/+#3)的定義見 §4.1,與它們的對比結果放在一起。

2.1 · 數字從哪裡來:server 自己量自己

主數據一律由 server 量測自己,client 端讀數不作為結論依據。要回答的問題是「server 的 能力」,而 client 收到 final 的時刻混了兩個與 server 無關的東西:client 自己的 CPU 排程,以及 client 與 server 同機時互搶 CPU。client 只負責產生刺激。

量測線內容每句/每次產生
[Seg]
Seg = segment
一次 interim 的分段成本 + 共用路徑的負載(欄位見下表)每次 interim 一行
[SegF]
Seg + F(inal)
final 路徑的分段:prep / rec_lock / decode / itn每句一行
[TTF]server 端 time to final(錨在音訊時間;主表用的是 client 端讀數,見 §2.1 的分工表)每句一行

[Seg]Seg 取自 segment:一次 interim 就是把成長中的 segment buffer整段重解一遍,這行印的是那一次重解的分段耗時。[SegF] 刻意用同一個字首,所以 grep '\[Seg' 一次抓到兩者、grep '\[Seg\]' 只抓 interim——兩行的欄位集合不同([Seg]seg_lockocc[SegF]itn),分析腳本靠這個分流。這三個標籤是量測期間 log 的原字串,本報告照抄以便重跑時 grep 得到;那次量測用的 logger.debug patch 已還原,現行程式碼裡沒有這幾行 log。後續已把同一批欄位(prepseg_lockrec_lockdecodepostitnaudio_lagocc)改記在既有的 Perfetto tracedebug.profile_dbg,不開時零額外行為),取代「量測時 patch 原始碼、量完再還原」的做法——見 tests/test_cost_trace.py(純觀測工具,依使用者裁定不掛 SW_id,同 test_live_uri.py 的處理方式)。本機以真實 server 產生過一份 trace 驗證:事件名稱/數量互相對得上/uid 正確路由到座位/數值量級合理。

哪一個數字用在哪

同一個量有 server 端與 client 端兩個讀數,兩者適用的場合不同,本報告的分工固定如下:

用途用哪個數字為什麼
單人步驟拆解(§3.1)server 自量 專用刺激、前面沒有重負載格點;84 筆全為正、排隊 3.5–4.7 ms
多人總表 time to finalclient 端 沒有錨點問題,且就是使用者感受到的量。client 已移出受測節點,pacing_slip ≤ 0.93%
逐段成本(prep/decode/ITN/鎖)server 自量 區間量測,不受錨點影響

2.2 · 指標與欄位定義

本節是全篇的命名依據。下面第一張表是數據圖與總表用的指標,第二張是單次解碼內部的分段欄位。 報告其他地方一律用第一張表的指標名稱,不再出現同義的別名。

指標定義量在哪原始欄位單位本報告哪裡用
使用者體感(client 端量到的,直接對應「使用者感覺到什麼」)
time to final 從 client 送完音訊到收到最終文字。多人格點引用各座位中最慢者——飽和時各座位相差 ≤3%,所以那個值就是每個座位的體感 clientworst_client_final_latency_msms §4 數據圖、§4 總表
TTFT
首字延遲
從 utterance 起算到第一次 interim 被推出。兩邊服務的定義相同(perf_counter() − tag_utterance_start),可直接對比 server 自量worst_server_ttft_msms §4 數據圖
interim 更新次數 整句期間 client 收到幾個 interim frame(=更新密度)。不等於下面的「interim 推論次數」——被合併或跳過的解碼不會產生 frame clientmean_interim_count §4 數據圖
interim 最大間隔 相鄰兩次更新之間的最大間隔,即「畫面看起來停住」的最長時間 clientworst_max_interim_gap_ss §4 數據圖
伺服器內部:latency 家族(server 自己量自己,不受量測機器的負載影響)
latency
母定義
一次解碼在 server 內部花的時間:等鎖 + decode + 後處理。服務對 interim 與 final 各自都吐這個值 (kitt_stt_service.pylatency_ms)。下面三列都是它的實例或聚合,不是另外的東西 server 自量latency_msms ——(用下面三列的名字)
interim latency
=解一次的錢
latency 的 interim 實例:一次 interim 解碼的內部時間。引用時取整句的平均 (total latency ÷ interim 推論次數)。要用它而不是 total latency, 否則「少解幾次」會偽裝成「每次變快」 相除total_inference_time_ms ÷ interim_count
interim_infer_ms ÷ interim_infer_count
ms §3.3.1/§3.3.2 的長句掃描(主證據)
final latency latency 的 final 實例:server 產生 final 那一次解碼的內部時間(搶鎖 + decode + ITN)。 它是 time to final 裡「真的在算」的那一段 server 自量worst_server_latency_msms §4 數據圖
total latency
推論時間總和
整句所有 interim latency 之和(程式碼就是 total_inference_time += latency_ms)。 它是「這句話總共花了多少推論」,不是一個延遲值 server 自量total_inference_time_msinterim_infer_mss §3.3.1/§3.3.2 的長句掃描
backlog time to finalfinal latency排隊 + 傳輸。它把「server 真的在算」與「在等」分開, 是判斷瓶頸在哪一側的關鍵欄位 相減worst_backlog_msms §4 數據圖
interim 推論次數 server 真正跑了幾次 interim 解碼。它是排程行為的指紋——次數對不上就代表兩個組態跑的不是同一個排程 server 自量interim_infer_countinterim_count §3.3.2 的長句掃描
RTF 純 GPU decode 時間 ÷ 音訊長度——分子是 session.total_decode_time只累加 decode_stream() 那一段,不含 prep、不含等鎖、不含後處理。 產能指標(一張卡能服務幾個座位),不是體感指標;併發會把它推高,所以「RTF 變差」不一定代表流程變差。 它看不見 prep 側的優化:#3 打的正是 prep,所以 #3 的效益不會出現在這一欄(1 人 10 秒句 0.143 → 0.165) server 自量worst_server_rtf(=total_decode_time ÷ 音訊 ms)—— §4 數據圖
資源與準確度
GPU 常駐記憶體 模型載入後行程常駐的 VRAM,取自服務自己的 [VRAM Analysis] log server logCurrent Process TotalGB §4 總評論
interim CER interim 顯示文字對 ground truth 的字元錯誤率。final CER 不受切法影響(final 一律重解整段),所以切法的準確度代價只出現在 interim 離線——% §3.3.2 C 節
兩組容易混淆的名字,這裡講定
interim 更新次數(client 收到幾個 frame)與 interim 推論次數(server 跑了幾次解碼)不是同一件事
final latency(server 內部算了多久)與 time to final(client 等了多久)差在 backlog
兩條恆等式(都直接對應程式碼,不是類比):
 time to final = final latency + backlog
 total latency = Σ interim latency = interim latency × interim 推論次數
total latencyinterim 推論次數interim latency 三項需要 server 端的逐次計數, 只有長句掃描那組實驗有(單人、3–90 秒);多人 24 格點沒有這三欄。
欄位量的是什麼為什麼要看它
單次 interim/final 的分段成本(一次解碼內部的時間去哪了)
prep建 stream + 串接波形 + 特徵抽取(fbank)98% 是 fbank,且與音訊長度嚴格線性——長句成長的主因(§3.2)
seg_lock自己座位那把鎖同一座位的兩次解碼不得重疊;單人恆為 0,是健全性檢查
rec_lock全車共用的 recognizer 鎖座位之間互相排隊的唯一直接證據(§4.2、2026-08-18 §附錄 D.1)
decodesherpa-onnx 的 GPU 推理呼叫本身屬面向 A 的成本;SW 優化動不到它
dec_ovh解碼前後的執行緒跳轉開銷次毫秒雜項,列出只為證明沒有藏東西
postsimplified→traditional(s2t同上,次毫秒
push把 frame 送進 pipeline同上,次毫秒
itnfinal 專屬:數字正規化 + s2tfinal 路徑上最大的 SW 成本,本輪唯一採用的單人優化打的就是它(§3.3.1)
共用路徑的負載(只有多人才有意義,也是本報告診斷多人問題的兩個量)
occ
handler 佔用率,%
這條連線的音訊 frame handler 有多少比例的時間在忙。
= handler 累計忙碌時間 ÷ 該句實際經過的時間
那條共用路徑被吃掉多少。它同時是 VAD-stop(觸發 final)的唯一通道, 所以佔用率高就等於 final 會被延後。100% = 飽和,積壓再也不會排空
audio_lag
實際經過的時間 − 已收到的音訊秒數。
「實際經過的時間」= wall-clock time:真實流逝的時間, 與 CPU time(核心實際執行的時間)不同——一個工作等在佇列裡時前者在走、後者不走。
刺激是以真實速度逐塊送的,所以這個差值就是伺服器落後多久才處理到最新的音訊
積壓的直接讀數。0 ≈ 即時;正值代表音訊在佇列裡等。 它是「使用者覺得卡住」與「server 內部忙」之間的橋——server 每次解碼都一樣快, 但如果音訊晚 50 秒才被處理,final 就晚 50 秒(§4.1、§4.2)
active整句期間看到的最大同時說話人數單人/多人分層的依據,見下段
三個量都跟「排隊」有關,但排的是不同的隊——不要互相代換
欄位誰在等誰讀數是什麼單位
seg_lock同一座位的下一次解碼前一次解碼 一次等了多久ms
rec_lock這個座位的解碼 等 別的座位的解碼(共用 recognizer) 一次等了多久ms
audio_lag音訊 framehandler 空出來 累積的積壓深度——到這一刻還有幾秒的音訊沒被處理

audio_lag 不是「某一次排隊等多久」,而是積壓:刺激以真實速度送出,handler 跟得上時「經過的時間 ≈ 已處理的音訊秒數」、差值 ≈ 0;handler 一卡住,時間繼續走而已處理量不動,差值就是還沒消化的音訊秒數

關鍵在「已處理」而非「已收到」:累加點在音訊 frame handler 內(src/kitt_stt_service.py:535),也就是序列化點 (1) 本身。socket 上早就收完的音訊,只要還沒被 handler 處理就仍算積壓。

active整句期間看到的最大同時說話人數,不是 log 當下的人數——在 final 那一刻計算會低估, 因為那時其他座位多半已經講完,4 人的格點會被標成 1 人,單人與多人兩節就會混在一起。

2.3 · 環境

項目設定
受測 serverT4 GPU,n1-highmem-2(2 vCPU),與 production kitt-stt 同機型。節點上只有受測 server + GKE 系統 daemon
第二台同機型 n1-highmem-2 + T4(asia-east1-a),整台專用。先跑與第一台相同的組態驗證兩台等價,通過後才敢讓不同方案平行跑——否則「哪台機器」會混進結論
Client另一個節點(e2-highcpu-8),pod 對 pod。刻意不與 server 同機:client 自己要 0.5–1 核,放在 2 核節點上等於量測工具製造了它要量的競爭
Client 資源request 1 核。client 被餓到會送不準音訊——那是刺激壞掉,不只是讀數壞掉
Production全程未觸碰(不同節點、0 restarts)

2.4 · 網格與統計

規則內容與理由
格點1–4 人 × 1/3/5/10 秒=16 格,每個方案完整跑三輪
取中位數,不取平均兩個可證明相同的組態在同一格點量到差 4.5×。單次量測不足以分辨任何小於 4.5× 的效應,平均值會被單一長尾拉走
一格一連線每個網格點各自建立連線,不跨格重用
每輪之間等到伺服器排空連續 45 秒無新 log 才開下一輪。4 人 × 10 秒那格會留下 60–100 秒積壓,不等會直接污染下一輪的乾淨格點(實測被拖到 2.4 秒)
分層機制若預測分歧集中在某條件,聚合前先按該條件分層。把句首/句尾、1 人/4 人平均在一起會抹掉正要找的效應
污染自動標記final 早於本輪說話結束即標 contaminated,不列入統計
刺激保真度每輪記錄 pacing_slip(實際送完所花的時間 ÷ 音訊本身的長度 − 1),超過 15% 該輪作廢。另有環境哨兵:1 人的格點理論上不可能排隊,backlog 超過 1 秒即警示該網格不可信
會計不變式prep + seg_lock + rec_lock + decode + dec_ovh + post + push 必須重建出 total(容差 10 ms)。有缺口代表有未量測的時間,該筆作廢而非硬分攤

2.5 · 「逾時」是什麼意思

格點表裡的逾時不是「等一下就放棄」——判準是工具寫死的 timeout = max(120 秒, 音檔長度 × 15),而且從 VAD-stop(講完)那一刻才開始計時

音檔長度1 s3 s5 s10 s15 s20 s
等待上限120 s120 s120 s150 s225 s300 s

門檻寬鬆是刻意的:正常情況 final 在講完後 100–300 ms 回來,而多人塞車時排隊本來就是要量的現象。上限設得緊會把「慢但仍有效」誤判成「壞掉」,就分不出劣化與失效——所以逾時代表該組態在該負載下不會回 final,那是結果,不是量測失敗。

🧠3 · 面向 A:模型推理

3.1 · 候選清單:承接的八項 + 本輪追加的兩項

上一輪把八項候選留給本輪,判準是「換掉模型之後這一項還算不算數」——只在「用 offline 模型定期重解整段」這個策略下才存在的手段,都不屬於 SW 流程設計。本輪處理了其中五項,另加兩項使用者提出的延伸方向。這張表只回答「有哪些候選、各自想做什麼、去哪一節看」;每一項的量測與判定在它自己的小節裡。

#方法機制:它想改什麼在哪一節
#3prep 增量化 每次 interim 只算新增音訊的 fbank,不重算整段——把特徵抽取從「正比於整段長度」變成「正比於新增長度」 §3.3.1
#4滑動窗口+保留前綴 每次 interim 只解「最近這一段」,不從句首重解。要落地必須回答兩個問題:左緣放哪裡(固定 N 秒/已確認的句中逗號),以及新解出的字怎麼接回已鎖定的前綴(合併) §3.3.2
#5final 重用最後一次 interim VAD-stop 前的靜音期間伺服器是閒著的,而最後一次 interim 已解過幾乎全部音訊——若新增的只有靜音就直接重用該結果,跳過 final 的 prepdecode §3.3.3
M5跨座位批次解碼 把同時到達的多個座位的解碼請求合成一批送進 GPU,靠 decode_streams 這類批次 API 攤平固定開銷 §3.3.6
M4 / M6
#6 / M7
四個「決定何時重解」的手段 間隔×人數、背壓感知跳過、提高間隔地板、全域併發上限——都是排程層的節流,不改單次解碼的成本, 只改多久解一次 不做
凍結時間判準
判定:四項都不做——判準是使用者感覺到的「凍結時間」

直覺會說「少解幾次 = 使用者更晚看到字 = 延遲變差」。對 time to final 而言相反。 final 由 VAD stop 觸發、解整段 buffer,跟 interim 排程無關;而 interim 少排幾次,共用 recognizer 鎖上的 隊伍就短,final 反而更早拿到 GPU。固定 500 ms 不節流時,飽和下的隊伍指數成長—— 20 秒句 time to final 92.5 s(RTF 4.96)、連 interim 自己的間隔都變成 18.9 s; 3 秒句 × 4 人 41.0 s → 1.90 s(21.6×),16 格點從 13/16 變成 16/16不節流才是延遲變差的那一邊。

但真正被換掉的東西是更新密度,而使用者感覺到的不是密度,是凍結時間—— 螢幕上兩次更新之間空白多久(max_interim_gap_s)。使用者裁定:更新密度變低、 且每次更新等更久,就判定不做,因為那會被讀成卡頓。四項套下去全部不通過:

候選對凍結時間的淨效果判定
#6 提高間隔地板(靜態)沒飽和時也拉長間隔,而那時沒有隊伍可省 不做
M4 間隔 × 人數(靜態)同上,人一多就無條件拉長 不做
M6 背壓感知跳過跳過的當下一定變長;唯一的辯護是「避免之後更大的間隔」,本輪沒量 不做
除非量到淨縮短
M7 全域併發上限「滿了就跳過」與 M6 同形狀;只排隊則與現有共用鎖無異 不做

判準套在「淨的間隔」上,不是套在機制的意圖上。 已上線的反應式排程(max(上次延遲 × 1.5, 0.5 s))表面上完全符合「密度變少、每次等更久」的描述—— 但它的淨效果是凍結時間大幅縮短(20 秒句 18.9 s → 低個位數秒)。不加這一句,這條規則會把一個 已驗證有效的機制誤判掉。反過來說這也是節流唯一站得住的理由:只有當「不節流會造成更長的凍結」時才做, 而且必須量出淨值,不能拿「減少爭用」當理所當然的好處。

這也是為什麼 M4#6 作為靜態旋鈕已大致被取代AudioCfg.next_interim_interval 現行就是 max(上次延遲 × 1.5, 0.5 s), 負載一重、延遲一長,間隔自己拉開——同樣的效果,靠量測自我調節,不必手調常數。 M6(背壓感知跳過)與它同方向但是事件驅動,值不值得做取決於反應式排程還有沒有反應不及的窗口, 本輪沒量M7 則要注意:上限若只能排隊,就與現有共用鎖沒有差別, 它的價值全部來自「滿了就跳過」。

本輪追加評估的兩項延伸方向(使用者提出,非原八項候選之一;都不需要改動 src/,量完才決定)
縮短 leading_pad_secs 減少每次送進模型的前導靜音,讓模型少處理一段沒有資訊的音訊 §3.3.4
調整 num_threads 改 ONNX Runtime 的執行緒數,看多人併發時的 CPU 競爭是否來自 ORT 自己開的執行緒 §3.3.5
上一輪把「C++ 底層滑動視窗」與「timestamp 對齊」列為兩個獨立候選,本輪查證後兩者都不是可選項GetFrames() 沒有切片參數也沒有幀淘汰介面,所以窗口化只能在 Python 端切波形;而 timestamp 對齊的方向隨語料翻轉、成本一致地更貴,已退出候選。因此 §3.3.2 的軸只有切法合併兩個,本表也不單列這兩項。

3.2 · 成本結構:為什麼先動 prep

單次 interim 的成本會隨句子變長而上升,prepdecode 兩者都在漲—— 差別在漲的幅度。10 秒句、比較句首 ¼ 與句尾 ¼ 這兩段的同一項成本:

位置prepdecodetotal
句首 ¼14.6 ms68.8 ms87.0 ms
句尾 ¼60.7 ms83.3 ms146.3 ms
成長4.16×1.21×1.68×

decode 也在漲(1.21×),但 prep 漲了 4.16×——句子越長,成本的增量越是由 prep 貢獻的。 再把 prep 拆開(上一輪的微基準):create_stream 是常數、波形串接可忽略, prep 的 98% 是 fbank,且嚴格線性(約 5.5 ms/每秒音訊);對照 decode 的 1.5 ms/秒, 斜率差 3.7 倍。所以 10 秒句上特徵抽取(55.4 ms)已經比模型推理(51.2 ms)還貴。

這就是把 #3 排在最前面的理由:兩項都值得優化,但 prep隨句長漲得最兇的那一項—— 打它拿到的是斜率,打 decode 拿到的只是截距;而且它是唯一不用拿準確度去換的(特徵逐 bit 相同)。 decode 那一側的斜率要打,就得動窗口化(§3.3.2),那才需要用準確度換。

3.3 · 逐項的量測與判定

下面六節依 §3.1 表格的順序展開,每節的結構相同:機制 → 效益 → 代價 → 判定。判定與依據摘要已在 §3.1 的表上,這裡是它們背後的量測。

3.3.1 · #3 prep 增量化 已採用

一句話結論

#3 是本輪唯一零代價的優化——增量餵入與一次性餵入算出的特徵逐 bit 相同, 所以它不是拿準確度換速度,而是把重複勞動刪掉。它打的正是 interim 路徑最大的一項:prep 的 98% 是 fbank, 而 10 秒句上 fbank(55.4 ms)已經比模型推理本身(51.2 ms)還貴。效益在三個尺度上都量到、方向一致(下表), 唯一未決的是多人端到端效益——跨 session 雜訊蓋過訊號,見 §4。

量在哪個尺度量的是什麼改動前+#3倍率這個尺度回答什麼問題
① 單一階段prep(10 秒句)63.0 ms3.4 ms18.5× 被打的那一項真的被打平了嗎——關鍵是斜率歸零,不只是變快
② 整通呼叫每次 interim(55 秒句)418 ms205 ms2.04× 階段效益會不會被其他階段稀釋掉,以及它額外買到的「不崩
③ 別人的成本他座位的 decode(4 人併發)27.2 ms20.7 ms1.31× 它有沒有順帶縮小 M1 引入的 CPU 競爭(二階效益)
三個尺度的絕對值不可互相比對——量的東西不同(① 只有 prep;② 是 prep + 搶鎖 + decode + ITN;③ 是別人的 decode)。 可比的是方向與倍率。本節依序展開這三列,最後是「代價=零」的證明。
一 · 機制:把「每次重算整段」換成「只算新增那一段」

差別只在「每次 interim 餵給特徵抽取器的是什麼」圖 1):

第 1 次 interim buffer 已累積 1 段 第 2 次 interim buffer 已累積 2 段 第 3 次 interim buffer 已累積 3 段 改動前 每次建新 stream 重餵全部 1 段 新 stream 重餵全部 2 段 新 stream 重餵全部 3 段 新 stream +#3 整句共用一個 stream 只餵第 1 段 只餵第 2 段 只餵第 3 段 同一個 stream,fbank 保留已算好的幀 成本 ∝ 整段 成本 ∝ 新增 這次餵進 fbank 的音訊: 改動前 +#3 已算過、這次不必重算
圖 1 · 同一句話的前三次 interim,改動前後各餵了什麼進 fbank實色方塊=這次真的送進特徵抽取器的音訊,顏色隨組態(上列橘=改動前、下列藍=+#3,與全報告一致);灰框=已算過、這次不必重算
改動前第 N 次要餵 N 段,累計量隨 N² 成長;改動後每次固定只餵新增的約 0.52 秒。兩者算出的特徵逐 bit 相同(5 條測試釘住)。
改動前+#3
stream 每次 interim create_stream() 建一個新的,用完丟掉 整句話共用同一個 stream,存在 session.incremental_stream,utterance 結束才丟
餵什麼 整個 buffer 重餵一次(第 5 次 interim 就把前面 4 次算過的音訊再算一遍) 只餵上次之後新增的位元組,靠 incremental_stream_fed_bytes 這個游標記住餵到哪
fbank 從第 0 幀重算到最後一幀 沿用已算好的幀,只算新音訊產生的那幾幀
成本 正比於整段長度(句子越長越慢) 正比於新增長度(每次都差不多,與句長無關)

為什麼原本做不到,以及為什麼改動成本很小OfflineStreamAcceptWaveform() 內部立刻呼叫 InputFinished(),fbank 一旦 finish 就不能再收音訊——所以只能一次性餵。但真正做增量的引擎(knf::OnlineFbank)早就在串流路徑上跑產線,OfflineStream 只是沒把它的增量能力暴露出來。改動是拆開「收音訊」與「宣告結束」兩件事,不是新寫演算法——這也是特徵能逐 bit 相同的原因。

檔案改動
csrc/offline-stream.h / .cc新增 AcceptWaveformIncremental()(不自動 finish)與獨立的 InputFinished()AcceptWaveformImplbool finish 參數。既有 AcceptWaveform() 語意完全不變(傳 finish=true),final 路徑零影響
python/csrc/offline-stream.cc新增三個 pybind11 binding,照抄 OnlineStream 既有樣式;get_frames 的 C++ 實作本來就存在,只是從未被綁定
src/asr/batch_asr_manager.pyrun_interim_inference_incremental():只餵新音訊、絕不呼叫 input_finished()(這個 stream 之後還要繼續餵)
src/session/user_session.py
src/kitt_stt_service.py
per-utterance 持續 stream 與已餵位元組游標;四個結束點都必須重置 stream(不得跨 utterance 復用,fbank 歷史會汙染下一句)
二 · 效益(1/3) prep 本身:從隨句長成長,變成與句長無關

k8s(T4)同機交替實測,依該次 interim 的 buffer 累積長度分層(圖 2)——分層才看得到斜率,而斜率才是這個優化的性質(打平),倍率只是它在某個長度上的截面。

句子累積長度改動前+#3改善樣本數
1–2 s13.00 ms8.37 ms1.6×34 / 36
2–4 s24.11 ms3.65 ms6.6×25 / 24
4–7 s45.81 ms3.47 ms13.2×18 / 18
7–12 s63.03 ms3.41 ms18.5×19 / 8
改動前 13.0 → 63.0 ms 隨句長線性上升;改動後固定 3.4–3.7 ms。fed_s(每次真正餵進 fbank 的音訊)中位數改動前 2.56–3.08 秒、改動後固定 0.52 秒——直接對應兩臂的定義差異。
0 ms 10 ms 20 ms 30 ms 40 ms 50 ms 60 ms 70 ms 1–2 s 2–4 s 4–7 s 7–12 s 句子累積長度 buffered_s(trace 新增的標註欄位) 63.0 ms 改動前(整段重算) 3.4 ms +#3(增量) 18.5× k8s(T4)上的 prep 前後對照:句子越長,改善越大 改動前+#3 同機交替、3 輪、單人 3/10 秒句;依 trace 的 buffered_s 分層(滑過看樣本數) 改動前正比於整段長度;改動後只正比於新增音訊(fed_s 中位數固定 0.52 秒)
圖 2 · prep 前後對照(k8s T4 同機交替),依該次 interim 的 buffer 累積長度分層——分層才看得到斜率
改動前 13.0 → 63.0 ms 隨句長線性上升;改動後固定 3.4–3.7 ms。最長那層 63.0 → 3.4 ms = 18.5×。平線代表 prep 不再隨句子變長而變貴,不只是「快了幾倍」。
二 · 效益(2/3) 整通 interim 呼叫:效益隨句長放大,而且買到「不崩」

2×2 四條線 × 15–90 秒 × 16 個長度點,單人、真實流速。加「固定 500 ms」那一軸是為了排除排程這個共變因——否則反應式排程「少解幾次」會偽裝成「每次變快」。所以主證據是total latency(推論時間總和) ÷ interim 更新次數=解一次的錢(下表;四指標全貌見圖 3):

句長l1
#3 on · 反應式
l2
#3 off · 反應式
l3
#3 on · 固定 500ms
l4
#3 off · 固定 500ms
15 s126176125174
30 s135232141223
45 s191318219641
55 s2054182021949
60 s266423268逾時
80 s318552993逾時
90 s3545871520逾時
每次 interim 的平均成本(ms)= total_inference_time_ms ÷ interim_count歸因成立的證據就在這張表的分群l3 緊貼 l1(125 vs 126、202 vs 205)、l2 緊貼 l4(176 vs 174、232 vs 223)——分群是照 #3 分的,不是照排程分的
原尺寸
#3 on · 反應式 #3 off · 反應式 #3 on · 固定 500ms #3 off · 固定 500ms interim latency(單次,ms) 200 500 1000 2000 15 30 45 60 75 90 語句長度 (s) 逾時 interim 最大間隔 (s) 6 12 17 23 15 30 45 60 75 90 語句長度 (s) 逾時 total latency:推論時間總和 (s) 5 10 20 50 100 200 500 15 30 45 60 75 90 語句長度 (s) 逾時 RTF 0.2 0.5 1 2 5 15 30 45 60 75 90 語句長度 (s) 逾時
圖 3 · #3 的長語句掃描:四指標 × 四條線(圖例見圖上方)。加固定 500 ms 那一軸是為了排除排程這個共變因。前三格為對數 y 軸;「逾時」實心點是該線最後拿到 final 的長度點。(1) 效益隨句長單調放大,15 s 1.40× → 55 s 2.04×,16 點全部同向。(2) l3 緊貼 l1l2 緊貼 l4——分群照 #3 分,不是照排程分,歸因成立。(3) 固定排程下關 #3 的 l4 在 45 s 發散、60 s 起無 final,開 #3 撐到 80 s(絕對門檻隨機器條件變動,可引用的是這個相對關係)。
四種搭配裡哪個最好:l1(#3 開+反應式排程)全程最優,而且是現行出貨組態

15–90 秒整個範圍,l1 的interim latency都是四線最低(126 → 354 ms),且從未逾時。 拆開看兩個優化各自的角色:只開反應式排程不開 #3(l2)不會崩,但一路貴(176 → 587 ms,55 秒起就是 l1 的 2 倍以上); 只開 #3 不換排程(l3)在 55 秒以前幾乎與 l1 打平,但80 秒後失控(993、1520 ms)—— 證明單靠 #3 頂不住極端長句,固定排程沒有煞車機制兩者都不開(l4)在 45 秒就發散、60 秒起完全逾時,是最差組合。

所以兩個優化不是各自獨立疊加,而是互補:反應式排程負責「絕不失控」,#3 負責「同樣不失控的前提下盡量便宜」—— 單開任一個都只拿到一半好處,兩個都開才是唯一在整個測試範圍內同時達成「便宜」與「不崩」的組合。

為什麼 RTF 那一格不能單獨看:l2 的 RTF 更平,卻是更差的行為

l2(#3 off + 反應式)的 RTF 幾乎不隨句長上升(0.175 → 0.226),比 l1 的 0.182 → 0.332 好看。但它的 interim 更新次數在 75 次左右停止成長(90 秒時 90 次 vs l1 的 115 次)——反應式排程是靠「少解幾次」把 RTF 壓住的,代價是更新變稀。正確讀法:排程保證不崩,#3 決定不崩的前提下能更新多密

「不崩」的門檻可以引用哪一半順序是穩定的(開 #3 撐得比關 #3 久),絕對門檻不是——§3.3.2 那輪同組態的 f0(#3 + 固定 500 ms)在 90 秒仍未崩潰(RTF 0.473、最大間隔 2.79 s),而本輪同組態的 l3 在 80 秒就失控。差異在量測條件(本輪帶 cpu request 1200m 且四臂共用同一時段負載)。所以「80 秒」是那次條件下的數值,不是組態的性質。
其他量測限制:單人,所以這裡看不到 §4 的多人排隊效應;每個長度點 1 輪,不重複取樣(16 點同向已足以定方向,個別點仍有 ±10% 級的機器雜訊)。l4 在 60–70 秒兩次逾時後放棄,75–90 秒未量——那不是缺資料,是該組態在那個長度不會回 final

二 · 效益(3/3) 連別人的 decode 一起變快:M1 引入的 CPU 競爭砍半

M1 把 prep 移出 frame 路徑後,四座位的 fbank 變成真的併發,於是互搶 CPU(2026-08-18 §附錄 D.1 量到 prep 33.8 → 62.1 ms)。#3 把每次 prep 從「整段成長中的 buffer」縮成「新增的 0.5 秒」,所以它應該連那個競爭一起縮小——這是先前沒量過的二階效益,也是三個尺度裡唯一「效益落在別人身上」的一個。

其他三座位正在做的 prepdecodevs 無競爭那個 prep 本身
無競爭基準13.6 ms1.00×
改動前:整段 10 秒重算27.2 ms2.00×20.1 ms
+#3:只算新增的 0.5 秒20.7 ms1.52×0.5 ms
競爭懲罰從 +100% 降到 +52%prep 本身 20.1 → 0.5 ms。所以 #3 的價值不只在「自己變快」——它連別人的 decode 一起變快
三 · 代價:把「零準確度風險」變成可執行的檢查

#3 的代價欄之所以能寫「零」,不是因為改動看起來安全,而是因為「零」在這裡剛好是一個可以直接測的命題: 增量餵入與一次性餵入算出的特徵必須逐 bit 相同。這一節就是那個命題被拆成哪些檢查、每個檢查在防哪一種失效 (依 CLAUDE.md §2.5 Rule 2:有門檻的驗收條件必須有可執行的檢查,否則只是願望)。

檢查條數它在防哪一種失效怎麼有偵測能力
特徵逐 bit 等價
test_offline_stream_incremental.py
4 邊界幀被丟掉或重複算——增量餵入最容易錯的地方就是跨 chunk 邊界那一幀 4 種 chunk 大小,其中 333 ms 刻意不是 10 ms 幀移的整數倍:天真實作正好在這裡掉幀或重複幀。 斷言是 np.array_equal(不是近似相等),所以差一個 bit 就紅
成長中的 buffer 每一步都等價1 中途讀取特徵擾動了抽取器狀態,導致後面的幀跟著錯 照 interim 路徑真正的用法跑:餵一塊 → 讀一次 → 再餵 → 再讀,最後與一次性餵入逐 bit 比對
C++/Python 綁定存在3 綁定漏掉或簽名改掉,改動靜默失效 hasattr 檢查三個新介面(accept_waveform_incrementalinput_finishedget_frames
manager 不自己動音訊
test_batch_asr_manager_incremental_prep.py
8 音訊被重複計入 fbank(切錯 slice、自己補 pad、自己累加),以及誤呼叫 input_finished() 讓後續餵入直接拋例外 用假 stream 記錄每次真正收到的陣列,斷言它逐一等於呼叫端給的那兩塊;另外釘住「絕不呼叫 input_finished()」與「重用同一個 stream、不新建」
service 接線正確
test_interim_incremental_wiring.py
6 持續 stream 跨 utterance 存活——上一句的 fbank 歷史會汙染下一句,這是唯一會真的改變輸出文字的失效 對原始碼做結構檢查:每一個清掉 segment buffer 的地方都必須同時重置 stream(不是抽樣檢查其中幾個), 並確認 interim 走的是增量路徑、沒有誤用一次性 accept_waveform()
合計22此表只列 interim 增量化本身的 22 條(8+8+6);final 沿用另有 tests/test_final_stream_reuse.py 9 條(附錄 B),全輪合計 31。全綠,併入標準離線套件
「零代價」是這 22 條的結論,不是前提。其中真正承載「零準確度風險」這句話的是前 5 條 (4 種 chunk + 成長中的 buffer)——它們直接比對特徵陣列;其餘 17 條防的是接線層面的失效, 那類失效不會讓特徵算錯,但會讓錯的音訊被送進去算。
✅ 判定:採用

三個尺度全部同向,且代價欄是空的:prep 打平(10 秒句 63.0 → 3.4 ms,斜率歸零)、 整通 interim 效益隨句長單調放大(16 點全部同向,55 秒 2.04×)、還順帶把 M1 的 CPU 競爭砍半(+100% → +52%); 逐 bit 等價由 5 條測試釘住。但它不能單獨出貨——單開 #3 頂不住固定排程在極端長句的失控, 它與反應式排程是互補而非各自獨立,兩者都開才是唯一同時做到「便宜」與「不崩」的組合。 唯一未決的是多人端到端效益:跨 session 雜訊蓋過訊號,仍待下一輪同機交替量測確認。

Frame 處理邏輯 —— ◇ 是判斷點,箭頭上標的是條件 IN · AudioRawFrame utterance 進行中? 只進 preroll 緩衝 append 到 segment buffer 已喚醒 且 排程到期 且 沒有解碼在飛? 略過(不排隊搶鎖) interim 重解(#3) 持續 stream 只餵新增音訊 M1:派發為背景 task OUT · Interim…Frame IN · VADUserStopped…Frame 快照整段音訊、清空 buffer 把 stream 交棒給本句的 final stream 握有自句首起 的完整音訊? 重抽整段 fbank 只補餵未餵入的尾巴 final 解碼 → HR → ITN 喚醒/解除喚醒的字串比對在此 OUT · Transcription + ASRMetadata 色語意:■ IN ■ interim 路徑 ■ final 路徑 ■ 退回從頭準備 ■ 待機/略過
圖 3b · #3 的資料流:interim 抽好的特徵怎麼交給 final。 左半是 interim 路徑(同一個 stream 只餵新增的位元組),右半是 final 路徑。 右半那個判斷點是本輪新增的:只有在「stream 握有自句首起的完整音訊」時才沿用, 否則退回重抽整段——裁切過的 stream 起點在句中,接上尾巴會靜默地解出另一段音訊的合理文字。

3.3.2 · #4 滑動窗口 三種切法都不做

本節怎麼讀

一條線:A 介紹三種切法是什麼(只講機制、不談結果)→ B 量效能C 量準確度D 兩軸交叉,回答能不能做。結論在 D,B 與 C 各自只回答自己那一軸。

E 是獨立的機制量測,放在判定之後:它量的是「左緣多久移動一次」如何決定 prep 的形狀—— 既回答了已採用的 #3 為什麼 prep 會隨句長成長,也回答了「切法會不會毀掉 #3 的增量性」 (重開 #4 時不必重做的功課)。那既不是效能軸也不是準確度軸。

A · 滑動窗口要怎麼實作

「滑動窗口」的意思是不要每次都從句首重解——只把「最近這一段」送去解碼。但「只解最近一段」本身還不是一個做法: 要落地必須回答兩個彼此獨立的問題,兩個答案組合起來才是一個具體實作。 第二個(合併)最容易被漏掉,而它正是本輪量出來影響最大的那一個—— 固定視窗曾經量到的 89% CER,量的其實是合併實作,不是切法。

選項這個選項具體做什麼
1 · 切法
左緣放哪裡
固定長度 左緣=右緣往回退固定 N 秒,不看內容buffer[-N×SR×2:]。隨時可切,切點純由秒數決定。本輪量 N=3 s 與 N=8 s 兩種
標點/句界 左緣=上一個已確認的句中逗號。判準是 LocalAgreement-2:連續兩次 interim 的前綴逐字相同才算「已確認」; 前綴裡出現 ,、, 就把左緣鎖到該逗號 token 的時間戳,此後不再重解它之前的音訊(左緣只前進、不回頭)。 句尾標點(。?!)不觸發——它代表模型認為整句講完了,拿它當「先鎖一段、繼續往下解」的中繼點自相矛盾
2 · 合併
新解出來的字
怎麼接回已鎖定的前綴
文字重疊
兩份實作的主訊號
找「已鎖定文字的最長後綴」同時是「本次視窗輸出的前綴」的那一段,剪掉再接。不看時間,只看文字——所以不受 CTC 峰值漂移影響
對不上時的後備 這才是兩種切法真正分岔的地方:標點切直接串接(重疊上限一個音節,最壞多一個字); 固定切退回時間戳,而時間戳會漂移,於是還要再補兩個機制。詳見軸 2
兩種切法互斥:固定長度隨時可切,標點切要等一個安全切點出現。 軸 2 不改變送進模型的音訊,只改變螢幕上出現什麼字——但本輪量到它的影響大於切法本身: 同一個切法、同一段音訊,只把合併補完整,90 秒句的 interim CER 就從 89.08% 降到 3.54%(C 節)。
另外兩件事本輪不是選項,所以不列成軸切點對齊(把左緣吸附到最近的 token 時間戳)已退出候選(§3.1)—— 它的方向隨語料翻轉、成本一致地更貴,而標點切的左緣本來就是一個 token 時間戳,沒有可吸附的對象; 實作層次只有一種可走——OfflineStream 沒有幀淘汰介面,所以只能在 Python 端切波形, 這一點決定的不是輸出而是能不能同時保住 #3(E 節)。
軸 1 · 切法:左緣由秒數決定,還是由內容決定
三種做法的差別,只在「這次送出去解碼的是哪一段音訊」——右緣都是現在,差別全在左緣 幫我導航到內湖科學園區,然後選最快的路線,不要走高架橋 整句音訊 時間 → 逗號 逗號 現行 #3 句首 → 現在 成本 ∝ 整段 固定長度切 現在−N 秒 → 現在 成本 ∝ N(定值) ▲ 左緣=右緣往回退固定 N 秒,三次都沒落在逗號上——③切進「路」的中間 標點切 上個逗號 → 現在 ← ①:還沒有逗號被確認,所以左緣仍是句首(此時=現行) 成本 ∝ 逗號後 左緣只在逗號上前進,不回頭 ▲ 左緣=上一個已確認的逗號,兩次都落在子句邊界;虛線段是已鎖定、不再送去解碼的前綴 =這次送出去解碼的那一段(顏色隨做法,每列一色) =已鎖定、不再送去解碼 =真正的逗號位置
圖 4 · 三種做法各自把「這次要解碼的那一段」的左緣交給誰決定。右緣一律是現在(①②③三個 interim 時刻),差別全在左緣:現行 #3 的左緣永遠是句首;固定長度切 的左緣=右緣往回退固定 N 秒,由秒數決定標點切 的左緣=上一個已確認的逗號,由內容決定,只在逗號上前進、不回頭。
兩者送去解碼的都是一小段,形狀相同——唯一的差別是那一刀落在哪裡:紫線(逗號、子句邊界)還是任意秒數(字的中間,紅色粗豎線)。
逗號的秒數從哪裡來:token 時間戳

軸 1 的「標點切」有一個前提:知道文字上有逗號沒有用,要知道它在第幾秒,才切得動音訊。 那個秒數來自 OfflineStream 的 token 時間戳,所以先把它講清楚——它是什麼、數字怎麼產生、 逗號為什麼也有一個。這不是第三個軸,是軸 1 能不能落地的先決條件。

問題答案
API 長什麼樣 OfflineStream.result.timestamps:一個 float 陣列,result.tokens 一一對應, 單位是秒、原點是這條 stream 的起點(不是句首——視窗切過之後這兩者就不同了,所以服務端要自己加回左緣偏移)
數字怎麼產生 SenseVoice 是 CTC:編碼器把音訊變成一串幀,每幀對所有 token 給一個機率。解碼時取每幀的最大值, 某個 token 第一次成為該幀贏家的那一幀就是它的位置,乘上幀移就是時間戳。 所以它標的是發射峰值,不是那個字的起點——這個區別是本軸整個結論的關鍵
解析度多少 0.060 秒(10 ms 幀移 × 6 倍降採樣)。實測:base phrase 的 51 個時間戳全部是 0.06 的整數倍, 相鄰字最小間隔 0.120 s。所以左緣不可能吸附到比 60 ms 更精細的位置
逗號為什麼有時間戳 因為標點在 SenseVoice 眼中就是一個普通 token——它出現在 tokens 陣列裡, 和「導」「航」並排,於是自然也有自己的時間戳。標點切能成立完全建立在這件事上: 文字上知道有逗號沒有用,要知道它在第幾秒,才切得動音訊
實際的一次解碼長什麼樣

拿本報告一路在用的那句 base phrase(10.495 秒、51 個 token)真的解一次,把前 14 個 token 與它的時間戳列出來:

序號token時間戳說明
00.180 s一般字元,間隔 0.12–0.24 秒
10.300 s
20.540 s
30.660 s
102.040 s
112.340 s 逗號就是一個 token——標點切要的左緣就是這個 2.340
122.580 s
132.820 s
143.000 s
整句的五個句中逗號落在 2.340 / 4.080 / 5.580 / 7.320 / 9.060 秒,句尾的 在 10.380 秒。 標點切的左緣就在這五個值之間往前跳(句尾標點不觸發)。
timestamp API 有兩個用途,其中一個是不可或缺的。
用途哪個軸必須嗎實測
定位逗號在音訊上的位置1 · 切法必須 標點切的整個機制就是「把左緣移到那個逗號」。沒有時間戳就沒有標點切
判斷哪些字是「新的」3 · 合併可被取代 時間戳去重需要它;文字前綴比對完全不需要。而時間戳去重正是重複的來源——峰值會隨視窗左緣移動而漂移
SenseVoice 是怎麼知道要加逗號的

整個標點切建立在「模型會自己吐逗號、而且那個逗號有時間戳」上,所以先講清楚這件事怎麼成立的—— 它不是外掛一顆標點模型(FunASR 那條線是另外接 ct-punc),而是標點本來就在 SenseVoice 的輸出詞彙表裡

問題答案
標點是特殊符號嗎 不是,就是普通 token。tokens.txt(25,055 個):,=9686、=9728、=9729、 =20046、=24879、=24883——id 夾在一般字元之間,沒有獨立區段, 解碼器不知道也不在乎它們是標點
那模型憑什麼知道要放 訓練語料的逐字稿本來就含標點,所以模型學到的是「在這種聲學情境下,下一個 token 是」。 可用的線索是停頓長度、句調下降、以及編碼器隱含學到的語言模型——與它判斷下一個字是「航」還是「行」用的是同一套機制
為什麼逗號會有時間戳 因為 CTC 對每一個輸出 token 都給一個發射幀。標點既然是普通 token,就自然有自己的幀,乘上幀移就是秒數。 標點切能成立完全靠這一點:知道文字上有逗號沒有用,要知道它在第幾秒才切得動音訊
所以它可靠嗎 不可靠,而且是量得出來的。不切那一臂(沒有任何視窗干擾,純看模型自己)比對同一段音訊的 最後一次 interim 與 final:46% 的句子兩者逗號數不同(n=24,中位差 0、最大差 2)。 同一顆模型、同一段音訊,只因為看到的音訊長度不同就改變標點
機制上必然如此——人講話的停頓不一定明顯,停頓不明顯時模型就靠隱含語言模型猜, 而「猜」會隨上下文改變。這正是 LocalAgreement-2(連續兩次 interim 都吐同一個逗號才算數)存在的理由: 單次出現的幻覺逗號會把左緣永久推到錯的位置,而左緣只前進、不回頭
但 LocalAgreement-2 擋不住真正的代價:被永久鎖進顯示文字的不是逗號本身, 是模型在逗號邊界順手吐出的語助詞)。見 C 節的實機量測。
實測佐證見上方 base phrase 的 token 表—— 出現在 tokens 陣列第 11 個,時間戳 2.340 s, 和「区」(2.040)「然」(2.580)並排,完全是一般 token 的樣子。
軸 2 · 合併:切完之後,新解出來的字怎麼接回已經固定的字

兩種切法各自有一份已寫成程式碼的合併實作。主訊號是同一個——最長文字重疊; 真正的差別是比對不到的時候做什麼,以及為此需要幾個額外機制。

同一個主訊號:先找「已鎖定的最長後綴」=「這次視窗的前綴」,剪掉再接 已鎖定前綴 導航到內湖 最後一個已鎖定 token 的絕對時間 = 2.28 s 這次視窗解出 內湖科學園區 各 token 絕對時間:2.16 / 2.34 / 2.52 / 2.70 / 2.88 / 3.06 (視窗的開頭必然重複到已鎖定的尾巴——「內湖」兩字兩邊都有,所以每次都要決定「從第幾個字起才算新的」) 兩邊都做這一步 內湖 k=2 剪掉 科學園區 接上 導航到內湖科學園區 正確 重疊區只要逐字相同就對得上。對不上的時候才是兩邊真正分岔的地方——而對不上是常態:兩次解碼的字本來就不保證一樣。 標點切 punct_window.py 對不上時 → 直接串接,什麼都不做 提交多少 → LocalAgreement-2,而且只有        確認過的逗號才推進左緣 為什麼夠:視窗從逗號本身開始,重疊上限 就是跨越切點的一個音節,串接最壞多一個字 額外機制: 100 段實測 CER 2.92% / 3.15%(30/90 秒) 固定切 fixed_window.py 對不上時 → 退回時間戳:丟掉 t ≤ 2.28−0.06 提交多少 → max(一致, 截止期限)——下一輪就        看不到的 token 這一輪強制提交 為什麼不夠:視窗裡有 2.5 秒是已鎖過的 串接會把整段重貼——實測 CER 超過 100% 額外機制:相鄰標點收斂、標點不當時間戳錨點 100 段實測 CER 4.13% / 3.54%(30/90 秒)
圖 5 · 兩份合併實作:主訊號相同,差別全在「對不上時做什麼」。 兩邊的第一步一模一樣——找已鎖定文字的最長後綴同時是本次視窗前綴的那一段,剪掉再接punct_window.py::merge_on_overlapfixed_window.py::advance)。 分岔只發生在比對不到的時候,而那是常態:兩次解碼的字不保證一樣。
標點切可以「什麼都不做」——直接串接。因為它的視窗從逗號本身開始, 與已鎖定前綴的重疊被結構性地限制在跨越切點的那一個音節,串接最壞多一個字。
固定切不能——它的視窗有 2.5 秒是剛剛才解過的內容,串接等於把整段再貼一次(實測 CER 破 100%), 所以必須有後備。它用時間戳當後備,並且因為時間戳會漂移,又得再補兩個機制。 「需要幾個機制」本身就是這張圖要說的事。
 標點切 src/asr/punct_window.py 固定切 src/asr/fixed_window.py 量測臂
主訊號 最長文字重疊:找「已鎖定文字的最長後綴」=「本次視窗輸出的前綴」,剪掉再接。 兩邊逐字相同的邏輯,而且都不受 CTC 峰值漂移影響——這是選它當主訊號的理由
比對不到時 直接串接。視窗從逗號本身開始,與已鎖定前綴的重疊被限制在跨越切點的一個音節, 所以串接最壞多一個字 退回時間戳:丟掉 t ≤ locked_t − 0.06 s 的 token。 不能串接——視窗裡有 2.5 秒是剛解過的,串接會把整段重貼(固定 8 s 實測 CER 61–172%,超過 100% 代表插入比參考文字還多)
提交多少 LocalAgreement-2,而且只有已確認前綴裡的逗號才推進左緣。 幻覺逗號會把左緣永久推到錯位置,而左緣只前進不回頭,所以必須兩次確認 max(一致, 截止期限)。音訊按時鐘丟棄、文字按一致性提交,兩者無同步; 一致性沒趕上,音訊已滑出視窗且永不重解,那段文字就永久遺失。 修法是「下一輪看不到的 token 這一輪強制提交」
額外機制 相鄰標點收斂(放寬截斷後同一位置會被兩個視窗各解一次,而模型對該放哪個標點不穩定—— 實測 46% 的句子兩次解碼逗號數不同,於是 各提交一次,字元不同所以重疊比對抓不到); 標點不當時間戳錨點(標點沒有聲學長度,拿它當錨點會刪掉它後面那個字:,順便看。便看
100 段實測
interim CER,30/90 秒
2.92% / 3.15%4.13% / 3.54%
後備的差別不是實作品味,是結構決定的。 重疊區有多長,是切法決定的:標點切的重疊上限是一個音節,固定 3 s 的重疊是 2.5 秒 (視窗 3 秒、每次滑 0.5 秒)。重疊越長,比對失敗的代價越大,所以固定切非有後備不可, 而唯一可用的另一個訊號是時間戳——它又會漂移,於是再補兩個機制。 四輪修補全部敗在同一件事上(見 fixed_window.py 的註解),最後定案的順序才變成 「文字能決定就讓文字決定,時間只在文字找不到接縫時說話」。
時間戳為什麼只能當後備,而不能當主訊號。 CTC 的時間戳標的是發射峰值,而峰值位置會隨視窗左緣移動漂移數十毫秒。 拿它當主訊號的後果是雙向的,而且兩個方向互相拉扯: 判準嚴一格就重複順便順便便導航導航航), 鬆一格就刪除,順便看。便看)。 文字重疊完全不看時間,所以對這個漂移免疫——這就是兩份實作都把它放在第一位的原因。 本輪也量過「先用時間戳錨定、再在附近做文字比對」(merge="anchored",±3 字內找最長匹配): 在無累積那一版上只改善 0.7 pt,因為瓶頸不在對齊,在解碼不穩—— 真正解決它的是後來的截止期限提交,那是繞過一致性、不再要求兩次解碼逐字相同。
兩個軸與動態排程在哪裡相遇
一次 interim 的實際執行順序(src/kitt_stt_service.py 先問「要不要做」,才問「做什麼」——動態公式在前,切法在後。 ① 音訊 frame 進來,累積進 segment buffer buffer 一直長大 ② 排程守門 ← 動態公式在這裡 interval = max(上一次解碼的延遲 × 1.5, 0.5 s) 距離上次 interim 還不到 interval → 直接 return,這一輪不解碼 到時間了 ③ 取這次要解的音訊 ← 切法在這裡 buffer[切點:] ——「切點」由切法決定: 固定 N 秒:切點 = 現在 − N 每一輪都不同 標點切 :切點 = 上一個已確認的逗號 多數輪次不動 ④ 解碼 → 文字 + 每個 token 的時間戳 ⑤ 記下這次的解碼延遲 latency_ms ⑥ 合併:新解出的字接到已鎖定文字後面 merge_on_overlap ⑦ 只有標點切:找到已確認的逗號 → 移動切點 沒找到就不動。固定 N 秒切沒有這一步——它的切點在 ③ 由時鐘決定 顯示給使用者 ⑤ 的延遲餵回 ② 這個順序造成的結果 固定 N 秒切 ③ 的切點 = 現在 − N,而「現在」離上一輪 正好隔了 ② 算出的 interval → 切點每輪移動的距離 = 動態公式的輸出 節奏一變,上下文變動量就跟著變 標點切 ③ 的切點來自 ⑦,而 ⑦ 只在「找到已確認 逗號」時才動 → 切點與動態公式無關 節奏只改變「同一個切點重解幾次」
圖 6 · 一次 interim 的執行順序,以及切法與動態排程在哪裡相遇畫的是移除前的版本:標點切/固定切的分支邏輯已從 _interim_redecode_body 整段移除, 圖上不標行號,因為對照目前的 src/ 已經找不到這幾步)。 順序是固定的:② 動態公式先決定「這一輪要不要解碼」,③ 切法才決定「解碼哪一段音訊」, 而 ⑤ 量到的延遲又回頭餵給下一輪的 ②——這就是那個回饋環。
兩種切法的差別全在「③ 的切點從哪裡來」:固定 N 秒切的切點是「現在 − N」, 而兩輪之間的「現在」正好相隔 ② 算出的 interval,所以切點每輪移動的距離就等於動態公式的輸出—— 節奏一變,兩次解碼之間的聲學上下文變動量就跟著變。 標點切的切點來自 ⑦,只在找到已確認逗號時才動,與 ② 無關;節奏快慢只改變同一個切點被重解幾次。
實作已從樹上移除

標點切曾經寫進服務、也有測試,但本輪判定不做,程式碼與設定項已一併移除src/asr/punct_window.pysrc/asr/fixed_window.py、對應測試檔、 features.punct_window)。留一份沒有人會走的路徑,下一個讀者會以為它還在服役, 而它不會有任何錯誤訊息——所以判定不做就要真的拿掉。下表記的是移除前它做了什麼, 供日後若要重開時當作起點;那時要重寫,不是翻旗標。

位置做什麼
kitt_stt_service.py
_advance_punct_window()
拿 interim 的 token+時間戳,用 LocalAgreement-2 找出已確認前綴裡最後一個句中逗號, 把 session.punct_trim_bytes 推到那個時間戳,並把 incremental_stream 設為 None ——強迫 #3 從新左緣重新開始累積 fbank,這個重置就是 E 節裡標點切能把 prep 壓下去的原因
_merge_on_overlap() 用最長文字重疊接回 punct_locked_text;比對不到就直接串接——在標點切下是安全的, 因為視窗是從逗號本身開始的,可能重複的範圍被限制在跨越切點的那一個音節
batch_asr_manager.py
run_interim_inference_incremental()
當時把回傳值從 (text, latency, decode) 擴充為 (text, latency, decode, tokens, timestamps) ——標點切需要時間戳才能把逗號放回音訊時間軸。切法移除後已還原成 3-tuple, 因為沒有任何呼叫端再讀那兩個欄位
config-basic.yaml
features.punct_window
當時的開關;本輪已刪除該設定項
鎖定是不可逆的,而這正是本輪判定不做的核心理由。已鎖定的字永遠不會再被改——所以模型在子句邊界產生的插入字會被永久留在螢幕上(100 段量到每 90 秒句多貼 3.84 字,不切是 0.00)。 實機驗證(kitt-stt-batch-test-c-reactive,20 秒句)看到的就是這個: 幫我導航到內湖科學園區,,然後選最快的路線,不要走高架橋,,接着把冷氣打開,溫度調到24度 ——那些語助詞是模型在逗號邊界吐出來的,鎖進去就回不去了。 不切的現行做法沒有這個問題,因為它每次都重解整段、隨時可以自我修正。
B · 成本/效能分析

同一顆 image、同一時段、同一顆 T4,四臂一起跑(基準+三種切法),所以圖 6 的線可以互相比較。

這一節只看一個量:total latency——整句話所有 interim 解碼的內部時間加總。 理由是 interim 路徑本來就是「把成長中的 buffer 重解很多次」,所以一句話真正花掉伺服器多少推論,就是那些次數的總和; 單看「一次多貴」會漏掉「跑了幾次」。它同時是唯一能跨輪對照的成本量:2026-07-29 的 RTF 定義就是 total latency ÷ 音檔長度,乘回音訊秒數即可換算成同一個軸(見 §2.2 跨輪對照表)。

為什麼 §3.3.1 用的是「解一次的錢」而這裡用總和:那一節的四條線刻意跑不同排程 (反應式 vs 固定 500 ms),解碼次數本來就不同(90 秒 90 次 vs 115 次),用總和會把「少解幾次」讀成「每次變快」。 這一節的四臂共用同一個排程,次數落在 123–140 之間,總和才是可以直接比的。兩節的選擇不同,是因為要排除的混淆不同。

整句的 total latency —— 一句話總共花掉多少推論時間 0.5 1 2 5 10 20 s 3 15 30 45 60 75 90 語句長度 (s) #3(現行,不切) 固定 3 s 固定 8 s 標點切
圖 6 · 整句的 total latency(對數 y 軸,12 個長度點,滑過任一點可讀值;顏色與下表一致)。 基準(橘)是唯一一條持續往上抬的線——3 秒 0.56 s、90 秒 36.7 s;三種切法都把成長壓下來, 90 秒時分別落在 14.7/26.8/20.5 s。短句端三條線全部貼在基準之上或旁邊:3 秒句沒有一種切法比較便宜。
total latency(3 / 10 / 30 / 60 / 90 秒) interim 推論次數
同上五個長度
90 秒
對基準
基準 #3(不切)0.6 s · 2.2 s · 7.2 s · 21.9 s · 36.7 s6 / 20 / 56 / 98 / 1231.00×
固定 3 s0.6 s · 1.7 s · 4.8 s · 8.5 s · 14.7 s6 / 20 / 56 / 103 / 1402.50×
固定 8 s0.8 s · 2.7 s · 7.7 s · 14.6 s · 26.8 s6 / 20 / 56 / 105 / 1351.37×
標點切0.9 s · 2.3 s · 5.4 s · 13.6 s · 20.5 s6 / 20 / 53 / 100 / 1381.79×
推論次數那一欄必須跟著看:切法讓單次變便宜,反應式排程就會多解幾次 (90 秒基準 123 次、三種切法 135–140 次)。所以 total latency 的倍率其實低估了單次的差距—— 固定 3 s 每次便宜 2.84×,但因為多跑了 14% 的次數,整句只省 2.50×。 這一欄同時排除了反方向的混淆:沒有任何一臂是靠「少跑幾次」變便宜的。
interim 最大間隔四臂幾乎重合(90 秒 3.38–4.11 s),所以更新節奏不是差異的來源,那由排程公式決定。
為什麼效能的上限就是這麼多:decode 的固定項是大頭

#3 已把 prep 打平,窗口化只剩 decode 的斜率可打。T4 隔離實測的 decode31.1 ms 固定 + 2.31 ms/s——固定項不會因為切掉音訊而消失,所以「切掉多少音訊」能省下來的比例有天花板: 2.3 秒 buffer 只有 14.6% 可省、10 秒 42.6%、30 秒 69.0%、90 秒 87.0%。 窗口化的效益本質上是「句子越長越大」,短句上它無論怎麼實作都省不到什麼。(此表基準是純 decode 呼叫, 與上方整通 interim 的絕對值不可互比。)

B 節分析:效能上三種都成立,但沒有一種在短句上划算

共同形狀:基準的 total latency 隨句長一路抬升(0.56 → 36.7 s,65×), 而三種切法都把它壓成接近線性甚至更平——因為每次解碼看的音訊被視窗界住,不再隨 buffer 成長。 代價是一筆被視窗界住的 prep 罰則(建新 stream、重抽視窗內的 fbank),它不隨句長成長, 所以句子越長,這筆固定罰則被攤得越薄——這就是三條線都「短句吃虧、長句轉正」的機制。

短句端沒有一種划算:3 秒句三種全部比基準貴 (固定 3 s 0.91×、固定 8 s 0.70×、標點切 0.60×)。 標點切在 3 秒最吃虧是因為那時還沒有逗號被鎖定,它解的仍是整段,卻額外要求 token 時間戳並每次做 LocalAgreement 記帳—— 那兩項是量測補丁的成本,不是切法本身的(見 D 節)。轉正點:固定 3 s 與標點切約 5 秒,固定 8 s 要 30–40 秒。

長句端的排序是 固定 3 s > 標點切 > 固定 8 s(90 秒 2.50× / 1.79× / 1.37×)。 固定 8 s 最差:視窗大等於少切,固定罰則卻最重。

所以效能這一軸淘汰不掉任何一種——三種都把成本的成長壓下來了, 真正的分野必須靠 C 節的準確度。

C · 準確度分析
這一節是怎麼量的

量的是使用者在句子講完前的最後一瞬間,螢幕上實際看到的那串字——最後一次 interim—— 拿它跟該段音訊自己的 final 逐字比對。

項目做法
參考文字 該段音訊自己的 final transcript,不是人工逐字稿。使用者實際感受到的是 interim 與取代它的 final 不一致,這正好量那個;而且參考與受測走同一顆模型, 語料品質的干擾被消掉了——模型聽錯的地方,參考也一樣錯,只有切法造成的差異會浮出來
正規化對齊 生產環境的 interim 只過 s2t(),final 過完整 itn()(s2t + 數字正規化)。 直接比會把 二十四24 算成四個錯字。所以受測的 interim 先過同一個 itn() 再比, 兩邊同一條管線。驗證:不切那一臂是 0.0%——若管線沒對齊,它不可能是 0
語料 100 段自撰的長句,每段一次 TTS 合成(不是短指令拼接,所以沒有接縫處的聲學不連續), 四種語音輪替。30 秒與 90 秒各 100 筆,同一段音訊裁切而來所以內容一致。 產生器 experiments/window_cer/gen_corpus/
迴圈 每 0.5 秒觸發一次 interim,每個臂都跑完整迴圈——鎖定前綴是逐次累積的,只解最後一刀量不到累積誤差
指標 CER,並用編輯回溯拆成替換/插入/刪除——CER 是三者相加,但它們是三種不同的病
四種做法,全部量完
先講定「替換/插入/刪除」是什麼——CER 是三者相加,但它們是三種不同的病

CER 把「螢幕上那串字」對「該段自己的 final」做編輯距離:算出最少要做幾次單字元編輯才能把前者變成後者, 再除以 final 的字數。那些編輯只有三種,分開看才知道是哪裡壞了

分型意思使用者看到什麼典型成因
替換
substitution
字在對的位置,但認錯了——一個字換成另一個字 導航設到 顯示成 導航射到老哥 顯示成 老歌 模型本身的聽錯,與切法無關。所以它在每一臂都差不多(多在 2% 上下),是本表的地板
插入
insertion
螢幕上多出 final 沒有的字 順便 變成 順便便導航 變成 導航航25度 變成 225度 合併的兩種失敗模式:①時間戳去重——CTC 發射峰值隨視窗左緣移動漂移數十毫秒,同一個字被當成新字再收一次;②文字重疊比對不到就直接串接,於是整段視窗被再貼一次(固定 8 s 實測 CER 61–172%,超過 100% 代表插入的字比參考文字還多)
刪除
deletion
final 有的字沒顯示出來——整段跳掉 「順便看還剩幾個空位導航設到最近那1個」整句消失 提交規則的失敗模式,不是合併的:LocalAgreement-2 要連續兩次視窗解出一樣才鎖定,而固定視窗的音訊按時鐘滑出且永不重解——一直對不上的那段就永遠沒進畫面(實測 3 s 視窗 81% 的步驟鎖 0 字)。合併比對不到時是直接串接,會多貼而不是跳過merge_on_overlap
看分型比看 CER 總數有用:兩個臂可以有同樣的 CER 卻壞得完全不一樣—— 插入為主代表「畫面上有贅字」,刪除為主代表「畫面上少了一整句」,後者對使用者嚴重得多。 三者相加就是該列的 CER(因為分母同樣是 final 的字數)。
切法平均 interim CER(對 final) 90 秒的錯誤分型
30 秒90 秒替換插入刪除
不切(基準)0.00%
0.00 字
0.00%
0.00 字
0.00%
0.00 字
0.00%
0.00 字
0.00%
0.00 字
固定 3 s4.13%
6.28 字
3.54%
16.12 字
2.13%
9.69 字
0.51%
2.33 字
0.90%
4.10 字
固定 8 s3.77%
5.73 字
3.10%
14.10 字
1.90%
8.64 字
0.21%
0.95 字
0.99%
4.51 字
標點切2.92%
4.43 字
3.15%
14.35 字
2.21%
10.07 字
0.84%
3.84 字
0.10%
0.44 字
每格 100 筆的平均,計分前已依 normalize_text() 移除所有標點—— 引用時務必連這個基準一起講,把標點算進去的數字完全不同(見 §C 末的實機量測)。每欄的字數都是「百分比 × 該長度的平均參考字數」——30 秒句參考平均 151.94 字、90 秒句 455.13 字(100 段 final 逐字數平均),CER/替換兩欄與插入/刪除共用同一組基準,可以直接互相對照。 兩個固定視窗跑的是 src/asr/fixed_window.py已鎖定前綴累積 + 截止期限提交 + 接縫刪重複 + 截斷放寬一格 + 相鄰標點收斂,各自跑自己實測的排程(90 秒 140 / 135 次)。
不切是 0.00%,而且是「建構上必然」而非量出來的:離線模擬把游標推到音檔結尾,所以它最後一次 interim 解的就是整段——與 final 同一份輸入,實測 100/100 段逐字相同那一列不帶資訊,只證明正規化管線對齊;真實服務的最後一次 interim 永遠落後 final 一個間隔,實測下限是 2.42%要比就跟這個數字比
三種切法之間的差距要看配對區間,不能看小數點(n=100,逐段配對,95% CI):90 秒句 固定 3 s−固定 8 s +0.45 ±0.14(顯著)、固定 3 s−標點切 +0.39 ±0.19(顯著)、固定 8 s−標點切 −0.06 ±0.18(不顯著,打平);30 秒句三組全部顯著,標點切最好(比固定 8 s 好 0.85 ±0.37、比固定 3 s 好 1.21 ±0.33)。合起來:標點切短句顯著最好、長句打平,沒有任何長度輸給固定視窗。
四種做法落在 1.2 pt 之內,而且錯誤都由「替換」主導——替換是模型自己聽錯、與切法無關, 就是這批語料的地板(1.90–2.21%,換算約 8.6–10.1 字/90 秒句,三種切法彼此打平,不切是 0.00)。 切法造成的錯只有插入與刪除,四列都已在 1% 上下——但那個「1% 上下」會騙人。
CER 是比例,對「少數幾個顯眼的字」不敏感,所以每一欄都同時印出字數,插入與刪除最值得配著字數讀: 標點切每 90 秒句多貼 3.84 字、固定 8 s 只有 0.95 字——4.1 倍的差距, 在百分比欄位裡被壓成 0.84% 對 0.21%,眼睛抓不到。刪除欄反過來: 固定視窗漏 4.1–4.5 字,標點切只漏 0.44 字兩欄要配著字數讀,不能只看百分比——一句 455 字的話裡多 4 個字在比例上接近零, 但使用者看到的是句子中間冒出一個「哎」(見下方那一則)。 所以準確度不再是它們之間的分野;分野在效能(B 節)與實作複雜度(D 節)。

但「插入 0.84%」這個數字說不出插進去的是什麼字,而那才是使用者看到的東西。 下表把每一列插入的字抓出來數——本節語料每段是一次 TTS 合成、沒有任何拼接點gen_corpus/make_texts.py),所以這裡出現的字都是切法造成的,不是音檔本身的瑕疵。

切法插入字數/段刪除字數/段 90 秒句最常插入的字
30 秒90 秒30 秒90 秒
不切(基準)0.000.000.000.00——
固定 3 s1.142.342.604.12×10 t×8 ×8 ×7 1×7 ×5 ×5 ×5
固定 8 s0.880.942.744.53d×5 c×5 a×4 ×4 1×4 s×3 t×3 ×3
標點切1.283.850.480.45×108 ×58 ×50 ×21 ×18 ×13 ×12 ×9
這是「鎖定不可逆」的量化形式,也是標點切唯一輸給不切的地方。 不切插入 0.00——它每次重解整段,模型在停頓處吐出的贅字下一次就被自己改掉。 標點切插入 3.85/段(90 秒),而且分佈高度集中×50、×21、 ×9 是語助詞×108、×58 是跨越切點那個字被下一個視窗再收一次 (「然後」的尾字)——正好是 A 節說「重疊上限一個音節」時,那個上限真的被用到的證據。 一個逗號一旦鎖定,這些字就永久留在畫面上。
兩個固定視窗的插入分佈是平坦的×10、t×8、×8…,沒有語助詞集中), 但刪除高一個量級(4.12 / 4.53 對標點切的 0.45)——兩類切法的錯不同型: 標點切多貼、固定視窗漏字。CER 總數看不出這件事,而對使用者來說漏掉一句話比多一個「哎」嚴重。
而這些字改不回來——鎖定是不可逆的,這正是切法的代價與它的機制同一件事。
切法特有的那一種錯:子句交界的語助詞。 它不會出現在下面「CER 最差」那兩段逐字對照裡,但它比那些替換更普遍: 30 秒句 30/100 段出現、90 秒句 50/100 段出現(合計 89 個),只是散開、單段最多 4 個。 實際長相([ ] 是多出來的字,標點在計分前已剝除,所以它落在兩個子句的接縫):
標點切的顯示文字
long_000…只留車速跟導航其他的先收起來[嗨]晚上開車那些燈太亮了會分心…
long_001…話提醒我把雨傘從後車廂拿出來[哎]順便查1下臺北101那邊的…
long_003…今天下午會不會下雨如果會的話[啊]提醒我把雨傘從後車廂拿出來…
long_005…避開還有把胎壓數據也顯示出來[哎]還有幫我導航到新店碧潭避開…
來源是「鎖定之後的第一個視窗太短」,不是合併。 合併只能丟或留解碼器吐出的 token——merge_on_overlap 找的是文字重疊,而 不是重複, 所以它走 fallback 的直接串接,原封不動上螢幕。真正的來源是切法: 每鎖定一個逗號,左緣就跳到那裡,下一個視窗只有約 0.5 秒而且開頭就是那個停頓。 直接量這件事(同一批音檔,從逗號起算不同長度的視窗,各 60 次):
 0.5 s 視窗 → 19% 的機率開頭吐語助詞×6 ×4 ×2 ×1 ×1);  1.0 s → 3%; 2.0 s → 1%; 3.0 s → 0%
而鎖定讓它永久:LocalAgreement-2 只要連續兩個短視窗都吐同一個字就確認,之後 locked_text 不再重算。 不切每次重解整段,同一個贅字下一次就被自己改掉——這就是它插入 0.00 的原因。
緩解方向明確且不必動切法或合併:視窗短於 1 秒時只顯示、不參與提交, 對應的就是上面 19% → 3% 那條曲線(§5 待辦)。
這一節的三個結論。

① 三種切法在準確度上分不出高下,但三種都比「不切」差。 90 秒句彼此只差 0.44 pt(配對差 0.06–0.45 pt,n=100、95% CI), 而要比的基準不是表上的 0.00%——那是建構上必然(參考文字就是不切自己的 final)。 真實服務裡最後一次 interim 永遠落後 final 一個間隔,實測下限 2.42%。 所以三種切法的代價是約 0.7 pt,換算成字是每 90 秒句多錯 3 個字左右

② 錯誤的長相比總數重要,而三種切法錯得不同型。 標點切以插入為主(3.84 字/段,含 ×50 等語助詞); 固定視窗以刪除為主(4.1–4.5 字/段)。不切兩者都是 0.00—— 它每次重解整段,同樣的贅字下一次就被自己改掉。切法的錯之所以留下,是因為鎖定不可逆。 剩下的替換(1.9–2.2%)也不是共同地板:不切的替換是 0.00, 所以那一項同樣是切出來的(視窗變短、上下文變少)。

③ 換一種 interim 節奏,結論不變。 本節用的是各長度各自實測的排程間隔(30 秒 0.536 s、90 秒 0.732 s)。 拿同一批音檔重跑固定 0.5 秒排程:固定 3 s 的差是 +0.35 pt(30 秒)與 +1.13 pt(90 秒), 標點切 ±0.2 pt——都在常數級,沒有一種切法會因為節奏改變而翻盤。 (資料:experiments/window_cer/fixed_cadence.json。)

逐字對照:實際長什麼樣

數字說有多少錯,說不出是哪一種。下面是實際字串,每一個編輯都標出來。

附註:以下兩段是「最差的一次」,不是平均。 每個長度取的是該長度 100 段裡標點切 CER 最高的那一段——30 秒 long_080(11.8%,中位數 2.5%)、 90 秒 long_045(5.9%,中位數 3.3%)。所以這裡看到的是使用者可能看到的上限, 不能拿它代表典型狀況;典型值請看上面兩張表的平均。
反過來,「最差的一段」也不會展示每一種失效。CER 最差是替換最多造成的,而替換是模型聽錯、四列都有; 切法特有的那種錯(子句交界的語助詞)反而不在這兩段裡——它散在各段、單段最多 4 個, 所以永遠不會是 CER 最差那一段。見上方「切法特有的那一種錯」那一則。
多出來的字(插入,峰值漂移重複)  解錯的字(替換)  刪除線 沒顯示出來的字(刪除,合併跳掉)
30 秒句 (long_080——標點切在 100 段裡最差的那一段,CER 11.8%;參考=該段自己的 final)
切法CER最後一次 interim
參考(final)幫我播放podcastcast音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天崗之後幫我找1間可以坐比較久的咖啡廳有插座跟無線網路的那種另外把行事曆上中午12點那個會議往後延半小時然後傳 …
不切(基準)0.0%幫我播放podcastcast音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天崗之後幫我找1間可以坐比較久的咖啡廳有插座跟無線網路的那種另外把行事曆上中午12點那個會議往後延半小時然後傳訊習
固定 3 s9.9%幫我播放podcastcatt音量稍微小1點等1下我要講電話的時候記得自動暫停有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停走走再來等1下到陽明山晴天之後幫我找1間可以比較久的咖啡廳有插座跟無線網的那種另外把行力立上中午12點那個會議往後延半小時然後傳訊
固定 8 s7.9%幫我播放p漏 6 字:odcast…cast音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停走走再來等1下到陽明山晴天崗之後幫我找1間可以比較久的咖啡廳有插座跟無線網的那種另外把行事上中午12點那個會議往後延半小時然後傳訊
標點切11.8%幫我播放p漏 9 字:odcast…t音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天之後幫我找1間可以比較久的咖啡廳有插座跟無線網的那種另外把邢世離上中午12點那個會議往後延半小時然後傳訊
90 秒句 (long_045——標點切在 100 段裡最差的那一段,CER 5.9%;參考=該段自己的 final)
切法CER最後一次 interim
參考(final)查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航設到最近那1個接着後照靜幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度掉到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我嗎跟他說我大概晚上9點 …
不切(基準)0.0%查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航設到最近那1個接着後照靜幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度掉到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我嗎跟他說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到適林夜市避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走接着幫我播放爵士樂音量稍微小1點等1下我要講電話的時候記得自動暫停還有後照靜幫我往下掉1點後排左邊的車窗降到1半就好車內循環先關掉換成外氣順便等1下到1欄傳移中心之後幫我找1間可以坐比較久的咖啡廳有插座跟無線網路的那種另外幫我播放古點了音量稍微小1點等1下我要講電話的時候記得自動暫停還有打電話給小李跟他說我大概中午12點會到路上如果有狀況我再回撥再來把儀表板切換成簡潔模式只留車速跟導航其他的先收起來晚上開車那些燈太亮了會分新順便先找附近評價比較好的燒臘店最好有停車位營業時間要開到傍晚6點以後對了先找附近評價比較好的壽司店最好有停車位營業時間要開到下午3點以後再來先找附近平價比較好的牛肉麪店 …
固定 3 s3.9%查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航到最近那1個接着後靜幫我往下掉1點駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我跟他說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 …
固定 8 s3.7%查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航設到最近那1個接着後靜幫我往下掉1點駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我跟他說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 …
標點切5.9%查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航到最近那1個接着後照幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 …
四列的錯已經同型,剩下的差異幾乎都是模型自己聽錯。 導航設到導航射到老哥老歌 在四列都一樣,與切法無關。
要看的是切法留下的痕跡:標點切那列的贅字(語助詞與跨切點被重收的字)——上表量到 90 秒句每段 3.85 個; 固定視窗那兩列則是零星漏字。這一段是該長度 100 段裡標點切最差的那一段, 所以它顯示的是使用者可能看到的上限,不是平均值:30 秒 long_080、90 秒 long_045, 兩者的中位數分別是 2.5% 與 3.3%。
為什麼固定視窗的合併長那個樣子——它是被兩個都不可靠的訊號夾出來的。

固定視窗每次滑動 0.5 秒、視窗卻有 3 秒,所以新視窗的開頭必然重複已鎖定尾巴約 2.5 秒。 要決定「這串字從第幾個開始才算新的」,手上只有兩種訊號,而兩種都有缺陷

訊號缺陷後果
時間戳 CTC 標的是發射峰值,位置隨視窗左緣移動漂移數十毫秒 判準嚴一格就重複順便順便便),鬆一格就刪掉下一個字,順便看。便看)——兩個方向互相拉扯,沒有安全的閾值
文字重疊 不受漂移影響,但要求重疊區逐字完全相同 重疊區越長,越容易有一個字這次解得不一樣;一旦對不上,最長比對直接掉到零, 整段視窗被再貼一次(固定 8 s 實測 CER 61–172%

所以順序是「文字優先、時間戳兜底」:文字對漂移免疫,能決定就讓它決定; 只有文字找不到接縫時,才退回那個會漂移的訊號。 而兩個訊號都不完美,正是固定視窗需要五個機制才站得住的原因——已鎖定前綴累積、截止期限提交、 接縫刪重複、截斷放寬一格、相鄰標點收斂,其中後四項全部在處理同一件事:峰值漂移。 補完之後 90 秒句 CER 從 89.08% 降到 3.54%(離線消融:只加接縫刪重複 43.64%、只加截止期限提交 13.45%, 兩者缺一不可、各治一種錯誤分型)。

標點切不需要這一族機制:它的視窗從逗號本身開始, 與已鎖定前綴的重疊被結構性地限制在跨越切點的一個音節,所以比對不到時直接串接最壞多一個字。
重疊區有多長,是切法決定的;合併能出多大的錯,是重疊區決定的——這是 A 節那兩個軸真正的耦合點, 也是「五個機制對一個機制」在 D 節成為判準的理由。
D · 總分析:能不能做

B 與 C 各自都不足以定案——效能最好的可能準確度最差。兩軸並排,答案就出來了。 C 節的結論是:準確度不再區分這三種切法(四列落在 1.2 pt 之內,錯誤都由模型自己的替換主導)。 所以判定實際上由效能實作複雜度決定,而不是由 CER。

切法效能(B)準確度(C) 能不能做為什麼
固定 3 s 三種最好:total latency 90 秒 2.50×、5 秒轉正,12 點只有 3 秒那點落後基準 4.13% / 3.54%(30/90 秒),錯誤已由替換主導(2.13%)=模型地板; 節奏敏感度 +1.13 pt 不做效能最好(2.50×)、準確度與標點切同級,但兩者都在「不切」之下。它需要五個機制才站得住,而換到的是產能而非體感——見下方判定
固定 8 s 三種最差:要到 40 秒才轉正,90 秒僅 1.37× 3.77% / 3.10%(30/90 秒)。90 秒句與標點切打平(配對差 −0.06 pt ±0.18,n=100,統計上分不出來);30 秒句顯著較差(+0.85 pt ±0.37)。插入 0.95 字/段、刪除 4.51 字/段 不能做 否決在效能,而且只在效能。放大視窗=少切,固定罰則卻最重:要到 40 秒才轉正,90 秒僅 1.37×,短句是三種裡最貴的。它的準確度不差(90 秒 3.10%,與標點切打平),但打平換不回 40 秒的轉正點——一個要講到 40 秒以上才開始省的優化,在車機的指令長度分佈上等於沒有作用
標點切 5 秒轉正、90 秒 1.79×;10 秒打平(0.99×),15 秒之後每一點都優於基準 2.92% / 3.15%,錯誤同樣由替換主導(2.21%);節奏敏感度 ±0.2 pt 不做三種裡代價最低、實作最簡單,但仍付 +0.7 pt 的 interim 退化,且那些字是語助詞與句首英文殘字、永久不可修——見下方判定
兩軸交叉的結果是:三種切法之間分不出高下,而三種都在「不切」之下。準確度這一軸淘汰不掉任何一種——三列彼此只差 0.44 pt;但三列對「不切」的真實下限 2.42% 都是退化,而退化的長相是使用者看得到、且不可修的。效能這一軸則只證明在產能上。所以不是選一種,是三種都不做。
❌ 判定:三種切法都不做。

三種切法之間分不出高下,但三種都比不切差——而那才是判定的依據。 90 秒句的錯誤分型(替換/插入/刪除):固定 3 s 2.13 / 0.51 / 0.90、固定 8 s 1.90 / 0.21 / 0.99、 標點切 2.21 / 0.84 / 0.10,彼此只差 0.44 pt。而不切是 0.00 / 0.00 / 0.00—— 它是建構上必然的 0(參考文字就是它自己的 final),所以真正要比的是真實服務下限 2.42%: 最好的切法 3.10%,代價約 0.7 pt,而且是使用者看得到、且永久不可修的那種

代價的長相比數字更關鍵。標點切每 90 秒句多貼 3.84 字(不切 0.00), 其中 ×50 ×21 ×9 ×8 是語助詞, 成因是每鎖定一個逗號、下一個視窗只有約 0.5 秒且開頭就是那個停頓(實測該情況 19% 會吐語助詞, 視窗長到 1 秒降到 3%、3 秒為 0%);句首的英文詞會被鎖成殘字(podcastcastpt)。 鎖定不可逆,所以這些字永久留在畫面上;不切每次重解整段,同樣的贅字下一次就被自己改掉。 固定視窗則是反過來——漏字 4.1–4.5 字/段(標點切 0.44)。

而好處這一側,到目前為止只證明在一個使用者感覺不到的量上。 切法改善的是 total latency(90 秒 1.37–2.50×),而 §2.2 的定義寫明那是產能指標—— 一張卡能服務幾個座位,不是體感。`interim 最大間隔` 的形狀則是單輪雜訊: 15–20 秒標點切把停頓從 1.06–1.22 s 壓到 0.61–0.66 s(接近一半), 但 30 秒(2.10 對 1.08)與 50 秒(2.34 對 2.20)方向相反,而同組態三輪展幅本身就有 2.2–2.4 倍。 「省下的算力會不會變成使用者感覺到的更新變密」這件事,本輪沒有量到可用的結論。

所以判定是不做一個確定、使用者看得到、且不可修的退化, 換一個尚未被證明存在的體感好處。三種切法在這個結構下都一樣,所以是三種都不做,不是選一種。

一個順帶的觀察,它支持同一個方向:三種切法要站得住所需的機制數量差很大。 標點切只需要一個(最長文字重疊,比對不到就直接串接);固定視窗需要五個 (已鎖定前綴累積、截止期限提交、接縫刪重複、截斷放寬一格、相鄰標點收斂),而後四項全部源自同一件事—— CTC 發射峰值會漂移。每補一個洞就冒出下一個,而第五項還暴露了一個它控制不了的依賴: 模型對「該放哪個標點」不穩定(實測 46% 的句子兩次解碼逗號數不同)。 三種都不做,所以這件事不再是選擇的依據,但它記錄下來是為了下一輪—— 若日後因為 GPU 吃緊而必須重開這個方向,標點切仍是實作代價最低的入口。

E · prep 的行為:左緣多久移動一次,決定 prep 長什麼樣

本節有兩個用途,都與已採用的 #3 直接相關。 其一:它量出 #3 自己的 prep 會隨句長成長(90 秒爬到 18.73 ms)—— 原因是 get_frames() 每次都複製 stream 從第 0 幀到現在的全部幀,而 stream 累積整句話。 這是 #3 已知的成本形狀,也是判斷「什麼手段能壓掉它」的依據。 其二:#4 判定不做,但若日後重開(§5 的重開條件), 「切法會不會把 #3 的增量性毀掉」是必須先答的問題——這裡把答案量了出來。

方法:把三種左緣行為各跑一遍完整的 interim 迴圈(0.5 秒一次,與服務的 fed_s 中位數 0.52 秒一致), 量每次 interim 的 prep:#3 單獨(左緣不動)、固定 3 s 視窗(左緣每次 interim 都動)、 標點切 + #3(左緣只在逗號跳一步)。

直接把三種策略各跑一遍完整的 interim 迴圈(0.5 秒一次,與服務的 fed_s 中位數 0.52 秒一致),量每次 interim 的 prep: #3 單獨(左緣不動)、固定 3 s 視窗(左緣每次 interim 都動)、標點切 + #3(左緣只在逗號跳一步,其餘時間走 #3)。

每次 interim 的 prep(餵入 + 取出幀)—— 左緣多久移動一次,決定 prep 長什麼樣 4 9 13 17 22 ms 3 15 30 45 60 75 90 語句長度 (s) #3 單獨 固定 3 s 視窗 標點切 + #3
圖 7 · 三種左緣行為下,每次 interim 的 prep(k8s、與本報告其他成本數字同一顆節點:n1-highmem-2、2 vCPU、服務閒置; 12 個長度點,滑過任一點可讀值)。三條線就是答案: #3 單獨隨句長爬(5.4 → 18.7 ms,解碼前要把累積到現在的所有幀取出來,句子越長越貴); 固定 3 s 每次 interim 都重建,所以不隨句長但一直很貴(全程約 13.5 ms); 標點切 + #3 最低而且不隨句長(4.0 → 2.4 ms)——每個逗號才付一次重建,其餘時間完全是 #3, 而每次鎖定又把累積長度重設,所以連 #3 自己那條成長曲線也一併被壓平。
策略 重建次數
30 秒句
整句 prep 累積
30 秒句
重建次數
90 秒句
整句 prep 累積
90 秒句
最後一次 interim 解出什麼
#3 單獨 0531 ms 03554 ms整段
固定 3 s 視窗 60
每次 interim
945 ms 180
每次 interim
3217 ms 最後 3 秒
標點切 + #3 14
每個逗號
232 ms 43
每個逗號
511 ms 上一個逗號之後
「整句 prep 累積」=該句所有 interim 的 prep 相加(30 秒句 60 次 interim、90 秒句 180 次,0.5 秒一次); 上面的圖 7 畫的則是每次 interim 的中位數,兩者單位不同。 90 秒句:標點切+#3 比 #3 單獨省 7.0×、比固定長度省 6.3×,30 秒句同向。 「最後一次 interim 解出什麼」是正確性的旁證:標點切解出的正是上一個逗號之後那一段,窗口語意正確,不是退化成 #3。 資料:experiments/prep_coexist/sim_loop_k8s.json
三條線為什麼長這樣:把 prep 拆成「餵入」與「取出幀」

一次 interim 的 prep 做兩件事:把音訊餵進 fbank,以及在解碼前把已算好的幀取出來get_frames())。 這兩件事的成長方式完全不同,三條線的差異全部由它們解釋。同一顆節點量的:

量的是什麼餵入+取出幀形狀
一次性餵入 3 秒
=固定 3 s 視窗每次 interim 做的事
87 ms90 ms 視窗固定,所以不隨句長成長——但每次都要把整個 3 秒重抽一遍
增量餵入 0.52 秒
=#3 每次 interim 做的事
10–11 ms18 → 275 ms 餵入不動(只有 0.52 秒),但取出幀隨累積長度成長——stream 裡的幀越多,複製越貴
資料:experiments/prep_coexist/bench_prep_k8s.jsonbench_prep.py,k8s 同一顆節點,中位數)。 絕對值受該節點當下負載影響,要讀的是「隨句長變不變」與「彼此幾倍」
答案:能一起做,而且合起來比兩者單獨都便宜

為什麼 #3 每次只餵 0.52 秒:interim 的節奏就是約 0.5 秒一次(反應式排程的地板 0.5 秒, 服務實測 fed_s 中位數 0.52 秒),所以兩次 interim 之間只新增了 0.5 秒的音訊。 #3 讓 stream 活著、已算好的幀留在裡面,所以只需要餵那 0.5 秒;沒有 #3 就必須重建 stream, 重建就得把整個 buffer 重餵一次。

為什麼固定 3 s 一直很貴:它的左緣是「現在−3 秒」,每一次 interim 都在移動, 所以每次都要重建 stream、把整個 3 秒視窗的 fbank 重抽一遍。3 秒的 fbank 對 0.52 秒的 fbank, 差距就是上表那 9 倍——它「不隨句長成長」是對的(視窗固定),但固定在一個高點

為什麼標點切+#3 反而比 #3 單獨還低:兩者的餵入完全一樣(都只餵新增的 0.5 秒), 差別全在取出幀get_frames() 回傳的是 stream 從第 0 幀到現在的全部幀—— #3 單獨的 stream 累積整句話,90 秒時要複製整整 90 秒的幀(上表 18 → 275 ms); 而標點切每遇到一個逗號就重建 stream,stream 裡永遠只有「上一個逗號到現在」(通常不到 2 秒)。 所以它不只沒有付罰則,還順手把 #3 自己那條成長曲線重設掉——這就是圖 7 裡它是最低那條線的原因。

標點切也要重建 stream——差別不在「要不要」,在三件事。這一點值得講明白,因為上面的敘述容易讀成「只有固定長度要重建」:

90 秒句interim 次數重建次數 重建佔比每次重建重餵prep 中位數
#3 單獨18000%18.73 ms
固定 3 s 視窗180180100%整個 3 秒視窗13.68 ms
標點切 + #31804324% 上一個逗號到現在
(約一次增量餵入的量)
2.42 ms
① 頻率:固定長度每次 interim 都重建,標點切四次才一次。 ② 每次重建的代價:固定 3 s 重建後要把整個 3 秒視窗重抽 fbank(上表 87 ms); 標點切重建後只餵「逗號到現在」,因為逗號是剛通過才觸發的——它的重建幾乎不用錢③ 對標點切來說重建是收益來源,不是代價get_frames() 每次要複製 stream 從第 0 幀到現在的全部幀, 那正是 #3 單獨爬到 18.7 ms 的原因;標點切付重建的錢,買掉的就是那條成長曲線。 固定 3 s 付一樣的錢卻什麼都沒買到——它的 get_frames() 本來就被 3 秒視窗綁住了。

三種策略的「跑得起來」都是,也就是在逗號處重建 stream、之後繼續增量餵,用現有介面就做得到run_interim_inference_incremental() 的 stream 生命週期本來就由呼叫端持有),不需要新的 C++ 能力。 所以「切法與 #3 互斥」只對固定長度成立——它的左緣每次 interim 都在動。 這一結論本輪沒有被採用(#4 不做),但它是重開 #4 時不必重做的功課: 標點切與 #3 可以共存,而且合起來比 #3 單獨還便宜。
對已採用的 #3 而言,本節真正的產出是那條成長曲線get_frames() 複製整條 stream,所以 90 秒句每次 interim 的 prep 是 18.73 ms 而不是常數。 本輪把 #3 延伸到 final 之所以有價值,正是因為 final 原本要重抽整段—— 沿用之後只補餵尾巴,k8s 實測 11 秒句只餵 0.16 秒

3.3.3 · #5 final 重用最後一次 interim 不做

機制:VAD 宣告停止前的靜音期間伺服器是閒著的,而最後一次 interim 已解過幾乎全部音訊。若 VAD-stop 到達時新增的只有靜音,就直接用該結果走 HR → ITN,跳過 prepdecode

效益:~94 ms,已被 #3 稀釋一半
final 路徑階段中位數p90#5 能省嗎
prep19.8 ms53.9 ms
decode74.0 ms117.5 ms
itn0.4 ms11.9 ms不能(但只有 0.4 ms,無所謂)
合計95.1 ms290.2 ms約省 94 ms

上一輪估「317 → 約 150 ms」(省 167 ms),實測只有 95 ms——因為 #3 已經先把 prep 壓下去了,#5 的效益被自己人吃掉一塊。

代價:7.4%,而緩解手段已驗證不可行

A2 量到最後一次 interim 與 final 不一致 7.4%(27 句中 2 句),兩句形狀完全一樣:final 比 interim 多收句尾一兩個字(「風量。」→「風量開。」)。這看起來可以用「新增的音訊是不是靜音」擋掉——本輪直接驗證這個推論,結果是推翻

方法:對 28 個真實 WAV 模擬「最後一次 interim 在結尾前 gap 秒處切斷」,分別解碼切斷版與完整版,問 (a) 剩下那段的 RMS 是否低於閾值 (b) 重用是否真的正確。gap 取 0.2/0.4/0.8/1.5 秒,共 112 組:

判定次數意義
判為靜音且真的可重用79正確重用
判為靜音但其實會出錯14危險誤判——這個數字必須是 0
判為有聲且確實不可重用14正確保守
判為有聲但其實可重用5白白錯過(底噪抬高 RMS)
根因:能量分不開「靜音」與「講得比較小聲的收尾字」

timeout-reset 的尾段 RMS 只有 0.00715——比許多真靜音還低——但那裡確實有字。中文詞尾常是輕聲/氣音(「開」「門」「打開」),能量本來就低。反向也不乾淨:5 次白白錯過是因為底噪把 RMS 抬高(wake-nav-window-seat_chen 底噪就有 0.09)。14 次危險誤判,每一次都代表使用者的指令被吃掉尾巴。

❌ 判定:不做

效益 ~94 ms,但失效模式是吃掉指令尾字(「打開」→「打」)——對車機是語意層級的破壞,不是可接受的品質折衷。 若未來要重啟,只剩兩條路且都不划算:用真正的 VAD(Silero)判斷語音(為了省 94 ms 再引入一次模型推論), 或改用 timestamp 判斷(需要 interim 也回傳 timestamp,複雜度不值得)。

3.3.4 · 縮短 leading_pad_secs 不做

每次解碼都在音訊前面補 0.5 秒靜音,理由寫在 config 註解:「讓模型前端有左側脈絡,第一個音素不被切掉」。這個值從未被驗證過,而多數語句開頭正是喚醒詞,所以縮短它會直接打到專案最敏感的指標。用產線相同設定(同模型、HR lexicon + rule FST、use_itn)對 28 支真實 WAV 逐一比對:

pad文字與 0.5 s 相同decode 中位相對 0.5 s
0.5 s(現況)28 / 2811.3 ms1.00×
0.3 s22 / 2811.1 ms0.98×
0.2 s27 / 2810.8 ms0.96×
0.1 s22 / 2811.0 ms0.97×
0 s21 / 2810.9 ms0.96×

好消息是那個 pad 保護的東西沒有失守:即使 pad=0,沒有任何一支音檔掉了第一個音素,所有「你好鴻華」都完整。差異幾乎全是標點你好,鴻華你好鴻華、逗號變句號),而喚醒比對本來就是逐字拼音、不看標點。甚至有一支是短 pad 反而更準multiuser-10s:pad=0.5 少了「大」字,pad=0.3/0 都正確)。

但省不到東西:2–4%,落在雜訊內。而它換到的是一個安全邊際的消失,以及文字會隨 pad 長度擾動這件事本身(模型對前導靜音長度是敏感的)。

❌ 判定:維持 0.5 s

這是一個「量了才知道不值得」的候選,記錄下來免得下一輪再花時間。

3.3.5 · 調整 num_threads 維持 2

num_threads=2寫死在 batch_asr_manager.py 的預設值、不能從 config 調,所以從沒有人變動過它。兩個懷疑它的理由:部署節點只有 2 vCPU,而 onnxruntime 在解碼期間自旋等待(2026-08-18 §附錄 D.1),加上 prep 刻意放在共用鎖外面、四座位真的併發——這些加起來會嚴重超訂那台機器。用 taskset 把行程綁在 2 核上模擬該節點:

num_threads單人 3 s單人 10 s3 個 prep 並行時的 decodevs 無競爭
19.9 ms13.2 ms29.5 ms2.23×
2(現況)9.2 ms13.2 ms30.2 ms2.28×
49.4 ms13.2 ms31.5 ms2.29×

三個值的差異都在 7% 以內,判為無差別。原因清楚:decode 跑在 GPU 上,ORT 的 intra-op 執行緒不是瓶頸,所以調它幾乎沒有作用。

但這個否定結果指出了競爭的真正來源

三種設定下 decode 都被競爭拖慢 2.23–2.29×——競爭是真的,只是不是來自 ORT 執行緒,而是 prep 的 fbank 在搶那 2 核。這把「怎麼解」指向了兩個方向:讓 prep 變便宜(=#3,已完成,見下方 §3.3.1 的追加量測)或給 pod 更多 CPU(=M2,依先前指示暫緩)。調執行緒數不在其中。

❌ 判定:維持 2

三個值差異都在雜訊內,改了也拿不到東西——競爭的根因是 prep 搶 CPU,不是執行緒數設少了。

3.3.6 · M5 跨座位批次解碼 不做

要解決的是搶鎖排隊,不是解碼速度。四個座位共用一個辨識器鎖,interim 同時到期時它們被一個一個服務—— 第四個座位得等前面三次解碼跑完,自己才開始。sherpa-onnx 的 decode_streams() 可以把多條 stream 折成一次呼叫, 所以搶到鎖的人可以把已經排在後面的人一起解掉鎖沒有變多,變的是一次拿鎖做多少事。

改動前:prep 四座位並行,只有 decode 被鎖序列化最慢座位 419 ms(prep 62.1+等鎖 268+decode 89.3)· 等鎖那一段就是排隊座位 1prep 62.1decode 89.3座位 2prep 62.1等鎖 89decode 89.3座位 3prep 62.1等鎖 179decode 89.3座位 4prep 62.1等鎖 268decode 89.3共用 recognizer 鎖:decode 永不重疊 —— 2026-08-18 §4.5.1 指出的新序列化點改動後:四條 stream 折成一次 decode_streams(),等鎖整段消失最慢座位 = prep 62.1 + 等鎖 0 + 一次共用 decode· 四個座位同時拿到結果座位 1prep 62.1共用 decode:一次呼叫座位 2prep 62.1座位 3prep 62.1座位 4prep 62.1沒有人等鎖:搶到鎖的座位把已排隊的三個一起解掉
圖 8 · M5 接在 M1 之後:把 M1 留下的序列化點也拆掉。 沿用 2026-08-18 §4.5.1 的畫法與該輪的 T4 實測值。 基礎要講清楚:那組 prep 62.1 ms/decode 89.3 ms 是 1/3/5/10 秒的混合中位數,不是某一個句長的值 ——08-18 §4.1 自己就提醒過混合會稀釋 prep 這種隨 buffer 成長的成分。
改動前是今天的狀態:M1 已讓 prep 四座位並行,但 decode 仍被共用鎖一個一個服務,第四個座位等鎖 268 ms; 那張圖的結語原文就是「共用 recognizer 鎖:decode 永不重疊 —— 新的序列化點」。 改動後是加上 M5:等鎖整段消失。
共用 decode 是推估的區間,不是直接量到的值:取 08-18 的每座位 89.3 ms × 4, 除以本輪實測的四座位批次加速,而加速本身隨 buffer 長度變化——為了與 08-18 同一個混合基礎, 用該基礎涵蓋的兩端計:1 秒 buffer 的 3.25× 給 110 ms、10 秒 buffer 的 1.57× 給 228 ms。 虛線段就是這個範圍。機制與加速比值是量到的;這個絕對值是兩者相乘,沒有在 T4 上直接量過批次解碼。
兩條線:decode_streamdecode_streams

M5 換掉的就是這一次呼叫,所以先只看這一次呼叫:四個座位同時到期時,把它們全部解完要多久decode_stream 是今天的行為(各自拿鎖、各自解一次),decode_streams 是把四條折成一次。 只計時 decode——建 stream 與抽特徵在兩臂是相同的工作,放進計時器只會稀釋要量的比值。

四個座位同時到期:解完全部要多久(4 條 stream,25 次中位數,4090) 10 20 50 100 200 ms 1 2 3 10 30 60 每個座位的 buffer 長度 (s) decode_stream(逐一解) decode_streams(批次解) 0.96× 批次已變慢
圖 15 · decode_streamdecode_streams,四個座位(4090、25 次中位數、每個 shape 先暖機;滑過任一點可讀值)。 x 軸是每個座位手上的 buffer 長度,而它就是決定批次到底贏不贏的變數: 0.8 秒 3.34×、2 秒 2.78×、3 秒 2.58×、10 秒 1.58×、30 秒 1.22×, 60 秒 0.96×——批次已經比逐一解慢。k8s T4 上翻負的更早(3 秒 2.66×、10 秒 1.10×、 30 秒 0.91×、60 秒 0.95×),因為那張卡較弱、補齊的浪費相對更貴。
N=1 那一行是這組量測的 identity check:四個長度全部落在 0.98–1.04×。 DecodeStreams 開頭就是 if (n == 1) DecodeOneStream(...),兩者是同一條程式碼路徑, 必須讀到 1.00×;沒讀到就代表暖機沒做對(曾經量到 2.27×,全是 shape 首次呼叫的建置成本)。
為什麼加速會隨 buffer 變長而消失:decode_streams 把每條 stream 補齊到最長那條。

批次解碼要求一個矩形張量,所以四條長度不同的 stream 會被右側補零到最長者的長度offline-recognizer-sense-voice-impl.hPadSequence(..., 0))。 編碼器接著對補齊後的整個矩形做運算——補進去的那些零一樣要跑完所有層。 所以批次省下的是「四次 kernel launch 與四次記憶體往返」,付出的是「補齊出來的無用計算」, 兩者的相對大小由 buffer 長度與長度差異決定

四座位的 buffer 組合逐一解批次解 加速補齊比為什麼
0.8 / 1.2 / 1.6 / 2.0 s
標點切之後的常態
59.8 ms10.8 ms5.55×1.43× 四條都短、長度又接近——補齊浪費最小,這是量到的最佳情況
1 / 2 / 3 / 5 s64.216.43.91×1.82× 仍短,但最長者是最短者的 5 倍
1 / 3 / 6 / 10 s66.626.02.57×2.00×補齊比升高,加速跟著掉
2 / 10 / 15 / 20 s79.348.01.65×1.70× 補齊比不高,但絕對長度大——補進去的零本身就很貴
60 / 60 / 60 / 60 s
沒有切法的長句
185.3192.1 0.96×1.00× 完全沒有補齊浪費,批次仍然較慢——省下的 launch 成本相對於 60 秒的運算量已經可以忽略
兩個獨立的因素,最後一列把它們分開了:等長 60 秒的補齊比是 1.00×(沒有任何浪費) 卻還是 0.96×,所以加速消失不只是補齊造成的,也是「省下的固定成本被攤薄」造成的。 兩者都指向同一個方向:buffer 越短,M5 越有價值
這件事把 #4 與 M5 綁在一起。標點切把每次送去解碼的音訊壓成一個子句(0.8–2 秒), 正好把批次推進它唯一有效的區間(5.55×);沒有切法時長句的 buffer 是 30–60 秒, 批次在 k8s T4 上是 0.91–0.95×,也就是倒賠兩個優化不是獨立相加的—— 收掉 #4 會把 M5 的紅利一併收掉。
1 個座位、10 秒 buffer時間說明
decode_stream45.1 ms 沒有差別,而且必然如此。DecodeStreams 開頭就是 if (n == 1) DecodeOneStream(...)——單一 stream 時兩者是同一條程式碼路徑。 服務端也不會走到批次:GroupDecoder_pending 只有自己時 len(streams)==1,直接走 decode_one所以單人開不開 M5 逐 bit 相同,不付任何代價,這也是它可以預設開啟的理由
decode_streams45.3 ms 0.99×
k8s T4、25 次中位數、先暖機。另外三個長度同向:3 秒 1.01×、30 秒 1.00×、60 秒 1.00×。
#4 與 M5 一起做,效益比兩者相加更大

先說量在哪:以下全部是本機 4090 的隔離實測experiments/m5_batch/bench_batch.py, 25 次中位數、每個 shape 先暖機、只計時 decode 呼叫)。k8s T4 的交叉確認列在最後一列。 選在 4090 先量是因為它快、可反覆重跑;方向確認後才拿去 T4 驗,而 T4 的翻負點更早——結論的方向兩台一致。

M5 的加速由兩件事決定,而 #4 同時改善了這兩件:

M5 加速的決定因素沒有 #4(buffer = 成長中的整段) 有 #4 標點切(buffer = 一個子句)
① 絕對長度
補進去的零本身要跑完所有層
長句 30–60 秒0.8–2 秒
② 長度差異
補齊比=最長×N ÷ 總長
座位進度不同 → 1.70–2.00×1.43×(都是子句,長度接近)
結果(4 座位、4090) 60 秒等長 0.96× · 2/10/15/20 s 1.65× · 30 秒等長 1.22× 5.55×(0.8/1.2/1.6/2.0 s) · 等長 0.8 秒 3.34×
k8s T4 交叉確認 30 秒 0.91× · 60 秒 0.95× · 10 秒 1.10× 3 秒 2.66× (0.8–2 秒未在 T4 量,方向由 4090 與 3 秒點外推)
所以兩者不是獨立相加,是相乘的。 #4 單獨的效益是它自己的 total latency 1.79×(B 節);M5 單獨在長句上是 0.91–0.96×,也就是倒賠兩個一起做,M5 才進到 3.34–5.55× 的區間——因為 #4 同時把「絕對長度」和「長度差異」兩個決定因素都壓下去了。
反過來也成立,而且這是本節最要緊的一句收掉 #4 會把 M5 的紅利一併收掉, 長句上甚至變成負的。評估「要不要留滑動窗口」時必須把這一項算進去,不能只看 #4 自己那 1.79×。
還沒量到的那一半:實際的 batch size 分佈。 上面的倍率是四個座位同時到期時的加速,也就是上限。而批次是由鎖爭用自然形成的—— 輕載時 _pending 只有一個人,走 decode_one,逐 bit 等同沒開 M5。 interim 節奏約 0.5 秒一次,而形成批次的窗口寬度=上一次 decode 的時間(3 秒 buffer 約 35 ms、30 秒約 140 ms), 所以若四個座位的 interim 均勻散開,窗口只覆蓋約 7% 的時間,batch 多數仍是 1那個 5.55× 有多少比例真的發生,取決於 batch size 的分佈,而這一輪沒有量。 它是可量的:GroupDecoder.last_batch_size 已寫進 trace (batch_asr_manager.pyargs={"batch_size": ...}), 開 debug.profile_dbg 跑一次多人負載即可抽出直方圖。在那之前,本節的倍率只能讀成上限,不能讀成期望值(§5 待辦)。
機制:先掛佇列,再搶鎖

能做到上圖下半的關鍵只有一行的位置——掛佇列發生在拿鎖之前

步驟做什麼
_pending.append(...) (stream, future) 掛進待解佇列。不需要鎖,純 list append,中間沒有 await, 所以不會被 event loop 切換打斷。若這一步在鎖裡面,佇列裡就永遠只有持鎖那一個,批次大小恆為 1
async with self._lock 照舊排隊搶同一把鎖。鎖沒有變多,變的是一次拿鎖做多少事
③ 整包取走並清空 batch, self._pending = self._pending, []——同樣沒有 await,所以是原子的。 之後才掛進來的人會進到新的空 list,由下一個 leader 服務,不會漏也不會被解兩次
decode_streams(batch) 一次解完,依序把結果配回各自的 future。解碼跑在 to_thread 裡, 所以 leader 持鎖期間 event loop 仍然活著,後到的座位還能掛佇列
⑤ 其他人醒來 發現自己的 future 已完成,直接返回。整個過程沒有任何人等待批次形成
排隊的代價

表格裡每一格是「毫秒」:從辨識器空出來的那一刻算起,到那個座位拿到自己的解碼文字為止。 四個座位在同一瞬間到達(interim 同時到期)。

每座位
buffer
各座位拿到自己的字,需要幾 ms 最慢的那位改善
座位 1座位 2 座位 3座位 4
1 s逐一排隊14.822.630.638.338.32.20×
decode_streams17.417.417.417.417.4
2 s逐一排隊15.424.032.440.840.82.05×
decode_streams19.919.919.919.919.9
3 s逐一排隊16.926.536.245.845.82.08×
decode_streams22.022.022.022.022.0
5 s逐一排隊17.628.339.149.649.61.64×
decode_streams30.230.230.230.230.2
10 s逐一排隊20.034.248.362.462.41.65×
decode_streams37.837.837.837.837.8
怎麼讀 2 秒那一列:逐一排隊時,座位 1 在 15.4 ms 拿到字、座位 2 要 24.0、座位 3 要 32.4、 座位 4 要等到 40.8 ms——那個 15→24→32→41 的階梯就是排隊本身,每一階是一次別人的解碼。 換成 decode_streams 之後四個座位都在 19.9 ms 拿到,因為它們是同一次呼叫解出來的。
座位 1 反而慢了(15.4 → 19.9),這是真實的取捨:它不再自己解完就走,而是等那一次四人份的解碼。 第一個座位付 4.5 ms,換最後一個座位省 20.9 ms。
量測條件:4090、9 次取中位數、GPU 淨空、批次大小 4。只含鎖等待與解碼——四條 stream 在計時開始前就建好餵完了, 所以不含 prep(fbank 抽取)、不含網路傳輸、不含 interim 發送路徑。這是回答「批次能不能解掉搶鎖排隊」的 microbenchmark, 不是端到端延遲;端到端那組是下方的 arm F 對 arm G。
這個設計最值得留的性質:它是自我調節的,而且剛好在需要的時候才啟動。

輕載 → 批次恆為 1 → 沒有效益,也沒有代價。單一座位走的就是原本那條單條路徑(if len(streams) == 1 直接 decode_one), 位元級等同今天的行為,不多一次等待、不多一個分支開銷。
重載 → 解碼變慢、鎖上開始排隊 → 重合的機率上升,批次自然變大 → 效益出現。
另外量了曲線的另一端作為對照:若四個座位不是同時掛進佇列,而是第一個先自己解掉(批次 1+3),改善只有 1.41–1.57×;上表的批次 4 是 1.64–2.20×負載越重、批次越大、省得越多。這比任何單一加速倍率都重要——因為它意味著這項優化不需要判斷什麼時候該開, 它在系統不痛的時候自己退場,在系統開始痛的時候自己放大。
但這也決定了它唯一該先回答的問題:批次到底有沒有形成。 批次由鎖爭用自然產生,沒有計時器、沒有等待——代價是它不保證發生。 一個子句的解碼只有 8–12 ms,而反應式排程的節奏是每座位約 500 ms 一次; 若四個座位的相位是隨機的,兩個落在同一次解碼期間內的機率並不高,批次大小就會一直是 1,等於沒開

本節與 §4 的量測都是四座位完全同步(同一瞬間 VAD START),相位被綁在一起所以必然重合。真實車上不是。 反向的效應是上面那條自我調節曲線——負載一重就會重合——但那是推理,不是量測batch_size 已寫進 trace,下一輪要先看那個數字的分布,再談任何加速倍率
❌ 判定:不做——它的紅利需要 #4,而 #4 不做。

機制本身是對的、實作也是對的:批次結果與逐一解 8 組逐字相同, 單人時 len(streams)==1 直接走 decode_one、逐 bit 等同沒開, 批次由鎖爭用自然形成、沒有任何呼叫者為了湊批次而等待,失敗會逐條退回。 問題出在它的加速依賴 buffer 夠短,而唯一能把 buffer 壓短的是 #4。

#4 不做之後,buffer 就是成長中的整段——長句 30–60 秒。 在那個區間批次不但沒有紅利,還是負的:k8s T4 四座位 30 秒 0.91×、60 秒 0.95×; 4090 上 60 秒 0.96×。原因是 decode_streams 把每條 stream 補齊到最長那條,而且省下的固定成本相對於 30–60 秒的運算量已可忽略(等長 60 秒補齊比 1.00× 卻仍 0.96×, 兩個因素都指向同一邊)。短 buffer 的 2.66–5.55× 在沒有 #4 的情況下拿不到

而且它的實際效益還有一半沒被證明:那些倍率是「四座位同時到期」的上限, 批次由爭用自然形成,窗口寬度只有上一次 decode 的時間(35–140 ms);interim 節奏約 0.5 秒, 所以座位散開時 batch 多數仍是 1。batch size 分佈本輪沒有量,所以連上限有多少比例會發生都不知道。

結論:程式碼與測試已移除(src/asr/group_decode.pytests/test_group_decode.pyfeatures.batch_decode 設定項)。 重開的條件很明確:只要有任何機制把送去解碼的音訊壓到 10 秒以內,它立刻重新有價值—— 不必是 #4,也可以是一個「buffer 超過 N 秒就不批次」的長度閘(那會讓它從負變中性,但也就只是中性)。

機制正確(批次結果與逐一解 8 組逐字相同)、對排隊有效(最慢座位 1.64–2.20×)、自我調節(單一座位走原本那條未批次的路徑,位元級不變)、實作與測試齊備(SW-PERF-06,5 條)。旗標 features.batch_decode 設為 false

上線後要看的一項:批次由鎖爭用自然形成,沒有計時器也不等待——代價是它不保證發生。本輪所有量測都是四座位完全同步(同一瞬間 VAD START),真實車上相位是散開的;若座位的 interim 從不落在同一次解碼期間內,批次大小會一直是 1,等於沒開(也沒有代價)。batch_size 已寫進 trace,上線後先看那個數字的分布,再談加速倍率
回退ASR_BATCH_DECODE=0 可在不改設定檔、不重新部署的情況下當場關掉。

3.4 · 小結

八項候選全部裁決完畢(+兩個延伸方向)。#3 是唯一「零準確度風險 × 效益最廣」的候選,已落地並驗證#4 依兩軸(效能/準確度)交叉裁決,三種切法都不做——三種之間分不出高下(90 秒句彼此只差 0.44 pt:固定 8 s 3.10%、標點切 3.15%、固定 3 s 3.54%),但三種對「不切」的真實服務下限 2.42% 都是退化,而退化的長相是使用者看得到、且永久不可修的:標點切每 90 秒句多貼 3.84 字(不切 0.00),其中 ×50 ×21 是語助詞,成因是鎖定後第一個視窗只有約 0.5 秒且開頭就是那個停頓(實測該情況 19% 會吐,1 秒降到 3%);句首英文詞會被鎖成殘字(podcastcastpt);固定視窗則漏 4.1–4.5 字/段。好處那一側只證明在 total latency產能指標,非體感)上,而 interim 最大間隔 的逐長度形狀是單輪雜訊(15–20 秒看起來好一倍,30/50 秒方向相反),「省下的算力會不會變成更新變密」本輪沒有量到可用的結論features.punct_window 設為 false。 timestamp 對齊已退出候選(方向隨語料翻轉、成本一致更貴);#5 代價高於效益,判定不做M5 判定不做——加速依賴 buffer 夠短,而 #4 不做之後長句 buffer 是 30–60 秒,批次在那個區間是負的(k8s T4 四座位 30 秒 0.91×、60 秒 0.95×;decode_streams 把每條 stream 補齊到最長那條);程式碼與測試已從樹上移除。重開條件:任何機制把送去解碼的音訊壓到 10 秒以內(四座位同時到達時最慢座位 1.64–2.20×,批次結果與逐一解 8 組逐字相同;4 座位加速 0.5 s 3.49×、20 s 僅 1.30×,而標點切之後四座位不等長的實際樣子是 5.37×——#4 把輸入壓短,M5 才吃得到紅利。批次由鎖爭用自然形成,不形成時沒有效益也沒有代價,上線後以 trace 的 batch_size 分布追蹤);兩個延伸(leading_padnum_threads)量了都判定維持現況。M4/M6/#6/M7 判定不做——判準是使用者感覺到的凍結時間(相鄰 interim 最大間隔):更新密度變低且每次更新等更久就不做,會被讀成卡頓。四者的淨效果都是把凍結時間拉長(M6 是唯一有翻案空間的,但要量出縮短,本輪沒量)。見 §3.1。

📊4 · 整體格點總表:這一系列優化的進度

本輪是這一系列優化的最後一輪,所以本節量的是整體:把 2026-07-29 的反應式排程、 2026-08-14 的 ITN 數字守門、2026-08-18 的 M1 背景解碼、以及本輪的 #3 全部關掉,對上全部打開。兩條線,其他什麼都不比。

4.1 · 兩條線的定義與對齊

代號=圖例用字內容
SenseVoice 優化前 固定 500 ms 排程(ASR_FIXED_INTERIM_MS=500)、無 M1 背景解碼、無 ITN 數字守門、無 #3 ——本系列優化全部關閉的狀態
SenseVoice 優化後 反應式排程 + M1 + ITN 守門 + #3(含延伸到 final 的特徵沿用)——本輪定案的組態。 #4M5 判定不做且程式碼已從樹上移除,不在這條線裡
兩臂的對齊:只差在優化本身

同一顆 T4、同一個 podkitt-stt-batch-test-c-reactive)、 同一個 imageoptswitches2)、同一份網格(1/3/5/10/15/20 秒 × 1–4 人 = 24 格點)、 每格三輪取中位數、同一支 client。CPU request/limit 兩臂完全相同requests.cpu 1limits.cpu 1800m)——只切換四個環境變數,rollout 之後重新量。 若兩臂連資源都不同,差異就同時來自資源和優化,哪一項都歸因不了。

量之前先驗證CLAUDE.md §3.5):每一臂先跑一個格點,斷言 total latencyinterim latency 欄位存在才開掃。優化前那一臂的 preflight 同時 證實固定排程真的生效——3 秒句吐 6 次 interim(500 ms 一次),優化後同一格是 5 次。

4.2 · 量測條件與限制

4.3 · 數據圖

六個面板 = 六個句長,面板內的橫軸是座位數(1–4 人)。要看的是「座位堆上同一顆辨識器時會發生什麼」, 所以比較一律在面板內沿人數軸進行——面板裡除了人數,其他條件全部固定。 四張圖都是同一批資料:24 格點 × 兩臂 × 三輪取中位數。

time to final(最慢座位,每格三輪中位數)六個面板共用同一條對數軸。優化前在座位數上升時陡升,優化後幾乎持平優化前優化後0.11101001000s(對數刻度)1 秒句12343 秒句12345 秒句123410 秒句123415 秒句1234逾時20 秒句1234逾時
圖 9 · time to final六個面板共用同一條對數軸——沒有共用軸的小倍數圖無法互相比較。1 人時兩條線幾乎重疊, 這正是重點:優化的價值不在閒置時。座位一堆上去,優化前就脫離軌道—— 10 秒 × 4 人 24.8 s、15 秒 × 3 人 55.0 s、20 秒 × 3 人 240.4 s空心「逾時」點代表該格所有座位都沒收到 final,折線在那裡斷開而不內插。 優化後 24 個格點全部有界。
RTF(最慢座位)1.0 是分水嶺:超過就代表處理得比說話還慢,積壓不會自己收斂優化前優化後00.511.521 秒句12343 秒句12345 秒句123410 秒句123415 秒句1234逾時20 秒句1234逾時
圖 10 · RTF1.0 是分水嶺——超過代表處理得比說話還慢,積壓不會自己收斂,只會一路滾到逾時。 優化前在 20 秒 × 3 人量到 1.62(15 秒 × 3 人的 0.50 已在半路), 優化後全格點 ≤ 0.14,沒有一個接近 1.0。
interim 最大間隔(畫面看起來停住的最長時間)§3.1 的凍結時間判準用的就是這個量優化前優化後010203040s1 秒句12343 秒句12345 秒句123410 秒句123415 秒句1234逾時20 秒句1234逾時
圖 11 · interim 最大間隔=使用者感覺到的「凍結時間」。 這是 M4M6#6M7 的判準(§3.1),所以兩邊都要誠實看: 低負載時優化後略長(20 秒 × 1 人 3.49 → 5.59 s)——反應式排程按上次延遲拉開間隔; 飽和時優化後遠短(20 秒 × 3 人 30.29 → 6.14 s)。而優化前那兩個逾時格點根本沒有間隔可言, 因為一個字都沒出來。最差值:優化前 30.29 s,優化後 6.85 s
total latency(整句累計推理成本,最慢座位)與 time to final 不同:後者含排隊,這個是實際花掉的算力優化前優化後0.1110100s(對數刻度)1 秒句12343 秒句12345 秒句123410 秒句123415 秒句1234逾時20 秒句1234逾時
圖 12 · total latency(整句累計推理成本)。 與 time to final 不同:後者含排隊,這個是實際花掉的算力。優化前 20 秒 × 3 人累計 89.7 s—— 那不是「解得比較慢」,是解得比較多次:固定 500 ms 在延遲拉長時仍照排,同一段音訊被反覆重解。

4.4 · 總表(24 格點,三輪中位數)

SenseVoice 優化前 SenseVoice 優化後 time to final
倍率
座位ttf (ms)total (ms)凍結 (s)RTF 座位ttf (ms)total (ms)凍結 (s)RTF
113/31613260.520.133/31222430.510.111.3×
126/62613340.520.136/62293580.520.131.1×
139/93522990.530.129/92503860.540.101.4×
1412/124652790.530.1112/123785060.540.101.2×
313/33076081.950.113/31285640.730.132.4×
326/64948570.580.126/62238290.580.142.2×
339/97238600.560.149/91,6941,0483.160.120.4×
3412/122,9262,8062.550.1612/124041,3430.620.147.2×
513/32901,3860.530.163/31517870.520.121.9×
526/61,0081,0491.700.126/62919680.570.113.5×
539/97521,2700.540.159/93181,7751.060.142.4×
5412/121,6983,2743.480.1212/125041,6242.450.093.4×
1013/34362,1942.330.133/34371,6131.040.121.0×
1026/68342,8251.060.146/66352,2042.610.091.3×
1039/92,7704,5473.350.149/93,2934,2296.100.110.8×
10412/1224,81311,2759.080.3312/124,1203,8075.930.126.0×
1513/35594,0823.350.153/34392,5655.690.121.3×
1526/61,2016,8743.930.146/69507,1855.840.101.3×
1539/955,02027,0308.760.509/91,2523,9716.090.1144.0×
1540/1212/121,8526,2626.240.11全滅→通過
2013/37535,4823.490.143/34813,3525.590.141.6×
2026/63,9548,9313.960.156/65,2613,9596.020.130.8×
2039/9240,37289,73830.291.629/91,5158,8026.140.13158.6×
2040/1212/125,68810,8546.850.12全滅→通過
座位完成率:優化前 156/180,優化後 180/180。 (24 格點 × 三輪 × 每格 1–4 座位;0/12 的兩格是全座位逾時。)

4.5 · 逐指標分析

指標結果怎麼讀
time to final 1 人幾乎無差;20 秒 × 3 人 158.6×(240,372 → 1,515 ms) 優化的價值全部在飽和區。閒置時兩條線重疊是正常的,也是唯一該有的樣子—— #3 省的是重複的 fbank,單人短句本來就沒多少可省
RTF 最差 1.62 → 0.14;優化前 24 格點有 1 格破 1.0 破 1.0 是質變不是量變:處理慢於說話,積壓不會自己收斂,只會一路滾到逾時。 優化後留了 7 倍餘裕
凍結時間
相鄰 interim 最大間隔
低負載 略長(3.49 → 5.59 s);飽和 大幅縮短(30.29 → 6.14 s) 唯一一項優化後在某些格點比較差的指標,來自反應式排程按延遲拉開間隔。 但它換到的是飽和區不崩潰——而崩潰時凍結是無限長(那兩格根本沒有 final)
total latency 優化前 20 秒 × 3 人 89.7 秒累計推理 不是「解得比較慢」,是解得比較多次——固定 500 ms 在延遲拉長時仍照排, 同一段音訊被反覆重解
座位完成率 156/180 → 180/180 最終的判準。優化前有兩個格點一個座位都沒完成,那不是慢,是功能失效

4.6 · 總判定

這一系列優化把「長句 × 多人」從功能失效變成有餘裕

四項優化合計的效果,用一句話講:優化前在 24 個格點裡有 2 格完全失效、1 格 RTF 破 1.0; 優化後 24/24 全通過,RTF 全部 ≤ 0.14。單人短句(產品主流句長 1–2 秒)兩條線幾乎重疊, 這是預期的,也說明這些優化沒有把成本轉嫁到常見情境

唯一的代價是低負載時的凍結時間略長(20 秒 × 1 人 3.49 → 5.59 s), 來自反應式排程。依 §3.1 的凍結時間判準,這一項值得在後續 cycle 追蹤—— 但它換到的是飽和區不崩潰,而崩潰時的凍結是無限長。

📋5 · 待辦(本輪已知未完項)

CLAUDE.md §6 第 6 點,這一節是 current-state:完成的項目從表中移除,不留紀錄列。

未完項阻擋收尾?說明
最短視窗提交門檻(若日後重開 #4 才需要) 本輪量到 #4 的 interim 退化幾乎全來自「鎖定後第一個視窗只有約 0.5 秒且開頭就是停頓」—— 該情況 19% 會吐語助詞,視窗長到 1 秒降到 3%、3 秒為 0%。 對應的修法是「視窗短於約 1 秒只顯示、不參與提交」,不必動切法也不必動合併。 機制已量(experiments/window_cer/run_filler_probe.py),實作與驗證未做—— #4 判定不做,所以這是重開的前置條件而非本輪缺口
M6 的淨凍結時間未量(重開 M6 的唯一前置) 依 §3.1 的凍結時間判準,M6(背壓感知跳過)是四項排程節流裡唯一有翻案空間的: 跳過一次的當下凍結變長,但它有可能避免之後更大的間隔。要翻案就得量出 max_interim_gap_s 縮短,本輪沒量,所以現在是不做
M5 的 batch size 分佈(若日後重開 M5 才需要) §3.3.6 的倍率是「四座位同時到期」的上限。批次由鎖爭用自然形成, 窗口寬度只有上一次 decode 的時間(35–140 ms),而 interim 節奏約 0.5 秒, 所以座位散開時 batch 多數仍是 1。在量到分佈之前,那些倍率只能讀成上限
#4 的體感效益未定論(重開 #4 的另一個前置) 切法改善的是 total latency(產能)。它是否轉成使用者感覺到的更新變密, 本輪只有單輪資料:15–20 秒標點切把停頓從 1.06–1.22 s 壓到 0.61–0.66 s, 但 30 秒(2.10 對 1.08)與 50 秒方向相反,而同組態三輪展幅本身就有 2.2–2.4 倍。 要定論需 12 個長度點 × 3 輪的單人對照
#3 多人端到端效益未定論 本輪的長語句掃描是單人。§4 的兩條線是組態級對照(優化前 vs 優化後), 不是 #3 的單項隔離;後者要比照 2026-08-18 §附錄 D.1 的同機交替協定,屬下一輪範圍
interim 準確度沒有例行量測 現有準確度工具的收集器只取 TranscriptionFrame(final),看不到 interim。 本輪的 interim 分析全靠一次性腳本。這是獨立的量測缺口,與本輪判定無關
total latency 只有本輪的線有 格點聚合本輪才補上,§4 的兩條線都用補好的 harness 量,所以本節是齊的; 2026-07-2908-18 兩輪報告的多人表沒有這一欄, 要跨輪比對 total latency 仍需重跑那兩輪的格點
守衛的代價:實測 1/17,其餘全部沿用到

交棒加了 interim_in_flight 守衛(見下方 callout)之後,第一個該問的是 「它多常吃掉優化」。量了,不是推理——本機 4090 起 server,跑 test_waketest_timeout 兩支 live 檔(84 個 final、17 次 interim),用一次性的 DEBUG 計數分類每個 final 為什麼沒沿用 (量完即移除,不留在樹上):

情況次數意義
final 沿用成功16省下整段 fbank 重抽
守衛擋掉(interim 正在餵入)1 這是守衛的全部代價:那一句 final 從頭抽一次 fbank,文字完全正確。 撞在一起的窗口很窄,因為 VAD stop 發生在靜音窗口之後,而 interim 由音訊 frame 驅動
沒有 stream 可交棒33 不是損失:這些句子太短,final 之前根本沒跑過 interim,沒有 fbank 可省,從頭抽是唯一選項

該看的分母是「有 stream 可用的句子」,不是「所有 final」: 17 句有 stream,其中 16 句沿用到(94%)。對所有 final 算是 16/84=19%, 那個數字反映的是這批測試音檔的長度分布,不是優化的命中率。 守衛沒有讓 #3 延伸到 final 這件事失效——它換掉的是 17 句裡的 1 句。

本輪最有價值的一個發現:一個從 #3 上線起就在、而且靜默的競態

修完游標順序後跑 live,test_wake_pausetest_timeout_reset 15 次中 2 次紅,final 讀出 打開車門。打開車門。——重複

根因:把 stream 交給本句 final 的那段程式碼只持有 buffer_lock, 不是 inference_lock,所以它可以在 interim 正在 await 餵入時插進來。 那個瞬間 stream 裡已經有游標還不知道的音訊,交棒把舊游標複製給 final,final 就再餵一次同一段。

版本交棒落在餵入中途時症狀
原本(游標推進)交棒讀到游標,但 stream 只餵了一半 final 少餵尾巴 → 靜默截斷
游標順序修好後(游標推進)交棒讀到游標,但 stream 已餵完 final 重複餵 → 文字重複

兩個都錯,而原本那個從 #3 延伸到 final 上線起就一直存在—— 症狀只是「這句 final 偶爾少幾個字」,沒有任何測試會紅。修游標順序把失效模式從「少字」翻成「重複字」, 而重複字剛好會讓既有的 live 斷言紅,這才被抓到。沒有任何一條測試是為這個競態寫的。

修法不是選順序,是競態時不要沿用:兩處交棒都加 interim_in_flight 守衛 (沿用只是省工,放棄它多付一次 fbank、永遠正確),並把 segment_consumed 的 post-await 檢查 移到游標推進之前(交棒已把欄位歸零,晚一步寫回舊游標會讓下一句從非零位移切 buffer)。 兩條 AST 測試釘住這兩件事。

方法論教訓:一個 2/15 命中率的競態,離線全綠、前一輪 live 也全綠(38 passed)。 「兩條路徑都在同一把鎖內」這種查證必須追到每一個寫入點,追到函式層級就停是不夠的—— 交棒確實不在那把鎖裡。

已完成、因此從本表移除的項目(依 §6 第 6 點,這一節是 current-state,不留紀錄列): #3 的游標 bug(本輪已修:餵入失敗改為拋 IncrementalFeedError、游標只在餵入成功後推進、 半餵的 stream 丟棄重建;final 的 tail 餵入失敗改為退回從頭準備而不是丟掉 final;4 條新測試)、 live 全套、附錄 A 的架構圖與 Frame 流程圖、標點切的短句誤觸發量測、以及本輪的量測開關(已隨定案移除)。 #4#5M5M4M6#6M7 判定不做,所以它們不再是「未完項」——上表的相關列都是「若日後重開才需要」的前置條件。

🏗️附錄 A · STT SW Stack 架構與資料流

STT SW stack —— 本輪之後(單一 offline SenseVoice;#3 的持續 stream 同時服務 interim 與 final) kitt-core FrameProcessorServer msgpack / WebSocket KittSttService(src/kitt_stt_service.py) per-connection processor · per-user UserSession(src/session/) interim 路徑(偽串流) 反應式排程決定「何時重解」 持續 stream 只餵新增音訊(#3) 後處理只做 s2t final 路徑(權威) 整段重新解碼 沿用 interim 的 stream,只補尾巴 HR → ITN(含數字守門) 已抽好的 fbank BatchASRManager(src/asr/batch_asr_manager.py) 一個 recognizer + 一把鎖 · SherpaOnnxModelCache 單例(preload) 選配階段 KWS 喚醒狀態 · HR · ITN offline SenseVoice(sherpa-onnx,CUDA) sherpa-onnx-sense-voice-kitt-wake-lora-v1 · FP32 本輪判定不做: #4 滑動窗口 · M5 跨座位批次(本輪判定不做,程式碼已移除)
圖 A1 · STT SW stack。本輪之後的資料流:一個 offline SenseVoice、一把鎖,interim 與 final 走不同後處理但共用同一條 stream——綠色虛線就是本輪新增的那條路(final 沿用 interim 已抽好的 fbank)。#4/M5 判定不做,程式碼已從樹上移除,不在主資料流上。

#3 的 stream 生命週期是本輪唯一改變資料流的東西,所以它的重置點必須列全:一條 stream 絕不可跨 utterance(fbank 的內部狀態會汙染下一句),所以每一個「buffer 被清空」或「新 utterance 開始」 的地方都要把它設回 None

位置為什麼要重置
_restart_stream_from_now674手動喚醒中途按鍵:buffer 被重置,殘留 stream 必須丟棄
_handle_vad_user_started_speaking767新 utterance 開始——上一句的 fbank 歷史絕不能帶進來
_finalize_utterance_impl(第一條 finalize 路徑)862 先交棒給 final,再設 None——交棒讓 final 沿用已抽好的 fbank,設 None 讓它不會跨到下一句
process_frame(第二條 finalize 路徑)1072同上
_interim_redecode_bodyIncrementalFeedError 復原)1342 餵入失敗、stream 內容不明:丟掉整個 stream 並歸零游標,下一次 interim 從整個 buffer 重抽—— 寧可多付一次 fbank,也不能帶著半餵的 stream 繼續用(見 §14)
交棒與重置在同一把鎖裡完成,順序不能顛倒:先 final_reuse_stream = incremental_streamincremental_stream = None。反過來就是 final 沿用不到,也就是這個優化悄悄失效—— 而它不會有任何錯誤訊息,只是 prep 變慢。交棒本身還有第二個守衛:只在 not session.interim_in_flight 時才真的把 stream 交出去——VAD stop 撞上 interim 正在餵入的 那個窄窗,交棒會拿到一個內容不明的半餵 stream,若照樣沿用會讓 final 重複解出同一段音訊(本輪 live 抓到過,見 §5)。 實機驗證沿用是否生效的方式是那一行 DEBUG log ([Final][prep] reused the interim stream: fed only the tail (0.16s of 11.00s))。
AudioRawFrame每 20 ms 一塊 · :900utterance 進行中?VAD START 已到?no只進 preroll不累積yesappend 到 segment bufferstreaming_interim 且已喚醒?_should_emit_interim :591no不出 interim只累積,等 finalyesinterim 重解碼(背景)只餵新增位元組 · :1290InterimTranscriptionFrameOUT · :1379VAD STOPVADUserStoppedSpeakingFrameturn 邊界 · :781final 解碼 → HR → ITN:1458exit-word 命中?:1410yes取消 turn回 standbynoshould_emit_final?已喚醒/bypass · :617yes純喚醒詞?wake-only · :1805yesTTSSpeakFrameOUT · :1812noTranscriptionFrame + ASRMetadataFrameOUT · :1691no丟棄未喚醒事件旁支(不改主流程)BotStarted/StoppedSpeakingFrame — barge-in 仲裁狀態 · :914InterruptionFrame(手動) — agent_active/agent_standby · :1000STTUpdateSettingsFrame — 執行期改設定 · :1189EndFrame — 收尾未完 turn、清 session · :1040KWS idle timeout — 室內 Active→Standby · :492◇ 菱形=決策 ▭ 圓角=處理步驟收入/一般送出 frame待機/中止turn 邊界
圖 A2 · Frame 處理邏輯流程圖。 主流程走中央縱軸;◇ 菱形是決策點,箭頭標的是選它的條件。顏色帶語意: 藍=收入/一般綠=送出 frame橘=待機/中止紫=turn 邊界。 不改變主流程的事件(TTS 播放邊界、手動 Interruption、執行期設定、連線收尾、idle timeout) 收在右上資訊框,不佔主幹。所有行號在產生圖時從 src/kitt_stt_service.py 直接讀出experiments/frame_flow_fig.py),不是手打的。
Frame I/O 契約表
Frame方向觸發 case動作/條件位置
StartFrameINpipeline 啟動建 session manager、載入 wake 設定、preload 模型:315
AudioRawFrameIN每 20 ms 一塊utterance 進行中則 append 到 segment buffer,否則只進 preroll;接著決定要不要觸發 interim 重解:900
VADUserStartedSpeakingFrameINVAD 判定開始說話開 turn、重置 per-utterance 狀態(含把持續 stream 設回 None:679
VADUserStoppedSpeakingFrameINVAD 判定停止說話turn 邊界:在同一把 buffer_lock 內快照音訊、清空 buffer、把 stream 交棒給本句 final,然後跑 final 與 TurnSense:781
UserStarted/StoppedSpeakingFrameIN上游非分區 VAD轉成對應的 zonal 事件由同一條路徑處理(kitt-core 基底類別):688
BotStartedSpeakingFrameINTTS 開始播barge-in 仲裁所需的狀態:914
BotStoppedSpeakingFrameINTTS 播完同上,並重新武裝 idle 計時:947
InterruptionFrameIN下游宣告 agent 開始/結束說話更新喚醒狀態與 KWS 共用計時器(agent_activeagent_standby:1000
STTUpdateSettingsFrameIN執行期改設定套用 STT 設定差異並回報實際生效值:1189
EndFrameIN連線收尾結束未完成的 turn 並清理 session:1040
ASRMetadataFrameOUT連線建立時 + 每個 final本服務自訂 frame,帶模型資訊與延遲/RTF/total_inference_time_ms 等量測欄位:351
InterimTranscriptionFrameOUT每次 interim 重解完成且文字有變顯示文字(只過 s2t,不過完整 ITN):1379
TranscriptionFrame ★OUTfinal 解碼完成且 _should_emit_final 通過權威文字(HR → ITN 之後),並在 metadata["eot"] 帶 EOT 判定:1691
InterruptionFrameOUT喚醒成功 → agent_active通知下游進入 active:587
InterruptionFrameOUT解除喚醒 / idle timeout / 離開詞 → agent_standby通知下游回 standby(三種 case 共用同一個 frame):492
TTSSpeakFrameOUT純喚醒詞(wake-only)播固定招呼語,該 turn 以空的語意 stop 收掉:1812
收 10 種、送 6 種。TranscriptionFrame ★ 是唯一的權威文字載體—— EOT 判定也掛在它的 metadata["eot"] 上,而不是另外送 frame。 InterruptionFrame 出現三次:一次是收(下游宣告 agent 狀態),兩次是送 (agent_activeagent_standby,後者涵蓋解除喚醒/idle timeout/離開詞三種 case)。

附錄 B · 測試(累積)

2026-08-31 baseline 新增(承接下表的歷輪聯集)

該 baseline cycle 新增四個測試檔、共 31 條,全部通過(刻意不掛 SW_id,見下)。離線全套 539 passed / 1 skipped。live 套件 39 passed(9 分 42 秒,./tests/docker-test.sh --build,乾淨隔離容器、GPU cuda)。這些是承接證據;2026-09-02 最新 gate 見本報告最上方。

SW_id行為測試結果

刻意不掛 SW_id
interim 重解碼的特徵抽取增量化:增量餵入與一次性餵入產生的特徵必須逐 bit 相同; 持續 stream 不得跨 utterance 復用;該句的 final 沿用同一個 stream 只補未餵的尾巴; 餵入失敗必須可見且可復原(拋 IncrementalFeedError、游標只在成功後推進、 半餵的 stream 丟棄重建);交棒不得在 interim 餵入中途發生(否則 final 重複餵同一段音訊) tests/test_offline_stream_incremental.py(8)
tests/test_batch_asr_manager_incremental_prep.py(8)
tests/test_interim_incremental_wiring.py(6)
tests/test_final_stream_reuse.py(9)
31/31 PASS
本輪(2026-08-31)實際執行結果

離線uv run --group test pytest539 passed / 1 skipped / 0 failed (數字取自 pytest --collect-only,非手記)。本輪自身新增 31 條,見上表。 全套跑完,前輪測試一條未停用。總數比前輪少,是因為本輪定案不採用的三個切法(標點切、固定切、批次解碼) 連同其測試檔一併移除,量測開關也隨定案移除——不是既有測試被停用。

live39 passed(9 分 42 秒,./tests/docker-test.sh --build,乾淨隔離容器、GPU cuda;組態為本輪定案的最終樣貌:只有 #3(含 final 沿用),未採用的優化與量測開關皆已從程式碼移除)。本輪跑完整 39 條,沒有任何 deselect。

歷輪結果(繼承,非本輪執行

2026-08-18(多人併發):live 37 passed / 0 failed./tests/docker-test.sh --build,隔離容器、GPU cuda、576.55 s;對象是 rebase 後、移除量測儀器的程式碼)。 該輪之前一次是 33 passed / 4 failed,那 4 條已不存在——main 於 2026-08-17 廢止 SW-W-06SW-B-04SW-B-09(「smart-turn 第二句句首不應觸發」族, 改由 SW-W-13SW-B-10SW-B-11 取代)並修好對應的 live 測試。 live 條數 37 = main 的 32 + 該輪多人併發 5。

2026-08-14(SW 整體優化):離線 357 passed / 1 skipped,該輪新增 15 條 (ITN 守門 2 + tests/test_interim_concurrency.py 13)。

上列數字是當時的全套規模,與本輪的 536 不同只是因為之後加減了測試 (main 的 test_report_integrity.pytest_spec_traceability.py、本輪新增的 31 條、本輪移除未採用切法與量測開關的測試檔、 以及成本拆解改記 trace 的 tests/test_cost_trace.py 8 條——純觀測工具,依使用者裁定不掛 SW_id)。 每個數字都標了它的量測基準(哪一輪、哪一棵樹),不要跨輪相減。

Feature 總覽

每個 PRD Feature 是否有 STT SW_id 與測試。

PRD F_idFeature有 STT SW_id?測試本輪結果
F_1多音區識別/控制✗ 下游/OOS(權限·多區)—(STT 提供 per-user_id hook)
F_2One-shot(喚醒詞+指令連說) SW-W-03/04/05/08/0955 / 5 ✓(本輪重跑確認)
F_3聆聽等待智慧斷句 SW-W-01/10/11/13 · SW-X-01/0266 / 6 ✓SW-W-06 已於 2026-08-17 由 SW-W-13 取代並修好,本輪重跑確認)
F_3.4c最大錄音時限自動停止✗ MISSING(GAP-1,Cycle 1 起追蹤)仍未解決——本輪調查確認正確歸屬是 kitt-web-uibackend/src/kitt/agent/vad.py::stop_user_vad() + 新的 max-utterance-duration watchdog,與現有 audio-stall watchdog 對稱),非 kitt-stt;另案處理
F_4.1語音打斷_背景併行✗ 下游(DM/TTS)
F_4.2語音打斷_後令壓前令✗ 下游(DM 仲裁)
F_4.3語音打斷-Barge-in / 解除喚醒 SW-X-04(終止詞)11 / 1 ✓(本輪重跑確認)
F_5連續指令✗ 下游(NLU 拆解)
F_6上下文理解✗ 下游(DM 快取)
STT_EXTRA免喚醒 / WebUI 按鈕 / 自定義 / ITN / 反應式排程 + A7 live 回歸(Cycle 3)/ interim-final ITN 拆分(Cycle 4)/ 併發排程(Cycle 6 本輪新增) SW-B/UI/CFG · ITN · AudioCfg · SW-PERF-01 · itn_split · B14-R1/R2 · B19-R1..R34747 / 47 ✓SW-B-04SW-B-09 已由 SW-B-10SW-B-11 取代並修好,SW-B-08 同批修好,本輪重跑確認)· Cycle 6 淨新增 13 全 ✓
F_2 · One-shot(喚醒詞 + 指令連說,沿用 Cycle 2,本輪重跑確認)
SW_id行為測試(file::test)結果
SW-W-03喚醒詞+指令連說(句首過濾喚醒詞)test_wake::test_wake_immediate✓ PASS
SW-W-04喚醒詞 <停頓> 指令test_wake::test_wake_pause✓ PASS
SW-W-05喚醒詞不在句首不觸發test_standby::test_standby_wake_not_at_start✓ PASS
SW-W-08Active 句首喚醒詞過濾test_active::test_active_wake_filter✓ PASS
SW-W-09Active 句首喚醒詞 + smart-turntest_active::test_active_smart_turn_wake_head✓ PASS
F_3 · 聆聽等待智慧斷句規範(沿用 Cycle 2,本輪重跑確認)
SW_id行為測試(file::test)結果
SW-W-01非喚醒不出文字、不往後送test_standby::test_standby_rejection✓ PASS
SW-W-10Active smart-turn 跨段黏合test_active::test_active_smart_turn✓ PASS
SW-W-11Active 第二句喚醒詞不過濾test_smart_turn_wake::test_active_smart_turn_wake_2nd✓ PASS
SW-W-06
2026-08-17 廢止,由 SW-W-13 取代
喚醒詞於 smart-turn 第二句句首觸發(行為由 SW-W-13 取代後反轉)test_smart_turn_wake::test_standby_smart_turn_wake_2nd✓ PASS(測試已隨 SW-W-13 一併修好,本輪重跑確認)
SW-X-01Timeout 15s 回 standbytest_timeout::test_timeout_deactivate✓ PASS
SW-X-02重新喚醒後 timeout 重算test_timeout::test_timeout_reset✓ PASS
F_4.3 · 語音打斷 Barge-in / 解除喚醒(沿用 Cycle 2,本輪重跑確認)
SW_id行為測試(file::test)結果
SW-X-04解除喚醒詞(退下/安靜/離開/暫停)→ 切 Standbytest_exit_word(offline, 3)✓ PASS
STT_EXTRA · STT 有、PRD 未定義(免喚醒 / WebUI 按鈕 / 自定義 / ITN,沿用 Cycle 2,本輪重跑確認)
SW_id行為測試(file::test)結果
SW-B-01Prefix 觸發整句往後送test_bypass_prefix::test_prefix_trigger✓ PASS
SW-B-02Prefix 不在句首不觸發test_bypass_prefix::test_prefix_not_at_start✓ PASS
SW-B-03Prefix + smart-turn 合併test_bypass_prefix::test_prefix_smart_turn✓ PASS
SW-B-05特定指令觸發往後送test_bypass_cmd::test_cmd_trigger✓ PASS
SW-B-06特定指令不在句首不觸發test_bypass_cmd::test_cmd_not_at_start✓ PASS
SW-B-07特定指令句中夾雜其他話不觸發test_bypass_cmd::test_cmd_extra_words✓ PASS
SW-B-08特定指令 smart-turn 第一句只傳指令test_bypass_cmd::test_standby_cmd_smart_turn✓ PASS(隨 2026-08-17 的 turn 仲裁修正一併修好,本輪重跑確認)
SW-B-04
2026-08-17 廢止,由 SW-B-10 取代
Prefix 於第二句句首觸發(行為由 SW-B-10 取代後反轉)test_smart_turn_bypass::test_standby_smart_turn_prefix_2nd✓ PASS(測試已隨 SW-B-10 一併修好,本輪重跑確認)
SW-B-09
2026-08-17 廢止,由 SW-B-11 取代
特定指令於第二句句首觸發(行為由 SW-B-11 取代後反轉)test_smart_turn_bypass::test_standby_smart_turn_cmd_2nd✓ PASS(測試已隨 SW-B-11 一併修好,本輪重跑確認)
SW-X-03按 WebUI 按鈕回 standbytest_btn_control::test_btn_deactivate✓ PASS
SW-UI-01WebUI 按鈕啟動喚醒test_btn_control::test_btn_activate✓ PASS
SW-UI-01按鈕喚醒後說話辨識往後送test_btn_control::test_btn_standby_voice✓ PASS
SW-UI-03連點切換穩定(active)test_btn_control::test_btn_rapid_toggle_active✓ PASS
SW-UI-04連點後語音喚醒test_btn_control::test_btn_rapid_toggle_wake✓ PASS
SW-UI-05講話中切 standby 不卡字test_btn_control::test_btn_standby_mid✓ PASS
SW-CFG-01客製化喚醒/免喚醒詞test_stt_config::test_custom_stt_config_active✓ PASS
SW-CFG-02刪除客製化詞後不再觸發test_stt_config::test_custom_stt_config_deleted✓ PASS
ITN中文數字/繁簡正規化(final)test_text_converter::test_all✓ PASS
(infra)STT_LIVE_URI resolver(Cycle 2 TDD)test_live_uri(3)✓ PASS
STT_EXTRA · 反應式 Interim 排程(Cycle 3 新增,離線)
驗收 ID行為測試(file::test)結果
A1第一次 interim 無前次延遲 → 用 initial_intervaltest_audio_cfg::test_next_interval_uses_initial_when_no_prior_latency✓ PASS
A2間隔 = max(延遲×margin, 地板),無上限test_audio_cfg::test_next_interval_is_latency_times_margin_above_the_floor / test_next_interval_has_no_upper_ceiling✓ PASS ×2
延遲很小時被地板夾住test_audio_cfg::test_next_interval_clamped_to_floor_for_small_latency✓ PASS
A3–A5margin≤1 / 地板≤0 / 初始值≤0 → ValidationErrortest_audio_cfg::test_safety_margin_must_be_greater_than_one / test_min_interval_must_be_positive / test_initial_interval_must_be_positive✓ PASS ×3
A6ASR_INTERIM_SAFETY_MARGIN 覆寫 / 未設時吃 YAMLtest_audio_cfg::test_env_var_overrides_margin / test_no_env_var_keeps_yaml_default✓ PASS ×2
STT_EXTRA · SW-PERF-01:A7 永久 live 回歸測試(本輪新增,live)
驗收 ID行為測試(file::test)結果
SW-PERF-01連線無錯誤test_audio_stress::test_audio_stress_no_errors✓ PASS
SW-PERF-0190 秒連續長句仍收到 finaltest_audio_stress::test_audio_stress_final_received✓ PASS
SW-PERF-01time to final 有界test_audio_stress::test_audio_stress_time_to_final_bounded✓ PASS
SW-PERF-01相鄰 interim 最大間隔有界test_audio_stress::test_audio_stress_interim_gap_bounded✓ PASS
STT_EXTRA · interim/final ITN 拆分(Cycle 4 本輪新增,離線)
驗收 ID行為測試(file::test)結果
B1interim 只套用 s2t(),不做數字正規化test_batch_asr_manager_itn_split::test_interim_inference_only_applies_s2t_not_numeral_normalization✓ PASS
B2final 仍套用完整 itn()(s2t + 數字),不受影響test_batch_asr_manager_itn_split::test_offline_inference_still_applies_full_itn✓ PASS
STT_EXTRA · 併發排程(Cycle 6 本輪新增,離線 13 條全 ✓,單一檔 tests/test_interim_concurrency.py
驗收 ID行為測試(file::test)結果
B14-R1RTF 只計 decode_stream() 的時間,不含鎖等待(排隊不得被算成運算)test_interim_concurrency::test_decode_ms_and_latency_ms_agree_with_no_contention / test_decode_ms_excludes_lock_queueing_but_latency_ms_includes_it✓ PASS ×2
B14-R2每個 session 有獨立buffer_lock,與 inference_lock 分開,且真的序列化併發存取(K 讓 interim 與音訊寫入真的併發後才需要)test_interim_concurrency::test_buffer_lock_is_per_session_and_separate_from_inference_lock / test_buffer_lock_serializes_append_against_read✓ PASS ×4
B19-R1_interim_redecode_body 不得被 inline await;恰有一次背景派發,且必須是 self.create_task(teardown 會取消),不得是裸 asyncio.create_tasktest_interim_concurrency::test_interim_redecode_is_dispatched_never_awaited✓ PASS
B19-R2×人數 不得偷渡回來:呼叫端只能帶 1 個位置引數,且函式簽名本身不得有人數參數test_interim_concurrency::test_the_schedule_is_never_given_a_speaker_count✓ PASS
B19-R3三個已移除的旗標保持移除FeaturesCfg 無欄位、config-basic.yaml 無鍵,且舊 override 檔仍可載入並為惰性(不是啟動失敗)test_interim_concurrency::test_the_removed_knobs_stay_removed✓ PASS
下游 / OOS(無 STT SW_id / 測試 — 歸屬 NLU · DM · TTS · web-ui,沿用 Cycle 1/2)
F_id / SW_idFeature / 行為歸屬
F_1多音區識別/控制(權限·AreaID·多區並行)下游 + STT per-user_id hook
F_4.1語音打斷_背景併行處理下游 DM/TTS
F_4.2語音打斷_後令壓前令下游 DM 仲裁
F_5連續指令下游 NLU 拆解
F_6上下文理解下游 DM 快取
F_3.4c最大錄音時限自動停止kitt-web-ui(本輪確認歸屬,見上方 F_3.4c 列)
SW-UI-06講話中切 mic 靜音、WebUI 不卡字web-ui(無 STT 測試)
SW-MU-01..08多使用者/多車喚醒同步、隔離web-ui(STT 提供 per-user_id / ?car= hook)

📐附錄 C · Feature / 驗收覆蓋檢查

本附錄沿用 2026-08-18 那一輪(覆蓋檢查是累積的),所以列內的「§n」節號指的是該輪報告的節號,不是本報告的。本輪自己的追溯在 §3 各小節與附錄 B。

ID驗收條件分類追溯
C1可重複執行的多使用者併發量測工具(單連線 N 個 user_id 完全重疊)COVEREDtests/batch_decode/concurrency_profile.py;smoke run 2/2 ok
C2每個 interim/final 正確歸屬到發話的 user_idCOVERED3 條離線測試(見附錄 B)
C3網格點之間正確重置 per-user 狀態COVERED::test_reset_clears_all_user_state
C4主動偵測並標記跨輪污染COVERED2 條離線測試;實測 0 contaminated / 44 筆
C5固定 500ms 在 1–4 人 × 1/3/5/10 秒的實測數據COVERED2026-08-18 報告 §5(本表沿用該輪,節號指向那一份);原始資料 tests/batch_decode/results/concurrency_fixed500ms_t4_20260805*/.gitignore 排除、未進版控)
C6使用 web-ui 真實座位 user_id、人數上限為座位數COVERED2 條離線測試(含 parametrize ×3)
GAP-B(前輪)≥2 人同時持續講話是否讓固定排程失效已關閉成立,且比預期嚴重:本報告舊協定資料(非 §5 的新協定)——3 秒句 3 人 RTF 1.02、4 人 2.51;5 秒 4 人與 10 秒 3/4 人直接 timeout
SW-PERF-03 整車 4 人同時講 10 秒句:四座皆收到 finaluser_id 不串話、time to final 與 interim 最大間隔有界不發散
刻意不訂數值門檻(PRD 延遲 TBD)
COVERED 原始 0/12 座位收到 final ✗ → SW 整體優化 36/36 ✓(2026-08-18 報告 §5.6 對照表); 自動化把關 tests/functional/live/test_multiuser_concurrency.py(5 條;本輪 live 重跑 39 passed)
SW-MU-03 / SW-MU-06多使用者長句期間的穩定性(規格標 web-ui + STTOUT_OF_SCOPE(STT) 但本輪提供了 STT 側資料SW 規格把這兩條的主體歸 web-ui/CarSessionManager;本輪補上 STT 側的併發容量事實,供該側設計參考
F_1(多音區識別/控制)PRD Feature下游 / OOS沿用前輪:STT 提供 per-user_id hook,權限與多區決策在下游
GAP-C反應式排程(margin=1.5)在多人下的表現COVERED(本輪追加完成)2026-08-18 報告 §5 逐點對照:13/16 → 16/16 全 ok;3 秒句 4 人 time to final 41.0s → 1.90s(21.6×);RTF 全網格未再破 1.0。原始資料 tests/batch_decode/results/concurrency_reactive150_t4_20260805/
其他 margin 值 / 錯開重疊 / >10 秒句 × 多人未量測沿用 2026-08-18:只測 margin=1.5、完全重疊、≤10 秒
2026-09-02 close-out:offline 562 passed / 1 skipped;乾淨 CUDA/4090 default STT live 39 passed / 4 manual skipped;manual targeted live 4 passed。完整依據見 docs/dev-specs/2026-09-02-switchable-wake-provider-design.mddocs/dev-plans/2026-09-02-switchable-wake-provider.md