session.update 深合并进 RuntimeConfig:配置是处理时现读的

2026-08-09

做语音代理的人迟早会遇到同一个问题:会话已经连上了,用户说到一半,我想换个系统提示词、换个音色、把打断关掉——是要断开重连,还是能热改?

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_scopereset() 同样发生在会话认领/释放时)。

所以别指望用一次 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_reachedunknown_or_invalid_eventinvalid_session_typeconversation_already_has_active_response 等几种。对配置这条链路来说,unknown_or_invalid_eventinvalid_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.Signatureto_code_prompt() 渲染出人类可读的函数签名块,再通过 Jinja2 模板注入系统提示词。

判断依据:如果你的产品形态是”运行中频繁增删工具”,那么在本地后端上,每次改 tools 都意味着系统提示词那一段要重新渲染并注入;在 API 后端上则只是换个请求参数。这不是”哪个更好”的问题——官方没给任何成功率或耗时数据,我们也不做这种比较——而是你在设计”工具热插拔”这个功能时,得知道两条路的形状不一样。

TTS 侧读的是 voice。 这一条官方文档里只有一句话的依据——session.update 深合并的字段清单里有 voice,TTS 在处理时读它。再往下的东西(换音色是下一句话就生效还是下一个响应才生效、有哪些合法取值)文档没写,我们也没连过这个服务,所以到此为止,不替它补细节。

五、会话级改动 vs 按响应覆盖:分界在哪

很多人一上来就把所有可变的东西都塞进 session.update,其实协议里另有一个更轻的口子。

五个客户端事件里,response.create 的说明是:触发 LLM 生成,支持按响应覆盖 instructionstool_choice

所以决策路径很清楚:

  • 这次调用专属的临时指令(比如”这一轮请用一句话回答”)→ 放 response.create 的按响应覆盖,用完即弃,不污染会话状态。
  • 整场对话都要遵守的设定(人设、语言、可用工具集)→ 写进 session.update,让它进 RuntimeConfig

另外还有一个常被误用的事件:conversation.item.create 是把 input_textfunction_call_output 注入 LLM 上下文,它不触发生成。想让注入的内容被说出来,得再发一个 response.create。这正是工具结果回流那套流程里紧挨着的两步——客户端发 conversation.item.createfunction_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/callsContent-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/completionsresponses-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.updateinstructionssession.updated 回读
这一轮的临时指令response.create 的按响应覆盖看本轮输出
可用工具集session.updatetoolssession.updated;注意本地后端要重渲提示词
音色session.update 的 voicesession.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 的实际输出为准。

安全相关做法请结合自身环境评估,本文不构成安全方案建议。

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