Иллюзия очевидного выбора

В сообществе AI-разработки существуют два лагеря. Первый говорит: «Используй фреймворк — зачем изобретать велосипед?». Второй отвечает: «Фреймворки — это bloatware, пиши всё сам». Оба лагеря ошибаются, потому что задают не тот вопрос.

Правильный вопрос не «фреймворк или нет», а «какая инфраструктура нужна моему конкретному проекту прямо сейчас». Ответ зависит от трёх факторов: размера команды, сложности задачи и ожидаемого срока жизни проекта. Эти факторы определяют, насколько дорого вам обойдётся инфраструктурный код, который фреймворк даёт бесплатно, — и насколько дорого обойдётся абстракция, которую фреймворк навязывает.

ℹ️ Это архитектурное решение

Выбор «чистый Python vs фреймворк» влияет на отладку, онбординг команды, upgrade-риски и итоговую сложность кода. Принимайте его осознанно, а не по инерции. Этот урок — инструмент для осознанного выбора.

Чистый Python: что это значит на практике

«Чистый Python» — не значит «простой код». Это значит: вы взаимодействуете с API провайдера напрямую, через его официальный SDK, и сами реализуете весь цикл агента. Никаких промежуточных абстракций — только ваш код и библиотека Anthropic/OpenAI.

Вы уже видели фрагменты такого кода в предыдущих уроках. Давайте соберём полный, production-ориентированный агент — тот, который можно реально задеплоить, а не просто запустить в Jupyter.

Полный агент на чистом Python

python
"""
Полный агент на чистом Python.
Не требует ничего, кроме: pip install anthropic
"""
import json
import logging
from typing import Any, Callable

from anthropic import Anthropic

logger = logging.getLogger(__name__)
client = Anthropic()


def run_agent(
    task: str,
    tools: list[dict],
    tool_handlers: dict[str, Callable],
    model: str = "claude-opus-4-6",
    max_iterations: int = 20,
) -> str:
    """
    Запускает агента и возвращает финальный текстовый ответ.

    Args:
        task: начальный запрос пользователя
        tools: список JSON Schema для инструментов (формат Anthropic)
        tool_handlers: словарь {имя_инструмента: функция}
        model: идентификатор модели
        max_iterations: защита от бесконечных циклов
    """
    messages: list[dict] = [{"role": "user", "content": task}]

    for iteration in range(max_iterations):
        logger.debug("Итерация %d, сообщений: %d", iteration, len(messages))

        response = client.messages.create(
            model=model,
            max_tokens=4096,
            tools=tools,
            messages=messages,
        )

        # Сохраняем ответ ассистента в историю
        messages.append({
            "role": "assistant",
            "content": response.content,
        })

        # Агент завершил работу — ищем текстовый ответ
        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text
            return ""

        # stop_reason == "tool_use" — обрабатываем вызовы инструментов
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue

            handler = tool_handlers.get(block.name)
            if handler is None:
                result: Any = f"Инструмент {block.name!r} не найден"
                logger.warning("Неизвестный инструмент: %s", block.name)
            else:
                try:
                    result = handler(**block.input)
                    logger.debug("Инструмент %s вернул: %s", block.name, str(result)[:100])
                except Exception as exc:
                    result = f"Ошибка при вызове {block.name}: {exc}"
                    logger.error("Инструмент %s упал: %s", block.name, exc, exc_info=True)

            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": str(result),
            })

        messages.append({"role": "user", "content": tool_results})

    return f"Достигнут лимит итераций ({max_iterations})"


# ── Пример использования ──────────────────────────────

def search_web(query: str) -> str:
    """Заглушка: в реальности — httpx-запрос к поисковому API."""
    return f"Результаты поиска по запросу «{query}»: ..."

def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()

TOOLS = [
    {
        "name": "search_web",
        "description": "Поиск информации в интернете",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Поисковый запрос"}
            },
            "required": ["query"],
        },
    },
    {
        "name": "read_file",
        "description": "Читает содержимое файла",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Путь к файлу"}
            },
            "required": ["path"],
        },
    },
]

HANDLERS = {"search_web": search_web, "read_file": read_file}

if __name__ == "__main__":
    answer = run_agent("Найди информацию о LangGraph и напиши краткое резюме", TOOLS, HANDLERS)
    print(answer)

Это ~90 строк. Всё прозрачно: нет магии, нет скрытых слоёв. Любой Python-разработчик прочитает этот код и поймёт, что происходит. Стектрейс при ошибке укажет ровно на ту строку, которая сломалась.

Что придётся построить самому

Код выше работает. Но у него нет ничего, что нужно для production. Вот полный список инфраструктурных проблем, которые вы решаете сами:

1. Персистентность. Если процесс упадёт на 15-й итерации, вы начнёте с нуля. Чтобы возобновить работу с контрольной точки, нужно явно сохранять список messages в базу данных после каждого шага — и реализовывать логику восстановления. Это 50–100 строк плюс схема БД.

2. Retry и backoff. Anthropic API возвращает RateLimitError и overloaded_error. Корректная реализация exponential backoff с jitter и максимальным числом попыток — ещё ~40 строк с тестами.

3. Human-in-the-loop. Если агенту нужно спросить подтверждение перед удалением файла или отправкой письма, вам нужен механизм паузы. В простом скрипте — это input(). В веб-сервисе — очередь сообщений и callback URL. Нетривиально.

4. Наблюдаемость. При 20-шаговом агенте вы хотите видеть: что именно вызвал LLM, с какими параметрами, что вернул инструмент, сколько токенов потратили на каждом шаге. logger.debug() даёт поток текста, но не структурированный трейс.

5. Параллелизм. Параллельный запуск нескольких агентов или нескольких инструментов одновременно требует явного управления через asyncio — конкурентные версии всех функций.

6. Стриминг. Показывать токены по мере генерации (как в ChatGPT) требует переключения на client.messages.stream() и отдельной обработки событий.

7. Версионирование состояния. Откат агента на N шагов назад («undo»), ветвление от определённой точки, сравнение двух запусков — всё это требует сохранения snapshot-ов состояния.

⚠️ «90 строк» не равно production-ready

Агент выше — это скелет. Добавление всех семи пунктов превращает его в 500–800 строк кода с тестами. Это ваш личный мини-фреймворк. Именно столько кода пишет фреймворк за вас.

Что фреймворк берёт на себя

Фреймворк — это не магия. Это заранее написанные реализации тех же семи инфраструктурных задач, что перечислены выше. Вместо того чтобы писать персистентность, retry, streaming и observability самому, вы платите другую цену: изучаете ментальную модель фреймворка, принимаете его абстракции и зависите от его эволюции.

Диаграмма ниже показывает, как выглядят стеки чистого Python и LangGraph применительно к одному и тому же агенту. Синие блоки — ваш код. Красные — то, что в чистом Python придётся написать самому. Фиолетовые — то, что LangGraph предоставляет из коробки.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Чистый Python Фреймворк (LangGraph) Цикл агента while True: → stop_reason check Управление состоянием messages: list[dict] Обработка инструментов handlers[name](**block.input) △ Персистентность реализуете самостоятельно (~80 строк) △ Retry / backoff реализуете самостоятельно (~40 строк) △ Наблюдаемость / трейсинг реализуете самостоятельно (логи, метрики) anthropic SDK Claude API Nodes агента def agent_node(state) → dict StateGraph / TypedDict class State(TypedDict): ... ToolNode ToolNode(tools) ← автоматически ✓ Checkpointing SqliteSaver / RedisSaver ✓ RetryPolicy RetryPolicy(max_attempts=3) ✓ Трейсинг LangSmith / Arize Phoenix langchain / framework SDK Claude API Логика агента Инфра- структура SDK Ваш код Строите сами Код фреймворка Внешние SDK

Главный вывод из диаграммы: фреймворк не меняет логику агента (синие блоки одинаковы с обеих сторон). Он берёт на себя инфраструктурный слой, который иначе пришлось бы писать вам. Красные блоки превращаются в фиолетовые — за счёт абстракции и зависимости.

ℹ️ Фреймворк — это не волшебство

В исходниках LangGraph вы найдёте ровно те же while-циклы, словари и вызовы SDK. Фреймворк — это чужой production-код, который вы берёте в зависимость. Иногда это хорошо. Иногда — лишняя связанность.

Три измерения компромисса

Когда разработчики говорят «фреймворк лучше» или «лучше без фреймворка», они обычно думают только об одном измерении — скорости начальной разработки. На самом деле измерений три, и они влияют на решение по-разному.

Контроль и прозрачность

Контроль — это возможность точно понять и изменить то, что происходит внутри агента. Чистый Python даёт максимальный контроль: каждая строка кода — ваша. Когда что-то ломается, стектрейс указывает ровно на проблемное место.

Чистый Python — ошибка инструмента
Traceback (most recent call last):
  File "agent.py", line 58, in run_agent
    result = handler(**block.input)
  File "tools.py", line 23, in search_web
    response = httpx.get(url, timeout=5)
httpx.TimeoutException: Request to api.search.com timed out

→ tools.py:23, timeout при вызове search_web.
  Сразу понятно что делать.
LangGraph — та же ошибка
Traceback (most recent call last):
  File ".../langgraph/pregel/__init__.py", line 1847
  File ".../langgraph/utils/runnable.py", line 412
  File ".../langchain_core/runnables/base.py", line 1628
  File ".../langchain_core/tools/base.py", line 476
  File "tools.py", line 23, in search_web
    response = httpx.get(url, timeout=5)
httpx.TimeoutException: ...

→ Та же ошибка, но нужно «продраться»
  через 4 уровня фреймворка.

Это не значит, что с LangGraph невозможно отлаживаться. После погружения в internals фреймворка (1–2 недели) всё становится знакомым. Но эти 1–2 недели — реальная стоимость.

Нестандартная логика — ещё один аспект контроля. Если ваш агент должен делать не «реагируй → действуй», а что-то специфическое (например, откат к предыдущему шагу по условию или динамическое создание sub-агентов на лету), в чистом Python это — несколько строк кода. В LangGraph — борьба с абстракциями фреймворка, которые спроектированы под стандартные паттерны.

Скорость разработки

Это самое интуитивное измерение, и здесь чаще всего ошибаются. Фреймворк ускоряет разработку — но не сразу, и не всегда.

Накопленные часы разработки — условный пример
Задача
Чистый Python Фреймворк
Первый working прототип
30 мин / 2–3 ч
+ Персистентность
+2 дня / +1 ч
+ Human-in-the-loop
+3 дня / +30 мин
+ Трейсинг / observability
+2 дня / +1 ч

Точка пересечения: ~3 инфраструктурные задачи. До этой точки чистый Python быстрее. После — фреймворк.

Паттерн очевиден: чистый Python быстрее стартует, фреймворк быстрее масштабируется. Точка пересечения наступает примерно тогда, когда вам нужны 3+ инфраструктурные возможности из списка выше. До этой точки — написание агента с нуля займёт меньше времени, чем изучение фреймворка.

Поддержка и сопровождение

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

Зависимость от фреймворка. LangGraph за 2024 год выпустил несколько мажорных версий с breaking changes. Команды, которые строили на 0.1, тратили дни на миграцию при выходе 0.2. Чистый Python зависит только от Anthropic SDK, который значительно более стабилен.

Онбординг команды. Новый разработчик, который знает Python, прочитает код чистого агента за час. Код на LangGraph потребует понимания StateGraph, nodes, edges, checkpointing, conditional routing — то есть фреймворка как такового. Это от одного дня до недели, в зависимости от человека.

«Умирающий» фреймворк. Риск маловероятный, но реальный: если основной мейнтейнер фреймворка уходит или компания закрывает проект, вы либо фризите версию (растущий технический долг), либо мигрируете (стоимость может быть очень высокой). С чистым Python у вас нет такой зависимости.

Долгосрочный проект → думайте о зависимостях

Если проект планируется на 1+ год и в команде несколько разработчиков — фреймворк с его конвенциями, трейсингом и готовой инфраструктурой окупается. Если это внутренний инструмент или эксперимент — возможно, лишняя связанность.

Итоговое сравнение

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

Аспект Чистый Python Фреймворк
Первый рабочий прототип 30 минут 2–3 часа (кривая обучения)
Читаемость кода Чистый Python, понятен всем Нужно знать фреймворк
Отладка ошибок Стандартный стектрейс Через слои абстракций
Нестандартная логика цикла Полная свобода Нужно «обходить» фреймворк
Персистентность (resume after crash) Реализуете сами (~2–3 дня) Из коробки
Human-in-the-loop Нетривиально в production interrupt()
Параллелизм инструментов asyncio вручную Parallel nodes
Трейсинг / observability UI Только логи LangSmith и аналоги
Зависимость от внешнего кода Только anthropic SDK SDK + фреймворк + экосистема
Онбординг нового разработчика Зависит от качества кода Есть конвенции и документация
Риск breaking changes Низкий Средний (мажорные версии)
Performance overhead Минимальный Сериализация состояния, сеть

Сигналы для выбора

Используйте следующий чек-лист как отправную точку. Если большинство сигналов из одной колонки — это ваш ответ.

Выбирайте чистый Python
  • Нужен рабочий прототип за один день
  • Логика цикла нестандартна или неизвестна заранее
  • Команда 1–2 человека, все знают этот код
  • Нет требований к персистентности между сессиями
  • Не нужен human-in-the-loop
  • Performance важнее удобства: много запросов/сек
  • Нет long-running сессий (агент отрабатывает за <30 секунд)
  • Хотите максимально понимать, что происходит
Выбирайте фреймворк
  • Нужна персистентность: агент должен выжить после краша
  • Human-in-the-loop обязателен (approval flows)
  • Multi-agent система: 3+ агентов взаимодействуют
  • Команда 3+ человек, нужны конвенции
  • Нужен tracing UI для отладки в production
  • Долгосрочный проект (>6 месяцев)
  • Long-running задачи с возможностью паузы и ветвления
  • Стандартный ReAct или Plan-and-Execute паттерн
⚠️ Не выбирайте фреймворк «на вырост»

«Потом может понадобиться персистентность» — не аргумент для фреймворка сейчас. Прагматичный подход: начните с чистого Python, спроектируйте код для лёгкой миграции (см. следующий раздел), добавляйте фреймворк только когда реально ощутите боль.

Путь миграции: как перейти от чистого Python к фреймворку

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

Дизайн для лёгкой миграции

Четыре принципа, которые делают migration-friendly код:

1
Инкапсулируйте состояние в dataclass
Не разбрасывайте messages: list по всем функциям. Оберните в @dataclass class AgentState. При переходе на LangGraph этот dataclass превратится в TypedDict один к одному.
2
Инструменты — чистые функции
Функция-инструмент принимает явные аргументы и возвращает результат. Никакого скрытого состояния, никаких глобальных переменных. LangGraph-совместимый инструмент — это та же функция с декоратором @tool.
3
Выделите цикл агента в одну функцию
Весь while True-цикл — в функции run_agent(state) → AgentState. Это прямой аналог node в LangGraph. При миграции функция станет узлом графа.
4
Единый публичный интерфейс
Внешний код вызывает только run(task: str) → str. Что внутри — детали реализации. Фреймворк оборачивает ту же сигнатуру, не меняя вызывающий код.
python
"""
Migration-friendly агент на чистом Python.
Принципы: инкапсулированное состояние, чистые функции,
единый интерфейс — всё это легко переехать на фреймворк.
"""
from dataclasses import dataclass, field
from anthropic import Anthropic

client = Anthropic()


# 1. Состояние как dataclass → при миграции станет TypedDict
@dataclass
class AgentState:
    messages: list = field(default_factory=list)
    iterations: int = 0

    def add_user(self, content):
        self.messages.append({"role": "user", "content": content})

    def add_assistant(self, content):
        self.messages.append({"role": "assistant", "content": content})


# 2. Инструменты — чистые функции (→ при миграции: @tool)
def search_web(query: str) -> str:
    """Поиск в интернете."""
    return f"Результаты по запросу: {query}"

def read_file(path: str) -> str:
    """Читает файл."""
    with open(path) as f:
        return f.read()


TOOLS_SCHEMA = [...]    # JSON Schema для инструментов
HANDLERS = {"search_web": search_web, "read_file": read_file}


# 3. Логика агента как отдельная функция (→ при миграции: node)
def agent_step(state: AgentState) -> AgentState:
    """Один шаг цикла агента. Аналог node в LangGraph."""
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=4096,
        tools=TOOLS_SCHEMA,
        messages=state.messages,
    )
    state.add_assistant(response.content)
    state.iterations += 1

    if response.stop_reason == "end_turn":
        return state  # сигнал завершения

    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = HANDLERS.get(block.name, lambda **kw: "not found")(**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": str(result),
            })
    state.add_user(tool_results)
    return state


# 4. Единый публичный интерфейс
def run(task: str, max_iterations: int = 20) -> str:
    """Внешний контракт не меняется при миграции на фреймворк."""
    state = AgentState()
    state.add_user(task)

    for _ in range(max_iterations):
        prev_len = len(state.messages)
        state = agent_step(state)
        # Если ассистент не добавил tool_results — он закончил
        if state.messages[-1]["role"] == "assistant":
            for block in state.messages[-1]["content"]:
                if hasattr(block, "text"):
                    return block.text

    return "Лимит итераций достигнут"

Когда реально мигрировать

Не мигрируйте по расписанию или из принципа. Мигрируйте, когда встретите одну из этих ситуаций:

  • «Мы потратили неделю на реализацию персистентности, которая в LangGraph — одна строка»
  • «Агент падает на 12-м шаге, и мы не можем воспроизвести это без запуска с нуля»
  • «Заказчик требует кнопку "подтвердить" перед каждым удалением»
  • «Новый разработчик не может разобраться в агенте за день»
  • «Нам нужны два агента, которые передают задачи друг другу»

Если ни одна из этих ситуаций не наступила — у вас нет веских оснований для миграции.

Шпаргалка

Чистый Python — выбирайте когда:

  • Прототип / эксперимент / PoC — скорость важнее инфраструктуры
  • Нестандартная логика — фреймворк будет мешать
  • Команда 1–2 человека, нет требований к персистентности и HiTL
  • Performance-критично: нет места накладным расходам фреймворка

Фреймворк — выбирайте когда:

  • Нужна персистентность, HiTL, трейсинг — хотя бы одно из трёх
  • Команда 3+ человек, нужны конвенции и онбординг
  • Долгосрочный production-проект (>6 месяцев)
  • Multi-agent архитектура из стандартных паттернов

Компромиссы по трём осям:

  • Контроль: чистый Python > фреймворк (прозрачнее отладка, свобода нестандартной логики)
  • Скорость разработки: чистый Python быстрее до 3 инфраструктурных задач, фреймворк — после
  • Поддержка: чистый Python — меньше зависимостей, фреймворк — лучшие конвенции для команды

Стратегия по умолчанию: начните с чистого Python, спроектируйте код migration-friendly (dataclass состояние, чистые функции-инструменты, единый интерфейс), мигрируйте только когда реально почувствуете инфраструктурную боль.

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

Три задачи для закрепления, от простой к сложной:

Задача 1. Возьмите агента из предыдущих уроков (на чистом Python) и перепишите его структуру по четырём принципам migration-friendly дизайна из этого урока: dataclass-состояние, чистые функции-инструменты, отдельная функция шага, единый run(). Убедитесь, что поведение не изменилось.

Задача 2. Реализуйте простую персистентность для вашего чистого Python-агента: после каждого шага сохраняйте AgentState в JSON-файл, при старте — проверяйте наличие файла и восстанавливайте состояние. Засеките, сколько времени это заняло. Теперь оцените: сколько времени займёт добавить retry-логику и structured logging — и когда суммарная стоимость превысит порог «проще взять фреймворк».

Задача 3 (продвинутая). Напишите один и тот же агент дважды: на чистом Python и на LangGraph (или OpenAI Agents SDK). Агент должен уметь: искать в интернете, читать файлы, сохранять результат. Сравните количество строк кода, читаемость, время, затраченное на написание каждой версии, и сложность добавления персистентности. Запишите выводы.