batchCooking/experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts
Nicolas f5f30923d0 feat(experiments): ajoute un 4e moteur — même LLM via Ollama
Nouveau src/ollama-tech-step-poc.ts : même tâche/SYSTEM_PROMPT (exporté
depuis llm-tech-step-poc.ts et réutilisé tel quel) que le moteur
node-llama-cpp, mais via Ollama — une implémentation architecturalement
différente plutôt qu'une redite :

- Ollama tourne comme serveur HTTP local séparé (ollama serve), pas comme
  binding natif dans ce process — le paquet npm ollama n'a aucune
  dépendance native (rien à compiler à l'install, contrairement à
  node-llama-cpp).
- Modèle géré par Ollama lui-même (ollama.pull(), cache dans
  ~/.ollama/models), pas par ce projet — progression de pull journalisée
  palier par palier plutôt que silencieuse.
- Schéma JSON imposé via `format` (JSON Schema standard, `type:
  ["string","null"]` pour un champ nullable) — plus simple que le détour
  `oneOf` qu'exige la grammaire GBNF de node-llama-cpp.
- initialize() échoue avec un message explicite si le serveur Ollama n'est
  pas joignable, plutôt que l'erreur fetch brute.
- Caveat documenté en tête de fichier et rappelé avant le récapitulatif :
  la colonne RSS du harness ne mesure rien d'utile ici, l'inférence tourne
  dans le process ollama serve, pas dans ce script.

OllamaStepAnalyzer.dispose() décharge le modèle du serveur (keep_alive: 0,
best effort). Env vars OLLAMA_TECH_STEP_MODEL/OLLAMA_TECH_STEP_HOST,
scripts pnpm bench:ollama.

Vérifié en conditions réelles (Ollama tournait déjà dans l'environnement) :
pull + inférence structurée + parsing JSON fonctionnels, latence nettement
inférieure à node-llama-cpp sur les mêmes phrases (748-1260 ms vs 3-13 s),
delta RSS confirmé proche de zéro/bruit comme attendu.

README mis à jour (4 moteurs, section Ollama avec tableau comparatif
architectural, limites).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 23:14:03 +02:00

328 lines
14 KiB
TypeScript

/**
* PoC autonome — même tâche que `llm-tech-step-poc.ts` (extraction JSON
* contrainte par schéma d'une séquence d'actions culinaires), même
* `SYSTEM_PROMPT` (importé tel quel, voir sa doc), mais via
* [Ollama](https://ollama.com/) au lieu de `node-llama-cpp` — un troisième
* point de comparaison, architecturalement différent des deux autres
* moteurs LLM/NLP de ce PoC plutôt qu'une simple redite :
*
* - **`node-llama-cpp`** charge le binding natif llama.cpp DANS ce process
* Node (mêmes poids, même mémoire, même thread pool que le script).
* - **Ollama** est un serveur HTTP local **séparé** (`ollama serve`, lancé
* par l'app de bureau ou en CLI) — ce script n'est qu'un client HTTP fin
* (`ollama` sur npm, aucune dépendance native, aucun binding à compiler à
* l'installation) qui lui parle en local (`http://127.0.0.1:11434` par
* défaut). Conséquences directes, documentées où elles s'appliquent :
* - Pas de téléchargement/cache GGUF géré par ce projet — Ollama gère ses
* propres modèles (`~/.ollama/models`), récupérés via `ollama.pull()`
* (voir {@link OllamaStepAnalyzer.initialize}).
* - **Le delta de RSS de ce process ne mesure RIEN d'utile ici** :
* l'inférence tourne dans le process `ollama serve`, pas dans celui-ci
* — contrairement à `node-llama-cpp`, où le binding natif partage la
* mémoire du process Node. Cette colonne du récapitulatif reste
* affichée (même harness que les deux autres moteurs) mais est à
* ignorer pour ce script, voir `README.md`.
* - Nécessite Ollama installé et **son serveur déjà lancé** en dehors de
* ce script (pas de "just works" comme le binding embarqué) —
* {@link OllamaStepAnalyzer.initialize} échoue avec un message explicite
* si le serveur n'est pas joignable plutôt qu'une erreur `fetch` brute.
* - Le schéma JSON imposé au modèle (`format`, voir
* {@link KITCHEN_ACTIONS_JSON_SCHEMA}) accepte du JSON Schema standard
* (`type: ["string", "null"]` pour un champ nullable) — plus simple que
* le détour `oneOf: [{type:"null"}, {type:"..."}]` qu'exige la
* grammaire GBNF de node-llama-cpp (voir `llm-tech-step-poc.ts`), un
* autre point de comparaison entre les deux mécanismes de contrainte.
*
* Usage : voir `README.md`. En bref :
*
* ```bash
* ollama serve # dans un terminal séparé, si pas déjà lancé
* cd experiments/llm-tech-step-poc
* pnpm install --ignore-workspace
* pnpm bench:ollama
* ```
*/
import { performance } from "node:perf_hooks";
import { Ollama } from "ollama";
import { SYSTEM_PROMPT } from "./llm-tech-step-poc.js";
import {
type BenchmarkSample,
printSummaryTable,
runBenchmark,
} from "./shared/benchmark-harness.js";
import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js";
import { isMainModule } from "./shared/module-entry.js";
import type { BenchmarkSentence } from "./shared/test-sentences.js";
// ---------------------------------------------------------------------------
// Schéma JSON — passé tel quel à Ollama via `format`
// ---------------------------------------------------------------------------
/**
* Schéma JSON standard (pas de dialecte GBNF-spécifique) — Ollama valide/
* contraint la génération directement contre ce schéma via son paramètre
* `format`. Champ à champ, en miroir strict de `KitchenAction`
* (`shared/kitchen-action.ts`), même remarque que côté `node-llama-cpp` :
* ça n'impose qu'une SYNTAXE JSON valide, jamais la justesse sémantique du
* contenu — c'est {@link SYSTEM_PROMPT} qui porte la sémantique.
*/
const KITCHEN_ACTION_JSON_SCHEMA = {
type: "object",
properties: {
action: { type: "string", enum: Object.values(KitchenActionType) },
verb: { type: "string" },
ingredients: { type: "array", items: { type: "string" } },
durationMinutes: { type: ["number", "null"] },
temperature: { type: ["string", "null"] },
utensils: { type: "array", items: { type: "string" } },
},
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
};
/** Racine du schéma — même choix qu'en `node-llama-cpp` (`{ actions: [...] }` plutôt qu'un tableau nu), `originalText` volontairement absent, voir `llm-tech-step-poc.ts` pour le raisonnement complet. */
const KITCHEN_ACTIONS_JSON_SCHEMA = {
type: "object",
properties: {
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
},
required: ["actions"],
};
/** Forme attendue du JSON renvoyé par Ollama (`response.message.content`, une chaîne à parser) une fois conforme à {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */
interface KitchenActionsSchemaResult {
actions: RecipeStepAnalysis["actions"];
}
// ---------------------------------------------------------------------------
// Modèles recommandés
// ---------------------------------------------------------------------------
/** Mêmes deux familles de modèles que `llm-tech-step-poc.ts` (voir son comparatif) — pour rester comparable, référencées ici par leur tag Ollama plutôt qu'une URI `hf:`. */
export type RecommendedOllamaModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
interface RecommendedOllamaModel {
/** Tag tel qu'Ollama le résout (`ollama pull <tag>`) — voir https://ollama.com/library. */
tag: string;
rationale: string;
}
const RECOMMENDED_MODELS: Record<RecommendedOllamaModelKey, RecommendedOllamaModel> = {
"qwen2.5-1.5b": {
tag: "qwen2.5:1.5b",
rationale:
"Même choix par défaut que côté node-llama-cpp : meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON.",
},
"llama-3.2-1b": {
tag: "llama3.2:1b",
rationale:
"Alternative plus légère — voir le comparatif détaillé et les résultats empiriques dans le README et dans llm-tech-step-poc.ts.",
},
};
const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
// ---------------------------------------------------------------------------
// OllamaStepAnalyzer
// ---------------------------------------------------------------------------
/**
* Client Ollama enrobé comme service d'analyse d'étapes de recette — vraie
* `class` (pas un objet littéral), même convention que
* `LocalLlmStepAnalyzer`/`NlpTechStepClassifier` : possède un état réel (le
* client HTTP, le tag du modèle sélectionné), même si ici l'état lourd
* (les poids du modèle) vit dans le process `ollama serve` séparé, pas
* dans cette instance.
*/
export class OllamaStepAnalyzer {
private readonly _client: Ollama;
private readonly _host: string;
/** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */
private _modelTag: string | undefined;
public constructor(host: string = DEFAULT_OLLAMA_HOST) {
this._host = host;
this._client = new Ollama({ host });
}
/**
* Vérifie/télécharge le modèle (`ollama pull`, no-op quasi instantané si
* déjà présent localement — Ollama compare les manifestes de couches
* avant de retélécharger quoi que ce soit) et journalise la progression
* par palier de statut plutôt que de rester muet le temps du
* téléchargement (potentiellement plusieurs centaines de Mo au premier
* pull d'un modèle).
*
* Échoue avec un message explicite (plutôt que l'erreur `fetch` brute
* remontée par `ollama-js`) si le serveur Ollama n'est pas joignable —
* contrairement à `node-llama-cpp`, ce PoC dépend d'un process externe
* que ce script ne lance pas lui-même.
*/
public async initialize(modelKey: RecommendedOllamaModelKey): Promise<void> {
const tag = RECOMMENDED_MODELS[modelKey].tag;
console.info(`[ollama] vérification/pull du modèle "${tag}" sur ${this._host}...`);
try {
await this._ensureModelPulled(tag);
} catch (err) {
throw new Error(
`OllamaStepAnalyzer: impossible de joindre Ollama sur ${this._host} — le serveur est-il lancé (\`ollama serve\`, ou l'app de bureau Ollama) ?`,
{ cause: err },
);
}
this._modelTag = tag;
}
/** Force un premier appel factice — même rôle que le warm-up des deux autres moteurs : le premier vrai appel `chat()` déclenche le chargement des poids en mémoire côté serveur Ollama, un coût cependant nettement moins visible ici qu'avec node-llama-cpp car mutualisé/mis en cache par le serveur entre plusieurs process clients. */
public async warmUp(): Promise<void> {
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}. */
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
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 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 },
);
}
return { originalText: stepText, actions: parsed.actions };
}
/**
* Décharge le modèle de la mémoire du serveur Ollama (`keep_alive: 0`) —
* best effort, purement pour ne pas laisser le modèle chargé
* indéfiniment après ce benchmark : `ollama serve` tourne indépendamment
* de ce script (pas lancé ni arrêté par lui), donc rien d'autre à
* libérer côté process Node.
*/
public async dispose(): Promise<void> {
if (this._modelTag === undefined) return;
try {
await this._client.chat({ model: this._modelTag, messages: [], keep_alive: 0 });
} catch (err) {
console.error("[ollama] échec du déchargement du modèle (non bloquant)", err);
}
}
/** Lance un `pull` en streaming et journalise chaque changement de statut (`pulling manifest`, `downloading`, `verifying sha256 digest`...) avec le pourcentage quand Ollama le fournit. */
private async _ensureModelPulled(tag: string): Promise<void> {
const progress = await this._client.pull({ model: tag, stream: true });
let lastStatus = "";
for await (const part of progress) {
if (part.status === lastStatus) continue;
lastStatus = part.status;
const percent =
part.completed !== undefined && part.total !== undefined && part.total > 0
? ` (${Math.round((part.completed / part.total) * 100)}%)`
: "";
console.info(`[ollama] ${part.status}${percent}`);
}
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
/** Imprime le détail de chaque échantillon — même format que `llm-tech-step-poc.ts`, pour comparer les deux moteurs LLM à l'œil ligne à ligne. */
function printDetailedResults(samples: readonly BenchmarkSample<RecipeStepAnalysis>[]): void {
for (const sample of samples) {
console.info(
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`,
);
console.info(` texte : ${sample.sentence.text}`);
console.info(` attendu : ${sample.sentence.note}`);
console.table(
sample.result.actions.map((action) => ({
action: action.action,
verbe: action.verb,
ingrédients: action.ingredients.join(", "),
"durée (min)": action.durationMinutes ?? "—",
température: action.temperature ?? "—",
ustensiles: action.utensils.join(", "),
})),
);
}
}
/**
* Point d'entrée : charge le modèle choisi via `OLLAMA_TECH_STEP_MODEL`
* (`"qwen2.5-1.5b"` par défaut), contre le serveur Ollama de
* `OLLAMA_TECH_STEP_HOST` (`http://127.0.0.1:11434` par défaut), lance le
* benchmark sur les 11 phrases partagées, imprime les résultats détaillés
* puis le récapitulatif, et décharge le modèle avant de quitter.
*/
async function main(): Promise<void> {
const modelKey: RecommendedOllamaModelKey =
process.env.OLLAMA_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
const host = process.env.OLLAMA_TECH_STEP_HOST ?? DEFAULT_OLLAMA_HOST;
console.info(
`[ollama] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
);
const analyzer = new OllamaStepAnalyzer(host);
try {
await analyzer.initialize(modelKey);
} catch (err) {
console.error("[ollama] échec de l'initialisation", err);
process.exitCode = 1;
return;
}
const warmUpStartedAt = performance.now();
try {
await analyzer.warmUp();
} catch (err) {
console.error("[ollama] échec du warm-up — le benchmark continue quand même", err);
}
console.info(`[ollama] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
try {
const benchmarkStartedAt = performance.now();
const samples = await runBenchmark<RecipeStepAnalysis>({
logPrefix: "[ollama]",
countOf: (result) => result.actions.length,
countLabel: "action(s) détectée(s)",
analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text),
});
console.info(
`[ollama] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
);
printDetailedResults(samples);
console.info(
"\n[ollama] rappel : la colonne 'RSS moy.' ci-dessous ne mesure rien d'utile pour ce moteur — l'inférence tourne dans le process `ollama serve`, pas dans ce script (voir le doc-comment en tête de fichier).",
);
printSummaryTable(samples, (result) => result.actions.length, "actions détectées");
} finally {
try {
await analyzer.dispose();
} catch (err) {
console.error("[ollama] erreur lors de la libération du modèle", err);
}
}
}
if (isMainModule(import.meta.url)) {
await main();
}