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>
328 lines
14 KiB
TypeScript
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();
|
|
}
|