基于 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
前端会:
- 标记该 turn 为 interrupted
- 停止所有正在播放的音频
- 清空播放队列
- 后续该 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 路由(面试 / 客服 / 教练)
- 语音情绪控制(更自然)
