基于WebRTC与Spring AI构建实时语音对话系统实战指南
最近我把一个语音交互实验项目跑通了:浏览器里打开页面,对着麦克风说一句话,系统捕获声音、识别意图、调用AI接口,再把AI的回答转成语音播出来。项目标题就是“基于WebRTC与AI接口构建实时语音对话系统”。如果把整条链路拆开看,真正花时间的不是调大模型接口,而是WebRTC这一层:采集、传输、链路容量估算、服务端对接,每一块都有坑。这篇记录从设计到落地的全过程,中间会穿插Spring AI 2.0接GLM接口的配置、WebRTC实例代码、线路容量估算方法,以及对AI接口调用、算力、API密钥权限的一些理解,给准备做类似系统的人一个可以参照的样本。
适合谁来参考?如果你已经会Spring Boot基本开发,但没碰过WebRTC,想做一个“能听会说”的AI语音助手,或者正准备在企业项目里接入实时语音对话能力,这篇内容应该能帮你把思路理清楚。我不会只贴能跑的代码,还会解释每个关键环节为什么这么设计,以及我在实验过程中踩过的坑。
1. 整体方案设计:实时语音对话系统的核心链路与选型
1.1 核心链路拆解:从麦克风到AI回复要过哪些环节
一个实时语音对话系统,从用户开口到听到回复,中间至少经过这样一条链路:
浏览器麦克风采集音频 -> WebRTC编码成Opus流 -> 经过STUN/TURN完成网络穿透 -> 媒体网关接收并解码成PCM -> 语音识别(ASR)转成文本 -> 大模型生成回复文本 -> 语音合成(TTS)转成音频 -> 浏览器播放。
这条链路里,WebRTC解决的是“实时音频传输”的问题,AI接口解决的是“语义理解与生成”的问题。两者是独立的,但把它们串起来时,细节非常多。
最典型的一个问题是:全双工还是半双工?如果只是按下按钮说话、松手停止,用WebSocket传录音也能做,但体验很生硬。我要做的是让用户像打电话一样自然交流,所以必须选支持双向实时音频传输的方案。另一个问题是延迟预算:从用户说完话到AI开始回复,不能超过1秒,否则对话感就没了。这个预算一旦确定,后面的ASR、LLM、TTS选型都要围绕它来设计。
1.2 为什么实时语音场景首选WebRTC而不是WebSocket
在做技术选型时,最容易想到的方案是WebSocket:浏览器采集音频,定时把二进制音频块推给服务端,识别后再推回来。这个方案在原型阶段可行,但放到真实语音对话场景里,会不断遇到以下问题:回声消除、背景噪声抑制、网络抖动、丢包重传、断线恢复,全部要自己实现。WebSocket本质上是一个通用消息通道,它不关心你传的是音频还是JSON,也不提供任何音频处理能力。
WebRTC则把这些问题都封装好了。它内置Opus音频编码、自动增益控制、回声消除、噪声抑制、抖动缓冲和丢包隐藏。浏览器端只要调用getUserMedia拿到麦克风流,再交给RTCPeerConnection,剩下的网络适配和音频质量处理都由协议栈解决。用一张表可以看得更清楚:
| 能力 | WebSocket方案 | WebRTC方案 |
|---|---|---|
| 音频编码 | 应用层自己处理 | 内置Opus/G711等编码 |
| 回声消除 | 无,需自行集成 | 内置AEC/ANS/AGC |
| 抖动缓冲 | 无 | 内置NetEq |
| 丢包恢复 | 无,需自己设计 | 内置丢包隐藏与重传策略 |
| NAT穿透 | 不适用 | 内置ICE/STUN/TURN |
| 全双工支持 | 可以做但较复杂 | 原生支持 |
当然,WebRTC的代价是复杂度。信令交换、ICE候选、SDP协商、TURN服务部署,这些东西对新手很不友好。但如果你做的是实时语音对话系统,这个复杂度是值得付的。项目目标,就是把这个复杂度拆解掉。
1.3 AI接口选型:Spring AI 2.0接GLM,还是裸调HTTP
语音对话系统里,AI接口是大脑。选型时面临一个很直接的问题:用Spring AI这样的框架封装,还是直接写HTTP调用?
我这边选的是Spring AI 2.0接GLM接口。原因有三个:Spring AI已经把模型调用抽象成了统一的ChatClient接口,项目本身是Spring Boot,集成成本低;GLM提供了OpenAI兼容接口,可以直接通过Spring AI的OpenAI模型模块接入;语音对话场景需要流式输出,Spring AI对流式返回封装得比较完整,不用自己解析SSE格式。
如果你问“生成Spring Boot项目时选择哪个AI”,我的建议是:优先选提供OpenAI兼容接口的模型。因为Spring AI生态里OpenAI协议适配最成熟,无论是GLM、通义还是其他国内模型,多数都支持OpenAI兼容模式,这样代码里只需要改base-url、api-key、model名称,不需要为每家厂商写独立的SDK逻辑。
如果只是做一次性实验,裸调HTTP也不是不行。但一旦要处理多轮对话、函数调用、流式输出、模型切换,Spring AI的抽象价值就体现出来了。后面第3章我会给出完整的配置和代码。
2. WebRTC技术详解与浏览器端实例
2.1 WebRTC三件套:采集、信令、传输
WebRTC并不是一个单点API,它由三块能力组成:媒体采集、信令协商、媒体传输。
媒体采集通过
navigator.mediaDevices.getUserMedia
完成,拿到麦克风或摄像头轨道。信令协商负责交换双方的SDP和ICE候选信息,这一步需要一个信令服务器,通常用WebSocket实现。媒体传输则是RTCPeerConnection建立之后的P2P通道,音频视频直接在这条通道上跑。
需要注意,信令服务器只传控制信息,不传媒体流。整个流程大概是:浏览器A创建RTCPeerConnection,生成offer SDP,通过信令服务器发给浏览器B;B收到后生成answer SDP回传;两边同时通过ICE协议探测最优网络路径,交换ICE候选。候选交换完成后,媒体流直接在选定的路径上传输。
在实际项目里,浏览器直接P2P连接的场景很少,因为客户端与服务端之间通常要经过媒体网关。网关同样作为WebRTC的Peer,参与SDP协商,但在媒体传输时承担转发和音频处理职责。我的实验环境就是:浏览器与媒体网关建立WebRTC连接,网关把音频解出来送给Spring Boot服务,Spring Boot调用AI接口。
2.2 浏览器端采集麦克风与建立连接的参考代码
前端是整个系统的入口。下面这段代码是浏览器端建立WebRTC连接的核心部分,可以直接放在一个普通的HTML页面上测试:
async function startChat(wsUrl) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
stream.getTracks().forEach(track => pc.addTrack(track, stream));
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription }));
}
这段代码里有几个地方值得特意说明。
echoCancellation
、
noiseSuppression
、
autoGainControl
三个参数必须在获取麦克风时就打开,否则外放场景下很容易产生啸叫和回声。实验时我试过只传
{ audio: true }
,结果声音里全是环境噪音和回声,后来把这三个参数显式打开,问题立刻缓解。
另外一个容易被忽视的点是
pc.onicecandidate
。很多新手建立连接后一直卡在“呼叫中”,就是因为候选信息没有通过信令服务器转发给对方。SDP交换完成后,两边还需要不断交换ICE候选,才能找到可用的网络路径。公共STUN服务器只能用于内网或简单网络环境,生产系统建议自建STUN,或直接部署TURN服务做中继。
2.3 WebRTC语音链路容量估算:别只看音频码率
项目做到后半段,我开始考虑一个实际问题:如果系统有1000路并发语音对话,服务器和带宽到底要准备多少?这就是“WebRTC链路容量估计”的核心问题。
最容易犯的错误是只看音频编码码率。比如Opus默认码率32kbps,觉得1000路只有32Mbps,服务器轻松扛住。实际上网络协议头开销会把这个数字推高一截。以Opus每20毫秒一个包为例,每秒钟50个音频包,每个包的有效负载只有80字节左右。但每个包还要加上RTP头12字节、UDP头8字节、IP头20字节,如果考虑以太网帧头还要再加14字节。简单计算如下:
单个音频包总大小约 80 + 12 + 8 + 20 + 14 = 134 字节。
每秒50个包,所以单路上行媒体带宽约 134 × 8 × 50 = 53.6kbps。
全双工通信需要上下行各一路,所以单路总媒体带宽约 107.2kbps。1000路并发就是 107Mbps左右,这还没算信令、ICE连接检查、DTLS加密握手、TURN中继等额外开销。
所以我给出的经验值是:全双工语音单路按150-200kbps预留,1000路并发至少准备200Mbps有效媒体带宽,信令带宽另算。更保守一点,线上容量规划建议按峰值并发的3倍做冗余,因为音频波动、重传和抖动缓冲都会吃掉额外带宽。
3. Spring Boot服务端与AI接口对接实战
3.1 服务端怎么接住WebRTC音频流
这里要面对一个现实:Java生态里没有像Python的aiortc那样成熟、好用的WebRTC媒体库。如果试图在Spring Boot进程内直接完成WebRTC SDP协商并接收音频流,开发成本会非常高。更合理的架构是拆分两个角色:媒体网关负责WebRTC接入,Spring Boot服务负责业务逻辑和AI接口调用。
我实验时采用的方案是:浏览器与媒体网关建立WebRTC连接,网关把接收到的音频解码成PCM数据,再通过内部消息通道发给Spring Boot服务。Spring Boot接收到音频帧后,先做VAD检测和分句,再把完整句子送去ASR识别,最后把识别文本交给大模型。这种方式的好处是,如果后续要换ASR或TTS,服务端不用改WebRTC相关代码。
如果你只是想快速验证效果,也可以退而求其次:浏览器用getUserMedia采集音频,通过WebSocket把PCM或压缩音频块发给Spring Boot。但要注意,这就不是严格的“WebRTC实时语音对话”了,回声消除、抖动缓冲都要自己处理。我之所以建议保留媒体网关,就是因为把专业的事交给专业的组件,业务服务才能保持简单。
3.2 Spring AI 2.0 接 GLM 接口的配置与代码
服务端的核心是AI接口对接。我在pom.xml里引入的是Spring AI的OpenAI模型starter,因为GLM的API兼容OpenAI协议。依赖如下:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
配置文件里把base-url指向GLM的接口地址,API密钥通过环境变量注入,避免硬编码:
spring:
ai:
openai:
base-url: https://open.bigmodel.cn/api/paas/v4
api-key: ${GLM_API_KEY}
chat:
options:
model: glm-4-flash
然后注入
ChatClient
。Spring AI 2.0里的ChatClient用法很简洁,我写了一个语音助手的服务类:
@Service
public class VoiceAssistantService {
private final ChatClient chatClient;
public VoiceAssistantService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public Flux<String> chat(String userText) {
return chatClient.prompt()
.system("你是一个语音助手,回答尽量简短,适合TTS朗读。")
.user(userText)
.stream()
.content();
}
}
这里最关键的设计是使用流式返回。语音交互场景下,用户对首字的等待时间非常敏感,如果等大模型生成完一整段再回复,体验会明显变差。用
stream().content()
可以把回复内容分片吐出,后续接TTS时可以做增量合成,用户还没听完上一句,下一句已经准备好了。
3.3 流式返回、VAD与打断控制的处理思路
流式返回只是第一步。要让系统像真人一样对话,还必须处理“用户何时说完话”和“AI播报时用户突然插话”这两个问题。
VAD(语音活动检测)就是用来解决第一个问题的。我在服务端用了一个轻量级VAD模块,对网关送来的PCM帧逐帧计算能量和静音比例。当检测到一段连续语音结束后,就把这一段音频送去ASR识别。第二问题的处理则是“打断控制”:如果AI正在通过TTS播报时,服务端检测到用户又开始说话,就立即停止TTS合成,把当前播报打断,进入新一轮的识别流程。
这个逻辑在代码里可以用状态机管理。简单来说就是三个状态:静音监听、用户说话中、AI播报中。每次状态切换都触发相应的处理动作。实验中发现,只做“说完再识别”不够,因为没有打断机制的语音助手用起来很憋屈,用户想插话时只能干等。所以我的建议是,任何一个实时语音对话系统,上线前必须把打断功能做进去,否则体验永远差一口气。
4. AI接口调用、算力消耗与API密钥权限实战
4.1 API密钥和权限管理:密钥只能待在服务端
语音对话系统要调用大模型接口、ASR接口、TTS接口,每一种接口都有对应的API密钥。密钥管理是安全问题,不是配置问题。
我在实验初期图省事,把
api-key
直接写在前端代码里,结果通过浏览器开发者工具就能看到完整的密钥,而且任何拿到这个页面的人都可以绕过我的业务逻辑直接调用模型接口,刷我的额度。后来我立刻改成:前端只负责上传音频,所有密钥都放在Spring Boot服务端环境变量或配置中心里,前端永远接触不到。
密钥权限还要遵循最小化原则。比如开通GLM密钥时,如果系统只用对话生成,就不要给文件处理、知识库管理等无关权限。团队开发时应为每个环境分配独立密钥,并设置月度调用配额和告警。这样一旦某个环境的密钥被泄露,可以单独吊销,不需要影响其他环境。
4.2 算力消耗在哪里:一次语音交互的成本拆解
AI接口调用并不等于免费的算力。实时语音对话系统里,算力消耗主要分为三块:ASR语音识别、大模型推理、TTS语音合成。三者的计费方式和成本模型完全不同。
| 环节 | 计费方式 | 典型延迟 | 成本敏感点 |
|---|---|---|---|
| ASR | 按音频时长秒数计费 | 300-800ms | 用户说话越长成本越高 |
| 大模型 | 按输入输出token数计费 | 首token 300-1000ms | 上下文越长成本越高 |
| TTS | 按合成字符数计费 | 100-500ms | 回复越长成本越高 |
我做过一次粗略测算:用户说“今天天气怎么样”,录音约3秒,ASR成本大概是按3秒音频计费,也就是一个很小的阶梯费用。识别出的文本不到10个token,大模型回复可能20个字,输入输出加起来约60个token,费用也很低。TTS把20个汉字合成语音,按字符计费。三者加在一起,单次交互大概是零点几分钱量级。
但要注意峰值算力。如果系统有1000路并发,每路都在做ASR识别和模型推理,那对GPU或云服务的并发额度消耗会非常快。所以成本优化的思路是:ASR和TTS尽量用流式和批量能力,大模型选便宜且响应快的模型如glm-4-flash,不必每次都上最强的参数版本。
4.3 接口限流与故障响应的处理策略
AI接口再稳定也难免出现限流或故障。我遇到过GLM接口返回429限流的情况,也遇到过ASR接口在高峰期响应变慢。如果代码里没有兜底策略,用户就会直接听到“系统错误”。
处理思路是:重试要有但必须带退避。最简单的指数退避重试逻辑如下:
public String callWithRetry(String prompt, int maxAttempts) {
for (int i = 0; i < maxAttempts; i++) {
try {
return chatClient.prompt()
.user(prompt)
.call()
.content();
} catch (Exception e) {
try {
Thread.sleep(1000 * (long) Math.pow(2, i));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
return "抱歉,我暂时处理不了,请稍后再试。";
}
除了重试,还要做降级。对时间、天气这类常见问题,可以缓存通用答案,不每次请求大模型。如果大模型接口完全不可用,至少让用户听到一个友好的提示音和一局“语音服务暂时不可用”,而不是静音或报错。另一个经验是,日志里记录每次AI调用的状态码和耗时,配合接口平台的可视化监控,能快速定位是密钥问题、额度问题还是模型过载问题。
5. 常见问题与排查技巧实录
5.1 各类故障快速定位速查表
整个实验过程中,我整理了一张故障速查表,基本覆盖了最容易踩的坑。
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 浏览器不弹麦克风授权 | 页面不是https/localhost | 查看地址栏协议 | 本地用localhost,线上配置HTTPS |
| 建立连接失败 | 信令服务器没有转发ICE候选 | 看WebSocket消息是否完整 | 检查onicecandidate回调 |
| 能连接但听不到声音 | 服务端没有接收或转发音频轨道 | 查看RTCPeerConnection的track状态 | 调用addTrack并确认远端收到流 |
| 回声和啸叫 | 外放场景没开回声消除 | 使用耳机测试 | 在getUserMedia中开启AEC |
| 首字延迟过高 | ASR等待完整句子或者LLM非流式 | 记录各环节时间戳 | 使用流式识别和流式生成 |
| API返回401 | 密钥错误或权限不足 | 查看HTTP状态码和响应体 | 检查密钥、权限、过期时间 |
| API返回429 | 触发限流 | 查看响应头Retry-After | 指数退避重试,增加配额 |
| 回复乱码或空内容 | 模型参数错误或system提示词问题 | 直接调用接口测试 | 简化system提示词,检查编码 |
这张表里最让我印象深刻的是一次“能连接但听不到声音”的排查。前端一切正常,信令也正常,但服务端始终没有收到音频。后来发现是媒体网关上有一个参数强制禁用了音频轨道的接收,前端发过来的轨道虽然建立了,却被网关丢弃了。这类问题光看信令日志发现不了,必须看WebRTC内部的收发统计。
5.2 排查工具与调试手段分享
WebRTC调试不能靠猜,要会用工具。Chrome浏览器内置的
chrome://webrtc-internals
页面是排查的第一利器。打开这个页面后,能看到每一路连接的RTP包收发数量、丢包率、抖动值、候选对类型、音轨状态。有一次我怀疑网络带宽不够,结果在这个页面看到发送端码率只有16kbps,原来是浏览器端把Opus码率降级了,根源是网络拥塞,而不是服务器问题。
服务端方面,我会在信令处理的关键节点打时间戳日志。比如用户从点击开始到SDP创建、信令发送、网关应答、远端轨道就绪,每一段都记录耗时。通过对比这些时间戳,就能快速定位延迟是发生在信令层还是媒体层。
音频质量无法用肉眼看出来,可以把网关收到的PCM音频落地成wav文件,用ffprobe检查时长和码率。如果录音内容正常但接入ASR后识别率低,问题多半在音频格式或采样率上,这时候单独播放落盘文件就能直接发现。
6. 实验收获与后续扩展
6.1 这次实验我收获最大的三点
整个过程跑下来,有三点是我觉得最值得写下来的经验。
第一,WebRTC的复杂度集中在信令和媒体链路,要分开设计。信令只是控制面,媒体链路才是实时语音的核心。不要把两者混在一个模块里,否则排查问题时无从下手。
第二,AI接口选型要优先考虑流式能力和OpenAI兼容性。语音对话不是传统的“请求-响应”模型,首字延迟比整体延迟更重要。选一个支持SSE流式输出的模型接口,配合Spring AI的抽象,能让代码简单一大截。
第三,API密钥权限和算力成本要在功能开发前就考虑。我在实验初期因为密钥直接暴露在前端,白白损失了一部分接口额度,这个教训很直接。密钥就是权限边界,权限边界不守住,系统做得再好也是给别人打工。
6.2 后续可以扩展的方向
这个项目目前还是单轮对话,下一阶段我打算把多轮对话上下文加进来。方案是用Redis缓存每个会话最近的对话记录,每次调用大模型时把上下文拼进prompt,这样用户问“那杭州呢”时,系统能记得前面聊过城市天气。
另一个方向是流式ASR。目前是检测到一句话结束后才识别,这会导致一定延迟。如果把ASR改成流式识别,用户边说边出文本,系统可以在用户还没说完时就预判意图,断句能力也会更强。配合上服务端VAD和打断控制,整个对话体验会更接近真人交流。
最后想提一点针对WebRTC链路容量估计的建议:上线前一定要按峰值并发做带宽和服务器规格评估,直接按“音频编码码率”估算会严重低估。先跑通浏览器到AI接口的最小闭环,再做容量和性能优化,这是最稳妥的顺序。
更多推荐
所有评论(0)