Обслуживание нескольких пользователей в одном сеансе размещённого агента

По умолчанию каждая вызывающая сторона получает собственный сеанс размещённого агента, как описано в разделе Изолировать сеансы размещённого агента для каждого пользователя. Приложения, обслуживающие многих пользователей — бот Teams, шлюз isV или платформу поддержки клиентов, не требуют одного сеанса для каждого пользователя. Вместо этого служба среднего уровня сопоставляет многих пользователей с ограниченным пулом общих сеансов и определяет каждого пользователя по каждому вызову.

В этой статье показано, как организовать пул сеансов для пользователей на уровне среднего слоя приложения, при этом данные каждого пользователя остаются изолированными в рамках общего сеанса.

Платформа изолирует состояние беседы для каждого пользователя, даже если пользователи используют один и тот же сеанс: цепочку ответов, которую создал один пользователь, другой пользователь не может продолжить через previous_response_id, а context.get_history() возвращает только ту историю, которую пользователь текущего запроса имеет право видеть. В вашей зоне ответственности находятся две вещи: сопоставление пользователя с сеансом в вашем промежуточном слое и разбиение любых данных, которые контейнер хранит сам (файлы, записи или кэш), помимо этого управляемого платформой состояния сеанса.

Полностью работоспособный пример мультиплексирования сеансов демонстрирует обе стороны: пул сеансов промежуточного уровня и обработчик контейнера, а в этой статье по ходу изложения приводятся ссылки на его файлы.

Prerequisites

  • Размещенный агент, использующий протокол контейнера версии 2.0.0. Чтобы выполнить обновление, см. раздел Миграция размещенных агентов.
  • Разрешение Microsoft.CognitiveServices/accounts/AIServices/agents/endpoints/UserIdentityImpersonation/action, назначенное удостоверению вашей службы промежуточного уровня. Это разрешение не входит во встроенные роли; предоставьте его через настраиваемую роль — см. раздел Делегирование идентичности конечного пользователя. Без него заголовок x-ms-user-identity отклоняется с сообщением 403.
  • Клиентская библиотека Azure AI Projects для промежуточного уровня и Azure AI AgentServer SDK для контейнера (azure-ai-agentserver-core 2.0.0b7+ для Python или Azure.AI.AgentServer.Core 1.0.0-beta.26+ для .NET).
  • Развернутый агент для проведения тестирования. Изоляция не применяется для локальных запусков.

Изоляция двух пользователей в общем сеансе

Начните с основного принципа работы: два пользователя — назовём их Алисой и Бобом, пользователи, от имени которых выполняются действия в примере, — могут совместно использовать один agent_session_id, и при этом платформа по-прежнему сохраняет конфиденциальность разговора каждого пользователя. Промежуточный уровень определяет пользователя, от имени которого выполняется каждый вызов, с помощью заголовка x-ms-user-identity (делегирование). Чтобы продолжить разговор пользователя, его предыдущий ответ передаётся в качестве previous_response_id.

Чтобы отправить тот же заголовок удостоверения из интерфейса командной строки разработчика Azure, используйте azd ai agent invoke --user-identity. См. передачу удостоверения конечного пользователя, чтобы увидеть примеры и описание того, как выбрать состояние сеанса и беседы пользователя.

Минимальный клиент invoke_previous_response_isolation.py в примере отправляет именно это, используя клиент Responses пакета SDK, привязанный к агенту:

# 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)

Платформа связывает каждую цепочку ответов с пользователем, создавшим его. Если Боб отправляет previous_response_id Алисы, находясь в том же сеансе, вызов завершается ошибкой — Боб не может продолжить разговор, начатый Алисой. Это гарантирует отсутствие дополнительного кода изоляции в контейнере.

Масштабирование до многих пользователей с пулом сеансов

Изоляция двух пользователей в рамках одного сеанса — это базовый элемент. Чтобы обслуживать многих пользователей, объедините их через ограниченный набор сеансов вместо открытия одного сеанса на пользователя.

Каждый сеанс засчитывается в региональные лимиты на количество одновременных сеансов, пока активно обрабатывает запрос, поэтому модель «один сеанс на пользователя» не масштабируется. Поскольку пользователи читают, думают и вводят текст между сообщениями, пиковое число одновременных запросов обычно составляет лишь небольшую долю от общего числа пользователей. Настройте размер пула в соответствии с этим пиковым значением, затем сопоставьте каждого пользователя с сеансом в этом пуле и передавайте идентификатор пользователя при каждом вызове, точно как в предыдущем разделе.

Решите, как сопоставить пользователей с сеансами. К общим стратегиям относятся:

  • Липкий, наименее загруженный. Возвращающий пользователь повторно использует сеанс; новые пользователи отправляются в наименее загруженный сеанс. Эта стратегия равномерно распределяет нагрузку и сохраняет все реплики пользователя вместе. Увеличивайте пул, когда число сеансов достигает лимита на пользователя.
  • На основе хеша. Назначьте сеанс с hash(user_id) % pool_size. Эта стратегия проста и не хранит состояние, но нагрузка может распределяться неравномерно, а при изменении размера пула пользователи перераспределяются.
  • По кругу. Равномерно распределяйте запросы по пулу. Эта стратегия проста, но реплики пользователя могут попадать в разные сеансы.
  • На базе групп. Маршрутизация по клиенту, команде или региону, чтобы связанные пользователи поделились сеансами. Эта стратегия полезна, когда пользователи в группе имеют общий контекст.

Компонент invoke_session_pool.py caller в примере реализует назначение, управляемое вызывающей стороной, с двумя стратегиями: sticky-fill и round-robin. Возвращающийся пользователь всегда сохраняет свой сеанс; Новый пользователь помещается выбранной стратегией. Алгоритм sticky-fill заполняет наименее загруженный сеанс и открывает новый только тогда, когда каждый сеанс заполнен до предела:

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

Передайте возвращённый идентификатор сеанса в тот же делегированный вызов, показанный ранее: он становится agent_session_id в extra_body, а x-ms-user-identity остаётся идентификатором для каждого пользователя.

Обработайте запрос в своем контейнере

В протоколе 2.0.0 платформа определяет пользователя, от имени которого выполняется действие, и передаёт его вашему обработчику через 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() для каждого запроса, пользователь в совместно используемом сеансе никогда не получает историю переписки другого пользователя.

Разделяйте данные отдельных пользователей, которые хранит ваш контейнер

Платформа изолированно хранит вашу историю разговоров. Если контейнер также хранит собственные данные — файлы, строки базы данных или кэш, то эти данные не секционируются автоматически. Используйте в качестве ключа одновременно идентификатор сессии и идентификатор пользователя, чтобы два пользователя в одной сессии не могли видеть данные друг друга:

partition = (agent_session_id, user_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, прочитайте этот заголовок напрямую.

Платформа заполняет get_request_context().user_id по протоколу 2.0.0. Никогда не используйте идентификатор сеанса только для данных, принадлежащих пользователю, когда несколько пользователей могут входить в сеанс.

Готовый пример реализации посеансового хранения, который можно взять за основу, см. в примере агента для ведения заметок. Он ключит один файл на сеанс в разделе $HOME. Для общего сеанса расширьте этот ключ с идентификатором пользователя из контекста запроса, чтобы каждый пользователь получил свою собственную секцию.

Проверка изоляции

Подтвердите гарантию с помощью A-A-B-теста образца, invoke_previous_response_isolation.py. Запустите это на вашем развернутом агенте с двумя разными пользователями (по умолчанию в примере используются Alice и Bob):

  1. От имени Алисы создайте ответ в совместном сеансе и зафиксируйте его id.
  2. От имени Алисы создайте второй ответ в том же сеансе, установив previous_response_id равным id первого ответа, и сохраните его id.
  3. От имени Боба в том же сеансе отправьте запрос, установив значение previous_response_id равным второму ответу Алисы. Вызов не удаётся — Боб не может продолжить цепочку вызовов Алисы.

Используйте два разных пользователя Entra или идентификаторы объектов. Две метки, которые указывают на одну и ту же учетную запись, не являются корректным тестом для разных пользователей.

Отправка устаревшего заголовка изоляции по пути протокола 2.0.0 приводит к ошибке, поскольку эта модель была заменена контекстом пользователя платформы.