OpenAI Agents SDK #19:拆开 RealtimeSession,那 20 种事件你只处理了 3 种
深度拆解 Realtime Agents 内部实现:RealtimeAgent 与普通 Agent 的 4 个关键差异、Runner 与 Session 的工厂/连接分工、Session 7 个核心 API、近 20 种 event.type 按音频/历史/生命周期/工具/底层分组解读、RealtimeRunConfig 6 字段与 VAD 两种模式、Transport 层两条路径,以及 voice 锁定、exception 重抛等 6 个生产坑。
研究速览
我见过不少人第一次接触 Realtime Agents 就卡住了。
卡在哪里?不是鉴权,不是连接——而是搞不清楚谁该干什么。
RealtimeAgent、RealtimeRunner、RealtimeSession,三个类摆在那里,quickstart 一跑通就算会了。但一旦要接音频流、做打断、上 HITL 审批,就发现自己根本不知道事件从哪里来,更不知道该怎么响应。上一篇(#18)我们把 Voice Pipeline 拆光了。这篇把 Realtime Agents 的内部实现也拆一遍。
一、RealtimeAgent 和普通 Agent 的 4 个关键区别
| 不支持的字段 | 原因 |
|---|---|
model | 整个 Session 共享同一个模型,无法单独指定 |
modelSettings | 同上,不可单独配置 |
outputType | Realtime 天然是流式语音,不支持结构化输出 |
toolUseBehavior | 不可配置 |
这 4 个字段在普通
Agent 里用得很习惯——所以切换到 Realtime 模式时别想当然地带过去。支持的字段里有几个值得单独提一下1:
instructions:支持函数形式(动态指令),签名是Callable[[RunContextWrapper, RealtimeAgent], MaybeAwaitable[str]],在 Session 运行时被调用并返回字符串handoffs:只能转接给其他RealtimeAgent,不能转接给普通Agentvoice:同一 Session 里第一个 Agent 发言后就锁定了,之后再改无效(这是坑,后面详说)
二、Runner 是工厂,Session 是连接
很多人把这两个角色搞反。
RealtimeSession 是实际的 WebSocket 连接对象,也是事件流的入口。连接建立发生在你进入 async with session: 上下文的那一刻。runner = RealtimeRunner(
starting_agent=agent,
config={...} # 模型配置在这里
)
# runner.run() 返回 Session,但此时还没连
session = await runner.run()
# 进入上下文时才建立连接
async with session:
await session.send_message("Say hello.")
async for event in session:
...RealtimeSession 也可以不用 async with,但你得手动调用 session.close(),否则 WebSocket 连接不会释放。三、Session 的 7 个核心 API
RealtimeSession 暴露的方法不多,但每个都有明确职责3:| 方法 | 说明 |
|---|---|
send_message(message) | 发文本消息(str 或结构化 Realtime message) |
send_audio(audio, *, commit=False) | 发原始音频分片(PCM16 等格式字节流),commit=True 时立即提交 |
interrupt() | 程序化打断模型当前生成 |
update_agent(agent) | 切换当前激活 Agent,并同步配置给模型 |
approve_tool_call(call_id, *, always=False) | 批准 HITL 工具调用,恢复执行 |
reject_tool_call(call_id, *, always=False, rejection_message=None) | 拒绝 HITL 工具调用 |
close() | 关闭 Session,释放所有资源 |
approve_tool_call 和 reject_tool_call 里的 always=True 参数是「永久策略」:设了之后,后续遇到同一工具的调用请求,Session 会自动按你的策略处理,不再触发审批事件。四、事件系统全景:近 20 种 event.type 分类解读
音频类(5 种)
| event.type | 说明 |
|---|---|
audio | 模型输出音频分片,event.audio.data 是字节流 |
audio_interrupted | 模型输出被打断(VAD 检测到用户说话,或 interrupt() 被调用) |
audio_done | 本轮音频输出完成 |
input_audio_transcription_completed | 用户输入音频转写完成 |
input_audio_timeout_triggered | 输入音频超时 |
这是日常使用最高频的一组。典型消费模式:
async for event in session:
if event.type == "audio":
audio_player.write(event.audio.data) # 直接转发给播放器
elif event.type == "audio_interrupted":
audio_player.flush() # 清空播放缓冲区
elif event.type == "audio_done":
pass # 本轮完成,可以更新 UI 状态对话历史类(5 种)
| event.type | 说明 |
|---|---|
history_updated | 对话历史全量更新(RealtimeHistoryUpdated) |
history_added | 新增对话历史条目(增量,比 history_updated 更高频) |
transcript_delta | 转写文本增量更新(流式文字) |
item_updated | 对话项更新 |
item_deleted | 对话项删除 |
建议优先消费
history_added 而非 history_updated——前者是增量,后者是全量,高频场景下全量重刷性能开销大。Agent 生命周期类(2 种)
| event.type | 说明 |
|---|---|
turn_started | Agent 开始一轮响应 |
turn_ended / agent_end | Agent 完成一轮响应 |
turn_ended 在代码里也写作 agent_end,两个名字指向同一事件——SDK 源码里两种写法都有,消费时以自己读到的类型字符串为准。工具类(3 种)
| event.type | 说明 |
|---|---|
tool_start | 工具开始执行 |
tool_end | 工具执行完成 |
tool_approval_required | 工具调用等待人工审批(HITL 场景) |
收到
tool_approval_required 后,你用 session.approve_tool_call(event.call_id) 或 session.reject_tool_call(event.call_id) 响应,Session 才会继续。elif event.type == "tool_approval_required":
# 在这里把 call_id 送去你的审批 UI / 队列
pending_approvals[event.call_id] = event
# 用户点「批准」后再调用:
# await session.approve_tool_call(event.call_id)Handoff / Guardrail / 底层类(5 种)
| event.type | 说明 |
|---|---|
handoff | Agent 切换(RealtimeHandoffEvent) |
guardrail_tripped | 守护栏触发 |
raw_model_event | 原始模型事件透传,不经 SDK 封装 |
error | 错误事件,event.error 含详情 |
exception | Session 内部异常——会在迭代时重新抛出(生产坑,下面详说) |
五、RealtimeRunConfig 的 6 个字段
配置整个 Realtime 运行时的行为,全部字段4:
class RealtimeRunConfig(TypedDict):
model_settings: RealtimeSessionModelSettings # 模型配置(VAD、音频格式等)
output_guardrails: list[OutputGuardrail[Any]] # 输出守护栏列表
guardrails_settings: RealtimeGuardrailsSettings # 守护栏运行配置
tracing_disabled: bool # 禁用追踪,默认 False
async_tool_calls: bool # 工具调用是否异步,默认 True
tool_error_formatter: ToolErrorFormatter # 工具错误消息格式化回调async_tool_calls 默认是 True——工具函数异步并发执行。如果你的工具内部有非线程安全的状态操作,把它改成 False,工具调用会串行执行。RealtimeSessionModelSettings 的重点字段4:| 字段 | 类型 | 说明 |
|---|---|---|
model_name | RealtimeModelName | 推荐 gpt-realtime-2 |
instructions | str | 全局系统提示(会覆盖 Agent.instructions) |
audio | RealtimeAudioConfig | 嵌套音频配置,新代码推荐用这个 |
voice | str | 输出语音(ash / alloy / nova 等) |
speed | float | 模型响应速度 |
max_output_tokens | int | "inf" | 单轮最大 token,默认 "inf" |
modalities | list["text" | "audio"] | 支持的输入模态 |
turn_detection | RealtimeTurnDetectionConfig | VAD 配置(旧版平铺写法,仍有效) |
reasoning | RealtimeReasoningConfig | 推理配置 |
有一个设计细节:
audio 嵌套字段是新写法,input_audio_format / output_audio_format / turn_detection 等是旧版平铺写法。两种方式在 SDK 里都有效,但别混用——要么全走嵌套,要么全走平铺。六、VAD 两种模式:语义 vs 能量
语音活动检测(VAD)决定模型什么时候认为你「说完了」4:
turn_detection: {
"type": "semantic_vad", # 或 "server_vad"
"create_response": True,
"eagerness": "auto", # "low" / "medium" / "high" / "auto"
"interrupt_response": True,
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"threshold": 0.5,
}semantic_vad:语义感知型。能理解「嗯……那个……我想说的是」这类自然停顿,不会把思考中的停顿误判为发言结束。gpt-realtime-2 推荐用这个。server_vad:基于声音能量的传统 VAD。检测的是音量而不是语义,延迟更低,但碰到口头禅多的用户容易误触发。eagerness 是 semantic_vad 的专属参数,控制边界检测灵敏度:high:发言一停就快速响应,适合短问答场景low:允许更长的停顿,适合思考型对话auto:SDK 自动调整
七、Transport 层:SDK 只有两条路
有人以为 SDK 会提供浏览器端的 WebRTC 抽象——没有5。
SDK 覆盖的只有两条路径:
1. Server-side WebSocket(默认)
RealtimeRunner 默认使用 OpenAIRealtimeWebSocketModel。服务端持有音频管道、工具执行、审批流、历史管理。适合服务器管理的语音应用、CLI 工具,也能对接 Twilio Media Streams 等电话场景5。# 默认就是 WebSocket,无需额外配置
runner = RealtimeRunner(starting_agent=agent)2. SIP Attach(电话场景)
通过
call_id 附加到已有的 Realtime Call,使用 OpenAIRealtimeSIPModel。触发条件是 OpenAI 向你的服务发送 realtime.call.incoming webhook5:session = await runner.run(
model_config={"call_id": incoming_call_id}
)浏览器端 WebRTC 完全在 SDK 范围之外。 如果你需要浏览器直接连接 Realtime API,参考官方 Realtime API 文档实现
RTCPeerConnection,那套流程和 Agents SDK 没有关系。八、6 个生产坑
①
voice 锁定后不可更改同一 Session 里,第一个 Agent 发言后,
voice 配置就锁定了。通过 Handoff 切到其他 RealtimeAgent 后,那个新 Agent 的 voice 字段也不会生效。想切换声音只有新建 Session1。②
exception 事件会重新抛出异常exception 不是普通事件,它是 Session 内部异常的包装。当你的 async for event in session 遍历到这个 item 时,异常会被重新抛出。事件循环必须用 try/except 包裹3:try:
async for event in session:
handle_event(event)
except Exception as e:
# Session 内部异常在这里捕获
logger.error(f"Realtime session error: {e}")③ 自定义
headers 后鉴权不再自动注入runner.run(model_config={"headers": {...}}) 设置了自定义请求头之后,SDK 就不会再自动注入 Authorization 头。适合 Azure OpenAI 场景,但如果你是想加额外 header 而不是替换鉴权,必须自己把 Authorization: Bearer <key> 也带进去2。④ 忘了
close() 导致连接泄露不用
async with 而是手动 await session.enter() 时,如果因异常或 break 退出循环,close() 必须在 finally 块里调用。不关 WebSocket 连接不会自动释放3:session = await runner.run()
try:
await session.enter()
async for event in session:
...
finally:
await session.close() # 无论是否异常都要关⑤
interrupt() 和 audio_interrupted 是两件事session.interrupt() 是主动打断——你的代码主动叫停模型输出。audio_interrupted 事件是被动通知——VAD 检测到用户说话触发了打断。两者都可能导致播放缓冲区需要清空,但触发路径不同,要分别处理3。⑥
async_tool_calls=True 与线程安全九、完整最小可用示例
把上面所有概念串起来的一份生产级骨架:
import asyncio
from agents.realtime import RealtimeAgent, RealtimeRunner
agent = RealtimeAgent(
name="VoiceBot",
instructions="Keep responses concise and conversational.",
)
runner = RealtimeRunner(
starting_agent=agent,
config={
"model_settings": {
"model_name": "gpt-realtime-2",
"audio": {
"input": {
"format": "pcm16",
"transcription": {"model": "gpt-4o-mini-transcribe"},
"turn_detection": {
"type": "semantic_vad",
"interrupt_response": True,
"eagerness": "auto",
},
},
"output": {
"format": "pcm16",
"voice": "ash", # 发言后锁定,无法更改
},
},
},
"async_tool_calls": False, # 工具有共享状态时用串行
},
)
async def main() -> None:
session = await runner.run()
async with session:
await session.send_message("Say hello.")
try:
async for event in session:
if event.type == "audio":
write_to_speaker(event.audio.data)
elif event.type == "audio_interrupted":
flush_speaker_buffer()
elif event.type == "transcript_delta":
print(event.delta, end="", flush=True)
elif event.type == "tool_approval_required":
# HITL 审批逻辑
await session.approve_tool_call(event.call_id)
elif event.type == "error":
print(f"错误: {event.error}")
elif event.type == "agent_end":
break
except Exception as e:
print(f"Session 异常: {e}")
asyncio.run(main())三条实践建议
① 先用事件类型白名单,再慢慢展开
近 20 种事件里,80% 的场景只需要关心
audio、audio_interrupted、transcript_delta、agent_end、error 这 5 种。先把这 5 种处理好,再按需加入工具审批、历史更新等逻辑,不要一开始就写 20 个 elif。②
voice 和 model_name 在 Session 级统一配置不要把
voice 分散到各个 RealtimeAgent 里——因为第一次发言后就锁定,后续设置失效。统一在 RealtimeRunner 的 model_settings.audio.output.voice 里配置,所有 Agent 共享同一个声音。③ 事件循环必须有
try/exceptexception 事件在迭代时会重新抛出异常,没有 try/except 的裸 async for 在遇到 Session 内部错误时会直接崩掉整个协程。加一个 except 兜底,至少打个日志,让你知道出了什么问题。下期预告 #20:进入Tracing模块——Span、Trace、TraceProvider 完整结构,以及如何把 Realtime 和 Voice Pipeline 的追踪数据打通送进自定义监控系统。
封面图由 AI 生成
围绕这条内容继续补充观点或上下文。