本文是 事件驱动的 ASR → LLM → ES 回写:设计拆解与实践 系列的第一篇,重点讲解 ASR 结果的清洗与归一化设计。

学习一个语音处理系统时,最容易被忽略的一步不是”如何识别”,而是”识别之后怎么继续处理”。

如果把 ASR 理解成”把声音翻译成文字”,那后处理就是”把翻译稿整理成可以继续分析的材料”。真正决定后续分析质量的,往往不是识别引擎本身,而是这一段整理逻辑。

这篇文章重点回答三个问题:

  1. 为什么原始 ASR 结果不能直接往下走
  2. 应该怎样对结果做清洗和归一化
  3. 为什么扩展点是处理这类问题的合适方式

为什么不能把原始结果直接交给后续流程

第一次接触 ASR 时,很容易产生一个直觉:只要拿到转写文本,后面的分析应该就能直接做了。实际上,工程里很少能这么简单。

原始识别结果通常会带着这些问题:

  • 分句粒度不稳定,有时太碎,有时太长
  • 语义连续但时间上被拆开,阅读起来不自然
  • 噪声词、提示音、播报词混在正文中
  • 不同场景对”保留/删除”的规则不一样
  • 后续分析可能需要的元信息还没有整理好

所以更合理的做法是把流程拆成两段:

  1. 识别器负责把音频转成结构化文本
  2. 结果整理器负责把文本整理成后续分析能直接消费的形式

这样拆开以后,识别引擎和业务规则就不会绑在一起。识别器只关心”识别准不准”,后处理只关心”结果好不好用”。

结果归一化到底在做什么

所谓归一化,不是为了”格式统一”这么简单,而是为了让后续处理稳定。

常见动作可以分成四类:

结构归一化

把分散的句子整理成更稳定的结构,例如:

  • 把时间连续的句子合并
  • 把断开的语义单元补完整
  • 把不规则的分段重新整理成更一致的粒度

噪声归一化

把明显不适合下游消费的内容处理掉,例如:

  • 自动播报内容
  • 无意义重复词
  • 场景中已知的干扰词
  • 特定设备或场景产生的固定噪声模式

元信息归一化

后续分析常常不只看文字,还要看:

  • 时间戳
  • 句子顺序
  • 说话人信息
  • 原始片段来源

这些信息如果不整理好,后面做摘要、场景识别或者字段提取时就很容易丢上下文。

业务规则归一化

不同场景对结果的容忍度不同。有的场景希望保留完整内容,有的场景希望剔除某些特殊片段。归一化层是最适合放这些规则的地方。

例如两个句子结束和开始时间刚好相连时,可以合并成一个更完整的语义单元。这样做的好处是后续的摘要、场景识别和字段提取都更容易理解上下文。

扩展点为什么特别适合做这件事

如果每一种特殊场景都写死在主流程里,代码会很快变得难以维护。更好的方式,是把”结果处理”做成扩展点。

扩展点的价值在于:

  • 主流程稳定不变
  • 特殊场景可以单独扩展
  • 不同来源可以走不同处理策略
  • 新规则上线时,不必改动识别核心链路

你可以把它理解成一个插槽:

  • 主流程负责调度
  • 扩展点负责定制化处理

这样以后如果某个场景需要过滤特定词、保留特殊标记、删除某类句子,或者按设备类型做差异化处理,都可以放在扩展点里完成。

更重要的是,扩展点天然适合做两件事:

  1. 局部替换:不同场景挂不同实现
  2. 局部增强:在不改主流程的情况下增加清洗规则

一个更舒服的分层方式

如果把这一层设计好,整个系统会变得很轻。

你可以把它拆成三层理解:

第一层:识别层

只负责把音频变成文本,不关心业务语义。

第二层:整理层

负责句子合并、噪声清除、格式规整、元信息修补。

第三层:消费层

负责摘要、场景识别、字段提取等真正的业务分析。

这三层分开以后,每一层都更容易测试,也更容易替换。最明显的好处是:当你调整某个清洗规则时,不需要重写识别器,也不会影响后续分析主流程。

伪代码:ASR 结果处理流水线

下面用伪代码把整条流程串起来,帮助你把概念落到步骤上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function handleAsrResult(audioBytes):
rawResult = speechRecognizer.recognize(audioBytes)
if rawResult is empty:
return empty

sentences = rawResult.sentences
normalized = []

for sentence in sentences:
if sentence is noise:
continue

if shouldMerge(normalized.last, sentence):
normalized.last.text = mergeText(normalized.last.text, sentence.text)
normalized.last.endTime = sentence.endTime
else:
normalized.add(copy(sentence))

for processor in postProcessors:
normalized = processor.apply(normalized)

return normalized

这段伪代码里有几个很值得注意的点:

  • 识别器输出的是原始结果
  • 清洗步骤先做基础过滤,再做合并
  • 扩展点处理器可以串联多个
  • 最终结果是给后续分析使用的标准输入

边界情况怎么想

学习这类设计时,不要只看理想路径,还要想边界情况:

  • 识别结果为空时怎么办
  • 某条句子文本为空时怎么办
  • 规则过于激进导致有效内容被删掉怎么办
  • 合并句子时如何避免把句号、停顿符处理错
  • 后处理器之间顺序不同会不会影响结果

这些问题都说明:后处理不是一个顺手加工的环节,而是整个链路的稳定器。

这一篇最该带走的理解

你可以把这一段设计记成一句话:

识别负责把声音变成文字,后处理负责把文字整理成能继续分析的材料,扩展点负责让不同场景各取所需。

下一篇继续看:整理好的结果是怎样进入分析链路的,以及多个阶段如何按顺序执行。详见 把 LLM 放进分析链路:阶段编排与事件触发