Vision structurée et outils de l'agent
Une réponse imposée : le schéma Inspection
Une sortie structurée oblige le modèle à répondre en JSON conforme à un schéma. Avec le SDK google-genai, on passe directement une classe Pydantic (bibliothèque de validation de données) dans response_schema.
class Inspection(BaseModel):
"""Sortie structurée imposée au modèle de vision."""
conforme: bool
defaut: Literal["aucun", "rayure", "fissure", "tache", "manque"]
gravite: Literal["aucune", "mineure", "majeure"]
confiance: float = Field(ge=0, le=1)
description: str = Field(description="une phrase factuelle sur ce qui est visible")Le Literal est essentiel : il transforme une question ouverte (« que vois-tu ? ») en classification sur une liste fermée.
L'appel de vision
def classer_image(image: bytes, mime: str = "image/png") -> Inspection:
r = _genai().models.generate_content(
model=VISION_MODEL,
contents=[types.Part.from_bytes(data=image, mime_type=mime), CONSIGNE_VISION],
config=types.GenerateContentConfig(
temperature=0, response_mime_type="application/json", response_schema=Inspection),
)
return r.parsed if isinstance(r.parsed, Inspection) else Inspection.model_validate_json(r.text)Part.from_bytesenvoie l'image brute, sans URL publique ;temperature=0réduit la variabilité.r.parsedcontient déjà l'objetInspectionvalidé ; le replimodel_validate_jsonlève une erreur claire si le JSON est invalide.- Le client est créé avec des réessais sur 429/503 (
HttpRetryOptions), car le quota Vertex AI est partagé.
sequenceDiagram
participant A as "Agent ADK"
participant T as "inspecter_image"
participant S as "Cloud Storage"
participant G as "Gemini (vision)"
A->>T: nom = images/piece-04.png
T->>S: download_as_bytes()
S-->>T: octets PNG
T->>G: image + consigne + schéma Inspection
G-->>T: JSON conforme au schéma
T-->>A: {conforme: false, defaut: fissure, gravite: majeure, ...}
Sans le Literal sur le champ defaut, quel problème risquez-vous ?
Une chaîne libre accepte n'importe quelle formulation. La liste fermée garantit des valeurs comparables, ce dont ont besoin le rapport, les statistiques et l'évaluation.
Les outils de l'agent
| Outil | Lit | Écrit | Garde-fou |
|---|---|---|---|
lister_images() | les objets sous images/ | rien | ignore ce qui n'est pas .png/.jpg |
inspecter_image(nom) | un objet | le résultat en mémoire | refuse tout chemin hors images/ ou contenant .. |
publier_rapport() | les résultats en mémoire | rapports/rapport-inspection.md | refuse s'il n'y a rien à publier |
def inspecter_image(nom: str) -> dict:
"""Inspecte une photo du bucket et renvoie le défaut détecté.
Args:
nom: chemin de l'objet dans le bucket, par exemple « images/piece-01.png ».
"""
if not nom.startswith(PREFIXE_IMAGES) or ".." in nom:
return {"erreur": f"seuls les objets sous {PREFIXE_IMAGES} sont inspectables"}
blob = _bucket().blob(nom)
mime = "image/jpeg" if nom.endswith(".jpg") else "image/png"
resultat = classer_image(blob.download_as_bytes(), mime).model_dump()
_resultats[nom] = resultat
return {"image": nom, **resultat}Le rapport : du code, pas du LLM
def construire_rapport(resultats: dict[str, dict]) -> str:
compte = Counter(r["defaut"] for r in resultats.values())
rejets = sum(not r["conforme"] for r in resultats.values())
lignes = ["# Rapport d'inspection qualité", "",
f"- Pièces inspectées : {len(resultats)}",
f"- Pièces à rejeter : {rejets}", ...]publier_rapport appelle cette fonction, envoie le texte dans le bucket (upload_from_string, type text/markdown) et ne renvoie à l'agent qu'un résumé : l'URI du rapport et la liste des rejets.
L'agent
root_agent = Agent(
name="inspecteur_qualite",
model=Gemini(model=MODEL, retry_options=types.HttpRetryOptions(attempts=5, initial_delay=2)),
instruction=(
"Procédure : 1) lister_images ; 2) inspecter_image pour CHAQUE image listée ; "
"3) publier_rapport ; 4) termine par une synthèse de 5 lignes maximum ... "
"Ne déduis jamais un défaut du nom du fichier : seul inspecter_image fait foi."
),
tools=[lister_images, inspecter_image, publier_rapport],
)L'agent appelle publier_rapport après n'avoir inspecté que 3 images sur 6. Où le voyez-vous immédiatement ?
Les sorties des outils sont structurées : comparer « nombre » de lister_images et « inspectees » de publier_rapport suffit. C'est aussi un excellent test automatique à ajouter.
Résultat obtenu
Extrait réel du rapport publié dans le bucket le 2026-10-15 (gcloud storage cat gs://.../rapports/rapport-inspection.md) :
# Rapport d'inspection qualité
- Pièces inspectées : 6
- Pièces à rejeter : 4
- Répartition : aucun = 2, rayure = 1, fissure = 1, tache = 1, manque = 1
| Image | Verdict | Défaut | Gravité | Confiance | Observation |
|---|---|---|---|---|---|
| images/piece-04.png | REJET | fissure | majeure | 0.95 | Une fissure sombre et irrégulière est visible sur la rondelle grise, s'étendant de l'intérieur vers l'extérieur. |Exercice
Ajoutez une règle métier : toute pièce dont la confiance est inférieure à 0,7 doit être marquée « À REVOIR » dans le rapport, quel que soit le verdict du modèle.
Voir la solution
La règle va dans le code déterministe, pas dans la consigne du modèle :
SEUIL_CONFIANCE = 0.7
def verdict(r: dict) -> str:
if r["confiance"] < SEUIL_CONFIANCE:
return "À REVOIR"
return "conforme" if r["conforme"] else "REJET"
# dans construire_rapport :
lignes.append(f"| {nom} | {verdict(r)} | {r['defaut']} | ...")Et un test : un résultat à confiance: 0.5 doit produire une ligne contenant | À REVOIR |.
Attention : la confiance déclarée par un LLM n'est pas une probabilité calibrée. C'est un signal utile pour trier, à valider sur vos propres données.
Je sais faire
- Imposer un schéma de réponse Pydantic à un appel de vision Gemini
- Envoyer une image depuis Cloud Storage sans l'exposer publiquement
- Valider les arguments d'un outil comme une entrée externe
- Séparer ce qui relève du modèle et ce qui relève du code