Construire un agent IA fiable : le pattern intent-first, du langage naturel aux actions métier déterministes
- L’entrée en langage naturel est une porte d’accès, pas une délégation totale : l’exécution peut rester 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 apparaîssent 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
Ce qui engage l’entreprise reste déterministe :
- validation métier
- contrôle d’accès
- calculs
- appels API
- écritures en base
L’IA devient une porte d’accès, un routeur, et non pas un moteur d’exécution libre.

É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 recommendations 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.
Il y a cependant des freins à utiliser uniquement l’IA: 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é viens de notre expertise interne, on ne peut pas juste donner les factures et laisser l’IA prendre l’intégralité des décisions.
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éfini 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.
Etape 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, donc on ne le laisse pas avancer sans.
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), mais s’il n’y est pas, 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.
Etape 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, dans le cas où 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).
Par ailleurs le LLM doit faire le lien entre la demande “sur l’année passée” et les dates de factures qu’il a récupéré, il est possible qu’aucune ne corresponde. En passant toolChoice à ‘auto’, on laisse la possibilité au LLM de ne pas appeler l’outil et donc de sortir lui-même de la boucle dans ce cas.
Implémentation des outils
Les outils sont défini avec le protocol MCP et via des schéma écrits avec Zod.
L’utilisation de .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, il est très important de retourner des erreurs compréhensibles et informatives pour le LLM. Celà lui permet 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 essai d’executer 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ême les données en forme dans un template. Ainsi les données sont 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 inclues 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’execution 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 naturellement avec un cadrage solide des règles et des responsabilités (voir aussi cadrage projet).