Привет, Хабр!
В одной из прошлых статей я рассказывал про дообучение языковых моделей, сегодня же я хочу поговорить про практическое использование LLM и создание AI-агентов. Но прежде, чем приступать к этому, необходимо изучить основные компоненты.
Что такое LangChain?
LanhChain - фреймворк, предоставляющий обширный и удобный функционал по использованию LLM, он служит для разработки приложений на основе больших языковых моделей, создания агентов, взаимодействия с векторными хранилищами и т.д.
Установка
Для установки необходимо выполнить:
pip install langchain
1. Интерфейс Runnable
Интерфейс Runnable - основа основ для работы со всеми компонентами LangChain. Его реализуют практически все сущности, с которыми нам придется работать.
Основные методы, которые предоставляет интерфейс:
invoke/ainvoke: преобразует одиночный входной сигнал в выходной, используется для вызова сущностей, например, языковых моделей.
batch/abatch: преобразует множество входных данных в выходные.
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 и выход моих статей:
audiserg
Спасибо! Есть вопрос:
LCEL здесь подразумевает создание цепочки посредством "|" или в целом создание цепочки?
Viacheslav-hub Автор
Здравствуйте,да LCEL подразумевает создание цепочки с помощью оператора |,а не с помощью объектов. Это позволяет сделать код более "минималистичным"