От сырого текста к пайплайнам: парсеры, LCEL, исполняемые элементы и память в LangChain
Как LangChain преобразует необработанный текст модели в структурированные данные, составляет шаги с использованием LCEL и интерфейса Runnable, а также обрабатывает память диалога в настоящее время.
Источники: LangChain Explained: Output Parsers, Chains, Runnables, and Memory
Вызов языковой модели с хорошо составленной шаблоном запроса — это лишь первый шаг на пути к реальному приложению. Модель по-прежнему возвращает неструктурированный строковый результат, каждый вызов забывает всё, что было до него, а ручное соединение шаблона запроса, модели и постобработки быстро превращается в шаблонный код. В этом руководстве рассматриваются четыре концепции LangChain, которые решают эти проблемы: парсеры вывода, цепочки, построенные с использованием LCEL, интерфейс Runnable, позволяющий осуществлять комбинирование компонентов, и механизм хранения данных. После изучения этого материала вы сможете читать любую цепочку обработки на LCEL, выбирать подходящий парсер или объект типа Runnable для выполнения задачи и понимать, где хранится состояние диалога в текущей реализации LangChain.
Здесь предполагается, что вы уже знаете, что такое обертка LLM и шаблон запроса в LangChain, поскольку всё, что описано далее, строится непосредственно на их основе.
Парсеры вывода: преобразование текста в данные
Проблемы, которые решают парсеры
Что бы вы ни спросили у модели, ответ поступает в виде свободного текста. Для простого чат-бота, который отображает ответ на экране, этого достаточно. Однако как только ваш код должен что-то сделать с ответом — например, сохранить его в базу данных, передать другой функции или вернуть из API в формате JSON — простая строка становится проблемой.
Парсер вывода находится между моделью и остальной частью вашей программы. Он берет сгенерированный текст и преобразует его в структурированную форму: список, словарь, объект JSON или экземпляр вашего собственного класса на Python. Удобно представить его как интерпретатор: модель общается на свободном языке, а парсер преобразует этот текст в форму, на которую может опираться ваш код.
Почему не стоит парсить вручную
- Формат вывода модели не гарантирован; один и тот же запрос может давать незначительно разные результаты при разных запусках.
Семейство встроенных парсеров
LangChain поставляет несколько парсеров, каждый из которых предназначен для обработки данных определённого формата:
- Парсер строкового вывода (
StrOutputParser): самый простой вариант, который извлекает обычный текст из ответа модели. Используйте его, когда вам нужна чистая строка для отображения или передачи на следующий этап обработки. - Парсер списка: разделяет ответ на список в Python, что удобно, когда требуется несколько элементов, таких как ключевые слова или предложения.
- Парсер вывода в формате JSON: преобразует ответ в объект JSON и является одним из наиболее часто используемых парсеров при необходимости в структурированных данных.
- Парсер вывода Pydantic: напрямую отображает ответ в модель Pydantic, что позволяет выполнять проверку типов помимо анализа структуры. Этот вариант подходит, когда данные должны соответствовать строгой схеме.
- Парсер даты и времени: генерирует настоящий объект
datetimeв Python вместо даты, записанной в текстовом формате. - Парсер перечислений: ограничивает вывод одним значением из заранее определенного набора и возвращает это значение.
- Парсер XML: читает данные, записанные в виде XML-тегов, и преобразует их в словарь.
Структурированные парсеры предоставляют метод get_format_instructions(). Он возвращает блок текста, который вы вставляете в свой запрос, четко указывая модели, как именно оформить ответ, чтобы парсер мог его прочитать. У обычного строкового парсера нет ничего, что требовалось бы обеспечить, поэтому именно он является единственным элементом этой семьи, куда не применяются инструкции по форматированию.
Парсеры против нативного структурированного вывода
Многие современные модели могут возвращать структурированные данные нативно, и LangChain предоставляет доступ к этой возможности через with_structured_output(). Когда это доступно, оно часто заменяет парсер, поскольку провайдер модели сам обеспечивает соблюдение схемы, вместо того чтобы вы просили об этом в своем запросе. Парсеры по-прежнему остаются необходимыми, когда у модели отсутствует нативный структурированный вывод, а также тогда, когда вам нужна дополнительная проверка или преобразование, выходящее за рамки того, что гарантирует провайдер.
Цепочки и LCEL: связывание шагов
Что такое цепочка
Цепочка — это серия шагов, при которых результат каждого шага становится входными данными для следующего. Вместо того чтобы писать три отдельных фрагмента кода для форматирования запроса, вызова модели и обработки результата, их объединяют в одну многоразовую цепочку.
Классический пример включает три этапа: шаблон запроса, затем модель и, наконец, парсер результата. Вы предоставляете сырые данные, такие как тема или вопрос, а цепочка форматирует запрос, вызывает модель и обрабатывает полученный ответ в едином потоке.
Что дают цепочки
- Меньше шаблонного кода. Связи между шагами пишутся один раз, а не повторяются при каждом вызове.
- Модульность. Шаблон запроса, модель и парсер являются независимыми компонентами, поэтому вы можете заменить любой из них без влияния на остальные.
Составление с помощью оператора трубки
Язык выражений LangChain, LCEL, делает создание цепочек простым. Для объединения компонентов используется оператор |, аналогично тому, как объединяются команды в Unix-шелле, вместо вложенных вызовов функций.
Два приведённых ниже кода создают трёхэтапную цепочку, описанную выше, и запускают её один раз с входными данными в виде словаря. Обратите внимание, что ключ словаря topic должен совпадать с переменной в шаблоне запроса.
chain = prompt | model | output_parser
result = chain.invoke({"topic": "black holes"})
Входные данные поступают в функцию prompt, которая формирует отформатированный запрос; этот запрос передается в model, а ответ модели — в output_parser. Каждый компонент передаёт свой результат непосредственно следующему, причём переменная result хранит то, что возвращает парсер.
Каждая цепочка LCEL также сопровождается стандартным набором методов выполнения:
.invoke()запускает цепочку один раз для одного входного данных..batch()выполняет её для списка входных данных за один вызов..stream()генерирует результат постепенно по мере его формирования, не заставляя пользователя ждать полного ответа. Режим потока передаёт данные по токенам только в том случае, если каждый шаг цепочки может обрабатывать поток; шаг, который требует всего входного данных сразу, будет использовать буферизацию.
Именно этот декларативный стиль во многом обусловливает возможность комбинирования компонентов в LCEL. Вы описываете, что должно произойти и в каком порядке, а не механизмы того, как каждый шаг вызывает следующий.
Runnable: основной интерфейс
Почему всё можно связывать через конвейер
В основе LCEL каждый компонент — будь то промпт, модель, парсер или функция, написанная вами самими — является объектом типа Runnable. Runnable представляет собой универсальный интерфейс, который может реализовать любой компонент, и именно этот общий контракт позволяет использовать оператор |. Поскольку все компоненты предоставляют одинаковые методы, их можно соединять независимо от того, как они работают внутри.
Именно благодаря такому интерфейсу взаимодействие с цепочкой кажется единообразным. Независимо от того, вызывает ли компонент модель, преобразует данные или выполняет несколько операций одновременно, всё управляется с помощью тех же методов .invoke(), .batch() и .stream(). Составленная цепочка сама по себе является объектом типа Runnable, поэтому её можно вкладывать в более крупные цепочки без какой-либо дополнительной обработки.
Объекты Runnable, которые вы будете использовать чаще всего
- RunnableSequence выполняет шаги строго в порядке, передавая результат каждого шага следующему. Соединение компонентов с помощью
|автоматически создаёт такой объект в фоновом режиме. Типичное применение: сначала ввод данных, затем обработка моделью, после этого парсинг.
RunnableParallel, когда необходимо, чтобы исходные данные сопровождали преобразованные значения. Типичное применение: возвращение первоначального вопроса пользователя вместе с сгенерированным ответом.Когда вы понимаете, что модели, парсеры и цепочки обработки — это все компоненты типа Runnable, имеющие единый интерфейс, LangChain перестаёт казаться набором специальных решений. Это становится обычной комбинаторикой: небольшие единицы с одинаковой структурой объединяются в более крупные единицы с той же структурой.
Внимание: чрезмерная комбинаторика
Поскольку любой объект может стать объектом типа Runnable, возникает соблазн представлять всю логику приложения в виде вложенных цепочек. Для простых операций это работает хорошо, но глубоко вложенные ветвления и лямбды могут затруднить отладку по сравнению с обычным Python. Разумное правило — использовать LCEL для работы с моделью и оставлять сложные структуры управления в обычном коде, или переносить их в фреймворк на основе графов, когда речь идет о циклах и состоянии.
Память: сохранение контекста между этапами
Что здесь подразумевается под памятью
Каждый вызов модели по умолчанию является безсостоянийным. Она не знает, о чем вы просили ранее, если только ваше приложение снова не отправит предыдущий запрос. В LangChain память — это набор механизмов для хранения контекста разговора и его последующего восстановления, что позволяет приложению продолжать диалог вместо того, чтобы рассматривать каждое сообщение как первое.
Почему чат-приложениям это необходимо
- Без памяти чат-бот теряет всё сразу после поступления нового сообщения: нет преемственности, нет продолжения диалога, нет общего контекста.
- Природный диалог зависит от предыдущих сообщений — например, необходимо знать, что «он» в последующем сообщении относится к человеку, упомянутому двумя сообщениями ранее.
- Запоминание контекста делает помощника более целостным и похожим на человека в диалоге, вместо того чтобы отвечать на каждое сообщение изолированно.
- Это базовое требование к ботам поддержки клиентов, персональным помощникам и любым другим приложениям с несколькими этапами взаимодействия.
Классические классы памяти
Традиционный подход использовал классы памяти, находившиеся внутри запущенного процесса. Самый простой из них хранил буфер всего диалога; другой сокращал старые сообщения до постоянно обновляемого резюме для экономии токенов. При каждом новом вызове сохранённый контекст вставлялся обратно в промпт.
Где теперь хранится память
Эти классы памяти в процессе были устаревшими, поскольку LangChain перенес состояние диалога в слой сохранения данных LangGraph. В этой модели инструменты для создания чекпоинтов сохраняют состояние диалога для каждой сессии (идентифицируемой потоком), в то время как отдельные хранилища содержат долгосрочную память, которая должна сохраняться между сессиями. Концепция осталась прежней: предыдущий контекст должен храниться где-то, чтобы его можно было повторно использовать. Изменилась лишь рекомендуемая реализация, которая теперь более надежна и лучше подходит для производственных условий, поскольку состояние может сохраняться в настоящей базе данных, а не исчезать при перезапуске процесса. Если вы хотите узнать, как именно инструмент для создания чекпоинтов сохраняет это состояние, статья «Inside LangGraph’s InMemorySaver» рассматривает его внутренние механизмы.
Разумный путь обучения — сначала разобраться в классической концепции памяти, поскольку она объясняет, почему память важна и что должно содержаться в запросе, а затем применить подход, основанный на сохранении данных, для проектов, предназначенных для реального использования. Поскольку эта область быстро развивается, перед началом работы ознакомьтесь с актуальной документацией LangChain и LangGraph, чтобы узнать точные API-интерфейсы.
Заключение
Вместе с обёртками для LLM и шаблонами запросов эти концепции составляют основной набор инструментов LangChain. Каждый из них сам по себе прост, но их ценность проявляется в сочетании: шаблон подаёт данные в модель, парсер преобразует текст модели в структурированные данные, каждый элемент представляет собой операцию, соединённую в цепочку с помощью символа |, а слой сохранения данных хранит информацию о диалоге между вызовами.
- Используйте парсер результатов или нативный структурированный вывод, если модель его поддерживает, всякий раз, когда код обрабатывает ответ модели.
RunnableSequence; обращайтесь к RunnableParallel, RunnablePassthrough, RunnableLambda и RunnableBranch, когда поток данных не является линейным.После того как эти основные элементы готовы, следующими естественными темами становятся генерация с усилением на основе данных из базы и агенты, которые создаются именно с использованием этих компонентов.