12: Создание инструментов для вашего агента
В части IV мы создадим агент(agent) — ИИ, который выясняет, что нужно сделать с запросом пользователя, и затем делает это, а не просто отвечает на него, как чат-бот.
Вот в чём разница на практике. Допустим, пользователь спрашивает: «Пожалуйста, отмените заказ #12345». Чат-бот ответил бы что-то вроде: «Перейдите в Моя страница > История заказов и нажмите кнопку „Отменить“ для этого заказа». Дальше выполнить эти шаги самому должен пользователь. Агент же сам выполняет работу — он находит заказ, проверяет, подлежит ли он отмене, и отменяет его. Он совершает действие.
Возможным это делают инструменты(tools): инструмент, который находит заказ, инструмент, который отменяет его, инструмент, который отправляет электронное письмо. С инструментами LLM перестаёт ограничиваться генерацией текста и начинает реально выполнять задачи.
Мы будем строить это шаг за шагом на протяжении части IV: сначала инструменты, которые будет использовать агент (эта глава), затем подключение этих инструментов к LLM (глава 13) и, наконец, цикл агента, который проходит через решить → действовать → наблюдать (глава 14).
Эта глава охватывает первую часть — определение инструментов, подключение их к реальным данным и безопасную обработку ошибок.
12.1) Определение инструментов с помощью декоратора @tool
12.1.1) Как работает инструмент?
Мы только что видели, как агент находил и отменял заказ с помощью инструментов. Прежде чем двигаться дальше, стоит прояснить один момент: когда говорят, что «LLM использует инструмент», звучит так, будто LLM сам вызывает его напрямую. Это не так. LLM никогда ничего не выполняет сам — всё, что он делает, это просит вызвать инструмент с определёнными аргументами. Само выполнение происходит в нашем коде.
Чтобы это работало, LLM должен знать, какие инструменты существуют и когда применяется каждый из них. Поэтому каждый инструмент сопровождается тремя элементами метаданных:
name— короткий идентификатор, такой какget_order, который LLM использует, чтобы указать, какой инструмент ему нужен.description— предложение, описывающее, что делает инструмент и когда его использовать. Именно это читает LLM, чтобы выбрать подходящий инструмент для задачи.- Схема входных данных — каковы параметры инструмента: их имена, типы и значение. LLM это нужно, чтобы правильно заполнить аргументы.
Ничто из этого не требует от вас дополнительной работы. name берётся прямо из имени функции, description — из её docstring, а схема входных данных — из аннотаций типов параметров. Всё, что вам нужно сделать, — это прикрепить декоратор @tool из LangChain.
12.1.2) Создание вашего первого инструмента
Давайте применим это на практике. Напишите функцию с аннотациями типов и docstring, затем украсьте её декоратором @tool.
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Получить текущую погоду для заданного города."""
return f"В городе {city} всегда солнечно!"Давайте проверим, что @tool сгенерировал для нас.
print(get_weather.name)
# Вывод: get_weather
print(get_weather.description)
# Вывод: Получить текущую погоду для заданного города.
print(get_weather.args)
# Вывод: {'city': {'title': 'City', 'type': 'string'}}Имя функции get_weather стало её name, docstring стал её description, а аннотация типа city: str стала её схемой входных данных. Все три элемента метаданных из предыдущего раздела были сгенерированы автоматически. Именно это LLM использует, чтобы выбрать инструмент и заполнить его аргументы.
После того как @tool прикреплён, функция становится объектом инструмента LangChain, а значит, вы больше не можете вызывать её как обычную функцию — get_weather("Paris") не сработает. Вместо этого вы вызываете её с помощью .invoke() — того же стандартного метода выполнения, который мы использовали для цепочек ещё в главе 6. Аргументы передаются в виде словаря:
result = get_weather.invoke({"city": "Paris"})
print(result)
# Вывод: В городе Paris всегда солнечно!12.1.3) Настройка name и description
По умолчанию name берётся из имени функции, а description — из docstring. Вы можете переопределить оба.
Передайте имя в качестве первого аргумента в @tool:
@tool("web_search")
def search(query: str) -> str:
"""Поиск информации в интернете."""
return f"Результаты для: {query}"
print(search.name)
# Вывод: web_searchВы также можете переопределить описание с помощью параметра description. Это полезно, когда вы хотите оставить docstring в качестве заметки для других разработчиков, давая LLM при этом что-то более подходящее:
@tool("calculator", description="Выполняет арифметические операции. Используйте это для любой математической задачи.")
def calc(expression: str) -> str:
"""Вычисляет строку с математическим выражением."""
return str(eval(expression)) # ПРЕДУПРЕЖДЕНИЕ: eval() небезопасен. Никогда не используйте его в продакшене.Придерживайтесь snake_case для имён инструментов — некоторые провайдеры LLM отклоняют имена с пробелами или специальными символами.
12.1.4) Определение схемы входных данных с помощью Pydantic
Когда инструмент принимает несколько параметров или вы хотите описать каждый из них по отдельности, определите схему входных данных с помощью модели Pydantic. Это те же BaseModel и Field, которые мы использовали для структурированного вывода ещё в главе 7.
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""Входные данные для запросов о погоде."""
location: str = Field(description="Название города (например, Seoul, Tokyo)")
units: str = Field(default="celsius", description="Единица измерения температуры (celsius или fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""Получить текущую погоду с выбранной единицей измерения температуры."""
temp = 22 if units == "celsius" else 72
return f"Текущая погода в {location}: {temp} градусов {units[0].upper()}"Что бы вы ни написали в Field(description=...), это становится частью схемы входных данных, которую читает LLM, чтобы точно знать, что означает каждый параметр. В большинстве случаев аннотаций типов и понятного docstring достаточно — прибегайте к args_schema только тогда, когда вам нужен этот дополнительный уровень детализации по каждому параметру.
12.2) Обработка ошибок инструмента
В реальном мире инструменты могут давать сбои — обрывается соединение с базой данных или появляется входное значение, которое вы не предусмотрели. В этом разделе мы будем обрабатывать эти ошибки внутри самого инструмента, чтобы агент мог разумно реагировать, а не останавливаться полностью. Сначала давайте настроим функции, на которые будут опираться наши инструменты.
12.2.1) Подготовка: функции поиска товаров
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""Найти информацию о товаре по ID."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""Вернуть количество товара на складе."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]Обе функции вызывают ValueError, когда им передаётся несуществующий ID товара.
12.2.2) Обработка ошибок в инструменте
Давайте обернём fetch_product в инструмент get_product.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Найти товар по его ID. Возвращает его название, цену и уровень запасов."""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."С корректным ID он работает как ожидается.
print(get_product.invoke({"product_id": 1}))
# Вывод: Product 1: Wireless Mouse — $29.99, 120 in stock.Но передайте несуществующий ID, и fetch_product вызовет ValueError, который никто не перехватывает, — выполнение агента останавливается прямо там.
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.Решение простое: перехватите исключение внутри инструмента и верните строку, которую LLM сможет понять, вместо того чтобы дать ему распространиться. Успех или неудача — инструмент всегда возвращает строку, и LLM использует эту строку, чтобы решить, что делать дальше.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""Найти товар по его ID. Возвращает его название, цену и уровень запасов."""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# Вывод: Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# Вывод: Error: Product with ID 99 not found.Несуществующий ID больше не вызывает исключение — вместо этого он возвращает сообщение об ошибке, которое LLM может понять.
Применим тот же шаблон к check_stock:
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""Проверить, есть ли товар сейчас в наличии."""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"Инструменты, построенные таким образом, можно тестировать напрямую с помощью .invoke(). Убедитесь, что инструмент работает корректно сам по себе, прежде чем подключать его к LLM. В противном случае, когда позже что-то пойдёт не так внутри агента, вы не сможете понять, сломан ли инструмент или модель просто приняла неудачное решение.