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:
parent
f5f30923d0
commit
79ed5afdba
1 changed files with 77 additions and 23 deletions
|
|
@ -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,12 +180,39 @@ 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<RecipeStepAnalysis> {
|
||||
if (this._modelTag === undefined) {
|
||||
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({
|
||||
model: this._modelTag,
|
||||
messages: [
|
||||
|
|
@ -190,23 +220,47 @@ export class OllamaStepAnalyzer {
|
|||
{ 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 },
|
||||
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;
|
||||
const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult;
|
||||
return { originalText: stepText, actions: parsed.actions };
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`OllamaStepAnalyzer: réponse non-JSON malgré le schéma imposé — "${response.message.content}"`,
|
||||
{ cause: 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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue