wake.provider 與集中式 effective mode,保留既有 Web UI manual override、bypass、exit、timeout 與 EOT authority;不引入 iGo、DSpotter、VID 或聲學音訊介面。features.enable_kws=false 是完全略過 Wake policy 的 off;
enable_kws=true + wake.provider=stt 維持 transcript wake;
enable_kws=true + wake.provider=manual 只停用 automatic wake,仍保留 Web UI Active/Standby、bypass、exit 與共享 timeout。
預設仍是 stt,既有部署不改設定即可維持原行為。
設計與施工依據:
2026-09-02-switchable-wake-provider-design.md、
2026-09-02-switchable-wake-provider.md。
本輪只交付 stt / manual;未來 iGo / DSpotter acoustic authority、generation/sample clock 與 One-shot 音訊裁切仍屬後續 cycle。
features.enable_kws | wake.provider | Effective mode | Runtime 行為 |
|---|---|---|---|
false | 合法值但 runtime 忽略 | off | 普通 STT/EOT passthrough;Web UI frame 不改 STT gating |
true | stt | stt | 既有 transcript wake + bypass |
true | manual | manual | 無 automatic wake;保留 Web UI override、bypass、exit、timeout |
| Frame / input | 方向 | 觸發條件 | 動作 | Owner |
|---|---|---|---|---|
InterruptionFrame(agent_active) | Web UI → STT | stt / manual | room-wide Active,啟動 shared timeout | KittSttService.process_frame |
InterruptionFrame(agent_standby) | Web UI / LM → STT | stt / manual | room-wide Standby,丟棄 pending turn | KittSttService.process_frame |
| ASR final transcript | internal | stt | 可做 automatic wake match / One-shot strip | _finalize_utterance_impl |
| ASR final transcript | internal | manual | 不做 automatic wake match;bypass 仍執行 | _finalize_utterance_impl |
| 任何 active/standby frame | passthrough | off | 不改 Wake state 或 STT/EOT gating | STTService base path |
| PRD Feature | SW_id | Behaviour | Executable evidence | Result |
|---|---|---|---|---|
| STT_EXTRA | SW-W-14 | STT / manual automatic wake provider switch | test_wake_provider_policy.py · test_wake_provider_manual.py | ✓ PASS |
| STT_EXTRA | SW-B-12 | manual 只停 automatic wake,保留 exact/prefix bypass | test_wake_provider_policy.py · test_wake_provider_manual.py | ✓ PASS |
| STT_EXTRA | SW-UI-07 | Web UI override 對所有 enabled provider 共用;off 不 gating | test_wake_provider_policy.py · test_btn_control.py | ✓ PASS |
| STT_EXTRA | SW-CFG-03 | typed provider、effective mode、狀態頁與 startup log | test_wake_provider_config.py · test_status_page_settings.py | ✓ PASS |
| Gate | Command / environment | Result |
|---|---|---|
| Offline full | .venv/bin/pytest -q(sandbox 外,native/thread 可用) | 562 passed, 1 skipped |
| Default STT live full | ./tests/docker-test.sh --build · RTX 4090 / CUDA | 39 passed, 4 manual skipped |
| Manual live targeted | WAKE_PROVIDER=manual LIVE_TEST_TARGET=... ./tests/docker-test.sh --build | 4 passed |
| Static | Ruff + scoped Pyright + bash -n + diff check | 0 errors |
以下內容承接 2026-08-31 report,保留歷輪 Feature ledger、coverage checklist、完整 STT stack SVG 與 frame-flow 圖;本輪差異與最新真值以上方區塊為準。
本報告引用四個代號家族。讀者手上只有這一份檔案,所以每一個都在這裡定義。
本輪只採用 #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 凍結時間判準) |
An/Bn |
驗收條件 ID。字母只在「哪一輪」的脈絡下唯一——每一輪都從 A1 重新編號,
所以看到這類代號要先確定它屬於哪一輪 |
2026-08-31 baseline cycle(定義在 docs/dev-specs/2026-08-31-aspect-a-model-inference-design.md §2):
A1 前綴定錯率 · A2 文字差異率 · A3 fork 成本評估 ·
B1–B6 #3 的實作驗收(B6=final 沿用 stream)。承接前輪(出現在附錄 C 的累積覆蓋表): C1–C6 上一輪多人量測工具的驗收 ·
GAP-B/GAP-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 裡的 B1/B2 是 2026-08-14 那一輪的驗收 ID
(interim 只套 s2t()/final 套完整 itn(),測試在 test_batch_asr_manager_itn_split),
不是本輪的 B1/B2(binding/逐 bit 等價)。同理 A3–A5
在 test_audio_cfg 那一列是 2026-07-29 那輪的編號。
③ #4(候選編號)與 F_4(PRD Feature 編號)沒有關係——後者只出現在附錄 C 的覆蓋表,一律帶底線。
上一輪(2026-08-18-multiuser-concurrency)把 SW 流程設計(面向 B)優化到接近極限——M1 背景解碼把共用 frame 路徑佔用率從 65–78% 壓到 2–4%——並把八個「換掉模型之後還算不算數」的候選整理成清單,判給面向 A(模型推理能力)交下一輪。本輪要回答的就是那張清單:哪個先做、代價多少、做完有沒有達到預期。
| 面向 | 它問的問題 | 怎麼量 | 狀態 |
|---|---|---|---|
| A · 模型推理能力 | 模型解一次要多久?成本怎麼隨句長與人數變化?能不能少算? | 逐段計時的 prep/decode、截斷 CER、前綴定錯率 | 本輪 |
| B · SW 流程設計 | 工作被放在哪條路徑上?多少被序列化?佇列在哪形成? | 逐段計時、time to final、更新節奏 | 上一輪已收斂(四個純 B 候選全部處理完) |
本段承接 2026-08-31 當時的最新累積表;依 CLAUDE.md §6 第 2 點與
SW-DOC-02,須涵蓋歷輪聯集的所有 SW_id。下列各條不是本輪驗證的——
本輪未觸及其行為,故沿用前輪結果,並指向規格與其測試;本輪自己的追溯在上方各表。
| SW_id | 行為(摘自 SW 規格) | 測試 |
|---|---|---|
SW-W-02 | 非喚醒下說無關長語音,需立即 smart-turn stop | test_standby.py |
SW-WM-01 | 喚醒詞比對與剝除:latin 大小寫無關、分隔字元先剝除、喚醒詞後可直接接指令、剝除喚醒詞後指令完整保留 | tests/test_wake_matcher.py |
SW-WM-02 | alias 只能買回實測誤聽,不得擴大誤觸發:全中文 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-01 | final 的 ITN:中文數字正規化 + 繁簡轉換 | tests/test_text_converter.py |
SW-ITN-02 | interim 與 final 的 ITN 刻意不同:interim 只做 s2t(),final 做完整 itn();故兩者在數字上可以不同,這是設計不是 bug | tests/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.yaml 的 model.key、utils/model_manager.py 註冊表、三個 Dockerfile* 的 COPY、deploy.sh 的 MODEL_DVC_FILES、**.dockerign | tests/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-03 | LoRA 接線:不可達目標必須拋錯而非靜默不啟用;可訓練集合非空且只含 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-DOC-01 | 新增測試檔必須在本規格留下紀錄:掛在某個 SW_id 之下,或明示「刻意不給 ID」與理由。反向亦然——本規格不得引用已不存在的測試檔(重新命名或刪除後留下的空指向,比未覆蓋更危險,因為它看起來有覆蓋)。§8/§9 兩區與其 ID 前綴不得被靜默移除 | tests/test_spec_traceability.py |
SW-DOC-02 | cycle 報告的累積 ledger 只增不減:最新一份報告必須涵蓋歷輪聯集的所有 SW_id(不只是對前一輪比對——那會讓某個 ID 消失一輪後就永遠消失,因為下一次比的是兩份都已缺它的報告)。報告檔名須與 docs/dev-specs/ 的 cycle stem 對應 | tests/test_report_integrity.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.py、tests/functional/live/test_debug_logs.py |
SW-OPS-04 |
狀態頁設定介面:EOT 等待秒數以滑桿呈現,其上下界與步進即 clamp() 實際夾制的範圍;
整頁只有一個儲存動作,任一欄位驗證失敗則全部不送出。上界必須低於 kitt-core 的
user_turn_stop_timeout,否則該 fallback 會搶在語尾判定之前結束 turn |
tests/test_wake_config.py |
§2.1–2.4 沿用上一輪、內容未改(重繪內嵌而非放連結,見 docs/spec.md 附錄 A 的規範);§2.5 是格點表裡「逾時」的判準。
這節寫下本報告每個數字是怎麼產生的,包含失敗過的做法。它們不是流程細節:本輪已經因為 違反其中幾條而撤回過結論(單次量測就下判斷、比較基準沒對齊、聚合把效應抹掉),而下面的 「量測缺陷」小節裡有三個缺陷,若沒被抓到,整份報告會建立在系統性偏差的數字上。
本節多處提到「組態」。兩個組態(SW 整體優化/+#3)的定義見 §4.1,與它們的對比結果放在一起。
主數據一律由 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_lock/occ,[SegF] 有 itn),分析腳本靠這個分流。這三個標籤是量測期間 log 的原字串,本報告照抄以便重跑時 grep 得到;那次量測用的 logger.debug patch 已還原,現行程式碼裡沒有這幾行 log。後續已把同一批欄位(prep/seg_lock/rec_lock/decode/post/itn/audio_lag/occ)改記在既有的 Perfetto trace(debug.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 final | client 端 | 沒有錨點問題,且就是使用者感受到的量。client 已移出受測節點,pacing_slip ≤ 0.93% |
| 逐段成本(prep/decode/ITN/鎖) | server 自量 | 區間量測,不受錨點影響 |
本節是全篇的命名依據。下面第一張表是數據圖與總表用的指標,第二張是單次解碼內部的分段欄位。 報告其他地方一律用第一張表的指標名稱,不再出現同義的別名。
| 指標 | 定義 | 量在哪 | 原始欄位 | 單位 | 本報告哪裡用 |
|---|---|---|---|---|---|
| 使用者體感(client 端量到的,直接對應「使用者感覺到什麼」) | |||||
| time to final | 從 client 送完音訊到收到最終文字。多人格點引用各座位中最慢者——飽和時各座位相差 ≤3%,所以那個值就是每個座位的體感 | client | worst_client_final_latency_ms | ms | §4 數據圖、§4 總表 |
| TTFT 首字延遲 |
從 utterance 起算到第一次 interim 被推出。兩邊服務的定義相同(perf_counter() − tag_utterance_start),可直接對比 |
server 自量 | worst_server_ttft_ms | ms | §4 數據圖 |
| interim 更新次數 | 整句期間 client 收到幾個 interim frame(=更新密度)。不等於下面的「interim 推論次數」——被合併或跳過的解碼不會產生 frame | client | mean_interim_count | 次 | §4 數據圖 |
| interim 最大間隔 | 相鄰兩次更新之間的最大間隔,即「畫面看起來停住」的最長時間 | client | worst_max_interim_gap_s | s | §4 數據圖 |
伺服器內部:latency 家族(server 自己量自己,不受量測機器的負載影響) | |||||
| latency 母定義 |
一次解碼在 server 內部花的時間:等鎖 + decode + 後處理。服務對 interim 與 final 各自都吐這個值
(kitt_stt_service.py 的 latency_ms)。下面三列都是它的實例或聚合,不是另外的東西 |
server 自量 | latency_ms | ms | ——(用下面三列的名字) |
| 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_ms | ms | §4 數據圖 |
| total latency 推論時間總和 |
整句所有 interim latency 之和(程式碼就是 total_inference_time += latency_ms)。
它是「這句話總共花了多少推論」,不是一個延遲值 |
server 自量 | total_inference_time_ms/interim_infer_ms | s | §3.3.1/§3.3.2 的長句掃描 |
| backlog | time to final − final latency = 排隊 + 傳輸。它把「server 真的在算」與「在等」分開,
是判斷瓶頸在哪一側的關鍵欄位 |
相減 | worst_backlog_ms | ms | §4 數據圖 |
| interim 推論次數 | server 真正跑了幾次 interim 解碼。它是排程行為的指紋——次數對不上就代表兩個組態跑的不是同一個排程 | server 自量 | interim_infer_count/interim_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 log | Current Process Total | GB | §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 + backlogtotal latency = Σ interim latency = interim latency × interim 推論次數total latency/interim 推論次數/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) |
decode | sherpa-onnx 的 GPU 推理呼叫本身 | 屬面向 A 的成本;SW 優化動不到它 |
dec_ovh | 解碼前後的執行緒跳轉開銷 | 次毫秒雜項,列出只為證明沒有藏東西 |
post | simplified→traditional(s2t) | 同上,次毫秒 |
push | 把 frame 送進 pipeline | 同上,次毫秒 |
itn | final 專屬:數字正規化 + s2t | final 路徑上最大的 SW 成本,本輪唯一採用的單人優化打的就是它(§3.3.1) |
| 共用路徑的負載(只有多人才有意義,也是本報告診斷多人問題的兩個量) | ||
occhandler 佔用率,% |
這條連線的音訊 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 | 音訊 frame 等 handler 空出來 | 累積的積壓深度——到這一刻還有幾秒的音訊沒被處理 | 秒 |
audio_lag 不是「某一次排隊等多久」,而是積壓:刺激以真實速度送出,handler 跟得上時「經過的時間 ≈ 已處理的音訊秒數」、差值 ≈ 0;handler 一卡住,時間繼續走而已處理量不動,差值就是還沒消化的音訊秒數。
關鍵在「已處理」而非「已收到」:累加點在音訊 frame handler 內(src/kitt_stt_service.py:535),也就是序列化點 (1) 本身。socket 上早就收完的音訊,只要還沒被 handler 處理就仍算積壓。
active 是整句期間看到的最大同時說話人數,不是 log 當下的人數——在 final 那一刻計算會低估,
因為那時其他座位多半已經講完,4 人的格點會被標成 1 人,單人與多人兩節就會混在一起。
| 項目 | 設定 |
|---|---|
| 受測 server | T4 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) |
| 規則 | 內容與理由 |
|---|---|
| 格點 | 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)。有缺口代表有未量測的時間,該筆作廢而非硬分攤 |
格點表裡的逾時不是「等一下就放棄」——判準是工具寫死的
timeout = max(120 秒, 音檔長度 × 15),而且從 VAD-stop(講完)那一刻才開始計時:
| 音檔長度 | 1 s | 3 s | 5 s | 10 s | 15 s | 20 s |
|---|---|---|---|---|---|---|
| 等待上限 | 120 s | 120 s | 120 s | 150 s | 225 s | 300 s |
門檻寬鬆是刻意的:正常情況 final 在講完後 100–300 ms 回來,而多人塞車時排隊本來就是要量的現象。上限設得緊會把「慢但仍有效」誤判成「壞掉」,就分不出劣化與失效——所以逾時代表該組態在該負載下不會回 final,那是結果,不是量測失敗。
上一輪把八項候選留給本輪,判準是「換掉模型之後這一項還算不算數」——只在「用 offline 模型定期重解整段」這個策略下才存在的手段,都不屬於 SW 流程設計。本輪處理了其中五項,另加兩項使用者提出的延伸方向。這張表只回答「有哪些候選、各自想做什麼、去哪一節看」;每一項的量測與判定在它自己的小節裡。
| # | 方法 | 機制:它想改什麼 | 在哪一節 | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| #3 | prep 增量化 | 每次 interim 只算新增音訊的 fbank,不重算整段——把特徵抽取從「正比於整段長度」變成「正比於新增長度」 | §3.3.1 | |||||||||||||||
| #4 | 滑動窗口+保留前綴 | 每次 interim 只解「最近這一段」,不從句首重解。要落地必須回答兩個問題:左緣放哪裡(固定 N 秒/已確認的句中逗號),以及新解出的字怎麼接回已鎖定的前綴(合併) | §3.3.2 | |||||||||||||||
| #5 | final 重用最後一次 interim | VAD-stop 前的靜音期間伺服器是閒著的,而最後一次 interim 已解過幾乎全部音訊——若新增的只有靜音就直接重用該結果,跳過 final 的 prep 與 decode |
§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。 不節流才是延遲變差的那一邊。 但真正被換掉的東西是更新密度,而使用者感覺到的不是密度,是凍結時間——
螢幕上兩次更新之間空白多久(
判準套在「淨的間隔」上,不是套在機制的意圖上。
已上線的反應式排程( 這也是為什麼 | ||||||||||||||||||
本輪追加評估的兩項延伸方向(使用者提出,非原八項候選之一;都不需要改動 src/,量完才決定) | ||||||||||||||||||
| + | 縮短 leading_pad_secs |
減少每次送進模型的前導靜音,讓模型少處理一段沒有資訊的音訊 | §3.3.4 | |||||||||||||||
| + | 調整 num_threads |
改 ONNX Runtime 的執行緒數,看多人併發時的 CPU 競爭是否來自 ORT 自己開的執行緒 | §3.3.5 | |||||||||||||||
GetFrames() 沒有切片參數也沒有幀淘汰介面,所以窗口化只能在 Python 端切波形;而 timestamp 對齊的方向隨語料翻轉、成本一致地更貴,已退出候選。因此 §3.3.2 的軸只有切法與合併兩個,本表也不單列這兩項。單次 interim 的成本會隨句子變長而上升,prep 與 decode 兩者都在漲——
差別在漲的幅度。10 秒句、比較句首 ¼ 與句尾 ¼ 這兩段的同一項成本:
| 位置 | prep | decode | total |
|---|---|---|---|
| 句首 ¼ | 14.6 ms | 68.8 ms | 87.0 ms |
| 句尾 ¼ | 60.7 ms | 83.3 ms | 146.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.1 表格的順序展開,每節的結構相同:機制 → 效益 → 代價 → 判定。判定與依據摘要已在 §3.1 的表上,這裡是它們背後的量測。
#3 是本輪唯一零代價的優化——增量餵入與一次性餵入算出的特徵逐 bit 相同,
所以它不是拿準確度換速度,而是把重複勞動刪掉。它打的正是 interim 路徑最大的一項:prep 的 98% 是 fbank,
而 10 秒句上 fbank(55.4 ms)已經比模型推理本身(51.2 ms)還貴。效益在三個尺度上都量到、方向一致(下表),
唯一未決的是多人端到端效益——跨 session 雜訊蓋過訊號,見 §4。
| 量在哪個尺度 | 量的是什麼 | 改動前 | +#3 | 倍率 | 這個尺度回答什麼問題 |
|---|---|---|---|---|---|
| ① 單一階段 | prep(10 秒句) | 63.0 ms | 3.4 ms | 18.5× | 被打的那一項真的被打平了嗎——關鍵是斜率歸零,不只是變快 |
| ② 整通呼叫 | 每次 interim(55 秒句) | 418 ms | 205 ms | 2.04× | 階段效益會不會被其他階段稀釋掉,以及它額外買到的「不崩」 |
| ③ 別人的成本 | 他座位的 decode(4 人併發) | 27.2 ms | 20.7 ms | 1.31× | 它有沒有順帶縮小 M1 引入的 CPU 競爭(二階效益) |
prep;② 是 prep + 搶鎖 + decode + ITN;③ 是別人的 decode)。
可比的是方向與倍率。本節依序展開這三列,最後是「代價=零」的證明。差別只在「每次 interim 餵給特徵抽取器的是什麼」(圖 1):
| 改動前 | +#3 | |
|---|---|---|
| stream | 每次 interim create_stream() 建一個新的,用完丟掉 |
整句話共用同一個 stream,存在 session.incremental_stream,utterance 結束才丟 |
| 餵什麼 | 整個 buffer 重餵一次(第 5 次 interim 就把前面 4 次算過的音訊再算一遍) | 只餵上次之後新增的位元組,靠 incremental_stream_fed_bytes 這個游標記住餵到哪 |
| fbank | 從第 0 幀重算到最後一幀 | 沿用已算好的幀,只算新音訊產生的那幾幀 |
| 成本 | 正比於整段長度(句子越長越慢) | 正比於新增長度(每次都差不多,與句長無關) |
為什麼原本做不到,以及為什麼改動成本很小:OfflineStream 的 AcceptWaveform() 內部立刻呼叫 InputFinished(),fbank 一旦 finish 就不能再收音訊——所以只能一次性餵。但真正做增量的引擎(knf::OnlineFbank)早就在串流路徑上跑產線,OfflineStream 只是沒把它的增量能力暴露出來。改動是拆開「收音訊」與「宣告結束」兩件事,不是新寫演算法——這也是特徵能逐 bit 相同的原因。
| 檔案 | 改動 |
|---|---|
| csrc/offline-stream.h / .cc | 新增 AcceptWaveformIncremental()(不自動 finish)與獨立的 InputFinished();AcceptWaveformImpl 加 bool finish 參數。既有 AcceptWaveform() 語意完全不變(傳 finish=true),final 路徑零影響 |
| python/csrc/offline-stream.cc | 新增三個 pybind11 binding,照抄 OnlineStream 既有樣式;get_frames 的 C++ 實作本來就存在,只是從未被綁定 |
| src/asr/batch_asr_manager.py | run_interim_inference_incremental():只餵新音訊、絕不呼叫 input_finished()(這個 stream 之後還要繼續餵) |
| src/session/user_session.py src/kitt_stt_service.py | per-utterance 持續 stream 與已餵位元組游標;四個結束點都必須重置 stream(不得跨 utterance 復用,fbank 歷史會汙染下一句) |
prep 本身:從隨句長成長,變成與句長無關k8s(T4)同機交替實測,依該次 interim 的 buffer 累積長度分層(圖 2)——分層才看得到斜率,而斜率才是這個優化的性質(打平),倍率只是它在某個長度上的截面。
| 句子累積長度 | 改動前 | +#3 | 改善 | 樣本數 |
|---|---|---|---|---|
| 1–2 s | 13.00 ms | 8.37 ms | 1.6× | 34 / 36 |
| 2–4 s | 24.11 ms | 3.65 ms | 6.6× | 25 / 24 |
| 4–7 s | 45.81 ms | 3.47 ms | 13.2× | 18 / 18 |
| 7–12 s | 63.03 ms | 3.41 ms | 18.5× | 19 / 8 |
fed_s(每次真正餵進 fbank 的音訊)中位數改動前 2.56–3.08 秒、改動後固定 0.52 秒——直接對應兩臂的定義差異。prep 前後對照(k8s T4 同機交替),依該次 interim 的 buffer 累積長度分層——分層才看得到斜率。prep 不再隨句子變長而變貴,不只是「快了幾倍」。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 s | 126 | 176 | 125 | 174 |
| 30 s | 135 | 232 | 141 | 223 |
| 45 s | 191 | 318 | 219 | 641 |
| 55 s | 205 | 418 | 202 | 1949 |
| 60 s | 266 | 423 | 268 | 逾時 |
| 80 s | 318 | 552 | 993 | 逾時 |
| 90 s | 354 | 587 | 1520 | 逾時 |
total_inference_time_ms ÷ interim_count。
歸因成立的證據就在這張表的分群:l3 緊貼 l1(125 vs 126、202 vs 205)、l2 緊貼 l4(176 vs 174、232 vs 223)——分群是照 #3 分的,不是照排程分的。l3 緊貼 l1、l2 緊貼 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 負責「同樣不失控的前提下盡量便宜」—— 單開任一個都只拿到一半好處,兩個都開才是唯一在整個測試範圍內同時達成「便宜」與「不崩」的組合。
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。
decode 一起變快:M1 引入的 CPU 競爭砍半M1 把 prep 移出 frame 路徑後,四座位的 fbank 變成真的併發,於是互搶 CPU(2026-08-18 §附錄 D.1 量到 prep 33.8 → 62.1 ms)。#3 把每次 prep 從「整段成長中的 buffer」縮成「新增的 0.5 秒」,所以它應該連那個競爭一起縮小——這是先前沒量過的二階效益,也是三個尺度裡唯一「效益落在別人身上」的一個。
| 其他三座位正在做的 prep | decode | vs 無競爭 | 那個 prep 本身 |
|---|---|---|---|
| 無競爭基準 | 13.6 ms | 1.00× | — |
| 改動前:整段 10 秒重算 | 27.2 ms | 2.00× | 20.1 ms |
| +#3:只算新增的 0.5 秒 | 20.7 ms | 1.52× | 0.5 ms |
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_incremental/input_finished/get_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。全綠,併入標準離線套件 | |
三個尺度全部同向,且代價欄是空的:prep 打平(10 秒句 63.0 → 3.4 ms,斜率歸零)、
整通 interim 效益隨句長單調放大(16 點全部同向,55 秒 2.04×)、還順帶把 M1 的 CPU 競爭砍半(+100% → +52%);
逐 bit 等價由 5 條測試釘住。但它不能單獨出貨——單開 #3 頂不住固定排程在極端長句的失控,
它與反應式排程是互補而非各自獨立,兩者都開才是唯一同時做到「便宜」與「不崩」的組合。
唯一未決的是多人端到端效益:跨 session 雜訊蓋過訊號,仍待下一輪同機交替量測確認。
#3 的資料流:interim 抽好的特徵怎麼交給 final。
左半是 interim 路徑(同一個 stream 只餵新增的位元組),右半是 final 路徑。
右半那個判斷點是本輪新增的:只有在「stream 握有自句首起的完整音訊」時才沿用,
否則退回重抽整段——裁切過的 stream 起點在句中,接上尾巴會靜默地解出另一段音訊的合理文字。一條線:A 介紹三種切法是什麼(只講機制、不談結果)→ B 量效能 → C 量準確度 → D 兩軸交叉,回答能不能做。結論在 D,B 與 C 各自只回答自己那一軸。
E 是獨立的機制量測,放在判定之後:它量的是「左緣多久移動一次」如何決定 prep 的形狀—— 既回答了已採用的 #3 為什麼 prep 會隨句長成長,也回答了「切法會不會毀掉 #3 的增量性」 (重開 #4 時不必重做的功課)。那既不是效能軸也不是準確度軸。
「滑動窗口」的意思是不要每次都從句首重解——只把「最近這一段」送去解碼。但「只解最近一段」本身還不是一個做法: 要落地必須回答兩個彼此獨立的問題,兩個答案組合起來才是一個具體實作。 第二個(合併)最容易被漏掉,而它正是本輪量出來影響最大的那一個—— 固定視窗曾經量到的 89% CER,量的其實是合併實作,不是切法。
| 軸 | 選項 | 這個選項具體做什麼 |
|---|---|---|
| 1 · 切法 左緣放哪裡 |
固定長度 | 左緣=右緣往回退固定 N 秒,不看內容:buffer[-N×SR×2:]。隨時可切,切點純由秒數決定。本輪量 N=3 s 與 N=8 s 兩種 |
| 標點/句界 | 左緣=上一個已確認的句中逗號。判準是 LocalAgreement-2:連續兩次 interim 的前綴逐字相同才算「已確認」;
前綴裡出現 ,、, 就把左緣鎖到該逗號 token 的時間戳,此後不再重解它之前的音訊(左緣只前進、不回頭)。
句尾標點(。?!)不觸發——它代表模型認為整句講完了,拿它當「先鎖一段、繼續往下解」的中繼點自相矛盾 | |
| 2 · 合併 新解出來的字 怎麼接回已鎖定的前綴 |
文字重疊 兩份實作的主訊號 |
找「已鎖定文字的最長後綴」同時是「本次視窗輸出的前綴」的那一段,剪掉再接。不看時間,只看文字——所以不受 CTC 峰值漂移影響 |
| 對不上時的後備 | 這才是兩種切法真正分岔的地方:標點切直接串接(重疊上限一個音節,最壞多一個字); 固定切退回時間戳,而時間戳會漂移,於是還要再補兩個機制。詳見軸 2 |
OfflineStream 沒有幀淘汰介面,所以只能在 Python 端切波形,
這一點決定的不是輸出而是能不能同時保住 #3(E 節)。軸 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 | 時間戳 | 說明 |
|---|---|---|---|
| 0 | 帮 | 0.180 s | 一般字元,間隔 0.12–0.24 秒 |
| 1 | 我 | 0.300 s | |
| 2 | 导 | 0.540 s | |
| 3 | 航 | 0.660 s | |
| … | … | … | |
| 10 | 区 | 2.040 s | |
| 11 | , | 2.340 s | 逗號就是一個 token——標點切要的左緣就是這個 2.340 |
| 12 | 然 | 2.580 s | |
| 13 | 后 | 2.820 s | |
| 14 | 选 | 3.000 s |
。 在 10.380 秒。
標點切的左緣就在這五個值之間往前跳(句尾標點不觸發)。| 用途 | 哪個軸 | 必須嗎 | 實測 |
|---|---|---|---|
| 定位逗號在音訊上的位置 | 1 · 切法 | 必須 | 標點切的整個機制就是「把左緣移到那個逗號」。沒有時間戳就沒有標點切 |
| 判斷哪些字是「新的」 | 3 · 合併 | 可被取代 | 時間戳去重需要它;文字前綴比對完全不需要。而時間戳去重正是重複的來源——峰值會隨視窗左緣移動而漂移 |
整個標點切建立在「模型會自己吐逗號、而且那個逗號有時間戳」上,所以先講清楚這件事怎麼成立的——
它不是外掛一顆標點模型(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 節的實機量測。 |
, 出現在 tokens 陣列第 11 個,時間戳 2.340 s,
和「区」(2.040)「然」(2.580)並排,完全是一般 token 的樣子。兩種切法各自有一份已寫成程式碼的合併實作。主訊號是同一個——最長文字重疊; 真正的差別是比對不到的時候做什麼,以及為此需要幾個額外機制。
punct_window.py::merge_on_overlap、fixed_window.py::advance)。
分岔只發生在比對不到的時候,而那是常態:兩次解碼的字不保證一樣。標點切 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% |
fixed_window.py 的註解),最後定案的順序才變成
「文字能決定就讓文字決定,時間只在文字找不到接縫時說話」。順便 → 順便便、導航 → 導航航),
鬆一格就刪除(,順便看 → 。便看)。
文字重疊完全不看時間,所以對這個漂移免疫——這就是兩份實作都把它放在第一位的原因。
本輪也量過「先用時間戳錨定、再在附近做文字比對」(merge="anchored",±3 字內找最長匹配):
在無累積那一版上只改善 0.7 pt,因為瓶頸不在對齊,在解碼不穩——
真正解決它的是後來的截止期限提交,那是繞過一致性、不再要求兩次解碼逐字相同。_interim_redecode_body 整段移除,
圖上不標行號,因為對照目前的 src/ 已經找不到這幾步)。
順序是固定的:② 動態公式先決定「這一輪要不要解碼」,③ 切法才決定「解碼哪一段音訊」,
而 ⑤ 量到的延遲又回頭餵給下一輪的 ②——這就是那個回饋環。標點切曾經寫進服務、也有測試,但本輪判定不做,程式碼與設定項已一併移除
(src/asr/punct_window.py、src/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.pyrun_interim_inference_incremental() |
當時把回傳值從 (text, latency, decode) 擴充為 (text, latency, decode, tokens, timestamps)
——標點切需要時間戳才能把逗號放回音訊時間軸。切法移除後已還原成 3-tuple,
因為沒有任何呼叫端再讀那兩個欄位 |
config-basic.yamlfeatures.punct_window |
當時的開關;本輪已刪除該設定項 |
kitt-stt-batch-test-c-reactive,20 秒句)看到的就是這個:
幫我導航到內湖科學園區,哎,然後選最快的路線,嗯不要走高架橋,好,接着把冷氣打開,嗨溫度調到24度
——那些語助詞是模型在逗號邊界吐出來的,鎖進去就回不去了。
不切的現行做法沒有這個問題,因為它每次都重解整段、隨時可以自我修正。同一顆 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(3 / 10 / 30 / 60 / 90 秒) | interim 推論次數 同上五個長度 |
90 秒 對基準 |
|---|---|---|---|
| 基準 #3(不切) | 0.6 s · 2.2 s · 7.2 s · 21.9 s · 36.7 s | 6 / 20 / 56 / 98 / 123 | 1.00× |
| 固定 3 s | 0.6 s · 1.7 s · 4.8 s · 8.5 s · 14.7 s | 6 / 20 / 56 / 103 / 140 | 2.50× |
| 固定 8 s | 0.8 s · 2.7 s · 7.7 s · 14.6 s · 26.8 s | 6 / 20 / 56 / 105 / 135 | 1.37× |
| 標點切 | 0.9 s · 2.3 s · 5.4 s · 13.6 s · 20.5 s | 6 / 20 / 53 / 100 / 138 | 1.79× |
decode 的固定項是大頭
#3 已把 prep 打平,窗口化只剩 decode 的斜率可打。T4 隔離實測的 decode 是
31.1 ms 固定 + 2.31 ms/s——固定項不會因為切掉音訊而消失,所以「切掉多少音訊」能省下來的比例有天花板:
2.3 秒 buffer 只有 14.6% 可省、10 秒 42.6%、30 秒 69.0%、90 秒 87.0%。
窗口化的效益本質上是「句子越長越大」,短句上它無論怎麼實作都省不到什麼。(此表基準是純 decode 呼叫,
與上方整通 interim 的絕對值不可互比。)
共同形狀:基準的 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 節的準確度。
量的是使用者在句子講完前的最後一瞬間,螢幕上實際看到的那串字——最後一次 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 把「螢幕上那串字」對「該段自己的 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) |
| 切法 | 平均 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 s | 4.13% 6.28 字 | 3.54% 16.12 字 | 2.13% 9.69 字 | 0.51% 2.33 字 | 0.90% 4.10 字 |
| 固定 8 s | 3.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 字 |
normalize_text() 移除所有標點——
引用時務必連這個基準一起講,把標點算進去的數字完全不同(見 §C 末的實機量測)。每欄的字數都是「百分比 × 該長度的平均參考字數」——30 秒句參考平均 151.94 字、90 秒句 455.13 字(100 段 final 逐字數平均),CER/替換兩欄與插入/刪除共用同一組基準,可以直接互相對照。
兩個固定視窗跑的是 src/asr/fixed_window.py:已鎖定前綴累積 + 截止期限提交 + 接縫刪重複
+ 截斷放寬一格 + 相鄰標點收斂,各自跑自己實測的排程(90 秒 140 / 135 次)。但「插入 0.84%」這個數字說不出插進去的是什麼字,而那才是使用者看到的東西。
下表把每一列插入的字抓出來數——本節語料每段是一次 TTS 合成、沒有任何拼接點
(gen_corpus/make_texts.py),所以這裡出現的字都是切法造成的,不是音檔本身的瑕疵。
| 切法 | 插入字數/段 | 刪除字數/段 | 90 秒句最常插入的字 | ||
|---|---|---|---|---|---|
| 30 秒 | 90 秒 | 30 秒 | 90 秒 | ||
| 不切(基準) | 0.00 | 0.00 | 0.00 | 0.00 | —— |
| 固定 3 s | 1.14 | 2.34 | 2.60 | 4.12 | 後×10 t×8 做×8 勢×7 1×7 聽×5 形×5 播×5 |
| 固定 8 s | 0.88 | 0.94 | 2.74 | 4.53 | d×5 c×5 a×4 後×4 1×4 s×3 t×3 勢×3 |
| 標點切 | 1.28 | 3.85 | 0.48 | 0.45 | 後×108 說×58 哎×50 啊×21 要×18 話×13 想×12 哦×9 |
哎×50、啊×21、
哦×9 是語助詞,後×108、說×58 是跨越切點那個字被下一個視窗再收一次
(「然後」的尾字)——正好是 A 節說「重疊上限一個音節」時,那個上限真的被用到的證據。
一個逗號一旦鎖定,這些字就永久留在畫面上。後×10、t×8、做×8…,沒有語助詞集中),
但刪除高一個量級(4.12 / 4.53 對標點切的 0.45)——兩類切法的錯不同型:
標點切多貼、固定視窗漏字。CER 總數看不出這件事,而對使用者來說漏掉一句話比多一個「哎」嚴重。[ ] 是多出來的字,標點在計分前已剝除,所以它落在兩個子句的接縫):
| 段 | 標點切的顯示文字 |
|---|---|
long_000 | …只留車速跟導航其他的先收起來[嗨]晚上開車那些燈太亮了會分心… |
long_001 | …話提醒我把雨傘從後車廂拿出來[哎]順便查1下臺北101那邊的… |
long_003 | …今天下午會不會下雨如果會的話[啊]提醒我把雨傘從後車廂拿出來… |
long_005 | …避開還有把胎壓數據也顯示出來[哎]還有幫我導航到新店碧潭避開… |
merge_on_overlap 找的是文字重疊,而 哎 不是重複,
所以它走 fallback 的直接串接,原封不動上螢幕。真正的來源是切法:
每鎖定一個逗號,左緣就跳到那裡,下一個視窗只有約 0.5 秒而且開頭就是那個停頓。
直接量這件事(同一批音檔,從逗號起算不同長度的視窗,各 60 次):嗯×6 哦×4 啊×2 哎×1 嗨×1);
1.0 s → 3%; 2.0 s → 1%; 3.0 s → 0%。locked_text 不再重算。
不切每次重解整段,同一個贅字下一次就被自己改掉——這就是它插入 0.00 的原因。① 三種切法在準確度上分不出高下,但三種都比「不切」差。 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。)
數字說有多少錯,說不出是哪一種。下面是實際字串,每一個編輯都標出來。
long_080(11.8%,中位數 2.5%)、
90 秒 long_045(5.9%,中位數 3.3%)。所以這裡看到的是使用者可能看到的上限,
不能拿它代表典型狀況;典型值請看上面兩張表的平均。| 切法 | CER | 最後一次 interim |
|---|---|---|
| 參考(final) | — | 幫我播放podcastcast音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天崗之後幫我找1間可以坐比較久的咖啡廳有插座跟無線網路的那種另外把行事曆上中午12點那個會議往後延半小時然後傳 … |
| 不切(基準) | 0.0% | 幫我播放podcastcast音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天崗之後幫我找1間可以坐比較久的咖啡廳有插座跟無線網路的那種另外把行事曆上中午12點那個會議往後延半小時然後傳訊習 |
| 固定 3 s | 9.9% | 幫我播放podcastcatt音量稍微小1點等1下我要講電話的時候記得自動暫停還有幫我導航到淡水老街避開有收費的路段如果路上塞車就重新規劃1條我不趕時間但不想1直停停走走再來等1下到陽明山晴天港之後幫我找1間可以做比較久的咖啡廳有插座跟無線網絡的那種另外把行勢力立上中午12點那個會議往後延半小時然後傳訊系統 |
| 固定 8 s | 7.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點那個會議往後延半小時然後傳訊系統 |
| 切法 | 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 s | 3.9% | 查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航射到最近那1個接着後趙靜幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我媽跟他說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 … |
| 固定 8 s | 3.7% | 查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航設到最近那1個接着後趙靜幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候記得自動暫停接着打電話給我媽跟他說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 … |
| 標點切 | 5.9% | 查1下這附近哪裏有充電站要快充的順便看還剩幾個空位導航射到最近那1個接着後照近幫我往下掉1點副駕的車窗降到1半就好車內循環先關掉要換成外氣順便車內溫度調到23度風量開2格後排右邊的座椅通風也打開然後幫我播放ipcast音量稍微小1點等1下我要講電話的時候後記得自動暫停接着打電話給我媽跟她說說我大概晚上9點會到路上如果有狀況我再回撥接着幫我導航到 … |
導航設到→導航射到、老哥→老歌 在四列都一樣,與切法無關。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%, 兩者缺一不可、各治一種錯誤分型)。
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 退化,且那些字是語助詞與句首英文殘字、永久不可修——見下方判定 |
三種切法之間分不出高下,但三種都比不切差——而那才是判定的依據。 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%);句首的英文詞會被鎖成殘字(podcastcast → pt)。
鎖定不可逆,所以這些字永久留在畫面上;不切每次重解整段,同樣的贅字下一次就被自己改掉。
固定視窗則是反過來——漏字 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 吃緊而必須重開這個方向,標點切仍是實作代價最低的入口。
本節有兩個用途,都與已採用的 #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)。
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 單獨 | 0 | 531 ms | 0 | 3554 ms | 整段 |
| 固定 3 s 視窗 | 60 每次 interim | 945 ms | 180 每次 interim | 3217 ms | 最後 3 秒 |
| 標點切 + #3 | 14 每個逗號 | 232 ms | 43 每個逗號 | 511 ms | 上一個逗號之後 |
experiments/prep_coexist/sim_loop_k8s.json。一次 interim 的 prep 做兩件事:把音訊餵進 fbank,以及在解碼前把已算好的幀取出來(get_frames())。
這兩件事的成長方式完全不同,三條線的差異全部由它們解釋。同一顆節點量的:
| 量的是什麼 | 餵入 | +取出幀 | 形狀 |
|---|---|---|---|
| 一次性餵入 3 秒 =固定 3 s 視窗每次 interim 做的事 |
87 ms | 90 ms | 視窗固定,所以不隨句長成長——但每次都要把整個 3 秒重抽一遍 |
| 增量餵入 0.52 秒 =#3 每次 interim 做的事 |
10–11 ms | 18 → 275 ms | 餵入不動(只有 0.52 秒),但取出幀隨累積長度成長——stream 裡的幀越多,複製越貴 |
experiments/prep_coexist/bench_prep_k8s.json(bench_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 單獨 | 180 | 0 | 0% | — | 18.73 ms |
| 固定 3 s 視窗 | 180 | 180 | 100% | 整個 3 秒視窗 | 13.68 ms |
| 標點切 + #3 | 180 | 43 | 24% | 上一個逗號到現在 (約一次增量餵入的量) | 2.42 ms |
get_frames() 每次要複製 stream 從第 0 幀到現在的全部幀,
那正是 #3 單獨爬到 18.7 ms 的原因;標點切付重建的錢,買掉的就是那條成長曲線。
固定 3 s 付一樣的錢卻什麼都沒買到——它的 get_frames() 本來就被 3 秒視窗綁住了。
run_interim_inference_incremental() 的 stream 生命週期本來就由呼叫端持有),不需要新的 C++ 能力。
所以「切法與 #3 互斥」只對固定長度成立——它的左緣每次 interim 都在動。
這一結論本輪沒有被採用(#4 不做),但它是重開 #4 時不必重做的功課:
標點切與 #3 可以共存,而且合起來比 #3 單獨還便宜。get_frames() 複製整條 stream,所以 90 秒句每次 interim 的 prep 是 18.73 ms 而不是常數。
本輪把 #3 延伸到 final 之所以有價值,正是因為 final 原本要重抽整段——
沿用之後只補餵尾巴,k8s 實測 11 秒句只餵 0.16 秒。
機制:VAD 宣告停止前的靜音期間伺服器是閒著的,而最後一次 interim 已解過幾乎全部音訊。若 VAD-stop 到達時新增的只有靜音,就直接用該結果走 HR → ITN,跳過 prep 與 decode。
| final 路徑階段 | 中位數 | p90 | #5 能省嗎 |
|---|---|---|---|
prep | 19.8 ms | 53.9 ms | 能 |
decode | 74.0 ms | 117.5 ms | 能 |
itn | 0.4 ms | 11.9 ms | 不能(但只有 0.4 ms,無所謂) |
| 合計 | 95.1 ms | 290.2 ms | 約省 94 ms |
上一輪估「317 → 約 150 ms」(省 167 ms),實測只有 95 ms——因為 #3 已經先把 prep 壓下去了,#5 的效益被自己人吃掉一塊。
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,複雜度不值得)。
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 / 28 | 11.3 ms | 1.00× |
| 0.3 s | 22 / 28 | 11.1 ms | 0.98× |
| 0.2 s | 27 / 28 | 10.8 ms | 0.96× |
| 0.1 s | 22 / 28 | 11.0 ms | 0.97× |
| 0 s | 21 / 28 | 10.9 ms | 0.96× |
好消息是那個 pad 保護的東西沒有失守:即使 pad=0,沒有任何一支音檔掉了第一個音素,所有「你好鴻華」都完整。差異幾乎全是標點(你好,鴻華 → 你好鴻華、逗號變句號),而喚醒比對本來就是逐字拼音、不看標點。甚至有一支是短 pad 反而更準(multiuser-10s:pad=0.5 少了「大」字,pad=0.3/0 都正確)。
但省不到東西:2–4%,落在雜訊內。而它換到的是一個安全邊際的消失,以及文字會隨 pad 長度擾動這件事本身(模型對前導靜音長度是敏感的)。
這是一個「量了才知道不值得」的候選,記錄下來免得下一輪再花時間。
num_threads 維持 2num_threads=2 是寫死在 batch_asr_manager.py 的預設值、不能從 config 調,所以從沒有人變動過它。兩個懷疑它的理由:部署節點只有 2 vCPU,而 onnxruntime 在解碼期間自旋等待(2026-08-18 §附錄 D.1),加上 prep 刻意放在共用鎖外面、四座位真的併發——這些加起來會嚴重超訂那台機器。用 taskset 把行程綁在 2 核上模擬該節點:
| num_threads | 單人 3 s | 單人 10 s | 3 個 prep 並行時的 decode | vs 無競爭 |
|---|---|---|---|---|
| 1 | 9.9 ms | 13.2 ms | 29.5 ms | 2.23× |
| 2(現況) | 9.2 ms | 13.2 ms | 30.2 ms | 2.28× |
| 4 | 9.4 ms | 13.2 ms | 31.5 ms | 2.29× |
三個值的差異都在 7% 以內,判為無差別。原因清楚:decode 跑在 GPU 上,ORT 的 intra-op 執行緒不是瓶頸,所以調它幾乎沒有作用。
三種設定下 decode 都被競爭拖慢 2.23–2.29×——競爭是真的,只是不是來自 ORT 執行緒,而是 prep 的 fbank 在搶那 2 核。這把「怎麼解」指向了兩個方向:讓 prep 變便宜(=#3,已完成,見下方 §3.3.1 的追加量測)或給 pod 更多 CPU(=M2,依先前指示暫緩)。調執行緒數不在其中。
三個值差異都在雜訊內,改了也拿不到東西——競爭的根因是 prep 搶 CPU,不是執行緒數設少了。
要解決的是搶鎖排隊,不是解碼速度。四個座位共用一個辨識器鎖,interim 同時到期時它們被一個一個服務——
第四個座位得等前面三次解碼跑完,自己才開始。sherpa-onnx 的 decode_streams() 可以把多條 stream 折成一次呼叫,
所以搶到鎖的人可以把已經排在後面的人一起解掉。鎖沒有變多,變的是一次拿鎖做多少事。
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 成長的成分。
decode_stream 對 decode_streamsM5 換掉的就是這一次呼叫,所以先只看這一次呼叫:四個座位同時到期時,把它們全部解完要多久。
decode_stream 是今天的行為(各自拿鎖、各自解一次),decode_streams 是把四條折成一次。
只計時 decode——建 stream 與抽特徵在兩臂是相同的工作,放進計時器只會稀釋要量的比值。
decode_stream 對 decode_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×),因為那張卡較弱、補齊的浪費相對更貴。DecodeStreams 開頭就是 if (n == 1) DecodeOneStream(...),兩者是同一條程式碼路徑,
必須讀到 1.00×;沒讀到就代表暖機沒做對(曾經量到 2.27×,全是 shape 首次呼叫的建置成本)。decode_streams 把每條 stream 補齊到最長那條。
批次解碼要求一個矩形張量,所以四條長度不同的 stream 會被右側補零到最長者的長度
(offline-recognizer-sense-voice-impl.h 的 PadSequence(..., 0))。
編碼器接著對補齊後的整個矩形做運算——補進去的那些零一樣要跑完所有層。
所以批次省下的是「四次 kernel launch 與四次記憶體往返」,付出的是「補齊出來的無用計算」,
兩者的相對大小由 buffer 長度與長度差異決定:
| 四座位的 buffer 組合 | 逐一解 | 批次解 | 加速 | 補齊比 | 為什麼 |
|---|---|---|---|---|---|
| 0.8 / 1.2 / 1.6 / 2.0 s 標點切之後的常態 |
59.8 ms | 10.8 ms | 5.55× | 1.43× | 四條都短、長度又接近——補齊浪費最小,這是量到的最佳情況 |
| 1 / 2 / 3 / 5 s | 64.2 | 16.4 | 3.91× | 1.82× | 仍短,但最長者是最短者的 5 倍 |
| 1 / 3 / 6 / 10 s | 66.6 | 26.0 | 2.57× | 2.00× | 補齊比升高,加速跟著掉 |
| 2 / 10 / 15 / 20 s | 79.3 | 48.0 | 1.65× | 1.70× | 補齊比不高,但絕對長度大——補進去的零本身就很貴 |
| 60 / 60 / 60 / 60 s 沒有切法的長句 | 185.3 | 192.1 | 0.96× | 1.00× | 完全沒有補齊浪費,批次仍然較慢——省下的 launch 成本相對於 60 秒的運算量已經可以忽略 |
| 1 個座位、10 秒 buffer | 時間 | 說明 |
|---|---|---|
decode_stream | 45.1 ms |
沒有差別,而且必然如此。DecodeStreams 開頭就是
if (n == 1) DecodeOneStream(...)——單一 stream 時兩者是同一條程式碼路徑。
服務端也不會走到批次:GroupDecoder 的 _pending 只有自己時
len(streams)==1,直接走 decode_one。
所以單人開不開 M5 逐 bit 相同,不付任何代價,這也是它可以預設開啟的理由 |
decode_streams | 45.3 ms 0.99× |
先說量在哪:以下全部是本機 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 秒點外推) |
total latency 1.79×(B 節);M5 單獨在長句上是 0.91–0.96×,也就是倒賠。
兩個一起做,M5 才進到 3.34–5.55× 的區間——因為 #4 同時把「絕對長度」和「長度差異」兩個決定因素都壓下去了。_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.py 的 args={"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.8 | 22.6 | 30.6 | 38.3 | 38.3 | 2.20× |
decode_streams | 17.4 | 17.4 | 17.4 | 17.4 | 17.4 | ||
| 2 s | 逐一排隊 | 15.4 | 24.0 | 32.4 | 40.8 | 40.8 | 2.05× |
decode_streams | 19.9 | 19.9 | 19.9 | 19.9 | 19.9 | ||
| 3 s | 逐一排隊 | 16.9 | 26.5 | 36.2 | 45.8 | 45.8 | 2.08× |
decode_streams | 22.0 | 22.0 | 22.0 | 22.0 | 22.0 | ||
| 5 s | 逐一排隊 | 17.6 | 28.3 | 39.1 | 49.6 | 49.6 | 1.64× |
decode_streams | 30.2 | 30.2 | 30.2 | 30.2 | 30.2 | ||
| 10 s | 逐一排隊 | 20.0 | 34.2 | 48.3 | 62.4 | 62.4 | 1.65× |
decode_streams | 37.8 | 37.8 | 37.8 | 37.8 | 37.8 | ||
decode_streams 之後四個座位都在 19.9 ms 拿到,因為它們是同一次呼叫解出來的。
if len(streams) == 1 直接 decode_one),
位元級等同今天的行為,不多一次等待、不多一個分支開銷。
batch_size 已寫進 trace,下一輪要先看那個數字的分布,再談任何加速倍率。機制本身是對的、實作也是對的:批次結果與逐一解 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.py、
tests/test_group_decode.py、features.batch_decode 設定項)。
重開的條件很明確:只要有任何機制把送去解碼的音訊壓到 10 秒以內,它立刻重新有價值——
不必是 #4,也可以是一個「buffer 超過 N 秒就不批次」的長度閘(那會讓它從負變中性,但也就只是中性)。
SW-PERF-06,5 條)。旗標 features.batch_decode 設為 false。batch_size 已寫進 trace,上線後先看那個數字的分布,再談加速倍率。ASR_BATCH_DECODE=0 可在不改設定檔、不重新部署的情況下當場關掉。八項候選全部裁決完畢(+兩個延伸方向)。#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%);句首英文詞會被鎖成殘字(podcastcast → pt);固定視窗則漏 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_pad、num_threads)量了都判定維持現況。M4/M6/#6/M7 判定不做——判準是使用者感覺到的凍結時間(相鄰 interim 最大間隔):更新密度變低且每次更新等更久就不做,會被讀成卡頓。四者的淨效果都是把凍結時間拉長(M6 是唯一有翻案空間的,但要量出淨縮短,本輪沒量)。見 §3.1。
本輪是這一系列優化的最後一輪,所以本節量的是整體:把 2026-07-29 的反應式排程、
2026-08-14 的 ITN 數字守門、2026-08-18 的 M1 背景解碼、以及本輪的 #3
全部關掉,對上全部打開。兩條線,其他什麼都不比。
| 代號=圖例用字 | 內容 |
|---|---|
| SenseVoice 優化前 | 固定 500 ms 排程(ASR_FIXED_INTERIM_MS=500)、無 M1 背景解碼、無 ITN 數字守門、無 #3
——本系列優化全部關閉的狀態 |
| SenseVoice 優化後 | 反應式排程 + M1 + ITN 守門 + #3(含延伸到 final 的特徵沿用)——本輪定案的組態。
#4/M5 判定不做且程式碼已從樹上移除,不在這條線裡 |
同一顆 T4、同一個 pod(kitt-stt-batch-test-c-reactive)、
同一個 image(optswitches2)、同一份網格(1/3/5/10/15/20 秒 × 1–4 人 = 24 格點)、
每格三輪取中位數、同一支 client。CPU request/limit 兩臂完全相同
(requests.cpu 1/limits.cpu 1800m)——只切換四個環境變數,rollout 之後重新量。
若兩臂連資源都不同,差異就同時來自資源和優化,哪一項都歸因不了。
量之前先驗證(CLAUDE.md §3.5):每一臂先跑一個格點,斷言
total latency/interim latency 欄位存在才開掃。優化前那一臂的 preflight 同時
證實固定排程真的生效——3 秒句吐 6 次 interim(500 ms 一次),優化後同一格是 5 次。
#3 自己的歸因在 §3.3.1
(同機交替、固定排程對照軸)。#3 的特徵逐 bit 相同,所以輸出文字不變。六個面板 = 六個句長,面板內的橫軸是座位數(1–4 人)。要看的是「座位堆上同一顆辨識器時會發生什麼」, 所以比較一律在面板內沿人數軸進行——面板裡除了人數,其他條件全部固定。 四張圖都是同一批資料:24 格點 × 兩臂 × 三輪取中位數。
M4/M6/#6/M7 的判準(§3.1),所以兩邊都要誠實看:
低負載時優化後略長(20 秒 × 1 人 3.49 → 5.59 s)——反應式排程按上次延遲拉開間隔;
飽和時優化後遠短(20 秒 × 3 人 30.29 → 6.14 s)。而優化前那兩個逾時格點根本沒有間隔可言,
因為一個字都沒出來。最差值:優化前 30.29 s,優化後 6.85 s。| 秒 | 人 | SenseVoice 優化前 | SenseVoice 優化後 | time to final 倍率 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 座位 | ttf (ms) | total (ms) | 凍結 (s) | RTF | 座位 | ttf (ms) | total (ms) | 凍結 (s) | RTF | |||
| 1 | 1 | 3/3 | 161 | 326 | 0.52 | 0.13 | 3/3 | 122 | 243 | 0.51 | 0.11 | 1.3× |
| 1 | 2 | 6/6 | 261 | 334 | 0.52 | 0.13 | 6/6 | 229 | 358 | 0.52 | 0.13 | 1.1× |
| 1 | 3 | 9/9 | 352 | 299 | 0.53 | 0.12 | 9/9 | 250 | 386 | 0.54 | 0.10 | 1.4× |
| 1 | 4 | 12/12 | 465 | 279 | 0.53 | 0.11 | 12/12 | 378 | 506 | 0.54 | 0.10 | 1.2× |
| 3 | 1 | 3/3 | 307 | 608 | 1.95 | 0.11 | 3/3 | 128 | 564 | 0.73 | 0.13 | 2.4× |
| 3 | 2 | 6/6 | 494 | 857 | 0.58 | 0.12 | 6/6 | 223 | 829 | 0.58 | 0.14 | 2.2× |
| 3 | 3 | 9/9 | 723 | 860 | 0.56 | 0.14 | 9/9 | 1,694 | 1,048 | 3.16 | 0.12 | 0.4× |
| 3 | 4 | 12/12 | 2,926 | 2,806 | 2.55 | 0.16 | 12/12 | 404 | 1,343 | 0.62 | 0.14 | 7.2× |
| 5 | 1 | 3/3 | 290 | 1,386 | 0.53 | 0.16 | 3/3 | 151 | 787 | 0.52 | 0.12 | 1.9× |
| 5 | 2 | 6/6 | 1,008 | 1,049 | 1.70 | 0.12 | 6/6 | 291 | 968 | 0.57 | 0.11 | 3.5× |
| 5 | 3 | 9/9 | 752 | 1,270 | 0.54 | 0.15 | 9/9 | 318 | 1,775 | 1.06 | 0.14 | 2.4× |
| 5 | 4 | 12/12 | 1,698 | 3,274 | 3.48 | 0.12 | 12/12 | 504 | 1,624 | 2.45 | 0.09 | 3.4× |
| 10 | 1 | 3/3 | 436 | 2,194 | 2.33 | 0.13 | 3/3 | 437 | 1,613 | 1.04 | 0.12 | 1.0× |
| 10 | 2 | 6/6 | 834 | 2,825 | 1.06 | 0.14 | 6/6 | 635 | 2,204 | 2.61 | 0.09 | 1.3× |
| 10 | 3 | 9/9 | 2,770 | 4,547 | 3.35 | 0.14 | 9/9 | 3,293 | 4,229 | 6.10 | 0.11 | 0.8× |
| 10 | 4 | 12/12 | 24,813 | 11,275 | 9.08 | 0.33 | 12/12 | 4,120 | 3,807 | 5.93 | 0.12 | 6.0× |
| 15 | 1 | 3/3 | 559 | 4,082 | 3.35 | 0.15 | 3/3 | 439 | 2,565 | 5.69 | 0.12 | 1.3× |
| 15 | 2 | 6/6 | 1,201 | 6,874 | 3.93 | 0.14 | 6/6 | 950 | 7,185 | 5.84 | 0.10 | 1.3× |
| 15 | 3 | 9/9 | 55,020 | 27,030 | 8.76 | 0.50 | 9/9 | 1,252 | 3,971 | 6.09 | 0.11 | 44.0× |
| 15 | 4 | 0/12 | — | — | — | — | 12/12 | 1,852 | 6,262 | 6.24 | 0.11 | 全滅→通過 |
| 20 | 1 | 3/3 | 753 | 5,482 | 3.49 | 0.14 | 3/3 | 481 | 3,352 | 5.59 | 0.14 | 1.6× |
| 20 | 2 | 6/6 | 3,954 | 8,931 | 3.96 | 0.15 | 6/6 | 5,261 | 3,959 | 6.02 | 0.13 | 0.8× |
| 20 | 3 | 9/9 | 240,372 | 89,738 | 30.29 | 1.62 | 9/9 | 1,515 | 8,802 | 6.14 | 0.13 | 158.6× |
| 20 | 4 | 0/12 | — | — | — | — | 12/12 | 5,688 | 10,854 | 6.85 | 0.12 | 全滅→通過 |
| 指標 | 結果 | 怎麼讀 |
|---|---|---|
| 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 | 最終的判準。優化前有兩個格點一個座位都沒完成,那不是慢,是功能失效 |
四項優化合計的效果,用一句話講:優化前在 24 個格點裡有 2 格完全失效、1 格 RTF 破 1.0; 優化後 24/24 全通過,RTF 全部 ≤ 0.14。單人短句(產品主流句長 1–2 秒)兩條線幾乎重疊, 這是預期的,也說明這些優化沒有把成本轉嫁到常見情境。
唯一的代價是低負載時的凍結時間略長(20 秒 × 1 人 3.49 → 5.59 s), 來自反應式排程。依 §3.1 的凍結時間判準,這一項值得在後續 cycle 追蹤—— 但它換到的是飽和區不崩潰,而崩潰時的凍結是無限長。
依 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-29/08-18 兩輪報告的多人表沒有這一欄,
要跨輪比對 total latency 仍需重跑那兩輪的格點 |
交棒加了 interim_in_flight 守衛(見下方 callout)之後,第一個該問的是
「它多常吃掉優化」。量了,不是推理——本機 4090 起 server,跑 test_wake 與 test_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 句。
修完游標順序後跑 live,test_wake_pause 與 test_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)。 「兩條路徑都在同一把鎖內」這種查證必須追到每一個寫入點,追到函式層級就停是不夠的—— 交棒確實不在那把鎖裡。
#3 的游標 bug(本輪已修:餵入失敗改為拋 IncrementalFeedError、游標只在餵入成功後推進、
半餵的 stream 丟棄重建;final 的 tail 餵入失敗改為退回從頭準備而不是丟掉 final;4 條新測試)、
live 全套、附錄 A 的架構圖與 Frame 流程圖、標點切的短句誤觸發量測、以及本輪的量測開關(已隨定案移除)。
#4/#5/M5/M4/M6/#6/M7
判定不做,所以它們不再是「未完項」——上表的相關列都是「若日後重開才需要」的前置條件。#3 的 stream 生命週期是本輪唯一改變資料流的東西,所以它的重置點必須列全:一條 stream
絕不可跨 utterance(fbank 的內部狀態會汙染下一句),所以每一個「buffer 被清空」或「新 utterance 開始」
的地方都要把它設回 None。
| 位置 | 行 | 為什麼要重置 |
|---|---|---|
_restart_stream_from_now | 674 | 手動喚醒中途按鍵:buffer 被重置,殘留 stream 必須丟棄 |
_handle_vad_user_started_speaking | 767 | 新 utterance 開始——上一句的 fbank 歷史絕不能帶進來 |
_finalize_utterance_impl(第一條 finalize 路徑) | 862 | 先交棒給 final,再設 None——交棒讓 final 沿用已抽好的 fbank,設 None 讓它不會跨到下一句 |
process_frame(第二條 finalize 路徑) | 1072 | 同上 |
_interim_redecode_body(IncrementalFeedError 復原) | 1342 | 餵入失敗、stream 內容不明:丟掉整個 stream 並歸零游標,下一次 interim 從整個 buffer 重抽—— 寧可多付一次 fbank,也不能帶著半餵的 stream 繼續用(見 §14) |
final_reuse_stream = incremental_stream
再 incremental_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))。src/kitt_stt_service.py 直接讀出
(experiments/frame_flow_fig.py),不是手打的。| Frame | 方向 | 觸發 case | 動作/條件 | 位置 |
|---|---|---|---|---|
StartFrame | IN | pipeline 啟動 | 建 session manager、載入 wake 設定、preload 模型 | :315 |
AudioRawFrame | IN | 每 20 ms 一塊 | utterance 進行中則 append 到 segment buffer,否則只進 preroll;接著決定要不要觸發 interim 重解 | :900 |
VADUserStartedSpeakingFrame | IN | VAD 判定開始說話 | 開 turn、重置 per-utterance 狀態(含把持續 stream 設回 None) | :679 |
VADUserStoppedSpeakingFrame | IN | VAD 判定停止說話 | turn 邊界:在同一把 buffer_lock 內快照音訊、清空 buffer、把 stream 交棒給本句 final,然後跑 final 與 TurnSense | :781 |
UserStarted/StoppedSpeakingFrame | IN | 上游非分區 VAD | 轉成對應的 zonal 事件由同一條路徑處理(kitt-core 基底類別) | :688 |
BotStartedSpeakingFrame | IN | TTS 開始播 | barge-in 仲裁所需的狀態 | :914 |
BotStoppedSpeakingFrame | IN | TTS 播完 | 同上,並重新武裝 idle 計時 | :947 |
InterruptionFrame | IN | 下游宣告 agent 開始/結束說話 | 更新喚醒狀態與 KWS 共用計時器(agent_active/agent_standby) | :1000 |
STTUpdateSettingsFrame | IN | 執行期改設定 | 套用 STT 設定差異並回報實際生效值 | :1189 |
EndFrame | IN | 連線收尾 | 結束未完成的 turn 並清理 session | :1040 |
ASRMetadataFrame | OUT | 連線建立時 + 每個 final | 本服務自訂 frame,帶模型資訊與延遲/RTF/total_inference_time_ms 等量測欄位 | :351 |
InterimTranscriptionFrame | OUT | 每次 interim 重解完成且文字有變 | 顯示文字(只過 s2t,不過完整 ITN) | :1379 |
TranscriptionFrame ★ | OUT | final 解碼完成且 _should_emit_final 通過 | 權威文字(HR → ITN 之後),並在 metadata["eot"] 帶 EOT 判定 | :1691 |
InterruptionFrame | OUT | 喚醒成功 → agent_active | 通知下游進入 active | :587 |
InterruptionFrame | OUT | 解除喚醒 / idle timeout / 離開詞 → agent_standby | 通知下游回 standby(三種 case 共用同一個 frame) | :492 |
TTSSpeakFrame | OUT | 純喚醒詞(wake-only) | 播固定招呼語,該 turn 以空的語意 stop 收掉 | :1812 |
TranscriptionFrame ★ 是唯一的權威文字載體——
EOT 判定也掛在它的 metadata["eot"] 上,而不是另外送 frame。
InterruptionFrame 出現三次:一次是收(下游宣告 agent 狀態),兩次是送
(agent_active 與 agent_standby,後者涵蓋解除喚醒/idle timeout/離開詞三種 case)。該 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 |
離線:uv run --group test pytest → 539 passed / 1 skipped / 0 failed
(數字取自 pytest --collect-only,非手記)。本輪自身新增 31 條,見上表。
全套跑完,前輪測試一條未停用。總數比前輪少,是因為本輪定案不採用的三個切法(標點切、固定切、批次解碼)
連同其測試檔一併移除,量測開關也隨定案移除——不是既有測試被停用。
live:39 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-06/SW-B-04/SW-B-09(「smart-turn 第二句句首不應觸發」族,
改由 SW-W-13/SW-B-10/SW-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.py/test_spec_traceability.py、本輪新增的 31 條、本輪移除未採用切法與量測開關的測試檔、
以及成本拆解改記 trace 的 tests/test_cost_trace.py 8 條——純觀測工具,依使用者裁定不掛 SW_id)。
每個數字都標了它的量測基準(哪一輪、哪一棵樹),不要跨輪相減。
每個 PRD Feature 是否有 STT SW_id 與測試。
| PRD F_id | Feature | 有 STT SW_id? | 測試 | 本輪結果 |
|---|---|---|---|---|
| F_1 | 多音區識別/控制 | ✗ 下游/OOS(權限·多區) | — | —(STT 提供 per-user_id hook) |
| F_2 | One-shot(喚醒詞+指令連說) | ✓ SW-W-03/04/05/08/09 | 5 | 5 / 5 ✓(本輪重跑確認) |
| F_3 | 聆聽等待智慧斷句 | ✓ SW-W-01/10/11/13 · SW-X-01/02 | 6 | 6 / 6 ✓(SW-W-06 已於 2026-08-17 由 SW-W-13 取代並修好,本輪重跑確認) |
| F_3.4c | 最大錄音時限自動停止 | ✗ MISSING(GAP-1,Cycle 1 起追蹤) | 無 | 仍未解決——本輪調查確認正確歸屬是 kitt-web-ui(backend/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(終止詞) | 1 | 1 / 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..R3 | 47 | 47 / 47 ✓(SW-B-04/SW-B-09 已由 SW-B-10/SW-B-11 取代並修好,SW-B-08 同批修好,本輪重跑確認)· Cycle 6 淨新增 13 全 ✓ |
| 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-08 | Active 句首喚醒詞過濾 | test_active::test_active_wake_filter | ✓ PASS |
| SW-W-09 | Active 句首喚醒詞 + smart-turn | test_active::test_active_smart_turn_wake_head | ✓ PASS |
| SW_id | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| SW-W-01 | 非喚醒不出文字、不往後送 | test_standby::test_standby_rejection | ✓ PASS |
| SW-W-10 | Active smart-turn 跨段黏合 | test_active::test_active_smart_turn | ✓ PASS |
| SW-W-11 | Active 第二句喚醒詞不過濾 | test_smart_turn_wake::test_active_smart_turn_wake_2nd | ✓ PASS |
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-01 | Timeout 15s 回 standby | test_timeout::test_timeout_deactivate | ✓ PASS |
| SW-X-02 | 重新喚醒後 timeout 重算 | test_timeout::test_timeout_reset | ✓ PASS |
| SW_id | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| SW-X-04 | 解除喚醒詞(退下/安靜/離開/暫停)→ 切 Standby | test_exit_word(offline, 3) | ✓ PASS |
| SW_id | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| SW-B-01 | Prefix 觸發整句往後送 | test_bypass_prefix::test_prefix_trigger | ✓ PASS |
| SW-B-02 | Prefix 不在句首不觸發 | test_bypass_prefix::test_prefix_not_at_start | ✓ PASS |
| SW-B-03 | Prefix + 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 仲裁修正一併修好,本輪重跑確認) |
2026-08-17 廢止,由 SW-B-10 取代 | Prefix 於第二句句首應觸發(行為由 SW-B-10 取代後反轉) | test_smart_turn_bypass::test_standby_smart_turn_prefix_2nd | ✓ PASS(測試已隨 SW-B-10 一併修好,本輪重跑確認) |
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 按鈕回 standby | test_btn_control::test_btn_deactivate | ✓ PASS |
| SW-UI-01 | WebUI 按鈕啟動喚醒 | 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 |
| 驗收 ID | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| A1 | 第一次 interim 無前次延遲 → 用 initial_interval | test_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–A5 | margin≤1 / 地板≤0 / 初始值≤0 → ValidationError | test_audio_cfg::test_safety_margin_must_be_greater_than_one / test_min_interval_must_be_positive / test_initial_interval_must_be_positive | ✓ PASS ×3 |
| A6 | ASR_INTERIM_SAFETY_MARGIN 覆寫 / 未設時吃 YAML | test_audio_cfg::test_env_var_overrides_margin / test_no_env_var_keeps_yaml_default | ✓ PASS ×2 |
| 驗收 ID | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| SW-PERF-01 | 連線無錯誤 | test_audio_stress::test_audio_stress_no_errors | ✓ PASS |
| SW-PERF-01 | 90 秒連續長句仍收到 final | test_audio_stress::test_audio_stress_final_received | ✓ PASS |
| SW-PERF-01 | time 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 |
| 驗收 ID | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| B1 | interim 只套用 s2t(),不做數字正規化 | test_batch_asr_manager_itn_split::test_interim_inference_only_applies_s2t_not_numeral_normalization | ✓ PASS |
| B2 | final 仍套用完整 itn()(s2t + 數字),不受影響 | test_batch_asr_manager_itn_split::test_offline_inference_still_applies_full_itn | ✓ PASS |
| 驗收 ID | 行為 | 測試(file::test) | 結果 |
|---|---|---|---|
| B14-R1 | RTF 只計 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_task | test_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 |
| F_id / SW_id | Feature / 行為 | 歸屬 |
|---|---|---|
| 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) |
本附錄沿用 2026-08-18 那一輪(覆蓋檢查是累積的),所以列內的「§n」節號指的是該輪報告的節號,不是本報告的。本輪自己的追溯在 §3 各小節與附錄 B。
| ID | 驗收條件 | 分類 | 追溯 |
|---|---|---|---|
| C1 | 可重複執行的多使用者併發量測工具(單連線 N 個 user_id 完全重疊) | COVERED | tests/batch_decode/concurrency_profile.py;smoke run 2/2 ok |
| C2 | 每個 interim/final 正確歸屬到發話的 user_id | COVERED | 3 條離線測試(見附錄 B) |
| C3 | 網格點之間正確重置 per-user 狀態 | COVERED | ::test_reset_clears_all_user_state |
| C4 | 主動偵測並標記跨輪污染 | COVERED | 2 條離線測試;實測 0 contaminated / 44 筆 |
| C5 | 固定 500ms 在 1–4 人 × 1/3/5/10 秒的實測數據 | COVERED | 2026-08-18 報告 §5(本表沿用該輪,節號指向那一份);原始資料 tests/batch_decode/results/concurrency_fixed500ms_t4_20260805*/(.gitignore 排除、未進版控) |
| C6 | 使用 web-ui 真實座位 user_id、人數上限為座位數 | COVERED | 2 條離線測試(含 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 秒句:四座皆收到 final、user_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 + STT) | OUT_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 秒 |