ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

# 流式 ASR 重构实战:从 3 秒延迟到亚秒级增量解码(第 3 篇 · 核心篇)

# 流式 ASR 重构实战:从 3 秒延迟到亚秒级增量解码(第 3 篇 · 核心篇) 纯语音转文字系列第 1 篇系统设计与四层流水线第 2 篇音频采集 语音活动检测第 3 篇流式 ASR 重构本篇 · 系列核心第 4 篇PySide6 悬浮字幕窗第 5 篇工程化与打包发布开篇一桩延迟 3 秒的悬案我们的实时字幕系统有一个致命问题说完整句话要等 3 秒才出字幕。做字幕的人都知道观众对延迟的容忍度极低——新闻直播的字幕几乎是话音刚落就上屏而我们的系统说话人已经说完一句话了屏幕上还是一片空白。经过排查我找到了三个元凶O(n²) 的重复计算—— 每 2 秒把之前所有音频重新识别一遍一句 30 秒的话实际处理了 240 秒的音频量模型思考开销—— 推理模型每次输出前先想几百毫秒白白浪费时间transformers 框架的固定开销—— 每次推理都有显存分配和动态 shape 的代价短音频也付全价更气人的是传了热词却不生效——专业术语照样识别错。这篇博客就是破案报告怎么把 3 秒延迟打到亚秒级怎么让 O(n²) 变成 O(n)怎么让热词真正起作用。一、旧方案为什么慢三个元凶逐一拆解元凶 1主犯O(n²) 全量重跑 —— 做了 8 倍的无用功旧方案用 Qwen3-ASR-0.6B为了实现边说边出字采用了一个看似合理的策略每累积 2 秒音频就对全部已累积音频重跑一次 ASR。t2s 识别 [0-2s] ← 第 1 次推理 t4s 识别 [0-4s] ← 前 2s 又识别了一遍第 2 次 t6s 识别 [0-6s] ← 前 4s 又识别了一遍第 3 次 t8s 识别 [0-8s] ← 前 6s 又识别了一遍第 4 次 ... t30s 识别 [0-30s] ← 第 15 次推理算一笔账一句 30 秒的话中间识别依次跑 2s、4s、6s…30s共 15 次推理总处理量 246…30 240 秒音频。而实际需要处理的只有 30 秒——做了 8 倍的无用功。这就是O(n²)的代价随着说话时间增长每次推理越来越慢直到单次推理时间超过 2 秒的累积周期GPU 开始排队积压字幕越追越慢。我们之前做了 15 秒强制切段只是压住了单次推理的上限并没有解决重复计算本身。元凶 2帮凶reasoning token —— 模型在思考Qwen3-ASR 是带推理thinking能力的模型。未显式关闭时每次推理在正式输出文字之前先生成一段思考 token——模型在想这段话说了什么然后才写出识别结果。这几百毫秒到几秒的思考时间在实时字幕场景下完全不可接受。元凶 3帮凶transformersgenerate()的固定开销即使用 transformers 的generate()做推理每次调用也有动态 shape 导致的图编译开销显存分配与回收无 KV cache 复用即使只处理 2 秒短音频也要付这个起步价。附带问题热词context参数形同虚设Qwen3-ASR 的 hotwords 支持是在vLLM 后端实现的。在 transformers 模式下context参数行为不完整甚至不一致——传了热词但可能根本没进解码器专业术语自然识别差。二、重构方案从 O(n²) 到 O(n) 的增量解码2.1 核心思想不再全量重跑而是逐帧增量这是整个重构最大的算法变化。旧方案O(n²)每次拿到完整音频片段从头推理整段内容。新方案O(n)每收到 32ms 音频帧喂入流式解码器解码器内部维护状态只做增量计算吐出新增的文字。旧方案全量重跑: t2s → [0-2s 音频] → 整段推理 → 你好 t4s → [0-4s 音频] → 整段推理 → 你好现在可以 ← 前2s重算了 t6s → [0-6s 音频] → 整段推理 → 你好现在可以听到我 ← 前4s又重算了 计算量: 246 12s实际只有6s音频 新方案增量解码: 每32ms → 32ms帧 → 喂入 → 状态更新 → 增量吐字 每32ms → 32ms帧 → 喂入 → 状态更新 → 增量吐字 ... 计算量: 严格等于音频时长零重复从 O(n²) 到 O(n)这不是优化是换了算法范式。2.2 为什么流式 Zipformer 能做到流式 Zipformer 基于transducerRNN-T架构解码器内部维护一个隐藏状态encoder state predictor state。每收到新帧只需基于当前状态做增量计算不需要重新处理历史音频。这就像旧方案 每写一段话把整篇文章重新读一遍再续写新方案 记住上文接着往下写2.3 模型选择使用 sherpa-onnx 官方流式模型streaming-zipformer-bilingual-zh-en中英双语models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/ ├── encoder-epoch-99-avg-1.onnx 330 MB # 编码器提取音频特征 ├── decoder-epoch-99-avg-1.onnx 13.9 MB # 解码器维护预测状态 ├── joiner-epoch-99-avg-1.onnx 12.8 MB # 联合器融合编码与预测 └── tokens.txt 56 KB # BPE 词表5755 个 CJK 单字相比 Qwen3-ASR-0.6B约 1.5GB需 GPU它更小356MB、CPU 即可实时推理、不占显存。三、核心代码StreamingASREngineclassStreamingASREngine:流式 ASR 引擎基于 sherpa-onnx 的增量解码。 与旧方案的核心区别不再每次对全量音频做推理 而是每收到 32ms 音频帧就喂入解码器增量吐字。 def_load_model(self):加载流式 Zipformer 模型配置增量解码参数。importsherpa_onnx self._recognizersherpa_onnx.OnlineRecognizer.from_transducer(tokenstokens,encoderencoder,decoderdecoder,joinerjoiner,num_threads2,# CPU 线程数2 线程即可实时sample_rate16000,# 16kHz 采样率feature_dim80,# 80 维 Fbank 特征enable_endpoint_detectionFalse,# 分句交给上游 VAD这里不重复做hotwords_fileself._hotwords_fileor,# 热词文件路径hotwords_score1.5,# 热词偏置强度越高越倾向识别热词decoding_methodmodified_beam_search,# 热词必须配 beam searchmax_active_paths4,# beam 保留 4 条候选路径)defcreate_stream(self):为每个语音段创建独立的解码流。 每个 stream 维护自己的解码状态互不干扰。 returnself._recognizer.create_stream()defaccept_waveform(self,stream,samples):喂入 32ms 音频帧float32, 16kHz。 这是增量解码的输入端每收到一帧就喂入 解码器内部状态会据此更新。 stream.accept_waveform(16000,samples.astype(np.float32))defdecode_and_get(self,stream):增量解码处理所有待处理帧返回当前部分结果。 注意一次 decode_stream 只处理部分帧 必须循环到 is_ready 为 False否则结果为空。 whileself._recognizer.is_ready(stream):self._recognizer.decode_stream(stream)returnself._recognizer.get_result(stream).strip()deffinish(self,stream):语音段结束最后一次解码返回完整文本。 当 VAD 检测到语音段结束时调用 确保所有剩余帧都被处理。 whileself._recognizer.is_ready(stream):self._recognizer.decode_stream(stream)returnself._recognizer.get_result(stream).strip()踩过的坑代码注释里没写的while is_ready: decode_stream循环不能省一次decode_stream只处理部分帧。如果不调循环结果为空你会以为模型没识别出来。热词要求modified_beam_search传了hotwords_file却用默认的greedy_searchsherpa-onnx 会直接抛异常——greedy 没有多路径比较热词偏置无从谈起。API 版本差异sherpa-onnx 1.13 的from_transducer是平铺参数encoder...老版本是transducerOnlineTransducerModelConfig(...)。升级时注意否则直接报参数错误。四、与 VAD 的衔接Pipeline 改造流式 ASR 不是独立工作的它需要和 VAD语音活动检测紧密配合。VAD 负责判断什么时候在说话ASR 负责把语音变成文字。def_handle_streaming_chunk(self,channel,chunk,is_speech,segment):处理 VAD 传来的每个音频块。 参数: channel: 音频通道标识 chunk: 32ms 音频数据numpy array is_speech: VAD 判断当前是否在说话 segment: VAD 检测到的语音段结束标记非 None 表示本段结束 streamself._streaming_streams.get(channel)# ---- 语音段结束喂入尾块并收尾得到最终文本 ----ifsegmentisnotNone:ifstreamisnotNone:self._asr_engine.accept_waveform(stream,chunk)# 喂入最后一块final_textself._asr_engine.finish(stream)# 收尾解码self._streaming_streams.pop(channel,None)# 清理流iffinal_text:self._handle_asr_text(final_text,channel,latency)# 推送终稿return# ---- 语音进行中喂入 定期增量解码中间字幕 ----ifis_speechorvad.is_speaking:# 如果是这段语音的第一块创建新的解码流ifstreamisNone:streamself._asr_engine.create_stream()self._streaming_streams[channel]stream# 喂入 32ms 音频帧self._asr_engine.accept_waveform(stream,chunk)# 每 0.8s 增量解码一次 → 更新中间字幕边说边出字ifnow-last_tself._provisional_interval:partialself._asr_engine.decode_and_get(stream)ifpartial!last_text:self.subtitle_provisional_updated.emit(partial)# 边说边出字这段代码的关键设计VAD 管分句ASR 管识别职责清晰不重复造轮子每 0.8 秒出一次中间结果观众看到的就是边说边出字的效果每个语音段独立 stream段与段之间状态隔离互不干扰五、性能对比重构前 vs 重构后实测条件7.7 秒音频你好现在可以听到我讲话吗内容识别正确。指标Qwen3-ASR旧流式 Zipformer新提升出字延迟2~3 秒等 2s 累积 推理~2 秒开始出字逐字增长体感质的飞跃计算复杂度O(n²)30s 句子 ≈ 240s 处理量O(n)30s ≈ 30s8 倍推理耗时0.5~3s / 段GPU876ms / 7.7s 音频CPU更快且不吃 GPU实时率 RTF受累积影响不稳定0.114远低于 1.0 实时处理速度-8.8× 实时绰绰有余推理设备GPU占显存CPU不占显存释放 GPU 给其他任务热词支持transformers 下不生效原生 hotwords 偏置专业术语终于对了RTFReal-Time Factor 处理时间 / 音频时长。RTF 1 意味着处理速度比实时快。0.114 意味着处理 1 秒音频只需要 0.114 秒还有 8.8 倍的余量。边说边出字实测这是最直观的体验验证——把 7.7 秒音频分帧喂入观察每个时间点的输出喂入至 1.1s: 尚无结果 —— 还在积累上下文 喂入至 2.1s: 你好 ← 2 秒出字 喂入至 3.1s: 你好现在可以听到我 ← 逐字增长 喂入至 4.1s: 你好现在可以听到我讲话吗 ← 完整识别对比旧方案说完 4 秒话要再等 2~3 秒才看到第一个字。现在2 秒就出字之后越说越跟手。六、热词偏置踩坑记传了热词却不生效6.1 问题重构后我信心满满地把热词列表传进去结果测试发现——专业术语还是识别错。传了等于没传6.2 原理热词是怎么工作的sherpa-onnx 的热词机制把热词文本编码成 token 序列在 beam search 解码时提高这些 token 路径的得分引导模型偏向识别它们。打个比方beam search 同时保留多条候选文字路径热词偏置就像给包含热词的路径加分让它们在竞争中更容易胜出。前提条件必须使用modified_beam_search解码——greedy search 只保留一条路径热词没有竞争机会。6.3 踩坑词表过滤我传了 53 个热词包括MCP、RAG、Transformer等英文缩写。结果 sherpa-onnx 启动时疯狂打印警告Cannot find ID for token MCP Cannot find ID for token RAG ...原因模型词表是中文单字级 BPE5755 个 CJK 单字英文整词根本不在词表里。传进去的热词如果不在词表中模型无法编码直接跳过。6.4 解决方案加载词表过滤不匹配的热词def_filter_hotwords(self,hotwords,token_set):过滤热词只保留模型词表中能编码的词。 规则 - 中文部分逐字检查是否在词表中 - 英文部分整词检查含或不含前缀 ▁ 实测53 个热词 → 17 个有效纯中文保留英文缩写被过滤 警告彻底消除。 filtered[]forwinhotwords:importre english_segsre.findall(r[A-Za-z0-9],w)# 提取英文段chinese_partsre.split(r[A-Za-z0-9],w)# 提取中文段okTrue# 英文段整词匹配BPE 词表中可能有 ▁prefix 形式forseginenglish_segs:ifsegnotintoken_setand(▁seg)notintoken_set:okFalse;break# 中文段逐字匹配每个字都要在词表中forpartinchinese_parts:ifpartandnotall(chintoken_setforchinpart):okFalse;breakifok:filtered.append(w)returnfiltered过滤后效果53 个热词 →17 个有效纯中文热词全部保留英文缩写被安全过滤Cannot find ID警告彻底消除中文专业术语如向量数据库“大模型”识别准确率明显提升6.5 热词文件配置# hotwords.txt —— 每行一个热词向量数据库 大模型 RAG检索增强生成...# 引擎加载时传入热词文件self._recognizerOnlineRecognizer.from_transducer(...,hotwords_filemodels/hotwords_sherpa.txt,# 过滤后的热词文件hotwords_score1.5,# 偏置强度1.0~2.0 之间调节decoding_methodmodified_beam_search,# 热词必须配 beam searchmax_active_paths4,# beam 宽度)hotwords_score调参建议1.0 无偏置1.5 中等偏置2.0 强偏置。太高会导致过度纠正把正常词也识别成热词建议从 1.5 开始试。七、小结回顾一下这次重构的核心收获问题根因解法出字慢3 秒延迟O(n²) 全量重跑流式增量解码 O(n)模型思考浪费时间reasoning token换用非推理模型短音频也付全价transformers 固定开销sherpa-onnxC 原生无 Python 开销热词不生效transformers 模式不支持sherpa-onnx 原生 hotwords 偏置GPU 显存被占大模型需 GPU356MB 小模型 CPU 即可实时一句话总结把每次从头算换成接着上次算O(n²) 变 O(n)3 秒变亚秒热词从摆设变利器。下一篇进入 UI 层PySide6 悬浮字幕窗 —— 透明置顶、点击穿透、双层字幕、屏幕共享隐身。本文代码与数据来自真实项目重构实战。
返回列表