Агентный цикл и ReAct
Что превращает LLM в агента, как устроен цикл «думай → действуй → наблюдай», откуда взялся ReAct и как работает tool calling в OpenAI-совместимых API.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Сама по себе большая языковая модельбольшая языковая модель (БЯМ, 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) | обычный код вокруг модели | разбор ответа, вызов функций, лимиты |
Самое важное: модель ничего не исполняет. Она только генерирует текст, в котором «просит» действие. Исполняет его ваш код. Поэтому агент — это не новая архитектура нейросети, а способ её использовать.
Агент или workflow?
Заголовок раздела «Агент или workflow?»В статье 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" # наблюдайЭто скелет. Дальше разберём, что в нём может сломаться и как это чинить.
ReAct: рассуждать и действовать вперемешку
Заголовок раздела «ReAct: рассуждать и действовать вперемешку»До 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: 35Thought: Теперь число гостей.Action: lookup[число_гостей]Observation: 12Thought: Осталось перемножить. Сделаю это калькулятором, а не в уме.Action: calculator[35 * 12]Observation: 420Thought: Ответ готов.Action: Finish[420]Формату модель учили не дообучением, а несколькими примерами в промпте (few-shotfew-shot (примеры в промпте)EN few-shot promptingНесколько пар «вход → выход» прямо в промпте; модель подхватывает формат без дообучения (in-context learning). Zero-shot — без примеров.). На интерактивных средах ALFWorld (текстовый «домашний робот») и WebShop (покупки в интернет-магазине) хватало одного-двух примеров, и ReAct обошёл методы на основе имитации (модель учится повторять записанные действия людей) и RL на 34 и 10 процентных пунктов успешности соответственно. На вопросах HotpotQA (ответ требует собрать несколько фактов из разных статей Википедии) ReAct реже галлюцинировал, чем чистый CoT, хотя по точности сам по себе немного ему уступал. Лучший результат дала комбинация обоих подходов.
Промпт ReAct
Заголовок раздела «Промпт ReAct»Модель знает формат, потому что мы описали его в системном промптесистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне.:
Отвечай на вопрос, используя инструменты:- calculator[выражение] — точно вычисляет арифметику- lookup[ключ] — читает значение из блокнота пользователя- Finish[ответ] — завершает работу и возвращает ответ
Формат строго такой:Thought: твои рассужденияAction: инструмент[аргумент]После Action остановись и жди Observation.Обратите внимание на последнюю строку. Она связана с самой частой ошибкой самописных агентов.
Ловушка 1: модель сама пишет Observation
Заголовок раздела «Ловушка 1: модель сама пишет Observation»Модель обучена продолжать текст. Увидев шаблон Action → Observation, она с удовольствием напишет
Observation: 5 за инструмент — и решит задачу на выдуманных данных. Это галлюцинация, оформленная как факт.
Решение — стоп-последовательность: в API передают stop=["Observation:"], и генерация обрывается до того,
как модель начнёт сочинять результат. Наблюдение дописывает среда. Если модель всё же что-то написала
после Observation: (например, API не поддерживает stop), это нужно отрезать в коде.
Ловушка 2: ошибки роняют агента
Заголовок раздела «Ловушка 2: ошибки роняют агента»Инструмент выбросил исключение, модель позвала несуществующую функцию, ответ не в формате — наивный цикл падает. Правильно — превратить ошибку в наблюдение:
Action: calculator[1 / 0]Observation: Ошибка: division by zeroThought: Делить на ноль нельзя, перечитаю условие...Сильные модели часто исправляются сами: меняют аргументы или выбирают другой инструмент. Это дешёвый способ сделать агента устойчивым.
Ловушка 3: бесконечный цикл
Заголовок раздела «Ловушка 3: бесконечный цикл»Модель может ходить по кругу: искать одно и то же, вызывать инструмент с теми же аргументами.
Минимальная защита — лимит шагов max_steps (и/или бюджет токенов и времени). Когда лимит исчерпан,
агент честно возвращает «не справился», а не висит.
Tool calling: ReAct, встроенный в модель
Заголовок раздела «Tool calling: ReAct, встроенный в модель»Разбирать 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) |
| Когда выбирать | учебные цели, слабые/базовые модели | почти всегда в продакшене |
Сколько это стоит
Заголовок раздела «Сколько это стоит»Каждый шаг отправляет модели всю историю. Пусть системный промпт с описаниями инструментов занимает токенов, а каждый шаг (мысль + вызов + наблюдение) добавляет токенов. На шаге вход равен , а за шагов:
При , и получаем входных токенов, хотя «новой» информации в диалоге всего около 4 тысяч токенов. Рост квадратичный. Отсюда три практических следствия:
- Кэш префиксакэширование контекста (кэш промпта)EN context caching / prompt cachingПровайдер хранит KV-кэш общего префикса запросов и не пересчитывает его; такие «попадания» стоят в разы дешевле. У DeepSeek — «кэш контекста на жёстком диске». — история только дописывается, начало промпта не меняется, поэтому провайдеры (включая DeepSeek и Qwen) берут за кэшированные токены заметно меньше. Не меняйте начало промпта между шагами!
- Короткие наблюдения — не возвращайте модели 50 КБ HTML; обрежьте, отфильтруйте, суммаризируйте.
- Лимит шагов — каждый лишний виток дороже предыдущего.
Практика
Заголовок раздела «Практика»Соберите ReAct-агента. Инструменты (безопасный калькулятор и «блокнот» lookup) уже готовы, а вместо LLM —
«фейковая» модель с заранее записанными ответами. Так вы проверите логику цикла без API-ключей.
Тесты подсовывают модель, которая ошибается: зовёт несуществующий инструмент, нарушает формат,
выдумывает Observation и ходит по кругу.
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 jsonfrom 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"}) -> 102417 · 23 = 391, а 2 в десятой степени = 1024.Сравните с вашим ReAct-агентом: регулярок нет, ошибок формата почти нет, а структура цикла та же самая.
Проверь себя
Заголовок раздела «Проверь себя»- Агент = LLM + инструменты + контекст + цикл. Модель только просит действие, исполняет его ваш код.
- ReAct чередует мысли, действия и наблюдения в одном тексте: мысль планирует, действие приносит факты.
- Три обязательные защиты: стоп-последовательность (никаких выдуманных
Observation), ошибки как наблюдения, лимит шагов. - Нативный tool calling — тот же ReAct со спецтокенами: JSON Schema на входе,
tool_callsна выходе, рольtoolдля результата. - Входные токены растут квадратично по числу шагов; спасают кэш префикса и короткие наблюдения.
Дальше: как правильно описывать инструменты (JSON Schema), что такое Model Context Protocol (MCP) и почему каждый инструмент — потенциальная дыра в безопасности.