Appels Parallèles de Fonctions
Mis à jour le 29 juillet 2026
Quand le modèle a besoin de plusieurs informations à la fois
Jusqu’à présent, chaque tour de conversation se soldait par un appel de fonction unique. Beaucoup de questions réelles n’entrent pas dans ce moule. Prenez un utilisateur qui écrit « Quel est le statut et la date du paiement T1001 ? » : le modèle a besoin de deux informations qui vivent dans deux fonctions distinctes, retrieve_payment_status et retrieve_payment_date. Il pourrait les demander l’une après l’autre, en attendant votre réponse entre les deux, mais il détermine ici qu’aucune des deux ne dépend de l’autre, et il génère plusieurs tool_calls dans une seule réponse. Vous recevez la totalité du travail à faire d’un coup, et c’est vous qui décidez ensuite comment l’exécuter.
Lire une réponse qui contient plusieurs appels
La conséquence pratique est simple : message.tool_calls cesse d’être une liste à un seul élément. Vous devez donc la parcourir plutôt que de piocher tool_calls[0], réflexe fréquent quand on a appris le function calling sur des exemples à une fonction.
response = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools
)
message = response.choices[0].message
# Plusieurs tool_calls dans la même réponse
for tc in message.tool_calls:
print(f"Fonction : {tc.function.name}")
print(f"Arguments : {tc.function.arguments}")
print(f"ID : {tc.id}")
print("---")
# Sortie possible :
# Fonction : retrieve_payment_status
# Arguments : {"transaction_id": "T1001"}
# ID : call_abc123
# ---
# Fonction : retrieve_payment_date
# Arguments : {"transaction_id": "T1001"}
# ID : call_def456
Remarquez que les deux appels portent le même argument T1001 mais des identifiants différents, call_abc123 et call_def456. Ces identifiants sont l’unique moyen dont dispose le modèle pour savoir quel résultat correspond à quelle demande.
Renvoyer chaque résultat à son appel d’origine
La règle est stricte : vous exécutez chaque fonction et vous renvoyez chaque résultat dans un message tool distinct, porteur du tool_call_id correspondant. Un identifiant oublié ou interverti et le modèle attribue la date au champ statut, ou refuse de répondre.
message = response.choices[0].message
messages.append(message)
# Exécuter toutes les fonctions demandées
for tool_call in message.tool_calls:
function_name = tool_call.function.name
function_params = json.loads(tool_call.function.arguments)
# Exécuter la fonction
result = available_functions[function_name](**function_params)
# Ajouter le résultat avec le bon tool_call_id
messages.append({
"role": "tool",
"name": function_name,
"content": result,
"tool_call_id": tool_call.id
})
# Renvoyer tous les résultats au modèle
final_response = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools
)
print(final_response.choices[0].message.content)
# → "Le paiement T1001 a été effectué le 15 mars 2026 et son statut est : payé."
Le modèle recolle alors les deux fragments en une phrase unique, comme si l’information venait d’une seule source. C’est exactement l’effet recherché du point de vue de l’utilisateur.
Passer d’un parallélisme apparent à un parallélisme réel
Une nuance échappe souvent à la première lecture : dans la boucle for ci-dessus, les fonctions s’exécutent l’une après l’autre. Le modèle a bien demandé ses appels en parallèle, mais votre code, lui, reste séquentiel. Tant que vos fonctions lisent un DataFrame en mémoire, cela n’a aucune importance. Dès qu’elles interrogent une API externe ou une base de données, deux appels de 300 ms deviennent 600 ms de latence perçue, et le gain du parallélisme s’évapore. Si vos fonctions sont asynchrones, asyncio.gather les lance ensemble et attend le lot complet.
import asyncio
import json
async def execute_tools_parallel(tool_calls, available_functions):
"""Exécute les tool_calls en parallèle et retourne les résultats."""
async def execute_one(tool_call):
function_name = tool_call.function.name
function_params = json.loads(tool_call.function.arguments)
# Si vos fonctions sont async :
result = await available_functions[function_name](**function_params)
return {
"role": "tool",
"name": function_name,
"content": result,
"tool_call_id": tool_call.id
}
# Exécuter toutes les fonctions en parallèle
tasks = [execute_one(tc) for tc in tool_calls]
return await asyncio.gather(*tasks)
La plupart des bases de code existantes exposent des fonctions synchrones, qu’on ne va pas réécrire en async pour l’occasion. Un pool de threads produit le même effet sans toucher aux fonctions métier.
from concurrent.futures import ThreadPoolExecutor
def execute_tools_threaded(tool_calls, available_functions):
"""Exécute les tool_calls avec un pool de threads."""
def execute_one(tool_call):
function_name = tool_call.function.name
function_params = json.loads(tool_call.function.arguments)
result = available_functions[function_name](**function_params)
return {
"role": "tool",
"name": function_name,
"content": result,
"tool_call_id": tool_call.id
}
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(execute_one, tool_calls))
return results
Reprendre la main avec parallel_tool_calls
Le parallélisme n’est pas toujours souhaitable, et le paramètre parallel_tool_calls vous permet de l’autoriser ou de l’interdire. À True, valeur par défaut, le modèle décide seul s’il regroupe ses appels ; à False, il ne peut produire qu’un tool_call par réponse.
# Permettre les appels parallèles (par défaut)
response = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools,
parallel_tool_calls=True # Le modèle décide
)
# Forcer les appels séquentiels
response = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools,
parallel_tool_calls=False # Un seul tool_call par réponse
)
Trois situations justifient de passer à False. La première est celle où l’ordre d’exécution des fonctions compte : facturer avant de vérifier le stock n’a pas le même résultat que l’inverse. La deuxième est la dépendance de données, quand le résultat d’une fonction est nécessaire pour appeler la suivante — chercher un contact pour obtenir son identifiant avant de le modifier, par exemple. La troisième relève simplement du besoin d’un contrôle plus fin sur le flux, typiquement en phase de mise au point, quand vous voulez observer chaque appel isolément avant d’ouvrir les vannes.
Ce que le parallélisme change vraiment
Le gain n’est pas seulement une question de threads : il porte d’abord sur le nombre d’allers-retours avec l’API, qui dominent la latence totale.
# Séquentiel (2 appels API au modèle)
user → assistant(fc.1) → tool(r.1) → assistant(fc.2) → tool(r.2) → assistant(réponse)
# Parallèle (1 seul appel API supplémentaire)
user → assistant(fc.1, fc.2) → tool(r.1), tool(r.2) → assistant(réponse)
Dans le schéma séquentiel, il faut deux tours complets de raisonnement du modèle avant la réponse finale. Dans le schéma parallèle, une seule requête suffit pour obtenir tous les tool_calls, et une seule requête supplémentaire pour la réponse. Sur une question à deux ou trois fonctions, cela représente couramment plusieurs secondes de moins pour l’utilisateur.
Points clés à retenir
- Le modèle peut générer plusieurs tool_calls dans une seule réponse
- Chaque résultat doit être renvoyé avec le bon
tool_call_id - Pour des fonctions réseau, utilisez
asyncioouThreadPoolExecutorpour une exécution parallèle réelle - Le paramètre
parallel_tool_callscontrôle ce comportement (Truepar défaut) - Le pattern parallèle réduit le nombre de requêtes API et améliore la latence