Azure OpenAI Realtime APIで既存セッションにsession.updateを送る方法|call_id/LocationとWebSocket・WebRTCの違い

Azure AI Foundry の Azure OpenAI Realtime API(Realtime Audio)で音声対話を作っていると、会話中に「もっとゆっくり話して」などの指示を後から差し替えたくなります。OpenAI 本家の Location ヘッダー/call_id を真似しても Azure では動かない…と悩む方向けに、接続方式ごとの仕様と、バックエンドから制御する現実的な設計パターンを整理します。

目次

まず押さえる:Realtime は「WebSocket セッション型」と「WebRTC/SIP コール型」で考え方が違う

Azure OpenAI の Realtime API は、WebRTC / SIP / WebSocket など複数の接続手段でリアルタイムに音声(およびテキスト)をやり取りできます。ここで重要なのは「どの接続方式を選ぶか」で、既存セッションを外部から操作できる範囲が変わる点です。

接続方式主に使う場面識別子既存セッションへ“別接続”で参加できる?session.update を外部から送りたい場合の現実解
WebSocket(GA: /openai/v1/realtime)サーバー to サーバー、低遅延必須ではない音声/イベント処理model(= デプロイ名)で開始基本的に「その WebSocket がセッション」接続を保持するプロセス(ハブ)経由で session.update
WebSocket(Preview: /openai/realtime)既存のプレビュー実装の延長deployment + api-version基本的に「その WebSocket がセッション」同上(ハブ or クライアントが送る)
WebRTC(/openai/v1/realtime/calls)ブラウザ/モバイルの低遅延音声対話Location ヘッダーから call_id条件付きで可能(observer/controller の WebSocket を別途張れる)call_id でコントローラー WebSocket を作り、そこから session.update
SIP(電話回線)コールセンター/IVR などWebHook で受け取る call_id可能(call_id で WebSocket 参加)call_id で WebSocket を張って session.update

この記事の核心は次の 1 行です。

「既存セッションにバックエンドから指示を差し込みたい」なら、まず自分が使っている Realtime の接続が “セッション型” なのか “コール型” なのかを切り分ける──ここを間違えると、永遠にハマります。

よくある悩み:OpenAI 本家の call_id / Location を Azure で再現できない

OpenAI 本家の Realtime では、WebRTC の SDP 交換をすると 201 Created と一緒に Location ヘッダーが返り、そこに含まれる call_id を使って「監視用 WebSocket」を別途張ったり、サーバー側から制御イベント(session.update など)を投げたりする設計が紹介されています。

一方で Azure 側は、プレビュー時代のドキュメントや実装に沿っていると、同じ発想で call_id を指定しても 404 や認証エラーになり、「Azure には同等の仕組みがないのでは?」という疑問にぶつかりがちです。実際、2025 年 9 月 4 日の Microsoft Q&A(プレビューの /openai/realtime?api-version=2025-04-01-preview を前提にした議論)では、Location ベースで既存セッションにイベントを注入する仕組みは提供されておらず、session.update は“同じアクティブ接続”から送る必要がある、という回答が採択されています。

ただし、ここからが重要です。Azure の Realtime は GA プロトコル(/openai/v1)が整備されるにつれ、「コール型(WebRTC/SIP)」では call_id を使った observer/controller の WebSocket を張れる形が、公式手順として明確に記載されるようになっています。つまり、“いつの・どの API で・何で接続しているか”で答えが変わります。

仕様の土台:session.update は「同じセッションに対して送るイベント」

session.update は Realtime のクライアントイベントで、セッションのデフォルト設定(指示文、温度、ツール設定、VAD など)を更新するために使います。ポイントは次のとおりです。

  • session.update はいつでも送れる
  • 更新されるのは「送ったフィールドだけ」(部分更新)
  • instructions などを消したい場合は空文字でクリアできる
  • ただし音声の voice は、いったん使われるとセッション中に変更できない
  • 成功すると session.updated が返り、有効な全設定が確認できる

この仕様だけを見ると「じゃあ、どの接続からでも同じセッション ID を指定して送れそう」と思いがちですが、セッション型(WebSocket の /realtime)はそもそも“接続そのもの”がセッションです。外部からイベントを注入したいなら、結局どこかがその接続を握っていないといけません。

ケース別の結論:あなたが今すぐ取るべき方針

ここでは、現場で一番多い 3 パターンに分けて、迷いどころを潰します。

WebSocket(/realtime)で接続している:同じ接続から session.update を送るのが基本

WebSocket 方式は、アプリが張っている WebSocket が“そのセッション”です。したがって、指示変更をしたい場合は、その WebSocket を保持している同一プロセス(またはそのプロセスが代理するコンポーネント)から session.update を送るのが基本設計になります。

GA と Preview で URI の形が違う点は要注意です。GA の例は /openai/v1/realtime?model=... で、Preview の例は /openai/realtime?api-version=...&deployment=... です。

概念としては、次のような流れになります(ライブラリやエンドポイント名はプロジェクトに合わせて置き換えてください)。

import asyncio
import json
import websockets

async def realtime_ws_send_session_update(ws_url: str, token: str):
    # ws_url 例(GA):
    #   wss://<resource>.openai.azure.com/openai/v1/realtime?model=<deployment-name>
    #
    # ws_url 例(Preview):
    #   wss://<resource>.openai.azure.com/openai/realtime?api-version=2025-04-01-preview&deployment=<deployment-name>

    headers = {
        # Entra ID を使う場合
        "Authorization": f"Bearer {token}",
        # 互換ヘッダーを要求されるケースがある場合のみ付与
        # "OpenAI-Beta": "realtime=v1",
    }

    async with websockets.connect(ws_url, extra_headers=headers) as ws:
        event = {
            "type": "session.update",
            "session": {
                "instructions": "もっとゆっくり、はっきり話してください。"
            }
        }
        await ws.send(json.dumps(event))

        # 成功すると session.updated が返る想定
        msg = await ws.recv()
        print(msg)

if __name__ == "__main__":
    asyncio.run(
        realtime_ws_send_session_update(
            ws_url="wss://<resource>.openai.azure.com/openai/v1/realtime?model=<deployment-name>",
            token="<your-token>",
        )
    )

この方法はシンプルですが、「別バックエンドから勝手に更新したい」という要望に対しては、その“別バックエンド”が直接 WebSocket に触れない限り実現できません。そこで次の設計パターンが登場します。

WebSocket セッションを別バックエンドから制御したい:接続保持プロセス(ハブ)を置く

複数のバックエンド(CRM、監視、オペレーター UI、ポリシーエンジンなど)から、会話中の指示を差し込みたいケースはよくあります。この場合、設計としては「Realtime 接続を握るコンポーネント」を 1 つに寄せるのが安全で、運用もしやすいです。

パターン概要メリット注意点向いている規模
接続ハブ(Gateway)方式WebSocket/WebRTC を保持するサーバーを用意し、他サービスは REST/gRPC/Queue で「更新指示」を送る接続管理が一箇所/権限管理しやすい/監査ログを集約できるハブが落ちると会話が切れるため冗長化が必須中〜大
クライアント主導方式バックエンドは「次はこの指示にして」と通知し、実際の session.update はクライアントが送るサーバー側の接続負荷が低いクライアントが信頼できないとプロンプトが漏れる/改ざんされる小〜中
レスポンス単位の上書きセッション更新ではなく、次の response.create にだけ指示を載せて一時的に挙動を変える影響範囲が限定される/ロールバックしやすい「以後ずっと」効かせたい場合は不向き小〜大

「OpenAI 本家みたいに、外部から 1 発 HTTP を叩いて session.update を注入したい」という発想は、セッション型の WebSocket ではあまり相性が良くありません。“外から操作したいなら、外からも参加できる構造(コール型)に寄せる”か、“接続を握る役(ハブ)を明確にする”のが現実解です。

WebRTC(/openai/v1)の場合:Location から call_id を取り、observer/controller の WebSocket を張れる

ブラウザ/モバイルで WebRTC を使う場合、Azure の公式手順では「SDP 交換をバックエンドがプロキシすると、Location ヘッダーを取得できる」「その Location から call_id を取り出して WebSocket に接続できる」と説明されています。さらに、その WebSocket は単なる監視だけでなく、session.update などのコマンドを発行して制御もできます。

流れを 5 ステップで理解する

  1. バックエンドが /openai/v1/realtime/client_secrets でエフェメラルトークン(短命トークン)を払い出す
  2. クライアント(ブラウザ)が SDP Offer を作る
  3. バックエンドが /openai/v1/realtime/calls に SDP Offer を送って SDP Answer を受け取り、同時にレスポンスヘッダー Location を捕まえる
  4. Location の末尾から call_id を抜き出し、wss://.../openai/v1/realtime?call_id=... に “別の WebSocket” として接続する(observer/controller)
  5. その controller WebSocket から session.update を送ると、会話中でも指示を差し替えられる

この方式の良いところは、音声ストリームは WebRTC のまま低遅延で維持しつつ、サーバー側が「操作用の接続」を持てる点です。たとえば、オペレーターが UI で「敬語に切り替え」「読み上げ速度を下げる」ボタンを押したら、バックエンドは controller WebSocket から session.update を送るだけで挙動を変えられます。

controller WebSocket の最小コード例(Python)

公式手順のサンプルは “監視” を主目的にしていますが、同じ接続上でイベントを送れば制御もできます。概念が伝わる最小例を載せます。

import asyncio
import json
import websockets

async def control_webrtc_call(azure_resource: str, call_id: str, bearer_token: str):
    # controller/observer 用の WebSocket
    ws_url = f"wss://{azure_resource}.openai.azure.com/openai/v1/realtime?call_id={call_id}"

    headers = {
        # controller 側は、エフェメラルトークンではなく
        # サーバー側の Bearer(Entra ID)や api-key で認証する設計が多い
        "Authorization": f"Bearer {bearer_token}"
    }

    async with websockets.connect(ws_url, extra_headers=headers) as ws:
        # 例:会話のデフォルト指示を差し替え
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {
                "instructions": "これ以降は、話す速度を少し落として、聞き取りやすく話してください。"
            }
        }))

        # 例:必要なら受信して確認(session.updated 等)
        while True:
            msg = await ws.recv()
            data = json.loads(msg)
            print("recv:", data.get("type"))
            if data.get("type") == "session.updated":
                break

if __name__ == "__main__":
    asyncio.run(control_webrtc_call(
        azure_resource="<your-resource-name>",
        call_id="<rtc_xxx>",
        bearer_token="<server-side token>",
    ))

「これで外部から session.update できるなら、最初の疑問は解決では?」と思うかもしれません。ポイントは次の違いです。

  • 外部から“HTTP 1 回で注入”できるわけではなく、結局は controller 用の WebSocket 接続 を張ってイベントを送る
  • call_id を知らないと参加できない(WebRTC なら Location を取る必要がある)
  • セッション型 WebSocket(/realtime?model=...)では、そもそも call_id という概念がない

SIP の場合:call_id を使って WebSocket から session.update を送れる

電話(SIP)連携のシナリオでは、まず Webhook で着信イベントを受け取り、call_id を取得します。その後、wss://.../openai/v1/realtime?call_id={call_id} で WebSocket を張り、通常の Realtime と同様に response.create や session.update を送って通話を制御できます。

WebRTC と同様、「音声のリアルタイム経路」と「制御(イベント)経路」を分離できるため、運用設計の自由度が上がります。

session.update で何を変えるべき?実務で効く“更新項目”の整理

session.update は何でも入れられそうに見えますが、運用では「頻繁に変える項目」と「原則固定する項目」を分けておくと事故が減ります。代表的なフィールドを整理します。

項目用途更新のしやすさ運用のコツ
instructions口調、速度、禁止事項、応答フォーマットなどの“基本方針”高(空文字でクリア可)「恒久ルール」と「一時指示」を分離。恒久ルールは短く、動的指示は追記・差し替えしやすい形にする
turn_detectionVAD(話し終わり検出)や割り込みの挙動中ノイズ環境やユーザーの話し方に合わせて調整。まずはログを取り、段階的に変える
tools / tool_choice関数呼び出しや外部システム連携の可否中セキュリティ的に強い項目。オペレーター操作で変えられる範囲を最小化する
temperature応答の揺らぎ(創造性)中緊急対応・FAQ は低め、雑談は高め、など状況で切り替えると UX が良い
max_response_output_tokens長話の抑制、コスト制御中「要約モード」「詳説モード」の切替に使える。上限を下げすぎると途中で切れる
voice読み上げ音声の種類低(使った後は変更不可)セッション開始前に確定させる。途中変更したいなら“新セッション”設計を検討

また、response.create の instructions で「次の 1 回だけ」上書きできる点も覚えておくと便利です。恒久的に変えたいなら session.update、一時的なら response.create の上書き、という使い分けができます。

よくあるエラーと切り分け(call_id が動かない/404 になる)

「call_id を付けてつないだのに 404」「認証が通らない」「つながるが制御できない」といったトラブルは、だいたい原因がパターン化します。

症状ありがちな原因チェックポイント対処
WebSocket 接続が 404エンドポイントが違う(/openai/realtime と /openai/v1/realtime の混同)自分が GA か Preview か。WebSocket URI の例と一致しているかGA なら /openai/v1/realtime?model=... または ?call_id=... を使う
call_id が取れないWebRTC の SDP 交換をクライアント直で実行しており、バックエンドが Location を見ていないSDP 交換をプロキシしているか。レスポンスヘッダーをログしているかバックエンドに SDP 交換を移し、Location を取得して call_id を抽出
認証エラーcontroller 側でエフェメラルトークンを使っている/ヘッダーが違うBearer(Entra ID)か api-key か。どちらで認証する設計かサーバー側の資格情報(Bearer/api-key)で controller WebSocket を認証
voice を変えたらエラーになるvoice はセッション中に変更できない(使った後)すでに音声応答を出していないかvoice を変える必要があるなら新セッション設計を検討
指示を変えたのに反映されないすでに応答生成が走っている/次のターンから反映session.updated を受け取れているか必要なら response.cancel → session.update → response.create の順にする

プロンプト秘匿と運用設計:ブラウザに見せない/ログを残す

「バックエンドから指示を注入したい」要望の裏には、だいたい次の 2 つがあります。

  • プロンプト(システム指示)をクライアントに渡したくない
  • オペレーターや監視が、会話をリアルタイムに観測・介入したい

WebRTC の公式手順では、セッションネゴシエーションをバックエンドでプロキシする案が示されており、さらに webrtcfilter=on のようなフィルタで、ブラウザに返すデータチャネルイベントを絞って指示を隠す例も紹介されています。設計の自由度が高い反面、どのイベントを誰に見せるか(見せないか)の線引きが重要になります。

実務でのおすすめは次のとおりです。

  • クライアントは「音声/テキスト I/O に必要な最小限のイベント」だけ受け取る
  • プロンプトやツール設定など、機密性が高いものは controller 側(サーバー)に寄せる
  • controller WebSocket で session.updated や error を必ず収集し、後から追えるログ(会話ID、call_id、操作履歴)を残す
  • “誰が・いつ・どの指示に変えたか” を監査できるよう、更新要求に署名や RBAC を掛ける

まとめ:Azure で既存セッションを更新する鍵は「同じ接続」か「call_id で別接続」か

最後に要点だけ整理します。

  • セッション型(WebSocket の /realtime)は、基本的に「その接続がセッション」なので、session.update は接続保持プロセスから送る設計が素直
  • プレビュー系では Location/call_id の外部注入が前提になっておらず、過去には「不可」と明言された例もある
  • 一方、コール型(WebRTC/SIP の /openai/v1)では、Location から得た call_id で controller WebSocket を張り、session.update で介入できる
  • 「別バックエンドから制御したい」なら、接続ハブ方式か、コール型+controller 方式でアーキテクチャを組むのが現実的

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次