agent-observability-auto-experiment

Par datadog-labs · agent-skills

Exécute une amélioration itérative de code en mode hill-climb sur de vraies données Datadog LLM-Obs, en local, avec Claude Code comme agent. Établit une baseline d'évaluation, apporte un changement ciblé, réévalue avec le même harnais, ne conserve le changement que s'il dépasse le meilleur score, et recommence. À utiliser quand l'utilisateur dit « run an auto experiment », « hill-climb this code », « iteratively improve X and measure the delta », « optimize this prompt/file against my traces », « auto-optimize against LLM-Obs », ou veut l'équivalent local du worker `auto_experiments`. Fonctionne à partir d'un `ml_app`, d'un `dataset_id`, ou d'une liste de `trace_ids`.

npx skills add https://github.com/datadog-labs/agent-skills --skill agent-observability-auto-experiment

auto-experiment — boucle locale d'amélioration par escalade

Ceci est la version locale, pilotée par Claude Code, du worker Temporal/Atlas auto_experiments (domains/ml_observability/apps/apis/auto_experiments/). Là-bas, un agent distant Bits/Code-Gen exécute la boucle ; ici VOUS (Claude Code) êtes l'agent et l'exécutez directement sur le checkout git actuel. Pas de Temporal, pas d'API Code-Gen — juste des commits git, un harness d'évaluation local, et les outils MCP Datadog LLM-Obs pour les données.

Lisez references/rubrics.md en entier avant l'itération 1 et gardez-le à l'esprit à chaque itération. Il contient les règles non négociables (ne jamais inventer un score ; quoi évaluer ; où vivent les données ; la spec du harness ; le schéma des métriques). Ce fichier est la boucle de contrôle ; ce fichier est la loi.

Entrées (la configuration de l'expérience)

Collectez celles-ci auprès de l'utilisateur (ne demandez que ce qui manque — déduisez le reste). Repo = répertoire de travail actuel.

Champ Sens Défaut
files_to_optimize la portée d'édition : un ou plusieurs fichiers, un dossier, ou des globs. Tout code à l'intérieur de la portée est fair game pour être modifié — code de tool/retrieval, pipeline, config, data-shaping, ou prompts — pas seulement la formulation des prompts. Tout ce qui est en dehors de la portée est interdit. requis
goal ce que « mieux » signifie ; le rubric du juge + direction d'optimisation requis
evaluators texte d'évaluateur/rubric explicite, le cas échéant fallback sur goal
model id du modèle juge le modèle Claude sélectionné dans cette session (voir rubric)
ml_app app Datadog LLM-Obs d'où récupérer les traces requis sauf si dataset_id/trace_ids donné
source de données dataset_id | trace_ids | traces récentes pour ml_app voir priorité ci-dessous
max_iterations combien de changements essayer (clamper 1–50) 2
base_branch branche sur laquelle mesurer la baseline branche actuelle / main

Persistez la config à .auto_experiment/config.json et mettez-la à jour au fur et à mesure que l'exécution progresse (c'est l'état de l'exécution + audit trail) :

{
  "repo_url": "...", "base_branch": "...", "files_to_optimize": [...],
  "goal": "...", "evaluators": "...", "ml_app": "...",
  "dataset_id": "...", "trace_ids": [...],
  "max_iterations": 2,
  "reps": 3,
  "min_delta": 0.02,
  "iteration_results": [],
  "final_result": {}
}

Portée — optimiser toute la surface sélectionnée, pas juste le prompt

files_to_optimize est une portée, pas un pointeur de prompt. Il peut être un ensemble de fichiers, un répertoire, ou des globs — développez un répertoire en ses fichiers éditables (p. ex. chaque *.py dedans) et traitez tous comme le code testé. À l'intérieur de cette portée vous pouvez changer n'importe quoi qui fait bouger la métrique : code de retrieval/tool, logique de requête, filtrage, forme de sortie, ranking, config, ou prompts. Laissez le census des défaillances décider quel fichier contient le levier — ne defaultez pas à reformuler un prompt. En pratique les plus gros gains sont souvent dans le code de tool/retrieval (ce que le modèle peut récupérer), pas la formulation du prompt ; une recherche prompt-only ne trouve rien quand la marge est dans les tools.

Garde de portée stricte : ne jamais éditer un fichier en dehors de files_to_optimize. Si le levier dominant du census est hors portée, dites-le (c'est une découverte) — ne tweakez pas silencieusement des fichiers in-scope-mais-hors-de-propos.

Setup

  1. Confirmez un arbre de travail propre-ish (stash ou warn sur les changements non liés). Notez le SHA de départ. Si files_to_optimize nomme un dossier/globs, résolvez-le à la liste concrète de fichiers éditables et enregistrez cette liste dans config.json (c'est la portée pour chaque itération + la limite de restauration).
  2. Créez une branche scratch off base_branch pour l'expérience (p. ex. auto-experiment/<short-goal>). Tous les commits d'itération vont ici ; l'utilisateur revoit/garde le meilleur commit à la fin.
  3. Écrivez .auto_experiment/config.json. Ajoutez les fichiers de sortie .auto_experiment/ à rien de spécial — ils sont committés exprès (c'est l'audit trail).
  4. Cette exécution rapporte un score par itération au LLM-Obs experiment identifié par la variable d'environnement DD_AUTO_EXPERIMENT_ID (à définir dans l'environnement avant que ce skill soit invoqué — lisez-la, ne la demandez pas à l'utilisateur). Voir Reporter le score de chaque itération à LLM-Obs.
  5. Enregistrez le contexte de la run sur l'expérience avant le démarrage des itérations. Appelez update_llmobs_experiment une fois avec experiment_id = $DD_AUTO_EXPERIMENT_ID (skip si unset) et metadata défini à une struct JSON contenant le nom du repo, le nom de la branche scratch, et le modèle exécutant ce skill, p. ex. {"repo": "<repo>", "branch": "<scratch-branch>", "model": "<model>"}. Dérivez repo du remote git (basename -s .git $(git remote get-url origin), ou owner/repo), branch de la branche créée à l'étape 2, et model = le provider/model-id du modèle/agent pilotant cette session (p. ex. openai/gpt-4-turbo, anthropic/claude-opus-4-8). metadata remplace les métadonnées existantes, donc incluez les trois clés dans l'appel unique. Faites ceci dans Setup, avant l'étape 1.

Modèle d'exécution — orchestrateur + sous-agents fresh par itération

Séparez les deux rôles pour que le contexte reste propre et les itérations ne s'ancrent pas l'une sur l'autre :

  • Vous êtes l'orchestrateur. Vous possédez l'état durable (config.json, census.json, best_sha, la branche), le harness, et chaque décision keep/discard. Vous n'accumulez PAS le travail brut de chaque tentative dans votre propre contexte.
  • Chaque itération d'amélioration s'exécute dans un sous-agent FRESH (spawné via l'outil Agent). Donnez-lui un briefing compact — pas votre transcription entière : le goal/evaluators, la portée éditable complète (files_to_optimize développée — il peut changer N'IMPORTE QUEL fichier en portée, pas juste un prompt), les buckets census.json rangés (+ le bucket à cibler cette itération), le best_sha actuel, et résumés d'une ligne des tentatives antérieures (qu'a-t-on essayé → kept/discarded, depuis iteration_results) pour qu'il ne les répète pas. Son travail : faire UN changement + retourner un court résumé (ce qu'il a changé, quel bucket, résultat de sonde de faisabilité). Vous (orchestrateur) exécutez le harness, appliquez la porte de bruit + audit mécanisme, commit/keep/discard, et mettez à jour l'état.
  • Pourquoi : un contexte limité fresh par itération évite l'ancrage sur les idées mortes et empêche le contexte de l'orchestrateur de gonfler sur une longue exécution — la même raison que la boucle de production spawne un nouveau claude --print par itération au lieu d'un agent long-lived unique. Si les sous-agents sont indisponibles, émoulez-le : avant chaque itération, relisez seulement le briefing ci-dessus et ignorez délibérément la narration des tentatives antérieures au-delà de leurs résultats d'une ligne.

Itération 1 — baseline + première amélioration

Reflète build_initial_prompt. Quatre étapes, dans l'ordre.

Étape 1 — Charger les données d'évaluation

Sélectionnez la source de données dans cet ordre de priorité et matérialisez-la à .auto_experiment/data.jsonl (un point de données scorable par ligne : l'input, plus la sortie attendue/référence si présente) :

  1. dataset_id présentget_llmobs_dataset_records + get_llmobs_full_dataset_records.
  2. sinon non-empty trace_idsget_llmobs_trace (arbre complet), get_llmobs_span_details, get_llmobs_span_content.
  3. sinon → récupérez les ~30 dernières traces LLM pour ml_app (searchez les spans LLM-Obs), et enregistrez les IDs de trace que vous avez utilisés dans config.json trace_ids pour que les itérations ultérieures réutilisent le MÊME corpus.

Extrayez input/output selon les directives de source messages dans references/rubrics.md (évaluez le champ messages sur le span LLM enfant, pas le mince root input.value). Appliquez les directives de sélection de données : gardez seulement les traces avec un span cible scorable ; excluez entièrement les spans infra/setup du set.

Puis splittez une fois, déterministiquement (hash d'id de datapoint, ~70/30) en .auto_experiment/data.val.jsonl (la porte de la colline) et .auto_experiment/data.test.jsonl (tenu en dehors) — voir le split Held-out du rubric. Chaque itération évalue sur val (AUTO_EXP_DATA=.auto_experiment/data.val.jsonl) ; test ne s'exécute que dans le rapport final.

Étape 2 — Construire le harness et calculer AVANT (baseline)

Copiez references/eval_harness_template.py vers .auto_experiment/eval_harness.py et remplissez generate_output (exécutez le VRAI code sous test depuis files_to_optimize) et judge. Préférez une métrique de vérité au sol déterministe (sortie de référence / vérificateur programmatique / compte de pipeline) et utilisez un LLM-as-judge seulement quand aucune vérité au sol n'existe — voir la sélection de Métrique du rubric. Aucun littéral de score nulle part.

Exécutez-le contre le code original, non modifié avec AUTO_EXP_REPS (= config reps, défaut 3) : le harness ré-exécute l'éval entier R fois et imprime {mean, stdev, rep_means, ...}. before_score = le mean imprimé ; enregistrez aussi stdev (le plancher de bruit). Les deux nombres calculés, jamais des littéraux — obéissez à la politique de score et la politique de Bruit & keep/discard du rubric.

Committez eval_harness.py, data.jsonl, data.val.jsonl, data.test.jsonl, eval_results.jsonl.

Puis reportez la baseline à LLM-Obs comme itération 0 — avant de démarrer la première itération. Soumettez exactement un point de donnée eval-metric avec score_value = before_score et tags ["iteration:0", "git.commit.sha:<baseline_commit_sha>", "decision:baseline"] (le sha est le hash 40-caractères complet du commit que vous venez de faire — git rev-parse HEAD, pas un short hash). Même forme d'appel et règles que Reporter le score de chaque itération à LLM-Obs ; c'est la seule soumission avec iteration:0 et decision:baseline.

Étape 2.5 — Recenser les défaillances de la baseline

Avant de rien changer, décomposez où la baseline perd selon le census de défaillances de Baseline du rubric : bucketez chaque point de donnée en défaut par cause racine, écrivez .auto_experiment/census.json, committez-le, et surfacez les buckets classés. Ceci vous dit quel levier vaut la peine d'être tiré — et si le mode de défaillance dominant est même atteignable en éditant files_to_optimize.

Étape 3 — Améliorer

Lisez la portée complète (files_to_optimize, développée). Faites UN changement focalisé vers goal, visant le plus grand bucket du census que vous pouvez plausiblement déplacer (nommez ce bucket dans la reasoning de l'itération), dans whichever fichier in-scope qui tient le levier — éditez le code de tool/retrieval si le census dit que les misses sont retrieval, le code de sortie/format s'ils sont du formatting, etc. Ne defaultez pas à reformuler un prompt quand le levier est ailleurs. Committez-le sur la branche scratch avec un message expliquant ce qui a changé et pourquoi.

Avant l'éval complet (cher), exécutez une sonde de faisabilité selon la Sonde de faisabilité du rubric : la vérification offline la moins chère que ce changement pourrait déplacer un bucket du census en défaut. Si la sonde atteint 0 points de donnée en défaut, enregistrez l'itération no_change avec le résultat de la sonde et skippez à la prochaine hypothèse — ne dépensez pas une éval complète sur un levier mort.

Étape 4 — Calculer APRÈS (ré-exécuter le MÊME harness)

Ré-exécutez .auto_experiment/eval_harness.py (même evaluate_line, mêmes données) contre le code modifié. after_score = la nouvelle moyenne imprimée. Ré-écrivez eval_results.jsonl. Écrivez l'objet métrique (schéma dans le rubric) à .auto_experiment/result.json et committez-le dans le même commit que le changement. delta = after_score - before_score.

Décidez is_best selon la direction d'optimisation dans goal et la politique de Bruit & keep/discard : gardez seulement si |after_score − before_score| > max(pooled_stdev, min_delta). Un gain dans-le-bruit est is_best: false (discarded), pas gardé. Si la bande est franchie, exécutez l'audit Mécanisme (rubric) — diffez l'eval_results.jsonl de cette itération contre celui de la baseline (même dénominateur de count ; le gain vient des points de donnée que le changement a touchés) avant de garder. Si l'itération 1 franchit la bande ET passe l'audit, elle devient la meilleure (best_sha = ce commit, best_score = after_score). Appendez la ligne à config.json iteration_results.

Puis reportez le score de cette itération à LLM-Obs (tag iteration:1) — voir Reporter le score de chaque itération à LLM-Obs.

Itérations 2+ — escalade de colline

Reflète build_followup_prompt. La baseline est déjà connue — ne la ré-calculez pas.

  1. Restaurez au best-so-far, pour qu'une tentative discardée ne contamine pas celle-ci :
    • si un commit a été gardé → git reset --hard <best_sha> (reste sur la branche scratch ; le harness committés + données vivent dans ce commit, donc ils sont préservés — ne les recréez pas).
    • si rien n'a été gardé encore → git checkout <base_branch> -- <files_to_optimize> (restaurez seulement les fichiers cibles ; le harness/données vivent seulement dans le commit antérieur sur cette branche, donc un hard reset à base les supprimerait).
  2. before_score = le meilleur score actuel (depuis iteration_results ; baseline d'itération 1 si rien n'a été gardé encore). Ne ré-exécutez PAS la baseline.
  3. Réutilisez les données depuis data.jsonl et l'eval_harness.py commité — ne rechargez ou ne rebuildez.
  4. Faites UN nouveau changement, différent de toute tentative antérieure (vous pouvez voir les tentatives antérieures dans iteration_results), visant un bucket census.json nommé, dans whichever fichier in-scope qui tient le levier (tool/retrieval/pipeline/config/prompt — pas prompt-only). Committez-le.
  5. Sonde de faisabilité en premier (rubric) : vérification offline bon marché que le changement peut déplacer son bucket cible ; si elle atteint 0 points de donnée en défaut, enregistrez no_change et skippez l'éval complète. Sinon ré-exécutez le MÊME harness sur valafter_score. Ré-écrivez eval_results.jsonl + result.json, committez.
  6. Gardez ou discardez : gardez seulement si le delta franchit la bande de bruit (`|after_score − before_score|

    max(pooled_stdev, min_delta), selon la politique de Bruit) dans la direction degoal**et passe l'audit Mécanisme** (rubric) — diffezeval_results.jsonlvs le commit best (git show <best_sha>:.auto_experiment/eval_results.jsonl) ; même dénominateur, gain depuis les points de donnée que le changement a touchés. Puis → mettez à jourbest_sha/best_score, décisionkept. Un gain dans-le-bruit, un artefact de dénominateur, ou une régression →discarded`. Appendez la ligne.

  7. Reportez le score de cette itération à LLM-Obs (tag iteration:<n>) — voir Reporter le score de chaque itération à LLM-Obs.

Reporter le score de chaque itération à LLM-Obs (chaque itération scorée)

Une fois que vous avez un score calculé pour une itération, soumettez exactement un point de donnée eval-metric à LLM-Obs avec l'outil MCP submit_llmobs_experiment_events. Faites ceci une fois par itération, juste après que le score soit calculé et le commit de l'itération / result.json soit écrit — incluant l'itération 1 et la baseline itération-0 (voir étape 2 ; là score_value = before_score et le tag de décision est decision:baseline).

Appelez submit_llmobs_experiment_events avec une seule métrique façonnée exactement comme ceci :

  • experiment_id : la valeur de la variable d'environnement DD_AUTO_EXPERIMENT_ID (lisez-la depuis l'environnement ; ne la demandez pas à l'utilisateur et ne l'inventez pas). Si elle est unset ou vide, skippez la soumission et notez-le dans la reasoning de l'itération.
  • metrics : un array contenant exactement un objet avec ces champs et aucun autre :
    • label : toujours la chaîne littérale auto_experiment_score.
    • metric_type : score.
    • score_value : le score que cette itération a produit (after_score) — le nombre calculé par le harness, jamais un littéral ou une valeur arrondie-pour-display.
    • timestamp_ms : le temps mur actuel comme un timestamp epoch en millisecondes.
    • tags : ["iteration:<n>", "git.commit.sha:<sha>", "decision:<decision>"], où <n> est le numéro de cette itération (1 pour la première amélioration, 2 pour la prochaine, etc.), <sha> est le 40-caractères complet Git commit SHA du commit que cette itération a créé pour son changement — le hash complet depuis git rev-parse HEAD après committing l'itération (p. ex. fd0fbab7c1232e125df7b22d9df856a2ef73ab65), jamais le short hash 7/8-caractères abbrégé — et <decision> est la décision keep/discard de cette itération enregistrée dans iteration_results (kept ou discarded ; baseline pour itération 0).
    • reasoning : la chaîne reasoning de cette itération depuis iteration_results — ce qui a été essayé, quel bucket du census il ciblait, et pourquoi il a été gardé ou discardé (pour itération 0, que c'est la baseline). Utilisez le même texte enregistré dans result.json ; ne le fabriquez pas.
    • Ne pas inclure span_id, categorical_value, ou boolean_value.

Exemple d'arguments pour l'itération 5 dont le harness a calculé un score de 0.72 :

{
  "experiment_id": "<valeur de $DD_AUTO_EXPERIMENT_ID>",
  "metrics": [
    {
      "label": "auto_experiment_score",
      "metric_type": "score",
      "score_value": 0.72,
      "reasoning": "Rescript du constructeur de requête retrieval pour inclure les synonymes d'entité (ciblant le bucket du census 'missed-retrieval') ; a franchi la bande de bruit et a passé l'audit de mécanisme, donc gardé.",
      "timestamp_ms": 1752430000000,
      "tags": ["iteration:5", "git.commit.sha:33ec6e0959bd46b0ea9c337cf6a28a763d3eeb0a", "decision:kept"]
    }
  ]
}

Règles :

  • Exactement une métrique par itération. Ne soumettez jamais plus d'une métrique pour la même itération et ne batchez jamais plusieurs itérations en un appel.
  • Seulement pour les itérations scorées. Une itération no_change n'a aucun score calculé, donc pas de score_value à envoyer — skippez la soumission (émettre une métrique score sans un vrai score violerait la politique de score). Enregistrez le skip dans reasoning.
  • La valeur que vous soumettez est le même after_score calculé enregistré dans result.json ; les deux doivent toujours être d'accord.

Conditions d'arrêt & gardes

  • Arrêtez quand iteration == max_iterations.
  • Plateau dans le bruit — arrêtez tôt. Si les 3 dernières itérations ont toutes atterri discarded à l'intérieur de la bande de bruit (aucun delta n'a franchi max(pooled_stdev, min_delta)), arrêtez et rapportez le meilleur actuel avec stop_reason: "plateau (deltas within noise)". Continuer au-delà d'un plateau de bruit brûle juste le budget en générant du wiggle dans-le-bruit ; escaladez plutôt (un nouveau bucket du census, une dimension différente, ou acceptez le plafond). Distinguez ceci d'une vraie streak de régression.
  • Un changement sans score computable est no_change, jamais un nombre fabriqué (harness ne s'exécutera pas / pas de nouveau commit / juge inatteignable / sonde de faisabilité a atteint 0). Enregistrez le blocker dans reasoning.
  • Tracez les itérations no_change consécutives ; après 5 d'affilée, arrêtez tôt et rapportez le meilleur résultat jusqu'à présent avec une raison d'arrêt (ne continuez pas à brûler les itérations).

Rapport final

  1. Posez-vous la question du wrap-up au niveau de la run et écrivez final_result dans config.json : { "baseline_score", "best_score", "best_iteration", "best_sha", "iterations_run", "stop_reason", "reasoning" } (reasoning = ce qui a été essayé à travers toutes les itérations, ce qui a marché, ce qui n'a pas marché, pourquoi le gagnant a gagné).
  2. Comparaison test held-out (le vrai titre). Exécutez le harness une fois sur le baseline commit et une fois sur le meilleur commit contre .auto_experiment/data.test.jsonl (AUTO_EXP_DATA=.auto_experiment/data.test.jsonl), tous deux à reps reps. Rapportez la baseline-vs-best test delta avec sa bande de bruit comme résultat de la run — le gain hill-climb val n'est pas le titre. Si test n'a pas franchi la bande de bruit même si val l'a fait, dites-le explicitement (le gain n'a pas généralisé) et traitez la baseline comme meilleure.
  3. Imprimez un tableau par itération (itération, delta val, décision, sha) et nommez le meilleur commit.
  4. Si rien n'a battu la baseline sur test : rapportez la baseline comme meilleur résultat et laissez le code original en place (best_sha vide). Ne fabriquez pas une amélioration.
  5. Dites à l'utilisateur la branche scratch + meilleur commit pour qu'il puisse ouvrir un PR dessus s'il le souhaite.
  6. Marquez l'expérience terminée dans LLM-Obs. Appelez update_llmobs_experiment avec experiment_id = $DD_AUTO_EXPERIMENT_ID (skip si unset) exactement une fois à la très fin — après la dernière itération, ou immédiatement quand vous renoncez tôt. Définissez status: "completed" pour toute run qui a atteint le rapport final (incluant une où baseline a resté meilleure — une run qui a fini proprement est complétée, pas failed). Définissez status: "failed" avec un court error quand la run n'a pas pu terminer — le harness ne s'est jamais exécuté, le setup a été bloqué, ou vous avez renoncé avant toute itération scorée. Cette mise à jour de status est séparée des soumissions de métrique par itération ; faites-la une fois, en dernier.

Notes

  • Chaque score est calculé en exécutant du code. Si vous vous trouvez jamais sur le point de taper un nombre de score, arrêtez — exécutez le harness à la place.
  • Gardez .auto_experiment/ committé ; c'est le dossier reproductible de la run.

Skills similaires