Azure AI AgentsのスレッドmetadataでユーザーID・位置情報を保持する方法|Pythonで状態管理

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 のような「状態が勝手にツールに流れてくる」設計ではなく、次の流れを自分で組み立てます。

  1. thread_id をキーにスレッドを取得する
  2. thread.metadata(必要なら直近メッセージの metadata)を読む
  3. ツールの処理に渡す(関数引数、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_idRun20251222-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 的な stateAzure 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 は参照キーとして使う

この記事を書いた人

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

コメント

コメントする

目次