База для старта разработки первого AI-агента
Что такое AI-агент
Хотите создать своего AI-агента, но не знаете, с чего начать? Эта статья даст вам необходимый минимум для разработки автономного помощника, способного понимать задачи, принимать решения и взаимодействовать с внешними сервисами.
AI-агенты — это умные программы на основе языковых моделей (LLM), которые не просто отвечают на вопросы, но и автоматизируют действия: ищут информацию, управляют приложениями или выполняют задачи по заданным правилам.
Мы разберём создание такого агента шаг за шагом на примере GigaChat API. Выбор пал на GigaChat из-за простоты его использования для пользователей из РФ и в случае необходимости переписать на другую llm не составит труда.
Руки ai-agent
Что отличает агента от обычного чата? Он умеет что-то делать. Да пока мы не дали ai возможность совершать действия, соответственно судный день не начнётся, поскольку нам не опасен терминатор, который только разглагольствовать умеет. Далее мы начнём давать возможность восстанию машин, в этом нам поможет tools из langchain.
Напишем простой пример
from langchain_gigachat.chat_models.gigachat import GigaChat
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
from dotenv import load_dotenv
import os
load_dotenv() # Загружает переменные из .env
llm_key = os.getenv("LLM_API_KEY")
if llm_key is None:
raise ValueError("LLM_API_KEY не найден в .env файле")
@tool
def summ(a: int, b: int) -> int:
"""Производит сложение двух чисел a и b и возвращает результат."""
print(f"summing {a} and {b}")
return a + b
model = GigaChat(model="GigaChat-2-Max",
credentials=llm_key,
verify_ssl_certs=False,
profanity_check=False,
streaming=False,
max_tokens=500,
timeout=60)
prompt = ChatPromptTemplate.from_messages(
[
("system", "Разбей задачу на под задачи из имеющихся инструментов и пошагово решай ее. \
Если для решения поставленного вопроса нужна информация, задай уточняющий \
вопрос в формате [Для решения вопроса мне не хватает информации: информация]"),
("placeholder", "{messages}"),
]
)
agent = create_react_agent(model=model, tools=[summ], prompt=prompt)
answer = agent.invoke({"messages": [
{"role": "user", "content": "сложи 2 и 2 а потом прибавь к результату 3"}]})
print("Result:", answer)
for chunk in answer['messages']:
print(chunk)
Теперь детально разберём его
Функция summ определяет как совершать действие сложения двух чисел. Распечатка в функции добавлена не случайно. Если не составить удачный промт для модели, то мы увидим крутой эффект, в котором LLM может как выполнить сложение со стороны модели (в зависимости от типа модели и запроса в неё), а может и вообще сначала сложить 2 + 2, используя тулу summ, и потом средствами LLM сложить 4 + 3. Наверное, мы ожидаем, что наш агент будет вызывать тулы, когда это возможно, а не сам выдавать какой-то результат, учитывая, что модели склонны к галлюцинациям.
prompt - удобный инструмент, который помогает задать поведение модели, а так же то, что будет попадать в модель. Например, можно даже добавлять к запросу от пользователя какой-то дополнительный запрос.
В базовом варианте по этому функционалу всё. Но хотелось бы обратить внимание на то, какие возможности уже есть в lang_chain. Если покопаться в документации, то в ней можно найти много интересных и уже готовых тулов, начиная с поиска по интернету, заканчивая работой с бд.
Отдельно на эту тему хотелось бы выделить такую вещь как MCP
MCP (Model Context Protocol)
Эта тема очень завязана с предыдущей главой, и вот почему. Если в langchain есть куча уже готовых тулов, то использование готовых инструментов можно расширить через использование MCP сервера, который может предоставить доступ к инструментам через sse или stdio. С учётом тренда на разработку MCP серверов, пройти мимо этой темы нельзя, поскольку сейчас их разрабатывается очень много и можно использовать всё больше и больше готовых решений. Вдаваться во внутрянку тоже не буду, на эту тему много чего написано и много чего не написано. А цель статьи — максимально прикладной уровень.
Для python существует sdk для поднятия mcp сервера.
В примере хоть и есть client, но мне он показался каким-то сложным, что-ли. Мне не очень нравятся такие примеры, поскольку отрывают от прикладной составляющей.
сервер
from mcp.server.fastmcp import FastMCP
# Create an MCP server
mcp = FastMCP("Math")
# Add an addition tool
@mcp.tool()
def add(a: int, b: int) -> int:
"""Складывает два числа a и b и возращает результат сложения"""
print(f"Adding {a} and {b}")
return a + b
if __name__ == "__main__":
mcp.run(transport="sse")
Клиент
import asyncio
from langchain_gigachat.chat_models.gigachat import GigaChat
from langgraph.prebuilt import create_react_agent
from langchain_mcp_adapters.client import MultiServerMCPClient
from dotenv import load_dotenv
import os
load_dotenv() # Загружает переменные из .env
llm_key = os.getenv("LLM_API_KEY")
if llm_key is None:
raise ValueError("LLM_API_KEY не найден в .env файле")
# LLM GigaChat
model = GigaChat(model="GigaChat",
credentials=llm_key,
verify_ssl_certs=False,
profanity_check=False,
streaming=False,
max_tokens=400,
timeout=60)
async def main():
async with MultiServerMCPClient(
{
"Math": {
"url": "http://localhost:8000/sse",
"transport": "sse",
}
}
) as client:
print("tools: ", client.get_tools())
agent = create_react_agent(model, client.get_tools())
agent_response = await agent.ainvoke({"messages": [
{"role": "user", "content": "сложи 2 и 2"}]})
print(agent_response)
# Run the main function
asyncio.run(main())
Очень советую всё-же почитать документацию по mcp sdk, там можно увидеть пример пуска mcp сервера с поднятием mcp inspector, с ним можно в графическом интерфейсе потыкать наш сервак. Очень удобный инструмент.
Теперь мы приблизили восстание машин. Наш агент умеет не впадая в галлюцинации складывать числа.
Мозги модели
Обученная модель, пусть идеально обученная, хорошо, но, а что делать, если есть специфические данные, которые отсутствуют в обучающей модели? Дообучать модель и поднимать локально? А если лапки? Можно воспользоваться RAG (Retriever-Augmented Generation). RAG-технология значительно повышает качество ответов, позволяя дополнять ответы информацией из заранее подготовленных документов. Мы можем заставить модель пользоваться информацией только из добавленных данных, а можем использовать как источник информации из RAG. Конечно, сейчас много где можно увидеть, что с увеличением контекстного окна многих моделей сама технология будет не столь востребована, можно же будет, например, в промт модели загнать нужные данные и всё. Но это потом, а сейчас посмотрим на RAG.
Подход работы с этим механизмом достаточно прост. Мы берём документ или текст, векторизуем. А дальше осуществляем поиск по документу уже через сравнение векторов. Нам не надо запихивать весь документ разом.
Давайте разберём максимально примитивный пример. У нас есть файлик с данными rag_info.txt, который векторизуем.
from langchain_gigachat.chat_models.gigachat import GigaChat
from langchain_gigachat import GigaChatEmbeddings
from langchain_chroma import Chroma
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains import create_retrieval_chain
from dotenv import load_dotenv
import os
load_dotenv() # Загружает переменные из .env
llm_key = os.getenv("LLM_API_KEY")
if llm_key is None:
raise ValueError("LLM_API_KEY не найден в .env файле")
model = GigaChat(model="GigaChat-2-Max",
credentials=llm_key,
verify_ssl_certs=False,
profanity_check=False,
streaming=False,
max_tokens=500,
timeout=60)
# Загружаем и разбиваем текст на части
loader = TextLoader("./rag/rag_info.txt")
raw_documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
documents = text_splitter.split_documents(raw_documents)
# Инициализируем модель эмбеддингов
embeddings = GigaChatEmbeddings(credentials=llm_key, verify_ssl_certs=False)
# Создаём векторное хранилище из документов
vectore_store = Chroma.from_documents(documents, embeddings)
# Поиск похожих документов по запросу
query = "Что-то о котиках"
results = vectore_store.similarity_search(
query, k=1) # k - количество результатов
print("search results:", results)
Вдаваться в содержимое файла мы не будем, можем закинуть туда любой произвольный текст. Нас интересует этот пример тем, что он демонстрирует то, как осуществляется поиск. У нас задаются параметры разбиения нашего документа через CharacterTextSplitter, нужно это для того, чтобы локализовывать информацию лучше + chunk_overlap указывает пересечение между чанками, т. к. мы не можем наверняка быть уверены в том, что в документе в разбитых чанках будет вся информация, подходящая под наш поиск. Поиск же максимально тупо работает. У нас есть векторизованные данные, а также есть векторизованный запрос поиска. similarity_search в примере выдаст какой-то результат, при этом этот «какой-то результат» может быть максимально бессмысленным. Если залезть в содержимое similarity_search, можно увидеть параметр filter, по которому можно фильтровать поиск. Удобная опция, если ваши документы как-то можно группировать, ниже приведу простой пример синтаксиса документа с метаданными. И если указать filter, то будет поиск по документам, подходящим конкретным метаданным.
documents = [
Document(
page_content="Собаки — отличные компаньоны, которые известны своей преданностью и дружелюбием.",
metadata={"source": "mammal-pets-doc"},
),
Document(
page_content="Кошки — независимые животные, которым нужно собственное пространство.",
metadata={"source": "mammal-pets-doc"},
),
Document(
page_content="Золотые рыбки — отличные домашние животные для начинающих. За ними достаточно просто ухаживать.",
metadata={"source": "fish-pets-doc"},
),
Document(
page_content="Попугаи — умные птицы, которые способны имитировать человеческую речь.",
metadata={"source": "bird-pets-doc"},
),
Document(
page_content="Кролики — социальные животные, которым нужно много места, чтобы прыгать.",
metadata={"source": "mammal-pets-doc"},
),
]
Настройка поиска по rag это отдельная тема, на которой не будем задерживаться сильно, но есть неплохая статья, в которой эта тема неплохо изложена
В примере выше просто поиск, отдельно без доп обработки llm он нам не очень нужен обычно, поэтому давайте допилим этот пример следующим кодом
prompt = ChatPromptTemplate.from_template('''Ответь на вопрос пользователя. \
Используй при этом только информацию из context. Если данных об этом нет \
для ответа, сообщи об этом пользователю в виде сообщения "Я не знаю ответа на этот вопрос". \
Контекст: {context}
Вопрос: {input}
Ответ:
''')
document_chain = create_stuff_documents_chain(
llm=model,
prompt=prompt
)
embedding_retriever = vectore_store.as_retriever(search_kwargs={"k": 2})
retrieval_chain = create_retrieval_chain(embedding_retriever, document_chain)
print(retrieval_chain.invoke(
{'input': "животное которое мурлычит"}
))
Вывод будет очень интересным
{'input': 'животное которое мурлычит', 'context': [Document(id='39d6a279-99af-4125-b57a-94d43f933ab1', metadata={'source': 'rag_info.txt'}, page_content='Фредерико Панини обладал выдающимися навыками наблюдать за камнем.')], 'answer': 'Я не знаю ответа на этот вопрос'}
Поиск по документам выдал ерунду, но т.к промт был такой, чтобы модель брала только из rag, то она выдала нам результат, какой мы и просили 'Я не знаю ответа на этот вопрос'. Если не ограничить модель, то она может всё равно выдать ответ.
Заключение
Хотя в статье я опустил некоторые детали, которые, на мой взгляд, кажутся достаточно простыми, этой информации достаточно, чтобы собрать своего агента, разработав свои или используя стандартные или чужие инструменты. Но, на всякий случай подсвечу, что в агента можно добавить память, и он сможет оперировать историей переписки. Я также не упомянул StateGraph — позволяет реализовать более продвинутую машину состояний, и заодно советую обратить внимание на LangSmith.
Код приведённый в статье можно посмотреть тут.