OpenAI「Agents SDK」⑨リアルタイムエージェント
以下の続き
今回は「リアルタイムエージェント」
クイックスタート
注意書きにもあるけど
ということで。
ローカルのMacでやる。uvでプロジェクト作成
uv init -p 3.12 openai-realtime-agents-work && cd $_
Agents SDKをインストール。extrasはいらないのかな?
uv add openai-agents
(snip)
+ openai-agents==0.3.0
(snip)
サンプルコード。前回やったVoicePipeline
の場合はエージェントをワークフローでラップして、さらにそれをパイプラインでラップする感じだったけど、リアルタイムの場合はより直接的にRealtimeAgent
で定義してRealtimeRunner
でラップするという感じ。
import asyncio
from agents.realtime import RealtimeAgent, RealtimeRunner
async def main():
# エージェントを作成
agent = RealtimeAgent(
name="Assistant",
instructions="You are a helpful voice assistant. Keep responses brief and conversational.",
)
# ランナーを設定
runner = RealtimeRunner(
starting_agent=agent,
config={
"model_settings": {
"model_name": "gpt-realtime",
"voice": "ash",
"modalities": ["audio"],
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"input_audio_transcription": {"model": "gpt-4o-mini-transcribe"},
"turn_detection": {"type": "semantic_vad", "interrupt_response": True},
}
},
)
# セッションを開始
session = await runner.run()
async with session:
print("セッションが開始されました!エージェントはリアルタイムで音声応答をストリーミングします。")
# イベントを処理
async for event in session:
try:
if event.type == "agent_start":
print(f"エージェントが開始されました: {event.agent.name}")
elif event.type == "agent_end":
print(f"エージェントが終了されました: {event.agent.name}")
elif event.type == "handoff":
print(f"{event.from_agent.name} から {event.to_agent.name} へハンドオフされました")
elif event.type == "tool_start":
print(f"ツールが開始されました: {event.tool.name}")
elif event.type == "tool_end":
print(f"ツールが終了されました: {event.tool.name}; output: {event.output}")
elif event.type == "audio_end":
print("オーディオが終了されました")
elif event.type == "audio":
# メタデータ付きでコールバック再生用の音声をキューに入れる
# 非ブロッキングで投入。キューは無制限のためドロップは発生しない。
pass
elif event.type == "audio_interrupted":
print("オーディオが中断されました")
# オーディオコールバックで穏やかなフェード+フラッシュを開始し、ジッタバッファを再構築する。
elif event.type == "error":
print(f"エラーが発生しました: {event.error}")
elif event.type == "history_updated":
pass # 頻出イベントのためスキップ
elif event.type == "history_added":
pass # 頻出イベントのためスキップ
elif event.type == "raw_model_event":
print(f"生のモデルイベント: {_truncate_str(str(event.data), 200)}")
else:
print(f"不明なイベントタイプ: {event.type}")
except Exception as e:
print(f"イベント処理中にエラーが発生しました: {_truncate_str(str(e), 200)}")
def _truncate_str(s: str, max_length: int) -> str:
if len(s) > max_length:
return s[:max_length] + "..."
return s
if __name__ == "__main__":
# セッションを実行
asyncio.run(main())
実行してみるとエラー。ってかやっぱりextrasが足りないんじゃないかな?
uv run sample.py
ModuleNotFoundError: No module named 'websockets'
パッケージ追加
uv add websockets
再度実行、エラーは出ないけど、以下からうんともすんとも動かない。
セッションが開始されました!エージェントはリアルタイムで音声応答をストリーミングします。
生のモデルイベント: RealtimeModelRawServerEvent(data={'type': 'session.created', 'event_id': 'event_CGMCJoHPcyOp6wsH558sy', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMCJK7mQ4LAHj8PSXeYj'...
生のモデルイベント: RealtimeModelRawServerEvent(data={'type': 'session.updated', 'event_id': 'event_CGMCKG6ZvHEOxwBhw8tuQ', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMCJK7mQ4LAHj8PSXeYj'...
生のモデルイベント: RealtimeModelRawServerEvent(data={'type': 'session.updated', 'event_id': 'event_CGMCKE3zmaBoGP1S0RvKn', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMCJK7mQ4LAHj8PSXeYj'...
マイクから入力音声を取得するとかは自分で実装しないといけないんじゃないかなぁ。RealtimeAgent
や RealtimeRunner
がどこまでやってくれるのかこの時点でわからないところに、これをもって「動作する完全な例」と書くのはどうしたもんかな、という気がする。
一応パラメータは以下。
カテゴリ | オプション名 | 説明・選択肢例 |
---|---|---|
モデル設定 | model_name |
利用可能なリアルタイムモデル(例: gpt-realtime ) |
voice |
音声の種類(alloy 、echo 、fable 、onyx 、nova 、shimmer ) |
|
modalities |
有効化するモード(["text"] または ["audio"] ) |
|
音声設定 | input_audio_format |
入力音声フォーマット(pcm16 、g711_ulaw 、g711_alaw ) |
output_audio_format |
出力音声フォーマット | |
input_audio_transcription |
文字起こしの設定 | |
ターン検出 | type |
検出方法(server_vad 、semantic_vad ) |
threshold |
音声活動のしきい値(0.0–1.0) | |
silence_duration_ms |
ターン終了を検出する無音時間(ミリ秒) | |
prefix_padding_ms |
発話前の音声パディング(ミリ秒) |
とりあえずサンプルは以下にもある。
レポジトリをまるっとクローンしてサンプルのディレクトリに。
git clone https://github.com/openai/openai-agents-python && cd openai-agents-python
cd examples/realtime/
デモは3つあるけど、cliのサンプルを。
cd cli
uv run demo.py
ModuleNotFoundError: No module named 'numpy'
パッケージ追加
uv add python
再実行
uv run demo.py
起動したみたい。
Connecting, may take a few seconds...
Connected. Starting audio recording...
Audio recording started. You can start speaking - expect lots of logs!
Raw model event: RealtimeModelRawServerEvent(data={'type': 'session.created', 'event_id': 'event_CGMlYuMf7QLlUmL8QIdz4', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMlYD4ofpvLEIaxBwyzT'...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'session.updated', 'event_id': 'event_CGMlYzsubGVIxMEyobReT', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMlYD4ofpvLEIaxBwyzT'...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'session.updated', 'event_id': 'event_CGMlY7bzQawJ12CklChtA', 'session': {'type': 'realtime', 'object': 'realtime.session', 'id': 'sess_CGMlYD4ofpvLEIaxBwyzT'...
音声で発話するとこんな感じで認識されているのがわかる。
Raw model event: RealtimeModelRawServerEvent(data={'type': 'input_audio_buffer.speech_started', 'event_id': 'event_CGMldigVaG6ki7t69NENf', 'audio_start_ms': 4780, 'item_id': 'item_CGMldPq1qS5Kp23GHErT3'}, type='raw_se...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'input_audio_buffer.speech_stopped', 'event_id': 'event_CGMlfLHj7cZLiL9rNGdae', 'audio_end_ms': 7320, 'item_id': 'item_CGMldPq1qS5Kp23GHErT3'}, type='raw_serv...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'input_audio_buffer.committed', 'event_id': 'event_CGMlfjousSV09J6L8o2LJ', 'previous_item_id': '', 'item_id': 'item_CGMldPq1qS5Kp23GHErT3'}, type='raw_server_...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'conversation.item.added', 'event_id': 'event_CGMlfGZL0SZGPreSzRVp8', 'previous_item_id': '', 'item': {'id': 'item_CGMldPq1qS5Kp23GHErT3', 'type': 'message', ...
(snip)
Raw model event: RealtimeModelInputAudioTranscriptionCompletedEvent(item_id='item_CGMldPq1qS5Kp23GHErT3', transcript='おはようございます。', type='input_audio_transcription_completed')
そしてレスポンスが音声で返ってくる。
Raw model event: RealtimeModelAudioEvent(data=b'\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x04\x00\x02\x00\xff\xff\x07\x00\xff\xff\x00\x00\xfe\xff\x00\x00\xfa\xff\x00\x00\x01\x00\xff\xff\xfe\xff\...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlg1dQIbXm9fTOfCcCA', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\xde\x03\xec\x05\xbb\x04\xa3\x02\xd7\x029\x04#\x01m\xff}\xfe;\xfcy\xf9\x80\xf9\xa7\xf9\x0f\xf8\x8e\xf8R\xf99\xfbj\xfa*\xfbt\xfbA\xfbA\xfc\xf9\xfca\xfd\xe5\xfbg\xfb\x7f\x...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlguz2WaS9VNTwFOAqS', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta=' to', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgrRtKI0tcf6CtrrwO', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\xfa\xff\x02\x00\x0b\x00\x11\x00\x15\x00\x18\x00\x19\x00\x1b\x00\x1d\x00 \x00"\x00$\x00&\x00\'\x00$\x00!\x00\x1d\x00\x1a\x00\x18\x00\x16\x00\x15\x00\x12\x00\x10\x00\x0f\...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgqixqF2Hs0c2jhGwv', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta=' you', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlg1FdBCVW0z0XGMHsn', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='。', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgIynzqj2jR5U0Rytt', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'!\nZ\x05\xf7\x02H\x05:\x04,\xfe\xb7\xf7\xdd\xf4\x82\xee\xbd\xe7|\xe2\xc9\xdc\x8e\xd6K\xce\xf1\xc8\x8a\xc3\x9f\xc2\r\xc4j\xc4^\xc0!\xbd\xb4\xbdB\xc02\xc27\xc4B\xc9l\xcc\x...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgOGYOuqn0P83QLRR5', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\x97\x03\x08\xff\xaf\xf8]\xf4\x89\xf0d\xee\xc0\xec|\xeb\xb5\xed\x96\xf1I\xf4=\xf9\xd4\xfdv\x01\xe2\x05M\x08a\x08S\t\x00\t\x02\x06\x03\x02P\xfe(\xfa\xc4\xf6:\xf4w\xf1[\xf...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgiJFBPjUf1ydtQxHX', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='お', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgP6HTEqyrYQIR8RWj', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='は', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgPZMIzLjRvnwaziAI', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\xb2\xfa\n\x07\xae\x08\xb4\x06\xe5\xde"\x07c\x17\xf2\xebr\x07\\\xef\xde\xffa\x15\x19\xf3\x1f\xf7\xf6\xfcx\xf93\x17\xb8\x04\xc5\xe8f\xfb\x81\xf6\xa8\x11\xf4\x06\x8c\xf6\x...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgRSyDjs66ch3oF1hr', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\xda\xf9G\xf9\x92\xf9\xc7\xf9\x07\xfa\x8d\xfa\xab\xfa\xc8\xfa=\xfa!\xfbD\xfd\x8e\xfd\xcb\xfe\xd1\xfe\x9c\xfex\xff\x06\xff\xca\xff\xab\x00u\x01:\x03\x81\x04\t\x05\x17\x06...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio.delta', 'event_id': 'event_CGMlgfI1o3R86Qhe2wThk', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE4X9OQtW3gm'...
Raw model event: RealtimeModelAudioEvent(data=b'\x04\x00\x04\x00\x03\x00\x03\x00\x01\x00\x02\x00\x02\x00\x03\x00\x03\x00\x02\x00\x02\x00\x04\x00\x02\x00\x03\x00\x03\x00\x02\x00\x03\x00\x02\x00\x03\x00\x02\x00\x03\x00\...
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgioRqyca2jedDl8mo', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='よう', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgsUrcBWL5DB3mlhB4', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='ございます', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgZJ1bWwQy1SooLh8o', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='!', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
Raw model event: RealtimeModelRawServerEvent(data={'type': 'response.output_audio_transcript.delta', 'event_id': 'event_CGMlgu8nlMOJQ5kp8yqzq', 'response_id': 'resp_CGMlfVv0iVPwMOCbGGrvf', 'item_id': 'item_CGMlfSCxphE...
Raw model event: RealtimeModelTranscriptDeltaEvent(item_id='item_CGMlfSCxphE4X9OQtW3gm', delta='今日は', response_id='resp_CGMlfVv0iVPwMOCbGGrvf', type='transcript_delta')
トレースはこんな感じ。
ざっとコードを読んでみたけど、エージェント部分はシンプルに1エージェントが定義されているだけで、オーディオ周りの処理がほとんどを占めている感じ。
もう一つのサンプルも。
cd ../app/
uv run server.py
INFO: Started server process [98695]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
ブラウザでアクセスするとこんな画面。
コードを見ると、受付エージェントがメインで、そこからFAQに答えるエージェント、座席の予約を行うエージェントにハンドオフされるようになっている。ハンドオフされるエージェントはツールが定義されている。
こんな感じで使える。
ざっとやってみた限り、リアルタイムモデルだけあってレスポンスはとても良好な感じ。
ガイド
概要にある通り、Realtime APIを使うのが「リアルタイムエージェント」
アーキテクチャ
コンポーネント
コンポーネント名 | 説明 |
---|---|
RealtimeAgent | instructions、tools、handoffsで構成されるエージェント。会話の主役になる。 |
RealtimeRunner | 設定を管理する。runner.run() でセッションを開始する。 |
RealtimeSession | 単一の対話セッション。ユーザーが会話を開始してから終了するまで1セッション。 |
RealtimeModel | 基盤となるモデルのインターフェイス。OpenAIのWebSocket実装。 |
セッションフロー
セッションフローの流れは以下とある
- RealtimeAgent を作成 し、instructions、tools、handoffs を設定します。
- RealtimeRunner をセットアップ し、エージェントと設定オプションを指定します。
- セッションを開始 します。
await runner.run()
を使用すると RealtimeSession が返ります。- 音声またはテキストメッセージを送信 します。
send_audio()
またはsend_message()
を使用します。- イベントをリッスン します。セッションを反復処理して、音声出力、文字起こし、ツール呼び出し、ハンドオフ、エラーなどのイベントを受け取ります。
- **割り込みに対応 **します。ユーザーがエージェントの発話に重ねて話した場合、現在の音声生成は自動的に停止します。
以下のサンプルコードで見てみる。
- RealtimeAgent を作成 し、instructions、tools、handoffs を設定します。
- RealtimeRunner をセットアップ し、エージェントと設定オプションを指定します。
- セッションを開始 します。await runner.run() を使用すると RealtimeSession が返ります。
- 音声またはテキストメッセージを送信 します。send_audio() または send_message() を使用します。
- イベントをリッスン します。セッションを反復処理して、音声出力、文字起こし、ツール呼び出し、ハンドオフ、エラーなどのイベントを受け取ります。
- **割り込みに対応 **します。ユーザーがエージェントの発話に重ねて話した場合、現在の音声生成は自動的に停止します。
ここは4でも処理されている
エージェントの設定
リアルタイムの場合は専用のエージェント、RealtimeAgent
を使って、エージェントを定義する。VoicePipeline
とはここが違うところだが、通常の Agent
クラスとは以下が異なる。
- エージェント単位でモデルは選択できない。セッション単位で設定する。まあリアルタイムモデルなのでそれはそうという気がする。
- structured outputはサポートされない。音声出力なのでそれはそう。
- 音声(
alloy
とかecho
とか)はエージェント単位で設定できるが、最初のエージェントが話し始めたあとは変更できない。ここはちょっと意味がわからなかった。
セッションの設定
モデル設定
モデルの動作を設定可能
- モデル名(
gpt-realtime
など) - 音声(
alloy
、echo
、fable
、onyx
、nova
、shimmer
) - モダリティ(テキスト and/or 音声)
- 音声フォーマット(入力・出力の両方、デフォルトは PCM16)
音声設定
セッションの音声入力・出力の扱いを設定可能
- 音声入力
- Whisper などのモデルを使った入力音声の文字起こし
- 言語設定
- 文字起こしプロンプト
- ターン検出
- VADのしきい値
- 無音時間
- 検出した発話の前後のパディング
ツールと関数 / ハンドオフの作成
ここは通常のAgent
と同じように思える。
イベント処理
イベント名 | 説明 |
---|---|
audio | エージェントの応答からの生の音声データ。音声再生に利用。 |
audio_end | エージェントの発話が終了したタイミングで発生。 |
audio_interrupted | ユーザーがエージェントの発話に割り込んだときに発生。音声再生を即座に停止する。 |
tool_start | ツール(関数)が実行開始したときに発生。 |
tool_end | ツール(関数)の実行が終了したときに発生。 |
handoff | エージェントのハンドオフ(会話の引き継ぎ)が発生。 |
error | 何かエラーが起きたときに発生。 |
詳細は以下。公式のサンプルコードも参考になりそう。
ガードレール
通常のAgentの場合との違い
- RealtimeAgentがサポートするのは出力ガードレールのみ
- まとまった出力単位で定期的に実行。
- 「デバウンス」というらしい。
- 逐次実行するとパフォーマンスに影響するため。
- 出力単位の長さ=デバウンス長は設定可能でデフォルト100文字」
- ガードレールが作動しても例外は発生しない。
音声処理
- 音声の入力
-
session.send_audio(audio_bytes)
: 音声を送信 -
session.send_message()
: テキストを送信
-
- 音声の出力
-
audio
イベントを監視して、任意の音声ライブラリで再生 -
audio_interrupted
イベントを監視して、エージェント発話中のユーザの割り込みを検知- 再生中の再生を停止
- キューにある音声をクリア
-
モデルへの直接アクセス
モデルに直接アクセスして、カスタムリスナーの設定などができるらしい。
session.model.add_listener(my_custom_listener)
RealtimeModel
をより低レベルで制御したい場合に使うみたい。
コード例
見た感じ、必要なものが一通り実装されているようなので、これをベースにすれば良さそう。
まとめ
自分はAgents SDKをある程度触って大体の雰囲気は理解したつもりだけども、Realtime APIはほとんど触ってないので、とりあえず Realtime API の基本を理解した上で使うのが良さそうと感じた。
あと、VoicePipeline
と RealtimeAgent
で、同じ音声エージェントだけどもコンポーネント構成が全然違ってるのも興味深い。