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.
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ą.
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.
Granica obejmuje cztery kroki:
- Ogranicz i sparsuj: wymagaj typowanego kontraktu wyniku.
- Znormalizuj: rozwiąż daty, jednostki, identyfikatory i lokalizację przy użyciu zaufanego kontekstu.
- Autoryzuj: zdecyduj, czy dany aktor może zażądać operacji.
- 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
- Eric Evans, Domain-Driven Design Reference — definicje wzorców strategicznych i taktycznych
- Martin Fowler, Bounded Context — dlaczego jeden model nie powinien obejmować każdego znaczenia terminu
- Martin Fowler, Repository — abstrakcja kolekcji persystencji
- Chris Richardson, Transactional Outbox — publikowanie po transakcji bazy danych bez utraty zdarzeń
- dokumentacja Pydantic — walidacja typowanej propozycji