봇에 사용자 지정 스토리지 구현

적용 대상: SDK v4

봇의 상호 작용은 Azure AI Bot Service와의 활동 교환, 봇 로드 및 저장, 메모리 저장소와의 대화 상태 저장, 백 엔드 서비스와의 통합의 세 가지 영역으로 나타니다.

Azure AI Bot Service, 봇, 메모리 저장소 및 기타 서비스 간의 관계를 간략하게 설명하는 상호 작용 다이어그램

이 문서에서는 Azure AI Bot Service와 봇의 메모리 상태 및 스토리지 간에 의미 체계를 확장하는 방법을 살펴봅니다.

참고 항목

Bot Framework JavaScript, C#및 Python SDK는 계속 지원되지만 Java SDK는 2023년 11월에 종료되는 최종 장기 지원으로 사용 중지됩니다.

Java SDK를 사용하여 빌드된 기존 봇은 계속 작동합니다.

새 봇 빌드의 경우 Power Virtual Agents 사용을 고려하고 올바른 챗봇 솔루션을 선택하는 방법을 읽어 보세요.

자세한 내용은 봇 빌드의 미래를 참조 하세요.

필수 조건

이 문서에서는 샘플의 C# 버전에 중점을 둡니다.

배경

Bot Framework SDK에는 봇 상태 및 메모리 스토리지의 기본 구현이 포함되어 있습니다. 이 구현은 많은 샘플에서 설명한 대로 조각이 몇 줄의 초기화 코드와 함께 사용되는 애플리케이션의 요구에 적합합니다.

SDK는 프레임워크이며 고정 동작이 있는 애플리케이션이 아닙니다. 즉, 프레임워크에서 많은 메커니즘을 구현하는 것은 기본 구현이며 가능한 유일한 구현은 아닙니다. 프레임워크는 Azure AI Bot Service와의 활동 교환과 봇 상태의 로드 및 저장 간의 관계를 결정하지 않습니다.

이 문서에서는 애플리케이션에서 제대로 작동하지 않는 경우 기본 상태 및 스토리지 구현의 의미 체계를 수정하는 한 가지 방법을 설명합니다. 스케일 아웃 샘플은 기본 의미 체계와 다른 의미 체계를 포함하는 상태 및 스토리지의 대체 구현을 제공합니다. 이 대체 솔루션은 프레임워크에서 동일하게 잘 유지됩니다. 시나리오에 따라 이 대체 솔루션이 개발 중인 애플리케이션에 더 적합할 수 있습니다.

기본 어댑터 및 스토리지 공급자의 동작

기본 구현을 사용하여 활동을 수신할 때 봇은 대화에 해당하는 상태를 로드합니다. 그런 다음 이 상태 및 인바운드 작업을 사용하여 대화 논리를 실행합니다. 대화 상자를 실행하는 과정에서 하나 이상의 아웃바운드 활동이 만들어지고 즉시 전송됩니다. 대화 상자 처리가 완료되면 봇은 업데이트된 상태를 저장하고 이전 상태를 덮어씁니다.

봇 및 해당 메모리 저장소의 기본 동작을 보여 주는 시퀀스 다이어그램

그러나 이 동작에는 몇 가지 문제가 될 수 있습니다.

  • 어떤 이유로 저장 작업이 실패하면 상태는 사용자가 채널에서 보는 것과 동기화되지 않습니다. 사용자는 봇의 응답을 보았으며 상태가 앞으로 이동했지만 그렇지 않다고 믿습니다. 이 오류는 상태 업데이트가 성공했지만 사용자가 응답 메시지를 받지 못한 경우보다 더 나쁠 수 있습니다.

    이러한 상태 오류는 대화 디자인에 영향을 미칠 수 있습니다. 예를 들어 대화 상자에는 사용자와의 추가적인 중복 확인 교환이 필요할 수 있습니다.

  • 구현이 여러 노드에 걸쳐 확장된 경우 상태를 실수로 덮어쓸 수 있습니다. 대화 상자가 확인 메시지를 전달하는 채널에 활동을 보냈을 가능성이 있으므로 이 오류는 혼동될 수 있습니다.

    봇이 사용자에게 토핑 선택을 요청하고 사용자가 버섯을 추가하는 메시지와 치즈를 추가하는 두 개의 빠른 메시지를 보내는 피자 주문 봇을 고려해 보세요. 스케일 아웃 시나리오에서는 봇의 여러 인스턴스가 활성화될 수 있으며, 두 개의 사용자 메시지는 별도의 컴퓨터에서 두 개의 개별 인스턴스에 의해 처리될 수 있습니다. 이러한 충돌은 한 컴퓨터가 다른 컴퓨터에서 작성한 상태를 덮어쓸 수 있는 경합 상태라고 합니다. 그러나 응답이 이미 전송되었기 때문에 사용자는 버섯과 치즈가 모두 주문에 추가되었다는 확인을 받았습니다. 불행히도 피자가 도착하면 버섯이나 치즈만 들어 있지만 둘 다 들어 있지는 않습니다.

낙관적 잠금

스케일 아웃 샘플에서는 상태 주위의 일부 잠금을 소개합니다. 이 샘플은 낙관적 잠금을 구현하여 각 인스턴스가 실행 중인 유일한 인스턴스인 것처럼 실행한 다음 동시성 위반에 대해 검사 수 있습니다. 이 잠금은 복잡해 보일 수 있지만 알려진 솔루션이 존재하며, Bot Framework에서 클라우드 스토리지 기술 및 올바른 확장 지점을 사용할 수 있습니다.

이 샘플에서는 ETag(엔터티 태그 헤더)를 기반으로 하는 표준 HTTP 메커니즘을 사용합니다. 이 메커니즘을 이해하는 것은 다음 코드를 이해하는 데 중요합니다. 다음 다이어그램에서는 시퀀스를 보여 줍니다.

두 번째 업데이트가 실패하는 경합 상태를 보여 주는 시퀀스 다이어그램

다이어그램에는 일부 리소스에 대한 업데이트를 수행하는 두 개의 클라이언트가 있습니다.

  1. 클라이언트가 GET 요청을 발급하고 서버에서 리소스가 반환되면 서버에 ETag 헤더가 포함됩니다.

    ETag 헤더는 리소스의 상태를 나타내는 불투명 값입니다. 리소스가 변경되면 서버는 리소스에 대한 ETag를 업데이트합니다.

  2. 클라이언트가 상태 변경을 유지하려는 경우 ETag 값을 If-Match 사전 조건 헤더에 사용하여 서버에 POST 요청을 실행합니다.

  3. 요청의 ETag 값이 서버와 일치하지 않으면 사전 조건 검사 (사전 조건 실패) 응답으로 실패합니다 412 .

    이 오류는 서버의 현재 값이 클라이언트가 작동하던 원래 값과 더 이상 일치하지 않음을 나타냅니다.

  4. 클라이언트가 사전 조건 실패 응답을 수신하는 경우 클라이언트는 일반적으로 리소스에 대한 새 값을 가져오고, 원하는 업데이트를 적용하고, 리소스 업데이트를 다시 게시하려고 시도합니다.

    다른 클라이언트가 리소스를 업데이트하지 않은 경우 이 두 번째 POST 요청이 성공합니다. 그렇지 않으면 클라이언트가 다시 시도할 수 있습니다.

이 프로세스는 클라이언트가 리소스가 있으면 처리를 계속하기 때문에 낙관적이라고 합니다. 다른 클라이언트는 제한 없이 액세스할 수 있으므로 리소스 자체가 잠기지 않습니다. 처리가 완료될 때까지 리소스 상태를 둘러싼 클라이언트 간의 경합은 결정되지 않습니다. 분산 시스템에서 이 전략은 종종 반대 의 비관적 접근 방식보다 더 최적입니다 .

설명된 낙관적 잠금 메커니즘은 프로그램 논리를 안전하게 다시 시도 할 수 있다고 가정합니다. 이상적인 상황은 이러한 서비스 요청이 idempotent인 경우입니다. 컴퓨터 과학에서 멱등 연산은 동일한 입력 매개 변수를 사용하여 두 번 이상 호출되는 경우 추가 효과가 없는 작업입니다. GET, PUT 및 DELETE 요청을 구현하는 순수 HTTP REST 서비스는 종종 idempotent입니다. 서비스 요청이 추가 효과를 생성하지 않는 경우 재시도 전략의 일환으로 요청을 안전하게 다시 실행할 수 있습니다.

이 문서의 스케일 아웃 샘플 및 re기본der는 봇이 사용하는 백 엔드 서비스가 모두 멱등 HTTP REST 서비스라고 가정합니다.

아웃바운드 활동 버퍼링

활동을 보내는 작업은 멱등원 작업이 아닙니다. 활동은 종종 사용자에게 정보를 릴레이하는 메시지이며, 동일한 메시지를 두 번 이상 반복하면 혼동되거나 오해의 소지가 있을 수 있습니다.

낙관적 잠금은 봇 논리를 여러 번 다시 실행해야 할 수 있음을 의미합니다. 지정된 활동을 여러 번 보내지 않도록 하려면 사용자에게 활동을 보내기 전에 상태 업데이트 작업이 성공할 때까지 기다립니다. 봇 논리는 다음 다이어그램과 같이 표시됩니다.

대화 상자 상태를 저장한 후 메시지가 전송되는 시퀀스 다이어그램

대화 상자 실행에 재시도 루프를 빌드하면 저장 작업에 사전 조건 오류가 발생할 때 다음과 같은 동작이 발생합니다.

재시도에 성공한 후 메시지가 전송되는 시퀀스 다이어그램

이 메커니즘을 사용하면 이전 예제의 피자 봇이 주문에 추가되는 피자 토핑에 대해 잘못된 긍정 승인을 보내서는 안 됩니다. 여러 컴퓨터에 배포된 봇이 있더라도 낙관적 잠금 체계는 상태 업데이트를 효과적으로 직렬화합니다. 피자 봇에서 항목 추가에 대한 승인은 이제 전체 상태를 정확하게 반영할 수도 있습니다. 예를 들어 사용자가 신속하게 "치즈"와 "버섯"을 입력하고 이러한 메시지가 봇의 두 다른 인스턴스에서 처리되는 경우 완료할 마지막 인스턴스에는 응답의 일부로 "치즈와 버섯이 있는 피자"가 포함될 수 있습니다.

이 새로운 사용자 지정 스토리지 솔루션은 SDK의 기본 구현에서 수행하지 않는 세 가지 작업을 수행합니다.

  1. ETag를 사용하여 경합을 검색합니다.
  2. ETag 오류가 감지되면 처리를 다시 시도합니다.
  3. 상태가 성공적으로 저장될 때까지 아웃바운드 활동을 보내기 위해 대기합니다.

이 문서의 나머지 부분에서는 이러한 세 부분의 구현에 대해 설명합니다.

ETag 지원 구현

먼저 ETag 지원을 포함하는 새 저장소에 대한 인터페이스를 정의합니다. 인터페이스는 ASP.NET 종속성 주입 메커니즘을 사용하는 데 도움이 됩니다. 인터페이스부터 단위 테스트 및 프로덕션에 대해 별도의 버전을 구현할 수 있습니다. 예를 들어 단위 테스트 버전은 메모리에서 실행될 수 있으며 네트워크 연결이 필요하지 않을 수 있습니다.

인터페이스는 로드저장 메서드로 구성됩니다. 두 방법 모두 키 매개 변수를 사용하여 스토리지에서 로드하거나 스토리지에 저장할 상태를 식별합니다.

  • Load 는 상태 값 및 연결된 ETag를 반환합니다.
  • 저장 에는 상태 값 및 연결된 ETag에 대한 매개 변수가 있으며 작업이 성공했는지 여부를 나타내는 부울 값이 반환됩니다. 반환 값은 일반적인 오류 지표가 아니라 사전 조건 실패의 특정 지표로 작용합니다. 반환 코드를 확인하면 재시도 루프 논리의 일부가 됩니다.

스토리지 구현을 광범위하게 적용하려면 직렬화 요구 사항을 적용하지 마십시오. 그러나 많은 최신 스토리지 서비스는 JSON을 콘텐츠 형식으로 지원합니다. C#에서 형식을 JObject 사용하여 JSON 개체를 나타낼 수 있습니다. JavaScript 또는 TypeScript에서 JSON은 일반 네이티브 개체입니다.

사용자 지정 인터페이스의 정의는 다음과 같습니다.

IStore.cs

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

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

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는 많은 작업을 수행합니다. 각 메서드는 호출 코드의 예상을 충족하기 위해 특정 예외를 검사.

  • 찾을 수 없는 상태 코드가 있는 스토리지 예외에 대한 응답으로 메서드는 LoadAsync null 값을 반환합니다.
  • SaveAsync 사전 조건 실패 코드가 있는 스토리지 예외에 대한 응답으로 메서드가 반환됩니다false.

다시 시도 루프 구현

다시 시도 루프의 디자인은 시퀀스 다이어그램에 표시된 동작을 구현합니다.

  1. 활동을 수신할 때 대화 상태에 대한 키를 만듭니다.

    작업과 대화 상태 간의 관계는 기본 구현과 사용자 지정 스토리지에 대해 동일합니다. 따라서 기본 상태 구현과 동일한 방식으로 키를 생성할 수 있습니다.

  2. 대화 상태를 로드하려고 시도합니다.

  3. 봇의 대화 상자를 실행하고 보낼 아웃바운드 활동을 캡처합니다.

  4. 대화 상태를 저장하려고 시도합니다.

    • 성공하면 아웃바운드 활동을 보내고 종료합니다.

    • 실패 시 단계에서 이 프로세스를 반복하여 대화 상태를 로드합니다.

      대화 상태의 새 로드는 새 ETag 및 대화 상태를 가져옵니다. 대화 상자가 다시 실행되고 저장 상태 단계에서 성공할 수 있습니다.

다음은 메시지 활동 처리기에 대한 구현입니다.

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

참고 항목

이 샘플에서는 대화 상자 실행을 함수 호출로 구현합니다. 보다 정교한 방법은 인터페이스를 정의하고 종속성 주입을 사용하는 것입니다. 그러나 이 예제에서는 정적 함수가 이 낙관적 잠금 접근 방식의 기능적 특성을 강조합니다. 일반적으로 코드의 중요한 부분을 기능적인 방식으로 구현하면 네트워크에서 성공적으로 작동할 가능성이 높아질 수 있습니다.

아웃바운드 활동 버퍼 구현

다음 요구 사항은 성공적으로 저장 작업이 수행될 때까지 아웃바운드 작업을 버퍼링하는 것이며, 사용자 지정 어댑터 구현이 필요합니다. 사용자 지정 SendActivitiesAsync 메서드는 용도로 활동을 보내지 말고 목록에 활동을 추가해야 합니다. 대화 상자 코드는 수정할 필요가 없습니다.

  • 이 특정 시나리오에서는 업데이트 작업삭제 작업 작업이 지원되지 않으며 연결된 메서드는 구현되지 않은 예외를 throw합니다.
  • 보내기 활동 작업의 반환 값은 봇이 이전에 보낸 메시지를 수정하거나 삭제할 수 있도록 일부 채널에서 사용되며, 예를 들어 채널에 표시되는 카드 단추를 사용하지 않도록 설정합니다. 이러한 메시지 교환은 특히 상태가 필요한 경우 복잡해질 수 있으며 이 문서의 범위를 벗어납니다.
  • 대화 상자에서 이 사용자 지정 어댑터를 만들고 사용하므로 활동을 버퍼링할 수 있습니다.
  • 봇의 턴 처리기는 더 많은 표준을 AdapterWithErrorHandler 사용하여 사용자에게 활동을 보냅니다.

다음은 사용자 지정 어댑터의 구현입니다.

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
}

봇에서 사용자 지정 스토리지 사용

마지막 단계는 기존 프레임워크 클래스 및 메서드에서 이러한 사용자 지정 클래스 및 메서드를 사용하는 것입니다.

  • 기본 재시도 루프는 봇의 ActivityHandler.OnMessageActivityAsync 메서드의 일부가 되며 종속성 주입을 통해 사용자 지정 스토리지를 포함합니다.
  • 대화 상자 호스팅 코드는 정적 RunAsync 메서드를 DialogHost 노출하는 클래스에 추가됩니다. 대화 상자 호스트:
    • 인바운드 활동과 이전 상태를 취한 다음 결과 활동과 새 상태를 반환합니다.
    • 사용자 지정 어댑터를 만들고 그렇지 않으면 SDK와 동일한 방식으로 대화 상자를 실행합니다.
    • 대화 상자를 대화 시스템으로 전달하는 shim인 사용자 지정 상태 속성 접근자를 만듭니다. 접근자가 참조 의미 체계를 사용하여 접근자 핸들을 대화 시스템에 전달합니다.

JSON 직렬화는 호스팅 코드에 인라인으로 추가되어 플러그형 스토리지 계층 외부에 유지되므로 다른 구현이 다르게 직렬화할 수 있습니다.

대화 상자 호스트의 구현은 다음과 같습니다.

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

마지막으로 사용자 지정 상태 속성 접근자의 구현은 다음과 같습니다.

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
}

추가 정보

스케일 아웃 샘플은 C#, PythonJava의 GitHub에 있는 Bot Framework 샘플 리포지토리에서 사용할 수 있습니다.