Implémenter un stockage personnalisé pour votre bot

S'APPLIQUE À : SDK v4

Les interactions d'un bot sont de trois types : l'échange d'activités avec Azure AI Bot Service, le chargement et l'enregistrement de l'état du bot et celui du dialogue avec une mémoire de stockage, et une intégration avec le service back end.

Diagramme d’interaction décrivant la relation entre Azure AI Bot Service, un bot, un magasin de mémoire et d’autres services.

Cet article explique comment étendre la sémantique entre Azure AI Bot Service et l'état de la mémoire de stockage du bot.

Remarque

Les kits SDK JavaScript, C# et Python Bot Framework continueront d’être pris en charge. Toutefois, le kit de développement logiciel (SDK) Java est mis hors service avec une prise en charge finale à long terme se terminant en novembre 2023.

Les bots existants créés avec le kit de développement logiciel (SDK) Java continueront de fonctionner.

Pour la nouvelle génération de bots, envisagez d'utiliser Power Virtual Agents et découvrez comment choisir la solution de chatbot appropriée.

Pour plus d’informations, consultez Les futures versions de bot.

Prérequis

Cet article se concentre sur la version C# de l'échantillon.

Background

Le kit de développement logiciel (SDK) Bot Framework inclut une implémentation par défaut du bot state et de la mémoire de stockage. Cette implémentation correspond aux besoins des applications où les éléments sont utilisés ensemble avec quelques lignes de code d'initialisation, comme illustré dans la plupart des échantillons.

Le kit de développement logiciel (SDK) est un cadre et non une application avec un comportement fixe. En d'autres termes, de nombreux mécanismes compris dans ce cadre sont une mise en œuvre par défaut et non la seule possible. Le cadre n'impose pas de relation entre l'échange d'activités avec Azure AI Bot Service, le chargement ainsi que l'enregistrement de n'importe quel bot state.

Cet article décrit une façon de modifier la sémantique de l'état de stockage par défaut et sa mise en œuvre lorsqu'il ne fonctionne pas convenablement sur votre application. L'échantillon de scale-out fournit une mise en œuvre alternative de l'état de stockage ayant une sémantique différente de celle par défaut. La présente solution alternative se trouve également bien dans le cadre. Selon votre scénario, cette solution alternative peut être plus appropriée pour l'application que vous développez.

Comportement de l'adaptateur par défaut et du fournisseur de stockage

Avec la mise en œuvre par défaut, à la réception d'une activité, le bot charge l'état correspondant à la conversation. Il exécute ensuite la logique de la boîte de dialogue avec cet état et l'activité entrante. Dans le processus d'exécution de la boîte de dialogue, une ou plusieurs activités sortantes sont créées et envoyées immédiatement. Lorsque le traitement de la boîte de dialogue est terminé, le bot enregistre l'état mis à jour, en remplaçant l'ancien état.

Diagramme de séquence montrant le comportement par défaut d’un bot et son magasin de mémoire.

Toutefois, ce comportement peut entrainer certaines erreurs.

  • Si l'opération d'enregistrement échoue pour une raison quelconque, l'état a implicitement été non synchronisé avec ce que l'utilisateur aperçoit sur le canal. L'utilisateur a vu des réponses du bot et croit que l'état a évolué, pourtant non. Cette erreur peut s'empirer si l'état de mise à jour a réussi, mais que l'utilisateur n'a pas reçu les messages de réponse.

    Ces erreurs d'état peuvent avoir des conséquences sur votre conception de conversation. Par exemple, la boîte de dialogue peut nécessiter des échanges de confirmation supplémentaires, sinon redondants, avec l'utilisateur.

  • Si la mise en œuvre est déployée avec un scale-out sur plusieurs nœuds, l'état peut accidentellement être remplacé. Cette erreur peut prêter à confusion, car la boîte de dialogue aura probablement envoyé des activités à la chaîne contenant des messages de confirmation.

    Considérez l'exemple d'un bot de commande de pizza qui demande à l'utilisateur de choisir une garniture et qui envoie deux messages instantanés : l'un pour ajouter des champignons et l'autre pour ajouter du fromage. Dans un scénario de Scale-out, plusieurs instances du bot peuvent être actives. De plus, les deux messages de l'utilisateur peuvent être gérés par deux instances distinctes sur des ordinateurs distincts. Un tel conflit est appelé « condition de concurrence », un ordinateur pouvant remplacer l'état écrit par une autre. Toutefois, étant donné que les réponses avaient déjà été envoyées, l'utilisateur a reçu la confirmation que les champignons et le fromage avaient été ajoutés à sa commande. Malheureusement, lorsque la pizza arrive, elle ne contient que des champignons ou du fromage, mais pas les deux.

Verrouillage optimiste

L'échantillon du scale-out introduit un verrouillage autour de l'état. L'échantillon met en œuvre le verrouillage optimiste, qui permet à chaque instance de s'exécuter comme si elle était la seule en cours d'exécution, puis de vérifier les éventuelles violations de la concurrence. Ce verrouillage peut sembler compliqué, mais les solutions connues existent, et vous pouvez utiliser les technologies de stockage cloud et les points d'extension appropriés dans Bot Framework.

L'échantillon utilise un mécanisme HTTP standard basé sur l'en-tête de l'étiquette d'entité (ETag). La compréhension de ce mécanisme est cruciale pour comprendre le code qui suit. Le diagramme suivant illustre la séquence.

Diagramme de séquence montrant une condition de concurrence, avec l’échec de la deuxième mise à jour.

Le diagramme présente deux clients qui effectuent une mise à jour d'une ressource.

  1. Lorsqu'un client émet une requête GET et qu'une ressource est renvoyée par le serveur, ce dernier inclut un en-tête ETag.

    L’en-tête ETag est une valeur opaque qui représente l’état de la ressource. Si une ressource est modifiée, le serveur met à jour son ETag pour la ressource.

  2. Lorsque le client souhaite conserver une modification d'état, il émet une requête POST sur le serveur, avec la valeur ETag dans une en-tête de condition préalable If-Match.

  3. Si la valeur ETag de la requête ne correspond pas à celle du serveur, la condition préalable case activée échoue avec une réponse 412 (échec de la condition préalable).

    Cet échec indique que la valeur actuelle sur le serveur ne correspond plus à la valeur d'origine sur laquelle le client fonctionnait.

  4. Si le client reçoit une réponse ayant échoué, le client obtient généralement une nouvelle valeur pour la ressource, applique la mise à jour souhaitée et tente de publier à nouveau la mise à jour de la ressource.

    Cette deuxième requête POST réussit si aucun autre client n'a mis à jour la ressource. Sinon, le client peut réessayer.

Ce processus est dit optimiste parce que le client, une fois qu'il dispose d'une ressource, procède à son traitement ; la ressource elle-même n'est pas verrouillée, puisque d'autres clients peuvent y accéder sans aucune restriction. Tout contention entre des clients concernant l'état de la ressource n'est déterminée qu'une fois le traitement effectué. Dans un système distribué, cette stratégie est souvent plus optimale que l'approche pessimiste opposée.

Le mécanisme de verrouillage optimiste, comme décrit, suppose que votre logique de programme peut être retentée en toute sécurité. Dans l'idéal, ces demandes de service sont idempotentes. En informatique, une opération idempotente est une opération n'entraînant aucun effet supplémentaire en cas d'appels multiples avec les mêmes paramètres d'entrée. Les services REST HTTP épurés qui mettent en œuvre les requêtes GET, PUT et DELETE sont souvent idempotents. Si une demande de service ne produit pas d'effets supplémentaires, les requêtes peuvent être à nouveau exécutées en toute sécurité dans le cadre d'une stratégie de nouvelle tentative.

L'échantillon de scale-out et le reste de cet article supposent que les services backend que votre bot utilise sont tous des services REST HTTP idempotents.

Mise en mémoire tampon des activités sortantes

L'envoi d'une activité n'est pas une opération idempotente. L'activité est souvent un message qui relaye des informations à l'utilisateur, et la répétition du même message deux ou plusieurs fois peut être déroutante ou trompeuse.

Le verrouillage optimiste implique que votre logique de bot doit être exécutée plusieurs fois. Pour éviter d'envoyer plusieurs fois une activité donnée, attendez que l'opération de mise à jour de l'état réussisse avant d'envoyer des activités à l'utilisateur. La logique de votre bot devrait ressembler au diagramme suivant.

Diagramme de séquence avec les messages envoyés après l’enregistrement de l’état de la boîte de dialogue.

Une fois que vous avez généré une boucle de nouvelle tentative dans votre exécution de boîte de dialogue, vous avez le comportement suivant en cas d'échec de condition préalable lors de l'opération d'enregistrement.

Diagramme de séquence avec les messages envoyés après une nouvelle tentative réussit.

Grâce à ce mécanisme, le bot pizza de l'exemple précédent ne devrait jamais envoyer d'accusé de réception positif erroné concernant l'ajout d'une garniture de pizza à une commande. Même avec le bot déployé sur plusieurs machines, le schéma de verrouillage optimiste sérialise efficacement les mises à jour d'état. Dans le bot pizza, l'accusé de réception de l'ajout d'un élément peut maintenant refléter l'état complet avec précision. Par exemple, si l'utilisateur tape rapidement « fromage », puis « champignons », et que ces messages sont gérés par deux instances différentes du bot, la dernière instance à terminer peut inclure « une pizza avec du fromage et des champignons » dans le cadre de sa réponse.

Cette nouvelle solution de stockage personnalisée effectue trois opérations que l'implémentation par défaut dans le kit de développement logiciel (SDK) ne fait pas :

  1. Il utilise des ETags pour détecter la contention.
  2. Il relance le traitement lorsqu'une erreur d'ETag est détectée.
  3. Il attend d'envoyer des activités sortantes jusqu'à ce qu'elle ait l'état enregistré.

Le reste de cet article décrit l’implémentation de ces trois parties.

Mise en œuvre de la prise en charge de l'ETag

Tout d'abord, définissons une interface pour notre nouveau magasin qui inclut la prise en charge de l'ETag. L'interface permet d'utiliser les mécanismes d'injection de dépendances dans ASP.NET. À compter de l'interface, vous pouvez implémenter des versions distinctes pour les tests unitaires et pour la production. Par exemple, la version de test unitaire peut s'exécuter en mémoire et ne nécessite pas de connexion réseau.

L'interface se compose des méthodes de chargement et d'enregistrement. Les deux méthodes utilisent un paramètre clé pour identifier l'état à charger à partir ou enregistrer dans le stockage.

  • Chargement retourne la valeur d'état et l'ETag associé.
  • Enregistrement aura des paramètres pour la valeur de l'état et l'ETag associé. De plus, il renverra une valeur booléenne indiquant l'état de réussite de l'opération. La valeur renvoyée ne sert pas d'indicateur d'erreur général, mais plutôt comme indicateur spécifique de défaillance de condition préalable. La vérification du code de retour fait partie de la logique de la boucle de relance.

Pour rendre l'implémentation de stockage largement applicable, évitez de placer les exigences de sérialisation dessus. Toutefois, de nombreux services de stockage modernes prennent en charge JSON comme type de contenu. Avec C#, vous pouvez utiliser le type JObject pour représenter un objet JSON. Avec JavaScript ou TypeScript, JSON est un objet natif régulier.

Voici une définition de l'interface personnalisée :

IStore.cs

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

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

Voici une mise en œuvre pour Stockage 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;
    }
}

Stockage Blob Azure fait le gros du travail. Chaque méthode vérifie l'existence d'une exception spécifique afin de répondre aux attentes du code d'appel.

  • La méthode LoadAsync, en réponse à une exception de stockage avec un code d'état introuvable , retourne une valeur nulle.
  • La méthode SaveAsync, en réponse à une exception de stockage avec un code d'échec de condition préalable, retourner false.

Mise en œuvre d'une boucle de relance

La conception de la boucle de relance met en œuvre le comportement illustré dans les diagrammes de séquence.

  1. Lors de la réception d'une activité, créer une clé pour l'état de la conversation.

    La relation entre une activité et l'état de conversation est la même pour le stockage personnalisé que pour l'implémentation par défaut. Par conséquent, vous pouvez construire la clé de la même façon que l'implémentation d'état par défaut.

  2. Essayez de charger l'état de la conversation.

  3. Exécutez les dialogues du bot et capturez les activités sortantes à envoyer.

  4. Essayez d'enregistrer l'état de la conversation.

    • En cas de réussite, envoyez les activités sortantes et fermez la session.

    • En cas d'échec, répétez ce processus à partir de l'étape pour charger l'état de la conversation.

      La nouvelle charge de l'état de conversation obtient un nouvel état ETag et conversation actuel. Le dialogue est une nouvelle fois exécuté et l'étape de sauvegarde de l'état a une chance d'aboutir.

Voici une mise en œuvre du gestionnaire d'activité de message.

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;
        }
    }
}

Remarque

L'échantillon met en œuvre l'exécution d'un dialogue sous la forme d'un appel de fonction. Une approche plus sophistiquée peut être de définir une interface et d'utiliser l'injection de dépendances. Toutefois, dans cet exemple, la fonction statique met l'accent sur la nature fonctionnelle de cette approche de verrouillage optimiste. En général, lorsque vous mettez en œuvre les parties cruciales de votre code de manière fonctionnelle, vous améliorez ses chances de fonctionner correctement sur les réseaux.

Mettre en place un tampon d'activité sortant

L'exigence suivante consiste à mettre en mémoire tampon les activités sortantes jusqu'à ce qu'une opération d'enregistrement réussie se produise, ce qui nécessite une implémentation d'adaptateur personnalisée. La méthode personnalisée SendActivitiesAsync ne doit pas envoyer les activités à l'utilisation, mais ajouter les activités à une liste. Votre code de boîte de dialogue n'aura pas besoin de modification.

  • Dans ce scénario particulier, les opérations d'activité de mise à jour et d'activité de suppression ne sont pas prises en charge et les méthodes associées lèveront des exceptions non implémentées.
  • La valeur renvoyée de l'opération d'activité d'envoi est utilisée par certains canaux pour permettre à un bot de modifier ou de supprimer un message précédemment envoyé, par exemple, pour désactiver les boutons sur les carte s affichées dans le canal. Ces échanges de message peuvent devenir complexes, en particulier lorsque l'état est obligatoire, ce qui est hors de portée de cet article.
  • Votre boîte de dialogue crée et utilise cet adaptateur personnalisé afin qu'il puisse mettre en mémoire tampon les activités.
  • Le gestionnaire de tour de votre bot utilisera un AdapterWithErrorHandler plus standard pour envoyer les activités à l'utilisateur.

Voici une mise en œuvre de l'adaptateur personnalisé.

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
}

Utiliser votre stockage personnalisé dans un bot

La dernière étape consiste à utiliser ces classes et méthodes personnalisées avec des classes et des méthodes du cadre existant.

  • La boucle de relance principale fait partie de la méthode ActivityHandler.OnMessageActivityAsync de votre bot et inclut votre stockage personnalisé à partir de l'injection de dépendances.
  • Le code de l'hébergement de la boîte de dialogue est ajouté à la classe DialogHost qui expose une méthode RunAsync statique. Hôte de la boîte de dialogue :
    • Saisit l'activité entrante et l'ancien état, puis renvoie les activités résultantes et le nouvel état.
    • Crée l'adaptateur personnalisé et exécute la boîte de dialogue de la même façon que le kit de développement logiciel (SDK).
    • Crée un accesseur de propriété d'état personnalisé, un shim qui passe l'état du dialogue dans le système de dialogue. L'accesseur utilise la sémantique de référence pour transmettre un descripteur d'accesseur au système de dialogue.

Conseil

La sérialisation JSON est ajoutée en filigrane au code d'hébergement pour le maintenir en dehors de la couche de stockage enfichable, de sorte que différentes mise en œuvre peuvent sérialiser différemment.

Voici une mise en œuvre de l'hôte de dialogue.

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) } };
    }
}

Pour finir, voici une mise en œuvre de l'accesseur à la propriété de l'état personnalisé.

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
}

Informations supplémentaires

L'échantillon de scale-out est disponible à partir du référentiel des échantillons Bot Framework sur GitHub en C#, Python, et Java.