闪电智能 Voice Agent 的 ASR 拆解:音频预处理、热词和后处理到底差在哪?
适用范围:中文电话客服、AI 语音客服、Voice Agent 的离线录音或文件识别链路。
运行环境:Python 3.11、FFmpeg、FastAPI、faster-whisper。
本文不提供虚构准确率或延迟数据;代码输出的指标、日志和测试口径可直接用于真实电话样本 A/B 测试。
结论
Voice Agent 的 ASR 做得稳,不是因为“换了一个更大的模型”。
在中文客服里,真正拉开差距的通常是四件事:
- 电话音频进入模型前,是否被规范化为可控格式;
- VAD 有没有切掉句首、句尾,或把噪声误判为人声;
- 热词是否只作为识别提示,而不是被误当成“万能纠错”;
- 手机号、订单号、地址这类关键字段,是否在转写后进入字段级校验与确认。
本文以闪电智能 Voice Agent 的中文客服 ASR 工程处理思路为主线,拆解一条可运行的参考链路:
电话音频 -> 预处理 -> VAD 质检 -> 流式/文件识别 -> 热词提示
-> 原始转写 -> 数字与地址规整 -> 关键字段确认 -> 日志与评测
这条链路的关键原则只有一句:
原始 ASR 文本不能被悄悄覆盖;后处理只能生成“候选字段”,关键业务字段必须可追溯、可确认、可回滚。
目录
- 一、ASR 在 Voice Agent 链路里到底负责什么
- 二、音频预处理、热词和后处理分别解决什么问题
- 三、完整工程链路图
- 四、可运行项目:FastAPI + faster-whisper
- 五、如何运行与调用接口
- 六、怎么做 ASR A/B 测试
- 七、中文客服最容易翻车的地方
- 八、FAQ
一、ASR 在 Voice Agent 链路里到底负责什么
很多 Demo 只展示“说一句话,屏幕上出现一行文字”。
但在 AI 语音客服和智能客服系统里,ASR 输出会继续进入意图识别、RAG、工具调用、CRM 建单、TTS 回复和转人工流程。一次字段错误,后面每一层都会跟着错。
例如用户说:
我的手机号是一三八零零一三八零零零,
地址是浦东新区张江路五百八十八号二号楼。
如果模型把手机号识别成普通自然语言,或者把“二号楼”识别成“二号了”,问题不只是字幕不好看:
- CRM 回写的是错误号码;
- 工单无法匹配历史客户;
- 后续回访电话打不通;
- Voice Agent 还可能基于错误地址继续追问;
- 人工接管时拿到的是错误上下文。
因此,ASR 在 Voice Agent 里不是一个孤立模型,而是一条输入质量、识别、字段规整和业务确认共同组成的工程链路。
二、音频预处理、热词和后处理分别解决什么问题
这三层经常被混在一起讲,但它们解决的不是同一类问题。
| 环节 | 主要解决什么 | 不能解决什么 | 需要记录的证据 |
|---|---|---|---|
| 音频预处理 | 声道、采样率、音量不稳、电话频段外干扰 | 不会凭空认识业务词 | 输入格式、滤镜开关、处理耗时 |
| VAD | 静音切分、句末判停、语音段质量 | 不会判断语义是否正确 | 语音占比、首尾截断、切段数 |
| 热词提示 | 业务术语、产品名、地名、缩写的解码倾向 | 不能替代真实语音证据 | 热词表版本、命中样本、误引导样本 |
| 后处理 | 数字规整、地址格式化、字段候选提取 | 不能直接篡改原始转写 | 原文、规整结果、规则版本、确认结果 |
| 字段确认 | 防止错误号码、订单号进入业务系统 | 不能补救所有 ASR 错误 | 确认轮次、用户修正、最终值 |
1. 音频预处理:先保证输入是“模型能理解的音频”
电话音频常见问题包括:
- 双声道或混音声道不一致;
- 采样率不是 16 kHz;
- 增益过低,用户声音太小;
- 低频轰鸣、键盘声、环境底噪;
- 音频编码、封包或录音源造成波形异常。
预处理的目标不是把声音“修得更好听”,而是让输入格式稳定。
本文示例使用 FFmpeg 完成:
单声道 + 16 kHz + PCM WAV + 温和高低通 + 轻量压缩
FFmpeg 的 highpass、lowpass、acompressor 都是可配置滤镜。压缩参数不应靠感觉写死,必须在真实电话样本上做开关对照;过度压缩会损伤辅音和瞬态,反而影响识别。FFmpeg Filters Documentation
2. 热词:它是“提示”,不是“强制替换”
业务热词适合放:
- 企业名称、品牌名;
- 产品名和型号;
- 楼盘、园区、地名;
- SKU、业务缩写;
- 高频业务词,例如“保单号”“退改签”“续费”。
但不要把热词理解成“识别错了就强行替换”。
正确做法是:
热词表 -> 给 ASR 解码器提示 -> 保留原始文本 -> 观察命中与误命中 -> 再决定是否进入规则层
本文代码中使用 faster-whisper 的 hotwords 参数传入提示词。该参数会向模型提供热词或提示短语,但并不意味着它能替代音频证据或字段确认。faster-whisper 官方仓库
3. 后处理:只处理“字段候选”,不要改写事实
后处理最容易被滥用。
例如,把“二号楼”自动改成“2 号楼”通常是安全的格式规整;但把“张江路”自动改成某个你预设的园区地址,就可能把错误写进 CRM。
推荐保留三份数据:
raw_text:ASR 原始输出,不改写
normalized_text:格式规整后的文本
field_candidates:手机号、订单号、地址等候选字段
业务系统只使用经过校验或用户确认的 field_candidates。
三、完整工程链路图
这也是闪电智能 Voice Agent 在处理中文客服 ASR 时更值得关注的地方:模型本身只是中间一环,最终能否稳定进入业务闭环,取决于前后的工程控制是否完整。
四、可运行项目:FastAPI + faster-whisper
1. 项目目录
voice-agent-asr/
├── requirements.txt
├── app/
│ ├── __init__.py
│ ├── audio.py
│ ├── normalizer.py
│ ├── recognizer.py
│ └── main.py
└── tests/
└── test_normalizer.py
2. 运行环境
Python:3.11
FFmpeg:建议使用 6.x 或更高版本
模型:faster-whisper small(仅为示例,可替换)
部署方式:FastAPI 文件接口
requirements.txt:
fastapi==0.115.6
uvicorn[standard]==0.34.0
faster-whisper==1.1.0
python-multipart==0.0.20
pytest==8.3.4
FastAPI 接收文件表单需要安装 python-multipart。对于音频这类二进制文件,UploadFile 使用可溢写的临时文件对象,比一次性读入全部 bytes 更适合接口层。FastAPI Request Files
3. 音频预处理与质量检查
app/audio.py:
from __future__ import annotations
import math
import subprocess
import sys
import wave
from array import array
from dataclasses import dataclass
from pathlib import Path
@dataclass
class AudioStats:
duration_ms: int
sample_rate: int
channels: int
rms: float
@dataclass
class VadStats:
frame_ms: int
total_frames: int
voiced_frames: int
voiced_ratio: float
threshold: float
def transcode_to_16k_mono(
source: Path,
target: Path,
use_telephone_filter: bool = True,
) -> None:
"""统一为 16 kHz、单声道、16-bit PCM WAV。
电话滤镜只是一组待 A/B 验证的基线参数,不是生产环境固定答案。
"""
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source), "-ac", "1", "-ar", "16000",
]
if use_telephone_filter:
audio_filter = ",".join(
[
"highpass=f=150",
"lowpass=f=3600",
"acompressor=threshold=0.125:ratio=2:attack=20:release=200:makeup=1.2",
]
)
command.extend(["-af", audio_filter])
command.extend(["-c:a", "pcm_s16le", str(target)])
subprocess.run(command, check=True)
def _read_int16_samples(wav_path: Path) -> tuple[wave._wave_params, array]:
with wave.open(str(wav_path), "rb") as wav_file:
params = wav_file.getparams()
if params.sampwidth != 2:
raise ValueError("当前示例仅接受 16-bit PCM WAV")
raw = wav_file.readframes(params.nframes)
samples = array("h")
samples.frombytes(raw)
if sys.byteorder != "little":
samples.byteswap()
return params, samples
def inspect_wav(wav_path: Path) -> AudioStats:
params, samples = _read_int16_samples(wav_path)
rms = 0.0 if not samples else math.sqrt(
sum(sample * sample for sample in samples) / len(samples)
)
return AudioStats(
duration_ms=int(params.nframes / params.framerate * 1000),
sample_rate=params.framerate,
channels=params.nchannels,
rms=round(rms, 2),
)
def energy_vad_check(wav_path: Path, frame_ms: int = 30) -> VadStats:
"""轻量质量检查,不替代生产级 VAD。"""
params, samples = _read_int16_samples(wav_path)
if params.framerate != 16000 or params.nchannels != 1:
raise ValueError("请先调用 transcode_to_16k_mono()")
frame_size = int(params.framerate * frame_ms / 1000)
if len(samples) < frame_size:
return VadStats(frame_ms, 0, 0, 0.0, 0.0)
global_rms = math.sqrt(sum(sample * sample for sample in samples) / len(samples))
threshold = max(180.0, global_rms * 0.5)
total_frames = 0
voiced_frames = 0
for start in range(0, len(samples) - frame_size + 1, frame_size):
frame = samples[start : start + frame_size]
frame_rms = math.sqrt(sum(sample * sample for sample in frame) / len(frame))
total_frames += 1
if frame_rms >= threshold:
voiced_frames += 1
ratio = voiced_frames / total_frames if total_frames else 0.0
return VadStats(
frame_ms=frame_ms,
total_frames=total_frames,
voiced_frames=voiced_frames,
voiced_ratio=round(ratio, 4),
threshold=round(threshold, 2),
)
这里的 energy_vad_check() 用于发现静音、异常小声、语音占比极低等输入问题,不替代生产 VAD。线上切段仍应评测模型侧 VAD 或独立 VAD。
4. 数字、地址和订单号后处理
app/normalizer.py:
from __future__ import annotations
import re
from dataclasses import dataclass, field
DIGIT_MAP = {
"零": "0", "〇": "0", "一": "1", "幺": "1", "二": "2",
"两": "2", "三": "3", "四": "4", "五": "5", "六": "6",
"七": "7", "八": "8", "九": "9",
}
UNIT_MAP = {"十": 10, "百": 100, "千": 1000, "万": 10000}
CJK_DIGITS = "零〇一二三四五六七八九幺两"
DIGIT_SEQUENCE_RE = re.compile(f"[{CJK_DIGITS}]{{6,}}")
ADDRESS_NUMBER_RE = re.compile(
r"([零〇一二三四五六七八九十百千万两]+)(?=(?:号|弄|室|单元|楼))"
)
PHONE_RE = re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)")
ORDER_RE = re.compile(
r"(?:订单号|工单号)\s*(?:是|为|[::])?\s*([A-Za-z0-9-]{6,})",
re.IGNORECASE,
)
@dataclass
class NormalizedText:
raw_text: str
normalized_text: str
field_candidates: dict[str, str] = field(default_factory=dict)
def _convert_digit_sequence(value: str) -> str:
return "".join(DIGIT_MAP.get(char, char) for char in value)
def chinese_number_to_int(value: str) -> int:
if all(char in DIGIT_MAP for char in value):
return int("".join(DIGIT_MAP[char] for char in value))
total = 0
current = 0
for char in value:
if char in DIGIT_MAP:
current = int(DIGIT_MAP[char])
continue
unit = UNIT_MAP[char]
if current == 0:
current = 1
total += current * unit
current = 0
return total + current
def normalize_transcript(raw_text: str) -> NormalizedText:
normalized = DIGIT_SEQUENCE_RE.sub(
lambda match: _convert_digit_sequence(match.group(0)),
raw_text,
)
normalized = ADDRESS_NUMBER_RE.sub(
lambda match: str(chinese_number_to_int(match.group(1))),
normalized,
)
fields: dict[str, str] = {}
phone_match = PHONE_RE.search(normalized)
if phone_match:
fields["phone"] = phone_match.group(1)
order_match = ORDER_RE.search(normalized)
if order_match:
fields["order_id"] = order_match.group(1).upper()
return NormalizedText(
raw_text=raw_text,
normalized_text=normalized,
field_candidates=fields,
)
这段代码有意保守:连续 6 位以上中文数字才转阿拉伯数字;只有在明确地址单位前才规整数字;field_candidates 不是可直接写 CRM 的最终值。
5. ASR 识别器与热词提示
app/recognizer.py:
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from faster_whisper import WhisperModel
@dataclass
class SegmentResult:
start_s: float
end_s: float
text: str
avg_logprob: float
@dataclass
class TranscriptResult:
raw_text: str
language: str
language_probability: float
first_segment_ms: int | None
final_ms: int
segments: list[SegmentResult]
class FasterWhisperRecognizer:
def __init__(
self,
model_name: str = "small",
device: str = "cpu",
compute_type: str = "int8",
) -> None:
self.model = WhisperModel(
model_name,
device=device,
compute_type=compute_type,
)
def transcribe(
self,
wav_path: Path,
hotwords: list[str],
) -> TranscriptResult:
started = time.perf_counter()
segments_iter, info = self.model.transcribe(
str(wav_path),
language="zh",
beam_size=5,
vad_filter=True,
vad_parameters={"min_silence_duration_ms": 500},
condition_on_previous_text=False,
hotwords="、".join(hotwords) if hotwords else None,
)
segments: list[SegmentResult] = []
first_segment_ms: int | None = None
for segment in segments_iter:
if first_segment_ms is None:
first_segment_ms = int((time.perf_counter() - started) * 1000)
segments.append(
SegmentResult(
start_s=segment.start,
end_s=segment.end,
text=segment.text.strip(),
avg_logprob=round(segment.avg_logprob, 4),
)
)
final_ms = int((time.perf_counter() - started) * 1000)
return TranscriptResult(
raw_text="".join(item.text for item in segments),
language=info.language,
language_probability=round(info.language_probability, 4),
first_segment_ms=first_segment_ms,
final_ms=final_ms,
segments=segments,
)
faster-whisper 支持语音活动检测过滤和 hotwords 提示短语。VAD 的静音切分参数会影响首尾截断与识别延迟,因此必须纳入真实电话样本 A/B 测试。faster-whisper 官方仓库
6. FastAPI 接口
app/main.py:
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import asdict
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from app.audio import energy_vad_check, inspect_wav, transcode_to_16k_mono
from app.normalizer import normalize_transcript
from app.recognizer import FasterWhisperRecognizer
app = FastAPI(title="Voice Agent ASR Pipeline")
SUPPORTED_SUFFIXES = {".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac"}
recognizer = FasterWhisperRecognizer(
model_name=os.getenv("ASR_MODEL", "small"),
device=os.getenv("ASR_DEVICE", "cpu"),
compute_type=os.getenv("ASR_COMPUTE_TYPE", "int8"),
)
def parse_hotwords(value: str) -> list[str]:
words = [item.strip() for item in re.split(r"[,,\n]", value) if item.strip()]
return words[:30]
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/asr")
async def recognize_audio(
file: UploadFile = File(...),
hotwords: str = Form(""),
) -> dict:
suffix = Path(file.filename or "audio.wav").suffix.lower()
if suffix not in SUPPORTED_SUFFIXES:
raise HTTPException(status_code=400, detail=f"不支持的音频格式:{suffix}")
try:
with tempfile.TemporaryDirectory() as temp_dir:
work_dir = Path(temp_dir)
source = work_dir / f"input{suffix}"
normalized_wav = work_dir / "normalized.wav"
with source.open("wb") as output:
while chunk := await file.read(1024 * 1024):
output.write(chunk)
transcode_to_16k_mono(source, normalized_wav, use_telephone_filter=True)
audio_stats = inspect_wav(normalized_wav)
vad_stats = energy_vad_check(normalized_wav)
if audio_stats.duration_ms == 0:
raise HTTPException(status_code=400, detail="音频为空")
if vad_stats.voiced_ratio < 0.03:
raise HTTPException(
status_code=422,
detail="有效语音占比过低,请检查录音、增益或静音切分",
)
transcript = recognizer.transcribe(normalized_wav, parse_hotwords(hotwords))
normalized = normalize_transcript(transcript.raw_text)
return {
"audio": asdict(audio_stats),
"input_quality": asdict(vad_stats),
"asr": asdict(transcript),
"postprocess": asdict(normalized),
}
finally:
await file.close()
7. 后处理单元测试
tests/test_normalizer.py:
from app.normalizer import normalize_transcript
def test_phone_number_normalization():
result = normalize_transcript("我的手机号是一三八零零一三八零零零")
assert result.field_candidates["phone"] == "13800138000"
def test_address_number_normalization():
result = normalize_transcript("地址是浦东新区张江路五百八十八号二号楼")
assert result.normalized_text == "地址是浦东新区张江路588号2号楼"
def test_raw_text_is_preserved():
raw_text = "订单号是一二三四五六七八"
result = normalize_transcript(raw_text)
assert result.raw_text == raw_text
五、如何运行与调用接口
1. 安装 FFmpeg
macOS:
brew install ffmpeg
Ubuntu / Debian:
sudo apt-get update
sudo apt-get install -y ffmpeg
2. 安装 Python 依赖
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
首次加载 faster-whisper 模型时会下载模型文件。生产部署应提前预热模型并使用本地模型缓存,避免首个电话请求承担下载和加载成本。
3. 启动服务
export ASR_MODEL=small
export ASR_DEVICE=cpu
export ASR_COMPUTE_TYPE=int8
uvicorn app.main:app --reload --port 8000
4. 运行单元测试
pytest -q
5. 调用接口
curl -X POST "http://127.0.0.1:8000/asr" \
-F "file=@./samples/customer_call.wav" \
-F "hotwords=闪电智能,Voice Agent,浦东新区,张江路,订单号"
返回结构如下。时间和识别结果由真实音频生成,本文不填入虚构数值。
{
"audio": {
"duration_ms": "<真实运行结果>",
"sample_rate": 16000,
"channels": 1,
"rms": "<真实运行结果>"
},
"input_quality": {
"voiced_ratio": "<真实运行结果>"
},
"asr": {
"raw_text": "<ASR 原始文本>",
"first_segment_ms": "<首段返回耗时>",
"final_ms": "<完整识别耗时>"
},
"postprocess": {
"raw_text": "<原始文本>",
"normalized_text": "<规整后的文本>",
"field_candidates": {
"phone": "<候选手机号>"
}
}
}
六、怎么做 ASR A/B 测试
如果只用一段录音跑通接口,得到的只是 Demo,不是结论。
建议至少建立四类中文客服测试样本:
| 样本组 | 需要覆盖的内容 | 重点指标 |
|---|---|---|
| 标准普通话 | 常规咨询、短句、长句 | CER、最终文本一致性 |
| 电话窄带 | 8 kHz、压缩、增益不稳 | 首尾截断、关键字段准确率 |
| 口音与方言 | 地域口音、混合普通话 | 业务词和地址错误率 |
| 关键字段 | 手机号、地址、订单号、金额、日期 | 字段完全匹配率 |
推荐对照组:
| 组别 | 音频预处理 | 热词 | 后处理 |
|---|---|---|---|
| Baseline | 否 | 否 | 否 |
| A | 是 | 否 | 否 |
| B | 是 | 是 | 否 |
| C | 是 | 是 | 是 |
每组必须使用同一批音频、同一份人工标注和同一套字段规则。
指标口径
字符错误率:
CER = (替换数 S + 删除数 D + 插入数 I) / 标注字符数 N
关键字段完全匹配率:
KeyFieldExactMatch = 完全匹配的关键字段数 / 全部关键字段数
首段返回耗时:
FirstSegmentLatency = 第一段识别结果产生时间 - 请求开始时间
最终识别耗时:
FinalLatency = 最后一段识别结果完成时间 - 请求开始时间
avg_logprob、模型语言概率、热词命中次数都不能直接当作“识别准确率”。最终仍要回到人工标注文本和关键字段完全匹配率。
七、中文客服最容易翻车的地方
1. 降噪把人声也削掉了
“降噪后更干净”不等于“识别更准”。电话窄带、音量偏低、辅音较弱的录音经过过度高低通、压缩或降噪后,数字、姓氏、地址尾音可能更难识别。正确做法是保存原始音频,对比预处理开关后的 CER、关键字段完全匹配率和句首句尾丢失率。
2. VAD 切掉了“嗯”“啊”“第一个字”
VAD 参数过激时,常见问题是:
- 句首被切掉;
- 用户短暂停顿被误判为说完;
- “幺”“二”“B”等短字段被忽略;
- 用户插话被当作噪声。
因此除了静音删除比例,还要测:
句首丢失率
句尾丢失率
短字段漏识率
误切段率
3. 热词表太长,反而引导错词
热词表不是越长越好。不同业务场景应加载不同热词:
售后场景:订单号、商品名、退款状态
预约场景:门店名、医生名、日期、地址
物流场景:快递公司、仓库、楼栋、取件码
并且给热词表加版本号,日志中记录本次识别到底使用了哪一版。
4. 后处理直接改写原文,导致无法追责
危险做法:
原文:我住张江路五百八十八号
规则改写:我住张江路 588 号
CRM 直接写入:张江路 588 号
更稳的做法:
raw_text:我住张江路五百八十八号
normalized_text:我住张江路588号
address_candidate:张江路588号
confirmed_address:等待用户确认或业务系统校验
5. 把文本准确率当作全部业务指标
对于 Voice Agent,真正重要的不只是 CER。例如“我不需要续费”和“我需要续费”,只差一个“不”,但业务含义完全不同。对高风险意图、金额、地址、手机号、身份信息等字段,应配置二次确认、规则校验和转人工边界。
八、FAQ
Q1:热词能解决所有业务词识别错误吗?
不能。热词只能给解码阶段提供提示。它不能修复音频失真、严重噪声、方言发音差异,也不能保证数字和地址一定正确。热词效果必须通过同一批真实业务样本 A/B 测试验证。
Q2:手机号、订单号应该直接自动写入 CRM 吗?
不建议。应先作为 field_candidates 输出,再结合长度校验、格式校验、历史客户匹配、用户二次确认或人工复核决定是否写入。高风险字段尤其不能只依赖一次 ASR。
Q3:为什么不直接用大模型修正 ASR 文本?
可以把大模型作为候选纠错器,但不能让它无约束改写关键字段。更稳的方式是让大模型只做疑似错误标注、结构化字段候选提取、追问语句生成和低置信度转人工摘要。最终字段仍应经过规则、业务数据库或用户确认。
Q4:这套代码能直接用于实时电话 ASR 吗?
本文实现的是文件上传参考链路,适合先验证预处理、热词和后处理策略。
实时电话场景还需要增加:
- WebRTC 或 SIP 音频流接入;
- 分片缓冲;
- partial / final 文本状态;
- VAD 端点检测;
- 首字延迟与最终识别延迟埋点;
- 用户打断时的 ASR 取消与会话状态恢复。
下一篇会继续拆解:中文客服里 ASR 最容易识别错什么,以及手机号、地址、订单号和方言样本应该如何分类复盘。
参考资料
如果你正在搭建 AI 语音客服或 Voice Agent,建议先不要急着比较“哪个 ASR 模型更强”。
先把电话音频、VAD、热词、字段规整和确认链路跑通,再用真实业务样本测 CER、关键字段完全匹配率、首段返回耗时和最终识别耗时。模型能力只是起点,能否稳定进入中文客服生产环境,才是 ASR 的最终考题。
更多推荐
所有评论(0)