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に同期 |
| アプリ用JSON | response.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やサーバーオブザーバ構成を組み合わせる。

コメント