fix(experiments): corrige les échecs de parsing JSON intermittents du moteur Ollama

Diagnostiqué et reproduit : sur un petit modèle (qwen2.5:0.5b) face à une
phrase longue (fr-concat-volumetrie, fr-recette-complete), le modèle part
en boucle de répétition dans le tableau `actions` et n'atteint jamais
l'accolade fermante avant la limite de tokens (response.done_reason ===
"length", jusqu'à ~130 000 caractères observés). La grammaire imposée par
`format` ne borne que la syntaxe token par token, pas la longueur du
tableau.

- options.repeat_penalty (1.3) décourage la boucle — réduit le dérapage
  d'un facteur ~18 sur le pire cas reproduit, sans l'éliminer à coup sûr.
- options.num_predict (2048) borne le dégât si ça dérape quand même.
- analyzeStep() réessaie jusqu'à 3 fois sur un parse invalide, avec une
  température légèrement relevée (0.3) à partir de la 2e tentative — à
  température 0 stricte, retenter à l'identique peut reproduire l'échec.

Vérifié : 3/3 runs réussissent sur le pire cas reproduit après correctif,
contre un échec systématique avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-22 00:12:33 +02:00
parent f5f30923d0
commit 79ed5afdba

View file

@ -135,6 +135,9 @@ const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
* dans cette instance. * dans cette instance.
*/ */
export class OllamaStepAnalyzer { export class OllamaStepAnalyzer {
/** Nombre de tentatives avant d'abandonner sur une réponse JSON invalide — voir le doc-comment de {@link OllamaStepAnalyzer.analyzeStep}. */
private static readonly _MAX_PARSE_ATTEMPTS = 3;
private readonly _client: Ollama; private readonly _client: Ollama;
private readonly _host: string; private readonly _host: string;
/** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */ /** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */
@ -177,12 +180,39 @@ export class OllamaStepAnalyzer {
await this.analyzeStep("Faites chauffer une poêle."); await this.analyzeStep("Faites chauffer une poêle.");
} }
/** Analyse une étape de recette et renvoie sa séquence ordonnée d'actions, via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */ /**
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions,
* via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}.
*
* Réessaie jusqu'à {@link _MAX_PARSE_ATTEMPTS} fois si la réponse ne
* parse pas en JSON valide un échec bien réel et reproduit en
* pratique, surtout sur les petits modèles (`qwen2.5:0.5b`,
* `smollm2:360m`...) face aux phrases longues de ce PoC
* (`fr-concat-volumetrie`, `fr-recette-complete`...) : le modèle part en
* boucle de répétition dans le tableau `actions` et n'atteint jamais
* l'accolade fermante avant la limite de tokens
* (`response.done_reason === "length"`, contenu de plusieurs dizaines de
* milliers de caractères observé en pratique). La grammaire imposée par
* `format` contraint la SYNTAXE token par token, elle ne borne pas la
* LONGUEUR du tableau rien ne l'empêche de continuer à générer des
* éléments indéfiniment.
*
* `repeat_penalty`/`num_predict` réduisent nettement l'ampleur du
* dérapage (÷18 observé en pratique sur le pire cas) sans l'éliminer à
* coup sûr sur un modèle assez faible d' le retry, avec une
* température légèrement relevée à partir de la 2e tentative : à
* température 0 stricte, retenter avec des paramètres identiques peut
* reproduire l'échec (déterminisme), une température non nulle donne une
* vraie chance de sortir de la boucle.
*/
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> { public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
if (this._modelTag === undefined) { if (this._modelTag === undefined) {
throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep()."); throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep().");
} }
let lastParseError: unknown;
let lastRawContent = "";
for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) {
const response = await this._client.chat({ const response = await this._client.chat({
model: this._modelTag, model: this._modelTag,
messages: [ messages: [
@ -190,23 +220,47 @@ export class OllamaStepAnalyzer {
{ role: "user", content: stepText }, { role: "user", content: stepText },
], ],
format: KITCHEN_ACTIONS_JSON_SCHEMA, format: KITCHEN_ACTIONS_JSON_SCHEMA,
// Température 0 — génération déterministe, cohérent avec l'usage options: {
// d'un schéma imposé : on veut la sortie la plus prévisible possible // Température 0 sur la 1re tentative — génération déterministe,
// pour ce qui reste discrétionnaire (le contenu, pas la syntaxe). // cohérent avec l'usage d'un schéma imposé : on veut la sortie la
options: { temperature: 0 }, // plus prévisible possible pour ce qui reste discrétionnaire (le
// contenu, pas la syntaxe). Relevée légèrement sur les tentatives
// suivantes uniquement, voir le doc-comment ci-dessus.
temperature: attempt === 1 ? 0 : 0.3,
// Décourage la boucle de répétition qui cause l'essentiel des
// échecs de parsing observés (voir doc-comment) — 1.3 plutôt que
// le défaut ~1.1 d'Ollama, choisi empiriquement contre le pire
// cas reproduit (phrase longue + petit modèle).
repeat_penalty: 1.3,
// Borne le dégât en cas de dérapage malgré repeat_penalty
// (arrête la génération avant plusieurs dizaines de milliers de
// caractères inutiles) sans pénaliser les cas normaux — même la
// phrase la plus longue de ce PoC (recette concaténée, jusqu'à
// une quinzaine d'actions) tient largement dans cette limite une
// fois correctement formée.
num_predict: 2048,
},
stream: false, stream: false,
}); });
let parsed: KitchenActionsSchemaResult;
try { try {
parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult;
return { originalText: stepText, actions: parsed.actions };
} catch (err) { } catch (err) {
throw new Error( lastParseError = err;
`OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`, lastRawContent = response.message.content;
{ cause: err }, if (attempt < OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS) {
console.error(
`[ollama] réponse JSON invalide (tentative ${attempt}/${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS}, ${response.message.content.length} caractères, done_reason="${response.done_reason}") — nouvelle tentative...`,
); );
} }
return { originalText: stepText, actions: parsed.actions }; }
}
throw new Error(
`OllamaStepAnalyzer: réponse JSON invalide malgré le schéma imposé, après ${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS} tentatives — dernière réponse (${lastRawContent.length} caractères) : "${lastRawContent.slice(0, 500)}${lastRawContent.length > 500 ? "..." : ""}"`,
{ cause: lastParseError },
);
} }
/** /**