Боль монолитного агента
Возьмём типичный «быстрый» агент для обработки заказов интернет-магазина:
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-агентов это означает четыре слоя с чёткими правилами зависимостей:
- Сущности (Entity)
- Value Objects
- Бизнес-правила
- Domain Services
- Исключения домена
- Agent (оркестратор)
- Use Cases
- Порты (интерфейсы)
- DTO / запросы
- Логика ReAct-цикла
- LLM-адаптеры
- Tool-реализации
- Репозитории (БД)
- HTTP-клиенты
- Кэш, очереди
- CLI / API (FastAPI)
- Telegram / Slack бот
- Вебхуки
- Конвертация входных данных
Правило зависимостей
Главный принцип — зависимости текут только внутрь. Внешние слои знают о внутренних; внутренние — нет:
Как тогда Application слой использует базу данных, если не может от неё зависеть? Через инверсию зависимостей: Application определяет интерфейс (порт), а Infrastructure предоставляет реализацию (адаптер).
Ports & Adapters: как это работает
Порт — это абстрактный интерфейс (Protocol/ABC в Python), который Application слой определяет и использует. Адаптер — конкретная реализация порта в Infrastructure слое.
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
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
Тестируемость: как работает разделение
Главная выгода Clean Architecture — возможность тестировать каждый слой изолированно, без поднятия реальной инфраструктуры.
# 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.
Типичные ошибки
from anthropic import Anthropic или import psycopg2
в файлах домена. Теперь смена провайдера требует редактирования бизнес-логики.
self.client = anthropic.Anthropic() в __init__ агента.
Агент сам решает, какой SDK использовать — невозможно подменить в тестах.
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).
Практическое задание
Закрепите материал тремя задачами:
- Рефакторинг монолита. Возьмите любой агент, который вы писали раньше. Разделите его на 4 слоя: вынесите бизнес-правила в Domain, создайте порты в Application, перенесите SDK-вызовы в Infrastructure. Цель: написать хотя бы 5 юнит-тестов для Domain слоя, которые запускаются без LLM за < 100 мс.
- Замена адаптера. Реализуйте второй LLM-адаптер (например, OpenAI, если первый Anthropic). Убедитесь, что смена провайдера требует изменения только одной строки в Composition Root и не затрагивает агента. Напишите тест, который прогоняет агента с обоими mock-адаптерами и проверяет одинаковое поведение.
-
Линтер архитектуры.
Установите пакет
import-linterи добавьте в.importlinterконтракт, запрещающийdomainимпортировать изinfrastructure. Добавьте проверку вMakefile:lint-arch: lint-imports. Преднамеренно нарушьте правило и убедитесь, что CI падает.