Цикл как архитектурный примитив
Если агент — это операционная система, то реактивный цикл — это его event loop. Всё, что делает агент, происходит внутри этого цикла: каждый вызов инструмента, каждое рассуждение, каждое обновление памяти.
Цикл называется реактивным, потому что агент реагирует на состояние среды — результаты инструментов, ответы API, ошибки — и каждый раз принимает новое решение. Это отличает его от детерминированного пайплайна, где порядок шагов жёстко задан заранее.
Цикл завершается в двух точках: нормально — когда LLM возвращает stop_reason="end_turn" на фазе THINK, и аварийно — когда счётчик итераций достигает лимита. Оба случая нужно обрабатывать явно.
Фаза PERCEIVE: что агент получает
PERCEIVE — точка входа в каждую итерацию. Здесь происходит одно важное действие: новая информация добавляется в список messages, который является контекстом для LLM.
В первой итерации это сообщение пользователя. В последующих — результаты инструментов, которые агент выполнил на предыдущем шаге. Именно так контекст растёт от итерации к итерации:
Список messages только пополняется — никогда не редактируется. Каждая итерация добавляет 1–2 сообщения. При долгих задачах контекст может достигнуть лимита токенов. Решение — суммаризация или скользящее окно (детально в уроке про память).
Фаза THINK: LLM принимает решение
THINK — единственная фаза, где участвует LLM. Модель получает весь накопленный контекст и возвращает структурированный ответ. Ключевой элемент этого ответа — stop_reason, который определяет дальнейший путь цикла.
В этой фазе происходит единственный сетевой вызов в цикле — запрос к LLM API. Стоимость итерации определяется количеством токенов в messages. Чем длиннее история — тем дороже каждая итерация.
После получения ответа нужно разобрать response.content: это список блоков, каждый из которых может быть либо текстом (TextBlock), либо запросом на вызов инструмента (ToolUseBlock). В одном ответе может быть несколько блоков обоих типов одновременно.
def think(messages: list, tools: list, system: str) -> tuple[str, list]:
"""
Фаза THINK: отправить контекст в LLM, получить решение.
Возвращает: (stop_reason, response_content)
"""
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
system=system,
tools=tools,
messages=messages,
)
# Добавляем ответ ассистента в историю (это тоже часть THINK)
messages.append({"role": "assistant", "content": response.content})
return response.stop_reason, response.content
# Использование в цикле:
stop_reason, content = think(messages, tools, system_prompt)
if stop_reason == "end_turn":
# Финальный ответ — извлекаем текст и выходим
return next(b.text for b in content if hasattr(b, "text"))
elif stop_reason == "tool_use":
# Есть запросы на инструменты — переходим в ACT
tool_calls = [b for b in content if b.type == "tool_use"]
# ...
else:
# max_tokens, stop_sequence или неизвестная причина
raise RuntimeError(f"Unexpected stop_reason: {stop_reason}")
Модель может вернуть и текст, и tool_use одновременно — это «рассуждение перед действием». Текстовый блок содержит промежуточные мысли (CoT), за ним идут tool_use. Всё содержимое нужно добавить в messages как есть — не фильтруй блоки при сохранении в историю.
Фаза ACT: диспетчеризация инструментов
ACT — фаза выполнения. Агент берёт все ToolUseBlock из ответа LLM и для каждого вызывает соответствующую Python-функцию. Ключевая задача — корректно обработать ошибки: если инструмент упал, агент не должен падать вместе с ним.
def act(tool_calls: list, tool_registry: dict) -> list[dict]:
"""
Фаза ACT: выполнить все запрошенные инструменты.
Возвращает список tool_result для фазы OBSERVE.
"""
results = []
for call in tool_calls:
fn = tool_registry.get(call.name)
if fn is None:
# Инструмент не зарегистрирован — сообщаем LLM, не падаем
result_content = f"Ошибка: инструмент '{call.name}' не найден."
is_error = True
else:
try:
result_content = fn(**call.input)
is_error = False
except Exception as e:
# Инструмент упал — передаём ошибку в контекст как строку
result_content = f"Ошибка выполнения {call.name}: {e}"
is_error = True
results.append({
"type": "tool_result",
"tool_use_id": call.id, # связываем результат с вызовом
"content": str(result_content),
# "is_error": is_error, # опционально: подсвечивает ошибку для LLM
})
return results
Почему не нужно бросать исключение при ошибке инструмента? Потому что LLM должна узнать о проблеме и скорректировать план. Если агент упадёт с исключением, LLM ничего не узнает и не сможет попробовать другой подход. Передай ошибку в контекст как строку — это даёт модели шанс исправиться.
Фаза OBSERVE: результаты возвращаются в контекст
OBSERVE замыкает цикл. Результаты выполненных инструментов упаковываются в формат tool_result и добавляются в messages как одно сообщение с ролью user.
Важный нюанс: все результаты от одной итерации идут одним сообщением, а не по одному. Если LLM запросила три инструмента, все три результата нужно положить в один {"role": "user", "content": [result1, result2, result3]}. API вернёт ошибку, если за assistant-сообщением с tool_use следует не user с tool_result.
def observe(messages: list, tool_results: list) -> None:
"""
Фаза OBSERVE: добавить результаты инструментов в контекст.
Все результаты одной итерации — одно сообщение user.
"""
messages.append({
"role": "user",
"content": tool_results, # список tool_result-блоков
})
# После observe — iteration += 1, и цикл начинается заново с THINK
В Anthropic API нет отдельной роли для инструментов. Результаты всегда добавляются как user-сообщение с типом tool_result внутри. На следующей итерации LLM видит это как «пользователь ответил на мои запросы» — это и есть наблюдение за результатом действия.
Условия выхода из цикла
Правильно спроектированный цикл завершается в четырёх ситуациях. Первые две — нормальные, вторые две — защитные:
DONE или агент достиг заявленной цели (например, файл записан).stop_reason. Логируй и пробрасывай выше.Полный цикл с защитой
Собираем все четыре фазы в production-готовый цикл с явными условиями выхода, логированием и обработкой крайних случаев:
import anthropic
import logging
import time
logger = logging.getLogger(__name__)
client = anthropic.Anthropic()
def run_agent(
user_message: str,
tools: list,
tool_registry: dict,
system_prompt: str,
max_iterations: int = 15,
timeout_seconds: float = 120.0,
) -> str:
"""
Реактивный цикл агента с полной обработкой выходов.
"""
messages = [{"role": "user", "content": user_message}]
start_time = time.monotonic()
for iteration in range(max_iterations):
# ── Проверка таймаута ────────────────────────────────
elapsed = time.monotonic() - start_time
if elapsed > timeout_seconds:
logger.warning(f"Agent timeout after {elapsed:.1f}s ({iteration} iterations)")
return "Превышено время ожидания. Попробуй сформулировать задачу короче."
logger.debug(f"[iter {iteration}] context size: {len(messages)} messages")
# ── THINK ────────────────────────────────────────────
try:
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=4096,
system=system_prompt,
tools=tools,
messages=messages,
)
except anthropic.APIError as e:
logger.error(f"LLM API error: {e}")
raise
messages.append({"role": "assistant", "content": response.content})
logger.debug(f"[iter {iteration}] stop_reason={response.stop_reason}")
# ── Выход: финальный ответ ───────────────────────────
if response.stop_reason == "end_turn":
text = next(
(b.text for b in response.content if hasattr(b, "text")), ""
)
logger.info(f"Agent done in {iteration + 1} iterations, {elapsed:.1f}s")
return text
# ── Выход: переполнение контекста ────────────────────
if response.stop_reason == "max_tokens":
logger.warning("Context window full, returning partial result")
return "Задача слишком объёмная. Разбей её на части."
# ── Неизвестный stop_reason ──────────────────────────
if response.stop_reason != "tool_use":
raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason!r}")
# ── ACT: выполняем инструменты ───────────────────────
tool_calls = [b for b in response.content if b.type == "tool_use"]
tool_results = []
for call in tool_calls:
logger.debug(f"[iter {iteration}] calling {call.name}({call.input})")
fn = tool_registry.get(call.name)
try:
result = fn(**call.input) if fn else f"Инструмент '{call.name}' не найден"
is_error = fn is None
except Exception as e:
result = f"Ошибка {call.name}: {e}"
is_error = True
logger.warning(f"Tool {call.name} failed: {e}")
tool_results.append({
"type": "tool_result",
"tool_use_id": call.id,
"content": str(result),
})
# ── OBSERVE: добавляем результаты в контекст ─────────
messages.append({"role": "user", "content": tool_results})
# ── Выход: лимит итераций ────────────────────────────────
logger.warning(f"Max iterations ({max_iterations}) reached")
return "Задача не завершена за допустимое число шагов."
Отладка цикла
Самое важное при отладке агента — видеть, что происходит на каждой итерации: сколько итераций прошло, какие инструменты вызывались, что вернул LLM. Минимальный инструмент для этого — структурированное логирование итераций.
def debug_iteration(iteration: int, stop_reason: str, content: list) -> None:
"""Выводит сводку одной итерации цикла."""
print(f"\n{'─'*50}")
print(f"Итерация {iteration} │ stop_reason: {stop_reason}")
for block in content:
if hasattr(block, "text") and block.text:
preview = block.text[:120].replace("\n", " ")
print(f" [text] {preview}{'…' if len(block.text) > 120 else ''}")
elif block.type == "tool_use":
print(f" [tool_use] {block.name}({block.input})")
# Вставляем в цикл сразу после вызова LLM:
debug_iteration(iteration, response.stop_reason, response.content)
Вывод при трёх итерациях выглядит примерно так:
──────────────────────────────────────────────────
Итерация 0 │ stop_reason: tool_use
[tool_use] search_web({'query': 'OpenAI news 2025'})
──────────────────────────────────────────────────
Итерация 1 │ stop_reason: tool_use
[tool_use] search_web({'query': 'OpenAI GPT-5 release date'})
──────────────────────────────────────────────────
Итерация 2 │ stop_reason: end_turn
[text] По последним данным, OpenAI представила GPT-5 в мае 2025 года…
Три итерации — нормально для исследовательской задачи. Если видишь 8+ итераций с одинаковыми вызовами инструментов — модель зациклилась. Чаще всего причина: инструмент возвращает данные в неожиданном формате, или в system prompt нет указания когда останавливаться.
Шпаргалка
Реактивный цикл — структура messages = [{"role": "user", "content": user_input}] for iteration in range(MAX_ITER): THINK: response = llm(messages) messages.append(assistant_message) if stop_reason == "end_turn" → return text if stop_reason == "max_tokens"→ compress or return partial if stop_reason != "tool_use" → raise error ACT: for call in tool_use_blocks: result = safe_call(call) # никогда не бросаем исключение tool_results.append(result) OBSERVE: messages.append({"role": "user", "content": tool_results}) return "лимит итераций исчерпан" Правила Контекст: messages только растёт; следи за длиной Ошибки: инструмент упал → строка в tool_result, не исключение Защита: max_iterations + timeout — всегда оба Результаты: все tool_result одной итерации = одно user-сообщение Стоимость: N итераций × (размер контекста) токенов — контекст накапливается
Практическое задание
-
Измерь стоимость контекста. Напиши агента с инструментом
count_tokens(text) → int, который считает длину строки в символах. Запусти задачу на 5 итераций и добавь логирование длиныmessagesперед каждым вызовом LLM. Посмотри, как быстро растёт контекст. -
Протестируй ошибку инструмента. Создай инструмент, который бросает исключение с вероятностью 50% (через
random.random()). Убедись, что агент не падает — LLM должна получить сообщение об ошибке и попробовать ещё раз или использовать другой инструмент. -
Добавь суммаризацию при переполнении. Если длина
messagesпревышает 20 сообщений — сожми старую историю через отдельный LLM-вызов (client.messages.create(..., system="Сожми историю в 3 предложения")) и замени первые N сообщений на одно суммарное. Это простейшая реализация управления памятью.