Aller au contenu principal

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 asyncio ou ThreadPoolExecutor pour une exécution parallèle réelle
  • Le paramètre parallel_tool_calls contrôle ce comportement (True par défaut)
  • Le pattern parallèle réduit le nombre de requêtes API et améliore la latence