DeepSuck

博客

技术拆解:王阿姨的语音链路

骗子在浏览器或电话里说一句话,王阿姨要在一两秒内应声,还要把对方报出的卡号、网址悄悄记下并打码。这篇文章按一轮对话的顺序,拆开语音识别、大模型、语音合成、填充音、打断和传输各环节,以及情报提取与脱敏的规则。

王阿姨是 DeepSuck 的语音人设之一:热心,爱确认,凡事都要问清楚再动手。语音版的目标是让她在电话那头听起来像一个真人,反应慢半拍,但不能慢到让对方觉得线路断了。

本文所有实现细节以仓库为准,主要参考 voice/README.md、docs/ARCHITECTURE.md、docs/VOICE_LATENCY.md 和 docs/INTEL.md。文中的数字都注明了出处。

一轮对话的数据流

一轮对话按下面的顺序走完:

  1. 骗子的声音经 WebRTC(网页)或 SIP 电话适配器 进入服务端;
  2. 云端流式语音识别 边听边出中间结果,判断说完后给出最终转写;
  3. 情报处理器 从转写中提取卡号、电话、网址等,先脱敏,再交给规则引擎选本轮策略;
  4. 根据人设、场景和策略拼出系统提示词,连同脱敏后的对话记录交给 大模型;
  5. 大模型的输出先过 输出守卫,再按子句流式送进 语音合成;
  6. 合成的音频经原路传回,同时通过数据通道把脱敏后的情报推到浏览器看板。

网页和电话共用同一条管线(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)。

语音链路:

部署与电话:

网站与数据: