用户一开口,服务端这八步依次发生

2026-08-09

语音代理里最难写对的一段,不是把语音转成文字,也不是把文字合成语音,而是用户在助手正在说话的时候突然开口。这个场景在 speech-to-speech 的文档里叫 barge-in(抢话)。

难在哪?看一眼它的架构就知道了。speech-to-speech 的 README 把自己描述成一条 VAD → STT → LLM → TTS 的级联流水线,关键在于后半句:每个组件跑在自己的线程里,用队列相连。这意味着用户开口的那一刻,可能有三四件事正在并行推进——LLM 还在吐 token,TTS 还在合成后面的句子,send loop 还在把已经合成好的 PCM 往外发。你想「停下来」,得让这几条线程全都认账,而且要保证已经在队列里排着的旧数据不会在下一轮里冒出来。

它给出的答案是一个叫 CancelScope 的对象(cancel_scope.py),以及围绕它的一套八步流程。这八步在 src/speech_to_speech/api/openai_realtime/README.md 的 Interruption Handling 一节里逐条写着。下面把它拆开讲,重点不是复述,而是每一步在防什么。

先看 CancelScope 替换掉了什么

原文说得很直白:CancelScope 替换了旧的双信号模式——过去是一个 cancel_response 的 Event,加上一个 discard_stale_output 的布尔值。现在改成一个对象管两样东西。

第一样是世代计数器 cancel_scope.generation 流水线线程(LLM、TTS)在每个响应开始时捕获当前世代,并在每个流式 token 上检查 cancel_scope.is_stale(gen)。调用 cancel() 时世代递增,所有更早的世代立刻变成过期。README 在这里专门加了一句评语:no timing games required——不需要玩时序把戏。

这句评语值得琢磨。用「取消标志 + 时间窗」做取消,你永远要回答一个没有正确答案的问题:等多久算晚?而单调递增的世代号把它变成了一次整数比较,不需要猜。

第二样是丢弃标志 cancel_scope.discardingcancel() 置位,被异步的 _send_loop 检查,用来丢掉那些「在 cancel()response_done() 之间抵达的、来自被取代世代」的输出。它在三种情况下被清除:世代匹配的 response_done(generation)(来自无关旧世代的哨兵会被忽略)、显式 response.create 触发的 new_response()、会话认领或释放时的 reset()

配套的是输出带世代标签AudioOutput 块与 AssistantTextEvent 都带一个 cancel_generation 字段,由产出它的 handler 盖章。send loop 的 _generation_is_discardable 在两种情况下丢弃一项:它的世代已过期,或者 discarding 置位该项不属于当前世代。

八步逐条

第一步,VAD 检测到语音。SpeechStartedEvent 放进 text_output_queue。注意它进的是文本队列而不是音频队列,这个安排在下一步才见效。

第二步,_send_loop 优先处理文本事件。 文本事件优先于音频——这是把打断信号插到还没发完的音频块前面的办法。翻译成协议事件时,如果当时有活跃响应,RealtimeService.dispatch_pipeline_event 的顺序是固定的:response.output_audio.done在产生过转写文本时发 response.output_audio_transcript.done最后response.done,带 status="cancelled"reason="turn_detected";而 input_audio_buffer.speech_started 跟在这些终态事件之后

这个顺序对客户端很重要:被打断的那一轮不是「没有结尾」,而是照样把三个终态事件补齐了,只是 response.done 的状态是 cancelled。写 UI 的时候,你可以放心地把气泡的关闭逻辑挂在 response.done 上,不用为打断单开一条分支。

第三步,取消 + 冲队列。 触发条件是响应处于活跃(in_response待处理(response_pending,即模型请求已排队但还没有任何输出)状态,并且打断被允许。满足时 send loop 调 cancel_scope.cancel()(世代递增、开启丢弃),清 response_pending,然后抽干两个队列——但都带保留清单

  • output_queue保留 __RESPONSE_DONE__ 哨兵
  • text_output_queue保留用户侧事件,包括 speech_stopped、直接音频完成、部分/完整转写、token 用量

最后清 response_playing

保留清单这件事,是这套设计里最容易在自己项目中抄漏的一条。冲队列的动机是清掉助手的旧输出,但队列里混着的用户侧数据(尤其是转写和用量统计)跟这次取消没关系,冲掉就是真丢了。哨兵要留,则是因为它是后面清除丢弃守卫的钥匙。

第四步,打断门控。 只有当 SpeechStartedEvent.interrupt_response 标志被置位会话配置允许时,才真的取消。会话侧的开关是 turn_detection.interrupt_response,经 RuntimeConfig.interrupt_response_enabled 读取,默认是 true

关掉它会怎样?README 写得很具体:响应期间的用户语音仍会被转写,但响应继续播放。也就是说这不是「关掉 VAD」,用户说的话照样进转写流,只是不再打断助手。什么时候用得上:需要把一段话完整播完的场景(提示音、免责声明、机器人执行动作前的引导语),或者采音环境里助手自己的声音会被麦克风拾到的时候。要提醒的是,这个开关不解决「误打断」的成因,它只是把打断能力整体关掉。

第五步,LLM / TTS 取消。 handler 在每个响应开始时捕获 gen = cancel_scope.generation,在每个流式 token 上检查 cancel_scope.is_stale(gen),过期就提前中止。这一步和第三步是分工关系:第三步管队列里已有的东西,第五步管还在源头产出的东西。

第六步,丢弃守卫。 cancel_scope.discarding 为真期间,send loop 丢掉 cancel_generation 非当前的音频块与助手文本。守卫在世代匹配的 __RESPONSE_DONE__ 到达时清除(经 cancel_scope.response_done(gen)),或在显式 response.create 开启新响应时清除(cancel_scope.new_response())。

第七步,客户端主动取消。 response.cancelcancel_scope.cancel(),但仅当有活跃响应时;按同样的保留规则冲两个队列,触发 finish_response(status="cancelled", reason="client_cancelled"),重新开启 should_listen,清 response_playing。所以 reason 字段是你在客户端区分「被用户说话打断」和「我自己按了停止」的唯一依据:turn_detectedclient_cancelled

第八步,伪取消保护。 如果没有活跃响应,不会调用 cancel_scope.cancel()——避免在没有 __RESPONSE_DONE__ 来清除它的情况下,把丢弃守卫置位。

那条最值得抄走的兜底

README 专门解释了一个边界条件:来自当前世代的输出永远放行,这样新响应就不会被残留的丢弃窗口吞掉。它举的例子是「一个被取代的推测轮次,它的 TTS 从来没发出过 __RESPONSE_DONE__ 哨兵」。

把第六步和第八步连起来看,就明白这条兜底防的是什么:丢弃守卫的清除依赖哨兵,而哨兵在某些路径上可能根本不会出现。一旦守卫留在置位状态没人清,后面所有输出都会被静默丢掉——按这套机制推演,用户侧的现象就是助手忽然不出声了。这类「清除信号丢失导致的静默吞没」是并发系统里最难复现的一类故障,而「当前世代永远放行」用一条不依赖哨兵的规则把它兜住了。

如果你正在自己写打断逻辑,这三条是可以直接迁移的:用单调递增的世代号做取消,比「取消标志 + 时间窗」健壮;冲队列要有保留清单;给守卫加一条不依赖清除信号的放行规则。

判断依据:几个容易搞错的点

VAD 侧的门槛不是同一个。 抢话和开新轮次都要求满 --min_speech_ms(默认 384 ms)的活跃语音,而接续一个可重开轮次只要求 --min_speech_continuation_ms(默认 192 ms)。理由是接续风险小——只是把同一轮延长;打断助手说话代价大,所以门槛保持高。这个参数被硬钳制在 [100, min_speech_ms] 区间,设成大于 min_speech_ms 的值不会生效。

WebRTC 传输多一件事要做。 未播放的音频在服务端缓冲,所以抢话与取消要在服务端冲掉它。对应的是仅 WebRTC 支持的 output_audio_buffer.clear;文档也说明,服务端在 response.cancel 与 VAD 打断时会自动冲。WebSocket 侧没有这个环节。用 WebRTC 需要装 speech-to-speech[webrtc] extra。

别把毫秒值加起来。 VAD 那一组参数里的毫秒数(min_silence_ms 默认 64、min_speech_ms 默认 384、speech_pad_ms 默认 500、speculative_reopen_ms 默认 800、smart_turn_max_wait_ms 默认 2000 等)都是流水线自己引入的、可配置的等待,不包含 STT 推理、LLM 首 token、TTS 首包这三段真正吃算力的时间。把它们相加当成「端到端首响应要花多少毫秒」是错的,那三段取决于你的模型和硬件,我们没有任何测量数据。

先说话再动作。 文档在工具结果回流那一节提到一个设计范式:对于「发射后不管」的动作类工具,助手在工具调用之前应该已经说过自然的引导语了。放在打断的语境下,这句话的分量更重——引导语说出去了,用户才有理由不在这时候开口。这是 README 结合 Reachy Mini 一类设备场景给出的做法,不是我们验证过的产品行为。

什么情况说明问题不在这条链路上

  • 客户端收到了 response.donereason="turn_detected",说明第二、三步走通了。此时若界面上文字还在往下滚,问题在渲染层,不在服务端取消逻辑。
  • 助手转写显示不全:注意 response.output_audio_transcript.done 在取消时包含的是已累积的部分转写,这是设计如此。另外文档明说要把实时渲染改到 delta 上、把 done 只当定稿,拼接全部 delta 能复现终态的 transcript
  • 完全没有触发取消:先回第四步查 turn_detection.interrupt_response,再回 VAD 侧看语音时长有没有到 min_speech_ms。这两处任一不满足,第三步压根不会执行。

事件顺序速查

顺序事件备注
1response.output_audio.done有活跃响应时先发
2response.output_audio_transcript.done仅在产生过转写文本时发,取消时含部分转写
3response.donestatus="cancelled"reason="turn_detected"
4input_audio_buffer.speech_started跟在上述终态事件之后

客户端主动取消走的是同一套终态事件,区别只在 reason="client_cancelled"

speech-to-speech 仓库在 2026-08-09 的快照里是 11893 star,仓库标注为 Apache-2.0(各模型权重另有各自许可,一律以官方 LICENSE 原文为准)。star 数说明不了稳定性,但这套八步流程本身值得一读——它把「取消」这件在并发系统里出了名难写对的事,压缩成了一个整数比较加一份保留清单。

延伸阅读


本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、 src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。 本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。 参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。