Faire tourner un agent IA 24/7 sur un VPS

Un agent qui tourne en boucle sur votre poste n'est pas un service. Voici ce qu'il faut ajouter pour qu'il survive à un redémarrage, à une panne d'API et à votre facture.

Boucle d'agent reliant le modèle, ses outils et ses observations

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.

agent.py
# /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.

bash
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.

bash
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.

python
logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s %(name)s %(message)s",   # pas d'horodatage : journald le pose
)
bash
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.

python
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 :

python
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 :

python
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 :

python
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

bash
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 640 root:agent.
  • Restart=on-failure avec un StartLimitBurst qui borne la boucle.
  • Durcissement systemd, avec ReadWritePaths réduit au strict nécessaire.
  • python -u pour des journaux en temps réel.
  • Erreurs distinguées : réessayables, différées, fatales.
  • Plafond de jetons, comptage avant envoi et effort ajusté à la tâche.
  • NRestarts surveillé.