Предметно-ориентированное проектирование для ИИ-агентов: контексты и правила
Автоматический перевод Эта статья была автоматически переведена с оригинальной английской версии.
Проекты с агентами становится сложно изменять, когда в промптах, коде и бизнес-процессах используются разные термины. Отдел комплаенса говорит о «проверке политики», а реализация предоставляет process_data(). Расплывчатое имя скрывает, какое правило применяется, кто за него отвечает и где нужно вносить изменения.
Предметно-ориентированное проектирование (DDD) помещает бизнес-язык и зоны ответственности в центр системы. Для агента модель может интерпретировать запрос и предложить типизированную команду. Затем application service передаёт доверенный контекст, а доменная модель принимает или отклоняет изменение состояния. В этом руководстве словарь и работу с границами связаны с этим путём выполнения.
Это руководство предназначено для инженеров, создающих агентов, которые изменяют состояние бизнеса и требуют явного владения доменными правилами. Вы узнаете, как сопоставить предложение модели с авторизованной командой приложения и на каком этапе показанному потоку всё ещё требуется конкретная реализация транзакции.
Кратко. Используйте DDD, когда агент изменяет состояние бизнеса в предметной области со значимым языком, зонами ответственности и правилами. Схема валидирует структуру предложения модели, а домен проверяет его смысл. Не приравнивайте агентов к ограниченным контекстам, а сгенерированный JSON — к корректному бизнес-решению.
Проблема принадлежности правил
В агентных системах одно правило часто распределено между системным промптом, описанием инструмента, обработчиком API и ограничением базы данных. Копии расходятся. Лимит на возврат изменился, один промпт остался старым, и синтаксически корректный tool call попадает под неправильную политику.
DDD начинается с других вопросов:
- Какая команда владеет правилом?
- Какой язык используют предметные эксперты?
- В какой границе этот термин имеет единственное значение?
- Какие изменения состояния должны оставаться согласованными вместе?
Эти вопросы полезны, когда рабочий процесс достаточно важен, чтобы иметь политики и жизненный цикл. Простому чат-боту только для чтения могут быть не нужны агрегаты, репозитории и события. Используйте DDD для управления сложностью предметной области, а не для украшения каждого вызова LLM.
Стратегическое проектирование до написания кода
Создайте ubiquitous language
Ubiquitous language — это словарь, общий для предметных экспертов и разработчиков внутри одного ограниченного контекста. Если специалисты поддержки говорят RefundRequest, approval limit и settlement, эти термины должны использоваться в требованиях, коде, контрактах инструментов и эвалах.
Речь не только о выборе понятных имён методов. Терминам нужны определения и примеры. Означает ли «одобрено», что менеджер нажал кнопку, платёжный процессор принял перевод или выполнены оба условия? Неоднозначность, обнаруженная в глоссарии, обходится дешевле неоднозначности, найденной в трейсе агента.
Проведите ограниченные контексты вокруг моделей и зон ответственности
Один и тот же термин может означать разное в разных контекстах. «Продукт» может быть единицей складского учёта в Inventory, строкой с ценой в Billing и обязательством по доставке в Order Management.
Fowler описывает такое разделение как способ не допустить, чтобы одна модель охватывала все значения термина (bounded contexts). Это не обязательно микросервис, репозиторий, агент или команда, хотя такие границы часто совпадают.
Это различие важно при проектировании агентов:
- один контекст может использовать внутри несколько вызовов модели или специализированных агентов
- одному агенту, охватывающему несколько контекстов, нужны явный перевод и полномочия для каждого из них
- оркестрация относится к уровню приложения; она не отменяет владение доменом
Сначала создайте карту контекстов, а уже потом рисуйте граф агентов. Иначе граф будет скорее отражать доступные инструменты, чем бизнес.
Классифицируйте субдомены
В DDD обычно выделяют:
- Ключевой домен: возможность, создающая дифференцированную ценность
- Поддерживающий субдомен: необходимая специфичная для бизнеса работа, не являющаяся дифференциатором
- Общий субдомен: решённая задача, например управление идентификацией или доставка электронной почты
Для ассистента по задачам управление задачами может быть ключевым доменом, планирование — поддерживающим, а доставка уведомлений — общим.
Классификация помогает распределять инвестиции. Она не означает, что каждому блоку нужна LLM.
Тактические паттерны определяют границу состояния
Сущности и value objects
Сущность имеет идентичность и жизненный цикл. Задача остаётся той же задачей после изменения описания. Value object определяется своими значениями и обычно неизменяем: адрес электронной почты, денежная сумма или временное окно.
Агрегаты и инварианты
Агрегат — это граница согласованности в DDD (Evans, Domain-Driven Design Reference). Его корень предоставляет операции, изменяющие составные объекты, и защищает инварианты, например:
- выполненную задачу нельзя выполнить снова
- у владельца не может быть двух открытых напоминаний на один и тот же день
- сумма возврата не может превышать оставшуюся доступной для возврата сумму
Агрегат не становится безопасным лишь потому, что за методом add_task() скрывается список Python. Внешний код не должен получать изменяемую ссылку, позволяющую обойти этот метод. Для персистентности также нужен контроль конкурентного доступа: иначе два корректных запроса могут одновременно сохранить состояние, нарушающее инвариант.
Репозитории и application services
Репозиторий загружает и сохраняет агрегаты, не раскрывая домену детали базы данных (Fowler, Repository). Application service координирует один use case: загружает состояние, вызывает доменную операцию, сохраняет его с ожидаемой версией и публикует возникшие события.
Домен не должен вызывать LLM, HTTP-клиент или ORM. Это адаптеры вокруг use case.
Доменные события — факты, а не шина сообщений
TaskAdded — это факт в прошедшем времени, порождённый доменом. Приложение может сохранить его в outbox вместе с обновлением агрегата, а затем опубликовать integration event после commit. Это transactional-outbox pattern, а не гарантия, которую предоставляет сам объект события (Richardson, Transactional Outbox). Прямая отправка сообщения в брокер из сущности может привести к публикации события для транзакции, которая впоследствии завершится ошибкой.
События могут координировать агентов, но сами по себе не делают координацию надёжной. Семантика доставки, идемпотентность, порядок и версионирование контрактов остаются задачами инфраструктуры.
Считайте вывод модели недоверенным предложением
Интеграция с LLM похожа на anti-corruption layer: она преобразует внешнее вероятностное представление в термины, понятные домену. Эта аналогия полезна, пока валидация и политики остаются разделёнными.
Схема слоёв явно показывает границу ответственности: модель остаётся внешней, адаптеры преобразуют её предложение, application service авторизует один use case, а домен сохраняет инвариант.
Граница состоит из четырёх шагов:
- Ограничьте и распарсьте: потребуйте типизированный контракт вывода.
- Нормализуйте: разрешите даты, единицы измерения, идентификаторы и локаль с использованием доверенного контекста.
- Авторизуйте: определите, может ли этот актор запросить операцию.
- Выполните: вызовите метод агрегата, который проверяет инвариант.
Pydantic может отклонить отсутствующее поле или некорректное значение enum через свою модель типизированной валидации (документация Pydantic). Но он не может решить, к какой дате относится «завтра», принадлежит ли пользователю список задач или открыта ли уже похожая задача.
Иллюстративный поток: добавление задачи
Следующие фрагменты показывают один запрос, а не готовый модуль. Актор — actor_id, целевое состояние — TaskList владельца, а доменный побочный эффект — событие TaskAdded. Адаптер инструмента или модели передаёт AddTaskProposal; application service авторизует актора, изменяет агрегат и отвечает за границу персистентности. TaskAdded, TaskAuthorizer и Outbox — опущенные типы. Ни сопутствующая реализация, ни тест в этом репозитории не делают эти фрагменты запускаемыми.
1. Определите предложение, обращённое к модели
Предложение должно быть близко к тому, что модель способна вывести. Не просите её придумывать идентификаторы базы данных или доверенные идентификаторы владельца.
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class AddTaskProposal(BaseModel):
description: str = Field(min_length=1, max_length=200)
due_date: date | None = None
priority: Literal["low", "normal", "high"] = "normal"
@field_validator("description")
@classmethod
def description_must_contain_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("description must contain non-whitespace characters")
return value
Если пользователь говорит «завтра», приложение должно передать модели явную локальную дату или разрешить относительное выражение с помощью протестированного парсера дат. Никогда не используйте часы inference-сервера как неявный бизнес-контекст.
2. Поместите инвариант в агрегат
from dataclasses import dataclass, field
from datetime import date
from uuid import UUID, uuid4
@dataclass(frozen=True)
class Task:
task_id: UUID
description: str
due_date: date | None
priority: str
@dataclass
class TaskList:
owner_id: UUID
version: int
_tasks: tuple[Task, ...] = ()
_events: list[object] = field(default_factory=list)
@property
def tasks(self) -> tuple[Task, ...]:
return self._tasks
def pull_events(self) -> tuple[object, ...]:
events = tuple(self._events)
self._events.clear()
return events
def add_task(
self,
description: str,
due_date: date | None,
priority: str,
) -> Task:
description = description.strip()
if not description:
raise ValueError("Task description must contain non-whitespace characters")
normalized = " ".join(description.casefold().split())
duplicate = any(
" ".join(task.description.casefold().split()) == normalized
and task.due_date == due_date
for task in self._tasks
)
if duplicate:
raise ValueError("A matching task already exists for that date")
task = Task(uuid4(), description, due_date, priority)
self._tasks += (task,)
self._events.append(TaskAdded(task.task_id, self.owner_id))
return task
Для краткости в примере опущено определение TaskAdded. В полном доменном модуле это был бы неизменяемый value object. Агрегат хранит задачи в неизменяемом кортеже, поэтому вызывающий код не получает изменяемую коллекцию, которую можно дополнять или обходить вокруг add_task(). Его изменяющие методы заменяют этот кортеж только после проверки правила.
Проверка дубликатов здесь намеренно проста. В реальных правилах могут потребоваться нормализация с учётом локали, семантика повторений или ограничение уникальности базы данных как финальная защита от гонок.
3. Определите порт репозитория
from typing import Protocol
from uuid import UUID
class ConcurrentUpdate(Exception):
pass
class TaskListRepository(Protocol):
def get(self, owner_id: UUID) -> TaskList: ...
def save(self, task_list: TaskList, expected_version: int) -> None: ...
Инфраструктурный адаптер может реализовать оптимистичный контроль конкурентного доступа с помощью колонки версии. Доменный контракт фиксирует существенное, не завязываясь на SQLAlchemy или конкретную базу данных.
4. Скоординируйте use case
from uuid import UUID
class AddTaskService:
def __init__(
self,
repository: TaskListRepository,
authorizer: TaskAuthorizer,
outbox: Outbox,
) -> None:
self.repository = repository
self.authorizer = authorizer
self.outbox = outbox
def execute(
self,
actor_id: UUID,
owner_id: UUID,
proposal: AddTaskProposal,
) -> Task:
self.authorizer.require_add_permission(actor_id, owner_id)
task_list = self.repository.get(owner_id)
expected_version = task_list.version
task = task_list.add_task(
description=proposal.description,
due_date=proposal.due_date,
priority=proposal.priority,
)
# Illustrative only: these two calls are not atomic through these ports.
self.repository.save(task_list, expected_version)
self.outbox.add_all(task_list.pull_events())
return task
Приведённый выше код не реализует транзакцию. Сохранение репозиторием и вставка в outbox должны выполняться через один конкретный unit of work, использующий общую транзакцию базы данных. Иначе ошибка после save может оставить задачу сохранённой без события. Диаграмма показывает предполагаемую границу, а не гарантию, предоставляемую этими фрагментами.
В этом сервисе модель отсутствует. Один адаптер может получить AddTaskProposal от LLM, другой — из HTTP-формы, а тесты могут создать его напрямую. Бизнес-поведение остаётся одинаковым.
Сопоставляйте инструменты с командами приложения
Инструменты агента должны предоставлять use case, а не примитивы базы данных. Предпочтительнее:
add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)
вместо:
insert_row(table, values)
update_record(table, id, patch)
Первый набор говорит на языке домена и даёт приложению место для авторизации и проверки правил. Второй позволяет модели описывать произвольные изменения персистентности.
Результат инструмента должен различать ошибки, на которые агент может реагировать: некорректное предложение, неавторизованный актор, конфликт в домене, конкурентное обновление и недоступную инфраструктуру. Не сводите все ошибки к строке, которая провоцирует слепые повторы.
Тестируйте границу по слоям
Доменные тесты
Тестируйте агрегаты без модели, сети и базы данных:
- дубликаты задач отклоняются
- корректные задачи порождают ожидаемое событие
- открытые коллекции не могут изменять внутреннее состояние
- правила переходов сохраняются при повторных операциях
Тесты приложения
Используйте фейковые репозитории и авторизаторы, чтобы проверить загрузку, порядок авторизации, сохранение с ожидаемой версией, работу outbox и сопоставление ошибок.
Эвалы модельного контракта
Оценивайте вероятностный адаптер отдельно:
- точность извлечения намерения и полей
- разрешение относительных дат с переданным контекстом часового пояса
- отказ или запрос уточнения при отсутствии обязательной информации
- устойчивость к prompt injection внутри цитируемого текста задачи
- долю предложений, валидных по схеме, но непригодных по смыслу
Затем end-to-end тест должен подтвердить, что плохие предложения не могут обойти те же доменные методы, которые используют доверенные интерфейсы.
Признаки работающего дизайна
Вы должны иметь возможность заменить провайдера модели, не меняя доменный тест. Изменение политики должно затрагивать один агрегат или доменный сервис, а не несколько промптов. В трейсах должны использоваться термины, понятные команде-владельцу. Некорректные или неавторизованные предложения должны отклоняться до персистентности, а конкурентное сохранение должно завершаться ошибкой, а не молча перезаписывать состояние.
DDD не делает модель детерминированной. Он делает полномочия, язык и границы согласованности системы достаточно явными, чтобы модели не приходилось их определять.
Ссылки
- Eric Evans, Domain-Driven Design Reference — определения стратегических и тактических паттернов
- Martin Fowler, Bounded Context — почему одна модель не должна охватывать все значения термина
- Martin Fowler, Repository — абстракция коллекции персистентности
- Chris Richardson, Transactional Outbox — публикация после транзакции базы данных без потери событий
- Документация Pydantic — типизированная валидация предложений