Azure AI Agents を Teams などのチャネルに組み込むと、ユーザーIDや位置情報など「会話に紐づく状態」を後続のツール処理で参照したくなります。本記事では、スレッド/メッセージの metadata を使って状態を保持し、Python ツールや OpenAPI 連携に安全に引き回す実装パターンを解説します。
Azure AI Agents の「スレッド」「メッセージ」「実行(Run)」をまず整理する
Azure AI Agents では、会話(あるいはタスク実行の文脈)を スレッド(Thread)として持ち、スレッドの中に メッセージ(Message)を追加していきます。実際にエージェントを動かすタイミングでは Run を開始し、エージェントがスレッド上のメッセージ履歴をもとに推論し、必要に応じてツールを呼び出し、結果(応答メッセージ)をスレッドに追記します。
この設計は「会話履歴を溜める」ことに強い一方で、Teams 連携などでは次のような“会話の外側の情報”も扱いたくなります。
- ユーザーの識別子(Teams / Entra ID のユーザーID、テナントIDなど)
- チャネル(Teams / Web / LINE など)、組織、部署、利用プラン
- ユーザーの概算位置(国・地域、店舗エリア、タイムゾーン)
- 監査・運用のための相関ID(request_id / trace_id)
これらは「LLM にそのまま渡したい情報」ではなく、ツール側の処理(権限判定、検索の絞り込み、宛先リージョン選択、監査ログ)で使いたい情報であることが多いはずです。そこで役立つのが、Azure AI Agents が提供する metadata です。
結論:スレッド/メッセージの metadata を“状態ストア”として使う
Azure AI Agents では、スレッドやメッセージ、さらに Run に対しても任意のキー/値ペアを metadata として付与できます。metadata は「追加情報を構造化して持たせるための領域」で、最大16個のキー/値を保持でき、キーは最大64文字、値は最大512文字です。
LangGraph の state のように「自動でノード(ツール)へ注入される」仕組みを期待するとギャップがありますが、metadata を 会話ID(thread_id)に紐づく小さなキーバリューストアとして扱うと実装が安定します。
| 保存先 | 向いている情報 | 例 | 使いどころ |
|---|---|---|---|
| スレッド metadata | 会話全体で共通の“固定状態” | user_id / tenant_id / plan / locale | 権限、テナント切替、ユーザー紐づけ、継続セッション |
| メッセージ metadata | その発話(ターン)固有の“可変状態” | geo(その時点の概算位置)/ client_version / channel_message_id | 「その瞬間」の入力条件での検索・分岐 |
| Run metadata | 実行単位での運用・観測用の情報 | request_id / trace_id / scenario | 監査ログ、トレース、再実行時の突合 |
Python でスレッド作成時にユーザーIDと位置情報を保存する
Azure AI Agents の Python SDK(azure-ai-agents)では、AgentsClient を作成し、threads.create に metadata を渡すだけでスレッドに状態を紐づけられます。
クライアントの初期化(最小)
import os
from azure.ai.agents import AgentsClient
from azure.identity import DefaultAzureCredential
agents_client = AgentsClient(
endpoint=os.environ["PROJECT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
スレッド作成時に metadata を付与
Teams などの受信イベントから取り出した「ユーザー識別子」「概算位置(国・地域)」を、スレッド metadata として保存します。値は後から取り出してツールに渡すため、文字列として扱える形に揃えておくのがコツです。
thread = agents_client.threads.create(
metadata={
"user_id": "42",
"tenant_id": "contoso.onmicrosoft.com",
"geo": "DE-Berlin",
"channel": "teams",
"locale": "ja-JP",
}
)
print(thread.id)
位置情報を「緯度経度の生値」で入れると個人情報になりやすく、512文字制限もあるため、まずは 国・地域コード/拠点コード/タイムゾーンのような粒度に落とすと運用しやすいです。
ターン固有の情報はメッセージ metadata に入れる
「この発話の時点の位置」や「クライアントアプリのバージョン」など、ターンごとに変わる情報はメッセージに付与します。Python SDK の messages.create でも metadata を渡せます。
message = agents_client.messages.create(
thread_id=thread.id,
role="user",
content="近くの店舗の営業時間を教えて",
metadata={
"geo": "DE-Berlin",
"client_version": "teams-1.0.12",
"channel_message_id": "19:[email protected]",
}
)
Run 開始時に相関IDを付けておく(運用・監査が一気に楽になる)
同じ thread_id の会話でも、実行(Run)は何度も発生します。障害調査や監査の観点では「この応答を生成した Run はどれか」を追えるようにしておくと強力です。Run も metadata を持てるため、request_id / trace_id / scenario のような“運用のための状態”は Run metadata に寄せるのがおすすめです。
# agent は事前に create_agent したものを想定
run = agents_client.runs.create(
thread_id=thread.id,
agent_id=agent.id,
metadata={
"request_id": "20251222-000123",
"scenario": "store_hours_lookup",
}
)
さらに、スレッド作成と Run 開始をまとめて行いたい場合は、create_thread_and_process_run のような“まとめ呼び出し”も利用できます。スレッド側(thread options)と Run 側の両方に metadata を付与できるので、初期状態の付け忘れ防止にもなります。
from azure.ai.agents.models import AgentThreadCreationOptions, ThreadMessageOptions
thread_options = AgentThreadCreationOptions(
metadata={"user_id": "42", "geo": "DE-Berlin", "channel": "teams"},
messages=[ThreadMessageOptions(role="user", content="近くの店舗の営業時間を教えて")],
)
run = agents_client.create_thread_and_process_run(
agent_id=agent.id,
thread=thread_options,
metadata={"request_id": "20251222-000124"},
)
重要:metadata はモデルにもツールにも自動では渡らない
ここが最もハマりやすいポイントです。Azure AI Agents の metadata は、LLM のプロンプトに自動で埋め込まれたり、ツールの引数に自動注入されたりはしません。「アプリ側が取得して、必要な場所へ明示的に渡す」のが基本です。
つまり、LangGraph の state のような「状態が勝手にツールに流れてくる」設計ではなく、次の流れを自分で組み立てます。
- thread_id をキーにスレッドを取得する
- thread.metadata(必要なら直近メッセージの metadata)を読む
- ツールの処理に渡す(関数引数、HTTPヘッダー、クエリ、ボディなど)
ツールで metadata を使う実装パターン(Python 関数ツール)
ツール連携を安定させるためにおすすめなのは、「metadata → RequestContext に変換」してからビジネスロジックに渡す形です。ツールの数が増えても、権限・地域・監査の処理を共通化できます。
RequestContext を作って“状態”を一本化する
from dataclasses import dataclass
from typing import Optional, Dict
@dataclass(frozen=True)
class RequestContext:
thread_id: str
user_id: str
tenant_id: Optional[str] = None
geo: Optional[str] = None
channel: Optional[str] = None
locale: Optional[str] = None
raw: Optional[Dict[str, str]] = None # 追跡やデバッグ用
def build_context(agents_client, thread_id: str) -> RequestContext:
thread = agents_client.threads.get(thread_id) # スレッドを取り直す
md = dict(thread.metadata or {})
return RequestContext(
thread_id=thread_id,
user_id=md.get("user_id", ""),
tenant_id=md.get("tenant_id"),
geo=md.get("geo"),
channel=md.get("channel"),
locale=md.get("locale"),
raw=md,
)
ツール処理側は、RequestContext を受け取れるようにしておきます。たとえば「近くの店舗検索」は geo を使って検索範囲や対象リージョンを変えられます。
def find_nearby_store(ctx: RequestContext, keyword: str) -> dict:
# 例:geo を使って検索対象を切り替える(実際はDB/検索APIへ)
region = ctx.geo or "unknown"
# 例:ユーザー単位の権限チェック
if not ctx.user_id:
return {"error": "user_id is missing"}
return {
"region": region,
"keyword": keyword,
"stores": [
{"name": "Contoso Berlin Center", "open": "10:00-20:00"},
{"name": "Contoso Berlin East", "open": "11:00-19:00"},
],
}
ツール呼び出しの直前に metadata を“注入”する
エージェントがツールを呼ぶたびに、あなたのアプリ(ツール実行コード)が thread_id を把握しているはずです。そこで、ツールを実行する直前に build_context() を呼んで state を注入します。
def dispatch_tool_call(agents_client, thread_id: str, tool_name: str, tool_args: dict) -> dict:
ctx = build_context(agents_client, thread_id)
if tool_name == "find_nearby_store":
return find_nearby_store(ctx, keyword=tool_args.get("keyword", ""))
raise ValueError(f"Unknown tool: {tool_name}")
この「ディスパッチ層」を置くと、ツールが増えても state の取得・検証・監査ログ出力を共通化できます。特に運用で効いてくるのが次の2点です。
- 監査ログ: tool 呼び出しのたびに user_id / tenant_id / thread_id を揃えて記録できる
- 権限: ユーザーに許可されないツール呼び出しを、LLM の前にアプリ側で遮断できる
OpenAPI ツールで metadata を使うときの現実的な選択肢
OpenAPI ツールは便利ですが、metadata が自動注入されない以上、「ユーザーIDや位置情報を API 呼び出しに含めたい」という要求は設計を選びます。結論から言うと、次のどれかです。
| 方式 | 概要 | メリット | 注意点 |
|---|---|---|---|
| アプリが API を呼ぶ(推奨) | エージェントには Python 関数ツールだけを公開し、その中で外部 API を呼び出す | metadata を安全に注入できる/署名・認可も一元化できる | 関数ツールの実装と運用が必要 |
| API 側がトークンからユーザーを特定 | OpenAPI ツールの呼び出しはユーザーの認証情報(OBO 等)に寄せ、API が user_id を解決 | LLM に user_id を渡さずに済む | 認証フロー設計が必要(Teams/Entra の構成) |
| モデルに必要最小限の値を渡す | system instructions や “コンテキストメッセージ”として geo 等をテキストで渡し、モデルに引数として使わせる | 実装が最短 | 情報がモデル入力に入る/PII 取り扱いに注意 |
「LLM に user_id を見せたくない」「勝手に別ユーザーのIDを作られたら困る」という要件がある場合は、OpenAPI ツールを直接公開するよりも、アプリ側が API 呼び出しを代行するパターンが堅いです。
metadata 設計のコツ:小さく・安定させる・参照キーにする
metadata は便利ですが、16キー/512文字という制約があるため、最初に設計しておくと後で崩れません。
おすすめのキー設計例
| キー | 保存先 | 例 | 意図 |
|---|---|---|---|
| user_id | スレッド | 42(またはハッシュ) | ツール側の権限・パーソナライズの軸 |
| tenant_id | スレッド | contoso.onmicrosoft.com | マルチテナント切替、データ分離 |
| geo | スレッド/メッセージ | JP-Tokyo / DE-Berlin | リージョン選択、店舗・拠点の絞り込み |
| locale | スレッド | ja-JP | 応答言語や単位系の決定(必要なら) |
| state_ref | スレッド | cosmos:threads/{thread_id} | 大きい状態は外部ストアへ逃がす参照キー |
| request_id | Run | 20251222-000123 | 監査ログ・トレースの突合 |
512文字を超える“状態”は外部ストアに置く
ユーザー設定、権限セット、検索用フィルタ、会話の要約など「サイズが読めない状態」は、metadata に詰め込むよりも 外部ストア(Cosmos DB / Redis / Table Storage)に置き、metadata には参照キー(state_ref)だけを持たせるほうが安全です。実際、SDK/バックエンドの挙動差や、取得できないケースに備えて外部ストアを推奨する案内もあります。
最近は Azure AI Foundry 側でも「Bring Your Own(BYO)Thread Storage」のように、スレッド状態(メッセージ、結果、metadata など)を自前ストレージで管理する考え方も紹介されています。規制業界やデータ主権要件が強い場合は、この方向性も検討価値があります。
後から metadata を更新する(ユーザーの位置が変わるケース)
位置情報や状態が変わる場合、スレッド metadata を更新して“最新値”として持たせることもできます。Python SDK には threads.update があり、metadata の更新が可能です。
updated = agents_client.threads.update(
thread_id=thread.id,
metadata={
"user_id": "42",
"tenant_id": "contoso.onmicrosoft.com",
"geo": "DE-Hamburg", # 位置が変わった
"channel": "teams",
"locale": "ja-JP",
}
)
ただし、スレッド metadata は「会話の固定状態」に向く反面、頻繁に書き換えると運用上の追跡が難しくなります。「最新値はスレッド」「その時点の値はメッセージ」の二層構造にすると、後で監査や不具合調査が楽になります。
よくある落とし穴と対策
metadata を付けたのに、後で取得できない
典型は「取得したオブジェクトに後から代入しただけ」で、サーバー側には保存されていないケースです。metadata は 作成/更新のリクエストで明示的に渡す必要があります。
- スレッド作成:
threads.create(metadata={...}) - メッセージ作成:
messages.create(..., metadata={...}) - スレッド更新:
threads.update(..., metadata={...})
“状態”をモデルに反映できていない
metadata はモデルに自動で渡らないため、モデルの判断に影響させたい場合(例:地域によって返答ルールを変える、言語を固定する)は、次のいずれかが必要です。
- Run の
instructions/additional_instructionsを使ってコンテキストを明示する - 会話開始時に「コンテキストメッセージ」をスレッドに入れる(ただしモデル入力になる)
- コンテキスト取得専用のツール(get_context など)を用意し、必要時に呼ばせる
ただし、どれも「モデルに見える」方向へ寄るため、個人情報の扱いとトレードオフになります。
PII/秘密情報を入れてしまう
metadata は便利な一方で、ログやトレース、運用ツールから参照される可能性があります。次のような値は入れないほうが無難です。
| 入れない方がよいもの | 理由 | 代替 |
|---|---|---|
| アクセストークン、APIキー | 漏洩時の影響が大きい | Key Vault、サーバー側の秘密管理 |
| 緯度経度の生値、住所 | 個人情報になりやすい | 国・都市・拠点コード、タイムゾーン |
| メールアドレスや電話番号の平文 | PII そのもの | ハッシュ化した user_key、内部ID |
LangGraph の state と比べたときの考え方(ギャップを埋める)
LangGraph は「state を各ノードへ渡し、ノードが state を更新しながらグラフが進む」ため、状態管理がフレームワーク中心です。一方、Azure AI Agents は「スレッドにメッセージ履歴が溜まり、Run がそれを読んで動く」ため、状態管理は アプリ(あなたのコード)中心になります。
| 観点 | LangGraph 的な state | Azure AI Agents の metadata |
|---|---|---|
| 受け渡し | ノードに自動で流れる | アプリが取得して渡す |
| サイズ | 比較的自由(実装次第) | 小さい(16キー/512文字) |
| モデルへの可視性 | 設計次第(state をプロンプト化することが多い) | 標準では不可視(自動では渡らない) |
| 運用 | アプリのメモリ/DB/ログと統合 | スレッドと一緒に保持しやすい(ただし小さい) |
「LangGraph のように state を扱いたい」場合は、次の折衷案が現実的です。
- metadata は“ポインタ”にする: 重要な state は外部ストアへ、metadata は参照キーだけ保持
- ContextResolver を必ず通す: ツールは必ず
RequestContextを受け取る設計に統一 - 監査・トレースを先に設計: run metadata に request_id を入れて追跡可能にする(Run も metadata を持てる)
実装チェックリスト(そのまま運用に持ち込める形)
| チェック項目 | OK の目安 |
|---|---|
| スレッド作成時に user_id / tenant_id を metadata に入れている | thread_id だけでユーザーに紐づく |
| ターン固有の値はメッセージ metadata に入れている | 後から「その時点の条件」を再現できる |
| ツール実行前に thread を get して metadata を取得している | ツールが state を前提に動ける |
| metadata に秘密情報・生の個人情報を入れていない | 漏洩時の影響を最小化できる |
| 大きい state は外部ストアに逃がし、metadata は参照キー | 上限や挙動差で詰まらない |
まとめ
- Azure AI Agents で「ユーザーIDや位置情報などの状態」を持たせるなら、まずは スレッド/メッセージの metadata が最短ルート
- metadata は モデルやツールに自動注入されないため、アプリ側で「取得→コンテキスト化→ツールへ渡す」流れを作る
- スレッド metadata は固定状態、メッセージ metadata はターン状態、Run metadata は運用状態と役割分担すると破綻しにくい
- 制限(16キー/512文字)を超える可能性がある state は外部ストアへ。metadata は参照キーとして使う

コメント