Перейти к содержимому
Ступень 88.3 Агенты

Агентный цикл и ReAct

Что превращает LLM в агента, как устроен цикл «думай → действуй → наблюдай», откуда взялся ReAct и как работает tool calling в OpenAI-совместимых API.

УРОКstage-8/8-3-agents/01-agent-loop.mdx1 ч 40 минУрок 1 из 4
После урока вы сможете
  • Объяснить, чем агент отличается от чат-бота и от жёстко заданного конвейера (workflow)
  • Написать агентный цикл ReAct с нуля — с разбором ответа, стоп-последовательностью и обработкой ошибок
  • Подключить инструменты через нативный tool calling в OpenAI-совместимом API (Ollama, DeepSeek, Qwen)
  • Оценить, как растёт стоимость агента с числом шагов

Сама по себе большая языковая модельбольшая языковая модель (БЯМ, LLM)EN large language model (LLM)В китайском «большая модель» («да мосин») — разговорное общее название для LLM и вообще foundation models. — функция «текст на входе → текст на выходе». Она не знает сегодняшней даты, не видит ваших файлов, не может запустить тесты или отправить письмо. Всё, что она умеет, — продолжать текст.

агентагентEN agentВ китайском встречается и «дайли» («посредник»), но в AI-контексте стандарт — «чжинэнти» (буквально «интеллектуальное тело»). появляется, когда эту функцию ставят в цикл и дают ей инструменты. Модель пишет: «хочу вызвать run_tests()», обычный код вызывает функцию, результат возвращается модели, и так до тех пор, пока задача не решена. Именно так устроены кодовые агенты (Claude Code, qwen-code), «глубокий поиск» (deep research) и почти всё, что в 2025–2026 годах называют агентами.

В китайских материалах агента называют словом «чжинэнти» — буквально «разумное тело». Этот модуль идёт по мотивам книги hello-agents от Datawhale, которая собирает агентов с нуля, без фреймворков. Мы сделаем так же: к концу урока у вас будет работающий агентный цикл из 40 строк чистого Python.

Классическое определение из учебника Рассела и Норвига: агент воспринимает среду и действует на неё. Для LLM-агента это значит:

ЧастьРольПример
Модель«мозг»: решает, что делать дальшеQwen3, DeepSeek-V3.2, Kimi K2
Инструменты«руки»: действуют в мире и приносят данныекалькулятор, поиск, bash, SQL-запросы к базе данных, браузер
Контекст и памятьчто модель знает о ходе работыистория шагов, заметки, результаты
Цикл (harness)обычный код вокруг моделиразбор ответа, вызов функций, лимиты

Самое важное: модель ничего не исполняет. Она только генерирует текст, в котором «просит» действие. Исполняет его ваш код. Поэтому агент — это не новая архитектура нейросети, а способ её использовать.

В статье Anthropic «Building effective agents» проведена полезная граница:

  • Workflow (конвейер) — шаги заданы кодом заранее. Например: «извлеки сущности → найди их в базе → напиши ответ». LLM работает внутри шагов, но порядок шагов решает программист.
  • Агент — LLM сама решает, какой инструмент вызвать, сколько раз и когда остановиться.

Конвейер предсказуемее, дешевле и проще в отладке. Агент гибче, но может зациклиться, потратить много токенов или сделать что-то неожиданное. Правило из той же статьи: начинайте с самого простого решения и усложняйте, только если это измеримо улучшает результат. Подробнее о том, когда агенты не нужны, — в уроке 8.3.3.

Любой агент сводится к циклу while:

flowchart LR
Q["Задача пользователя"] --> T["Думай:<br/>LLM читает историю<br/>и пишет следующий шаг"]
T -->|"просит инструмент"| A["Действуй:<br/>код вызывает функцию"]
A --> O["Наблюдай:<br/>результат дописывается<br/>в историю"]
O --> T
T -->|"Finish / ответ"| R["Ответ пользователю"]
Агентный цикл: модель думает, код действует, среда возвращает наблюдение

Каждый виток цикла — это новый вызов модели со всей историей. Модель не «помнит» прошлые шаги сама: она видит их, потому что мы каждый раз отправляем ей растущий текст (подробно об этом — в уроке 8.1.1 про API).

# скелет агента (псевдокод)
transcript = f"Question: {question}\n"
for step in range(max_steps):
output = llm(transcript) # думай
action = parse(output)
if action is FINISH:
return action.answer
result = tools[action.name](action.input) # действуй
transcript += output + f"Observation: {result}\n" # наблюдай

Это скелет. Дальше разберём, что в нём может сломаться и как это чинить.

До 2022 года было два подхода, и у обоих была своя слабость.

  • Только рассуждение (цепочка рассужденийцепочка рассужденийEN chain of thought (CoT)): модель думает шаг за шагом, но берёт факты из своей «памяти». Если факта там нет, она его выдумывает (галлюцинациягаллюцинацияEN hallucination), и ошибка тянется по всей цепочке.
  • Только действия: модель вызывает поиск, клики и т. п., но не формулирует план. Она теряет цель и повторяет одно и то же.

ReActReAct (рассуждение + действие)EN ReAct (Reason + Act)Парадигма агента, где модель чередует Thought → Action → Observation (Yao и др., 2022). (Reason + Act; Yao и др., Принстон и Google, 2022) предложил чередовать мысли и действия в одном тексте. Мысль (Thought) планирует и интерпретирует, действие (Action) добывает факты, наблюдение (Observation) возвращает их в контекст.

Question: Сколько стоит чай на всех гостей?
Thought: Мне нужна цена чая и число гостей. Начну с цены.
Action: lookup[цена_чая]
Observation: 35
Thought: Теперь число гостей.
Action: lookup[число_гостей]
Observation: 12
Thought: Осталось перемножить. Сделаю это калькулятором, а не в уме.
Action: calculator[35 * 12]
Observation: 420
Thought: Ответ готов.
Action: Finish[420]

Формату модель учили не дообучением, а несколькими примерами в промпте (few-shotfew-shot (примеры в промпте)EN few-shot promptingНесколько пар «вход → выход» прямо в промпте; модель подхватывает формат без дообучения (in-context learning). Zero-shot — без примеров.). На интерактивных средах ALFWorld (текстовый «домашний робот») и WebShop (покупки в интернет-магазине) хватало одного-двух примеров, и ReAct обошёл методы на основе имитации (модель учится повторять записанные действия людей) и RL на 34 и 10 процентных пунктов успешности соответственно. На вопросах HotpotQA (ответ требует собрать несколько фактов из разных статей Википедии) ReAct реже галлюцинировал, чем чистый CoT, хотя по точности сам по себе немного ему уступал. Лучший результат дала комбинация обоих подходов.

Модель знает формат, потому что мы описали его в системном промптесистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне.:

Отвечай на вопрос, используя инструменты:
- calculator[выражение] — точно вычисляет арифметику
- lookup[ключ] — читает значение из блокнота пользователя
- Finish[ответ] — завершает работу и возвращает ответ
Формат строго такой:
Thought: твои рассуждения
Action: инструмент[аргумент]
После Action остановись и жди Observation.

Обратите внимание на последнюю строку. Она связана с самой частой ошибкой самописных агентов.

Модель обучена продолжать текст. Увидев шаблон Action → Observation, она с удовольствием напишет Observation: 5 за инструмент — и решит задачу на выдуманных данных. Это галлюцинация, оформленная как факт.

Решение — стоп-последовательность: в API передают stop=["Observation:"], и генерация обрывается до того, как модель начнёт сочинять результат. Наблюдение дописывает среда. Если модель всё же что-то написала после Observation: (например, API не поддерживает stop), это нужно отрезать в коде.

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

Action: calculator[1 / 0]
Observation: Ошибка: division by zero
Thought: Делить на ноль нельзя, перечитаю условие...

Сильные модели часто исправляются сами: меняют аргументы или выбирают другой инструмент. Это дешёвый способ сделать агента устойчивым.

Модель может ходить по кругу: искать одно и то же, вызывать инструмент с теми же аргументами. Минимальная защита — лимит шагов max_steps (и/или бюджет токенов и времени). Когда лимит исчерпан, агент честно возвращает «не справился», а не висит.

Разбирать Action: tool[...] регулярками хрупко. Поэтому с 2023 года модели учат вызывать функции нативно (вызов инструментоввызов инструментовEN tool calling / function calling): описание инструментов передаётся отдельным полем, а модель отвечает структурированным вызовом. Формат OpenAI Chat Completions стал стандартом де-факто — его поддерживают API DeepSeek и Qwen, а также локальные Ollama, vLLM и SGLang.

1. Вы описываете инструменты — JSON Schema аргументов (подробно о схемах — в следующем уроке):

{"type": "function",
"function": {
"name": "calculator",
"description": "Точно вычисляет арифметическое выражение.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"]}}}

2. Модель отвечает не текстом, а вызовом (finish_reason: "tool_calls"):

{"role": "assistant", "content": null,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "calculator",
"arguments": "{\"expression\": \"17 * 23\"}"}}]}

Обратите внимание: arguments — это строка с JSON, её нужно разобрать json.loads (и она может оказаться невалидной).

3. Вы исполняете функцию и возвращаете результат сообщением с ролью tool:

{"role": "tool", "tool_call_id": "call_1", "content": "391"}

tool_call_id связывает результат с вызовом. Это важно, потому что модель может запросить несколько вызовов сразу (parallel tool calls), и каждый ответ должен попасть к своему вызову.

Выбором управляет параметр tool_choice: "auto" — модель решает сама (по умолчанию, если инструменты переданы), "none" — вызывать нельзя, "required" — обязательно вызвать какой-нибудь инструмент. Поддержка вариантов зависит от сервера, так что сверяйтесь с документацией провайдера.

Под капотом это тот же ReAct, только со специальными токенами. Например, в чат-шаблонеchat-шаблонEN chat templateПравило (обычно Jinja в tokenizer_config.json), которое превращает список сообщений с ролями в одну строку со спецтокенами. Qwen2.5/Qwen3 описания инструментов кладутся в системный промпт внутри тегов <tools>, модель пишет вызов как <tool_call>{"name": ..., "arguments": ...}</tool_call>, а результат возвращается в <tool_response>. Сервер инференса парсит эти теги и отдаёт вам аккуратный JSON. Модель умеет это, потому что её дообучили на таких диалогах (SFT и RL — ступень 6 и урок 8.3.4).

Текстовый ReActНативный tool calling
Работает слюбой моделью, даже базовоймоделями, обученными вызывать функции
Разбор ответарегулярки, хрупкоготовый JSON от сервера
Параллельные вызовынеудобновстроены
Где «мысль»в тексте Thought:в content или в отдельном поле рассуждений (например, reasoning_content у DeepSeek)
Когда выбиратьучебные цели, слабые/базовые моделипочти всегда в продакшене

Каждый шаг отправляет модели всю историю. Пусть системный промпт с описаниями инструментов занимает SS токенов, а каждый шаг (мысль + вызов + наблюдение) добавляет dd токенов. На шаге kk вход равен S+d(k−1)S + d(k-1), а за nn шагов:

входных токенов=∑k=1n(S+d(k−1))=nS+d n(n−1)2\text{входных токенов} = \sum_{k=1}^{n} \bigl(S + d(k-1)\bigr) = nS + d\,\frac{n(n-1)}{2}

При S=1000S = 1000, d=300d = 300 и n=10n = 10 получаем 10 000+300⋅45=23 50010\,000 + 300 \cdot 45 = 23\,500 входных токенов, хотя «новой» информации в диалоге всего около 4 тысяч токенов. Рост квадратичный. Отсюда три практических следствия:

  1. Кэш префиксакэширование контекста (кэш промпта)EN context caching / prompt cachingПровайдер хранит KV-кэш общего префикса запросов и не пересчитывает его; такие «попадания» стоят в разы дешевле. У DeepSeek — «кэш контекста на жёстком диске». — история только дописывается, начало промпта не меняется, поэтому провайдеры (включая DeepSeek и Qwen) берут за кэшированные токены заметно меньше. Не меняйте начало промпта между шагами!
  2. Короткие наблюдения — не возвращайте модели 50 КБ HTML; обрежьте, отфильтруйте, суммаризируйте.
  3. Лимит шагов — каждый лишний виток дороже предыдущего.

Соберите ReAct-агента. Инструменты (безопасный калькулятор и «блокнот» lookup) уже готовы, а вместо LLM — «фейковая» модель с заранее записанными ответами. Так вы проверите логику цикла без API-ключей. Тесты подсовывают модель, которая ошибается: зовёт несуществующий инструмент, нарушает формат, выдумывает Observation и ходит по кругу.

Агентный цикл ReAct с «фейковой» LLM8 тестов
import ast
import operator
import re

# ── Инструменты (готовы, менять не нужно) ─────────────────────────────────────

_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
    ast.UAdd: operator.pos,
}


def _eval(node):
    if isinstance(node, ast.Constant) and type(node.value) in (int, float):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
        left, right = _eval(node.left), _eval(node.right)
        if isinstance(node.op, ast.Pow) and abs(right) > 100:
            raise ValueError("слишком большая степень")
        return _OPS[type(node.op)](left, right)
    if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("разрешены только числа, + - * / ** и скобки")


def calculator(expression):
    """Безопасный калькулятор: разбирает выражение через ast, без eval()."""
    value = _eval(ast.parse(expression, mode="eval").body)
    if isinstance(value, float) and value.is_integer():
        value = int(value)
    return str(round(value, 6) if isinstance(value, float) else value)


NOTES = {"цена_чая": "35", "число_гостей": "12"}


def lookup(key):
    """Ищет значение в «блокноте» пользователя."""
    if key not in NOTES:
        raise ValueError(f"нет записи «{key}»")
    return NOTES[key]


TOOLS = {"calculator": calculator, "lookup": lookup}
STOP = "Observation:"  # стоп-последовательность: наблюдения пишет среда, а не модель


# ── Ваша часть ────────────────────────────────────────────────────────────────

def parse_step(text):
    """Разбирает ответ модели в формате ReAct.

    1. Всё, начиная с первого "Observation:", отбросить: модель не имеет права
       сама писать наблюдения (это эмуляция стоп-последовательности).
    2. Найти строку "Action: имя[аргумент]".
       * имя == "Finish" (без учёта регистра) → {"type": "final", "answer": аргумент}
       * иначе → {"type": "action", "tool": имя, "input": аргумент}
       Аргумент — без пробелов по краям; внутри могут быть скобки: calculator[(2+3)*4].
    3. Если строки Action нет — raise ValueError("...").
    """
    # TODO: ваш код (пригодится re.search(r"Action:\s*(\w+)\[(.*)\]", ...))
    ...


def run_agent(question, llm, tools, max_steps=6):
    """Цикл «думай → действуй → наблюдай».

    transcript начинается с f"Question: {question}\\n". На каждом шаге:
      * output = llm(transcript) — модель видит ВСЮ историю;
      * в transcript дописываем только часть output до "Observation:" (+ "\\n");
      * Finish → вернуть {"answer": ответ, "steps": номер шага, "transcript": transcript};
      * иначе вызвать инструмент и дописать f"Observation: {результат}\\n".
    Ошибки не должны ронять агента — они становятся наблюдениями:
      * неверный формат ответа → "Observation: Ошибка формата: ..."
      * неизвестный инструмент → "Observation: Ошибка: неизвестный инструмент ..."
      * исключение в инструменте → "Observation: Ошибка: <текст исключения>"
    Если за max_steps ответа нет → {"answer": None, "steps": max_steps, "transcript": ...}.
    """
    # TODO: ваш код
    ...


# ── «Фейковая» LLM для кнопки «Запустить» ─────────────────────────────────────

def fake_llm(prompt):
    """Правила вместо нейросети: решает по числу наблюдений в промпте."""
    obs = re.findall(r"Observation: (.*)", prompt)
    if len(obs) == 0:
        return "Thought: Узнаю цену чая.\nAction: lookup[цена_чая]"
    if len(obs) == 1:
        return "Thought: Теперь число гостей.\nAction: lookup[число_гостей]"
    if len(obs) == 2:
        return f"Thought: Перемножу.\nAction: calculator[{obs[0]} * {obs[1]}]"
    return f"Thought: Ответ готов.\nAction: Finish[{obs[2]}]"


result = run_agent("Сколько стоит чай на всех гостей?", fake_llm, TOOLS)
if result is None:
    print("run_agent пока ничего не возвращает — допишите TODO")
else:
    print(result["transcript"])
    print("Ответ:", result["answer"], "| шагов:", result["steps"])

Когда цикл понятен, замените «фейковую» LLM на настоящую через нативный tool calling. Код ниже работает с любым OpenAI-совместимым сервером. Бесплатный вариант — локальная Ollama (ollama pull qwen3:8b, о локальном запуске — урок 7.2.2), нужен пакет pip install openai.

import json
from openai import OpenAI
# Ollama: сервер на localhost:11434. Для DeepSeek: base_url="https://api.deepseek.com",
# model="deepseek-flash" (на октябрь 2026; имена моделей сверяйте с документацией провайдера).
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen3:8b"
def calculator(expression: str) -> str:
# Почему не eval(expression)? eval выполнит ЛЮБОЙ Python-код, который пришлёт модель (или злоумышленник
# через модель), например удаление файлов. Поэтому разбираем строку модулем ast в дерево выражения:
# "2 + 3 * 4" → BinOp(2, Add, BinOp(3, Mult, 4)) — и сами вычисляем узлы, разрешая только арифметику.
import ast, operator as op
ops = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, # тип узла → функция-операция
ast.Div: op.truediv, ast.Pow: op.pow, ast.USub: op.neg}
def ev(n): # рекурсивно (урок 0.2.2) вычисляет узел дерева
if isinstance(n, ast.Constant) and type(n.value) in (int, float):
return n.value
if isinstance(n, ast.BinOp) and type(n.op) in ops:
a, b = ev(n.left), ev(n.right)
if isinstance(n.op, ast.Pow) and abs(b) > 100: # 9**9**9 повесит процесс
raise ValueError("слишком большая степень")
return ops[type(n.op)](a, b)
if isinstance(n, ast.UnaryOp) and type(n.op) in ops:
return ops[type(n.op)](ev(n.operand))
raise ValueError("только арифметика")
return str(ev(ast.parse(expression, mode="eval").body))
TOOLS = [{"type": "function", "function": {
"name": "calculator",
"description": "Точно вычисляет арифметическое выражение: + - * / ** и скобки.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string",
"description": "Например: (17 * 23) + 4"}},
"required": ["expression"]}}}]
REGISTRY = {"calculator": calculator}
def run(question: str, max_steps: int = 6) -> str:
messages = [{"role": "system", "content": "Для любых вычислений вызывай calculator."},
{"role": "user", "content": question}]
for step in range(max_steps):
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
msg = resp.choices[0].message
# model_dump превращает объект-ответ SDK в обычный словарь (exclude_none — без пустых полей)
messages.append(msg.model_dump(exclude_none=True)) # ответ модели — в историю
if not msg.tool_calls: # инструментов не просит → это ответ
return msg.content
for call in msg.tool_calls: # вызовов может быть несколько
args = call.function.arguments # строка JSON!
try:
result = REGISTRY[call.function.name](**json.loads(args))
except Exception as e:
result = f"Ошибка: {e}" # ошибка → наблюдение
print(f"[шаг {step + 1}] {call.function.name}({args}) -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "Не уложился в лимит шагов"
print(run("Сколько будет 17·23 и 2 в десятой степени?"))

Типичный вывод (текст ответа зависит от модели; хорошая модель запросит оба вызова параллельно в одном шаге):

[шаг 1] calculator({"expression": "17 * 23"}) -> 391
[шаг 1] calculator({"expression": "2 ** 10"}) -> 1024
17 · 23 = 391, а 2 в десятой степени = 1024.

Сравните с вашим ReAct-агентом: регулярок нет, ошибок формата почти нет, а структура цикла та же самая.

Проверь себя6 вопросов
1 Что в агентной системе на самом деле выполняет вызов run_tests()?
2 Агент с ReAct-промптом решил задачу за 1 шаг, но ответ неверный. В логе: Action: search[курс юаня] и сразу Observation: 1 юань = 50 рублей, а инструмент поиска ни разу не вызывался. Что пошло не так?
3 Системный промпт агента — 800 токенов, каждый шаг добавляет в историю 200 токенов. Сколько входных токенов суммарно получит модель за 5 шагов?
4 Какие утверждения о нативном tool calling в OpenAI-совместимом API верны?Можно выбрать несколько ответов
5 Инструмент calculator выбросил ZeroDivisionError. Как правильно поступить агентному циклу?
6 Как называется парадигма, где модель чередует Thought, Action и Observation? (одно слово)
  • Агент = LLM + инструменты + контекст + цикл. Модель только просит действие, исполняет его ваш код.
  • ReAct чередует мысли, действия и наблюдения в одном тексте: мысль планирует, действие приносит факты.
  • Три обязательные защиты: стоп-последовательность (никаких выдуманных Observation), ошибки как наблюдения, лимит шагов.
  • Нативный tool calling — тот же ReAct со спецтокенами: JSON Schema на входе, tool_calls на выходе, роль tool для результата.
  • Входные токены растут квадратично по числу шагов; спасают кэш префикса и короткие наблюдения.

Дальше: как правильно описывать инструменты (JSON Schema), что такое Model Context Protocol (MCP) и почему каждый инструмент — потенциальная дыра в безопасности.