[!NOTE] Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.
Conception pilotée par le domaine pour les agents AI : contextes bornés, outils et règles métier
Les projets d’agents deviennent difficiles à modifier lorsque prompts, le code et les processus métier emploient des termes différents. Les exigences de conformité imposent une « vérification de politique », tandis que l’implémentation révèle process_data(). Ce nom vague empêche de savoir quelle règle est appliquée, à qui elle appartient, et où une modification doit être effectuée.
Le Domain-Driven Design (DDD) place ce langage métier ainsi que la responsabilité associée au cœur du système. Pour un agent, le modèle peut interpréter une requête et proposer une commande typée, mais c’est un service d’application qui fournit un contexte fiable, permettant au modèle de domaine d’accepter ou de rejeter la modification d’état. Ce guide établit un lien entre le vocabulaire, les définitions des frontières et ce flux d’exécution.
En résumé. Utilisez la DDD lorsque un agent modifie l’état métier au sein d’un domaine doté d’un langage, de responsabilités et de règles bien définis. Un schéma sert à valider la structure d’une proposition de modèle, tandis que le domaine garantit sa signification. Ne confondez pas les agents avec des contextes bornés, ni les JSON générés avec une décision métier valide.
Le véritable problème réside dans la propriété des règles
Les systèmes d’agents distribuent fréquemment une même règle à travers un system prompt, une description d’outil, un gestionnaire API, ainsi qu’une contrainte de base de données. Les versions de cette règle finissent par diverger. Lorsqu’un plafond de remboursement est modifié, une prompt reste obsolète, et un tool call syntaxiquement valide est appliqué à la politique incorrecte.
DDD commence par poser des questions différentes :
- Quelle équipe est responsable de cette règle ?
- Quel langage utilisent les experts du domaine pour elle ?
- Dans quelles limites ce terme possède-t-il une seule signification ?
- Quelles modifications d’état doivent rester cohérentes entre elles ?
Ces questions s’avèrent utiles lorsque le flux de travail est suffisamment important pour nécessiter des politiques et un cycle de vie définis. Un chatbot simple en lecture seule n’a pas forcément besoin d’agrégats, de répertoires ou d’événements. Il convient d’utiliser la DDD pour gérer la complexité du domaine, et non pour accompagner systématiquement chaque appel à LLM.
Conception stratégique avant l’écriture du code
Construire un langage ubiquitaire
Un langage ubiquitaire est un vocabulaire partagé par les experts du domaine et les développeurs au sein d’un contexte défini. Si les équipes de support affirment RefundRequest, approval limit, et settlementCes termes doivent figurer dans les exigences, le code, les contrats de outils ainsi que dans les évaluations.
Il ne s’agit pas seulement de choisir des noms de méthodes descriptifs. Les termes nécessitent des définitions et des exemples concrets. Est-ce que « approuvé » signifie qu’un manager a cliqué sur un bouton, que le processeur de paiement a accepté le virement, ou les deux ? Détecter une ambiguïté dans un glossaire est moins coûteux que de la découvrir dans les traces d’exécution d’un agent.
Définir des contextes bornés autour des modèles et de leurs responsables
Le même nom peut désigner des choses différentes selon le contexte. Le terme « produit » peut représenter une unité de stock dans la gestion des inventaires, une ligne tarifée dans la facturation, ou encore un engagement de livraison dans la gestion des commandes.
Un contexte borné possède son propre modèle et effectue la traduction à sa frontière. Il ne s’agit pas automatiquement d’un microservice, d’un repository, d’un agent ou d’une équipe, bien que ces frontières coïncident souvent.
Cette distinction est importante dans la conception d’agents :
- Un contexte peut faire appel à plusieurs appels de modèle ou à des agents spécialisés en interne
- Un agent qui couvre plusieurs contextes nécessite une traduction explicite ainsi qu’une autorisation pour chacun d’eux
- L’orchestration relève de la responsabilité de l’application ; elle ne supprime pas la propriété du domaine concerné
Commencez par un plan de contexte avant de dessiner un graphe d’agent. Sinon, le graphe a tendance à refléter la disponibilité des outils plutôt que les objectifs métier.
Classifier les sous-domaines
DDD sépare généralement :
- Domaine principal : la capacité qui génère une valeur différenciante
- Sous-domaine d’appui : tâches nécessaires spécifiques à l’entreprise qui ne constituent pas le facteur de différenciation
- Sous-domaine générique : une capacité déjà résolue, telle que l’identité ou la livraison d’e-mails
Pour un assistant de tâches, la gestion des tâches peut être le cœur du système, l’organisation des plannings étant une fonction secondaire, tandis que la livraison des notifications relève d’une fonction générale.
La classification oriente les décisions d’investissement. Cela ne signifie pas pour autant que chaque catégorie doive impérativement faire l’objet d’un LLM.
Les motifs tactiques définissent la frontière d’état
Entités et objets de valeur
Une entité possède une identité ainsi qu’un cycle de vie. Une tâche reste la même tâche même si sa description est modifiée. Un objet de valeur est défini par ses valeurs et est généralement immuable : il s’agit par exemple d’une adresse e-mail, d’un montant d’argent ou d’une fenêtre temporelle.
Agrégats et invariants
Un aggregate constitue une frontière de cohérence. Son noyau expose les opérations permettant de modifier ses membres et protège des invariants tels que :
- Une tâche terminée ne peut pas être à nouveau finalisée.
- Un responsable ne peut pas disposer de rappels ouverts dupliqués pour la même journée.
- Un remboursement ne peut pas dépasser le montant restant remboursable.
Un agrégat ne devient pas sûr simplement parce qu’une liste Python se trouve derrière lui. add_task() Méthode ; le code externe ne doit pas recevoir de référence mutable permettant de contourner cette méthode. La persistance nécessite également un contrôle de concurrence, car deux requêtes valides peuvent violer une invariante lorsqu’elles sont enregistrées simultanément.
Répertoires et services d’application
Un répertoire charge et enregistre des agrégats sans laisser filtrer les problématiques liées à la base de données dans le domaine métier. Un service d’application coordonne un cas d’utilisation : charger l’état, appeler l’opération du domaine, enregistrer les données avec la version attendue, et publier les événements générés.
Le domaine ne doit pas faire appel à un LLM, un client HTTP ou un ORM. Il s’agit de couches d’adaptation destinées à gérer les cas d’usage.
Les événements de domaine sont des faits, et non un bus de messages
TaskAdded Il s’agit d’un fait au passé signalé par le domaine concerné. L’application peut le conserver dans une file d’attente en même temps que la mise à jour globale, puis publier un événement d’intégration après l’exécution du commit. Envoyer directement des données vers un broker depuis une entité comporte le risque de publier un événement correspondant à une transaction qui échouera ultérieurement.
Les événements permettent de coordonner les agents, mais ils ne garantissent pas à eux seuls une coordination fiable. Les sémantiques de livraison, l’idempotence, le ordonnancement ainsi que les contrats versionnés relèvent toujours du domaine des infrastructures.
Considérer la sortie du modèle comme une proposition non fiable
Une intégration LLM ressemble à une couche de lutte contre la corruption : elle convertit une représentation externe et probabiliste en termes compréhensibles par le domaine cible. Cette analogie reste pertinente tant que les opérations de validation et les règles de politique restent distinctes.
La frontière comporte quatre étapes :
- Restreindre et parser : exiger un contrat de sortie typé.
- Normaliser : résoudre les dates, les unités, les identifiants et la configuration locale à l’aide d’un contexte fiable.
- Autoriser : déterminer si cet acteur peut demander l’opération.
- Exécuter : appeler une méthode globale qui impose l’invariant.
Pydantic peut rejeter un champ manquant ou une valeur d’enum invalide. Il ne peut pas déterminer que « demain » correspond à la bonne date, que l’utilisateur possède la liste de tâches, ou qu’une tâche similaire est déjà en cours.
Exemple concret : ajouter une tâche en toute sécurité
1. Définir la proposition destinée au modèle
Gardez la proposition proche de ce que le modèle peut inférer. Ne lui demandez pas d’inventer des identifiants de base de données ou des identifiants d’owner fiables.
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class AddTaskProposal(BaseModel):
description: str = Field(min_length=1, max_length=200)
due_date: date | None = None
priority: Literal["low", "normal", "high"] = "normal"
Si l’utilisateur indique « demain », l’application doit fournir au modèle une date locale explicite ou résoudre l’expression relative à l’aide d’un analyseur de dates éprouvé. Il ne faut jamais utiliser l’horloge du serveur d’inférence comme contexte métier implicite.
2. Intégrer l’invariant dans l’agrégat
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: dict[UUID, Task] = field(default_factory=dict)
_events: list[object] = field(default_factory=list)
@property
def tasks(self) -> tuple[Task, ...]:
return tuple(self._tasks.values())
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:
normalized = " ".join(description.casefold().split())
duplicate = any(
" ".join(task.description.casefold().split()) == normalized
and task.due_date == due_date
for task in self._tasks.values()
)
if duplicate:
raise ValueError("A matching task already exists for that date")
task = Task(uuid4(), description.strip(), due_date, priority)
self._tasks[task.task_id] = task
self._events.append(TaskAdded(task.task_id, self.owner_id))
return task
L’exemple omet le TaskAdded Définition pour des raisons de concision. Dans un module de domaine complet, il s’agit d’un objet de valeur immuable. L’agrégat expose une vue sous forme de tuple plutôt que son dictionnaire mutable, de sorte que les appels ne peuvent pas y ajouter de nouveaux éléments. add_task().
La détection de doublons est ici délibérément simplifiée. Les règles réelles peuvent nécessiter une normalisation tenant compte du contexte linguistique, des sémantiques de récurrence, ou une contrainte d’unicité dans la base de données en tant que mécanisme de secours fiable face aux concurrences.
3. Définir le port du répertoire
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: ...
L’adaptateur d’infrastructure peut mettre en œuvre une concurrence optimiste grâce à une colonne de version. Le contrat de domaine définit les exigences sans dépendre de SQLAlchemy ni d’une base de données spécifique.
4. Coordonner le cas d’utilisation
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,
)
# Implement both writes in one database transaction.
self.repository.save(task_list, expected_version)
self.outbox.add_all(task_list.pull_events())
return task
Le commentaire concernant une seule transaction est essentiel : l’enregistrement dans le répertoire ainsi que l’insertion dans la file d’attente doivent réussir ou échouer simultanément. Une abstraction de unité de travail peut gérer cette transaction lorsque le répertoire concret et la file d’attente partagent une même base de données.
Le modèle est absent de ce service. Un adaptateur peut le récupérer. AddTaskProposal L’un provient d’un LLM, un autre d’un formulaire HTTP, et les tests peuvent le construire directement. Le comportement métier reste identique.
Associer les outils de cartographie aux commandes de l’application
Les outils d’agent doivent exposer des cas d’utilisation, et non des primitives de base de données. Préférez :
add_task(description, due_date, priority)
complete_task(task_id)
reschedule_task(task_id, due_date)
sur :
insert_row(table, values)
update_record(table, id, patch)
Le premier ensemble parle le langage du domaine et fournit à l’application un mécanisme pour autoriser et appliquer des règles. Le second permet au modèle de décrire des mutations de persistance arbitraires.
Un tool result doit permettre de distinguer les échecs sur lesquels l’agent peut agir : proposition invalide, acteur non autorisé, conflit de domaine, mise à jour concurrente et infrastructure indisponible. Il ne faut pas regrouper tous ces échecs en une seule chaîne de caractères qui inciterait à des tentatives répétées de manière aveugle.
Tester la frontière par couches
Tests de domaine
Tester les agrégats sans modèle, réseau ou base de données :
- Les tâches dupliquées sont rejetées
- Les tâches valides déclenchent l’événement attendu
- Les collections exposées ne peuvent pas modifier l’état interne
- Les règles de transition restent en vigueur lors de multiples opérations
Tests d’application
Utilisez des répertoires et des autorisateurs fictifs pour valider le chargement, l’ordre d’autorisation, les enregistrements de la version attendue, le comportement du panier d’envoi, ainsi que la mise en correspondance des erreurs.
Évaluations des contrats de modèle
Évaluer l’adaptateur probabiliste séparément :
- Précision de l’extraction des intentions et des champs
- Résolution des dates relatives en tenant compte du fuseau horaire fourni
- Refus ou demande de précisions en cas d’absence d’informations nécessaires
- Résistance aux prompt injection présents dans le texte de la tâche entre guillemets
- Taux de propositions valides selon le schéma mais non utilisables sur le plan sémantique
Un test bout en bout doit alors vérifier que les propositions non fiables ne parviennent jamais à contourner les mêmes méthodes de domaine utilisées par les interfaces fiables.
Lorsque la conception fonctionne correctement
Vous devez pouvoir modifier le fournisseur de modèle sans avoir à modifier un test de domaine. Une modification de politique doit affecter un service d’agrégation ou un service de domaine unique, et non plusieurs prompts. Un journal d’audit doit utiliser des termes reconnus par l’équipe responsable. Une proposition mal formatée ou non autorisée doit échouer avant toute persistance, et une opération de sauvegarde simultanée doit également échouer plutôt que de supprimer silencieusement l’état actuel.
Telle est la valeur pratique du DDD pour les agents. Il ne rend pas le modèle déterministe. Il rend suffisamment explicites les limites d’autorité, de langage et de cohérence du système, de sorte que le modèle n’a pas besoin de l’être.
Références
- Eric Evans, Référence sur la conception pilotée par le domaine — définitions de motifs stratégiques et tactiques Martin Fowler, Contexte borné — pourquoi un seul modèle ne doit pas couvrir toutes les significations d’un terme
- Martin Fowler, Repository — abstraction de la collection de persistance Chris Richardson, boîte aux lettres transactionnelle — publication après une transaction de base de données sans perte d’événements Documentation Pydantic — validation de proposition tapée