Strona główna / Artykuły / Od tekstu prostego do zweryfikowanych obiektów: wybór parsera wyników LangChain

Od tekstu prostego do zweryfikowanych obiektów: wybór parsera wyników LangChain

Porównaj StrOutputParser, JsonOutputParser, StructuredOutputParser oraz PydanticOutputParser w łańcuchach LangChain i dowiedz się, jak dużą strukturę faktycznie gwarantuje każdy z nich.

5418 słów

Model czatowy zwraca tekst, który jest dobrze czytelny dla osoby patrzącej na ekran. Gdy odpowiedź musi służyć jako podstawa dla kolejnego zapytania, trafić do bazy danych lub sterować komponentem interfejsu użytkownika, potrzebny jest coś bardziej przewidywalnego: czysta ciąg znaków, obiekt JSON lub zapis typowany, który został już sprawdzony. LangChain wypełnia tę lukę za pomocą parserów wyników, dostarczając kilku z nich o zupełnie różnych gwarancjach. W tym przewodniku budujemy ten sam mały projekt Układu Słonecznego przy użyciu czterech parserów, wyjaśniamy każdą linię każdego łańcucha i kończymy praktyczną zasadą wyboru między nimi.

Jeśli chcesz dowiedzieć się więcej o tym, jak parserzy współpracują z LCEL, uruchamialnymi elementami i pamięcią, przeczytaj od surowego tekstu do pipeline’ów w LangChain. Tutaj skupienie jest węższe: na tym, co każdy parser faktycznie obiecuje i gdzie kończy się ta obietnica.

Dlaczego sam surowy tekst modelu nie wystarcza

Gdy zapytasz model o Układ Słoneczny, możesz otrzymać coś w rodzaju poniższego tekstu. Jest dobrze napisany, ale program nie może z niego wydobyć konkretnej informacji bez domyślania się, gdzie kończy się jedno zdanie, a zaczyna następne.

The Solar System consists of the Sun and the objects that orbit it.
It contains eight planets along with moons, asteroids, and comets.

Kod, który przetwarza tę odpowiedź, wolałby otrzymywać wartości nazwane, do których może się bezpośrednio odwołać, na przykład obiekt z jedną kluczem dla każdej informacji:

{
    "fact_1": "The Solar System contains eight planets.",
    "fact_2": "The Sun is at the center of the Solar System.",
    "fact_3": "The Solar System also contains moons, asteroids, and comets."
}

Parser wyjściowy to komponent, który łączy te dwa elementy. Otrzymuje wszystko, co wygenerował model, i zwraca wartość, której aplikacja może używać bez dodatkowej obróbki ciągów znaków. Parserzy różnią się stopniem zaawansowania: niektóre jedynie odsłaniają tekst, inne parsują JSON, a te najbardziej rygorystyczne weryfikują wynik pod kątem schematu.

Raw LLM Response
       ↓
Output Parser
       ↓
Parsed Output

Pamiętaj o tym trójetapowym modelu. Każdy przykład poniżej jest jego wariacją – do środkowej części dodaje się coraz więcej elementów w miarę zaostrzania wymagań.

Cztery parserzy, cztery poziomy struktury

Cztery parserzy omówione tutaj tworzą „drabinę”, przy czym każdy kolejny poziom zapewnia większą kontrolę nad wynikiem:

  • StrOutputParser przekształca wiadomość modelu w zwykły ciąg znaków w Pythonie.
  • JsonOutputParser parsuje odpowiedź na wartość kompatybilną z JSON, taką jak słownik lub lista.
  • StructuredOutputParser umożliwia określenie pól o nazwach, które model powinien zwrócić.
  • PydanticOutputParser opisuje oczekiwany kształt danych za pomocą modelu Pydantic i weryfikuje wynik pod względem tego opisu.
  • Niezależnie od wyboru, ścieżka danych wygląda tak samo:

    LLM Response
         ↓
    Output Parser
         ↓
    Parsed Output
    

    Zmienia się to, co pojawia się na końcu, oraz stopień, w jakim można mu ufać.

    Przykład działania: raport, a następnie streszczenie

    Pierwszy projekt to proces składający się z dwóch kroków. Temat „System Słoneczny” trafia do modelu, który tworzy długi raport. Ten raport jest następnie podawany do drugiego modelu, który prosi o pięciolinijowe streszczenie. Kluczowym elementem jest przekazanie danych: wynik pierwszego wywołania modelu staje się wejściem dla następnego modelu.

    Solar System
         ↓
    LLM
         ↓
    Detailed Report
         ↓
    LLM
         ↓
    5-Line Summary
    

    Bез parsera pierwszy krok zwraca obiekt wiadomości, a nie tekst raportu, więc trzeba by go rozpakować przed utworzeniem drugiego zapytania. Parser umieszczony pomiędzy nimi wykonuje to rozpakowanie jako część łańcucha, dzięki czemu oba kroki mogą funkcjonować sprawnie.

    StrOutputParser: gdy potrzebny jest tylko tekst

    StrOutputParser to najprostszy parser dostępny w LangChain. Modele do rozmów zwracają obiekt AIMessage; ten parser wydobywa jego zawartość i dostarcza zwykłą ciąg znaków. Jest to odpowiedni narzędzie, gdy kolejnym odbiorcą jest człowiek, inne zapytanie lub cokolwiek innego, co potrzebuje jedynie tekstu opisowego, a nie JSON ani schematu.

    Włączenie go do procesu tworzenia raportów

    W projekcie report-then-summary kroki wyglądają następująco:

    1. Pierwsze zapytanie prosi model o szczegółowy raport na dany temat.
  • Raport ten jest przekazywany drugiemu poleceniu.
  • Druge polecenie prosi model o streszczenie raportu do pięciu wierszy.
  • Po każdej wywołaniu modelu znajduje się StrOutputParser, dzięki czemu każda przekazana wartość to zwykła ciąg znaków. Pełna sekwencja komponentów jest następująca:

    Solar System
         ↓
    Prompt 1
         ↓
    LLM
         ↓
    StrOutputParser
         ↓
    Detailed Report
         ↓
    Prompt 2
         ↓
    LLM
         ↓
    StrOutputParser
         ↓
    5-Line Summary
    

    Wersja OpenAI

    Oto pełny łańcuch używający ChatOpenAI. Przeczytaj go od góry do dołu raz, a potem omówimy te części, które są istotne.

    from langchain_openai import ChatOpenAI
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain_core.output_parsers import StrOutputParser
    
    load_dotenv()
    model = ChatOpenAI()
    # 1st prompt -> detailed report
    template1 = PromptTemplate(
        template='Write a detailed report on {topic}',
        input_variables=['topic']
    )
    # 2nd prompt -> summary
    template2 = PromptTemplate(
        template='Write a 5 line summary on the following text. /n {text}',
        input_variables=['text']
    )
    parser = StrOutputParser()
    chain = template1 | model | parser | template2 | model | parser
    result = chain.invoke({'topic': 'Solar System'})
    print(result)
    

    Model jest tworzony przy użyciu domyślnych ustawień. Funkcja load_dotenv() pobiera klucz API z lokalnego pliku .env, więc żadne dane uwierzytelniające nie znajdują się w skrypcie.

    model = ChatOpenAI()
    

    Pierwszy szablon przyjmuje jedną zmienną, topic, i żąda szczegółowego raportu na jej temat.

    template1 = PromptTemplate(
        template='Write a detailed report on {topic}',
        input_variables=['topic']
    )
    

    Drugi szablon oczekuje zmiennej o nazwie text, która będzie przechowywać raport, oraz prosi o pięciowierszowe podsumowanie tego raportu.

    template2 = PromptTemplate(
        template='Write a 5 line summary on the following text. /n {text}',
        input_variables=['text']
    )
    

    Jeden mały błąd warto naprawić, jeśli to kopiujesz: ciąg szablonu zawiera /n, co jest dosłownym ukośnikiem połączonym z literą n, a nie przejściem do nowej linii. Użyj \n, jeśli chcesz, aby raport zaczynał się na nowej linii. Modele zazwyczaj radzą sobie w obu przypadkach, ale komenda, którą myślisz, że wysyłasz, powinna być tą samą, którą faktycznie wysyłasz.

    Następnie przychodzi parser. Jedna instancja może być ponownie wykorzystana w kilku miejscach łańcucha, ponieważ nie przechowuje żadnego stanu pomiędzy wywołaniami.

    parser = StrOutputParser()
    

    Linia, która łączy wszystko razem, to definicja łańcucha:

    chain = template1 | model | parser | template2 | model | parser
    

    Operator rury stanowi kompozycję w języku wyrażeń LCEL (LangChain Expression Language): wynik każdego komponentu staje się wejściem dla następnego. Ułożone wertykalnie, kolejność wykonywania jest następująca:

    template1
        ↓
    model
        ↓
    parser
        ↓
    template2
        ↓
    model
        ↓
    parser
    

    Zwróć uwagę, co daje ci pierwszy parser. Po pierwszym wywołaniu modelu parser zwraca raport w postaci ciągu znaków, a właśnie ten ciąg znaków wypełnia {text} w drugim szablonie. Drugi parser wykonuje tę samą czynność dla ostatecznej odpowiedzi, więc wynikiem łańcucha jest sam streszczenie, a nie obiekt wiadomości.

    Rozpoczynasz łańcuch, przekazując słownik, którego klucze odpowiadają zmiennym wejściowym pierwszego szablonu:

    result = chain.invoke({'topic': 'Solar System'})
    

    Następnie wyświetlasz wynik:

    print(result)
    

    Co zwraca łańcuch

    Wartość wyświetlona na końcu to pięciowierszowy streszczenie pochodzące z wygenerowanego raportu. Ponieważ ostatnim komponentem jest StrOutputParser, wynikiem jest zwykła wartość typu str w Pythonie:

    print(result)
    

    Typowy przeprowadzony test daje wynik w przybliżeniu taki:

    1. The Solar System consists of the Sun and all objects that orbit it.
    2. It includes eight planets, along with dwarf planets, moons, asteroids, and comets.
    3. The four inner planets are rocky, while the outer planets are mostly gas or ice giants.
    4. The Sun contains most of the Solar System's mass and provides the energy that drives many processes.
    5. The Solar System is located in the Milky Way galaxy.
    

    Traktuj to jako ilustrację struktury, a nie jako stałą odpowiedź. Sformułowania będą się różnić w zależności od przeprowadzanych testów oraz modeli.

    Ta sama procedura z modelem Hugging Face

    Proces raportowania, a następnie tworzenia streszczenia może być również stosowany z otwartym modelem. Ta wersja wykorzystuje HuggingFaceEndpoint skierowany na adres google/gemma-2-2b-it i otacza go warstwą ChatHuggingFace, dzięki czemu zachowuje się jak model do rozmów:

    from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    
    load_dotenv()
    
    llm = HuggingFaceEndpoint(
        repo_id="google/gemma-2-2b-it",
        task="text-generation"
    )
    
    model = ChatHuggingFace(llm=llm)
    
    # 1st prompt -> detailed report
    template1 = PromptTemplate(
        template='Write a detailed report on {topic}',
        input_variables=['topic']
    )
    
    # 2nd prompt -> summary
    template2 = PromptTemplate(
        template='Write a 5 line summary on the following text. /n {text}',
        input_variables=['text']
    )
    
    prompt1 = template1.invoke({'topic': 'Solar System'})
    result = model.invoke(prompt1)
    
    prompt2 = template2.invoke({'text': result.content})
    result1 = model.invoke(prompt2)
    
    print(result1.content)
    

    Istnieje tu ważna różnica. Ta wersja nigdy nie używa StrOutputParser ani nie buduje łańcucha rurek. Każde polecenie formatuje ręcznie za pomocą .invoke(), wywołuje model i odczytuje .content z zwróconej wiadomości, zanim ją przekaże dalej. To działa, ale jest to dokładnie ten ręczny proces rozpakowywania, który ma na celu usunięcie parsera.

    Obok siebie obie metody wyglądają w ten sposób. Z OpenAI i parserem:

    OpenAI
    
    Prompt
      ↓
    ChatOpenAI
      ↓
    StrOutputParser
      ↓
    String
    

    Z Hugging Face i ręcznym dostępem:

    Hugging Face
    
    Prompt
      ↓
    ChatHuggingFace
      ↓
    result.content
      ↓
    String
    

    Oba kończą się ciągiem znaków. Skrypt OpenAI pokazuje, jak parser wykonywać tę funkcję w ramach łańcucha, natomiast skrypt Hugging Face przedstawia tę samą logikę aplikacji z innym dostawcą i bez parsera. Nic nie stoi na przeszkodzie w użyciu konstrukcji template1 | model | parser | template2 | model | parser również z modelem Hugging Face – parser nie przejmuje się tym, który dostawca wygenerował wiadomość.

    Wniosek z tego etapu: używaj StrOutputParser, gdy aplikacja potrzebuje odpowiedzi wyłącznie w formie tekstu.

    JsonOutputParser: JSON bez kontraktu

    JsonOutputParser to kolejny krok w rozwoju. Prosi model o dane w formacie JSON i przekształca odpowiedź na dane w języku Python, co jest przydatne, gdy kod musi wybierać wartości na podstawie kluczy zamiast czytać tekst opisowy.

    To, czego nie robi, to narzucanie określonego kształtu. Bez schematu model jest proszony o odpowiedź w formacie JSON, ale nie podaje się żadnych informacji na temat tego, które klucze muszą występować ani jakiego typu powinny być ich wartości. Dwa wykonywania z tym samym promptem mogą legalnie zwracać obiekty o różnym kształcie, a Twój kod musi być na to przygotowany.

    Jak wszystko się łączy

    W tym projekcie od modelu wymaga się pięciu faktów o Układzie Słonecznym. Kroki są następujące:

    1. Stworzyć JsonOutputParser.
    2. Zapytać go o instrukcje formatowania za pomocą get_format_instructions().
    3. Wstawić te instrukcje do promptu.
    4. Przesłać prompt do modelu.
    5. Pozwolić parserowi przekształcić odpowiedź na wartość w Pythonie.
    Solar System
         ↓
    PromptTemplate
         ↓
    Format Instructions
         ↓
    LLM
         ↓
    JsonOutputParser
         ↓
    JSON Object
    

    wersja OpenAI

    Cały łańcuch jest krótki:

    from langchain_openai import ChatOpenAI
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain_core.output_parsers import JsonOutputParser
    
    load_dotenv()
    # Define Model
    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    parser = JsonOutputParser()
    template = PromptTemplate(
        template="Give me 5 facts about {topic} \n {format_instruction}",
        input_variables=["topic"],
        partial_variables={"format_instruction": parser.get_format_instructions()},
    )
    chain = template | model | parser
    result = chain.invoke({"topic": "Solar System"})
    print(result)
    

    Model jest skonfigurowany z gpt-4.1-mini i temperaturą 0, co zapewnia wynik tak powtarzalny, na jaki pozwala model. To właśnie ten komponent będzie zapisywał pięć faktów.

    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    

    Parser jest tworzony bez żadnych argumentów, i to właśnie dlatego nie ma schematu do egzekwowania:

    parser = JsonOutputParser()
    

    Instrukcje formatu wykonują rzeczywistą pracę

    Zanim model zostanie uruchomiony, parser może opisać format, którego oczekuje. Ten opis pochodzi z jednej wywołania metody:

    parser.get_format_instructions()
    

    Zwraca on blok tekstu, który instruuje model do odpowiedzi w formacie JSON. Nie jest to kod uruchamiany na modelu; to tekst promptu. Wstrzykuje się go do szablonu za pomocą partial_variables, co polega na uzupełnieniu zmiennej szablonu tylko raz, podczas definiowania szablonu, a nie przy każdym wywołaniu:

    partial_variables={
        "format_instruction": parser.get_format_instructions()
    }
    

    Wynikowy szablon zawiera dwa miejsca zastępcze:

    template = PromptTemplate(
        template="Give me 5 facts about {topic} \n {format_instruction}",
        input_variables=["topic"],
        partial_variables={
            "format_instruction": parser.get_format_instructions()
        },
    )
    
    • {topic} jest podawane w momencie wywołania i określa, jakie informacje są potrzebne.
    • {format_instruction} jest z góry wypełniane wytycznymi dotyczącymi formatowania od strony parsera.

    Zatem gdy wywołasz łańcuch z tymi danymi, model otrzymuje zarówno temat, jak i instrukcję dotyczącą odpowiedzi w formacie JSON:

    {"topic": "Solar System"}
    

    Kompletowanie i uruchamianie łańcucha

    Sam łańcuch składa się z zaledwie trzech etapów:

    chain = template | model | parser
    

    W kolejności wykonywania:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    JsonOutputParser
          ↓
    Parsed JSON
    

    Szablon generuje ostateczne zapytanie, ChatOpenAI na nie odpowiada, a JsonOutputParser przetwarza tę odpowiedź na dane w języku Python. Parser jest również wyrozumiały wobec niektórych powszechnych nawyków modeli, takich jak umieszczanie JSON w ramkach kodu Markdown, które są usuwane przed przetwarzaniem.

    Wywołaj to z tematem:

    result = chain.invoke({"topic": "Solar System"})
    

    I wydrukuj to, co wróciło:

    print(result)
    

    Co wraca

    Wynik zawiera pięć faktów w formacie JSON. Skrypt wydrukuje tylko wartość, więc nie ma kanonicznego wyniku do ujęcia w cudzysłowy; prawdopodobna odpowiedź wygląda tak:

    {
      "facts": [
        "The Solar System is centered around the Sun.",
        "There are eight recognized planets in the Solar System.",
        "The four inner planets are rocky planets.",
        "The outer planets include gas giants and ice giants.",
        "The Solar System is located in the Milky Way galaxy."
      ]
    }
    

    Taka struktura, pojedynczy klucz facts zawierający listę, to jedna z kilku możliwych opcji wybranych przez model. Inny przeprowadzony test może zwrócić fact_1 do fact_5, albo prostą listę. Jeśli kod poniżej odwołuje się do result["facts"], będzie działał błędnie w momencie, gdy model wybierze inną strukturę.

    Śledzenie przepływu

    W porównaniu z StrOutputParser, nowym elementem jest to, że parser bierze udział dwa razy: raz przed wywołaniem modelu, dostarczając instrukcje, i raz po nim, przeprowadzając analizę.

    Prompt
      ↓
    JSON Format Instructions
      ↓
    LLM
      ↓
    JsonOutputParser
      ↓
    JSON
    

    Instrukcje są generowane przez sam parser:

    parser.get_format_instructions()
    

    Dostają się do interfejsu użytkownika za pośrednictwem zmiennej częściowej:

    partial_variables={
        "format_instruction": parser.get_format_instructions()
    }
    

    Model odpowiada, korzystając z tych instrukcji, a parser przekształca odpowiedź na wartość w języku Python. Od początku do końca:

    Solar System
         ↓
    PromptTemplate
         ↓
    JSON Format Instructions
         ↓
    ChatOpenAI
         ↓
    JsonOutputParser
         ↓
    JSON Object
    

    Różnica w porównaniu z poprzednim etapem mieści się w dwóch liniach. StrOutputParser generuje:

    StrOutputParser
        ↓
    Plain String
    

    Podczas gdy JsonOutputParser generuje:

    JsonOutputParser
        ↓
    JSON-compatible Structured Data
    

    Pamiętaj o ograniczeniu: nadal nie ma ustalonej struktury. Otrzymujesz JSON, ale pola i ich ułożenie zależą od modelu. Należy dodać, że aktualne wersje JsonOutputParser przyjmują również opcjonalny argument pydantic_object, który dodaje strukturę do instrukcji formatowania, ale w przedstawionej tutaj formie, bez argumentów, wymaga jedynie poprawnego JSON.

    Wariant Hugging Face

    Przepływ pracy w formacie JSON jest bezpośrednio przekazywany do modelu Gemma. Ustawienia modelu ulegają zmianie; parser, instrukcje formatowania oraz łańcuch przetwarzania pozostają bez zmian:

    from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain_core.output_parsers import JsonOutputParser
    
    load_dotenv()
    # Define the model
    llm = HuggingFaceEndpoint(
        repo_id="google/gemma-2-2b-it",
        task="text-generation"
    )
    model = ChatHuggingFace(llm=llm)
    parser = JsonOutputParser()
    template = PromptTemplate(
        template='Give me 5 facts about {topic} \n {format_instruction}',
        input_variables=['topic'],
        partial_variables={
            'format_instruction': parser.get_format_instructions()
        }
    )
    chain = template | model | parser
    result = chain.invoke({'topic': 'Solar System'})
    print(result)
    

    Proces jest identyczny, z wyjątkiem pola z modelem:

    PromptTemplate
          ↓
    Hugging Face Model
          ↓
    JsonOutputParser
          ↓
    JSON Object
    

    To jest praktyczna korzyść z umieszczenia procesu parsowania w osobnym komponencie: zmiana dostawców nie wpływa na logikę parsowania. Należy jednak pamiętać, że małe modele otwarte traktują instrukcje formatowania mniej wiarygodnie niż większe modele hostowane. Jeśli odpowiedź zawiera tekst otaczający JSON lub przecinek na końcu, parser wywołuje błąd OutputParserException, dlatego kod produkcyjny powinien go przechwycić i spróbować ponownie lub przejść na alternatywę.

    StructuredOutputParser: nadawanie nazw oczekiwanych pól

    StructuredOutputParser wydobywa dane w formacie JSON na podstawie listy pól, które zdefiniujesz z góry. Podczas gdy JsonOutputParser wymaga jedynie „odpowiedzi w formacie JSON”, ten parser określa „odpowiedź w formacie JSON z tymi kluczami”.

    Pola są deklarowane za pomocą ResponseSchema. Każde z nich ma name oraz description, a opis ten informuje model, co powinno znajdować się w danym polu. Dzięki temu uzyskujemy znacznie większą kontrolę nad strukturą odpowiedzi.

    W tym projekcie wymagane są trzy fakty na temat Układu Słonecznego, po jednym polu na każdy fakt:

    • fact_1 zawiera pierwszy fakt na dany temat.
    • fact_2 zawiera drugi.
    • fact_3 zawiera trzeci.

    Parser przekształca te deklaracje w instrukcje, a następnie sprawdza odpowiedź pod kątem ich spełnienia:

    Solar System
         ↓
    PromptTemplate
         ↓
    Predefined Field Schema
         ↓
        LLM
         ↓
    StructuredOutputParser
         ↓
    Structured JSON
    

    Wersja OpenAI

    Oto pełny skrypt:

    from langchain_openai import ChatOpenAI
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain.output_parsers import StructuredOutputParser, ResponseSchema
    
    load_dotenv()
    # Define Model
    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    schema = [
        ResponseSchema(name="fact_1", description="Fact 1 about the topic"),
        ResponseSchema(name="fact_2", description="Fact 2 about the topic"),
        ResponseSchema(name="fact_3", description="Fact 3 about the topic"),
    ]
    parser = StructuredOutputParser.from_response_schemas(schema)
    template = PromptTemplate(
        template="Give 3 fact about {topic} \n {format_instruction}",
        input_variables=["topic"],
        partial_variables={"format_instruction": parser.get_format_instructions()},
    )
    chain = template | model | parser
    result = chain.invoke({"topic": "Solar System"})
    print(result)
    

    Model ma taką samą konfigurację gpt-4.1-mini jak wcześniej:

    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    

    Prawdziwa różnica zaczyna się od listy schematów:

    schema = [
        ResponseSchema(name="fact_1", description="Fact 1 about the topic"),
        ResponseSchema(name="fact_2", description="Fact 2 about the topic"),
        ResponseSchema(name="fact_3", description="Fact 3 about the topic"),
    ]
    

    Każdy element ResponseSchema dostarcza dwie informacje:

    • name staje się kluczem w otrzymanym słowniku.
    • description określa, co powinien zawierać dany klucz.

    Trzy schematy dają trzy wymagane klucze: fact_1, fact_2 i fact_3.

    Nie tworzy się tego parsera bezpośrednio. Metoda klasowa buduje go na podstawie listy schematów:

    parser = StructuredOutputParser.from_response_schemas(schema)
    

    Podobnie jak w przypadku parsera JSON, instrukcje dotyczące formatu pochodzą od samego parsera:

    parser.get_format_instructions()
    

    Tym razem instrukcje są bardziej rozbudowane. Zawierają szkielet w formacie JSON z wyliczeniem każdej nazwy pola wraz z jej opisem oraz proszą model o umieszczenie odpowiedzi w zamkniętym bloku JSON. Są one połączone w ten sam sposób:

    partial_variables={
        "format_instruction": parser.get_format_instructions()
    }
    

    Ścieżka promptu zawiera dwa znajome miejsca zastępcze:

    template = PromptTemplate(
        template="Give 3 fact about {topic} \n {format_instruction}",
        input_variables=["topic"],
        partial_variables={
            "format_instruction": parser.get_format_instructions()
        },
    )
    

    {topic} jest wypełniane podczas wywołania, a {format_instruction} zawiera listę pól wygenerowaną na podstawie schematów. Wywołanie z tymi danymi wysyła oba elementy do modelu:

    {"topic": "Solar System"}
    

    Uruchamianie łańcucha

    Łańcuch składa się z tych samych trzech etapów co wcześniej:

    chain = template | model | parser
    

    Gdzie parser znajduje się na ostatniej pozycji:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    StructuredOutputParser
          ↓
    Structured JSON
    

    Ścieżka tworzy prompt, model udziela odpowiedzi, a StructuredOutputParser wyodrębnia deklarowane pola z tej odpowiedzi.

    result = chain.invoke({"topic": "Solar System"})
    
    print(result)
    

    Jak wygląda wynik

    Wynikiem jest słownik zawierający trzy dane pod kluczami, które zdefiniowałeś. Skrypt je wyświetla:

    print(result)
    

    A przykładowy wynik wygląda następująco:

    {
        "fact_1": "The Solar System is centered around the Sun.",
        "fact_2": "There are eight recognized planets in the Solar System.",
        "fact_3": "The Solar System is located in the Milky Way galaxy."
    }
    

    Konkretne dane mogą się różnić. To, co nie powinno ulegać zmianie, to zbiór kluczy:

    fact_1
    fact_2
    fact_3
    

    To jest zaleta w porównaniu z prostym parsowaniem JSON: to twoja aplikacja decyduje o nazwach pól, a nie model. Jeśli model pominie jeden z deklarowanych kluczy, parser wywoła błąd zamiast cicho zwrócić inny format, co jest o wiele łatwiejsze do obsługi niż błąd KeyError trzy funkcje później.

    Śledzenie przepływu

    Pełna ścieżka, od tematu do wyniku z kluczami:

    Solar System
         ↓
    PromptTemplate
         ↓
    ResponseSchema
         ↓
    Format Instructions
         ↓
    ChatOpenAI
         ↓
    StructuredOutputParser
         ↓
    {
        fact_1: ...,
        fact_2: ...,
        fact_3: ...
    }
    

    Zaczyna się od definicji pól:

    ResponseSchema(
        name="fact_1",
        description="Fact 1 about the topic"
    )
    

    Parser przekształca je w instrukcje formatowania, które trafiają do promptu, model odpowiada, a następnie parser wyodrębnia deklarowane pola. W porównaniu z poprzednim etapem:

    JsonOutputParser
           ↓
    JSON output
           ↓
    Structure can vary
    
    StructuredOutputParser
           ↓
    Predefined fields
           ↓
    More controlled structure
    

    Krótko mówiąc, JsonOutputParser służy do uzyskania JSON w ogóle, natomiast StructuredOutputParser służy do uzyskania JSON z kluczami, o które prosiłeś.

    Istnieje ograniczenie, które warto jasno podkreślić. ResponseSchema ma atrybut type, którego domyślna wartość to string, ale zmienia on jedynie sformułowanie instrukcji. Parser sprawdza obecność kluczy; nie weryfikuje typów ani zakresów wartości. Jeśli potrzebujesz, aby age było liczbą całkowitą powyżej określonego progu, ten parser tego nie zagwarantuje.

    Należy również sprawdzić ścieżkę importu w odniesieniu do wersji LangChain, którą używasz. Przykład importuje z langchain.output_parsers, a w nowszych wersjach ten starszy parser został usunięty z pakietów podstawowych, więc import może wymagać zmiany.

    Wariant Hugging Face

    Wersja Gemma ponownie wykorzystuje te same trzy schematy oraz tę samą strukturę parsera:

    from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain.output_parsers import StructuredOutputParser, ResponseSchema
    
    load_dotenv()
    # Define the model
    llm = HuggingFaceEndpoint(
        repo_id="google/gemma-2-2b-it",
        task="text-generation"
    )
    model = ChatHuggingFace(llm=llm)
    schema = [
        ResponseSchema(name='fact_1', description='Fact 1 about the topic'),
        ResponseSchema(name='fact_2', description='Fact 2 about the topic'),
        ResponseSchema(name='fact_3', description='Fact 3 about the topic'),
    ]
    parser = StructuredOutputParser.from_response_schemas(schema)
    template = PromptTemplate(
        template='Give 3 fact about {topic} \n {format_instruction}',
        input_variables=['topic'],
        partial_variables={
            'format_instruction': parser.get_format_instructions()
        }
    )
    chain = template | model | parser
    result = chain.invoke({'topic': 'Solar System'})
    print(result)
    

    I ten sam pipeline:

    PromptTemplate
          ↓
    ChatHuggingFace
          ↓
    StructuredOutputParser
          ↓
    Structured JSON
    

    Różni się tylko dostawca modelu. Schemat i parser pozostają bez zmian.

    PydanticOutputParser: struktura plus walidacja

    PydanticOutputParser jest najbardziej rygorystycznym z czterech. Opisuje oczekiwaną odpowiedź za pomocą modelu Pydantic, więc definicja wyjścia jest jednocześnie definicją tego, co uznaje się za ważne.

    To wykracza poza zwykłe parsowanie. Pola przechowują rzeczywiste typy w Pythonie, a Field() umożliwia dodawanie ograniczeń, na przykład wymógu, aby liczba całkowita przekraczała określoną wartość minimalną. Gdy odpowiedź modelu ich nie spełnia, otrzymujemy wyjątek zamiast nieprawidłowych danych.

    Dlaczego warto poświęcić dodatkowy czas na konfigurację

    • Wymuszanie zgodności ze schematem: odpowiedź musi odpowiadać dobrze zdefiniowanemu kształtowi.
    • Bезpieczeństwo typów: pola wykorzystują typy z Pythona, takie jak str, int i float, a wartości są odpowiednio przekształcane lub odrzucane.
    • Walidacja: Pydantic sprawdza każde zadeklarowane ograniczenie.
    • Integracja z łańcuchami: pozwala na integrację z promptami, modelami oraz łańcuchami LCEL dokładnie tak samo jak inne narzędzia do parsowania.

    Projekt dotyczący fikcyjnych postaci

    To przykład poleca modelowi stworzenie osoby pochodzącej z określonego miejsca, w tym przypadku „Indii”, z trzema polami:

    • name – imię osoby.
    • age – wiek osoby.
    • city – miasto, w którym mieszka.

    Pole age ma również ograniczenie: musi być większe od 18.

    Input
      ↓
    PromptTemplate
      ↓
    Pydantic Model
      ↓
    Format Instructions
      ↓
    LLM
      ↓
    PydanticOutputParser
      ↓
    Validated Pydantic Object
    

    Wersja OpenAI

    Pełny skrypt:

    from langchain_openai import ChatOpenAI
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain_core.output_parsers import PydanticOutputParser
    from pydantic import BaseModel, Field
    
    load_dotenv()
    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    class Person(BaseModel):
        name: str = Field(description="Name of the person")
        age: int = Field(gt=18, description="Age of the person")
        city: str = Field(description="Name of the city of the person")
    parser = PydanticOutputParser(pydantic_object=Person)
    template = PromptTemplate(
        template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
        input_variables=["place"],
        partial_variables={"format_instruction": parser.get_format_instructions()},
    )
    chain = template | model | parser
    final_result = chain.invoke({"place": "Indian"})
    print(final_result)
    

    Przestrzega tej samej struktury co wcześniej: definiuje się model Person, przekazuje go do PydanticOutputParser, a następnie łączy parser z promptem i modelem.

    Konfiguracja modelu pozostała bez zmian:

    model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
    

    Głównym elementem jest klasa Pydantic:

    class Person(BaseModel):
        name: str = Field(description="Name of the person")
        age: int = Field(gt=18, description="Age of the person")
        city: str = Field(description="Name of the city of the person")
    

    Oto specyfikacja odpowiedzi:

    • name musi być łańcuchem znaków.
  • age musi być liczbą całkowitą wyraźnie większą od 18.
  • city musi być łańcuchem tekstowym.
  • Field() umożliwia dodanie zarówno opisu czytelnego dla człowieka, który trafia do wiadomości, jak i ograniczeń, które są sprawdzane po parsowaniu. Pole objęte ograniczeniami samo w sobie:

    age: int = Field(gt=18, description="Age of the person")
    

    gt=18 oznacza „większe od 18”, więc wiek dokładnie 18 lat nie przechodzi walidacji. Jeśli chodziło o „18 lat lub więcej”, użyj zamiast tego ge=18.

    Parsery tworzy się poprzez przekazanie klasy, a nie instancji:

    parser = PydanticOutputParser(pydantic_object=Person)
    

    To określa, który model ma być używany zarówno do generowania instrukcji, jak i do walidacji odpowiedzi.

    Instrukcje formatowania pochodzą z tej samej metody co wcześniej:

    parser.get_format_instructions()
    

    Dla tego parsera zawierają one schemat JSON wygenerowany na podstawie modelu Pydantic, w tym opisy pól oraz ograniczenie exclusiveMinimum dla pola age. Są one wstawiane za pomocą zmiennej partial, jak zwykle:

    partial_variables={
        "format_instruction": parser.get_format_instructions()
    }
    

    Ścieżka promptu:

    template = PromptTemplate(
        template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
        input_variables=["place"],
        partial_variables={"format_instruction": parser.get_format_instructions()},
    )
    

    {place} określa, jaki rodzaj osoby powinien stworzyć model. Użycie tego wprowadzenia polega na zapytaniu o imię, wiek i miasto fikcyjnej osoby z Indii:

    {"place": "Indian"}
    

    Uruchamianie łańcucha

    Łańcuch zachowuje znajomy trójetapowy układ:

    chain = template | model | parser
    

    Tym razem ostatni etap zwraca instancję modelu:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    PydanticOutputParser
          ↓
    Pydantic Object
    

    Ścieżka buduje prompt, model udziela odpowiedzi, a PydanticOutputParser parsuje JSON i waliduje je jako obiekt typu Person.

    final_result = chain.invoke({"place": "Indian"})
    
    print(final_result)
    

    Jak wygląda wynik

    Wynikiem jest obiekt typu Person, a nie słownik. Jego wydrukowanie pokazuje standardową reprezentację dostarczaną przez Pydantic:

    name='Rahul Sharma' age=28 city='Mumbai'
    

    Wartości mogą się różnić przy każdym uruchomieniu. Gwarancje natomiast pozostaną niezmienne:

    name → string
    age  → integer (> 18)
    city → string
    

    Tutaj różnice są najbardziej wyraźne. JsonOutputParser prosił jedynie o format JSON, StructuredOutputParser określał nazwy pól, natomiast PydanticOutputParser reprezentuje cały kontrakt jako rzeczywistą klasę. Można uzyskać dostęp do final_result.age za pomocą autodopowiadania w edytorze i mieć pewność, że jest to wartość typu int większa niż 18, ponieważ wszystko inne spowodowałoby błąd walidacji jeszcze przed dotarciem do kodu.

    Śledzenie przepływu

    Od danych wejściowych do zweryfikowanego obiektu:

    "Indian"
        ↓
    PromptTemplate
        ↓
    Pydantic Model
        ↓
    Format Instructions
        ↓
    ChatOpenAI
        ↓
    PydanticOutputParser
        ↓
    Person Object
    

    Zaczyna się od struktury, pokazanej tutaj bez opisów i ograniczeń dla lepszej czytelności:

    class Person(BaseModel):
        name: str
        age: int
        city: str
    

    Klasa jest przekazywana parserowi:

    PydanticOutputParser(pydantic_object=Person)
    

    Parser generuje instrukcje na podstawie modelu, instrukcje te są zawarte w promptzie, model odpowiada, a parser analizuje tę odpowiedź na obiekt typu Person i przeprowadza weryfikację za pomocą Pydantic. Koncepcyjnie:

    Pydantic Model
          ↓
    Defines Structure + Types + Constraints
          ↓
    LLM Response
          ↓
    PydanticOutputParser
          ↓
    Validated Pydantic Object
    

    Otrzymujemy obiekt w Pythonie, którego dane z góry spełniają ustalone reguły, co jest lepsze niż to, co może zagwarantować dowolny słownik JSON.

    Jedną z praktycznych konsekwencji jest to, że błąd weryfikacji pojawia się jako OutputParserException w łańcuchu przetwarzania. Należy zdecydować, co w takim przypadku robić. Powszechnymi opcjami są ponowne wykonanie operacji, przekazanie błędu z powrotem do modelu za pomocą OutputFixingParser z LangChain lub zapisanie informacji o błędzie i zwrócenie bezpiecznego wartości domyślnej.

    Wariant Hugging Face

    Wersja Gemma definiuje ten sam model Person i przekazuje go do tego samego parsera:

    from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
    from dotenv import load_dotenv
    from langchain_core.prompts import PromptTemplate
    from langchain_core.output_parsers import PydanticOutputParser
    from pydantic import BaseModel, Field
    
    load_dotenv()
    llm = HuggingFaceEndpoint(
        repo_id="google/gemma-2-2b-it",
        task="text-generation"
    )
    model = ChatHuggingFace(llm=llm)
    class Person(BaseModel):
        name: str = Field(description='Name of the person')
        age: int = Field(gt=18, description='Age of the person')
        city: str = Field(description='Name of the city the person belongs to')
    parser = PydanticOutputParser(pydantic_object=Person)
    template = PromptTemplate(
        template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
        input_variables=['place'],
        partial_variables={
            'format_instruction': parser.get_format_instructions()
        }
    )
    chain = template | model | parser
    final_result = chain.invoke({'place': 'Indian'})
    print(final_result)
    

    Proces przetwarzania pozostaje niezmieniony:

    PromptTemplate
          ↓
    ChatHuggingFace
          ↓
    PydanticOutputParser
          ↓
    Pydantic Object
    

    Zmienia się tylko dostawca; model Pydantic oraz parser są wspólne. Mniejsze modele częściej naruszają ograniczenia lub dodają niepotrzebny tekst, co jest dokładnie sytuacją, w której walidacja się sprawdza: błędna odpowiedź jest wykrywana na wstępie, zamiast przedostać się do Twoich danych.

    Wybór odpowiedniego parsera

    Decyzja zależy od tego, ile struktury i ile walidacji faktycznie potrzebuje odbiorca odpowiedzi. Poniżej znajduje się streszczenie każdej opcji.

    StrOutputParser

    Użyj go, gdy odpowiedź modelu to po prostu tekst.

    • Najlepszy do: raportów, wyjaśnień, streszczeń, odpowiedzi w czacie.
    • Zwraca: ciąg znaków.
    • Analiza JSON: nie.
    • Schemat: nie.

    JsonOutputParser

    Użyj go, gdy potrzebujesz formatu JSON, ale możesz zaakceptować lub obsłużyć zmienną strukturę.

    • Najlepiej nadaje się do: eksploracyjnego wyświetlania danych strukturalnych, elastycznych treści.
    • Zwraca: słownik lub listę.
    • Analiza JSON: tak.
    • Schemat: nie (w formie bez argumentów użytej tutaj).
    • Walidacja: tylko sprawdzenie, czy jest to ważny JSON.

    StructuredOutputParser

    Użyj go, gdy twój kod oczekuje określonych kluczy, takich jak fact_1 do fact_3.

    • Najlepiej nadaje się do: prostych rekordów z znanych nazw pól.
    • Zwraca: słownik zawierający deklarowane klucze.
    • Analiza JSON: tak.
    • Schemat: tak, z nazwami pól i opisami.
    • Walidacja: tylko obecność kluczy, brak sprawdzeń typów.

    PydanticOutputParser

    Użyj go wtedy, gdy wynik jest podawany bezpośrednio do logiki aplikacji i musi być poprawny.

    • Najlepszy do: danych, które przechowujesz, nad którymi obliczasz lub przekazujesz do API.
    • Zwraca: instancję twojego modelu Pydantic.
    • Analiza JSON: tak.
    • Schemat: tak, pełne typy i ograniczenia.

    Szybki model mentalny

    • StrOutputParser: wystarcza tekst.
    • JsonOutputParser: zadziała każdy ważny plik JSON.
    • StructuredOutputParser: plik JSON musi zawierać te klucze.
    • PydanticOutputParser: sprawdzane są te klucze, te typy oraz wszystkie ograniczenia.

    Wybierz najprostszy parser, który zapewnia wymagane gwarancje. Każdy kolejny poziom dodaje tokeny szybkiego działania dla instrukcji oraz nowe sposoby odrzucenia odpowiedzi, więc wyższy stopień ścisłości powinien być celowym wyborem.

    Jeszcze jedna opcja powinna być uwzględniona. Wszystkie cztery parserzy działają poprzez opisanie formatu w instrukcji i następne przetworzenie tekstu. Wiele modeli czatowych obsługuje również natywny strukturyzowany wynik lub wywoływanie narzędzi, co LangChain umożliwia za pomocą metody with_structured_output() w modelu. Gdy dostawca tego obsługuje, taki podejście jest zazwyczaj bardziej niezawodne dla danych w formie schematu, natomiast parserzy oparte na instrukcjach pozostają przydatne dla dostawców i modeli, które tego nie mają.

    Główne wnioski

    • Parserzy wyników przekształcają wiadomość modelu w wartość, którą może wykorzystać kod, i łączą się z łańcuchami LCEL za pomocą operatora pipe.
    • StrOutputParser usuwa otoczkę wiadomości, dzięki czemu tekst jednego modelu może służyć jako treść następnej instrukcji.
    • JsonOutputParser przetwarza JSON, ale nie koryguje jego struktury, chyba że podasz mu schemat.
  • StructuredOutputParser koryguje nazwy kluczy za pomocą ResponseSchema, ale nie sprawdza typów wartości.
  • PydanticOutputParser łączy proces parsowania z kontrolą typów i ograniczeniami, zwracając rzeczywisty obiekt.
  • Instrukcje formatowania to po prostu tekst promptu: model nadal może je zignorować, dlatego należy liczyć się z błędami parsowania i walidacji, szczególnie przy mniejszych modelach otwartych.
  • Zmiana dostawcy nie wpływa na parser, co ułatwia porównywanie modeli hostowanych i otwartych przy wykonywaniu tej samej zadania.
  • Gdy odpowiedzi będą dostępne w spójnej formie, naturalnym następnym krokiem jest łączenie różnych promptów, modeli i parserów w większe procesy pracy, w tym sekwencyjne, równoległe i warunkowe łańcuchy budowane za pomocą RunnableParallel oraz RunnableBranch. Aby dowiedzieć się więcej na ten temat, zapoznaj się z tworzeniem pipeline’ów LangChain przy użyciu LCEL.