En prod, savoir ce qu'un LLM a effectivement reçu et répondu est non-négociable. Pas de Datadog, pas de LangSmith dans ce post — juste une table SQL et une cinquantaine de lignes de PHP. C'est suffisant pour 90 % des cas, et tu garderas le contrôle de tes données.

La table

CREATE TABLE llm_log (
    id INT AUTO_INCREMENT PRIMARY KEY,
    model VARCHAR(50) NOT NULL,
    prompt LONGTEXT NOT NULL,
    response LONGTEXT NULL,
    tokens_in INT NULL,
    tokens_out INT NULL,
    cost_usd DECIMAL(10, 6) NULL,
    latency_ms INT NULL,
    error VARCHAR(255) NULL,
    request_id VARCHAR(64) NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX (created_at),
    INDEX (model, created_at)
) ENGINE=InnoDB;

Le wrapper

Toute la logique tient dans une classe qui prend une closure et la mesure :

class LlmLogger {
    public function __construct(private PDO $pdo) {}

    public function call(string $model, string $prompt, callable $fn): mixed {
        $t0 = microtime(true);
        $requestId = bin2hex(random_bytes(8));
        $response = $tokensIn = $tokensOut = $cost = $error = null;

        try {
            $result = $fn();
            $response = $result['text'] ?? null;
            $tokensIn = $result['usage']['input'] ?? null;
            $tokensOut = $result['usage']['output'] ?? null;
            $cost = $this->computeCost($model, $tokensIn, $tokensOut);
            return $result;
        } catch (Throwable $e) {
            $error = substr($e->getMessage(), 0, 250);
            throw $e;
        } finally {
            $this->pdo->prepare(
                'INSERT INTO llm_log (model, prompt, response, tokens_in, tokens_out,
                 cost_usd, latency_ms, error, request_id)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
            )->execute([
                $model, $prompt, $response, $tokensIn, $tokensOut, $cost,
                (int) ((microtime(true) - $t0) * 1000), $error, $requestId,
            ]);
        }
    }

    private function computeCost(string $model, ?int $in, ?int $out): ?float {
        $prices = [
            'claude-sonnet-4-5' => [0.000003, 0.000015],
            'claude-haiku-4-5'  => [0.000001, 0.000005],
        ];
        if (!isset($prices[$model]) || $in === null || $out === null) return null;
        return $in * $prices[$model][0] + $out * $prices[$model][1];
    }
}

Usage

$logger = new LlmLogger($pdo);
$response = $logger->call('claude-sonnet-4-5', $userPrompt, function () use ($userPrompt) {
    return callAnthropicApi($userPrompt); // ta fonction d'appel API
});

Pourquoi ça suffit

  • Tu vois tout : un SELECT * FROM llm_log ORDER BY id DESC LIMIT 50 donne tes 50 derniers appels avec le contenu intégral.
  • Tu sais combien tu payes : SELECT model, SUM(cost_usd) FROM llm_log GROUP BY model.
  • Tu détectes les anomalies : moyenne mobile de la latence, % d'erreurs, requests dupliqués.
  • Tu reproduis les bugs : copier-coller un prompt depuis la DB et le rejouer.

Quand tu passeras à 100 req/s, il faudra basculer vers un système async (queue Symfony Messenger + worker) pour ne pas bloquer la requête utilisateur sur le INSERT. Mais pour démarrer, le sync direct fait l'affaire.