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.
|
* 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,36 +180,87 @@ 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'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> {
|
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().");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this._client.chat({
|
let lastParseError: unknown;
|
||||||
model: this._modelTag,
|
let lastRawContent = "";
|
||||||
messages: [
|
for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) {
|
||||||
{ role: "system", content: SYSTEM_PROMPT },
|
const response = await this._client.chat({
|
||||||
{ role: "user", content: stepText },
|
model: this._modelTag,
|
||||||
],
|
messages: [
|
||||||
format: KITCHEN_ACTIONS_JSON_SCHEMA,
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
// Température 0 — génération déterministe, cohérent avec l'usage
|
{ role: "user", content: stepText },
|
||||||
// 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).
|
format: KITCHEN_ACTIONS_JSON_SCHEMA,
|
||||||
options: { temperature: 0 },
|
options: {
|
||||||
stream: false,
|
// 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 {
|
||||||
try {
|
const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult;
|
||||||
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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue