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 50donne 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.