Implementare risorse di archiviazione personalizzate per il bot

SI APPLICA A: SDK v4

Le interazioni di un bot rientrano in tre aree: lo scambio di attività con Azure AI servizio Bot, il caricamento e il salvataggio dello stato del bot e del dialogo con un archivio di memoria e l'integrazione con i servizi back-end.

Diagramma di interazione che delinea la relazione tra l'servizio Bot di intelligenza artificiale di Azure, un bot, un archivio di memoria e altri servizi.

Questo articolo illustra come estendere la semantica tra il servizio Bot di intelligenza artificiale di Azure e lo stato di memoria e l'archiviazione del bot.

Nota

Gli SDK JavaScript, C# e Python di Bot Framework continueranno a essere supportati, ma Java SDK verrà ritirato con il supporto finale a lungo termine che termina a novembre 2023.

I bot esistenti creati con Java SDK continueranno a funzionare.

Per la creazione di nuovi bot, è consigliabile usare Microsoft Copilot Studio e leggere le informazioni sulla scelta della soluzione copilota appropriata.

Per altre informazioni, vedere Il futuro della compilazione di bot.

Prerequisiti

Questo articolo è incentrato sulla versione C# dell'esempio.

Background

Bot Framework SDK include un'implementazione predefinita dello stato del bot e dell'archiviazione della memoria. Questa implementazione soddisfa le esigenze delle applicazioni in cui vengono usate le parti insieme a poche righe di codice di inizializzazione, come illustrato in molti degli esempi.

L'SDK è un framework e non un'applicazione con comportamento fisso. In altre parole, l'implementazione di molti dei meccanismi nel framework è un'implementazione predefinita e non l'unica possibile implementazione. Il framework non determina la relazione tra lo scambio di attività con Azure AI servizio Bot e il caricamento e il salvataggio di qualsiasi stato del bot.

Questo articolo descrive un modo per modificare la semantica dello stato predefinito e dell'implementazione dell'archiviazione quando non funziona abbastanza per l'applicazione. L'esempio di scalabilità orizzontale fornisce un'implementazione alternativa dello stato e dell'archiviazione con semantica diversa rispetto a quelle predefinite. Questa soluzione alternativa si trova ugualmente bene nel framework. A seconda dello scenario, questa soluzione alternativa potrebbe essere più appropriata per l'applicazione in fase di sviluppo.

Comportamento dell'adattatore predefinito e del provider di archiviazione

Con l'implementazione predefinita, durante la ricezione di un'attività, il bot carica lo stato corrispondente alla conversazione. Esegue quindi la logica del dialogo con questo stato e l'attività in ingresso. Durante il processo di esecuzione della finestra di dialogo, vengono create e inviate immediatamente una o più attività in uscita. Al termine dell'elaborazione del dialogo, il bot salva lo stato aggiornato sovrascrivendo lo stato precedente.

Diagramma di sequenza che mostra il comportamento predefinito di un bot e del relativo archivio di memoria.

Tuttavia, alcune cose possono andare storte con questo comportamento.

  • Se l'operazione di salvataggio non riesce per qualche motivo, lo stato è stato interrotto in modo implicito dalla sincronizzazione con ciò che l'utente vede nel canale. L'utente ha visto risposte dal bot e ritiene che lo stato sia stato spostato in avanti, ma non lo è. Questo errore può essere peggiore di se l'aggiornamento dello stato è riuscito, ma l'utente non ha ricevuto i messaggi di risposta.

    Tali errori di stato possono avere implicazioni per la progettazione della conversazione. Ad esempio, la finestra di dialogo potrebbe richiedere scambi di conferma aggiuntivi, altrimenti ridondanti con l'utente.

  • Se l'implementazione viene distribuita con scalabilità orizzontale in più nodi, lo stato può essere sovrascritto accidentalmente. Questo errore può generare confusione perché la finestra di dialogo probabilmente ha inviato attività al canale che trasporta messaggi di conferma.

    Si consideri un bot per l'ordine della pizza, in cui il bot chiede all'utente scelte di condimento e l'utente invia due messaggi rapidi: uno per aggiungere funghi e uno per aggiungere formaggio. In uno scenario con scalabilità orizzontale, più istanze del bot potrebbero essere attive e i due messaggi utente potrebbero essere gestiti da due istanze separate in computer separati. Tale conflitto viene definito race condition, in cui una macchina potrebbe sovrascrivere lo stato scritto da un altro. Tuttavia, poiché le risposte sono già state inviate, l'utente ha ricevuto la conferma che sia funghi che formaggio sono stati aggiunti al loro ordine. Purtroppo, quando arriva la pizza, contiene solo funghi o formaggio, ma non entrambi.

Blocco ottimistico

L'esempio di scalabilità orizzontale introduce alcuni blocchi per lo stato. L'esempio implementa il blocco ottimistico, che consente l'esecuzione di ogni istanza come se fosse l'unica in esecuzione e quindi verifica la presenza di eventuali violazioni di concorrenza. Questo blocco può sembrare complicato, ma esistono soluzioni note ed è possibile usare tecnologie di archiviazione cloud e i punti di estensione corretti in Bot Framework.

L'esempio usa un meccanismo HTTP standard basato sull'intestazione del tag di entità (ETag). La comprensione di questo meccanismo è fondamentale per comprendere il codice che segue. Il diagramma seguente illustra la sequenza.

Diagramma di sequenza che mostra una race condition, con il secondo aggiornamento non riuscito.

Il diagramma include due client che eseguono un aggiornamento di alcune risorse.

  1. Quando un client invia una richiesta GET e viene restituita una risorsa dal server, il server include un'intestazione ETag.

    L'intestazione ETag è un valore opaco che rappresenta lo stato della risorsa. Se una risorsa viene modificata, il server aggiorna il relativo ETag per la risorsa.

  2. Quando il client vuole rendere persistente una modifica dello stato, invia una richiesta POST al server, con il valore ETag in un'intestazione If-Match di precondizione.

  3. Se il valore ETag della richiesta non corrisponde a quello del server, il controllo della precondizione ha esito negativo con una 412 risposta (Precondizione non riuscita).

    Questo errore indica che il valore corrente nel server non corrisponde più al valore originale su cui il client stava operando.

  4. Se il client riceve una risposta con precondizione non riuscita, il client ottiene in genere un nuovo valore per la risorsa, applica l'aggiornamento desiderato e tenta di pubblicare nuovamente l'aggiornamento della risorsa.

    Questa seconda richiesta POST ha esito positivo se nessun altro client ha aggiornato la risorsa. In caso contrario, il client può riprovare.

Questo processo viene chiamato ottimistico perché il client, una volta che ha una risorsa, procede per l'elaborazione, ovvero la risorsa stessa non è bloccata, perché altri client possono accedervi senza alcuna restrizione. Qualsiasi contesa tra i client rispetto allo stato della risorsa non viene determinata fino a quando non viene eseguita l'elaborazione. In un sistema distribuito, questa strategia è spesso più ottimale rispetto all'approccio pessimistico opposto.

Il meccanismo di blocco ottimistico come descritto presuppone che la logica del programma possa essere ritentata in modo sicuro. La situazione ideale è la posizione in cui queste richieste di servizio sono idempotenti. In informatica, un'operazione idempotente è uno che non ha alcun effetto aggiuntivo se viene chiamato più volte con gli stessi parametri di input. I servizi REST HTTP puri che implementano le richieste GET, PUT e DELETE sono spesso idempotenti. Se una richiesta di servizio non produrrà effetti aggiuntivi, le richieste possono essere rieseguite in modo sicuro come parte di una strategia di ripetizione dei tentativi.

L'esempio di scalabilità orizzontale e il resto di questo articolo presuppongono che i servizi back-end usati dal bot siano tutti i servizi REST HTTP idempotenti.

Memorizzazione nel buffer di attività in uscita

L'invio di un'attività non è un'operazione idempotente. L'attività è spesso un messaggio che inoltra le informazioni all'utente e ripete lo stesso messaggio due o più volte potrebbe generare confusione o fuorviante.

Il blocco ottimistico implica che la logica del bot potrebbe dover essere rieseguita più volte. Per evitare di inviare più volte una determinata attività, attendere che l'operazione di aggiornamento dello stato abbia esito positivo prima di inviare attività all'utente. La logica del bot dovrebbe essere simile al diagramma seguente.

Diagramma sequenza con messaggi inviati dopo il salvataggio dello stato del dialogo.

Dopo aver compilato un ciclo di ripetizione dei tentativi nell'esecuzione del dialogo, si avrà il comportamento seguente quando si verifica un errore di precondizione nell'operazione di salvataggio.

Diagramma sequenza con messaggi inviati dopo un tentativo di ripetizione riuscito.

Con questo meccanismo, il bot pizza dell'esempio precedente non deve mai inviare un riconoscimento positivo errato di una pizza da aggiungere a un ordine. Anche con il bot distribuito in più computer, lo schema di blocco ottimistico serializza efficacemente gli aggiornamenti dello stato. Nel bot pizza, il riconoscimento dall'aggiunta di un elemento ora può anche riflettere lo stato completo in modo accurato. Ad esempio, se l'utente digita rapidamente "cheese" e poi "mushroom" e questi messaggi vengono gestiti da due istanze diverse del bot, l'ultima istanza da completare può includere "una pizza con formaggio e funghi" come parte della risposta.

Questa nuova soluzione di archiviazione personalizzata esegue tre operazioni che l'implementazione predefinita nell'SDK non esegue:

  1. Usa ETags per rilevare conflitti.
  2. Riprova l'elaborazione quando viene rilevato un errore ETag.
  3. Attende l'invio di attività in uscita fino a quando non viene salvato correttamente lo stato.

Il resto di questo articolo descrive l'implementazione di queste tre parti.

Implementare il supporto ETag

Prima di tutto, definire un'interfaccia per il nuovo archivio che include il supporto ETag. L'interfaccia consente di usare i meccanismi di inserimento delle dipendenze in ASP.NET. A partire dall'interfaccia è possibile implementare versioni separate per unit test e per la produzione. Ad esempio, la versione di unit test potrebbe essere eseguita in memoria e non richiede una connessione di rete.

L'interfaccia è costituita da metodi di caricamento e salvataggio . Entrambi i metodi useranno un parametro chiave per identificare lo stato da cui caricare o salvare nell'archiviazione.

  • Il caricamento restituirà il valore dello stato e l'ETag associato.
  • Save avrà parametri per il valore di stato e l'ETag associato e restituirà un valore booleano che indica se l'operazione è riuscita. Il valore restituito non fungerà da indicatore di errore generale, ma come indicatore specifico dell'errore di precondizione. Il controllo del codice restituito farà parte della logica del ciclo di ripetizione dei tentativi.

Per rendere ampiamente applicabile l'implementazione di archiviazione, evitare di inserire i requisiti di serializzazione. Tuttavia, molti servizi di archiviazione moderni supportano JSON come tipo di contenuto. In C# è possibile usare il JObject tipo per rappresentare un oggetto JSON. In JavaScript o TypeScript JSON è un normale oggetto nativo.

Ecco una definizione dell'interfaccia personalizzata.

IStore.cs

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

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

Ecco un'implementazione per Archiviazione BLOB di 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;
    }
}

Archiviazione BLOB di Azure fa gran parte del lavoro. Ogni metodo verifica la presenza di un'eccezione specifica per soddisfare le aspettative del codice chiamante.

  • Il LoadAsync metodo, in risposta a un'eccezione di archiviazione con un codice di stato non trovato , restituisce un valore Null.
  • Il SaveAsync metodo, in risposta a un'eccezione di archiviazione con una precondizione non riuscita codice, restituisce false.

Implementare un ciclo di ripetizione dei tentativi

La progettazione del ciclo di ripetizione implementa il comportamento illustrato nei diagrammi di sequenza.

  1. Quando si riceve un'attività, creare una chiave per lo stato della conversazione.

    La relazione tra un'attività e lo stato della conversazione è la stessa per l'archiviazione personalizzata come per l'implementazione predefinita. Pertanto, è possibile costruire la chiave allo stesso modo in cui l'implementazione dello stato predefinito esegue.

  2. Tentare di caricare lo stato della conversazione.

  3. Eseguire i dialoghi del bot e acquisire le attività in uscita da inviare.

  4. Tentare di salvare lo stato della conversazione.

    • In caso di esito positivo, inviare le attività in uscita e uscire.

    • In caso di errore, ripetere questo processo dal passaggio per caricare lo stato della conversazione.

      Il nuovo carico di stato della conversazione ottiene uno stato ETag e conversazione nuovo e corrente. La finestra di dialogo viene rieseguita e il passaggio salva stato ha la possibilità di avere esito positivo.

Ecco un'implementazione per il gestore attività del messaggio.

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

Nota

L'esempio implementa l'esecuzione del dialogo come chiamata di funzione. Un approccio più sofisticato potrebbe essere quello di definire un'interfaccia e usare l'inserimento delle dipendenze. Per questo esempio, tuttavia, la funzione statica sottolinea la natura funzionale di questo approccio di blocco ottimistico. In generale, quando si implementano le parti cruciali del codice in modo funzionale, si migliorano le probabilità di funzionare correttamente nelle reti.

Implementare un buffer di attività in uscita

Il requisito successivo consiste nel memorizzare nel buffer le attività in uscita fino a quando non si verifica un'operazione di salvataggio riuscita, che richiede un'implementazione personalizzata dell'adapter. Il metodo personalizzato SendActivitiesAsync non deve inviare le attività all'uso, ma aggiungere le attività a un elenco. Il codice della finestra di dialogo non richiede modifiche.

  • In questo scenario specifico, le operazioni di attività di aggiornamento ed eliminazione non sono supportate e i metodi associati generano eccezioni non implementate .
  • Il valore restituito dall'operazione di invio delle attività viene usato da alcuni canali per consentire a un bot di modificare o eliminare un messaggio inviato in precedenza, ad esempio per disabilitare i pulsanti nelle schede visualizzate nel canale. Questi scambi di messaggi possono diventare complicati, in particolare quando è necessario lo stato e non rientrano nell'ambito di questo articolo.
  • La finestra di dialogo crea e usa questa scheda personalizzata, in modo che possa memorizzare nel buffer le attività.
  • Il gestore dei turni del bot userà uno standard più standard AdapterWithErrorHandler per inviare le attività all'utente.

Ecco un'implementazione dell'adapter personalizzato.

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
}

Usare l'archiviazione personalizzata in un bot

L'ultimo passaggio consiste nell'usare queste classi e metodi personalizzati con classi e metodi framework esistenti.

  • Il ciclo principale di ripetizione dei tentativi diventa parte del metodo del ActivityHandler.OnMessageActivityAsync bot e include l'archiviazione personalizzata tramite l'inserimento delle dipendenze.
  • Il codice host della finestra di dialogo viene aggiunto alla DialogHost classe che espone un metodo statico RunAsync . Host finestra di dialogo:
    • Accetta l'attività in ingresso e lo stato precedente e quindi restituisce le attività risultanti e il nuovo stato.
    • Crea l'adattatore personalizzato e in caso contrario esegue la finestra di dialogo nello stesso modo dell'SDK.
    • Crea una funzione di accesso alla proprietà di stato personalizzata, uno shim che passa lo stato del dialogo nel sistema del dialogo. La funzione di accesso usa la semantica di riferimento per passare un handle della funzione di accesso al sistema di dialoghi.

Suggerimento

La serializzazione JSON viene aggiunta inline al codice di hosting per mantenerla al di fuori del livello di archiviazione collegabile, in modo che diverse implementazioni possano serializzare in modo diverso.

Ecco un'implementazione dell'host del dialogo.

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

Infine, ecco un'implementazione della funzione di accesso alla proprietà di stato personalizzata.

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
}

Informazioni aggiuntive

L'esempio di scalabilità orizzontale è disponibile nel repository di esempi di Bot Framework in GitHub in C#, Python e Java.