Un agent, c'est une boucle : le modèle réfléchit, appelle un outil, lit le résultat, recommence jusqu'à ce que la tâche soit finie. Écrire cette boucle est devenu facile, les bibliothèques la fournissent. Ce qui reste à faire, c'est tout ce qui sépare un script qui marche sur votre poste d'un service qui tourne sans surveillance : redémarrage, secrets, journaux, reprises et plafond de dépense.
Le VPS est le bon support pour cela : l'agent y tourne en continu, avec une adresse IP stable et une facture fixe, indépendamment de votre portable.
1. L'agent lui-même
Voici une boucle complète en une trentaine de lignes. Les outils sont de simples fonctions Python : le décorateur en dérive le schéma, et le tool runner enchaîne appels et résultats jusqu'à la réponse finale.
# /srv/agent/agent.py
import logging
import anthropic
from anthropic import beta_tool
log = logging.getLogger("agent")
client = anthropic.Anthropic() # lit ANTHROPIC_API_KEY dans l'environnement
@beta_tool
def disk_usage(path: str = "/") -> str:
"""Retourne l'occupation disque d'un point de montage.
Args:
path: Le point de montage à inspecter, par exemple "/" ou "/var".
"""
import shutil
total, used, free = shutil.disk_usage(path)
return f"{path} : {used // 2**30} Gio utilisés sur {total // 2**30} Gio"
def run(question: str) -> str:
runner = client.beta.messages.tool_runner(
model="claude-opus-5",
max_tokens=8000,
tools=[disk_usage],
messages=[{"role": "user", "content": question}],
)
final = None
for message in runner:
final = message
log.info("tour terminé, jetons produits : %s", message.usage.output_tokens)
return next(b.text for b in final.content if b.type == "text")
La description de l'outil compte autant que son code : c'est sur elle que le modèle décide de l'appeler ou non. Décrivez quand l'utiliser, pas seulement ce qu'il fait.
2. Sortir les secrets du dépôt
Une clé d'API dans le code finit dans l'historique Git, dans les journaux et dans les sauvegardes. systemd sait charger un fichier d'environnement dont les droits interdisent la lecture aux autres comptes.
sudo useradd --system --home /srv/agent --shell /usr/sbin/nologin agent
sudo install -d -o agent -g agent -m 750 /srv/agent /var/lib/agent
sudo install -o root -g agent -m 640 /dev/null /etc/agent.env
sudo tee /etc/agent.env <<'EOF'
ANTHROPIC_API_KEY=sk-ant-...
EOF
Le mode 640 avec le groupe agent laisse le service lire
le fichier tout en le fermant aux autres comptes de la machine. Vérifiez que le
fichier n'est pas versionné : git check-ignore -v /etc/agent.env n'a
pas de sens ici puisqu'il est hors du dépôt, et c'est exactement l'objectif.
3. L'unité systemd, durcie
Le fichier d'unité fait deux choses : relancer le service quand il tombe, et réduire ce qu'il peut atteindre s'il est compromis.
sudo tee /etc/systemd/system/agent.service <<'EOF'
[Unit]
Description=Agent IA
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=agent
Group=agent
WorkingDirectory=/srv/agent
EnvironmentFile=/etc/agent.env
ExecStart=/srv/agent/.venv/bin/python -u agent.py
Restart=on-failure
RestartSec=10s
StartLimitBurst=5
StartLimitIntervalSec=300
# durcissement : le service ne voit presque rien du système
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
ReadWritePaths=/var/lib/agent
SyslogIdentifier=agent
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now agent
Le couple StartLimitBurst / StartLimitIntervalSec
est le garde-fou qui manque le plus souvent. Sans lui, un agent qui
plante au démarrage, clé invalide, dépendance absente, est relancé en boucle
indéfiniment, remplit les journaux et, s'il atteint l'API avant de tomber,
consomme du budget à chaque tentative. Avec lui, systemd abandonne après cinq
échecs en cinq minutes et laisse le service en état failed, visible.
ProtectSystem=strict monte tout le système en lecture seule ;
ReadWritePaths rouvre la seule arborescence dont l'agent a besoin.
Si le service ne démarre plus après ce durcissement, c'est presque toujours un
chemin d'écriture manquant, journalctl -u agent le nomme.
4. Des journaux qu'on peut exploiter
-u dans ExecStart désactive la mise en tampon de la
sortie Python : sans lui, les messages n'apparaissent dans journald que par blocs,
parfois avec des minutes de retard. Côté application, écrivez sur la sortie
standard et laissez systemd s'occuper du reste.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s %(message)s", # pas d'horodatage : journald le pose
)
journalctl -u agent -f # suivre en direct
journalctl -u agent --since "1 hour ago" # fenêtre récente
journalctl -u agent -p err # erreurs uniquement
Journalisez systématiquement trois choses : le nom de chaque outil appelé avec ses arguments, les jetons consommés par tour, et la raison d'arrêt de chaque réponse. Les deux premières expliquent la facture, la troisième explique les comportements bizarres.
5. Reprises sur erreur
Le SDK réessaie déjà les erreurs 429 et 5xx avec un recul exponentiel, deux tentatives par défaut. Ce qu'il ne fait pas, c'est distinguer ce qui mérite une nouvelle tentative de ce qui n'en mérite aucune.
import time
import anthropic
client = anthropic.Anthropic(max_retries=5) # au lieu de 2
def ask(question: str) -> str | None:
try:
return run(question)
except anthropic.RateLimitError as exc:
delai = int(exc.response.headers.get("retry-after", "60"))
log.warning("limite atteinte, pause de %ss", delai)
time.sleep(delai)
return None
except anthropic.APIConnectionError:
log.warning("réseau indisponible, on retentera au prochain cycle")
return None
except anthropic.BadRequestError:
log.exception("requête invalide, réessayer ne changera rien")
raise
La dernière branche est la plus importante : une requête invalide est un bug de
votre côté. La réessayer en boucle masque le problème et brûle du budget. Laissez
le service tomber, laissez StartLimitBurst l'arrêter, et corrigez.
6. Plafonner la dépense
Un agent en boucle peut consommer beaucoup plus que prévu, en particulier s'il boucle sur un outil qui échoue. Trois garde-fous, du plus simple au plus fin.
Compter, d'abord. Accumulez les jetons de chaque tour et arrêtez au-delà d'un seuil quotidien :
PLAFOND_JOUR = 2_000_000 # jetons produits
depense = 0
for message in runner:
depense += message.usage.output_tokens
if depense > PLAFOND_JOUR:
log.error("plafond quotidien atteint, arrêt")
break
Estimer avant d'envoyer. Sur une entrée de taille variable, un document, un journal, une page web, comptez les jetons avant l'appel plutôt que de découvrir la note après :
compte = client.messages.count_tokens(
model="claude-opus-5",
messages=[{"role": "user", "content": contenu}],
)
if compte.input_tokens > 200_000:
log.warning("entrée trop volumineuse (%s jetons), on découpe", compte.input_tokens)
Régler l'effort. Le paramètre effort gouverne la
profondeur de raisonnement et donc le coût. Une tâche mécanique n'a pas besoin du
réglage maximal :
client.messages.create(
model="claude-opus-5",
max_tokens=4000,
output_config={"effort": "low"}, # low | medium | high | xhigh | max
messages=[{"role": "user", "content": "Classe ce ticket : bug, question ou demande."}],
)
Mise en cache du prompt. Si votre agent renvoie la même
consigne système à chaque tour, mettez-la en cache : les lectures ultérieures
coûtent une fraction du prix plein. Un
cache_control={"type": "ephemeral"} au niveau de la requête suffit,
à condition que le préfixe soit strictement identique d'un appel à l'autre, un horodatage glissé dans la consigne système invalide le cache à chaque fois.
7. Vérifier que le service tient
systemctl status agent --no-pager
systemctl show agent -p NRestarts # combien de redémarrages depuis le boot
journalctl -u agent -p err --since today
sudo -u agent env | grep -c ANTHROPIC # la clé est-elle bien absente du shell ?
Le compteur NRestarts est le meilleur indicateur de santé : s'il
grimpe, quelque chose échoue silencieusement et se fait relancer. Un service
stable affiche zéro.
Checklist
- Compte système dédié, sans shell de connexion.
- Clés dans un fichier hors dépôt, en
640root:agent. Restart=on-failureavec unStartLimitBurstqui borne la boucle.- Durcissement systemd, avec
ReadWritePathsréduit au strict nécessaire. python -upour des journaux en temps réel.- Erreurs distinguées : réessayables, différées, fatales.
- Plafond de jetons, comptage avant envoi et
effortajusté à la tâche. NRestartssurveillé.