news 2026/9/12 14:49:31

Qwen Live 工具延续与权限恢复机制解析:语音会话响应仲裁与权限生命周期设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qwen Live 工具延续与权限恢复机制解析:语音会话响应仲裁与权限生命周期设计

Qwen Live 工具延续与权限恢复机制解析:语音会话响应仲裁与权限生命周期设计

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

导读

本文以 docs/design/2026-09-01-qwen-live-tool-continuation-permission-resume.md 设计文档为主体,结合 qwen-live 包的源码实现,深入解析 Qwen Live 在 DashScope Realtime 语音链路上如何处理"工具调用结果后的会话延续"与"跨会话权限请求的恢复"两大问题。读完本文,你将掌握:为何 provider 的自动响应创建必须被关闭、直接响应如何与用户语音仲裁、continuesResponse工具延续的触发条件、pre-ack 替换窗口的竞态处理,以及 daemon-session 范围的 PermissionBroker 如何让权限询问在 Live 调用重启后依然可重放、可幂等恢复。

背景:语音链路上的三个生命周期缺口

Qwen Live 的语音客户端通过 WebSocket 连接 DashScope 的 qwen-omni realtime 服务(对应实现见 packages/qwen-live/src/realtime/realtime-session.ts),同时通过 ACP/适配器与后端 Agent 会话(如 qwen-acp、qodercli)交互。设计文档指出该架构存在三类生命周期错配:

  1. 工具结果后对话静默:DashScope Realtime 在收到function_call_output不会自动继续生成回复。旧版 Live 客户端只提交工具输出、不请求新一轮响应,导致携带结果的工具(如权限确认、命令执行回执)完成后,对话一直静默,直到用户再次开口。
  2. 用户语音所有权冲突:如果对每个工具结果都立即创建续接响应,又会与"用户正在说话"的轮次竞争。后端结果在用户说话期间到达时,必须被保留并折叠进对该话语的回答,而不是与 provider 的直接响应竞速、或打断用户语音。
  3. response.done之后的播放尾部:provider 响应已经结束时,Host 端可能仍有缓冲音频在播放,这是第二个生命周期缺口。

而权限请求还存在一个更深层的第二生命周期不匹配:ACP 会话的存活时间长于一次 Live 调用(Live call 可能被重启),且一段注入的语音询问也可能在用户侧被打断或忽略,但后端请求仍挂起——因此"语音已投递"并不能证明"权限已被裁决"。

设计总览:两条主线的统一方案

整个设计围绕两条主线展开:

  • 响应仲裁线:让服务端 VAD 负责检测与提交话语,但禁用其自动响应创建;由客户端在输入提交回调同步排空后端上下文后,创建恰好一个直接响应,并将工具结果与后端语音请求合并进该响应。
  • 权限生命周期线:将权限 Broker 的所有权上提到daemon-session 作用域,让它在 Live 调用重启后重建事件泵、幂等重放权限事件,并持续报告waiting_for_permission状态。

两条主线在"语音注入窗口"和"可重放的权限询问"处交汇,最终由一组 realtime 单元测试与 orchestrator 集成测试兜底验证。

服务端 VAD 提交与"恰好一个直接响应"仲裁

关闭 provider 的自动响应创建

会话建立时,客户端发送session.update,其中turn_detection被配置为semantic_vad,且create_response: falseinterrupt_response: true(见 realtime-session.ts):

{ "type": "session.update", "session": { "modalities": ["text", "audio"], "input_audio_transcription": { "model": "qwen3-asr-flash-realtime" }, "turn_detection": { "type": "semantic_vad", "create_response": false, // 关键:VAD 只提交话语,不自动创建响应 "interrupt_response": true }, "tool_choice": "auto" } }

这意味着 VAD 仍然负责检测话语结束并提交输入,但"提交后自动生成回复"这一步改由客户端显式控制,为后面的合并式仲裁腾出空间。

两种提交事件被当作同一事件的幂等形式

DashScope 对一次输入提交可能以两种事件之一确认:conversation.item.created(携带 VAD 输入 item)或input_audio_buffer.committed。在 realtime-session.ts 中,两者都会进入同一个commitInputItem()处理函数:

  • conversation.item.created:仅当 item 为message类型、包含input_audio内容,且该 itemId 在待提交集合(pendingSpeechItemIds)中时,才视为输入提交;
  • input_audio_buffer.committed:直接携带item_id提交。

commitInputItem()内部(realtime-session.ts)先做幂等去重:若 itemId 已提交或已消费,则标记为duplicate_event并忽略。随后推进speechGeneration(语音世代计数器,用于判定后续事件是否过期),并通过onInputCommitted回调通知上层。

直接响应只创建一个

提交回调的关键行为是:同步排空排队的后端上下文后,只创建一个直接响应

if (activeDirectResponse) { // 用户话语已绑定到进行中的直接响应:不创建新响应 directResponsePending = false; responseToolCapabilities.set(activeResponseId, 'direct'); } else if (directResponsePending) { // 没有活跃直接响应:创建恰好一个 requestResponseCreate('direct', undefined, itemId); }

在 requestResponseCreate 中可以看到整套仲裁规则:

  • authority !== 'direct'directResponsePending为真(用户话语的响应尚未被 provider 接受)时,新的后端语音请求(如[SPEAK_TO_USER])不会另起响应,而是以[MERGE_WITH_USER]前缀作为对话 item 注入,等待合并进那一个直接响应;
  • 若已存在pendingResponseCreateactiveResponseId,新请求进入responseCreateQueue队列排队;
  • 每个非直接响应还受两档超时保护:NON_DIRECT_RESPONSE_CREATED_TIMEOUT_MS = 15_000(等待response.created)与NON_DIRECT_RESPONSE_DONE_TIMEOUT_MS = 120_000(等待response.done),超时即上报response.created/response.done超时错误(见 realtime-session.ts 与 armResponseCreatedTimer / armResponseDoneTimer)。

pre-ack 替换窗口:合并必须发生在响应创建之前

竞态的关键窗口是:response.create已离开 socket,但 provider 的response.created确认尚未返回。此时若合并内容(后端结果)到达,直接取消未确认请求并排队替换,才能保证合并 item 落在响应上下文中,而不是变成第二段播报。源码注释明确写道(realtime-session.ts):

// The direct request has left the socket but has not been accepted by // the provider yet. Cancel and replace it so the merged item, which is // ordered after that first request on the wire, is guaranteed to be in // the response context instead of becoming a second spoken turn. if ( pendingResponseCreate?.authority === 'direct' && !pendingResponseCreate.cancelled ) { pendingDirect.cancelled = true; pendingDirect.cancellationReason = 'superseded'; responseCreateQueue.unshift({ /* 原样重建 direct 请求 */ }); }

取消的响应保持活跃直到response.done

被替换的响应不能立即释放"响应槽位"。设计约定:被取消的响应保持活跃,直到 DashScope 确认response.done,才释放槽位并创建替代响应response.created分支(realtime-session.ts)会处理"provider 在同一轮次拆分多个连续响应"的情况:若新响应并非来自客户端的response.create,且没有新的未绑定输入,则把上一个响应的输入 item 与对话文本转移给新响应(splitResponseInputItemId/splitDialogueText),保留真正的麦克风能力。response.done分支(realtime-session.ts)则负责回收状态,并通过queueMicrotask(flushResponseCreate)触发队列中下一个响应的发送。

新语音使旧输入作废

如果另一个话语先开始,则退役旧的排队输入,并忽略其迟到的 ASR 完成事件input_audio_buffer.speech_started分支(realtime-session.ts)会:

  1. 推进speechGeneration,并置speechCommitPending = truedirectResponsePending = true
  2. 清空responseCreateQueue,对队列中带speechMessage的请求以[MERGE_WITH_USER]前缀回注为对话 item;
  3. pendingResponseCreate(若为 direct)标记为user_interrupted取消;
  4. 对因打断而过期的输入 item 调用consumeInputItem()消费;
  5. 若存在活跃响应,触发onBargeIn回调并标记取消(user_interrupted)。

随后在conversation.item.input_audio_transcription.completed分支中,已消费(consumedInputItemIds)的 itemId 的迟到最终转录会被当作stale_input忽略(realtime-session.ts),保证健康的调用不会被误判为协议违例。

工具延续:continuesResponse与响应权威类型

工具声明的两个语义标记

实时会话的工具声明由 RealtimeToolDefinition 描述,除了 OpenAI 风格的 function schema 外,还有两个本地语义标记:

  • capturesTranscript:标记 handoff 型工具。模型调用它时,会话捕获实时转录尾部(供 orchestrator 打包进后端提示词),并把该响应标记为"委托",使其不进入直接回答的转录收集。
  • continuesResponse:标记收据型工具,其结果需要再生成一轮模型响应(工具延续)。异步 handoff 收据则不设置此标记,因为其后的后端事件本身就是用户可见的结果,不需要冗余确认。

工具延续的触发链路

工具调用的生命周期是:response.function_call_arguments.done/response.output_item.donedispatchFunctionCall()(realtime-session.ts)→ 调度器经onFunctionCall回调交给 orchestrator → 后端执行后经submitFunctionOutput()以收据形式回传 →sendFunctionCallOutput()发送conversation.item.create(类型function_call_output)。

发送完工具输出后,maybeRequestToolContinuation 检查该响应是否声明了continuesResponse(记录在toolContinuationStates中),若声明且语音世代未过期,则发起authority: 'tool_continuation'response.create

const maybeRequestToolContinuation = (responseId: string): void => { if ([...pendingCalls.values()].some((call) => call.responseId === responseId)) { return; // 仍有未完成的调用,不续接 } const continuation = toolContinuationStates.get(responseId); if (!continuation) return; toolContinuationStates.delete(responseId); if (continuation.speechGeneration !== speechGeneration) return; // 语音世代过期 requestResponseCreate('tool_continuation', undefined, continuation.inputItemId, undefined, continuation.toolCapability); };

注意两个细节:

  • 权限投票也走工具延续:权限确认工具声明continuesResponse后,Live 只有在"收据已投递"之后才通过延续响应确认成功,从而把"语音已播放"与"权限已裁决"解耦。
  • 不冗余确认异步收据handoff型工具的收据不触发延续,后续后端事件本身就是用户可见的结果,避免产生多余的播报。

特殊工具:remain_silent与未知工具

  • REMAIN_SILENT_TOOL_NAME = 'remain_silent'(realtime-session.ts):模型请求静默时,客户端直接以空字符串作为输出提交,不产生任何播报。
  • 未知工具:配置中从未声明的工具被调用时,客户端以错误收据回复(Unknown tool: ...),让模型能以语音恢复,而不是永远等待一个不会有 handler 完成的调用(realtime-session.ts)。
  • 非直接响应禁止调工具:若响应的工具能力(toolCapability)不是direct,工具调用会被以RESPONSE_TOOL_REJECTION_OUTPUT拒绝("This response is not authorized to call tools."),防止后台注入的轮次越权执行工具。

响应权威类型

会话内部用 RealtimeResponseAuthority 标注每个响应的来源,它是整个仲裁机制的可观测锚点:

type RealtimeResponseAuthority = | 'direct' // 用户话语的直接回答 | 'tool_continuation' // 工具结果后的续接 | 'backend_speech' // 后端请求的语音 | 'proactive' // 主动推送 | 'proactive_repair';// 主动修复

注入窗口与播放尾部管理

后端事件回灌由 packages/qwen-live/src/orchestrator/injector.ts 负责。其头部注释明确了注入窗口(injection window)关闭的三个条件(injector.ts):

  1. 用户正在说话(VAD 进行中);
  2. realtime 响应在途(response in flight);
  3. Host 播放已开始但尚未完成(playback started but not completed)。

设计文档对注入窗口做了两处收紧:

  • 窗口从speech_started一直关到输入提交(input commit),而不只是到speech_stoppedspeech_stopped只表示用户停顿,提交前仍有 ASR 尾巴,此时注入仍可能与用户话语竞争。
  • 开始新语音会结束旧的播放静默间隔估计:即使音频本身已经播完(playbackCompleted),一旦新语音开始,旧的 quiet-gap 估计即作废。相关实现见 injector.ts(playbackInProgress = false; playbackCompletedAt = 0)与QUIET_GAP_MS = 800的静默间隔常量(injector.ts)。

若语音在 Host 估计的播放尾部仍挂起时开始,即使 provider 已发出response.done,也必须清除该尾部估计。orchestrator 侧的playbackStarted/playbackCompleted收据(packages/qwen-live/src/orchestrator/live-session.ts)配合 Host 播放协议提供这一判定依据:playbackSuppressed标记用于忽略被显式静音清除的输出收据。

注入内容本身遵循 spoken/detail 分离:每个 item 都以静默上下文注入(模型可据此回答追问),语音价值的 item 额外触发一段简短逐字播报(MAX_SPOKEN_CHARS = 280MAX_CONTEXT_CHARS = 6_000)。权限类 item(kind: 'permission')通过requestId让远端裁决可以撤回已排队的语音询问(injector.ts)。

权限恢复:daemon-session 作用域的 PermissionBroker

生命周期不匹配的根源

权限请求的生命周期横跨两层:ACP/后端会话(长生命周期)与Live 调用(短生命周期,可被重启)。旧的实现把权限状态绑定在单次 Live call 内,于是出现:语音询问被注入并播报,但用户没回应、或 Live 调用被重启,后端请求仍挂着——投递语音 ≠ 解决权限。

Broker 的设计要点

packages/qwen-live/src/permissions/permission-broker.ts 在文件头注释中明确了两个设计点(permission-broker.ts):

  • "始终允许(allow always)"绝不作为持久授权到达后端。协议投票永远是一次性 allow;常驻规则(standing rule)保存在 Broker 本地,带 TTL,通过静默自动回答相似请求来生效。
  • 在其他地方(如 WebShell)解决的请求,会撤回已排队的语音询问。

具体机制包括:

  • 作用域化 requestIdscopedRequestId(backend, requestId)将适配器局部的 requestId 与后端句柄绑定,避免两个 Live 后端撞 key 导致误撤回(permission-broker.ts)。
  • 常驻规则参数DEFAULT_RULE_TTL_MS = 30 * 60_000(30 分钟 TTL)、MAX_RULES = 64(permission-broker.ts)。规则键 = 工具名 + 完整规范化 detail(trim 后折叠空白但保留大小写,只按第一个冒号分割),保证"始终允许"只覆盖用户听到并批准过的精确命令。
  • 挂起权限模型:PendingPermission 携带requestHandle(供状态工具引用)、requestIdbackendsessionHandlejobRef(后端作业引用)、title(人类可读标题)、optionscreatedAt

重连、重放与幂等

Broker 的所有权在 daemon-session 作用域(orchestrator 在 live-session.ts 构造PermissionBroker)。当新的 Live call 启动时:

  • 重建事件泵:为之前观察过的每个后端会话重新连接事件泵,排空缓冲的 ACP 事件;
  • 只问仍需用户裁决的决策:进行中的常驻规则投票不重复询问;
  • 幂等重放:重放的权限事件通过 PermissionAskEvent 的alreadyPending标记识别——若该 ask 已在等待投票,则不重复提问。这样重订阅永远不会把同一个未解决问题问两遍。

可重放的询问、提醒去重与撤回

未解决的权限询问保持可重放:

  • 用户语音打断输出时,未决询问被重新入队
  • 直接响应未能成功投出投票时,同样重新入队;
  • 排队的提醒(reminder)去重,避免同一问题重复播报;
  • 本地或外部裁决(如 WebShell 侧的request.action === 'permission')都会撤回已排队的语音询问(orchestrator 通过broker.resolveHandle(request.requestHandle)解析并撤回,见 live-session.ts)。

作业引用与状态报告

权限事件保留后端作业引用(jobRef):会话级状态可以报告任何挂起的投票,但作业专属监控只有在投票属于该确切作业时才报告。状态工具从waiting_for_permission状态(live-session.ts、live-session.ts)中携带请求句柄与人类可读标题,供上层回答该问题。orchestrator 在 Live 调用重启后通过重放待决 ask 恢复权限中继(源码注释 "Suppress asks until buffered backend events have drained on resume" 表明恢复时先抑制询问、待缓冲事件排空后再重放)。

语音契约与内部句柄脱敏

内部句柄(requestHandle、jobRef 等)保留在模型上下文与工具收据中(它们是状态工具回答权限问题所必需的),但设计同时要求强化语音契约与主动摘要,使这些句柄不被朗读出来。即:句柄可以进入模型上下文供其引用,但注入语音时必须过滤或改写,避免把perm-1、UUID 之类内部标识念给用户听。

直接转录去重与中断保留

直接回答(direct response)的完整转录按responseId/inputItemId去重(collectedDirectResponseIds/collectedDirectInputItemIds集合),避免同一轮对话被重复收集;与此同时,响应被中断前已经听到的部分输出必须保留——因为被中断的响应可能永远收不到规范的response.done回调,其部分输出(partial output)仍应进入对话记录(见collectDialogueResponse(responseId, true)在打断路径中的调用,realtime-session.ts)。

验证矩阵:测试覆盖与回归清单

设计文档列出了明确的验证要求,源码仓库中均有对应落点:

  • Realtime 单元测试(packages/qwen-live/src/realtime/realtime-session.test.ts)覆盖:手动直接响应仲裁、单工具与多工具调用、provider 的两种输入提交形式(conversation.item.createdinput_audio_buffer.committed)、选择性延续与合并式延续、pre-ack 替换窗口、过期延续、重复语音取代排队输入、未知工具、remain_silent
  • Orchestrator 集成测试(packages/qwen-live/src/orchestrator/live-session.test.ts)覆盖:waiting_for_permission挂起状态(如第 6326 行附近的状态断言)、调用重启、重启后的权限中继、输入提交注入门控、播放尾部打断、日志不重复、无句柄的口头完成。工具延续的集成场景同样有覆盖(如第 2046 行附近tool_continuation权威类型断言,以及延迟修复通过排队工具延续并在新语音到达时丢弃的用例)。
  • 发布前流程:先运行 qwen-live 包测试、构建与类型检查,再使用qwen-acpqodercli两个适配器做人工 Host 重测。

小结

Qwen Live 的工具延续与权限恢复机制,本质上是一套"以用户语音为最高优先级"的实时对话仲裁协议:

  1. 关闭 provider 自动响应,客户端在输入提交后只创建一个直接响应,后端结果以合并 item 形式折叠其中;
  2. 通过 pre-ack 替换窗口、response.done后才释放槽位、语音世代(speechGeneration)过期判定,消除创建续接与用户说话之间的竞态;
  3. continuesResponse让收据型工具(含权限投票)在输出投递后获得确认性延续,而异步 handoff 收据不做冗余播报;
  4. 注入窗口从speech_started持续关闭到输入提交,并清除过期的播放尾部估计;
  5. 权限 Broker 提升到 daemon-session 作用域,通过重连事件泵、幂等重放、可重放询问与去重撤回,让权限裁决的可靠性不再依赖单次 Live 调用的存活时间。

这套设计对应的全部实现细节,均可回溯到 packages/qwen-live/src/realtime/realtime-session.ts、packages/qwen-live/src/orchestrator/injector.ts、packages/qwen-live/src/orchestrator/live-session.ts 与 packages/qwen-live/src/permissions/permission-broker.ts,是理解 Qwen Live 语音链路并发模型的重要入口。

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 14:49:28

命令执行漏洞原理、攻击与防御实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 14:46:16

Unity光照模型解析:从Lambert到PBR实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 14:43:35

LangGraph生产级错误处理:RateLimitError与AuthError的四层防御体系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华