Azure Realtime WebRTCでOpenAI Realtimeのvariables相当を実現する方法|function callingでJSONと音声を分離しプロンプトを隠す

Azure OpenAI Realtime API(WebRTC)を使うと、音声応答は滑らかでも、アプリ側で使うJSONの扱いとプロンプト秘匿でつまずきがちです。本記事ではOpenAI Realtimeのvariablesとの違いを整理し、function callingで“話す内容”と“構造化データ”を分離する実装と、ブラウザ直結時の秘匿設計を解説します。

目次

まず整理:OpenAI Realtimeの「variables」は何で、Azure Realtime WebRTCでは何が違うのか

「OpenAI Realtime WebRTCだとvariablesが使えるのに、Azure Realtime WebRTCには見当たらない」という疑問は、とても自然です。ここで言うvariablesは、単にJSONを“埋め込む”機能ではなく、Prompt Management(保存プロンプト)とテンプレート変数の仕組みに紐づいた機能です。

OpenAI側のドキュメントでは、Realtimeセッション更新(session.update)で、保存したPrompt IDを指定し、variablesを渡してテンプレートの穴埋めを行う例が示されています(prompt: { id, version, variables })。この設計だと「プロンプト本文はテンプレートで固定し、呼び出しごとに変数だけ差し替える」運用がしやすくなります。

一方、Azure OpenAI Realtime(WebRTC)では、少なくとも現時点の公開情報とQ&Aの回答ベースでは、OpenAI Realtimeのような「variables」フィールドをそのまま渡す仕組みが“その形では”提供されていません。Azure側でやりたいこと(JSONの一部だけ読み上げ、残りはアプリで使う)自体は可能ですが、手段が異なります。

観点OpenAI Realtime(Prompt variables)Azure Realtime WebRTC(現実的な代替)
テンプレート差し込み保存Prompt+variablesで穴埋め同等のvariables入口は見当たりにくい(別アプローチが必要)
「話す内容」と「アプリ用JSON」の分離Prompt設計次第(JSONを本文に混ぜると事故りやすい)function calling(ツール)でイベントレベルに分離しやすい
ブラウザ直結でのプロンプト秘匿設計次第(クライアント送信なら見える)ephemeral token+webrtcfilterで「見えにくく」できる設計が用意されている

やりたいことの本質:JSONの“messageだけ”を音声にして、残りはアプリで使いたい

要件を分解すると、次の2つに集約できます。

  • 人間向け(読み上げ/表示):短く自然な文章(例:message)
  • アプリ向け(ロジック):構造化データ(例:意図、スロット、状態、UI操作、ログ用メタ情報など)

この2つを同じテキストに混ぜると、現場ではすぐに破綻します。たとえば「JSONをコードブロックで返して」と指示しても、音声モデルは途中で要約を挟んだり、引用符を崩したり、ユーザー向け文と混在させたりします。結果として、TTSがJSONを読んでしまったり、パースが不安定になったりします。

そこで狙うべきは、生成物を“チャネル(イベント)”のレベルで分けることです。Azure Realtime WebRTCでは、これをやりやすくする仕組みとしてfunction calling(ツール呼び出し)が使えます。

結論:Azure Realtime WebRTCでは「function calling」がvariables相当の役割を担う

Azure Realtime WebRTCで「話す内容」と「構造化データ(JSON)」をきれいに分離したいなら、最も実装が安定するのは関数ツール(function calling)です。理由は単純で、ツール呼び出しの引数は“ツール引数用のイベント”として流れ、音声や通常テキストとは別ルートで受け取れるためです。

Azureのイベント仕様(Audio events reference)でも、function callingに関するイベントとして response.function_call_arguments.delta / response.function_call_arguments.done が定義されています。つまり、JSONを本文から抽出するのではなく、最初から「関数引数」として受け取るのが正攻法です。

「JSONをテキストで返す」よりfunction callingが強い理由

方式メリットデメリット(実務で詰むポイント)
テキストにJSONを混ぜる実装が一見簡単音声で読まれる/JSONが壊れる/抽出ロジックが肥大化/プロンプトが増えるほど不安定
「構造化出力(JSON Schema)」系整形されやすいRealtime WebRTCのフローでは取り回しに工夫が必要(用途次第で別APIの方が適する)
function calling(ツール)イベントが分離される/パースが安定/TTSと衝突しないツール設計(スキーマ)とイベント購読の実装が必要

実装:Azure Realtime WebRTCで「話す内容」と「アプリ用JSON」を分離する手順

ツール(function)のスキーマを“アプリ都合”で定義する

最初にやるべきは「アプリが欲しいJSON」を決めることです。重要なのは、読み上げ用の文(message)を必須にし、それ以外はアプリ都合で拡張する設計にすることです。

例として、読み上げ用のmessageと、アプリが使うmetadataを分けた最小構成を示します(JSON Schema)。

{
  "type": "function",
  "name": "send_app_payload",
  "description": "ユーザー向け発話とアプリ用の構造化データを分離して返す",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "message": {
        "type": "string",
        "description": "ユーザーに読み上げる(または表示する)短いメッセージ"
      },
      "metadata": {
        "type": "object",
        "description": "アプリ側で利用する任意の構造化データ",
        "additionalProperties": true
      }
    },
    "required": ["message"]
  }
}

この段階では、metadataの中身を作り込みすぎない方が成功します。まずは「intent」「slots」「confidence」「next_action」あたりの最低限に絞り、運用で増やすのが安全です。

セッションにtoolsを登録し、tool_choiceで“必ず呼ぶ”方向に寄せる

ツールはセッション設定に登録します。Azure Realtimeのサンプルでも、セッション更新(session.update)でtoolsをセットする例が示されています。

さらに安定させたいなら、tool_choiceでこのツールを選ぶよう寄せます。これにより「たまにツールを呼ばず自然文だけ返る」事故を減らせます(完全にゼロにはならないので、後述のフェイルセーフも用意します)。

{
  "type": "session.update",
  "session": {
    "instructions": "(後述の指示文)",
    "tools": [
      {
        "type": "function",
        "name": "send_app_payload",
        "description": "ユーザー向け発話とアプリ用データを分離して返す",
        "parameters": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "message": { "type": "string" },
            "metadata": { "type": "object", "additionalProperties": true }
          },
          "required": ["message"]
        }
      }
    ],
    "tool_choice": {
      "type": "function",
      "function": { "name": "send_app_payload" }
    }
  }
}

補足として、Realtime API(OpenAI側のGA移行情報)ではイベント名が変わっているケースがあります。response.text.delta系を前提にした古い実装だと、GAではresponse.output_text.delta系に変わる、というようなマッピングがあります。Azure側でもドキュメントやサンプルによって表記が揺れるため、ログで実際のイベント型を確認しつつ、両方を受けられるようにしておくと堅牢です。

プロンプト(instructions)で“二重出力の責務分離”を明示する

ここが一番効きます。モデルにやってほしいことは、実質「二重出力」です。

  • ユーザー向けに自然な返答(音声/画面表示に使う)
  • アプリ向けに構造化データ(ツール引数として返す)

例えば次のように書きます。ポイントは「JSONを本文に混ぜるな」「構造化データはツール引数だけ」「読み上げ文は短く」というルールを、禁止+期待形+失敗時の挙動まで含めて書くことです。

あなたは音声対話アシスタントです。

【重要】
- ユーザーに話す内容(自然な文章)は、通常の応答(音声/テキスト)として返してください。
- 構造化データは、必ず send_app_payload 関数の引数にのみ入れてください。
- 応答本文にJSON、コードブロック、波括弧を含めないでください。

【send_app_payloadのルール】
- message: ユーザーへ読み上げる短い文章(1〜2文、敬体、具体的)
- metadata: アプリで利用する構造化情報(意図、抽出した値、次のアクションなど)
- 迷ったら metadata は最小限でよいが、message は必ず入れる

このように責務を分離すると、TTSは本文だけを読み、アプリはツール引数(JSON)だけをパースすればよくなります。

クライアント実装:音声/テキストと、function_call_argumentsを別々に受け取る

実装の肝は「購読先の分離」です。WebRTCの場合、音声はメディアトラックで受け、イベント(データチャネル)でテキストやツール引数を受け取る構成になります。

Azureのイベント仕様では、ツール引数はストリーミングで届くため、doneまでバッファしてからJSON.parseするのが基本です。

受け取るもの代表的なイベント(例)使い道
ユーザー向けテキストresponse.text.delta / response.text.done(またはresponse.output_text.*)画面表示、外部TTS用の原稿
ユーザー向け音声の文字起こしresponse.audio_transcript.*(またはresponse.output_audio_transcript.*)「実際に喋った内容」をUIに同期
アプリ用JSONresponse.function_call_arguments.delta / .doneアプリロジックでパースして利用

疑似コード(イベントの受け分け)例です。

// dataChannel.onmessage = (ev) => { ... } の中で処理する想定
let fnArgsBuffer = "";

function onServerEvent(serverEvent) {
  switch (serverEvent.type) {

    // テキスト表示(イベント名は環境で揺れるので両方拾う)
    case "response.text.delta":
    case "response.output_text.delta":
      appendToChat(serverEvent.delta);
      break;

    case "response.text.done":
    case "response.output_text.done":
      finalizeChatText(serverEvent.text);
      break;

    // 音声トランスクリプト(実際に喋った内容の同期に便利)
    case "response.audio_transcript.delta":
    case "response.output_audio_transcript.delta":
      appendToTranscript(serverEvent.delta);
      break;

    case "response.audio_transcript.done":
    case "response.output_audio_transcript.done":
      finalizeTranscript(serverEvent.text || serverEvent.transcript);
      break;

    // ここが本命:関数呼び出し引数(JSON)を別バッファで受ける
    case "response.function_call_arguments.delta":
      fnArgsBuffer += serverEvent.delta;
      break;

    case "response.function_call_arguments.done": {
      // doneが来たら1回だけJSON.parse
      const jsonText = serverEvent.arguments || fnArgsBuffer;
      fnArgsBuffer = "";

      let payload;
      try {
        payload = JSON.parse(jsonText);
      } catch (e) {
        console.error("Invalid JSON from tool args:", jsonText);
        return;
      }

      // messageはTTSに回す(またはUIに表示)
      if (payload.message) {
        playTTS(payload.message);
      }

      // metadataはアプリロジックへ
      if (payload.metadata) {
        handleAppMetadata(payload.metadata);
      }
      break;
    }

    default:
      // 必要に応じてログ
      break;
  }
}

この設計にすると、アプリ側の処理は非常にシンプルになります。

  • TTS:payload.messageだけを読む
  • ロジック:payload.metadataだけを見る
  • UI:response.text.*やresponse.audio_transcript.*を好きな方で同期

「モデル音声を使う」か「外部TTSにする」かで設計が変わる

Realtime WebRTCの美味しさは“speech in / speech out”の低遅延です。したがって、モデルの音声をそのまま使う構成は最も自然です。この場合、messageはUI表示用に使い、読み上げはモデル音声に任せる、という割り切りもできます。

一方で、ブランドボイスや音声品質、SSML制御などの理由で、外部TTSを使いたいこともあります。その場合は、

  • モデル側の音声出力を抑えたい(テキストだけ欲しい)
  • テキストはmessageのみに統一したい

という要求が出ます。ここは利用しているAzureのプロトコルやSDKの制約で「音声のみ」「テキストのみ」の指定が効いたり効かなかったりする報告もあるため、まずはセッション設定(modalities等)を最小で始め、イベントログで挙動を確認するのが堅実です。

ハマりどころとフェイルセーフ(実務で効くチェックリスト)

  • ツール呼び出しが来ない:tool_choiceで寄せる。さらに、ツールが来なかった時は「本文テキストをそのままmessageとして扱う」などフォールバックを入れる。
  • JSONが途中で途切れる:deltaは断片なので、doneまでバッファする。中断時にもdone相当が来るケースがあるため、レスポンス状態もログする。
  • “話す文”と“message”がズレる:instructionsで「本文の要旨とmessageは同一」と明示。UIやログで差分検知して改善する。
  • metadataが肥大化して不安定:最初は小さく。運用で必要になったキーだけ追加。スキーマにadditionalProperties: falseを入れて暴走を抑えるのも有効。
  • イベント名がドキュメントと違う:GA/BetaやSDK差で変わることがある。response.text.*とresponse.output_text.*を両対応してログで確定させる。

ブラウザ直結のAzure Realtime WebRTCでプロンプト(instructions)を隠したい

まず大前提:「ブラウザが送ったもの」は基本的に隠せない

あなたのブラウザがData Channelでsession.updateを送り、その中にinstructions(システムプロンプト)を埋めている場合、その内容はユーザーがDevToolsなどで観測・改変できます。これはAzureに限らず「クライアントで生成した秘密」は成立しません。

ここで重要なのは、プロンプトを「秘匿すべき資産」として扱うなら、プロンプトはサーバー側に置くしかない、ということです。

Azureの公式推奨に近い解:ephemeral token(client_secrets)でサーバー側からセッション設定を渡す

AzureのWebRTC手順では、ブラウザが直接Azureに長期キーで接続するのではなく、バックエンドがephemeral token(短命トークン)を発行し、それをブラウザに渡す構成が示されています。さらにこのephemeral token発行時のリクエストに、セッション設定(プロンプトinstructionsや出力音声)を含められる旨が明記されています。

つまり、ブラウザは「短命トークンを受け取って接続するだけ」にして、肝心のプロンプト文はバックエンドが持つ、という構成に寄せられます。

ブラウザ ──( /token )──> 自前バックエンド
  ↑                          │
  │(ephemeral tokenのみ)     │(client_secretsにsession構成を同梱)
  └─────────────── Azure Realtime(WebRTC)

さらに一歩:webrtcfilter=onでデータチャネルのメッセージを制限し、プロンプトを“露出しにくく”する

AzureのWebRTCガイドでは、webrtcfilter=onというクエリパラメータで、ブラウザに返すデータチャネルメッセージを制限し、プロンプトinstructionsをプライベートに保つ意図が明確に書かれています。

フィルタをオンにすると、ブラウザに返るメッセージ種別は限定されます(ドキュメントに列挙あり)。この挙動は「ブラウザに余計な内部イベントを見せない」ため、プロンプトの露出リスクを下げる方向に働きます。

ただし、ここで設計上のトレードオフがあります。フィルタで落ちるイベントがあるため、あなたが欲しい「関数呼び出し引数(JSON)」のイベントまでブラウザに届かない可能性があります。実際、ガイドの列挙を見る限り、response.function_call_arguments.*は含まれていません。

結論として、「プロンプト秘匿」と「ブラウザでツール引数JSONを直接受けたい」を同時に満たすには、次のどちらかを選ぶことになります。

  • フィルタをオフ:ブラウザでfunction callingイベントまで受ける(ただし露出リスクは上がる)
  • フィルタをオン+サーバー側で観測:サーバーがWebSocketオブザーバでフルイベントを受け、必要なJSONだけをブラウザへ中継

サーバー側オブザーバ/コントローラで「見せたいものだけ返す」構成

AzureのWebRTCガイドには、SDPネゴシエーションを自前サービスでプロキシし、LocationヘッダからWebSocket接続を作って、セッションを観測・制御できる、というオプション構成が説明されています(録音やsession.updateによる制御も可能)。

この構成に寄せると、設計は次のように分割できます。

[ブラウザ]
  - WebRTCで音声の送受信(低遅延)
  - データチャネルはwebrtcfilter=onで最小限(プロンプトを露出させにくい)
  - UI表示に必要なテキスト/トランスクリプトだけ受け取る

[バックエンド]
  - client_secretsでセッション構成(秘密プロンプト)を付与
  - WebSocketオブザーバでフルイベントを受け取る
  - function calling引数(JSON)をサーバー側でパースし、必要な項目だけブラウザへ返す
  - 監査ログ、レート制限、危険な発話のブロック等もここで実施

この設計だと、ブラウザに渡るのは「ユーザーに見せてよい情報」だけになり、プロンプト本文や内部ルールはバックエンドに閉じ込められます。さらに、function callingで受け取ったJSON(予約情報、操作意図など)もサーバー側で検証・正規化してからフロントに渡せるため、安全性と堅牢性が上がります。

目的別:おすすめ構成の選び方(実務の落としどころ)

目的おすすめ構成理由
とにかく早く動くプロトタイプブラウザ直結+session.updateでinstructions実装が最短。ただしプロンプトは見える前提で割り切る
JSON分離を最優先(messageだけTTS)function callingで分離JSON抽出不要、イベント分離で安定運用
プロンプトを秘匿したい(社内ルール/機密)client_secrets(ephemeral token)+webrtcfilter=onブラウザにプロンプトを渡さない設計に寄せられる
秘匿+JSON分離を両立したいwebrtcfilter=on+サーバーオブザーバでfunction callingを処理ブラウザには必要最小限、アプリ用JSONはサーバーで受ける

まとめ:Azure Realtime WebRTCで“variables的な体験”を作る最短ルート

  • OpenAI Realtimeのvariablesは、保存Promptとテンプレート変数の仕組みによるもの。Azure Realtime WebRTCでは同じ形の入口が見つからないケースがある。
  • 「話す内容」と「アプリ用JSON」を分離したいなら、Azureではfunction calling(ツール)が最も安定する。ツール引数は専用イベントで流れるため、JSON抽出の泥沼を避けられる。
  • プロンプトを隠したいなら、ブラウザからinstructionsを送る設計は避ける。Azureのガイドにあるephemeral token発行(client_secrets)でサーバー側に寄せ、必要に応じてwebrtcfilter=onやサーバーオブザーバ構成を組み合わせる。

この記事を書いた人

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

コメント

コメントする

目次