Implémenter la boucle multi-tours
Sur cette page
Avec les utilitaires de la leçon précédente, on peut écrire la boucle qui fait vivre un assistant à outils : appeler Claude, exécuter les outils qu’il demande, lui renvoyer les résultats, et recommencer jusqu’à sa réponse finale.
Le signal : stop_reason
La réponse dit elle-même si Claude attend un outil : son champ stop_reason vaut alors "tool_use". Inutile de deviner en inspectant le texte. Tant que stop_reason vaut "tool_use", on exécute et on relance ; dès qu’il vaut autre chose, la boucle s’arrête.
La boucle
def run_conversation(messages, tools, max_turns=10):
for _ in range(max_turns):
response = chat(messages, tools=tools)
add_assistant_message(messages, response)
text = text_from_message(response)
if text:
print(text)
if response.stop_reason != "tool_use":
return response
add_user_message(messages, run_tools(response))
raise RuntimeError(f"Claude demande encore des outils après {max_turns} tours")
À chaque tour, on appelle Claude avec les outils, on ajoute sa réponse entière à l’historique et on affiche son texte éventuel. S’il ne demande pas d’outil, on rend la réponse finale. Sinon, on exécute les outils et on renvoie leurs résultats dans un message user.
Le plafond max_turns n’existe pas dans la version du cours officiel, qui boucle avec while True. Il vaut mieux l’écrire dès la première version : chaque tour est un appel facturé, et un outil qui renvoie toujours un résultat insatisfaisant peut faire tourner la boucle sans fin.
Les autres valeurs de stop_reason
La boucle rend la réponse dès que stop_reason n’est plus "tool_use". À l’appelant de regarder la valeur :
| Valeur | Signification |
|---|---|
end_turn | Claude a terminé : c’est la réponse finale. |
max_tokens | La réponse a été coupée par le plafond de jetons. Augmentez max_tokens et relancez ; si la coupure tombe au milieu d’un appel d’outil, ne l’exécutez pas. |
refusal | Claude a refusé de poursuivre. N’exécutez aucun outil de ce tour. |
pause_turn | Un outil serveur, comme la recherche web, a mis le tour en pause : renvoyez la conversation telle quelle pour qu’il reprenne (leçon 44). |
Exécuter tous les outils demandés
Une réponse peut contenir plusieurs blocs tool_use. On les traite tous, et chacun reçoit son tool_result :
import json
def run_tools(message):
tool_results = []
for block in message.content:
if block.type != "tool_use":
continue
try:
output = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output if isinstance(output, str) else json.dumps(output, ensure_ascii=False),
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Erreur : {e}",
"is_error": True,
})
return tool_results
Oublier un bloc ne fait pas attendre Claude en silence : l’API refuse la requête suivante avec une erreur 400 (tool_use ids were found without tool_result blocks immediately after). C’est l’erreur la plus fréquente des premières boucles, qui ne traitent que le premier appel.
Les erreurs d’outil vont à Claude
Si un outil lève une exception, on ne l’avale pas : on renvoie un tool_result avec is_error: True et le message d’erreur. Claude peut alors corriger ses arguments et réessayer, choisir une autre approche ou expliquer le problème à l’utilisateur. C’est là que servent les messages d’erreur instructifs de la leçon 33.
Le bloc except Exception attrape tout, y compris vos propres bogues. En production, journalisez aussi l’exception côté serveur : Claude saura que l’outil a échoué, et vous saurez pourquoi.
Router vers la bonne fonction
TOOLS = {
"get_current_datetime": get_current_datetime,
}
def run_tool(name, tool_input):
if name not in TOOLS:
raise ValueError(f"outil inconnu : {name}")
return TOOLS[name](**tool_input)
Le cours officiel écrit ce routeur avec une suite de if et elif. Un dictionnaire fait la même chose : ajouter un outil revient à ajouter une ligne, et un nom inconnu produit une erreur claire, renvoyée à Claude comme les autres.
L’essai
messages = []
add_user_message(messages, "Quelle heure est-il, au format HH:MM:SS ?")
final = run_conversation(messages, tools=[get_current_datetime_schema])
print(final.stop_reason) # "end_turn"
La même boucle, écrite par le SDK
Le SDK Python propose un exécuteur d’outils (tool runner, en bêta) qui fait tourner cette boucle à votre place : on décore les fonctions avec @beta_tool, et client.beta.messages.tool_runner(...) appelle Claude, exécute les outils et renvoie les résultats jusqu’à la réponse finale. La documentation le recommande pour la plupart des applications. Écrire la boucle une fois à la main reste le meilleur moyen de comprendre ce qu’il fait, et de savoir la reprendre quand il faut un contrôle plus fin.
À vous de jouer
Mettre la boucle à l’épreuve. Faites tourner run_conversation avec get_current_datetime, puis provoquez une erreur : faites lever une ValueError à la fonction quand le format contient %H. Observez comment Claude réagit au tool_result marqué is_error. Enfin, passez max_turns=1 et vérifiez que la boucle s’arrête avec l’erreur prévue.
Sources officielles : Implementing multiple turns (Claude Academy) · How tool use works (documentation Claude : la boucle et ses sorties) · Handle tool calls (
is_error) · Tool runner (SDK)
Points clés à retenir
- La boucle relance Claude tant que
stop_reasonvaut"tool_use", et s’arrête sur toute autre valeur. - Plafonnez le nombre de tours : chaque tour est un appel facturé.
- Chaque bloc
tool_usereçoit sontool_result; un oubli provoque une erreur 400, pas une attente. - Une exception d’outil repart vers Claude avec
is_error: True, et dans vos journaux. - Le tool runner du SDK fait tourner la même boucle pour vous.