真实电话环境下怎么测 ASR?数字、地址、噪声与业务热词实战

📅 发布时间:2026/7/6 5:26:37 👁️ 浏览次数:
真实电话环境下怎么测 ASR?数字、地址、噪声与业务热词实战
Voice Agent 的 ASR 不能只看一条“识别准确率”也不能只拿安静环境里的麦克风录音测试。电话链路至少要同时看原始 CER、业务归一化 CER、关键实体召回率、业务热词召回率并按数字、地址、噪声、重叠语音等类别拆开统计。本文给出一个只依赖 Python 标准库的可运行评测项目还会用 FFmpeg 把普通 WAV 转成 8 kHz、单声道的 G.711 μ-law 电话音频。文中的 12 条转写样例是为了验证代码而人工编写的合成数据不是任何 ASR 厂商或模型的真实跑分。上一篇我拆了 Voice Agent 怎么安全转人工。转人工之前还有一个很现实的问题系统到底有没有听对比如用户说手机号是“一三八零零一三八零零零”地址是“杭州市余杭区文一西路九六九号”产品名是“星火续费包”“不是退款我想改收货地址”。整句只错一个字CER 可能并不高但错的是手机号、门牌号、产品名后面的 RAG、工单和 CRM 回写都会一起错。对 Voice Agent 来说这类错误比漏掉一个语气词严重得多。一、本文适用范围本文适合下面几种情况已经能调用任意一家流式或非流式 ASR想建立自己的离线测试集需要比较“基础模型”和“加热词模型”但不想只看厂商控制台给出的总准确率电话场景上线后数字、地址、专有名词仍然频繁出错想把 ASR 错误继续传给 RAG、工单或 CRM 做端到端回归。本文不会绑定某个 ASR SDK。评测程序的输入只有两列核心内容人工确认的参考文本以及 ASR 返回的识别文本。换模型时只需要替换识别文本不需要改评测逻辑。二、运行环境我本地验证使用Python: 3.12 最低建议版本: Python 3.10 第三方 Python 依赖: 无 可选工具: FFmpeg 系统: macOS / Linux / Windows 均可Python 部分只使用json、re、unicodedata、argparse、pathlib等标准库。项目目录voice-agent-asr-eval/ ├── evaluate.py ├── make_phone_audio.sh ├── test_evaluate.py └── data/ └── eval.jsonl三、先把“真实电话环境”拆清楚电话音频和电脑麦克风录音不是一回事。常见电话媒体流会使用 8 kHz 窄带音频例如 Twilio Media Streams 文档明确要求传出的音频为audio/x-mulaw、8 kHz。G.711 则定义了 A-law 和 μ-law 两类语音 PCM 编码。因此至少要把下面几层分开用户真实说话 │ ├─ 口音、语速、吞字、数字读法 │ ├─ 环境噪声、回声、旁人说话、双讲 │ ├─ 电话链路8 kHz / G.711 / 丢包 / 抖动 │ ├─ VAD 切句句首截断、句尾截断 │ └─ ASR声学识别、语言模型、热词偏置 │ └─ 文本后处理标点、数字归一化、实体抽取只把录音从 16 kHz 降到 8 kHz只模拟了采样率和编解码影响不能凭空生成真实回声、丢包、串音和口音。因此我的建议是第一层用转码音频做快速回归第二层加入公开或自录的授权噪声音频第三层使用取得授权并完成脱敏的真实电话录音所有系统必须跑同一份音频、同一份参考标注。不要把生产通话直接丢进测试仓库。手机号、地址、订单号属于敏感业务数据采样、脱敏、访问权限和保留期限都应先确定。四、把普通 WAV 转成电话链路音频FFmpeg 官方文档中-ar用于设置采样率-ac用于设置声道数。下面脚本先编码为 8 kHz、单声道、G.711 μ-law再解码为 16-bit PCM WAV方便送入只接受 PCM/WAV 的 ASR SDK。#!/usr/bin/env bashset-euopipefailif[[$#-ne2]];thenechoUsage:$0input.wav output.wav2exit2fiinput$1output$2encoded${output%.wav}.g711.wavffmpeg-hide_banner-loglevelerror-y\-i$input-ar8000-ac1-c:apcm_mulaw$encodedffmpeg-hide_banner-loglevelerror-y\-i$encoded-ar8000-ac1-c:apcm_s16le$outputechogenerated:$output执行chmodx make_phone_audio.sh ./make_phone_audio.sh input.wav phone_8k.wav ffprobe-verror\-show_entriesstreamcodec_name,sample_rate,channels\-ofdefaultnoprint_wrappers1phone_8k.wav预期输出codec_namepcm_s16le sample_rate8000 channels1这里有个常见误区把 48 kHz 录音降成 8 kHz 后不能称为“真实电话实测”。准确说法应该是“电话带宽与编解码回归”。只有音频确实经过真实通话链路才能覆盖网络抖动、设备差异、声学回声和运营商链路变化。五、测试集怎么分不要随机抓几十句话我会先按业务风险建测试桶而不是把所有句子混在一起算平均值。测试类别重点错误业务风险手机号、订单号、验证码漏数字、数字顺序错误、中文数字与阿拉伯数字格式差异查错客户、查错订单、验证失败日期、时间、金额“十二”识别成“二”、“十五点”识别成“五点”预约和交易出错省市区、道路、门牌号层级缺失、同音路名、门牌号漏位配送或上门地址错误产品名、套餐名、品牌名同音替换、英文缩写错误RAG 检索不到正确资料噪声删除词、空转写、错误插入意图判断不稳定重叠语音否定词丢失、说话人内容混合“不是退款”变成“退款”VAD 边界句首、句尾被截断关键条件丢失每条样本还要带上条件标签例如{id:address_001,condition:g711_noise,category:地址,reference:地址是杭州市余杭区文一西路九六九号,hypotheses:{baseline:地址是杭州市余杭区文一西路六九号,hotword:地址是杭州市余杭区文一西路九六九号},entities:[{type:city,value:杭州市},{type:district,value:余杭区},{type:road,value:文一西路},{type:number,value:969号}]}这样既能比较两个 ASR 配置也能按类别和音频条件回溯问题。六、为什么我不只看 WER经典 ASR 评测通常用最小编辑距离统计替换、删除和插入错误率 (替换数 S 删除数 D 插入数 I) / 参考序列长度 NNIST 的 SCTK/SCLITE 就是常见的语音识别评分工具。JiWER 也基于最小编辑距离提供 WER、CER 等指标。但中文 WER 依赖分词方式。同一句中文用不同分词器WER 可能不同。为了让自己的回归结果可复现我会用原始 CER 检查 ASR 原样输出用业务归一化 CER 消除全角/半角、大小写和逐位中文数字的格式差异单独计算关键实体召回率单独计算业务热词召回率保留按类别拆分的 CER不让平均值掩盖问题。注意业务归一化不能过度。下面的演示只把“零一二三四五六七八九幺”这类逐位数字映射为0-9没有擅自解析“十、百、千、万”。因为“二百二十八号”和“二二八号”的语义转换需要更完整的数字规则错误归一化反而会把模型错误洗掉。七、完整评测代码将下面内容保存为evaluate.pyfrom__future__importannotationsimportargparseimportjsonimportreimportunicodedatafromcollectionsimportdefaultdictfromdataclassesimportdataclassfrompathlibimportPathfromtypingimportSequence HAN_DIGITSstr.maketrans({零:0,〇:0,一:1,二:2,两:2,三:3,四:4,五:5,六:6,七:7,八:8,九:9,幺:1,})PUNCT_OR_SPACEre.compile(r[\W_],re.UNICODE)dataclassclassEditCounts:substitutions:intdeletions:intinsertions:intreference_length:intpropertydefrate(self)-float:ifself.reference_length0:returnfloat(self.insertions0)return(self.substitutionsself.deletionsself.insertions)/self.reference_lengthdef__add__(self,other:EditCounts)-EditCounts:returnEditCounts(self.substitutionsother.substitutions,self.deletionsother.deletions,self.insertionsother.insertions,self.reference_lengthother.reference_length,)defnormalize_text(text:str,business_digits:boolFalse)-str:textunicodedata.normalize(NFKC,text).casefold()ifbusiness_digits:texttext.translate(HAN_DIGITS)returnPUNCT_OR_SPACE.sub(,text)defedit_counts(reference:Sequence[str],hypothesis:Sequence[str])-EditCounts:rows,colslen(reference)1,len(hypothesis)1dp[[(0,0,0,0)for_inrange(cols)]for_inrange(rows)]foriinrange(1,rows):dp[i][0](i,0,i,0)forjinrange(1,cols):dp[0][j](j,0,0,j)foriinrange(1,rows):forjinrange(1,cols):ifreference[i-1]hypothesis[j-1]:dp[i][j]dp[i-1][j-1]continuediagonaldp[i-1][j-1]deletiondp[i-1][j]insertiondp[i][j-1]dp[i][j]min((diagonal[0]1,diagonal[1]1,diagonal[2],diagonal[3]),(deletion[0]1,deletion[1],deletion[2]1,deletion[3]),(insertion[0]1,insertion[1],insertion[2],insertion[3]1),)_,substitutions,deletions,insertionsdp[-1][-1]returnEditCounts(substitutions,deletions,insertions,len(reference))defcontains_value(hypothesis:str,value:str)-bool:returnnormalize_text(value,True)innormalize_text(hypothesis,True)defload_cases(path:Path)-list[dict]:cases[]withpath.open(encodingutf-8)asfile:forline_number,lineinenumerate(file,1):ifnotline.strip():continuecasejson.loads(line)required{id,condition,category,reference,hypotheses}missingrequired.difference(case)ifmissing:raiseValueError(f第{line_number}行缺少字段:{sorted(missing)})cases.append(case)ifnotcases:raiseValueError(评测集为空)returncasesdefevaluate(cases:list[dict])-dict:systemssorted({nameforcaseincasesfornameincase[hypotheses]})report{}forsysteminsystems:raw_totalEditCounts(0,0,0,0)business_totalEditCounts(0,0,0,0)entity_hitsentity_total0keyword_hitskeyword_total0category_cerdefaultdict(list)forcaseincases:ifsystemnotincase[hypotheses]:continuereferencecase[reference]hypothesiscase[hypotheses][system]rawedit_counts(list(normalize_text(reference)),list(normalize_text(hypothesis)),)businessedit_counts(list(normalize_text(reference,True)),list(normalize_text(hypothesis,True)),)raw_totalraw business_totalbusiness category_cer[case[category]].append(raw.rate)entitiescase.get(entities,[])entity_hitssum(contains_value(hypothesis,item[value])foriteminentities)entity_totallen(entities)keywordscase.get(keywords,[])keyword_hitssum(contains_value(hypothesis,keyword)forkeywordinkeywords)keyword_totallen(keywords)report[system]{raw_cer:round(raw_total.rate,4),business_cer:round(business_total.rate,4),entity_recall:round(entity_hits/entity_total,4)ifentity_totalelseNone,keyword_recall:round(keyword_hits/keyword_total,4)ifkeyword_totalelseNone,category_macro_cer:{name:round(sum(values)/len(values),4)forname,valuesinsorted(category_cer.items())},}returnreportdefmain()-None:parserargparse.ArgumentParser()parser.add_argument(dataset,typePath)parser.add_argument(--json,typePath)argsparser.parse_args()reportevaluate(load_cases(args.dataset))print(json.dumps(report,ensure_asciiFalse,indent2))ifargs.json:args.json.write_text(json.dumps(report,ensure_asciiFalse,indent2),encodingutf-8,)if__name____main__:main()这段代码故意没有引入第三方分词器和数字解析库目的是让评测口径透明。正式项目可以换成 JiWER 或 NIST SCTK 复核但在版本迭代中必须固定文本归一化与分词规则。八、准备一份最小测试数据创建data/eval.jsonl每行一个 JSON{id:digit_phone_001,condition:g711_clean,category:数字,reference:联系电话是一三八零零一三八零零零,hypotheses:{baseline:联系电话是三八零零一三八零零零,hotword:联系电话是13800138000},entities:[{type:phone,value:13800138000}]}{id:address_001,condition:g711_noise,category:地址,reference:地址是杭州市余杭区文一西路九六九号,hypotheses:{baseline:地址是杭州市余杭区文一西路六九号,hotword:地址是杭州市余杭区文一西路九六九号},entities:[{type:city,value:杭州市},{type:district,value:余杭区},{type:road,value:文一西路},{type:number,value:969号}]}{id:hotword_001,condition:g711_noise,category:热词,reference:我要咨询星火续费包,hypotheses:{baseline:我要咨询星火续费宝,hotword:我要咨询星火续费包},keywords:[星火续费包]}{id:overlap_001,condition:double_talk,category:重叠语音,reference:不是退款我想改收货地址,hypotheses:{baseline:退款我想改收货地址,hotword:不是退款我想改收货地址},keywords:[改收货地址]}运行python evaluate.py data/eval.jsonl--jsonreport.json我在完整的 12 条合成样例上得到system samples raw-CER business-CER entity-recall keyword-recall -------------------------------------------------------------------------------- baseline 12 13.01% 8.90% 70.59% 40.00% hotword 12 11.64% 0.00% 100.00% 100.00%再次强调这是为了检查评测代码是否能识别“数字格式差异、实体错误和热词错误”而设计的合成样例不是模型性能结论。这组输出反而说明了一个容易踩的坑hotword系统输出阿拉伯数字时原始 CER 仍会把138和“一三八”当成不同字符业务归一化后才视为等价。所以报告里应同时保留两套 CER不能只挑看起来最好的一套。九、真实跑分时还要补哪些指标上面的代码解决的是离线文本准确率。Voice Agent 上线前我还会增加下面几项1. 空转写率空转写率 空识别结果数 / 有效语音样本数噪声较大或 VAD 截断时系统可能不返回任何文本。空转写不能被 CER 汇总悄悄忽略。2. 关键实体完全正确率手机号、订单号、验证码这类字段通常要求整段完全一致。11 位手机号错 1 位不应该按“十一个字符对了十个”解释为基本可用。实体完全正确率 完全匹配的实体数 / 实体总数3. 否定词准确率“不、没有、别、取消、不是”需要单独建桶。否定词漏掉经常会直接反转用户意图。4. 首个中间结果与最终结果延迟离线 CER 一样的两个 ASR流式体验可能完全不同。建议记录t_audio_start t_first_partial t_final_text 首个中间结果延迟 t_first_partial - t_audio_start 最终结果延迟 t_final_text - t_audio_end5. RTFRTF 识别耗时 / 音频时长RTF 小于 1 表示处理速度快于音频播放速度但流式系统仍应单独看首个中间结果延迟。十、热词不是越多越好业务热词通常能改善产品名、套餐名和专有名词但不要把整个产品库全塞进热词表。我会记录热词文本权重或 boost生效范围版本号加热词前后的热词召回率普通句子的误触发率。例如“云盾”权重过高后普通句子里的“云端”可能被强行拉成“云盾”。所以 A/B 测试不仅要看热词桶还要保留一份不包含热词的对照集。比较两个系统时也不能只跑它们各自最擅长的热词配置。音频、参考文本、归一化规则和后处理必须一致否则测到的是配置差异不是模型差异。十一、常见报错与排查报错 1JSONDecodeErrorJSONL 要求每一行都是完整 JSON不能跨行也不能在行尾加注释。python-mjson.tool data/eval.jsonl上面这条命令适合单个 JSON 文件不适合直接验证多行 JSONL。JSONL 应逐行解析本文代码会在错误时报告行号。报错 2中文数字与阿拉伯数字导致 CER 异常升高同时输出原始 CER 和业务归一化 CER。不要直接覆盖原始文本否则后续无法审计到底是 ASR 改进了还是后处理把错误遮住了。报错 3FFmpeg 提示找不到编码器先检查当前构建是否包含pcm_mulawffmpeg-encoders|greppcm_mulawWindows PowerShell 可以使用ffmpeg-encoders|Select-Stringpcm_mulaw报错 48 kHz 音频识别比 16 kHz 差很多先确认模型或接口是否真的支持 8 kHz不要把 8 kHz 音频伪装成 16 kHz 参数上传。还要检查声道数是否为 1PCM 位深和字节序是否正确G.711 解码是否执行了两次音频头是否和实际数据一致VAD 是否按错误采样率计算帧长。报错 5总体 CER 下降但线上投诉没有减少按数字、地址、否定词、热词和噪声拆桶。总体 CER 很可能被大量简单句拉低而高风险实体仍在出错。十二、FAQ中文 ASR 应该看 WER 还是 CER中文缺少天然空格边界WER 会受到分词器影响。做稳定回归时可以优先看 CER同时固定归一化规则涉及手机号、订单号、地址和产品名时再补实体完全正确率与召回率。只用合成噪声可以吗可以做快速回归不能代替真实电话测试。合成噪声难以完整覆盖设备回声、真实串音、丢包、口音、吞字和运营商链路差异。参考文本由谁标注最好采用“初标 复核”。高风险实体要回听音频确认标注规范里明确数字、英文缩写、语气词和不可辨认片段的写法。否则不同标注员的书写差异会被算成模型错误。热词提升后就可以上线吗不能。还要检查非热词对照集的误触发率、不同权重下的稳定性以及热词版本是否和当前产品、区域、活动一致。地址怎么评测更合理除了整句 CER还应拆成省、市、区、道路、门牌号等字段。前三级都正确但门牌号错误仍然不能算业务成功。测试集多久更新一次每次出现新的线上典型错误都应脱敏后加入回归集模型、VAD、热词、音频编解码或文本归一化规则变更时重新跑完整测试。十三、最后总结电话 ASR 评测最容易犯的错误是用一个总体准确率替代所有问题。更可靠的做法是用同一份音频和参考标注比较不同系统同时报告原始 CER 与业务归一化 CER单独统计数字、地址、否定词和业务热词关键实体看完全匹配不接受“差不多正确”合成电话音频用于回归真实电话录音用于最终验证保存错误样本和配置版本让每次调参都有证据。下一篇将把前面 WebRTC、VAD、ASR、RAG、TTS、打断和转人工代码合成一个可运行的 Voice Agent v1。参考资料NIST SCTK语音识别评分工具JiWERWER、CER 等 ASR 指标ITU-T G.711语音频率的 PCM 编码Twilio Media Streams WebSocket 消息格式FFmpeg 官方文档Python unicodedata 官方文档