背景痛点:智能客服拨号的三座大山
做智能客服的同学都懂,每天一睁眼就是“电话打不出去”的噩梦。业务高峰期,几千路并发一起往外拨,运营商直接甩回来“频率超限”;好不容易接通,用户那边却听不清,投诉工单雪花一样飞来。总结下来,核心痛点就三条:
- 并发压力:营销活动时,峰值 QPS 能冲到 5 k,传统 VoIP 网关一秒只能建 200 路 SIP/Session Initiation Protocol 会话,瞬间被秒成渣。
- 运营商限制:同一主叫号码 1 分钟呼出超过 20 次就进黑名单,封号 24 h,导致“号池”几天就被打废。
- 通话质量:公网丢包 3 % 就能让语音卡成 PPT,再加上 NAT/Network Address Translation 穿透失败,30 % 呼叫单通,客服小姐姐只能“喂喂喂”到怀疑人生。
技术选型:为什么最终选了“SIP + WebRTC”混合架构
为了搞定上述三座大山,我们把市面上能用的方案全拉出来跑分:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 纯 SIP/RTP | 协议成熟,运营商兼容好 | 媒体走 UDP,NAT 穿透差;并发高时 CPU 软解 RTP 扛不住 | 只做信令,不管媒体 |
| Twilio API | 一站式,全球号码池 | 按分钟计费,量大后成本翻倍;信令黑盒,定位问题靠工单 | 预算充足可上 |
| 纯 WebRTC | P2P 打洞,延迟低 | 对端如果是传统固话,需要转码;浏览器兼容性坑多 | 做客户端,不做落地 |
最终拍板:
“SIP 管信令,WebRTC 管媒体”——信令走 SIP,保证运营商认账;媒体流走 WebRTC + 自建的 SFU/Selective Forwarding Unit,边缘节点负责转码、回声消除,既省钱又可控。
核心实现:Python 示范三步走
下面用最小可运行代码,带你跑通“鉴权→呼叫→挂断”全生命周期。所有代码均符合 PEP8,可直接粘到生产。
1. SIP 信令交互(基于pjsua2)
先装依赖:
pip install pjsua2==2.13.1 redis==5.0.0# sip_dialer.py import pjsua2 as pj import time import redis class SipAccount(pj.Account): """封装账户回调,只关心注册成功与来电""" def onRegState(self, prm): print(f"SIP 注册状态: {prm.code} {prm.reason}") def build_acc_config(user, pwd, realm, sip_host): acc_cfg = pj.AccountConfig() acc_cfg.idUri = f"sip:{user}@{sip_host}" acc_cfg.regConfig.registrarUri = f"sip:{sip_host}" cred = pj.AuthCredInfo("digest", realm, user, 0, pwd) acc_cfg.sipConfig.authCreds.append(cred) return acc_cfg def make_call(acc, dst_uri, call_id): """发起一路呼叫,返回 Call 对象""" call = pj.Call(acc) prm = pj.CallOpParam() call.makeCall(dst_uri, prm) print(f"[{call_id}] 呼叫已发起 -> {dst_uri}") return call2. WebRTC 媒体服务器关键配置(mediasoup为例)
// config.js module.exports = { // 网络层:只开 UDP,TCP 留给 TURN 备用 webRtcTransport: { listenIps: [{ ip: "0.0.0.0", announcedIp: "1.2.3.4" }], enableUdp: true, enableTcp: false, preferUdp: true, }, // 音频:48 kHz 立体声,带回声消除 router: { mediaCodecs: [ { kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2, parameters: { "useinbandfec": 1, "usedtx": 1, }, }, ], }, };3. Redis 并发令牌桶(防止瞬间把号池打爆)
# rate_limiter.py import redis import time class TokenBucket: """每秒放 N 个令牌,超了就排队""" def __init__(self, key, rate, burst, redis_cli): self.key = key self.rate = rate self.burst = burst self.r = redis_cli def acquire(self, need=1): pipe = self.r.pipeline() now = time.time() pipe.zadd(self.key, {str(now): now}) # 记录请求时间 pipe.zremrangebyscore(self.key, 0, now - 1) # 清理 1 s 前 pipe.zcard(self.key) _, _, curr = pipe.execute() if curr > self.burst: return False return True使用示例:
bucket = TokenBucket("sip:rate", rate=10, burst=20, redis_cli=redis.Redis()) if bucket.acquire(): call = make_call(acc, "sip:13800138000@carrier.com", call_id) else: print("触发流控,呼叫降级")生产考量:让 99.9 % 可用性落地
压力测试基线
- QPS:单机 500 路并发,CPU 65 %,内存 2.3 GB
- 延迟:端到端首包 < 200 ms,99 分位 < 300 ms
- 丢包率:在 100 Mb 带宽、5 % 背景丢网下,OPUS in-band FEC 把 MOS 分维持在 3.8 以上
号码防封策略
- 横向:号池 5000 个主叫,随机轮询
- 纵向:单号 1 分钟 ≤ 15 次,1 小时 ≤ 100 次,Redis 计数器滑动窗口
- 异常:一旦收到 486 Busy/603 Decline 超过 5 %,自动踢出号池 2 h
DTMF 容错
- 带内 RFC4733 + 带外 SIP INFO 双发,接收端“谁先到用谁”
- 如果 200 ms 内两条通道冲突,优先采信带内,防止“按 1 退订”被误判
避坑指南:三次踩坑血泪史
NAT 穿透失败 → 30 % 单通
现象:外呼成功,但客服听不到用户说话。
根因:服务端只开 TCP 3478,没开 UDP 10000-10100。
解决:iptables 放行 UDP 端口段,并在 SDP 里写对candidate。回声消除失效 → 自己说话被循环播放
现象:客服耳机里全是自己 500 ms 后的声音。
根因:笔记本自带麦克风与扬声器串音,WebRTC AEC 默认关闭。
解决:在getUserMedia加echoCancellation: true,同时让客服戴耳机。Redis 流控 Key 没设 TTL → 内存暴涨
现象:运行三天 Redis 占用 12 GB。
根因:zadd后未给 Key 设置过期时间,导致历史时间戳无限累加。
解决:每次zadd后expire(key, 2),保证 2 s 后自动清理。
代码规范小结
- 函数 ≤ 30 行,嵌套 ≤ 3 层
- 公共模块加
__all__,私有函数下划线前缀 - 所有外部依赖通过
requirements.txt锁版本,避免“我本地能跑”灾难 - 单元测试覆盖 ≥ 80 %,CI 里跑
flake8+black双检查
开放讨论
如何设计智能路由策略,应对运营商区域性限制?当北京联通屏蔽你的号段,而广州电信依旧畅通时,系统该怎样实时切换、动态优选线路?期待在评论区看到你的脑洞!