Model stack Chapitre 17 / 42

Router par phase & mini-benchs maison

La zone moteurs s'achève : un quatrième roster au prix plancher et un banc d'essai maison qui départage vos quatre équipes sur vos propres flux — verdict, coût, durée.

Hier, vous avez posé deux équipes de plus dans adws/adw_config/, et une question est restée ouverte sur l’établi : sur la prochaine demande, laquelle envoyer ? Le roster de production, le tout-jugement, le tout-poids-publiés ? Aujourd’hui, vous fermez la question par les deux bouts. D’abord en complétant le portefeuille : un quatrième roster, éco, où rien ne dépasse l’étage workhorse. Ensuite en posant l’instrument qui départage : adw_bench, un banc d’essai qui lance le même ADW, avec la même demande, sur chacune de vos équipes, et relève verdict, coût et durée. À la fin du chapitre, l’usine atteint le jalon du module : quatre rosters interchangeables par une option de ligne de commande, et un banc pour les mettre à l’épreuve sur vos propres flux, pas sur ceux d’un classement public.

Le bon modèle pour la bonne phase

L’idée en une phrase

Le routage par phase assigne un étage du model stack à chaque phase selon la nature de son travail (jugement, exécution, voie mécanique) et un roster n’est rien d’autre qu’une politique de routage figée en YAML. La pièce du jour, eco.config.yaml, vit entièrement côté code déterministe, quatrième et dernière équipe du portefeuille.

Points clés

  • Trois natures de travail, trois étages naturels. Le jugement (plan, review) décide et mérite le meilleur moteur que la demande justifie. L’exécution (build) avale un long contexte et vit bien sur un workhorse à 1 M de tokens. Les voies mécaniques (scout, document) rapportent sans décider et tournent au prix plancher. On route une nature, jamais un script.
  • Un roster = une politique de routage. Le portefeuille en compte désormais quatre : production (mixte), frontier (tout jugement), open weights (tout poids publiés), éco (rien au-dessus du workhorse). Quatre réponses YAML à la même question : combien vaut cette demande ?
  • --config est le contrat commun de vos ADW. La même commande, une équipe au choix : adw_scout, adw_plan, adw_build et adw_sdlc acceptent tous le même levier. Changer d’équipe ne touche aucun script, c’est le jalon du module encaissé.
  • Le roster éco ne déroge à rien. Mêmes cinq agents, mêmes permissions, mêmes fichiers protégés, mêmes gates : seul le standing des moteurs descend d’un cran. La jauge du chapitre 14 y lit deux workhorses, un léger, et aucun frontier.

Exemple concret

Prenez une demande moyenne, « ajoute un raccourci clavier pour sauvegarder dans Plume », et faites-la tourner en imagination sur les quatre équipes. Le SDLC éco tient en ~15 à 25 centimes, la production du chapitre 13 en ~40 à 60 centimes, l’open weights en quelques dizaines de centimes, le frontier en quelques dollars. Un facteur 10 à 20 entre les extrêmes, pour le même graphe, les mêmes dix-huit phases, les mêmes gates. Ce que ce calcul ne dit pas, et ne peut pas dire, c’est si le plan éco vaut le plan frontier sur vos demandes. Deviner coûte cher dans les deux sens : c’est exactement le travail du second sous-thème.

Le routage par phase, appliqué au roster éco

PhaseNature du travailÉtage naturelMoteur du roster éco
planjugementle meilleur que la demande justifieGLM 5.3 (workhorse haut)
buildexécution longueworkhorse, gros contexteDeepSeek V4 Pro (1 M)
reviewjugementle même que le planGLM 5.3
scout, documentvoie mécaniqueléger, prix plancherDeepSeek V4 Flash

Config — les sièges de jugement du roster éco

Un extrait de la pièce du jour : le jugement reste au sommet, mais au sommet de ce qui vit sous le seuil frontier. Le fichier complet est dans les travaux pratiques.

# eco.config.yaml (extrait) — le jugement au meilleur prix sous le seuil frontier.
# Prix releves le 2026-09-01 : des reperes dates, pas des verites.
  - name: planner
    purpose: Transformer une demande en plan que le builder implemente sans questions.
    model: z-ai/glm-5.3              # ~1,20-1,75 $/M in — un cinquieme d'Opus environ
    thinking: high
    writes:
      - specs/

  - name: reviewer
    purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
    model: z-ai/glm-5.3              # le meme jugement que celui qui a signe le plan
    thinking: high
    writes: []

Piège courant : « un roster éco est un roster au rabais » est inexact. Le routage par phase ne change pas d’un roster à l’autre : le jugement garde le meilleur moteur disponible à son étage, et les gates, elles, ne descendent jamais. Le fini reste fini au même niveau d’exigence. Ce qui change, c’est le prix de la proposition, pas celui de la disposition.


Mesurer soi-même : des benchs sur ses propres ADW

L’idée en une phrase

Un bench maison rejoue le même ADW, avec la même demande, sur chaque roster du portefeuille, et relève trois chiffres par équipe : verdict des gates, coût, durée. La pièce du jour, adw_bench.py, vit entièrement côté code déterministe, et les agents ne savent jamais qu’ils sont sur le banc.

Points clés

  • L’unité de mesure est le run d’ADW complet. Harnais, enveloppes, reprises, gates : le banc mesure le système que vous exploitez, pas un modèle nu sur des exercices standardisés. C’est ce qui rend le relevé transposable à votre production : il est votre production.
  • Trois chiffres suffisent pour trancher. Le verdict des gates dit si l’équipe finit le travail, le coût et la durée disent à quel prix. Un run rouge est un résultat : il élimine un roster à zéro analyse supplémentaire.
  • Le banc n’invente aucune mécanique. Il lance chaque ADW en subprocess avec --config, chronomètre, et lit la ligne de bilan du runner (chapitre 8), la seule « API » dont il a besoin. Aucun harnais nouveau, aucune enveloppe nouvelle.
  • Un relevé est daté, jamais gravé. Les agents ont de la variance, les routes changent de prix : rejouez le banc avant une décision qui engage, et relancez-le quand la jauge du chapitre 14 vous signale que le marché a bougé. Le relevé s’écrit sous adws/adw_data/bench/, le runtime, jamais commité.

Exemple concret

Lancez un banc de plans : adw_plan sur les quatre équipes, même demande. Quatre runs, ~10 minutes en tout, ~1 à 2 $, dont l’essentiel sur le siège Opus des rosters production et frontier. Le relevé tombe : quatre verts, des coûts étagés d’un facteur ~20 entre éco et frontier, des durées comparables. Le banc a chiffré, il vous reste le travail qualitatif : ouvrir les quatre specs posées sous specs/ et juger sur pièces ce que le jugement frontier a acheté de plus. Souvent : une décomposition plus fine. Parfois : rien de mesurable. C’est précisément ce « parfois » que le banc rend visible, demande par demande.

Bench public, bench maison

QuestionBench publicBench maison
Qui passe l’épreuve ?le modèle nu, hors harnaisvos ADW complets, gates comprises
Sur quel sujet ?des exercices standardisésvos demandes, votre repo
Quel verdict ?un score globalverts/rouges, $ et minutes par roster
Quelle durée de vie ?figé à la publicationrejouable à volonté, daté à chaque run

Commande — le banc sur vos quatre équipes

Une seule version suffit ici : le banc est du code déterministe pur, il ne parle qu’aux ADW, et le harnais reste derrière le port du chapitre 7, pi ou Claude Code selon ce que chaque roster déclare. Trois usages, du moins cher au plus parlant, une commande par ligne :

# le banc au prix plancher : la reconnaissance sur les quatre equipes (~quelques centimes)
uv run adws/adw_bench.py "Ou vivent les tests de Plume ?"

# le banc qui departage : un plan complet par roster (~1 a 2 $, ~10 min)
uv run adws/adw_bench.py "Ajoute un compteur de caracteres a Plume" --adw adws/adw_plan.py

# variante eco : le SDLC entier, mais seulement sur les equipes a petit prix
uv run adws/adw_bench.py "Ajoute un compteur de caracteres a Plume" --adw adws/adw_sdlc.py --configs adws/adw_config/eco.config.yaml adws/adw_config/open-weights.config.yaml

Piège courant : « le classement des benchs publics vaut décision de roster » est inexact. Un score public mesure un modèle nu sur des exercices que votre usine ne rencontrera jamais. Votre usine, elle, exécute des ADW harnachés, avec vos prompts, vos enveloppes et vos gates, sur votre repo : deux moteurs voisins au classement peuvent y être séparés par un facteur 3 en coût ou par un rouge net. Mesurez chez vous, datez le relevé, rejouez avant de trancher.


Fil rouge — la pièce posée aujourd’hui

Deux pièces, et la zone « moteurs » du plan est complète : eco.config.yaml rejoint les trois rosters posés aux chapitres 15 et 16, et adw_bench.py s’installe à côté de la jauge du chapitre 14 : elle vérifie l’existence et le prix à zéro token, lui mesure le travail réel en tokens. Le jalon du module est atteint : quatre équipes interchangeables par --config, un banc pour les départager. La couture ne bouge pas d’un millimètre : le banc est du code qui lance du code. Les agents voient un run ordinaire et proposent comme d’habitude, les gates disposent comme d’habitude, et le banc se contente de relever les verdicts. À l’usage : la gate du jour coûte quelques centimes, un banc de plans ~1 à 2 $. L’économie qu’il rend possible, envoyer chaque demande au roster le moins cher qui la finit en vert, se compte en facteur 10 sur la facture, sans toucher au taux de vert.


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, deux fichiers : la quatrième équipe du portefeuille, et le banc d’essai qui les départage toutes.

Pièce — adws/adw_config/eco.config.yaml

Le roster au prix plancher : rien au-dessus de l’étage workhorse. Même schéma que les trois rosters déjà posés (mêmes cinq agents, mêmes permissions, mêmes fichiers protégés), seuls les moteurs descendent d’un cran. Il s’appuie sur le chargeur du chapitre 10 et la jauge du chapitre 14, qui le valident sans dépenser un centime.

# eco.config.yaml — l'usine au prix plancher : rien au-dessus du workhorse.
# Le routage par phase ne change pas — jugement, execution, voies mecaniques —
# seul le standing des moteurs descend d'un cran. Les gates, elles, ne
# descendent jamais : le fini reste fini, quel que soit le prix de l'equipe.
# Prix releves le 2026-09-01 sur le registre — des fourchettes datees.
defaults:
  harness: pi                    # pi | claude — les deux adaptateurs du port (ch. 7)
  model: deepseek/deepseek-v4-flash-0731   # leger : ~0,03-0,44 $/M in selon la route
  thinking: medium
  tools: [read, bash, grep, find, ls]
  protected_files:
    - adws/adw_modules/
    - adws/adw_config/
    - adws/adw_*.py
    - PLAN.md
  data_dir: adws/adw_data        # le runtime : TOUJOURS ouvert, jamais commite

agents:
  - name: scout
    purpose: Reperer ou vivent les choses dans le repo ; ne rien changer.
    # herite du leger par defaut : la reconnaissance est une voie mecanique
    thinking: low
    writes: []
    tools: [read, bash, grep, find, ls, write]

  - name: planner
    purpose: Transformer une demande en plan que le builder implemente sans questions.
    model: z-ai/glm-5.3              # ~1,20-1,75 $/M in — le meilleur jugement
    thinking: high                   # disponible sous le seuil frontier
    writes:
      - specs/
    tools: [read, bash, grep, find, ls, write]

  - name: builder
    purpose: Implementer le plan, exactement ; declarer chaque fichier modifie.
    model: deepseek/deepseek-v4-pro-0813   # workhorse a 1 M de contexte :
    thinking: high                         # ~0,66-1,45 $/M in, heures creuses en prime
    tools: [read, bash, grep, find, ls, edit, write]

  - name: reviewer
    purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
    model: z-ai/glm-5.3              # le meme jugement que celui qui a signe le plan
    thinking: high
    writes: []

  - name: documenter
    purpose: Rediger apres coup ce qui a change et pourquoi ; n'ecrire que sous app_docs/.
    # modele et thinking herites des defauts : rediger d'apres un diff
    # est un travail de rapport, pas de decision.
    writes:
      - app_docs/
    tools: [read, bash, grep, find, ls, write]

Pièce — adws/adw_bench.py

Le banc d’essai. Bibliothèque standard uniquement, entièrement côté déterministe : il découvre les rosters de adws/adw_config/, lance le même ADW en subprocess sur chacun (par la même option --config que vous utiliseriez à la main), chronomètre, lit la ligne de bilan du runner (chapitre 8), et écrit son relevé daté sous adws/adw_data/bench/. Comme les autres scripts de l’usine, il porte sa gate dans son propre lancement.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""adw_bench — le banc d'essai de l'usine : un ADW, une demande, N rosters.

Usage :
    uv run adws/adw_bench.py "Ou vivent les tests de Plume ?"
    uv run adws/adw_bench.py "Votre demande" --adw adws/adw_plan.py
    uv run adws/adw_bench.py "Votre demande" --adw adws/adw_sdlc.py --configs adws/adw_config/eco.config.yaml

Le banc vit entierement cote code deterministe : il lance le MEME ADW avec
la MEME demande sur chaque roster, laisse les gates trancher, et releve
trois chiffres par equipe — verdict, cout, duree. Les agents ne savent
jamais qu'ils sont sur le banc : ils voient un run ordinaire.
"""
import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path

CONFIG_DIR = Path("adws/adw_config")
BENCH_DIR = Path("adws/adw_data/bench")

# La ligne de bilan du runner (ch. 8) : la seule "API" dont le banc a besoin.
# Un run rouge ne l'imprime pas — et c'est deja un resultat.
COST_LINE = re.compile(r"cout total ~([0-9.]+) \$")


def label(config: Path) -> str:
    """eco.config.yaml -> eco : le nom court du roster dans le releve."""
    return config.name.removesuffix(".config.yaml")


def _text(value) -> str:
    """Sortie de subprocess, y compris apres timeout : toujours du texte."""
    if value is None:
        return ""
    if isinstance(value, (bytes, bytearray)):
        return value.decode("utf-8", "replace")
    return value


def bench_one(adw: str, prompt: str, config: Path, timeout: int) -> tuple[dict, str]:
    """Un run complet sur un roster : subprocess, chrono, verdict, cout."""
    command = ["uv", "run", adw, prompt, "--config", str(config)]
    clock = time.monotonic()
    try:
        proc = subprocess.run(command, capture_output=True, text=True,
                              encoding="utf-8", timeout=timeout)
        returncode, stdout, stderr = proc.returncode, proc.stdout, proc.stderr
        verdict = "vert" if returncode == 0 else "rouge"
    except subprocess.TimeoutExpired as timed_out:
        returncode, verdict = -1, "timeout"
        stdout, stderr = _text(timed_out.stdout), _text(timed_out.stderr)
    duration = time.monotonic() - clock

    # Le cout, releve dans le bilan du runner. Un run rouge n'a pas de bilan :
    # cost reste None, et le tableau l'affichera comme tel — pas de faux zero.
    found = COST_LINE.search(stderr)
    cost = float(found.group(1)) if found else None
    result = {"roster": label(config), "verdict": verdict, "cost_usd": cost,
              "seconds": round(duration, 1), "returncode": returncode}
    log = (f"$ {' '.join(command)}\n\n--- stdout ---\n{stdout}"
           f"\n--- stderr ---\n{stderr}")
    return result, log


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Le meme ADW, la meme demande, chaque roster du portefeuille.")
    parser.add_argument("prompt", help="la demande, identique pour toutes les equipes")
    parser.add_argument("--adw", default="adws/adw_scout.py",
                        help="l'ADW a mettre sur le banc (defaut : le moins cher)")
    parser.add_argument("--configs", nargs="*", type=Path,
                        help="les rosters a comparer (defaut : tous ceux de adw_config/)")
    parser.add_argument("--timeout", type=int, default=1800,
                        help="secondes par roster avant abandon du run")
    args = parser.parse_args()

    configs = args.configs or sorted(CONFIG_DIR.glob("*.config.yaml"))
    if not configs:
        print(f"aucun roster sous {CONFIG_DIR} — rien a comparer", file=sys.stderr)
        return 1

    bench_id = time.strftime("%Y%m%d-%H%M%S")
    outdir = BENCH_DIR / bench_id
    outdir.mkdir(parents=True, exist_ok=True)

    results = []
    for config in configs:
        print(f"[bench {bench_id}] {label(config)} : {args.adw} en cours…",
              file=sys.stderr)
        result, log = bench_one(args.adw, args.prompt, config, args.timeout)
        (outdir / f"{result['roster']}.log").write_text(log, encoding="utf-8")
        results.append(result)
        print(f"[bench {bench_id}] {result['roster']} : {result['verdict']} "
              f"en {result['seconds']} s", file=sys.stderr)

    releve = {"bench_id": bench_id, "adw": args.adw, "prompt": args.prompt,
              "date": time.strftime("%Y-%m-%d %H:%M:%S"), "results": results}
    (outdir / "results.json").write_text(
        json.dumps(releve, indent=2, ensure_ascii=False), encoding="utf-8")

    # Le tableau final : trois chiffres par equipe, dates du jour du releve.
    print(f"\n{'roster':<16} {'verdict':<9} {'cout $':>9} {'duree s':>9}")
    for result in results:
        cost = f"{result['cost_usd']:.4f}" if result["cost_usd"] is not None else "—"
        print(f"{result['roster']:<16} {result['verdict']:<9} "
              f"{cost:>9} {result['seconds']:>9}")
    greens = sum(1 for r in results if r["verdict"] == "vert")
    print(f"banc d'essai : {greens}/{len(results)} verts — "
          f"releve : {outdir / 'results.json'}")
    # Un banc entierement rouge n'a rien mesure de comparable : exit 1.
    return 0 if greens else 1


if __name__ == "__main__":
    sys.exit(main())

La gate du TP

Deux commandes à la racine de plume-factory, une par ligne : la première à zéro token, la seconde pour quelques centimes :

uv run adws/model_stack.py --config adws/adw_config/eco.config.yaml
uv run adws/adw_bench.py "Ou vivent les tests de Plume ?"

Attendu : jauge : OK sur le roster éco (deux workhorses, un léger, aucun frontier), puis le banc qui déroule quatre runs de scout et termine sur banc d'essai : 4/4 verts, avec le tableau des trois chiffres par équipe et le chemin du relevé. Ordre de grandeur : quelques centimes et 3 à 6 minutes au total. Les rosters n’y diffèrent que sur le siège du scout, et c’est déjà visible dans la colonne coût.


Quiz — teste tes connaissances
Model stack 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.