目录一、整体链路从麦克风到实时转写二、为什么 Voice Agent 要优先接流式 ASR三、前端浏览器获取麦克风音频四、后端FastAPI 负责信令aiortc 负责接音频五、aiortc 接收到的音频帧怎么转 PCM六、把 ASR 封装成一个可替换接口七、识别结果怎么回传前端八、实时 ASR 接入后还要看哪些指标九、接上 RAG 后链路会变成什么样十、常见问题FastAPI WebRTC 怎么实现实时语音识别WebRTC 音频怎么传给后端 ASRaiortc 接收到的音频帧怎么转 PCMVoice Agent 为什么需要流式 ASRDataChannel 和 WebSocket 哪个更适合返回识别结果实时 ASR 延迟应该怎么优化十一、总结上一篇我用 FastAPI WebRTC RAG 跑通了一个简化版 Voice Agent Demo浏览器和后端能建连用户问题可以通过 DataChannel 发到后端后端再用一个本地知识库返回回答。但那个 demo 还缺最关键的一步浏览器麦克风里的声音怎么实时变成文字如果要用 FastAPI WebRTC 接入流式 ASR可以把链路拆成四步浏览器用getUserMedia()获取麦克风音频WebRTC 把音频轨道传给后端后端用aiortc接收音频帧并转换成 ASR 可用的 PCM 数据流式 ASR 返回识别文本再通过 DataChannel 或 WebSocket 推回前端。也就是说Voice Agent 从“文本问答 Demo”走向“语音助手 Demo”中间最关键的一层就是流式 ASR。没有 ASRWebRTC 只是在传音频接入 ASR 后系统才真正能理解用户说了什么。一、整体链路从麦克风到实时转写一个最小的实时语音识别链路可以这样理解浏览器麦克风 - WebRTC audio track - FastAPI / aiortc 接收音频帧 - 转成 PCM / wav chunk - 发送给流式 ASR - 得到 transcript - DataChannel / WebSocket 返回前端 - 后续接 RAG / LLM / TTS这里有几个点很容易混在一起FastAPI 不是直接处理音频流它主要负责 HTTP 接口、信令、页面托管和业务接口WebRTC 负责低延迟传输浏览器音频aiortc负责在 Python 后端接住 WebRTC audio trackASR 负责把音频转成文字DataChannel 或 WebSocket 负责把识别结果推回浏览器。如果是 AI 语音客服、网页语音助手、实时语音对话系统这条链路基本都绕不开。差别只在于ASR 用本地模型还是云服务后面接不接 RAG最后是否用 TTS 把回答读出来。二、为什么 Voice Agent 要优先接流式 ASR做 Voice Agent 时我不建议一开始就把 ASR、RAG、LLM、TTS、转人工全部堆到一个 demo 里。更稳的顺序是先跑通 WebRTC 建连再接流式 ASR让语音能变成文字然后接 RAG把回答拉回知识库再接 LLM 生成回答最后接 TTS、打断、转人工和日志。原因很简单语音链路出问题时排查成本很高。如果用户说话后系统没反应可能是麦克风没采到音也可能是 WebRTC 没建连可能是音频格式不对也可能是 ASR 没返回还可能是后面的模型卡住了。所以第二步先接 ASR是为了把“实时音频输入”这件事单独验证清楚。三、前端浏览器获取麦克风音频前端先创建RTCPeerConnection再通过getUserMedia()获取麦克风音频轨道。constpcnewRTCPeerConnection();constdcpc.createDataChannel(events);dc.onmessage(event){constdataJSON.parse(event.data);console.log(ASR result:,data);};conststreamawaitnavigator.mediaDevices.getUserMedia({audio:true});for(consttrackofstream.getTracks()){pc.addTrack(track,stream);}这里的audio track会通过 WebRTC 传到后端。前端不需要自己把音频拆成 PCM也不需要手动上传 wav 文件。如果只是做最小 demo浏览器端只要负责三件事采集麦克风建立 WebRTC 连接接收后端返回的识别结果。四、后端FastAPI 负责信令aiortc 负责接音频后端先提供一个/offer接口接收浏览器传来的 SDP offer并返回 answer。fromfastapiimportFastAPI,Requestfromfastapi.responsesimportJSONResponsefromaiortcimportRTCPeerConnection,RTCSessionDescription appFastAPI()pcsset()app.post(/offer)asyncdefoffer(request:Request):paramsawaitrequest.json()offerRTCSessionDescription(sdpparams[sdp],typeparams[type])pcRTCPeerConnection()pcs.add(pc)pc.on(track)defon_track(track):iftrack.kindaudio:# 这里把音频轨道交给 ASR 处理start_asr_task(track)awaitpc.setRemoteDescription(offer)answerawaitpc.createAnswer()awaitpc.setLocalDescription(answer)returnJSONResponse({sdp:pc.localDescription.sdp,type:pc.localDescription.type,})这段代码只做两件事完成 WebRTC 信令协商并在收到音频轨道时启动 ASR 任务。真正关键的地方在track.recv()。五、aiortc 接收到的音频帧怎么转 PCM在aiortc里音频轨道是一个不断产生 frame 的对象。后端可以在循环里持续接收音频帧。asyncdefconsume_audio(track,asr_client):whileTrue:frameawaittrack.recv()pcmframe.to_ndarray()awaitasr_client.send_audio(pcm)这个例子只是说明思路。真实接 ASR 时还要处理三个问题采样率是否符合 ASR 要求例如 16k 或 48k声道数是否需要转成 monoframe 数据是否需要重新编码成 PCM bytes。很多 ASR 服务并不直接接受numpy.ndarray而是要求bytes。这时可以把音频帧转成指定格式后再发送。asyncdefconsume_audio(track,asr_client):whileTrue:frameawaittrack.recv()pcm_arrayframe.to_ndarray()# 根据 ASR 服务要求做重采样、声道转换和编码pcm_bytesconvert_to_pcm16(pcm_array)awaitasr_client.send_audio(pcm_bytes)这里的convert_to_pcm16()可以根据实际 ASR 服务来实现。比如本地模型、云厂商 ASR、FunASR、Whisper streaming、SenseVoice、讯飞、阿里云、火山等对输入格式的要求都不完全一样。六、把 ASR 封装成一个可替换接口为了避免 demo 和某一个 ASR 厂商绑死建议先抽象一个StreamingASRClient。classStreamingASRClient:asyncdefstart(self):passasyncdefsend_audio(self,pcm_bytes:bytes):passasyncdefreceive_text(self)-dict:passasyncdefclose(self):pass后面无论接本地模型还是云服务都尽量保持这几个方法不变。这样做的好处是Voice Agent 的主链路不用关心 ASR 厂商是谁只关心“音频发出去文字拿回来”。一个识别结果可以统一成这样的结构{type:asr_partial,text:我想查一下订单,is_final:false,latency_ms:320}最终句可以这样返回{type:asr_final,text:我想查一下订单为什么还没有发货,is_final:true,latency_ms:860}这两个字段很重要is_finalfalse表示中间识别结果前端可以实时显示is_finaltrue表示一句话基本结束可以交给 RAG 或 LLM。七、识别结果怎么回传前端识别结果可以通过 DataChannel 回传也可以通过 WebSocket 回传。如果这只是一个 WebRTC 语音助手 DemoDataChannel 更简单。因为音频轨道和事件消息都挂在同一个 PeerConnection 上状态更集中。pc.on(datachannel)defon_datachannel(channel):pc.asr_channelchannel当 ASR 返回文字时channel.send(json.dumps({type:asr_final,text:我想查一下订单为什么还没有发货,is_final:True},ensure_asciiFalse))如果你希望 ASR 和 WebRTC 解耦比如后面要接多个页面、多个会话、客服工作台、日志系统那么 WebSocket 会更清晰。我的建议是demo 阶段用 DataChannel生产环境根据会话管理复杂度选择 WebSocket 或消息队列无论用哪种方式都要带上session_id、trace_id和时间戳。八、实时 ASR 接入后还要看哪些指标接入 ASR 不等于语音助手就能用了。至少要看这几个指标首字延迟用户开始说话后多久能看到第一段识别文本最终句延迟用户说完后多久返回稳定结果识别准确率业务名词、数字、地址、订单号是否识别正确噪声鲁棒性办公室、门店、电话外放环境下是否能用中断处理用户说到一半停顿系统是否误判结束热词能力产品名、品牌名、业务术语能不能识别日志追踪每次识别是否记录音频时长、延迟、模型版本和错误原因。对 AI 语音客服来说ASR 错了后面的 RAG 和 LLM 很可能都会错。所以 ASR 是 Voice Agent 链路里最应该先测清楚的一层。九、接上 RAG 后链路会变成什么样当 ASR 返回asr_final后就可以把文字交给 RAG。asyncdefon_asr_final(text:str):docsretrieve(text)answercompose_answer(text,docs)returnanswer这一步的重点不是“让大模型自由发挥”而是让回答有依据。比如用户问“我的订单为什么还没发货”系统应该先根据用户身份和订单号查业务系统再检索售后政策、物流规则、发货说明。如果知识库和业务系统都没有依据就应该返回“当前资料不足需要转人工”而不是编一个看起来很顺的答案。这也是 Voice Agent 和普通语音聊天机器人的区别它不只是把话听懂还要知道哪些话能回答哪些话必须交给人工。十、常见问题FastAPI WebRTC 怎么实现实时语音识别核心做法是浏览器用 WebRTC 发送麦克风音频FastAPI 提供/offer信令接口后端用aiortc接收 audio track再把音频帧转换成 ASR 可用的 PCM 数据最后把识别结果通过 DataChannel 或 WebSocket 返回前端。WebRTC 音频怎么传给后端 ASR浏览器通过getUserMedia()获取麦克风音频再用pc.addTrack()加入 PeerConnection。后端aiortc会在on_track事件里拿到 audio track然后通过await track.recv()持续接收音频帧。aiortc 接收到的音频帧怎么转 PCM可以先用frame.to_ndarray()取出音频数据再根据 ASR 服务要求做重采样、声道转换和 PCM16 编码。不同 ASR 服务对采样率、声道数、编码格式要求不同不能直接假设所有服务都接受同一种格式。Voice Agent 为什么需要流式 ASR因为 Voice Agent 需要低延迟理解用户说话内容。如果只等用户整段说完再上传音频文件响应会很慢也很难支持打断、实时字幕和多轮对话。流式 ASR 可以边听边识别是实时语音助手和 AI 语音客服的基础能力。DataChannel 和 WebSocket 哪个更适合返回识别结果demo 阶段 DataChannel 更简单因为音频和文本事件都在同一个 WebRTC 连接里。生产环境如果需要更复杂的会话管理、日志系统、客服工作台或多端同步WebSocket 或消息队列会更清晰。实时 ASR 延迟应该怎么优化可以从几个方向入手减少音频 buffer选择更低延迟的 ASR 服务避免不必要的格式转换使用 VAD 判断说话边界把 ASR 中间结果及时返回前端并记录首字延迟和最终句延迟。十一、总结用 FastAPI WebRTC 接入流式 ASR本质上是在解决一个问题浏览器里的实时语音如何稳定地变成后端可处理的文字。最小链路可以概括为getUserMedia 采集麦克风 - WebRTC 传输音频 - aiortc 接收音频帧 - 转成 PCM - 流式 ASR 返回 transcript - DataChannel / WebSocket 推回前端 - 后续接 RAG、LLM、TTS 和转人工如果第一篇文章解决的是“Voice Agent 的最小闭环怎么搭”那么这一篇解决的是“语音输入层怎么补上”。下一步再往下做就可以把asr_final接到 RAG要求回答必须带引用依据再把回答接到 TTS形成真正的实时语音问答闭环。但在继续加能力之前我建议先把 ASR 这一层测清楚首字延迟、最终句延迟、识别准确率、业务热词、噪声鲁棒性和日志追踪。因为在 AI 语音客服里听错一句话后面所有智能都可能变成“认真地答错”。