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 App | JWT/OAuth を付けてAPIを呼ぶ | thread_id だけでアクセスできてしまう | 必ずトークン検証し、ユーザーIDを取り出す |
| 会話IDの確定 | Function App | 初回は thread を作成し、以後は thread_id を再利用 | クライアント任せで thread_id が混線 | ユーザーID↔thread_id をDBで管理 |
| メッセージ追加 | Function App → Assistants | thread にユーザー発言を追加 | run中のロックで追加に失敗 | runの状態を見て待つ/エラーをUIで吸収 |
| run開始 | Function App → Assistants | thread_id で run を作成 | 並列runで競合 | 同一threadは直列化(キュー/ロック) |
| tool_calls処理 | Function App | requires_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 を使えるか) |
| ThreadState | thread_id | domain_state(JSON) / version / updated_at / etag | 業務状態(選択中の顧客ID、入力フォーム進捗など) |
| ToolCallLedger | tool_call_id | thread_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 App | JWT / OAuth(短命アクセストークン+更新) | ユーザー単位の権限判定ができ、漏えい時の被害も限定できる |
| Function App → Azure OpenAI | Microsoft 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 が変わっても移植しやすい土台になります。
すぐ使えるチェックリスト
| 項目 | やること | やらないこと |
|---|---|---|
| 会話ID | thread.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 を返す
- データ保持・削除・監査ログまで含めて設計すると、運用で詰まない

コメント