Coûts, rétention et passage en production
Le contrôleur de gestion d'Arvea pose une question simple : combien coûte l'assistant par conseiller et par mois, et combien coûtera-t-il en janvier ? Le premier tableau de coûts produit pendant ce projet affichait 0,00 USD pour l'agent : aucune définition de prix ne correspondait aux modèles utilisés. Un chiffre faux est pire qu'une absence de chiffre, parce qu'on le croit. Cette leçon rend les coûts justes, les garde sous contrôle, et prépare la sortie du lab.
Le mécanisme : le coût est calculé une fois, à l'ingestion
Langfuse a deux façons d'obtenir un coût : vous l'envoyez (cost_details), ou il le déduit à partir de l'usage et d'une définition de modèle (expression régulière de correspondance + prix par clé d'usage). Le calcul a lieu dans le worker, au moment de l'ingestion, et n'est jamais refait : une définition ajoutée ou corrigée ne s'applique qu'aux nouvelles observations.
flowchart LR
G["Génération<br/>model = gemini-3.5-flash-lite<br/>usage : input 196, output 47"] --> W["Worker"]
D[("Définitions de modèles<br/>motif : gemini-3.5-flash-lite, avec ou sans models/<br/>input 0,30 / output 2,50 USD par M")] --> W
W -->|"196 × 0,30e-6 + 47 × 2,50e-6"| C[("ClickHouse<br/>totalCost 0,0001763")]
C --> M["API Metrics v2<br/>coût par modèle, par conseiller"]
La correspondance porte sur le nom de modèle enregistré et le prix sur chaque clé d'usage : une clé sans prix compte pour zéro. D'où deux pièges vus dans ce projet : un modèle servi différent du modèle demandé (leçon 2), et les tokens de réflexion, que Gemini facture au prix de la sortie mais que l'instrumentation range sous une clé distincte, output_reasoning_tokens.
La mise en œuvre
1. Déclarer les prix, toutes les clés
Les tarifs viennent de la page officielle de l'API Gemini (palier payant standard, relevés le 2026-10-15) :
PRIX: dict[str, dict[str, float]] = {
"gemini-3.5-flash-lite": {"input": 0.30e-6, "input_cached_tokens": 0.03e-6,
"output": 2.50e-6, "output_reasoning_tokens": 2.50e-6},
"gemini-3.8-flash": {"input": 0.75e-6, "input_cached_tokens": 0.075e-6,
"output": 3.75e-6, "output_reasoning_tokens": 3.75e-6},
}
def declarer_prix(langfuse) -> int:
existants = {m.model_name for m in langfuse.api.models.list(limit=100).data if not m.is_langfuse_managed}
for nom, prix in PRIX.items():
if nom in existants:
continue
langfuse.api.models.create(
model_name=nom, match_pattern=motif(nom), unit="TOKENS",
pricing_tiers=[PricingTierInput(name="Standard", is_default=True, priority=0,
conditions=[], prices=prix)])Un test unitaire garantit que la réflexion est facturée au prix de la sortie pour chaque modèle. Les définitions vivent dans le code, versionnées, et make seed les applique : une instance reconstruite retrouve les mêmes prix.
2. Lire les coûts là où ils sont agrégés
L'API Metrics v2 agrège côté Langfuse ; inutile de rapatrier les observations pour les additionner :
base = {"view": "observations", "metrics": [{"measure": "totalCost", "aggregation": "sum"},
{"measure": "count", "aggregation": "count"}],
"fromTimestamp": debut.isoformat(), "toTimestamp": fin.isoformat(),
"orderBy": [{"field": "sum_totalCost", "direction": "desc"}], "config": {"row_limit": 10}}
for dimension in ("providedModelName", "userId"):
rep = lf.api.metrics.metrics(query=json.dumps({**base, "dimensions": [{"field": dimension}]}))userId a une forte cardinalité : Langfuse n'accepte de grouper dessus qu'en requête « top N » (limite explicite, tri décroissant sur une mesure).
3. Limiter ce qu'on garde
LANGFUSE_INIT_PROJECT_RETENTION=30 (leçon 1) supprime automatiquement les observations de plus de 30 jours. Pour un assistant qui manipule des données d'adhérents, c'est la durée de conservation qu'il faut pouvoir justifier au DPO. Les prompts, jeux de données et scores d'expérience, eux, ne contiennent pas de données de production et restent.
4. Arrêter proprement
make down arrête la pile et supprime ses volumes, puis vérifie qu'il ne reste aucun volume portant l'étiquette du projet Compose :
down:
@$(COMPOSE) down -v --remove-orphans
@left=$$(docker volume ls -q --filter label=com.docker.compose.project=lab-$(SLUG)); \
if [ -n "$$left" ]; then echo "Volumes restants : $$left"; exit 1; else echo "Pile et volumes lab-$(SLUG) supprimés."; fiRésultat obtenu
make couts après make e2e, le 2026-10-15 :
Coût par providedModelName (7 j) :
gemini-3.8-flash 0.025795 USD 16 observations
gemini-3.5-flash-lite 0.003674 USD 23 observations
None 0.000000 USD 105 observations
Coût par userId (7 j) :
None 0.025795 USD 96 observations
evaluation 0.002221 USD 32 observations
cons-0977 0.001126 USD 12 observations
cons-0412 0.000327 USD 4 observationsDeux lectures : les 105 observations sans modèle sont des spans, agents et outils, sans coût par nature. Et 0,0258 USD n'est rattaché à aucun utilisateur : ce sont les appels du juge, exécutés par le SDK hors du bloc propagate_attributes de la tâche. L'exercice ci-dessous corrige cela.
Capture réelle : le tableau d'accueil du projet après make e2e. Le tableau de bord « Langfuse Cost Dashboard » fourni par défaut affichait « Widget not found » sur cette instance 4.59.0 : l'accueil et l'API Metrics donnent les mêmes chiffres.
Le même make e2e tourne sur Vertex AI sans modifier le code : le client google-genai et ADK lisent GOOGLE_GENAI_USE_VERTEXAI. Rejoué ainsi le 2026-10-16 (location global, mêmes modèles) :
$ env -u GEMINI_API_KEY GOOGLE_GENAI_USE_VERTEXAI=TRUE GOOGLE_CLOUD_PROJECT=<projet> GOOGLE_CLOUD_LOCATION=global make e2e
production : {'moyenne_cite_reference': 0.125, 'moyenne_exactitude_juge': 0.8125}
staging : {'moyenne_cite_reference': 0.875, 'moyenne_exactitude_juge': 0.78125}
staging promu en production (version 2)
OK : appli 2 générations (prompt conseiller-reponse v1), agent 5 générations, coût 0.001245 USD, 103 sLa dépense passe alors sur la facture Google Cloud du projet. Les prix déclarés dans Langfuse sont ceux de l'API Gemini : comparez-les à la grille Vertex AI avant de vous fier au coût affiché.
Puis l'arrêt, vérifié :
$ make down
Volume lab-langfuse_langfuse_postgres_data Removed
Network lab-langfuse_default Removed
Pile et volumes lab-langfuse supprimés.
$ docker ps -a --filter name=lab-langfuse -q | wc -l ; docker volume ls --filter name=lab-langfuse -q | wc -l
0
0Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Coût à 0 pour un modèle | pas de définition, ou motif qui ne correspond pas au nom enregistré (models/…, alias, modèle servi) | bouton Test Model Match dans les réglages ; motif tolérant au préfixe ; alerte « coût nul et usage non nul » |
| Coût sous-estimé d'un tiers sur un modèle qui réfléchit | output_reasoning_tokens sans prix | prix explicite pour chaque clé d'usage émise |
| Coût faux sur tout l'historique après correction d'un prix | calcul à l'ingestion, sans recalcul | corriger vite ; pour l'historique, recalculer hors Langfuse à partir de l'usage exporté |
| Part importante du coût sans utilisateur | appels hors contexte (juge, traitements par lot) | propagate_attributes aussi autour des traitements internes, avec un user_id technique |
| Disque plein sur la VM | ClickHouse et MinIO sans rétention | rétention de projet, surveillance des volumes |
| Langfuse indisponible, l'application ralentit | export synchrone ou file pleine | l'export du SDK est asynchrone et borné : le vérifier sous charge, ne jamais attendre Langfuse dans le chemin de réponse |
Passer du lab à la production sur Google Cloud
Le fichier Compose convient à un pilote. Pour une instance d'équipe durable, l'éditeur fournit un module Terraform pour Google Cloud (langfuse/langfuse-terraform-gcp) : GKE pour les conteneurs web et worker, Cloud SQL pour PostgreSQL, Memorystore pour Redis, Cloud Storage pour le stockage objet, ClickHouse déployé dans le cluster. Ce projet ne crée aucune de ces ressources (coût d'un cluster), mais la correspondance est directe :
| Lab (Compose) | Production GCP |
|---|---|
langfuse-web, langfuse-worker | Deployments GKE, plusieurs réplicas, mises à jour progressives |
| PostgreSQL 17 | Cloud SQL, sauvegardes automatiques et récupération à un instant donné |
Redis 7 noeviction | Memorystore for Redis |
| MinIO | Cloud Storage (API compatible S3) |
| ClickHouse | ClickHouse dans GKE (ou service managé tiers), sauvegardes planifiées |
.env | Secret Manager |
make down -v | jamais : la suppression d'une instance de production est une décision, pas une commande de nettoyage |
La règle de décision
Un coût n'est fiable que si chaque clé d'usage émise a un prix et si chaque modèle enregistré a une définition : vérifiez-le avec un test, et alertez sur toute génération dont l'usage est non nul et le coût nul. Attribuez chaque appel payant à un utilisateur, réel ou technique. Fixez la rétention le premier jour. Restez sur Compose tant qu'une interruption de quelques heures est acceptable ; au-delà, passez au module Terraform ou à Langfuse Cloud.
Pratique
Le 3 janvier 2027, le contrôleur de gestion compare la facture Google et le tableau Langfuse de décembre et de janvier. Décembre concorde, janvier est sous-estimé de moitié pour le juge. Que s'est-il passé ?
Moitié exactement, et uniquement en janvier, sur un seul modèle : c'est la signature d'un prix doublé non reporté. Des traces perdues donneraient un écart irrégulier sur tous les modèles. Moins d'appels se verrait dans le nombre d'observations, que la facture ne montre pas.
Le DPO demande de pouvoir garder un an les scores d'évaluation mais pas plus de 30 jours les conversations des conseillers. Comment l'organisez-vous ?
La rétention s'applique aux observations du projet : 30 jours règle la question des conversations. Les jeux de données ne contiennent que des questions anonymisées, et les agrégats exportés ne contiennent plus de données personnelles. Une suppression manuelle mensuelle finit toujours par être oubliée ; désactiver le traçage revient à la situation de départ.
Exercice. Faites en sorte que les appels du juge soient attribués à un utilisateur technique juge-llm et portent le tag evaluation, puis vérifiez avec make couts que la ligne None ne porte plus de coût.
Voir la solution
Dans juge_exactitude, entourez la génération d'un bloc propagate_attributes :
from langfuse import propagate_attributes
with propagate_attributes(user_id="juge-llm", tags=["evaluation"]):
with get_client().start_as_current_observation(as_type="generation", name="juge",
model=SETTINGS.judge_model, input=prompt) as gen:
...Après make down && make up && make e2e && make couts, la ligne juge-llm porte l'essentiel du coût et None tombe à 0. Les spans sans modèle restent sans coût, ce qui est normal.
- Je sais déclarer des prix de modèle couvrant toutes les clés d'usage, et le vérifier par un test.
- Je sais pourquoi un changement de tarif doit être appliqué le jour même.
- Je sais obtenir le coût par modèle et par conseiller sans exporter les observations.
- Je sais fixer une rétention et arrêter le lab en supprimant tout, vérification comprise.
- Je sais décrire la cible Google Cloud équivalente au fichier Compose.