SOTA, workhorse, léger & le triangle coût-vitesse-qualité
Trois étages de moteurs, un triangle d'arbitrage — et une jauge qui relève les prix du jour avant que l'usine ne dépense un centime.
Hier, votre usine a déroulé son premier SDLC vert de bout en bout, et la facture est tombée :
quarante à soixante centimes. Relisez-la ligne à ligne : chaque centime est allé à un modèle que
le roster a désigné, et ces modèles, vous les avez pris sur parole aux chapitres 10 et 13.
Aujourd’hui, vous apprenez à les choisir vous-même. À la fin du chapitre, vous saurez lire le
catalogue des moteurs comme un parc de machines : trois étages (SOTA, que le livre appelle
frontier, workhorse, léger) et un triangle d’arbitrage (coût, vitesse, qualité) qui se résout
phase par phase, jamais en bloc. Le module 4 s’ouvre sur sa première pièce : la jauge des
moteurs, adws/model_stack.py, qui confronte votre roster aux prix du jour avant que l’usine
n’engage un dollar.
Les trois étages du model stack
L’idée en une phrase
Le model stack de l’usine range les moteurs en trois étages : frontier (le SOTA, on lui achète du jugement), workhorse (on lui achète de l’exécution), léger (on lui achète du volume). La pièce du jour, la jauge des moteurs, vit entièrement côté code déterministe : connaître ses moteurs coûte zéro token.
Points clés
- Un étage est un rôle, pas une marque. Frontier, workhorse et léger désignent ce qu’on achète (jugement, exécution, volume) et n’importe quel fournisseur peut occuper n’importe quel étage. Les noms de modèles tournent en quelques semaines. Les étages, eux, survivent à toutes les rotations.
- Chaque marche vaut un à deux ordres de grandeur. Au relevé du jour, le léger de votre roster s’affiche à 0,04 $ le million de tokens en entrée, le workhorse à 1,40 $, le frontier autour de 5 $ : un facteur ~125 sépare les extrêmes, davantage encore sur la sortie.
- Les étages s’assoient sur le roster que vous avez déjà. Depuis le chapitre 10, les ADW
nomment des agents, jamais des modèles : affecter un étage à une phase, c’est changer une ligne
model:dansfactory.config.yaml, aucun script ne bouge. - Un modèle change d’étage sans vous prévenir. Les prix montent, baissent, passent en promotion. Un identifiant disparaît au profit d’une révision datée. C’est un fait de marché, pas un accident, et la raison d’être d’une jauge qui relève les prix du jour.
Exemple concret
Relevez la jauge sur le roster du chapitre 13, tel quel, le 25 août 2026. Le scout et le
documenter roulent sur deepseek/deepseek-v4-flash-0731 : 0,04 $/M en entrée, 0,08 en
sortie. Le roster, écrit il y a deux jours, notait ~0,07/0,18 : le prix a fondu de moitié sans
que vous ne touchiez à rien. Le reviewer roule sur google/gemini-3.7-flash : 0,375/1,875,
en promotion de 75 % au moment du relevé. Le builder sur z-ai/glm-5.3 : 1,40/4,40, un
facteur ~35 au-dessus du léger. Le planner sur le frontier du roster : ~5/25 au relevé du
chapitre 13. Trois étages, et environ un ordre de grandeur entre chacun : voilà toute la
géométrie du module.
Les trois étages, relevés le 25 août 2026
| Étage | Ce qu’on lui achète | Exemples du jour (datés, à revérifier) | Ordre de prix en entrée |
|---|---|---|---|
| frontier (SOTA) | du jugement : le plan, la review exigeante | le frontier de votre roster (ch. 13, ~5 $/M) | quelques $/M et au-delà |
| workhorse | de l’exécution : le build | z-ai/glm-5.3 (1,40 $) · deepseek/deepseek-v4-pro-0813 (0,66 $) · google/gemini-3.7-flash (0,375 $, promo) | quelques dizaines de centimes à ~3 $/M |
| léger | du volume : scout, documenter | deepseek/deepseek-v4-flash-0731 (0,04 $) · openai/gpt-5.6-luna (0,20 $) | quelques centimes/M |
Script — la jauge en trois seuils
Le cœur de la pièce du jour (fichier complet dans les travaux pratiques) : deux seuils sur le prix d’entrée suffisent à situer l’étage. Des repères de lecture assumés simplistes, pas des vérités : quand le marché bouge, vous les bougez.
# Les seuils de la jauge, en $ par million de tokens d'ENTREE.
# Des reperes de lecture, pas des verites : quand le marche bouge, ils bougent.
FRONTIER_FLOOR = 3.00 # au-dessus : frontier — on paie le jugement
WORKHORSE_FLOOR = 0.30 # entre les deux : workhorse — le milieu qui execute
# en dessous : leger — le volume a prix plancher
def etage(prix_entree: float) -> str:
"""Classer un modele par son prix d'entree — la lecture la plus honnete du marche."""
if prix_entree >= FRONTIER_FLOOR:
return "frontier"
if prix_entree >= WORKHORSE_FLOOR:
return "workhorse"
return "leger"
Piège courant : « le meilleur modèle partout, c’est plus sûr » est inexact. La qualité d’un modèle ne se convertit en résultat que dans les phases de jugement (le plan, la review). Dans les phases mécaniques, elle se convertit surtout en facture. Un builder frontier suit la même spec que le workhorse, sur le même code, derrière les mêmes gates : il rend un travail comparable, dix à trente fois plus cher.
Arbitrer coût, vitesse et qualité
L’idée en une phrase
Le triangle coût-vitesse-qualité ne se résout jamais en bloc mais phase par phase. Le
roster est l’endroit où cet arbitrage s’écrit, une ligne model: par agent, et la loi de l’usine
tient en une règle : les modèles chers pour décider, les modèles bon marché pour exécuter.
L’arbitrage lui-même vit côté code déterministe, dans un fichier YAML que vous possédez.
Points clés
- La qualité s’achète là où elle décide. Le plan porte tout le run : un plan frontier à ~15 à 25 centimes évite des reprises qui coûteraient davantage, et votre attention avec. La review juge la conformité : c’est l’autre endroit où se tromper coûte plus cher que payer.
- Le coût se compte en entrée ET en sortie, et la sortie est la plus chère. Les prix de
sortie valent couramment trois à cinq fois l’entrée, et les tokens de réflexion (
thinking) sont facturés comme de la sortie : unthinking: highsur un frontier se paie deux fois. - La vitesse ne vaut pas partout le même prix. Un scout qui rend son relevé en une minute plutôt que trois change votre attente à l’écran. Le même écart sur un SDLC lancé pour la nuit ne vaut rien. Payez la vitesse dans les boucles où vous attendez, jamais dans celles qui tournent seules.
- Le prix affiché n’est pas un score de qualité. Les promotions déplacent un modèle d’un étage sans changer son comportement, et un modèle open-weights réhébergé casse les prix du niveau au-dessus. Le prix dit ce que le marché facture aujourd’hui, rien de plus. La jauge existe pour ça.
- Les gates gagnent le triangle par forfait. Bon, rapide ET pas cher à la fois :
bun testrend son verdict en secondes, pour zéro token. C’est toute la loi du livre : ne déléguez à l’agence que ce que le déterminisme ne sait pas faire.
Exemple concret
Reprenez le SDLC d’hier : dix-huit phases, quatre agents, ~40 à 60 centimes sur le roster étagé. Passez tout en frontier « pour être tranquille » et les phases mécaniques rendent à peu près le même travail, mais leur facture est multipliée par dix à cent selon la ligne. Le même run ressort à quelques dollars, sans un test de plus au vert. Passez tout en léger et le run tombe à quelques centimes, mais le plan perd en précision, les gates recalent le build, les reprises s’enchaînent, et c’est vous qui relisez les échecs. L’économie s’évapore en re-runs et en attention. Le roster étagé n’est pas un compromis mou, c’est l’optimum du triangle, poste par poste.
Le triangle, phase par phase
| Phase | Le coin qui prime | Le coin qu’on lâche | Étage |
|---|---|---|---|
| plan | qualité | coût | frontier |
| build | coût, à qualité suffisante | le prestige du moteur | workhorse |
| test (gates) | les trois à la fois | rien — c’est du code | aucun : zéro token |
| scout | coût et vitesse | l’élégance du rapport | léger |
| review | qualité | vitesse | frontier ou haut workhorse |
| document | coût | le style | léger |
Commande — relever la jauge avant de payer
Cette pièce ne touche pas le harnais : elle vit entièrement côté code, en amont de tout run. Une seule version suffit donc, quel que soit l’adaptateur derrière le port du chapitre 7.
# La jauge des moteurs : le roster face aux prix du jour — zero token, ~2 s
uv run adws/model_stack.py
Piège courant : « je note les prix dans le roster, ça suffit » est inexact. Un commentaire YAML date du jour où il a été écrit, et les prix relevés au chapitre 13 avaient déjà bougé deux jours plus tard. Pire : un identifiant peut disparaître du registre au profit d’une révision datée, et cette faute-là ne coûte pas un mauvais prix, elle coûte un run mort au milieu, phases déjà payées. La jauge remplace la mémoire par un relevé : zéro token, deux secondes, avant chaque run qui engage des dollars.
Fil rouge — la pièce posée aujourd’hui
Le module 4 ouvre la zone « moteurs » du plan, et sa première pièce est une pièce de mesure :
adws/model_stack.py, la jauge des moteurs, posée à côté du préflight doctor.py du chapitre 4.
Elle s’appuie sur le roster du chapitre 10 (elle lit factory.config.yaml et hérite des défauts
comme le fait roster.py) et interroge le registre public de la passerelle de modèles, celle
dont votre .env.sample réserve la clé depuis le chapitre 1 et que vous brancherez pleinement au
chapitre 15. La couture ne bouge pas d’un millimètre : aucun agent ne participe, aucune enveloppe
ne traverse. La jauge est du déterminisme pur, et c’est le point : savoir ce que coûtent vos
moteurs, vérifier qu’ils existent encore, situer leur étage, tout cela coûte zéro token et
~2 secondes. Ce qu’elle économise : le run mort sur un identifiant périmé (trois phases payées
pour rien) et les décisions de roster prises sur des prix de mémoire, dans un marché où le léger
de votre usine a changé de prix en deux jours. Le plan du lecteur évolue aussi : PLAN.md passe en
version chapitre 14.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui, la jauge des moteurs, et la mise à jour
du plan qui l’inscrit sur l’arbre.
Pièce — adws/model_stack.py
La jauge vit entièrement côté code déterministe, comme doctor.py qu’elle complète : doctor
vérifie le poste, model_stack vérifie les moteurs. Elle lit le roster (chapitre 10), interroge
le registre public de la passerelle (aucune clé requise) et rend un verdict : chaque modèle du
roster existe, voici son prix du jour et son étage. Un modèle absent du registre fait échouer la
jauge : mieux vaut un exit 1 gratuit ici qu’un run mort après trois phases payées.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""model_stack — la jauge des moteurs : votre roster face aux prix du jour.
Usage :
uv run adws/model_stack.py [--config adws/adw_config/factory.config.yaml]
Zero token, ~2 s, reseau requis. La jauge repond a trois questions avant
que l'usine ne depense un centime :
1. chaque modele du roster existe-t-il encore dans le registre ?
2. combien coute-t-il AUJOURD'HUI — pas le jour ou le roster a ete ecrit ?
3. sur quel etage du model stack est-il assis ?
"""
import argparse
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
import yaml
REGISTRY_URL = "https://openrouter.ai/api/v1/models"
# Les seuils de la jauge, en $ par million de tokens d'ENTREE.
# Des reperes de lecture, pas des verites : quand le marche bouge, ils bougent.
FRONTIER_FLOOR = 3.00 # au-dessus : frontier — on paie le jugement
WORKHORSE_FLOOR = 0.30 # entre les deux : workhorse — le milieu qui execute
# en dessous : leger — le volume a prix plancher
def etage(prix_entree: float) -> str:
"""Classer un modele par son prix d'entree — la lecture la plus honnete du marche."""
if prix_entree >= FRONTIER_FLOOR:
return "frontier"
if prix_entree >= WORKHORSE_FLOOR:
return "workhorse"
return "leger"
def modeles_du_roster(config: Path) -> dict[str, list[str]]:
"""{id de modele : [agents qui roulent dessus]} — heritage des defauts compris."""
data = yaml.safe_load(config.read_text(encoding="utf-8"))
defaut = data["defaults"]["model"]
table: dict[str, list[str]] = {}
for agent in data["agents"]:
model = agent.get("model", defaut)
table.setdefault(model, []).append(agent["name"])
return table
def registre() -> dict[str, dict]:
"""Le registre public : id -> prix du jour ($/M) et fenetre de contexte.
L'API rend les prix en $ PAR TOKEN, sous forme de chaines : on convertit
en $ par million — l'unite dans laquelle tout le livre raisonne.
"""
with urllib.request.urlopen(REGISTRY_URL, timeout=30) as reponse:
modeles = json.load(reponse)["data"]
table = {}
for modele in modeles:
prix = modele.get("pricing") or {}
table[modele["id"]] = {
"entree": float(prix.get("prompt") or 0) * 1_000_000,
"sortie": float(prix.get("completion") or 0) * 1_000_000,
"contexte": int(modele.get("context_length") or 0),
}
return table
def jauge(config: Path) -> int:
"""Imprimer la jauge et rendre le verdict : 0 si le roster est sain."""
roster = modeles_du_roster(config)
try:
prix_du_jour = registre()
except (urllib.error.URLError, TimeoutError) as erreur:
print(f"registre inaccessible ({erreur}) — la jauge exige le reseau",
file=sys.stderr)
return 1
print(f"{'agents':<22} {'modele':<40} {'etage':<10} "
f"{'$/M in':>8} {'$/M out':>8} {'contexte':>10}")
absents = []
for model_id, agents in sorted(roster.items()):
noms = "+".join(sorted(agents))
fiche = prix_du_jour.get(model_id)
if fiche is None:
# Le nom a change ou n'a jamais existe : mieux vaut le savoir ICI,
# a zero token, qu'au milieu d'un run qui a deja paye trois phases.
print(f"{noms:<22} {model_id:<40} ABSENT DU REGISTRE")
absents.append(model_id)
continue
alerte = (" <- prix a zero : gratuit, promo ou piege — verifiez"
if fiche["entree"] == 0 else "")
print(f"{noms:<22} {model_id:<40} {etage(fiche['entree']):<10} "
f"{fiche['entree']:>8.2f} {fiche['sortie']:>8.2f} "
f"{fiche['contexte']:>10,}{alerte}")
if absents:
print(f"jauge : ECHEC — modele(s) introuvable(s) : {', '.join(absents)}",
file=sys.stderr)
return 1
couts = [prix_du_jour[m]["entree"] for m in roster
if prix_du_jour[m]["entree"] > 0]
if len(couts) > 1:
print(f"ecart d'etages : x{max(couts) / min(couts):,.0f} "
"entre le plus cher et le moins cher du roster")
print("jauge : OK — chaque agent connait le prix de son moteur")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="La jauge des moteurs de l'usine.")
parser.add_argument("--config", default="adws/adw_config/factory.config.yaml",
help="le roster a jauger (un autre roster : un autre fichier)")
sys.exit(jauge(Path(parser.parse_args().config)))
Pièce — PLAN.md
La jauge ne figurait pas sur le plan initial : conformément à la règle de continuité n° 1, la
déviation s’annonce et le plan se met à jour. Cette version remplace celle du chapitre 4.
Trois changements : la ligne model_stack.py dans l’arbre, la ligne fleet.py du chapitre 6 qui
manquait à l’appel, et les jalons des chapitres 7 et 13 cochés, votre usine les a atteints.
# plume-factory — le plan de l'usine
Ce dépôt contient une **usine logicielle agentique** et le payload qu'elle travaille.
Loi fondamentale : **l'agent propose, le code dispose.** Le code déterministe possède le
séquencement, les reprises et l'acceptation. Un agent n'est qu'un nœud borné à l'intérieur d'une
phase nommée. Le contexte ne traverse une frontière que dans une enveloppe JSON typée. Les gates
définissent « fini » ; un échec de gate revient à l'agent par la même porte qu'un rapport d'agent.
## Arbre cible
plume-factory/
├── PLAN.md # ce fichier — ch. 1
├── justfile # recettes racine — ch. 5
├── just/
│ ├── adws.just # lancer les workflows
│ ├── obs.just # observer les runs
│ └── sandbox/ # lifecycle, keys, orch — module 6
├── apps/plume/ # le payload : app d'écriture Bun + TS — ch. 2
├── specs/ # les specs : la vôtre (ch. 2), puis celles d'adw_plan (ch. 11+)
├── adws/
│ ├── hello_factory.py # l'embryon du runner — ch. 3
│ ├── doctor.py # le préflight du poste de pilotage — ch. 4
│ ├── fleet.py # le montage du poste herdr — ch. 6
│ ├── model_stack.py # la jauge des moteurs : le roster face aux prix du jour — ch. 14
│ ├── adw_prompt.py # une phase agent, une enveloppe — ch. 8
│ ├── adw_scout.py / adw_plan.py # reconnaissance et plan — ch. 11
│ ├── adw_build.py # implémentation + gates — ch. 12
│ ├── adw_sdlc.py # la chaîne complète — ch. 13
│ ├── adw_bench.py # mini-benchs par roster — ch. 17
│ ├── adw_modules/
│ │ ├── harness.py # LE port harnais + adaptateurs pi / claude_code — ch. 7
│ │ ├── runner.py # phases, séquencement, reprises — ch. 8
│ │ ├── envelopes.py # enveloppes JSON typées — ch. 9
│ │ ├── roster.py # chargement de factory.config.yaml — ch. 10
│ │ ├── permissions.py # tools, writes, fichiers protégés — ch. 10
│ │ ├── gates.py # lint / tests / typecheck — ch. 12-13
│ │ └── tracer.py # événements de phase vers SQLite — ch. 18-20
│ ├── adw_config/ # factory, frontier, open-weights, eco — ch. 10, 15-17
│ ├── prompts/ # system.md + user.md par agent
│ └── adw_data/ # runtime : sessions, enveloppes, factory.db — jamais commité
├── app_docs/ # sorties du documenter — ch. 13
├── .claude/skills/factory/ # l'usine empaquetée en skill — ch. 26
├── .env.sample # OPENROUTER_API_KEY ; provisioning côté hôte seulement
└── .gitignore # posé ch. 1
## Zones de l'usine
| Module | Zone | Pièces principales |
|---|---|---|
| 1 — Fondations | le plan et la baseline | PLAN.md, apps/plume, hello_factory.py |
| 2 — Poste de pilotage | l'outillage | uv, justfile, herdr, harness.py |
| 3 — Le squelette ADW | le coeur | runner, envelopes, roster, gates, adw_sdlc |
| 4 — Model stack | les moteurs | model_stack.py, 4 rosters, adw_bench |
| 5 — Observabilité | la salle de contrôle | tracer.py, obs.just, export OTel |
| 6 — Sandboxes & scale | le hors-site | just/sandbox, clés provisionnées, best-of-N |
| 7 — Distribution | l'emballage | skill factory, /install, capstone |
## Jalons
- [x] ch. 1 — dépôt initialisé, PLAN.md et .gitignore posés
- [x] ch. 2 — Plume générée en un shot, `bun test` vert : la baseline « sans usine »
- [x] ch. 3 — hello_factory.py : un subprocess, une sortie JSON validée
- [x] ch. 7 — plus aucun appel direct au harnais hors adw_modules/harness.py
- [x] ch. 13 — premier adw_sdlc.py vert de bout en bout sur Plume
- [ ] ch. 17 — quatre rosters interchangeables, adw_bench les compare
- [ ] ch. 20 — chaque run laisse une trace SQLite exploitable
- [ ] ch. 25 — un best-of-N de 3 à 5 rosters lancé, moissonné, comparé
- [ ] ch. 27 — l'usine s'installe dans un repo vierge via son skill
## Règles de continuité
1. Une pièce posée ne se casse pas. Toute évolution redonne le fichier entier et l'annonce.
2. `adws/adw_data/` et `.env` ne sont jamais commités.
3. Chaque pièce a sa gate : la commande qui prouve qu'elle fonctionne, coût et durée à l'appui.
4. Le payload Plume reste petit (5 à 8 fichiers) : les runs restent lisibles et bon marché.
5. Les ADW nomment des agents, jamais des modèles. Le roster décide du modèle.
La gate du TP
uv run adws/model_stack.py
Attendu : une ligne par modèle du roster (agents, étage, prix du jour en entrée et en sortie,
fenêtre de contexte), l’écart d’étages du roster (un facteur de l’ordre de la centaine), puis
jauge : OK — chaque agent connait le prix de son moteur et un code retour 0. Coût : zéro
token, ~2 secondes, seul le réseau est requis. Si un modèle sort en ABSENT DU REGISTRE,
c’est la jauge qui fait son travail : corrigez la ligne model: du roster (zéro token) et
relancez. Quand elle passe, commitez les deux fichiers.