この記事では、Azure OpenAI マルチモーダル モデルを使用して、チャット アプリでユーザー メッセージとアップロードされた画像に対する応答を生成する方法について説明します。 このチャット アプリサンプルには、Azure Developer CLI を使用して、Azure OpenAI リソースをプロビジョニングし、アプリをAzure Container Appsにデプロイするために必要なすべてのインフラストラクチャと構成も含まれています。
この記事の手順に従うことで、次の操作を行います:
- 認証にマネージド ID を使用する Azure Container チャット アプリをデプロイします。
- チャット ストリームの一部として使用する画像をアップロードします。
- OpenAI ライブラリの Responses API を使用して、Azure OpenAI マルチモーダル 大規模言語モデル (LLM) とチャットします。
この記事の手順の完了後は、カスタム コードを使用して、新しいプロジェクト用の変更を開始できます。
注記
この記事では、記事内の例とガイダンスの土台として、1 つ以上の AI アプリ テンプレートを使用しています。 AI アプリ テンプレートは、適切にメンテナンスされ、デプロイが容易なリファレンス実装を提供します。これは、高品質な AI アプリの作成を開始するために役立ちます。
アーキテクチャの概要
チャット アプリは、Azure コンテナー アプリとして実行されています。 このアプリでは、API キーではなく、Microsoft Entra ID を介してマネージド ID を使用して、運用環境の Azure OpenAI で認証します。 開発中、アプリでは、Azure Developer CLI 資格情報と API キーを含む複数の認証方法がサポートされます。
アプリケーション アーキテクチャは、次のサービスとコンポーネントに依存しています。
- Azure OpenAI は、ユーザーのクエリの送信先となる AI プロバイダーを表します。
- Azure Container Apps は、アプリケーションがホストされているコンテナー環境です。
- マネージド ID は、クラス最高のセキュリティを確保するのに役立ち、開発者がシークレットを安全に管理する必要がなくなります。
- Bicep ファイル Azure OpenAI、Azure Container Apps、Azure Container Registry、Azure Log Analytics、ロールベースのアクセス制御 (RBAC) ロールなど、Azure リソースをプロビジョニングします。
- Python Quart アプリ。
openaiパッケージを使用して、アップロードされたイメージ ファイルを含むユーザー メッセージに対する応答を生成します。 - ReadableStreamを介してJSON Linesを使用してバックエンドからの応答をストリーミングする基本的な HTML/JavaScript フロントエンド。
コスト
このサンプルで可能な限り価格を低く保つために、ほとんどのリソースでは Basic 価格レベルまたは従量課金価格レベルが使用されます。 目的の使用法に基づいて、必要に応じてレベルを変更します。 料金の発生を停止するには、記事が完了したらリソースを削除します。
サンプル リポジトリのコストの詳細についてはを参照してください。
前提条件
開発コンテナー環境は、この記事を完了するために必要なすべての依存関係と共に使用できます。 開発コンテナーは、GitHub Codespaces (ブラウザー) で実行することも、Visual Studio Codeを使用してローカルで実行することもできます。
この記事を使用するには、次の前提条件を満たす必要があります。
Azure サブスクリプション - 無料で作成します
Azure アカウントのアクセス許可 - Azure アカウントには、
Microsoft.Authorization/roleAssignments/writeのアクセス許可が必要です。例えば、ユーザー アクセス管理者 や 所有者 などです。GitHub アカウント
開発環境を開く
この記事を完了するために必要なすべての依存関係を含む、構成済みの開発環境をデプロイするには、次の手順に従います。
重要
すべてのGitHubアカウントは、2 つのコア インスタンスで毎月最大 60 時間無料で Codespaces を使用できます。 詳細については、「GitHub Codespaces の月単位のストレージとコア時間を参照してください。
main GitHub リポジトリの Azure-Samples/openai-chat-vision-quickstart ブランチに新しい GitHub Codespace を作成するには、次の手順に従います。
次のボタンを右クリックし、 [新しいウィンドウでリンクを開く]を選択します。 このアクションを使用すると、開発環境とドキュメントを確認できます。
コードスペースの作成 ページで内容を確認し、新しいコードスペースを作成を選択します。
Codespace が起動するまで待ちます。 この起動プロセスには数分かかることがあります。
画面の下部にあるターミナルで、Azure Developer CLI を使用してAzureにサインインします。
azd auth loginターミナルからコードをコピーし、ブラウザーに貼り付けます。 手順に従って、Azure アカウントで認証します。
この記事の残りのタスクは、この開発コンテナーのコンテキストで行われます。
デプロイして実行する
サンプル リポジトリには、チャット アプリAzureデプロイ用のすべてのコードと構成ファイルが含まれています。 次の手順では、サンプル チャット アプリAzure展開プロセスについて説明します。
チャット アプリを Azure に展開する
重要
コストを低く抑えるために、このサンプルでは、ほとんどのリソースに基本価格レベルまたは従量課金レベルを使用します。 必要に応じてレベルを調整し、料金が発生しないように完了したらリソースを削除します。
次の Azure Developer CLI コマンドを実行して、Azureリソースのプロビジョニングとソース コードのデプロイを行います。
azd upプロンプトに応答するには、次の表を使用します。
プロンプト 回答 環境名 常に短くし、小文字を使用します。 自分の名前またはエイリアスを追加します。 たとえば、 chat-visionのようにします。 リソース グループ名の一部として使用されます。サブスクリプション リソースの作成先となるサブスクリプションを選択します。 場所 (ホスティング用) リストから、自分に近い場所を選択します。 Azure OpenAI モデルの場所 リストから、自分に近い場所を選択します。 最初の場所と同じ場所を使用できる場合は、その場所を選択します。 アプリがデプロイされるまで待ちます。 通常、デプロイの完了には 5 分から 10 分かかります。
チャット アプリを使用して大規模言語モデルに質問する
アプリケーションのデプロイが正常に完了すると、ターミナルに URL が表示されます。
Deploying service webとラベルの付いたその URL を選択して、ブラウザーでチャット アプリケーションを開きます。ブラウザーで[ファイルの選択]をクリックしイメージ選択して、画像をアップロードします。
アップロードされた画像に関する質問をします (例: "What is the image about?")。
答えは OpenAI Azureから取得され、結果が表示されます。
サンプル コードの探索
このサンプルでは、Azure OpenAI マルチモーダル モデルを使用して、ユーザー メッセージとアップロードされた画像への応答を生成します。
フロントエンドでアップロードされたイメージを Base64 エンコードする
アップロードされたイメージは、メッセージの一部としてデータ URI として直接使用できるように、Base64 でエンコードする必要があります。
このサンプルでは、script ファイルのsrc/quartapp/templates/index.htmlタグにある次のフロントエンド コード スニペットがその機能を処理します。
toBase64矢印関数はreadAsDataURLのFileReaderメソッドを使用して、アップロードされたイメージ ファイルを base64 でエンコードされた文字列として非同期的に読み取ります。
const toBase64 = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
toBase64関数は、フォームの submit イベントのリスナーによって呼び出されます。
submit イベント リスナーは、チャットの対話フロー全体を処理します。 ユーザーがメッセージを送信すると、次のフローが発生します。
- アップロードされたイメージ ファイル (存在する場合) を取得し、Base64 としてエンコードします
- アップロードされた画像を含め、チャットにユーザーのメッセージを作成して表示します
- "Typing..." インジケータを使用してアシスタント メッセージ コンテナーを準備します。
- Responses API 形式のメッセージ履歴配列にユーザーのメッセージを追加します
- メッセージ履歴とコンテキスト (Base64 でエンコードされたイメージとファイル名を含む) を使用して、
fetchエンドポイントに/chat/streamPOST 要求を送信します。 - ストリーミングされた JSON 行応答 を処理して、各テキストデルタを増分表示します
- ストリーミング中のエラーを処理します
- 完全な応答を受信した後に音声出力ボタンを追加して、ユーザーが応答を聞くことができるようにします
- 入力フィールドをクリアし、次のメッセージのフォーカスを返します。
form.addEventListener("submit", async function(e) {
e.preventDefault();
// Hide the no-messages-heading when a message is added
document.getElementById("no-messages-heading").style.display = "none";
const file = document.getElementById("file").files[0];
const fileData = file ? await toBase64(file) : null;
const message = messageInput.value;
const userTemplateClone = userTemplate.content.cloneNode(true);
userTemplateClone.querySelector(".message-content").innerText = message;
if (file) {
const img = document.createElement("img");
img.src = fileData;
userTemplateClone.querySelector(".message-file").appendChild(img);
}
targetContainer.appendChild(userTemplateClone);
const assistantTemplateClone = assistantTemplate.content.cloneNode(true);
let messageDiv = assistantTemplateClone.querySelector(".message-content");
targetContainer.appendChild(assistantTemplateClone);
messages.push({
"role": "user",
"content": [{"type": "input_text", "text": message}]
});
try {
messageDiv.scrollIntoView();
const response = await fetch("/chat/stream", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
messages: messages,
context: {
file: fileData,
file_name: file ? file.name : null
}
})
});
if (!response.ok || !response.body) {
throw new Error(`Request failed (${response.status})`);
}
let answer = "";
for await (const chunk of readNDJSONStream(response.body)) {
if (chunk.type === "error" || chunk.type === "response.failed") {
messageDiv.innerHTML = "Error: " + (chunk.error || "Unknown error");
break;
}
if (chunk.type === "response.output_text.delta") {
// Clear out the DIV if its the first answer chunk we've received
if (answer == "") {
messageDiv.innerHTML = "";
}
answer += chunk.delta;
messageDiv.innerHTML = converter.makeHtml(answer);
messageDiv.scrollIntoView();
}
}
messages.push({
"role": "assistant",
"content": [{"type": "output_text", "text": answer}]
});
messageInput.value = "";
const speechOutput = document.createElement("speech-output-button");
speechOutput.setAttribute("text", answer);
messageDiv.appendChild(speechOutput);
messageInput.focus();
} catch (error) {
messageDiv.innerHTML = "Error: " + error;
}
});
バックエンドでのイメージの処理
src\quartapp\chat.py ファイルでは、キーレス認証を構成した後、イメージ処理のバックエンド コードが開始されます。
注記
認証と認可を目的として、キーレス接続を使用した Azure OpenAI の利用方法の詳細については、「Azure OpenAI セキュリティビルディングブロック入門」という Microsoft Learn の記事を参照してください。
認証の構成
configure_openai()関数は、アプリが要求の処理を開始する前に OpenAI クライアントを設定します。 Quart の @bp.before_app_serving デコレーターを使用して、環境変数に基づいて認証を構成します。 この柔軟なシステムにより、開発者はコードを変更せずにさまざまなコンテキストで作業できます。
認証モードの説明
-
ローカル開発 (
OPENAI_HOST=local): 認証なしでローカルの OpenAI 互換 API サービス (Ollama や LocalAI など) に接続します。 このモードは、インターネットや API のコストなしでテストする場合に使用します。 -
Azure OpenAI API キー (
AZURE_OPENAI_KEY_FOR_CHATVISION環境変数): API キーを使用して認証を行います。 API キーには手動でのローテーションが必要であり、公開された場合はセキュリティ リスクが発生するため、運用環境ではこのモードを避けてください。 Azure CLIの資格情報なしに、Dockerコンテナー内でのローカルテストに使用します。 -
マネージド ID での運用 (
RUNNING_IN_PRODUCTION=true):ManagedIdentityCredentialを使用して、コンテナー アプリのマネージド ID を介して Azure OpenAI で認証します。 この方法は、シークレットを管理する必要がないため、運用環境に推奨されます。 Azure Container Apps自動的にマネージド ID を提供し、Bicep経由でデプロイ中にアクセス許可を付与します。 -
Azure CLI を使用した開発 (デフォルトモード):
AzureDeveloperCliCredentialを使用して、Azure CLI にローカルサインインした資格情報で Azure OpenAI に認証します。 このモードでは、API キーを管理することなく、ローカル開発が簡略化されます。
主要な実装の詳細
-
get_bearer_token_provider()関数は、資格情報Azure更新し、ベアラー トークンとして使用します。 - Azure OpenAI エンドポイント パスは、
/openai/v1/(Microsoft Foundry モデルで一般公開されている OpenAI 互換エンドポイント) で終わります。 - Quart は非同期 Web アプリ フレームワークであるため、関数は非同期です。 Quart では要求ハンドラーを非同期にできるため、アプリが低速の LLM API 応答を待機している間、サーバーは他の要求を処理し続けることができます。
chat.pyからの完全な認証セットアップ コードを次に示します。
@bp.before_app_serving
async def configure_openai():
bp.model_name = os.getenv("OPENAI_MODEL", "gpt-4o")
openai_host = os.getenv("OPENAI_HOST", "azure")
if openai_host == "local":
bp.openai_client = AsyncOpenAI(api_key="no-key-required", base_url=os.getenv("LOCAL_OPENAI_ENDPOINT"))
current_app.logger.info("Using local OpenAI-compatible API service with no key")
elif os.getenv("AZURE_OPENAI_KEY_FOR_CHATVISION"):
bp.openai_client = AsyncOpenAI(
base_url=os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + "/openai/v1",
api_key=os.getenv("AZURE_OPENAI_KEY_FOR_CHATVISION"),
)
current_app.logger.info("Using Azure OpenAI with key")
elif os.getenv("RUNNING_IN_PRODUCTION"):
client_id = os.environ["AZURE_CLIENT_ID"]
azure_credential = ManagedIdentityCredential(client_id=client_id)
token_provider = get_bearer_token_provider(azure_credential, "https://cognitiveservices.azure.com/.default")
bp.openai_client = AsyncOpenAI(
base_url=os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + "/openai/v1",
api_key=token_provider,
)
current_app.logger.info("Using Azure OpenAI with managed identity credential for client ID %s", client_id)
else:
tenant_id = os.environ["AZURE_TENANT_ID"]
azure_credential = AzureDeveloperCliCredential(tenant_id=tenant_id)
token_provider = get_bearer_token_provider(azure_credential, "https://cognitiveservices.azure.com/.default")
bp.openai_client = AsyncOpenAI(
base_url=os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + "/openai/v1",
api_key=token_provider,
)
current_app.logger.info("Using Azure OpenAI with az CLI credential for tenant ID: %s", tenant_id)
チャット ハンドラー関数
chat_handler()関数は、/chat/stream エンドポイントに送信されたチャット要求を処理します。 JSON ペイロードを含む POST 要求を受け取ります。
JSON ペイロードには次のものが含まれます。
-
messages: 会話履歴の一覧。 各メッセージには、
role("user" または "assistant") とcontent( Responses API 入力形式を使用したコンテンツ パーツの配列) があります。 -
context: 次のような処理のための追加データ:
-
file: Base64 でエンコードされた画像データ (たとえば、
data:image/png;base64,...)。 - file_name: アップロードされた画像の元のファイル名 (画像の種類のログ記録や識別に役立ちます)。
-
file: Base64 でエンコードされた画像データ (たとえば、
ハンドラーは、メッセージ履歴と画像データを抽出します。 画像がアップロードされない場合、画像の値は nullされ、コードはこのケースを処理します。
@bp.post("/chat/stream")
async def chat_handler():
request_json = await request.get_json()
request_messages = request_json["messages"]
# Get the base64 encoded image from the request context
# This will be None if no image was uploaded
image = request_json["context"]["file"]
視覚要求用の入力配列の構築
response_stream() 関数は、Azure OpenAI Responses API に送信される入力配列を準備します。
@stream_with_contextデコレーターは、応答のストリーミング中に要求コンテキストを保持します。
入力準備ロジック
-
会話履歴から始める: 関数は
all_inputで始まり、最新のメッセージ (request_messages[0:-1]) を除く以前のすべてのメッセージが含まれます。 メッセージは既にフロントエンドからの Responses API 形式です。 -
イメージの存在に基づいて現在のユーザー メッセージを処理します。
-
画像付き:
input_imageコンテンツ パーツをユーザーの既存のコンテンツ配列に追加します。 - 画像なし: ユーザーのメッセージを as-is追加します。
-
画像付き:
@stream_with_context
async def response_stream():
# This sends all messages, so API request may exceed token limits
all_input = list(request_messages[0:-1])
# Add the current user message, appending image if provided
if image:
user_content = request_messages[-1]["content"] + [{"type": "input_image", "image_url": image}]
all_input.append({"role": "user", "content": user_content})
else:
all_input.append(request_messages[-1])
次に、bp.openai_client.responses.create は Azure OpenAI Responses API を呼び出し、応答をストリーミングします。
store=False パラメーターは、応答をサーバーに格納しないように API に指示し、呼び出しをステートレスにします。
openai_stream = await bp.openai_client.responses.create(
model=bp.model_name,
input=all_input,
stream=True,
temperature=0.3,
store=False,
)
最後に、応答がクライアントにストリーミングされます。 Responses API は多数の イベントの種類を出力しますが、生成されたテキストをストリーミングするために必要なのは response.output_text.delta イベントのみです。 エラー イベントがログに記録され、フロントエンドに転送されます。
try:
async for event in openai_stream:
if event.type == "response.output_text.delta":
yield json.dumps({"type": event.type, "delta": event.delta}, ensure_ascii=False) + "\n"
elif event.type in ("response.failed", "error"):
current_app.logger.error("Responses API error: %s", event)
yield json.dumps({"type": event.type}, ensure_ascii=False) + "\n"
except Exception as e:
current_app.logger.exception("Error in response stream")
yield json.dumps({"error": str(e)}, ensure_ascii=False) + "\n"
return Response(response_stream())
フロントエンド ライブラリと機能
フロントエンドは、最新のブラウザー API とライブラリを使用して対話型チャット エクスペリエンスを作成します。 開発者は、次のコンポーネントを理解することで、インターフェイスをカスタマイズしたり、機能を追加したりできます。
音声入力/出力: カスタム Web コンポーネントでは、ブラウザーの Speech API が使用されます。
<speech-input-button>: Web Speech API のSpeechRecognitionを使用して音声をテキストに変換します。 音声入力をリッスンし、文字起こしされたテキストでspeech-input-resultイベントを出力するマイク ボタンを提供します。<speech-output-button>:SpeechSynthesisAPI を使用してテキストを読み上げます。 各アシスタントの応答の後にスピーカー アイコンが表示され、ユーザーに応答が読み上げられます。
なぜ Azure Speech Services の代わりにブラウザー API を使用するのですか?
- コストなし - ブラウザーで完全に実行
- インスタント応答 - ネットワーク待機時間なし
- プライバシー - 音声データはユーザーのデバイスにとどまります
- 余分なAzureリソースは必要ありません
これらのコンポーネントは、
src/quartapp/static/speech-input.jsとspeech-output.jsにあります。画像プレビュー: 確認のために分析の送信前に、チャットにアップロードされた画像を表示します。 プレビューは、ファイルが選択されると自動的に更新されます。
fileInput.addEventListener("change", async function() { const file = fileInput.files[0]; if (file) { const fileData = await toBase64(file); imagePreview.src = fileData; imagePreview.style.display = "block"; } });ブートストラップ 5 とブートストラップ アイコン: 応答性の高い UI コンポーネントとアイコンを提供します。 アプリは、モダンな外観のためにブーツウォッチからコスモテーマを使用しています。
テンプレート ベースのメッセージ レンダリング: 再利用可能なメッセージ レイアウトに HTML
<template>要素を使用し、一貫性のあるスタイルと構造を確保します。
探索するその他のサンプル リソース
チャット アプリのサンプルに加えて、リポジトリには、さらに学習するために探索する他のリソースがあります。
notebooks ディレクトリにある次のノートブックを確認します。
| Notebook | 説明 |
|---|---|
| chat_pdf_images.ipynb | このノートブックでは、PDF ページを画像に変換し、推論のためにビジョン モデルに送信する方法を示します。 |
| chat_vision.ipynb | このノートブックは、アプリで使用されるビジョン モデルを手動で実験するために提供されます。 |
ローカライズされたコンテンツ: スペイン語版のノートブックは notebooks/Spanish/ ディレクトリにあり、スペイン語を話す開発者にも同じ実践的な学習を提供します。 英語とスペイン語の両方のノートブックには、次の情報が表示されます。
- 実験のためにビジョン モデルを直接呼び出す方法
- PDF ページを分析用の画像に変換する方法
- パラメーターとテスト プロンプトを調整する方法
リソースをクリーンアップする
Azure リソースをクリーンアップする
この記事で作成したAzure リソースは、Azure サブスクリプションに課金されます。 今後これらのリソースが必要になるとは思わない場合は、削除して、より多くの料金が発生しないようにします。
Azure リソースを削除し、ソース コードを削除するには、次の Azure Developer CLI コマンドを実行します。
azd down --purge
GitHub Codespaces をクリーンアップする
GitHub Codespaces 環境を削除すると、アカウントに対して取得するコア時間単位の無料エンタイトルメントの量を最大化できます。
重要
GitHub アカウントの権利の詳細については、「GitHub Codespaces の月単位のストレージとコア時間を参照してください。
GitHub Codespaces ダッシュボードにサインインします。
現在実行中の Codespaces を
Azure-Samples//openai-chat-vision-quickstartGitHub リポジトリから見つけます。codespace のコンテキスト メニューを開き、[削除] を選択します。
ヘルプを取得
リポジトリの Issues に問題を記録します。
