先把结论放前面:

Voice Agent 的“自然插话”不是检测到用户声音后执行一次 stop(),而是一条完整的中断链路:VAD 发现用户重新开口,服务端切换会话状态,取消正在运行的 LLM/TTS 任务,前端清空播放队列,最后让新一轮语音接管上下文。

这条链路通常叫 Barge-in

只接一个 VAD,最多只能知道“好像有人说话了”;只停止 TTS,旧的 LLM 仍可能在后台继续生成;只取消异步任务,又可能被一个迟到的响应把旧答案重新送到前端。要让 Voice Agent 不抢话、不复读、不串轮次,至少要同时处理:

  1. 语音开始与结束检测;
  2. 对话状态机;
  3. 可取消的生成任务;
  4. 带版本号的消息;
  5. 前端播放队列清理;
  6. 回声、噪声和误触发。

这篇文章用一个可运行的最小 Demo,把这些环节串起来:

浏览器麦克风
  -> WebRTC audio track
  -> FastAPI + aiortc
  -> 16 kHz / mono / PCM16
  -> Silero VAD
  -> speech_start / speech_end
  -> 会话状态机
  -> Barge-in 取消旧任务
  -> DataChannel 通知前端停止播音

为了把注意力集中在 VAD 和 Barge-in 上,Demo 使用浏览器 speechSynthesis 代替正式 TTS,服务端用一个可取消的异步函数模拟 ASR + LLM。接口位置会完整保留,后续可以替换成真实的流式 ASR、LLM 和流式 TTS。

一、先分清 VAD、Endpointing 和 Barge-in

这三个概念经常被揉成一句“语音打断”,但它们解决的不是同一个问题。

1. VAD:现在有没有人在说话

VAD(Voice Activity Detection)处理的是音频活动检测。它持续读取短音频块,输出语音开始和语音结束事件。

安静 -> speech_start -> 用户说话 -> speech_end -> 安静

VAD 不理解用户说了什么,也不知道这句话是否完整。它只判断当前音频更像语音还是非语音。

2. Endpointing:这轮话是不是说完了

Endpointing 解决的是“什么时候把当前语音提交给 ASR/LLM”。用户说话时会自然停顿:

我想查一下……昨天那个订单。

如果静音 100 ms 就判定结束,系统可能在“查一下”后面立刻抢答;如果等待 1.5 秒,交互又会明显拖沓。

所以 speech_end 不是越快越好。它要在误切句和响应速度之间找平衡。

3. Barge-in:AI 说话时,用户能不能接管

Barge-in 是一套跨模块控制逻辑。典型触发条件是:

当前状态 = THINKING 或 SPEAKING
并且 VAD 检测到新的 speech_start

触发后需要同时执行:

取消旧 ASR/LLM/TTS 任务
增加 turn_id
停止客户端播放
丢弃迟到的旧结果
切换到 LISTENING

所以,VAD 是 Barge-in 的传感器,不是 Barge-in 本身。

二、Demo 的状态机怎么设计

最小状态机不需要很花哨,四个状态已经够用:

LISTENING   正在听用户说话
THINKING    ASR/LLM 正在处理
SPEAKING    AI 正在播报
INTERRUPTED 旧轮次被用户打断

正常流程:

LISTENING
  -> speech_end
  -> THINKING
  -> agent_text
  -> SPEAKING
  -> playback_done
  -> LISTENING

打断流程:

THINKING 或 SPEAKING
  -> speech_start
  -> INTERRUPTED
  -> cancel old task
  -> turn_id + 1
  -> send barge_in
  -> LISTENING

这里最值得保留的是 turn_id

asyncio.Task.cancel() 是协作式取消。它会在协程下一次获得执行机会时抛出 CancelledError,但第三方 SDK、线程池任务或已经发出的 HTTP 请求未必能在同一时刻停下来。

因此要做双保险:

task.cancel()                  # 尽快停止旧任务

if local_turn != self.turn_id: # 阻止迟到结果污染新轮次
    return

取消负责节省资源,版本号负责保证正确性。两者不能互相替代。

三、准备项目

建议使用 Python 3.11。项目结构如下:

voice-barge-in-demo/
├── app.py
└── static/
    └── index.html

创建虚拟环境并安装依赖:

python -m venv .venv

# macOS / Linux
source .venv/bin/activate

# Windows PowerShell
# .venv\Scripts\Activate.ps1

pip install fastapi "uvicorn[standard]" aiortc av numpy torch silero-vad

Silero VAD 支持 8 kHz 和 16 kHz 音频。这个 Demo 统一把浏览器音频重采样为 16 kHz、单声道、PCM16,再按每块 512 个采样点送给模型。

512 个采样点在 16 kHz 下约等于 32 ms:

512 / 16000 = 0.032 秒

这并不意味着端到端打断延迟一定是 32 ms。实际延迟还包括 WebRTC 传输、音频缓冲、VAD 计算、事件回传和播放器停止时间。

四、后端:FastAPI + aiortc + Silero VAD

新建 app.py

import asyncio
import json
from contextlib import suppress
from pathlib import Path

import numpy as np
import torch
from av.audio.resampler import AudioResampler
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from aiortc import (
    RTCDataChannel,
    RTCPeerConnection,
    RTCSessionDescription,
)
from silero_vad import VADIterator, load_silero_vad


BASE_DIR = Path(__file__).parent
SAMPLE_RATE = 16000
VAD_WINDOW = 512

app = FastAPI()
pcs: set[RTCPeerConnection] = set()

# Demo 为每个连接创建独立 VADIterator。
# VADIterator 内部有时序状态,不能让多个会话共用同一个 iterator。
torch.set_num_threads(1)


class Offer(BaseModel):
    sdp: str
    type: str


class VoiceSession:
    def __init__(self) -> None:
        model = load_silero_vad()
        self.vad = VADIterator(
            model,
            threshold=0.55,
            sampling_rate=SAMPLE_RATE,
            min_silence_duration_ms=350,
            speech_pad_ms=30,
        )

        self.channel: RTCDataChannel | None = None
        self.state = "LISTENING"
        self.turn_id = 0
        self.reply_task: asyncio.Task | None = None
        self.audio_task: asyncio.Task | None = None
        self.sample_buffer = np.empty(0, dtype=np.float32)

    def attach_channel(self, channel: RTCDataChannel) -> None:
        self.channel = channel

        @channel.on("message")
        def on_message(message) -> None:
            if not isinstance(message, str):
                return

            try:
                event = json.loads(message)
            except json.JSONDecodeError:
                return

            if event.get("type") == "playback_done":
                asyncio.create_task(
                    self.on_playback_done(event.get("turn_id"))
                )

    def send(self, event: dict) -> None:
        if self.channel and self.channel.readyState == "open":
            self.channel.send(json.dumps(event, ensure_ascii=False))

    def set_state(self, state: str) -> None:
        self.state = state
        self.send({
            "type": "state",
            "state": state,
            "turn_id": self.turn_id,
        })

    async def consume_audio(self, track) -> None:
        resampler = AudioResampler(
            format="s16",
            layout="mono",
            rate=SAMPLE_RATE,
        )

        try:
            while True:
                frame = await track.recv()

                for audio_frame in resampler.resample(frame):
                    pcm16 = (
                        audio_frame
                        .to_ndarray()
                        .reshape(-1)
                        .astype(np.int16)
                    )
                    float_samples = pcm16.astype(np.float32) / 32768.0

                    self.sample_buffer = np.concatenate(
                        [self.sample_buffer, float_samples]
                    )

                    while self.sample_buffer.size >= VAD_WINDOW:
                        chunk = self.sample_buffer[:VAD_WINDOW].copy()
                        self.sample_buffer = self.sample_buffer[VAD_WINDOW:]

                        event = self.vad(torch.from_numpy(chunk))
                        if event:
                            await self.on_vad_event(event)
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            self.send({
                "type": "error",
                "message": f"audio loop stopped: {exc}",
            })

    async def on_vad_event(self, event: dict) -> None:
        if "start" in event:
            self.send({
                "type": "speech_started",
                "sample": event["start"],
                "turn_id": self.turn_id,
            })

            if self.state in {"THINKING", "SPEAKING"}:
                await self.interrupt()
            else:
                self.set_state("LISTENING")

        if "end" in event:
            self.send({
                "type": "speech_ended",
                "sample": event["end"],
                "turn_id": self.turn_id,
            })
            await self.start_agent_turn()

    async def interrupt(self) -> None:
        interrupted_turn = self.turn_id
        self.state = "INTERRUPTED"

        # 先让旧轮次失效。即使取消不及时,迟到结果也无法通过 turn_id 校验。
        self.turn_id += 1

        if self.reply_task and not self.reply_task.done():
            self.reply_task.cancel()

        self.send({
            "type": "barge_in",
            "interrupted_turn": interrupted_turn,
            "turn_id": self.turn_id,
        })
        self.set_state("LISTENING")

    async def start_agent_turn(self) -> None:
        # 每次 speech_end 都启动一个新轮次。
        # 如果旧任务仍存在,先让它失效并取消。
        if self.reply_task and not self.reply_task.done():
            self.reply_task.cancel()

        # 新 turn_id 同时让所有旧任务和旧消息失效。
        self.turn_id += 1
        local_turn = self.turn_id
        self.set_state("THINKING")

        self.reply_task = asyncio.create_task(
            self.run_agent(local_turn),
            name=f"agent-turn-{local_turn}",
        )

    async def run_agent(self, local_turn: int) -> None:
        try:
            # 这里替换成真实链路:
            # transcript = await asr.finish_utterance()
            # answer = await llm.generate(transcript)
            # await tts.stream(answer)
            await asyncio.sleep(0.6)

            answer = (
                "我已经检测到你说完了。这是一段故意写得比较长的演示回复,"
                "请在播报过程中直接开口,观察系统是否立即停止旧回复。"
            )

            # 防止已经失效的旧请求把答案发到新轮次。
            if local_turn != self.turn_id:
                return

            self.set_state("SPEAKING")
            self.send({
                "type": "agent_text",
                "text": answer,
                "turn_id": local_turn,
            })

        except asyncio.CancelledError:
            # 可以在这里关闭流式 LLM/TTS 连接、释放生成器和清空服务端队列。
            raise

    async def on_playback_done(self, finished_turn) -> None:
        # 浏览器取消旧播报时也可能触发结束事件,因此必须校验轮次。
        if finished_turn != self.turn_id:
            return
        if self.state == "SPEAKING":
            self.set_state("LISTENING")

    async def close(self) -> None:
        for task in (self.reply_task, self.audio_task):
            if task and not task.done():
                task.cancel()
                with suppress(asyncio.CancelledError):
                    await task


@app.post("/offer")
async def offer(params: Offer):
    pc = RTCPeerConnection()
    session = VoiceSession()
    pcs.add(pc)

    @pc.on("datachannel")
    def on_datachannel(channel) -> None:
        session.attach_channel(channel)

    @pc.on("track")
    def on_track(track) -> None:
        if track.kind == "audio":
            session.audio_task = asyncio.create_task(
                session.consume_audio(track),
                name="webrtc-audio-consumer",
            )

    @pc.on("connectionstatechange")
    async def on_connectionstatechange() -> None:
        if pc.connectionState in {"failed", "disconnected", "closed"}:
            await session.close()
            pcs.discard(pc)

        if pc.connectionState in {"failed", "disconnected"}:
            await pc.close()

    await pc.setRemoteDescription(
        RTCSessionDescription(sdp=params.sdp, type=params.type)
    )
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)

    return {
        "sdp": pc.localDescription.sdp,
        "type": pc.localDescription.type,
    }


@app.on_event("shutdown")
async def shutdown() -> None:
    await asyncio.gather(
        *(pc.close() for pc in pcs),
        return_exceptions=True,
    )
    pcs.clear()


# 放在 /offer 路由之后,避免静态站点抢先匹配接口。
app.mount(
    "/",
    StaticFiles(directory=BASE_DIR / "static", html=True),
    name="static",
)

这段后端代码最关键的四个点

第一,浏览器传来的音频格式并不固定,所以先统一重采样:

AudioResampler(format="s16", layout="mono", rate=16000)

第二,Silero VAD 的流式输入必须按模型需要的窗口切块。16 kHz 下每块使用 512 个采样点,不能把任意长度的 WebRTC frame 直接塞进去。

第三,每个会话都需要自己的 VAD 时序状态。VADIterator 会记录当前是否已经进入语音段、临时静音位置和累计采样数,多路会话共用同一个 iterator 会串状态。

第四,真正保护会话正确性的是:

if local_turn != self.turn_id:
    return

没有这层判断,旧请求即使晚到 200 ms,也可能让前端重新播出已经被打断的答案。

五、前端:收到 Barge-in 后立即停止播音

新建 static/index.html

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Voice Agent Barge-in Demo</title>
  <style>
    body {
      max-width: 760px;
      margin: 40px auto;
      padding: 0 20px;
      font-family: system-ui, sans-serif;
      color: #202124;
    }
    button {
      padding: 10px 16px;
      cursor: pointer;
    }
    #status {
      margin: 18px 0;
      font-weight: 700;
    }
    #log {
      min-height: 260px;
      padding: 16px;
      overflow: auto;
      background: #111827;
      color: #d1fae5;
      white-space: pre-wrap;
    }
  </style>
</head>
<body>
  <h1>Voice Agent Barge-in Demo</h1>
  <button id="start">连接麦克风</button>
  <div id="status">DISCONNECTED</div>
  <pre id="log"></pre>

  <script>
    const startButton = document.querySelector("#start");
    const statusEl = document.querySelector("#status");
    const logEl = document.querySelector("#log");

    let pc;
    let dc;
    let currentTurn = -1;

    function log(message) {
      const time = new Date().toLocaleTimeString();
      logEl.textContent += `[${time}] ${message}\n`;
      logEl.scrollTop = logEl.scrollHeight;
    }

    function setStatus(status) {
      statusEl.textContent = status;
    }

    function waitForIceGatheringComplete(peerConnection) {
      if (peerConnection.iceGatheringState === "complete") {
        return Promise.resolve();
      }

      return new Promise((resolve) => {
        function checkState() {
          if (peerConnection.iceGatheringState === "complete") {
            peerConnection.removeEventListener(
              "icegatheringstatechange",
              checkState
            );
            resolve();
          }
        }

        peerConnection.addEventListener(
          "icegatheringstatechange",
          checkState
        );
      });
    }

    function stopPlayback(reason) {
      // cancel() 会停止当前 utterance,并清空尚未播放的队列。
      window.speechSynthesis.cancel();
      log(`停止播音: ${reason}`);
    }

    function speak(text, turnId) {
      stopPlayback("开始新轮次");
      currentTurn = turnId;

      const utterance = new SpeechSynthesisUtterance(text);
      utterance.lang = "zh-CN";
      utterance.rate = 1;

      utterance.onstart = () => {
        setStatus("SPEAKING");
        log(`开始播报 turn=${turnId}`);
      };

      utterance.onend = () => {
        log(`播报结束 turn=${turnId}`);
        if (dc?.readyState === "open") {
          dc.send(JSON.stringify({
            type: "playback_done",
            turn_id: turnId
          }));
        }
      };

      utterance.onerror = (event) => {
        log(`播报异常 turn=${turnId}: ${event.error}`);
      };

      window.speechSynthesis.speak(utterance);
    }

    startButton.addEventListener("click", async () => {
      startButton.disabled = true;

      try {
        pc = new RTCPeerConnection();
        dc = pc.createDataChannel("events");

        dc.addEventListener("open", () => {
          setStatus("LISTENING");
          log("DataChannel 已连接,可以开始说话");
        });

        dc.addEventListener("message", (event) => {
          const data = JSON.parse(event.data);
          log(JSON.stringify(data));

          if (data.type === "state") {
            setStatus(data.state);
          }

          if (data.type === "barge_in") {
            stopPlayback(`用户插话,旧 turn=${data.interrupted_turn}`);
            currentTurn = data.turn_id;
          }

          if (data.type === "agent_text") {
            // 只播放当前最新轮次,旧消息直接丢弃。
            if (data.turn_id < currentTurn) {
              log(`丢弃迟到消息 turn=${data.turn_id}`);
              return;
            }
            speak(data.text, data.turn_id);
          }
        });

        pc.addEventListener("connectionstatechange", () => {
          log(`PeerConnection: ${pc.connectionState}`);
        });

        const stream = await navigator.mediaDevices.getUserMedia({
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true
          }
        });

        for (const track of stream.getAudioTracks()) {
          pc.addTrack(track, stream);
        }

        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);
        await waitForIceGatheringComplete(pc);

        const response = await fetch("/offer", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            sdp: pc.localDescription.sdp,
            type: pc.localDescription.type
          })
        });

        if (!response.ok) {
          throw new Error(`offer failed: ${response.status}`);
        }

        const answer = await response.json();
        await pc.setRemoteDescription(answer);
      } catch (error) {
        log(error.stack || error.message);
        setStatus("ERROR");
        startButton.disabled = false;
      }
    });
  </script>
</body>
</html>

浏览器这里有两个防线:

window.speechSynthesis.cancel();

负责立刻停止当前播报并清空排队内容。

if (data.turn_id < currentTurn) {
  return;
}

负责丢弃已经过期的消息。生产环境使用 AudioWorkletMediaSource 或 Web Audio API 播放流式 TTS 时,也要做同样的事情:停止当前音源节点、清空未播放 PCM buffer,并拒绝旧 turn_id 的新音频块。

六、运行并验证自然插话

启动服务:

uvicorn app:app --reload --host 0.0.0.0 --port 8000

浏览器打开:

http://localhost:8000

点击“连接麦克风”并允许浏览器使用麦克风。

建议按下面四步测试:

  1. 说一句完整的话,然后停顿;
  2. 等页面进入 SPEAKING,听到浏览器开始播报;
  3. 在播报中途直接开口;
  4. 检查日志里是否依次出现 speech_startedbarge_inLISTENING

理想日志大致如下:

state=THINKING turn=1
state=SPEAKING turn=1
agent_text turn=1
speech_started turn=1
barge_in interrupted_turn=1 turn=2
state=LISTENING turn=2
speech_ended turn=2
state=THINKING turn=3

如果第一次测试,建议戴耳机。外放场景下,扬声器播放的 TTS 可能重新进入麦克风,VAD 会把 AI 自己的声音误判成用户插话。浏览器的 echoCancellation 能缓解问题,但不能代替生产级 AEC 和设备实测。

七、接入真实 ASR、LLM 和 TTS 时怎么改

这个 Demo 的 run_agent() 只是模拟延迟。正式链路可以拆成三个可取消接口:

async def run_agent(self, local_turn: int) -> None:
    try:
        transcript = await self.asr.finish_utterance(
            turn_id=local_turn
        )

        answer_stream = self.llm.stream(
            text=transcript,
            turn_id=local_turn,
        )

        async for text_delta in answer_stream:
            if local_turn != self.turn_id:
                return

            async for pcm_chunk in self.tts.stream(text_delta):
                if local_turn != self.turn_id:
                    return

                self.send_audio_chunk(
                    pcm_chunk,
                    turn_id=local_turn,
                )

    except asyncio.CancelledError:
        await self.asr.abort(local_turn)
        await self.llm.abort(local_turn)
        await self.tts.abort(local_turn)
        self.clear_server_audio_queue(local_turn)
        raise

这里有几个工程细节不能省:

1. VAD 音频和 ASR 音频要来自同一条时间线

不要让 VAD 和 ASR 各自重新采集音频。最稳的做法是对同一份 16 kHz PCM 分流:

PCM chunk
  ├── VAD:判断 speech_start / speech_end
  └── ASR:持续做流式识别

否则两条链路的缓冲、重采样和时间戳容易错位。

2. 打断时不一定要清空全部上下文

被用户打断的“播报内容”和“已经确认的对话历史”不是一回事。

例如 AI 正在说:

你的订单预计明天下午送达,如果需要修改地址……

用户插话:

不用了,帮我查退款。

合理做法是保留“用户查询过订单”这件事,但不要把 AI 尚未播完的整段答案当成已经成功传达给用户。生产环境最好记录:

  • 生成到哪里;
  • 实际播放到哪里;
  • 用户在哪个时间点打断;
  • 哪些内容已经进入对话历史。

3. 播放器必须支持按轮次清空

流式 TTS 常常会提前生成多个音频块。收到 barge_in 后只暂停当前音频不够,队列里剩余的块也必须删除,否则旧声音过一会儿还会继续冒出来。

推荐每个音频块都携带:

{
  "type": "audio_chunk",
  "turn_id": 12,
  "sequence": 7,
  "sample_rate": 24000
}

前端只接受当前 turn_id,并按 sequence 排序。过期轮次直接丢弃。

4. 第三方请求要设置超时和主动关闭

task.cancel() 不等于远端推理立即停止。使用流式 ASR、LLM 或 TTS SDK 时,要确认它是否提供:

  • WebSocket close;
  • HTTP response close;
  • generator aclose()
  • request ID cancel;
  • 服务端生成中止接口。

否则前端虽然安静了,后台仍可能继续消耗计算资源。

八、VAD 参数怎么调

这篇 Demo 使用:

threshold=0.55
min_silence_duration_ms=350
speech_pad_ms=30

它们只是调试起点,不是所有设备的标准答案。

threshold

值越高,越不容易把噪声当成人声,但轻声说话可能漏检;值越低,越灵敏,但键盘声、电视声和扬声器回放更容易触发。

min_silence_duration_ms

值越小,句尾响应越快,但更容易把自然停顿切成两句;值越大,对停顿更宽容,但用户说完后要等更久。

中文客服场景可以先从 300 到 500 ms 试起,再用真实录音调参。不要只对着安静办公室说十句话就宣布参数可用。

speech_pad_ms

它会在检测到的语音边界前后保留少量音频,避免切掉爆破音或句尾。这个参数主要影响送给 ASR 的完整度,不应该拿来掩盖过慢的 Endpointing。

九、真正值得记录的打断指标

“听起来还行”很难定位问题。至少记录下面五个时间点:

t0 用户真实开口
t1 后端收到对应音频
t2 VAD 输出 speech_start
t3 前端收到 barge_in
t4 播放器实际停止

可以得到:

传输与缓冲延迟 = t1 - t0
VAD 检测延迟     = t2 - t1
事件回传延迟    = t3 - t2
播放器停止延迟  = t4 - t3
端到端打断延迟  = t4 - t0

除此之外,还要看:

  1. 误打断率:键盘、咳嗽、扬声器回声是否触发 Barge-in;
  2. 漏打断率:用户轻声或短促说“等等”时能否识别;
  3. 句尾误判率:自然停顿是否被切成多个轮次;
  4. 旧音频泄漏量:触发打断后还有多少毫秒旧声音被播放;
  5. 上下文恢复率:打断后新问题是否接到正确会话;
  6. 资源释放时间:旧 LLM/TTS 请求多久真正停止。

我更关心第五项。一个系统停得很快,却把用户的新问题接到错误上下文里,只是“反应很快地答错”。

十、常见问题

Voice Agent 如何实现自然插话?

完整做法是:用 VAD 检测新的 speech_start,在用户开口时切换会话状态,取消旧 ASR/LLM/TTS 任务,增加 turn_id,通知前端清空播放队列,并让新语音轮次接管上下文。仅停止播放器不等于完成 Barge-in。

VAD 和 Barge-in 有什么区别?

VAD 只判断当前音频是否包含语音;Barge-in 是一套中断控制流程,涉及 VAD、会话状态机、任务取消、TTS 队列清理和上下文管理。VAD 是触发信号,Barge-in 才是完整能力。

为什么用户开口后,Voice Agent 还是会多说半句话?

常见原因包括音频缓冲过大、VAD 窗口或阈值不合适、事件回传慢、播放器预缓存过多,以及停止当前音源后没有清空后续 TTS 队列。需要按 t0t4 分段记录延迟,不能只盯着 VAD。

Silero VAD 为什么要把音频切成 512 个采样点?

Silero VAD 的流式模型对输入窗口长度有要求。16 kHz 音频使用 512 个采样点,8 kHz 音频使用 256 个采样点。WebRTC frame 的长度不一定正好匹配,因此需要先重采样,再用缓冲区重新切块。

如何避免 Voice Agent 被自己的 TTS 声音打断?

先开启浏览器或设备的 AEC、降噪和自动增益控制,再使用耳机与外放两组设备测试。生产环境还要结合回声参考信号、说话人方向、播放状态和连续语音时长做判定,不能只靠提高 VAD 阈值。

打断时应该删除整段对话历史吗?

通常不应该。要区分已经确认的用户意图、已经实际播放给用户的内容和尚未播出的模型生成内容。旧轮次的未播放部分可以丢弃,但已确认事实仍可保留。更严谨的系统会记录生成进度和播放进度。

十一、总结

Voice Agent 的自然插话可以压缩成一句话:

VAD 负责发现用户重新开口,
状态机负责决定是否允许打断,
任务取消负责停止旧计算,
turn_id 负责拦住迟到结果,
播放器负责清空旧声音。

这五层少一层,都会出现很具体的问题:

  • 没有 VAD:不知道用户什么时候开口;
  • 没有状态机:正常说话也可能被当成打断;
  • 没有任务取消:旧模型继续浪费资源;
  • 没有 turn_id:迟到结果污染新轮次;
  • 没有清空播放器:旧 TTS 还会继续播放。

如果只是做 Demo,先把本文这条链路跑通,再替换真实 ASR、LLM 和 TTS。不要一开始把所有模型都接上,否则“用户插话后系统没有停”这个问题,可能藏在音频采集、VAD、网络、生成任务和播放器中的任何一层。

技术上,Barge-in 并不是一个特别神秘的算法。真正难的是让多个异步模块在同一个轮次协议下保持一致。

这也是我测 Voice Agent 时最喜欢直接上手试的一项:AI 正说到一半,我突然换一个问题。它是自然停下来,还是坚持把准备好的话念完?

参考资料

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐