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.
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"]
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.
| Type | Usage | Coût |
|---|---|---|
equals | Égalité stricte (classification) | Gratuit |
contains / not-contains | Présence d'un mot ou phrase | Gratuit |
contains-any / contains-all | Liste de mots-clés | Gratuit |
regex | Pattern précis (format date, ID...) | Gratuit |
is-json | Sortie JSON valide | Gratuit |
json-schema | JSON conforme à un schéma Ajv | Gratuit |
javascript | Fonction JS custom (max flexibilité) | Gratuit |
similar | Similarité d'embedding ≥ seuil | 1 appel embedding |
llm-rubric | Évaluation qualitative LLM-as-judge | 1 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
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
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.