Боль монолитного агента

Возьмём типичный «быстрый» агент для обработки заказов интернет-магазина:

❌ Монолитный агент
class OrderAgent:
    def process(self, order_id: str):
        # бизнес-логика прямо в агенте
        db = psycopg2.connect(DB_URL)
        order = db.execute(
            "SELECT * FROM orders WHERE id = %s",
            (order_id,)
        ).fetchone()

        # LLM-вызов рядом с бизнес-правилами
        if order["total"] > 10000:
            discount = 0.15  # VIP-скидка
        else:
            discount = 0.0

        client = anthropic.Anthropic()
        response = client.messages.create(
            model="claude-opus-4-6",
            messages=[{"role": "user",
                "content": f"Order: {order}"}]
        )
        # парсинг + запись в ту же БД
        ...
✅ Слоистый агент
class OrderAgent:
    def __init__(
        self,
        order_repo: OrderRepository,   # порт
        llm: LLMPort,                  # порт
        pricer: PricingService,        # домен
    ):
        self.order_repo = order_repo
        self.llm = llm
        self.pricer = pricer

    async def process(self, order_id: str):
        # только оркестрация
        order = await self.order_repo.get(order_id)
        price = self.pricer.calculate(order)
        response = await self.llm.complete(
            prompt=build_prompt(order, price)
        )
        return parse_response(response)

Монолитный вариант работает — но стоит потребоваться замене PostgreSQL на MongoDB или переходу с Anthropic на OpenAI, придётся переписывать агента целиком. Бизнес-правило «скидка 15% при заказе >10000» закопано между SQL-запросом и LLM-вызовом — его невозможно протестировать изолированно.

💡 Три индикатора монолитного агента: прямой импорт anthropic / openai в класс с бизнес-логикой; SQL-запросы или HTTP-вызовы внутри «умных» методов; невозможность запустить тесты без реального LLM или базы данных.

Теория: Clean Architecture для агентов

Clean Architecture (Роберт Мартин, 2012) и Hexagonal Architecture / Ports & Adapters (Алистер Кокбёрн, 2005) — это два родственных паттерна, которые решают одну проблему: бизнес-логика не должна зависеть от деталей реализации (базы данных, сети, UI, LLM-провайдера).

В контексте AI-агентов это означает четыре слоя с чёткими правилами зависимостей:

Domain
  • Сущности (Entity)
  • Value Objects
  • Бизнес-правила
  • Domain Services
  • Исключения домена
Application
  • Agent (оркестратор)
  • Use Cases
  • Порты (интерфейсы)
  • DTO / запросы
  • Логика ReAct-цикла
Infrastructure
  • LLM-адаптеры
  • Tool-реализации
  • Репозитории (БД)
  • HTTP-клиенты
  • Кэш, очереди
Interface
  • CLI / API (FastAPI)
  • Telegram / Slack бот
  • Вебхуки
  • Конвертация входных данных

Правило зависимостей

Главный принцип — зависимости текут только внутрь. Внешние слои знают о внутренних; внутренние — нет:

✔ можно: Infrastructure → Application → Domain
✔ можно: Interface → Application → Domain
✖ нельзя: Domain → Infrastructure (знать о БД)
✖ нельзя: Application → anthropic SDK (знать о конкретном LLM)
✖ нельзя: Domain → Application (знать об агенте)

Как тогда Application слой использует базу данных, если не может от неё зависеть? Через инверсию зависимостей: Application определяет интерфейс (порт), а Infrastructure предоставляет реализацию (адаптер).

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
INTERFACE LAYER INFRASTRUCTURE LAYER APPLICATION LAYER DOMAIN LAYER Order id · items · total PricingService calculate_discount() OrderValidator validate() → bool OrderAgent оркестрация ReAct-цикла process(order_id) → get order → validate → price → llm «port» OrderRepository «port» LLMPort использует AnthropicAdapter implements LLMPort OpenAIAdapter implements LLMPort PostgresOrderRepo implements OrderRepository implements impl. FastAPI / CLI вызывает OrderAgent Зависимости текут внутрь → Domain ничего не знает о LLM, БД и агенте Interface Infra App Domain

Ports & Adapters: как это работает

Порт — это абстрактный интерфейс (Protocol/ABC в Python), который Application слой определяет и использует. Адаптер — конкретная реализация порта в Infrastructure слое.

Концепция
Что это
Пример
Port (порт)
Интерфейс в Application слое. Описывает что нужно агенту, не как это делается
LLMPort, OrderRepository, SearchPort
Adapter (адаптер)
Реализация порта в Infrastructure. Знает о конкретном API, БД или сервисе
AnthropicAdapter, PostgresRepo, BraveSearchAdapter
Driving port
Порт, через который внешний мир вызывает агента (входящий)
AgentPort ← вызывает FastAPI
Driven port
Порт, через который агент обращается к инфраструктуре (исходящий)
LLMPort → вызывает Anthropic API
DI (инъекция зависимостей)
Передача конкретных адаптеров в агент через конструктор — не хардкод
OrderAgent(llm=AnthropicAdapter())

Domain Layer: сущности без AI

Domain слой — самое ценное в приложении. Он содержит бизнес-правила, которые не меняются при смене LLM, базы данных или фреймворка. Ключевое ограничение: ноль импортов из инфраструктуры. Только стандартная библиотека Python и Pydantic/dataclasses.

# domain/entities.py
from dataclasses import dataclass, field
from decimal import Decimal
from typing import List
from enum import Enum


class OrderStatus(Enum):
    PENDING   = "pending"
    CONFIRMED = "confirmed"
    CANCELLED = "cancelled"


@dataclass
class OrderItem:
    product_id: str
    name: str
    quantity: int
    unit_price: Decimal

    @property
    def subtotal(self) -> Decimal:
        return self.unit_price * self.quantity


@dataclass
class Order:
    id: str
    customer_id: str
    items: List[OrderItem]
    status: OrderStatus = OrderStatus.PENDING

    @property
    def total(self) -> Decimal:
        return sum(item.subtotal for item in self.items)

    def add_item(self, item: OrderItem) -> None:
        if self.status != OrderStatus.PENDING:
            raise ValueError(f"Cannot add items to {self.status} order")
        self.items.append(item)

    def cancel(self) -> None:
        if self.status == OrderStatus.CONFIRMED:
            raise ValueError("Cannot cancel confirmed order")
        self.status = OrderStatus.CANCELLED
# domain/services.py
# Чистая бизнес-логика — ни одного внешнего импорта
from decimal import Decimal
from .entities import Order


class PricingService:
    """Правила расчёта скидок — не зависит от LLM или БД."""

    VIP_THRESHOLD = Decimal("10000")
    VIP_DISCOUNT  = Decimal("0.15")
    BULK_THRESHOLD = 5
    BULK_DISCOUNT  = Decimal("0.05")

    def calculate_discount(self, order: Order) -> Decimal:
        """Возвращает итоговую скидку (0.0–1.0)."""
        discount = Decimal("0")

        if order.total >= self.VIP_THRESHOLD:
            discount = max(discount, self.VIP_DISCOUNT)

        total_items = sum(item.quantity for item in order.items)
        if total_items >= self.BULK_THRESHOLD:
            discount = max(discount, self.BULK_DISCOUNT)

        return discount

    def final_price(self, order: Order) -> Decimal:
        discount = self.calculate_discount(order)
        return order.total * (1 - discount)


class OrderValidator:
    """Валидация бизнес-правил — без LLM и без БД."""

    def validate(self, order: Order) -> list[str]:
        """Возвращает список ошибок (пустой = валидный заказ)."""
        errors = []
        if not order.items:
            errors.append("Order must have at least one item")
        for item in order.items:
            if item.quantity <= 0:
                errors.append(f"Item {item.product_id}: quantity must be > 0")
            if item.unit_price <= 0:
                errors.append(f"Item {item.product_id}: price must be > 0")
        return errors
Тест за миллисекунды: Весь Domain слой тестируется моментально — никаких mock'ов, никакого LLM, никакой базы данных. Просто PricingService().calculate_discount(order). Если бизнес-логика живёт здесь, её можно покрыть сотнями тестов за секунду.

Application Layer: порты и агент

Application слой определяет что нужно агенту через порты и как он это использует через оркестрацию. Никаких конкретных SDK — только интерфейсы.

# application/ports.py
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
from ..domain.entities import Order


# --- Driven ports (исходящие: агент вызывает инфраструктуру) ---

class OrderRepository(Protocol):
    """Порт для доступа к заказам. Не знает о PostgreSQL или MongoDB."""

    async def get(self, order_id: str) -> Order: ...
    async def save(self, order: Order) -> None: ...
    async def list_pending(self) -> list[Order]: ...


@runtime_checkable
class LLMPort(Protocol):
    """Порт для общения с языковой моделью."""

    async def complete(
        self,
        prompt: str,
        system: str = "",
        max_tokens: int = 1024,
    ) -> str: ...


class SearchPort(Protocol):
    """Порт для поиска информации о товарах."""

    async def search(self, query: str, limit: int = 5) -> list[dict]: ...


# --- Driving port (входящий: внешний мир вызывает агента) ---

class AgentPort(ABC):
    """Интерфейс агента для Interface слоя."""

    @abstractmethod
    async def process_order(self, order_id: str) -> dict: ...

    @abstractmethod
    async def get_recommendation(self, order: Order) -> str: ...
# application/agent.py
import json
from ..domain.entities import Order
from ..domain.services import PricingService, OrderValidator
from .ports import OrderRepository, LLMPort, SearchPort, AgentPort


def _build_prompt(order: Order, final_price, context: list[dict]) -> str:
    context_text = "\n".join(
        f"- {r['title']}: {r['snippet']}" for r in context
    ) if context else "No additional context found."

    return (
        f"Order #{order.id}: {len(order.items)} items, "
        f"total {order.total:.2f}, final price {final_price:.2f}.\n\n"
        f"Additional context:\n{context_text}\n\n"
        f"Write a brief order confirmation message for the customer."
    )


class OrderAgent(AgentPort):
    """
    Агент = чистая оркестрация.
    Не содержит бизнес-логику (она в Domain).
    Не знает о конкретных технологиях (они за портами).
    """

    SYSTEM_PROMPT = (
        "You are a helpful order management assistant. "
        "Be concise and professional."
    )

    def __init__(
        self,
        order_repo: OrderRepository,
        llm: LLMPort,
        search: SearchPort,
        pricer: PricingService | None = None,
        validator: OrderValidator | None = None,
    ):
        self.order_repo = order_repo
        self.llm = llm
        self.search = search
        self.pricer = pricer or PricingService()
        self.validator = validator or OrderValidator()

    async def process_order(self, order_id: str) -> dict:
        # 1. Загрузить заказ
        order = await self.order_repo.get(order_id)

        # 2. Валидировать (Domain)
        errors = self.validator.validate(order)
        if errors:
            return {"status": "error", "errors": errors}

        # 3. Рассчитать цену (Domain)
        discount = self.pricer.calculate_discount(order)
        final_price = self.pricer.final_price(order)

        # 4. Обогатить контекстом (Infrastructure через порт)
        context = await self.search.search(
            query=f"order {order_id} product info",
            limit=3,
        )

        # 5. Сгенерировать сообщение (LLM через порт)
        prompt = _build_prompt(order, final_price, context)
        message = await self.llm.complete(
            prompt=prompt,
            system=self.SYSTEM_PROMPT,
        )

        # 6. Сохранить результат
        await self.order_repo.save(order)

        return {
            "status": "ok",
            "order_id": order_id,
            "total": str(order.total),
            "discount": str(discount),
            "final_price": str(final_price),
            "message": message,
        }

    async def get_recommendation(self, order: Order) -> str:
        final_price = self.pricer.final_price(order)
        prompt = f"Suggest 2-3 complementary products for order with total {final_price:.2f}."
        return await self.llm.complete(prompt=prompt)

Infrastructure Layer: адаптеры

Адаптеры — это единственное место, где живут конкретные SDK, SQL-запросы и HTTP-клиенты. Если нужно сменить провайдера — меняем один адаптер, остальной код не трогаем.

# infrastructure/llm_adapters.py
import anthropic
import openai
from ..application.ports import LLMPort


class AnthropicAdapter:
    """Адаптер для Anthropic Claude. Реализует LLMPort."""

    def __init__(self, model: str = "claude-haiku-4-5-20251001"):
        self._client = anthropic.AsyncAnthropic()
        self._model = model

    async def complete(
        self,
        prompt: str,
        system: str = "",
        max_tokens: int = 1024,
    ) -> str:
        kwargs = {"model": self._model, "max_tokens": max_tokens,
                  "messages": [{"role": "user", "content": prompt}]}
        if system:
            kwargs["system"] = system
        response = await self._client.messages.create(**kwargs)
        return response.content[0].text


class OpenAIAdapter:
    """Адаптер для OpenAI. Тот же интерфейс — легко заменить."""

    def __init__(self, model: str = "gpt-4o-mini"):
        self._client = openai.AsyncOpenAI()
        self._model = model

    async def complete(
        self,
        prompt: str,
        system: str = "",
        max_tokens: int = 1024,
    ) -> str:
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        response = await self._client.chat.completions.create(
            model=self._model,
            messages=messages,
            max_tokens=max_tokens,
        )
        return response.choices[0].message.content
# infrastructure/repositories.py
import asyncpg
from decimal import Decimal
from ..domain.entities import Order, OrderItem, OrderStatus
from ..application.ports import OrderRepository


class PostgresOrderRepository:
    """Реализует OrderRepository через PostgreSQL."""

    def __init__(self, dsn: str):
        self._dsn = dsn
        self._pool: asyncpg.Pool | None = None

    async def _get_pool(self) -> asyncpg.Pool:
        if not self._pool:
            self._pool = await asyncpg.create_pool(self._dsn)
        return self._pool

    async def get(self, order_id: str) -> Order:
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            row = await conn.fetchrow(
                "SELECT * FROM orders WHERE id = $1", order_id
            )
            if not row:
                raise ValueError(f"Order {order_id} not found")
            items_rows = await conn.fetch(
                "SELECT * FROM order_items WHERE order_id = $1", order_id
            )
        items = [
            OrderItem(
                product_id=r["product_id"],
                name=r["name"],
                quantity=r["quantity"],
                unit_price=Decimal(str(r["unit_price"])),
            )
            for r in items_rows
        ]
        return Order(
            id=row["id"],
            customer_id=row["customer_id"],
            items=items,
            status=OrderStatus(row["status"]),
        )

    async def save(self, order: Order) -> None:
        pool = await self._get_pool()
        async with pool.acquire() as conn:
            await conn.execute(
                "UPDATE orders SET status = $1 WHERE id = $2",
                order.status.value, order.id,
            )

Dependency Injection: сборка системы

Все слои определены — теперь нужно их соединить. Это делается в одном месте: в точке входа (Interface слой). Ни один внутренний слой не знает, как создаются его зависимости.

# interface/container.py  — «корень компоновки» (Composition Root)
import os
from ..infrastructure.llm_adapters import AnthropicAdapter, OpenAIAdapter
from ..infrastructure.repositories import PostgresOrderRepository
from ..infrastructure.search_adapters import BraveSearchAdapter
from ..application.agent import OrderAgent


def build_agent(use_openai: bool = False) -> OrderAgent:
    """
    Единственное место, где принимается решение:
    какой LLM-провайдер, какая БД, какой поиск.
    Меняем здесь — меняется во всей системе.
    """
    llm = OpenAIAdapter() if use_openai else AnthropicAdapter()
    repo = PostgresOrderRepository(dsn=os.environ["DATABASE_URL"])
    search = BraveSearchAdapter(api_key=os.environ["BRAVE_API_KEY"])

    return OrderAgent(
        order_repo=repo,
        llm=llm,
        search=search,
    )


# interface/api.py  — FastAPI
from fastapi import FastAPI, HTTPException
from .container import build_agent

app = FastAPI()
agent = build_agent()

@app.post("/orders/{order_id}/process")
async def process_order(order_id: str):
    result = await agent.process_order(order_id)
    if result["status"] == "error":
        raise HTTPException(status_code=422, detail=result["errors"])
    return result
💡 Composition Root — паттерн, при котором все зависимости создаются и связываются в одном месте (обычно в точке входа приложения). Это предотвращает «расползание» инициализации по всему коду и делает замену адаптеров тривиальной.

Тестируемость: как работает разделение

Главная выгода Clean Architecture — возможность тестировать каждый слой изолированно, без поднятия реальной инфраструктуры.

❌ Без разделения
Интеграционные тесты всего
Скорость: ~5–30 сек / тест
Требует: БД, сеть, реальный LLM
Стоимость: $0.01–$0.1 за тест-ран
Нестабильность: rate limit, таймауты
Покрытие: ограничено ценой
✅ С разделением
Каждый слой отдельно
Domain: < 1 мс / тест, никаких зависимостей
Application: mock-адаптеры, без LLM API
Infrastructure: тест-БД в Docker
E2E: только 1–2 сценария, real API
Стоимость: $0 для 99% тестов
# tests/test_domain.py — тесты домена: быстро, без зависимостей
import pytest
from decimal import Decimal
from src.domain.entities import Order, OrderItem, OrderStatus
from src.domain.services import PricingService, OrderValidator


def make_order(total: float) -> Order:
    price = Decimal(str(total))
    return Order(
        id="test-1", customer_id="cust-1",
        items=[OrderItem("p1", "Product", 1, price)]
    )


class TestPricingService:
    pricer = PricingService()

    def test_vip_discount_applied(self):
        order = make_order(15000)
        assert self.pricer.calculate_discount(order) == Decimal("0.15")

    def test_no_discount_below_threshold(self):
        order = make_order(5000)
        assert self.pricer.calculate_discount(order) == Decimal("0")

    def test_final_price_with_discount(self):
        order = make_order(10000)
        assert self.pricer.final_price(order) == Decimal("8500")


class TestOrderValidator:
    validator = OrderValidator()

    def test_empty_order_is_invalid(self):
        order = Order(id="x", customer_id="y", items=[])
        errors = self.validator.validate(order)
        assert len(errors) == 1

    def test_valid_order_passes(self):
        order = make_order(100)
        assert self.validator.validate(order) == []
# tests/test_agent.py — тесты агента с mock-адаптерами
import pytest
from decimal import Decimal
from unittest.mock import AsyncMock
from src.application.agent import OrderAgent
from src.domain.entities import Order, OrderItem, OrderStatus


@pytest.fixture
def sample_order():
    return Order(
        id="order-42",
        customer_id="customer-1",
        items=[OrderItem("p1", "Laptop", 1, Decimal("15000"))],
    )


@pytest.fixture
def mock_repo(sample_order):
    repo = AsyncMock()
    repo.get.return_value = sample_order
    return repo


@pytest.fixture
def mock_llm():
    llm = AsyncMock()
    llm.complete.return_value = "Your order #order-42 has been confirmed."
    return llm


@pytest.fixture
def mock_search():
    search = AsyncMock()
    search.search.return_value = [{"title": "Laptop specs", "snippet": "..."}]
    return search


@pytest.mark.asyncio
async def test_process_order_success(mock_repo, mock_llm, mock_search):
    agent = OrderAgent(mock_repo, mock_llm, mock_search)
    result = await agent.process_order("order-42")

    assert result["status"] == "ok"
    assert result["order_id"] == "order-42"
    assert result["discount"] == "0.15"   # VIP-скидка за 15000
    assert "confirmed" in result["message"]

    # Проверяем, что LLM был вызван ровно один раз
    mock_llm.complete.assert_called_once()
    # Проверяем, что заказ был сохранён
    mock_repo.save.assert_called_once()


@pytest.mark.asyncio
async def test_process_order_rejects_invalid(mock_llm, mock_search):
    empty_order = Order(id="x", customer_id="y", items=[])
    repo = AsyncMock()
    repo.get.return_value = empty_order

    agent = OrderAgent(repo, mock_llm, mock_search)
    result = await agent.process_order("x")

    assert result["status"] == "error"
    # LLM не должен вызываться для невалидного заказа
    mock_llm.complete.assert_not_called()

Структура файлов

Слои — это не просто концепция, они отражаются в структуре директорий. Каждый слой живёт в отдельном пакете, и импорты между ними однонаправлены.

order_agent/
├── domain/                  # Domain Layer
│   ├── __init__.py
│   ├── entities.py          # Order, OrderItem, OrderStatus
│   └── services.py          # PricingService, OrderValidator
│
├── application/             # Application Layer
│   ├── __init__.py
│   ├── ports.py             # OrderRepository, LLMPort, SearchPort
│   └── agent.py             # OrderAgent (оркестрация)
│
├── infrastructure/          # Infrastructure Layer
│   ├── __init__.py
│   ├── llm_adapters.py      # AnthropicAdapter, OpenAIAdapter
│   ├── repositories.py      # PostgresOrderRepository
│   └── search_adapters.py   # BraveSearchAdapter
│
├── interface/               # Interface Layer
│   ├── __init__.py
│   ├── container.py         # Composition Root (DI)
│   └── api.py               # FastAPI endpoints
│
└── tests/
    ├── test_domain.py        # ← без зависимостей, молниеносные
    ├── test_agent.py         # ← mock-адаптеры
    └── test_integration.py   # ← реальная БД в Docker
⚠️ Запрещённые импорты — линтер для архитектуры: domain/ не должен импортировать из application/ или infrastructure/. application/ не должен импортировать из infrastructure/. Настройте flake8-import-order или import-linter (пакет import-linter) для автоматической проверки в CI.

Типичные ошибки

Бизнес-логика в промпте
Правила расчёта скидок или условия валидации написаны прямо в system prompt: «если заказ больше 10000 — дай скидку 15%». Промпт нельзя протестировать юнит-тестом; его логику LLM может интерпретировать по-разному.
→ Правила расчёта — в Domain сервис. LLM только генерирует текст.
Прямой импорт SDK в Domain
from anthropic import Anthropic или import psycopg2 в файлах домена. Теперь смена провайдера требует редактирования бизнес-логики.
→ Domain знает только об абстракциях. SDK — только в Infrastructure.
Fat Agent
Агент содержит SQL-запросы, HTTP-вызовы, бизнес-правила и шаблоны промптов в одном классе на 500+ строк. Нарушает Single Responsibility и тестируемость.
→ Агент = оркестратор. Всё остальное — в соответствующих слоях.
Создание адаптеров внутри агента
self.client = anthropic.Anthropic() в __init__ агента. Агент сам решает, какой SDK использовать — невозможно подменить в тестах.
→ Передавать адаптеры снаружи через конструктор (Dependency Injection).
Слои в одном файле
Классы Domain, Application и Infrastructure в одном agent.py. Физическая структура файлов не отражает архитектуру — со временем граница размывается и снова появляются прямые зависимости.
→ Слои = отдельные пакеты (директории). Импорты проверяются линтером.

Шпаргалка

📋 Четыре слоя и правило зависимостей
  • Domain — сущности + бизнес-правила. Нет внешних зависимостей. Тестируется без моков.
  • Application — агент + порты (интерфейсы). Знает о Domain, не знает об SDK.
  • Infrastructure — адаптеры (LLM, БД, HTTP). Реализует порты из Application.
  • Interface — точка входа (API, CLI). Composition Root: создаёт и связывает адаптеры.
  • Зависимости только внутрь: Infrastructure → Application → Domain.
  • Порт = Protocol/ABC в application/ports.py. Адаптер = класс в infrastructure/.
  • Смена провайдера = смена адаптера в Composition Root без изменения агента.
  • 99% тестов — без LLM и без сети: Domain (чистый Python) + Application (AsyncMock).

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

Закрепите материал тремя задачами:

  1. Рефакторинг монолита. Возьмите любой агент, который вы писали раньше. Разделите его на 4 слоя: вынесите бизнес-правила в Domain, создайте порты в Application, перенесите SDK-вызовы в Infrastructure. Цель: написать хотя бы 5 юнит-тестов для Domain слоя, которые запускаются без LLM за < 100 мс.
  2. Замена адаптера. Реализуйте второй LLM-адаптер (например, OpenAI, если первый Anthropic). Убедитесь, что смена провайдера требует изменения только одной строки в Composition Root и не затрагивает агента. Напишите тест, который прогоняет агента с обоими mock-адаптерами и проверяет одинаковое поведение.
  3. Линтер архитектуры. Установите пакет import-linter и добавьте в .importlinter контракт, запрещающий domain импортировать из infrastructure. Добавьте проверку в Makefile: lint-arch: lint-imports. Преднамеренно нарушьте правило и убедитесь, что CI падает.