ancienne salle de bal vide, parquet éclairé au fond

Un crawler LLM qui répond « ok » pendant 12 jours sans rien extraire

Chaque matin à 11 h, le crawler de swinghop.ch lit une quarantaine de pages d’écoles et d’organisateurs de danse swing en Suisse romande. Il envoie le texte et l’image de chaque page à un LLM, Claude Sonnet, qui en tire les événements à venir. Les fiches créées passent ensuite par une file de modération avant d’être publiées.

Du 19 au 30 septembre, ce crawler a tourné tous les jours. L’URL appelée par le cron répondait 200 avant même que le crawl commence, et le rapport affichait ensuite pour chaque source « 0 extrait, ok ». Pendant ces douze jours, il n’a créé aucune fiche, ni publiée ni refusée.

Personne n’a été prévenu. Je l’ai découvert le 1er octobre, en comptant les fiches par origine pour une autre raison.

Pourquoi rien n’a sonné

Le compte Claude avait atteint sa limite de dépense, et chaque appel au modèle échouait. Jusqu’au 29 septembre, la lecture d’image passait par Claude seul. Le repli gratuit ajouté ce jour-là lit environ une page par minute, trop peu pour un crawl.

Le vrai problème était ailleurs. Voici comment le crawl traitait une page dont l’extraction échouait :

try {
    return $this->extractor->extract($candidate);
} catch (\Throwable $e) {
    // Une page illisible ne doit pas faire tomber toute la source.
    $this->logger->warning('Extraction failed for a candidate: {error}', ['error' => $e->getMessage()]);

    return [];
}

L’intention était bonne : une page mal formée ne doit pas arrêter le crawl des autres. Mais ce return [] renvoie exactement la même chose qu’une page qui ne contient aucun événement. Une semaine creuse et une panne totale du modèle produisaient le même rapport.

Chaque échec produisait un avertissement dans les logs, mais aucun ne déclenchait d’alerte.

Distinguer une page vide d’un modèle indisponible

Premier correctif : si tous les fournisseurs échouent, on lève une exception qui conserve l’erreur de chacun. Avant, seule l’erreur du dernier fournisseur essayé remontait.

final class LlmUnavailableException extends \RuntimeException
{
    /**
     * @param array<string, string> $errors nom du fournisseur => son erreur
     */
    public function __construct(public readonly array $errors, ?\Throwable $previous = null)
    {
        $message = implode('; ', array_map(
            static fn (string $name, string $error): string => $name.': '.$error,
            array_keys($errors),
            $errors,
        ));

        parent::__construct('All LLM providers failed. '.$message, 0, $previous);
    }
}

L’extraction distingue maintenant trois résultats. Une liste, vide ou non, veut dire que le modèle a lu la page. null veut dire que l’extraction a échoué pour une autre raison, par exemple une réponse inutilisable. L’exception veut dire qu’aucun fournisseur n’a répondu, et seules ces pages sont comptées comme non lues.

try {
    return $this->extractor->extract($candidate);
} catch (LlmUnavailableException $e) {
    throw $e;
} catch (\Throwable $e) {
    $this->logger->warning('Extraction failed for a candidate: {error}', ['error' => $e->getMessage()]);

    return null;
}

La boucle du crawl compte alors les pages non lues, au lieu de les confondre avec des pages vides.

Les pages qui renvoient null ne comptent pas dans l’alerte. Le modèle a répondu, et la page sera relue le lendemain. Une réponse illisible de temps en temps arrive. Le cas qui m’a coûté douze jours, c’est celui où plus personne ne répond.

Alerter sur l’ensemble du crawl

Une page non lue de temps en temps, c’est du bruit : un timeout, une réponse lente. Un mail à chaque fois finirait ignoré. Ce qui compte, c’est la proportion sur l’ensemble du passage.

public function modelLooksUnavailable(): bool
{
    $unread = $this->unread();

    return $unread > 0 && 2 * $unread > $this->sentToModel();
}

Si plus de la moitié des pages envoyées au modèle reviennent non lues, je reçois un mail. Il donne le nombre de pages lues sur le total, l’erreur de chaque fournisseur et ce qu’il faut vérifier : le crédit Claude, puis les quotas des replis.

Un passage lancé à la main en --dry-run n’envoie pas de mail, puisque quelqu’un regarde déjà le résultat.

Ne pas payer deux fois la même page

En cherchant la cause, j’ai vu autre chose : le crawl relisait chaque page avec Claude tous les jours, même quand elle n’avait pas changé. C’est très probablement ce qui avait vidé le crédit.

Le crawl calcule maintenant une empreinte de ce que le modèle reçoit pour une page, sauf la date du jour :

public function fingerprint(string $sourceKey, EventCandidate $candidate, ?string $imageBase64, SwingGate $gate): string
{
    return hash('sha256', json_encode([
        $sourceKey,
        $candidate->sourceUrl,
        $candidate->text,
        null === $imageBase64 ? null : hash('sha256', $imageBase64),
        $gate->name,
        EventExtractor::instructionsFingerprint(),
    ], \JSON_THROW_ON_ERROR));
}

Les consignes d’extraction font partie de l’empreinte. Quand je change le prompt, toutes les pages sont relues le lendemain, au lieu d’attendre une semaine pour profiter du nouveau prompt.

Si la même empreinte a été lue dans les 7 derniers jours, la page est sautée. Le prompt contient aussi la date du jour, pour que le modèle sache à quoi correspond « ce samedi ». Elle n’entre pas dans l’empreinte, sinon l’empreinte changerait chaque jour et aucune page ne serait jamais sautée. Une page inchangée est donc quand même relue une fois par semaine : si elle dit « tous les mercredis », c’est cette relecture qui fait apparaître les mercredis suivants.

Un détail compte : l’empreinte n’est enregistrée qu’une fois tous les événements de la page traités. Une page non lue, ou dont l’enregistrement a échoué, sera donc relue au prochain passage. Sans ça, la mémoire aurait caché la panne une deuxième fois.

Le résultat

Le 4 octobre, le crawl a relu 3 pages sur 42, en 10 secondes au lieu de 8 minutes. Environ 93 % des appels à Claude ont été évités ce jour-là.

Et si plus de la moitié des pages envoyées échouent chez tous les fournisseurs, le crawl m’envoie maintenant un mail le jour même.

Un job qui répond « ok » dit seulement qu’il n’a pas planté. Il ne dit pas qu’il a fait son travail. Pour un crawler qui dépend d’un LLM, il faut compter ce qu’il produit et ce qu’il n’a pas pu lire, et séparer clairement « rien à trouver » de « personne n’a regardé ».

À lire aussi

Laisser un commentaire