Concevoir des Workflows IA qui fonctionnent en production
Un guide pratique pour construire des pipelines IA robustes. Apprends l'architecture de pipeline, le sequencement des etapes, la logique de branchement, la gestion des erreurs et le chainage de prompts par des ingenieurs qui ont deploye ces systemes.
Pourquoi la plupart des workflows IA echouent en production
Voici quelque chose qu'on a appris a nos depens : faire faire a une IA quelque chose d'impressionnant en demo, c'est facile. Lui faire faire la meme chose de maniere fiable, des milliers de fois par jour, avec des donnees reelles et des cas limites reels ? C'est la que la plupart des equipes se heurtent a un mur.
On a construit des workflows IA pour le traitement de documents, l'automatisation du support client, la generation de contenu et l'analyse de donnees. En chemin, on a fait toutes les erreurs imaginables. Ce guide est ce qu'on aurait aime qu'on nous dise avant de commencer.
La difference entre un prototype et un systeme de production, ce n'est pas le modele que tu utilises. C'est tout ce qu'il y a autour.
Laisse-moi te montrer comment on concoit vraiment les workflows IA maintenant, apres avoir appris ces lecons.
L'anatomie d'un pipeline IA de production
Imagine un workflow IA comme une serie d'etapes de traitement, chacune avec un job specifique. Voici la structure de base qu'on utilise :
Entree → Validation → Pre-traitement → Traitement IA → Post-traitement → Validation → Sortie
↓ ↓ ↓ ↓ ↓
[Gestion] [Transformation] [Appel LLM] [Parsing] [Controle
Erreur] + Enrichissement + Outils + Format Qualite]
Chaque etape a des entrees, sorties et modes d'echec clairs. Decortiquons-les.
Etape 1 : Validation et normalisation des entrees
Ne fais jamais confiance aux donnees entrantes. Jamais. On l'a appris quand un client nous a envoye un "document texte" qui etait en fait un fichier binaire de 50 Mo. Le pipeline s'est etouffe, la file d'attente s'est engorgee, et on a passe un weekend a reparer.
const validateInput = async (input) => {
const checks = {
exists: input !== null && input !== undefined,
sizeOk: Buffer.byteLength(input, 'utf8') < MAX_INPUT_SIZE,
formatOk: isValidFormat(input),
contentOk: !containsMaliciousPatterns(input)
};
const failures = Object.entries(checks)
.filter(([_, passed]) => !passed)
.map(([check]) => check);
if (failures.length > 0) {
throw new ValidationError(`Entree echouee : ${failures.join(', ')}`);
}
return normalizeInput(input);
};
Ce qu'il faut verifier :
- Limites de taille de fichier (mets-les plus basses que tu ne le penses)
- Validation du format (c'est vraiment du JSON, pas juste nomme
.json?) - Encodage des caracteres (les problemes UTF-8 vont te hanter)
- Securite du contenu (n'envoie pas de contenu malveillant a ton LLM)
Etape 2 : Pre-traitement et enrichissement
Les entrees brutes vont rarement directement a un LLM. Tu dois generalement les transformer, ajouter du contexte ou les decouper en morceaux.
| Tache de pre-traitement | Quand l'utiliser | Exemple |
|---|---|---|
| Chunking | Documents longs depassant les limites de contexte | Decouper un PDF de 100 pages en chunks de 2000 tokens |
| Enrichissement | Contexte supplementaire necessaire | Ajouter l'historique client avant traitement du ticket |
| Extraction | Seules certaines parties sont pertinentes | Extraire juste le champ "description" d'un payload JSON |
| Transformation | Conversion de format necessaire | Convertir HTML en markdown pour un traitement plus propre |
| Deduplication | Le contenu repete gaspille des tokens | Supprimer les paragraphes en double du contenu scrappe |
Voici une strategie de chunking qu'on utilise vraiment :
const chunkDocument = (text, options = {}) => {
const {
maxTokens = 2000,
overlap = 200,
preserveParagraphs = true
} = options;
const chunks = [];
let currentChunk = '';
const paragraphs = text.split(/\n\n+/);
for (const para of paragraphs) {
const combined = currentChunk + '\n\n' + para;
if (estimateTokens(combined) > maxTokens) {
if (currentChunk) {
chunks.push(currentChunk.trim());
// Garder le chevauchement pour la continuite du contexte
currentChunk = getLastNTokens(currentChunk, overlap) + '\n\n' + para;
} else {
// Paragraphe unique trop long, split force
chunks.push(...forceSplitParagraph(para, maxTokens));
currentChunk = '';
}
} else {
currentChunk = combined;
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
};
Le chevauchement est crucial. Sans lui, les informations qui couvrent les limites de chunks sont perdues. On l'a appris quand notre summarizer de documents ratait constamment des points cles qui tombaient entre les chunks.
Concevoir le coeur du traitement IA
C'est la que les appels LLM reels se produisent. Mais un seul appel suffit rarement pour les taches complexes.
Le pattern Prompt Chain
Au lieu d'un prompt geant qui essaie de tout faire, decoupe les taches complexes en etapes ciblees :
[Comprendre] → [Planifier] → [Executer] → [Verifier] → [Formater]
Exemple : Traitement d'une reclamation client
const processComplaint = async (complaint) => {
// Etape 1 : Comprendre - Extraire les informations cles
const analysis = await llm.call({
system: `Extrais des informations structurees des reclamations clients.
Retourne du JSON avec : issue_type, urgency, customer_emotion, key_details`,
user: complaint
});
// Etape 2 : Planifier - Determiner la strategie de reponse
const strategy = await llm.call({
system: `Base sur cette analyse de reclamation, determine la strategie de reponse.
Considere : options de resolution, besoins d'escalade, eligibilite compensation`,
user: JSON.stringify(analysis)
});
// Etape 3 : Executer - Generer la reponse
const response = await llm.call({
system: `Ecris une reponse client suivant cette strategie.
Ton : empathique, professionnel. Longueur : 2-3 paragraphes.`,
user: `Strategie : ${JSON.stringify(strategy)}\nReclamation originale : ${complaint}`
});
// Etape 4 : Verifier - Controle qualite
const verification = await llm.call({
system: `Examine cette reponse client. Verifie :
- Repond a toutes les preoccupations ? - Ton approprie ? - Prochaines etapes actionnables ?
Retourne : {approved: boolean, issues: string[]}`,
user: response
});
if (!verification.approved) {
return await regenerateWithFeedback(response, verification.issues);
}
return response;
};
Chaque etape est plus simple, plus testable et plus facile a debugger. Quand quelque chose ne va pas, tu sais exactement quelle etape a echoue.
Quand chainer vs. quand paralleliser
Tout n'a pas besoin d'etre sequentiel. Voici comment on decide :
| Pattern | Utiliser quand | Exemple |
|---|---|---|
| Chaine sequentielle | Chaque etape depend de la sortie precedente | Comprendre → Planifier → Executer |
| Execution parallele | Les etapes sont independantes | Analyser plusieurs documents simultanement |
| Map-Reduce | Traiter des elements puis agreger | Resumer chaque chunk, puis combiner les resumes |
| Branchement conditionnel | Differents chemins pour differentes entrees | Requetes simples vs. analyse complexe |
Exemple d'execution parallele :
const analyzeDocuments = async (documents) => {
// Traiter tous les documents en parallele
const analyses = await Promise.all(
documents.map(doc => analyzeDocument(doc))
);
// Reduce : combiner en rapport final
const combinedReport = await llm.call({
system: 'Synthetise ces analyses individuelles en un rapport coherent',
user: analyses.map((a, i) => `Document ${i + 1}:\n${a}`).join('\n\n---\n\n')
});
return combinedReport;
};
Logique de branchement : Router vers le bon handler
Les entrees du monde reel varient enormement. Un "message client" peut etre une reclamation, une question, un compliment ou du spam. Chacun necessite un traitement different.
Routage base sur la classification
const routeCustomerMessage = async (message) => {
const classification = await llm.call({
system: `Classe ce message client dans exactement une categorie :
- complaint : Client exprimant son mecontentement
- question : Client cherchant des informations
- feedback : Retours generaux ou suggestions
- urgent : Problemes de securite, menaces legales, escalade executive
- spam : Messages non pertinents ou automatises
Retourne uniquement le nom de la categorie.`,
user: message
});
const handlers = {
complaint: handleComplaint,
question: handleQuestion,
feedback: handleFeedback,
urgent: escalateToHuman,
spam: markAsSpam
};
const handler = handlers[classification.toLowerCase()] || handleUnknown;
return await handler(message);
};
Routage base sur la confiance
Parfois l'IA n'est pas sure. Integre cette incertitude dans ton routage :
const routeWithConfidence = async (input) => {
const result = await llm.call({
system: `Analyse et route cette requete. Retourne du JSON :
{
"category": "string",
"confidence": 0.0-1.0,
"reasoning": "pourquoi cette categorie"
}`,
user: input
});
if (result.confidence < 0.7) {
// Faible confiance - obtenir input humain ou utiliser fallback
return await handleLowConfidence(input, result);
}
if (result.confidence < 0.9 && isHighStakes(result.category)) {
// Confiance moyenne sur decisions importantes - verifier
return await verifyThenRoute(input, result);
}
// Haute confiance - proceder automatiquement
return await routeToHandler(result.category, input);
};
Gestion des erreurs : Parce que les choses vont casser
Les workflows IA echouent de manieres que les logiciels traditionnels ne connaissent pas. Le LLM peut retourner du JSON invalide, halluciner des informations, ou simplement... ne pas suivre les instructions. Planifie pour ca.
La hierarchie des erreurs
class AIWorkflowError extends Error {
constructor(message, stage, recoverable = true) {
super(message);
this.stage = stage;
this.recoverable = recoverable;
}
}
class ValidationError extends AIWorkflowError {
constructor(message) {
super(message, 'validation', true);
}
}
class LLMError extends AIWorkflowError {
constructor(message, type) {
super(message, 'llm_call', type !== 'rate_limit');
this.type = type; // 'timeout', 'rate_limit', 'invalid_response', 'refused'
}
}
class OutputError extends AIWorkflowError {
constructor(message) {
super(message, 'output', true);
}
}
Strategies de retry qui fonctionnent
Differentes erreurs necessitent differentes approches de retry :
| Type d'erreur | Strategie de retry | Max retries | Backoff |
|---|---|---|---|
| Rate limit | Attendre et reessayer | 5 | Exponentiel avec jitter |
| Timeout | Reessayer immediatement | 3 | Lineaire |
| Reponse invalide | Reessayer avec feedback | 2 | Aucun |
| Modele refuse | Reformuler et reessayer | 2 | Aucun |
| Erreur serveur | Attendre et reessayer | 3 | Exponentiel |
const withRetry = async (operation, options = {}) => {
const {
maxRetries = 3,
backoffMs = 1000,
backoffMultiplier = 2,
shouldRetry = () => true
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (!shouldRetry(error) || attempt === maxRetries) {
throw error;
}
const delay = backoffMs * Math.pow(backoffMultiplier, attempt);
const jitter = Math.random() * delay * 0.1;
await sleep(delay + jitter);
}
}
throw lastError;
};
// Utilisation avec les appels LLM
const safeLLMCall = async (params) => {
return await withRetry(
() => llm.call(params),
{
maxRetries: 3,
shouldRetry: (error) => {
if (error.type === 'rate_limit') return true;
if (error.type === 'timeout') return true;
if (error.type === 'server_error') return true;
return false;
}
}
);
};
Auto-guerison pour les reponses invalides
Quand le LLM retourne des dechets, parfois tu peux les reparer :
const parseWithRecovery = async (llmResponse, expectedSchema) => {
// Premiere tentative : parsing direct
try {
const parsed = JSON.parse(llmResponse);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// Parsing JSON echoue, continuer vers la recuperation
}
// Tentative de recuperation : demander au LLM de corriger sa sortie
const fixed = await llm.call({
system: `La reponse suivante devrait etre du JSON valide correspondant a ce schema :
${JSON.stringify(expectedSchema)}
Corrige la reponse en JSON valide. Retourne UNIQUEMENT le JSON corrige.`,
user: llmResponse
});
try {
const parsed = JSON.parse(fixed);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// Toujours casse
}
// Fallback final : extraire ce qu'on peut
return extractPartialData(llmResponse, expectedSchema);
};
Monitoring et observabilite
Tu ne peux pas reparer ce que tu ne vois pas. Voici ce qu'on trace :
Metriques cles
| Metrique | Pourquoi c'est important | Seuil d'alerte |
|---|---|---|
| Latence (p50, p95, p99) | Experience utilisateur, risque de timeout | p95 > 10s |
| Taux de succes | Sante globale | < 95% |
| Utilisation de tokens | Controle des couts | > 150% de la baseline |
| Taux de retry | Instabilite cachee | > 10% |
| Distribution des classifications | Detecter la derive | Changement significatif par rapport a la baseline |
| Scores de qualite de sortie | Attraper la degradation | Moyenne < 0.8 |
Logging structure
Chaque execution de workflow devrait produire des logs tracables :
const runWorkflow = async (input, context) => {
const runId = generateRunId();
const startTime = Date.now();
const log = (stage, data) => {
logger.info({
runId,
stage,
timestamp: Date.now(),
elapsed: Date.now() - startTime,
...data
});
};
try {
log('start', { inputSize: input.length });
const validated = await validate(input);
log('validated', { valid: true });
const processed = await process(validated);
log('processed', {
tokensUsed: processed.usage.total,
model: processed.model
});
const output = await format(processed);
log('complete', {
success: true,
outputSize: output.length,
totalTime: Date.now() - startTime
});
return output;
} catch (error) {
log('error', {
error: error.message,
stage: error.stage,
recoverable: error.recoverable
});
throw error;
}
};
Tout assembler : Un exemple complet
Construisons un workflow d'analyse de documents qui utilise tout ce dont on a parle :
class DocumentAnalysisPipeline {
constructor(options = {}) {
this.maxChunkSize = options.maxChunkSize || 3000;
this.concurrency = options.concurrency || 5;
}
async run(document) {
// Etape 1 : Valider
const validated = await this.validate(document);
// Etape 2 : Pre-traiter
const chunks = await this.preprocess(validated);
// Etape 3 : Analyse parallele avec controle de concurrence
const analyses = await this.analyzeChunks(chunks);
// Etape 4 : Synthetiser
const synthesis = await this.synthesize(analyses);
// Etape 5 : Controle qualite
const final = await this.qualityCheck(synthesis, document);
return final;
}
async validate(document) {
if (!document || typeof document !== 'string') {
throw new ValidationError('Le document doit etre une chaine non vide');
}
if (document.length > 1000000) {
throw new ValidationError('Le document depasse la taille maximale');
}
return document;
}
async preprocess(document) {
return chunkDocument(document, {
maxTokens: this.maxChunkSize,
overlap: 200
});
}
async analyzeChunks(chunks) {
const results = [];
// Traiter par lots pour controler la concurrence
for (let i = 0; i < chunks.length; i += this.concurrency) {
const batch = chunks.slice(i, i + this.concurrency);
const batchResults = await Promise.all(
batch.map((chunk, idx) => this.analyzeChunk(chunk, i + idx))
);
results.push(...batchResults);
}
return results;
}
async analyzeChunk(chunk, index) {
return await withRetry(async () => {
const result = await llm.call({
system: `Analyse cette section de document. Extrais :
- Themes et sujets cles
- Faits et chiffres importants
- Citations ou declarations notables
- Questions ou lacunes d'information
Retourne du JSON structure.`,
user: `Section ${index + 1}:\n\n${chunk}`
});
return parseWithRecovery(result, ANALYSIS_SCHEMA);
});
}
async synthesize(analyses) {
const combined = analyses.map((a, i) =>
`Section ${i + 1}:\n${JSON.stringify(a, null, 2)}`
).join('\n\n---\n\n');
return await llm.call({
system: `Synthetise ces analyses de sections en un resume de document complet.
Structure :
1. Resume executif (2-3 phrases)
2. Conclusions cles (puces)
3. Details importants
4. Lacunes ou questions
5. Recommandations`,
user: combined
});
}
async qualityCheck(synthesis, originalDocument) {
const check = await llm.call({
system: `Examine cette analyse pour la qualite. Verifie :
- Precision : Reflete-t-elle le document original ?
- Completude : Les points majeurs sont-ils couverts ?
- Clarte : Est-elle bien organisee et claire ?
Retourne : {score: 0-1, issues: string[], approved: boolean}`,
user: `Analyse:\n${synthesis}\n\nOriginal (premiers 2000 caracteres):\n${originalDocument.slice(0, 2000)}`
});
if (!check.approved) {
// Logger pour revision mais ne pas echouer
logger.warn({ issues: check.issues, score: check.score });
}
return {
analysis: synthesis,
qualityScore: check.score,
qualityIssues: check.issues
};
}
}
Pieges courants et comment les eviter
Apres avoir construit des dizaines de ces systemes, voici les erreurs qu'on voit le plus souvent :
1. Pas de limites de timeout
Chaque appel LLM a besoin d'un timeout. Definis-les agressivement.
// Mauvais : Pas de timeout
const result = await llm.call(params);
// Bon : Timeout explicite
const result = await Promise.race([
llm.call(params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 30000)
)
]);
2. Ignorer les limites de tokens
Trace l'utilisation et definis des budgets :
const tokenBudget = {
max: 10000,
used: 0,
canSpend(amount) {
return this.used + amount <= this.max;
},
spend(amount) {
this.used += amount;
if (this.used > this.max * 0.8) {
logger.warn('Budget de tokens consomme a 80%');
}
}
};
3. Pas de fallback pour les chemins critiques
Aie toujours un plan B :
const processWithFallback = async (input) => {
try {
return await primaryProcess(input);
} catch (error) {
if (error.recoverable) {
return await simplifiedProcess(input);
}
// Chemin critique - mettre en file pour traitement manuel
await queueForManualReview(input, error);
return { status: 'queued_for_review' };
}
};
Quelle est la suite
Les workflows IA deviennent de plus en plus sophistiques. Voici ou on voit les choses aller :
- Meilleure utilisation des outils : Les modeles s'ameliorent pour decider quand et comment utiliser des outils externes
- Contexte plus long : Des fenetres de contexte plus grandes signifient moins de maux de tete de chunking
- Inference plus rapide : La latence diminue, permettant des workflows temps reel plus complexes
- Modeles specialises : Les modeles fine-tunes pour des taches specifiques surpassent les modeles polyvalents
Mais les fondamentaux ne changent pas. Valide les entrees, gere les erreurs elegamment, surveille tout et aie toujours un fallback. Construis sur ces principes, et tes workflows IA survivront au contact avec le monde reel.
Si tu construis des workflows IA et que tu te heurtes a des murs, on a probablement deja vu ton probleme. Contacte-nous, et trouvons la solution ensemble.
Topics covered
Ready to implement agentic AI?
Our team specializes in building production-ready AI systems. Let's discuss how we can help you leverage agentic AI for your enterprise.
Start a conversation