Поделиться через


Реализация пользовательского хранилища для бота

ОБЛАСТЬ ПРИМЕНЕНИЯ: ПАКЕТ SDK версии 4

Взаимодействие бота входит в три области: обмен действиями с azure AI Служба Bot, загрузка и сохранение состояния бота и диалогового окна с хранилищем памяти и интеграция с внутренними службами.

Схема взаимодействия, включающая связь между Служба Bot ИИ Azure, ботом, хранилищем памяти и другими службами.

В этой статье описывается, как расширить семантику между Служба Bot ИИ Azure и состоянием памяти и хранилищем бота.

Примечание.

Пакеты SDK для JavaScript, C# и Python для Bot Framework по-прежнему будут поддерживаться, однако пакет SDK java отменяется с окончательной долгосрочной поддержкой, заканчивающейся в ноябре 2023 года.

Существующие боты, созданные с помощью пакета SDK для Java, будут продолжать функционировать.

Для создания нового бота рекомендуется использовать Microsoft Copilot Studio и ознакомиться с выбором подходящего решения copilot.

Дополнительные сведения см. в статье "Будущее создания бота".

Необходимые компоненты

В этой статье рассматриваются версии C# примера.

Общие сведения

Пакет SDK Bot Framework включает реализацию состояния бота и хранилища памяти по умолчанию. Эта реализация соответствует потребностям приложений, где элементы используются вместе с несколькими строками кода инициализации, как показано во многих примерах.

Пакет SDK — это платформа, а не приложение с фиксированным поведением. Другими словами, реализация многих механизмов в платформе является реализацией по умолчанию, а не единственной возможной реализацией. Платформа не диктует связь между обменом действиями с azure AI Служба Bot и загрузкой и сохранением любого состояния бота.

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

Поведение адаптера по умолчанию и поставщика хранилища

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

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

Однако некоторые вещи могут пойти не так с этим поведением.

  • Если операция сохранения завершается ошибкой по какой-то причине, состояние неявно выскользнуло из синхронизации с тем, что пользователь видит в канале. Пользователь видел ответы от бота и считает, что государство продвинулось вперед, но оно не было. Эта ошибка может быть хуже, чем в случае успешного обновления состояния, но пользователь не получил ответные сообщения.

    Такие ошибки состояния могут повлиять на структуру беседы. Например, в диалоговом окне может потребоваться дополнительная избыточность, обмен подтверждением с пользователем.

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

    Рассмотрим бот заказа пиццы, где бот просит пользователя о выборе начинки, и пользователь отправляет два быстрых сообщения: один, чтобы добавить грибы и один, чтобы добавить сыр. В сценарии горизонтального масштабирования несколько экземпляров бота могут быть активными, а два пользовательских сообщения могут обрабатываться двумя отдельными экземплярами на отдельных компьютерах. Такой конфликт называется состоянием расы, где одна машина может перезаписать состояние, написанное другим. Тем не менее, поскольку ответы уже отправлены, пользователь получил подтверждение о том, что грибы и сыр были добавлены в свой заказ. К сожалению, когда пицца прибывает, она содержит только гриб или сыр, но не оба.

Оптимистическая блокировка

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

В примере используется стандартный механизм HTTP на основе заголовка тега сущности (ETag). Чтобы понять приведенный ниже код, важно сначала как следует разобраться в основном механизме. На схеме ниже показана вся последовательность действий.

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

На схеме есть два клиента, выполняющих обновление для некоторых ресурсов.

  1. Когда клиент выдает запрос GET и ресурс возвращается с сервера, сервер включает заголовок ETag.

    Заголовок ETag содержит непрозрачное значение, которое обозначает состояние ресурса. Если ресурс изменен, сервер обновляет ETag для ресурса.

  2. Если клиент хочет сохранить изменение состояния, он выдает запрос POST на сервер с значением ETag в заголовке предварительных условий If-Match .

  3. Если значение ETag запроса не соответствует серверу, проверка предварительных условий завершается сбоем с ответом (сбой 412 предварительных условий).

    Этот сбой означает, что текущее значение на сервере больше не соответствует исходному значению, на которое работал клиент.

  4. Если клиент получает предварительный ответ сбоем, клиент обычно получает новое значение ресурса, применяет нужное обновление и пытается снова опубликовать обновление ресурса.

    Второй запрос POST завершается успешно, если другой клиент не обновил ресурс. В противном случае клиент может повторить попытку.

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

Оптимистичный механизм блокировки, как описано, предполагает, что логика программы может быть безопасно извлечена. Идеальная ситуация заключается в том, что эти запросы на обслуживание являются идемпотентными. В компьютерной науке идемпотентная операция — это операция, которая не имеет дополнительного эффекта, если она вызывается несколько раз с теми же входными параметрами. Чистые службы REST HTTP, реализующие запросы GET, PUT и DELETE, часто идемпотентны. Если запрос на обслуживание не приведет к дополнительным эффектам, запросы можно безопасно выполнить повторно в рамках стратегии повторных попыток.

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

Буферизация исходящих действий

Отправка действия не является идемпотентной операцией. Это действие часто представляет собой сообщение, которое передает информацию пользователю, и повторение одного сообщения два или более раз может быть запутанным или вводящим в заблуждение.

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

Схема последовательности с сообщениями, отправляемыми после сохранения состояния диалогового окна.

После создания цикла повторных попыток в выполнении диалогового окна вы получите следующее поведение при возникновении сбоя предварительных условий для операции сохранения.

Схема последовательности с сообщениями, отправляемыми после успешной попытки повтора.

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

Это новое пользовательское решение хранилища делает три действия, которые реализация по умолчанию в пакете SDK не выполняет:

  1. Он использует ETags для обнаружения состязаний.
  2. Он повторяет обработку при обнаружении сбоя ETag.
  3. Он ожидает отправки исходящих действий, пока не будет успешно сохранено состояние.

В оставшейся части этой статьи мы опишем реализацию этих трех функциональных компонентов.

Реализация поддержки ETag

Сначала определите интерфейс для нового магазина, включающего поддержку ETag. Интерфейс помогает использовать механизмы внедрения зависимостей в ASP.NET. Начиная с интерфейса можно реализовать отдельные версии для модульных тестов и для рабочей среды. Например, версия модульного теста может выполняться в памяти и не требует сетевого подключения.

Интерфейс состоит из методов загрузки и сохранения . Оба метода будут использовать ключевой параметр для идентификации состояния загрузки или сохранения в хранилище.

  • Загрузка вернет значение состояния и связанный ETag.
  • Сохраните параметры для значения состояния и связанного ETag и возвращает логическое значение, указывающее, выполнена ли операция успешно. Возвращаемое значение не будет служить общим индикатором ошибки, а в качестве определенного индикатора сбоя предварительных условий. Проверка возвращаемого кода будет частью логики цикла повторных попыток.

Чтобы сделать реализацию хранилища широко применимой, избегайте размещения требований сериализации к ней. Однако многие современные службы хранилища поддерживают JSON в качестве типа контента. В C#можно использовать JObject тип для представления объекта JSON. В JavaScript или TypeScript JSON — это обычный собственный объект.

Ниже приведено определение пользовательского интерфейса.

IStore.cs

public interface IStore
{
    Task<(JObject content, string etag)> LoadAsync(string key);

    Task<bool> SaveAsync(string key, JObject content, string etag);
}

Вот реализация для Хранилище BLOB-объектов Azure.

BlobStore.cs

public class BlobStore : IStore
{
    private readonly CloudBlobContainer _container;

    public BlobStore(string accountName, string accountKey, string containerName)
    {
        if (string.IsNullOrWhiteSpace(accountName))
        {
            throw new ArgumentException(nameof(accountName));
        }

        if (string.IsNullOrWhiteSpace(accountKey))
        {
            throw new ArgumentException(nameof(accountKey));
        }

        if (string.IsNullOrWhiteSpace(containerName))
        {
            throw new ArgumentException(nameof(containerName));
        }

        var storageCredentials = new StorageCredentials(accountName, accountKey);
        var cloudStorageAccount = new CloudStorageAccount(storageCredentials, useHttps: true);
        var client = cloudStorageAccount.CreateCloudBlobClient();
        _container = client.GetContainerReference(containerName);
    }

    public async Task<(JObject content, string etag)> LoadAsync(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        var blob = _container.GetBlockBlobReference(key);
        try
        {
            var content = await blob.DownloadTextAsync();
            var obj = JObject.Parse(content);
            var etag = blob.Properties.ETag;
            return (obj, etag);
        }
        catch (StorageException e)
            when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.NotFound)
        {
            return (new JObject(), null);
        }
    }

    public async Task<bool> SaveAsync(string key, JObject obj, string etag)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        if (obj == null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        var blob = _container.GetBlockBlobReference(key);
        blob.Properties.ContentType = "application/json";
        var content = obj.ToString();
        if (etag != null)
        {
            try
            {
                await blob.UploadTextAsync(content, Encoding.UTF8, new AccessCondition { IfMatchETag = etag }, new BlobRequestOptions(), new OperationContext());
            }
            catch (StorageException e)
                when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.PreconditionFailed)
            {
                return false;
            }
        }
        else
        {
            await blob.UploadTextAsync(content);
        }

        return true;
    }
}

Хранилище BLOB-объектов Azure делает большую часть работы. Каждый метод проверяет наличие определенного исключения в соответствии с ожиданиями вызывающего кода.

  • МетодLoadAsync, в ответ на исключение хранилища с не найденным кодом состояния, возвращает значение NULL.
  • МетодSaveAsync, возвращаемый в ответ на исключение хранилища с предварительным условием сбой кодаfalse.

Реализация цикла повторных попыток

Конструкция цикла повторных попыток реализует поведение, показанное на схемах последовательностей.

  1. При получении действия создайте ключ для состояния беседы.

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

  2. Попытайтесь загрузить состояние беседы.

  3. Запустите диалоги бота и запишите исходящие действия для отправки.

  4. Попытайтесь сохранить состояние беседы.

    • При успешном выполнении отправьте исходящие действия и выход.

    • При сбое повторите этот процесс с шага, чтобы загрузить состояние беседы.

      Новая нагрузка состояния беседы получает новое и текущее состояние ETag и беседы. Диалоговое окно выполняется повторно, а шаг состояния сохранения имеет шанс на успешное выполнение.

Ниже приведена реализация обработчика действий сообщения.

ScaleoutBot.cs

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    // Create the storage key for this conversation.
    var key = $"{turnContext.Activity.ChannelId}/conversations/{turnContext.Activity.Conversation?.Id}";

    // The execution sits in a loop because there might be a retry if the save operation fails.
    while (true)
    {
        // Load any existing state associated with this key
        var (oldState, etag) = await _store.LoadAsync(key);

        // Run the dialog system with the old state and inbound activity, the result is a new state and outbound activities.
        var (activities, newState) = await DialogHost.RunAsync(_dialog, turnContext.Activity, oldState, cancellationToken);

        // Save the updated state associated with this key.
        var success = await _store.SaveAsync(key, newState, etag);

        // Following a successful save, send any outbound Activities, otherwise retry everything.
        if (success)
        {
            if (activities.Any())
            {
                // This is an actual send on the TurnContext we were given and so will actual do a send this time.
                await turnContext.SendActivitiesAsync(activities, cancellationToken);
            }

            break;
        }
    }
}

Примечание.

В примере реализовано выполнение диалогового окна в качестве вызова функции. Более сложный подход — определить интерфейс и использовать внедрение зависимостей. Однако в этом примере статическая функция подчеркивает функциональный характер этого оптимистического подхода блокировки. Как правило, при реализации важных частей кода в функциональном режиме вы повышаете вероятность успешной работы в сетях.

Реализация буфера исходящего действия

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

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

Ниже приведена реализация пользовательского адаптера.

DialogHostAdapter.cs

public class DialogHostAdapter : BotAdapter
{
    private List<Activity> _response = new List<Activity>();

    public IEnumerable<Activity> Activities => _response;

    public override Task<ResourceResponse[]> SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken)
    {
        foreach (var activity in activities)
        {
            _response.Add(activity);
        }

        return Task.FromResult(new ResourceResponse[0]);
    }

    #region Not Implemented
    public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public override Task<ResourceResponse> UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
    #endregion
}

Использование пользовательского хранилища в боте

Последний шаг — использовать эти пользовательские классы и методы с существующими классами и методами платформы.

  • Основной цикл повторных попыток становится частью метода бота ActivityHandler.OnMessageActivityAsync и включает пользовательское хранилище с помощью внедрения зависимостей.
  • Код размещения диалогов добавляется в DialogHost класс, предоставляющий статический RunAsync метод. Узел диалогового окна:
    • Принимает входящий трафик и старое состояние, а затем возвращает результирующее действие и новое состояние.
    • Создает настраиваемый адаптер и в противном случае запускает диалоговое окно так же, как и пакет SDK.
    • Создает пользовательский метод доступа к свойству состояния, который передает состояние диалога в систему диалогов. Метод доступа использует семантику ссылок для передачи дескриптора доступа в систему диалоговых окон.

Совет

Сериализация JSON добавляется в код размещения, чтобы сохранить его вне подключаемого уровня хранения, чтобы различные реализации могли сериализоваться по-разному.

Ниже приведена реализация узла диалога.

DialogHost.cs

public static class DialogHost
{
    // The serializer to use. Moving the serialization to this layer will make the storage layer more pluggable.
    private static readonly JsonSerializer StateJsonSerializer = new JsonSerializer() { TypeNameHandling = TypeNameHandling.All };

    /// <summary>
    /// A function to run a dialog while buffering the outbound Activities.
    /// </summary>
    /// <param name="dialog">THe dialog to run.</param>
    /// <param name="activity">The inbound Activity to run it with.</param>
    /// <param name="oldState">Th eexisting or old state.</param>
    /// <returns>An array of Activities 'sent' from the dialog as it executed. And the updated or new state.</returns>
    public static async Task<(Activity[], JObject)> RunAsync(Dialog dialog, IMessageActivity activity, JObject oldState, CancellationToken cancellationToken)
    {
        // A custom adapter and corresponding TurnContext that buffers any messages sent.
        var adapter = new DialogHostAdapter();
        var turnContext = new TurnContext(adapter, (Activity)activity);

        // Run the dialog using this TurnContext with the existing state.
        var newState = await RunTurnAsync(dialog, turnContext, oldState, cancellationToken);

        // The result is a set of activities to send and a replacement state.
        return (adapter.Activities.ToArray(), newState);
    }

    /// <summary>
    /// Execute the turn of the bot. The functionality here closely resembles that which is found in the
    /// IBot.OnTurnAsync method in an implementation that is using the regular BotFrameworkAdapter.
    /// Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted
    /// to other conversation modeling abstractions.
    /// </summary>
    /// <param name="dialog">The dialog to be run.</param>
    /// <param name="turnContext">The ITurnContext instance to use. Note this is not the one passed into the IBot OnTurnAsync.</param>
    /// <param name="state">The existing or old state of the dialog.</param>
    /// <returns>The updated or new state of the dialog.</returns>
    private static async Task<JObject> RunTurnAsync(Dialog dialog, ITurnContext turnContext, JObject state, CancellationToken cancellationToken)
    {
        // If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.)
        var dialogStateProperty = state?[nameof(DialogState)];
        var dialogState = dialogStateProperty?.ToObject<DialogState>(StateJsonSerializer);

        // A custom accessor is used to pass a handle on the state to the dialog system.
        var accessor = new RefAccessor<DialogState>(dialogState);

        // Run the dialog.
        await dialog.RunAsync(turnContext, accessor, cancellationToken);

        // Serialize the result (available as Value on the accessor), and put its value back into a new JObject.
        return new JObject { { nameof(DialogState), JObject.FromObject(accessor.Value, StateJsonSerializer) } };
    }
}

И, наконец, вот реализация пользовательского метода доступа к свойству состояния.

RefAccessor.cs

public class RefAccessor<T> : IStatePropertyAccessor<T>
    where T : class
{
    public RefAccessor(T value)
    {
        Value = value;
    }

    public T Value { get; private set; }

    public string Name => nameof(T);

    public Task<T> GetAsync(ITurnContext turnContext, Func<T> defaultValueFactory = null, CancellationToken cancellationToken = default(CancellationToken))
    {
        if (Value == null)
        {
            if (defaultValueFactory == null)
            {
                throw new KeyNotFoundException();
            }

            Value = defaultValueFactory();
        }

        return Task.FromResult(Value);
    }

    #region Not Implemented
    public Task DeleteAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(ITurnContext turnContext, T value, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }
    #endregion
}

Дополнительная информация:

Пример горизонтального масштабирования доступен из репозитория примеров Bot Framework на сайте GitHub на C#, Python и Java.