qwen-audio-agent 实时语音 Agent Runtime
qwen-audio-agent 把 Qwen Audio Realtime 放在前台,把 Codex、Claude、OpenClaw、OpenCode 等后台 Agent 放在 ACP 适配层后面,形成“语音协调 Agent + 后台执行 Agent + 项目 Session”的三层结构。
它解决什么,凭什么值得拆
第一层入口不是简单 WebSocket 转发。server/src/voice/realtime-gateway.mjs:86 创建 noServer 网关,server/src/voice/realtime-gateway.mjs:112 到 server/src/voice/realtime-gateway.mjs:126 在 upgrade 阶段同时做路径、Origin 和身份校验,这解释了为什么它能被桌面、Web 和 TUI 共用而不是暴露任意 socket。
语音所有权是这个项目的核心状态机之一。server/src/voice/realtime-gateway.mjs:98 到 server/src/voice/realtime-gateway.mjs:109 对同一 owner 广播 active/busy/available,server/src/voice/realtime-gateway.mjs:279 到 server/src/voice/realtime-gateway.mjs:292 决定 takeover 后输入和输出是否真正生效,避免两个客户端同时抢麦。
响应相关性不是靠“最后一条消息”猜测。server/src/voice/realtime-gateway.mjs:144 到 server/src/voice/realtime-gateway.mjs:158 保存 response candidate、turn correlation、transcript 和 permission 集合,server/src/voice/realtime-gateway.mjs:377 到 server/src/voice/realtime-gateway.mjs:400 还放了 12 秒 watchdog,实时模型不开始回复时会重建前端连接。
前台模型拿到的工具定义集中在 server/src/voice/realtime-provider.mjs:17 到 server/src/voice/realtime-provider.mjs:163,其中 server/src/voice/realtime-provider.mjs:197 到 server/src/voice/realtime-provider.mjs:222 把 Qwen Realtime session 和这些工具绑定,所以语音 Agent 可以委派任务、查询状态、处理权限和读写用户记忆。
▸核心数据
深度阅读 → 入口展开。
三层语音 Agent 架构
后台任务提交经过明显的防重复和防陈旧保护。server/src/voice/tools/tool-call-handler.mjs:187 到 server/src/voice/tools/tool-call-handler.mjs:215 解析 call_id、工具名和 turn generation,server/src/voice/tools/tool-call-handler.mjs:350 到 server/src/voice/tools/tool-call-handler.mjs:374 防止同一轮重复提交。
▸系统拓扑
▸模块清单
任务账本不是旁路日志,而是交付机制。server/src/task/task-manager.mjs:225 到 server/src/task/task-manager.mjs:288 创建 work,server/src/task/task-manager.mjs:291 到 server/src/task/task-manager.mjs:317 由 scheduler 控制启动和 progress 事件,语音层再根据这些事件决定何时播报结果。
▸关键数据结构
86export function attachRealtimeGateway(server, { 87 identityManager, 88 memoryStore, 89 coordinator, 90 coordinatorAvailable = async () => true, 91 respondPermission, 92 permissionPolicy, 93}) { 94 const wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 }) 95 const activeVoiceClients = new ActiveVoiceClients() 96 const voiceConnections = new Map() 97 98 const broadcastVoiceOwnership = ownerId => { 99 const active = activeVoiceClients.active(ownerId) 100 const holder = active?.descriptor || null 101 for (const client of voiceConnections.get(ownerId) || []) { 102 send(client.ws, { 103 type: 'voice.ownership', 104 state: active === client 105 ? 'active'
Realtime、工具调用与任务账本
▸ToolCallHandler:把语音函数调用变成后台 work
ACP 后台适配的亮点在 Session 复用。server/src/agent/acp-backend-adapter.mjs:280 到 server/src/agent/acp-backend-adapter.mjs:329 尝试恢复 coordinator session,失败才新建;server/src/agent/acp-backend-adapter.mjs:335 到 server/src/agent/acp-backend-adapter.mjs:357 再统一配置模式和模型。
187 async handle(event, callContext = {}) { 188 const callId = event.call_id || event.item?.call_id || '' 189 const toolName = event.name || event.item?.name || '' 190 if (!callId) throw new Error('Realtime 工具调用缺少 call_id') 191 if (this.processedCalls.has(callId)) return 192 this.processedCalls.add(callId) 193 if (this.processedCalls.size > 500) { 194 this.processedCalls.delete(this.processedCalls.values().next().value) 195 } 196 197 const turnId = callContext.turnId 198 || event.__voiceContext?.turnId 199 || this.getTurnId() 200 const generation = Number.isInteger(callContext.turnGeneration) 201 ? callContext.turnGeneration 202 : Number.isInteger(event.__voiceContext?.turnGeneration) 203 ? event.__voiceContext.turnGeneration 204 : this.getTurnGeneration() 205 let args = {} 206 try { 207 args = JSON.parse(event.arguments || '{}') 208 } catch { 209 // Invalid arguments are handled as missing fields below. 210 } 211 212 if (this.isStale(turnId, generation)) { 213 await this.closeStaleCall(callId, turnId) 214 return 215 } 216 217 if (toolName === GET_CURRENT_TIME_TOOL_NAME) { 218 await this.getCurrentTime(callId, turnId) 219 return 220 } 221 if (toolName === USER_MEMORY_TOOL_NAME) { 222 await this.userMemory(callId, turnId, generation, args) 223 return 224 } 225 if (toolName === CANCEL_AGENT_TASK_TOOL_NAME) { 226 await this.cancelAgentTask(callId, turnId, args) 227 return 228 } 229 if (toolName === GET_AGENT_TASK_STATUS_TOOL_NAME) { 230 await this.getAgentTaskStatus(callId, turnId, args) 231 return 232 } 233 if (toolName === RESPOND_AGENT_PERMISSION_TOOL_NAME) { 234 await this.respondAgentPermission(callId, turnId, args) 235 return 236 } 237 if (toolName !== DELEGATE_TOOL_NAME) { 238 await this.sendOutput( 239 callId, 240 failure('unsupported_tool', '当前无法执行这个操作。'), 241 turnId, 242 ) 243 return 244 }
▸AcpBackendAdapter:恢复 Session、模型与权限
权限不是模型一句话放行。server/src/agent/acp-backend-adapter.mjs:501 到 server/src/agent/acp-backend-adapter.mjs:550 把原生工具权限转成 pending permission,server/src/voice/tools/tool-call-handler.mjs:440 到 server/src/voice/tools/tool-call-handler.mjs:531 再把用户自然语言决定转回后台。
280 return this.coordinatorSessions.get(key) 281 } 282 if (this.coordinatorSessionPromises.has(key)) { 283 return this.coordinatorSessionPromises.get(key) 284 } 285 const pending = (async () => { 286 const stored = this.registry.get(key) 287 let session 288 if (stored?.sessionId) { 289 try { 290 session = await this.client.resumeSession(stored.sessionId, { 291 cwd: stored.cwd || this.directory, 292 mcpServers, 293 meta: this.coordinatorMeta(ownerId), 294 ownerId, 295 role: 'coordinator', 296 }) 297 const accepted = this.profile.acceptResumedSession?.({ 298 session, 299 role: 'coordinator', 300 coordinatorAgent: this.coordinatorAgent, 301 }) !== false 302 if (!accepted) { 303 this.registry.delete(key) 304 session = null 305 } else { 306 session.isNew = false 307 } 308 } catch { 309 this.registry.delete(key) 310 } 311 } 312 if (!session) { 313 session = await this.client.newSession({ 314 cwd: this.directory, 315 mcpServers, 316 meta: this.coordinatorMeta(ownerId), 317 ownerId, 318 role: 'coordinator', 319 }) 320 session.isNew = true 321 } 322 await this.configureSession(session, 'coordinator') 323 this.coordinatorSessions.set(key, session) 324 this.registry.set(key, session) 325 return session 326 })().finally(() => this.coordinatorSessionPromises.delete(key)) 327 this.coordinatorSessionPromises.set(key, pending) 328 return pending 329 }
从语音轮次到后台执行
CLI runtime 负责把本地网关进程安全拉起。cli/src/runtime.mjs:302 到 cli/src/runtime.mjs:349 在没有健康网关时 spawn server,cli/src/runtime.mjs:351 到 cli/src/runtime.mjs:389 再验证实际 backend 协议、归属和凭据,避免连接到配置不一致的旧进程。
▸语音轮次如何关联响应
▸后台任务如何回到前台播报
测试覆盖与失败保护
浏览器端不是只播放音频。src/useRealtimeVoice.js:256 到 src/useRealtimeVoice.js:357 管理 socket 重连和服务端事件,src/useRealtimeVoice.js:414 到 src/useRealtimeVoice.js:493 打开麦克风、重采样并上报音频,同时用音量电平驱动 UI。
▸测试覆盖
测试覆盖集中在协议边界和恢复逻辑:server/test/realtime-gateway.test.mjs:9 到 server/test/realtime-gateway.test.mjs:37 验证 WebSocket upgrade 只接受 /api/realtime,server/test/realtime-gateway.test.mjs:52 到 server/test/realtime-gateway.test.mjs:73 验证播放回执必须来自 active output client。ACP 测试则在 server/test/acp-backend-adapter.test.mjs:221 到 server/test/acp-backend-adapter.test.mjs:307 覆盖空响应重试和初始化冲突重试。
39test('confirms task notifications when client playback starts', () => { 40 assert.equal(confirmsTaskNotificationOnPlaybackStart({ 41 origin: 'announcement', 42 }), true) 43 assert.equal(confirmsTaskNotificationOnPlaybackStart({ 44 origin: 'model', 45 consumesTaskNotification: true, 46 }), true) 47 assert.equal(confirmsTaskNotificationOnPlaybackStart({ 48 origin: 'model', 49 }), false) 50}) 51 52test('accepts playback receipts only from the active output client for a known response', () => { 53 assert.equal(acceptsPlaybackReceipt({ 54 outputEnabled: true, 55 active: true, 56 responseKnown: true, 57 }), true) 58 assert.equal(acceptsPlaybackReceipt({ 59 outputEnabled: true, 60 active: false, 61 responseKnown: true, 62 }), false) 63 assert.equal(acceptsPlaybackReceipt({ 64 outputEnabled: false, 65 active: true, 66 responseKnown: true, 67 }), false) 68 assert.equal(acceptsPlaybackReceipt({ 69 outputEnabled: true, 70 active: true, 71 responseKnown: false, 72 }), false) 73})
▸已知边界 / 失败模式
- Realtime 模型不开始回复时,
server/src/voice/realtime-gateway.mjs:377到server/src/voice/realtime-gateway.mjs:400会清理旧 frontend 并重连。 - 用户新一轮打断旧响应时,
server/src/voice/realtime-gateway.mjs:720到server/src/voice/realtime-gateway.mjs:735会清空播放并提升 turn generation。 - 后台权限待决时,
server/src/voice/tools/tool-call-handler.mjs:246到server/src/voice/tools/tool-call-handler.mjs:278阻止把回答误提交成新任务。
值得拆的工程亮点
server/src/voice/realtime-gateway.mjs:94、server/src/voice/realtime-gateway.mjs:98 和 server/src/voice/realtime-gateway.mjs:279 附近的 owner 级激活逻辑。416 sendFunctionOutput(callId, output, context = {}, { 417 createResponse = true, 418 response, 419 } = {}) { 420 const sendOutput = () => this.createConversationItem({ 421 type: 'function_call_output', 422 call_id: callId, 423 output: JSON.stringify(output), 424 }) 425 if (!createResponse) return this.enqueueAction(sendOutput) 426 return this.enqueueResponse('agent', context, async () => { 427 await sendOutput() 428 this.send({ 429 type: 'response.create', 430 ...(response ? { response } : {}), 431 }) 432 }) 433 }
server/src/voice/realtime-gateway.mjs:411、server/src/voice/realtime-gateway.mjs:594、server/src/voice/realtime-gateway.mjs:688 说明结果持久化、通知 claim 和 inline 时间线是同一条链。302export async function ensureRuntime(options, { 303 root, 304 env = process.env, 305 fetchImpl = fetch, 306 spawnImpl = spawn, 307 platform = process.platform, 308 loadEnvironment = () => loadRuntimeEnvironment({ root, env }), 309 requireCredential = () => requireDashScopeCredential(env), 310} = {}) { 311 loadEnvironment() 312 const runtime = new ManagedRuntime([], { platform }) 313 const local = isLocalGateway(options.url) 314 const backend = resolveBackend(options, env) 315 const backendLabel = backend.protocol 316 ? backendDefinition(backend.protocol)?.label || backend.protocol 317 : '后台 Agent' 318 let health = await readGatewayHealth(options.url, fetchImpl) 319 const existingGateway = Boolean(health) 320 321 try { 322 if (!health) { 323 if (!local) throw new Error(`无法连接远程 Gateway:${options.url}`) 324 if (options.allowMissingCredential !== true) requireCredential() 325 const target = new URL(options.url) 326 if (target.protocol !== 'http:') { 327 throw new Error('本地自动启动 Gateway 只支持 http 地址') 328 } 329 Object.assign(env, backendEnvironment(env, backend)) 330 const spec = gatewaySpawn( 331 root, 332 platform, 333 backendEnvironment(env, backend), 334 target, 335 options, 336 ) 337 const gateway = spawnImpl(spec.command, spec.args, spec.options) 338 runtime.children.push(gateway) 339 health = await waitForReadiness( 340 gateway, 341 waitForGateway(options.url, { 342 fetchImpl, 343 requireBackend: Boolean( 344 backend.protocol && options.waitForBackend !== false 345 ), 346 }), 347 'Gateway', 348 ) 349 }
技术亮点之外,别忽略这些
这个项目的风险主要来自实时音频、后台 Agent 和权限系统同时运行;任何一层的时序错误都会变成用户能听见的错误确认。
server/src/voice/realtime-provider.mjs:572 到 server/src/voice/realtime-provider.mjs:620 用 response queue generation 控制并发,逻辑可靠但对维护者理解成本较高。server/src/voice/tools/tool-call-handler.mjs:440 到 server/src/voice/tools/tool-call-handler.mjs:531 把自然语言同意映射为权限决定,体验自然但必须防止模型误判。cli/src/runtime.mjs:351 到 cli/src/runtime.mjs:389 检查旧 Gateway 兼容性,说明用户机器上已有进程会造成配置漂移风险。server/test/realtime-gateway.test.mjs:39 到 server/test/realtime-gateway.test.mjs:73 覆盖关键函数,但端到端音频设备和真实 Qwen Realtime 网络抖动仍需要更重的集成测试。src/useRealtimeVoice.js:256 到 src/useRealtimeVoice.js:285 建立 socket 并声明 voice/input/output 能力,src/useRealtimeVoice.js:287 到 src/useRealtimeVoice.js:339 分派 voice state、audio delta 和 transcript 事件,说明服务端上下文会被完整投影到 UI。
播放队列是前端最容易出错的部分,src/useRealtimeVoice.js:140 到 src/useRealtimeVoice.js:168 统一取消正在排队和已经开始的音频 source,src/useRealtimeVoice.js:170 到 src/useRealtimeVoice.js:188 等待 response done、transcript done 和 source count 清零后才发送播放结束,这与服务端的 task notification 确认互相咬合。
麦克风输入不是原样透传。src/useRealtimeVoice.js:414 到 src/useRealtimeVoice.js:428 请求带回声消除和降噪的媒体流,src/useRealtimeVoice.js:436 到 src/useRealtimeVoice.js:448 把浏览器采样率重采样到服务端要求的输入采样率并编码为 PCM base64,这让 Qwen Realtime 的输入格式保持稳定。
可视化状态也来自真实音频数据。src/useRealtimeVoice.js:457 到 src/useRealtimeVoice.js:476 用 analyser 计算能量电平并驱动 listening UI,src/useRealtimeVoice.js:500 到 src/useRealtimeVoice.js:503 的 interrupt 同时停止本地播放并通知网关,形成用户打断的双端闭环。
客户端连接恢复设计和服务端 watchdog 对齐。src/useRealtimeVoice.js:341 到 src/useRealtimeVoice.js:357 在 socket 错误或关闭后清理播放并指数退避重连,src/useRealtimeVoice.js:362 到 src/useRealtimeVoice.js:369 在组件卸载时关闭 socket、停止播放、清空 muted response,避免页面切换留下幽灵音频。
这也是为什么主页面把它归类为 runtime,而不是单纯 React 组件:src/useRealtimeVoice.js:202 到 src/useRealtimeVoice.js:254 自己调度 AudioBufferSource 的开始时间和完成回执,src/useRealtimeVoice.js:31 到 src/useRealtimeVoice.js:56 则把语音所有权和输入/输出 mute 行为抽成共享判断函数。