5 个上行、15 个下行:Realtime 事件表怎么读

2026-08-09

写语音对话客户端的人,第一次拿到协议文档时通常会做两件事:把事件名抄进一个枚举,然后照着名字猜语义。response.created 听起来像”响应创建了”,那就在这里点亮”正在思考”;response.output_audio_transcript.done 听起来像”这一段转写完了”,那就拿它往聊天气泡里追加文字。

这两件事在 speech-to-speech 的 Realtime 协议里都会错。

这套协议的事件总数不多——客户端发给服务端的 5 个,服务端发给客户端的 15 个,加起来 20 个,一屏能放下。真正需要读的不是名字,是每个事件在什么时机发出、和其它事件的先后顺序是什么。下面把这张表拆成几组,每组给出”你的客户端应该拿它绑什么状态”的判断依据。

先看上行这 5 个:唯一要分清的是”触发不触发生成”

事件说明
input_audio_buffer.append流式发送 base64 PCM 音频。服务端解码、重采样到 16 kHz、切块给 VAD
session.update深合并会话配置(instructions、tools、voice、turn detection、音频格式)
conversation.item.createinput_textfunction_call_output 注入 LLM 上下文,不触发生成
response.create触发 LLM 生成。支持按响应覆盖 instructionstool_choice
response.cancel取消进行中的响应并重新开启监听

五个里面,conversation.item.createresponse.create 的分工是最容易写错的一处。前者只往上下文里塞东西——不管你塞的是一段文字(input_text)还是工具执行结果(function_call_output),服务端都只回一个 conversation.item.created 确认,不会开始生成。想让模型开口,必须再发一个 response.create

这不是冗余设计,官方文档里给的场景说明了它的用途:如果工具结果是需要念给用户听的(搜索结果、传感器读数),客户端就在 conversation.item.created 之后补一个 response.create;如果是”发射后不管”的动作类工具(README 举的是 Reachy Mini 那类设备上的转头、表情、待机),客户端收到 conversation.item.created 就可以收工了——按这套设计的假设,助手在调工具之前就已经说过引导语了。先说话再动作,别让用户干等,这条设计范式比事件表本身更值得抄走。

session.update 的语义也要注意两点。一是深合并,不是整体替换,你只发想改的那几个字段即可。二是改动怎么生效:合并进的是一个共享的 RuntimeConfig(Pydantic 模型),VAD 读它拿回合检测阈值、LLM 读它拿 instructions 和 tools、TTS 读它拿 voice,而且是在处理时现读——不是重启会话生效。这意味着运行中途改 instructions 是被支持的路径,而不是要断线重连的将就做法。

下行 15 个,按”谁的状态”分四组读

事件说明
session.created连接时发出,携带当前会话配置
session.updated确认一次成功的 session.update,返回生效的会话配置
error协议错误
input_audio_buffer.speech_startedVAD 检测到用户说话
input_audio_buffer.speech_stopped用户语音段结束
conversation.item.created确认 conversation.item.create 注入的 input_text
conversation.item.input_audio_transcription.delta流式局部转写(启用实时转写时)
conversation.item.input_audio_transcription.completed用户这一轮的最终转写(带 duration usage)
response.created在第一个出站音频块时发出(响应进入 in_progress
response.output_audio.deltaTTS 的 base64 PCM 音频块
response.output_audio.done当前输出项的音频流结束
response.output_audio_transcript.delta当前音频输出项的助手转写增量后缀
response.output_audio_transcript.done完整助手转写,输出项关闭时发一次
response.function_call_arguments.done工具调用,带 call_idname 和 JSON arguments
response.done响应结束

四组分别是:会话级session.createdsession.updatederror)、用户侧(两个 input_audio_buffer.*、两个 input_audio_transcription.*conversation.item.created)、助手侧(六个 response.*)、收尾response.done)。

分组不是为了好看。做客户端状态机的时候,用户侧那一组和助手侧那一组的生命周期是独立的:即使助手正在说话、你把打断关掉了,用户这段语音照样会被转写并推给你(turn_detection.interrupt_response 关闭时,响应期间的用户语音仍会被转写,只是响应继续播放)。把两组塞进同一个”当前状态”变量,是这类客户端最常见的结构性错误。

★ 陷阱一:response.created 不是”请求受理了”

表里那一行写得很克制:在第一个出站音频块时发出。翻译成客户端的语言就是——从你发出 response.create(或 VAD 判定用户说完),到 response.created 到达,中间隔着 STT 转写、LLM 生成、TTS 首包的全部时间。

所以拿 response.created 去点亮”正在思考”的转圈动画,逻辑上是反的:它到达的时候,思考已经结束,音频马上就要响了。

判断依据很直接:“正在思考”这个状态应该由你自己的动作或用户侧事件来点亮——你主动发 response.create 的那一刻,或者收到 input_audio_buffer.speech_stopped 的时候;而 response.created 应该用来切换到”助手开始说话”这个状态。想更精细的话,response.output_audio.delta 的第一块到达才是真正的出声时刻,但那和 response.created 是同一时机,二选一即可。

顺带说一句为什么会有这个设计。服务端内部这条链是队列驱动的:VAD 把 16 kHz、512 采样点的块喂进去,STT 出转写,LLM 出文本,LMOutputProcessor 把输出劈成两路——干净文本给 TTS,assistant_text 连同工具调用字典进 text_output_queue;路由器的异步 _send_loop 同时抽干音频和文本两个队列,再把内部消息翻译成协议事件。响应”存在”与否,是以第一个音频块进入发送环节为准的。理解了这个”劈两路”,也就顺带理解了为什么助手的文字和语音是两条独立的事件流,而不是一条流里带两种字段。

★ 陷阱二:助手转写改成了 delta 拼接,老客户端会掉字

这条是文档里单开一节讲的破坏性变更,也是最容易在升级后咬人的地方。

现在的语义是:助手转写块以 response.output_audio_transcript.delta 发出,把这些 delta 的值按顺序拼接起来,能复现最终 response.output_audio_transcript.done.transcript 的内容。而一个产生了转写文本的响应,恰好发一次转写 done,位置在 response.output_audio.done 之后、response.done 之前——包括取消关闭了未完成助手项的那种情况,此时它带的是已经累积的部分转写。

给老客户端的迁移指引原文很明确:以前消费每个块级 done 事件来实时渲染的客户端,必须把实时渲染改到 delta 上,把 done 只当作定稿。仓库自带的音频客户端仍然接受遗留的 done-only 流,但它不会在显示完 delta 之后再重新渲染一遍完整终态转写。

落到实现上有两个判断点。第一,done 到达时你是”用它整体替换”还是”忽略”,取决于你有没有在 delta 阶段做过任何截断或格式化——如果做过,用 done 覆盖一次更安全;如果 delta 是原样追加的,覆盖不覆盖结果一样。第二,取消场景下 done 里是部分转写,所以别把”收到 done”当成”这句话说完了”的语义信号,那是 response.done 的活。

★ 陷阱三:被打断时,三个终态事件排在 speech_started 前面

这是整张表里最反直觉的一段时序,也是自己写状态机时最容易死在上面的地方。

直觉上,用户抢话,应该先收到 input_audio_buffer.speech_started(用户开始说了),然后才是助手响应被取消的一串事件。实际顺序是反的。

按文档描述的流程:VAD 检测到语音后,把事件放进 text_output_queue_send_loop 优先处理文本事件(文本事件优先于音频)。如果当时有活跃响应,服务端response.output_audio.done在产生过转写文本时发 response.output_audio_transcript.done最后response.donestatus="cancelled"reason="turn_detected";而 input_audio_buffer.speech_started 跟在这些终态事件之后

判断依据:你的客户端不能把”收到 speech_started”当作”该停止播放助手音频了”的触发点——等它到达时,服务端早就把助手这一轮收尾了。停播的触发点应该是 response.done,并且要看 statusreason 来区分是正常说完(completed)还是被打断(cancelled)。reason 有两个值需要分开处理:turn_detected 是 VAD 判定用户抢话,client_cancelled 是你自己发了 response.cancel

另外,打断是有门控的:只有在 SpeechStartedEvent.interrupt_response 被置位、且会话配置允许(turn_detection.interrupt_response,经 RuntimeConfig.interrupt_response_enabled 读取,默认为 true)时,取消才真的发生。抢话本身还要满足 VAD 的语音时长门槛——新轮次和抢话始终要求满 min_speech_ms(默认 384 ms)的活跃语音,只有接续一个可重开轮次才放宽到 min_speech_continuation_ms(默认 192 ms)。这两个数字是参数默认值,是流水线自己引入的可配置等待,不代表任何测量出来的延迟。

服务端内部这套取消是靠 CancelScope 做的:cancel_scope.generation 是单调递增的世代计数器,流水线线程在每个响应开始时捕获当前世代,在每个流式 token 上检查 cancel_scope.is_stale(gen)cancel_scope.discarding 标志负责丢掉”在 cancel()response_done() 之间抵达的、来自被取代世代”的输出。这些细节客户端看不见,但有一点会影响你:来自当前世代的输出永远放行,这条兜底是为了防”一个被取代的推测轮次,它的 TTS 从来没发出过 __RESPONSE_DONE__ 哨兵”导致新响应被残留的丢弃窗口吞掉。换句话说,协议这一侧不会因为上一轮没收尾干净就沉默——这是它给客户端的隐含保证。

两种传输:有三条事件是不通用的

同一个 FastAPI / uvicorn 服务同时暴露 WebSocket 与 WebRTC 两种传输,协议事件基本相同,但有三处差异必须写进代码:

差异说明
input_audio_buffer.append在 WebRTC 下被拒绝,返回 invalid_event_for_transport——音频走媒体轨
output_audio_buffer.clear仅 WebRTC 支持:未播放音频在服务端缓冲,抢话与取消要在服务端冲掉它;服务端在 response.cancel 与 VAD 打断时也会自动冲
session.createdWebRTC 下在数据通道打开时发送,而不是连接时

WebRTC 侧需要装 webrtc extra,握手是客户端 POST 一个 SDP offer 到 POST /v1/realtime/callsContent-Type: application/sdp),收到 SDP answer(201,带 Location: /v1/realtime/calls/{call_id} 头);之后音频走 RTP 媒体轨(Opus,48 kHz,用有状态重采样器与流水线的 16 kHz 互转),所有 JSON 事件走 oai-events 数据通道。ICE 服务器用环境变量 SPEECH_TO_SPEECH_ICE_SERVERS 配(JSON 列表),不设则用 aiortc 默认。文档明确指出:客户端无法直连服务端的部署(对称 NAT、没暴露 UDP 的容器)需要 TURN 服务器

选哪种传输的判断依据也就清楚了:如果你的客户端和服务端在同一台机器或同一内网,WebSocket 更省事,音频直接用 input_audio_buffer.append 推,不用管 ICE;如果要跨公网,WebRTC 那套 20 ms RTP 帧节奏与服务端侧缓冲更合适,但你得连带准备 TURN,并且要额外实现 output_audio_buffer.clear——因为未播放音频压在服务端,不清就会在抢话之后继续冒出来。

error 事件与一条不能省的安全声明

error 事件带的已知错误码有:session_limit_reachedunknown_or_invalid_eventinvalid_session_typeconversation_already_has_active_response。第一个和 --num_pipelines(模块级参数,实时流水线池大小,默认 1)直接相关——每个会话从池里认领一个队列驱动的 PipelineUnit,池子只有一个单位,第二个会话自然会撞上限。最后一个则提醒你:同一会话不能并发开两个响应,客户端要自己维护”是否已有 in-flight 响应”。

最后必须带一句边界。文档给的 WebSocket 示例里,客户端连的是 http://localhost:8765/v1ws://localhost:8765/v1api_key 写的是 "not-needed"——因为服务端自己不做任何认证。如果你还开了 --enable_llm_proxy(默认关闭,且要求远端后端,否则返回 501 并附原因;开启后把配置好的远端 LLM 也暴露成普通 OpenAI 兼容端点——跑 --llm_backend chat-completions 时是 POST /v1/chat/completions,跑 responses-api 时是 POST /v1/responses),官方声明必须原样记住:服务端自己不做任何认证,也不做任何限流,只在受信网络上启用该代理,或者把服务部署在一个自己掌管访问控制的网关后面。README 举的例子是 s2s-endpoint 的计算副本充当这样的网关,只对”用 HF token 创建了会话的客户端”开放这些路径。同理,--host 0.0.0.0 也不是一个可以随手加上的参数。这里不存在”这样配就安全了”的说法,访问控制得由你自己那一层负责。

延伸阅读


本文依据 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?报名体系课或加入会员,照着学、照着用。