1 つのホストされたエージェント セッションで複数のユーザーを多重化する

既定では、「ユーザーごとにホストされるエージェント セッションを分離する」で説明されているように、各呼び出し元 は独自のホストされたエージェント セッションを取得します。 Teams ボット、ISV ゲートウェイ、またはカスタマー サポート プラットフォームなど、多くのユーザーにサービスを提供するアプリケーションでは、ユーザーごとに 1 つのセッションは必要ありません。 代わりに、中間層サービスは、多くのユーザーを共有セッションの境界付きプールにマップし、すべての呼び出しで各ユーザーを識別します。

この記事では、各ユーザーのデータを共有セッション内で分離したまま、中間層のユーザー間でセッションをプールする方法について説明します。

プラットフォームは、ユーザーがセッションを共有する場合でも、会話の状態を分離します。1 人のユーザーが作成した応答チェーンを別のユーザーが previous_response_id経由で続行することはできません。 context.get_history() は、現在の要求のユーザーが表示する権限を持つ履歴のみを返します。 管理すべきものは 2 つあります。1 つはミドルティアでのユーザーとセッションの対応付け、もう 1 つは、そのプラットフォームで管理される会話状態を除き、コンテナー 自体 が保存するあらゆるデータ(ファイル、行、またはキャッシュ)のパーティション分割です。

完全で実行可能な セッション多重化のサンプル では、中間層セッション プールとコンテナー ハンドラーの両方を示しています。この記事では、そのファイルをリンクします。

前提条件

  • コンテナー プロトコル バージョン 2.0.0 を使用するホステッド エージェント。 アップグレードするには、 ホストされているエージェントの移行に関するページを参照してください。
  • ミドルティア サービスの ID に割り当てられた Microsoft.CognitiveServices/accounts/AIServices/agents/endpoints/UserIdentityImpersonation/action アクセス許可。 このアクセス許可は、組み込みロールには含まれません。カスタム ロールを使用して許可する — エンド ユーザー ID の委任に関する参照。 これを指定しないと、 x-ms-user-identity ヘッダーは 403で拒否されます。
  • 中間層用の Azure AI Projects クライアント ライブラリと、コンテナー用の Azure AI AgentServer SDK (Python の場合は azure-ai-agentserver-core 2.0.0b7 以降、.NET の場合は Azure.AI.AgentServer.Core 1.0.0-beta.26 以降)。
  • テスト対象となるデプロイ済みエージェント。 ローカル実行では分離は適用されません。

共有セッションで 2 人のユーザーを分離する

まずは中核となる挙動から見ていきましょう。サンプルで代理対象となるユーザーである 2 人のユーザー、Alice と Bob は、1 つの agent_session_id を共有できます。それでも、プラットフォームは各ユーザーの会話を個別にプライベートなまま保ちます。 中間層は、各呼び出しで x-ms-user-identity ヘッダー(委任)を使用して、代理対象ユーザーを識別します。 ユーザー自身の会話を続けるために、そのユーザーの以前の応答が previous_response_idとして渡されます。

サンプル内の最小限の invoke_previous_response_isolation.py 呼び出し側は、SDK のエージェントにバインドされた Responses クライアントを使用して、まさにそれを送信します。

# Agent-bound Responses client from the Foundry SDK.
responses_client = project_client.get_openai_client(agent_name=agent_name).responses

# Target the shared session with agent_session_id, and identify the acted-for
# user with x-ms-user-identity (delegation). Pass previous_response_id to
# continue this user's own chain. Don't send x-agent-user-id; Foundry sets the
# container-side request context after it resolves the user.
kwargs = {
    "input": user_message,
    "stream": False,
    "store": True,
    "extra_body": {"agent_session_id": session_id},
    "extra_headers": {"x-ms-user-identity": user_id},
}
if previous_response_id:
    kwargs["previous_response_id"] = previous_response_id

response = responses_client.create(**kwargs)

プラットフォームは、各応答チェーンを作成したユーザーに結び付けます。 Bob が同じセッションに座っている間に Alice の previous_response_id を送信すると、呼び出しは失敗します。Bob は Alice の会話を続行できません。 その保証は、コンテナー内の余分な分離コードなしで保持されます。

セッション プールを使用して多数のユーザーにスケーリングする

1 つのセッションで 2 人のユーザーを分離することは、構成要素です。 多くのユーザーにサービスを提供するには、ユーザーごとに 1 つのセッションを開くのではなく、境界のある一連のセッションにプールします。

各セッションは、ターンをアクティブに処理している間、 リージョンの同時セッション制限 に対してカウントされるため、ユーザーごとに 1 つのセッションはスケーリングされません。 ユーザーはターン間で読み取り、考え、入力するため、ピーク時の同時要求は通常、ユーザーの合計数のごく一部です。 プールのサイズをそのピークに設定し、各ユーザーをセッションにマップし、前のセクションとまったく同じように、すべての呼び出しでそのユーザーの ID を渡します。

ユーザーをセッションにマップする方法を決定します。 一般的な戦略は次のとおりです。

  • スティッキー、最小負荷。 戻るユーザーはセッションを再利用します。新しいユーザーは、最も読み込まれていないセッションに移動します。 この戦略では、負荷が均等に分散され、ユーザーのターンが一緒に維持されます。 セッションがユーザーごとの上限に達したときにプールを拡張します。
  • ハッシュベース。 hash(user_id) % pool_sizeを使用してセッションを割り当てます。 この戦略はシンプルでステートレスですが、負荷に偏りが生じる可能性があり、プールのサイズ変更によりユーザーの割り当てが再シャッフルされます。
  • ラウンドロビン。 要求をプール全体に均等に分散します。 この戦略は単純ですが、ユーザーのターンは異なるセッションに配置できます。
  • グループベース。 関連するユーザーがセッションを共有するように、テナント、チーム、またはリージョンごとにルーティングします。 この戦略は、グループ内のユーザーがコンテキストを共有する場合に便利です。

サンプルの invoke_session_pool.py 呼び出し元は、 sticky-fillround-robinの 2 つの戦略を持つ呼び出し元所有の割り当てを実装します。 戻るユーザーは常にセッションを保持します。選択した戦略によって新しいユーザーが配置されます。 スティッキーフィルパスは、最も負荷の低いセッションを埋め、すべてのセッションが容量上限に達した場合にのみ新しいセッションを開きます。

def get_session_for_user(self, user_id: str) -> str:
    if user_id in self.user_to_session:
        return self.user_to_session[user_id]      # returning user is sticky
    session_id = self._next_fill_session()        # new user: place by strategy
    self.user_to_session[user_id] = session_id
    self.session_user_counts[session_id] += 1
    return session_id

def _next_fill_session(self) -> str:
    # Reuse a session with capacity; open a new one only when all are full.
    session_id = next(
        (s for s, count in self.session_user_counts.items()
         if count < self.max_users_per_session),
        None,
    )
    if session_id is None:
        session_id = self._session_name(len(self.session_user_counts))
        self.session_user_counts[session_id] = 0
    return session_id

返されたセッション ID を、前に示したのと同じ委任された呼び出しにフィードします。agent_session_idではextra_bodyになり、x-ms-user-identityはユーザーごとの識別子のままです。

コンテナー内の要求を処理する

プロトコル 2.0.0 では、プラットフォームは acted-for ユーザーを解決し、 get_request_context()を介してハンドラーに公開します。 そのコンテキストを検証し、存在しない場合は安全のため失敗として扱い(ローカル実行時など)、そのうえでプラットフォームが context.get_history() でユーザーごとの履歴を返せるようにします。 サンプルの main.py ハンドラーは、それ自体の会話状態を保持しません。

from azure.ai.agentserver.core import get_request_context

@app.response_handler
async def handler(request, context, _cancellation_signal):
    ctx = get_request_context()
    if not (ctx.user_id and ctx.call_id):
        # Hosted protocol 2.0.0 populates this context; off-platform it's absent.
        raise ValueError("A user context is required on protocol 2.0.0.")

    user_input = await context.get_input_text() or "Hello!"
    history = await context.get_history()       # platform-authorized for this user
    input_items = _build_input(user_input, history)

    response = _responses_client.create(model=_model, input=input_items, store=False)
    return TextResponse(context, request, text=response.output_text)

プラットフォームは要求ごとに context.get_history() を承認するため、共有セッションのユーザーは別のユーザーの会話履歴を受け取ることはありません。

コンテナーが格納するユーザーごとのデータをパーティション分割する

プラットフォームによって会話履歴が分離されます。 コンテナーに独自のデータ (ファイル、データベース行、キャッシュ ) も 格納されている場合、そのデータは自動的にパーティション分割されません。 同じセッション内の 2 人のユーザーが互いのデータを表示できないように、セッション ID とユーザー ID の両方でキーを設定します。

partition = (agent_session_id, user_id)

Warning

ユーザーがセッションを共有する場合、プラットフォームはコンテナー自体を格納するデータをパーティション分割しません。 コンテナーがセッション ID だけでそのデータをキー設定する場合、プール内のすべてのユーザーに同じデータが表示されます。 パーティション キーには常にユーザー ID を含めます。

要求ごとのプラットフォーム コンテキストからユーザー ID を読み取る:

from azure.ai.agentserver.core import get_request_context

def partition_key() -> tuple[str, str]:
    ctx = get_request_context()
    if not ctx or not ctx.user_id:
        raise PermissionError("A user context is required on protocol 2.0.0.")
    return (ctx.session_id, ctx.user_id)   # key all user-owned data by this

プラットフォームは、ユーザー情報を x-agent-user-id リクエストヘッダーとして付加します。 ランタイムで SDK コンテキストが使用されていない場合は、このヘッダーを直接読み取ります。

プラットフォームはプロトコル 2.0.0 で get_request_context().user_id を設定します。 複数のユーザーがセッションに参加できる場合は、ユーザー所有のデータにセッション ID のみを使用しないでください。

構築するセッションごとのストレージの作業例については、 ノート作成エージェントのサンプルを参照してください。 $HOME のセッションごとに 1 つのファイルにキーを設定します。 共有セッションの場合は、各ユーザーが独自のパーティションを取得できるように、要求コンテキストからユーザー ID でそのキーを拡張します。

分離を確認する

サンプルの A-A-B テスト ( invoke_previous_response_isolation.py) で保証を確認します。 2 人の異なるユーザーを使用して、デプロイされたエージェントに対して実行します (サンプルの既定値は Alice と Bob)。

  1. Alice として、共有セッションで応答を作成し、その idをキャプチャします。
  2. Alice として、同じセッションで 2 つ目の応答を作成し、 previous_response_id 最初の応答の idに設定し、その idをキャプチャします。
  3. Bob として、同じセッションで、previous_response_id を Alice の 2 番目の応答に設定したリクエストを送信します。 通話は失敗します - Bob は Alice の通話を引き継げません。

2 つの異なる Entra ユーザーまたはオブジェクト ID を使用します。 同じ ID に解決される 2 つのラベルは、有効なクロスユーザー テストではありません。

プロトコル 2.0.0 パスでレガシ分離ヘッダーを送信すると、そのモデルがプラットフォーム ユーザー コンテキストに置き換えられるため、エラーが返されます。