Инструменты и цикл вызова | Курс LangChain урок 9
Цель урока: превращать обычную функцию Python в инструмент, который модель может попросить вызвать. Видеть по печати каждый этап цикла "модель попросила, инструмент отработал, результат вернулся". Писать описание и схему аргументов так, чтобы модель выбирала инструмент правильно. Знать, что произойдёт, когда инструмент упадёт.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 3: типы сообщений, свойство text, первый вызов инструмента через bind_tools, tool_calls и ToolMessage
3) урок 5: системный промпт агента, middleware и то, что физически уходит в модель
4) урок 6: схема на Pydantic, Field(description=...), Literal и значения по умолчанию
5) Python на уровне джуниора: декораторы, аннотации типов, docstring, исключения, потоки на уровне "код может выполняться одновременно"
Ключевые концепции:
1) @tool превращает функцию в объект инструмента со схемой, именем и описанием
2) цикл вызова состоит из трёх этапов, и агент отличается от ручной сборки только тем, кто их выполняет
3) docstring уходит в модель как описание инструмента, а parse_docstring=True раскладывает его по аргументам
4) схема аргументов на Pydantic задаёт и типы, и допустимые значения
5) исключение внутри инструмента по умолчанию останавливает весь запуск агента, перехватить его можно через middleware
6) несколько вызовов одного шага исполняются одновременно, в пуле потоков
7) return_direct=True отдаёт вывод инструмента пользователю без ещё одного вызова модели
Зачем модели инструменты
Модель работает с текстом и больше ни с чем. Она не знает, который сейчас час, не видит вашу базу заказов, не умеет списать деньги со счёта и не может отправить письмо. Спросите у неё статус заказа A-1002, и она либо откажется отвечать, либо придумает статус, потому что придумывать правдоподобный текст, это ровно то, что она делает.
Инструмент это описание (имя, текст назначения, схема аргументов) и функция, которую можно выполнить. Описание уходит в модель вместе с запросом. Модель только возвращает просьбу: "вызови get_order_status с аргументом order_id="A-1002"". Выполняете вы или, чаще, агент за вас.
Модель выбирает инструмент по описанию, код функции она не видит. Поэтому ошибку выбора инструмента ищут в описании.
В уроке 3 цикл уже собран в минимальном виде. Здесь он развёрнут с печатью каждого этапа: так ошибку внутри агента видно по этапу, на котором она случилась.
Общая сборка модели
Модуль сборки модели тот же, что в уроках 4-8. Положите его рядом с примерами под именем course_model.py, и дальше каждый пример вызывает его одной строкой.
"""Общая сборка модели для примеров урока 9.
Тот же модуль, что course_model.py уроков 4-8, без изменений: у build_model есть
необязательный первый аргумент с именем модели, а рядом лежит gateway_kwargs()
для примеров, которые собирают модель сами.
Файл .env берётся тот же, что в уроке 0. Положите его рядом с этой папкой или
выше по дереву: load_dotenv() ищет файл начиная с папки этого модуля и поднимается
вверх.
"""
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
def gateway_kwargs():
"""Возвращает аргументы доступа к провайдеру: имя, адрес, ключ.
Нужны примерам, которые вызывают init_chat_model сами. У настраиваемой модели
имени модели при создании нет, поэтому build_model ей не подходит.
"""
base_url = os.getenv("MODEL_BASE_URL")
if base_url:
return {
"model_provider": "openai",
"base_url": base_url,
"api_key": os.environ["OPENAI_API_KEY"],
}
return {}
def build_model(model_name=None, **kwargs):
"""Собирает модель курса.
model_name без значения означает модель из переменной MODEL_NAME. Явное имя
нужно примерам, где моделей в приложении больше одной.
Все именованные аргументы уходят в init_chat_model как есть: temperature,
max_tokens, timeout, max_retries, rate_limiter, profile и прочее из раздела
Parameters.
"""
model_name = model_name or os.environ["MODEL_NAME"]
access = gateway_kwargs()
if access:
# Путь для любого адреса, совместимого с OpenAI Chat Completions API.
return init_chat_model(model=model_name, **access, **kwargs)
# Путь напрямую к провайдеру.
return init_chat_model(model_name, **kwargs)
Температура в примерах этого урока нулевая везде, где ответ модели попадает в текст как вывод.
Первый инструмент: что делает декоратор
Самый прямой способ создать инструмент, это декоратор @tool. Docstring функции по умолчанию становится описанием инструмента, а аннотации типов задают схему аргументов. По документации LangChain аннотации обязательны, но схема собирается и без них. Тогда в схеме нет типа аргумента, и модель видит только его имя.
Пример 01_first_tool.py
"""Пример 1. Что декоратор @tool делает с обычной функцией.
Модель здесь не вызывается. Пример печатает объект инструмента и вызывает его двумя способами.
"""
import json
from langchain.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""Узнать статус заказа в магазине по его номеру."""
catalog = {
"A-1001": "собран, ждёт курьера",
"A-1002": "в пути, доставка завтра",
"A-1003": "отменён покупателем",
}
return catalog.get(order_id, "заказ с таким номером не найден")
print("ТИП ОБЪЕКТА:", type(get_order_status).__name__)
print("ИМЯ:", get_order_status.name)
print("ОПИСАНИЕ:", get_order_status.description)
print("АРГУМЕНТЫ:", get_order_status.args)
print()
print("СХЕМА, КОТОРУЮ УВИДИТ МОДЕЛЬ:")
schema = get_order_status.tool_call_schema.model_json_schema()
print(json.dumps(schema, ensure_ascii=False, indent=2))
print()
print("ВЫЗОВ СЛОВАРЁМ АРГУМЕНТОВ:")
print(repr(get_order_status.invoke({"order_id": "A-1002"})))
print()
print("ВЫЗОВ ТАК, КАК ЭТО ДЕЛАЕТ АГЕНТ:")
fake_call = {
"name": "get_order_status",
"args": {"order_id": "A-1003"},
"id": "call_demo_1",
"type": "tool_call",
}
message = get_order_status.invoke(fake_call)
print("тип результата:", type(message).__name__)
print("tool_call_id:", message.tool_call_id)
print("status:", message.status)
print("содержимое:", message.content)
ТИП ОБЪЕКТА: StructuredTool
ИМЯ: get_order_status
ОПИСАНИЕ: Узнать статус заказа в магазине по его номеру.
АРГУМЕНТЫ: {'order_id': {'title': 'Order Id', 'type': 'string'}}
СХЕМА, КОТОРУЮ УВИДИТ МОДЕЛЬ:
{
"description": "Узнать статус заказа в магазине по его номеру.",
"properties": {
"order_id": {
"title": "Order Id",
"type": "string"
}
},
"required": [
"order_id"
],
"title": "get_order_status",
"type": "object"
}
ВЫЗОВ СЛОВАРЁМ АРГУМЕНТОВ:
'в пути, доставка завтра'
ВЫЗОВ ТАК, КАК ЭТО ДЕЛАЕТ АГЕНТ:
тип результата: ToolMessage
tool_call_id: call_demo_1
status: success
содержимое: отменён покупателем
После декоратора get_order_status это объект инструмента StructuredTool. Если вызвать его как обычную функцию, get_order_status("A-1002"), поднимется TypeError: 'StructuredTool' object is not callable. Инструмент вызывается методом invoke.
invoke со словарём аргументов возвращает результат функции как есть. Если передать словарь вызова целиком, с ключами name, args, id и type со значением "tool_call", результат придёт в ToolMessage с проставленным tool_call_id, как в уроке 3. Так инструмент вызывает агент: такой словарь приходит от модели в поле tool_calls.
Имя инструмента берётся из имени функции. Придерживайтесь snake_case: часть провайдеров не принимает имена с пробелами и спецсимволами. Если имя функции неудачное, задайте своё первым аргументом декоратора, @tool("web_search").
Цикл вызова, собранный руками
Цикл тот же, что в уроке 3. Там он разложен на четыре шага, здесь на три этапа. Модель порождает вызовы, вы исполняете инструменты и собираете результаты, результаты уходят обратно в модель за финальным ответом.
Пример ниже проходит все три этапа без агента.
Пример 02_loop_by_hand.py
"""Пример 2. Цикл вызова инструмента, собранный руками.
Показывает, из чего состоит шаг агента: модель возвращает вызов инструмента,
вызов исполняется вручную, результат кладётся обратно в список сообщений.
"""
from course_model import build_model
from langchain.messages import ToolMessage
from langchain.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""Узнать статус заказа в магазине по его номеру."""
catalog = {
"A-1001": "собран, ждёт курьера",
"A-1002": "в пути, доставка завтра",
"A-1003": "отменён покупателем",
}
return catalog.get(order_id, "заказ с таким номером не найден")
model = build_model(temperature=0)
model_with_tools = model.bind_tools([get_order_status])
messages = [{"role": "user", "content": "Что с заказом A-1002?"}]
# Этап 1. Модель решает, нужен ли инструмент, и возвращает вызов.
ai_message = model_with_tools.invoke(messages)
messages.append(ai_message)
print("ТЕКСТ ПЕРВОГО ОТВЕТА:", repr(ai_message.text))
print("ЗАПРОШЕНО ВЫЗОВОВ:", len(ai_message.tool_calls))
for call in ai_message.tool_calls:
print(f" {call['name']} {call['args']} id={call['id']}")
# Этап 2. Инструменты исполняются вручную, ответы собираются в список.
tool_messages = []
for call in ai_message.tool_calls:
if call["name"] == get_order_status.name:
tool_messages.append(get_order_status.invoke(call))
else:
# Модель может назвать инструмент, которого нет в списке. Ответ нужен и на такой вызов.
tool_messages.append(
ToolMessage(
content=f"инструмента {call['name']} нет",
tool_call_id=call["id"],
status="error",
)
)
messages.extend(tool_messages)
print()
print("ОТВЕТОВ ИНСТРУМЕНТА:", len(tool_messages))
for message in tool_messages:
print(f" id={message.tool_call_id} -> {message.content}")
ids_requested = {call["id"] for call in ai_message.tool_calls}
ids_answered = {message.tool_call_id for message in tool_messages}
print("ВЫЗОВЫ И ОТВЕТЫ СОВПАДАЮТ:", ids_requested == ids_answered)
# Этап 3. Результаты уходят обратно в модель за финальным ответом.
final = model_with_tools.invoke(messages)
messages.append(final)
print()
print("СООБЩЕНИЙ В ИСТОРИИ:", len(messages))
print("ТИПЫ:", [type(m).__name__ if hasattr(m, "type") else "dict" for m in messages])
print("ФИНАЛЬНЫЙ ОТВЕТ:", final.text)
ТЕКСТ ПЕРВОГО ОТВЕТА: ''
ЗАПРОШЕНО ВЫЗОВОВ: 1
get_order_status {'order_id': 'A-1002'} id=call_d0b5ebc81a39492db0f8dc8f
ОТВЕТОВ ИНСТРУМЕНТА: 1
id=call_d0b5ebc81a39492db0f8dc8f -> в пути, доставка завтра
ВЫЗОВЫ И ОТВЕТЫ СОВПАДАЮТ: True
СООБЩЕНИЙ В ИСТОРИИ: 4
ТИПЫ: ['dict', 'AIMessage', 'ToolMessage', 'AIMessage']
ФИНАЛЬНЫЙ ОТВЕТ: Заказ **A-1002** находится **в пути**, доставка ожидается **завтра**. 🚚
Без bind_tools модель ничего не знает про инструменты. Метод возвращает новый объект, и описания инструментов уходят с каждым его вызовом. Исходная model остаётся без инструментов.
В выводе текст первого ответа пустой, и это нормально. Модель курса возвращает только список tool_calls, другие модели могут добавить к вызовам пояснение текстом. Поэтому судите об ответе по tool_calls: пустой текст при вызове инструмента не ошибка.
Строки с ids_requested и ids_answered в примере сверяют идентификаторы. На каждый вызов в сообщении модели должен прийти один ответ с тем же tool_call_id.
LangChain это соответствие не проверяет. По документации фреймворка неверную историю отклоняет модель: ответов меньше, чем вызовов, на один вызов два ответа, ToolMessage без вызова. Модель курса принимает любую такую историю и отвечает без ошибки. Поэтому проверяйте соответствие в своём коде, как в примере.
Тот же цикл внутри агента
Ручная сборка нужна один раз, чтобы понять устройство. Дальше цикл выполняет агент. Агент это модель с инструментами, которые вызываются в цикле до выполнения задачи. Пример ниже берёт тот же инструмент и тот же вопрос.
Пример 03_agent_loop.py
"""Пример 3. Тот же цикл, его выполняет агент.
Инструмент и вопрос те же, что в примере 2. Разница в том, что этапы 1-3
здесь не написаны: их делает create_agent.
"""
from course_model import build_model
from langchain.agents import create_agent
from langchain.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""Узнать статус заказа в магазине по его номеру."""
catalog = {
"A-1001": "собран, ждёт курьера",
"A-1002": "в пути, доставка завтра",
"A-1003": "отменён покупателем",
}
return catalog.get(order_id, "заказ с таким номером не найден")
agent = create_agent(
model=build_model(temperature=0),
tools=[get_order_status],
system_prompt="Вы отвечаете на вопросы о заказах. Номер заказа берите из вопроса.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "Что с заказом A-1002?"}]})
print("СООБЩЕНИЙ В СОСТОЯНИИ:", len(result["messages"]))
for number, message in enumerate(result["messages"], start=1):
kind = type(message).__name__
if kind == "AIMessage" and message.tool_calls:
detail = "просит вызвать: " + ", ".join(
f"{call['name']}({call['args']})" for call in message.tool_calls
)
elif kind == "ToolMessage":
detail = f"{message.name} -> {message.content}"
else:
detail = message.text
print(f"{number}. {kind}: {detail}")
print()
print("ОТВЕТ ПОЛЬЗОВАТЕЛЮ:", result["messages"][-1].text)
СООБЩЕНИЙ В СОСТОЯНИИ: 4
1. HumanMessage: Что с заказом A-1002?
2. AIMessage: просит вызвать: get_order_status({'order_id': 'A-1002'})
3. ToolMessage: get_order_status -> в пути, доставка завтра
4. AIMessage: Статус заказа **A-1002**: заказ **в пути**, доставка ожидается **завтра**. 🚚📦
ОТВЕТ ПОЛЬЗОВАТЕЛЮ: Статус заказа **A-1002**: заказ **в пути**, доставка ожидается **завтра**. 🚚📦
История в состоянии получилась той же длины и того же состава, что вы собирали руками. Агент повторяет этапы сам: если после результата инструмента модель попросит ещё один вызов, агент выполнит и его, и так до ответа без вызовов.
Устройство агента, его состояние и настройка разбираются в уроке 11. Здесь агент нужен для того, чтобы не писать три этапа руками.
Docstring это часть промпта
Модель выбирает инструмент по имени и описанию, а описание по умолчанию берётся из docstring функции. Строчка "Поиск заказов" даёт одно описание. Фраза "Найти заказы покупателя по строке поиска, вызывать, когда в вопросе есть фамилия или номер заказа" даёт другое описание и другое поведение модели.
Своё имя задаётся первым аргументом декоратора, своё описание параметром description, разбор docstring в стиле Google включает параметр parse_docstring. Пример ниже печатает варианты одного инструмента и то, что из каждого уходит в модель.
Пример 04_docstring.py
"""Пример 4. Docstring это часть промпта: четыре варианта одного инструмента.
Модель здесь не вызывается. Пример печатает то, что уйдёт в модель вместе с запросом:
описание инструмента и схему его аргументов.
"""
from langchain.tools import tool
@tool
def search_orders_v1(query: str, limit: int = 5) -> str:
"""Поиск заказов."""
return f"{limit} заказов по запросу {query}"
@tool
def search_orders_v2(query: str, limit: int = 5) -> str:
"""Найти заказы покупателя по строке поиска.
Args:
query: номер заказа, имя покупателя или название товара
limit: сколько заказов вернуть, по умолчанию 5
"""
return f"{limit} заказов по запросу {query}"
@tool(parse_docstring=True)
def search_orders_v3(query: str, limit: int = 5) -> str:
"""Найти заказы покупателя по строке поиска.
Args:
query: номер заказа, имя покупателя или название товара
limit: сколько заказов вернуть, по умолчанию 5
"""
return f"{limit} заказов по запросу {query}"
@tool(
"order_search",
description="Искать заказы. Вызывайте, только когда в вопросе есть номер заказа.",
)
def search_orders_v4(query: str, limit: int = 5) -> str:
"""Этот текст в модель не уйдёт: description его перекрывает."""
return f"{limit} заказов по запросу {query}"
def show(title, tool_object):
print("=" * 70)
print(title)
print("имя:", tool_object.name)
print("описание:", repr(tool_object.description))
schema = tool_object.tool_call_schema.model_json_schema()
print("описания аргументов:")
for name, field in schema["properties"].items():
print(f" {name}: {field.get('description', '<нет>')}")
print()
show("1. Короткий docstring", search_orders_v1)
show("2. Docstring с блоком Args, parse_docstring не задан", search_orders_v2)
show("3. То же самое с parse_docstring=True", search_orders_v3)
show("4. Своё имя и своё описание в декораторе", search_orders_v4)
======================================================================
1. Короткий docstring
имя: search_orders_v1
описание: 'Поиск заказов.'
описания аргументов:
query: <нет>
limit: <нет>
======================================================================
2. Docstring с блоком Args, parse_docstring не задан
имя: search_orders_v2
описание: 'Найти заказы покупателя по строке поиска.
Args:
query: номер заказа, имя покупателя или название товара
limit: сколько заказов вернуть, по умолчанию 5'
описания аргументов:
query: <нет>
limit: <нет>
======================================================================
3. То же самое с parse_docstring=True
имя: search_orders_v3
описание: 'Найти заказы покупателя по строке поиска.'
описания аргументов:
query: номер заказа, имя покупателя или название товара
limit: сколько заказов вернуть, по умолчанию 5
======================================================================
4. Своё имя и своё описание в декораторе
имя: order_search
описание: 'Искать заказы. Вызывайте, только когда в вопросе есть номер заказа.'
описания аргументов:
query: <нет>
limit: <нет>
У вариантов 2 и 3 один и тот же docstring, а описание и схема получаются разными. Без parse_docstring весь текст docstring вместе с блоком Args: уходит в описание инструмента одним куском, а у аргументов в схеме описаний нет. С parse_docstring=True описанием становится текст до блока Args:, а строки блока расходятся по полям схемы. Отступы в описании варианта 2 видны в Python 3.12 и ниже: начиная с 3.13 интерпретатор сам срезает их у docstring.
Пишите docstring в стиле Google и ставьте parse_docstring=True: тогда пояснение к аргументу попадает в схему, рядом с самим аргументом. Разбор строгий, ValueError поднимается, когда:
1) у функции есть аргументы, а блока Args: нет
2) между описанием и блоком нет пустой строки
3) в блоке описан аргумент, которого нет в подписи функции
Первые два случая снимает параметр error_on_invalid_docstring=False, третий остаётся ошибкой при любом его значении.
Вариант 4 показывает приоритет: аргумент description перекрывает docstring. Это нужно, когда docstring написан для людей, которые читают код, а модели надо сказать другое.
Модель читает описание как инструкцию. Фраза "Вызывайте, только когда в вопросе есть номер заказа" работает в нём как правило выбора инструмента.
Схема аргументов, когда аннотаций мало
Аннотации типов задают только тип. Часто нужно больше: закрытый список значений, границы числа, пояснение к полю. Схема задаётся явно параметром args_schema: моделью Pydantic или словарём JSON Schema. Возьму модель Pydantic, она разбиралась в уроке 6.
Пример печатает схему, затем отдаёт инструмент агенту и печатает аргументы, которые пришлёт модель.
Пример 05_args_schema.py
"""Пример 5. Схема аргументов на Pydantic вместо аннотаций.
Схема добавляет к подписи функции описание каждого поля,
закрытый список допустимых значений и границы чисел.
"""
import json
from typing import Literal
from course_model import build_model
from langchain.agents import create_agent
from langchain.tools import tool
from pydantic import BaseModel, Field
ORDERS = [
{"id": "A-1001", "customer": "Иванов", "status": "shipped", "sum": 4300},
{"id": "A-1002", "customer": "Иванов", "status": "new", "sum": 1200},
{"id": "A-1003", "customer": "Петрова", "status": "cancelled", "sum": 890},
{"id": "A-1004", "customer": "Иванов", "status": "shipped", "sum": 15600},
]
class OrderSearch(BaseModel):
"""Параметры поиска по журналу заказов."""
customer: str = Field(description="Фамилия покупателя ровно так, как в вопросе")
status: Literal["new", "shipped", "cancelled"] = Field(
default="new",
description="Статус заказа: new это новый, shipped это отправлен, "
"cancelled это отменён",
)
limit: int = Field(default=5, ge=1, le=50, description="Сколько заказов вернуть")
@tool(args_schema=OrderSearch)
def search_orders(customer: str, status: str = "new", limit: int = 5) -> str:
"""Найти заказы покупателя с указанным статусом."""
found = [o for o in ORDERS if o["customer"] == customer and o["status"] == status]
if not found:
return "ничего не найдено"
return "; ".join(f"{o['id']} на {o['sum']} рублей" for o in found[:limit])
print("СХЕМА, КОТОРУЮ УВИДИТ МОДЕЛЬ:")
print(json.dumps(search_orders.tool_call_schema.model_json_schema(), ensure_ascii=False, indent=2))
agent = create_agent(
model=build_model(temperature=0),
tools=[search_orders],
system_prompt="Вы помощник менеджера магазина. Отвечайте одним предложением.",
)
question = "Какие отправленные заказы есть у Иванова?"
result = agent.invoke({"messages": [{"role": "user", "content": question}]})
print()
print("ВОПРОС:", question)
for message in result["messages"]:
if type(message).__name__ == "AIMessage" and message.tool_calls:
for call in message.tool_calls:
print("МОДЕЛЬ ПРИСЛАЛА АРГУМЕНТЫ:", call["args"])
if type(message).__name__ == "ToolMessage":
print("ИНСТРУМЕНТ ВЕРНУЛ:", message.content)
print("ОТВЕТ:", result["messages"][-1].text)
СХЕМА, КОТОРУЮ УВИДИТ МОДЕЛЬ:
{
"description": "Найти заказы покупателя с указанным статусом.",
"properties": {
"customer": {
"description": "Фамилия покупателя ровно так, как в вопросе",
"title": "Customer",
"type": "string"
},
"status": {
"default": "new",
"description": "Статус заказа: new это новый, shipped это отправлен, cancelled это отменён",
"enum": [
"new",
"shipped",
"cancelled"
],
"title": "Status",
"type": "string"
},
"limit": {
"default": 5,
"description": "Сколько заказов вернуть",
"maximum": 50,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
"required": [
"customer"
],
"title": "search_orders",
"type": "object"
}
ВОПРОС: Какие отправленные заказы есть у Иванова?
МОДЕЛЬ ПРИСЛАЛА АРГУМЕНТЫ: {'customer': 'Иванов', 'status': 'shipped'}
ИНСТРУМЕНТ ВЕРНУЛ: A-1001 на 4300 рублей; A-1004 на 15600 рублей
ОТВЕТ: У Иванова есть два отправленных заказа: A-1001 на 4300 рублей и A-1004 на 15600 рублей.
В вопросе сказано "отправленные", а в базе лежит shipped. Перевод делает модель, и схема с Literal ей помогает: список значений закрыт, каждое значение пояснено. Замените поле на status: str = Field(description="Статус заказа"), без списка и пояснений, и модель может ошибиться. В одном запуске из трёх она прислала "отправленные", инструмент вернул "ничего не найдено", и агент сообщил, что отправленных заказов у Иванова нет.
Границы ge и le тоже попадают в схему, полями minimum и maximum. Pydantic проверяет на входе в инструмент и границы числа, и список Literal. Значение вне схемы не дойдёт до тела функции, вызов упадёт на проверке. Что при этом происходит с агентом, показано в следующем разделе.
Подпись функции при args_schema всё равно нужна: схема описывает то, что приходит от модели, а функция принимает эти поля как именованные аргументы.
Когда инструмент падает
Инструмент обращается к внешним системам, и они иногда отказывают: база недоступна, API вернуло 500, записи нет. Агент обрабатывает ошибку по-разному в зависимости от того, где она случилась:
1) модель прислала аргументы, не проходящие схему: не тот тип, пропущено обязательное поле, значение вне Literal. Функция не запускается
2) функция запустилась и подняла исключение
Первый случай фреймворк обрабатывает сам: узел инструментов поднимает свой ToolInvocationError и возвращает его текст модели обычным ToolMessage. Так же узел отвечает на вызов инструмента, которого нет в списке: модель получает ToolMessage со статусом error и списком доступных имён. Модель видит, что не так с аргументами, и может исправиться на следующем шаге. Второй случай не обрабатывается: исключение поднимается дальше и останавливает весь вызов agent.invoke. Так работает обработчик ошибок узла инструментов по умолчанию.
Перехватить исключение можно с помощью middleware wrap_tool_call. Обработчик ловит исключение и возвращает на его месте ToolMessage. Пример ниже собирает агента дважды: без обработчика и с ним.
Пример 06_errors.py
"""Пример 6. Что происходит, когда инструмент падает.
Две сборки одного агента с одним и тем же ломающимся инструментом.
Первая, без обработки: исключение выходит наружу из agent.invoke.
Вторая, с middleware wrap_tool_call: исключение превращается
в ToolMessage, и агент доходит до ответа.
"""
from collections.abc import Callable
from course_model import build_model
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain.messages import ToolMessage
from langchain.tools import tool
from langchain.tools.tool_node import ToolCallRequest
@tool
def get_balance(account: str) -> str:
"""Узнать остаток на счёте по его номеру."""
if account != "40817":
# Внешняя система отвечает отказом. В рабочем приложении это был бы
# таймаут базы, 500 от API или отсутствующая запись.
raise ValueError(f"счёт {account} не найден в реестре")
return "остаток 12 400 рублей"
QUESTION = "Сколько денег на счёте 99999?"
SYSTEM = "Вы банковский помощник. Остаток узнавайте только инструментом."
def build_agent(middleware):
return create_agent(
model=build_model(temperature=0),
tools=[get_balance],
system_prompt=SYSTEM,
middleware=middleware,
)
print("СБОРКА 1: без обработки ошибок")
try:
result = build_agent([]).invoke({"messages": [{"role": "user", "content": QUESTION}]})
print("агент дошёл до ответа:", result["messages"][-1].text)
except Exception as error:
print("agent.invoke упал:", type(error).__name__)
print("текст:", error)
print()
@wrap_tool_call
def handle_tool_errors(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage],
) -> ToolMessage:
"""Превращает исключение инструмента в сообщение, понятное модели."""
try:
return handler(request)
except Exception as error:
return ToolMessage(
content=f"Инструмент не отработал: {error}. Не повторяйте вызов, "
"скажите пользователю, что счёт не найден.",
tool_call_id=request.tool_call["id"],
name=request.tool_call["name"],
status="error",
)
print("СБОРКА 2: то же самое с middleware wrap_tool_call")
result = build_agent([handle_tool_errors]).invoke(
{"messages": [{"role": "user", "content": QUESTION}]}
)
for message in result["messages"]:
kind = type(message).__name__
if kind == "ToolMessage":
print(f"ToolMessage status={message.status}: {message.content}")
elif kind == "AIMessage" and message.tool_calls:
print("AIMessage просит:", [c["args"] for c in message.tool_calls])
print("ОТВЕТ ПОЛЬЗОВАТЕЛЮ:", result["messages"][-1].text)
СБОРКА 1: без обработки ошибок
agent.invoke упал: ValueError
текст: счёт 99999 не найден в реестре
СБОРКА 2: то же самое с middleware wrap_tool_call
AIMessage просит: [{'account': '99999'}]
ToolMessage status=error: Инструмент не отработал: счёт 99999 не найден в реестре. Не повторяйте вызов, скажите пользователю, что счёт не найден.
ОТВЕТ ПОЛЬЗОВАТЕЛЮ: Извините, счёт с номером **99999** не найден в нашем реестре. Проверьте, пожалуйста, правильность номера счёта, и попробуйте снова.
tool_call_id берётся из request.tool_call["id"]. С придуманным идентификатором ответ не совпадает ни с одним вызовом, и история нарушает правило из примера 2. Модель курса такую историю принимает, агент доходит до ответа, и ошибка в идентификаторе остаётся незамеченной.
Текст сообщения, это инструкция для модели. Фраза "Не повторяйте вызов, скажите пользователю, что счёт не найден" говорит ей, что делать на следующем шаге. Модель курса не повторила вызов и без этой фразы, ни в одном из трёх запусков. Другая модель может вызвать упавший инструмент снова, и каждый повтор, это ещё один платный вызов.
Без middleware исключение тоже можно вернуть модели. Тело инструмента поднимает ToolException, а у самого инструмента включено поле handle_tool_error, например get_balance.handle_tool_error = True. Тогда текст исключения уходит модели в ToolMessage со статусом error. В пакете есть и готовые middleware: ToolErrorMiddleware превращает исключение в сообщение для модели, ToolRetryMiddleware повторяет упавший вызов, ToolCallLimitMiddleware ограничивает число вызовов инструмента. Они разобраны в уроке 15.
Несколько вызовов на одном шаге
Спросите про три склада сразу, и модель может вернуть три вызова в одном сообщении. Это параллельные вызовы. Большинство моделей с поддержкой инструментов делают так по умолчанию, а часть провайдеров позволяет выключить это параметром parallel_tool_calls=False при связывании.
Агент отправляет каждый вызов из ответа модели в узел инструментов отдельной задачей. Среда исполнения графа запускает эти задачи одновременно, в пуле потоков, а число рабочих потоков задаёт ключ max_concurrency в конфигурации запуска. Пример ниже проверяет это замером времени: инструмент спит секунду.
Пример 07_parallel.py
"""Пример 7. Несколько вызовов инструмента на одном шаге идут одновременно.
Инструмент спит секунду. Пример печатает, сколько вызовов модель
запросила на одном шаге и сколько времени заняло их исполнение.
"""
import threading
import time
from course_model import build_model
from langchain.agents import create_agent
from langchain.tools import tool
LOG = []
LOCK = threading.Lock()
START = time.perf_counter()
@tool
def check_stock(city: str) -> str:
"""Узнать остаток товара на складе города. Запрос идёт около секунды."""
began = time.perf_counter() - START
time.sleep(1.0)
ended = time.perf_counter() - START
with LOCK:
LOG.append((city, began, ended, threading.current_thread().name))
return f"на складе {city} осталось 7 штук"
agent = create_agent(
model=build_model(temperature=0),
tools=[check_stock],
system_prompt=(
"Вы отвечаете про остатки на складах. Если в вопросе несколько городов, "
"запросите все города сразу, одним ответом."
),
)
question = "Сколько товара на складах в Казани, Самаре и Перми?"
def run(title, config=None):
LOG.clear()
began = time.perf_counter()
result = agent.invoke({"messages": [{"role": "user", "content": question}]}, config=config)
spent = time.perf_counter() - began
calls_per_turn = [
len(m.tool_calls)
for m in result["messages"]
if type(m).__name__ == "AIMessage" and m.tool_calls
]
print(title)
print(" шагов с вызовами:", len(calls_per_turn), "вызовов по шагам:", calls_per_turn)
print(" всего исполнений инструмента:", len(LOG))
print(f" время всего прогона: {spent:.2f} с")
for city, start_at, end_at, thread_name in sorted(LOG, key=lambda row: row[1]):
print(f" {city}: {start_at:.2f} -> {end_at:.2f} ({thread_name})")
print()
run("ПРОГОН 1: как есть")
run("ПРОГОН 2: max_concurrency=1", config={"max_concurrency": 1})
ПРОГОН 1: как есть
шагов с вызовами: 1 вызовов по шагам: [3]
всего исполнений инструмента: 3
время всего прогона: 5.48 с
Казань: 4.13 -> 5.13 (ThreadPoolExecutor-2_0)
Самара: 4.13 -> 5.13 (ThreadPoolExecutor-3_0)
Пермь: 4.13 -> 5.13 (ThreadPoolExecutor-4_0)
ПРОГОН 2: max_concurrency=1
шагов с вызовами: 1 вызовов по шагам: [3]
всего исполнений инструмента: 3
время всего прогона: 7.23 с
Казань: 9.08 -> 10.08 (ThreadPoolExecutor-6_0)
Самара: 10.08 -> 11.08 (ThreadPoolExecutor-7_0)
Пермь: 11.08 -> 12.08 (ThreadPoolExecutor-8_0)
В первом прогоне три вызова начинаются в одну и ту же сотую секунды и заканчиваются вместе. Во втором пул ограничен одним рабочим потоком, и каждый следующий вызов начинается, когда закончился предыдущий. Имена потоков разные в обоих прогонах: каждый вызов внутри узла получает собственный пул, отсюда новый номер в имени.
Для вашего кода из этого следует:
1) тело инструмента должно быть безопасным для одновременного вызова. К общему счётчику, общему файлу, соединению с базой в глобальной переменной обращаются из нескольких потоков сразу. Как защитить такое место блокировкой, показано в ошибке 4
2) сколько вызовов будет на одном шаге, решает модель. Она может запросить три вызова сразу или по одному на каждом из трёх шагов подряд, а с parallel_tool_calls=False у поддерживающих провайдеров вызов на шаге всегда один
Инструмент может и писать в состояние агента. Если два одновременных вызова записали одно поле без редьюсера, шаг падает с InvalidUpdateError: такое поле принимает одно значение за шаг. Сама запись из инструмента, это тема урока 10, редьюсеры полей разобраны в уроке 11.
return_direct: выход сразу после инструмента
Обычно результат инструмента идёт обратно в модель, и пользователь видит пересказ. Иногда пересказ вредит: ссылка на оплату, код подтверждения, готовая выписка должны дойти до пользователя дословно. Для этого есть return_direct=True.
Выход срабатывает по правилу "все или никто": агент завершает работу, только когда return_direct=True стоит у всех инструментов, вызванных на этом шаге. Если на том же шаге вызван и обычный инструмент, цикл продолжается, и все результаты уходят в модель как обычно. Пример ниже проверяет оба случая.
Пример 08_return_direct.py
"""Пример 8. return_direct: выход из цикла сразу после инструмента.
Два прогона. В первом единственный инструмент помечен return_direct=True,
и агент отдаёт его вывод как есть. Во втором в системном промпте указано
вызвать оба инструмента на одном шаге. Если модель так и сделает, выход
сразу не срабатывает.
"""
from course_model import build_model
from langchain.agents import create_agent
from langchain.tools import tool
PAYMENT_LINK = "https://pay.example.com/invoice/A-1002?sum=1200"
@tool(return_direct=True)
def create_payment_link(order_id: str) -> str:
"""Выдать ссылку на оплату заказа. Ответ показывается пользователю дословно."""
return f"Ссылка на оплату заказа {order_id}: {PAYMENT_LINK}"
@tool
def get_order_sum(order_id: str) -> str:
"""Узнать сумму заказа в рублях."""
return "1200"
def report(title, agent, question):
result = agent.invoke({"messages": [{"role": "user", "content": question}]})
messages = result["messages"]
print(title)
print(" типы сообщений:", [type(m).__name__ for m in messages])
print(" вызовов модели:", sum(1 for m in messages if type(m).__name__ == "AIMessage"))
last = messages[-1]
print(" последнее сообщение:", type(last).__name__)
print(" его содержимое:", last.content)
print()
report(
"ПРОГОН 1: один инструмент с return_direct=True",
create_agent(
model=build_model(temperature=0),
tools=[create_payment_link],
system_prompt="Вы помощник магазина. Ссылку на оплату выдавайте инструментом.",
),
"Дайте ссылку на оплату заказа A-1002.",
)
report(
"ПРОГОН 2: рядом обычный инструмент, вызваны оба",
create_agent(
model=build_model(temperature=0),
tools=[create_payment_link, get_order_sum],
system_prompt=(
"Вы помощник магазина. Когда просят ссылку на оплату, сначала одним ответом "
"вызовите оба инструмента: сумму заказа и ссылку на оплату."
),
),
"Дайте ссылку на оплату заказа A-1002 и скажите его сумму.",
)
ПРОГОН 1: один инструмент с return_direct=True
типы сообщений: ['HumanMessage', 'AIMessage', 'ToolMessage']
вызовов модели: 1
последнее сообщение: ToolMessage
его содержимое: Ссылка на оплату заказа A-1002: https://pay.example.com/invoice/A-1002?sum=1200
ПРОГОН 2: рядом обычный инструмент, вызваны оба
типы сообщений: ['HumanMessage', 'AIMessage', 'ToolMessage', 'ToolMessage', 'AIMessage']
вызовов модели: 2
последнее сообщение: AIMessage
его содержимое: Вот информация по заказу **A-1002**:
- 💰 **Сумма заказа:** 1 200 рублей
- 🔗 **Ссылка на оплату:** [https://pay.example.com/invoice/A-1002?sum=1200](https://pay.example.com/invoice/A-1002?sum=1200)
Если понадобится ещё что-то, обращайтесь! 😊
В первом прогоне последнее сообщение в состоянии, это ToolMessage. Пересказа нет, пользователю уходит строка, которую вернула функция. Вызов модели один, и это экономия и денег, и времени.
Второй прогон зависит от того, вызовет ли модель оба инструмента на одном шаге. Если вызовет оба, правило "все или никто" не даст выйти: агент вернётся в модель с обоими результатами. Если на шаге вызван один create_payment_link, агент выйдет сразу, как в первом прогоне. Какой из двух случаев произошёл, видно по списку типов сообщений.
return_direct=True подходит там, где вывод инструмента уже готов для пользователя и не нуждается ни в обобщении, ни в цепочке с другими вызовами. После такого выхода вывод инструмента в модель уже не попадает.
Готовые инструменты
Писать всё самому не обязательно. Готовые инструменты берутся отсюда:
1) каталог инструментов и наборов фреймворка: поиск в интернете, исполнение кода, доступ к базам. Имена и пакеты собраны на странице Tool integrations
2) инструменты на стороне провайдера, разобранные в уроке 4. Их исполняет сам провайдер, вы только включаете их словарём: model.bind_tools([{"type": "web_search"}]). Результат приходит блоками server_tool_call и server_tool_result в том же ответе, ToolMessage возвращать не нужно. С моделью курса такой запрос не проходит, причина разобрана там же
3) серверы MCP. MCP это открытый протокол, по которому приложения передают модели инструменты и контекст. Это тема урока 18 (выйдет позже)
4) инструменты, которые добавляют готовые middleware: поиск по файлам, выполнение команд в терминале, список задач. Это тема урока 15
Каталог и серверы MCP дают обычные объекты инструментов, они кладутся в список tools. Инструмент провайдера задаётся словарём в том же списке. Инструменты middleware в tools не кладутся: их добавляет сам middleware из списка middleware. В tools можно положить и функцию Python без декоратора, агент обернёт её тем же @tool с настройками по умолчанию. Декоратор нужен, когда настройки свои: parse_docstring, args_schema, return_direct, имя и описание. Без него не обойтись и вне агента: в ручном цикле у инструмента вызывается invoke.
Распространённые ошибки
Ошибка 1: функция без аннотаций типов
# Неправильно: в схеме нет типа аргумента
@tool
def get_order_status(order_id):
"""Узнать статус заказа."""
return "..."
# Правильно
@tool
def get_order_status(order_id: str) -> str:
"""Узнать статус заказа."""
return "..."
Почему: при выводе схемы из подписи типы аргументов берутся только из аннотаций. Без аннотации аргумент попадает в схему без типа, и модель угадывает формат значения.
Ошибка 2: результат инструмента возвращают не тем идентификатором
# Неправильно: свой идентификатор вместо идентификатора вызова
return ToolMessage(content="ошибка сервиса", tool_call_id="error-1")
# Правильно
return ToolMessage(content="ошибка сервиса", tool_call_id=request.tool_call["id"])
Почему: на каждый вызов из tool_calls должен прийти один ToolMessage с тем же tool_call_id. Агент это не проверяет. По документации такая история отклоняется, а модель курса её принимает, и ошибка остаётся незамеченной.
Ошибка 3: расчёт на то, что падение инструмента обработается само
# Неправильно: агент собран без обработки, инструмент обращается к сети
agent = create_agent(model=model, tools=[fetch_from_partner])
# Правильно: отказ внешнего сервиса становится сообщением для модели
agent = create_agent(model=model, tools=[fetch_from_partner], middleware=[handle_tool_errors])
Почему: по умолчанию узел инструментов сам отвечает модели, только когда модель назвала несуществующий инструмент или прислала аргументы не по схеме. Исключение из тела функции поднимается дальше и останавливает agent.invoke. За уже потраченные токены вы заплатите, а ответа не получите.
Ошибка 4: общее изменяемое состояние в теле инструмента
# Неправильно: три вызова одного шага одновременно меняют счётчик
CALLS = 0
@tool
def check_stock(city: str) -> str:
"""Остаток на складе."""
global CALLS
CALLS += 1
return "..."
# Правильно: изменение общего состояния под блокировкой
LOCK = threading.Lock()
@tool
def check_stock(city: str) -> str:
"""Остаток на складе."""
global CALLS
with LOCK:
CALLS += 1
return "..."
Почему: вызовы одного шага исполняются в пуле потоков одновременно. CALLS += 1 это чтение, сложение и запись. Два потока могут прочитать одно и то же значение, и тогда один вызов из счёта пропадёт. Такая ошибка появляется не на каждом запуске, поэтому тестом её поймать трудно.
Практическое задание
Соберите агента службы поддержки с двумя инструментами и проверьте его четырьмя запросами.
1) инструмент find_ticket(ticket_id: str) -> str возвращает статус заявки из словаря на пять записей. Docstring в стиле Google, parse_docstring=True, описание аргумента объясняет формат номера
2) инструмент escalate(ticket_id: str, reason: str) -> str со схемой на Pydantic: reason это Literal из трёх значений ("bug", "billing", "other") с пояснением каждого. Инструмент помечен return_direct=True и возвращает готовый для пользователя текст с номером обращения
3) find_ticket поднимает ValueError, когда номер не найден. Middleware wrap_tool_call превращает исключение в ToolMessage с инструкцией не повторять вызов
4) печатайте после каждого запуска: список типов сообщений, аргументы каждого вызова и последнее сообщение состояния
Проверочные запросы:
1) "Что с заявкой T-77?" (заявка есть): в состоянии один вызов инструмента и текстовый ответ
2) "Что с заявкой T-999?" (заявки нет): ToolMessage со статусом error, агент доходит до ответа и не падает
3) "Заявка T-77 не решается, передайте разработчикам": вызван escalate с reason="bug", последнее сообщение состояния, это ToolMessage
4) "Проверьте заявки T-77, T-78 и T-79": посмотрите, пришли ли три вызова одним ответом модели или тремя шагами по одному
Подсказка: чтобы увидеть, что именно уходит в модель, напечатайте tool.tool_call_schema.model_json_schema() для обоих инструментов до первого запуска.
Итоги урока
Инструмент это описание и функция. Описание (имя, docstring, схема аргументов) уходит в модель, функцию выполняет ваш код или агент. Декоратор @tool собирает инструмент из функции, а tool_call_schema показывает, что увидит модель.
Цикл вызова: модель возвращает вызовы, инструменты исполняются, результаты уходят обратно в модель. Агент повторяет этот цикл, пока модель не ответит без вызовов. На каждый вызов должен прийти один ToolMessage с тем же tool_call_id, и LangChain это не проверяет.
Модель выбирает инструмент по описанию, поэтому docstring это часть промпта. parse_docstring=True раскладывает блок Args: по полям схемы, а args_schema с Literal и Field закрывает список значений и задаёт границы.
Ошибку аргументов узел инструментов возвращает модели сам, а исключение из тела функции останавливает весь запуск. Перехватывается оно middleware wrap_tool_call. Вызовы одного шага идут одновременно в пуле потоков, поэтому тело инструмента должно быть безопасным для одновременного вызова. return_direct=True отдаёт вывод инструмента пользователю дословно, если так помечены все инструменты шага.
Все инструменты в этом уроке были замкнутыми функциями: получили аргументы, вернули строку. В реальном приложении инструменту нужно знать, кто его вызвал, какой это разговор и что уже было сказано. А иногда ему надо и записать что-то обратно в состояние агента, чтобы это увидели следующие шаги.
В уроке 10 разберу параметр runtime с типом ToolRuntime. Его подставляет узел инструментов, и в схему для модели он не попадает. Через него инструмент читает состояние разговора и хранилище, получает конфигурацию сессии из context и пишет в состояние через Command. Там же покажу зарезервированные имена аргументов и прежние схемы внедрения (InjectedState, InjectedStore, get_runtime), которые встречаются в чужом коде. Редьюсеры, которые принимают параллельные записи в одно поле, разобраны в уроке 11 вместе с устройством состояния агента.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_09. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Поток событий: stream_events версии v3
Следующий урок: ToolRuntime и Runtime
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru