Используем языковые модели в AI-агентах. Часть 1. Введение в LangChain

от автора

Привет, Хабр!

В одной из прошлых статей я рассказывал про дообучение языковых моделей, сегодня же я хочу поговорить про практическое использование LLM и создание AI-агентов. Но прежде, чем приступать к этому, необходимо изучить основные компоненты.

Что такое LangChain?

LanhChain — фреймворк, предоставляющий обширный и удобный функционал по использованию LLM, он служит для разработки приложений на основе больших языковых моделей, создания агентов, взаимодействия с векторными хранилищами и т.д.

Установка

Для установки необходимо выполнить:

pip install langchain

1. Интерфейс Runnable

Интерфейс Runnable — основа основ для работы со всеми компонентами LangChain. Его реализуют практически все сущности, с которыми нам придется работать.

Основные методы, которые предоставляет интерфейс:

  1. invoke/ainvoke: преобразует одиночный входной сигнал в выходной, используется для вызова сущностей, например, языковых моделей.

  2. batch/abatch: преобразует множество входных данных в выходные.

  3. stream/astream: потоковая передача выходных данных с одного входного сигнала.

ainvoke, abatch, astream — асинхронные вариации.

2. Язык выражений LangChain (LCEL)

Одно из главных преимуществ фреймворка — возможность объединять создаваемые сущности в последовательные «цепочки», где выходные данные одного элемента служат входными данными для следующего.

Давайте разными способами напишем небольшой пример с использованием цепочки:

RunnableLambda — преобразует вызываемый объект Python в Runnable, который предоставляет преимущества LangChain.

RunnableSequence — самый важный оператор композиции, поскольку он используется в каждой цепочке и реализует интерфейс Runnable,поэтому для него доступны методы invoke, batch и т.д.

from langchain_core.runnables import RunnableLambda, RunnableSequence  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  #создаем цепочку из двух обьект RunnableLambda chain = RunnableSequence(runnable1, runnable2)  #вызывваем цепочку print(chain.invoke(2)) 

Входное значение сначала поступило в runnable1, где было увеличено на 1, а затем поступило в runnable2, где к нему была прибавлена 2. Результатом работы:

5

Этот пример можно написать без использования RunnableSequence, передав входные значения вручную:

langchain_core.runnables import RunnableLambda  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  output1 = runnable1.invoke(2) output2 = runnable2.invoke(output1)  print(output2)

Результат работы будет таким же:

5

Еще один вариант, уже с использованием LCEL:

from langchain_core.runnables import RunnableLambda  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  chain = runnable1 | runnable2 print(chain.invoke(2))  # 5

Знак | заменяет использование RunnableSequence.

И последний способ реализации, для тех, кому не нравится использование |:

from langchain_core.runnables import RunnableLambda  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  chain = runnable1.pipe(runnable2) print(chain.invoke(2))  # 5

Во всех этих примерах мы вызывали цепочки с помощью метода invoke, но мы помним, что помимо invoke нам доступны и другие методы, например, batch:

from langchain_core.runnables import RunnableLambda  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  chain = runnable1 | runnable2 print(chain.batch([1, 2, 3]))  # [4, 5, 6]

Каждый элемент был изменен runnable1 и передан в runnable2.

Помимо этого, цепочки можно сделать динамическими в зависимости от входного значения:

from langchain_core.runnables import RunnableLambda  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  chain  = RunnableLambda(lambda x: runnable1 if x > 6 else runnable2) chain.invoke(7)

RunnableParallel

Итак, мы рассмотрели четыре способа создания последовательной цепочки, но помимо последовательных существуют и параллельные цепочки. Главное отличие — входные данные передаются не только первому элементу, а сразу всем элементам цепочки.

Для создания такой цепочки необходимо использовать RunnableParallel:

from langchain_core.runnables import RunnableLambda, RunnableParallel  runnable1 = RunnableLambda(lambda x: x + 1) runnable2 = RunnableLambda(lambda x: x + 2)  chain = RunnableParallel({     "runnable_1": runnable1,     "runnable_2": runnable2, })  print(chain.invoke(2))

Результатом работы будет:

{'runnable_1': 3, 'runnable_2': 4}

Как видим, в runnable1 и runnable2 поступили одинаковые входные данные.

RunnableParallel и RunnableSequence можно использовать совместно в одной цепочке.

Создадим словарь, где в качестве значений используем наши RunnableLambda:

mapping = {     "key1": runnable1,     "key2": runnable2, }

Позже этот словарь будет автоматически преобразован в RunnableParallel.

Создадим еще один объект RunnableLambda, который будет складывать результаты выполнения runnable1, runnable2:

runnable3 = RunnableLambda(lambda x: x['key1'] + x['key2'])

Объединяем все в одну цепочку и смотрим на результат:

chain = mapping | runnable3  print(chain.invoke(2)) #7

Из runnable1 на место ‘key1’ вернулась 3, а на место ‘key2’ из runnable2 4.

Function to RunnableLambda

Внутри LCEL функция автоматически преобразуется в RunnableLambda.

Создадим простую функцию и воспользуемся runnable1 из прошлых примеров:

def some_func(x):     return x  chain = some_func | runnable1 print(chain.invoke(2))  # 3

Преобразовать функцию можно явно:

runnable_func = RunnableLambda(some_func)

Использование генератора с помощью stream.

Создадим простой генератор и вспомним, что помимо invoke и batch также имеем stream:

def func(x):     for y in x:         yield str(y)*2          runnable_gen = RunnableLambda(func) for chunk in runnable_gen.stream(range(5)):     print('chunk', chunk)

В результате получим:

chunk 00 chunk 11 chunk 22 chunk 33 chunk 44

Дополнительные методы и RunnablePassthrough

Давайте посмотрим еще на несколько интересных методов, которые предоставляет Runnable.

Допустим, в нашей цепочке есть элемент, который с какой то вероятностью может выполняться некорректно, поэтому мы хотим повторить его выполнение n-е количество раз в надежде на успешное выполнение.

Создадим две функции:

1-я будет просто увеличивать значение входного аргумента на 1

def add_one(x):     return x + 1

2-я будет имитировать некорректную работу:

def bad_function(x):     if random.random() > 0.3:         print('Неудачный вызов')         raise ValueError('bad value')     return x * 2

Объедим их в цепочку и для второй функции воспользуемся методом with_retry, который позволяет повторно вызывать элемент:

chain = RunnableLambda(add_one) | RunnableLambda(bad_function).with_retry(     stop_after_attempt=10, #количество повторений     wait_exponential_jitter=False  # следует ли добавлять задержку между потоврными вызовами )

Возможный результат:

Code failed Code failed Code failed 6

Если спустя 10 попыток не будет достигнуто нужное значение, получим ошибку.

with_fallbacks

Еще один метод, который пригодиться при работе с цепочками. Он позволяет добавить действие, когда мы так и не дождались успешного выполнения элемента цепочки.

Для примера возьмем код из примера выше и немного изменим:

def buggy_double(x):     if random.random() > 0.0001: #изменим вероятность         print('Code failed')         raise ValueError('bad value')     return x * 2  #дополнительная функция def failed_func(x):     return x * 2   chain = RunnableLambda(add_one) | RunnableLambda(buggy_double).with_retry(     stop_after_attempt=10,     wait_exponential_jitter=False ).with_fallbacks([RunnableLambda(failed_func)])

После 10 неудачных попыток будет вызвана failed_func.

Bind

bind — метод, который нужен, когда в цепочке необходимо использовать аргумент, которого нет в входных данных или выходных данных предыдущего узла. При этом создается новый объект Runnable.

Создадим простую функцию, результат которой будет отличаться в зависимости от выходных данных:

from langchain_core.runnables import RunnableLambda   def func(main_arg,other_arg = None):     if other_arg:         return {**main_arg, **{"foo": other_arg}}     return main_arg   runnable1 = RunnableLambda(func) bound_runnable1 = runnable1.bind(other_arg="bye") #добавляем аргумент  bound_runnable1.invoke({"bar": "hello"})

В результате получаем:

{'bar': 'hello', 'foo': 'bye'}

RunnablePassthrough

RunnablePassthrough позволяет прокидывать входные данные в элементы цепочки без применения к ним результатов предыдущих узлов. Это бывает полезно, когда в нескольких узлах нам необходимо использовать исходные данные.

Создадим RunnableParallel c использованием RunnablePassthrough:

from langchain_core.runnables import RunnablePassthrough  runnable = RunnableParallel(     origin=RunnablePassthrough(),     modified=lambda x: x + 1 )

И вызовем runnable с помощью batch:

print(runnable.batch([1, 2, 3]))

В результате получим список словарей, которые содержат исходное значение и измененное:

[{'origin': 1, 'modified': 2}, {'origin': 2, 'modified': 3}, {'origin': 3, 'modified': 4}]

Давайте посмотрим на еще один пример. Создадим функцию, которая будет выступать в качестве LLM и возвращать какой то ответ. После получения ответа, будем производить над ним дополнительную обработку:

def fake_llm(prompt):     return {'origin': prompt, 'answer': 'complete'}  chain = RunnableLambda(fake_llm) | {     'orig': RunnablePassthrough(),     "parsed": lambda text: text['answer'][::-1] }  print(chain.invoke('hello'))

Результат работы:

{'orig': {'origin': 'hello', 'answer': 'complete'}, 'parsed': 'etelpmoc'}

Как видим, в «orig» сохранился ответ от fake_llm без изменений, в ‘parsed’ получили новое значение.

assing

Еще один полезный метод, который часто будет использоваться в работе — assing. Он позволяет добавить новое значение в выходной словарь цепочки.

def fake_llm(prompt: str) -> str:     return "complete"   runnable = {'llm1': fake_llm,             'llm2': fake_llm,             } | RunnablePassthrough.assign(     total_chars=lambda inputs: len(inputs['llm1'] + inputs['llm2']) )

В результате мы дополнительно можем получить суммарное количество символов после ответа от наших LLM.

Результат работы:

{'llm1': 'complete', 'llm2': 'complete', 'total_chars': 16}

3. Messages

Messages — основная единица/сущность с которой работает LLM. Они используется для передачи входных и выходных данных, контекста и дополнительной информации. Каждое сообщение имеет свою роль (‘system’, ‘user’, …) и содержание. Перед использованием той или иной роли следует убедиться, что она поддерживается используемой моделью.

Пример сообщения:

("system", "You should only give answers in Spanish.")

Основные роли сообщений:

  • system — используется для сообщения модели ее «поведения»

  • user — используется для передачи сообщений от пользователя

  • assistant — используется для представления ответа модели

  • tool — используется для передачи модели результата выполнения инструмента (об этом позже). Поддерживается моделями, которые поддерживают вызов инструментов.

Основные виды сообщений:

  • SystemMessage

  • HumanMessage

  • AIMessage

  • ToolMessage

4. Prompt Templates

Следующий важный элемент, который необходим в работе с языковыми моделями — prompt templates /шаблоны подсказок. Они служат многоразовым шаблоном, который можно заполнять конкретной информацией для генерации подсказок для различных задач или сценариев.

В LangChain существует несколько видов промптов:

  • String PromptTemplates — используются для форматирования одной отдельной строки.

  • ChatPromptTemplates — используются для форматирования нескольких сообщений.

  • MessagesPlaceholder — позволяет вставить список сообщений в определенное место в ChatPromptTemplates.

Все виды реализуют интерфейс Runnable, поэтому поддерживают такие методы, как invoke.

PromptTemplate

Создадим простой пример ,в котором попросим рассказать шутку на какую-то тему:

from langchain_core.prompts import PromptTemplate, FewShotChatMessagePromptTemplate  prompt_template = PromptTemplate.from_template("Tell me a joke about {topic}") print(prompt_template.invoke({"topic": "cats"}))

После вызова получим строку:

Tell me a joke about cats

ChatPromptTemplate

Более интересный вид подсказок. Создадим шаблон, в котором будем просить пока не существующую модель отвечать только на Испанском языке и в который передадим запрос пользователя из предыдущего примера:

from langchain_core.prompts import ChatPromptTemplate  prompt_template = ChatPromptTemplate([     ("system", "You should only give answers in Spanish."),     ("user", "Tell me a joke about {topic}") ])  print(prompt_template.invoke({"topic": "cats"}))

В результате получим список сообщений:

messages=[           SystemMessage(content='You should only give answers in Spanish.', additional_kwargs={}, response_metadata={}),            HumanMessage(content='Tell me a joke about cats', additional_kwargs={}, response_metadata={})          ]

MessagesPlaceholder

Создадим ChatPromptTemplate с одним системным сообщением и местом (MessagesPlaceholder), куда позже могут быть добавлены другие cообщения:

from langchain_core.prompts import MessagesPlaceholder from langchain_core.messages import HumanMessage  prompt_template = ChatPromptTemplate([     ("system", "You should only give answers in Spanish."),     MessagesPlaceholder("msgs") ])  print(prompt_template.invoke({'msgs': [HumanMessage(content='Hi'), HumanMessage(content="Hello")]}))

В результате получим промтп:

messages=[           SystemMessage(content='You must get answers on Spanish', additional_kwargs={}, response_metadata={}),            HumanMessage(content='Hi', additional_kwargs={}, response_metadata={}),           HumanMessage(content='Hello', additional_kwargs={}, response_metadata={})           ]

Второй пример:

MessagesPlaceholder можно составить из нескольких статичных сообщений без использования invoke:

from langchain_core.prompts import MessagesPlaceholder  prompt = MessagesPlaceholder("history") prompt = prompt.format_messages(     history=[         ("system", "You should only give answers in Spanish."),         ("human", "Hello")     ] ) print(prompt)
[  SystemMessage(content='You must get answers on Spanish', additional_kwargs={}, response_metadata={}),   HumanMessage(content='Hello', additional_kwargs={}, response_metadata={}) ]

И последний пример с использованием этого вида подсказок. Создадим шаблон, состоящий из системного сообщения, истории запросов пользователя и ответов модели, а также нового запроса пользователя:

from langchain_core.prompts import MessagesPlaceholder, ChatPromptTemplate  prompt = ChatPromptTemplate.from_messages(     [         ("system", "You should only give answers in Spanish."),         MessagesPlaceholder("history"),         ("human", "{question}")     ] )  print(prompt.invoke(     {         "history": [('human', "what is 5 +2?"), ("ai", "5+2 is 7")],         "question": "now now multiply that by 4"     } ))

В результате получим список сообщений, который содержит всю историю:

messages=[SystemMessage(content='You must get answers on Spanish', additional_kwargs={}, response_metadata={}),            HumanMessage(content='what is 5 +2?', additional_kwargs={}, response_metadata={}),           AIMessage(content='5+2 is 7', additional_kwargs={}, response_metadata={}),            HumanMessage(content='now now multiply that by 4', additional_kwargs={}, response_metadata={})          ]

Теперь мы знаем достаточно, чтобы перейти к простому использованию LLM)

5. ChatModels

Наконец то, мы можем познакомиться со способами использования языковых моделей.

Начнем с того, что все модели происходят от базового класса BaseLanguageModel, который реализует интерфейс Runnbale, поэтому мы можем вызывать модели с помощью invoke,batch и т.д.

Чтобы создать модель существует множество способов, например, такой с использование OpenAI:

import getpass import os  if not os.environ.get("OPENAI_API_KEY"):   os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter API key for OpenAI: ")  from langchain_openai import ChatOpenAI  llm = ChatOpenAI(model="gpt-4o-mini")

Но мне больше нравится использование моделей с Hugging Face. Как раз для этого у Hugging Face и LangChain существует партнерский пакет, который предоставляет простой доступ к LLM.

Для этого необходимо установить:

pip install langchain-huggingface

Существует несколько способов использовать модель с Hugging Face:

С помощью HuggingFacePipeline:

from langchain_huggingface import HuggingFacePipeline  llm = HuggingFacePipeline.from_model_id(     model_id=model_repo_id,     task="text-generation",     pipeline_kwargs={         "max_new_tokens": 100,         "top_k": 50,         "temperature": 0.1,     } )

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

С помощью HuggingFaceEndpoint:

llm = HuggingFaceEndpoint(     repo_id=model_repo_id,     temperature=0.8,     task="text-generation",     max_new_tokens=1000,     do_sample=False, )

В этом случае будет использовано serverless API, поэтому необходимо создать аккаунт на HuggingFace и получить huggingface_token.

Про параметры моделей я рассказывал в предыдущей статье.

Пример использования

Рассмотрим простой вариант использования модели и шаблонов подсказок.

from langchain_core.prompts import ChatPromptTemplate  model_repo_id = "meta-llama/Meta-Llama-3-8B-Instruct"  llm = HuggingFaceEndpoint(     repo_id=model_repo_id,     temperature=0.8,     task="text-generation",     max_new_tokens=1000,     do_sample=False, )  #ChatHuggingFace помогает составить правильный запрос к модели и является  #оберткой поверх llm model = ChatHuggingFace(llm=llm)  prompt = ChatPromptTemplate.from_messages([     ("system", "You should only give answers in Spanish."),     ("user", "Hello, how are you?") ])  #используем LCEL chain = prompt | model  print(chain.invoke({})) 

В результате получим ответ модели:

content='Hola, estoy bien, ¿y tú?' additional_kwargs={} response_metadata={'token_usage': ChatCompletionOutputUsage(completion_tokens=10, prompt_tokens=29, total_tokens=39), 'model': '', 'finish_reason': 'stop'} id='run-3c0fe167-c083-4274-9d21-f29d9ed7873a-0'

Заключение

В этой статье я рассмотрел базовый синтаксис и набор инструментов, которые в дальнейшем потребуются нам для создания AI-агентов и работы с языковыми моделями. В следующей части я расскажу про более сложные цепочки, векторные хранилище и многое другое.

Мой телеграмм канал, там я пишу про LLM и выход моих статей:

https://t.me/Viacheslav_Talks


ссылка на оригинал статьи https://habr.com/ru/articles/871830/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *