示例成品 看全文 ↓

AI语音助手/实时对话架构设计

做语音对话时延迟高、打断难,ASR与TTS怎么和大模型串起来做到边说边答,一直没有清晰的实时链路方案。

免费看示例成品:文字 · Word方案报告 ›

有人这样说「我们做语音助手,现在说话老卡顿,边说边答的链路一直没跑通。」

能替你选的都选好了。不想动,直接点下面「交给 AI 办」,点一下就开始做;想改,就点一下要的那个。

模板:已按内容配好换一套

标题、表头的配色和字体

已替你选好想改就点一下

只有你知道的不填就先空着,成品里留【待填】

还想怎么改可以不选

高级选项一般不用动 · 这件事通常 1 分钟左右做好

示例成品全文按上面这组示例选项真跑出来的

语音助手“边说边答”架构:直接给骨架

先给结论:你大概率卡在三个地方:

  1. ASR 等整句 final 才给大模型。
  2. 大模型生成完一整段才给 TTS。
  3. 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/24k

4. 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.

同类还能办

全部 ›

同样出方案报告的

首页