Implementieren von benutzerdefiniertem Speicher für Ihren Bot

GILT FÜR: SDK v4

Die Interaktionen eines Bots lassen sich in drei Bereiche unterteilen: der Austausch von Aktivitäten mit dem Azure KI Bot Service, das Laden und Speichern des Bots und Dialogzustands mit einem Speicher und schließlich die Integration mit Back-End-Diensten.

Interaktionsdiagramm zur Gliederung der Beziehung zwischen dem Azure AI Bot-Dienst, einem Bot, einem Speicher und anderen Diensten.

In diesem Artikel wird beschrieben, wie Sie die Semantik zwischen dem Azure KI Bot Service und dem Speicherstatus und -speicher des Bots erweitern können.

Hinweis

Die JavaScript-, C#- und Python-SDKs für Bot Framework werden weiterhin unterstützt, das Java-SDK wird jedoch eingestellt und der langfristige Support endet im November 2023.

Bestehende Bots, die mit dem Java SDK erstellt wurden, werden weiterhin funktionieren.

Wenn Sie einen neuen Bot erstellen möchten, sollten Sie den Einsatz von Power Virtual Agents in Betracht ziehen und sich über die Auswahl der richtigen Chatbot-Lösung informieren.

Weitere Informationen finden Sie unter Die Zukunft des Bot-Design.

Voraussetzungen

Dieser Artikel konzentriert sich auf die C#-Version des Beispiels.

Hintergrund

Das Bot Framework SDK enthält eine Standardimplementierung von Botzustand und Speicher. Diese Implementierung passt zu den Anforderungen von Anwendungen, in denen die Teile zusammen mit einigen Zeilen Initialisierungscode verwendet werden, wie in vielen Beispielen gezeigt.

Das SDK ist ein Framework und keine Anwendung mit festem Verhalten. Anders ausgedrückt: Die Implementierung vieler Mechanismen im Framework ist eine Standardimplementierung und nicht die einzige mögliche Implementierung. Das Framework schreibt die Beziehung zwischen dem Austausch von Aktivitäten mit dem Azure KI Bot Service und dem Laden und Speichern eines Botzustands nicht vor.

In diesem Artikel wird eine Möglichkeit zum Ändern der Semantik des Standardzustands und der Speicherimplementierung beschrieben, wenn sie für Ihre Anwendung nicht ganz funktioniert. Das Scale-Out-Beispiel stellt eine alternative Implementierung von Zustand und Speicher bereit, die eine andere Semantik als die Standardsemantik aufweist. Diese alternative Lösung fügt sich ebenso gut in das Framework ein. Je nach Szenario eignet sich diese alternative Lösung möglicherweise besser für die Anwendung, die Sie entwickeln.

Verhalten des Standardadapters und des Speicheranbieters

Bei der Standardimplementierung lädt der Bot beim Empfang einer Aktivität den der Konversation entsprechenden Status. Anschließend führt er die Dialoglogik mit diesem Zustand und der eingehenden Aktivität aus. Während der Ausführung des Dialogs werden eine oder mehrere ausgehende Aktivitäten erstellt und sofort gesendet. Wenn die Verarbeitung des Dialogs abgeschlossen ist, speichert der Bot den aktualisierten Zustand, wobei der alte Zustand überschrieben wird.

Sequenzdiagramm mit dem Standardverhalten eines Bots und seines Speichers.

Ein paar Dinge können jedoch mit diesem Verhalten schiefgehen.

  • Wenn der Speichervorgang aus irgendeinem Grund fehlschlägt, ist der Zustand implizit nicht mehr synchron mit dem, was der Benutzer im Kanal sieht. Der Benutzer hat Antworten vom Bot gesehen und ist der Ansicht, dass der Zustand vorwärts verschoben wurde, was aber nicht der Fall ist. Dieser Fehler kann schlimmer sein als, wenn die Zustandsaktualisierung erfolgreich war, der Benutzer aber die Antwortnachrichten nicht erhalten hat.

    Solche Zustandsfehler können Auswirkungen auf Ihr Unterhaltungsdesign haben. Das Dialogfeld kann z. B. zusätzliche, andernfalls redundante Bestätigungsaustausche mit dem Benutzer erfordern.

  • Wenn die Implementierung über mehrere Knoten verteilt bereitgestellt wird, kann der Zustand versehentlich überschrieben werden. Dieser Fehler kann verwirrend sein, da das Dialogfeld wahrscheinlich Aktivitäten an den Kanal gesendet hat, der Bestätigungsmeldungen enthält.

    Nehmen wir einen Bot für eine Pizzabestellung, bei der der Bot den Benutzer nach der Wahl des Belags fragt und der Benutzer zwei schnelle Nachrichten sendet: eine, um Pilze hinzuzufügen, und eine, um Käse hinzuzufügen. In einem skalierten Szenario sind möglicherweise mehrere Instances des Bots aktiv und die beiden Benutzernachrichten können von zwei separaten Instances auf separaten Computern behandelt werden. Ein solcher Konflikt wird als Racebedingung bezeichnet, bei dem ein Computer den von einem anderen geschriebenen Zustand überschreiben kann. Da die Antworten bereits gesendet wurden, hat der Benutzer jedoch die Bestätigung erhalten, dass sowohl Pilze als auch Käse zu ihrer Bestellung hinzugefügt wurden. Die gelieferte Pizza ist jedoch leider nur mit Pilzen oder nur mit Käse belegt, aber nicht mit beidem.

Optimistische Sperre

Im Scale-Out-Beispiel wird eine Sperrung des Zustands eingeführt. Im Beispiel wird eine optimistische Sperrung implementiert, wodurch jede Instance so ausgeführt werden kann, als wäre sie die einzige, die läuft, und dann auf Verstöße gegen die Gleichzeitigkeit geprüft wird. Diese Sperrung klingt möglicherweise kompliziert, aber bekannte Lösungen sind vorhanden und Sie können Cloud-Speichertechnologien und die richtigen Erweiterungspunkte im Bot Framework verwenden.

Das Beispiel verwendet einen HTTP-Standardmechanismus, der auf dem Entitätstagheader (ETag) basiert. Das Verständnis dieses Mechanismus ist entscheidend, um den folgenden Code zu verstehen. Das unten stehende Diagramm veranschaulicht die Sequenz.

Sequenzdiagramm mit einer Racebedingung, bei der das zweite Update fehlschlägt.

Das Diagramm zeigt zwei Clients, die eine Aktualisierung einer Ressource durchführen.

  1. Wenn ein Client eine GET-Anforderung ausgibt und eine Ressource vom Server zurückgegeben wird, enthält der Server einen ETag-Header.

    Der ETag-Header ist ein nicht transparenter Wert, der den Zustand der Ressource darstellt. Wenn eine Ressource geändert wird, aktualisiert der Server das ETag für die Ressource.

  2. Wenn der Client eine Zustandsänderung beibehalten möchte, gibt er eine POST-Anforderung an den Server mit dem ETag-Wert in einem If-Match-Vorbedingungsheader aus.

  3. Wenn der ETag-Wert der Anforderung nicht mit dem Server übereinstimmt, schlägt die Vorbedingungsprüfung mit einer 412-Antwort (Vorbedingung fehlgeschlagen) fehl.

    Dieser Fehler gibt an, dass der aktuelle Wert auf dem Server nicht mehr mit dem ursprünglichen Wert übereinstimmt, auf dem der Client ausgeführt wurde.

  4. Wenn der Client eine Vorbedingungsfehlerantwort empfängt, erhält der Client in der Regel einen neuen Wert für die Ressource, wendet das gewünschte Update an und versucht erneut, die Ressourcenaktualisierung zu veröffentlichen.

    Diese zweite POST-Anforderung ist erfolgreich, wenn kein anderer Client die Ressource aktualisiert hat. Andernfalls kann der Client es erneut versuchen.

Dieser Prozess wird als optimistisch bezeichnet, weil der Client, weil der Client, sobald er eine Ressource hat, mit der Verarbeitung fortfährt. Die Ressource selbst wird nicht gesperrt, da andere Clients uneingeschränkt auf sie zugreifen können. Konflikte zwischen Clients bezüglich des Zustands der Ressource sollten erst nach Abschluss der Verarbeitung ermittelt werden. In einem verteilten System ist diese Strategie oft optimaler als der entgegengesetzte pessimistische Ansatz.

Der optimistische Sperrmechanismus, wie beschrieben, geht davon aus, dass Ihre Programmlogik sicher wiederholt werden kann. Im Idealfall sind diese Service Requests idempotent. In der Informatik ist ein idempotenter Vorgang ein Vorgang, der keine zusätzlichen Auswirkungen hat, wenn er mehrmals mit den gleichen Eingabeparametern aufgerufen wird. Reine HTTP-REST-Dienste, die die GET-, PUT- und DELETE-Anforderungen implementieren, sind häufig idempotent. Wenn ein Service Request keine zusätzlichen Auswirkungen hat, können die Anforderungen im Rahmen einer Wiederholungsstrategie sicher erneut ausgeführt werden.

Das Scale-Out-Beispiel und der Rest dieses Artikels gehen davon aus, dass die von Ihrem Bot verwendeten Back-End-Dienste alle idempotente HTTP-REST-Dienste sind.

Puffern von ausgehenden Aktivitäten

Das Senden einer Aktivität ist kein idempotenter Vorgang. Bei der Aktivität handelt es sich oft um eine Nachricht, die dem Nutzer Informationen vermittelt, und die Wiederholung derselben Nachricht zwei oder mehr Mal könnte verwirrend oder irreführend sein.

Optimistisches Sperren bedeutet, dass Ihre Bot-Logik möglicherweise mehrmals erneut ausgeführt werden muss. Um zu vermeiden, dass eine bestimmte Aktivität mehrmals gesendet wird, warten Sie, bis der Zustandsaktualisierungsvorgang erfolgreich war, bevor Sie Aktivitäten an den Benutzer senden. Ihre Bot-Logik sollte in etwa wie das folgende Diagramm aussehen.

Sequenzdiagramm mit Nachrichten, die nach dem Speichern des Dialogfeldzustands gesendet werden.

Nachdem Sie eine Wiederholungs-Loop in die Ausführung des Dialogfelds erstellt haben, haben Sie das folgende Verhalten, wenn beim Speichervorgang ein Vorbedingungsfehler auftritt.

Sequenzdiagramm mit Nachrichten, die gesendet werden, nachdem ein Wiederholungsversuch erfolgreich war.

Mit diesem Mechanismus sollte der Pizzabot aus dem vorangegangenen Beispiel niemals eine fälschliche positive Bestätigung senden, dass ein Pizzabelag zu einer Bestellung hinzugefügt wurde. Auch wenn der Bot auf mehreren Computern bereitgestellt wird, serialisiert das optimistische Sperrschema die Zustandsupdates effektiv. Im Pizza-Bot kann die Bestätigung für das Hinzufügen eines Artikels nun sogar den vollständigen Zustand genau wiedergeben. Wenn der Benutzer beispielsweise schnell „Käse“ und dann „Pilz“ eingibt und diese Nachrichten von zwei verschiedenen Instances des Bots behandelt werden, kann die letzte vollständige Instance „eine Pizza mit Käse und Pilz“ als Teil ihrer Antwort enthalten.

Diese neue benutzerdefinierte Speicherlösung führt drei Dinge aus, die die Standardimplementierung im SDK nicht ausführt:

  1. Sie verwendet ETags zum Erkennen von Inhalten.
  2. Wenn ein ETag-Fehler festgestellt wird, wird die Verarbeitung wiederholt.
  3. Sie wartet darauf, ausgehende Aktivitäten zu senden, bis der Zustand erfolgreich gespeichert wurde.

Im weiteren Verlauf dieses Artikels wird die Implementierung dieser drei Teile beschrieben.

ETag-Unterstützung implementieren

Definieren Sie zunächst eine Schnittstelle für unseren neuen Store, der die ETag-Unterstützung enthält. Die Schnittstelle hilft bei der Verwendung der Abhängigkeitsinjektionsmechanismen in ASP.NET. Ab der Schnittstelle können Sie separate Versionen für Unittests und für die Produktion implementieren. So kann die Unittest-Version beispielsweise im Speicher laufen und benötigt keine Netzwerkverbindung.

Die Schnittstelle besteht aus Load- und Save-Methoden. Beide Methoden verwenden einen Schlüsselparameter, um den Zustand zu identifizieren, aus dem geladen oder im Speicher gespeichert werden soll.

  • Load gibt den Zustandswert und das zugehörige ETag zurück.
  • Save verfügt über Parameter für den Zustandswert und das zugehörige ETag und gibt einen Boole’schen Wert zurück, der angibt, ob der Vorgang erfolgreich war. Der Rückgabewert dient nicht als allgemeiner Fehlerindikator, sondern als spezifischer Indikator für den Vorbedingungsfehler. Das Überprüfen des Rückgabecodes ist Teil der Logik des Wiederholungs-Loops.

Um die Speicherimplementierung allgemein anwendbar zu machen, vermeiden Sie die Serialisierungsanforderungen. Viele moderne Speicherdienste unterstützen jedoch JSON als Inhaltstyp. In C# können Sie den JObject-Typ verwenden, um ein JSON-Objekt darzustellen. In JavaScript oder TypeScript ist JSON ein reguläres natives Objekt.

Hier ist eine Definition der benutzerdefinierten Schnittstelle.

IStore.cs

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

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

Hier ist eine Implementierung 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 erledigt einen Großteil der Arbeit. Jede Methode sucht nach einer bestimmten Ausnahme, um die Erwartungen des aufrufenden Codes zu erfüllen.

  • Die LoadAsync-Methode gibt als Antwort auf eine Speicherausnahme mit dem Statuscode nicht gefunden einen Nullwert zurück.
  • Die SaveAsync-Methode gibt als Reaktion auf eine Speicherausnahme mit einem Code für die fehlgeschlagene Vorbedingungfalse zurück.

Implementieren eines Wiederholungs-Loops

Der Entwurf des Wiederholungs-Loops implementiert das Verhalten, das in den Sequenzdiagrammen gezeigt wird.

  1. Wenn Sie eine Aktivität erhalten, erstellen Sie einen Schlüssel für den Konversationszustand.

    Die Beziehung zwischen einer Aktivität und dem Unterhaltungszustand ist für den benutzerdefinierten Speicher identisch mit der Standardimplementierung. Daher können Sie den Schlüssel auf dieselbe Weise konstruieren wie bei der Implementierung des Standardzustands.

  2. Versuchen Sie, den Unterhaltungszustand zu laden.

  3. Führen Sie die Dialogfelder des Bots aus und erfassen Sie die zu sendenden ausgehenden Aktivitäten.

  4. Versuchen Sie, den Unterhaltungszustand zu speichern.

    • Bei Erfolg senden Sie die ausgehenden Aktivitäten und beenden die Sitzung.

    • Bei einem Fehler wiederholen Sie diesen Vorgang ab dem Schritt zum Laden des Konversationszustands.

      Das neue Laden des Unterhaltungszustands erhält einen neuen und aktuellen ETag- und Unterhaltungszustand. Das Dialogfeld wird erneut ausgeführt und der Schritt zum Speichern des Zustands hat eine Chance, erfolgreich zu sein.

Hier ist eine Implementierung für den Nachrichtenaktivitätshandler.

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

Hinweis

Im Beispiel wird die Dialogausführung als Funktionsaufruf implementiert. Ein komplexerer Ansatz könnte sein, eine Schnittstelle zu definieren und Abhängigkeitsinjektion zu verwenden. In diesem Beispiel betont die statische Funktion jedoch die funktionale Natur dieses optimistischen Sperransatzes. Im Allgemeinen verbessern Sie die Chancen, erfolgreich an Netzwerken zu arbeiten, wenn Sie die entscheidenden Teile Ihres Codes auf funktionale Weise implementieren.

Implementieren eines Puffers für ausgehende Aktivitäten

Die nächste Anforderung besteht darin, ausgehende Aktivitäten zu puffern, bis nach einem erfolgreichen Speichervorgang eine benutzerdefinierte Adapterimplementierung erforderlich ist. Die benutzerdefinierte SendActivitiesAsync-Methode sollte die Aktivitäten nicht an die Anwendung senden, sondern die Aktivitäten in eine Liste aufnehmen. Ihr Dialogcode benötigt keine Änderung.

  • In diesem speziellen Szenario werden die Aktualisierungsaktivitäts- und Löschaktivitäts-Vorgänge nicht unterstützt und die zugehörigen Methoden lösen keine implementierten Ausnahmen aus.
  • Der Rückgabewert des Vorgangs Aktivitäten senden wird von einigen Kanälen verwendet, um einem Bot das Ändern oder Löschen einer zuvor gesendeten Nachricht zu ermöglichen, z. B. zum Deaktivieren von Schaltflächen auf Karten, die im Kanal angezeigt werden. Dieser Nachrichtenaustausch kann insbesondere dann kompliziert sein, wenn der Zustand erforderlich ist und in diesem Artikel nicht beschrieben wird.
  • Das Dialogfeld erstellt und verwendet diesen benutzerdefinierten Adapter, sodass er Aktivitäten puffern kann.
  • Der Turn-Handler Ihres Bots wird ein standardmäßiges AdapterWithErrorHandler verwenden, um die Aktivitäten an den Benutzer zu senden.

Hier ist eine Implementierung des benutzerdefinierten Adapters.

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
}

Verwenden Sie Ihren benutzerdefinierten Speicher in einem Bot

Der letzte Schritt besteht darin, diese benutzerdefinierten Klassen und Methoden mit vorhandenen Frameworkklassen und -methoden zu verwenden.

  • Der Haupt-Wiederholungs-Loop wird Teil der ActivityHandler.OnMessageActivityAsync-Methode Ihres Bots und umfasst ihren benutzerdefinierten Speicher durch Abhängigkeitsinjektion.
  • Der Dialoghostingcode wird der DialogHost-Klasse hinzugefügt, die eine statische RunAsync-Methode verfügbar macht. Der Dialoghost:
    • Nimmt die eingehende Aktivität und den alten Zustand auf und gibt dann die resultierenden Aktivitäten und den neuen Zustand zurück.
    • Erstellt den benutzerdefinierten Adapter und führt andernfalls den Dialog auf die gleiche Weise aus wie das SDK.
    • Erstellt einen benutzerdefinierten Zustandseigenschaftsaccessor, einen Shim, der den Dialogzustand an das Dialogfeldsystem übergibt. Der Accessor verwendet Referenzsemantik, um ein Accessorhandle an das Dialogfeldsystem zu übergeben.

Tipp

Die JSON-Serialisierung wird dem Hostingcode inline hinzugefügt, um sie außerhalb der austauschbaren Speicherebene beizubehalten, sodass unterschiedliche Implementierungen unterschiedlich serialisiert werden können.

Hier ist eine Implementierung des Dialoghosts.

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

Und schließlich ist hier eine Implementierung des Accessors für die benutzerdefinierte Zustandseigenschaft.

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
}

Weitere Informationen

Das Scale-Out-Beispiel ist aus dem Bot-Framework-Beispiel-Repository auf GitHub in C#, Python und Java verfügbar.