Skip to content

基于 tio-boot + ElevenLabs 构建实时语音 Agent(支持打断与主动介入) ​

这篇文章记录一次完整的实时语音 Agent 实践: 使用 tio-boot 作为后端 WebSocket 网关,对接 ElevenLabs Agents 实时语音能力,实现一个支持:

  • 实时语音对话(浏览器麦克风)
  • 中文语音输出
  • 多轮对话
  • 用户打断(interrupt)
  • 主动介入(proactive intervention)
  • 面试场景定制(system + resume + questions)

的语音 Agent 系统。


一、整体架构 ​

整个系统分为三层:

1)前端(浏览器) ​

负责:

  • 采集麦克风音频(16k PCM)
  • 通过 WebSocket 发送给后端
  • 接收后端返回的语音与文本事件
  • 解码音频并播放
  • 管理 turn 状态(打断 / 完成)

2)后端(tio-boot) ​

负责:

  • WebSocket 接入(/api/v1/voice/11/agent)
  • 会话管理(sessionKey)
  • 音频转发(前端 → ElevenLabs)
  • 音频与事件转发(ElevenLabs → 前端)
  • turn 管理
  • 主动介入逻辑(callback)

3)ElevenLabs Agent ​

负责:

  • 实时语音理解(ASR)
  • 对话生成(LLM)
  • 语音合成(TTS)
  • 中断控制(interrupt)

二、核心设计:Prompt 与 First Message ​

在 ElevenLabs Agent 中,有两个关键概念:

system message(系统提示词) ​

我们将以下信息拼接成一个统一的 system message:

  • system_prompt
  • user_prompt
  • job_description
  • resume
  • questions

这样做的好处:

  • 所有上下文集中管理
  • 更稳定的对话行为
  • 适合面试、客服等复杂场景

first message(首次发言) ​

将 greeting 单独作为:

  • first_message

作用是:

  • 控制开场风格
  • 保证首轮输出自然

三、音频链路设计(关键) ​

这是整个系统最核心的一部分。


上行(用户 → 模型) ​

链路如下:

浏览器麦克风
→ Float32
→ 重采样 16k
→ 转 Int16 PCM
→ WebSocket binary
→ 后端
→ base64
→ ElevenLabs

特点:

  • 前端始终发送 binary(性能最好)
  • 后端只做一次 base64 转换
  • 完全符合 ElevenLabs 实时接口要求

下行(模型 → 用户) ​

这是这次优化的重点。


❌ 旧方案(问题所在) ​

ElevenLabs → 后端 → binary → 前端

问题:

  • 音频没有 turn 信息
  • interrupt 后残留音频无法区分
  • 容易出现“已经打断还在播放”

✅ 新方案(最终方案) ​

ElevenLabs → base64
→ 后端封装
→ audio_chunk (带 turnId)
→ 前端解码播放

每个音频包都包含:

  • turnId
  • audioBase64

四、为什么必须引入 turnId ​

这是整个系统最关键的一个演进点。


之前为什么“没问题” ​

在简单场景下:

  • 没有频繁打断
  • 音频顺序稳定
  • 单轮对话

前端只需要:

  • 当前是否在播放
  • 收到 interrupt 就 stop

就能工作。


现在为什么“出问题” ​

当系统具备这些能力后:

  • 用户随时打断
  • Agent 主动发言
  • 多轮快速切换
  • 网络存在延迟

就会出现:

interrupt 已经发生,但旧音频仍然在播放

原因是:

  • 前端不知道每个音频包属于哪一轮对话

turnId 的作用 ​

引入 turnId 后:

前端可以做到:

  • 只播放当前 turn 的音频
  • 已 interrupt 的 turn → 全部丢弃
  • 已完成的 turn → 全部丢弃
  • 晚到的旧包 → 自动丢弃

本质变化 ​

从:

“基于当前状态猜测是否播放”

升级为:

“基于音频归属精确判断是否播放”


五、前端播放控制机制 ​

前端核心有三个集合:

1)activeAssistantTurnId ​

当前正在播放的 turn


2)interruptedTurnIds ​

已被打断的 turn


3)completedTurnIds ​

已完成的 turn


播放判断逻辑 ​

只有满足以下条件才播放:

  • turnId == 当前 active turn
  • 不在 interrupted 集合
  • 不在 completed 集合

否则直接丢弃音频。


六、打断(interrupt)处理流程 ​

当收到:

assistant_turn_interrupt

前端会:

  1. 标记该 turn 为 interrupted
  2. 停止所有正在播放的音频
  3. 清空播放队列
  4. 后续该 turn 的音频全部丢弃

效果:

  • 打断立即生效
  • 不会再出现“残音”

七、主动介入(Proactive Intervention) ​

系统还实现了一个高级能力:

当用户长时间不说话,Agent 主动发言

逻辑在后端 callback 中:

  • 记录 assistant 完成时间
  • 检测用户沉默时间
  • 达到阈值自动触发 prompt

特点:

  • 不依赖前端
  • 与 ElevenLabs 完全解耦
  • 可以自由定制策略

八、工程上的几个关键优化 ​

1)前端始终使用 AudioWorklet ​

比 ScriptProcessor 更稳定、延迟更低


2)统一使用 16k PCM ​

避免多次重采样带来的质量损失


3)后端不再使用 binary 下行 ​

全部改为 JSON + base64


4)turnId 贯穿整个链路 ​

保证状态一致性


5)sessionKey 作为桥接唯一标识 ​

保证:

  • 一个前端连接 → 一个 bridge
  • 一个 bridge → 一个 ElevenLabs session

九、最终效果 ​

完成后系统具备:

实时语音能力 ​

  • 低延迟对话
  • 流式播放

强交互能力 ​

  • 支持打断
  • 支持追问
  • 支持主动介入

高可控性 ​

  • system prompt 完全自定义
  • 面试 / 客服 / 教学场景可扩展

十、总结 ​

这次实现的核心收获是:

实时语音系统中,“音频属于哪一轮对话”是必须建模的。

如果没有 turnId:

  • 系统只能“尽量正确”
  • 无法做到“绝对正确”

一旦引入 turnId:

  • 打断变得可靠
  • 残留音频完全可控
  • 系统行为稳定

一句话总结 ​

这是一个从“能用”升级到“可控、稳定、可扩展”的关键一步。


如果你后续还想继续优化,可以往这几个方向继续:

  • VAD(自动检测说话结束)
  • 降噪与音频增强
  • 多 Agent 路由(面试 / 客服 / 教练)
  • 语音情绪控制(更自然)

MIT Licensed