- Les noms de modèles LLM changent silencieusement : ne jamais les coder en dur, utiliser une variable d'environnement.
- Les LLM retournent du JSON entouré de balises markdown dans environ 5% des cas ; un parser en 3 couches (strip, parse direct, extraction regex) évite les exceptions en production.
- Les rate limits combinent requêtes/minute ET tokens/minute ; un prompt long peut déclencher un 429 même sous la limite de requêtes.
- Sans timeout explicite, un appel LLM peut bloquer le script indéfiniment.
- Centraliser nom de modèle, timeout et logique de retry dans un wrapper dédié réduit les bugs de production à un seul endroit à corriger.
J’avais un script qui tournait parfaitement depuis trois semaines.
Un matin, il s’est mis à planter avec une erreur que je n’avais jamais vue :
404 models/gemini-pro is not found for API version v1.
Le modèle n’avait pas bougé de mon côté. Mon code n’avait pas changé. Google avait simplement renommé le modèle dans une mise à jour silencieuse, sans notification, sans période de transition visible dans la doc que j’avais lue.
J’ai passé deux heures à chercher ce qui n’allait pas dans mon code avant de comprendre que le problème venait d’ailleurs.
C’est ce genre de galère que les documentations officielles ne couvrent pas. Elles t’expliquent comment faire un premier appel qui fonctionne. Elles ne t’expliquent pas pourquoi ton code va casser en production trois semaines plus tard.
Voici les quatre problèmes que j’ai rencontrés en intégrant Gemini sur SL2S-Bot et Claude sur Copyboost, et comment les gérer proprement.
Problème 1 : les noms de modèles qui changent et cassent ton code en silence
Réponse directe : Les fournisseurs LLM mettent à jour et renomment leurs modèles régulièrement. Un nom de modèle codé en dur dans ton script va finir par renvoyer une erreur 404 ou 400 sans que tu aies touché une ligne de code. La solution est de centraliser le nom du modèle dans une variable d’environnement et de logger précisément l’erreur quand elle arrive.
C’est le bug le plus frustrant parce qu’il ne ressemble pas à un bug. Ton code est correct. Ton token est valide. Mais la réponse est 404.
Quelques exemples concrets de changements de noms qui ont cassé des intégrations :
gemini-proest devenugemini-1.0-pro, puis certains endpoints ont migré versgemini-1.5-pro- OpenAI a déprécié
gpt-3.5-turbo-0301etgpt-4-0314avec des dates limites qui ne sont pas toujours communiquées clairement - Anthropic a des noms de modèles versionnés comme
claude-haiku-4-5-20251001qui incluent une date, ce qui rend la dépréciation plus prévisible, mais cela reste à surveiller
La fix :
Ne colle jamais un nom de modèle en dur dans le corps de ton code. Mets-le dans ton .env :
LLM_MODEL=gemini-1.5-flash
Et dans ton code :
import os
from dotenv import load_dotenv
load_dotenv()
MODEL = os.environ["LLM_MODEL"]
Quand le modèle change, tu updates une ligne dans ton .env sans toucher au code. Et quand ça plante, ton message d’erreur te dit exactement quel modèle a posé problème.
Le deuxième réflexe : mets en favoris la page “Deprecation” de chaque fournisseur que tu utilises. Google, Anthropic et OpenAI les maintiennent toutes. Consulte-les avant de partir en vacances, pas après que ton script ait planté un samedi matin.
Problème 2 : le parsing JSON qui plante en production de manière imprévisible
Réponse directe : Quand tu demandes à un LLM de retourner du JSON, il ne le fait pas toujours proprement. Il peut entourer la réponse de balises markdown, ajouter un commentaire explicatif avant le JSON, ou retourner un JSON invalide quand le prompt est ambigu. Un json.loads() direct sans protection va lever une exception non gérée en production.
Sur Copyboost, j’ai un module qui demande à Claude d’analyser un texte et de retourner un score structuré en JSON. Ça fonctionne à 95% du temps. Les 5% restants, le modèle décide d’entourer sa réponse de balises markdown comme ceci :
```json
{
"score": 72,
"axes": [...]
}
```
Et mon json.loads() explose parce qu’il reçoit une chaîne qui commence par des backticks, pas par une accolade.
La plupart des incidents de ce type sont auto-infligés, causés par des formats de sortie impossibles à parser à l’échelle. Ce n’est pas une défaillance du modèle, c’est une absence de robustesse dans le code qui reçoit la réponse.
La fix en trois couches :
import json
import re
def parse_llm_json(raw_response: str) -> dict:
text = raw_response.strip()
# Couche 1 : suppression des balises markdown si présentes
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
# Couche 2 : tentative de parse direct
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Couche 3 : extraction du premier objet JSON trouvé dans la réponse
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise ValueError(f"Impossible de parser la réponse LLM comme JSON : {raw_response[:200]}")
Le réflexe complémentaire : dans ton prompt, précise explicitement le format attendu avec un exemple. “Réponds uniquement avec un objet JSON valide, sans texte avant ni après, selon ce format exactement : {...}”. Ça réduit le taux d’erreur de parsing, sans l’éliminer complètement.
Problème 3 : les rate limits que tu ne vois pas venir
Réponse directe : Chaque fournisseur LLM impose deux types de limites simultanément : un nombre de requêtes par minute et un nombre de tokens par minute. Tu peux être sous la limite de requêtes et quand même te faire throttler si tes prompts sont longs. L’erreur HTTP 429 arrive sans prévenir et sans retry automatique dans la plupart des implémentations naïves.
Anthropic mesure ses rate limits en requêtes par minute, tokens d’entrée par minute et tokens de sortie par minute simultanément. Tu peux envoyer 10 requêtes par minute sans problème. Mais si chaque requête contient un prompt de 5 000 tokens, tu vas dépasser le plafond de tokens par minute bien avant d’atteindre la limite de requêtes.
Sur SL2S-Bot, j’ai eu ce problème exactement. Le bot recevait plusieurs messages WhatsApp en rafale, lançait autant de requêtes Gemini en parallèle, et la deuxième ou troisième réponse revenait en 429. L’utilisateur voyait le bot répondre à son premier message, puis plus rien.
La fix : un retry avec exponential backoff
import time
import random
from functools import wraps
def with_retry(max_retries: int = 3, base_delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
error_str = str(e).lower()
is_rate_limit = "429" in error_str or "rate limit" in error_str
is_last_attempt = attempt == max_retries - 1
if not is_rate_limit or is_last_attempt:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limit atteint. Nouvelle tentative dans {delay:.1f}s...")
time.sleep(delay)
return wrapper
return decorator
@with_retry(max_retries=3, base_delay=2.0)
def call_llm(prompt: str) -> str:
# ton appel API ici
pass
Le jitter aléatoire évite que plusieurs instances de ton script tentent toutes de réessayer exactement au même moment, ce qui aggraverait le problème.
Problème 4 : les timeouts et les réponses tronquées
Réponse directe : Les appels LLM peuvent durer 10 à 30 secondes pour des prompts longs. Sans timeout explicite dans ton code, une requête qui ne répond pas va bloquer ton script indéfiniment. Et même avec un timeout, si tu ne vérifies pas la présence d’un finish_reason dans la réponse, tu peux traiter une réponse tronquée comme si elle était complète.
J’ai eu ce problème sur Copyboost. Un utilisateur soumettait un texte très long, le modèle commençait à répondre, atteignait la limite de max_tokens avant de terminer, et ma fonction recevait une réponse tronquée au milieu d’un JSON. Résultat : exception de parsing, puis timeout côté utilisateur.
La fix : timeout explicite et vérification du finish_reason
import anthropic
import os
def call_claude(prompt: str, max_tokens: int = 1000) -> str:
client = anthropic.Anthropic(timeout=30.0)
message = client.messages.create(
model=os.environ["LLM_MODEL"],
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}]
)
if message.stop_reason == "max_tokens":
raise ValueError(
f"Réponse tronquée : le modèle a atteint la limite de {max_tokens} tokens. "
f"Augmente max_tokens ou réduis la taille du prompt."
)
return message.content[0].text
Pour Gemini, le champ équivalent s’appelle finish_reason dans la réponse et peut valoir MAX_TOKENS quand la réponse est incomplète.
La vérification du finish_reason est une ligne que la documentation officielle mentionne rarement dans les exemples d’introduction, mais qui est critique en production.
Le wrapper complet : tout centraliser en un seul fichier
Si tu utilises un seul fournisseur LLM dans ton projet, voici comment regrouper toutes ces protections en un fichier llm_client.py que tu importes partout :
# llm_client.py
import os
import json
import re
import time
import random
import anthropic
from dotenv import load_dotenv
load_dotenv()
MODEL = os.environ["LLM_MODEL"]
client = anthropic.Anthropic(timeout=30.0)
def _parse_json_response(raw: str) -> dict:
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
return json.loads(match.group())
raise ValueError(f"JSON invalide dans la réponse : {raw[:200]}")
def call_llm(
prompt: str,
max_tokens: int = 1000,
expect_json: bool = False,
max_retries: int = 3
):
for attempt in range(max_retries):
try:
message = client.messages.create(
model=MODEL,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}]
)
if message.stop_reason == "max_tokens":
raise ValueError(
f"Réponse tronquée après {max_tokens} tokens. "
f"Augmente max_tokens ou réduis le prompt."
)
raw_text = message.content[0].text
if expect_json:
return _parse_json_response(raw_text)
return raw_text
except Exception as e:
error_str = str(e).lower()
is_rate_limit = "429" in error_str or "rate limit" in error_str
is_last_attempt = attempt == max_retries - 1
if not is_rate_limit or is_last_attempt:
raise
delay = 2.0 * (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limit. Retry dans {delay:.1f}s (tentative {attempt + 1}/{max_retries})")
time.sleep(delay)
Ce fichier résout les quatre problèmes couverts dans cet article. Tu l’importes, tu appelles call_llm(prompt) ou call_llm(prompt, expect_json=True), et tu n’as plus à penser à la gestion d’erreur dans le reste de ton code.
Si tu veux voir comment j’ai intégré un pattern similaire dans un pipeline automatisé, j’ai documenté l’ensemble de ma stack d’automatisation LinkedIn avec Python et Notion qui utilise la même approche de centralisation.
Une intégration LLM qui fonctionne en local et qui tient en production, ce sont deux choses différentes.
La documentation officielle t’apprend à faire le premier appel qui marche. Ce que tu lis ici, c’est ce qui m’a planté après, en conditions réelles, sur de vrais projets.
Le wrapper est là, prêt à coller. La prochaine fois que ton script explose à 23h, tu sauras exactement pourquoi.
Si tu as eu d’autres erreurs LLM que je n’ai pas couvertes ici, dis-le en commentaire. J’enrichirai l’article avec les cas réels remontés.
Cet article fait partie d’une série sur la stack technique de builtwithbugs.com. Le prochain couvre les 5 erreurs fatales sur un premier déploiement Python en VPS.
Discussion