14. Построение цикла агента
В главе 13 мы узнали, как выполнять запросы LLM на вызов инструментов и возвращать результаты. Однако в той работе предполагалось, что каждая задача может быть решена за один вызов инструмента. На практике же вызовы инструментов часто должны продолжаться на протяжении нескольких раундов, пока LLM не соберёт всю необходимую информацию для окончательного ответа.
Рассмотрим запрос «найди население Франции, а затем умножь его на два». Эта задача требует как минимум двух вызовов инструментов. Сначала нужно узнать население — и только потом можно запустить вычисление. Второй вызов зависит от результата первого, поэтому обработать его за один вызов инструмента невозможно.
В этой главе мы превратим единичный цикл из главы 13 в цикл (loop). Пока LLM запрашивает вызовы инструментов, мы продолжаем их выполнять — повторяя до тех пор, пока LLM сам не прекратит запрашивать инструменты. Затем мы добавим ограничения безопасности, чтобы цикл не работал вечно, и рассмотрим обработку ошибок, чтобы агент не падал при сбое инструмента.
14.1) От единичного цикла к циклу
14.1.1) Как работает цикл агента?
Как мы видели во введении, задачи, где следующее действие зависит от результата предыдущего шага, часто нельзя решить одним вызовом инструмента. LLM нужно вызвать инструмент, проверить результат и снова принять решение. Именно это и делает цикл агента, и он работает в три этапа:
- Think (Обдумывание) — LLM читает диалог на текущий момент и решает, что делать дальше. Если ему нужен инструмент, он запрашивает вызов инструмента через
tool_calls. Если нет — возвращает окончательный ответ. - Act (Действие) — выполнить инструмент, указанный в
tool_calls. - Observe (Наблюдение) — проверить результат выполнения инструмента и добавить его в диалог в виде
ToolMessage.
Цикл агента повторяет эти три этапа до тех пор, пока LLM больше не запрашивает инструменты. Этот паттерн также известен как ReAct (Reason + Act), и ключевая идея — чередование рассуждения и действия.
Если tool_calls присутствует, выполните инструмент, добавьте результат в диалог и снова вызовите LLM. Если tool_calls пусто, значит LLM вернул окончательный ответ и цикл завершается. Теперь давайте реализуем это в коде.
14.1.2) Реализация цикла Think-Act-Observe
Давайте превратим цикл Think-Act-Observe из предыдущего раздела в код. Сначала подготовим инструменты и модель.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
# Определяем инструменты
@tool
def get_weather(city: str) -> str:
"""Получить текущую погоду для города."""
fake_data = {"Tokyo": "18°C, облачно", "Cairo": "31°C, солнечно"}
return fake_data.get(city, f"Нет данных о погоде для {city}.")
@tool
def calculate(expression: str) -> str:
"""Вычислить простое арифметическое выражение, например '3 * 21'."""
return str(eval(expression)) # Внимание: eval() представляет угрозу безопасности. Не используйте в продакшене.
# Привязываем инструменты
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}В главе 13 мы один раз выполняли инструмент и останавливались. Теперь же мы повторяем, пока LLM не прекратит запрашивать вызовы инструментов. Внутри цикла while True мы вызываем LLM, и если ответ содержит tool_calls, то выполняем инструменты и снова вызываем LLM. Если tool_calls нет, значит LLM вернул окончательный ответ, поэтому мы выходим из цикла.
def run_agent(user_input: str) -> str:
"""Запускает цикл Think-Act-Observe, пока LLM не вернёт окончательный ответ."""
messages = [
SystemMessage(content="Вы полезный помощник."),
HumanMessage(content=user_input),
]
while True:
# THINK: Просим LLM определить следующее действие
print("THINK: Просим LLM принять решение")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# Условие выхода: отсутствие tool_calls означает, что это окончательный ответ
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE: Выполняем запрошенные инструменты и добавляем результаты в диалог
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
print(f"ACT: вызов '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVE: {tool_message.content}")
messages.append(tool_message)Давайте сравним это с кодом из главы 13. В главе 13 после выполнения инструментов мы вызывали LLM ещё один раз, чтобы получить ответ. В главе 14 мы помещаем тот же самый процесс внутрь while True и на каждой итерации проверяем tool_calls, чтобы решить, продолжать ли. Строительные блоки те же, что и в главе 13 — мы просто обернули их в цикл.
Давайте запустим.
answer = run_agent("Какая погода в Токио, и достаточно ли тепло для прогулки?")
print(f'Окончательный ответ: {answer}')Вывод:
THINK: Просим LLM принять решение
ACT: вызов 'get_weather', args={'city': 'Tokyo'}
OBSERVE: 18°C, облачно
THINK: Просим LLM принять решение
Окончательный ответ: Сейчас в Токио 18°C (около 64°F) и облачно.
Такая температура в целом мягкая и комфортная для прогулки для большинства людей.Вывод показывает поток THINK → ACT → OBSERVE → THINK. На первой итерации LLM запросил вызов get_weather, а на второй итерации он увидел результат погоды и сгенерировал окончательный ответ. Поскольку tool_calls было пустым, был возвращён окончательный ответ и цикл завершился.
Теперь давайте протестируем сценарий с зависимыми шагами из введения — где следующий вызов может произойти только после того, как увиден результат предыдущего.
answer = run_agent("Получи температуру в Каире, затем умножь это число на 3.")
print(f'Окончательный ответ: {answer}')Вывод:
THINK: Просим LLM принять решение
ACT: вызов 'get_weather', args={'city': 'Cairo'}
OBSERVE: 31°C, солнечно
THINK: Просим LLM принять решение
ACT: вызов 'calculate', args={'expression': '31 * 3'}
OBSERVE: 93
THINK: Просим LLM принять решение
Окончательный ответ: Текущая температура в Каире: 31°C. Умноженная на 3 = 93.На этот раз цикл выполнил три итерации.
- Первая итерация — LLM запрашивает
get_weather("Cairo"). - Вторая итерация — увидев результат
"31°C, солнечно", LLM запрашиваетcalculate("31 * 3"). Он смог составить выражение только после того, как увидел температуру. - Третья итерация — увидев оба результата
"31°C, солнечно"и"93", LLM вернул окончательный ответ.
14.2) Добавление ограничений безопасности
У цикла, который мы построили выше, есть только одно условие выхода: когда LLM отвечает без tool_calls, мы выходим из цикла. При обычных обстоятельствах этого достаточно, но что произойдёт, если LLM никогда не прекратит запрашивать вызовы инструментов?
Например, если инструмент всегда возвращает неоднозначные результаты, LLM может продолжать его вызывать в надежде получить что-то лучшее. Поскольку цикл — это while True, если LLM не останавливается, то и программа не останавливается. Затраты на вызовы API продолжают накапливаться, пока программа работает бесконечно.
Самое простое решение — установить потолок на то, сколько раз может выполняться цикл. Замените while True на for step in range(max_steps), и цикл гарантированно завершится после max_steps итераций, независимо от того, что делает LLM.
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""Запускает цикл Think-Act-Observe в пределах max_steps итераций."""
messages = [
SystemMessage(content="Вы полезный помощник."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# THINK
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# Условие выхода: отсутствие tool_calls означает, что это окончательный ответ
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
# Достигнут max_steps: цикл завершился без окончательного ответа
return f"[Остановлено после достижения максимального числа итераций ({max_steps})]"По сравнению с предыдущим кодом изменились две вещи. while True стал for step in range(max_steps), и было добавлено возвращаемое значение для случая, когда цикл достигает max_steps. Теперь цикл завершается одним из двух способов: LLM сам возвращает окончательный ответ (естественное завершение), или достигается max_steps (завершение по безопасности).
Давайте убедимся, что ограничение безопасности действительно работает. Мы создадим инструмент, который никогда не возвращает полезных результатов, вынуждая LLM в ситуацию, когда он никогда не прекращает запрашивать вызовы инструментов.
@tool
def unhelpful_search(query: str) -> str:
"""Поиск информации."""
return "Результатов не найдено. Попробуйте переформулировать запрос."
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
messages = [
SystemMessage(content=(
"Вы ВСЕГДА должны использовать инструмент unhelpful_search для поиска информации. "
"Вам НЕ разрешено отвечать из собственных знаний. "
"Если инструмент не возвращает результатов, вы ДОЛЖНЫ переформулировать и искать снова. "
"Продолжайте искать, пока не найдёте ответ."
)),
HumanMessage(content=user_input),
]
for step in range(max_steps):
print(f"--- Шаг {step + 1} ---")
ai_message = llm_with_bad_tool.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
for tool_call in ai_message.tool_calls:
selected_tool = tool_map_bad[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
print(f"ACT: '{tool_call['name']}' → {tool_message.content}")
messages.append(tool_message)
return f"[Остановлено после достижения максимального числа итераций ({max_steps})]"
answer = run_agent_bad("Какое население Франции?")
print(f"\nОкончательный ответ: {answer}")Вывод:
--- Шаг 1 ---
ACT: 'unhelpful_search' → Результатов не найдено. Попробуйте переформулировать запрос.
--- Шаг 2 ---
ACT: 'unhelpful_search' → Результатов не найдено. Попробуйте переформулировать запрос.
--- Шаг 3 ---
ACT: 'unhelpful_search' → Результатов не найдено. Попробуйте переформулировать запрос.
--- Шаг 4 ---
ACT: 'unhelpful_search' → Результатов не найдено. Попробуйте переформулировать запрос.
--- Шаг 5 ---
ACT: 'unhelpful_search' → Результатов не найдено. Попробуйте переформулировать запрос.
Окончательный ответ: [Остановлено после достижения максимального числа итераций (5)]Без max_steps этот цикл работал бы вечно. Благодаря max_steps=5 он был принудительно завершён после пяти итераций.
Правильное значение для max_steps зависит от сложности вашего агента. Слишком низкое — и сложные задачи будут обрываться. Слишком высокое — и неисправно работающий агент накопит расходы, прежде чем будет остановлен. 15–25 — распространённая отправная точка; корректируйте, исходя из ваших реальных рабочих нагрузок.
14.3) Обработка ошибок инструментов в цикле
Цикл, который мы построили в 14.1 и 14.2, предполагает, что инструменты всегда выполняются успешно. Но что произойдёт, если инструмент выбросит исключение? В текущем коде нет обработки исключений, поэтому если во время выполнения инструмента возникнет исключение, весь агент рухнет.
В главе 12 мы узнали, как перехватывать исключения внутри самого инструмента с помощью try/except и возвращать сообщения об ошибках в виде строк. Если инструмент построен таким образом, проблем нет. Но не каждый инструмент обрабатывает ошибки внутренне. Инструменты, которые вызывают внешние библиотеки или API, могут выбрасывать непредвиденные исключения.
Чтобы защититься от этого, хорошей идеей будет обрабатывать ошибки также и на уровне цикла. Подход прост: оберните выполнение инструмента в try/except, и если возникнет исключение, поместите сообщение об ошибке в ToolMessage и передайте его LLM. LLM может прочитать это сообщение об ошибке и повторить попытку с исправленными аргументами или выбрать другой подход. Это называется самокоррекцией (self-correction).
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
# Определяем инструменты
@tool
def calculate(expression: str) -> str:
"""Вычислить простое арифметическое выражение, например '3 * 21'."""
return str(eval(expression)) # Внимание: eval() представляет угрозу безопасности. Не используйте в продакшене.
# Привязываем инструменты
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""Цикл агента, который передаёт ошибки инструментов LLM, обеспечивая самокоррекцию."""
messages = [
SystemMessage(content="Вы полезный помощник."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# THINK
print("THINK: Просим LLM принять решение")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
try:
print(f"ACT: вызов '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVE: {tool_message.content}")
except Exception as e:
print(f"OBSERVE: Ошибка - {e}")
tool_message = ToolMessage(
content=f"Ошибка: {e}",
tool_call_id=tool_call["id"],
)
messages.append(tool_message)
return f"[Остановлено после достижения максимального числа итераций ({max_steps})]"По сравнению с кодом из 14.2 единственное изменение — это try/except. Если инструмент выбрасывает исключение, сообщение об ошибке помещается в ToolMessage и добавляется в диалог. Обратите внимание, что даже неудавшийся вызов должен иметь ToolMessage с соответствующим tool_call_id. LLM увидит эту ошибку на следующей итерации и решит, что делать дальше.
Давайте убедимся, что самокоррекция работает. Мы спровоцируем исключение, попросив инструмент calculate разделить на ноль.
answer = run_agent("Используй инструмент калькулятора, чтобы вычислить 10 / 0")
print(f"Окончательный ответ: {answer}")Вывод:
THINK: Просим LLM принять решение
ACT: вызов 'calculate', args={'expression': '10 / 0'}
OBSERVE: Ошибка - division by zero
THINK: Просим LLM принять решение
Окончательный ответ: Я использовал инструмент калькулятора, и он вернул ошибку: "division by zero."
Пояснение: 10 / 0 не определено в обычной арифметике, поэтому не может дать конечного числа.
Хотите, чтобы я:
- вычислил односторонние пределы,
- показал результат с плавающей точкой по стандарту IEEE-754,
- или вычислил другое выражение?На первой итерации LLM запросил calculate("10 / 0"), и было выброшено исключение ZeroDivisionError. try/except перехватил исключение и передал сообщение об ошибке LLM. На второй итерации LLM увидел сообщение об ошибке и вернул окончательный ответ, объясняющий, что деление на ноль невозможно. Без try/except программа рухнула бы при первом же ZeroDivisionError.