Skip to content
Published on

开源实时对话式语音聊天机器人构建指南:Barge-In(应答中断)支持架构与实现

分享
Authors

引言

用过语音 AI 助手的人大概都体验过这种感觉——AI 正滔滔不绝地回答,你却必须等它说完才能开口,那种憋闷感。人与人对话时,可以在对方讲到一半时插话(barge-in),对方也会自然地停下来听你说。在实时语音聊天机器人里实现这种 barge-in,并不是「把 TTS 停掉就行了」这么简单的问题。在麦克风输入和扬声器输出同时运行的 full-duplex(全双工)环境中,需要消除回声、用 VAD(语音活动检测)侦测用户发话、立即取消正在进行的 LLM 生成与 TTS 合成,并控制处理新用户输入的整条流水线。

本文将设计一套可以100% 用开源方案搭建的实时语音聊天机器人架构,并用 Python 实现包括 barge-in 在内的核心功能。目标是打造一套无需商用 API、仅靠一块本地 GPU(或纯 CPU)就能运行的系统。

开源技术栈对比与选择

VAD(语音活动检测)对比

判断用户正在说话还是处于静音状态的 VAD,是 barge-in 的核心触发器。在实时系统中,判定必须在 30ms 以内完成。

项目Silero VADwebrtcvadPicovoice Cobra
方式DNN (PyTorch/ONNX)基于 GMM 的信号处理DNN(专有引擎)
准确率 (TPR@5%FPR)87.7%50%98.5%
模型大小1.8 MB~100 KB~5 MB
处理时间(30ms 分块)~1ms不到 0.1ms~0.5ms
语言支持6000+ 种语言与语言无关多语言
许可证MITBSD商用(有限免费)
流式处理OOO

推荐:在纯开源构成中,Silero VAD 在准确率和许可证方面都是最优选择。webrtcvad 会出现过多的 false negative(漏掉一半发话),Picovoice Cobra 虽然准确率高,但属于商用许可证。

唤醒词(Wake Word)选项

与其一直开着麦克风,不如用特定的唤醒词来唤醒聊天机器人,这就需要唤醒词检测。

项目openWakeWordPorcupineSnowboy(遗留)
自定义唤醒词O(需要训练)O(在控制台生成)O
内置 VAD集成 Silero VADXX
准确率中上
许可证Apache 2.0商用(有限免费)Apache 2.0(已停止支持)

推荐:若追求完全开源构成,选 openWakeWord + Silero VAD 组合;若可以混用商用方案,Porcupine 在准确率上更胜一筹。

STT(语音转文本)对比

项目faster-whisperwhisper.cppVosk
基础CTranslate2 (Whisper)ggml (Whisper)Kaldi/自有模型
韩语 WER~12%(large-v3)~12%(large-v3)~25%
GPU 加速CUDA(CTranslate2)Metal/CUDA/VulkanX(仅 CPU)
实时流式处理△(基于 VAD 的分块)△(按分块)O(原生支持)
内存~3 GB(large-v3)~3 GB(large-v3)~50 MB(small)
处理速度(GPU)~15x RT~10x RT~1x RT(CPU)
许可证MITMITApache 2.0

推荐:在有 GPU 的环境下,faster-whisper(large-v3 或 medium)在准确率和速度上都是最优选择;在仅有 CPU 的轻量环境下,Vosk 凭借原生支持实时流式处理更具优势。

LLM 本地部署对比

项目OllamavLLMLocalAI
安装难度★☆☆(一条命令)★★☆★★☆
OpenAI 兼容 APIOOO
并发用户处理弱(1~2 人)(PagedAttention)中等
GPU 内存效率中等(KV 缓存优化)中等
流式输出O(SSE)O(SSE)O(SSE)
模型生态Ollama Hub(丰富)直接对接 HuggingFace支持多种
适用场景个人/原型开发生产环境/多用户多模态集成

推荐:在原型开发和单用户环境中,Ollama 在安装和运维便利性上最出色;在 5 人以上并发的环境中,vLLM 在吞吐量和延迟可预测性上更有优势。两者都提供 OpenAI 兼容 API,因此无需修改代码即可互相替换。

TTS(文本转语音)对比

项目PiperCoqui XTTS v2StyleTTS 2
架构VITSGPT + VITSDiffusion + Style
韩语O(社区模型)O(17 种语言)△(需要微调)
声音克隆XO(6 秒样本)O(需微调)
流式处理O(按分块)O(延迟不到 200ms)X(批量合成)
需要 GPUX(CPU 即可)O(推荐)O(必需)
合成速度~50x RT(CPU)~5x RT(GPU)~3x RT(GPU)
音质(MOS)3.8~4.14.2~4.54.3~4.5
许可证MITMPL 2.0MIT
维护状态活跃⚠️ Coqui 已停止运营社区维护

推荐:在 barge-in 系统中,Piper 是最优选择。仅凭 CPU 就能达到 50 倍实时速度,TTS 延迟几乎为零,支持流式合成,也很容易立即中断。若更看重音质,可以使用 XTTS v2,但在 Coqui AI 结束运营(2025.12)之后,需要改用社区分支(coqui-tts)。

实时音频传输对比

项目WebSocket (raw)LiveKitaiortc
协议WS over TCPWebRTC(SFU)WebRTC(P2P/SFU)
延迟50~200ms不到 50ms不到 50ms
回声消除需自行实现内置 AEC需自行实现
NAT 穿透需额外设置内置 TURN/STUN支持 ICE
可扩展性需自行实现SFU 自动扩展有限
Python SDKwebsocketslivekit-agentsaiortc
实现复杂度

推荐:如果只在本地单机运行,WebSocket 就够用。如果需要浏览器客户端,或网络质量不稳定,LiveKit 能一并提供回声消除、NAT 穿透和 SFU 扩展。LiveKit 的 Agents 框架内置了 STT/LLM/TTS 流水线和 barge-in 轮次检测,在生产环境中更具优势。

架构设计

整体流水线架构

┌─────────────────────────────────────────────────────────────┐
Client Device│  ┌──────────┐    ┌──────────┐    ┌──────────┐               │
│  │   Mic    │───▶│  Audio   │───▶│ WebSocket│──── network ──┤
│  │  Input   │    │  Capture │    │  Client  │               │
│  └──────────┘    └──────────┘    └──────────┘               │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐               │
│  │ Speaker  │◀───│  Audio   │◀───│ WebSocket│◀── network ──┤
│  │  Output  │    │ Playback │    │  Client  │               │
│  └──────────┘    └──────────┘    └──────────┘               │
└─────────────────────────────────────────────────────────────┘
                           │ ▲
                     audio │ │ audio
                     bytes │ │ bytes
                           ▼ │
┌─────────────────────────────────────────────────────────────┐
Voice Pipeline Server│                                                             │
│  ┌───────────┐   ┌───────────┐   ┌───────────┐             │
│  │ Silero    │──▶│ faster-   │──▶│  Ollama   │             │
│  │ VAD       │   │ whisper   │     (LLM)    │             │
│  │           │    (STT)     │   │           │             │
│  └───────────┘   └───────────┘   └─────┬─────┘             │
│       │                                 │                   │
│       │  barge-in                token   │                   │
│       │  signal                 stream  │                   │
│       ▼                                 ▼                   │
│  ┌───────────┐               ┌───────────┐                  │
│  │  State    │◀─────────────▶│  Piper    │                  │
│  │  Machine  │   cancel/       (TTS)    │                  │
│  │           │   resume      │           │                  │
│  └───────────┘               └───────────┘                  │
│                                    │                        │
│                              audio │ chunks                 │
│                                    ▼                        │
│                             ┌───────────┐                   │
│                             │  Audio    │                   │
│                             │  Output   │                   │
│                             │  Queue    │                   │
│                             └───────────┘                   │
└─────────────────────────────────────────────────────────────┘

核心设计原则

  1. 异步流水线:所有组件都基于 asyncio 运行,并通过 asyncio.Queue 连接
  2. 可立即取消:LLM 流式生成与 TTS 合成都由 asyncio.Task 管理,调用 .cancel() 即可立即中断
  3. Full-Duplex(全双工):麦克风输入与扬声器输出同时运行,通过回声消除(AEC)过滤自身输出
  4. 基于状态的控制:由状态机决定整个流水线的运行流程

状态机设计

要稳定地实现 barge-in,明确的状态转移(state transition)必不可少。下面设计了 5 种状态。

                    ┌──────────────────────────────────┐
                    │                                  │
                    ▼                                  │
              ┌──────────┐                             │
              │          │  voice_detected             │
         ┌───▶│   IDLE   │─────────────────┐           │
         │    │          │                 │           │
         │    └──────────┘                 ▼           │
         │                          ┌──────────┐       │
         │                          │LISTENING │       │
         │                          │          │       │
         │                          └────┬─────┘       │
         │                               │             │
         │                    silence_   │             │
         │                    detected   │             │
         │                               ▼             │
         │                          ┌──────────┐       │
         │         timeout/THINKING  │       │
         │         error            │          │       │
         │           ┌──────────────┴────┬─────┘       │
         │           │                   │             │
         │           │        first_     │             │
         │           │        audio_     │             │
         │           │        chunk      │             │
         │           ▼                   ▼             │
         │    ┌──────────┐         ┌──────────┐        │
         │    │  ERROR   │         │ SPEAKING │        │
         │    │          │         │          │───┐    │
         │    └──────────┘         └────┬─────┘   │    │
         │                              │         │    │
         │                   voice_     │   barge_│    │
         │                   end        │   in    │    │
         │                              │         │    │
         │                              ▼         ▼    │
         │                                  ┌──────────┐
         └──────────────────────────────────│INTERRUPTED                                            └──────────┘

状态转移规则

from enum import Enum, auto

class State(Enum):
    IDLE = auto()        # 待机中(等待唤醒词或语音检测)
    LISTENING = auto()   # 正在录制用户发话
    THINKING = auto()    # STT → LLM 处理中
    SPEAKING = auto()    # 正在播放 TTS 语音
    INTERRUPTED = auto() # 发生 barge-in,清理后转为 LISTENING

TRANSITIONS = {
    State.IDLE:        {Event.VOICE_DETECTED: State.LISTENING},
    State.LISTENING:   {Event.SILENCE_DETECTED: State.THINKING,
                        Event.TIMEOUT: State.IDLE},
    State.THINKING:    {Event.FIRST_AUDIO_CHUNK: State.SPEAKING,
                        Event.ERROR: State.IDLE,
                        Event.BARGE_IN: State.INTERRUPTED},
    State.SPEAKING:    {Event.PLAYBACK_DONE: State.IDLE,
                        Event.BARGE_IN: State.INTERRUPTED},
    State.INTERRUPTED: {Event.CLEANUP_DONE: State.LISTENING},
}

发生 Barge-In 时的处理顺序

  1. VAD 在 SPEAKING/THINKING 状态中检测到用户语音
  2. 状态转换为 INTERRUPTED
  3. 立即执行:清空 TTS 音频输出队列 + 中断扬声器播放
  4. 立即执行:取消 LLM 流式生成 Task(task.cancel())
  5. 立即执行:取消 TTS 合成 Task
  6. 保留部分响应历史(保持上下文)
  7. 发出 CLEANUP_DONE 事件 → 转换为 LISTENING
  8. 开始录制新的用户发话

Python 最小可运行示例

下面是把麦克风输入 → VAD → STT → LLM → TTS → 扬声器输出的完整流水线、连同 barge-in 支持一起实现的最小可运行代码。

安装依赖

pip install silero-vad faster-whisper openai-whisper piper-tts \
            sounddevice numpy httpx asyncio
# 安装 Ollama (macOS/Linux)
curl -fsSL https://ollama.com/install.sh | sh
ollama pull gemma3:4b  # 或替换为你想用的模型

核心代码

import asyncio
import numpy as np
import sounddevice as sd
import httpx
import io
import wave
from enum import Enum, auto
from collections import deque
from dataclasses import dataclass, field
from typing import Optional

# ──────────────────────────────────────────────
# 1. 状态机定义
# ──────────────────────────────────────────────
class State(Enum):
    IDLE = auto()
    LISTENING = auto()
    THINKING = auto()
    SPEAKING = auto()
    INTERRUPTED = auto()

class Event(Enum):
    VOICE_DETECTED = auto()
    SILENCE_DETECTED = auto()
    FIRST_AUDIO_CHUNK = auto()
    PLAYBACK_DONE = auto()
    BARGE_IN = auto()
    CLEANUP_DONE = auto()
    TIMEOUT = auto()
    ERROR = auto()

TRANSITIONS = {
    State.IDLE:        {Event.VOICE_DETECTED: State.LISTENING},
    State.LISTENING:   {Event.SILENCE_DETECTED: State.THINKING,
                        Event.TIMEOUT: State.IDLE},
    State.THINKING:    {Event.FIRST_AUDIO_CHUNK: State.SPEAKING,
                        Event.ERROR: State.IDLE,
                        Event.BARGE_IN: State.INTERRUPTED},
    State.SPEAKING:    {Event.PLAYBACK_DONE: State.IDLE,
                        Event.BARGE_IN: State.INTERRUPTED},
    State.INTERRUPTED: {Event.CLEANUP_DONE: State.LISTENING},
}

@dataclass
class PipelineContext:
    state: State = State.IDLE
    audio_buffer: bytearray = field(default_factory=bytearray)
    conversation: list = field(default_factory=list)
    llm_task: Optional[asyncio.Task] = None
    tts_task: Optional[asyncio.Task] = None
    playback_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
    interrupt_event: asyncio.Event = field(default_factory=asyncio.Event)

    def transition(self, event: Event) -> bool:
        allowed = TRANSITIONS.get(self.state, {})
        if event in allowed:
            old = self.state
            self.state = allowed[event]
            print(f"[FSM] {old.name} --{event.name}--> {self.state.name}")
            return True
        print(f"[FSM] {self.state.name}: {event.name} ignored")
        return False

# ──────────────────────────────────────────────
# 2. VAD 模块(Silero VAD)
# ──────────────────────────────────────────────
import torch

class VoiceActivityDetector:
    def __init__(self, threshold: float = 0.5):
        self.model, self.utils = torch.hub.load(
            'snakers4/silero-vad', 'silero_vad', onnx=True
        )
        self.threshold = threshold
        self.sample_rate = 16000
        self._silence_frames = 0
        self.silence_limit = 30  # 30 frames × 30ms = 900ms

    def process_chunk(self, audio_chunk: np.ndarray) -> str:
        """接收 30ms 音频分块,返回 'speech'/'silence'/'end'。"""
        tensor = torch.from_numpy(audio_chunk).float()
        prob = self.model(tensor, self.sample_rate).item()

        if prob >= self.threshold:
            self._silence_frames = 0
            return "speech"
        else:
            self._silence_frames += 1
            if self._silence_frames >= self.silence_limit:
                self._silence_frames = 0
                return "end"
            return "silence"

    def reset(self):
        self.model.reset_states()
        self._silence_frames = 0

# ──────────────────────────────────────────────
# 3. STT 模块(faster-whisper)
# ──────────────────────────────────────────────
from faster_whisper import WhisperModel

class SpeechToText:
    def __init__(self, model_size: str = "medium", device: str = "cuda"):
        self.model = WhisperModel(
            model_size, device=device, compute_type="float16"
        )

    def transcribe(self, audio: np.ndarray) -> str:
        segments, _ = self.model.transcribe(
            audio, language="ko", beam_size=5,
            vad_filter=True
        )
        return " ".join(seg.text for seg in segments).strip()

# ──────────────────────────────────────────────
# 4. LLM 模块(Ollama OpenAI 兼容 API)
# ──────────────────────────────────────────────
async def stream_llm_response(
    messages: list[dict],
    model: str = "gemma3:4b",
    base_url: str = "http://localhost:11434/v1",
) -> asyncio.AsyncGenerator:
    """向 Ollama 发起流式请求,按 token 逐个 yield。"""
    async with httpx.AsyncClient(timeout=30.0) as client:
        async with client.stream(
            "POST",
            f"{base_url}/chat/completions",
            json={
                "model": model,
                "messages": messages,
                "stream": True,
                "max_tokens": 512,
            },
        ) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                data = line[6:]
                if data == "[DONE]":
                    break
                import json
                chunk = json.loads(data)
                delta = chunk["choices"][0].get("delta", {})
                if content := delta.get("content"):
                    yield content

# ──────────────────────────────────────────────
# 5. TTS 模块(Piper)
# ──────────────────────────────────────────────
import subprocess

class TextToSpeech:
    def __init__(self, model_path: str, config_path: str):
        self.model_path = model_path
        self.config_path = config_path

    async def synthesize(self, text: str) -> bytes:
        """将文本转换为 WAV 字节。"""
        proc = await asyncio.create_subprocess_exec(
            "piper",
            "--model", self.model_path,
            "--config", self.config_path,
            "--output-raw",
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await proc.communicate(text.encode("utf-8"))
        return stdout

    async def synthesize_streaming(
        self, text_chunks: asyncio.Queue, audio_queue: asyncio.Queue
    ):
        """按句子合成 TTS 后放入音频队列。"""
        buffer = ""
        sentence_delimiters = {".", "!", "?", "。", "!", "?", "\n"}

        while True:
            chunk = await text_chunks.get()
            if chunk is None:  # 结束信号
                if buffer.strip():
                    audio = await self.synthesize(buffer)
                    await audio_queue.put(audio)
                await audio_queue.put(None)  # 播放结束信号
                break

            buffer += chunk
            # 句子完成时立即合成
            for delim in sentence_delimiters:
                if delim in buffer:
                    parts = buffer.split(delim, 1)
                    sentence = parts[0] + delim
                    buffer = parts[1] if len(parts) > 1 else ""
                    if sentence.strip():
                        audio = await self.synthesize(sentence.strip())
                        await audio_queue.put(audio)
                    break

# ──────────────────────────────────────────────
# 6. 音频播放(应对 barge-in)
# ──────────────────────────────────────────────
async def play_audio(ctx: PipelineContext, sample_rate: int = 22050):
    """从音频队列取出分块进行播放。interrupt 时立即中断。"""
    while True:
        audio_bytes = await ctx.playback_queue.get()
        if audio_bytes is None:
            ctx.transition(Event.PLAYBACK_DONE)
            break

        audio_array = np.frombuffer(audio_bytes, dtype=np.int16)
        chunk_size = sample_rate // 10  # 按 100ms 为单位播放

        for i in range(0, len(audio_array), chunk_size):
            if ctx.interrupt_event.is_set():
                # barge-in!立即中断播放
                # 清空队列
                while not ctx.playback_queue.empty():
                    try:
                        ctx.playback_queue.get_nowait()
                    except asyncio.QueueEmpty:
                        break
                return

            segment = audio_array[i:i + chunk_size]
            sd.play(segment, samplerate=sample_rate, blocking=True)

# ──────────────────────────────────────────────
# 7. 主流水线(支持 Barge-In)
# ──────────────────────────────────────────────
async def run_pipeline():
    ctx = PipelineContext()
    vad = VoiceActivityDetector(threshold=0.5)
    stt = SpeechToText(model_size="medium", device="cuda")
    tts = TextToSpeech(
        model_path="ko_KR-kss-medium.onnx",
        config_path="ko_KR-kss-medium.onnx.json",
    )

    SAMPLE_RATE = 16000
    CHUNK_MS = 30
    CHUNK_SAMPLES = int(SAMPLE_RATE * CHUNK_MS / 1000)

    print("[Pipeline] 已启动。请开始说话...")

    while True:
        ctx.interrupt_event.clear()

        # ── IDLE → LISTENING ──
        if ctx.state == State.IDLE:
            vad.reset()
            ctx.audio_buffer = bytearray()

            # 从麦克风读取 30ms 分块
            audio_chunk = sd.rec(
                CHUNK_SAMPLES, samplerate=SAMPLE_RATE,
                channels=1, dtype="float32"
            )
            sd.wait()
            audio_np = audio_chunk.flatten()

            result = vad.process_chunk(audio_np)
            if result == "speech":
                ctx.transition(Event.VOICE_DETECTED)
                ctx.audio_buffer.extend(
                    (audio_np * 32767).astype(np.int16).tobytes()
                )

        # ── LISTENING ──
        elif ctx.state == State.LISTENING:
            audio_chunk = sd.rec(
                CHUNK_SAMPLES, samplerate=SAMPLE_RATE,
                channels=1, dtype="float32"
            )
            sd.wait()
            audio_np = audio_chunk.flatten()
            ctx.audio_buffer.extend(
                (audio_np * 32767).astype(np.int16).tobytes()
            )

            result = vad.process_chunk(audio_np)
            if result == "end":
                ctx.transition(Event.SILENCE_DETECTED)

        # ── THINKING ──
        elif ctx.state == State.THINKING:
            # STT 转换
            audio_data = np.frombuffer(
                ctx.audio_buffer, dtype=np.int16
            ).astype(np.float32) / 32767.0
            user_text = stt.transcribe(audio_data)
            print(f"[User] {user_text}")

            if not user_text.strip():
                ctx.transition(Event.ERROR)
                continue

            ctx.conversation.append({"role": "user", "content": user_text})

            # LLM 流式生成 + TTS 同时执行
            text_queue: asyncio.Queue = asyncio.Queue()
            full_response = []

            async def llm_to_tts():
                try:
                    async for token in stream_llm_response(ctx.conversation):
                        if ctx.interrupt_event.is_set():
                            break
                        full_response.append(token)
                        await text_queue.put(token)
                    await text_queue.put(None)
                except asyncio.CancelledError:
                    await text_queue.put(None)

            async def tts_worker():
                await tts.synthesize_streaming(text_queue, ctx.playback_queue)

            async def vad_monitor():
                """在 THINKING/SPEAKING 状态中检测 barge-in。"""
                while ctx.state in (State.THINKING, State.SPEAKING):
                    chunk = sd.rec(
                        CHUNK_SAMPLES, samplerate=SAMPLE_RATE,
                        channels=1, dtype="float32"
                    )
                    sd.wait()
                    r = vad.process_chunk(chunk.flatten())
                    if r == "speech" and ctx.state == State.SPEAKING:
                        print("[Barge-In] 检测到用户发话!中断响应")
                        ctx.interrupt_event.set()
                        ctx.transition(Event.BARGE_IN)
                        return
                    await asyncio.sleep(0.01)

            # 执行任务
            ctx.llm_task = asyncio.create_task(llm_to_tts())
            ctx.tts_task = asyncio.create_task(tts_worker())
            playback_task = asyncio.create_task(play_audio(ctx))
            monitor_task = asyncio.create_task(vad_monitor())

            ctx.transition(Event.FIRST_AUDIO_CHUNK)

            # 等待完成或中断
            done, pending = await asyncio.wait(
                [ctx.llm_task, ctx.tts_task, playback_task, monitor_task],
                return_when=asyncio.FIRST_COMPLETED,
            )

            # 发生 barge-in 时进行清理
            if ctx.interrupt_event.is_set():
                for task in pending:
                    task.cancel()
                    try:
                        await task
                    except asyncio.CancelledError:
                        pass

                # 保留部分响应
                partial = "".join(full_response)
                if partial:
                    ctx.conversation.append(
                        {"role": "assistant", "content": partial + " [已中断]"}
                    )
                ctx.transition(Event.CLEANUP_DONE)
                ctx.audio_buffer = bytearray()
                vad.reset()
            else:
                # 正常完成
                for task in pending:
                    await task
                response_text = "".join(full_response)
                ctx.conversation.append(
                    {"role": "assistant", "content": response_text}
                )
                print(f"[Assistant] {response_text}")

        # ── INTERRUPTED → LISTENING ──
        elif ctx.state == State.INTERRUPTED:
            ctx.transition(Event.CLEANUP_DONE)

        await asyncio.sleep(0.001)

if __name__ == "__main__":
    asyncio.run(run_pipeline())

代码要点

  1. interrupt_event:用 asyncio.Event 传播 barge-in 信号,所有 worker 都会检查这个事件
  2. asyncio.wait(return_when=FIRST_COMPLETED):一旦 VAD 监控器检测到 barge-in,就立即取消其余任务
  3. 保留部分响应:被中断的响应也会连同 [已中断] 标签一起保存到对话历史中,防止上下文丢失
  4. 按句子分段的 TTS:在 LLM 流式输出 token 期间,一旦遇到句子分隔符(.?! 等)就立即开始 TTS 合成

错误处理与队列设计

重试策略

import asyncio
from dataclasses import dataclass

@dataclass
class RetryConfig:
    max_retries: int = 3
    base_delay: float = 0.5
    max_delay: float = 5.0

async def with_retry(coro_fn, config: RetryConfig = RetryConfig()):
    """指数退避重试包装器。"""
    for attempt in range(config.max_retries):
        try:
            return await coro_fn()
        except Exception as e:
            if attempt == config.max_retries - 1:
                raise
            delay = min(
                config.base_delay * (2 ** attempt),
                config.max_delay
            )
            print(f"[Retry] {attempt+1}/{config.max_retries}: {e}, "
                  f"{delay}s 后重试")
            await asyncio.sleep(delay)

队列管理模式

class AudioPipelineQueues:
    """管理流水线队列。各阶段之间通过异步队列连接。"""

    def __init__(self, maxsize: int = 100):
        self.vad_to_stt: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
        self.stt_to_llm: asyncio.Queue = asyncio.Queue(maxsize=10)
        self.llm_to_tts: asyncio.Queue = asyncio.Queue(maxsize=50)
        self.tts_to_speaker: asyncio.Queue = asyncio.Queue(maxsize=20)

    async def flush_all(self):
        """在 barge-in 时清空所有队列。"""
        for q in [self.vad_to_stt, self.stt_to_llm,
                   self.llm_to_tts, self.tts_to_speaker]:
            while not q.empty():
                try:
                    q.get_nowait()
                except asyncio.QueueEmpty:
                    break

    async def flush_downstream(self):
        """只清空 STT 之后的队列(barge-in 时保留新输入)。"""
        for q in [self.llm_to_tts, self.tts_to_speaker]:
            while not q.empty():
                try:
                    q.get_nowait()
                except asyncio.QueueEmpty:
                    break

错误恢复矩阵

错误类型发生阶段恢复策略用户反馈
麦克风访问失败Audio Input重新搜索设备,重试 3 次TTS 播报“请检查麦克风”
VAD 模型加载失败初始化ONNX → PyTorch 回退输出日志
STT 超时THINKING用更短的音频重试无响应 → 回到 IDLE
LLM 服务器连接失败THINKING重试 3 次 + 指数退避“请稍后再试”
LLM 无响应THINKING超时 15 秒 → IDLE“请重新说一遍”
TTS 合成失败SPEAKING跳过该句子,继续下一句可能有部分缺失
音频输出失败SPEAKING重新搜索输出设备日志警告

实务架构指南

本地单机架构 vs 服务器-客户端架构

本地单机(All-in-One)

┌──────────────────────────────────┐
Single MachineMicVADSTTLLMTTS│         → Speaker│                                  │
GPU: RTX 3060+ (12GB VRAM)│  或 CPU-only (Vosk + Piper)└──────────────────────────────────┘

优点:网络延迟 0ms、隐私完全可控、无需联网 缺点:受硬件限制、模型大小受限

服务器-客户端架构

┌──────────┐     WebSocket/     ┌────────────────┐
Client  │◀────WebRTC────────▶│   GPU Server (树莓派   │    audio stream   │ STT + LLM + TTS/ 浏览器) │                  │                │
└──────────┘                    └────────────────┘

优点:客户端轻量化、可使用更强大的模型、支持多客户端 缺点:增加网络延迟(20~100ms)、服务器成本

混合架构(推荐)

┌──────────────────┐          ┌────────────────┐
Client       │  text    │   GPU ServerMicVADSTT │─────────▶│  LLM (vLLM)  (Vosk, 本地)    │  text    │               │
SpeakerTTS   │◀─────────│               │
  (Piper, 本地)   │          └────────────────┘
└──────────────────┘

VAD/STT/TTS 在本地运行(将延迟降到最低),只有 LLM 在服务器上处理。只有文本经过网络传输,带宽负担最小。

无 GPU 环境的优化技巧

即便在没有 GPU 的环境(树莓派、旧款笔记本等)中,也能运行实时语音聊天机器人。

组件GPU 配置纯 CPU 配置备注
VADSilero VAD (ONNX)Silero VAD (ONNX)相同 — ONNX Runtime 在 CPU 上也只要 1ms
STTfaster-whisper large-v3Vosk(small-ko)Vosk 用 50MB 模型实现实时流式处理
LLMOllama (gemma3:4b)Ollama (gemma3:1b, q4_0)或委托给远程服务器
TTSPiper (medium)Piper(低音质)在 CPU 上也能达到 50 倍实时

其他优化技巧:

  1. 使用 ONNX Runtime:Silero VAD 和 Piper 都支持 ONNX 模型,用 onnxruntime 优化 CPU 推理
  2. INT8 量化:faster-whisper 的 compute_type="int8" 可将 CPU 性能提升 2~3 倍
  3. 调整音频分块大小:把 30ms 增大到 60ms 可将 VAD 调用次数减半(以准确率为代价)
  4. LLM 量化:用 q4_0q4_K_M 量化可将 RAM 占用减少 60%
  5. 避免批处理:CPU 环境下优先流式处理,将批大小固定为 1

韩语质量改善技巧

与英语相比,韩语的语音识别与合成需要额外的优化。

STT 韩语优化:

  • 使用 faster-whisper 时明确指定 language="ko"(相比自动检测,WER 可改善 3~5%)
  • 设置 initial_prompt="음성 인식 결과입니다." 提供韩语语境提示
  • 推荐 beam_size=5 以上(韩语同音异义词多,beam search 效果显著)
  • 启用 VAD 过滤(vad_filter=True)去除静音区间

TTS 韩语优化:

  • Piper 韩语模型:ko_KR-kss-medium(基于 KSS 数据集)
  • XTTS v2 支持韩语 zero-shot,但微调后自然度会大幅提升
  • 数字/英文混排文本的预处理:应用“3시 30분” → “세시 삼십분”这类转换规则
  • 按句子合成时以句号为界切分(韩语在逗号后语调变化较小,按句子切分更合适)

LLM 韩语优化:

  • 在系统提示词中明确写明“한국어로 간결하게 답변하세요. 2~3문장 이내로.”(即:请用韩语简洁作答)
  • Ollama 模型选择:gemma3:4b(韩语质量良好)或 EXAONE-3.5-2.4B(LG 针对韩语优化)
  • 限制响应长度(max_tokens: 256)以提高在 barge-in 之前完成响应的概率

延迟基准标准

在实时语音对话中,用户感觉自然的延迟基准如下。

各阶段延迟目标

阶段目标允许上限测量方法
VAD 判定5ms 以内30ms 以内分块输入 → 返回结果
STT(发话结束→文本)500ms 以内1500ms 以内silence_end → text_ready
LLM TTFT(首个 token)300ms 以内1000ms 以内request → first_token
LLM 完整响应2000ms 以内5000ms 以内request → last_token
TTS(文本→首个音频)100ms 以内300ms 以内text_ready → first_audio
总响应延迟800ms 以内2000ms 以内silence_end → first_audio_out
Barge-in 响应100ms 以内200ms 以内voice_detected → playback_stop

各环境下可达成的延迟

环境总响应延迟Barge-in 响应备注
RTX 4090 + SSD~500ms~50ms最优配置
RTX 3060 (12GB)~800ms~60ms推荐的最低 GPU 配置
M2 MacBook Pro~900ms~70msMetal 加速
CPU only (i7-12700)~2500ms~80msVosk + Piper 组合
Raspberry Pi 5~4000ms~100ms必须使用远程 LLM

实现检查清单

在开始项目之前,先确认下面的检查清单。

环境准备

  • 安装 Python 3.10+
  • CUDA 12.x + cuDNN(使用 GPU 时)
  • 安装 portaudio 系统库(brew install portaudio / apt install portaudio19-dev)
  • 确认麦克风与扬声器工作正常
  • 安装 Ollama 并下载模型(ollama pull gemma3:4b)

模型下载

  • Silero VAD ONNX 模型(自动下载)
  • faster-whisper 模型(mediumlarge-v3)
  • Piper 韩语模型(ko_KR-kss-medium)
  • (可选)openWakeWord 模型

流水线验证

  • VAD 单独测试:麦克风 → VAD → 控制台输出
  • STT 单独测试:WAV 文件 → faster-whisper → 文本
  • LLM 单独测试:用 curl 确认 Ollama 的流式响应
  • TTS 单独测试:文本 → Piper → 播放 WAV 文件
  • 集成测试:确认整个流水线正常运行
  • barge-in 测试:确认响应过程中发话会立即中断

生产部署

  • 设置错误日志(结构化日志)
  • 收集指标(各阶段延迟)
  • 内存泄漏测试(长时间运行)
  • 并发连接测试(服务器-客户端架构时)

故障排查指南

症状原因解决方法
VAD 总是判定为 speechthreshold 太低 / 背景噪音将 threshold 调高到 0.6~0.8。噪音环境下推荐 0.7 以上
VAD 漏掉发话threshold 太高 / 麦克风增益低将 threshold 降到 0.3~0.4。检查系统麦克风音量
STT 识别到自己的声音(回声)扬声器 → 麦克风反馈使用耳机,或应用软件 AEC(如 speexdsp)
STT 韩语识别率低模型或设置不当明确指定 language="ko"beam_size=5,使用 large-v3 模型
LLM 首个 token 延迟 >2 秒模型冷启动 / GPU 内存不足ollama run 预先加载模型,检查 GPU 内存
TTS 合成后出现卡顿/噪音采样率不一致确认 Piper 输出采样率(通常为 22050)与 sd.play 一致
barge-in 响应慢(>500ms)VAD 分块过大 / 监控循环延迟将分块缩小到 30ms,并将 asyncio.sleep 值降到最低
barge-in 触发过于频繁回声消除不足 / VAD 灵敏度过高应用 AEC,并在 SPEAKING 状态下将 VAD threshold 调高到 0.8
内存持续增长音频缓冲区/队列未清理确认调用了 flush_all(),并限制对话历史长度
asyncio 事件循环阻塞使用了 sd.rec(blocking=True)改为基于 sd.InputStream 回调的方式

进阶主题:用 LiveKit Agents 构建生产环境

若想超越本地原型、迈向生产环境,可以考虑 LiveKit Agents 框架。它在基于 WebRTC 的 SFU 服务器中内置了 STT/LLM/TTS 流水线和轮次检测,同时支持浏览器、移动端、IoT 客户端。

# 基于 LiveKit Agents 的 barge-in 语音聊天机器人(简化示例)
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.agents.voice import Agent, AgentSession
from livekit.plugins import silero, openai, deepgram

async def entrypoint(ctx: JobContext):
    await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)

    agent = Agent(
        vad=silero.VAD.load(),
        stt=deepgram.STT(language="ko"),
        llm=openai.LLM(
            base_url="http://localhost:11434/v1",  # Ollama
            model="gemma3:4b",
        ),
        tts=openai.TTS(),  # 或自定义的 Piper TTS 插件
        # barge-in 默认启用 — 当 VAD 检测到用户发话时
        # 会自动中断 LLM/TTS 并开始新的一轮
    )
    session = AgentSession()
    await session.start(agent=agent, room=ctx.room)

if __name__ == "__main__":
    cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))

LiveKit Agents 用轮次检测 Transformer 模型来处理 barge-in,相比单纯基于 VAD 的方式,能提供更精细的中断判断(区分咳嗽、背景噪音等)。

其他开源框架

框架说明barge-in韩语GitHub Stars
Pipecat多模态对话 AI 框架OO5k+
Vocode语音智能体构建工具O2.5k+
RealtimeSTT实时 STT 库(内置 Silero VAD)O3k+
ShuoSub-500ms 电话智能体OX新项目

结语

仅凭开源方案构建具备实时 barge-in 能力的语音聊天机器人,是完全可行的。核心要点有三个:

  1. 异步流水线:用 asyncio 连接所有组件,并设计成可通过 Task.cancel() 立即中断
  2. 基于状态机的控制:用 5 种状态(IDLE/LISTENING/THINKING/SPEAKING/INTERRUPTED)定义清晰的转移规则
  3. 延迟预算管理:以总延迟 800ms 以内为目标,为每个阶段分配预算并加以测量

最实用的起点是 Silero VAD + faster-whisper + Ollama + Piper 组合。如果有 GPU,可以实现 800ms 以内的响应;即便只用 CPU,只要把 Vosk + Piper 组合接上远程 LLM,也能达到 2 秒左右的响应速度。

在韩语环境中,STT 明确指定 language="ko"、引导 LLM 给出简洁的回答、TTS 对数字/英文的预处理,这三点决定了整体质量。以本文的代码为基础,逐个替换、改进各个模块,打造属于你自己的语音 AI 助手吧。

参考资料

  1. Silero VAD — GitHub — MIT 许可证的 VAD,支持 ONNX/PyTorch
  2. faster-whisper — GitHub — 基于 CTranslate2 的 Whisper 高速推理
  3. whisper.cpp — GitHub — 基于 ggml 的 Whisper C++ 实现,支持 Metal/CUDA/Vulkan
  4. Vosk — GitHub — 轻量级离线 STT,原生支持流式处理
  5. Piper TTS — GitHub — 基于 VITS 的轻量级 TTS,支持多语言
  6. Ollama — 官方网站 — 一条命令即可部署本地 LLM
  7. LiveKit Agents — GitHub — 实时语音 AI 智能体框架
  8. Pipecat — GitHub — 开源多模态对话 AI 框架
  9. openWakeWord — GitHub — 开源唤醒词检测
  10. RealtimeSTT — GitHub — 带 VAD 与唤醒词的实时 STT
  11. vLLM — GitHub — 高性能 LLM 服务引擎
  12. Coqui TTS(社区分支) — PyPI — XTTS v2 声音克隆 TTS