Цикл как архитектурный примитив

Если агент — это операционная система, то реактивный цикл — это его event loop. Всё, что делает агент, происходит внутри этого цикла: каждый вызов инструмента, каждое рассуждение, каждое обновление памяти.

Цикл называется реактивным, потому что агент реагирует на состояние среды — результаты инструментов, ответы API, ошибки — и каждый раз принимает новое решение. Это отличает его от детерминированного пайплайна, где порядок шагов жёстко задан заранее.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Финальный ответ stop_reason = "end_turn" выход PERCEIVE Воспринять • получить ввод • добавить в messages • обновить контекст {"role":"user", …} context THINK Рассуждать • LLM получает messages • принимает решение • tool_use или ответ? stop_reason → ветвление tool_use ACT Действовать • диспетчер инструментов • вызов Python-функций • сбор результатов tool_result × N results OBSERVE Наблюдать • упаковать tool_result • добавить в messages • обновить итератор {"role":"user", …} ← следующая итерация (until end_turn or max_iterations) iteration += 1

Цикл завершается в двух точках: нормально — когда LLM возвращает stop_reason="end_turn" на фазе THINK, и аварийно — когда счётчик итераций достигает лимита. Оба случая нужно обрабатывать явно.

Фаза PERCEIVE: что агент получает

PERCEIVE — точка входа в каждую итерацию. Здесь происходит одно важное действие: новая информация добавляется в список messages, который является контекстом для LLM.

В первой итерации это сообщение пользователя. В последующих — результаты инструментов, которые агент выполнил на предыдущем шаге. Именно так контекст растёт от итерации к итерации:

Итерация 0 — начало
user
"Найди последние новости об OpenAI и кратко изложи"
После итерации 1 — LLM вызвал инструмент, мы добавили результат
user
"Найди последние новости об OpenAI…"
assistant
[tool_use] name: "search_web", input: {query: "OpenAI news 2025"}
user (tool_result) ← новое
[tool_result] tool_use_id: "toolu_01…", content: "OpenAI выпустила GPT-5…"
После итерации 2 — финальный ответ (end_turn)
user
"Найди последние новости…"
assistant
[tool_use] search_web(…)
user (tool_result)
…результат поиска…
assistant ← финальный ответ
"По последним данным: OpenAI выпустила GPT-5 в мае 2025…"
⚠️ Контекст только растёт

Список messages только пополняется — никогда не редактируется. Каждая итерация добавляет 1–2 сообщения. При долгих задачах контекст может достигнуть лимита токенов. Решение — суммаризация или скользящее окно (детально в уроке про память).

Фаза THINK: LLM принимает решение

THINK — единственная фаза, где участвует LLM. Модель получает весь накопленный контекст и возвращает структурированный ответ. Ключевой элемент этого ответа — stop_reason, который определяет дальнейший путь цикла.

THINK
Вызов LLM и разбор ответа
Одна итерация = один запрос к API

В этой фазе происходит единственный сетевой вызов в цикле — запрос к 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}")
ℹ️ TextBlock + ToolUseBlock в одном ответе

Модель может вернуть и текст, и 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 ничего не узнает и не сможет попробовать другой подход. Передай ошибку в контекст как строку — это даёт модели шанс исправиться.

❌ Исключение убивает цикл
try:
  result = fn(**call.input)
except Exception as e:
  raise # цикл падает
LLM не знает об ошибке, пользователь видит 500
✅ Ошибка передаётся в контекст
try:
  result = fn(**call.input)
except Exception as e:
  result = f"Ошибка: {e}"
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
ℹ️ Почему role = "user", а не "tool"?

В Anthropic API нет отдельной роли для инструментов. Результаты всегда добавляются как user-сообщение с типом tool_result внутри. На следующей итерации LLM видит это как «пользователь ответил на мои запросы» — это и есть наблюдение за результатом действия.

Условия выхода из цикла

Правильно спроектированный цикл завершается в четырёх ситуациях. Первые две — нормальные, вторые две — защитные:

end_turn
LLM решила, что задача выполнена. Возвращаем финальный текстовый ответ пользователю.
явная остановка
Инструмент вернул специальный сигнал DONE или агент достиг заявленной цели (например, файл записан).
max_iterations
Счётчик достиг лимита (обычно 10–20). Возвращаем лучший найденный результат или сообщение об ошибке.
max_tokens
Контекст переполнен, LLM не может ответить. Нужна суммаризация истории или сброс диалога.
timeout
Общее время выполнения превысило лимит. Критично для real-time систем — всегда добавляй таймаут на весь цикл.
критическая ошибка
Ошибка аутентификации, недоступность API, неизвестный 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 итераций × (размер контекста) токенов — контекст накапливается

Практическое задание

  1. Измерь стоимость контекста. Напиши агента с инструментом count_tokens(text) → int, который считает длину строки в символах. Запусти задачу на 5 итераций и добавь логирование длины messages перед каждым вызовом LLM. Посмотри, как быстро растёт контекст.
  2. Протестируй ошибку инструмента. Создай инструмент, который бросает исключение с вероятностью 50% (через random.random()). Убедись, что агент не падает — LLM должна получить сообщение об ошибке и попробовать ещё раз или использовать другой инструмент.
  3. Добавь суммаризацию при переполнении. Если длина messages превышает 20 сообщений — сожми старую историю через отдельный LLM-вызов (client.messages.create(..., system="Сожми историю в 3 предложения")) и замени первые N сообщений на одно суммарное. Это простейшая реализация управления памятью.
← Предыдущий урок
Анатомия агента
LLM + инструменты + память
→ Следующий урок
Как LLM вызывает функции
Tool Calling в деталях