# 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 核心原则 1. **只加耳朵和嘴巴,不动大脑**——现有 Dispatcher/Eino/工具/RAG/报告 一行不改 2. **复用现有通信管道**——NATS token 流 + SSE 回流原样利用 3. **控制面统一管理**——语音配置(ASR/TTS)走现有的 admin 控制面 + NATS 热更新 4. **可降级**——语音服务不可用时平台文字功能不受影响 ### 1.3 不做什么(本期) - ❌ 语音唤醒 / 声纹识别 - ❌ 多人同时语音会议 - ❌ 视频通话 - ❌ 端到端语音大模型(绕开编排引擎的方案不考虑) --- ## 2. 火山引擎 API 选型 ### 2.1 需要开通的服务 | 服务 | 用途 | API 协议 | 文档 | |---|---|---|---| | **流式语音识别** | 🎤 用户说话 → 文字 | WebSocket 双向流 | [流式语音识别 WebSocket](https://www.volcengine.com/docs/6561/1354869) | | **双向流式语音合成 (V3)** | 🔊 文字 → Agent 说话 | WebSocket 双向流 | [双向流式 TTS WebSocket V3](https://www.volcengine.com/docs/6561/1329505) | ### 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.(现有 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.(现有,不改) 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 ```go // 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 客户端 ```go // 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 客户端 ```go // 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 — 攒句器 ```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 路由变更 ```go // router.go 加一条: api.GET("/voice/stream", h.VoiceStream) // WebSocket 升级,须在 Auth 后 ``` ### 6.3 配置管理(复用现有控制面) 语音配置与模型配置走**完全相同的管道**: ``` Admin 控制台 → POST /admin/voice → Gateway 写 DB → NATS 广播 "voice" 配置 ↓ Gateway 自身热更新 VoiceConfig (ASR/TTS 客户端用新配置重建) ``` 契约: ```go // 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 周** | **改动影响**: ```diff + 新增文件: ~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 改动 ``` --- *本文档描述语音交互功能的设计方案;实现时以本文档为准,如有重大变更需更新本文档。*