Un LLM local qui tombe en timeout au premier appel après chaque déploiement, sans aucune erreur dans le code : c’est ce qu’a fait l’assistant du Journal des Nuisances dès que son prompt système changeait. Relire le prompt sans cache prenait ~90 s sur la machine d’inférence, alors que l’Edge Function qui l’appelle est plafonnée à 60 s. La requête était coupée avant la fin de la lecture, la lecture annulée, et le prompt ne finissait jamais en cache. Le correctif : préchauffer le cache dans la CI, juste après le déploiement.
Le symptôme : HTTP 500 après chaque changement du socle
L’assistant tourne sur Ollama, sur pc-llm, un PC Snapdragon X Elite 64 Go qui calcule uniquement sur le CPU. Une Supabase Edge Function, assistant-repondre, compose la requête et relaie la réponse. Le socle est la partie fixe du prompt système : les règles de l’assistant, suivies des fiches validées.
En temps normal, le socle est lu une fois puis réutilisé par le cache de préfixe d’Ollama, qui ne relit que ce qui suit le plus long début commun avec la requête précédente. J’ai détaillé ce mécanisme et ses pièges dans l’article sur l’heure en tête du prompt qui casse le cache Ollama.
Le 9 octobre 2026, après une modification du socle, chaque question s’est soldée par un HTTP 500 avant le premier mot. La recette avait déjà relevé des premiers mots jusqu’à 98 s ; cette fois, aucun n’arrivait.
Pourquoi le premier appel au LLM local tombe-t-il en timeout à chaque fois ?
C’est un interblocage (deadlock) : deux conditions qui s’attendent l’une l’autre.
- Le socle a changé : son préfixe n’est plus en cache, il faut le relire en entier, ~90 s.
- L’Edge Function coupe la requête à 60 s.
- Couper la requête annule la lecture côté Ollama : le nouveau préfixe n’est jamais mis en cache.
- La question suivante repart donc de zéro, et retombe au point 1.
Quand la lecture à froid dépasse le timeout de l’appelant, le cache ne se remplit jamais : l’assistant reste hors service sans aucune erreur de code.
Attendre ne sert à rien, relancer non plus. Rien dans les logs de la fonction n’indique un bug : c’est un problème de temps de lecture, pas de logique.
Préchauffer le cache du prompt dans la CI
La lecture à froid doit donc se faire hors de la fonction, par un appel qui n’a pas de plafond de 60 s. La requête de préchauffage est construite par le même code que la production : un seul octet de différence dans le prompt, et le préfixe mis en cache ne servirait à rien.
export function corpsPrechauffage(fiches: FicheValidee[], modele: string) {
return {
model: modele,
stream: false,
think: false,
keep_alive: -1,
options: { num_predict: 1 }, // un seul token généré : on ne veut que la lecture
messages: composerMessages(promptSysteme(fiches), null, [
{ role: "user" as const, content: "Bonjour" },
]),
};
}
num_predict: 1 limite la génération à un token : le coût est celui de la lecture du prompt, rien de plus. La même fonction sert à une action réservée aux administrateurs dans assistant-repondre, qui lance l’appel sans l’attendre, puisque la lecture à froid dépasse aussi le délai du proxy.
Dans le workflow de déploiement, une étape lit les fiches validées en base puis lance le script dans un conteneur Node jetable, sur le runner de srv-a :
- name: Préchauffer l'assistant
timeout-minutes: 20
run: |
fiches=$(docker exec "$DB_CONTAINER" psql -U postgres -d postgres -At -c \
"select coalesce(json_agg(json_build_object('id', id, 'version', version, 'texte', texte)), '[]') from public.knowledge_cards where statut = 'validee'") \
|| { echo "::warning::fiches illisibles, préchauffage sur le socle seul"; fiches='[]'; }
echo "$fiches" | docker run --rm -i --network host -v "$PWD:/w:ro" -w /w \
node:24-alpine node --experimental-strip-types scripts/prechauffer-assistant.ts \
|| echo "::warning::préchauffage de l'assistant impossible"
Le script appelle /api/chat d’Ollama avec un AbortSignal.timeout(15 * 60 * 1000), puis journalise prompt_eval_count et prompt_eval_duration. Il ne fait jamais échouer le déploiement : un préchauffage manqué produit un ::warning::, qui rappelle que les questions seront coupées à 60 s tant que le préfixe n’est pas relu.
Raccourcir la lecture à froid
Préchauffer règle l’interblocage ; raccourcir le prompt réduit la fenêtre où il peut se produire. Deux réécritures, chacune tenue par un test de budget :
| Partie du prompt | Avant | Après | Gain |
|---|---|---|---|
| Socle (lu une fois, puis en cache) | 11 674 car. | 9 634 car. | 17 % |
| Contexte de la personne (relu à chaque question, ~29 tokens/s) | 3 829 car. | 2 236 car. | 42 % |
Pour le socle, les redites ont fusionné, avec les mêmes règles. Pour le contexte, tout ce qui vaut pour tout le monde est passé dans le socle, qui profite du cache. Le test de budget est volontairement bête :
describe("le budget du socle", () => {
it("tient en 9 700 caractères", () => {
expect(promptSysteme().length).toBeLessThanOrEqual(9700);
});
});
Une correction future du socle doit tenir dans le budget, ou remplacer une phrase.
⚠️ Autre plafond sur le même chemin : traefik coupait vers 124 s les réponses non streamées. La fonction relaie donc le flux d’Ollama en SSE (Server-Sent Events, un flux HTTP où le serveur pousse les morceaux au fil de l’eau) : le texte arrive dès qu’il est prêt.
Lessons learned
- Un timeout plus court que la lecture à froid empêche le cache de se remplir. La requête coupée annule la lecture, et chaque nouvel essai repart de zéro.
- Un LLM local peut être hors service sans aucune erreur de code. Le symptôme est un
HTTP 500avant le premier mot, la cause un temps de lecture. - Le préchauffage doit construire le prompt avec le code de production. Sinon le préfixe mis en cache ne correspond pas à celui des vraies requêtes.
- Une étape de préchauffage ne doit jamais faire échouer un déploiement. Un avertissement suffit ; bloquer la mise en production pour un cache serait pire.
- Un test de budget en caractères protège la lecture à froid. Le prompt grossit par petites retouches ; le test oblige à retirer une phrase pour en ajouter une.