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 就卡住了。
卡在哪里?不是鉴权,不是连接——而是搞不清楚谁该干什么
RealtimeAgentRealtimeRunnerRealtimeSession,三个类摆在那里,quickstart 一跑通就算会了。但一旦要接音频流、做打断、上 HITL 审批,就发现自己根本不知道事件从哪里来,更不知道该怎么响应。
上一篇(#18)我们把 Voice Pipeline 拆光了。这篇把 Realtime Agents 的内部实现也拆一遍。

一、RealtimeAgent 和普通 Agent 的 4 个关键区别

RealtimeAgentAgentBase 的子类,和普通 Agent 共享一个基类。但有 4 个字段在 RealtimeAgent 上明确不支持1
不支持的字段原因
model整个 Session 共享同一个模型,无法单独指定
modelSettings同上,不可单独配置
outputTypeRealtime 天然是流式语音,不支持结构化输出
toolUseBehavior不可配置
这 4 个字段在普通 Agent 里用得很习惯——所以切换到 Realtime 模式时别想当然地带过去。
支持的字段里有几个值得单独提一下1
  • instructions:支持函数形式(动态指令),签名是 Callable[[RunContextWrapper, RealtimeAgent], MaybeAwaitable[str]],在 Session 运行时被调用并返回字符串
  • handoffs只能转接给其他 RealtimeAgent,不能转接给普通 Agent
  • voice同一 Session 里第一个 Agent 发言后就锁定了,之后再改无效(这是坑,后面详说)

二、Runner 是工厂,Session 是连接

很多人把这两个角色搞反。
RealtimeRunner 只干一件事:工厂。拿到 starting_agent 和配置,调用 .run() 返回一个 RealtimeSession。连接不在这里建立2
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_callreject_tool_call 里的 always=True 参数是「永久策略」:设了之后,后续遇到同一工具的调用请求,Session 会自动按你的策略处理,不再触发审批事件。

四、事件系统全景:近 20 种 event.type 分类解读

async for event in session 能消费到的 event.type 有近 20 种3。按职责分成 5 组:

音频类(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_startedAgent 开始一轮响应
turn_ended / agent_endAgent 完成一轮响应
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说明
handoffAgent 切换(RealtimeHandoffEvent
guardrail_tripped守护栏触发
raw_model_event原始模型事件透传,不经 SDK 封装
error错误事件,event.error 含详情
exceptionSession 内部异常——会在迭代时重新抛出(生产坑,下面详说)

五、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_nameRealtimeModelName推荐 gpt-realtime-2
instructionsstr全局系统提示(会覆盖 Agent.instructions)
audioRealtimeAudioConfig嵌套音频配置,新代码推荐用这个
voicestr输出语音(ash / alloy / nova 等)
speedfloat模型响应速度
max_output_tokensint | "inf"单轮最大 token,默认 "inf"
modalitieslist["text" | "audio"]支持的输入模态
turn_detectionRealtimeTurnDetectionConfigVAD 配置(旧版平铺写法,仍有效)
reasoningRealtimeReasoningConfig推理配置
有一个设计细节: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。检测的是音量而不是语义,延迟更低,但碰到口头禅多的用户容易误触发。
eagernesssemantic_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 与线程安全
工具调用默认并发执行。如果你的工具访问全局状态、数据库连接池,或者有共享资源竞争,把 RealtimeRunConfig 里的 async_tool_calls 改成 False4

九、完整最小可用示例

把上面所有概念串起来的一份生产级骨架:
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% 的场景只需要关心 audioaudio_interruptedtranscript_deltaagent_enderror 这 5 种。先把这 5 种处理好,再按需加入工具审批、历史更新等逻辑,不要一开始就写 20 个 elif
voicemodel_name 在 Session 级统一配置
不要把 voice 分散到各个 RealtimeAgent 里——因为第一次发言后就锁定,后续设置失效。统一在 RealtimeRunnermodel_settings.audio.output.voice 里配置,所有 Agent 共享同一个声音。
③ 事件循环必须有 try/except
exception 事件在迭代时会重新抛出异常,没有 try/except 的裸 async for 在遇到 Session 内部错误时会直接崩掉整个协程。加一个 except 兜底,至少打个日志,让你知道出了什么问题。

下期预告 #20:进入 Tracing 模块——Span、Trace、TraceProvider 完整结构,以及如何把 Realtime 和 Voice Pipeline 的追踪数据打通送进自定义监控系统。
封面图由 AI 生成

このコンテンツについて、さらに観点や背景を補足しましょう。

  • ログインするとコメントできます。