[!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 :

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.

Le terme « produit » est modélisé différemment selon les contextes délimités.

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 :

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 :

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.

Gestion des tâches, planification et notifications en tant que contextes distincts

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 :

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 sortie du modèle est traduite en commande de domaine et vérifiée selon des règles déterministes

La frontière comporte quatre étapes :

  1. Restreindre et parser : exiger un contrat de sortie typé.
  2. Normaliser : résoudre les dates, les unités, les identifiants et la configuration locale à l’aide d’un contexte fiable.
  3. Autoriser : déterminer si cet acteur peut demander l’opération.
  4. 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 :

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 :

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