Pamięć w LangGraph, wątki i magazyn trwały
LangGraph rozdziela pamięć agenta na dwie warstwy, które rozwiązują różne problemy i bywają mylone: punkty kontrolne przypisane do wątku oraz magazyn działający ponad wątkami. Bieżące wydanie nosi numer 1.2.10, licencja to MIT, wymagany Python zaczyna się od 3.10.
Zrozumienie tego podziału jest właściwie całą treścią tego tekstu, bo prawie wszystkie kłopoty z pamięcią w tej bibliotece biorą się z użycia jednej warstwy tam, gdzie potrzebna była druga.
Dwie warstwy i co która robi
Punkt kontrolny zapisuje stan grafu i jest przypisany do wątku. Wątek to jedna rozmowa albo jedno zadanie, rozpoznawane po identyfikatorze podawanym przy wywołaniu.
Magazyn trwały przechowuje dane, które mają przetrwać poza jedną rozmową, i jest rozpoznawany po przestrzeni nazw, zwykle związanej z użytkownikiem. Ta sama osoba wracająca w nowym wątku nadal ma dostęp do tego, co tam zapisano.
Objawy pomyłki są charakterystyczne i warto je znać.
Jeśli użytkownik wraca po tygodniu, zaczyna nową rozmowę i agent nie pamięta jego preferencji, to znaczy, że zapisywałeś je w stanie grafu. Stan jest związany z wątkiem, a wątek jest nowy.
Jeśli odwrotnie, rozmowa nie wznawia się od miejsca, w którym została przerwana, to znaczy, że nie ma punktu kontrolnego albo podajesz inny identyfikator wątku niż poprzednio.
Typowa aplikacja potrzebuje obu warstw naraz i to jest właściwy układ, nie kompromis. Punkty kontrolne trzymają przebieg bieżącej rozmowy, magazyn trzyma to, co wiadomo o człowieku.
Punkty kontrolne w praktyce
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph
with PostgresSaver.from_conn_string(POLACZENIE) as saver:
saver.setup()
graf = builder.compile(checkpointer=saver)
wynik = graf.invoke(
{"messages": [{"role": "user", "content": "kontynuujmy"}]},
config={"configurable": {"thread_id": "rozmowa-123"}},
)Identyfikator wątku jest tu jedyną rzeczą, która decyduje o ciągłości. Podanie tego samego wznawia od ostatniego zapisanego stanu, podanie nowego zaczyna od zera.
Ważne jest to, że zapis następuje po każdym kroku grafu, a nie na koniec wywołania. Skutki są trzy i wszystkie warto znać.
Pierwszy to odporność na awarie. Proces, który padł w połowie przetwarzania, po ponownym uruchomieniu podejmuje pracę od ostatniego zapisanego kroku zamiast zaczynać od nowa.
Drugi to możliwość wstrzymania grafu i poczekania na decyzję człowieka. Graf zatrzymuje się przed wybranym krokiem, stan jest zapisany, a wznowienie może nastąpić za godzinę albo za dwa dni.
Trzeci to rozmiar. Zapis po każdym kroku znaczy, że rozmowa złożona z dwudziestu tur w grafie o pięciu węzłach generuje setkę zapisów, a nie dwadzieścia. Baza rośnie szybciej, niż wynikałoby z liczby wiadomości, i polityka usuwania starych wątków jest tu obowiązkowa, a nie opcjonalna.
Wybór miejsca zapisu
Dostępne warianty różnią się przeznaczeniem na tyle, że pomyłka jest kosztowna.
Wariant trzymający wszystko w pamięci procesu jest przeznaczony wyłącznie do pracy deweloperskiej. Działa, dopóki proces żyje, i nie widzi go drugi proces. Uruchomienie tego na produkcji z dwiema instancjami aplikacji daje objaw, który wygląda jak losowe gubienie rozmów: użytkownik trafia raz na jedną instancję, raz na drugą.
Wariant oparty o bazę plikową nadaje się do prób lokalnych, bo przeżywa restart, i nie nadaje się do pracy wielu procesów naraz.
Do produkcji służy wariant oparty o bazę relacyjną. Wymaga jednorazowego przygotowania schematu i to jest krok, o którym łatwo zapomnieć, bo bez niego pierwsze wywołanie kończy się błędem o brakującej tabeli.
Praktyczna rada: ustaw wariant produkcyjny od początku, także w środowisku testowym. Różnica w zachowaniu między pamięcią procesu a bazą jest na tyle duża, że błędy wychodzą dopiero po zmianie, czyli w najgorszym możliwym momencie.
Magazyn ponad wątkami
Druga warstwa jest prostsza w użyciu i trudniejsza w zaprojektowaniu.
Zapis polega na podaniu przestrzeni nazw, klucza i wartości. Przestrzeń nazw zwykle zawiera identyfikator użytkownika, dzięki czemu dane jednej osoby nie mieszają się z danymi innej.
store.put(("uzytkownik", user_id, "preferencje"), "jezyk", {"wartosc": "polski"})
wynik = store.search(("uzytkownik", user_id, "preferencje"), query="jezyk komunikacji")Trudność leży gdzie indziej niż w interfejsie. Trzeba zdecydować, co w ogóle zapisywać, a to jest decyzja produktowa, nie techniczna.
Dwa podejścia są rozsądne. Pierwsze to zapisywanie tego, co użytkownik powiedział wprost: preferowany język, godziny kontaktu, format odpowiedzi. Proste, przewidywalne i wystarczające w większości przypadków.
Drugie to pozwolenie modelowi na decydowanie, przez udostępnienie mu narzędzia zapisującego do magazynu. Bogatsze i mniej przewidywalne, bo model zapisuje rzeczy nieistotne i gubi istotne. Przy tym wariancie oglądanie zawartości magazynu po tygodniu pracy jest obowiązkowe.
Trzecie podejście, czyli zapisywanie wszystkiego, co padło w rozmowie, jest kuszące i prawie zawsze złe. Magazyn zapełnia się szumem, wyszukiwanie zwraca fragmenty bez znaczenia, a koszt rośnie.
Co trafia do promptu
Sama pamięć jest bezużyteczna, dopóki nie wpłynie na odpowiedź, i ten krok bywa zaniedbywany.
Punkty kontrolne wchodzą do modelu automatycznie, bo historia wiadomości jest częścią stanu grafu. Tu problem jest odwrotny: przy długiej rozmowie historia przestaje się mieścić w oknie i trzeba ją przyciąć albo streścić.
Przycinanie proste, czyli zachowanie ostatnich N wiadomości, działa w większości przypadków i jest tanie. Streszczanie starszej części rozmowy daje więcej kontekstu i kosztuje dodatkowe wywołania modelu. Wybór zależy od tego, czy rozmowy faktycznie sięgają do rzeczy sprzed pięćdziesięciu wiadomości.
Dane z magazynu nie wchodzą do promptu same. Musisz je pobrać i wstawić, i tu obowiązuje ta sama zasada co przy każdej pamięci: kilka trafnych faktów pomaga bardziej niż dwadzieścia dowolnych. Wyszukanie po treści bieżącego pytania jest zwykle lepsze niż wciągnięcie całej przestrzeni nazw.
Warto też opisać w prompcie, skąd te fakty pochodzą. Zdania wklejone bez nagłówka model potraktuje na równi z pytaniem użytkownika, a sekcja oznaczona jako wiedza o rozmówcy daje wyraźnie lepsze wyniki.
Identyfikator wątku, czyli decyzja projektowa
To wygląda na szczegół techniczny i jest jedną z ważniejszych decyzji przy budowaniu aplikacji na tej bibliotece.
Wybór sprowadza się do pytania, co uznajesz za jedną rozmowę. Identyfikator równy identyfikatorowi użytkownika znaczy, że każda osoba ma jeden nieskończony wątek: cała historia zawsze dostępna, cała historia zawsze rosnąca. Identyfikator generowany przy każdym otwarciu okna czatu znaczy, że rozmowy są rozdzielone i nic się między nimi nie przenosi.
Oba warianty mają swoje miejsce. Asystent obsługi klienta, gdzie każde zgłoszenie jest osobną sprawą, pasuje do drugiego. Asystent osobisty, gdzie ciągłość jest sensem produktu, pasuje do pierwszego, o ile masz plan na przycinanie historii.
Trzeci wariant, najczęściej właściwy, to identyfikator sesji z ograniczonym czasem życia: nowa rozmowa po kilku godzinach bezczynności, a to, co ma przetrwać, trafia do magazynu ponad wątkami. Łączy przewidywalny rozmiar stanu z ciągłością tam, gdzie jest potrzebna.
Warto tę decyzję podjąć przed pierwszym wdrożeniem, bo zmiana schematu identyfikatorów po fakcie oznacza, że istniejące wątki stają się nieosiągalne. Historia nie znika z bazy, natomiast nikt już do niej nie trafi.
Współbieżność i to, co się dzieje przy dwóch żądaniach
Jeden wątek obsługiwany przez dwa równoległe wywołania to sytuacja, o której łatwo nie pomyśleć, a zdarza się częściej, niż wynikałoby z projektu.
Typowy scenariusz: użytkownik wysyła wiadomość, nie doczekał się odpowiedzi, wysyła ponownie. Albo interfejs ponawia żądanie po przekroczeniu limitu czasu, choć poprzednie nadal się wykonuje. Oba wywołania pracują na tym samym wątku i oba zapisują punkty kontrolne.
Skutek zależy od kolejności zapisów i bywa trudny do odtworzenia: część kroków z jednego przebiegu, część z drugiego, a stan końcowy niezgodny z żadnym z nich. Objawy wyglądają jak losowe błędy w logice agenta.
Rozwiązania są dwa, oba po Twojej stronie. Można blokować równoległe wywołania na tym samym wątku, odrzucając drugie z czytelnym komunikatem. Można też generować nowy identyfikator dla ponowionego żądania, akceptując rozdzielenie przebiegów.
Przy interfejsie strumieniującym odpowiedź warto dodatkowo zadbać o to, żeby przerwanie połączenia przez użytkownika nie zostawiało wątku w stanie pośrednim. Graf zatrzymany w połowie zapisze to, co zdążył, i przy wznowieniu podejmie pracę od tego miejsca, co bywa pożądane i bywa zaskoczeniem.
LangGraph kontra rozwiązania zewnętrzne
| Rozwiązanie | Co obsługuje | Gdzie leżą dane | Kiedy wybrać |
|---|---|---|---|
| Warstwy wbudowane | Wątki i proste fakty | W Twojej bazie | Domyślny wybór w tym frameworku |
| Mem0 | Fakty z wyszukiwaniem | U dostawcy albo u Ciebie | Chcesz gotowego wyciągania faktów |
| Zep | Graf z osią czasu | U dostawcy albo u Ciebie | Fakty zmieniają się w czasie |
| Letta | Całe środowisko agenta | U Ciebie albo u dostawcy | Budujesz agenta od zera |
Pierwszy wiersz warto potraktować jako punkt wyjścia, a nie jako rozwiązanie tymczasowe. Magazyn z przestrzeniami nazw i wyszukiwaniem po treści obsługuje większość realnych przypadków, a dane zostają w Twojej bazie razem z resztą aplikacji.
Sięganie po zewnętrzne warstwy ma sens wtedy, gdy potrzebujesz czegoś, czego wbudowana nie robi: automatycznego wyciągania faktów z rozmowy bez pisania własnej logiki albo rozstrzygania sprzeczności między faktami z różnych momentów. To są konkretne potrzeby, a nie domyślne.
Ostatni wiersz jest innego rodzaju wyborem, bo dotyczy architektury całej aplikacji, a nie warstwy pamięci. Przy działającym grafie w tym frameworku przejście tam oznacza przepisanie.
Podróż w czasie i wznawianie
Zapis po każdym kroku daje możliwość, która przy diagnozowaniu jest bezcenna.
Historię stanów wątku da się odczytać i cofnąć do dowolnego punktu, a potem uruchomić graf od tego miejsca z inną konfiguracją albo innym wejściem. Przy pytaniu „dlaczego agent podjął tę decyzję" pozwala to odtworzyć przebieg zamiast zgadywać.
Ta sama możliwość obsługuje wstrzymanie na decyzję człowieka. Graf zatrzymany przed krokiem wykonującym operację nieodwracalną czeka, a wznowienie następuje po zatwierdzeniu, także z innego procesu i po dowolnym czasie.
Warto to połączyć z narzędziem śledzącym, na przykład LangSmith albo Arize Phoenix. Punkty kontrolne pokazują stan, a ślady pokazują, co dokładnie poszło do modelu i co wróciło. Jedno bez drugiego zostawia lukę w rekonstrukcji przebiegu.
Typowe błędy
Pierwszy to wariant trzymający stan w pamięci procesu na produkcji. Przy dwóch instancjach aplikacji objaw wygląda jak losowe gubienie rozmów.
Drugi to mylenie warstw. Preferencje użytkownika zapisane w stanie grafu znikają razem z wątkiem, a historia rozmowy zapisana w magazynie nie wznawia przebiegu.
Trzeci to brak polityki usuwania starych wątków. Zapis po każdym kroku sprawia, że baza rośnie znacznie szybciej, niż sugeruje liczba rozmów.
Czwarty to pominięcie przygotowania schematu bazy. Pierwsze wywołanie kończy się wtedy błędem o brakującej tabeli, a przyczyna wygląda jak problem z połączeniem.
Piąty to wysyłanie do modelu całej historii bez przycinania. Przy dłuższych rozmowach kończy się to przekroczeniem okna kontekstu albo rachunkiem rosnącym kwadratowo.
Szósty to zapisywanie do magazynu wszystkiego, co użytkownik napisał. Wyszukiwanie zwraca wtedy szum, a przy danych wrażliwych zapis zostaje na dłużej niż rozmowa.
FAQ
Czym różni się checkpointer od magazynu?
Zasięgiem. Punkty kontrolne zapisują stan grafu i są przypisane do wątku, więc obsługują ciągłość jednej rozmowy. Magazyn przechowuje dane ponad wątkami, zwykle w przestrzeni nazw związanej z użytkownikiem, więc obsługuje to, co ma przetrwać między rozmowami.
Dlaczego agent nie pamięta użytkownika po powrocie?
Najczęściej dlatego, że informacje zapisano w stanie grafu zamiast w magazynie. Stan jest związany z wątkiem, a wracający użytkownik zwykle zaczyna nowy wątek, więc dostaje pustą historię.
Którego wariantu zapisu użyć na produkcji?
Opartego o bazę relacyjną, bo przeżywa restart i działa przy wielu procesach. Wariant trzymający stan w pamięci procesu jest przeznaczony do pracy deweloperskiej, a plikowy do prób lokalnych na jednym procesie.
Jak radzić sobie z długą historią rozmowy?
Przycinaniem do ostatnich wiadomości albo streszczaniem starszej części. Przycinanie jest tanie i wystarcza w większości przypadków; streszczanie daje więcej kontekstu kosztem dodatkowych wywołań modelu.
Czy potrzebuję zewnętrznej warstwy pamięci?
Zwykle nie na start. Wbudowany magazyn z przestrzeniami nazw obsługuje typowe przypadki, a dane zostają w Twojej bazie. Po Mem0 albo Zep warto sięgnąć wtedy, gdy potrzebujesz automatycznego wyciągania faktów albo rozstrzygania sprzeczności w czasie.
Dokumentacja warstwy trwałości stoi na stronie projektu, a kod i wydania w repozytorium na GitHubie.