技术拆解:王阿姨的语音链路
骗子在浏览器或电话里说一句话,王阿姨要在一两秒内应声,还要把对方报出的卡号、网址悄悄记下并打码。这篇文章按一轮对话的顺序,拆开语音识别、大模型、语音合成、填充音、打断和传输各环节,以及情报提取与脱敏的规则。
王阿姨是 DeepSuck 的语音人设之一:热心,爱确认,凡事都要问清楚再动手。语音版的目标是让她在电话那头听起来像一个真人,反应慢半拍,但不能慢到让对方觉得线路断了。
本文所有实现细节以仓库为准,主要参考 voice/README.md、docs/ARCHITECTURE.md、docs/VOICE_LATENCY.md 和 docs/INTEL.md。文中的数字都注明了出处。
一轮对话的数据流
一轮对话按下面的顺序走完:
- 骗子的声音经 WebRTC(网页)或 SIP 电话适配器 进入服务端;
- 云端流式语音识别 边听边出中间结果,判断说完后给出最终转写;
- 情报处理器 从转写中提取卡号、电话、网址等,先脱敏,再交给规则引擎选本轮策略;
- 根据人设、场景和策略拼出系统提示词,连同脱敏后的对话记录交给 大模型;
- 大模型的输出先过 输出守卫,再按子句流式送进 语音合成;
- 合成的音频经原路传回,同时通过数据通道把脱敏后的情报推到浏览器看板。
网页和电话共用同一条管线(voice/app.py 的 build_pipeline),只是延迟参数分两套(voice/tuning.py)。整条链路搭在开源的实时语音管线框架 Pipecat 上,服务端是 FastAPI 加 Uvicorn;语音识别、大模型和语音合成都调用云端接口。
语音识别:什么时候算说完
识别本身不难,难的是判断对方说完了没有。说完判断得太早,会把一句话切成两段;太晚,王阿姨的反应就慢。
现在的做法是三层叠加(见 VOICE_LATENCY.md「回合判定」一节):
- 语音活动检测(VAD):开源的 Silero VAD 模型判断有没有人在说话。网页上开口需持续 0.3 秒、置信度 0.75,静音 0.2 秒算停说;电话线路更保守。
- 识别服务的断句静音:从服务端默认的约 800 毫秒调到网页 450 毫秒、电话 500 毫秒,让最终转写提前约 0.3 秒。
- 回合判定模型 Smart Turn(v3,开源):根据这段音频判断一句话是否完整。判为“没说完”时,原本要再等满 3 秒。
在此之上,我们加了一条针对数字的规则(voice/turndigits.py)。骗子念卡号时常常停顿,如果按普通句子处理,一个 19 位卡号会被切成三段。规则是:转写结尾是没念完的数字串时(比如“卡号”后面不足 16 位、“手机号”后面不足 11 位),停说后至少再等 1800 毫秒;一句话已经以句末标点结束、不在念数字时,最多等 1200 毫秒,不再等 3 秒。
本地测试里,两次停顿的 19 位卡号从切成 3 段变为 1 段;“卡号我现在念给您”这类预告句,从停说到判定说完由 3200 毫秒降到 1802 毫秒(VOICE_LATENCY.md「前后对比」表)。
大模型:只拿脱敏后的文字
交给大模型的只有脱敏后的转写。卡号等原值只在本进程内存里用于黑名单比对,不写盘、不上报(voice/README.md「隐私与安全」)。
系统提示词由 voice/persona.py 按人设、场景和本轮策略生成。策略来自规则引擎:拿到新情报就复述追问,要验证码就拖延,要转账就引导对方报出账户。有一条容易忽略的约束:只接对方已经说过的话。比如骗子还没提到平台,提示词里就不会出现“问问是哪个平台”,免得王阿姨抢先说出对方没提的东西,反而露馅。
输出守卫:不让模型编号码
大模型的回复在送去合成之前,先过一道守卫:去掉括号里的动作描写和 Markdown 符号,抹掉 5 位以上的数字串,防止模型编出看起来像真的验证码或卡号。需要复述号码时,按“逐位读”的方式念,不念打码用的星号。
语音合成:边生成边说
最早的实现是每句话新建一条连接,等整句生成完再合成,首包要等很久。现在的做法(VOICE_LATENCY.md「改动」表):
- 每通会话保持一条长连接,开局就建好;
- 每个大模型回合一开始就预开合成任务;
- 大模型边吐字,分句器边切:句末标点立即切,逗号处攒够 6 个字就切,括号或引号没闭合时不切,保证输出守卫拿到的是完整片段;
- 被打断时,如果服务端还在合成就断开取消并立即重连。
这一轮优化后,合成首包的中位数从 2781 毫秒降到 1385 毫秒(VOICE_LATENCY.md「结果」分阶段表,优化前 n=6,优化后 n=9)。剩下的大约 1.2 秒基本是合成服务端从收到文字到吐出首包的时间,我们单独直连测量,结论是快慢两档与模型、音色、采样率都无明显关系,客户端能做的已经做了。
填充音:先应一声
即便如此,从对方说完到正式回复出声,仍要两三秒。真人在这段时间里通常会先“嗯”一声。
现在的做法是 短口头语(voice/backchannel.py):每个人设预先合成 10 条,比如“嗯……”“你说啥?”“等一下啊……”,缓存到磁盘,裁掉合成音频开头 0.34–0.47 秒的静音。回合判定一结束就播一条,不等大模型。以下情况不播:对方只说了不到 4 个有效字,含“快点”“在吗”这类催促词,或者是 8 个字以内的追问,或者正在念数字。
同时在系统提示词里告诉大模型“本轮你已经先应了一声”,让正式回复别再以语气词开头。本地对比中,正式回复以语气词开头的比例从 9/14 降到 3/14(VOICE_LATENCY.md「短口头语」开关对比表)。
打断:中文要数字数
对方在王阿姨说话时插话,要能打断她。Pipecat 默认按空格分词数“词数”,对中文无效。我们改成数“有效字”(voice/interrupt.py):去掉标点和“嗯”“对”“好的”这类应答词,“不是”“不对”算反驳。王阿姨说话时,网页上有效字达到 2 个、电话上达到 3 个才打断;她没在说话时,任何文字都开启新回合。口头语就是普通的输出音频,被打断时随输出队列一起清掉。
传输:网页、中继与电话
- 网页:浏览器与服务端点对点的 WebRTC,服务端用的是 aiortc。服务端只用一段固定的 UDP 端口,在应答里补上公网地址候选。
- UDP 被封时:公司网、校园网或某些代理不转发 UDP。部署时可以启用自建的 coturn 中继,经 443 端口走 TCP 或 TLS:Caddy 借助 caddy-l4 插件按连接的首字节把 TURN 流量和 HTTPS 分开,不用另开端口。临时凭据在点击“开始通话”后现取,默认有效期 600 秒,不落盘。页面会显示“正在连接 / 已接通 / 连接失败”,20 秒未接通会给出换网络、关代理等建议。
- 电话:可选的 Asterisk 电话交换机经 WebSocket 适配器接入同一条管线,音频在传输层统一重采样到 8 kHz。电话接入默认关闭,只接不拨,部署脚本会同时装上 fail2ban 拦截暴力注册。
延迟:实测数字
以下数字都来自 VOICE_LATENCY.md,用同一段 6 句的合成骗子音频、在同一台机器上测量。每次都是真实计费调用,样本量小,P90 只作参考。
| 指标(客户端,骗子停说 → 听到机器人) | P50 | P90 |
|---|---|---|
| 优化前,回复本身 | 5842 ms | 6282 ms |
| 优化后,首个可听声音(含填充音) | 1653 ms | 2803 ms |
| 优化后,回复本身(关闭填充音,3 句) | 4162 ms | 5827 ms |
上表测于 2026-10-18,当时用的还是之前用的模型。
更换模型后,我们单独复测了大模型这一段:从发出请求到第一个字(首字),以及到整句输出完(整句)。这只是大模型这一段的延迟,连接已预热,不含语音识别、判断对方说完(语音端点检测)和语音合成,不能和上表直接相加或比较。每个模型 86 轮,在线上服务器上测量;之前用的模型是同时段测的对照。
| 大模型这一段(毫秒) | 首字 P50 | 首字 P90 | 整句 P50 | 整句 P90 |
|---|---|---|---|---|
| 现在线上用的模型 | 441 | 574 | 642 | 838 |
| 之前用的模型(同时段对照) | 364 | 434 | 713 | 820 |
现在线上用的模型首字比之前用的模型慢约 80 毫秒(P90 慢约 140 毫秒),但回复更短,整句 P50 反而快约 70 毫秒,整句 P90 两者接近。数据来源:docs/VOICE_LATENCY.md「大模型首字 / 整句」一节。
同样在更换模型之前,线上服务的一次外部探测中,正式回复首包为 2469、2627、2484 毫秒,其中识别约 650 毫秒、大模型首字 430–680 毫秒、合成首包 1050–1340 毫秒,合成仍是最大的一段(VOICE_LATENCY.md「TTS 首包拆解」)。
情报提取
提取规则只有一份,放在 site/assets/core/extractors.json,浏览器和 Python 两套实现逐字节对齐,用共享的测试夹具互相校验(INTEL.md「提取器噪声鲁棒性与跨回合拼接」)。语音端直接复用 Python 实现。
能识别的类型包括银行卡、手机号与座机、即时通讯和支付账号、邮箱、网址、加密钱包地址,以及收款户名。口语转写里的写法很乱,所以提取前先做规范化:
- 数字混淆:全角数字、圈码、中文和大写数字、“幺”“两”等读法,都换算回阿拉伯数字;
- 异体字与错别字:“帐”“帳”统一为“账”,常见的同音错字归一;
- 分段拼接:数字之间夹着空格、横杠或标点时,只有拼起来像合法手机号或银行卡(校验位、发卡行前缀)才拼;
- 跨回合拼接:骗子把卡号分两三句报完时,同一说话方相邻至多 3 个回合的数字片段会被拼起来,并给出置信度。更正句(“不对,……”)、带单位的数字、订单号和验证码旁边的数字不参与拼接。
提取到网址或钱包地址后,会与公开黑名单比对,标注“命中”或“未命中”。命中仅供参考,未命中也不代表安全。
脱敏规则
所有展示、导出、落盘和发送给大模型的文本都先脱敏,规则在 INTEL.md「各类型打码规则」中有完整表格,要点如下:
| 类型 | 规则 | 示例 |
|---|---|---|
| 银行卡 | 16–19 位留前 6 位与后 4 位;8–15 位只留后 4 位;更短全部打码 | 622202*********3445 |
| 手机号 | 留前 3 位与后 4 位 | 139****2233 |
| 身份证号 | 留前 6 位与后 4 位,中间固定 8 个星号 | 110101********123X |
| 姓名 | 只留姓 | 王** |
| 通讯与支付账号 | 首尾各留 2 位 | ke******26 |
| 网址 | 保留协议与顶级域,域名主体留首尾各 2 位,路径隐去 | https://pa***re.top/*** |
| 加密钱包地址 | 留前 4 位(部分格式留前 6 位)与后 4 位 | TXyz...5678 |
打码结果是确定的,同一输入总得到同一输出,可以直接用来去重。脱敏只替换识别到的片段,识别不到的写法不会被打码,所以文档建议落盘前再用规则兜底,比如抹掉 7 位以上的连续数字;情报库的校验器也会拒绝含 7 位以上连续数字的打码类条目。
留存方面:网页通话由访客勾选是否保存,勾选时每轮记录脱敏文字和延迟,并录两轨音频;不勾选时只记无内容的计数。电话线路只存脱敏文字、不录音,主叫号码也打码。追踪记录默认保留 30 天,磁盘接近上限时自动停止录音(voice/README.md「通话追踪与问题反馈」)。
拖得住吗
在离线评测中,我们让大模型扮演骗子,与三个人设在 7 个场景下各对话 6 个随机种子,每档 126 场。默认档的起疑挂断率为 13.5%(95% 区间 8.6–20.5%),平均拖延 23.6 轮(上限 25 轮),情报提取率 99.8%(430/431),泄露率为 0(docs/EVAL.md 第 1 节主指标表,结果文件 eval/results-llm-scammer.json)。这是模拟对手的结果,不代表真实骗子的反应;文档里也写明了最近几次人设改动之后需要复测。
还没解决的
- 打断门槛是按合成音频定的,还没有用真人通话校准;
- 端到端只在 Linux 与桌面浏览器、伪造麦克风输入下测过,真实麦克风、回声消除和移动端尚未测试;
- 带逗号停顿的长句,偶尔仍会被回合判定切成两段。
代码、参数和复现命令都在仓库里,欢迎提 issue 或 PR。
致谢
DeepSuck 用到了下面这些开源项目,感谢它们的作者和维护者。名单以仓库里的依赖和部署配置为准(voice/requirements.txt、deploy/、scripts/、site/assets/core/blocklists.json)。
语音链路:
- Pipecat:实时语音管线框架
- Silero VAD:语音活动检测
- Smart Turn:回合判定模型
- aiortc:Python 的 WebRTC 实现
- FastAPI、Uvicorn、websockets:服务端与 WebSocket
部署与电话:
网站与数据:
- marked:本博客的 Markdown 转换
- destroylist、Phishing.Database:公开的钓鱼域名黑名单
