Strona główna / Artykuły

Od tekstu surowego do pipeline’ów: parserzy, LCEL, elementy wykonywalne i pamięć w LangChain

Jak LangChain przekształca surowy tekst modelu w strukturyzowane dane, komponuje kroki za pomocą LCEL i interfejsu Runnable oraz jak obsługuje pamięć konwersacyjną obecnie.

Wydawca: REACTAPP.TOP10 min czytania2276 słów

Źródła: LangChain Explained: Output Parsers, Chains, Runnables, and Memory

Zawołanie modelu językowego za pomocą dobrze skonstruowanego szablonu promptu to dopiero pierwszy krok w rzeczywistej aplikacji. Model nadal zwraca niestrukтуrowany ciąg znaków, każde wezwanie zapomina o wszystkim, co było wcześniej, a ręczne łączenie promptu, modelu i procesowania poznawczego szybko przeradza się w standardową strukturę. Ten przewodnik omawia cztery koncepcje LangChain, które rozwiązują te problemy: parserzy wyników, łańcuchy budowane za pomocą LCEL, interfejs Runnable umożliwiający kompozycję oraz mechanizm pamięci. Po jego przeczytaniu powinieneś być w stanie odczytać dowolny pipeline LCEL, wybrać odpowiedni parser lub obiekt typu Runnable do wykonania zadania oraz wiedzieć, gdzie przechowywany jest stan konwersacji w obecnym LangChain.

Zakłada się, że już znasz, czym są otulacz LLM oraz szablon promptu w LangChain, ponieważ wszystko poniżej opiera się bezpośrednio na nich.

Parserzy wyników: przekształcanie tekstu w dane

Problemy, które rozwiązują parserzy

Cokolwiek zapytasz modelu, odpowiedź przychodzi w formie tekstu swobodnego. Dla prostego chatbota, który wyświetla odpowiedź na ekranie, to wystarcza. Gdy tylko twój kod musi coś zrobić z tą odpowiedzią – na przykład zapisać ją do bazy danych, przekazać innej funkcji lub zwrócić ją z API w formacie JSON – zwykła ciąg znaków staje się problemem.

Parser wyjścia znajduje się pomiędzy modelem a resztą twojego programu. Bierze wygenerowany tekst i przekształca go w coś ustrukturyzowanego: listę, słownik, obiekt JSON lub instancję twojej własnej klasy w Pythonie. Przydatnym sposobem na to przedstawienie jest porównanie z interpretatorem: model komunikuje się w postaci swobodnego tekstu, a parser przekształca ten tekst w formę, na której może polegać twój kod.

Dlaczego nie powinieneś parsuć ręcznie

  • Wyjście modelu nie ma gwarantowanego formatu; ten sam prompt może w różnych sesjach dawać nieco inne struktury.
  • API, bazy danych i interfejsy użytkownika wymagają danych spójnych i przewidywalnych.
  • Dywersywne metody dzielenia ciągów znaków lub wyrażenia regularne są kruche i zawodzą przy pierwszej nieoczekiwanej zmianie.
  • Parsery mogą również weryfikować dane, dzięki czemu brakujące pola lub błędnie sformatowane wartości są wykrywane na wczesnym etapie, zamiast powodować mylące błędy później w procesie.
  • Rodzina wbudowanych parserów

    LangChain dostarcza kilka parserów, z których każdy jest przeznaczony do przetwarzania danych o innej strukturze:

    • Parser wyjścia typu ciąg znaków (StrOutputParser): najprostsza opcja, która wyodrębnia zwykły tekst z wiadomości zwrotnej modelu. Użyj jej, gdy chcesz uzyskać czysty ciąg znaków do wyświetlenia lub przekazania dalej w procesie.
    • Parser listy: dzieli odpowiedź na listę w Pythonie, co jest przydatne, gdy potrzebujesz kilku elementów, takich jak słowa kluczowe lub sugestie.
    • Parser wyjściowy JSON: przekształca odpowiedź na obiekt JSON i jest jednym z najczęściej używanych parserów, gdy potrzebne są dane strukturyzowane.
    • Parser wyjściowy Pydantic: mapuje odpowiedź bezpośrednio na model Pydantic, dzięki czemu uzyskujesz walidację typów oprócz struktury. Jest to właściwy wybór, gdy dane muszą odpowiadać ścisłemu schematowi.
    • Parser daty i godziny: tworzy prawdziwy obiekt datetime w Pythonie zamiast daty zapisanej jako tekst.
    • Parser Enum: ogranicza wynik do jednej wartości z z góry zdefiniowanego zbioru i zwraca tę wartość.
    • Parser XML: odczytuje dane zapisane jako tagi XML i przekształca je w słownik.

    Parsery strukturalne oferują metodę get_format_instructions(). Zwraca ona blok tekstu, który należy wstawić do promptu, aby dokładnie poinstruować model, jak ułożyć swoją odpowiedź, tak by parser mógł ją odczytać. Parser prostych ciągów znaków nie ma nic do egzekwowania, dlatego jest jedynym elementem tej grupy, w którym instrukcje formatowania nie mają zastosowania.

    Parsery a natywny wyjście strukturalne

    Wiele obecnych modeli może zwracać dane strukturalne w sposób natywny, a LangChain umożliwia to za pomocą with_structured_output(). Gdy ta funkcja jest dostępna, często zastępuje ona parser, ponieważ dostawca modelu egzekwuje schemat zamiast tego, by prompt prosił o to uprzejmie. Parsery nadal są przydatne, gdy model nie posiada natywnego wyjścia strukturalnego lub gdy chcesz dodatkowej weryfikacji lub transformacji wykraczającej poza to, co gwarantuje dostawca.

    Łańcuchy i LCEL: łączenie kroków

    Czym jest łańcuch

    Łańcuch to seria kroków, w których wynik każdego kroku staje się wejściem dla następnego. Zamiast pisać trzy oddzielne fragmenty kodu do formatowania zapytania, wywoływania modelu i analizy wyniku, łączy się je w jeden wielokrotnie używalny pipeline.

    Klasyczny przykład składa się z trzech etapów: szablonu zapytania, modelu oraz parsera wyników. Podaje się surowe dane wejściowe, takie jak temat lub pytanie, a łańcuch formatuje zapytanie, wywołuje model i analizuje otrzymany wynik w jednym ciągu operacji.

    Czego dają łańcuchy

    • Mniej kodu szablonowego. Połączenia między krokami są zapisywane raz, a nie powtarzane przy każdym wywołaniu.
    • Modularność. Zapytanie, model i parser to niezależne elementy, więc można zastąpić którykolwiek z nich bez wpływu na pozostałe.
  • Czytelność. Łańcuch pozwala od razu zobaczyć kierunek przepływu danych, nawet gdy proces jest skomplikowany.
  • Ponowne użycie. Po zdefiniowaniu łańcuch można wywoływać go wielokrotnie z różnymi danymi wejściowymi.
  • Komponowanie za pomocą operatora pipe

    Język wyrażeń LangChain, LCEL, sprawia, że budowanie łańcuchów jest proste. Do łączenia komponentów używa się operatora |, podobnie jak łączy się polecenia w shellu Unix, zamiast układać nawzajem funkcje.

    Poniższe dwa wiersze tworzą opisany wcześniej trójetapowy łańcuch i uruchamiają go raz z danymi wejściowymi w postaci słownika. Należy zwrócić uwagę, że klucz słownika topic musi odpowiadać zmiennej w szablonie promptu.

    chain = prompt | model | output_parser
    result = chain.invoke({"topic": "black holes"})
    

    Dane wejściowe trafiają do prompt, który tworzy sformatowany komunikat; następnie trafia on do model, a odpowiedź modelu jest przekazywana do output_parser. Każdy komponent przekazuje swój wynik bezpośrednio następnemu, a result przechowuje to, co zwraca parser.

    Każdy łańcuch LCEL posiada również standardowy zestaw metod wykonywania:

    • .invoke() uruchamia łańcuch jeden raz dla pojedynczych danych wejściowych.
    • .batch() uruchamia go dla listy danych wejściowych w jednej próbie.
    • .stream() generuje wynik stopniowo, w miarę jego tworzenia, zamiast zmuszania do oczekiwania na pełną odpowiedź. Przekazanie wyniku strumieniowo odbywa się token po tokenie tylko wtedy, gdy każdy krok łańcucha może przetwarzać strumień; krok, który wymaga najpierw całych danych wejściowych, będzie je buforował.

    Ten deklaratywny styl stanowi istotną część tego, dlaczego LCEL wydaje się modułowy. Opisujesz, co powinno się wydarzyć i w jakiej kolejności, a nie mechanizmy tego, jak każdy krok wywołuje następny.

    Runnables: interfejs w tle

    Dlaczego wszystko można łączyć za pomocą pipeline’ów

    Za plecami LCEL każdy komponent, czy to prompt, model, parser, czy funkcja napisana przez Ciebie, jest Runnableem. Runnable to wspólny interfejs, który może implementować każdy komponent, a właśnie ten wspólny kontrakt sprawia, że | funkcjonuje. Ponieważ każdy element udostępnia te same metody, można je łączyć bez względu na to, co robią wewnętrznie.

    To właśnie ta sama interfejs sprawia, że interakcja z łańcuchem wydaje się jednolita. Niezależnie od tego, czy komponent wywołuje model, przekształca dane lub uruchamia kilka gałęzi jednocześnie, steruje się nim za pomocą tych samych metod .invoke(), .batch() i .stream(). Skomponowany łańcuch sam w sobie jest obiektem typu Runnable, więc można układać łańcuchy wewnątrz większych łańcuchów bez żadnego specjalnego traktowania.

    Najczęściej używane obiekty typu Runnable

    • RunnableSequence wykonywa poszczególne kroki ściśle w kolejności, przekazując wynik jednego kroku do następnego. Łączenie komponentów za pomocą | tworzy dla nas takiego obiektu w tle. Typowe zastosowanie: najpierw prompt, potem model, a następnie parser.
  • RunnableParallel uruchamia kilka obiektów typu Runnable jednocześnie na tym samym wejściu i gromadzi ich wyniki w jednym słowniku, gdzie kluczem jest nazwa. Typowe zastosowanie: jednoczesne streszczanie tekstu i wykrywanie jego języka zamiast wykonywania tych czynności po kolei.
  • RunnablePassthrough zwraca swoje wejście bez zmian. Najczęściej występuje wewnątrz RunnableParallel, gdy potrzebne jest oryginalne wejście obok przetworzonych wartości. Typowe zastosowanie: zwracanie oryginalnego pytania użytkownika wraz z wygenerowaną odpowiedzią.
  • RunnableLambda otacza zwykłą funkcję w Pythonie, aby mogła stanowić element łańcucha operacji. Typowe zastosowanie: dostosowane przetwarzanie przed lub po wykonaniu operacji, takie jak czyszczenie tekstu, liczenie słów lub stosowanie reguł biznesowych pomiędzy poszczególnymi krokami.
  • RunnableBranch umożliwia routowanie typu if/else, kierując dane wejściowe do różnych elementów typu Runnable w zależności od określonej warunku. Typowe zastosowanie: kierowanie pytań technicznych do jednego łańcucha, a ogólnych pytań do innego.
  • Gdy zobaczysz, że modele, parserzy i łańcuchy to wszystko elementy typu Runnable dzielące jedną interfejs, LangChain przestaje wyglądać jak zbiór specjalnych przypadków. Staje się to zwykłą kompozycją: małe jednostki o tym samym kształcie łączone w większe jednostki o tym samym kształcie.

    Ostrzeżenie dotyczące nadmiernej kompozycji

    Ponieważ cokolwiek może stać się obiektem typu Runnable, kusi nas chęć wyrażania całej logiki aplikacji za pomocą nawarstwionych łańcuchów. Działa to dobrze przy prostych krokach, ale głęboko nawarstwione gałęzie i funkcje lambda mogą być trudniejsze do debugowania niż w zwykłym Pythonie. Rozsądną zasadą jest używanie LCEL do obsługi procesów skierowanych do modelu oraz przechowywanie złożonych struktur sterowania w zwykłym kodzie, albo przeniesienie ich do frameworku grafowego, gdy dotyczą pętli i stanu.

    Pamięć: przechowywanie kontekstu między rundami

    Co oznacza tutaj pamięć

    Każde wezwanie modelu jest domyślnie bezstanowe. Nie ma on żadnej wiedzy o tym, o co pytałeś chwilę wcześniej, chyba że twoja aplikacja ponownie wysyła wcześniejszą wymianę danych. W LangChain pamięć to zbiór mechanizmów służących do przechowywania kontekstu rozmowy i późniejszego jego odzyskiwania, dzięki czemu aplikacja może kontynuować rozmowę zamiast traktować każdą wiadomość jakby była pierwszą.

    Dlaczego aplikacje konwersacyjne tego potrzebują

    • Bez pamięci chatbot traci wszystko w momencie przybycia nowej wiadomości: brak ciągłości, brak dalszych odpowiedzi, brak wspólnego kontekstu.
    • Prawdziwa rozmowa zależy od wcześniejszych kroków w dialogu, na przykład od wiedzy o tym, że „on” w kolejnej odpowiedzi odnosi się do osoby wspomnianej dwa wiadomości wcześniej.
    • Pamięć kontekstu sprawia, że asystent wydaje się spójny i prowadzący rozmowę, zamiast odpowiadać na każdą wiadomość osobno.
    • Jest to podstawowy wymóg dla botów obsługi klienta, asystentów osobistych oraz wszelkich innych aplikacji wymagających wielokrotnych interakcji.

    Klasyczne typy pamięci

    Klasyczne podejście wykorzystywało klasy pamięci znajdujące się wewnątrz działającego procesu. Najprostsze z nich przechowywały bufor całej rozmowy; inne skracały starsze wiadomości do aktualnego streszczenia w celu oszczędzania zasobów. Przy każdym nowym wywołaniu przechowywany kontekst był ponownie włączany do promptu.

    Gdzie teraz znajduje się pamięć

    Klasy pamięci w trakcie przetwarzania zostały uznane za przestarzałe, ponieważ LangChain przeniósł stan rozmowy do warstwy persistencji LangGraph. W tym modelu narzędzia typu checkpointer zapisują stan rozmowy dla każdej sesji (zidentyfikowanej za pomocą wątku), natomiast oddzielne magazyny przechowują dłuższościowo pamięć, która powinna przetrwać między sesjami. Koncepcja pozostaje niezmieniona: wcześniejszy kontekst musi być gdzieś przechowywany, aby mógł zostać ponownie wykorzystany. Zmieniła się natomiast zalecana implementacja, która jest teraz bardziej trwała i lepiej przystosowana do użycia w produkcji, ponieważ stan może być zapisywany do rzeczywistej bazy danych, zamiast znikać po ponownym uruchomieniu procesu. Jeśli chcesz zobaczyć, jak checkpointer faktycznie przechowuje ten stan, artykuł „inside LangGraph’s InMemorySaver” opisuje jego wewnętrzną strukturę.

    Rozsądna ścieżka nauki polega najpierw na zrozumieniu klasycznej koncepcji pamięci, ponieważ wyjaśnia ona, dlaczego pamięć jest ważna i co powinno znaleźć się w promptzie, a następnie na zastosowaniu podejścia opartego na trwałości danych w przypadku rozwiązań przeznaczonych do użycia w produkcji. Ponieważ ta dziedzina szybko się rozwija, przed rozpoczęciem pracy sprawdź aktualną dokumentację LangChain i LangGraph w celu poznania dokładnych interfejsów API.

    Podsumowanie

    Razem z narzędziami do obsługi LLM oraz szablonami promptów, te koncepcje stanowią podstawowy zestaw narzędzi LangChain. Każde z nich samo w sobie jest proste, ale ich wartość objawia się w połączeniu: szablon dostarcza dane modelowi, parser przekształca tekst modelu w dane, każdy element jest elementem wykonywalnym połączonym łańcuchem za pomocą |, a warstwa trwałości przechowuje informacje z rozmowy pomiędzy poszczególnymi wywołaniami.

    • Wykorzystuj parser wyników lub natywny strukturyzowany wyjście, jeśli model to obsługuje, zawsze wtedy, gdy kod wykorzystuje odpowiedź modelu.
  • Każdą pozostałą w łańcuchu LCEL należy czytać od lewej do prawej jako RunnableSequence; używaj RunnableParallel, RunnablePassthrough, RunnableLambda oraz RunnableBranch, gdy przepływ nie jest liniowy.
  • Pamiętaj, że przetwarzanie strumieniowe jest tak szybkie, jak najsłabszy etap w tym procesie.
  • Rozpatruj starsze klasy pamięci jako koncepcję do opanowania, a do rzeczywistych zastosowań stosuj narzędzia checkpointingu i przechowywania z LangGraph.
  • Gdy te elementy bazowe są już gotowe, naturalnymi kolejnymi tematami są generowanie wzbogacone o dane z bazy oraz agenci, które powstają właśnie na podstawie tych komponentów.

    Literatura pokrewna

  • LangGraph kontra Pydantic AI: Dlaczego to model, a nie framework, decydował o dokładności — Kontrolowane badanie porównawcze przeprowadzone 160 razy pomiędzy LangGraph a Pydantic AI pokazuje identyczną dokładność wywoływania narzędzi i ujawnia, że wybór modelu oraz projekt oceny mają znacznie większe znaczenie.
  • Od chatbota jednogłównego do agenta wspieranego przez MCP w LangGraph — Buduj aplikację w LangGraph warstwa po warstwie: stan i reduktory, krawędzie, pętle narzędziowe, wątki z zapisem stanu, trzy tryby strumieniowania oraz narzędzia dostarczane przez MCP.
  • Sześć prymitywów LangGraph i sposoby maskowania błędów w każdym z nich — Poznaj stan, węzły, krawędzie, warunkowe routowanie, funkcje checkpointingu oraz przerwy w LangGraph poprzez konkretne błędy, do których może doprowadzić każda z tych funkcji, oraz sposoby ich uniknięcia.