Implement custom storage for your bot (Aangepaste opslag voor uw bot implementeren)
VAN TOEPASSING OP: SDK v4
De interacties van een bot vallen in drie gebieden: de uitwisseling van activiteiten met Azure AI Bot Service, het laden en opslaan van bot en dialoogvensterstatus met een geheugenopslag en integratie met back-endservices.
In dit artikel wordt beschreven hoe u de semantiek tussen de Azure AI Bot Service en de geheugenstatus en opslag van de bot kunt uitbreiden.
Notitie
De Sdk's voor Bot Framework JavaScript, C# en Python blijven ondersteund, maar de Java SDK wordt buiten gebruik gesteld met definitieve langetermijnondersteuning die eindigt op november 2023.
Bestaande bots die zijn gebouwd met de Java SDK blijven functioneren.
Voor het bouwen van nieuwe bots kunt u Microsoft Copilot Studio gebruiken en lezen over het kiezen van de juiste copilot-oplossing.
Zie De toekomst van botbouw voor meer informatie.
Vereisten
- Kennis van basisbeginselen van het Microsoft Bot Framework, gebeurtenisgestuurde gesprekken met behulp van een activiteitshandler en beheerstatus.
- Een kopie van het uitschaalvoorbeeld in C#, Python of Java.
Dit artikel is gericht op de C#-versie van het voorbeeld.
Achtergrond
De Bot Framework SDK bevat een standaard implementatie van botstatus en geheugenopslag. Deze implementatie past bij de behoeften van toepassingen waarbij de onderdelen samen met een paar regels initialisatiecode worden gebruikt, zoals in veel van de voorbeelden is gedemonstreerd.
De SDK is een framework en geen toepassing met vast gedrag. Met andere woorden, de implementatie van veel van de mechanismen in het kader is een standaard implementatie en niet de enige mogelijke implementatie. Het framework bepaalt niet de relatie tussen de uitwisseling van activiteiten met Azure AI Bot Service en het laden en opslaan van een botstatus.
In dit artikel wordt een manier beschreven om de semantiek van de standaardstatus en opslag-implementatie te wijzigen wanneer deze niet helemaal werkt voor uw toepassing. Het uitschaalvoorbeeld biedt een alternatieve implementatie van status en opslag met verschillende semantiek dan de standaardwaarden. Deze alternatieve oplossing is even goed in het kader. Afhankelijk van uw scenario is deze alternatieve oplossing mogelijk geschikter voor de toepassing die u ontwikkelt.
Gedrag van de standaardadapter en opslagprovider
Bij de standaard implementatie laadt de bot bij het ontvangen van een activiteit de status die overeenkomt met het gesprek. Vervolgens wordt de dialoogvensterlogica uitgevoerd met deze status en de binnenkomende activiteit. Tijdens het uitvoeren van het dialoogvenster worden een of meer uitgaande activiteiten gemaakt en onmiddellijk verzonden. Wanneer de verwerking van het dialoogvenster is voltooid, wordt de bijgewerkte status door de bot opgeslagen en wordt de oude status overschreven.
Er kunnen echter enkele dingen misgaan met dit gedrag.
Als de opslagbewerking om een of andere reden mislukt, is de status impliciet niet gesynchroniseerd met wat de gebruiker in het kanaal ziet. De gebruiker heeft reacties van de bot gezien en is van mening dat de status vooruit is gegaan, maar niet. Deze fout kan erger zijn dan als de statusupdate is geslaagd, maar de gebruiker de antwoordberichten niet heeft ontvangen.
Dergelijke statusfouten kunnen gevolgen hebben voor uw gespreksontwerp. Het dialoogvenster kan bijvoorbeeld extra, anders redundante bevestigingsuitwisselingen met de gebruiker vereisen.
Als de implementatie wordt geïmplementeerd op meerdere knooppunten, kan de status per ongeluk worden overschreven. Deze fout kan verwarrend zijn omdat het dialoogvenster waarschijnlijk activiteiten heeft verzonden naar het kanaal met bevestigingsberichten.
Overweeg een pizzaorderbot, waarbij de bot de gebruiker vraagt om topping-keuzes en de gebruiker twee snelle berichten verzendt: één om champignons toe te voegen en één om kaas toe te voegen. In een uitgeschaald scenario kunnen meerdere exemplaren van de bot actief zijn en kunnen de twee gebruikersberichten worden verwerkt door twee afzonderlijke exemplaren op afzonderlijke computers. Een dergelijk conflict wordt aangeduid als een racevoorwaarde, waarbij de ene machine de status die door een andere machine is geschreven, kan overschrijven. Omdat de antwoorden echter al zijn verzonden, heeft de gebruiker bevestiging ontvangen dat zowel champignon als kaas aan hun bestelling zijn toegevoegd. Helaas, wanneer de pizza aankomt, bevat het alleen champignon of kaas, maar niet beide.
Optimistische vergrendeling
In het uitschaalvoorbeeld wordt een aantal vergrendelingen rond de status geïntroduceerd. In het voorbeeld wordt optimistische vergrendeling geïmplementeerd, waardoor elk exemplaar kan worden uitgevoerd alsof het de enige is die wordt uitgevoerd en vervolgens controleert op eventuele gelijktijdigheidsschendingen. Deze vergrendeling klinkt mogelijk ingewikkeld, maar bekende oplossingen bestaan en u kunt cloudopslagtechnologieën en de juiste uitbreidingspunten in het Bot Framework gebruiken.
In het voorbeeld wordt een standaard HTTP-mechanisme gebruikt op basis van de entiteitstagheader (ETag). Het is van cruciaal belang om inzicht te krijgen in de code die volgt. In het volgende diagram ziet u de volgorde.
Het diagram heeft twee clients die een update uitvoeren voor een bepaalde resource.
Wanneer een client een GET-aanvraag uitgeeft en een resource wordt geretourneerd van de server, bevat de server een ETag-header.
De ETag-header is een ondoorzichtige waarde die de status van de resource vertegenwoordigt. Als een resource wordt gewijzigd, wordt de ETag van de server bijgewerkt voor de resource.
Wanneer de client een statuswijziging wil behouden, wordt er een POST-aanvraag naar de server verzonden met de ETag-waarde in een
If-Match
voorwaardeheader.Als de ETag-waarde van de aanvraag niet overeenkomt met de server, mislukt de voorwaardecontrole met een
412
(voorwaarde mislukt) antwoord.Deze fout geeft aan dat de huidige waarde op de server niet meer overeenkomt met de oorspronkelijke waarde waarop de client werkt.
Als de client een voorwaarde voor een mislukt antwoord ontvangt, krijgt de client doorgaans een nieuwe waarde voor de resource, past de gewenste update toe en probeert de resource-update opnieuw te posten.
Deze tweede POST-aanvraag slaagt als er geen andere client de resource heeft bijgewerkt. Anders kan de client het opnieuw proberen.
Dit proces wordt optimistisch genoemd omdat de client, zodra deze een resource heeft, de verwerking ervan uitvoert: de resource zelf is niet vergrendeld, omdat andere clients er zonder enige beperking toegang toe hebben. Conflicten tussen clients over wat de status van de resource moet zijn, wordt pas bepaald als de verwerking is uitgevoerd. In een gedistribueerd systeem is deze strategie vaak optimaler dan de tegenovergestelde pessimistische benadering.
Bij het optimistische vergrendelingsmechanisme zoals beschreven, wordt ervan uitgegaan dat uw programmalogica veilig opnieuw kan worden geprobeerd. De ideale situatie is waar deze serviceaanvragen idempotent zijn. In computerwetenschappen is een idempotente bewerking een bewerking die geen extra effect heeft als deze meerdere keren wordt aangeroepen met dezelfde invoerparameters. Pure HTTP REST-services die de GET-, PUT- en DELETE-aanvragen implementeren, zijn vaak idempotent. Als een serviceaanvraag geen extra effecten produceert, kunnen aanvragen veilig opnieuw worden uitgevoerd als onderdeel van een strategie voor opnieuw proberen.
In het uitschaalvoorbeeld en de rest van dit artikel wordt ervan uitgegaan dat de back-endservices die uw bot gebruikt, alle idempotente HTTP REST-services zijn.
Uitgaande activiteiten bufferen
Het verzenden van een activiteit is geen idempotente bewerking. De activiteit is vaak een bericht dat informatie doorgeeft aan de gebruiker en het herhalen van hetzelfde bericht twee of meer keren verwarrend of misleidend kan zijn.
Optimistische vergrendeling impliceert dat uw botlogica mogelijk meerdere keren opnieuw moet worden uitgevoerd. Als u wilt voorkomen dat een bepaalde activiteit meerdere keren wordt verzonden, wacht u totdat de statusupdatebewerking is voltooid voordat u activiteiten naar de gebruiker verzendt. Uw botlogica moet er ongeveer uitzien als in het volgende diagram.
Wanneer u een lus voor opnieuw proberen inbouwt in de uitvoering van het dialoogvenster, hebt u het volgende gedrag wanneer er een voorwaardefout optreedt in de opslagbewerking.
Met dit mechanisme zou de pizzabot uit het eerdere voorbeeld nooit een onjuiste positieve bevestiging moeten sturen van een pizza-topping die wordt toegevoegd aan een bestelling. Zelfs als de bot op meerdere computers is geïmplementeerd, worden de statusupdates in het optimistische vergrendelingsschema effectief geserialiseerd. In de pizzabot kan de bevestiging van het toevoegen van een item nu zelfs de volledige status nauwkeurig weergeven. Als de gebruiker bijvoorbeeld snel 'kaas' en vervolgens 'champignon' typt en deze berichten worden verwerkt door twee verschillende exemplaren van de bot, kan het laatste exemplaar dat moet worden voltooid 'een pizza met kaas en paddestoel' bevatten als onderdeel van de reactie.
Deze nieuwe aangepaste opslagoplossing doet drie dingen die de standaard implementatie in de SDK niet doet:
- Er wordt gebruikgemaakt van ETags om conflicten te detecteren.
- De verwerking wordt opnieuw uitgevoerd wanneer er een ETag-fout wordt gedetecteerd.
- Er wordt gewacht tot uitgaande activiteiten worden verzonden totdat de status is opgeslagen.
In de rest van dit artikel wordt de implementatie van deze drie onderdelen beschreven.
ETag-ondersteuning implementeren
Definieer eerst een interface voor onze nieuwe winkel met ETag-ondersteuning. De interface helpt bij het gebruik van de mechanismen voor afhankelijkheidsinjectie in ASP.NET. Vanaf de interface kunt u afzonderlijke versies implementeren voor eenheidstests en voor productie. De testversie van de eenheid kan bijvoorbeeld in het geheugen worden uitgevoerd en er is geen netwerkverbinding vereist.
De interface bestaat uit methoden voor laden en opslaan . Beide methoden gebruiken een sleutelparameter om de status te identificeren die moet worden geladen van of opgeslagen in de opslag.
- Laden retourneert de statuswaarde en de bijbehorende ETag.
- Opslaan bevat parameters voor de statuswaarde en de bijbehorende ETag en retourneert een Booleaanse waarde die aangeeft of de bewerking is geslaagd. De retourwaarde fungeert niet als een algemene foutindicator, maar in plaats daarvan als een specifieke indicator van voorwaardefout. Het controleren van de retourcode maakt deel uit van de logica van de lus voor opnieuw proberen.
Om de opslag-implementatie algemeen van toepassing te maken, vermijdt u het plaatsen van serialisatievereisten erop.
Veel moderne opslagservices ondersteunen echter JSON als het inhoudstype.
In C# kunt u het JObject
type gebruiken om een JSON-object weer te geven.
In JavaScript of TypeScript is JSON een normaal systeemeigen object.
Hier volgt een definitie van de aangepaste interface.
IStore.cs
public interface IStore
{
Task<(JObject content, string etag)> LoadAsync(string key);
Task<bool> SaveAsync(string key, JObject content, string etag);
}
Hier volgt een implementatie voor 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 doet veel van het werk. Elke methode controleert op een specifieke uitzondering om te voldoen aan de verwachtingen van de aanroepende code.
- De
LoadAsync
methode retourneert als reactie op een opslagonderzondering met een niet-gevonden statuscode een null-waarde. - De
SaveAsync
methode retourneertfalse
als reactie op een opslagonderzondering met een voorwaarde mislukte code.
Een lus voor opnieuw proberen implementeren
Het ontwerp van de lus voor opnieuw proberen implementeert het gedrag dat wordt weergegeven in de sequentiediagrammen.
Bij het ontvangen van een activiteit maakt u een sleutel voor de gespreksstatus.
De relatie tussen een activiteit en de gespreksstatus is hetzelfde voor de aangepaste opslag als voor de standaard implementatie. Daarom kunt u de sleutel op dezelfde manier samenstellen als de standaardstatus-implementatie.
Probeer de gespreksstatus te laden.
Voer de dialoogvensters van de bot uit en leg de uitgaande activiteiten vast die moeten worden verzonden.
Probeer de gespreksstatus op te slaan.
Verzend bij succes de uitgaande activiteiten en sluit deze af.
Bij een fout herhaalt u dit proces uit de stap om de gespreksstatus te laden.
De nieuwe belasting van de gespreksstatus krijgt een nieuwe en huidige ETag- en gespreksstatus. Het dialoogvenster wordt opnieuw uitgevoerd en de stap Status opslaan heeft de kans om te slagen.
Hier volgt een implementatie voor de berichtactiviteit-handler.
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;
}
}
}
Notitie
In het voorbeeld wordt de uitvoering van dialoogvensters geïmplementeerd als een functie-aanroep. Een geavanceerdere benadering kan zijn om een interface te definiëren en afhankelijkheidsinjectie te gebruiken. In dit voorbeeld benadrukt de statische functie echter de functionele aard van deze optimistische vergrendelingsbenadering. Wanneer u de cruciale onderdelen van uw code op een functionele manier implementeert, verbetert u de kansen om succesvol te werken aan netwerken.
Een buffer voor uitgaande activiteiten implementeren
De volgende vereiste is om uitgaande activiteiten te bufferen totdat een geslaagde opslagbewerking plaatsvindt. Hiervoor is een aangepaste adapter-implementatie vereist.
De aangepaste SendActivitiesAsync
methode mag de activiteiten niet naar het gebruik verzenden, maar de activiteiten toevoegen aan een lijst.
De dialoogvenstercode hoeft niet te worden gewijzigd.
- In dit specifieke scenario worden de update-activiteit - en verwijderactiviteitsbewerkingen niet ondersteund en de bijbehorende methoden genereren geen geïmplementeerde uitzonderingen.
- De retourwaarde van de verzendactiviteitenbewerking wordt door sommige kanalen gebruikt om een bot toe te staan een eerder verzonden bericht te wijzigen of te verwijderen, bijvoorbeeld om knoppen uit te schakelen op kaarten die in het kanaal worden weergegeven. Deze berichtenuitwisseling kan ingewikkeld worden, met name wanneer de status is vereist en valt buiten het bereik van dit artikel.
- Uw dialoogvenster maakt en gebruikt deze aangepaste adapter, zodat deze activiteiten kan bufferen.
- De draaihandler van uw bot gebruikt een meer standaard
AdapterWithErrorHandler
om de activiteiten naar de gebruiker te verzenden.
Hier volgt een implementatie van de aangepaste adapter.
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
}
Uw aangepaste opslag gebruiken in een bot
De laatste stap is het gebruik van deze aangepaste klassen en methoden met bestaande frameworkklassen en -methoden.
- De belangrijkste lus voor opnieuw proberen wordt onderdeel van de methode van
ActivityHandler.OnMessageActivityAsync
uw bot en bevat uw aangepaste opslag via afhankelijkheidsinjectie. - De dialoogvensterhostingcode wordt toegevoegd aan
DialogHost
klasse die een statischeRunAsync
methode beschikbaar maakt. De dialoogvensterhost:- Neemt de inkomende activiteit en de oude status en retourneert vervolgens de resulterende activiteiten en nieuwe status.
- Hiermee maakt u de aangepaste adapter en wordt het dialoogvenster op dezelfde manier uitgevoerd als de SDK.
- Hiermee maakt u een accessor voor aangepaste statuseigenschappen, een shim die de dialoogvensterstatus doorgeeft aan het dialoogvenstersysteem. De accessor maakt gebruik van verwijzingssemantiek om een toegangsgreep door te geven aan het dialoogvenstersysteem.
Tip
De JSON-serialisatie wordt inline toegevoegd aan de hostingcode om deze buiten de pluggable-opslaglaag te houden, zodat verschillende implementaties anders kunnen worden geserialiseerd.
Hier volgt een implementatie van de dialoogvensterhost.
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) } };
}
}
En ten slotte is hier een implementatie van de toegangsoptie voor aangepaste statuseigenschappen.
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
}
Aanvullende informatie
Het uitschaalvoorbeeld is beschikbaar in de opslagplaats voor Bot Framework-voorbeelden op GitHub in C#, Python en Java.