Regrouper les documents par lot et valider chaque réponse
Pourquoi regrouper ?
Chaque requête porte un coût fixe : l'instruction système (le rôle et les règles, répétés à chaque appel), la latence réseau, et une place dans le quota de requêtes par minute. En envoyant 10 avis courts par requête :
- on divise par 10 le nombre de requêtes (donc la pression sur le RPM) ;
- l'instruction système n'est payée qu'une fois pour 10 avis.
flowchart LR
subgraph U["1 avis par requête"]
S1["Instruction + avis 1"] --> R1["Réponse 1"]
S2["Instruction + avis 2"] --> R2["Réponse 2"]
S3["... x 10"]
end
subgraph L["10 avis par requête"]
SL["Instruction + avis 1 à 10"] --> RL["Liste de 10 réponses"]
end
| Taille du lot | Avantages | Inconvénients |
|---|---|---|
| 1 | une erreur ne touche qu'un document ; prompt simple | beaucoup de requêtes, instruction répétée |
| 5 à 20 (documents courts) | bon compromis | une réponse incomplète touche plusieurs documents |
| 100 et plus | très peu de requêtes | réponses longues, oublis plus probables, délais plus longs |
Le prompt d'un lot
Chaque avis est préfixé par son id, pour que le modèle puisse le renvoyer :
def prompt_lot(avis: list[dict]) -> str:
return "\n".join(f"[{a['id']}] {a['texte']}" for a in avis)[A0001] Génial, mon grille-pain Nimbus est arrivé avec trois semaines de retard. Vraiment bravo.
[A0002] Colis du ventilateur Lumo perdu par le transporteur, toujours pas de nouvelles.
...Le schéma de réponse
On passe un JSON Schema (une description formelle d'une structure JSON) avec response_json_schema. Les valeurs permises sont des enum : le modèle ne peut pas inventer une catégorie « logistique ».
SCHEMA = {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"sentiment": {"type": "string", "enum": SENTIMENTS},
"categorie": {"type": "string", "enum": CATEGORIES},
"a_recontacter": {"type": "boolean",
"description": "vrai si le client attend une action du support"},
},
"required": ["id", "sentiment", "categorie", "a_recontacter"],
},
}Ce schéma est un simple dictionnaire : il sert tel quel dans l'appel synchrone et dans le fichier JSONL de la Batch API (module 3).
Valider : ne garder que ce qui a été demandé
Le schéma garantit la forme, pas le contenu : le modèle pourrait oublier un avis, en renvoyer un deux fois, ou renvoyer un id qu'on n'a jamais envoyé.
def valider(avis: list[dict], brut: str) -> list[dict]:
"""Garde uniquement les réponses valides pour des ids demandés (le reste sera refait)."""
attendus = {a["id"] for a in avis}
ok = []
for r in json.loads(brut):
if (r.get("id") in attendus and r.get("sentiment") in SENTIMENTS
and r.get("categorie") in CATEGORIES and isinstance(r.get("a_recontacter"), bool)):
ok.append(r)
attendus.discard(r["id"]) # un id en double n'est compté qu'une fois
return okUn avis oublié n'apparaît pas dans les résultats du lot : deja_faits ne le connaît pas, il sera donc refait à la prochaine exécution. Validation et reprise travaillent ensemble.
Le modèle renvoie 9 réponses valides pour un lot de 10 avis. Que fait le pipeline ?
On garde ce qui est valide, on ne paie pas deux fois les 9 avis corrects, et l'avis manquant est naturellement repris parce qu'il n'est pas dans le point de reprise.
Quels cas la fonction valider écarte-t-elle ? (plusieurs réponses)
Ids inconnus, valeurs hors liste et doublons sont écartés. Un sentiment négatif est une réponse parfaitement valide.
Exercice : un test pour l'avis oublié
Écrivez un test qui vérifie qu'un avis oublié par le modèle est bien retraité à l'exécution suivante.
Voir la solution
def test_avis_oublie_refait(tmp_path):
sortie = tmp_path / "r.jsonl"
avis = generer(10, 1)
async def oublie_le_dernier(client, lot):
brut = json.dumps([{"id": a["id"], "sentiment": "neutre", "categorie": "autre",
"a_recontacter": False} for a in lot[:-1]])
return {"resultats": classer.valider(lot, brut), "tokens_in": 1, "tokens_out": 1}
lancer(avis, sortie, traiter_lot=oublie_le_dernier)
assert len(classer.deja_faits(str(sortie))) == 9
appels = []
lancer(avis, sortie, traiter_lot=faux_lot(appels=appels))
assert appels == [["A0010"]]Résultat obtenu
Mesure réelle du 2026-10-15 sur les 10 premiers avis, gemini-3.5-flash-lite :
lot de 1 : 10 appels, 861 jetons en entrée, 501 en sortie, 2.8 s (concurrence 4)
lot de 10 : 1 appel, 339 jetons en entrée, 474 en sortie, 1.8 sLe regroupement divise ici par environ 2,5 les jetons d'entrée (l'instruction n'est envoyée qu'une fois) et par 10 le nombre de requêtes. Sur les 200 avis du projet final, 20 lots sur 20 ont renvoyé 10 réponses valides sur 10.
Je sais faire
- Choisir une taille de lot selon la longueur des documents
- Imposer un schéma JSON avec des
enumviaresponse_json_schema - Valider ids, valeurs et doublons avant d'écrire
- Laisser la reprise retraiter ce que la validation a écarté