From 79ed5afdba8d81c7c327b1bb2c9fa2834f85a224 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 22 Aug 2026 00:12:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(experiments):=20corrige=20les=20=C3=A9checs?= =?UTF-8?q?=20de=20parsing=20JSON=20intermittents=20du=20moteur=20Ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/ollama-tech-step-poc.ts | 100 ++++++++++++++---- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts index 379bc75..44339fc 100644 --- a/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts +++ b/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts @@ -135,6 +135,9 @@ const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"; * dans cette instance. */ 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 _host: string; /** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */ @@ -177,36 +180,87 @@ export class OllamaStepAnalyzer { 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'où 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 { if (this._modelTag === undefined) { throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep()."); } - const response = await this._client.chat({ - model: this._modelTag, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: stepText }, - ], - format: KITCHEN_ACTIONS_JSON_SCHEMA, - // Température 0 — génération déterministe, cohérent avec l'usage - // d'un schéma imposé : on veut la sortie la plus prévisible possible - // pour ce qui reste discrétionnaire (le contenu, pas la syntaxe). - options: { temperature: 0 }, - stream: false, - }); + let lastParseError: unknown; + let lastRawContent = ""; + for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) { + const response = await this._client.chat({ + model: this._modelTag, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: stepText }, + ], + format: KITCHEN_ACTIONS_JSON_SCHEMA, + options: { + // Température 0 sur la 1re tentative — génération déterministe, + // cohérent avec l'usage d'un schéma imposé : on veut la sortie la + // 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, + }); - let parsed: KitchenActionsSchemaResult; - try { - parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; - } catch (err) { - throw new Error( - `OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`, - { cause: err }, - ); + try { + const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult; + return { originalText: stepText, actions: parsed.actions }; + } catch (err) { + lastParseError = err; + lastRawContent = response.message.content; + 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 }, + ); } /**