Исходный код проекта
github.com/ivanshamaev/research-agent
Research-агент на чистом Python — полная реализация всех концепций модуля
Открыть →

Зачем без фреймворка?

LangChain, LlamaIndex, CrewAI — мощные инструменты, но они скрывают то, что происходит «под капотом». Когда агент ломается на продакшне, вы не знаете, где именно и почему. Research-агент написан без фреймворков намеренно: каждая строка кода — ваша, каждое решение — объяснено.

После этого урока вы поймёте, что любой фреймворк для агентов — это просто обёртка над теми же примитивами: цикл while, список сообщений, вызов API. Понять примитивы важнее, чем выучить конкретный фреймворк.

Что охватывает этот урок. Архитектура компонентов и их взаимодействие, главный ReAct-цикл (Orchestrator), поток данных за один запрос, настройка и запуск. Память (AgentState) и инструменты разобраны в отдельных уроках — Память и состояние и Инструменты и реестр.

Структура проекта

Проект разбит на слои. Каждый слой знает только о соседнем — это делает компоненты независимыми и тестируемыми по отдельности.

research-agent/ ├── main.py # Точка входа: argparse + asyncio.run │ ├── agent/ # Ядро агента │ ├── orchestrator.py # ★ ReAct-цикл — главный компонент │ ├── state.py # Память сессии: история сообщений │ └── llm_client.py # Общение с LLM API (Anthropic / OpenAI) │ ├── tools/ # Инструменты агента │ ├── registry.py # Каталог: схемы + dispatch │ ├── search.py # search_web — DuckDuckGo │ ├── fetch.py # fetch_pages — параллельный httpx │ ├── summarize.py # summarize_page — LLM-суммаризация │ └── report.py # write_report — финальный отчёт │ ├── config/ │ └── settings.py # Pydantic Settings (.env) │ ├── ui/ │ └── display.py # Rich: красивый вывод в терминал │ └── tests/ ├── test_tools.py # Юнит-тесты инструментов └── test_agent.py # Интеграционные тесты цикла

Архитектура: компоненты и их роли

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

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ТОЧКА ВХОДА main.py ОРКЕСТРАТОР orchestrator.py ReAct-цикл · лимит шагов · инварианты ПАМЯТЬ СЕССИИ state.py история · источники · отчёт · шаг LLM КЛИЕНТ llm_client.py Anthropic · OpenAI-совместимые РЕЕСТР ИНСТРУМЕНТОВ registry.py схемы для LLM · dispatch 🔍 search_web DuckDuckGo 📥 fetch_pages httpx + BeautifulSoup 📝 summarize LLM-вызов ✍️ write_report ★ завершает сессию CONFIG settings.py .env · MAX_STEPS run(query) read/write complete() LLMResponse dispatch()
orchestrator.py
Главный цикл. Не принимает решений — только управляет порядком: вызов LLM → dispatch инструмента → сохранение результата → повтор.
state.py
Единственный источник истины. Хранит историю сообщений, список источников, итоговый отчёт, счётчик шагов.
llm_client.py
Единственное место, где происходит запрос к LLM. Поддерживает Anthropic, OpenAI, DeepSeek, Ollama — через один интерфейс.
registry.py
Каталог инструментов: хранит JSON-схемы для LLM и диспетчеризует вызовы по имени к нужной Python-функции.

Настройка и запуск

Для запуска нужен Python 3.11+ и ключ любого LLM-провайдера. Рекомендуем начать с GateLLM — он доступен из России и поддерживает мощные open-source модели.

# 1. Клонируем и устанавливаем зависимости
git clone <repo-url>
cd research-agent
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# 2. Создаём .env из шаблона
cp .env.example .env

Откройте .env и укажите провайдер и ключ. Нужен только один:

# Вариант 1: GateLLM (OpenAI-совместимый, работает в России)
LLM_PROVIDER=gatellm
GATELLM_API_KEY=sk-...
DEFAULT_MODEL=qwen/qwen-2.5-72b-instruct

# Вариант 2: Anthropic Claude
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=claude-sonnet-4-6

# Вариант 3: Ollama (локально, бесплатно)
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434
DEFAULT_MODEL=llama3.2

# Параметры агента
MAX_STEPS=10
REQUEST_TIMEOUT=30
# 3. Проверяем установку
pytest tests/ -x -q
python3 -c "from tools.registry import ToolRegistry; print(ToolRegistry().list_tools())"
# ['search_web', 'fetch_pages', 'summarize_page', 'write_report']

# 4. Первый запуск
python main.py "Что такое RAG и как его построить?"

# Опции
python main.py "тема" --provider deepseek --max-steps 15 --save --verbose

Поток данных: что происходит за один запрос

Разберём по шагам, что происходит, когда вы запускаете: python main.py "RAG best practices"

1
CLI разбирает аргументы
main.py читает запрос из командной строки, создаёт LLMClient и Orchestrator, запускает asyncio.run(orchestrator.run("RAG best practices")).
2
Orchestrator инициализирует AgentState
Создаётся объект AgentState(query="RAG best practices") — пустая «память» сессии. В неё добавляется первое сообщение: Message(role="user", content="Research topic: RAG best practices").
3
Первый вызов LLM с инструментами
Orchestrator отправляет историю сообщений + список всех инструментов (JSON-схемы) + системный промпт в LLM. Модель анализирует задачу и возвращает stop_reason: "tool_use" — решение вызвать search_web.
4
Dispatch инструмента через ToolRegistry
LLM возвращает блок tool_use с именем и аргументами. Orchestrator передаёт это в ToolRegistry.dispatch("search_web", query=...), который находит нужную Python-функцию и вызывает её.
5
Результат добавляется в историю
Ответ инструмента (список URL) добавляется в AgentState как сообщение role="user" с блоком tool_result. Это требование Anthropic API — результаты инструментов передаются от имени пользователя.
6
Цикл повторяется (шаги 3–5)
LLM снова видит всю историю (с результатами поиска) и решает: «загружу несколько страниц». Вызывает fetch_pages. Получает текст страниц. Снова вызов LLM... Так до тех пор, пока LLM не вызовет write_report.
7
write_report завершает сессию
Orchestrator видит, что вызван терминальный инструмент write_report. Сохраняет готовый отчёт в state.report и немедленно выходит из цикла. main.py выводит отчёт через Rich.

Оркестратор: ReAct-цикл изнутри

Orchestrator — самый важный файл проекта. Давайте разберём его код по частям, начиная с системного промпта, который программирует поведение LLM.

Системный промпт — инструкции для LLM

LLM без инструкций может ответить на вопрос из памяти, не вызывая инструменты. Системный промпт задаёт жёсткие правила поведения.

SYSTEM_PROMPT = """\
You are a research assistant. Your job is to research a topic and produce a report.

RULES — follow them strictly:
1. You MUST use tools. Do NOT answer from memory alone.
2. You MUST end every session by calling the write_report tool.
3. Never stop with a plain text response — always call a tool.

WORKFLOW:
Step 1 — Call search_web with a specific query to find sources.
Step 2 — Call fetch_pages on the most promising URLs (pick 2-4).
Step 3 — If any page is very long, call summarize_page on it.
Step 4 — Call write_report with a structured Markdown report and all sources.
"""
Почему такой строгий промпт? Без правил LLM может: ответить из памяти не проверив источники, написать отчёт как обычный текст не вызвав write_report, зациклиться на поиске. Чёткие правила в системном промпте — это программирование поведения модели.

Главный цикл

async def run(self, query: str) -> AgentState:
    state = AgentState(query=query)
    state.append_message(Message(role="user", content=f"Research topic: {query}"))
    tools = self.registry.get_schemas()   # JSON-схемы для LLM

    while state.step < self.max_steps:   # Инвариант 1: лимит шагов
        state.increment_step()
        log.info("react_step", step=state.step)

        # --- Шаг 1: запрос к LLM ---
        response = await self.llm.complete(
            messages=state.to_api_messages(),  # вся история
            tools=tools,                        # доступные инструменты
            system=SYSTEM_PROMPT,
        )

        # --- Шаг 2: сохраняем ответ LLM в историю ---
        state.append_message(Message(role="assistant", content=response["content"]))

        stop_reason = response.get("stop_reason", "")

        # --- Случай А: LLM не вызвала инструмент ---
        if stop_reason == "end_turn":
            # ... обработка (см. ниже)
            break

        # --- Случай Б: LLM вызывает инструмент ---
        if stop_reason == "tool_use":
            tool_results = []
            done = False

            for block in response["content"]:
                if block.get("type") != "tool_use":
                    continue

                result_str = await self._dispatch_tool(
                    state, block["name"], block["input"], block["id"]
                )

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block["id"],
                    "content": result_str,
                })

                if block["name"] == "write_report":
                    done = True    # Инвариант 5: write_report завершает
                    break

            # Все результаты — одним сообщением user (требование API)
            if tool_results:
                state.append_message(Message(role="user", content=tool_results))

            if done:
                break

    return state

Обработка end_turn: LLM не вызвала инструмент

Иногда LLM возвращает текст вместо вызова инструмента. Оркестратор обрабатывает три сценария:

if stop_reason == "end_turn":
    text_parts = [b["text"] for b in response["content"] if b.get("type") == "text"]

    if text_parts and not state.report:
        # Сценарий 1: LLM написала отчёт как текст, не через write_report
        # Сохраняем как fallback
        state.report = f"# Research: {query}\n\n" + "\n\n".join(text_parts)
        log.warning("fallback_report_from_text")
        break

    if not state.report and state.step < self.max_steps:
        # Сценарий 2: пустой ответ — напоминаем LLM, что нужно написать отчёт
        log.warning("end_turn_no_content_retry")
        state.append_message(Message(
            role="user",
            content="You stopped without calling write_report. "
                    "You MUST call write_report now."
        ))
        continue    # ← ещё одна итерация

    # Сценарий 3: отчёт уже есть или шаги закончились
    break
Почему retry через continue, а не рекурсию? Рекурсия при большом числе шагов могла бы переполнить стек. Цикл while с continue гарантирует постоянное потребление стека O(1) и соблюдение лимита шагов max_steps.

Безопасный dispatch инструмента

async def _dispatch_tool(self, state, tool_name, tool_input, tool_use_id) -> str:
    log.info("tool_dispatched", tool=tool_name, step=state.step)  # Инвариант 2

    try:
        result = await self.registry.dispatch(tool_name, **tool_input)

        if tool_name == "write_report":
            state.report = result.content          # сохраняем отчёт
            for src in result.sources:
                state.add_source(Source(...))
            return f"Report written: {result.title}"

        if tool_name == "search_web":
            for r in result:
                state.add_source(Source(url=r["url"], title=r.get("title", "")))

        return json.dumps(result, ensure_ascii=False, default=str)

    except ToolError as e:
        # Инвариант 3: ToolError НИКОГДА не ломает цикл
        log.warning("tool_error", tool=tool_name, error=str(e))
        return f"Error executing {tool_name}: {e}"  # LLM увидит ошибку и адаптируется

5 инвариантов цикла

Инвариант — утверждение, которое остаётся истинным при любых условиях. Это контракт между разработчиком и системой. В комментарии класса Orchestrator прописаны 5 инвариантов:

1
Лимит шагов всегда соблюдается. Цикл завершается после max_steps итераций, даже если write_report не был вызван. Защита от бесконечного зацикливания и бесконечных трат на API.
2
Каждый dispatch логируется. Все вызовы инструментов записываются на уровне INFO. Это позволяет полностью восстановить историю работы агента из логов.
3
ToolError никогда не ломает цикл. Ошибки инструментов превращаются в строку и возвращаются LLM как результат. Модель видит ошибку и может попробовать другой подход — цикл продолжается.
4
Состояние только растёт. Сообщения никогда не удаляются и не изменяются. LLM всегда видит полную историю, что исключает «забывание» уже сделанного.
5
write_report всегда завершает сессию. При вызове этого инструмента цикл немедленно выходит через break. Невозможна ситуация, когда агент продолжает работу после написания отчёта.

Диаграмма состояний оркестратора

Полная картина всех переходов между состояниями цикла:

INIT ──→ THINKING шаг++, вызов LLM THINKING ──→ TOOL_CALL stop_reason = "tool_use" THINKING ──→ TEXT_FALLBACK stop_reason = "end_turn" + есть текст THINKING ──→ RETRY stop_reason = "end_turn" + пусто + шаги ≤ max THINKING ──→ DONE step ≥ max_steps TOOL_CALL ──→ DISPATCH для каждого tool_use блока DISPATCH ──→ TOOL_CALL следующий блок DISPATCH ──→ WRITE_REPORT tool = "write_report" TOOL_CALL ──→ THINKING все блоки обработаны WRITE_REPORT ──→ DONE state.report = content TEXT_FALLBACK ──→ DONE state.report = текст LLM RETRY ──→ THINKING добавляем напоминание → continue DONE ──→ return AgentState

LLM-клиент: абстракция над провайдерами

llm_client.py — единственное место, где происходит запрос к API. Оркестратор не знает, с каким провайдером работает — он вызывает client.complete(messages, tools) и получает единый формат ответа.

class LLMClientProtocol(Protocol):
    """Интерфейс, которому должны соответствовать все клиенты."""
    async def complete(
        self,
        messages: list[dict],
        tools: list[dict],
        system: str = "",
    ) -> LLMResponse: ...

# Все провайдеры возвращают единый формат:
{
    "content": [
        {"type": "text", "text": "Сначала поищу источники."},
        {
            "type": "tool_use",
            "id": "tu_001",
            "name": "search_web",
            "input": {"query": "RAG best practices 2024"}
        }
    ],
    "stop_reason": "tool_use",  # или "end_turn"
    "usage": {"input_tokens": 150, "output_tokens": 30}
}

Поддерживаемые провайдеры: Anthropic (нативный SDK), OpenAI, DeepSeek, Qwen, OpenRouter, GateLLM, Minimax, Ollama, Custom (любой OpenAI-совместимый). Для смены провайдера достаточно изменить одну переменную в .env.

Шпаргалка

КомпонентФайлРоль
Orchestratoragent/orchestrator.pyReAct-цикл, 5 инвариантов, dispatch через ToolRegistry
AgentStateagent/state.pyAppend-only история сообщений + источники + отчёт
LLMClientagent/llm_client.pyЕдинственная точка работы с API; поддержка 9 провайдеров
ToolRegistrytools/registry.pyJSON-схемы для LLM + dispatch по имени
search_webtools/search.pyDuckDuckGo через asyncio.to_thread
fetch_pagestools/fetch.pyПараллельный httpx + BeautifulSoup
summarize_pagetools/summarize.pyLLM-суммаризация длинного текста
write_reporttools/report.py★ Терминальный — завершает сессию
Settingsconfig/settings.pyPydantic Settings: .env → типизированные настройки
stop_reasonЧто означаетДействие оркестратора
"tool_use"LLM хочет вызвать инструментdispatch → добавить tool_result → следующий шаг
"end_turn"LLM завершила ответ текстомfallback в отчёт или retry с напоминанием
"max_tokens"Превышен лимит токеновlog warning, break

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

  1. Запустите агента с любым запросом и понаблюдайте за логами. Убедитесь, что видите: номер шага, имя вызванного инструмента, количество токенов.
  2. Измените MAX_STEPS=2 в .env и повторите запрос. Что происходит, когда шаги заканчиваются до вызова write_report? Где в коде это обрабатывается?
  3. Найдите место в orchestrator.py, где нарушение одного из 5 инвариантов привело бы к зависанию или некорректному отчёту. Напишите, какого инварианта не хватает в такой ситуации.
Следующий шаг. Теперь, когда понятна общая архитектура, разберём детали: как работает память агента (AgentState) и почему важно управлять историей сообщений вручную.