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>
This commit is contained in:
parent
b8e599106f
commit
f5f30923d0
5 changed files with 424 additions and 22 deletions
|
|
@ -6,24 +6,28 @@ référence que `apps/*`/`packages/*`) : ce dossier a son propre
|
||||||
Docker de `apps/api`.
|
Docker de `apps/api`.
|
||||||
|
|
||||||
Objectif : comparer, sur la même tâche (structurer une étape de recette en
|
Objectif : comparer, sur la même tâche (structurer une étape de recette en
|
||||||
séquence ordonnée d'actions culinaires) et le même jeu de 7 phrases, trois
|
séquence ordonnée d'actions culinaires) et le même jeu de 11 phrases, quatre
|
||||||
moteurs qui tournent tous 100 % en local :
|
moteurs qui tournent tous 100 % en local :
|
||||||
|
|
||||||
| Script | Moteur | Ce qu'il apporte |
|
| Script | Moteur | Ce qu'il apporte |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
|
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/) (binding natif **dans ce process**), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
|
||||||
|
| `pnpm bench:ollama` | Le même LLM (même `SYSTEM_PROMPT`, même tâche), mais via [Ollama](https://ollama.com/) — un serveur HTTP **local séparé** plutôt qu'un binding embarqué | Compare l'impact de l'architecture (client-serveur vs in-process) à sémantique identique, pas juste un autre modèle. |
|
||||||
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
|
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
|
||||||
| `pnpm bench:hybrid` | NLP d'abord, LLM en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
|
| `pnpm bench:hybrid` | NLP d'abord, LLM (`node-llama-cpp`) en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
|
||||||
|
|
||||||
Les trois partagent le même code (`src/shared/`) : la taxonomie
|
Les quatre partagent le même code (`src/shared/`) : la taxonomie
|
||||||
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
|
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
|
||||||
(`shared/kitchen-action.ts`), les 7 phrases de test
|
(`shared/kitchen-action.ts`), les 11 phrases de test
|
||||||
(`shared/test-sentences.ts`), et le harness de mesure/affichage
|
(`shared/test-sentences.ts`), et le harness de mesure/affichage
|
||||||
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
|
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
|
||||||
de sortie pour que les trois runs soient directement comparables, plutôt que
|
de sortie pour que les runs soient directement comparables, plutôt que
|
||||||
recopiés à la main dans trois scripts (le défaut d'une toute première
|
recopiés à la main dans chaque script (le défaut d'une toute première
|
||||||
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
|
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
|
||||||
phrases manuellement).
|
phrases manuellement). Les deux moteurs LLM (`node-llama-cpp`/Ollama)
|
||||||
|
partagent en plus le `SYSTEM_PROMPT` lui-même (exporté par
|
||||||
|
`llm-tech-step-poc.ts`, importé par `ollama-tech-step-poc.ts`) — même
|
||||||
|
sémantique testée sur les deux, seul le mécanisme de contrainte JSON change.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -42,7 +46,8 @@ ce PoC.
|
||||||
l'installation (binaire prébuilt pour les plateformes courantes, sinon
|
l'installation (binaire prébuilt pour les plateformes courantes, sinon
|
||||||
compilation locale — nécessite alors un toolchain C++, voir sa doc
|
compilation locale — nécessite alors un toolchain C++, voir sa doc
|
||||||
["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting)
|
["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting)
|
||||||
en cas d'échec).
|
en cas d'échec). `ollama` (le paquet npm) n'a lui aucune dépendance
|
||||||
|
native — c'est un simple client HTTP, rien à compiler.
|
||||||
|
|
||||||
## 1. Benchmark LLM seul — `pnpm bench`
|
## 1. Benchmark LLM seul — `pnpm bench`
|
||||||
|
|
||||||
|
|
@ -77,7 +82,46 @@ LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench
|
||||||
pointe directement vers un fichier déjà téléchargé, sans passer par la
|
pointe directement vers un fichier déjà téléchargé, sans passer par la
|
||||||
résolution/téléchargement Hugging Face.
|
résolution/téléchargement Hugging Face.
|
||||||
|
|
||||||
## 2. Benchmark NLP seul — `pnpm bench:nlp`
|
## 2. Benchmark LLM via Ollama — `pnpm bench:ollama`
|
||||||
|
|
||||||
|
Prérequis : [Ollama](https://ollama.com/download) installé, **et son serveur
|
||||||
|
lancé** (`ollama serve` dans un terminal séparé, ou l'app de bureau Ollama
|
||||||
|
qui le lance automatiquement) — ce script ne démarre pas le serveur
|
||||||
|
lui-même, contrairement à `pnpm bench` qui charge son modèle directement.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama serve # si pas déjà lancé (ou l'app de bureau Ollama)
|
||||||
|
pnpm bench:ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
Même tâche, même `SYSTEM_PROMPT`, mêmes modèles (`OLLAMA_TECH_STEP_MODEL`,
|
||||||
|
mêmes valeurs `qwen2.5-1.5b`/`llama-3.2-1b` que `LLM_TECH_STEP_MODEL`) que
|
||||||
|
la section 1 — mais une **implémentation architecturalement différente**,
|
||||||
|
testée pour ça plutôt que comme une simple redite :
|
||||||
|
|
||||||
|
| | `node-llama-cpp` (section 1) | Ollama (cette section) |
|
||||||
|
|---|---|---|
|
||||||
|
| Où tourne l'inférence | Dans CE process Node (binding natif) | Dans le process `ollama serve`, séparé |
|
||||||
|
| Installation | Binaire natif compilé/téléchargé au `pnpm install` | Client HTTP pur, rien à compiler |
|
||||||
|
| Gestion du modèle | `resolveModelFile` télécharge le GGUF dans `models/` de ce projet | `ollama pull` — Ollama gère son propre cache (`~/.ollama/models`) |
|
||||||
|
| Schéma JSON nullable | `oneOf: [{type:"null"}, {type:"..."}]` (contrainte de la grammaire GBNF) | `type: ["string", "null"]` (JSON Schema standard, plus simple) |
|
||||||
|
| Mesure RSS du benchmark | Fiable — le binding alloue dans ce process | **Sans intérêt** — l'inférence tourne ailleurs, voir ci-dessous |
|
||||||
|
|
||||||
|
**La colonne `RSS moy.` du récapitulatif ne veut RIEN dire pour ce script** —
|
||||||
|
`process.memoryUsage()` mesure ce process Node, pas le process `ollama
|
||||||
|
serve` où l'inférence a réellement lieu. Le script l'affiche quand même
|
||||||
|
(même harness que les trois autres) mais rappelle ce point juste avant le
|
||||||
|
tableau.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLLAMA_TECH_STEP_MODEL=llama-3.2-1b pnpm bench:ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hôte Ollama personnalisé** (serveur distant, port non standard) :
|
||||||
|
`OLLAMA_TECH_STEP_HOST=http://mon-serveur:11434 pnpm bench:ollama`
|
||||||
|
(défaut : `http://127.0.0.1:11434`).
|
||||||
|
|
||||||
|
## 3. Benchmark NLP seul — `pnpm bench:nlp`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm bench:nlp
|
pnpm bench:nlp
|
||||||
|
|
@ -111,7 +155,7 @@ revenir" (2 mots) ratait "faites-**les**-revenir" (le pronom clitique
|
||||||
français insère un mot entre les deux et casse un matching de phrase
|
français insère un mot entre les deux et casse un matching de phrase
|
||||||
contiguë), un synonyme mono-mot matche quel que soit ce qui le précède.
|
contiguë), un synonyme mono-mot matche quel que soit ce qui le précède.
|
||||||
|
|
||||||
## 3. Pipeline hybride — `pnpm bench:hybrid`
|
## 4. Pipeline hybride — `pnpm bench:hybrid`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm bench:hybrid
|
pnpm bench:hybrid
|
||||||
|
|
@ -141,9 +185,10 @@ chemin fournit réellement.
|
||||||
## Limites de ce PoC
|
## Limites de ce PoC
|
||||||
|
|
||||||
- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée
|
- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée
|
||||||
— les 7 phrases sont inspectées à l'œil, pas notées.
|
— les 11 phrases sont inspectées à l'œil, pas notées.
|
||||||
- La grammaire GBNF (moteur LLM) ne garantit qu'une syntaxe JSON conforme au
|
- La contrainte JSON (grammaire GBNF ou JSON Schema Ollama) ne garantit
|
||||||
schéma, jamais la justesse sémantique du contenu.
|
qu'une syntaxe JSON conforme au schéma, jamais la justesse sémantique du
|
||||||
|
contenu.
|
||||||
- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un
|
- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un
|
||||||
remplacement du corpus production `tech-step-training-data.ts`) — des
|
remplacement du corpus production `tech-step-training-data.ts`) — des
|
||||||
formes non couvertes (conjugaisons, synonymes absents) manqueront, comme
|
formes non couvertes (conjugaisons, synonymes absents) manqueront, comme
|
||||||
|
|
@ -152,8 +197,14 @@ chemin fournit réellement.
|
||||||
valeur empiriquement optimisée — à ajuster en observant la colonne
|
valeur empiriquement optimisée — à ajuster en observant la colonne
|
||||||
`moteur` du récapitulatif hybride sur des étapes réelles.
|
`moteur` du récapitulatif hybride sur des étapes réelles.
|
||||||
- Le delta de RSS process est une approximation de la RAM réellement utilisée
|
- Le delta de RSS process est une approximation de la RAM réellement utilisée
|
||||||
(un binding natif alloue dans le même process, donc le RSS la capture,
|
pour `node-llama-cpp`/`node-nlp` (un binding natif alloue dans le même
|
||||||
mais au bruit du GC/de l'allocateur près) — pas une mesure isolée.
|
process, donc le RSS la capture, mais au bruit du GC/de l'allocateur près)
|
||||||
- Latence LLM mesurée en CPU pur (pas de configuration GPU dans ce PoC) — un
|
— pas une mesure isolée, et carrément **sans valeur** pour `bench:ollama`
|
||||||
déploiement réel voudrait évaluer l'offload GPU (`gpuLayers` dans les
|
(l'inférence tourne dans `ollama serve`, un process séparé, voir la
|
||||||
options `loadModel`) si la cible dispose d'un GPU.
|
section 2).
|
||||||
|
- Latence `node-llama-cpp` mesurée en CPU pur (pas de configuration GPU dans
|
||||||
|
ce PoC) — un déploiement réel voudrait évaluer l'offload GPU (`gpuLayers`
|
||||||
|
dans les options `loadModel`) si la cible dispose d'un GPU. Ollama, lui,
|
||||||
|
détecte et utilise l'accélération matérielle disponible automatiquement —
|
||||||
|
une différence qui peut à elle seule expliquer un écart de latence entre
|
||||||
|
les deux moteurs LLM, indépendamment du modèle choisi.
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "PoC autonome : trois moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, classifieur node-nlp frais, pipeline hybride NLP+LLM), benchmarkés sur le même jeu de phrases.",
|
"description": "PoC autonome : quatre moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, le même LLM via Ollama, classifieur node-nlp frais, pipeline hybride NLP+LLM), benchmarkés sur le même jeu de phrases.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bench": "tsx src/llm-tech-step-poc.ts",
|
"bench": "tsx src/llm-tech-step-poc.ts",
|
||||||
|
"bench:ollama": "tsx src/ollama-tech-step-poc.ts",
|
||||||
"bench:nlp": "tsx src/nlp-tech-step-poc.ts",
|
"bench:nlp": "tsx src/nlp-tech-step-poc.ts",
|
||||||
"bench:hybrid": "tsx src/hybrid-tech-step-poc.ts",
|
"bench:hybrid": "tsx src/hybrid-tech-step-poc.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-llama-cpp": "^3.20.0",
|
"node-llama-cpp": "^3.20.0",
|
||||||
"node-nlp": "4.27.0"
|
"node-nlp": "4.27.0",
|
||||||
|
"ollama": "^0.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ importers:
|
||||||
node-nlp:
|
node-nlp:
|
||||||
specifier: 4.27.0
|
specifier: 4.27.0
|
||||||
version: 4.27.0
|
version: 4.27.0
|
||||||
|
ollama:
|
||||||
|
specifier: ^0.6.3
|
||||||
|
version: 0.6.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.9.0
|
specifier: ^22.9.0
|
||||||
|
|
@ -870,6 +873,9 @@ packages:
|
||||||
node-nlp@4.27.0:
|
node-nlp@4.27.0:
|
||||||
resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz}
|
resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz}
|
||||||
|
|
||||||
|
ollama@0.6.3:
|
||||||
|
resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==, tarball: https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz}
|
||||||
|
|
||||||
onetime@7.0.0:
|
onetime@7.0.0:
|
||||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, tarball: https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz}
|
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, tarball: https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1031,6 +1037,9 @@ packages:
|
||||||
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==, tarball: https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz}
|
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==, tarball: https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
|
whatwg-fetch@3.6.20:
|
||||||
|
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==, tarball: https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1952,6 +1961,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ollama@0.6.3:
|
||||||
|
dependencies:
|
||||||
|
whatwg-fetch: 3.6.20
|
||||||
|
|
||||||
onetime@7.0.0:
|
onetime@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
@ -2110,6 +2123,8 @@ snapshots:
|
||||||
|
|
||||||
validate-npm-package-name@7.0.2: {}
|
validate-npm-package-name@7.0.2: {}
|
||||||
|
|
||||||
|
whatwg-fetch@3.6.20: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,14 @@ interface KitchenActionsGrammarResult {
|
||||||
* du benchmark est justement de voir si un seul prompt, sur un modèle
|
* du benchmark est justement de voir si un seul prompt, sur un modèle
|
||||||
* multilingue, tient la route en français ET en anglais sans bascule
|
* multilingue, tient la route en français ET en anglais sans bascule
|
||||||
* explicite de langue.
|
* explicite de langue.
|
||||||
|
*
|
||||||
|
* Exporté et réutilisé tel quel par `ollama-tech-step-poc.ts` — les deux
|
||||||
|
* moteurs LLM de ce PoC doivent tester exactement la même sémantique/tâche,
|
||||||
|
* seul le mécanisme de contrainte JSON (grammaire GBNF ici, JSON Schema
|
||||||
|
* natif côté Ollama) diffère ; dupliquer ce texte risquerait de faire
|
||||||
|
* dériver les deux prompts sans que ce soit voulu.
|
||||||
*/
|
*/
|
||||||
const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation.
|
export const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation.
|
||||||
|
|
||||||
Action taxonomy (pick exactly one per action):
|
Action taxonomy (pick exactly one per action):
|
||||||
- CUT: knife work — chopping, dicing, mincing, slicing, peeling.
|
- CUT: knife work — chopping, dicing, mincing, slicing, peeling.
|
||||||
|
|
|
||||||
328
experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts
Normal file
328
experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue