# Un LLM comme conseiller stratégique

> Confiez à un LLM les questions « que faut-il économiser, où affecter les ouvriers, faut-il attaquer ou temporiser cette minute », et laissez la couche de règles se contenter d'exécuter et d'opposer son veto. Le cerveau de référence fonctionne déjà ainsi ; cette page en explique le modèle et les pièges.

Source: https://war3ai.com/fr/docs/llm-coach/

Passé un certain stade, vous remarquerez que les règles d'économie de votre Bot s'empilent les unes sur les autres : une règle pour le nombre de bûcherons, une pour 5 ouvriers par mine, une pour réduire le bois de moitié quand il y en a trop, une pour envoyer plus de monde à l'or quand l'or manque et que le bois abonde… Chacune est juste prise isolément, mais ensemble elles produisent des situations comme « la mine manque d'ouvriers alors que tous les paysans coupent du bois », dont **aucune règle n'est responsable**.

Ce genre de jugement, « regarder l'ensemble et fixer des priorités », ne se prête pas à des `if / else`, mais c'est exactement ce que les LLM font bien. Le cerveau de référence (`brains/xwar3/strategy/brain/coach.py`) utilise le découpage en couches ci-dessous.

## Couches

```text
LLM (conseiller)        Une fois toutes les 20 secondes de jeu, asynchrone, ne bloque jamais un tick
  Entrée : un instantané d'une page de la partie (ressources, nourriture, répartition des paysans, mines, unités, technologies, héros, renseignements sur l'ennemi, événements récents)
  Sortie : JSON strict — un diagnostic en une phrase + répartition des ouvriers + quoi produire en priorité + posture de cette minute + choses à éviter
        │
        ▼  liste blanche + bornage min/max + veto
Couche de règles (Bot, à chaque tick)   Traduit les conseils en « biais » sur les capacités existantes : répartition des ouvriers, priorités de construction / d'entraînement, posture d'attaque
        │
        ▼
Couche d'exécution (SDK / couche réflexe)   Donne les ordres, lit les reçus, fait la micro
```

## Contrat de sortie

Faites en sorte que le modèle ne produise qu'un JSON aux champs fixes, sans ajout ni omission :

```json
{
  "diagnosis": "Une phrase : le plus gros problème de la partie, qui doit s'appuyer sur les données d'entrée",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Ne recherchez pas Iron Plating en premier si le bois manque"]
}
```

| Champ | Usage par la couche de règles | Bornage du cerveau de référence |
|---|---|---|
| `workers` | Nombre cible d'ouvriers à l'or et au bois | Or 2 ~ 25, bois 1 ~ 20 ; la somme ne peut pas dépasser le nombre total de paysans |
| `priority` | Ordre de priorité pour l'entraînement / la construction / la recherche | 4 au maximum ; seuls les codes à quatre caractères présents dans la table des « codes autorisés » sont acceptés |
| `posture` | Posture de cette minute | Uniquement l'une des valeurs `attack` `defend` `creep` `expand` `recover` `hold` |
| `avoid` | Choses à ne pas faire cette minute | 2 au maximum |
| `diagnosis` | Sert uniquement aux journaux et à l'affichage dans la console | — |

Prévoyez un prompt par race, qui ne couvre que les arbitrages propres à cette race (la construction coopérative et la Milice des Humains, les Terriers des Orcs, la Mine d'or hantée des Morts-vivants, la Mine d'or enchevêtrée des Elfes de la nuit…). Placez les règles générales dans une partie commune, ne les recopiez pas quatre fois.

## Quatre contraintes strictes

Le cerveau de référence a appris chacune d'elles à ses dépens :

1. **Le conseiller ne donne jamais d'ordre direct aux unités.** Il ne voit pas ce qui se passe à l'échelle de 150 ms, et il hallucine. Il ne modifie que les objectifs et les priorités ; qui va où et qui attaque qui reste décidé par la couche de règles et la couche réflexe — le commandement ne peut avoir qu'un seul maître.
2. **Asynchrone.** Un appel au conseiller prend environ 1 seconde et tourne dans un thread d'arrière-plan ; le résultat le plus récent s'applique, et il **ne bloque jamais un tick**. Si le modèle n'est pas démarré, dépasse le délai ou répond n'importe quoi, faites comme si cette couche n'existait pas : le comportement revient aux règles pures. Un conseil trop ancien (plus de 3 intervalles) n'est pas utilisé non plus.
3. **Liste blanche + bornage.** Chaque champ doit correspondre à une capacité existante, et les valeurs numériques sont bornées dans une plage raisonnable. Tout contenu non reconnu est **compté puis rejeté**, pas ignoré en silence.
4. **Tout compter.** Combien de requêtes, combien de succès, combien de délais dépassés, combien de bornages, combien de fois chaque champ a été retenu : publiez tout cela, avec la dernière entrée envoyée au modèle. Sinon, « cette couche sert-elle vraiment à quelque chose ? » reste une question sans réponse.

> **Ce qui se dégrade sans danger est aussi ce qui se dégrade le plus discrètement**
>
> Le conseiller est conçu pour que « échec = faire comme si cette couche n'existait pas » ; quand le service du modèle ne tourne pas, le Bot se comporte donc exactement comme avec des règles pures, et rien n'y paraît de l'extérieur. Le cerveau de référence a ainsi passé une journée entière avec le conseiller injoignable sur les 6 instances, sans que personne ne s'en aperçoive. Publiez toujours « l'heure du dernier succès » et « la raison du dernier échec » — c'est précisément le rôle de la page « Conseiller stratégique » de la [console Farsight](https://war3ai.com/fr/docs/console/).

## Implémentation dans votre propre Bot

Voici un squelette minimal qui fonctionne avec n'importe quelle API compatible OpenAI (LM Studio, Ollama ou une API cloud) et n'utilise que la bibliothèque standard :

```python title="coached_bot.py"
import collections, json, threading, urllib.request
from openwar3 import Bot

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / tout service compatible OpenAI
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Tu es un coach de macro pour Warcraft III. Tu ne t'occupes que de l'économie et de la stratégie, pas de la micro.
Réponds uniquement en JSON, avec des champs fixes : {"diagnosis": une phrase, "workers": {"gold": entier, "lumber": entier},
"priority": [codes à quatre caractères, 4 au maximum, uniquement parmi allowed], "posture": une valeur parmi six, "avoid": [2 au maximum]}
Appuie-toi uniquement sur les données de partie fournies ; n'invente rien qui n'y figure pas."""

def ask(state: dict) -> dict:
    body = {"model": MODEL, "temperature": 0.3, "max_tokens": 260,
            "messages": [{"role": "system", "content": SYSTEM},
                         {"role": "user", "content": json.dumps(state, ensure_ascii=False)}]}
    req = urllib.request.Request(f"{BASE}/chat/completions", json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=8) as r:
        text = json.load(r)["choices"][0]["message"]["content"]
    return json.loads(text[text.index("{"): text.rindex("}") + 1])

class CoachedBot(Bot):
    EVERY = 20.0                              # secondes de jeu : la macro se décide à l'échelle de la minute, inutile de demander à chaque tick
    allowed = {"hpea", "hfoo", "hrif", "hkni", "hhou", "hbar", "hbla", "Rhme", "Rhar"}

    def on_start(self, g):
        self.plan, self.plan_at, self.asked_at, self.busy = {}, -1e9, -1e9, False
        self.stats = collections.Counter()

    def summary(self, g) -> dict:             # lire l'instantané dans le thread principal ; le thread d'arrière-plan ne touche jamais g
        res = g.resources() or {}
        return {"clock": round(g.clock() or 0), "gold": res.get("gold"), "lumber": res.get("lumber"),
                "food": [res.get("food_used"), res.get("food_cap")],
                "workers": len(g.my_workers()), "idle_workers": len(g.idle_workers()),
                "army": collections.Counter(u.type for u in g.my_army()),
                "enemy_seen": collections.Counter(u.type for u, _t, _age in g.last_seen(max_age=90)),
                "night": g.is_night(), "allowed": sorted(self.allowed)}

    def consult(self, state, now):
        try:
            p = ask(state)
            self.stats["ok"] += 1
            posture = p.get("posture")
            if posture not in POSTURES:
                self.stats["bad_posture"] += 1                       # compter puis rejeter, jamais en silence
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # bornage
                         "lumber": min(20, max(1, int(p["workers"]["lumber"]))),
                         "priority": [c for c in p.get("priority", []) if c in self.allowed][:4],
                         "posture": posture}
            self.plan_at = now
        except Exception as e:                                       # délai dépassé / réponse incohérente : faire comme si cette couche n'existait pas
            self.stats[f"error:{type(e).__name__}"] += 1
        finally:
            self.busy = False

    def on_tick(self, g):
        now = g.clock() or 0.0
        if not self.busy and now - self.asked_at >= self.EVERY:
            self.busy, self.asked_at = True, now
            threading.Thread(target=self.consult, args=(self.summary(g), now), daemon=True).start()
        plan = self.plan if now - self.plan_at <= 3 * self.EVERY else {}   # ne pas utiliser un conseil trop ancien
        # ↓ couche de règles : sans plan, suivre les règles par défaut ; avec un plan, n'ajuster que la répartition, les priorités et la posture — les ordres concrets restent décidés par les règles
        ...
```

## Choisir un modèle

| Situation | Recommandation |
|---|---|
| En local, besoin de vitesse | Les modèles MoE (qui n'activent qu'une petite partie de leurs paramètres à chaque appel) sont bien plus rapides que les modèles denses de même taille. Le cerveau de référence utilise Qwen3.6-35B-A3B (LM Studio, Q4) : médiane **1.09 s**, pire cas 1.45 s, et 5/5 sorties passent directement dans `json.loads` |
| En local, modèle qui « réfléchit » | **Désactivez impérativement la section de réflexion**, sinon tous les tokens partent dans la réflexion et aucun JSON ne sort. LM Studio ignore `/no_think` ; le cerveau de référence est passé à `/v1/completions`, construit lui-même le ChatML et pré-remplit un `<think></think>` vide suivi d'un `{` |
| Modèle cloud | La latence est généralement plus élevée, mais ce découpage est asynchrone par conception ; les décisions de macro se comptent en minutes, quelques secondes de latence sont acceptables |

> **Remarque**
>
> Le même modèle peut aussi donner la parole à vos unités : voir [Bulles de dialogue et modèles locaux](https://war3ai.com/fr/docs/speech/). Si vous voulez que le modèle donne directement les ordres à chaque tick (au lieu de jouer les conseillers), attendez la passerelle JSON de l'[Arène](https://war3ai.com/fr/arena/).
