Intelligence Artificielle angularforall.com

- Promptfoo : tester et benchmarker les prompts LLM en CI

Promptfoo Evaluation-Llm Tests-Llm Prompt-Engineering Llm-As-Judge Ci-Cd Github-Actions Llm Openai Anthropic Benchmarking-Ia Qualite-Prompts Ia-Generative Yaml Regression-Tests
Promptfoo : tester et benchmarker les prompts LLM en CI

Testez et benchmarkez vos prompts LLM avec Promptfoo : YAML de tests, assertions LLM-as-judge, comparaison de modèles côte à côte et intégration GitHub Actions.

Pourquoi tester ses prompts comme du code

Un prompt LLM est du code : il a un input (variables), une logique (instructions, exemples), et un output attendu. Sans tests, vous ne saurez pas qu'une modification de prompt casse 30 % de vos cas d'usage avant qu'un utilisateur le signale. Pire, OpenAI peut déprécier gpt-4o-2024-08-06 et basculer vers une version où votre prompt ne fonctionne plus de la même façon — sans que vous changiez une ligne.

Promptfoo apporte au prompt engineering ce que Jest a apporté au code : déclaration de cas de test, exécution en lot, comparaison de résultats, intégration CI. Vous gagnez trois choses :

  • Régression détectée : modifier un mot dans le system prompt et voir immédiatement quels tests cassent.
  • Choix de modèle objectif : tableau de score gpt-4o vs gpt-4o-mini vs claude-haiku sur votre dataset.
  • Budget contrôlé : coût et latence calculés pour chaque modèle, sur le même set de cas.
Quand commencer : dès qu'un prompt a 3+ cas d'usage distincts ou est appelé en production. Le coût d'écriture d'une suite Promptfoo (1-2 jours pour 50 cas) est largement amorti dès le premier incident évité.

Installation et premier promptfooconfig

Promptfoo se distribue en CLI Node.js. Pas de service à héberger pour démarrer — les résultats sont stockés en local (option de cloud sync disponible).

# Installation globale (ou via npx)
npm install -g promptfoo

# Initialisation : cree promptfooconfig.yaml + README
promptfoo init

# Lancer les tests
promptfoo eval

# Visualiser le rapport HTML interactif
promptfoo view
# promptfooconfig.yaml - Premier fichier minimal
description: "Classification d'intent support client"

prompts:
  - |
    Tu es un classificateur d'intent.
    Categories : facturation, technique, commercial, autre.
    Reponds UNIQUEMENT par le nom de la categorie.
    Message : {{message}}

providers:
  - openai:gpt-4o-mini

tests:
  - vars:
      message: "Ma derniere facture est incorrecte, vous avez double le montant."
    assert:
      - type: equals
        value: facturation

  - vars:
      message: "Comment annuler mon abonnement ?"
    assert:
      - type: equals
        value: commercial

  - vars:
      message: "Le bouton de connexion ne fonctionne plus depuis hier."
    assert:
      - type: equals
        value: technique

Lancement : OPENAI_API_KEY=sk-... promptfoo eval. Promptfoo exécute chaque test, applique les assertions, et affiche un tableau dans le terminal : 3/3 ✓, ou la liste précise des cas qui échouent.

Anatomie d'un test : prompts, providers, assertions

Quatre concepts couvrent 95 % des cas : prompts (templates avec variables), providers (modèles à interroger), tests (jeux de variables + assertions), et defaultTest (assertions appliquées partout).

# promptfooconfig.yaml - Structure complete
description: "Resumes d'articles techniques"

# 1. PROMPTS : peuvent etre inline ou dans des fichiers
prompts:
  # Variante A - inline
  - |
    Resume cet article en 3 phrases, ton professionnel, sans markdown.
    Article : {{article}}

  # Variante B - chargee depuis un fichier
  - file://prompts/resume-bullet.txt

# 2. PROVIDERS : modeles a tester, avec parametres
providers:
  - id: openai:gpt-4o-mini
    config:
      temperature: 0
      max_tokens: 200

  - id: anthropic:messages:claude-3-5-haiku-latest
    config:
      temperature: 0
      max_tokens: 200

# 3. defaultTest : assertions appliquees a TOUS les tests
defaultTest:
  assert:
    - type: latency
      threshold: 3000              # echoue si > 3 secondes
    - type: cost
      threshold: 0.002             # echoue si > 0.2 cents par appel

# 4. TESTS : variables + assertions specifiques
tests:
  - description: "Article technique court"
    vars:
      article: |
        Angular 17 introduit les Signals, un nouveau systeme reactif
        plus performant que les Subjects RxJS pour la majorite des cas.
    assert:
      - type: contains-any
        value: ["Signals", "Angular", "reactif"]
      - type: not-contains
        value: ["**", "##", "- "]   # pas de markdown
      - type: javascript
        value: output.split('.').length >= 3 && output.split('.').length <= 5

  - description: "Article vide - cas limite"
    vars:
      article: ""
    assert:
      - type: contains-any
        value: ["vide", "aucun contenu", "pas de texte"]
Variables et fixtures : Les vars peuvent aussi être chargées depuis un CSV (tests: file://tests.csv) — pratique pour scaler de 10 à 500 cas sans gonfler le YAML.

Assertions déterministes : contains, regex, JSON schema

Avant de payer un LLM-as-judge, épuisez les assertions gratuites. Elles couvrent la majorité des contrats : format, présence d'éléments clés, structure de données.

TypeUsageCoût
equalsÉgalité stricte (classification)Gratuit
contains / not-containsPrésence d'un mot ou phraseGratuit
contains-any / contains-allListe de mots-clésGratuit
regexPattern précis (format date, ID...)Gratuit
is-jsonSortie JSON valideGratuit
json-schemaJSON conforme à un schéma AjvGratuit
javascriptFonction JS custom (max flexibilité)Gratuit
similarSimilarité d'embedding ≥ seuil1 appel embedding
llm-rubricÉvaluation qualitative LLM-as-judge1 appel LLM
# Exemples d'assertions deterministes
tests:
  - description: "Extraction d'email JSON"
    vars:
      texte: "Contactez-nous : support@acme.io ou 06 12 34 56 78"
    assert:
      - type: is-json
      - type: json-schema
        value:
          type: object
          properties:
            email: { type: string, format: email }
            phone: { type: string }
          required: [email, phone]
          additionalProperties: false
      - type: javascript
        # Acces a `output` (string), `context.vars`, `context.prompt`
        value: |
          const parsed = JSON.parse(output);
          return parsed.email === 'support@acme.io';

  - description: "Format date FR strict"
    vars:
      input: "Reunion le 15 mars 2026 a 14h"
    assert:
      - type: regex
        value: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}'   # ISO 8601 attendu

LLM-as-judge et rubrique d'évaluation

Quand le critère est qualitatif — "le ton est-il professionnel ?", "la réponse est-elle utile ?", "le résumé capture-t-il les idées clés ?" — un LLM-as-judge évalue selon une rubrique en langage naturel. Coût : 1 appel par test ; précision : 80-90 % d'accord avec un humain selon les benchmarks Microsoft.

# Assertion llm-rubric : evaluation qualitative
tests:
  - description: "Reponse support empathique"
    vars:
      message: "Je suis tres frustre, ca fait 3 fois que je signale ce bug."
    assert:
      - type: llm-rubric
        # Le LLM-juge note sur 1-5 selon ces criteres
        value: |
          La reponse doit :
          - Reconnaitre la frustration de l'utilisateur explicitement
          - Eviter le jargon technique
          - Proposer une etape concrete (escalade, callback, ticket)
          - Ne pas commencer par "Je comprends" (formule cliche)
        # Threshold : note >= 0.7 sur 1.0
        threshold: 0.7

  # Personnaliser le modele juge - utile pour reduire les couts
  - description: "Resume factuel"
    vars:
      article: "Long article de 3000 mots..."
    assert:
      - type: llm-rubric
        value: "Le resume couvre-t-il les 3 idees principales sans hallucination ?"
        provider: openai:gpt-4o-mini   # juge moins cher
        threshold: 0.8
Anti-pattern LLM-as-judge : Utiliser le même modèle pour générer et évaluer. Le modèle a tendance à valider ses propres sorties (biais d'auto-affirmation documenté). Utilisez un modèle différent ou plus capable pour le juge.

Comparer plusieurs modèles côte à côte

Le vrai pouvoir de Promptfoo : exécuter les mêmes tests sur plusieurs modèles et obtenir un tableau de score, latence et coût. C'est ce qui transforme un choix de modèle subjectif ("GPT-4o me semble meilleur") en décision data-driven.

# promptfooconfig.yaml - Comparatif 4 modeles sur 50 cas
description: "Benchmark RAG sur documentation interne"

prompts:
  - file://prompts/rag-system.txt

providers:
  - id: openai:gpt-4o-2024-08-06
    label: "GPT-4o (premium)"
    config:
      temperature: 0

  - id: openai:gpt-4o-mini
    label: "GPT-4o-mini"
    config:
      temperature: 0

  - id: anthropic:messages:claude-3-5-haiku-latest
    label: "Claude 3.5 Haiku"
    config:
      temperature: 0

  - id: mistral:mistral-small-latest
    label: "Mistral Small"
    config:
      temperature: 0

# Tests charges depuis un CSV (1 ligne = 1 cas)
tests: file://tests/rag-cases.csv

defaultTest:
  assert:
    - type: contains-any
      value: ["{{expected_keyword}}"]
    - type: llm-rubric
      value: "La reponse cite-t-elle au moins 1 source de la base de connaissance ?"
      threshold: 0.7
    - type: latency
      threshold: 5000
# Lancement avec rapport HTML
promptfoo eval -c promptfooconfig.yaml
promptfoo view --port 15500

# Export CSV pour analyse spreadsheet
promptfoo eval --output report.csv

# Sortie typique dans le terminal :
# Provider                  Pass    Fail   Pass%   Avg latency   Total cost
# GPT-4o (premium)          48/50   2      96%     1.4s          $0.087
# GPT-4o-mini               45/50   5      90%     0.9s          $0.005
# Claude 3.5 Haiku          47/50   3      94%     0.8s          $0.012
# Mistral Small             43/50   7      86%     1.1s          $0.003

La grille de décision devient évidente : Mistral Small économise 96 % vs GPT-4o pour 10 % de qualité en moins — acceptable pour les requêtes en lot, refusé pour le support critique. Claude Haiku offre le meilleur rapport qualité-latence-prix sur ce benchmark précis.

Tester votre vrai endpoint HTTP

Tester le LLM brut n'est qu'une partie du problème. En production, vous appelez votre propre endpoint NestJS ou Express qui embarque le system prompt, les guardrails, le RAG, le post-processing. Le provider HTTP de Promptfoo cible cet endpoint réel.

# Provider HTTP - teste votre vrai backend
providers:
  - id: https
    label: "Mon API support"
    config:
      url: https://api.acme.io/v1/agents/support
      method: POST
      headers:
        Content-Type: application/json
        Authorization: Bearer {{env.API_TOKEN}}
      body:
        userMessage: "{{message}}"
        userId: "{{userId}}"
        tenantId: "{{tenantId}}"
      # Extrait la valeur a comparer depuis la reponse JSON
      transformResponse: 'json.answer'

tests:
  - vars:
      message: "Ma facture du mois dernier est introuvable."
      userId: "test-user-1"
      tenantId: "acme"
    assert:
      - type: contains-any
        value: ["facture", "espace client", "telecharger"]
      - type: not-contains
        value: ["erreur", "500", "internal"]
      - type: llm-rubric
        value: "La reponse propose-t-elle une action concrete ?"
        threshold: 0.8
// custom-provider.ts - Encore plus de controle avec un provider JS
// Sauvegarder dans providers/my-agent.ts puis referencer dans le YAML
import type { ApiProvider, ProviderResponse } from 'promptfoo';

export default class MyAgentProvider implements ApiProvider {
    id() { return 'my-agent'; }

    async callApi(prompt: string, context?: any): Promise<ProviderResponse> {
        // Appel a votre logique custom - SDK interne, agent LangGraph, etc.
        const start = Date.now();
        const result = await myAgentGraph.invoke({
            messages: [{ role: 'user', content: prompt }]
        }, { configurable: { thread_id: `eval-${context?.test?.idx}` } });

        return {
            output: result.messages.at(-1)?.content as string,
            tokenUsage: {
                total: result.totalTokens,
                prompt: result.promptTokens,
                completion: result.completionTokens
            },
            cost: estimateCost(result),
            latencyMs: Date.now() - start
        };
    }
}

Red team et tests adversariaux

Au-delà des cas fonctionnels, Promptfoo fournit un module red team qui génère automatiquement des prompts adversariaux : jailbreaks, prompt injections, demandes hors-périmètre. C'est l'équivalent d'un OWASP ZAP pour LLM.

# Generation automatique de cas adversariaux
promptfoo redteam init

# Cible declaree dans redteam.yaml
description: "Red team de l'agent support"
prompts:
  - file://prompts/support-system.txt
providers:
  - openai:gpt-4o-mini

# Plugins : types d'attaques a generer
redteam:
  purpose: "Assistant support qui ne doit JAMAIS donner de conseil medical ou juridique."
  plugins:
    - harmful           # contenus nuisibles
    - pii               # tentatives de fuite de PII
    - prompt-injection  # injections directes et indirectes
    - hijacking         # detournement hors perimetre
    - excessive-agency  # actions au-dela du mandat
  numTests: 50

# Lancement
promptfoo redteam generate     # cree des centaines de cas
promptfoo redteam eval         # execute et evalue avec un juge dedie
promptfoo redteam report       # rapport HTML avec criticite
À faire tourner avant chaque release majeure : le module red team prend 5-15 minutes selon le nombre de tests, et fait apparaître des failles que les tests fonctionnels ne couvrent pas. Combiné aux guardrails (voir notre article dédié), c'est la double-vérification standard avant production.

Intégration GitHub Actions et seuils de qualité

L'intérêt final de Promptfoo : bloquer une PR si la qualité baisse. Concrètement, on définit un pass rate minimum, et le workflow CI échoue dès qu'un commit fait dériver les scores.

# .github/workflows/promptfoo.yml
name: Prompt Evaluation

on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'promptfooconfig.yaml'
      - 'tests/**'

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install Promptfoo
        run: npm install -g promptfoo

      - name: Run evaluation
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          promptfoo eval \
            -c promptfooconfig.yaml \
            --output report.json \
            --no-cache

      - name: Enforce quality threshold
        run: |
          PASS_RATE=$(jq '.results.stats.successes / .results.stats.runs' report.json)
          THRESHOLD=0.9
          echo "Pass rate: $PASS_RATE (min: $THRESHOLD)"
          awk -v p="$PASS_RATE" -v t="$THRESHOLD" 'BEGIN { exit (p < t) }'

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: promptfoo-report
          path: report.json

      - name: Post comment on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const report = JSON.parse(fs.readFileSync('report.json', 'utf8'));
            const passRate = (report.results.stats.successes / report.results.stats.runs * 100).toFixed(1);
            const body = `**Promptfoo** : pass rate ${passRate}% sur ${report.results.stats.runs} tests`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body
            });
  • Seuil de pass rate : 85-95 % typique, à calibrer sur l'historique de votre dataset.
  • Cache désactivé en CI (--no-cache) : on veut tester le comportement réel à chaque PR.
  • Secrets : clés API stockées dans GitHub Secrets, jamais commitées.
  • Tests longs séparés : workflow nightly pour les 500+ cas, workflow PR sur un sous-ensemble de 30-50.
  • Snapshot des résultats dans un dossier eval-history/ versionné pour suivre l'évolution.
  • Forks externes : ne pas exposer les secrets API ; utiliser un workflow déclenché manuellement par un mainteneur.
  • Coût CI : un eval de 50 cas sur 3 modèles coûte 0,05-0,30 $ — négligeable vs le coût d'un incident.

Conclusion

Promptfoo apporte au prompt engineering la même discipline que les frameworks de test classiques : déclaration de cas, assertions, comparaison de modèles, intégration CI. Le surcoût initial (1-2 jours pour 50 cas) est largement compensé dès la première régression évitée ou le premier choix de modèle data-driven.

Pour démarrer : créez un promptfooconfig.yaml avec 5-10 cas couvrant vos golden paths, ajoutez une assertion llm-rubric sur le critère qualitatif principal, et branchez le workflow GitHub Actions avec un seuil à 90 %. Une fois ce socle stable, ajoutez le module red team avant chaque release, comparez 2-3 modèles côte à côte chaque trimestre, et migrez votre defaultTest vers cost et latency pour piloter le couple qualité-budget en production.

Partager