Anatomia produkcyjnego systemu wieloagentowego: LangGraph, .NET i zarządzanie kontekstem
„Agent, który odpowiada na maile" to demo, które napiszesz w godzinę. „Agent, który bezpiecznie wykonuje zwrot środków albo ponawia realizację w działającym systemie produkcyjnym" — to już inżynieria. Ostatnie tygodnie spędziłem, projektując i kodując właśnie taki system: wieloagentową automatyzację obsługi zgłoszeń, w której każdy agent ma konkretną rolę, własne narzędzia i realny wpływ na systemy firmy.
Ten wpis to spojrzenie pod maskę — architektura, decyzje projektowe i najważniejsza lekcja, której się nie spodziewałem. Świadomie pomijam tu domenę biznesową klienta; skupiam się na inżynierii, którą da się przenieść do dowolnego procesu.
Spis treści:
1. Architektura: dwa mózgi, jedna kolejka
Punkt wyjścia: oddzielić „myślenie" od „wykonywania". System to dwa procesy spięte szyną komunikatów.
- .NET — orkiestracja i I/O. Przyjmuje webhooki, waliduje podpisy, trzyma stan (EF Core + Postgres), wystawia wewnętrzne API z danymi i jako jedyny gada ze światem zewnętrznym. Tu siedzą transakcje, idempotencja i audyt.
- Python — AI Brain. Graf agentów na LangGraph: klasyfikacja, routing, dobór narzędzi, generowanie draftu. Nie pisze do bazy i nie woła zewnętrznych API — od tego jest .NET.
- RabbitMQ — szyna między nimi. Kolejki z TTL i DLQ odsprzęgają oba procesy: AI Brain może paść, wstać i nadrobić backlog, nic nie ginie.
Granica między procesami to celowa granica zaufania: LLM nie ma bezpośredniego write'a do bazy ani dostępu do zewnętrznych systemów klienta — każde wywołanie akcji przechodzi przez .NET, gdzie jest autoryzowane i logowane. Niedeterminizm modelu zostaje po jednej stronie szyny.
Backend (.NET): warstwy i szyna zdarzeń
Backend to pięć projektów, zależności skierowane do środka — domena nie zna EF Core, RabbitMQ ani tego, jaki helpdesk wisi po drugiej stronie:
- Events — kontrakty zdarzeń, zero zależności; subskrybuje je dowolny konsument,
- Domain — encje, interfejsy, value objecty,
- Infrastructure — EF Core, MassTransit, typed
HttpClienty, - ApiClient — typed client do REST-owego API helpdesku,
- API — ingest webhooków, use-case'y (MediatR), REST dla GUI i wewnętrzne endpointy dla AI.
Szynę zdarzeń obsługuje MassTransit na RabbitMQ: producent nie zna konsumentów, więc kolejny odbiorca (analityka, SLA monitor, notyfikacje) subskrybuje te same exchange'e bez dotykania nadawcy. Delivery guarantees siedzą w konfiguracji endpointów: per-message TTL, retry z backoffem i DLQ po wyczerpaniu prób.
Webhooki: adapter per źródło
Webhooki wpadają na /webhook/{source}. Po {source} wybieramy adapter, walidujemy HMAC SHA256 z nagłówka i mapujemy raw payload na wewnętrzny, niezależny od dostawcy IncomingTicketDto. Nowe źródło = nowy adapter + wpis w DI, bez ruszania domeny. Każdy przyjęty event ląduje też w append-only logu (raw JSON w jsonb) — audyt i replay za free.
2. Graf agentów zamiast jednego promptu
Single-prompt design — jeden system prompt obsługujący wszystkie intencje — nie skaluje się jakościowo: boundary między rolami się zacierają, model dostaje tools których nie potrzebuje i halucynuje tam, gdzie nie powinien. Podeszliśmy odwrotnie: graf wyspecjalizowanych agentów.
Na wejściu stoi agent klasyfikujący. Rozpoznaje intencję zgłoszenia i poziom pewności, po czym kieruje sprawę do właściwego specjalisty. Każdy specjalista jest wąski: ma jasną rolę i własny, ograniczony zestaw narzędzi. Agent statusu nie potrafi zrobić zwrotu — i o to chodzi. Mniejsza powierzchnia działania to mniej miejsca na halucynacje i łatwiejsze testowanie.
Jak to wygląda w LangGraph
Graf to węzły (agenci) i warunkowe krawędzie (routing). Między węzłami przepływa jeden, jawny stan — i jest świadomie wąski (o tym za chwilę). Routing zależy od intencji i pewności:
class AgentState(TypedDict):
raw_message: str
intent: str | None
confidence: float
reference: str | None # wyłuskany identyfikator sprawy
case_data: dict | None # dane pobrane z systemów klienta
draft: str | None
needs_escalation: bool
trace: list[dict] # krok-po-kroku, w bazie jako jsonb
def route(state: AgentState) -> str:
if state["confidence"] < 0.7: # próg regulowany w locie
return "escalation_agent"
return ROUTES.get(state["intent"], "escalation_agent")
graph.add_conditional_edges("classifier_agent", route)
Próg pewności (domyślnie 0.7) jest tu twardą bramką: poniżej niego sprawa idzie do eskalacji, a nie do automatycznej akcji. Niepewność traktujemy jako sygnał, nie błąd.
3. Tools, API i integracja z systemami zewnętrznymi
Najwięcej pracy poszło nie w prompt engineering, lecz w tools. Spięliśmy system z istniejącą infrastrukturą klienta przez dedykowane endpointy i tool functions stworzone wyłącznie pod tę architekturę — nie generyczne API „do wszystkiego", tylko interfejsy skrojone pod konkretną akcję konkretnego agenta.
Tools w dwóch kategoriach:
- Read-only — pobierają kontekst: dane sprawy, historia konwersacji. W LangGraph to
@tool-decorated funkcje wołające prywatny REST endpoint w .NET (auth API key, private network). Idempotentne, testowalne w izolacji. - Action tools — wykonują realną operację: zwrot środków, ponowienie realizacji, eskalacja do queue człowieka. To one zamieniają „chatbota" w system, który faktycznie coś robi — i dlatego są objęte confidence threshold, trybem Copilot i Dry Run (sekcja 5).
AI Brain nigdy nie woła zewnętrznych systemów klienta bezpośrednio — wszystko przechodzi przez .NET jako jedyne zaufane boundary. Tool functions dostępne per-agent to m.in. classify_intent, extract_reference, fetch_conversation_history, fetch_case_data, execute_action, generate_draft.
Modele podpięte przez OpenRouter, konfigurowane per-agent (model ID, temperatura, max_tokens) w bazie — zmieniane w locie, bez restartu. Klasyfikator dostaje tańszy/szybszy model; agenci generujący draft korzystają z mocniejszego. Nowy dostawca helpdesku = nowy adapter + wpis w DI, bez dotykania logiki grafu. Każda jednostka wdrożeniowa ma własny Dockerfile.
4. Najważniejsza lekcja: zarządzanie kontekstem
Byłem przekonany, że w systemie wieloagentowym najtrudniejszy będzie dobór modelu. Myliłem się. Najtrudniejsze — i najbardziej decydujące o jakości — okazało się zarządzanie kontekstem.
Cała jakość sprowadza się do jednego pytania: co dokładnie widzi każdy agent?
- Za dużo kontekstu — model się gubi, miesza wątki, ciągnie nieistotne dane i halucynuje. Wrzucenie całej historii i wszystkich pól do każdego węzła grafu to najprostsza droga do spadku jakości.
- Za mało kontekstu — agent podejmuje decyzję bez połowy obrazka (np. nie widzi, że klient pisał już trzy razy w tej samej sprawie).
Rozwiązanie nie jest magiczne, ale wymaga rygoru: każdy agent dostaje dokładnie tyle kontekstu, ile potrzebuje do swojej roli — i ani grama więcej. Stan grafu (graph state) jest jawnym kontraktem, a nie workiem na wszystko. Temat zgłoszenia doklejamy do treści tylko tam, gdzie pomaga klasyfikacji. Historię rozmowy pobiera się świadomie, dla agentów, którym jest potrzebna. To kontekst, nie model, jest tu prawdziwą dźwignią — model wymienisz w jednej linijce konfiguracji, dobrze poukładanego kontekstu nie kupisz w żadnym API.
5. Bezpieczeństwo i kontrola: Copilot, progi pewności, Dry Run
System, który wykonuje realne akcje, musi mieć hamulce wpisane w architekturę — nie doklejone na końcu.
- Tryb Copilot (human-in-the-loop). Domyślnie AI nie wysyła odpowiedzi do klienta — przygotowuje ją jako prywatną notatkę, którą człowiek zatwierdza. Ostatnie słowo zostaje po stronie operatora.
- Próg pewności. Poniżej ustalonego progu (konfigurowalnego w locie) sprawa automatycznie trafia do eskalacji zamiast do automatycznej akcji. Niepewność jest sygnałem, nie błędem.
- Dry Run domyślnie włączony. Nowy system startuje w trybie „licz i proponuj, ale nic nie wysyłaj". Pełną analizę widać w panelu, zanim świadomie przełączysz przełącznik na produkcję.
- Pełna obserwowalność. Każde uruchomienie zapisuje krok-po-kroku trace: który agent zadziałał, jakich narzędzi użył, jaką podjął decyzję, ile to trwało i dlaczego eskalował. Bez tego debugowanie systemu, który „czasem podejmuje inne decyzje", jest drogą przez mękę.
- Model-agnostyczność. Różne role sięgają po różne modele (m.in. OpenAI GPT i Anthropic Claude), dobierane pod jakość, koszt i szybkość. Model klasyfikatora konfigurujemy osobno od reszty.
Trace to uporządkowana tablica JSON (jsonb), budowana w trakcie wykonania grafu — jeden wpis na węzeł. To z niej panel rysuje przepływ i to ona ratuje skórę przy debugowaniu:
{
"node": "classifier_agent",
"agent": "ClassifierAgent",
"duration_ms": 320,
"skills_called": ["classify_intent", "extract_reference"],
"decisions": { "intent": "status", "confidence": 0.95 },
"output_fields": ["intent", "confidence", "reference"]
}
6. Stos technologiczny w pigułce
- Orkiestracja AI: Python + LangGraph — graf agentów, warunkowy routing, jawny stan
- Backend: .NET (MediatR, EF Core), PostgreSQL, PK UUIDv7
- Messaging: RabbitMQ + MassTransit — TTL, retry z backoffem, DLQ, wielu konsumentów
- Integracje: adaptery per źródło, webhooki z HMAC SHA256, wewnętrzne REST API (read-only, API key)
- Modele: OpenRouter — GPT i Claude, konfiguracja per-agent
- Obserwowalność i kontrola: GraphTrace (jsonb), tryb Copilot, Dry Run, próg pewności
- Dostarczanie: każda jednostka z własnym Dockerfile
7. Podsumowanie
Granica między niedeterministycznym modelem a produkcyjnymi systemami musi być twarda i świadomie zaprojektowana. Kiedy agent popełni błąd — a popełni — GraphTrace mówi ci dokładnie w którym węźle, confidence score tłumaczy dlaczego wątpił, a DLQ trzyma wiadomość do replay. Bez tej obserwowalności debugujesz system, który „czasem podejmuje inne decyzje", bez żadnego uchwytu.
Kontekst okazał się trudniejszy i ważniejszy niż dobór modelu. Swap modelu to jedna linijka w bazie. Dobrze poukładany graph state — co dokładnie trafia do każdego węzła, kiedy pobierasz dane, jak budujesz prompt — to tygodnie pracy, którą żaden model hosting nie zastąpi.
Masz pytania o konkretne decyzje architektoniczne albo budujesz podobny system? Odezwij się.