Dela via


Implementera anpassad lagring för din robot

GÄLLER FÖR: SDK v4

En robots interaktioner faller inom tre områden: utbyte av aktiviteter med Azure AI Bot Service, inläsning och sparande av robot- och dialogtillstånd med ett minneslager och integrering med serverdelstjänster.

Interaktionsdiagram som beskriver relationen mellan Azure AI Bot Service, en robot, ett minnesarkiv och andra tjänster.

I den här artikeln beskrivs hur du utökar semantiken mellan Azure AI Bot Service och robotens minnestillstånd och lagring.

Kommentar

Bot Framework JavaScript-, C#- och Python-SDK:erna fortsätter att stödjas, men Java SDK dras tillbaka med slutligt långsiktigt stöd som slutar i november 2023.

Befintliga robotar som skapats med Java SDK fortsätter att fungera.

Om du vill skapa en ny robot bör du överväga att använda Power Virtual Agents och läsa om hur du väljer rätt chattrobotlösning.

Mer information finns i Framtiden för robotbygge.

Förutsättningar

Den här artikeln fokuserar på C#-versionen av exemplet.

Bakgrund

Bot Framework SDK innehåller en standardimplementering av robottillstånd och minneslagring. Den här implementeringen passar behoven för program där delarna används tillsammans med några rader initieringskod, vilket visas i många av exemplen.

SDK är ett ramverk och inte ett program med fast beteende. Med andra ord är genomförandet av många av mekanismerna i ramverket ett standardimplementering och inte det enda möjliga genomförandet. Ramverket dikterar inte relationen mellan utbytet av aktiviteter med Azure AI Bot Service och inläsning och sparande av något robottillstånd.

I den här artikeln beskrivs ett sätt att ändra semantiken för standardtillståndet och lagringsimplementeringen när det inte fungerar riktigt för ditt program. Utskalningsexemplet ger en alternativ implementering av tillstånd och lagring som har olika semantik än standardexemplet. Den här alternativa lösningen är lika bra i ramverket. Beroende på ditt scenario kan den här alternativa lösningen vara lämpligare för det program som du utvecklar.

Beteende för standardkortet och lagringsprovidern

Med standardimplementeringen läser roboten in tillståndet som motsvarar konversationen när den tar emot en aktivitet. Sedan körs dialoglogik med det här tillståndet och den inkommande aktiviteten. När dialogrutan körs skapas en eller flera utgående aktiviteter och skickas omedelbart. När bearbetningen av dialogrutan är klar sparar roboten det uppdaterade tillståndet och skriver över det gamla tillståndet.

Sekvensdiagram som visar standardbeteendet för en robot och dess minnesarkiv.

Några saker kan dock gå fel med det här beteendet.

  • Om spara-åtgärden misslyckas av någon anledning har tillståndet implicit blivit osynkroniserat med det som användaren ser på kanalen. Användaren har sett svar från roboten och tror att tillståndet har gått framåt, men det har det inte. Det här felet kan vara värre än om tillståndsuppdateringen lyckades men användaren inte fick svarsmeddelandena.

    Sådana tillståndsfel kan påverka din konversationsdesign. Dialogrutan kan till exempel kräva extra, annars redundant, bekräftelseutbyten med användaren.

  • Om implementeringen distribueras skalas ut över flera noder kan tillståndet skrivas över av misstag. Det här felet kan vara förvirrande eftersom dialogrutan troligen har skickat aktiviteter till kanalen med bekräftelsemeddelanden.

    Överväg en pizzabeställningsrobot, där roboten ber användaren om toppningsalternativ, och användaren skickar två snabba meddelanden: en för att lägga till svamp och en för att lägga till ost. I ett utskalat scenario kan flera instanser av roboten vara aktiva och de två användarmeddelandena kan hanteras av två separata instanser på separata datorer. En sådan konflikt kallas ett konkurrenstillstånd, där en dator kan skriva över tillståndet som skrivits av en annan. Men eftersom svaren redan skickades fick användaren en bekräftelse på att både svamp och ost lades till i deras beställning. Tyvärr, när pizzan kommer, innehåller den bara svamp eller ost, men inte båda.

Optimistisk låsning

Utskalningsexemplet introducerar viss låsning runt tillståndet. Exemplet implementerar optimistisk låsning, vilket gör att varje instans kan köras som om det vore den enda som kördes och sedan söka efter samtidighetsöverträdelser. Den här låsning kan låta komplicerad, men det finns kända lösningar, och du kan använda molnlagringstekniker och rätt tilläggspunkter i Bot Framework.

Exemplet använder en HTTP-standardmekanism baserat på entitetstagghuvudet (ETag). Att förstå den här mekanismen är avgörande för att förstå koden som följer. Följande diagram illustrerar sekvensen.

Sekvensdiagram som visar ett konkurrenstillstånd, där den andra uppdateringen misslyckas.

Diagrammet innehåller två klienter som utför en uppdatering av en resurs.

  1. När en klient utfärdar en GET-begäran och en resurs returneras från servern innehåller servern ett ETag-huvud.

    ETag-huvudet är ett täckande värde som representerar resursens tillstånd. Om en resurs ändras uppdaterar servern sin ETag för resursen.

  2. När klienten vill spara en tillståndsändring utfärdar den en POST-begäran till servern med ETag-värdet i ett If-Match förhandsvillkorshuvud.

  3. Om begärans ETag-värde inte matchar serverns misslyckas förhandsvillkorskontrollen med ett 412 (villkorsfel) svar.

    Det här felet anger att det aktuella värdet på servern inte längre matchar det ursprungliga värdet som klienten kördes på.

  4. Om klienten får ett förhandsvillkorsfel får klienten vanligtvis ett nytt värde för resursen, tillämpar den uppdatering som den ville ha och försöker publicera resursuppdateringen igen.

    Den andra POST-begäran lyckas om ingen annan klient har uppdaterat resursen. Annars kan klienten försöka igen.

Den här processen kallas optimistisk eftersom klienten, när den har en resurs, fortsätter att bearbeta den – själva resursen är inte låst eftersom andra klienter kan komma åt den utan begränsningar. Eventuella konflikter mellan klienter om resursens tillstånd bestäms inte förrän bearbetningen har utförts. I ett distribuerat system är den här strategin ofta mer optimal än den motsatta pessimistiska metoden.

Den optimistiska låsningsmekanismen enligt beskrivningen förutsätter att programlogik kan göras om på ett säkert sätt. Den idealiska situationen är att dessa tjänstbegäranden är idempotent. Inom datavetenskap är en idempotent åtgärd en åtgärd som inte har någon extra effekt om den anropas mer än en gång med samma indataparametrar. Rena HTTP REST-tjänster som implementerar GET-, PUT- och DELETE-begäranden är ofta idempotent. Om en tjänstbegäran inte ger extra effekter kan begäranden köras på ett säkert sätt igen som en del av en återförsöksstrategi.

Utskalningsexemplet och resten av den här artikeln förutsätter att de serverdelstjänster som roboten använder är alla idempotenta HTTP REST-tjänster.

Buffring av utgående aktiviteter

Att skicka en aktivitet är inte en idempotent åtgärd. Aktiviteten är ofta ett meddelande som vidarebefordrar information till användaren, och att upprepa samma meddelande två eller flera gånger kan vara förvirrande eller vilseledande.

Optimistisk låsning innebär att robotlogik kan behöva köras på nytt flera gånger. Om du vill undvika att skicka en viss aktivitet flera gånger väntar du på att tillståndsuppdateringen ska lyckas innan du skickar aktiviteter till användaren. Robotlogik bör se ut ungefär som i följande diagram.

Sekvensdiagram med meddelanden som skickas efter att dialogtillståndet har sparats.

När du har skapat en återförsöksloop i din dialogkörning har du följande beteende när det uppstår ett förhandsvillkorsfel för sparandeåtgärden.

Sekvensdiagram med meddelanden som skickas efter att ett nytt försök har slutförts.

Med den här mekanismen på plats bör pizzaroboten från det tidigare exemplet aldrig skicka en felaktig positiv bekräftelse av att en pizza topping läggs till i en beställning. Även med roboten distribuerad på flera datorer serialiserar det optimistiska låsschemat effektivt tillståndsuppdateringarna. I pizzaroboten kan bekräftelsen från att lägga till ett objekt nu till och med återspegla det fullständiga tillståndet korrekt. Om användaren till exempel snabbt skriver "ost" och sedan "svamp", och dessa meddelanden hanteras av två olika instanser av roboten, kan den sista instansen som ska slutföras inkludera "en pizza med ost och svamp" som en del av svaret.

Den här nya anpassade lagringslösningen gör tre saker som standardimplementeringen i SDK:n inte gör:

  1. Den använder ETags för att identifiera konkurrens.
  2. Den försöker bearbeta igen när ett ETag-fel identifieras.
  3. Den väntar med att skicka utgående aktiviteter tills det har sparat tillståndet.

Resten av den här artikeln beskriver implementeringen av dessa tre delar.

Implementera ETag-stöd

Definiera först ett gränssnitt för vår nya butik som innehåller ETag-support. Gränssnittet hjälper dig att använda mekanismerna för beroendeinmatning i ASP.NET. Från och med gränssnittet kan du implementera separata versioner för enhetstester och för produktion. Enhetstestversionen kan till exempel köras i minnet och inte kräva någon nätverksanslutning.

Gränssnittet består av metoder för att läsa in och spara . Båda metoderna använder en nyckelparameter för att identifiera tillståndet som ska läsas in från eller sparas till lagring.

  • Inläsning returnerar tillståndsvärdet och tillhörande ETag.
  • Spara har parametrar för tillståndsvärdet och tillhörande ETag och returnerar ett booleskt värde som anger om åtgärden lyckades. Returvärdet fungerar inte som en allmän felindikator, utan i stället som en specifik indikator för förhandsvillkorsfel. Om du kontrollerar returkoden ingår logiken i återförsöksloopen.

Undvik att placera serialiseringskrav på lagringsimplementeringen för att göra lagringsimplementeringen allmänt tillämplig. Många moderna lagringstjänster stöder dock JSON som innehållstyp. I C# kan du använda JObject typen för att representera ett JSON-objekt. I JavaScript eller TypeScript är JSON ett vanligt internt objekt.

Här är en definition av det anpassade gränssnittet.

IStore.cs

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

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

Här är en implementering för Azure Blob Storage.

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

Azure Blob Storage utför mycket av arbetet. Varje metod söker efter ett specifikt undantag för att uppfylla förväntningarna hos den anropande koden.

  • Metoden LoadAsync returnerar ett null-värde som svar på ett lagringsfel med en statuskod som inte hittades .
  • Metoden SaveAsync returnerar falsesom svar på ett lagringsfel med en förutsättningsfelkod .

Implementera en återförsöksloop

Utformningen av återförsöksloopen implementerar det beteende som visas i sekvensdiagrammen.

  1. När du tar emot en aktivitet skapar du en nyckel för konversationstillståndet.

    Relationen mellan en aktivitet och konversationstillståndet är densamma för den anpassade lagringen som för standardimplementeringen. Därför kan du skapa nyckeln på samma sätt som standardtillståndsimplementeringen.

  2. Försök att läsa in konversationstillståndet.

  3. Kör robotens dialogrutor och samla in de utgående aktiviteter som ska skickas.

  4. Försök att spara konversationstillståndet.

    • När det är klart skickar du utgående aktiviteter och avslutar.

    • Vid fel upprepar du den här processen från steget för att läsa in konversationstillståndet.

      Den nya inläsningen av konversationstillståndet får ett nytt och aktuellt ETag- och konversationstillstånd. Dialogrutan körs igen och steget spara tillstånd har en chans att lyckas.

Här är en implementering för meddelandeaktivitetshanteraren.

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

Kommentar

Exemplet implementerar dialogkörning som ett funktionsanrop. En mer avancerad metod kan vara att definiera ett gränssnitt och använda beroendeinmatning. I det här exemplet betonar dock den statiska funktionen den funktionella karaktären hos den här optimistiska låsmetoden. När du implementerar viktiga delar av koden på ett funktionellt sätt kan du i allmänhet förbättra dess möjligheter att arbeta framgångsrikt i nätverk.

Implementera en utgående aktivitetsbuffert

Nästa krav är att buffrar utgående aktiviteter tills en lyckad sparande åtgärd inträffar, vilket kräver en anpassad adapterimplementering. Den anpassade SendActivitiesAsync metoden bör inte skicka aktiviteterna till användningen, utan lägga till aktiviteterna i en lista. Din dialogkod behöver inte ändras.

  • I det här scenariot stöds inte uppdateringsaktiviteten och borttagningsåtgärderna och de associerade metoderna genererar inte implementerade undantag.
  • Returvärdet från åtgärden Skicka aktiviteter används av vissa kanaler för att tillåta att en robot ändrar eller tar bort ett tidigare skickat meddelande, till exempel för att inaktivera knappar på kort som visas i kanalen. Dessa meddelandeutbyten kan bli komplicerade, särskilt när tillstånd krävs och ligger utanför omfånget för den här artikeln.
  • Dialogrutan skapar och använder det här anpassade adaptern, så att den kan buffras aktiviteter.
  • Robotens turhanterare använder en mer standard AdapterWithErrorHandler för att skicka aktiviteterna till användaren.

Här är en implementering av det anpassade adaptern.

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
}

Använda din anpassade lagring i en robot

Det sista steget är att använda dessa anpassade klasser och metoder med befintliga ramverksklasser och metoder.

  • Huvudloopen för återförsök blir en del av robotens ActivityHandler.OnMessageActivityAsync metod och innehåller din anpassade lagring via beroendeinmatning.
  • Dialogrutans värdkod läggs till DialogHost i klassen som exponerar en statisk RunAsync metod. Dialogrutans värd:
    • Tar den inkommande aktiviteten och det gamla tillståndet och returnerar sedan de resulterande aktiviteterna och det nya tillståndet.
    • Skapar det anpassade adaptern och kör i övrigt dialogrutan på samma sätt som SDK:t.
    • Skapar en egenskapsåtkomst för anpassat tillstånd, en shim som skickar dialogtillståndet till dialogsystemet. Accessorn använder referenssemantik för att skicka ett accessorhandtag till dialogsystemet.

Dricks

JSON-serialiseringen läggs till infogad i värdkoden för att hålla den utanför det pluggbara lagringsskiktet, så att olika implementeringar kan serialiseras på olika sätt.

Här är en implementering av dialogvärden.

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

Och slutligen, här är en implementering av den anpassade tillståndsegenskapsåtkomstorn.

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
}

Ytterligare information

Utskalningsexemplet är tillgängligt från Bot Framework-exempellagringsplatsen på GitHub i C#, Python och Java.