Construire un agent IA fiable : le pattern intent-first, du langage naturel aux actions métier déterministes
- L’entrée en langage naturel sert de porte d’accès pendant que l’exécution reste strictement déterministe.
- La fiabilité vient d’un contrat d’actions typées, de validations métier et d’une orchestration en étapes (state machine, gating).
Utiliser une application “traditionnelle” revient souvent à naviguer dans une arborescence : choisir un client, sélectionner un module, remplir un formulaire, valider une action. C’est robuste, mais lent. À l’inverse, exprimer son besoin dans un tchat est immédiat.
Les problèmes apparaissent dès que le domaine est strict (finance, assurance, RH, logistique, santé, industrie). L’intention se formule facilement en langage naturel, mais l’exécution doit rester conforme à une logique métier, à des droits, à des seuils, à des preuves d’audit. Un agent qui improvise “à la conversation” crée rapidement de la dette, des erreurs, ou une perte de confiance.
Ce billet décrit un paradigme de conception qui réconcilie les deux : un produit piloté par l’intention (intent-first), où le langage naturel sert d’entrée, et où l’exécution suit un workflow déterministe, cadré et observable.
Un produit piloté par l’intention, c’est quoi exactement ?
Un produit piloté par l’intention permet à un utilisateur d’exprimer un besoin en langage naturel, puis transforme ce besoin en actions typées exécutées par le système.
L’IA intervient là où elle excelle :
- traduire une phrase en action réalisable par le système
- désambiguïser quand l’information manque
- résumer une situation complexe et proposer des options
Les opérations qui engagent l’entreprise restent déterministes :
- validation métier
- contrôle d’accès
- calculs
- appels API
- écritures en base
L’IA devient une porte d’accès et un routeur. Le moteur d’exécution reste le système.

Étude de cas : analyse commerciale d'après facturation
Prenons le cas d’une plateforme de facturation. Nous souhaitons produire une analyse de la relation client basée sur les factures d'un client sur l'année passée, avec par exemple les taux de marge de chaque produit, les délais de paiement, des recommandations commerciales.
Cette opération peut être effectuée via l’UI mais elle se prête très bien à une demande en langage naturel :
Analyse ma relation client avec John Doe sur l'année passée.Utiliser uniquement l'IA se heurte cependant à plusieurs freins : le domaine métier est complexe, de nombreux calculs doivent être effectués de façon précise, et la plus-value de la fonctionnalité vient de notre expertise interne. Donner les factures à l'IA et la laisser prendre l'intégralité des décisions reste hors de portée.
L’intérêt du pattern intent-first, c’est de proposer une porte d’entrée vers une succession d’étapes déterministes.
Le LLM comprend la demande et route vers l’agent approprié
Dans un premier temps, un agent routeur interprète la demande et sélectionne un agent dédié à cette tâche, dit “d’exécution”.
C’est le premier filtre vers un traitement déterministe : chaque agent d’exécution a accès à un sous-ensemble d’outils, et suit un pipeline précis.
stateDiagram-v2
state "Routage via LLM" as Routage
state fork_state <<fork>>
state "Agent clientelle" as Clientelle {
state "..." as cli_internal
[*] --> cli_internal
cli_internal --> [*]
}
state "Agent facturation" as Facturation {
state "..." as fact_internal
[*] --> fact_internal
fact_internal --> [*]
}
state "Agent analyse commerciale" as AnalyseCommerciale {
state "..." as analysis_internal
[*] --> analysis_internal
analysis_internal --> [*]
}
[*] --> Routage: "Analyse ma relation client avec John Doe sur l'année passée."
Routage --> fork_state: { "agent": "..." }
fork_state --> Clientelle
fork_state --> Facturation
fork_state --> AnalyseCommerciale
Boucle d’exécution
L’agent d’exécution utilise une boucle itérative (contrôlée par le SDK Vercel) qui peut se résumer comme ceci :
stateDiagram-v2
state "Préparation: collecte d'outils et de contexte (prepareStep)" as Preparation
state "Génération LLM" as Generation
state "Appel de l'outil" as Appel
state "Ajout du résultat de l'outil au contexte" as Ajout
state "Résultat final" as Resultat
[*] --> Preparation
Preparation --> Generation
Generation --> Appel: Si la génération a produit un appel d'outil
Generation --> Resultat
Appel --> Ajout
Ajout --> Preparation
Resultat --> [*]
Note : en pratique, d'autres étapes comme la gestion d'erreurs avant et après l'appel d'outil sont automatiquement appliquées par le SDK Vercel.
Nous utilisons le hook prepareStep pour diriger la génération à chaque étape en indiquant au LLM les outils qu'il a à disposition.
onPrepareStep: (options: { steps: StepResult<MyToolSet>[] }): { // en sortie nous voulons une liste d'outils à disposition du LLM activeTools: (keyof MyToolSet)[] // en fonction de nos contraintes on veut pouvoir imposer au LLM l'execution d'un outil // ou au contraire interdire d'utiliser un outil ce qui aura pour effet de sortir de la boucle toolChoice: 'auto' | 'required' | 'none'} => {
// steps est la liste des itérations précédentes passée par vercel const { steps = [] } = options ?? {}Chaque étape de notre workflow définit un ensemble d'outils. On détermine le passage d'une étape à l'autre par la présence d'un résultat d'outil valide.
Étape 1 : récupérer les factures du client
// Etape 1: appeler GET_BILLS_FOR_CUSTOMER if ( !hasToolWithResult( steps, 'GET_BILLS_FOR_CUSTOMER', result => result.output != null )) { return { activeTools: [ 'GET_BILLS_FOR_CUSTOMER', 'SEARCH_CUSTOMER', ], toolChoice: 'required' as const } }Pour la première étape de notre workflow, le LLM a besoin d’une liste de factures depuis la base de données. Il lui serait impossible de répondre sans ces données, on l’oblige donc à les récupérer d’abord.
hasToolWithResult est un helper qui nous permet de détecter un appel d’outil dans une itération précédente et de valider son résultat. Ici, tant que le LLM n’a pas appelé l’outil GET_BILLS_FOR_CUSTOMER, on le force à l’appeler.
On note la présence d'un deuxième outil : SEARCH_CUSTOMER.
L’outil GET_BILLS_FOR_CUSTOMER nécessite un customerId.
Cet identifiant se trouve potentiellement dans le contexte (si la demande intervient au cours d'une discussion plus large sur John Doe), et quand il manque, le LLM doit avoir les moyens de le retrouver.
Le LLM peut choisir de commencer par un SEARCH_CUSTOMER, repoussant l’appel de GET_BILLS_FOR_CUSTOMER à l’itération suivante.
Enfin, toolChoice est passé à required, car à cette étape on sait que le LLM n’a aucune raison de sortir de la boucle. On lui demande donc d’itérer avec les outils fournis.
Étape 2 : appliquer notre logique métier
// Etape 2: appliquer une méthode de calcul métier // pour produire des données exploitables if ( !hasToolWithResult( steps, 'COLLECT_ANALYSIS_DATA', result => result.output != null )) {
// escape hatch: si aucune facture on s'arrête ! const bills = getToolResults( steps, 'GET_BILLS_FOR_CUSTOMER' ).flatMap( result => result.output.items ) if (bills.length === 0) { return { activeTools: [], toolChoice: 'none' as const } }
return { activeTools: ['COLLECT_ANALYSIS_DATA'], toolChoice: 'auto' as const } }Pour la deuxième étape nous allons envoyer les factures trouvées vers notre algorithme afin de produire des données exploitables. C'est dans cet outil que réside l'expertise de la plateforme.
Comme pour la première étape, on utilise hasToolWithResult pour forcer l’itération tant que le résultat attendu n’est pas obtenu.
Avant tout, quand la période ne contient aucune ligne facturable, on doit s’arrêter. Si on laissait le choix au LLM, il y a de fortes chances qu’il essaie de créer une fausse analyse en inventant des factures qui n'existent pas (hallucination).
Le LLM doit aussi faire le lien entre la demande “sur l'année passée” et les dates des factures qu'il a récupérées, il est possible qu'aucune ne corresponde. En passant toolChoice à 'auto', on laisse le LLM sauter l'outil et sortir lui-même de la boucle dans ce cas.
Implémentation des outils
Les outils sont définis avec le protocole MCP et via des schémas écrits avec Zod.
Utiliser .describe() sur les schémas permet d'ajouter des instructions à destination du LLM
pour le guider dans son usage.
export const collectAnalysisDataToolDefinition = defineTool( 'COLLECT_ANALYSIS_DATA', 'Get analytics data from a set of bills.', z.object({ clientId: z.uuid().describe('The ID of the client.'), billIds: z.array(z.number()) .describe(`The IDs of the bills to analyze.\ Send empty array if all bills of the client must be included.` ), }), z.object({ averageSpent: z.number(), maxSpent: z.number(), mostOrderedProducts: z.array(z.object({ productId: z.number(), productName: z.string(), quantity: z.number(), })), averagePaymentDelay: z.number(), customerConfidencePercentage: z.number() }),)Côté implémentation, les erreurs renvoyées au LLM doivent être compréhensibles et informatives. Elles lui permettent de prendre en compte ses erreurs et d'itérer, là où des erreurs génériques conduisent à un abandon rapide, ou pire, à une corruption du contexte, ce qui produira des hallucinations.
export const collectAnalysisData = implementTool( collectAnalysisDataToolDefinition, async (args, extra) => {
const { customerId, billIds } = args
// Guard: vérifier l'existence du client const customer = await DatabaseService.getCustomer(customerId); if (customer == null) { throw new ToolArgumentError(['customerId'], `Customer not found: ${customerId}.\ You may need to use SEARCH_CUSTOMER to find the correct id first.` ) }
// Guard: vérifier la période et les lignes facturables const items = await DatabaseService.getBills(customerId); const invalidItemIds = itemIds.filter( iid => !items.some(i => iid === i.id) ) if (invalidItemIds.length > 0) { throw new ToolArgumentError(['billIds'], `Invalid bill ids: ${invalidItemIds.join(', ')}.\ Possible bill ids are: ${items?.map(i => i.id).join(', ')}.` ) }
return AnalysisService.analyzeBills(customerId, billIds);
// ...Attention tout de même, donner trop d’information peut produire des résultats inattendus. Par exemple ici, si le LLM essaie d'exécuter une analyse avec un identifiant inconnu, il recevra une liste complète d'identifiants valides, il peut essayer de “se débrouiller” avec une facture sans rapport, simplement parce qu’on lui a suggéré que c’était une possibilité. Ce genre de problème se corrige en partie avec un prompt système.
Une autre approche consiste à demander une validation explicite à l'utilisateur grâce à l'élicitation (article à venir).
Rendu via un template prédéterminé
Une fois les outils appelés nous avons des données concrètes provenant de calculs déterministes. On pourrait laisser le LLM finaliser sa boucle et rédiger un message, mais il aurait la possibilité de modifier nos données si durement validées !
À la place nous allons lui demander de générer une phrase d'introduction et de conclusion, nous mettrons nous-mêmes les données en forme dans un template. Les données restent sûres et le format est répétable.
Pour les parties générées de notre message nous utilisons un outil "vide" qui, uniquement par le format de ses arguments, va inciter le LLM à produire les textes dont nous avons besoin.
export const writeAnalysisSummaryToolDefinition = defineTool( 'WRITE_ANALYSIS_SUMMARY', 'Write a short introduction and conclusion for a customer analysis.', z.object({ introductionMessage: z.string() .describe('A short sentence explaining what the analysis covers (period, scope).'), conclusionMessage: z.string() .describe('A summary of the analysis and proposed next steps.'), }), z.object({ introductionMessage: z.string(), conclusionMessage: z.string() }),)
export const writeAnalysisSummary = implementTool( writeAnalysisSummaryToolDefinition, async (args, extra) => { const { introductionMessage, conclusionMessage } = args
return { introductionMessage, conclusionMessage } })Cet appel a pour seul but de produire dans le contexte des valeurs qui seront incluses dans le template de réponse.
On laisse ensuite l'itération se terminer. Le message produit n'a pas d'importance et sera remplacé par l'exécution du template suivant :
{{ introductionMessage }}
## Analytics
| Dépense moyenne | Dépense maximale | Délai de paiement moyen | Indice de confiance || --------------- | ---------------- | ----------------------- | ------------------- || {{ averageSpent }} | {{ maxSpent }} | {{ averagePaymentDelay }} | {{ customerConfidencePercentage }} |
## Produits les plus commandés
| Produit | Quantité || ------------- | ------------- || {{ mostOrderedProducts[0].productName }} | {{ mostOrderedProducts[0].quantity }} || {{ mostOrderedProducts[1].productName }} | {{ mostOrderedProducts[1].quantity }} || {{ mostOrderedProducts[2].productName }} | {{ mostOrderedProducts[2].quantity }} |
## Conclusion{{ conclusionMessage }}Conclusion
Le produit piloté par l’intention apporte une expérience utilisateur très directe, surtout quand l’action “à demander” est plus simple à exprimer qu’à chercher dans une UI. La fiabilité vient d’une idée simple : laisser le langage naturel ouvrir la porte, puis faire avancer l’utilisateur dans un workflow strict, explicite, vérifiable et observable.
Ce paradigme se prête bien aux applications métier et se combine avec un cadrage solide des règles et des responsabilités (voir aussi cadrage projet).
Questions fréquentes
En quoi ce pattern est-il différent d’un chatbot “classique” ?
Un chatbot “classique” vise surtout à répondre en texte, avec une confiance totale faite au LLM sur la logique métier. Un produit piloté par l’intention transforme la demande en actions typées exécutées par le système, avec des validations métier et une traçabilité. L’IA sert d’interface et de routeur, l’exécution reste cadrée.
Quand une interface en langage naturel vaut-elle mieux qu’une UI traditionnelle ?
Une interface intent-first est pertinente quand l’utilisateur connaît le résultat souhaité (“créer une facture”, “créer un client”, “générer un rapport”) et que le chemin UI serait long ou fragmenté. Quand le besoin est exploratoire ou visuel (comparaison fine, navigation, édition), une UI dédiée reste souvent plus efficace.
Comment garder une logique métier stricte avec un LLM ?
La logique métier reste dans une couche déterministe : schémas d’actions, validations, contrôles d’accès, state machine, et confirmation au point d’engagement. Les sorties structurées aident à limiter l’ambiguïté (voir structured outputs Mistral).
Comment éviter qu’un agent appelle le mauvais outil au mauvais moment ?
Une approche efficace consiste à limiter les tools disponibles à chaque step. Par exemple, au début seuls des outils de recherche et de lecture sont autorisés. Les outils d’écriture ne deviennent disponibles qu’après validation et confirmation.
Où placer la confirmation utilisateur ?
La confirmation se place au point d’engagement : écriture en base, transaction, envoi, publication. MCP propose un mécanisme standard pour cela via l’elicitation (elicitation MCP).
Ce pattern est-il compatible avec des modèles plus petits et moins coûteux ?
Oui, parce qu’il réduit la part “ouverte” du problème. Le système guide le modèle via un ensemble d’actions typées, des étapes explicites et des validations déterministes. Les résultats restent stables même quand la capacité de raisonnement du modèle est limitée.
Comment tester un produit piloté par l’intention ?
Le test se fait au niveau des workflows : jeux de prompts réalistes, cas limites (données manquantes, ambiguïtés), erreurs outils, et mesures de taux de succès par step. Une approche progressive de type POC permet de cadrer les risques avant une mise en production.