Série · Certification Claude Certified ArchitectÉpisode 04 / 20
Claude Architect, épisode 4 : un coordinateur et deux sous-agents, en Python
Le projet qui assemble les épisodes 1 à 3 : un remboursement traité par un coordinateur et deux sous-agents, avec la porte en code et les contrôles d'outil que l'examen attend.
Trois épisodes de concepts, et maintenant un vrai programme. Un client demande le remboursement d’une commande. Un coordinateur découpe la demande, un premier sous-agent vérifie le client, un second traite le remboursement, et le coordinateur assemble la réponse.
Le scénario est simple, et c’est voulu : il suffit pour tomber dans les deux pièges que l’examen adore. Le premier est un « résultat structuré » qui n’est que de la prose dans une enveloppe. Le second est une règle métier confiée au modèle alors qu’elle devait être écrite en code.
Domaine 1 — Architecture agentique et orchestration (27 % de l'examen). Cet épisode met en pratique :
- 1.1 — la boucle agentique, ici avec ses garde-fous ;
- 1.2 et 1.3 — le coordinateur, ses sous-agents et le contexte transmis ;
- 1.4 — un workflow en plusieurs étapes, avec contrôles et passage de relais.
Objectifs de l’épisode
À la fin, vous saurez :
- assembler une boucle agentique, des sous-agents et un coordinateur dans un seul programme ;
- donner à chaque sous-agent uniquement les outils de son rôle ;
- construire un constat structuré à partir des résultats d’outils, pas de la prose du modèle ;
- placer une porte en code entre deux étapes ;
- faire vérifier les règles métier par l’outil lui-même ;
- distinguer un résultat vide d’une vraie erreur d’outil ;
- relier ce code « à la main » à l’outil
Task(ouAgent) de l’Agent SDK.
Prérequis : les épisodes 1, 2 et 3.
1. Le scénario
Le client C001 demande le remboursement de la commande 0100. Voici le trajet de la demande :
Chaque concept des épisodes précédents a sa place :
| Concept | Où il apparaît | Épisode |
|---|---|---|
| boucle agentique | chaque sous-agent tourne jusqu’à end_turn |
1 |
| décomposition | le coordinateur crée deux sous-tâches | 2 |
| isolation du contexte | le vérificateur ne reçoit que l’identifiant du client | 2 |
| passage de contexte structuré | le constat est transmis en JSON | 3 |
| agrégation | le coordinateur produit un statut et un résumé | 2 |
| contrôle entre étapes | la porte et les vérifications de l’outil | 4 |
2. Les outils et leurs données
Pour se concentrer sur l’orchestration, les données sont factices. Les outils restent du code ordinaire : Claude peut seulement demander à les appeler.
import json
from functools import partial
import anthropic
client = anthropic.Anthropic() # lit la clé dans ANTHROPIC_API_KEY
MODEL = "claude-haiku-4-5"
MAX_ITERATIONS = 10 # soupape de sécurité, pas la condition d'arrêt
# --- Fausses données : aucune vraie API n'est appelée ---
CLIENTS = {
"C001": {"name": "Alice", "email": "alice@example.com", "plan": "pro"},
"C002": {"name": "Bob", "email": "bob@example.com", "plan": "free"},
}
COMMANDES = {
"0100": {"customer_id": "C001", "item": "Annual Plan", "amount": 99.00, "status": "delivered"},
"0101": {"customer_id": "C002", "item": "Monthly Plan", "amount": 9.00, "status": "refunded"},
}
# --- Les outils : du code ordinaire, que Claude ne peut que demander ---
def get_customer(customer_id: str) -> dict:
fiche = CLIENTS.get(customer_id)
if fiche:
return {"found": True, "customer_id": customer_id, **fiche}
return {"found": False, "customer_id": customer_id} # résultat valide, pas une erreur
def lookup_order(order_id: str) -> dict:
commande = COMMANDES.get(order_id)
if commande:
return {"found": True, "order_id": order_id, **commande}
return {"found": False, "order_id": order_id}
def process_refund(order_id: str, amount: float, *, client_verifie: str) -> dict:
# client_verifie est fixé par le coordinateur, jamais par le modèle.
commande = COMMANDES.get(order_id)
if not commande:
return {"ok": False, "reason": f"no order {order_id}"}
if commande["customer_id"] != client_verifie:
return {"ok": False, "reason": "order does not belong to the verified customer"}
if commande["status"] == "refunded":
return {"ok": False, "reason": "order already refunded"}
if amount > commande["amount"]:
return {"ok": False, "reason": "amount exceeds the order total"}
commande["status"] = "refunded"
return {"ok": True, "order_id": order_id, "refunded": amount}Regardez process_refund : c’est lui qui vérifie que la commande appartient au client, qu’elle
n’est pas déjà remboursée et que le montant est cohérent. Une description d’outil qui dit « n’appelez
ceci qu’après avoir vérifié le client » est une consigne. Le modèle la suit presque toujours, mais
presque toujours ne suffit pas pour de l’argent.
Le paramètre client_verifie n’apparaît pas dans le schéma envoyé à Claude. Le modèle ne peut donc
pas l’inventer : c’est le coordinateur qui le fixe (section 5).
Chaque sous-agent ne reçoit que les outils de son rôle :
| Sous-agent | get_customer |
lookup_order |
process_refund |
|---|---|---|---|
| Vérificateur | oui | non | non |
| Remboursement | non | oui | oui |
Les schémas, eux, sont ce que Claude sait des outils : un nom, une description et des paramètres.
# --- Les schémas envoyés à l'API : ce que Claude sait des outils ---
SCHEMAS = {
"get_customer": {
"name": "get_customer",
"description": "Fetch a customer record by ID. Returns found, name, email and plan.",
"input_schema": {
"type": "object",
"properties": {"customer_id": {"type": "string", "description": "e.g. C001"}},
"required": ["customer_id"],
},
},
"lookup_order": {
"name": "lookup_order",
"description": "Fetch an order by ID. Returns item, amount, status and the linked customer ID.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "e.g. 0100"}},
"required": ["order_id"],
},
},
"process_refund": {
"name": "process_refund",
"description": "Refund an order for the verified customer. Fails if the order belongs "
"to someone else, is already refunded, or the amount is too high.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number", "description": "Amount to refund, in USD"},
},
"required": ["order_id", "amount"],
},
},
}3. La boucle de chaque sous-agent
C’est la boucle de l’épisode 1, avec trois garde-fous qu’on oublie facilement dans un projet :
Un arrêt qui n'est ni end_turn ni tool_use. Avec max_tokens ou refusal, une boucle qui ne teste que ces deux valeurs renvoie la même requête à l'infini. Ici, elle s'arrête avec une erreur explicite.
Un outil hors du rôle. Si le modèle demande un outil qui n'est pas dans le registre du sous-agent, la boucle ne plante pas : elle renvoie un tool_result en erreur, et Claude peut se corriger.
Une vraie erreur, et seulement elle. « Client introuvable » est un résultat valide : found: false, sans is_error. Seule une exception (outil cassé, accès refusé) est marquée is_error: true.
# --- Épisode 1 : la boucle agentique, avec ses garde-fous ---
def run_agentic_loop(system_prompt: str, user_message: str, schemas: list, fonctions: dict):
"""Renvoie le texte final de Claude ET le journal des appels d'outils."""
messages = [{"role": "user", "content": user_message}]
journal = []
for _ in range(MAX_ITERATIONS):
response = client.messages.create(
model=MODEL, max_tokens=1024, system=system_prompt,
tools=schemas, messages=messages,
)
if response.stop_reason == "end_turn":
texte = "".join(b.text for b in response.content if b.type == "text")
return texte, journal
if response.stop_reason != "tool_use":
# max_tokens, refusal… : s'arrêter plutôt que renvoyer la même requête
raise RuntimeError(f"arrêt inattendu : {response.stop_reason}")
messages.append({"role": "assistant", "content": response.content})
resultats = []
for block in response.content:
if block.type != "tool_use":
continue
fonction = fonctions.get(block.name)
try:
if fonction is None:
raise PermissionError(f"tool {block.name} is not allowed here")
sortie, en_erreur = fonction(**block.input), False
except Exception as exc: # l'outil a échoué : on le dit à Claude
sortie, en_erreur = {"error": str(exc)}, True
journal.append({"tool": block.name, "input": block.input, "output": sortie})
resultats.append({
"type": "tool_result",
"tool_use_id": block.id, # doit correspondre au bloc tool_use
"content": json.dumps(sortie),
"is_error": en_erreur,
})
messages.append({"role": "user", "content": resultats})
raise RuntimeError("MAX_ITERATIONS atteint")La boucle renvoie aussi un journal des appels d’outils. C’est lui qui va servir à construire des constats fiables.
4. Le sous-agent : la même boucle, un rôle étroit
Un sous-agent n’a rien de spécial : c’est une boucle agentique avec une consigne système centrée sur un rôle, et des outils limités.
# --- Épisode 2 : un sous-agent, c'est la même boucle avec un rôle étroit ---
def run_subagent(role: str, task_prompt: str, schemas: list, fonctions: dict) -> dict:
system_prompt = (
f"You are the {role} subagent in a customer support system. "
"Use only your tools. Be concise. Never make up data."
)
texte, journal = run_agentic_loop(system_prompt, task_prompt, schemas, fonctions)
return {"role": role, "summary": texte, "tool_calls": journal}
def dernier_resultat(sous_agent: dict, outil: str):
sorties = [c["output"] for c in sous_agent["tool_calls"] if c["tool"] == outil]
return sorties[-1] if sorties else NoneMettre la réponse du modèle dans un dictionnaire, par exemple {"role": …, "result": texte, "status": "complete"}, ne la rend pas structurée. Le champ result reste de la prose, et status dit « complete » même quand le client n'existe pas. Un constat est structuré quand ses champs portent les faits eux-mêmes, et que du code peut les tester.
5. Le coordinateur : découper, contrôler, transmettre, agréger
C’est ici que tout se joue. Le constat est construit à partir du résultat de l’outil, une porte bloque la suite si le client n’est pas vérifié, et le constat est transmis au second sous-agent en JSON.
# --- Le coordinateur : décomposer, contrôler, transmettre, agréger ---
def coordinator(customer_id: str, order_id: str) -> dict:
# Sous-tâche 1 : le vérificateur ne reçoit que l'identifiant du client.
verification = run_subagent(
"Customer Verifier",
f"Look up customer {customer_id} and confirm they exist. Return their name and plan.",
[SCHEMAS["get_customer"]],
{"get_customer": get_customer},
)
# Épisode 3 : le constat structuré vient du résultat de l'outil, pas de la prose.
fiche = dernier_resultat(verification, "get_customer") or {}
constat = {
"customer_id": customer_id,
"verified": fiche.get("found") is True,
"plan": fiche.get("plan"),
}
# La porte : sans client vérifié, le remboursement ne démarre jamais.
if not constat["verified"]:
return {"status": "rejected", "reason": "customer not verified", "verification": constat}
# Sous-tâche 2 : le constat est passé explicitement, en JSON.
remboursement = run_subagent(
"Refund Processor",
f"Process a refund for order {order_id}.\n\n"
f"PRIOR VERIFICATION (structured):\n{json.dumps(constat, indent=2)}\n\n"
"Steps: 1. Look up the order. 2. Check it belongs to the verified customer. "
"3. If so, refund the full order amount. Otherwise, explain why not.",
[SCHEMAS["lookup_order"], SCHEMAS["process_refund"]],
{
"lookup_order": lookup_order,
"process_refund": partial(process_refund, client_verifie=customer_id),
},
)
# Agrégation : un statut lisible par du code, et le résumé pour un humain.
refund = dernier_resultat(remboursement, "process_refund") or {}
return {
"status": "refunded" if refund.get("ok") else "not_refunded",
"verification": constat,
"refund": refund,
"summary": remboursement["summary"],
}
if __name__ == "__main__":
for customer_id, order_id in [("C001", "0100"), ("C999", "0100"), ("C002", "0101")]:
print(f"\n=== {customer_id} / commande {order_id}")
print(json.dumps(coordinator(customer_id, order_id), indent=2, ensure_ascii=False))Mis bout à bout, ces cinq blocs forment le programme complet. Il faut pip install anthropic et
une clé dans ANTHROPIC_API_KEY.
Trois détails font la différence :
- Si le vérificateur n’appelle jamais
get_customer,ficheest vide etverifiedvautFalse. Par défaut, on refuse. partial(process_refund, client_verifie=customer_id)attache le client vérifié à l’outil. Le modèle choisit la commande et le montant, jamais le client.- Le statut final vient du résultat de
process_refund, pas du résumé du modèle. Un modèle qui écrit « remboursement effectué » alors que l’outil a refusé ne trompe pas le coordinateur.
6. Trois niveaux de contrôle
Ce projet empile trois protections. Seules les deux dernières sont des garanties :
Sans porte, rien n’empêche le second sous-agent de démarrer après un client introuvable. Il reçoit alors un constat négatif et doit décider seul de refuser. Il le fera probablement, mais vous payez un appel inutile et vous confiez la décision au modèle.
L’Agent SDK offre un endroit dédié pour ce genre de contrôle : les hooks, qui interceptent un appel d’outil avant son exécution. Ce sera la tâche 1.5, dans un prochain épisode.
7. Ce que donnent les trois cas
| Cas | Ce qui se passe | Statut final |
|---|---|---|
| C001, commande 0100 | client vérifié, commande à lui, livrée : remboursement de 99,00 | refunded |
| C999, commande 0100 | get_customer renvoie found: false : la porte arrête tout, un seul sous-agent lancé |
rejected |
| C002, commande 0101 | client vérifié, mais commande déjà remboursée : l’outil refuse | not_refunded |
Les statuts sont garantis par le code, quel que soit le texte du modèle. Les résumés, eux, changent d’une exécution à l’autre.
Pour tester d’autres scénarios, ajoutez des clients et des commandes. Essayez par exemple la
commande 0101 avec le client C001 : la commande appartient à C002, et process_refund refusera
même si le modèle tente le remboursement.
Ce programme lance ses sous-agents avec de simples fonctions Python : l'API Messages n'a pas d'outil de lancement de sous-agents. L'outil Task que cite l'examen appartient à Claude Code et à l'Agent SDK, où la documentation actuelle l'appelle Agent.
L'architecture reste la même : un coordinateur, des sous-agents au contexte isolé et aux outils limités, et un contexte transmis explicitement. Pour l'examen, ce sont ces principes qui comptent, pas le mécanisme de lancement.
Dans l’Agent SDK, les deux sous-agents se déclareraient à l’avance, comme dans l’épisode 2. Les outils maison passent par un serveur MCP, d’où leurs noms préfixés. Vérifiez ces noms dans la documentation de votre version :
from claude_agent_sdk import ClaudeAgentOptions, AgentDefinition
options = ClaudeAgentOptions(
allowed_tools=["Agent"], # "Task" dans les anciennes versions
agents={
"customer-verifier": AgentDefinition(
description="Verifies that a customer exists. Use before any refund.",
prompt="Look up the customer and report found, name and plan. Never make up data.",
tools=["mcp__support__get_customer"],
model="haiku",
),
"refund-processor": AgentDefinition(
description="Processes a refund for an already verified customer.",
prompt="Check the order belongs to the verified customer, then refund it.",
tools=["mcp__support__lookup_order", "mcp__support__process_refund"],
model="haiku",
),
},
)8. Les anti-patterns de l’examen
| Anti-pattern | Pourquoi c’est faux | La bonne approche |
|---|---|---|
| renforcer le prompt pour qu’un outil sensible soit appelé au bon moment | une consigne reste une probabilité | une porte ou un contrôle en code |
| emballer la prose du modèle dans un dictionnaire | les faits restent illisibles pour le code | des champs tirés des résultats d’outils |
| donner tous les outils à tous les sous-agents | un sous-agent peut sortir de son rôle | seulement les outils du rôle |
| laisser le modèle fournir l’identité vérifiée | il peut se tromper ou l’inventer | le coordinateur la fixe dans l’outil |
| marquer « introuvable » comme une erreur | Claude croit à une panne et réessaie | un résultat valide, sans is_error |
| lire le statut final dans le résumé du modèle | le texte peut contredire les faits | le statut vient des résultats d’outils |
9. Deux scénarios d’examen
Scénario 1. Un agent de support appelle parfois process_refund sans avoir appelé
get_customer. L’équipe propose d’ajouter « TOUJOURS vérifier le client avant un remboursement »
en majuscules dans le prompt système.
Le problème : une consigne plus forte réduit la fréquence, mais n’élimine pas le cas.
La correction : rendre la vérification obligatoire en code. process_refund est bloqué tant que
get_customer n’a pas renvoyé un client vérifié, par une porte dans l’orchestration ou par un hook.
Scénario 2. Le sous-agent de remboursement rembourse parfois des commandes d’un autre client.
Le coordinateur lui transmet : « Le vérificateur dit que le client semble correct. »
Le problème : une conclusion vague en prose, sans identifiant ni champ testable.
La correction : transmettre un constat structuré (customer_id, verified, plan), et faire
vérifier l’appartenance de la commande par process_refund lui-même.
Si le scénario 1 dit que l'erreur arrive « dans 0,1 % des cas, sans conséquence financière », la réponse attendue peut changer. Une consigne plus précise ou un exemple ajouté au prompt peut alors suffire. Le code devient indispensable quand l'erreur coûte de l'argent, expose des données ou touche à la sécurité.
Fiche de révision
| Construire | Contrôler |
|---|---|
| un sous-agent = une boucle agentique avec un rôle étroit | une règle métier critique se met en code, pas en consigne |
| chaque sous-agent ne reçoit que ses outils | une porte entre deux étapes évite de lancer un sous-agent pour rien |
| le contexte est transmis explicitement, en JSON | l’outil sensible vérifie lui-même ses conditions |
| le constat est construit à partir des résultats d’outils | l’identité vérifiée est fixée par le coordinateur |
| l’agrégation produit un statut testable et un résumé | la boucle gère les arrêts autres que end_turn et tool_use |
Glossaire
| Terme | Définition |
|---|---|
| coordinateur | le code ou l’agent qui découpe la tâche, lance les sous-agents et assemble les résultats |
| constat (finding) | le résultat d’une étape transmis à la suivante, sous forme de champs testables |
| porte (gate) | un contrôle en code entre deux étapes, qui bloque la suite si une condition n’est pas remplie |
| contrôle dans l’outil | une vérification faite par la fonction de l’outil elle-même, quelle que soit la demande du modèle |
| journal des appels | la liste des outils appelés, avec leurs entrées et leurs sorties |
| moindre privilège | ne donner à chaque sous-agent que les outils de son rôle |
is_error |
le champ d’un tool_result qui signale une vraie panne de l’outil |
| hook | dans l’Agent SDK, une fonction qui intercepte un appel d’outil avant ou après son exécution |
Quiz d’entraînement
Ces questions sont de moi, écrites dans le style de l’examen. Ce ne sont pas des questions officielles. Choisissez une réponse : la correction s’affiche aussitôt, et le score se calcule en bas. Connecté, votre meilleur score est gardé dans votre compte, et « Recommencer » efface seulement vos réponses à l’écran. Sans compte, rien n’est enregistré.
Pour la suite
On a maintenant un programme complet : une boucle avec ses garde-fous, deux sous-agents limités à leur rôle, un constat structuré et des contrôles en code. L’épisode 5 continue le domaine 1 de l’examen.