1. 项目概述:为什么一个能打真人电话的AI语音代理,必须绕开浏览器、直连PSTN?
你有没有试过在网页里点一下“呼叫客服”,然后听AI用温柔的声音回答问题?那种体验很酷,但离真实业务场景差了整整一层防火墙。真正的客户不会打开你的网页,他们只会拨一个印在名片上的号码——那个号码背后是PSTN(公共交换电话网),是运营商铺设了几十年的铜缆与光纤,是每天承载数亿通语音通话的底层基础设施。而OpenAI Realtime API本身不提供SIP或RTP能力,它只认WebSocket;浏览器WebRTC再强,也穿不过企业级NAT、扛不住运营商信令搓揉、更没法接进你现有的呼叫中心队列。这就是为什么我花了三个月重写整套链路:不是为了炫技,而是因为上一家客户在上线第三天凌晨两点打来电话说,“AI接了电话,但没人能听见它说话”。排查结果是:他们的云服务器在AWS新加坡区,SIP信令走的是UDP 5060,而RTP媒体流被VPC安全组默认丢弃了前20个包——OpenAI的server-VAD等不及握手完成就发出了第一段TTS音频,结果全飘进了黑洞。
这个项目标题里藏着三个硬核关键词: OpenAI Realtime API 、 Asterisk SIP 、 Python 。它们不是简单拼凑,而是按电信级可靠性重新咬合的齿轮。Realtime API提供毫秒级双向语音流和server-VAD(服务端语音活动检测),它让AI能真正“听懂停顿”而非靠前端静音计时器瞎猜;Asterisk不是老古董,它的ARI(Asterisk REST Interface)+ ExternalMedia通道是官方唯一支持的“外部媒体引擎接入标准”,会主动告诉你“把模型生成的PCM音频发到哪个IP和端口”,而不是让你去抓包反推;Python则承担了最危险也最关键的中间层——它必须在500ms内完成RTP解包→PCM转码→WebSocket推送→接收delta音频→RTP重封→UDP发送这一整套流水线,任何环节卡顿超过80ms,用户就会感知为“AI反应迟钝”。我见过太多教程教你怎么用Flask搭个WebRTC demo,却对 UNICASTRTP_LOCAL_ADDRESS 这个变量只字不提——而恰恰是它,决定了你的AI声音能不能从服务器声卡里真正“传出去”。
适合谁来读?如果你正在做以下任何一件事,这篇就是为你写的:
- 已经买了Twilio或Telnyx的DID号码,但发现他们的AI语音方案只能跑在WebRTC里,无法对接你现有的PBX;
- 在用Asterisk做呼叫中心,想给坐席加个AI协作者,但拒绝用商业闭源插件;
- 技术栈是Python为主,不想为了语音切到Node.js或Go;
- 被“NAT穿透失败”“RTP单通”“首字丢失”这类报错折磨超过两小时。
它不是理论科普,而是我把三套生产环境踩过的坑、调过的27个参数、重写的4版RTP packetizer全部摊开给你看。文末的GitHub仓库不是玩具,.env.example里每一行配置都来自客户现场的真实值,docker-compose.yml里Prometheus的scrape interval设为3s,是因为我们实测发现5s会导致barg-in响应延迟跳变——这些细节,只有真正在机房守过夜的人才敢写。
2. 系统架构深度拆解:为什么ExternalMedia是唯一正确的起点?
2.1 Asterisk ARI与ExternalMedia的设计哲学
很多开发者第一次接触Asterisk时,本能地想去改 extensions.conf 里的 Dial() 命令,试图把通话直接路由到Python进程。这是条死路。Asterisk的Dialplan是面向传统电话逻辑设计的,它处理的是“呼叫建立-媒体协商-挂断”这种原子事件,而AI语音代理需要的是“实时双向流式媒体注入”。ARI(Asterisk REST Interface)正是为此而生——它把整个呼叫生命周期暴露为HTTP事件流,让你用Python脚本监听 StasisStart 、 ChannelStateChange 等事件,像操作数据库一样操作通话信道。但ARI本身不解决媒体流问题,它只是个“调度员”。真正的媒体管道,由 ExternalMedia 这个通道类型提供。
ExternalMedia的本质,是Asterisk主动创建的一个“媒体黑洞”。当你通过ARI API调用 POST /ari/channels/{channelId}/externalMedia 时,Asterisk会在内核层面分配一个UDP端口(默认10000-20000区间),并设置两个关键环境变量: UNICASTRTP_LOCAL_ADDRESS 和 UNICASTRTP_LOCAL_PORT 。注意,这不是你指定的地址,而是Asterisk根据当前网络栈自动推导出的、 对外可见的有效地址 。比如你的服务器在阿里云ECS上,公网IP是 47.98.123.45 ,但内网地址是 172.16.10.5 ,Asterisk会聪明地把 UNICASTRTP_LOCAL_ADDRESS 设为 47.98.123.45 ,而不是 127.0.0.1 。这个设计解决了NAT穿透中最棘手的问题:无需STUN/TURN服务器,无需手动配置 external_media_address ,Asterisk自己就能算出“该往哪发包”。我在客户现场遇到过最典型的错误,就是开发者在 pjsip.conf 里硬编码了 external_media_address=192.168.1.100 ,结果所有RTP包都发向了内网,运营商的SIP中继根本收不到。
提示:ExternalMedia的
format=slin16参数必须严格匹配。slin16即16-bit linear PCM at 16kHz,这是OpenAI Realtime API唯一原生支持的输入格式。如果这里设成alaw或ulaw,Asterisk会尝试转码,但Realtime API收到的将是失真的G.711压缩流,server-VAD会彻底失效——它听到的不是人声,而是“咔哒咔哒”的编码噪声。
2.2 OpenAI Realtime API的语音流协议真相
OpenAI Realtime API文档里写着“bidirectional audio over WebSocket”,但没明说的是: 它根本不是传统意义上的“流式API” 。你不能像curl那样发个请求就拿到音频流,而必须维护一个长连接状态机。整个会话生命周期由 session.update 事件驱动,其中最关键的是 turn_detection: { "type": "server_vad", "threshold": 0.5, "prefix_padding_ms": 300, "silence_duration_ms": 1000 } 这段配置。server-VAD是服务端做的语音端点检测,它比前端VAD可靠十倍,因为它能看到完整的音频上下文,能区分“用户思考停顿”和“网络抖动丢包”。但代价是:你必须严格遵守它的帧率要求——所有输入音频必须是 PCM16@16kHz,20ms一帧(即320个采样点) 。少一帧,server-VAD就可能误判为“讲话结束”;多一帧,WebSocket连接会被强制关闭。



被折叠的 条评论
为什么被折叠?



