你对语音助手说:「帮我查查这个项目的测试为什么失败,修好以后提交上去。」
这句话会难住绝大多数语音 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——两个模型都是现成的,这个项目做的是把它们连接起来。
最简单的心智模型是:
这一区分很重要。它解决的不是「音频 token 怎么编码」「TTS 怎么更自然」,而是另一组问题:
- 用户说话时,谁负责收音、判断说完、生成和播放声音?
- 一项工作要跑几分钟时,语音对话凭什么还能继续?
- 多个请求先后到达,怎么避免同时写坏同一个 Agent 会话?
- 后台任务完成后,怎样保证结果不会在用户说话时硬插进来?
- 用户打断播报,究竟只是「别说了」,还是「后台的活也别干了」?
- 后台要执行危险操作时,怎么把「问用户要授权」这件事带回语音界面?
这些问题没有一个能靠单次模型调用解决,它们都是系统层面的职责——谁排队、谁串行、谁确认、谁重试。这也是为什么本文接下来讲的几乎全是协议和状态机,而不是模型。
二、全景图:表面是一个助理,内部是两条流水线
从用户视角看,始终只有「千问 Audio」这一个助理在跟你说话。但内部至少有四个角色:
- 客户端:WebUI、终端 TUI 或 macOS 悬浮球,负责麦克风、扬声器和界面;
- Realtime Gateway:跑在本机的唯一核心服务,管理连接、身份、对话、任务和结果播报,后面所有机制都住在这里;
- Realtime 前台:Gateway 连接的 Qwen Audio Realtime 模型,会听、会说,也负责判断「这句话我直接答,还是交给后台干」;
- Backend Agent:通过 ACP 接入的持久后台 Agent,真正拥有搜索、文件、代码、MCP、Skill 等执行能力。
复杂项目还会再多一层:后台 Agent 可以新建或续接一个项目 Session,把耗时工作异步交给它。项目自己的提示词里把这类任务叫「第三层任务」,README 里也放了一张三层接入参考架构图;但规范性的架构文档明确说,产品边界只有「Realtime 前台 + Backend 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 可以顺口说一句「我去看看测试」,如果刚才已经预告过就保持安静。此后你继续聊天、追问进度、再提交别的任务,都不受影响。
源码里的能力边界划得非常克制:Realtime 前台恰好只有六个工具。
| 工具 | 作用 |
|---|---|
spawn_thinking | 提交一个新的执行或调查目标 |
cancel_agent_task | 取消此前提交且仍活跃的 Work |
get_agent_task_status | 查询生命周期、进度或阶段结果 |
get_current_time | 获取用户时区下的准确时间 |
user_memory | 读取、增加、更正或删除前台记忆 |
respond_agent_permission | 转交用户对当前权限请求的明确决定 |
它没有文件读写、Shell、浏览器、GitHub 或数据库工具,也不能选择后台 Session、子 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 正在播上一句的回答,客户端还有没播完的音频块,而后台的测试恰好修完了、结果正等着播报。
为了把这些瞬间处理对,qwen-audio-agent 区分了几种看似相近、实则不同的事件:
response.done:模型生成完了,但不代表扬声器播完了;playback.started:客户端真的开始播放——后面会看到,结果通知以这一刻为「已送达」;playback.ended:本地播放队列已经排空;interrupt/playback.cancelled:停止当前说话,但不自动取消后台 Work。
最后一条尤其重要。你说「停一下」可能只是嫌它回答啰嗦,绝不等于「放弃修测试」。所以打断语音和取消任务是两套独立的控制面:前者只清掉播放,后者必须明确调用 cancel_agent_task。
跨平台的音频体验也有现实差异:
| 平台 | 默认音频模式 | 打断方式 |
|---|---|---|
| macOS TUI | 原生音频桥(Swift/CoreAudio),带 AEC 的全双工 | 直接说话 |
| Linux / Windows TUI | PortAudio 半双工 | 播报时按 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(相邻两字组)重合度对比,超过三分之一就判为同一句话的改写、跳过播报。十几行代码,治好了语音助手特有的复读病。
插话也要走正门:结果与权限的注入
第八节会讲后台结果什么时候允许开口,这里先看怎么插进对话。注入分两步:先用 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。状态机是:
queued:已受理,等待调度;running:持久后台 Agent 正在处理;delegated:后台 Agent 已把活交给项目 Session(下一节展开);finalizing:项目 Session 干完了,结果正交回后台 Agent 做最终整理;cancelling:取消已发出,但还没确认底层真的停了;completed/failed/cancelled:终态。
要强调的是,Work 不是后台内部任务图的镜像,它只是一张交付回执:记录用户原始请求、时间、最终结果、有限的工具活动、待确认权限和通知状态。子 Agent ID、内部推理、原生权限载荷、目标目录都不会暴露给前台。UI 甚至把 queued 和 running 统一显示为「处理中」——排在第几位是调度细节,用户不需要知道。
为什么既有全局并发,又有「每人一条协调车道」
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_start 和 session_send 都是异步的:工具一返回 started,后台 Agent(此时扮演 Coordinator 角色)必须输出一份 delegated 传输结果并结束本轮——不许轮询、不许自己把活再干一遍。此后的等待、取消、权限中转和结果关联全部由 ACP 适配器接管:只有携带匹配 delegation ID 的那次完成,才能关闭这个 Work;无关的 Session 更新、空结果、旧结果都进不来。
为什么项目 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。故事结束了吗?对语音产品来说才走了一半:计算完成和用户听到了之间,还隔着一条小型消息投递系统。所谓「回注」,就是把后台结果重新注入到进行中的语音对话里。链路是:
TaskManager把该任务的通知标为pending;- 当前语音连接领取一批通知——领取带租约(60 秒过期,每 20 秒续一次),防止两个前端同时播同一条;
- 如果你正在说话、已有回复在播或者输出被禁用,先等安全窗口;
- 把一批结果注入 Realtime 上下文(120 毫秒窗口内合并,单批最多 8 项、总文本不超过 6000 字符,超长截断但完整结果仍留在任务记录里);
- Realtime 生成适合当下对话的播报——它知道你刚才在聊天气,会自然衔接而不是硬切;
- 客户端回报
playback.started(真的开始出声)后,通知才标为已送达; - 断线、生成失败或 120 秒内等不到播放确认,就指数退避重试(1 秒起步、上限 10 秒),最多 8 次,用尽后放弃并释放,绝不让一条坏结果永远堵住后面的通知。
其中最值得咀嚼的是第 6 步的确认点选择。response.done 只说明模型生成完,音频可能还压在客户端队列里;真等 playback.ended 又太晚——一段长播报要几十秒,期间挂掉就会重复播报。0.9.1 的 Changelog 记录了正是这个 bug 的修复:Gateway 在结果已开始播报后重启,会把同一条旧消息再播一遍;修复方案就是把确认点定在 playback.started——既最接近「用户真的听到了」,又不必为落账等完整段音频。
这套机制本质上是在语音 UI 上实现了一遍「带租约、可重试、去重的异步消息投递」——只是队列的消费端不是服务,而是你的耳朵。
九、权限:一句「可以」为什么要逐字留证
回到例子的最后一步:修完测试要 git push,这是受限操作,后台 Agent 通过 ACP 发来权限请求。Gateway 的立场是:不猜用户意图,也不因为「任务本来就要提交」就自动放行。
默认的 native 模式下,一次授权走这条链:
- 适配器把后台原生的权限选项转换成有界摘要(脱敏、截断),并生成一个绑定当前用户的
authorization_id; - Work 进入待授权状态,Realtime 用自然语言向你说明具体操作:「后台想执行 git push,允许吗?」;
- 你在本轮明确表态——「可以」「不行」都行,不要求固定口令;
- Realtime 调用
respond_agent_permission,参数里必须附上从你本轮原话中逐字复制的证据片段; - 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_memory 的 recall 拿到稳定 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 / 后台提示词与各后台工作区模板
建议按这条链读:
docs/architecture.md:先掌握不可破坏的产品不变量;realtime-provider.mjs:六个工具的定义和 Qwen Realtime 会话;tool-call-handler.mjs:语音工具调用怎样变成 Work,权限证据在哪校验;task-manager.mjs:状态机、调度、取消和通知;acp-backend-adapter.mjs:持久 Session、委托、权限和结果关联;announcement-manager.mjs:结果如何真正送到耳朵;- 最后回到
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 和后台工作继续存在,这本身就是架构不变量之一。
十三、安全与隐私:哪些在本机,哪些会出机器
项目默认不含遥测、广告分析和自动崩溃上报,但它当然不是「数据完全不出本机」:
- 麦克风音频、实时转写上下文和回复请求默认发送到 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 放在一起,差异一目了然:
| 维度 | 朴素语音工具 Demo | qwen-audio-agent |
|---|---|---|
| 对话与执行 | 同一轮里串行 | 两条流水线并行 |
| 长任务 | 占住对话,或扔给外部脚本 | 持久 Work + 可委托项目 Session |
| 多请求 | 容易并发写坏同一上下文 | owner 车道 + 适配器双重串行 |
| 打断 | 常与取消混为一谈 | 停止播报与取消任务分离 |
| 完成通知 | 生成完即算完成 | 等安全窗口,以播放回执确认 |
| 后台兼容 | 每个 Agent 一套私有适配 | ACP 统一接入 + 通用 stdio 入口 |
| 权限 | UI 临时拼一个弹窗 | owner 绑定请求 + 本轮语音逐字证据 |
| 重启 | 状态基本丢光 | 终态与通知持久化,委托可条件恢复 |
它最漂亮的地方不是用了多少 Agent,而是几条边界画得很稳:
- Realtime 负责在场感,Backend 负责完成度;
- Work 是交付回执,不假装复制后台的内部世界;
- 进度只用于展示,不反过来控制执行;
- 用户听见了,才算接近真正的送达;
- 对用户呈现同一个助理,不等于内部只许有一个模型或一个 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 工程观:不要让一个模型同时扮演对话界面、任务队列、执行器、权限系统和消息中间件。 把职责拆开,再用小而明确的协议缝回「一个助理」,才有机会从能演示走向能长期相处。
参考
- QwenAudio/qwen-audio-agent(项目主页、安装与支持矩阵)
- Architecture(两层产品边界、非阻塞请求流、Work 与结果交付的不变量)
- Configuration(后台、权限、音频模式、远程访问与默认配置)
- Privacy(本地数据、外部服务与远程部署边界)
- Changelog(0.2.0 到 0.9.1 的架构演进)
- Agent Client Protocol Introduction(ACP 的定位、传输与互操作目标)