smart-turn v3.2:本地 ONNX 判停真实推理评测
一、背景速览:EOU 到底在解决什么
纯 VAD(静音检测)判停的问题是:用户说到"我想问一下我的套餐是……"停顿思考 0.6 秒,VAD 认为说完了, AI 直接抢话。反过来把静音阈值调到 1.5 秒,用户说完"好的。"要等 1.5 秒才有回应,对话变得迟钝。
EOU(End-of-Utterance)模型的作用是听语义和韵律,判断"这句话在语法/语义上说完了没有", 从而把静音等待从固定阈值变成动态决策。
配套调研结论见:
EOU 选型调研:东南亚 + 南美语种话轮检测模型选型 ↗、
EOU 模型专题 · 话轮结束检测 ↗。
调研阶段已定选型为 pipecat-ai/smart-turn,本次实战要回答的是:它在本机真跑起来是什么水平。
二、环境准备:装什么、模型从哪来
2.1 依赖
| 依赖 | 用途 | 说明 |
|---|---|---|
onnxruntime | 推理引擎 | CPU 版即可,不用装 GPU 版 |
transformers | WhisperFeatureExtractor 做梅尔特征 | 只用特征提取器,不需要装 PyTorch |
librosa / soundfile | 音频加载与重采样 | 模型只吃 16kHz 单声道 float32 |
numpy | 数组处理 | — |
pytest | 跑单元测试 | 可选 |
$ cd smart-turn $ python3 -m venv venv # 只做推理用 requirements_inference.txt,比全量依赖小很多 $ ./venv/bin/pip install -r requirements_inference.txt # 确认模型文件在位(约 30MB,CPU 量化版) $ ls -la smart-turn-v3.2-cpu.onnx
inference.py 已改成支持环境变量覆盖:SMART_TURN_ONNX_PATH,
而且这个变量必须在 import inference 之前设置——因为模块在导入时就构建了 ONNX session。
三、数据集自建:为什么不用现成样本
目录里原本有一批 *_zh.wav 样本,但它们的朗读文本没有出处记录——不知道当初念的到底是哪句话,
也就无法确定"期望判定"该标 0 还是 1。用来源不明的样本算准确率,结论是不可信的。
所以写了 batch_eval.py,自带 --regen:用 macOS say + afconvert
现场合成整套数据集,朗读文本与人工标注在代码里一一对应,任何人重跑都能得到同一份数据。
10 条样本设计(正 5 负 5)
| 样本 | 朗读文本 | 期望 | 为什么要这条 |
|---|---|---|---|
p1_statement | 帮我查询一下我的话费余额。 | 说完 | 最标准的陈述句,基线 |
p2_question | 请问人工客服的工作时间是几点到几点? | 说完 | 疑问句语调上扬,容易被误判成未完 |
p3_long | 我的宽带今天上午突然断网了,你们能不能派个师傅上门来修一下。 | 说完 | 长句,接近 8 秒窗口边界 |
p4_short | 好的。 | 说完 | 极短音频(0.41s),考察补零后是否仍能判对 |
p5_number | 我的订单号是八八一二三。 | 说完 | 数字结尾,韵律与文字句不同 |
n1_cutoff | 我想问一下我的套餐是 | 未完 | 动词后缺宾语,句子被硬截断 |
n2_filler | 我想要查询一下那个 | 未完 | 以指示词结尾 |
n3_hesitate | 嗯,我这边是想说 | 未完 | 填充词犹豫,最容易被 VAD 误切 |
n4_conj | 我上个月已经交过费了,但是 | 未完 | 以连词结尾,后面还有转折 |
n5_prep | 这个费用能不能从 | 未完 | 以介词结尾,语法上不可能结束 |
四、跑通步骤
-
单条试跑,确认模型能加载
$ ./venv/bin/python predict.py samples/eou_p1_statement.wav Loading audio file: samples/eou_p1_statement.wav Loaded audio with sample rate: 16000 Hz, duration: 2.62 seconds Running endpoint prediction... Results: Prediction: Complete Probability of complete: 0.6345
-
合成数据集 + 批量评测(每条重复 3 次取中位数)
重复 3 次是为了排除单次抖动;模型本身是确定性的(无 dropout/采样),概率完全可复现,重复只影响耗时统计。
批量评测$ ./venv/bin/python batch_eval.py --regen --repeats 3 --json /tmp/eou_result.json [合成] eou_p1_statement.wav <- 「帮我查询一下我的话费余额。」 [合成] eou_p2_question.wav <- 「请问人工客服的工作时间是几点到几点?」 ... 共 10 条
-
读结果表
===== smart-turn v3.2 本机批量判停结果 ===== 样本 时长s 预期 实际 完成概率 耗时ms 结果 eou_p1_statement.wav 2.62 说完 说完 0.6345 34.2 PASS eou_p2_question.wav 3.85 说完 说完 0.9836 34.0 PASS eou_p3_long.wav 6.31 说完 未完 0.0597 34.9 FAIL eou_p4_short.wav 0.41 说完 说完 0.9425 34.6 PASS eou_p5_number.wav 2.35 说完 未完 0.3564 33.6 FAIL eou_n1_cutoff.wav 2.28 未完 未完 0.1142 33.7 PASS eou_n2_filler.wav 2.04 未完 未完 0.0164 33.5 PASS eou_n3_hesitate.wav 2.00 未完 未完 0.0151 34.1 PASS eou_n4_conj.wav 2.98 未完 未完 0.0057 33.9 PASS eou_n5_prep.wav 1.68 未完 未完 0.4902 44.9 PASS 准确率 8/10 = 80.0% 单次推理耗时 P50=34.1ms P95=34.9ms 最小=33.5ms 最大=44.9ms 正类均值=0.5953 负类均值=0.1283 区分度=0.4670
五、本机验证结果
10 条样本的完成概率分布(红线 = 0.5 判定阈值)
绿色 = 期望"说完",橙色 = 期望"未说完"。理想情况绿色全在 0.5 线上、橙色全在线下。实测两根绿柱掉到线下(p3_long 0.0597、p5_number 0.3564),一根橙柱贴着线(n5_prep 0.4902)。
| 样本 | 时长 | 期望 | 实际 | 完成概率 | 耗时 | 判定 |
|---|---|---|---|---|---|---|
p1_statement | 2.62s | 说完 | 说完 | 0.6345 | 34.2ms | PASS |
p2_question | 3.85s | 说完 | 说完 | 0.9836 | 34.0ms | PASS |
p3_long | 6.31s | 说完 | 未完 | 0.0597 | 34.9ms | FAIL |
p4_short | 0.41s | 说完 | 说完 | 0.9425 | 34.6ms | PASS |
p5_number | 2.35s | 说完 | 未完 | 0.3564 | 33.6ms | FAIL |
n1_cutoff | 2.28s | 未完 | 未完 | 0.1142 | 33.7ms | PASS |
n2_filler | 2.04s | 未完 | 未完 | 0.0164 | 33.5ms | PASS |
n3_hesitate | 2.00s | 未完 | 未完 | 0.0151 | 34.1ms | PASS |
n4_conj | 2.98s | 未完 | 未完 | 0.0057 | 33.9ms | PASS |
n5_prep | 1.68s | 未完 | 未完 | 0.4902 | 44.9ms | PASS |
六、核心发现
6.1 错误是单向的:只漏判"说完",不误判"未说完"
负类 5/5 全对,且概率极低(0.0057 ~ 0.1142,四条都在 0.12 以下)——说明模型对 「以连词/介词/指示词/填充词结尾」这类语法未完信号抓得非常稳。
而正类只有 3/5,两个错误都是把"说完了"判成"没说完"。这两种错误在真实通话里的代价完全不同:
| 错误类型 | 本次实测 | 用户感受 | 严重度 |
|---|---|---|---|
| 假阴性:说完了判成没说完 | 2 例 | AI 傻等,对话出现尴尬空白,最后靠静音超时兜底 | 中(延迟增加,不致命) |
| 假阳性:没说完判成说完 | 0 例 | AI 抢话打断用户,体验断崖式下降 | 高(用户直接挂机) |
6.2 两个漏判的共同点:长句与数字结尾
6.3 阈值不是安全的:有样本贴着 0.5
n5_prep("这个费用能不能从")概率 0.4902,判对了,但只比阈值低 0.0098。
任何把阈值从 0.5 调到 0.49 的操作,都会让这一条翻错。
6.4 延迟完全不是瓶颈
单次推理 P50 34.1ms、P95 34.9ms,CPU 单线程。放在语音链路的延迟预算里几乎可以忽略 (相比 ASR 首包 200ms+、LLM 首字 300ms+)。唯一的异常是 n5_prep 的 44.9ms—— 比其它样本高 10ms,是系统调度抖动而非模型问题(同一模型对固定输入是确定性的)。
七、单元测试:21 项全过
除了批量评测,仓库里还有三层单元测试,这次一并跑通:
| 层次 | 测什么 | 典型断言 |
|---|---|---|
| 契约测试 | 输出结构、取值范围、阈值一致性、确定性 | 概率必须在 [0,1];prediction == (probability > 0.5);同输入两次结果必须一致 |
| 健壮性测试 | 纯静音、15 秒超长、100ms 极短、float64 输入 | 都不能崩;超长要内部截尾、极短要补零 |
| 语义测试 | 真实中文语音的方向性 | 完整句概率必须比 filler 结尾高出 0.2 以上 |
$ ./venv/bin/python -m pytest tests/ -q ..................... [100%] 21 passed, 1 warning in 1.59s
八、踩坑记录
inference.py 在模块级就执行了 session = build_session(ONNX_MODEL_PATH),
所以 os.environ["SMART_TURN_ONNX_PATH"] = ... 一旦写在 import inference 之后就完全无效,
而且不会报错,只会静默加载错的模型文件(或者报"文件不存在"让人一头雾水)。
*_zh.wav,我按文件名猜了朗读文本去标注,跑出 4/6 = 66.7%。
但那个数字毫无意义——标注可能本身就是错的。改成脚本现场合成、文本与标注在代码里绑定后,
结果变成 8/10 = 80%,这次的数字才是可信、可复现的。
评测的第一条纪律是:先保证标注可信,再谈模型准确率。
say 的句末降调远不如真人明显,而模型很依赖韵律信号。
所以本次的 80% 是下界不是真实水平——真人录音上正类召回大概率更高。
反过来说:不能用合成语音的评测结果去否定一个 EOU 模型,正式选型必须用真实通话录音。
transformers 不装 PyTorch 会打印一行警告,不影响使用。
"None of PyTorch, TensorFlow >= 2.0, or Flax have been found" —— 这是正常的,
我们只用 WhisperFeatureExtractor 做梅尔特征,不需要任何深度学习框架,推理走 onnxruntime。
装 PyTorch 只会让镜像大好几个 G。
truncate_audio_to_last_n_seconds(audio, n_seconds=8) 保留的是结尾而非开头——
符合判停逻辑(只关心最近说的话)。但这意味着超过 8 秒的连续说话,前面的语义上下文全丢,
长句场景要靠 ASR 文本层补判。