Skip to main content

Command Palette

Search for a command to run...

Jak efektywnie zarządzać  kontekstem agentów AI w .NET

Published
13 min read
Jak efektywnie zarządzać  kontekstem agentów AI w .NET

Wprowadzenie

Nowoczesne platformy do budowania agentów AI znacząco przyspieszają tworzenie takich rozwiązań. Pozwalają w prosty sposób wybrać duży model językowy (LLM), zarządzać promptami oraz definiować narzędzia (tools), z których agent może korzystać automatycznie.

Prawdziwym wyzwaniem po stronie programisty pozostaje jednak zapewnienie odpowiedniego kontekstu dla modelu. Frameworki takie jak LangChain w Pythonie czy Semantic Kernel w .NET oczekują, że przy każdym zapytaniu przekażemy historię rozmowy lub inne dane opisujące aktualny stan interakcji.

Intuicyjnie przekazywanie całej historii rozmowy za każdym razem do agenta wydaje się rozsądnym podejściem. Powinno to pozwolić agentowi lepiej rozumieć kontekst i podejmować trafniejsze decyzje. W praktyce pojawia się jednak kilka pytań:

  • Czy większa liczba tokenów w promptcie zawsze prowadzi do lepszych odpowiedzi?

  • Jak nie przekroczyć limitu długości kontekstu poszczególnych modeli językowych?

  • Co zrobić z rosnącym czasem przetwarzania i kosztem kolejnych wywołań agenta?

W tym artykule pokażę, jak zarządzać kontekstem przekazywanym do dużych modeli językowych na przykładach w .NET agenta zbudowanego na platformie Semantic Kernel. Omówię podejścia, które pozwalają kontrolować rozmiar kontekstu, a tym samym zapewnić stabilność kosztów oraz jakość działania rozwiązań opartych na agentach AI.

Context window – podstawowe ograniczenie LLM

Modele językowe mają ograniczenie na maksymalny rozmiar kontekstu nazywane context window. Obejmuje ono wszystkie tokeny używane podczas jednego wywołania modelu — zarówno wejściowe, jak i wyjściowe. W zależności od modelu limit to np. 128k tokenów (OpenAI GPT-5).

W praktyce, przy budowie agentów AI oznacza to, że w tym limicie muszą zmieścić się między innymi:

  • prompt systemowy,

  • historia rozmowy,

  • wyniki narzędzi (tool outputs),

  • nowy kontekst dostarczony do agenta, np. pytanie użytkownika lub zdarzenie z systemu.

Ponieważ context window obejmuje również tokeny wyjściowe, zbyt dużo danych na wejściu może spowodować, że model będzie miał mniej przestrzeni na proces wnioskowania (reasoning) oraz wygenerowanie odpowiedzi.

Czy więcej kontekstu zawsze pomaga?

Intuicyjnie więcej kontekstu powinno poprawiać wyniki. W praktyce nie zawsze tak jest. W pracy Lost in the Middle: How Language Models Use Long Contexts pokazano, że zwiększanie liczby dokumentów w promptcie przestaje poprawiać jakość odpowiedzi mimo rosnącej liczby trafnych danych. Dodatkowo modele najlepiej wykorzystują informacje z początku i końca kontekstu, a najgorzej te znajdujące się w jego środku (tzw. Lost in the Middle).

Jak zarządzać kontekstem agentów AI

Skoro większy kontekst nie zawsze prowadzi do lepszych wyników, pojawia się naturalne pytanie - jak zarządzać kontekstem agentów AI?

Temat ten został szerzej opisany w artykule Cutting Through the Noise: Smarter Context Management for LLM-Powered Agents dotyczącym efektywnego zarządzania kontekstem w systemach opartych na LLM. W praktyce stosuje się dwa główne podejścia:

  • LLM summarization – kompresowanie historii do krótszej formy

  • observation masking – ograniczanie liczby przekazywanych danych

Każde z nich ma swoje trade-offy: summarization lepiej kontroluje rozmiar kontekstu, ale generuje dodatkowy koszt i może gubić szczegóły. Masking jest prostszy i tańszy, ale nie zapobiega nieograniczonemu wzrostowi kontekstu. W praktyce najlepsze rezultaty daje podejście hybrydowe, łączące oba mechanizmy. W kolejnych sekcjach pokażę, jak zaimplementować je przy użyciu Semantic Kernel w .NET.

Zarządzanie kontekstem agentów AI w Semantic Kernel .NET

Agent AI rekomendujący produkty

Wyobraźmy sobie sklep internetowy ze sprzętem komputerowym, w którym agent AI pomaga użytkownikowi wybrać laptop na podstawie preferencji, takich jak budżet, waga, czas pracy baterii czy zastosowanie. Agent, krok po kroku zbiera wymagania użytkownika, wyszukuje pasujące produkty, analizuje ich opisy i rekomenduje sprzęt.

Na początku tworzymy podstawowego agenta, a następnie implementujemy strategie zarządzania kontekstem - observation masking oraz LLM summarization. Przeanalizujemy ich wpływ na liczbę zużytych tokenów wejścia (input tokens) i tokenów całościowych (total tokens) na podstawie dodatkowych mechanizmów analitycznych zbudowanych w ramach projektu.

Kod źródłowy, którego fragmenty znajdują się w artykule dostępny jest w repozytorium GitHub SemanticKernelContextManagement.

Podstawowy agent AI

Do stworzenia agenta w .NET wykorzystamy framework Semantic Kernel, którego przykład typu "Hello World" łatwo zbudować na podstawie oficjalnej dokumentacji. Na potrzeby eksperymentu wyposażymy go w ProductsPlugin, który zwróci informacje o laptopach, które dla ułatwienia będą zawarte w projekcie w statycznych plikach JSON i wczytane do pamięci programu. Do rekomendacji:

  • Ogólnej, która zwraca skrócone informacje o wszystkich laptopach:

            [KernelFunction]
            [Description("Get all products from shop. Returns only product names and short summaries.")]
            public string GetProducts()
            {
                var productSummaries = products.Select(p => new
                {
                    p.Name,
                    p.ShortSummary,
                });
    
                return JsonSerializer.Serialize(productSummaries, ProductJsonOptions);
            }
    
  • Szczegółowej, dotyczącej jednego produktu (laptopa):

        [Description("Get full detailed description of a product by its name.")]
        public string GetProductDetails([Description("Product name, e.g. Laptop Pro 14")] string productName)
        {
            var product = products.FirstOrDefault(p => p.Name.Equals(productName, StringComparison.OrdinalIgnoreCase));
            if (product == null)
            {
                return $"Product not found: {productName}";
            }

            return JsonSerializer.Serialize(product, ProductJsonOptions);
        }

W pierwszym zapytaniu przekazujemy dodatkowo system prompt, który instruuje, że model ma się wcielić w rolę asystenta w sklepie. Z zalet warto zwrócić uwagę, że asystent zawsze odpowie w języku rozmówcy 🙂:

        private const string SystemPrompt = """
            You are a shop assistant that recommends products from our catalog.
            Use the Products plugin (GetProducts, GetProductDetails) to read real catalog data before suggesting items.
            Do not invent products, prices, or stock. If nothing matches, say so clearly.
            Match the user's language in your replies.
            """;

W Semantic Kernel możemy zdecydować, czy agent powinien skorzystać ze zdefiniowanych narzędzi w plugin. W tym wypadku użyjemy FunctionChoiceBehavior.Auto co oznacza, że może skorzystać z funkcji, ale nie musi:

        private readonly OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new()
        {
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
        };

Program tworzymy jako aplikację konsolową. Z konsoli pobieramy zapytanie klienta sklepu, przykładowo "Chcę kupić laptopa". W podstawowym podejściu dodajemy pytanie do kontekstu jako UserMessage i wysyłamy całość do LLM. Po otrzymaniu odpowiedzi zapisujemy ją również w historii i zwracamy do użytkownika:

        public async Task<string> GetRecommendationAsync(string userInput)
        {
            ChatHistory.AddUserMessage(userInput);

            var result = await chatCompletionService.GetChatMessageContentAsync(
                ChatHistory,
                executionSettings: openAIPromptExecutionSettings,
                kernel: kernel);

            if (string.IsNullOrWhiteSpace(result.Content))
            {
                throw new InvalidOperationException($"{nameof(chatCompletionService)} did not return any content.");
            }

            ChatHistory.Add(result);
            return result.Content;
        }

Przykładowy dialog w konsoli:

Maskowanie obserwacji (observation masking)

W agentach zbudowanych na platformie Semantic Kernel surowe wyniki narzędzi trafiają jeden pod drugim do historii rozmowy jako wiadomości z przypisaną rolą AuthorRole.Tool. Nie są one widoczne dla klienta naszego sklepu. Zwracamy użytkownikowi przetworzoną przez agenta odpowiedź, wygenerowaną właśnie na podstawie tych wyników.

W przypadku funkcji GetProductDetails wynikiem jest JSON zawierający pełne informacje o konkretnym laptopie. Załóżmy, że w większości przypadków użycia te same informacje o produkcie trafią do wygenerowanej przez asystenta odpowiedzi. Podejmujemy świadomą decyzję o wprowadzeniu maskowania obserwacji. W ten sposób zmniejszamy koszty i czas oczekiwania na odpowiedź w zamian za obniżenie jakości - większe ryzyko halucynacji oraz ewentualne zwiększenie ilości ponownych wywołań narzędzi.

Maskujemy wyniki narzędzi przez wprowadzenie w ich miejsce placeholdera:

private const string ObservationMaskedPlaceholder = "[TOOL_OBSERVATION_MASKED]";

Mechanizm wprowadzamy, tuż przed zwróceniem odpowiedzi do klienta, tak by zadziałał on dla kolejnego zapytania użytkownika:

        public async Task<string> GetRecommendationAsync(string userInput)
        {
            ChatHistory.AddUserMessage(userInput);

            var result = await chatCompletionService.GetChatMessageContentAsync(
                ChatHistory,
                executionSettings: openAIPromptExecutionSettings,
                kernel: kernel);

            if (string.IsNullOrWhiteSpace(result.Content))
            {
                throw new InvalidOperationException($"{nameof(chatCompletionService)} did not return any content.");
            }

            ChatHistory.Add(result);

            // maskowanie obserwacji
            if (useObservationMasking)
            {
                MaskObservations();
            }

            return result.Content;
        }
        private void MaskObservations()
        {
            var toolMessages = ChatHistory.Where(m => m.Role == AuthorRole.Tool);
            foreach (var message in toolMessages)
            {
                message.Content = ObservationMaskedPlaceholder;

                if (message.Items.Count == 0)
                {
                    message.Items.Add(new TextContent(ObservationMaskedPlaceholder));
                    continue;
                }

                var originals = message.Items.ToArray();
                message.Items.Clear();

                foreach (var item in originals)
                {
                    if (item is FunctionResultContent functionResult)
                    {
                        message.Items.Add(new FunctionResultContent(
                            functionResult.FunctionName,
                            functionResult.PluginName,
                            functionResult.CallId,
                            ObservationMaskedPlaceholder));
                    }
                    else
                    {
                        message.Items.Add(new TextContent(ObservationMaskedPlaceholder));
                    }
                }
            }
        }

Podsumowywanie kontekstu przez LLM (LLM summarization)

Wyobraźmy sobie, że klienci naszego sklepu zadają wiele pytań o laptopy w trakcie jednej rozmowy. Wraz z rosnącą historią interakcji zwiększa się liczba tokenów przekazywanych do modelu. W efekcie kolejne odpowiedzi są generowane coraz wolniej, koszt ich przetwarzania rośnie liniowo, a jakość odpowiedzi nie ulega istotnej poprawie. W tej sytuacji możemy świadomie wprowadzić okresowe podsumowywanie przez AI kontekstu. Pozwala ono kontrolować rozmiar przekazywanych danych, stabilizując koszt i czas odpowiedzi. W przypadku podejścia asynchronicznego wpływ na latency jest minimalny. Odbywa się to jednak kosztem większej złożoności rozwiązania oraz ryzyka utraty części informacji.

Obok naszego agenta dodajemy zapytanie, które będzie wykonywane po każdych 10 turach z użytkownikiem. Warto zaznaczyć, że mechanizm wywołujemy już po zamaskowaniu obserwacji, gdy korzystamy z podejścia hybrydowego:

        public async Task<string> GetRecommendationAsync(string userInput)
        {
            ChatHistory.AddUserMessage(userInput);

            var result = await chatCompletionService.GetChatMessageContentAsync(
                ChatHistory,
                executionSettings: openAIPromptExecutionSettings,
                kernel: kernel);

            if (string.IsNullOrWhiteSpace(result.Content))
            {
                throw new InvalidOperationException($"{nameof(chatCompletionService)} did not return any content.");
            }

            ChatHistory.Add(result);

            if (useObservationMasking)
            {
                MaskObservations();
            }

            // Dodanie summarization
            if (useSummarization)
            {
                var numberOfTurnsWithUser = ChatHistory.Count(c => c.Role == AuthorRole.User);
                if (numberOfTurnsWithUser % 10 == 0)
                {
                    await SummarizeChatAsync();
                }
            }

            return result.Content;
        }

Proces podsumowywania może korzystać z innego, tańszego modelu niż główny agent, ponieważ nie wymaga on wysokiej jakości generowania odpowiedzi, a jedynie kompresji informacji. W naszym przypadku dla ułatwienia reużywamy Semantic Kernel. Jednak dla tego zapytania wysyłamy osobny system prompt:

private const string SummarizationSystemPrompt = """
    You compress conversation transcripts for a product-recommendation assistant.
    Preserve concrete facts the user asked about, product names, prices, stock, and language the user used.
    Do not invent catalog data. Output a concise third-person summary suitable as context for continuing the chat.
    """;

wyłączamy możliwość korzystania z tools:

   private readonly OpenAIPromptExecutionSettings summarizationPromptExecutionSettings = new()
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.None()
};

oraz budujemy osobne chat history jako transkrypt z dotychczasowej rozmowy między agentem, a użytkownikiem:

private string FormatConversationForSummary()
{
    var blocks = new List<string>();
    foreach (var message in ChatHistory)
    {
        if (message.Role == AuthorRole.System)
        {
            continue;
        }

        var parts = new List<string>();
        if (!string.IsNullOrWhiteSpace(message.Content))
        {
            parts.Add(message.Content.Trim());
        }

        foreach (var item in message.Items)
        {
            switch (item)
            {
                case FunctionCallContent call:
                    parts.Add($"[called {call.PluginName}.{call.FunctionName} with arguments: {call.Arguments}]");
                    break;

                case FunctionResultContent result:
                    parts.Add($"[result from {result.PluginName}.{result.FunctionName}: {FormatResultObject(result.Result)}]");
                    break;

                case TextContent text when !string.IsNullOrWhiteSpace(text.Text):
                    parts.Add(text.Text.Trim());
                    break;
            }
        }

        var body = parts.Count > 0 ? string.Join(" ", parts) : "(no text)";
        blocks.Add($"{message.Role}: {body}");
    }

    return string.Join(Environment.NewLine + Environment.NewLine, blocks);
}

Ostatecznie wysyłamy request o summary, i z odpowiedzi tworzymy nowy kontekst dla agenta AI, dodając jeszcze raz oryginalny system prompt.

private async Task SummarizeChatAsync()
{
    var transcript = FormatConversationForSummary();

    var summarizationChat = new ChatHistory();
    summarizationChat.AddSystemMessage(SummarizationSystemPrompt);
    summarizationChat.AddUserMessage(
        "Summarize the following conversation transcript.\n\n" + transcript);

    var summaryResponse = await chatCompletionService.GetChatMessageContentAsync(
        summarizationChat,
        executionSettings: summarizationPromptExecutionSettings,
        kernel: null);

    if (string.IsNullOrWhiteSpace(summaryResponse.Content))
    {
        throw new InvalidOperationException("Summarization did not return any content.");
    }

    NotifySummarizationTokenUsed(summaryResponse);

    ChatHistory.Clear();
    ChatHistory.AddSystemMessage(SystemPrompt);
    ChatHistory.AddUserMessage("Summary of the conversation so far:\n" + summaryResponse.Content);
}

Porównanie strategii zarządzania kontekstem na przykładzie 21-turowej rozmowy

Przygotowany system należy przetestować, by udowodnić skuteczność mechanizmów redukcji tokenów.

Przygotowałem 21-turową rozmowę, którą przeprowadziłem w 4 scenariuszach:

  1. Bez zarządzania kontekstem (Basic).

  2. Tylko maskowanie obserwacji (Omasking).

  3. Wyłącznie LLM summarization (Summarization).

  4. Maskowanie obserwacji oraz LLM Summarization (OMask+Summ).

To wszystkie pytania:

1. Wypisz wszystkie produkty z katalogu z krótkim opisem każdego.
2. Szukam laptopa pod programowanie i pracę mobilną — co polecasz i dlaczego?
3. Potrzebuję czegoś do gier — który model ma sens i jakie ma kluczowe parametry?
4. Porównaj Laptop Pro 14 z DevBook 15 pod kątem developera (.NET, VS / VS Code).
5. Jaki jest dokładny opis techniczny modelu Creator Studio 16? Wypisz specyfikację i zastosowania.
6. A Campus Note 14 — dla kogo jest i czym różni się od BalanceBook 14?
7. UltraLite 13 vs Silent Lite 13: który jest lżejszy lub bardziej „mobilny” według danych z katalogu?
8. Podaj szczegóły produktu Gaming Max 17 — CPU, RAM, ekran, wagę jeśli są w opisie.
9. Czy masz coś poniżej 14 cali ekranu? Wymień modele z katalogu, które pasują.
10. Zaproponuj zestaw: laptop do nauki na uczelnię i krótko uzasadnij jednym zdaniem na model.
11. Wróćmy do Laptop Pro 14: powtórz najważniejsze zalety i ograniczenia z opisu.
12. Silent Lite 13 — pełny opis z szczegółami; interesuje mnie bateria i wyświetlacz.
13. DevBook 15 — szczegóły z katalogu: dla jakich obciążeń się nadaje, a dla jakich nie?
14. Czy któryś laptop ma wyraźny nacisk na ciszę lub mobilność w opisie? Wskaż który i dlaczego.
15. Potrzebuję czegoś do photo/video (creator workflow) — co wybierasz z katalogu i dlaczego?
16. BalanceBook 14 — pełne szczegóły produktu z katalogu.
17. Znowu lista wszystkich nazw produktów, ale tylko nazwy w jednej linii, bez opisów.
18. Campus Note 14 — szczegóły techniczne i dla kogo jest według długiego opisu.
19. Jeśli budżet nie gra roli: jaki jeden laptop byś polecił „universal” i dlaczego?
20. Porównaj krótko trzy modele: Gaming Max 17, Creator Studio 16, Laptop Pro 14 — kto dla kogo.
21. Podsumuj całą naszą rozmowę: jakie modele brałem pod uwagę i co finalnie rekomendujesz.

Nie zauważyłem znaczącej różnicy jakości między odpowiedziami modelu w każdym scenariuszu. Dla każdego z nich zmierzyłem ilość tokenów wejściowych (input tokens) oraz total tokens każdej interakcji z modelem i przedstawiłem na wykresach:

Przykładowe input tokens w poszczególnych strategiach

Wartości bezwzględne:

Strategy/Turn 1 10 11 20 21
Basic 782 18652 19817 33657 17794
OMask+Summ 782 11902 2433 3005 1525
OMasking 782 11398 12045 21853 11055
Summarization 782 9651 1337 2357 2266

% względem Basic:

Strategy/Turn 1 10 11 20 21
Basic 100.00% 100.00% 100.00% 100.00% 100.00%
OMask+Summ 100.00% 63.81% 12.28% 8.93% 8.57%
OMasking 100.00% 61.11% 60.78% 64.93% 62.13%
Summarization 100.00% 51.74% 6.75% 7.00% 12.73%

Redukcja vs Basic:

Strategy/Turn 1 10 11 20 21
Basic 0.00% 0.00% 0.00% 0.00% 0.00%
OMask+Summ 0.00% 36.19% 87.72% 91.07% 91.43%
OMasking 0.00% 38.89% 39.22% 35.07% 37.87%
Summarization 0.00% 48.26% 93.25% 93.00% 87.27%

Zgodnie z oczekiwaniami zastosowanie maskowania obserwacji doprowadziło do stabilnej redukcji liczby tokenów wejściowych na poziomie około 35–40% względem podejścia bazowego (poza pierwszą turą).

Z kolei LLM summarization spowodowało wyraźny spadek liczby tokenów po 10 turze, osiągając redukcję rzędu 93% w dalszych interakcjach.

Podejście hybrydowe połączyło oba efekty, zapewniając redukcję liczby tokenów zarówno przed, jak i po wykonaniu summarization.

Wnioski

Sposób zarządzania kontekstem agentów AI jest decyzją architektoniczną, która ma bezpośredni wpływ na koszt, czas odpowiedzi, jakość oraz skalowalność rozwiązania i powinien być uzależniony od charakteru aplikacji.

Przekazywanie pełnej historii rozmowy nie jest podejściem skalowalnym — wraz z długością interakcji rośnie liczba tokenów, co prowadzi do zwiększenia kosztów i czasu odpowiedzi bez proporcjonalnej poprawy jakości.

Observation masking stanowi prosty i efektywny mechanizm redukcji tokenów, który pozwala ograniczyć „szum” wynikający z odpowiedzi narzędzi. Nie rozwiązuje jednak problemu rosnącego kontekstu w dłuższej perspektywie.

LLM summarization umożliwia kontrolowanie długości kontekstu poprzez jego kompresję. W badanym scenariuszu pozwoliło to osiągnąć redukcję liczby tokenów nawet o ponad 90%, kosztem dodatkowego wywołania modelu oraz potencjalnej utraty części informacji.

Najlepsze rezultaty osiąga podejście hybrydowe, które łączy oba mechanizmy — redukuje liczbę tokenów w każdej interakcji (poza pierwszą) oraz kontroluje długoterminowy wzrost kontekstu.