Від сирого тексту до пайплайнів: парсери, 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 замість дати, записаної у текстовому форматі. - Парсер типу Enum: обмежує вихід одним значенням із заздалегідь визначеного набору та повертає це значення.
- Парсер XML: читає вихідні дані, записані у форматі XML-тегів, та перетворює їх у словник.
Структуровані парсери надають метод get_format_instructions(). Він повертає блок тексту, який потрібно вставити у свій запит, щоб чітко пояснити моделі, як саме оформлювати свою відповідь, щоб парсер міг її прочитати. Парсер звичайного рядка не має нічого, що потрібно було б забезпечити, тому це єдиний представник цієї групи, для якого інструкції щодо формату не застосовуються.
Парсери проти нативного структурованого виводу
Багато сучасних моделей можуть нативно повертати структуровані дані, і LangChain забезпечує це через with_structured_output(). Коли ця функція доступна, вона часто замінює парсер, оскільки постачальник моделі забезпечує дотримання схеми, а не просто слухається запиту користувача. Парсери все ще залишаються корисними, коли у моделі відсутній нативний структурований вивід, або коли потрібна додаткова перевірка чи трансформація, яку не гарантує постачальник.
Ланцюги та LCEL: поєднання кроків
Що таке ланцюг
Ланцюг — це серія кроків, у яких результат кожного кроку стає вхідними даними для наступного. Замість того, щоб писати три окремі частини коду для форматування запиту, виклику моделі та обробки результату, їх об’єднують у один багаторазово використовуваний конвеєр.
Класичний приклад складається з трьох етапів: шаблон запиту, потім модель та потім парсер результату. Ви надаєте сирі дані, такі як тема чи запитання, а ланцюг форматує запит, викликає модель та обробляє отриманий результат у єдиному потоці.
Що дають ланцюги
- Менше шаблонного коду. Підключення між кроками пишеться один раз, а не повторюється на кожному місці виклику.
- Модульність. Запит, модель та парсер є незалежними елементами, тож ви можете замінити будь-який з них, не торкаючись інших.
Складання з використанням оператора трубки
Mова виразів 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, існує спокуса представляти всю логіку додатку у вигляді вкладених ланцюгів. Для простих кроків це добре працює, але глибоко вкладені гілки та функції типу lambda можуть ускладнити дебаггінг порівняно з звичайним Python. Розумне правило — використовувати LCEL для обробки даних, орієнтованих на модель, а складні схеми керування залишати у звичайному коді, або переносити їх у фреймворк типу граф, якщо вони включають цикли та стани.
Пам’ять: зберігання контексту між кроками
Що означає пам’ять тут
Кожен виклик моделі за замовчуванням є безстановим. Вона не має жодної інформації про те, що ви запитували раніше, якщо тільки ваш додаток знову не надсилає попередні дані. У LangChain пам’ять — це набір механізмів для зберігання контексту розмови та її подальшого отримання, щоб додаток міг продовжувати розмову замість того, щоб сприймати кожне повідомлення як перше.
Чому діалоговим додаткам це потрібно
- Без пам’яті чат-бот втрачає все у момент отримання нового повідомлення: немає послідовності, немає продовження розмови, немає спільного контексту.
- Природна розмова залежить від попередніх кроків у діалозі, наприклад, від знання того, що „він“ у наступному повідомленні стосується особи, про яку йшлося два повідомлення тому.
- Збережений контекст робить асистента більш послідовним та схожим на людину під час розмови, замість того щоб відповідати на кожне повідомлення окремо.
- Це базова вимога для ботів підтримки клієнтів, особистих асистентів та будь-яких інших додатків з багатьма кроками у розмові.
Класичні класи пам’яті
Традиційний підхід використовував класи пам’яті, які знаходились всередині запущеного процесу. Найпростіші з них зберігали буфер усього діалогу; інші скорочували старі повідомлення до поточного огляду для економії ресурсів. При кожному новому запиті збережений контекст вводився назад у запит до моделі.
Де тепер знаходиться пам’ять
Ці класи пам’яті у процесі були відмінені, оскільки LangChain перемістив стан розмови до шару зберігання даних LangGraph. У цій моделі інструменти для створення чекпоїнтів зберігають стан розмови за кожною сесією (що ідентифікується потоком), тоді як окремі сховища зберігають довгострокову пам’ять, яка має залишатися доступною між сесіями. Концепція залишилась незмінною: попередній контекст має зберігатися десь, щоб його можна було повторно використати. Змінилася лише рекомендована реалізація, яка тепер є більш надійною та краще підходить для продакшну, оскільки стан можна зберігати у справжній базі даних, а не втрачати після перезапуску процесу. Якщо ви хочете побачити, як саме інструмент для створення чекпоїнтів зберігає цей стан, стаття «inside LangGraph's InMemorySaver» детально розглядає його внутрішню структуру.
Розумний шлях навчання — спочатку зрозуміти класичну концепцію пам’яті, адже вона пояснює, чому пам’ять має значення та що входить до запиту, а потім застосувати підхід, заснований на постійстві, для всього, що призначене для експлуатації. Оскільки ця галузь швидко змінюється, перевірте актуальну документацію LangChain та LangGraph щодо точних API перед початком розробки.
Підсумок
Разом із обгортками LLM та шаблонами запитів ці концепції становлять основний набір інструментів LangChain. Кожен з них окремо є простим. Їхня цінність проявляється у поєднанні: шаблон надсилає дані моделі, парсер перетворює текст моделі на дані, кожен елемент є виконуваним кодом, об’єднаним у ланцюг за допомогою |, а шар постійства зберігає інформацію про розмову між викликами.
- Використовуйте парсер результатів або нативний структурований вихід, якщо модель його підтримує, щоразу, коли код використовує відповідь моделі.
RunnableSequence; використовуйте RunnableParallel, RunnablePassthrough, RunnableLambda та RunnableBranch, коли потік не є прямолінійним.За наявності цих основних елементів наступними природними темами є генерація з підсиленням за допомогою пошуку та агенти, які будуються саме з цих компонентів.