session.update 深合并进 RuntimeConfig:配置是处理时现读的
做语音代理的人迟早会遇到同一个问题:会话已经连上了,用户说到一半,我想换个系统提示词、换个音色、把打断关掉——是要断开重连,还是能热改?
speech-to-speech 这个仓库给的答案藏在它 Realtime Engine 架构文档的最后一句机制描述里:session.update 事件深合并进 RuntimeConfig——一个共享的 Pydantic 模型,由 VAD(回合检测阈值)、LLM(instructions、tools)、TTS(voice)在处理时读取。
这句话短,但它决定了你客户端代码的整个形状。下面把它拆开讲清楚:深合并到底省了什么、“处理时现读”管到哪几个消费者、怎么确认改动真的生效、以及哪些东西压根不归它管。
一、先定位 RuntimeConfig 在流水线里的位置
这个服务是 FastAPI / uvicorn 起的一个进程,同时暴露 WebSocket 与 WebRTC 两种传输。每个会话从池里认领一个队列驱动的 PipelineUnit,里面装着这个会话自己的 RealtimeService、轮次追踪器和取消状态。池子大小由模块级参数 --num_pipelines 控制,默认值是 1——它的 help 原文说得很直白:一个 uvicorn 服务监听 --port,把每个进来的客户端路由到下一个空闲流水线,最大并发 WebSocket 会话数就等于 num_pipelines,再多的连接会被拒绝。默认 Realtime 端口是 8765。
把这两件事摆在一起看,RuntimeConfig 的作用域就清楚了:它是会话级的运行时配置,不是进程级的全局开关。同一个进程里如果开了多条流水线,各会话各改各的;会话结束、单元被释放,这份状态也就跟着走了(cancel_scope 的 reset() 同样发生在会话认领/释放时)。
所以别指望用一次 session.update 去调整”整台服务”的行为。要影响整台服务的东西,得在启动命令行上定。
二、“深合并”省掉的是什么
客户端到服务端的事件一共只有五个,session.update 是其中之一,官方对它的说明是:深合并会话配置(instructions、tools、voice、turn detection、音频格式)。
“深合并”这三个字的实际价值,看官方 README 里那段 WebSocket 客户端示例最直观:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8765/v1",
websocket_base_url="ws://localhost:8765/v1",
api_key="not-needed",
)
with client.realtime.connect(model="local") as conn:
conn.send(
{
"type": "session.update",
"session": {
"type": "realtime",
"instructions": "You are a helpful assistant.",
"audio": {
"input": {
"turn_detection": {
"type": "server_vad",
"interrupt_response": True,
}
}
},
},
}
)
for event in conn:
print(event.type)
注意 session 这个字典里并没有出现 voice、tools、音频格式这些字段。深合并的含义就是:你只发要改的那一小棵子树,没提到的部分保持原样,不需要每次把整份会话配置重新拼一遍发过去。对客户端来说,这意味着你不必在本地维护一份”当前完整配置”的影子副本再整体覆盖——那种写法最常见的事故是并发两处改配置时互相抹掉对方。
顺带说一句这段示例里的 api_key="not-needed":这个服务端自身不做认证,示例里的 key 只是 SDK 要求有个值。这一点后面还会再提。
还有一个容易忽略的细节:合并的粒度只覆盖官方明确列出的这几类字段,README 没有逐字段列出深合并的边界,我们也没有抓过一次报文,所以别去推测某个未列出的字段能不能热改——不确定的时候用下一节的办法去问服务端。
三、怎么确认改动真的生效:读 session.updated,别靠猜
服务端到客户端的 15 个事件里,有两个专门管配置:
| 事件 | 什么时候发 | 带什么 |
|---|---|---|
session.created | 连接时发出 | 当前会话配置 |
session.updated | 确认一次成功的 session.update | 生效的会话配置 |
判断依据就一条:session.updated 返回的是生效后的配置,不是你发过去的原文回显。所以客户端最稳的写法是——发完 session.update 之后,等 session.updated,拿返回的配置去更新本地 UI 状态,而不是在发送成功的那一刻就乐观地认为改好了。
如果压根没等到 session.updated,就该去看 error 事件。已知的错误码有 session_limit_reached、unknown_or_invalid_event、invalid_session_type、conversation_already_has_active_response 等几种。对配置这条链路来说,unknown_or_invalid_event 和 invalid_session_type 是最直接相关的两个信号:前者说明事件本身没被识别,后者说明 session 里那个 type 字段不对(示例里写的是 "realtime")。session_limit_reached 则跟配置无关,它对应的是前面说的 --num_pipelines 名额用完了。
四、“处理时现读”分别被谁读走
这是本篇最该展开的一段。同样一次 session.update,落到三个消费者身上代价完全不同。
VAD 侧读的是回合检测阈值。 一个具体且有依据的例子是打断门控:官方对打断流程的描述里写明,只有当 SpeechStartedEvent.interrupt_response 标志被置位、且会话配置允许时才真的取消当前响应;会话配置这一侧的读法是 turn_detection.interrupt_response,经 RuntimeConfig.interrupt_response_enabled 读取,默认为 true。关掉之后的行为也写清楚了:响应期间的用户语音仍然会被转写,但响应继续播放。
这条对产品设计极有价值——它不是”关掉打断就听不见用户说话了”,而是”照样听、照样转写,只是不停嘴”。你要做的如果是”用户可以插话补充信息、但助手把这句话说完”,这个开关就是原生支持的,不需要自己在客户端做队列。
LLM 侧读的是 instructions 和 tools。 这里有个必须点破的差异:tools 在两种 LLM 后端下走的是两条截然不同的路径。走 OpenAI API 的 ResponsesApiModelHandler 时,tools 作为 tools= 参数原生传给 client.responses.create,API 直接返回结构化的 function_call 项。而走本地 LLM 的 LanguageModelHandler(transformers / mlx-lm)时,session.update 里定义的 tools 要先被转成 FunctionTool 对象,每个工具的 JSON Schema parameters 通过 signature_from_schema 变成 Python 的 inspect.Signature,to_code_prompt() 渲染出人类可读的函数签名块,再通过 Jinja2 模板注入系统提示词。
判断依据:如果你的产品形态是”运行中频繁增删工具”,那么在本地后端上,每次改 tools 都意味着系统提示词那一段要重新渲染并注入;在 API 后端上则只是换个请求参数。这不是”哪个更好”的问题——官方没给任何成功率或耗时数据,我们也不做这种比较——而是你在设计”工具热插拔”这个功能时,得知道两条路的形状不一样。
TTS 侧读的是 voice。 这一条官方文档里只有一句话的依据——session.update 深合并的字段清单里有 voice,TTS 在处理时读它。再往下的东西(换音色是下一句话就生效还是下一个响应才生效、有哪些合法取值)文档没写,我们也没连过这个服务,所以到此为止,不替它补细节。
五、会话级改动 vs 按响应覆盖:分界在哪
很多人一上来就把所有可变的东西都塞进 session.update,其实协议里另有一个更轻的口子。
五个客户端事件里,response.create 的说明是:触发 LLM 生成,支持按响应覆盖 instructions 与 tool_choice。
所以决策路径很清楚:
- 这次调用专属的临时指令(比如”这一轮请用一句话回答”)→ 放
response.create的按响应覆盖,用完即弃,不污染会话状态。 - 整场对话都要遵守的设定(人设、语言、可用工具集)→ 写进
session.update,让它进RuntimeConfig。
另外还有一个常被误用的事件:conversation.item.create 是把 input_text 或 function_call_output 注入 LLM 上下文,它不触发生成。想让注入的内容被说出来,得再发一个 response.create。这正是工具结果回流那套流程里紧挨着的两步——客户端发 conversation.item.create 带 function_call_output,服务端追加进上下文并回 conversation.item.created,要说出来才再发 response.create。它不是配置事件,但因为它同样”改变了后续生成的输入”,实践中经常和 session.update 混着用——记住分工:改配置用 session.update,塞上下文用 conversation.item.create,触发说话用 response.create。
六、WebRTC 传输下,第一条 session.update 的时机要改
如果你走的是 WebRTC 传输(需要装 webrtc extra),协议本身与 WebSocket 模式相同,JSON 事件走 oai-events 数据通道,但有一处差异会直接咬到配置初始化的代码:
session.created 是在数据通道打开时发送的,而不是连接时。
WebSocket 模式下你可以在连上之后立刻发 session.update;WebRTC 模式下,客户端 POST 一个 SDP offer 到 /v1/realtime/calls(Content-Type: application/sdp),拿到 SDP answer(201,带 Location: /v1/realtime/calls/{call_id} 头),此后还得等数据通道真正打开。把”发初始配置”这个动作挂在数据通道的 open 回调上,而不是挂在信令完成上,是这里唯一稳妥的写法。
顺带记一条同源的差异:WebRTC 下 input_audio_buffer.append 会被拒绝,返回 invalid_event_for_transport——音频走媒体轨。跨传输复用客户端代码时,这两条是最先炸的地方。
七、哪些东西 RuntimeConfig 管不着
这是本篇的收口,也是最容易踩空的边界。
VAD 的一大堆行为参数是进程级 CLI 参数,在启动时就定死了:--thresh 默认 0.6、--min_silence_ms 默认 64、--min_speech_ms 默认 384、--min_speech_continuation_ms 默认 192、--speech_pad_ms 默认 500,几个重开窗口 --speculative_reopen_ms 默认 800、--smart_turn_max_wait_ms 默认 2000、--unanswered_reopen_ms 默认 7000,Smart Turn 侧的 --smart_turn_threshold 默认 0.5、--smart_turn_incomplete_delay_ms 默认 600。
必须说清楚的一点:上面这些毫秒值全是参数默认值,是流水线自己引入的、可配置的等待,不包含 STT、LLM、TTS 的推理耗时。它们不能相加,加出来的东西不是任何意义上的端到端延迟。
官方文档在会话配置这一侧只写了 VAD 读取”回合检测阈值”,并没有逐一列出哪些 VAD 参数可以被会话级配置覆盖。所以务实的做法是:想调这些数值,就在启动命令上调;确实需要在会话里改的,先发一次 session.update,再用 session.updated 回读的配置去确认它是否真的被接受——不要凭推测写死在客户端里。
另一个明确不归 RuntimeConfig 管的是 LLM 代理。开启 --enable_llm_proxy 后,服务会把它配置的远端 LLM 也作为普通 OpenAI 兼容端点暴露出来(--llm_backend chat-completions 时是 POST /v1/chat/completions,responses-api 时是 POST /v1/responses),供客户端跑摘要、起标题一类的侧边任务,与语音对话并发且不会被新的说话打断。但这条路是无状态的:每次发完整消息列表,model 字段总是被覆写成服务端配置的 --model_name,请求用服务端持有的 key 代理到上游、该 key 永不到达客户端,而这个服务忽略客户端传的 API key。代理默认关闭,且要求远端后端,否则返回 501 并附原因。
也就是说,你在 session.update 里设的 instructions,跟你通过这个代理发出去的 messages 是两套东西,别指望它们互相继承。
关于这个代理,官方的安全声明必须原样带出,不能弱化:服务端自己不做任何认证,也不做任何限流。只应在受信网络上启用该代理,或者把服务部署在一个自己掌管访问控制的网关后面。 同样的道理适用于 --host 0.0.0.0——serve 默认绑定 127.0.0.1,暴露到网络必须显式传这个参数,而暴露之后的访问控制,这个服务不负责。这里不存在”这样配就安全了”的结论。
八、一张收尾的决策表
| 你想改的东西 | 该动哪儿 | 怎么确认 |
|---|---|---|
| 人设、长期指令 | session.update 的 instructions | 等 session.updated 回读 |
| 这一轮的临时指令 | response.create 的按响应覆盖 | 看本轮输出 |
| 可用工具集 | session.update 的 tools | 等 session.updated;注意本地后端要重渲提示词 |
| 音色 | session.update 的 voice | 等 session.updated |
| 允不允许打断 | turn_detection.interrupt_response(默认 true) | 关闭后用户语音仍被转写、响应继续播放 |
| VAD 各毫秒阈值 | 启动时的 CLI 参数 | speech-to-speech serve -h |
| 并发会话数 | --num_pipelines(默认 1) | 超出时收到 session_limit_reached |
把”配置是处理时现读的”这句话再翻译一遍就是:这套服务允许你在对话进行中调整行为,但它只给了你一条确认通道——session.updated。 客户端代码写得稳不稳,取决于你是等这条确认,还是发完就当改好了。
延伸阅读
本文依据 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 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。