你对语音助手说:「帮我查查这个项目的测试为什么失败,修好以后提交上去。」

这句话会难住绝大多数语音 Agent。它们通常落入两种尴尬:

  • 要么一直占着这一轮:工具没跑完就不再开口,你追问「做到哪了」,它也听不见;
  • 要么先说一句「我去看看」,把任务扔进后台,但做完以后不会自然回到原来的对话——你不知道它做完没有,它也不知道结果有没有真正播到你耳朵里。

qwen-audio-agent 想填的正是「模型会说话」到「助理始终在场」之间的这段工程空白。它不是新的音频模型,也没有训练任何 ASR(语音转文字)、TTS(文字转语音)或代码模型;它更像一个实时语音 Agent 操作系统:前台用 Qwen Audio Realtime 维持低延迟、可打断的交谈,后台把搜索、读文件、改代码这类需要真正动手的活,交给 OpenCode、OpenClaw、Qoder、Hermes、CodeBuddy、Codex 或任意兼容 ACP 协议的 Agent。

于是聊天继续,任务也继续。任务完成后,结果还会挑一个不抢话的时机,自己回到同一段语音对话里。

这篇以 qwen-audio-agent 0.9.1、主分支提交 9074ca5(2026-07-28)为快照,从产品直觉一路拆到 Gateway、Realtime WebSocket、对话管理、Work 状态机、ACP Session、权限与结果送达协议。文中开头那句「修测试」的请求,会作为例子贯穿全文。项目仍在快速迭代,后续版本的默认模型和支持范围可能变化。

一、先把定位说准:它是运行时,不是模型

名字里的 qwen-audio 很容易让人以为这是一个新的端到端语音模型。实际打开 package.json,会看到它是一个 Node.js monorepo,运行时依赖只有五个:ACP SDK、MCP SDK、Express、WebSocket 和 Zod。默认语音模型是云端的 qwen-audio-3.0-realtime-plus,后台模型默认配置为 qwen3.7-max——两个模型都是现成的,这个项目做的是把它们连接起来。

最简单的心智模型是:

不是再造一个模型,而是组合两类能力Qwen Audio Realtime听懂 · 直接回答 · 流式说话低延迟、可打断、保持对话Backend Agent搜索 · 文件 · 代码 · 应用工具、MCP、Skill、Session提交工作回注结果qwen-audio-agent RuntimeGateway · 调度 · 权限 · 记忆 · 可靠送达
qwen-audio-agent 不负责重新训练模型,而是把实时语音模型与能使用工具的后台 Agent 组合成一个持续在线的助理

这一区分很重要。它解决的不是「音频 token 怎么编码」「TTS 怎么更自然」,而是另一组问题:

  1. 用户说话时,谁负责收音、判断说完、生成和播放声音?
  2. 一项工作要跑几分钟时,语音对话凭什么还能继续?
  3. 多个请求先后到达,怎么避免同时写坏同一个 Agent 会话?
  4. 后台任务完成后,怎样保证结果不会在用户说话时硬插进来?
  5. 用户打断播报,究竟只是「别说了」,还是「后台的活也别干了」?
  6. 后台要执行危险操作时,怎么把「问用户要授权」这件事带回语音界面?

这些问题没有一个能靠单次模型调用解决,它们都是系统层面的职责——谁排队、谁串行、谁确认、谁重试。这也是为什么本文接下来讲的几乎全是协议和状态机,而不是模型。

二、全景图:表面是一个助理,内部是两条流水线

从用户视角看,始终只有「千问 Audio」这一个助理在跟你说话。但内部至少有四个角色:

  • 客户端:WebUI、终端 TUI 或 macOS 悬浮球,负责麦克风、扬声器和界面;
  • Realtime Gateway:跑在本机的唯一核心服务,管理连接、身份、对话、任务和结果播报,后面所有机制都住在这里;
  • Realtime 前台:Gateway 连接的 Qwen Audio Realtime 模型,会听、会说,也负责判断「这句话我直接答,还是交给后台干」;
  • Backend Agent:通过 ACP 接入的持久后台 Agent,真正拥有搜索、文件、代码、MCP、Skill 等执行能力。

复杂项目还会再多一层:后台 Agent 可以新建或续接一个项目 Session,把耗时工作异步交给它。项目自己的提示词里把这类任务叫「第三层任务」,README 里也放了一张三层接入参考架构图;但规范性的架构文档明确说,产品边界只有「Realtime 前台 + Backend Agent」两层——项目 Session 是后台内部的执行策略,不是第三个对用户暴露的助理。

一个助理,两条并行路径客户端WebUI浏览器TUI终端 + 原生音频桥DesktopmacOS 悬浮球麦克风 / 扬声器文字 / 任务时间线本机 Realtime GatewayVoice WebSocket轮次、音频、打断Task Manager队列、状态、持久化ACP AdapterSession、权限、委托Announcement结果租约与安全插入Qwen Audio RealtimeASR + 对话 + 流式语音六个前台工具DashScope WebSocketBackend Coordinator持久 ACP Session理解目标、决定执行策略项目 Session可选:异步执行长任务音频ACP客户端可替换;Gateway 是唯一核心服务;项目 Session 属于后台内部能力
整体架构:客户端只连本机 Gateway;Gateway 一边连接 Qwen Audio Realtime,一边通过 ACP 管理持久后台 Agent,必要时再委托项目 Session

这张图里最值得注意的是两条并行路径:

  • 声音路径追求低延迟:PCM 音频在客户端、Gateway 与 Realtime 服务之间流动;
  • 工作路径追求可执行、可靠:用户请求被包装成一条叫 Work 的任务记录,排队进入持久后台 Agent。

它们只在几个清晰的接点相遇:提交工作、查询状态、确认权限、播报结果。工作跑得再久,也不应该堵住声音路径。

三、一句话进来,走哪条路

现在把开头那句「帮我查查测试为什么失败,修好以后提交」丢进系统,看它怎么被处理。

用户每说完一轮,Realtime 前台会拿到最终 ASR(整句话的最终转写文本),然后在两条路之间选择:

路 A:直接回答

如果完整答案已经在当前对话里,不需要最新信息、文件、工具或实际交付物,Realtime 直接生成语音。比如「TCP 和 UDP 有什么区别」。这条路最短,延迟最低,也不会凭空制造后台任务。

路 B:提交工作

「修测试」显然不是路 A:它需要读文件、跑命令、改代码。这时 Realtime 调用一个叫 spawn_thinking 的工具,把请求提交给后台。注意它传的不是执行计划,而是对用户目标的保守整理(项目叫 objective):忠实保留目标、对象和限制,可以消解「这个项目」之类的指代,但不许规定后台用什么工具、开什么 Session。用户的原话(最终 ASR)会原样带上,作为事实来源。

Gateway 创建 Work 后立刻返回 accepted——注意是「已受理」,不是「已完成」,工具调用不等后台跑完。Realtime 可以顺口说一句「我去看看测试」,如果刚才已经预告过就保持安静。此后你继续聊天、追问进度、再提交别的任务,都不受影响。

用户最终 ASR本轮原话是事实来源需要当前信息、工具或实际交付物吗?不需要Realtime 直答流式语音最低延迟需要spawn_thinking立即返回 accepted不等待执行后台 Work排队执行,完成后自动回注执行期间,对话仍可继续
一轮语音请求的分流:知识性问题由 Realtime 直接回答;需要工具和实际交付的请求立即变成后台 Work,结果稍后自动回注

源码里的能力边界划得非常克制:Realtime 前台恰好只有六个工具

工具作用
spawn_thinking提交一个新的执行或调查目标
cancel_agent_task取消此前提交且仍活跃的 Work
get_agent_task_status查询生命周期、进度或阶段结果
get_current_time获取用户时区下的准确时间
user_memory读取、增加、更正或删除前台记忆
respond_agent_permission转交用户对当前权限请求的明确决定

没有文件读写、Shell、浏览器、GitHub 或数据库工具,也不能选择后台 Session、子 Agent 或执行策略。为什么故意做得这么「弱」?因为实时语音模型最重要的职责是把对话维持住;一旦让它亲自跑工具,它就会陷进高延迟、长上下文的执行轨迹里——正是开头说的「干活时聋了」的根源。

小前台,大后台Realtime:只有六个窄工具spawn_thinkingcancel_agent_taskget_agent_task_statusget_current_timeuser_memoryrespond_agent_permission负责意图分流、状态与对话连续性不知道 Shell、文件、浏览器和子 SessionBackend Agent:开放执行世界文件 / Shell搜索 / 浏览器代码 / GitMCP / Skill项目 Session自行选择工具与执行策略目标结果开放能力被收敛到可排队、可取消、可审计的边界之后
能力边界:Realtime 只持有六个窄工具;开放世界的文件、代码、浏览器与 MCP 能力全部留在 Backend Agent 一侧

这是整个项目最关键的设计判断:把开放世界的执行能力,收敛到一个可排队、可取消、可审计的边界后面。

四、全双工不是「同时开着麦克风和扬声器」这么简单

全双工指双方可以同时说话、随时打断,像打电话,而不是对讲机。听起来只是个音频问题,实际难在时序。

先看数据通路:客户端把 PCM 音频块通过 /api/realtime WebSocket 送进 Gateway(默认 DashScope Provider 的输入采样率 16 kHz),Gateway 转发给 Qwen Audio Realtime;模型返回的音频增量(24 kHz PCM)由客户端排队播放,文本增量同步进时间线。语音模式的会话配置是:

{
  modalities: ["text", "audio"],
  input_audio_format: "pcm",
  output_audio_format: "pcm",
  turn_detection: { type: "smart_turn" }
}

真正难的是同时发生。在我们的例子里,完全可能出现这样的瞬间:你正在问「顺便查下明天天气」,Realtime 正在播上一句的回答,客户端还有没播完的音频块,而后台的测试恰好修完了、结果正等着播报。

对话的时间,不再等于任务的时间时间用户说话请检查测试进度呢?解释下 ACP助理播报已开始查状态直接回答后台 Work排队 → 执行 → 委托项目 Session → 完成等待空闲窗口,再播结果
全双工时间线:语音轮次继续前进,后台 Work 独立运行;结果完成不等于立刻抢话,而是等待用户与当前播报都空闲

为了把这些瞬间处理对,qwen-audio-agent 区分了几种看似相近、实则不同的事件:

  • response.done:模型生成完了,但不代表扬声器播完了;
  • playback.started:客户端真的开始播放——后面会看到,结果通知以这一刻为「已送达」;
  • playback.ended:本地播放队列已经排空;
  • interrupt / playback.cancelled:停止当前说话,但不自动取消后台 Work

最后一条尤其重要。你说「停一下」可能只是嫌它回答啰嗦,绝不等于「放弃修测试」。所以打断语音和取消任务是两套独立的控制面:前者只清掉播放,后者必须明确调用 cancel_agent_task

跨平台的音频体验也有现实差异:

平台默认音频模式打断方式
macOS TUI原生音频桥(Swift/CoreAudio),带 AEC 的全双工直接说话
Linux / Windows TUIPortAudio 半双工播报时按 x
Linux / Windows 可选--audio-mode full,无 AEC 全双工直接说话,建议戴耳机

AEC 是回声消除:没有它,扬声器放出的助理声音会被麦克风收回去,误判成「用户在插话」。项目宁可让 Linux / Windows 默认退回半双工,也不把「同时开流」冒充成可靠的全双工——这是个很诚实的取舍。

五、对话管理:Gateway 怎么知道「现在是哪一轮」

上一节讲了事件时序,但还有一个更基础的问题没回答。语音对话没有回车键:转写是流式的、模型回复是异步的、后台播报随时可能到——Gateway 必须自己记清三件事:这句话属于哪一轮这个回复是不是已经过时重连之后话头怎么接上。这就是对话管理。它没有集中在某个大类里,而是散在 voice/ 下几个百行上下的小模块和 conversation/ 目录中,拼起来是一套完整的记账系统。

一轮的诞生:你一开口,世界让路

Realtime 服务的 smart_turn 检测到你开口的瞬间,Gateway 立刻铸造一个新话轮(摘自 realtime-gateway.mjs,注释为本文所加,有精简):

if (event.type === 'input_audio_buffer.speech_started') {
  turnGeneration += 1                                // 代次:单调递增
  turnId = `voice-${Date.now()}-${turnGeneration}`   // 本轮唯一标识
  inputTurns.remember(event.item_id, currentTurn())  // ASR 条目 → 本轮
  announcements.dismissActive()                      // 正在播的通知按已送达落账
  send(ws, { type: 'playback.clear', reason: 'user_interruption' })
  frontend?.cancel()                                 // 作废进行中的模型回复
}

短短几行做了三个对话管理决定。其一,打断是默认行为:只要检测到你开口,客户端播放队列立刻清空、进行中的模型回复立刻作废——「直接说话就能打断」不是额外功能,而是每一轮的第一步。其二,打断视为送达:正在播报的任务通知直接落账为已送达,不会因为你插了句嘴,稍后又复读一遍。其三,代次(generation):每开口一次加一,此后每个模型回复都带着自己所属轮次的代次戳。

代次戳防的是「迟到的回复」。设想你问了 A,模型还没答完你又开始问 B——A 的回复可能在 B 进行到一半时才姗姗来迟。Gateway 的 commitTurn 只接受代次不低于当前已提交代次的上下文,旧代次的迟到回复直接拒收,不会插进新话题里。0.7.0 Changelog 里那句「响应关联保护……降低串台风险」,说的就是这套代次机制。

流式转写:TurnCorrelation 记账

ASR 结果不是整句一次性到达的:增量按「条目」(item)流入,Gateway 用一张最多 100 项的 TurnCorrelation 表记住 item ID 到话轮的映射。增量阶段实时回显给界面(整句替换式刷新),转写完成时才结账(同一文件,转写完成分支):

} else if (event.type === 'conversation.item.input_audio_transcription.completed') {
  const { context: turn, invalid } = inputTurns.complete(event.item_id, currentTurn())
  if (invalid) return                          // 被判无效的话轮,转写整体丢弃
  const transcript = String(event.transcript || '').trim()
  if (!transcript) { /* 发 transcript.discard,界面撤回气泡 */ }
  commitTurn(turn)                             // 单调提交:旧代次不许倒灌
  transcripts.record(turn.turnId, transcript)  // 第九节的权限证据,查的就是它
  conversationSync.record({ role: 'user', source: 'voice-user', /* … */ })
}

「无效话轮」来自话轮检测的 turn_invalid 判定(比如环境噪声误触发),连同空转写一起丢弃、界面撤回气泡,不留半句幽灵输入。按轮存档的 transcripts 还有个贴心细节:第九节的权限证据校验来查时,如果这一轮的最终稿还没落地,它会等最多 800 毫秒,而不是立刻判失败。反方向也有兜底:你说完 12 秒模型仍没开始回复,watchdog 会报错并重建 Realtime 连接,而不是让对话永远停在「思考中」。

一条时间线,三路写入

记好账的内容最终汇进 ConversationSync——Gateway 为每个「用户 × 语音会话」维护的服务端时间线,最多 100 条、闲置 6 小时回收。每条消息带着来源标签,主要有三类:

  • voice-user:你每一轮的最终转写;
  • realtime-direct:Realtime 直接回答的话;
  • agent-presentation:后台工作结果的播报。

时间线握在 Gateway 手里而不是浏览器里,所以 WebUI 刷新、TUI 重启都能按序取回完整对话;agent-result 类的原始结果只在从未被播报过时才补进上下文,同一件事不会出现两遍。

去重还有更细的一层。提交任务时前台常顺口预告一句「我去看看测试」,稍后 Coordinator 的委托确认可能又是几乎同一句话——源码注释专门点了这个场景。ConversationSync 把两句话都归一化(转小写、去掉标点和空白),按 bigram(相邻两字组)重合度对比,超过三分之一就判为同一句话的改写、跳过播报。十几行代码,治好了语音助手特有的复读病。

一轮对话的账本:从开口到共享时间线用户开口speech_started新 turnId · 代次 +1清播放 · 废弃旧回复流式转写回显item → turn 关联最终转写提交空 · 无效 → 丢弃12 秒没开口回复重建 Realtime 连接最终转写voice-user模型直接回答realtime-direct · 带代次戳后台结果播报agent-presentation旧代次迟到回复代次不符 · 拒收ConversationSync 共享时间线(在 Gateway,不在浏览器里)每「用户 × 会话」一条 · 最多 100 条 · 闲置 6 小时回收UI 刷新或重连都按序取回 · 相似改写去重,同一句话不播两遍重连 / 新客户端近期 ≤ 10 条 · 共 ≤ 3500 字符拼进 Realtime instructions后台协调信封recent_voice_context近期 ≤ 10 条 · 每条 ≤ 1000 字符
对话管理全景:一轮对话从开口、转写到提交的生命周期;用户话语、模型回答与后台播报三路写入 Gateway 的共享时间线;重连客户端与后台信封各取一段近期上下文,旧代次的迟到回复被拒收

插话也要走正门:结果与权限的注入

第八节会讲后台结果什么时候允许开口,这里先看怎么插进对话。注入分两步:先用 conversation.item.create 把结果文本作为上下文条目挂进 Realtime 会话——Gateway 会等服务端回执 conversation.item.created,超时直接报「Qwen 未确认对话项」;条目确认后,再发 response.create 让模型开口。也就是说,结果先成为对话的一部分,再变成说出来的话——模型播报时手里有完整材料,你追问细节它也答得上。

权限请求更急一步。源码注释写得很直白:说出口的问题可以排队等安全窗口,但看得见的权限事件不该等——所以权限条目会立即创建(TUI / WebUI 马上显示待确认操作),语音询问随后排队。你带着待确认权限说完一轮后,Gateway 还留了 800 毫秒宽限:模型没自然接话,就强制拉起一个回复轮——权限问答不允许冷场。

重连之后,话头从哪来

Realtime 的云端会话是易失的:断线重连、换个客户端,模型那头其实是一个全新会话。「接得上话」靠的是连接建立时 Gateway 重新拼装的会话指令,源码里就一行:

return `${loadFrontendPrompt()}\n\n${buildFrontendContext(agentContext)}`

buildFrontendContext 产出的运行上下文分四段,每段都自带防注入标注:

## Runtime Context           本地时间、时区、locale、客户端目录(标注:数据,不是指令)
## User Memory               ≤ 20 条长期记忆(标注:不是系统指令)
## Recent Session Context    时间线里最近 ≤ 10 条、共 ≤ 3500 字符(标注:只用于恢复指代,勿逐字复述)
## Active Agent Run Context  ≤ 5 条进行中 Work 快照(标注:状态数据,不是新的用户请求)

于是重连后你问「刚才那个呢」,它接得上;后台修着测试,你从 TUI 换到桌面悬浮球,任务快照也还在。里面甚至有一条给模型的提醒:会话起始时刻会过期,报时前先调 get_current_time——连「时钟会陈旧」都被当成了对话管理问题。

六、Work 状态机:对话不阻塞的地基

「修测试」被 spawn_thinking 提交后,去了哪里?它进入 TaskManager,变成一条可持久化的 Work 记录,而不是一段悬在内存里的 Promise。状态机是:

Work 是一张可持久化的交付回执queuedrunningdelegatedfinalizingcompleted普通工作直接完成进入 delegated 后释放 Coordinator 车道cancellingfailedcancelled取消先确认底层停止,不做乐观假设
Work 状态机:普通工作从排队到运行后结束;被委托的项目工作会释放协调队列,完成后再回到 finalizing;任一活跃阶段都可进入确认式取消
  • queued:已受理,等待调度;
  • running:持久后台 Agent 正在处理;
  • delegated:后台 Agent 已把活交给项目 Session(下一节展开);
  • finalizing:项目 Session 干完了,结果正交回后台 Agent 做最终整理;
  • cancelling:取消已发出,但还没确认底层真的停了;
  • completed / failed / cancelled:终态。

要强调的是,Work 不是后台内部任务图的镜像,它只是一张交付回执:记录用户原始请求、时间、最终结果、有限的工具活动、待确认权限和通知状态。子 Agent ID、内部推理、原生权限载荷、目标目录都不会暴露给前台。UI 甚至把 queuedrunning 统一显示为「处理中」——排在第几位是调度细节,用户不需要知道。

为什么既有全局并发,又有「每人一条协调车道」

TaskScheduler 的默认约束有三层:全局最多 4 个活动任务;每个 owner(用户身份)最多 2 个;同一 laneKey(车道)还可以再设上限。

语音提交的普通 Work 都挂在 coordinator:<ownerId> 这条车道上,上限为 1。含义是:同一用户对持久后台会话的写入严格串行——你连说两个请求,第二个会排队,绝不会出现两轮消息交错写进同一段会话历史的竞态。

但串行不等于独占到底。当后台 Agent 把「修测试」委托给项目 Session 后,Work 进入 delegated,调度器会释放这条协调车道。于是你问「修到哪了」「再帮我查个别的」,后台 Agent 都能立刻响应,而项目 Session 在另一边继续跑测试。

这比「后台开个线程」多想了一步:它区分了协调资源(后台 Agent 的会话,稀缺、必须串行)和执行资源(项目 Session,可以并行挂着)。

重启以后会怎样

Work 记录连同通知状态会持久化到 tasks.json,但恢复能力是有边界的:

  • 已完成的结果在重启、重连后可以继续投递(待播通知最长保留 7 天);
  • 重启时仍处于 queued / running 的普通 Work 会直接转为失败,错误信息明说「重启时这项工作尚未完成,请重新提交」——因为无法证明原来的调用还能安全续上;
  • 只有 delegated / finalizing 的 Work,且保存了精确的 delegation ID 与目标 Session ID、所选后台具备原生委托恢复能力时,才会尝试恢复等待;条件不满足同样明确置为失败。

宁可明确失败,也不假装一个失去关联的操作还在正常执行——这是整个项目反复出现的品味。

七、ACP 与三段式委托:活怎么交出去,结果怎么收回来

前面一直说「通过 ACP 接入后台」,现在展开。

Agent Client Protocol(ACP)可以理解成「Agent 世界的 LSP」:编辑器不必为每种语言写一套私有协议,语言服务器也不必绑定某个编辑器;同理,客户端不必为每个代码 Agent 重写适配,Agent 也不必绑定某个 UI。协议覆盖 Session 创建与恢复、发送 Prompt、流式更新、工具调用和权限请求。

qwen-audio-agent 的 Gateway 是 ACP 客户端。对 OpenCode、Qoder、Hermes、CodeBuddy、Codex 和通用 ACP 后台,它启动一个 stdio 子进程、用 JSON-RPC 通信;OpenClaw 则经由一个小的 ACP bridge 接入。

持久会话:换个话题,它还记得你

Gateway 按「用户 × 后台协议」维护一个稳定的协调会话键,格式是 <协议>:<owner>:backend,比如 opencode:personal:backend;后台真实的 Session ID 保存在本地索引里,藏在这个稳定键后面。下一轮语音请求到来时,适配器优先调 session/resume 续接原会话,而不是新建一个失忆的 Agent。你昨天让它修的测试,今天问「那个问题解决了吗」,指的就是同一段后台记忆。

再配合上一节的车道限制(Gateway 队列串行)和适配器自身的写入串行,同一后台会话有双重保险不会被并发写坏。

三段式委托:Coordinator 只协调,不长跑

对 OpenCode 和 Qoder,Gateway 会临时起一个只监听 127.0.0.1 的 MCP Server(MCP 是给模型注入工具的标准协议),通过 ACP 塞给后台 Agent 五个协调工具,名字都带 qwen_audio_agent_ 前缀:

qwen_audio_agent_sessions_list      # 列出可续接的项目 Session
qwen_audio_agent_session_start      # 新建项目 Session 并派活
qwen_audio_agent_session_send       # 向已有 Session 追加派活
qwen_audio_agent_session_status     # 只读查询状态
qwen_audio_agent_session_cancel     # 取消

OpenClaw 的 ACP 不接受客户端注入的 MCP Server,同一套协调契约就映射到它的原生 Session 工具上——契约一致,载体不同。

关键在于 session_startsession_send 都是异步的:工具一返回 started,后台 Agent(此时扮演 Coordinator 角色)必须输出一份 delegated 传输结果并结束本轮——不许轮询、不许自己把活再干一遍。此后的等待、取消、权限中转和结果关联全部由 ACP 适配器接管:只有携带匹配 delegation ID 的那次完成,才能关闭这个 Work;无关的 Session 更新、空结果、旧结果都进不来。

执行与表达分两次经过 Coordinator① Coordinator理解用户目标start / send 项目 Session返回 delegated 后立即释放② Project Session搜索、读写、编码、测试独立异步执行可使用原生 Agent 能力③ Coordinator接收已验证最终结果整理 speech + inline不重复执行原任务ACP Adapter 持有相关性work_id + delegation_id + target_session_id等待、权限、状态查询、取消与防串结果都在这里完成项目 Session 负责把事做完;Coordinator 负责让结果回到原对话
三段式执行:Coordinator 发起或续接项目 Session 后立即释放;Adapter 按 delegation ID 等待精确结果;完成后再唤醒 Coordinator 做面向用户的最终整理

为什么项目 Session 的结果不直接塞给 Realtime,还要绕回 Coordinator 走一遍?因为执行结果适合说出口的表达不是一回事。项目 Session 返回的可能是测试日志、补丁摘要和文件路径;Coordinator 手里还有你的原话、近期对话和偏好,由它整理成最终交付:

{
  "work_id": "work id",
  "state": "completed",
  "mode": "respond",
  "presentation": {
    "speech": "适合口语的简洁结果",
    "inline": {
      "title": "屏幕上显示的详细内容",
      "format": "markdown",
      "content": "代码、链接或长说明"
    }
  }
}

speech语义材料而非逐字稿——Realtime 会结合此刻的对话自然表达,而不是照本宣科。inline 则把不适合耳朵的代码、Markdown、链接(format 三选一)留给屏幕上的时间线。

八、完成不等于送达:结果回注的可靠性设计

假设测试修好了,Work 变成 completed。故事结束了吗?对语音产品来说才走了一半:计算完成用户听到了之间,还隔着一条小型消息投递系统。所谓「回注」,就是把后台结果重新注入到进行中的语音对话里。链路是:

  1. TaskManager 把该任务的通知标为 pending
  2. 当前语音连接领取一批通知——领取带租约(60 秒过期,每 20 秒续一次),防止两个前端同时播同一条;
  3. 如果你正在说话、已有回复在播或者输出被禁用,先等安全窗口;
  4. 把一批结果注入 Realtime 上下文(120 毫秒窗口内合并,单批最多 8 项、总文本不超过 6000 字符,超长截断但完整结果仍留在任务记录里);
  5. Realtime 生成适合当下对话的播报——它知道你刚才在聊天气,会自然衔接而不是硬切;
  6. 客户端回报 playback.started(真的开始出声)后,通知才标为已送达;
  7. 断线、生成失败或 120 秒内等不到播放确认,就指数退避重试(1 秒起步、上限 10 秒),最多 8 次,用尽后放弃并释放,绝不让一条坏结果永远堵住后面的通知。
「算完」之后,还要跨过一条送达链Work 完成notification=pending领取租约防多个前端重复播等待窗口用户和回复都空闲注入 Realtime结合当前对话改写生成语音response.done客户端开始播放playback.started确认 delivered不再重复播报阻塞或失败退避重试 / 释放租约关键区别response.done = 生成完;playback.started = 用户端真正开播批处理、有界上下文和最大重试次数避免一条坏结果堵住所有通知
结果投递链:完成记录先进入待通知队列,经租约、防抢话窗口和 Realtime 口语化,直到客户端确认真正开始播放才算送达

其中最值得咀嚼的是第 6 步的确认点选择。response.done 只说明模型生成完,音频可能还压在客户端队列里;真等 playback.ended 又太晚——一段长播报要几十秒,期间挂掉就会重复播报。0.9.1 的 Changelog 记录了正是这个 bug 的修复:Gateway 在结果已开始播报后重启,会把同一条旧消息再播一遍;修复方案就是把确认点定在 playback.started——既最接近「用户真的听到了」,又不必为落账等完整段音频。

这套机制本质上是在语音 UI 上实现了一遍「带租约、可重试、去重的异步消息投递」——只是队列的消费端不是服务,而是你的耳朵。

九、权限:一句「可以」为什么要逐字留证

回到例子的最后一步:修完测试要 git push,这是受限操作,后台 Agent 通过 ACP 发来权限请求。Gateway 的立场是:不猜用户意图,也不因为「任务本来就要提交」就自动放行。

默认的 native 模式下,一次授权走这条链:

  1. 适配器把后台原生的权限选项转换成有界摘要(脱敏、截断),并生成一个绑定当前用户的 authorization_id
  2. Work 进入待授权状态,Realtime 用自然语言向你说明具体操作:「后台想执行 git push,允许吗?」;
  3. 你在本轮明确表态——「可以」「不行」都行,不要求固定口令;
  4. Realtime 调用 respond_agent_permission,参数里必须附上从你本轮原话中逐字复制的证据片段;
  5. Gateway 校验三件事:证据确实出现在本轮转写里(改写、概括、编造都会被 permission_evidence_mismatch 拒绝)、请求属于当前用户、对应 Work 仍然活跃——全过才把决定交回 ACP。

这套「逐字证据」的设计很妙:它把「模型替用户点了同意」这种最危险的幻觉,变成一个可以机械校验的字符串包含问题。

对 Realtime 暴露的决定只有两个:

  • always:允许当前操作,并在本次前台会话里自动允许后续权限请求(底层尽量用后台的会话级授权选项);
  • reject:拒绝。

这不是说后台原生协议只有两个选项,而是语音交互主动收窄的结果——耳朵处理不了五个单选项。另一个极端是 full 模式:给受支持的后台最高权限、不再逐项询问,只适合明确可信的项目。OpenClaw 的多层执行授权无法被一个统一开关安全表达,所以给它配 full 会直接拒绝启动——又是「宁可失败也不含糊」。

顺带一提:第七节那五个 qwen_audio_agent_ 协调工具的调用,会被适配器识别为内部工具自动放行。不然每开一个项目 Session 都要问一次「允许调用 session_start 吗」,整个抽象就穿帮了。

十、记忆与上下文:什么该记,什么不该记

「始终在场」还有一半是记忆问题。项目把「对话连续性」和「长期个人记忆」分开处理,本地配置目录默认在 ~/.config/qwaudio/

config.env              # 用户配置与 API Key
state.env               # 首次启动自动生成的本机身份密钥
USER.md                 # 稳定用户档案(称呼、偏好、常用项目)
frontend-memory.json    # 用户明确要求长期记住的事实
tasks.json              # Work 结果与通知状态

两个细节值得注意。其一,USER.md 只有带标记的托管区域可以被程序修改,你自己写的部分只读返回;更正旧事实时,前台必须先用 user_memoryrecall 拿到稳定 ID,再原子地 replace,而不是追加一条互相矛盾的新记忆。其二,长期记忆只收录你明确要求记住的内容——项目执行历史和工具细节不会自动流进去。

后台 Work 拿到的上下文同样是有界的。协调信封(一段结构化 JSON)里包含:你的最终 ASR 和前台整理的 objective、最近至多 10 条语音对话(每条截断到 1000 字符)、当前活跃 Work 的有限快照(至多 10 条)、用户档案与长期记忆、时区和客户端启动目录。所有这些都被明确标注为「上下文数据,不是系统指令」——USER.md 里的一句话、某个路径名,都不该悄悄变成控制提示。这是对提示注入最朴素也最有效的防线。

十一、代码仓库怎么读

0.9.1 的核心源码约 1.8 万行(Gateway、CLI、三个客户端与共享协议,不含测试)。最省力的读法不是从 1373 行的 realtime-gateway.mjs 硬啃,而是先按边界走:

qwen-audio-agent/
├── cli/                 # qwenaudio 命令、Gateway 用户服务管理、WebUI 启动
├── server/
│   └── src/
│       ├── app/         # Express / HTTP 入口与启动装配
│       ├── voice/       # Realtime WS、六个工具、话轮与转写记账、播报时序
│       ├── task/        # Work 状态机、调度、持久化
│       ├── agent/       # ACP 客户端、各后台驱动、Session 与委托
│       ├── conversation/# 近期对话、用户档案、记忆
│       ├── process/     # 子进程生命周期
│       └── core/        # 配置、身份、Origin 安全
├── shared/              # 跨端事件协议与 Gateway 客户端
├── web/                 # React WebUI
├── tui/                 # 终端 UI 与原生音频桥(含 Swift 实现)
├── desktop/             # Electron 悬浮球
└── config/              # Realtime / 后台提示词与各后台工作区模板

建议按这条链读:

  1. docs/architecture.md:先掌握不可破坏的产品不变量;
  2. realtime-provider.mjs:六个工具的定义和 Qwen Realtime 会话;
  3. tool-call-handler.mjs:语音工具调用怎样变成 Work,权限证据在哪校验;
  4. task-manager.mjs:状态机、调度、取消和通知;
  5. acp-backend-adapter.mjs:持久 Session、委托、权限和结果关联;
  6. announcement-manager.mjs:结果如何真正送到耳朵;
  7. 最后回到 realtime-gateway.mjs,把连接、轮次和所有竞态串起来。

测试结构也很有信号:重点不是「模型答得聪不聪明」,而是 FIFO 串行、Session 恢复、委托结果关联、播放回执、断线退避、权限所有权、Origin 校验和跨端音频桥——全是本文讲的这些边界。CI 在 Ubuntu、macOS、Windows 上分别覆盖 Node 22.22.2 与 24.15.0。

十二、跑起来:最小配置与常用入口

环境要求:Node.js 22.22.2+ 或 24.15.0+、npm 10+,以及 DashScope API Key(阿里云百炼对 Qwen Audio 3.0 Realtime 提供免费体验额度)。

npm install -g qwen-audio-agent
qwenaudio config

在命令提示的 config.env 里至少填两项——AGENT_PROTOCOL 是必填项,没有默认值:

DASHSCOPE_API_KEY=your-key
AGENT_PROTOCOL=opencode

后台可选 openclaw | opencode | qoder | hermes | codebuddy | codex,或用通用入口 acp 配合 ACP_COMMAND 接入任何支持 ACP stdio 的 Agent。然后启动 Gateway 和一个客户端:

# 终端一:本机 Gateway
qwenaudio

# 终端二:终端语音界面
qwenaudio tui

# 或浏览器界面
qwenaudio webui

希望助理长期在线,可以装成用户级后台服务:

qwenaudio gateway install
qwenaudio gateway status
qwenaudio gateway restart

Gateway、WebUI、TUI 和桌面悬浮球可以同时存在,但同一用户同一时刻只有一个活跃语音入口;TUI 和 WebUI 都支持 --takeover 抢过语音控制权。任何 UI 随时可以关掉——Gateway 和后台工作继续存在,这本身就是架构不变量之一。

十三、安全与隐私:哪些在本机,哪些会出机器

项目默认不含遥测、广告分析和自动崩溃上报,但它当然不是「数据完全不出本机」:

默认是本机 Gateway,不是离线系统本机信任边界 · 127.0.0.1:3101客户端麦克风 / 播放 / UIGateway身份 / Origin / 调度~/.config/qwaudio/USER.mdfrontend-memory.jsontasks.jsonconfig.env / state.env档案、记忆、任务状态和本机身份默认留在本地DashScope麦克风音频Realtime 对话上下文Qwen Audio Realtime所选 Backend Agent目标与必要对话上下文工具、模型、MCP 的后续流向由用户自己的配置决定WSSACP远程访问:带认证的 HTTPS 反向代理 + 显式可信 Origin;不要直接暴露 Gateway 端口
数据边界:配置、记忆和任务状态默认留在本机;麦克风与实时上下文发送到 DashScope;工作请求与必要上下文交给用户选择的后台 Agent
  • 麦克风音频、实时转写上下文和回复请求默认发送到 DashScope;
  • 后台目标、必要对话上下文和结果发送给所选 Agent,而该 Agent 还可能按你的配置调用其他模型、MCP 和外部服务;
  • 用户档案、长期记忆、任务状态与身份密钥默认保存在本机配置目录,卸载不会自动删除;
  • Agent 返回的 Markdown 里的远程图片、音频、视频不会自动加载,需要用户点击;
  • Gateway 默认只绑定 127.0.0.1:3101,且只对字面量 loopback 的 Host / Origin 放行浏览器请求——拿域名解析到 127.0.0.1 的 DNS rebinding 攻击因此无效;
  • 远程访问必须放在带认证的 HTTPS 反向代理后面,并显式配置可信 Origin;本机身份签名密钥(state.env)明确不是远程访问密码,不能拿它当认证用。

这套默认值背后是一条很实用的原则:本地 Agent Gateway 不应该因为「方便手机访问」就裸奔在局域网或公网上。

十四、它做对了什么,代价是什么

把它和一个朴素的「麦克风 → Realtime 模型 → 工具调用」Demo 放在一起,差异一目了然:

维度朴素语音工具 Demoqwen-audio-agent
对话与执行同一轮里串行两条流水线并行
长任务占住对话,或扔给外部脚本持久 Work + 可委托项目 Session
多请求容易并发写坏同一上下文owner 车道 + 适配器双重串行
打断常与取消混为一谈停止播报与取消任务分离
完成通知生成完即算完成等安全窗口,以播放回执确认
后台兼容每个 Agent 一套私有适配ACP 统一接入 + 通用 stdio 入口
权限UI 临时拼一个弹窗owner 绑定请求 + 本轮语音逐字证据
重启状态基本丢光终态与通知持久化,委托可条件恢复

它最漂亮的地方不是用了多少 Agent,而是几条边界画得很稳:

  1. Realtime 负责在场感,Backend 负责完成度;
  2. Work 是交付回执,不假装复制后台的内部世界;
  3. 进度只用于展示,不反过来控制执行;
  4. 用户听见了,才算接近真正的送达;
  5. 对用户呈现同一个助理,不等于内部只许有一个模型或一个 Session。

代价也很实在:

  • 它依赖云端 Qwen Audio Realtime 和 DashScope Key,不是离线语音栈;
  • Node 版本、后台 Agent 版本、ACP 能力、系统音频栈都进了兼容性矩阵;
  • Linux / Windows 默认做不到 macOS 那种免耳机全双工;
  • 可靠通知、Session 恢复、取消确认和权限中转,引入了大量状态与竞态处理——本文每一节几乎都对应一坨这样的代码;
  • 普通 running 中的 Work 无法在 Gateway 重启后续跑;
  • 0.9.1 仍在 1.0 之前的快速演进期,默认模型和集成成熟度都会继续变。

所以它不是一个「几十行就能复刻」的语音 Demo。恰恰相反,它把产品化语音 Agent 真正复杂的部分摊开给你看:模型调用只占其中一段,剩下的是协议、调度、身份、权限、音频设备、失败恢复和交付语义。

十五、最后:把「在场」做成系统属性

语音 Agent 的拟人感,常被归因于音色、情绪或更短的首包延迟。但当它开始替你做事,另一种体验很快变得更重要:

我随时可以继续跟它说话;它不会因为在干活就消失,也不会因为我打断一句话就把活弄丢;做完后,它记得为什么做,并挑一个合适的时机回来告诉我。

qwen-audio-agent 的价值,是把这种「始终在场」从产品口号拆成了一组可以写测试的系统不变量:

  • 声音路径不被工作路径阻塞;
  • 同一后台会话不被并发写坏;
  • 委托任务与原请求精确关联;
  • 权限必须落到当前用户本轮的明确表达;
  • 结果只在合适的时机进入对话;
  • UI 随时可退,Gateway 与后台工作继续存在。

从这个角度看,它最值得借鉴的不是某个类或提示词,而是一种 Agent 工程观:不要让一个模型同时扮演对话界面、任务队列、执行器、权限系统和消息中间件。 把职责拆开,再用小而明确的协议缝回「一个助理」,才有机会从能演示走向能长期相处。

参考