24 KiB
24 KiB
sundynix-agentix · 语音交互设计文档
版本:2026-07-17 定位:让用户通过语音与 Agent 双向对话——语音命令让 Agent 干活(做任务/写报告/检索知识库),Agent 语音回答结果。 配套文档:
ARCHITECTURE_DESIGN.md(架构总览)、DEPTH_ROADMAP.md(路线图)
1. 目标与范围
1.1 要做什么
用户对着麦克风说「帮我写一份关于 AI 医疗的报告」→ 系统实时识别语音 → 自动触发现有 Agent 编排 → Agent 边想边"说"结果给用户听。
全链路:
🎤 用户说话 → ASR(语音→文字) → 现有编排引擎(Eino) → Token 流 → TTS(文字→语音) → 🔊 用户听到
1.2 核心原则
- 只加耳朵和嘴巴,不动大脑——现有 Dispatcher/Eino/工具/RAG/报告 一行不改
- 复用现有通信管道——NATS token 流 + SSE 回流原样利用
- 控制面统一管理——语音配置(ASR/TTS)走现有的 admin 控制面 + NATS 热更新
- 可降级——语音服务不可用时平台文字功能不受影响
1.3 不做什么(本期)
- ❌ 语音唤醒 / 声纹识别
- ❌ 多人同时语音会议
- ❌ 视频通话
- ❌ 端到端语音大模型(绕开编排引擎的方案不考虑)
2. 火山引擎 API 选型
2.1 需要开通的服务
| 服务 | 用途 | API 协议 | 文档 |
|---|---|---|---|
| 流式语音识别 | 🎤 用户说话 → 文字 | WebSocket 双向流 | 流式语音识别 WebSocket |
| 双向流式语音合成 (V3) | 🔊 文字 → Agent 说话 | WebSocket 双向流 | 双向流式 TTS WebSocket V3 |
2.2 为什么选这两个
| 决策 | 原因 |
|---|---|
| 流式 ASR(非一句话识别) | 用户说长句/多句时能实时出部分结果,体验像实时字幕 |
| 双向流式 TTS(非 HTTP 非流式) | 可以边喂文字边拿音频,不用等全文生成完;与 token 流天然配合 |
| 不用端到端语音大模型 API | 那个会绕开 Eino 编排引擎,图编排/工具调用/RAG/报告全废 |
2.3 火山引擎配置参数
# .env 新增(与现有 LLM 配置同级)
VOLC_ASR_APPID= # 火山引擎 appid
VOLC_ASR_TOKEN= # 火山引擎 access token
VOLC_ASR_CLUSTER= # ASR 集群(如 volcengine_streaming_common)
VOLC_TTS_APPID= # 可与 ASR 同一个 appid
VOLC_TTS_TOKEN= # 可与 ASR 同一个 token
VOLC_TTS_CLUSTER= # TTS 集群(如 volcano_tts)
VOLC_TTS_VOICE_TYPE= # 音色(如 BV700_streaming)
3. 架构设计
3.1 整体架构(在现有 5 层上的增量)
┌── 客户端层 ─────────────────────────────────────────────────────────┐
│ 桌面端 Wails / 浏览器 │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ 🎤 录音按钮 │ │ 📝 实时转写显示│ │ 🔊 音频播放队列 │ │
│ │ MediaRecorder│ │ (边说边显字) │ │ (边收边播放) │ │
│ └──────┬─────┘ └──────────────┘ └────────▲────────┘ │
│ │ 音频帧上行 音频帧下行 │ │
│ └──────────── WebSocket ────────────┘ │
└────────────────────────┬────────────────────────────────────────────┘
│
┌── Gateway(接入层)─────┴────────────────────────────────────────────┐
│ │
│ 新端点: GET /api/v1/voice/stream (WebSocket 升级) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ VoiceSession │ │
│ │ │ │
│ │ 上行链路: │ │
│ │ 客户端音频帧 → volcASRClient(WS) → 转写文字 → 推回客户端显示 │ │
│ │ ↓ │ │
│ │ 用户说完(静音检测/手动结束) │ │
│ │ ↓ │ │
│ │ 自动组装 DSL → POST /tasks │ │
│ │ (复用现有任务提交,零改动) │ │
│ │ │ │
│ │ 下行链路: │ │
│ │ 订阅 sundynix.streams.<task_id>(现有 token 流) │ │
│ │ ↓ │ │
│ │ SentenceBuffer(攒到句号/逗号/问号/感叹号/换行) │ │
│ │ ↓ │ │
│ │ volcTTSClient(WS) → 音频帧 → 推回客户端播放 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 现有路由/中间件/NATS 发布/SSE 回流 → 全部不动 │
└──────────────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
现有后端全套(不改) 火山引擎云端
NATS → Dispatcher ASR WebSocket 端点
Eino 编排 TTS WebSocket 端点
MCP 工具
RAG/报告/记忆
3.2 关键设计决策
| # | 决策 | 理由 |
|---|---|---|
| D1 | 语音逻辑全在 Gateway 内,不加新微服务 | 音频只是 I/O 转码,不是业务逻辑;遵循 Monolith First |
| D2 | Gateway ↔ 火山引擎直连 WebSocket | 音频帧需毫秒级中继,过 NATS 多一跳反而增延迟 |
| D3 | 客户端 ↔ Gateway 用单条 WebSocket | 同一连接承载上行音频 + 下行转写 + 下行 TTS 音频,用消息类型区分 |
| D4 | 转写完成后复用 POST /tasks 逻辑 | 语音只是输入方式替换,任务提交/编排/回流全走现有管道 |
| D5 | TTS 攒句再合成 | 逐 token 喂 TTS 太碎(单字合成不自然);攒到标点再喂,自然度好 |
| D6 | 语音配置走 admin 控制面 | 与模型配置同管理,支持热更新(改音色/切 provider 不重启) |
4. 数据流详解
4.1 上行:用户说话 → 触发任务
时间线 →
用户: [====说话中====] [停顿/点击结束]
↓↓↓↓↓↓↓↓↓↓↓↓↓
客户端: 音频帧(PCM 16kHz/16bit) 每100ms一帧(3.2KB)
↓ WebSocket binary
Gateway: 转发 → 火山 ASR WebSocket
← 部分转写结果(partial) ← 火山 ASR
← 最终转写结果(final) ← 火山 ASR
↓
推回客户端显示(实时字幕)
↓
用户停止说话 → 取 final 结果
↓
组装简单 DSL:
{
"version": "1",
"nodes": [{"id":"input","kind":"input","config":{"text":"<转写文字>"}},
{"id":"agent","kind":"agent","config":{"autonomous":true}}],
"edges": [{"source":"input","target":"agent"}]
}
↓
调用现有 h.SubmitTask() 内部逻辑(经 NATS 发布任务)
↓
返回 task_id 给 VoiceSession(用于下行订阅)
4.2 下行:Agent 回答 → 用户听到
时间线 →
Dispatcher: token: "人" → "工" → "智" → "能" → "在" → "医" → "疗" → "领" → "域" → "," → ...
↓ sundynix.streams.<task_id>(现有,不改)
Gateway
VoiceSession: 订阅 token 流,逐 token 累积到 SentenceBuffer
↓
遇到断句符(,。!?\n)→ 截取一句完整文字
↓
喂给火山 TTS WebSocket → 收音频帧(PCM/opus)
↓
WebSocket binary 推给客户端
↓
客户端: AudioContext 播放队列,顺序播放每段音频
同时文字也在屏幕上显示(双通道:看+听)
4.3 WebSocket 消息协议
客户端 ↔ Gateway 的 WebSocket 用 JSON 控制帧 + Binary 音频帧:
// 客户端 → Gateway
// 1. 开始录音
{"type": "asr_start", "session_id": "xxx", "graph": {...}} // 可选携带编排图
// 2. 音频帧(binary,PCM 16kHz 16bit mono)
[binary data]
// 3. 停止录音
{"type": "asr_stop"}
// 4. 打断 TTS 播放(用户开始说下一句时)
{"type": "tts_interrupt"}
// ---
// Gateway → 客户端
// 1. ASR 部分结果(实时字幕)
{"type": "asr_partial", "text": "人工智能在医"}
// 2. ASR 最终结果
{"type": "asr_final", "text": "人工智能在医疗领域的应用"}
// 3. 任务已提交
{"type": "task_submitted", "task_id": "task_xxx"}
// 4. 文字流(同步显示)
{"type": "text_chunk", "text": "人工智能在医疗领域,"}
// 5. TTS 音频帧(binary,带前缀字节区分)
[0x01][binary audio data] // 0x01 前缀标识这是 TTS 音频
// 6. 回答完毕
{"type": "done"}
// 7. 错误
{"type": "error", "message": "ASR 连接失败"}
5. 模块设计
5.1 新增文件清单
sundynix-gateway/
internal/
voice/ ← 新增包
session.go ← VoiceSession:管理一次语音对话的生命周期
asr.go ← 火山 ASR WebSocket 客户端封装
tts.go ← 火山 TTS WebSocket 客户端封装
sentence_buffer.go ← Token 流攒句器
config.go ← 语音配置(appid/token/cluster/voice)
handler/
voice.go ← WebSocket 升级 + VoiceSession 入口(新增)
router/
router.go ← 加一条路由(改 1 行)
sundynix-desktop/frontend/
src/
components/
VoiceButton.tsx ← 🎤 按住说话按钮 + 录音逻辑(新增)
VoicePlayer.tsx ← 🔊 TTS 音频播放队列(新增)
lib/
voice.ts ← WebSocket 连接管理 + 音频采集/播放(新增)
sundynix-admin/
src/pages/
DatasourcesPage.tsx ← 加语音配置表单(改几行)
sundynix-shared/
contract/
voice.go ← 语音配置契约 VoiceConfig(新增)
5.2 Gateway voice 包设计
session.go — VoiceSession
// VoiceSession 管理一次语音对话的完整生命周期:
// 1. 接收客户端音频帧 → 转发 ASR → 回传转写文字
// 2. 转写完成 → 组装 DSL → 调用现有任务提交
// 3. 订阅 token 流 → 攒句 → 喂 TTS → 回传音频帧
// 4. 支持打断:用户再次说话时中止当前 TTS
type VoiceSession struct {
ws *websocket.Conn // 客户端连接
asr *ASRClient // 火山 ASR
tts *TTSClient // 火山 TTS
buf *SentenceBuffer // 攒句器
bus *nats.Bus // 复用现有 NATS bus
submit func(text, graph) // 复用现有 SubmitTask 逻辑
taskID string // 当前任务 ID
mu sync.Mutex
}
func (s *VoiceSession) Run(ctx context.Context) // 主循环
func (s *VoiceSession) handleUpstream(ctx) // 上行:音频→ASR→转写
func (s *VoiceSession) handleDownstream(ctx) // 下行:token→攒句→TTS→音频
func (s *VoiceSession) interrupt() // 打断 TTS
asr.go — 火山 ASR 客户端
// ASRClient 封装火山引擎流式语音识别 WebSocket 连接。
// 协议:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
// 上行:音频帧(PCM 16kHz 16bit)
// 下行:JSON(partial/final 转写结果)
type ASRClient struct {
conn *websocket.Conn
appid string
token string
cluster string
}
func NewASRClient(cfg VoiceConfig) (*ASRClient, error)
func (c *ASRClient) SendAudio(data []byte) error // 发音频帧
func (c *ASRClient) Recv() (text string, isFinal bool, err error) // 收转写
func (c *ASRClient) Close() error
tts.go — 火山 TTS 客户端
// TTSClient 封装火山引擎双向流式语音合成 WebSocket 连接。
// 协议:wss://openspeech.bytedance.com/api/v3/tts/bidirection
// 上行:文字(可多次发送,流式喂入)
// 下行:音频帧(PCM/opus,流式返回)
type TTSClient struct {
conn *websocket.Conn
appid string
token string
cluster string
voiceType string
}
func NewTTSClient(cfg VoiceConfig) (*TTSClient, error)
func (c *TTSClient) SendText(text string) error // 喂一句文字
func (c *TTSClient) RecvAudio() (data []byte, done bool, err error) // 收音频
func (c *TTSClient) Close() error
sentence_buffer.go — 攒句器
// SentenceBuffer 把逐 token 的文字流攒成完整句子。
// 遇到断句符(,。!?;\n)时输出一个句子段,喂给 TTS。
// 设超时兜底:超过 2s 没遇到断句符也强制输出(防长无标点段卡住)。
type SentenceBuffer struct {
buf strings.Builder
out chan string // 攒好的句子
timeout time.Duration // 无标点强制输出超时(默认 2s)
}
func NewSentenceBuffer() *SentenceBuffer
func (b *SentenceBuffer) Feed(token string) // 喂一个 token
func (b *SentenceBuffer) Flush() // 强制输出剩余
func (b *SentenceBuffer) Sentences() <-chan string // 读取攒好的句子
5.3 客户端设计
VoiceButton.tsx — 录音按钮
两种交互模式:
A. 按住说话(Push-to-Talk):按下录音、松开发送 — 适合短命令
B. 点击切换(Toggle):点一次开始录音、再点一次结束 — 适合长段落
录音参数:
- MediaRecorder / AudioWorklet 采集
- PCM 16kHz 16bit mono(火山 ASR 要求)
- 每 100ms 切一帧发送(~3.2KB/帧)
VoicePlayer.tsx — 音频播放
- Web Audio API (AudioContext) 播放队列
- 收到 TTS 音频帧 → 解码 → 入队 → 顺序播放
- 支持打断:用户再次说话时清空队列、发 tts_interrupt
- 播放状态指示:🔊 动画
voice.ts — WebSocket 管理
- 建立/维护到 gateway /api/v1/voice/stream 的 WebSocket
- 区分 JSON 控制帧和 binary 音频帧
- 自动重连 + 心跳保活
- 暴露 hooks:useVoice() → { startRecording, stopRecording, isListening, transcript, isPlaying }
6. 与现有系统的集成点
6.1 改动清单(最小化)
| 文件 | 改动 | 行数 |
|---|---|---|
sundynix-gateway/internal/router/router.go |
加一条 WebSocket 路由 | +1 行 |
sundynix-gateway/internal/handler/voice.go |
新增 handler(调 VoiceSession) | 新文件 ~80 行 |
sundynix-gateway/internal/voice/*.go |
新增包(ASR/TTS/Session/Buffer/Config) | 新文件 ~500 行 |
sundynix-shared/contract/voice.go |
VoiceConfig 契约 | 新文件 ~30 行 |
sundynix-shared/bus/bus.go |
加 ServeConfig/SubscribeConfig("voice",...) | 复用现有 config 模式,0 改动 |
sundynix-admin/.../DatasourcesPage.tsx |
加语音配置表单 | +~50 行 |
sundynix-desktop/frontend/... |
新增 3 个文件 | 新文件 ~400 行 |
| 现有后端(Dispatcher/MCP/Eino/NATS) | 0 改动 |
6.2 路由变更
// router.go 加一条:
api.GET("/voice/stream", h.VoiceStream) // WebSocket 升级,须在 Auth 后
6.3 配置管理(复用现有控制面)
语音配置与模型配置走完全相同的管道:
Admin 控制台 → POST /admin/voice → Gateway 写 DB → NATS 广播 "voice" 配置
↓
Gateway 自身热更新 VoiceConfig
(ASR/TTS 客户端用新配置重建)
契约:
// contract/voice.go
type VoiceConfig struct {
ASRAppID string `json:"asr_appid"`
ASRToken string `json:"asr_token"` // 密文(AES-256-GCM,复用现有 secrets)
ASRCluster string `json:"asr_cluster"`
TTSAppID string `json:"tts_appid"`
TTSToken string `json:"tts_token"` // 密文
TTSCluster string `json:"tts_cluster"`
TTSVoiceType string `json:"tts_voice_type"`
TTSEncoding string `json:"tts_encoding"` // pcm / opus / mp3
TTSRate int `json:"tts_rate"` // 24000
Enabled bool `json:"enabled"`
}
7. 实现步骤
Phase 1:ASR(耳朵)— 3 天
目标:用户说话 → 屏幕上实时显示转写文字 → 手动提交为任务
| # | 任务 | 产出 |
|---|---|---|
| 1.1 | 火山引擎开通流式 ASR 服务,拿到 appid/token/cluster | 配置 |
| 1.2 | voice/asr.go:封装火山 ASR WebSocket 客户端 |
代码 |
| 1.3 | voice/config.go:读 env 配置 |
代码 |
| 1.4 | voice/session.go:VoiceSession 上行链路(音频→ASR→转写) |
代码 |
| 1.5 | handler/voice.go + router.go:WebSocket 端点 |
代码 |
| 1.6 | 客户端 voice.ts + VoiceButton.tsx:录音+WS+显示转写 |
代码 |
| 1.7 | 端到端验证:说话 → 实时转写 → 手动复制到输入框提交 | 验证 |
Phase 2:语音直接触发任务 — 1 天
目标:说完自动提交任务,不用手动操作
| # | 任务 | 产出 |
|---|---|---|
| 2.1 | session.go:转写完成 → 组装默认 DSL → 调 SubmitTask |
代码 |
| 2.2 | 客户端:说完 → 自动提交 → 切到任务运行视图 | 代码 |
| 2.3 | 支持携带当前画布编排图(用户在 Studio 页说话时用画布图跑) | 代码 |
Phase 3:TTS(嘴巴)— 3-4 天
目标:Agent 回答时语音朗读
| # | 任务 | 产出 |
|---|---|---|
| 3.1 | 火山引擎开通双向流式 TTS,选音色 | 配置 |
| 3.2 | voice/tts.go:封装火山 TTS WebSocket 客户端 |
代码 |
| 3.3 | voice/sentence_buffer.go:token 流攒句器 |
代码 + 单测 |
| 3.4 | session.go:下行链路(订阅 token 流→攒句→TTS→音频推送) |
代码 |
| 3.5 | 客户端 VoicePlayer.tsx:AudioContext 播放队列 |
代码 |
| 3.6 | 端到端验证:说话 → 任务执行 → Agent 边想边说 | 验证 |
Phase 4:打断 + 连续对话 — 2 天
目标:自然对话体验
| # | 任务 | 产出 |
|---|---|---|
| 4.1 | 打断:用户再次说话时中止 TTS + 清空播放队列 | 代码 |
| 4.2 | 连续对话:上一轮完毕后自动重新激活麦克风 | 代码 |
| 4.3 | 会话历史串联:同一 session_id 下多轮对话共享上下文 | 代码 |
| 4.4 | 静音检测(VAD):客户端 3s 无声自动结束录音 | 代码 |
Phase 5:控制面 + 上线 — 2 天
目标:运维可管理,生产可用
| # | 任务 | 产出 |
|---|---|---|
| 5.1 | Admin 控制台加语音配置页(ASR/TTS appid/token/音色选择) | 代码 |
| 5.2 | contract/voice.go + token 密文存储(复用 secrets) |
代码 |
| 5.3 | 语音配置热更新(NATS 广播,复用 config 模式) | 代码 |
| 5.4 | 健康检查:/admin/status 加 ASR/TTS 连通性探测 |
代码 |
| 5.5 | 语音用量计量(ASR 秒数 + TTS 字数)→ 现有计费管道 | 代码 |
| 5.6 | .env.example 加语音配置项说明 |
文档 |
8. 性能与延迟分析
8.1 端到端延迟拆解
用户说完最后一个字 → 听到 Agent 第一个字的时间:
ASR 尾部延迟 ~300ms (火山 ASR final 结果延迟)
+ 任务提交 → NATS ~10ms (现有管道)
+ Dispatcher 消费 ~5ms (现有管道)
+ LLM TTFT ~500ms (首 token 延迟,取决于模型)
+ 攒句(到第一个标点) ~200ms (模型每秒约 30-50 token,逗号很快出现)
+ TTS 首段合成 ~200ms (火山 TTS 流式首包延迟)
─────────────────────────────────
总计 ~1.2s ← 可接受(人类对话轮转间隔约 0.5-2s)
8.2 后续句子的延迟
首句之后,TTS 与 LLM 推理流水线重叠——LLM 在生成下一句时,上一句的 TTS 还在播放。用户感知是连续朗读,无等待。
8.3 音频带宽
上行(ASR): PCM 16kHz 16bit mono = 32KB/s ≈ 256kbps → 完全可接受
下行(TTS):
- PCM 24kHz 16bit: 48KB/s
- opus 编码后: ~6-12KB/s ≈ 48-96kbps → 推荐用 opus 省带宽
9. 安全考量
| 维度 | 措施 |
|---|---|
| ASR/TTS Token | 复用现有 secrets.Encrypt/Decrypt (AES-256-GCM),DB 存密文,NATS 过密文 |
| WebSocket 鉴权 | 升级前经过 middleware.Auth(),未登录不可连 |
| 音频不落盘 | 语音帧只在内存中转,不存储不持久化 |
| TTS 内容脱敏 | 复用现有 harness/output.go 的流式脱敏——脱敏后的文字再喂 TTS |
| 限流 | 语音连接数按用户限制(默认同时 1 个语音会话) |
10. 可观测性
| 指标 | 来源 |
|---|---|
| ASR 识别延迟 | Gateway OTel span voice.asr |
| TTS 合成延迟 | Gateway OTel span voice.tts |
| 语音会话数 | Prometheus gauge sundynix_voice_sessions_active |
| ASR 用量(秒) | 计入现有用量管道 |
| TTS 用量(字) | 计入现有用量管道 |
| 错误率 | span error + Prometheus counter |
11. 未来扩展(本期不做)
| 方向 | 说明 |
|---|---|
| 声纹识别 | 用声纹替代/辅助登录鉴权 |
| 多语种实时翻译 | ASR → 翻译 → TTS,三段流水线 |
| 自定义唤醒词 | "Hey Sundynix" 免点击启动 |
| 音色克隆 | 用户上传自己的声音,Agent 用用户喜欢的声音回答 |
| Provider failover | ASR/TTS 也做主备链(火山→讯飞),复用 LLM failover 模式 |
12. 总工期与资源
| 阶段 | 内容 | 工期 |
|---|---|---|
| Phase 1 | ASR(耳朵) | 3 天 |
| Phase 2 | 语音触发任务 | 1 天 |
| Phase 3 | TTS(嘴巴) | 3-4 天 |
| Phase 4 | 打断 + 连续对话 | 2 天 |
| Phase 5 | 控制面 + 上线 | 2 天 |
| 总计 | ~2 周 |
改动影响:
+ 新增文件: ~10 个(Go 5 个 + TS 3 个 + 契约 1 个 + handler 1 个)
+ 新增代码: ~1,500 行(Go ~600 + TS ~400 + 测试 ~300 + 配置/文档 ~200)
~ 修改文件: 3 个(router.go +1行, DatasourcesPage.tsx +50行, .env.example +8行)
不动文件: Dispatcher / MCP-Go / MCP-Py / Eino / NATS bus / shared 核心 = 0 改动
本文档描述语音交互功能的设计方案;实现时以本文档为准,如有重大变更需更新本文档。