Azure OpenAI Assistants APIとAzure Functionsで安全に状態管理する方法|thread.id・JWT・Cosmos DB

Azure Assistants API と Azure Functions(Function App)を組み合わせると、会話の文脈と業務状態をどこで管理するかが急に難しくなります。本記事では、thread.id を軸にユーザー認証と永続ストアを組み合わせ、安全に状態を同期する設計パターンを具体例とともに解説します。

目次

Azure Assistants API × Function App で「状態」が問題になる理由

Function App はスケールアウトしやすい一方で、インスタンスの入れ替わりが前提のため「前回の会話でどこまで進んだか」「どの顧客レコードを選択中か」といった業務状態をメモリに置いておけません。さらに Assistants はスレッド(thread)と実行(run)という単位で動き、ツール呼び出し(function calling)を挟むと、同じ会話でも複数回の往復が発生します。結果として、フロント(クライアント)・AI(Assistants)・バックエンド(Function App)で状態の持ち方がズレると、別ユーザーの状態を参照したり、同じ更新を二重実行したりする事故が起きやすくなります。

結論:thread.id を正規キーにして「ユーザー認証+永続ストア」で守る

まず押さえたい結論は、クライアントが自由に発行するセッションIDを“正”とするよりも、Assistants が発行する thread.id を会話の正規キーとして扱い、バックエンドで認証済みユーザーと紐づけて管理する方式が堅牢です。Microsoft Q&A でも、thread.id を会話IDとして用い、JWT/OAuth などの認証済みユーザーとマッピングし、Cosmos DB や Redis、Table Storage に状態を永続化し、競合を避けるための排他やバージョニングを入れるパターンが推奨されています。

要素推奨する役割設計の狙い
thread.id会話ごとの正規キー(サーバ側で発行・保存)会話コンテキストと1:1で紐づくIDを使い、識別のブレをなくす
ユーザー認証(JWT / OAuth)バックエンドへのアクセス権を証明thread.id の“持ち込み”だけで他人の状態を触れないようにする
永続ストア(Cosmos DB / Redis / Table など)業務状態・進捗・キャッシュを保存スケールアウトや再起動を跨いで状態を保持する
競合対策(ETag / バージョン / キュー)同一 thread の同時更新を安全に処理二重更新・取りこぼし・順序逆転を防ぐ

クライアント発行セッションID方式が抱えるリスク

「クライアントでセッションIDを作って、run instructions でエージェントに渡し、全ツール呼び出しで必須パラメータにする」方式は動作します。ただし、長期運用を考えると以下の弱点が出やすいです。

リスク起きやすい症状なぜ起きるか現実的な対策
IDのなりすまし他人の状態が参照・更新されるセッションIDを「知っている=権限がある」と扱うと破綻するthread.id とユーザーIDの紐づけをバックエンドで検証
プロンプト経由の漏えいセッションIDが回答文やログに混入instructions はモデル入力に近く、意図せず出力される可能性があるモデルに渡すべきでない識別子はバックエンドで保持
改ざん・注入ツール引数に異なるIDが混ざるユーザー入力や外部テキストで instructions が上書きされる設計だと危険ツール実行時に「認証ユーザーが許可された thread か」を再チェック
多端末・再ログインで破綻同一ユーザーの会話が分断、逆に混線するクライアント起点のID管理は端末間同期が難しいサーバ側で会話一覧(ユーザー→thread)を持つ

thread / run / tool call の関係を押さえる

設計を崩さないために、Assistants の基本概念を一度整理します。Azure OpenAI の Assistants では、スレッドが会話セッションを表し、スレッド上にメッセージが蓄積され、run がスレッド内容を使ってアシスタントを実行します。スレッドは履歴が長くなっても自動的に圧縮・調整されるため、クライアントがトークン数を毎回気にしなくてよいのが特徴です。

概念ひとことで状態管理で重要になる点
Thread会話セッションthread.id が会話の軸。ここをキーにバックエンド状態を引く
Messageユーザー/アシスタントの発言どの run の結果か(run_id)も追える。監査ログの粒度になる
Runスレッド内容に基づく実行実行中はスレッドがロックされ、新規メッセージ追加や新規 run 作成ができない
Tool call(function)外部処理の要求requires_action の tool_calls を受けて、アプリ側が実行し、結果を返す

run 中のスレッドロックを前提にする

実務でつまずきやすいのが「同じ thread に対して、run が走っている間は操作が制限される」点です。具体的には、run が完了していない状態では thread がロックされ、メッセージ追加や新しい run の開始ができません。つまり、クライアントが連打したり、並列で処理を投げたりすると、エラーや取りこぼしが起きるため、UI/バックエンドで順序制御が必要です。

「モデルに thread_id を渡させない」ほうが安全な理由

質問文では「すべてのツール呼び出しにセッションID(thread_id)を必須で含める」設計になっています。しかし、Assistants の function calling はモデルが勝手に外部APIを叩くのではなく、run の状態が requires_action になったときに、アプリ側が tool_calls を受け取って実行し、tool_call_id と結果をまとめて送る仕組みです。つまり、ツール実行の主体はアプリであり、アプリは run/thread のコンテキスト(thread_id を含む)をすでに知っています。thread_id をモデル入力(instructions)に混ぜてまで、モデルに“渡させる”必要は基本的にありません。

おすすめは、モデルが生成する引数は「業務に必要な入力(例:地域、商品コード、検索条件)」に絞り、thread_id や user_id のような権限に関わる情報は、バックエンドが信頼できるチャネルで付与して Function App に渡すことです。たとえば内部HTTP呼び出しなら、リクエストボディに thread_id を入れるのはアプリ側(オーケストレーター)で、モデルには見せません。これにより、プロンプト注入や出力漏えいに強くなります。

実装の全体フロー(推奨シーケンス)

ここからは、実装時に迷いがちなポイントを「実際の流れ」に落として整理します。ポイントは、クライアントが送るのは認証トークン+(あれば)thread_idまでで、状態更新の正当性は必ずサーバで検証することです。

ステップ誰が何をする失敗しやすい点対策
認証クライアント → Function AppJWT/OAuth を付けてAPIを呼ぶthread_id だけでアクセスできてしまう必ずトークン検証し、ユーザーIDを取り出す
会話IDの確定Function App初回は thread を作成し、以後は thread_id を再利用クライアント任せで thread_id が混線ユーザーID↔thread_id をDBで管理
メッセージ追加Function App → Assistantsthread にユーザー発言を追加run中のロックで追加に失敗runの状態を見て待つ/エラーをUIで吸収
run開始Function App → Assistantsthread_id で run を作成並列runで競合同一threadは直列化(キュー/ロック)
tool_calls処理Function Apprequires_action の tool_calls を実行二重実行・タイムアウトtool_call_id を冪等キーとして保存、期限内にまとめて返す
状態更新Function App → DB業務状態を更新レースで上書きETag/バージョンで楽観的ロック

実装例:Function App を「会話オーケストレーター」にする

Assistants の function calling は、ツール実行をアプリ側が担当し、結果を Assistants に返して run を先に進めるモデルです。そのため Function App は、AI と内部システムの間をつなぐオーケストレーターとして設計すると、責務が分かれて保守しやすくなります。下記は TypeScript 風の疑似コードです(実際の SDK・認証方式・例外処理に合わせて読み替えてください)。

export async function chat(req, context) {
  // 1) 認証(JWT/OAuth)を検証し userId を取得
  const userId = await verifyAndGetUserId(req.headers["authorization"]);

  // 2) threadId を確定(初回は作成、以後は userId から復元)
  const clientThreadId = req.body.thread_id; // 任意(クライアントは保持してよい)
  const threadId = await resolveThreadId({ userId, clientThreadId });

  // 3) 進行中 run がある場合の扱い(直列化)
  await waitUntilThreadUnlocked(threadId); // もしくは 409 を返してUIでリトライ

  // 4) ユーザーメッセージ追加
  await assistants.threads.messages.create({ thread_id: threadId, role: "user", content: req.body.text });

  // 5) run 開始
  let run = await assistants.threads.runs.create({ thread_id: threadId, assistant_id: getAssistantId() });

  // 6) run をポーリング(またはストリーミング)して完了まで進める
  while (run.status === "queued" || run.status === "in_progress") {
    await sleep(500);
    run = await assistants.threads.runs.retrieve({ thread_id: threadId, run_id: run.id });
  }

  // 7) ツール呼び出しが必要なら実行してまとめて返す
  if (run.status === "requires_action") {
    const toolOutputs = [];

    for (const call of run.required_action.submit_tool_outputs.tool_calls) {
      // 冪等キー:tool_call_id
      const cached = await toolLedger.get(call.id);
      if (cached) {
        toolOutputs.push({ tool_call_id: call.id, output: cached.output });
        continue;
      }

      // 重要:threadId / userId はモデル入力に頼らず、サーバが付与して内部ツールへ渡す
      const output = await internalTools.execute({
        functionName: call.function.name,
        argumentsJson: call.function.arguments,
        context: { userId, threadId, toolCallId: call.id }
      });

      // 状態更新(例:Cosmos DB を ETag 付きで更新)
      await updateThreadStateWithETag(threadId, (state) => applyDomainUpdate(state, output));

      await toolLedger.put({ tool_call_id: call.id, thread_id: threadId, output });

      toolOutputs.push({ tool_call_id: call.id, output });
    }

    run = await assistants.threads.runs.submit_tool_outputs_and_poll({
      thread_id: threadId,
      run_id: run.id,
      tool_outputs: toolOutputs
    });
  }

  // 8) 最終メッセージを返す
  const messages = await assistants.threads.messages.list({ thread_id: threadId, order: "desc", limit: 1 });
  return { thread_id: threadId, answer: messages.data[0] };
}

この構成にしておくと、内部ツール(Function App 内の別関数、あるいは別サービス)は「thread_id を受け取って状態を引く」だけで済み、AI 側のプロンプト設計と切り離せます。結果として、セキュリティレビューや障害解析の責務分離もやりやすくなります。

Function App 側のデータ設計(最小構成)

thread.id を中心に置く場合、最低限「ユーザー⇔thread の対応」と「thread ごとの業務状態」の2つを保存します。保存先は Cosmos DB / Redis / Table Storage などでよく、重要なのはスケールアウトしても整合性が崩れないことです。

エンティティ主キー例持たせたいフィールド例使いどころ
UserThreadMap(user_id, thread_id)created_at / last_active / assistant_id / tenant_idアクセス制御(この user がこの thread を使えるか)
ThreadStatethread_iddomain_state(JSON) / version / updated_at / etag業務状態(選択中の顧客ID、入力フォーム進捗など)
ToolCallLedgertool_call_idthread_id / function_name / input_hash / output / status冪等性(同じ tool_call を二度実行しない)

thread/message の metadata は「補助情報」として使う

Azure SDK のモデルでも、メッセージには metadata(最大16件のキー/値)を付与できます。検索や監査のために「社内の問い合わせ番号」「UIの画面ID」程度を入れるのは有効です。ただし、容量や件数に上限があり、ここに業務状態を詰め込むと破綻します。あくまでバックエンドの永続ストアを正とし、metadata は補助に留めるのが安全です。

楽観的ロックで“正しく上書き拒否”する(Cosmos DB 例)

同じ thread_id に対して、ツール呼び出しが連続したり、ユーザーが短時間に複数操作したりすると、バックエンド状態の更新が競合します。Cosmos DB では各アイテムに _etag が付き、If-Match ヘッダーで条件付き更新ができます。読み出したときの ETag と一致しない場合は更新が拒否され、HTTP 412 になります。これを利用すると、ロックをかけずに“古い状態での上書き”を防げます。

方式向いているケースメリット注意点
ETag(楽観的ロック)更新競合がたまに起きる性能が落ちにくい、実装が比較的シンプル412 時のリトライ設計が必須
Redis 分散ロック競合が頻発し、順序が重要直列化で分かりやすいロックの取り逃し・タイムアウト設計が必要
キュー(Service Bus など)で直列化ワークフロー型で順番が絶対スケールしても順序保証しやすい遅延が増える。設計が重くなりがち

ツール呼び出しを堅牢にするコツ(期限・冪等・まとめて返す)

function calling では、run が requires_action になり、required_action.submit_tool_outputs.tool_calls に呼び出すべき関数と引数が並びます。Azure のドキュメントでも、tool_call_id を参照して出力を紐づけ、複数の tool_outputs をまとめて送る流れが示されています。また run には有効期限があり、一定時間内に tool outputs を返さないと失効します。実装では次の3点を押さえると、運用が安定します。

  • tool_call_id を冪等キーにする:同じ tool_call を受け取ったら過去の結果を返す
  • tool_outputs はまとめて送る:並列で実行しても、返却は一括で行う
  • 期限を意識する:外部APIが遅い場合はタイムアウトと代替応答を用意する

認証とアクセス制御の設計ポイント

セキュリティ面で最重要なのは、thread.id を単体で信用しないことです。クライアントが thread_id を送ってきたとしても、それが「今ログインしているユーザーに属する thread か」をバックエンドの対応表で必ず確認します。これだけで、thread_id の推測や漏えいが起きた場合の被害を大幅に抑えられます。

また、Azure OpenAI 側の認証は API キー方式と Microsoft Entra ID(トークン)方式が提供されています。運用では、キーの配布・ローテーションが難しくなりがちなため、可能なら Entra ID のトークンベース認証(Authorization: Bearer)を選び、Function App から Azure OpenAI を呼び出すときも最小権限で管理するのが安全です。

通信経路推奨する認証なぜ
クライアント → Function AppJWT / OAuth(短命アクセストークン+更新)ユーザー単位の権限判定ができ、漏えい時の被害も限定できる
Function App → Azure OpenAIMicrosoft Entra ID(可能なら)キー管理より安全に運用でき、権限をIDベースで絞れる
Function App → 内部ツール(他API)Managed Identity / mTLS / サービス間トークンツール実行を社内境界に閉じ、外部からの直叩きを防ぐ

データ保持と削除まで設計に入れる

Assistants の Threads は「状態を持つ」機能です。Azure OpenAI の解説でも、Threads のような状態保持機能を使うと会話履歴などが Azure OpenAI リソース内に保存されることが示されています。つまり、状態同期を設計する際は「どこに何が保存されるか」「いつ消すか」を明確にしないと、監査や個人情報対応で後から詰みます。

  • thread をアプリ側で永続管理するなら、削除API(または運用手順)を用意する
  • 保存期間(例:最終アクセスから30日)を決め、期限到来で thread と対応する状態を削除する
  • ログには thread_id を記録しつつ、ユーザーの個人情報を直接書かない(必要ならマスキング)

新規開発なら「Foundry Agent Service」も視野に入れる

Azure 側のドキュメントでは、Microsoft Foundry Agent Service が一般提供され、より多くのツールやエンタープライズ向け機能を提供するため、最新の改善を取り込むなら新しいサービスの利用が推奨されています。既存の Assistants API を使い続ける場合でも、オーケストレーター+外部状態ストアの設計にしておくと、将来の選択肢が広がります。

将来の互換性も考える(Assistants API の動向)

設計を長く使うなら、API の将来も無視できません。OpenAI の公式ドキュメントでは、Assistants API は Responses API への移行方針が示され、Assistants API の提供終了日も明記されています。Azure 側の提供形態は別としても、「会話の正規キー+外部ストアで状態管理」という考え方は、API が変わっても移植しやすい土台になります。

すぐ使えるチェックリスト

項目やることやらないこと
会話IDthread.id を正規キーにし、DBで user_id と紐づけるクライアント発行IDを“権限”として扱う
ツール引数モデル引数は業務入力に限定し、thread_id はバックエンドで付与thread_id を instructions に埋め込んでモデルに保持させる
競合対策ETag/バージョンで更新競合を検知しリトライする最後に書いたもの勝ちで上書きする
冪等性tool_call_id を保存して二重実行を防ぐ外部APIをそのまま毎回叩く
セキュリティ短命トークン+thread マッピング検証、最小権限thread_id 単体を信用する

まとめ

  • Assistants × Function App では、会話コンテキスト(thread)と業務状態(DB)を分離して設計すると安定する
  • thread.id を会話の正規キーとして扱い、JWT/OAuth などの認証済みユーザーとバックエンドで紐づける
  • 状態は Cosmos DB / Redis などに永続化し、ETag などで競合を検知して安全に更新する
  • ツール実行は tool_call_id を軸に冪等化し、期限内にまとめて tool_outputs を返す
  • データ保持・削除・監査ログまで含めて設計すると、運用で詰まない

この記事を書いた人

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

コメント

コメントする

目次