Technical Guide

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.

10 juillet 202518 min de lectureEquipe d'Ingenierie Oronts

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-traitementQuand l'utiliserExemple
ChunkingDocuments longs depassant les limites de contexteDecouper un PDF de 100 pages en chunks de 2000 tokens
EnrichissementContexte supplementaire necessaireAjouter l'historique client avant traitement du ticket
ExtractionSeules certaines parties sont pertinentesExtraire juste le champ "description" d'un payload JSON
TransformationConversion de format necessaireConvertir HTML en markdown pour un traitement plus propre
DeduplicationLe contenu repete gaspille des tokensSupprimer 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 :

PatternUtiliser quandExemple
Chaine sequentielleChaque etape depend de la sortie precedenteComprendre → Planifier → Executer
Execution paralleleLes etapes sont independantesAnalyser plusieurs documents simultanement
Map-ReduceTraiter des elements puis agregerResumer chaque chunk, puis combiner les resumes
Branchement conditionnelDifferents chemins pour differentes entreesRequetes 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'erreurStrategie de retryMax retriesBackoff
Rate limitAttendre et reessayer5Exponentiel avec jitter
TimeoutReessayer immediatement3Lineaire
Reponse invalideReessayer avec feedback2Aucun
Modele refuseReformuler et reessayer2Aucun
Erreur serveurAttendre et reessayer3Exponentiel
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

MetriquePourquoi c'est importantSeuil d'alerte
Latence (p50, p95, p99)Experience utilisateur, risque de timeoutp95 > 10s
Taux de succesSante globale< 95%
Utilisation de tokensControle des couts> 150% de la baseline
Taux de retryInstabilite cachee> 10%
Distribution des classificationsDetecter la deriveChangement significatif par rapport a la baseline
Scores de qualite de sortieAttraper la degradationMoyenne < 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

conception workflow IAarchitecture pipeline IAchainage de promptsgestion erreurs IAorchestration LLMautomatisation IAbranchement workflowingenierie systemes IA

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