Головна / Статті / Від звичайного тексту до перевірених об’єктів: вибір парсера результату LangChain

Від звичайного тексту до перевірених об’єктів: вибір парсера результату LangChain

Порівняйте StrOutputParser, JsonOutputParser, StructuredOutputParser та PydanticOutputParser у ланцюгах LangChain та дізнайтеся, наскільки сильно кожен з них гарантує структуру даних.

5418 слів

Модель чату повертає текст, і такий текст підходить для людини, яка читає екран. Як тільки відповідь має бути використана для іншого запиту, збережена у базі даних чи для формування елемента інтерфейсу, потрібно щось більш прогнозоване: чисту рядок, об’єкт JSON або типізований запис, який вже був перевірений. LangChain заповнює цю прогалину за допомогою парсерів вихідних даних, пропонуючи кілька з них із дуже різними гарантіями. У цьому посібнику створюється той самий невеликий проект Сонячної системи з використанням чотирьох парсерів, пояснюється кожен рядок кожної ланки, а наприкінці наводиться практичне критерій для вибору між ними.

Якщо ви хочете дізнатися більше про те, як парсери поєднуються з LCEL, виконуваними модулями та пам’яттю, перегляньте статтю про перехід від сирого тексту до пайплайнів у LangChain. Тут увага зосереджена на більш конкретних аспектах: що саме обіцяє кожен парсер та де закінчується ця обіцянка.

Чому сирий текст моделі недостатній

Якщо запитати модель про Сонячну систему, ви можете отримати щось на кшталт наведеного нижче. Цей текст зрозумілий, але програма не може витягнути з нього конкретний факт, не вгадуючи, де закінчується одне речення та починається наступне.

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

Код, який обробляє таку відповідь, краще отримувати іменовані значення, до яких можна буде звертатися безпосередньо, наприклад об’єкт із одним ключем на кожен факт:

{
    "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."
}

Парсер виводу — це компонент, який поєднує ці два елементи. Він отримує те, що створив модель, і повертає значення, яке ваше додатку може використовувати без додаткової обробки рядків. Парсери відрізняються за ступенем обробки: деякі лише розпаковують текст, інші парсують JSON, а найсуворіші перевіряють результат за схемою.

Raw LLM Response
       ↓
Output Parser
       ↓
Parsed Output

Пам’ятайте про цю трьохетапну структуру. Кожен приклад нижче є її варіацією, з додаванням додаткових елементів у середню частину у міру посилення вимог.

Чотири парсери, чотири рівні структури

Чотири парсери, про які йдеться тут, утворюють „драбину“, де кожна сходинка додає контроль над результатом:

  • StrOutputParser перетворює повідомлення моделі на звичайний рядок Python.
  • JsonOutputParser парсує відповідь у значення, сумісне з JSON, таке як словник або список.
  • StructuredOutputParser дозволяє вказати назви полів, які модель повинна повертати.
  • PydanticOutputParser описує бажану структуру за допомогою моделі Pydantic та перевіряє результат на відповідність цій структурі.
  • Незалежно від того, який інструмент ви оберете, шлях передачі даних залишається однаковим:

    LLM Response
         ↓
    Output Parser
         ↓
    Parsed Output
    

    Змінюється лише те, що отримуємо в кінцевому підсумку, та наскільки можна йому довіряти.

    Приклад роботи: звіт, потім короткий огляд

    Перший проект — це двокроковий процес. Тема „Сонячна система“ надсилається до моделі, яка створює довгий звіт. Цей звіт потім передається до другого запиту, який вимагає п’ятирядкового огляду. Ключовою деталлю є передача даних: результат першого виклику моделі стає вхідними даними для наступного запиту.

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

    Без парсера перший крок повертає об’єкт повідомлення, а не текст звіту, тож доводиться розпакувати його перед створенням другого запиту. Парсер, розташований посередині, виконує це розпакування як частину ланцюга обробки, завдяки чому два кроки працюють без проблем.

    StrOutputParser: коли потрібен лише текст

    StrOutputParser — це найпростіший парсер, який пропонує LangChain. Моделі чату повертають об’єкт AIMessage; цей парсер витягує з нього контент та надає звичайний рядок. Цей інструмент підходить у всіх випадках, коли наступним споживачем є людина, інший запит чи будь-що інше, що потребує лише прози, і вам не потрібен JSON чи схема.

    Інтеграція в процес створення звіту

    У проекті report-then-summary кроки є такими:

    1. Перший запит просить модель надати детальний звіт на задану тему.
  • Цей звіт передається другому запиту.
  • Другий запит просить модель стиснути звіт до п’яти рядків.
  • Після кожного виклику моделі працює StrOutputParser, тож кожна передача містить звичайний рядок. Повний порядок компонентів є таким:

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

    Версія OpenAI

    Ось повна послідовність використання ChatOpenAI. Прочитайте її зверху вниз один раз, а потім ми розглянемо важливі частини.

    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)
    

    Модель створюється зі стандартними налаштуваннями. Функція load_dotenv(), розташована вище, завантажує ключ API з локального файлу .env, тому у скрипті немає конфіденційних даних.

    model = ChatOpenAI()
    

    Перший шаблон приймає одну змінну — topic — та просить підготувати детальний звіт на цю тему.

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

    Другий шаблон очікує змінну під назвою text, яка буде містити звіт, та просить надати п’ятирядкове узагальнення цього звіту.

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

    Є одна невелика помилка, яку варто виправити при копіюванні цього коду: рядок шаблону містить /n, що є буквальним слешем за літерою n, а не переходом на новий рядок. Використовуйте \n, якщо хочете, щоб звіт починався на новому рядку. Моделі зазвичай працюють у будь-якому випадку, але запит, який ви думаєте надсилати, має бути саме тим запитом, який ви фактично надсилаєте.

    Далі йде парсер. Одну інстанцію можна повторно використовувати в кількох місцях ланцюга, оскільки вона не зберігає стан між викликами.

    parser = StrOutputParser()
    

    Рядок, який об’єднує все разом, — це визначення ланцюга:

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

    Оператор трубки є композицією у мові виразів LCEL (LangChain Expression Language): результат кожного компонента стає вхідними даними для наступного. Якщо представити це вертикально, порядок виконання буде таким:

    template1
        ↓
    model
        ↓
    parser
        ↓
    template2
        ↓
    model
        ↓
    parser
    

    Зверніть увагу, що робить перший парсер. Після першого виклику моделі парсер повертає звіт у вигляді рядка, і саме цей рядок заповнює {text} у другій шаблоні. Другий парсер виконує ту саму роботу з кінцевою відповіддю, тож результат ланцюга — це сам огляд, а не об’єкт повідомлення.

    Ланцюг запускається шляхом передачі словника, ключі якого збігаються з параметрами вхідних даних першої шаблону:

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

    Потім ви виводите результат:

    print(result)
    

    Що повертає ланцюг

    Значення, яке виводиться в кінці, — це п’ятирядковий огляд, отриманий з генерованого звіту. Оскільки останнім компонентом є StrOutputParser, результатом є звичайний Python-об’єкт типу str:

    print(result)
    

    Типова робота програми дає результат приблизно такий:

    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.
    

    Розглядайте це як ілюстрацію формату, а не як фіксовану відповідь. Формулювання буде відрізнятися між різними запусками та між різними моделями.

    Той самий процес із моделлю Hugging Face

    Процес створення звіту та його огляду також може використовуватися з відкритою моделлю. У цьому варіанті використовується HuggingFaceEndpoint, налаштований на google/gemma-2-2b-it, та він обгортається класом ChatHuggingFace, щоб він поводився як чат-модель:

    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)
    

    Тут є важлива різниця. Ця версія ніколи не використовує StrOutputParser та не створює ланцюгів трубок. Вона вручну форматує кожен запит за допомогою .invoke(), викликає модель та читає .content з поверненого повідомлення перед тим, як передати його далі. Це працює, але саме цей ручний процес розбирання є тим, що має усунути парсер.

    Якщо порівнювати обидва підходи, вони виглядають так. З OpenAI та парсером:

    OpenAI
    
    Prompt
      ↓
    ChatOpenAI
      ↓
    StrOutputParser
      ↓
    String
    

    З Hugging Face та ручним доступом:

    Hugging Face
    
    Prompt
      ↓
    ChatHuggingFace
      ↓
    result.content
      ↓
    String
    

    Обидва варіанти закінчуються рядком тексту. У скрипті OpenAI показано, як парсер виконує цю роботу в межах ланцюга операцій, тоді як у скрипті Hugging Face така сама логіка застосування використовується з іншим постачальником без парсера. Ніщо не заважає вам написати template1 | model | parser | template2 | model | parser також із моделлю Hugging Face; парсеру байдуже, який постачальник створив повідомлення.

    Висновок для цього етапу: використовуйте StrOutputParser кожного разу, коли програмі потрібна відповідь лише у вигляді тексту.

    JsonOutputParser: JSON без контракту

    JsonOutputParser — це наступний крок. Він просить модель надати JSON та перетворює відповідь у дані формату Python, що зручно, коли ваш код потребує отримання значень за ключем замість читання прозового тексту.

    Воно не змушує форматувати відповідь у певній структурі. Без схеми воно просить модель надати відповідь у форматі JSON, але не вказує, які ключі мають бути присутніми чи який тип мають мати їхні значення. Два запуски з однаковим запитом можуть законно повертати об’єкти різної структури, і ваш код має бути готовим до цього.

    Як усе взаємопов’язано

    У цьому проєкті від моделі запитують п’ять фактів про Сонячну систему. Кроки є наступними:

    1. Створити JsonOutputParser.
    2. Запитати у нього інструкції щодо формату за допомогою get_format_instructions().
    3. Вставити ці інструкції у запит.
    4. Надіслати запит моделі.
    5. Дозволити парсеру перетворити відповідь на значення у форматі Python.
    Solar System
         ↓
    PromptTemplate
         ↓
    Format Instructions
         ↓
    LLM
         ↓
    JsonOutputParser
         ↓
    JSON Object
    

    Версія від OpenAI

    Повний ланцюг є коротким:

    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)
    

    Модель налаштована на gpt-4.1-mini з температурою 0, що забезпечує максимально можливу повторюваність результату. Саме цей компонент буде записувати п’ять фактів.

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

    Парсер створюється без аргументів, і саме тому у нього немає схеми для її дотримання:

    parser = JsonOutputParser()
    

    Інструкції з форматування виконують основну роботу

    Перед запуском моделі парсер може описати формат, який він очікує. Цей опис отримується за допомогою одного виклику методу:

    parser.get_format_instructions()
    

    Він повертає блок тексту, який просить модель відповісти у форматі JSON. Це не код, який виконується на моделі; це текст запиту. Його вставляють у шаблон через partial_variables, що заповнює змінну шаблону лише один раз під час його визначення, а не при кожному виклику:

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

    Отриманий шаблон містить два місця під заміну:

    template = PromptTemplate(
        template="Give me 5 facts about {topic} \n {format_instruction}",
        input_variables=["topic"],
        partial_variables={
            "format_instruction": parser.get_format_instructions()
        },
    )
    
    • {topic} вказується під час виклику та позначає те, про що потрібні дані.
    • {format_instruction} вже заповнюється інструкціями щодо форматування від парсера.

    Тож коли ви викликаєте ланцюг із цими даними, модель отримує як тему, так і інструкцію щодо відповіді у форматі JSON:

    {"topic": "Solar System"}
    

    Складання та запуск ланцюга

    Сам ланцюг має лише три етапи:

    chain = template | model | parser
    

    У порядку виконання:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    JsonOutputParser
          ↓
    Parsed JSON
    

    Шаблон генерує кінцеве запитання, ChatOpenAI на нього відповідає, а JsonOutputParser перетворює цю відповідь у дані формату Python. Парсер також толерантний до деяких поширених особливостей моделей, наприклад до того, що JSON може бути обгорнуте у рамки коду Markdown; він видаляє їх перед обробкою.

    Викличте його з цією темою:

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

    І виведіть те, що повернулося:

    print(result)
    

    Що повертається

    Результат містить п’ять фактів у форматі JSON. Скрипт лише виводить значення, тому немає стандартного вихідного даних для цитування; ймовірна відповідь виглядає так:

    {
      "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."
      ]
    }
    

    Така структура — один ключ facts, який містить список, — є однією з кількох можливих у моделі. Інший запуск може повернути fact_1 через fact_5 або просто список. Якщо наступний код буде індексувати за допомогою result["facts"], це призведе до проблем, як тільки модель обере іншу структуру.

    Відстеження потоку

    Порівняно з StrOutputParser, новим елементом є те, що парсер бере участь двічі: спочатку перед викликом моделі, надаючи інструкції, а потім — після цього, виконуючи парсинг.

    Prompt
      ↓
    JSON Format Instructions
      ↓
    LLM
      ↓
    JsonOutputParser
      ↓
    JSON
    

    Інструкції генеруються самим парсером:

    parser.get_format_instructions()
    

    Вони потрапляють до інтерфейсу користувача через змінну partial:

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

    Модель відповідає, враховуючи ці інструкції, а парсер перетворює відповідь на значення у форматі Python. Від початку до кінця:

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

    Різниця з попереднім етапом поміщається у дві рядки. StrOutputParser генерує:

    StrOutputParser
        ↓
    Plain String
    

    тоді як JsonOutputParser генерує:

    JsonOutputParser
        ↓
    JSON-compatible Structured Data
    

    Пам’ятайте про застереження: досі немає фіксованої схеми. Ви отримуєте JSON, але поля та їхнє вкладення залежать від моделі. До речі, поточні версії JsonOutputParser також приймають необов’язковий аргумент pydantic_object, який додає схему до інструкцій форматування, але у наведеному тут варіанті без аргументів він вимагає лише коректного JSON.

    Варіант Hugging Face

    Робочий процес у форматі JSON безпосередньо підключається до моделі Gemma. Налаштування моделі змінюються; парсер, інструкції щодо формату та ланцюг обробки залишаються незмінними:

    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)
    

    Пайплайн ідентичний, за винятком блоку з моделлю:

    PromptTemplate
          ↓
    Hugging Face Model
          ↓
    JsonOutputParser
          ↓
    JSON Object
    

    Це практична перевага розміщення логіки парсингу у окремому компоненті: зміна постачальників не впливає на логіку парсингу. Однак слід пам’ятати, що невеликі моделі у відкритому форматі дотримуються інструкцій щодо формату менш надійно, ніж більші моделі, розміщені на серверах. Якщо у відповіді є проза навколо JSON або крайня кома, парсер викидає помилку OutputParserException, тому код у продакшені повинен її перехопити та спробувати знову або використати альтернативний підхід.

    StructuredOutputParser: найменування полів, які ви очікуєте

    StructuredOutputParser витягує JSON на основі списку полів, які ви визначаєте заздалегідь. Якщо JsonOutputParser просто вимагає „відповідь у форматі JSON“, то цей парсер вимагає „відповідь у форматі JSON із цими ключами“.

    Поля оголошуються за допомогою ResponseSchema. Кожне з них має name та description, причому опис вказує моделі, що має знаходитися в цьому полі. Це значно покращує контроль над формою відповіді.

    Проєкт „Три факти“

    У цьому проєкті потрібно надати три факти про Сонячну систему, по одному полю на кожен факт:

    • fact_1 містить перший факт про тему.
    • fact_2 містить другий факт.
    • fact_3 містить третій факт.

    Парсер перетворює ці оголошення на інструкції, а потім перевіряє отриману відповідь за ними:

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

    Версія OpenAI

    Ось повний скрипт:

    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)
    

    Модель має ту саму конфігурацію gpt-4.1-mini, що й раніше:

    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"),
    ]
    

    Кожен об’єкт ResponseSchema вносить два елементи:

    • name стає ключем у отриманому словнику.
    • description вказує моделі, що має міститися в цьому ключі.

    Три схеми забезпечують наявність трьох обов’язкових ключів: fact_1, fact_2 та fact_3.

    Ви не створюєте цей парсер безпосередньо. Класовий метод створює його на основі списку схем:

    parser = StructuredOutputParser.from_response_schemas(schema)
    

    Як і у випадку з парсером JSON, інструкції щодо формату походять від самого парсера:

    parser.get_format_instructions()
    

    Цього разу інструкції є багатшими. Вони містять схему у форматі JSON із переліком кожної назви поля та її опису, а також прослять модель обгорнути відповідь у блок JSON. Вони підключені так само:

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

    Шаблон запиту містить два знайомі місця для підстановки:

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

    {topic} заповнюється під час виклику, а {format_instruction} містить список полів, створений на основі ваших схем. Виклик із цими даними надсилає їх обидва до моделі:

    {"topic": "Solar System"}
    

    Запуск ланцюга

    Ланцюг має ті самі три етапи, що й раніше:

    chain = template | model | parser
    

    З парсером у останньому положенні:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    StructuredOutputParser
          ↓
    Structured JSON
    

    Шаблон формує запит, модель надає відповідь, а StructuredOutputParser витягує оголошені поля з цієї відповіді.

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

    Як виглядає результат

    Результатом є словник, який містить три факти під ключами, які ви визначили. Скрипт їх виводить:

    print(result)
    

    А приклад результату виглядає так:

    {
        "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."
    }
    

    Конкретні факти можуть відрізнятися. Але набір ключів має залишатися незмінним:

    fact_1
    fact_2
    fact_3
    

    Це перевага порівняно зі звичайним парсингом JSON: ваше застосування визначає назви полів, а не модель. Якщо модель пропускає один із оголошених ключів, парсер піднімає помилку замість того, щоб мовчки повернути іншу структуру, що набагато простіше обробити, ніж KeyError через три функції потім.

    Відстеження потоку

    Повний шлях від теми до результату з ключами:

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

    Усе починається з визначень полів:

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

    Парсер перетворює їх на інструкції форматування, ці інструкції потрапляють у запит, модель відповідає, а парсер видобуває оголошені поля. Порівняно з попереднім етапом:

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

    Коротко кажучи, JsonOutputParser призначений для отримання JSON у будь-якому вигляді, тоді як StructuredOutputParser — для отримання JSON із ключами, які ви зазначили.

    Існує обмеження, яке варто чітко зазначити. У ResponseSchema є атрибут type, який за замовчуванням дорівнює string, але він лише змінює формулювання інструкцій. Парсер перевіряє наявність ключів; він не перевіряє типи чи діапазони значень. Якщо вам потрібно, щоб age був цілим числом вище певного порогу, цей парсер цього не забезпечить.

    Також перевірте шлях імпорту у зв’язку з версією LangChain, яку ви використовуєте. У прикладі імпортується з langchain.output_parsers, але у новіших версіях цей застарілий парсер було видалено з основних пакетів, тому імпорт може знадобитися змінити.

    Варіант Hugging Face

    Версія Gemma використовує ті самі три схеми та ту саму структуру парсера:

    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)
    

    І ту саму схему обробки даних:

    PromptTemplate
          ↓
    ChatHuggingFace
          ↓
    StructuredOutputParser
          ↓
    Structured JSON
    

    Лише постачальник моделі відрізняється. Схема та парсер залишаються незмінними.

    PydanticOutputParser: структура та перевірка

    PydanticOutputParser є найсуворішим із чотирьох. Він описує очікувану відповідь за допомогою моделі Pydantic, тож визначення формату вихідних даних є водночас визначенням того, що вважається коректним.

    Це виходить за межі простого парсингу. Поля містять справжні типи Python, і Field() дозволяє додавати обмеження, наприклад вимогу до того, щоб ціле число перевищувало певне мінімум. Якщо відповідь моделі не відповідає цим обмеженням, з’являється виняток, а не некоректні дані.

    Чому варто докласти додаткових зусиль

    • Забезпечення схеми: відповідь має відповідати чітко визначеній структурі.
    • Безпека типів: поля використовують типи Python, такі як str, int та float, а значення відповідним чином перетворюються або відхиляються.
    • Перевірка: Pydantic перевіряє кожне заявлене вами обмеження.
    • Інтеграція в ланцюги: він інтегрується з запитами, моделями та ланцюгами LCEL так само, як інші парсери.

    Проєкт з вигаданими особами

    У цьому прикладі від моделі просить створити персону з певного місця, тут „Індії“, з трьома полями:

    • name – ім’я особи.
    • age – вік особи.
    • city – місто, в якому вона проживає.

    До поля age також застосовується обмеження: воно має бути більшим за 18.

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

    Версія OpenAI

    Повний скрипт:

    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)
    

    Він дотримується тієї ж структури, що й раніше: визначається модель Person, яка передається PydanticOutputParser, а цей парсер під’єднується до запиту та моделі.

    Конфігурація моделі залишилась незмінною:

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

    Ключовою частиною є клас 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")
    

    Це контракт для відповіді:

    • name має бути рядком.
  • age має бути цілим числом, строго більшим за 18.
  • city має бути рядком.
  • Field() додає як опис, зрозумілий людині, який потрапляє у запит, так і обмеження, які перевіряються після обробки. Саме поле з обмеженнями:

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

    gt=18 означає „більше за 18“, тому вік рівний 18 не пройде перевірку. Якщо ви мали на увазі „18 або більше“, використовуйте ge=18.

    Парсер створюється шляхом передачі класу, а не екземпляра:

    parser = PydanticOutputParser(pydantic_object=Person)
    

    Це вказує, яку модель використовувати як для генерації інструкцій, так і для перевірки відповіді.

    Інструкції щодо формату походять з того ж методу, що й раніше:

    parser.get_format_instructions()
    

    Для цього парсера використовується JSON Schema, створене на основі моделі Pydantic, яке включає описи полів та обмеження exclusiveMinimum для поля age. Його вставляють через змінну partial, як це зазвичай робиться:

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

    Шаблон запиту:

    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} визначає, яку людину має створити модель. Використання цього параметра означає необхідність введення імені, віку та міста вигаданої особи з Індії:

    {"place": "Indian"}
    

    Запуск ланцюга

    Ланцюг зберігає звичний трьохетапний формат:

    chain = template | model | parser
    

    Цього разу останній етап повертає екземпляр моделі:

    PromptTemplate
          ↓
    ChatOpenAI
          ↓
    PydanticOutputParser
          ↓
    Pydantic Object
    

    Шаблон формує запит, модель надає відповідь, а PydanticOutputParser парсує JSON та перевіряє його, перетворюючи на об’єкт типу Person.

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

    Як виглядає результат

    Результатом є об’єкт Person, а не словник. Його виведення показує стандартне представлення від Pydantic:

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

    Значення будуть відрізнятися при кожному запуску. Однак гарантії залишатимуться незмінними:

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

    Саме тут різниця між методами стає найбільш очевидною. JsonOutputParser просив лише JSON, StructuredOutputParser називав поля, а PydanticOutputParser представляє весь контракт у вигляді справжнього класу. Ви можете користуватися автодоповненням у редакторі для доступу до final_result.age та бути впевненими, що це число типу int, більше 18, адже будь-що інше викликало б помилку перевірки ще до того, як код це обробить.

    Відстеження потоку

    Від вхідних даних до перевіреного об’єкта:

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

    Все починається зі структури, яка тут показана без описів та обмежень для кращої читабельності:

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

    Клас передається парсеру:

    PydanticOutputParser(pydantic_object=Person)
    

    Парсер генерує інструкції на основі моделі; ці інструкції включаються до запиту, модель надсилає відповідь, а парсер розбирає її на об’єкти типу Person та перевіряє їх за допомогою Pydantic. Концептуально:

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

    У результаті у вас з’являється об’єкт Python, дані якого гарантовано відповідають вашим правилам, що є більшим, ніж може запропонувати будь-який JSON-словник. Одним із практичних наслідків є те, що невдача перевірки проявляється у вигляді винятку OutputParserException. Треба вирішити, що робити у такому разі. Поширені варіанти – повторна спроба виклику, передача помилки назад до моделі за допомогою OutputFixingParser від LangChain чи ж логування та повернення безпечного стандартного значення.

    Варіант Hugging Face

    Версія Gemma визначає ту саму модель Person та передає її до того самого парсера:

    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)
    

    Потік обробки залишається незмінним:

    PromptTemplate
          ↓
    ChatHuggingFace
          ↓
    PydanticOutputParser
          ↓
    Pydantic Object
    

    Змінюється лише постачальник; модель Pydantic та парсер залишаються спільними. Невеликі моделі частіше порушують обмеження або додають зайвий текст, саме тоді і стає необхідною валідація: некоректна відповідь фіксується на початковому етапі, замість того щоб потрапити до ваших даних.

    Вибір правильного парсера

    Рішення залежить від того, скільки структури та скільки перевірок насправді потребує користувач відповіді. Ось короткий огляд кожного варіанту.

    StrOutputParser

    Використовуйте його, коли відповідь моделі — це просто текст.

    • Найкраще підходить для: звітів, пояснень, коротких узагальнень, відповідей у чаті.
    • Повертає: рядок.
    • Парсинг JSON: ні.
  • Схема: ні.
  • Перевірка: ні.
  • JsonOutputParser

    Використовуйте його, коли вам потрібен JSON, але ви можете миритися зі змінною структурою даних або вмієте з нею працювати.

    • Найкраще підходить для: дослідницького структурованого виведення, гнучких навантажень даних.
    • Повертає: словник або список.
    • Розпакування JSON: так.
    • Схема: ні (у формі без аргументів, яка використовується тут).
    • Перевірка: лише на те, чи є це коректним JSON.

    StructuredOutputParser

    Використовуйте його, коли ваш код очікує певні ключі, наприклад від fact_1 до fact_3.

    • Найкраще підходить для: простих записів із відомими назвами полів.
    • Повертає: словник із заявленими ключами.
    • Розпакування JSON: так.
    • Схема: так, із назвами полів та їхніми описами.
    • Перевірка: лише наявність ключів, без перевірки типів.

    PydanticOutputParser

    Використовуйте його тоді, коли результат безпосередньо подається до логіки додатку та має бути точним.

    • Найкраще підходить для: даних, які ви зберігаєте, обробляєте чи передаєте до API.
    • Повертає: екземпляр вашої моделі Pydantic.
    • Розбір JSON: так.
    • Схема: так, повні типи та обмеження.
    • Перевірка: так.

    Коротка уявна модель

    • StrOutputParser: достатньо тексту.
    • JsonOutputParser: підійде будь-який коректний JSON.
    • StructuredOutputParser: JSON має містити ці ключі.
    • PydanticOutputParser: перевіряються ці ключі, типи та всі обмеження.

    Виберіть найпростіший парсер, який забезпечує необхідні гарантії. Кожен наступний рівень додає додаткові токени для інструкцій та нові способи відхилення відповіді, тому більш суворість має бути свідомим вибором.

    Ще один варіант також слід врахувати. Усі чотири парсери працюють шляхом опису формату в запиті та подальшого аналізу тексту. Багато моделей чату також підтримують нативний структурований вихід або виклик інструментів, що LangChain забезпечує за допомогою методу with_structured_output() у моделі. Якщо ваш постачальник це підтримує, такий підхід зазвичай є надійнішим для даних у форматі схеми, тоді як парсери на основі запитів залишаються корисними для постачальників та моделей, які цього не мають.

    Основні висновки

    • Парсери вихідних даних перетворюють повідомлення моделі на значення, яке може використовуватися вашим кодом, і вони включаються до ланцюгів LCEL за допомогою оператора „|“.
    • StrOutputParser видаляє обгортку повідомлення, щоб текст однієї моделі міг стати вхідним даним для наступного запиту.
    • JsonOutputParser аналізує JSON, але не коригує його формат, якщо тільки ви не надасте йому схему.
  • StructuredOutputParser виправляє назви ключів через ResponseSchema, проте не перевіряє типи значень.
  • PydanticOutputParser поєднує обробку даних із перевірками типів та обмеженнями, повертаючи справжній об’єкт.
  • Інструкції щодо форматування — це просто текст запиту: модель все одно може їх проігнорувати, тому слід очікувати помилок під час обробки та верифікації, особливо з меншими відкритими моделями.
  • Зміна постачальників не впливає на парсер, що полегшує порівняння хостованих та відкритих моделей для виконання однакових завдань.
  • Як тільки відповіді надходитимуть у зрозумілому форматі, наступним логічним кроком буде об’єднання кількох запитів, моделей та парсерів у більш складні робочі процеси, включаючи послідовні, паралельні та умовні ланцюги, створені за допомогою RunnableParallel та RunnableBranch. Щоб дізнатися більше з цього приводу, перегляньте складання пайплайнів LangChain за допомогою LCEL.