十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建实时语音交互系统:Realtime API架构与工程实践

从零搭建实时语音交互系统:Realtime API架构与工程实践 实时语音交互这件事我最早是从电话客服机器人这个场景开始接触的。当时用传统的录音—上传—识别—合成—播放链路端到端延迟稳定在2.5秒以上用户说完一句话要愣一下才听到回应体验非常割裂。后来OpenAI Realtime API出来我第一次跑通Demo的时候端到端延迟压到了600毫秒以内那种像打电话一样自然的感觉确实让我重新思考了语音应用的架构方式。这篇内容就是把我从零搭一套实时语音交互系统的完整过程拆开讲包括WebSocket连接怎么建、音频流怎么处理、会话状态怎么管、踩过哪些坑。适合已经会写基础后端代码、想上手实时语音能力的开发者也适合正在评估要不要把现有语音链路换成Realtime API的技术负责人。1. 先搞清楚Realtime API到底在解决什么问题1.1 传统语音链路的四段式延迟从哪来在Realtime API出现之前做语音交互基本是这么一条流水线客户端录音把音频文件或音频流上传到服务端服务端调用语音识别ASR拿到文本把文本送进大模型生成回复再把回复文本送进语音合成TTS拿到音频最后把音频推回客户端播放。这条链路里每一段都有独立的网络往返和计算耗时。我实测过一组数据录音结束到上传完成约200msASR识别约400ms大模型首token约500msTTS合成首包约300ms音频回传约150ms。加起来就是1.5秒以上而且这还是理想网络。如果中间任何一环用了轮询或者非流式接口延迟会直接翻倍。更关键的是这四段是串行的用户说完话之后系统要等ASR完全结束才能开始生成生成完全结束才能开始合成没有任何重叠。Realtime API的核心改变是把这条串行链路压成了一个双向流。音频通过WebSocket持续双向传输服务端在收到音频的同时就在做识别和推理模型可以直接输出音频增量不需要等一整句话结束。这就好比原来是你写完整封信再寄出去等回信现在变成了打电话边说边听。1.2 双向流式会话的本质事件驱动的状态机Realtime API不是简单的发音频收音频它是一套基于事件event的会话协议。客户端和服务端通过WebSocket交换JSON事件每个事件有明确的type字段。整个会话是一个状态机从session创建、到对话进行、到响应生成、到会话结束每一步都有对应的事件类型。理解这一点非常重要因为很多人第一次接的时候以为只要把音频丢过去就能收到回复结果发现要么没反应要么收到一堆看不懂的事件。实际上你需要监听服务端发来的事件根据事件类型决定下一步动作。比如收到response.audio.delta说明有音频增量了要立刻推给播放器收到response.done说明这一轮回复结束了可以准备下一轮输入。这套事件机制的好处是灵活你可以精确控制什么时候打断、什么时候提交输入、什么时候取消响应。坏处是心智负担比简单的请求-响应模式高必须把状态管理做扎实否则很容易出现音频还在播但用户已经说了新话这种状态错乱。1.3 和流式ASR流式TTS方案的本质区别有人会问我用流式ASR加流式TTS再配一个流式大模型不也能做到低延迟吗理论上可以但工程复杂度完全不是一个量级。你需要自己维护三条流之间的时序对齐处理打断时的资源回收还要保证ASR的中间结果和TTS的输入文本能对上。任何一条流的抖动都会传导到整条链路。Realtime API把这些都封装在服务端了。模型内部直接处理音频输入和音频输出中间不需要你拼接文本。它甚至支持在生成音频的同时输出文本转录方便你做日志和内容审核。代价是你对中间过程的控制粒度变粗了比如你没法单独替换ASR模型也没法在文本层面做精细的后处理。所以选型的时候要想清楚如果你需要深度定制每一环自建链路更合适如果你要的是快速上线和稳定低延迟Realtime API是更省事的选择。2. 建立WebSocket连接前必须想清楚的几件事2.1 鉴权方式与密钥管理Realtime API的鉴权走的是标准的Bearer Token在建立WebSocket连接时通过请求头或者子协议传递。这里有个很容易踩的坑很多人习惯把API Key直接写在前端代码里这在Realtime场景下风险极高因为WebSocket连接是长连接密钥暴露的时间窗口很长。正确的做法是后端做一层代理。前端连你自己的后端后端用密钥去连Realtime API中间做一次转发。这样密钥永远不出服务端前端拿到的是一个短期有效的会话凭证。如果你确实需要前端直连那至少要生成临时Token并且设置很短的过期时间。我在实际项目里用的是后端代理方案架构是浏览器WebSocket连Node服务Node服务再开一条WebSocket连Realtime API两条连接之间做事件转发。多了一跳但延迟增加不到20ms安全性提升是值得的。2.2 连接参数里的模型与音频格式选择建立连接时需要在URL里指定模型和音频格式。模型方面实时语音场景通常用专门的realtime模型不要用普通的文本模型去凑。音频格式方面输入推荐PCM16采样率24kHz单声道。这个格式是未压缩的原始音频服务端处理起来最快不需要额外解码。输出格式同样建议PCM16。如果你要用在网页上播放PCM16需要前端自己做一下封装转成AudioBuffer再送进Web Audio API。如果嫌麻烦也可以选服务端支持的压缩格式但会引入编解码延迟。我的经验是网页端用PCM16配合AudioWorklet延迟最低可控性最好。采样率这块要注意匹配。如果你麦克风采集的是48kHz直接发过去服务端会按24kHz解读声音会变调。必须在客户端做重采样或者用支持指定采样率的采集接口。这个坑我第一次就踩了发过去的音频听起来像快进排查了半天才发现是采样率没对齐。2.3 连接保活与断线重连策略WebSocket是长连接但长连接不等于永远不断。网络切换、服务端主动回收、中间设备超时都会导致连接断开。Realtime API的会话是有状态的连接一断当前会话就没了正在生成的响应也会丢失。所以必须做断线重连。我的做法是客户端监听close事件一旦断开立即用指数退避重连第一次等500ms第二次1s第三次2s最多重试5次。重连成功后新建会话把最近的对话历史通过文本形式补回去让模型知道上下文。虽然音频上下文丢了但至少对话逻辑能接上。保活方面可以定期发送一个轻量的客户端事件保持连接活跃。但不要发太频繁否则会占用带宽和配额。我一般30秒发一次实测下来连接稳定性明显提升。3. 音频采集、编码与实时推送的完整实现3.1 浏览器端麦克风采集的正确姿势网页端采集音频首选getUserMedia配合AudioContext。但直接用MediaRecorder不行因为它输出的是压缩格式而且有缓冲延迟高。正确做法是用AudioWorklet在音频线程里直接拿PCM数据。具体流程是getUserMedia拿到MediaStream创建AudioContext把MediaStream源接到一个AudioWorkletNode上。在AudioWorklet的process回调里每一帧会拿到Float32Array的采样数据把它转成Int16Array也就是PCM16然后通过postMessage发回主线程主线程再通过WebSocket发出去。这里有个细节AudioContext的采样率默认跟随系统可能是44.1kHz或48kHz。你需要在创建AudioContext时指定采样率为24kHz或者拿到数据后自己做重采样。指定采样率更省事但不是所有浏览器都支持任意采样率所以最好还是加一层重采样兜底。3.2 PCM16转换与分帧发送的节奏控制PCM16的转换很简单Float32的范围是-1到1乘以32767再取整就是Int16。但分帧发送的节奏有讲究。发太快服务端处理不过来会积压发太慢延迟会上去。我的经验是每100ms发一帧每帧2400个采样点24kHz下100ms就是2400个点对应4800字节。为什么是100ms因为这是延迟和网络开销的平衡点。50ms一帧的话包数量翻倍网络头部开销占比上升200ms一帧的话用户说完话到服务端收到完整音频的延迟就多了100ms。100ms实测下来最舒服。发送的时候要注意WebSocket的send是异步的不要在一个循环里疯狂send要根据bufferedAmount做背压控制。如果bufferedAmount超过某个阈值比如1MB就暂停发送等它降下来再继续。否则内存会涨严重时页面会卡死。3.3 输入音频的静音检测与自动提交Realtime API支持服务端的语音活动检测VAD也就是说你不需要手动告诉它用户说完了它会自己判断。但服务端VAD有延迟而且对背景噪音敏感。我的做法是客户端也做一层简单的能量检测当连续300ms的能量低于阈值时认为用户说完了主动发送一个提交事件。这样做的好处是响应更快而且可以避免服务端VAD在嘈杂环境下误判。但要注意客户端检测不能太激进否则用户说话中间的停顿会被误判为结束。我用的阈值是-40dB连续300ms低于这个值才提交实测下来误判率很低。另外提交之后要立即停止发送音频直到收到响应结束事件。否则用户的新语音会和正在生成的响应冲突导致模型混乱。4. 服务端事件处理与会话状态管理4.1 必须监听的核心事件类型服务端会发来很多类型的事件但真正需要处理的核心事件就那么几个。我整理了一张表把关键事件和对应的处理动作列出来事件类型含义处理动作session.created会话创建成功记录session id开始发送音频response.audio.delta音频增量Base64解码后推给播放器response.audio_transcript.delta音频对应的文本增量追加到文本缓冲区用于显示字幕response.done本轮响应结束停止播放准备接收下一轮输入input_audio_buffer.speech_started检测到用户开始说话如果正在播放立即打断error发生错误记录错误码决定是否重连其中input_audio_buffer.speech_started这个事件特别重要它是实现打断的关键。当用户在模型说话时开口服务端会发这个事件你收到后要立刻停止播放当前音频并取消正在进行的响应。这样用户就能自然地打断模型体验接近真人对话。4.2 打断处理的时序陷阱打断这件事听起来简单做起来坑很多。最大的坑是时序你收到speech_started事件时可能已经有一些audio.delta在路上了这些音频如果继续播放会和用户的新语音重叠听起来很乱。我的处理方式是收到speech_started后立即做三件事。第一清空播放器的缓冲区把还没播的音频丢掉。第二发送response.cancel事件告诉服务端取消当前响应。第三重置本地的状态标志把isPlaying设为false。这三件事要按顺序做而且要在同一个事件循环里完成不能有异步等待。还有一个坑是取消响应之后服务端可能还会发来几个audio.delta这是取消指令到达前的残留。所以取消之后要设置一个短暂的忽略窗口比如200ms内收到的audio.delta直接丢弃。这个窗口不能太长否则会丢掉新响应的音频。4.3 会话上下文的维护与截断Realtime API的会话是有上下文记忆的模型能记住之前几轮对话。但上下文不是无限的超过一定长度后早期的内容会被截断。如果你需要模型记住很久之前的信息就得自己维护一份对话历史在合适的时机通过文本形式重新注入。我的做法是每轮对话结束后把用户输入的转录文本和模型输出的转录文本存到本地数组里。当会话重连或者上下文明显丢失时把最近10轮对话拼成一段文本通过conversation.item.create事件注入回去。这样模型就能接上之前的逻辑。但要注意注入的文本会占用上下文窗口不要注入太多。10轮是个比较安全的数字再多的话当前对话的可用窗口就变小了。5. 实测中暴露的五个典型问题与排查过程5.1 音频播放出来是快进或慢放这个问题我第一次遇到时懵了很久明明代码逻辑没问题但声音就是不对。排查过程是这样的先确认采集端的采样率发现AudioContext默认是48kHz而我发送时按24kHz处理等于把48kHz的数据当24kHz发服务端按24kHz播放速度就快了一倍。解决办法有两个一是在创建AudioContext时显式指定sampleRate为24000二是拿到数据后做重采样。我选了第一种因为更简单。但不是所有浏览器都支持指定采样率所以加了个判断如果不支持就降级到重采样方案。重采样用的是线性插值虽然音质有损失但语音场景下听不出来。5.2 连接建立成功但收不到任何响应这种情况通常是事件发送顺序有问题。Realtime API要求先创建会话再发送音频最后提交。如果你在session.created事件到达之前就发音频服务端会忽略。我当时的代码是连接一建立就立即发音频结果前几帧全丢了。正确的顺序是WebSocket的onopen回调里不要急着发音频而是等服务端发来session.created事件在事件处理里再启动音频采集和发送。这个顺序不能乱否则就是白发。还有一个可能原因是音频格式不对。如果服务端期望PCM16但你发了别的格式它可能解析失败但不报错只是静默丢弃。所以格式一定要按文档来不要想当然。5.3 打断后模型仍然继续说话这个问题的根因是取消指令没有及时到达或者到达了但本地播放器没停。排查时我先在服务端日志里确认response.cancel有没有发出去确认发出去了。然后检查客户端发现播放器的缓冲区没有清空虽然停止了新的音频写入但缓冲区里还有几百毫秒的音频在播。解决办法是在收到speech_started时除了发取消指令还要主动清空播放器的AudioBuffer队列。我用的是Web Audio API做法是断开当前source node重新创建一个新的。这样缓冲区里的音频就全部作废了。实测下来打断延迟从原来的500ms降到了100ms以内。5.4 长时间运行后内存持续增长跑压力测试的时候发现连续对话30分钟后浏览器内存涨到了1GB以上。排查发现是两个地方泄漏一是AudioWorklet的postMessage没有做节流每帧都发消息队列积压二是音频缓冲区没有及时释放旧的AudioBuffer一直挂在引用上。修复方式是AudioWorklet里做批量发送攒够100ms的数据再postMessage一次而不是每128个采样点就发。播放器那边每次播放完成后主动把AudioBuffer引用置空让GC能回收。改完之后跑2小时内存稳定在200MB左右。5.5 弱网环境下音频断续严重在模拟弱网的测试里限制带宽到500kbps丢包率5%音频出现明显断续。原因是PCM16未压缩24kHz单声道下每秒是48KB也就是384kbps加上WebSocket头部开销500kbps带宽下余量很小一丢包就卡。优化方案是降低发送码率。可以把采样率降到16kHz这样每秒32KB带宽压力小很多。语音场景下16kHz足够清晰听感上差别不大。另外可以开启WebSocket的permessage-deflate压缩虽然PCM16压缩率不高但也能省10%到15%的带宽。改完之后同样的弱网条件下断续明显减少。6. 从Demo到生产还需要补哪些能力6.1 音频前处理降噪与回声消除Demo阶段用耳机麦克风环境干净效果很好。但生产环境用户可能用外放会有回声和背景噪音。这时候需要开启浏览器的音频前处理getUserMedia的constraints里可以指定echoCancellation、noiseSuppression、autoGainControl都为true。但要注意这些前处理会引入额外延迟大概20到50ms。如果对延迟极度敏感可以只开回声消除关掉降噪和自动增益。回声消除是必须的否则模型会听到自己的声音陷入自问自答的死循环。我实测过不开回声消除的话模型确实会把自己的输出当成用户输入然后一直说下去。6.2 会话录制与合规留痕生产环境通常需要录制对话内容用于质量分析和合规审计。Realtime API本身不提供录制功能需要你自己做。我的做法是在服务端代理层做旁路录制把客户端发来的音频流和模型返回的音频流分别写到文件里同时把转录文本也存下来。存储格式建议用WAV方便后续处理。转录文本存JSON带上时间戳和角色标识。注意录制要征得用户同意这是合规的基本要求。技术上可以在会话开始时播放一段提示音或者在前端做一个明确的授权弹窗。6.3 多轮对话的意图保持与话题切换实时语音交互里用户经常会突然切换话题或者在一句话里包含多个意图。模型有时候会跟不上回答到一半发现话题变了。这时候需要做一些引导。我的经验是在系统提示词里明确告诉模型如果用户切换话题先简短确认再回答新话题。另外可以在检测到用户输入包含明显的转折词时主动注入一个系统消息提醒模型注意话题变化。这个技巧在多轮对话里很管用能明显减少答非所问的情况。6.4 成本控制Token消耗与并发限制Realtime API按音频时长和Token消耗计费长时间开着麦克风但没人说话也是要花钱的。所以必须做空闲检测超过一定时间没有语音活动就自动断开连接等用户再次说话时重连。并发方面每个会话占用一个连接服务端有并发上限。如果用户量大需要做连接池或者排队机制。我的做法是设置一个最大并发数超过之后新用户进入等待队列前端显示正在排队。虽然体验有损但比直接报错好。7. 一些容易被忽略的工程细节7.1 时间戳对齐与字幕同步如果你要在界面上显示字幕字幕和音频必须对齐。Realtime API的转录事件是增量到达的每个delta对应一小段音频。我的做法是给每个delta打上本地时间戳播放器播放到对应时间点时高亮对应的文本。这样字幕就能跟着语音走不会出现声音到了字幕还没到的情况。但要注意网络抖动会导致delta到达时间不均匀所以时间戳不能直接用到达时间要用音频的累计时长来算。每收到一个audio.delta就累加它的时长用累计时长作为字幕的时间轴。这样即使网络抖动字幕和音频也是对齐的。7.2 移动端后台切换的处理移动端浏览器切到后台时AudioContext会被挂起麦克风采集停止但WebSocket连接可能还活着。这时候如果服务端继续发音频客户端收不到也播不了回来之后就会积压一堆音频。处理方式是在页面可见性变化时做相应操作。切到后台时主动发送取消事件停止当前响应并暂停音频发送。切回前台时重新激活AudioContext恢复发送。这样能避免后台期间的资源浪费和状态错乱。iOS上尤其要注意Safari对后台音频的限制很严格不处理的话回来基本是废的。7.3 错误重试的边界条件不是所有错误都值得重试。比如鉴权失败401重试多少次都没用应该直接提示用户检查配置。而网络超时504或者服务端临时不可用503重试是有意义的。我的策略是4xx错误不重试5xx错误重试重试上限3次每次退避时间翻倍。还有一个边界是如果错误发生在响应生成过程中重试会导致用户听到重复的内容。所以重试前要判断当前状态如果已经播放了一部分响应就不要重试而是把当前响应播完再在下一轮恢复。这个逻辑有点绕但能避免用户体验上的割裂。7.4 日志与可观测性建设实时语音系统的调试比普通接口难得多因为涉及音频流和事件流两条线。我的做法是把所有事件都打上时间戳记到日志里包括发送的和接收的。同时记录音频的帧序号和时长这样出问题的时候可以精确复现。关键指标要监控连接建立耗时、首音频延迟、端到端延迟、打断响应时间、错误率。这些指标能帮你快速定位问题。比如首音频延迟突然变大可能是网络问题或者服务端负载高打断响应时间变长可能是客户端播放器没及时清空。我在实际项目里踩过的坑远不止上面这些但核心的经验就一条实时语音交互的难点不在能不能通而在稳不稳。Demo跑通可能只要半天但要让它在一百个用户、各种网络环境下都稳定需要处理的边界情况是Demo的十倍。所以如果你准备上生产建议先把弱网测试、长时间运行测试、并发测试做扎实再考虑上线。后面如果要做多语言或者情感识别可以在现有链路上叠加架构不用大改这也是选Realtime API的一个隐性好处。
返回列表