Les LLMs sont excellents pour générer du texte. Ils sont mauvais pour générer des données structurées de manière fiable. Si vous avez déjà essayé de faire produire à un agent un objet JSON avec un schéma précis, vous connaissez le douloureux résultat : champs manquants, clés hallucinées, types incohérents, et des sorties qui cassent votre pipeline en aval.
Dépassant le stade du code de démo pour travailler sur de vraies applications IA en production, j'ai été confronté au problème et j'ai trouvé une approche qui fonctionne remarquablement bien pour une application IA que je développe : utiliser les outils comme le pattern Builder de la programmation orientée objet. Au lieu de demander au modèle de produire un blob JSON final, vous lui donnez des outils qui construisent la sortie de manière incrémentale - comme appeler des méthodes sur un objet. Le modèle ne voit ni ne produit jamais la structure finale directement. Il appelle simplement des outils, et la sortie structurée émerge comme un effet de bord.
C'est particulièrement important quand votre agent traite des documents volumineux (formulaires d'assurance, dossiers juridiques, dossiers médicaux) qui consomment la majeure partie de la fenêtre de contexte. Quand l'entrée est volumineuse et que la tâche comporte plusieurs étapes, vous ne pouvez pas vous permettre de réserver aussi de l'espace pour une sortie structurée massive à la fin. Le pattern accumulateur vous permet de compresser la conversation en cours de route sans perdre aucune des données structurées déjà collectées, car ces données vivent entièrement en dehors de la fenêtre de contexte.
Défis
"Génère-moi un gros JSON" : les soucis
L'approche naïve - demander au modèle de produire une structure JSON complète - échoue de manière quasi systématique lorsque le volume augmente :
-
Dérive de schéma. Le modèle oublie des champs obligatoires, en invente de nouveaux, ou change les types d'une exécution à l'autre. Un champ
datepeut être une chaîne une fois et un objet la suivante. - Tout-ou-rien. Si le modèle fait une seule erreur dans une sortie JSON de 200 lignes, l'ensemble est impossible à parser. Vous devez soit relancer toute la génération, soit écrire du code de correction fragile.
- Pas de progrès incrémental. Quand un agent doit collecter des informations et produire une sortie structurée, lui demander de faire les deux en une seule passe signifie qu'il ne peut pas itérer. Il s'engage sur une structure avant d'avoir tous les faits.
Pourquoi response_format et les schémas de function-calling ne suffisent pas
Les modes de sortie structurée (comme response_format: json_schema d'OpenAI ou les schémas de résultats d'outils de Bedrock) aident avec la syntaxe - vous obtiendrez du JSON valide. Mais ils ne résolvent pas le problème sémantique. Le modèle doit toujours produire la structure entière en une seule passe, et il hallucine toujours du contenu pour remplir les champs obligatoires.
Un problème répandu
Toute équipe qui construit des agents autonomes ou semi-autonomes fait face à ce problème, pas seulement moi. Kiro CLI, le compagnon de développement agentique d'AWS, par exemple, a beaucoup galéré avec les grandes structures de données à son lancement.
Depuis, ses mainteneurs ont équipé son harnais de capacités JSON (manipulations jq, par exemple) et de multiples stratégies (utilisation extensive de grep, glob, tail..) pour éviter de remplir la fenêtre de contexte.
Ça fait quand même plaisir de savoir que je ne suis pas le seul à avoir galéré :)
Mes solutions
Voici quelques astuces que j'ai utilisées avec succès pour contrôler à la fois la sortie de l'agent et la fenêtre de contexte. Comme je ne prétends pas avoir toutes les recettes, n'hésitez pas à commenter les vôtres ou à me taguer dans vos propres posts :)
Utiliser les outils comme des Builder méthodes
L'idée centrale : définir des outils qui agissent comme des méthodes Builder en POO. Chaque appel d'outil ajoute un élément bien typé à un accumulateur. Le travail du modèle passe de "produis cette structure" à "appelle ces fonctions dans le bon ordre."
Voici le pattern - imaginez un agent qui traite des sinistres d'assurance en lisant des documents et en construisant une évaluation structurée :
from strands import tool
# L'accumulateur - c'est votre sortie structurée
claim_output = {
"parties": [],
"events": [],
"damages": [],
"evidence": [],
"assessment": None,
}
def reset_output():
claim_output["assessment"] = None
for k in ["parties", "events", "damages", "evidence"]:
claim_output[k] = []
@tool
def add_party(name: str, role: str, policy_id: str = "") -> str:
"""Enregistrer une partie impliquée dans le sinistre.
Args:
name: Nom complet de la personne ou de l'organisation.
role: Un parmi : claimant, insured, witness, adjuster, third_party
policy_id: Numéro de police si applicable.
Returns:
Confirmation avec les détails de la partie.
"""
if role not in ("claimant", "insured", "witness", "adjuster", "third_party"):
return f"Error: invalid role '{role}'. Must be one of: claimant, insured, witness, adjuster, third_party"
claim_output["parties"].append({
"name": name,
"role": role,
"policy_id": policy_id,
})
return f"Added {role}: {name}"
@tool
def add_event(description: str, date: str, location: str = "") -> str:
"""Enregistrer un événement chronologique pertinent pour le sinistre.
Args:
description: Ce qui s'est passé (1-3 phrases).
date: Date au format ISO (AAAA-MM-JJ).
location: Où cela s'est produit (optionnel).
"""
claim_output["events"].append({
"description": description,
"date": date,
"location": location,
})
return f"Recorded event on {date} ({len(claim_output['events'])} events total)"
@tool
def add_damage(item: str, amount: float, category: str, evidence_ref: str = "") -> str:
"""Enregistrer un poste de dommage avec le coût estimé.
Args:
item: Description de l'élément endommagé ou du coût.
amount: Coût estimé en dollars.
category: Un parmi : property, medical, liability, lost_income
evidence_ref: Référence à une preuve justificative (optionnel).
"""
if category not in ("property", "medical", "liability", "lost_income"):
return f"Error: invalid category '{category}'."
claim_output["damages"].append({
"item": item,
"amount": amount,
"category": category,
"evidence_ref": evidence_ref,
})
total = sum(d["amount"] for d in claim_output["damages"])
return f"Added damage: {item} (${amount:.2f}). Running total: ${total:.2f}"
L'agent reçoit ces outils et un prompt système qui lui dit de traiter un sinistre. Au fur et à mesure qu'il lit les documents et découvre des informations, il appelle add_party, add_event et add_damage. La sortie structurée se construit de manière incrémentale.
Validation à la frontière
Chaque appel d'outil est un point de contrôle de validation. Vous pouvez rejeter les entrées invalides immédiatement :
@tool
def add_damage(item: str, amount: float, category: str, evidence_ref: str = "") -> str:
if category not in ("property", "medical", "liability", "lost_income"):
return f"Error: invalid category '{category}'."
if amount <= 0:
return f"Error: amount must be positive, got {amount}."
if evidence_ref and evidence_ref not in [e["id"] for e in claim_output["evidence"]]:
return f"Error: evidence '{evidence_ref}' not registered. Call add_evidence first."
# ...
Le modèle reçoit un feedback instantané. S'il essaie de référencer une preuve qu'il n'a pas encore enregistrée, l'outil le lui dit. Le modèle se corrige au tour suivant. Comparez cela à la validation d'un blob JSON de 500 lignes après coup - à ce moment-là, le modèle est passé à autre chose et ne peut plus corriger ses erreurs dans le contexte.
Décorréler la phase de réflexion de la construction de la sortie
Un avantage clé : le même agent peut avoir des outils de lecture et des outils d'écriture. Les outils de lecture récupèrent et explorent les données. Les outils d'écriture construisent la sortie. Le modèle les entrelace naturellement :
agent = Agent(
model=model,
system_prompt=prompt,
tools=[
# Outils de lecture
read_document,
search_policy,
get_weather_report,
# Outils d'écriture (méthodes Builder)
add_party,
add_event,
add_damage,
add_evidence,
set_assessment,
# Suivi de progression
mark_step_done,
],
)
# Un seul appel - l'agent lit les documents ET construit la sortie structurée
agent("Process this claim: " + claim_text)
# La sortie est prête
print(claim_output)
Le modèle lit un rapport de police, extrait une partie, lit une facture médicale, enregistre un poste de dommage, vérifie la police d'assurance, et ainsi de suite. Recherche et construction de la sortie sont entrelacées plutôt que séquentielles.
Suivi de progression et récupération
Parce que la sortie s'accumule de manière incrémentale, vous obtenez la récupération après crash gratuitement :
STEPS = [
"1. Identify all parties",
"2. Establish timeline of events",
"3. Catalog damages with evidence",
"4. Cross-reference policy coverage",
"5. Produce assessment",
]
completed_steps: list[int] = []
@tool
def mark_step_done(step_number: int) -> str:
"""Marquer une étape de traitement comme terminée."""
completed_steps.append(step_number)
remaining = [s for i, s in enumerate(STEPS, 1) if i not in completed_steps]
return f"Step {step_number} done. Remaining: {', '.join(remaining)}"
Si l'agent atteint une limite de fenêtre de contexte ou plante, vous avez déjà des résultats partiels - chaque partie identifiée, chaque événement enregistré, chaque poste de dommage catalogué jusqu'à ce point. Vous pouvez reprendre ou utiliser ce que vous avez.
Gestion du contexte par injection d'état
C'est là que ce pattern prend tout son sens. Quand votre agent ingère un document de 30 pages puis fait des dizaines d'appels d'outils pour récupérer des sources supplémentaires, la fenêtre de contexte se remplit vite. Dans une approche traditionnelle, vous perdriez votre sortie structurée en même temps que la conversation quand vous atteignez la limite. Mais parce que l'accumulateur vit dans la mémoire Python - pas dans l'historique des messages - vous pouvez compresser agressivement la conversation sans perdre un seul point de données.
Un gestionnaire de conversation personnalisé (une possibilité offerte, par exemple, par le SDK Strands Agents) remplace les anciens messages par un résumé d'état compact dérivé de l'accumulateur :
class ClaimConversationManager(ConversationManager):
def apply_management(self, agent, **kwargs):
messages = agent.messages
if len(messages) <= 2:
return
# Garder le premier message + les 2 derniers messages
# Remplacer tout le reste par un résumé d'état
first_msg = messages[0]
recent = messages[-2:]
state = self._build_state_summary()
state_msg = {
"role": "user",
"content": [{"text": f"[STATE]\n{state}\n\nContinue."}],
}
messages[:] = [first_msg, state_msg] + recent
def _build_state_summary(self) -> str:
"""Résumer ce qui a été fait en utilisant l'état de l'accumulateur."""
lines = []
if claim_output["parties"]:
parties = [f"{p['name']} ({p['role']})" for p in claim_output["parties"]]
lines.append(f"Parties: {', '.join(parties)}")
if claim_output["damages"]:
total = sum(d["amount"] for d in claim_output["damages"])
lines.append(f"Damages: {len(claim_output['damages'])} items, ${total:.2f} total")
if claim_output["events"]:
lines.append(f"Events: {len(claim_output['events'])} recorded")
return "\n".join(lines)
Parce que la sortie structurée vit en Python (pas dans la conversation), la compression du contexte ne perd aucune donnée. Le modèle peut toujours voir ce qu'il a déjà produit en lisant le résumé d'état.
Bénéfices
Sûreté de typage sans coercition
Chaque outil a des paramètres typés imposés par le framework. Le modèle doit fournir une category parmi property, medical, liability, lost_income - non pas parce que vous parsez du JSON et vérifiez après coup, mais parce que la signature de l'outil l'exige. Les appels invalides sont rejetés avec des messages d'erreur clairs.
Composabilité
Les outils se composent naturellement. Vous pouvez ajouter de nouveaux champs de sortie en ajoutant de nouveaux outils sans modifier les existants. Vous voulez suivre les pièces justificatives ? Ajoutez un outil add_evidence. Vous voulez une recommandation finale ? Ajoutez un outil set_assessment. Le modèle découvre les nouvelles capacités via sa liste d'outils.
Testabilité
Chaque outil est une fonction pure (ou presque). Vous pouvez les tester unitairement de manière indépendante :
def test_add_damage_rejects_invalid_category():
reset_output()
result = add_damage(item="Roof repair", amount=5000, category="cosmetic")
assert "Error" in result
assert len(claim_output["damages"]) == 0
def test_add_damage_tracks_total():
reset_output()
add_damage(item="Roof repair", amount=5000, category="property")
add_damage(item="Water damage", amount=2000, category="property")
assert len(claim_output["damages"]) == 2
assert sum(d["amount"] for d in claim_output["damages"]) == 7000
Schéma de sortie déterministe
Le schéma de sortie est défini par votre code Python, pas par l'interprétation du modèle d'un prompt. claim_output a toujours les mêmes clés avec les mêmes types. Les consommateurs en aval peuvent compter sur la structure de manière inconditionnelle.
Dégradation gracieuse
Si le modèle manque de contexte ou rencontre une erreur, vous avez tout ce qu'il a produit jusqu'à ce point. Vous pouvez même détecter une sortie vide et relancer avec un coup de pouce :
try:
agent(claim_text)
except Exception:
pass
if not claim_output["parties"] and not claim_output["events"]:
agent("You haven't started processing. Begin by identifying the parties involved.")
Comportement naturel de l'agent
Le modèle n'a pas besoin de basculer entre "réfléchir" et "formater." Il réfléchit en appelant des outils. La sortie structurée est un sous-produit du travail de l'agent, pas un fardeau de formatage supplémentaire ajouté par-dessus.
Ce pattern - outils comme Builder, accumulateur comme sortie, validation à la frontière - est la manière la plus fiable que j'ai trouvée pour obtenir des données structurées d'un workflow agentique. Ça fonctionne parce que c'est aligné avec la façon dont les modèles à appels d'outils se comportent déjà : ils raisonnent, ils agissent, ils observent les résultats, et ils agissent à nouveau. Vous faites simplement en sorte que "agir" signifie "construire un morceau de la sortie."
Top comments (0)