AI语音助手/实时对话架构设计
做语音对话时延迟高、打断难,ASR与TTS怎么和大模型串起来做到边说边答,一直没有清晰的实时链路方案。
有人这样说「我们做语音助手,现在说话老卡顿,边说边答的链路一直没跑通。」
能替你选的都选好了。不想动,直接点下面「交给 AI 办」,点一下就开始做;想改,就点一下要的那个。
模板:已按内容配好换一套
标题、表头的配色和字体
已替你选好想改就点一下
只有你知道的不填就先空着,成品里留【待填】
还想怎么改可以不选
高级选项一般不用动 · 这件事通常 1 分钟左右做好
示例成品全文按上面这组示例选项真跑出来的
语音助手“边说边答”架构:直接给骨架
先给结论:你大概率卡在三个地方:
- ASR 等整句 final 才给大模型。
- 大模型生成完一整段才给 TTS。
- TTS 不是流式,首包延迟高。
正确链路是:
20ms 音频流 → 流式 ASR 出 interim → 提前判断“能答了” → OpenAI 流式 LLM → 句子级流式 TTS → 浏览器边收边播。
用户一说话,就打断当前 LLM/TTS。
1. 两条落地路线
路线 A:先用 OpenAI Realtime API 跑通,最省事
如果你不强制自己拆 ASR / LLM / TTS,OpenAI Realtime API 是最快的:
- 浏览器采集音频。
- 后端只做 OpenAI Realtime API 的转发和鉴权。
- 音频进出都由 OpenAI 完成。
- 天然支持边听边说、打断、流式。
适合先把产品跑起来验证体验。
路线 B:自己串 ASR → LLM → TTS
如果要做自建串行链路,下面这套骨架可直接拿过去改。
2. 目标架构
React 浏览器
- 麦克风 PCM16/16k,每 20ms 一包
- WebSocket 发二进制音频
- 收到后端 PCM 后 AudioContext 播放
- 本地检测用户说话,触发 interrupt
│
│ WebSocket binary + JSON
▼
Python FastAPI
- 音频队列
- 流式 ASR:interim / final
- Turn 控制器:提前触发 LLM
- OpenAI 流式 LLM
- 句子缓冲 → 流式 TTS
- 上下文摘要 / 滑动窗口核心原则:
- 音频用二进制 WebSocket,不要用 JSON base64。
- ASR 必须流式,能吐 interim。
- LLM 必须流式。
- TTS 必须流式或至少按小句子合成。
- 打断不是单纯前端停止播放,后端必须取消当前任务。
3. 前后端通信协议
前端 → 后端
binary: PCM16/16k 音频分片,20ms~40ms
JSON:
{ "type": "interrupt", "reason": "user_speech" }后端 → 前端
{ "type": "session", "session_id": "abc", "sample_rate": 16000, "tts_sample_rate": 24000 }
{ "type": "transcript", "status": "interim", "text": "我想问" }
{ "type": "transcript", "status": "final", "text": "我想问一下今天天气" }
{ "type": "llm_start", "epoch": 12 }
{ "type": "llm_delta", "text": "好的" }
{ "type": "tts_end", "epoch": 12 }
{ "type": "interrupt_ack" }后端还会发:
binary: TTS 音频 PCM16/24k4. React 前端关键骨架
下面代码是核心逻辑,生产建议把 ScriptProcessor 换成 AudioWorklet,但 ScriptProcessor 能跑。
// useVoiceChat.ts
import { useRef, useState } from "react";
const MIC_SAMPLE_RATE = 16000;
const TTS_SAMPLE_RATE = 24000;
export function useVoiceChat(sessionId: string) {
const wsRef = useRef<WebSocket | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const playQueueRef = useRef<AudioBufferSourceNode[]>([]);
const nextPlayTimeRef = useRef<number>(0);
const playingRef = useRef(false);
const playStartedAtRef = useRef(0);
const [liveText, setLiveText] = useState("");
async function start() {
const ws = new WebSocket(
`ws://localhost:8000/ws/voice?session_id=${sessionId}`
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onmessage = async (ev) => {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data);
if (msg.type === "llm_delta") {
setLiveText((prev) => prev + msg.text);
}
if (msg.type === "tts_end") {
playingRef.current = false;
}
return;
}
// 二进制音频
if (ev.data instanceof ArrayBuffer) {
playPCM(ev.data);
}
};
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(1024, 1, 1);
source.connect(processor);
processor.connect(ctx.destination);
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const inputRate = e.inputBuffer.sampleRate;
// 重采样到 16k,转 Int16
const pcm16 = downsampleTo16k(input, inputRate);
if (ws.readyState === WebSocket.OPEN) {
ws.send(pcm16.