Le function calling (ou tool calling) permet à un LLM de déclencher l'exécution d'une fonction définie côté application plutôt que de se contenter de produire du texte : le modèle répond par une demande d'appel structurée (nom de fonction + arguments), l'application l'exécute, et renvoie le résultat au modèle pour qu'il poursuive. Cet article détaille la différence entre ce mécanisme et un simple prompt ; celui-ci couvre la fiabilisation d'une sortie JSON brute. Ici, on implémente la mécanique complète en PHP : décrire les outils, dispatcher les appels en sécurité, boucler jusqu'à la réponse finale.

Étape 1 — décrire les outils disponibles

Chaque outil est annoncé au modèle via un schéma JSON (nom, description, paramètres attendus) — c'est sur cette description seule que le modèle décide quand et comment l'appeler, donc elle mérite autant de soin qu'un prompt.

$tools = [
    [
        'name' => 'search_docs',
        'description' => 'Recherche dans la documentation interne et retourne les passages pertinents.',
        'parameters' => [
            'type' => 'object',
            'properties' => [
                'query' => ['type' => 'string', 'description' => 'La question à chercher'],
            ],
            'required' => ['query'],
        ],
    ],
];

Étape 2 — une table de dispatch fermée

Le modèle renvoie un nom de fonction sous forme de chaîne — jamais une référence de code. La tentation est d'appeler call_user_func($name, ...$args) directement sur ce nom : c'est exactement le schéma du confused deputy, où le code applicatif, qui a plus de droits que le modèle, exécute une instruction au nom d'un tiers sans vérifier qu'elle est légitime. La fonction qui reçoit le nom décidé par le LLM doit toujours passer par une liste fermée, jamais par un appel dynamique sur une chaîne arbitraire :

final class ToolDispatcher {
    /** @var array<string, callable> */
    private array $handlers;

    public function __construct(array $handlers) {
        $this->handlers = $handlers; // liste fermée, définie par le code, pas par le modèle
    }

    public function call(string $name, array $args): string {
        if (!isset($this->handlers[$name])) {
            return "Erreur : outil '$name' inconnu."; // jamais d'exécution sur un nom inattendu
        }
        return ($this->handlers[$name])($args);
    }
}

Étape 3 — brancher un vrai outil

Le pipeline RAG construit dans l'article précédent se branche directement comme handler : le modèle décide quand chercher dans la documentation, plutôt que de systématiquement injecter du contexte à chaque message.

$dispatcher = new ToolDispatcher([
    'search_docs' => function (array $args) use ($pdo, $embedder, $llm): string {
        $rows = topChunks($pdo, $embedder, $args['query'], limit: 4);
        return implode("\n\n", array_column($rows, 'chunk_text'));
    },
]);

Étape 4 — la boucle d'exécution

Le modèle peut enchaîner plusieurs appels d'outils avant de répondre. La boucle s'arrête soit quand il renvoie une réponse finale sans demande d'outil, soit à une limite de sécurité — sans elle, un modèle qui boucle sur un outil qui échoue tourne indéfiniment, à vos frais.

function runWithTools(LlmClient $llm, ToolDispatcher $dispatcher, array $tools, string $question, int $maxTurns = 6): string {
    $messages = [['role' => 'user', 'content' => $question]];

    for ($turn = 0; $turn < $maxTurns; $turn++) {
        $response = $llm->completeWithTools($messages, $tools);

        if ($response->toolCall === null) {
            return $response->text; // réponse finale, on sort de la boucle
        }

        $result = $dispatcher->call($response->toolCall->name, $response->toolCall->arguments);
        $messages[] = ['role' => 'assistant', 'tool_call' => $response->toolCall];
        $messages[] = ['role' => 'tool', 'content' => $result];
    }

    throw new RuntimeException("Pas de réponse finale après $maxTurns tours d'outils.");
}
Messages + liste des outils LLM décide demande d'outil Dispatch fermé Handler PHP ex. search_docs résultat ajouté aux messages réponse finale (pas d'outil) Sortie
Le dispatch fermé est la seule étape où le code applicatif reprend la main sur ce que le modèle a demandé — c'est là que la vérification doit avoir lieu, pas après.
Dispatch dynamiqueDispatch fermé
Nom d'outil inattendutente l'appel, erreur ou pirerejeté avant exécution
Surface exposée au modèletoute fonction nommableseulement les handlers déclarés
Auditdifficile, impliciteliste explicite, relisable
Le coût de la table fermée est une ligne de déclaration par outil — négligeable face à ce qu'elle empêche.

Règle

Le modèle choisit quand et avec quels arguments appeler un outil ; le code applicatif garde le contrôle total sur ce qui peut être appelé. Une table de dispatch fermée, une limite de tours, et des handlers qui valident leurs propres arguments avant d'agir — le reste (format des messages, nom exact des champs) varie d'un provider à l'autre, mais cette discipline ne change pas.

FAQ

Pourquoi ne pas utiliser directement call_user_func sur le nom renvoyé par le modèle ?

Parce que ce nom vient d'une source non fiable (la sortie du modèle, elle-même influencée par le contenu qu'il a lu). Une table de dispatch fermée garantit qu'aucun nom inattendu ne peut déclencher une exécution.

Faut-il une limite au nombre d'appels d'outils ?

Oui, systématiquement. Sans limite, un outil qui renvoie une erreur ou un modèle qui boucle sur une mauvaise stratégie peut enchaîner les appels indéfiniment, à vos frais et sans résultat.

Le function calling remplace-t-il le RAG ?

Non, ils se combinent : le RAG peut être exposé comme un outil parmi d'autres, que le modèle décide d'appeler ou non selon la question — plutôt que d'injecter systématiquement du contexte, pertinent ou non.