Fiabilise le préchargement et le benchmark

This commit is contained in:
Michael committed 2026-07-07 15:25:12 +02:00
1 parent d9169a1d27
commit 0f6abf6dde
7 files changed
+154 -49

No files matched your search

+9 -4
View File
@@ -17,7 +17,7 @@ from typing import AsyncIterator
import httpx
from . import db
from .ollama_client import ollama
from .ollama_client import OllamaError, ollama
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
@@ -155,8 +155,9 @@ async def _task_json(model: str) -> tuple[int, str]:
return 5, "JSON invalide"
ok_nom = "marie" in str(data.get("nom", "")).lower()
ok_ville = "lyon" in str(data.get("ville", "")).lower()
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle")
score = 10 + 5 * ok_nom + 5 * ok_ville
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
return score, detail
async def _task_format(model: str) -> tuple[int, str]:
@@ -190,8 +191,12 @@ async def run_bench(model: str) -> AsyncIterator[dict]:
yield {"type": "task_start", "task": name}
try:
score, detail = await fn(model)
except (httpx.HTTPError, OSError) as exc:
except (OllamaError, httpx.HTTPError, OSError) as exc:
score, detail = 0, f"erreur : {str(exc)[:80]}"
except Exception as exc:
# Une épreuve défaillante ne doit pas couper silencieusement le SSE :
# elle vaut zéro et les autres épreuves continuent.
score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}"
total += score
details.append({"task": name, "score": score, "detail": detail})
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
+1 -1
View File
@@ -125,7 +125,7 @@ class OllamaClient:
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
resp = await client.post(
f"{self.host}/api/generate",
json={"model": model, "keep_alive": keep_alive},
json={"model": model, "keep_alive": keep_alive, "stream": False},
)
resp.raise_for_status()
return resp.json()
+10 -3
View File
@@ -24,9 +24,16 @@ async def scores() -> dict:
@router.post("")
async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream():
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
try:
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = json.dumps(
{"message": f"benchmark interrompu : {str(exc)[:200]}"},
ensure_ascii=False,
)
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse(
event_stream(),
+43 -27
View File
@@ -47,16 +47,24 @@ export interface LoadedModel {
gpu_percent: number;
}
/** Précharge un modèle en VRAM (best-effort, ne lève jamais). */
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
async function apiError(res: Response, fallback: string): Promise<Error> {
try {
await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
const payload = await res.json();
return new Error(payload?.detail ?? payload?.error ?? fallback);
} catch {
/* préchargement best-effort */
return new Error(fallback);
}
}
/** Précharge un modèle en mémoire et remonte toute erreur à l'interface. */
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
const res = await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
if (!res.ok) {
throw await apiError(res, `préchargement refusé (${res.status})`);
}
}
@@ -175,39 +183,47 @@ export async function runBench(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.body) return null;
if (!res.ok) {
throw await apiError(res, `benchmark refusé (${res.status})`);
}
if (!res.body) throw new Error("le serveur n'a pas renvoyé de progression");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let final: BenchResult | null = null;
const dispatch = (block: string) => {
let event = "message";
const dataLines: string[] = [];
for (const line of block.replace(/\r\n/g, "\n").split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
const payload = JSON.parse(dataLines.join("\n"));
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "error")
throw new Error(payload.message ?? "le benchmark a échoué");
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
let event = "";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (!data) continue;
try {
const payload = JSON.parse(data);
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
} catch {
/* bloc partiel */
}
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
return final;
}
+16 -3
View File
@@ -4,7 +4,14 @@ import { ChevronDown } from "./Icon";
/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */
export function ModelSelector() {
const { models, selectedModel, setSelectedModel, loadedModels } = useStore();
const {
models,
selectedModel,
setSelectedModel,
loadedModels,
warmingModel,
warmError,
} = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -30,14 +37,20 @@ export function ModelSelector() {
>
<span
className={`h-2.5 w-2.5 border-2 border-line ${
selectedLoaded
warmingModel === selectedModel
? "bg-accent animate-pulse"
: selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
selectedLoaded
warmError
? warmError
: warmingModel === selectedModel
? "préchargement en cours…"
: selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
+42
View File
@@ -23,6 +23,9 @@ export function SettingsView() {
selectedModel,
setSelectedModel,
refreshModels,
loadedModels,
warmingModel,
warmError,
status,
config,
availableTools,
@@ -153,6 +156,8 @@ export function SettingsView() {
)}
{models.map((m) => {
const on = m.name === selectedModel;
const loaded = loadedModels.find((item) => item.name === m.name);
const warming = warmingModel === m.name;
return (
<div
key={m.name}
@@ -187,6 +192,28 @@ export function SettingsView() {
.filter(Boolean)
.join(" · ")}
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px]">
<span
className={`h-2 w-2 border border-line ${
warming
? "bg-accent animate-pulse"
: loaded?.on_gpu
? "bg-ok"
: loaded
? "bg-warn"
: "bg-card"
}`}
/>
<span className={on ? "text-on-dark-2" : "text-muted-2"}>
{warming
? "Préchargement en cours…"
: loaded?.on_gpu
? "Chargé sur GPU"
: loaded
? `Chargé · ${loaded.gpu_percent}% GPU`
: "Non chargé"}
</span>
</div>
</div>
</button>
<button
@@ -205,6 +232,11 @@ export function SettingsView() {
{deleteError}
</div>
)}
{warmError && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Préchargement : {warmError}
</div>
)}
</div>
<div className="my-4 h-[3px] bg-line" />
@@ -461,6 +493,7 @@ function BenchCard() {
const selectedModel = useStore((s) => s.selectedModel);
const [scores, setScores] = useState<Record<string, BenchResult>>({});
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<
{ task: string; score: number | null; detail?: string }[]
>([]);
@@ -472,6 +505,7 @@ function BenchCard() {
const launch = async () => {
if (!selectedModel || running) return;
setRunning(true);
setError(null);
setProgress([]);
try {
const result = await runBench(selectedModel, (task, score, detail) => {
@@ -481,6 +515,8 @@ function BenchCard() {
});
});
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
} catch (err) {
setError(err instanceof Error ? err.message : "benchmark impossible");
} finally {
setRunning(false);
}
@@ -524,6 +560,12 @@ function BenchCard() {
</div>
)}
{error && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Échec du test : {error}
</div>
)}
{!running && current && (
<div className="border-2 border-line bg-base px-3 py-2">
<div className="mb-1 flex items-center justify-between">
+33 -11
View File
@@ -30,6 +30,8 @@ interface LokiState {
status: OllamaStatus | null;
systemStats: SystemStats | null;
loadedModels: LoadedModel[];
warmingModel: string | null;
warmError: string | null;
models: OllamaModel[];
selectedModel: string;
loadingModels: boolean;
@@ -81,6 +83,8 @@ export const useStore = create<LokiState>((set, get) => ({
status: null,
systemStats: null,
loadedModels: [],
warmingModel: null,
warmError: null,
models: [],
selectedModel: "",
loadingModels: false,
@@ -129,10 +133,11 @@ export const useStore = create<LokiState>((set, get) => ({
},
refreshConfig: async () => {
const { config, available_tools } = await getConfig(
get().selectedModel || undefined
);
set({ config, availableTools: available_tools });
const model = get().selectedModel;
const { config, available_tools } = await getConfig(model || undefined);
if (get().selectedModel === model) {
set({ config, availableTools: available_tools });
}
},
updateConfig: async (patch) => {
@@ -146,11 +151,26 @@ export const useStore = create<LokiState>((set, get) => ({
},
setSelectedModel: (name) => {
set({ selectedModel: name });
void get().refreshConfig();
// Préchargement : le modèle est chargé en VRAM dès la sélection.
const ka = get().config?.keep_alive ?? "30m";
void warmModel(name, ka).then(() => get().refreshLoadedModels());
set({ selectedModel: name, warmingModel: name || null, warmError: null });
void (async () => {
try {
await get().refreshConfig();
if (!name || get().selectedModel !== name) return;
// La configuration est propre au modèle : on attend son chargement avant
// d'utiliser keep_alive, sinon la valeur du modèle précédent est envoyée.
const ka = get().config?.keep_alive ?? "30m";
await warmModel(name, ka);
await get().refreshLoadedModels();
} catch (err) {
if (get().selectedModel === name) {
set({
warmError: err instanceof Error ? err.message : "préchargement impossible",
});
}
} finally {
if (get().warmingModel === name) set({ warmingModel: null });
}
})();
},
refreshFiles: async () => {
@@ -193,8 +213,10 @@ export const useStore = create<LokiState>((set, get) => ({
: installed.has(def)
? def
: models[0]?.name ?? "";
set({ models, selectedModel });
await get().refreshConfig();
set({ models });
// Passe par l'action de sélection afin de précharger aussi le modèle choisi
// automatiquement au démarrage (défaut ou premier modèle installé).
get().setSelectedModel(selectedModel);
} finally {
set({ loadingModels: false });
}