Domain-Driven Design dla agentów AI: konteksty i reguły

Tłumaczenie automatyczne Ten artykuł został automatycznie przetłumaczony z angielskiego oryginału.

Projekty agentów stają się trudne do modyfikowania, gdy prompty, kod i procesy biznesowe używają różnych terminów. Dział compliance mówi o „sprawdzeniu polityki”, podczas gdy implementacja udostępnia process_data(). Nieprecyzyjna nazwa ukrywa to, która reguła jest stosowana, kto jest za nią odpowiedzialny i gdzie należy wprowadzić zmianę.

Domain-Driven Design (DDD) stawia język biznesowy i odpowiedzialność w centrum. W przypadku agenta model może zinterpretować żądanie i zaproponować typowaną komendę. Następnie usługa aplikacyjna dostarcza zaufany kontekst, a model domenowy akceptuje zmianę stanu albo ją odrzuca. Ten przewodnik łączy pracę nad słownictwem i granicami z tą ścieżką wykonania.

Ten przewodnik jest przeznaczony dla inżynierów budujących agentów, którzy zmieniają stan biznesowy i wymagają jawnego przypisania odpowiedzialności za reguły domenowe. Dowiesz się, jak mapować propozycję modelu na autoryzowaną komendę aplikacyjną oraz gdzie przykładowy przepływ nadal wymaga konkretnej implementacji transakcji.

TL;DR. Używaj DDD, gdy agent zmienia stan biznesowy w domenie z istotnym językiem, odpowiedzialnością i regułami. Schemat waliduje strukturę propozycji modelu; domena egzekwuje jej znaczenie. Nie utożsamiaj agentów z ograniczonymi kontekstami ani wygenerowanego JSON z poprawną decyzją biznesową.


Problemem jest przypisanie odpowiedzialności za reguły

Systemy agentowe często rozpraszają jedną regułę między system promptem, opisem narzędzia, handlerem API i ograniczeniem w bazie danych. Kopie rozjeżdżają się w czasie. Zmienia się limit zwrotu, jeden prompt pozostaje nieaktualny, a poprawne składniowo wywołanie narzędzia trafia do niewłaściwej polityki.

DDD zaczyna od zadania innych pytań:

  • Który zespół odpowiada za tę regułę?
  • Jakiego języka używają w jej odniesieniu eksperci domenowi?
  • W jakiej granicy ten termin ma jedno znaczenie?
  • Które zmiany stanu muszą pozostać spójne?

Te pytania są przydatne, gdy przepływ pracy jest na tyle ważny, że ma polityki i cykl życia. Prosty chatbot tylko do odczytu może nie potrzebować agregatów, repozytoriów ani zdarzeń. Używaj DDD do zarządzania złożonością domeny, a nie do ozdabiania każdego wywołania LLM.

Projekt strategiczny przed napisaniem kodu

Zbuduj wszechobecny język

Wszechobecny język to słownictwo współdzielone przez ekspertów domenowych i deweloperów w ramach jednego ograniczonego kontekstu. Jeśli zespoły operacyjne wsparcia używają terminów RefundRequest, approval limit i settlement, terminy te powinny pojawić się w wymaganiach, kodzie, kontraktach narzędzi i ewaluacjach.

To coś więcej niż wybór opisowych nazw metod. Terminy potrzebują definicji i przykładów. Czy „zatwierdzone” oznacza, że menedżer kliknął przycisk, procesor płatności zaakceptował przelew, czy jedno i drugie? Niejednoznaczność wykryta w glosariuszu jest tańsza niż niejednoznaczność wykryta w śladzie agenta.

Wyznacz ograniczone konteksty wokół modeli i odpowiedzialności

Ten sam rzeczownik może oznaczać różne rzeczy w różnych kontekstach. „Produkt” może być jednostką ewidencji zapasów w Inventory, wycenioną pozycją w Billing oraz zobowiązaniem dotyczącym dostawy w Order Management.

Słowo „produkt” modelowane różnie w ograniczonych kontekstachSłowo „produkt” modelowane różnie w ograniczonych kontekstach

Fowler opisuje tę separację jako sposób na to, by jeden model nie obejmował każdego znaczenia terminu (ograniczone konteksty). Ograniczony kontekst nie jest automatycznie mikroserwisem, repozytorium, agentem ani zespołem, choć te granice często się pokrywają.

To rozróżnienie ma znaczenie przy projektowaniu agentów:

  • jeden kontekst może wewnętrznie korzystać z wielu wywołań modelu lub wyspecjalizowanych agentów
  • jeden agent obejmujący kilka kontekstów wymaga jawnego tłumaczenia i określenia uprawnień dla każdego z nich
  • orkiestracja jest kwestią aplikacyjną; nie znosi odpowiedzialności domenowej

Najpierw utwórz mapę kontekstów, a dopiero potem rysuj graf agentów. W przeciwnym razie graf będzie odzwierciedlał dostępność narzędzi zamiast biznesu.

Sklasyfikuj poddomeny

DDD zwykle rozróżnia:

  • Domenę kluczową: zdolność tworzącą wyróżniającą wartość
  • Poddomenę wspierającą: niezbędną, specyficzną dla biznesu pracę, która nie stanowi wyróżnika
  • Poddomenę generyczną: rozwiązaną zdolność, taką jak obsługa tożsamości lub dostarczanie wiadomości e-mail

W przypadku asystenta do zadań zarządzanie zadaniami może być domeną kluczową, planowanie — poddomeną wspierającą, a dostarczanie powiadomień — poddomeną generyczną.

Zarządzanie zadaniami, planowanie i powiadomienia jako osobne kontekstyZarządzanie zadaniami, planowanie i powiadomienia jako osobne konteksty

Klasyfikacja pomaga kierować inwestycjami. Nie oznacza, że każde pole wymaga LLM.


Wzorce taktyczne definiują granicę stanu

Encje i obiekty wartości

Encja ma tożsamość i cykl życia. Zadanie pozostaje tym samym zadaniem po zmianie opisu. Obiekt wartości jest definiowany przez swoje wartości i zwykle niezmienny: adres e-mail, kwota pieniężna lub przedział czasowy.

Agregaty i niezmienniki

Agregat jest granicą spójności w DDD (Evans, Domain-Driven Design Reference). Jego korzeń udostępnia operacje, które mogą zmieniać elementy agregatu, i chroni niezmienniki, takie jak:

  • ukończonego zadania nie można ukończyć ponownie
  • właściciel nie może mieć dwóch otwartych przypomnień na ten sam dzień
  • zwrot nie może przekraczać pozostałej kwoty podlegającej zwrotowi

Agregat nie staje się bezpieczny tylko dlatego, że za metodą add_task() znajduje się lista w Pythonie. Kod zewnętrzny nie może otrzymać zmiennej referencji, która omija tę metodę. Warstwa persystencji również potrzebuje kontroli współbieżności — w przeciwnym razie dwa poprawne żądania mogą jednocześnie naruszyć niezmiennik podczas zapisu.

Repozytoria i usługi aplikacyjne

Repozytorium ładuje i zapisuje agregaty bez ujawniania szczegółów bazy danych w domenie (Fowler, Repository). Usługa aplikacyjna koordynuje jeden przypadek użycia: ładuje stan, wywołuje operację domenową, zapisuje z oczekiwaną wersją i publikuje wynikowe zdarzenia.

Domena nie powinna wywoływać LLM, klienta HTTP ani ORM. Są to adaptery wokół przypadku użycia.

Zdarzenia domenowe to fakty, a nie magistrala komunikatów

TaskAdded jest faktem wyrażonym w czasie przeszłym, zgłaszanym przez domenę. Aplikacja może zapisać je w outboxie razem z aktualizacją agregatu, a następnie opublikować zdarzenie integracyjne po zatwierdzeniu transakcji. To wzorzec transactional outbox, a nie gwarancja zapewniana przez sam obiekt zdarzenia (Richardson, Transactional Outbox). Bezpośrednie wysyłanie do brokera z encji grozi opublikowaniem zdarzenia dla transakcji, która później zakończy się niepowodzeniem.

Zdarzenia mogą koordynować agentów, ale same z siebie nie zapewniają niezawodnej koordynacji. Semantyka dostarczania, idempotencja, kolejność i wersjonowane kontrakty nadal należą do infrastruktury.


Traktuj wynik modelu jako niezaufaną propozycję

Integracja z LLM przypomina warstwę antykorupcyjną: tłumaczy zewnętrzną, probabilistyczną reprezentację na terminy zrozumiałe dla domeny. Ta analogia jest użyteczna, o ile walidacja i polityka pozostają rozdzielone.

Mapa warstw jasno pokazuje granicę odpowiedzialności: model pozostaje zewnętrzny, adaptery tłumaczą jego propozycję, usługa aplikacyjna autoryzuje jeden przypadek użycia, a domena zachowuje niezmiennik.

Warstwy DDD utrzymują model poza domenąWarstwy DDD utrzymują model poza domeną

Przykładowy przepływ docelowy od wyniku modelu do komendy domenowej i deterministycznych regułPrzykładowy przepływ docelowy od wyniku modelu do komendy domenowej i deterministycznych reguł

Granica obejmuje cztery kroki:

  1. Ogranicz i sparsuj: wymagaj typowanego kontraktu wyniku.
  2. Znormalizuj: rozwiąż daty, jednostki, identyfikatory i lokalizację przy użyciu zaufanego kontekstu.
  3. Autoryzuj: zdecyduj, czy dany aktor może zażądać operacji.
  4. Wykonaj: wywołaj metodę agregatu, która egzekwuje niezmiennik.

Pydantic może odrzucić brakujące pole lub nieprawidłową wartość enum za pomocą typowanego modelu walidacji (dokumentacja Pydantic). Nie może zdecydować, czy „jutro” oznacza właściwą datę, czy użytkownik jest właścicielem listy zadań ani czy podobne zadanie jest już otwarte.

Przykładowy przepływ: dodawanie zadania

Poniższe fragmenty pokazują jedno żądanie, a nie kompletny moduł. Aktor to actor_id, docelowy stan to TaskList właściciela, a efekt uboczny po stronie domeny to zdarzenie TaskAdded. Narzędzie lub adapter modelu dostarcza AddTaskProposal; usługa aplikacyjna autoryzuje aktora, zmienia agregat i odpowiada za granicę persystencji. TaskAdded, TaskAuthorizer i Outbox to pominięte typy. W tym repozytorium nie ma implementacji towarzyszącej ani testu, które umożliwiałyby uruchomienie tych fragmentów.

1. Zdefiniuj propozycję przeznaczoną dla modelu

Utrzymuj propozycję blisko tego, co model może wywnioskować. Nie proś go o wymyślanie identyfikatorów z bazy danych ani zaufanych identyfikatorów właścicieli.

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

Jeśli użytkownik mówi „jutro”, aplikacja powinna przekazać modelowi jawną lokalną datę albo rozwiązać wyrażenie względne za pomocą przetestowanego parsera dat. Nigdy nie używaj zegara serwera inferencyjnego jako niejawnego kontekstu biznesowego.

2. Umieść niezmiennik w agregacie

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

Dla zwięzłości przykład pomija definicję TaskAdded. W kompletnym module domenowym byłby to niezmienny obiekt wartości. Agregat przechowuje zadania w niezmiennej krotce, dzięki czemu wywołujący nie otrzymuje zmiennej kolekcji, którą mógłby rozszerzyć lub zmodyfikować poza add_task(). Jego metody mutujące zastępują tę krotkę dopiero po wyegzekwowaniu reguły.

Wykrywanie duplikatów jest tu celowo proste. Rzeczywiste reguły mogą wymagać normalizacji uwzględniającej lokalizację, semantyki cykliczności albo ograniczenia unikatowości w bazie danych jako ostatecznego zabezpieczenia przed wyścigiem.

3. Zdefiniuj port repozytorium

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: ...

Adapter infrastruktury może implementować optymistyczną kontrolę współbieżności za pomocą kolumny wersji. Kontrakt domenowy określa to, co istotne, bez zależności od SQLAlchemy ani konkretnej bazy danych.

4. Skoordynuj przypadek użycia

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

Powyższy kod nie implementuje transakcji. Zapis repozytorium i wstawienie do outboxa muszą odbywać się w ramach jednej konkretnej jednostki pracy współdzielącej transakcję bazy danych. W przeciwnym razie awaria po save może pozostawić zapisane zadanie bez odpowiadającego mu zdarzenia. Diagram pokazuje zamierzoną granicę, a nie gwarancję zapewnianą przez te fragmenty.

Model nie występuje w tej usłudze. Jeden adapter może uzyskać AddTaskProposal z LLM, inny z formularza HTTP, a testy mogą utworzyć go bezpośrednio. Zachowanie biznesowe pozostaje identyczne.


Mapuj narzędzia na komendy aplikacyjne

Narzędzia agenta powinny udostępniać przypadki użycia, a nie prymitywy bazy danych. Preferuj:

add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)

zamiast:

insert_row(table, values)
update_record(table, id, patch)

Pierwszy zestaw posługuje się językiem domeny i zapewnia aplikacji miejsce na autoryzację oraz egzekwowanie reguł. Drugi pozwala modelowi opisywać dowolne mutacje persystencji.

Wynik narzędzia powinien rozróżniać błędy, na które agent może zareagować: niepoprawną propozycję, nieautoryzowanego aktora, konflikt domenowy, równoczesną aktualizację i niedostępną infrastrukturę. Nie spłaszczaj wszystkich błędów do jednego ciągu znaków, który zachęca do bezrefleksyjnych ponowień.

Testuj granicę warstwami

Testy domeny

Testuj agregaty bez modelu, sieci i bazy danych:

  • duplikaty zadań są odrzucane
  • poprawne zadania zgłaszają oczekiwane zdarzenie
  • udostępnione kolekcje nie mogą modyfikować wewnętrznego stanu
  • reguły przejść obowiązują przy wielokrotnym wykonywaniu operacji

Testy aplikacyjne

Użyj fałszywych repozytoriów i autoryzatorów, aby zweryfikować ładowanie, kolejność autoryzacji, zapisy z oczekiwaną wersją, zachowanie outboxa i mapowanie błędów.

Ewaluacje kontraktu modelu

Oceniaj probabilistyczny adapter osobno:

  • dokładność rozpoznawania intencji i ekstrakcji pól
  • rozwiązywanie dat względnych przy dostarczonym kontekście strefy czasowej
  • odmowę lub prośbę o doprecyzowanie, gdy brakuje wymaganych informacji
  • odporność na prompt injection w cytowanym tekście zadania
  • odsetek propozycji poprawnych względem schematu, lecz nieużytecznych semantycznie

Test end-to-end powinien następnie potwierdzić, że błędne propozycje nigdy nie omijają tych samych metod domenowych, z których korzystają zaufane interfejsy.

Kiedy projekt działa

Powinieneś móc zmienić dostawcę modelu bez modyfikowania testu domeny. Zmiana polityki powinna dotyczyć jednego agregatu lub serwisu domenowego, a nie kilku promptów. Ślady powinny używać terminów rozpoznawalnych dla zespołu będącego właścicielem domeny. Zniekształcone lub nieautoryzowane propozycje powinny kończyć się błędem przed persystencją, a równoczesny zapis powinien zakończyć się niepowodzeniem zamiast po cichu nadpisywać stan.

DDD nie czyni modelu deterministycznym. Sprawia, że odpowiedzialność, język i granice spójności systemu są na tyle jawne, że model nie musi ich przejmować.

Materiały referencyjne