mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Fiabilise le préchargement et le benchmark
This commit is contained in:
1 parent
d9169a1d27
commit
0f6abf6dde
7 files changed
+154
-49
No files matched your search
@@ -17,7 +17,7 @@ from typing import AsyncIterator
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .ollama_client import ollama
|
from .ollama_client import OllamaError, ollama
|
||||||
|
|
||||||
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
|
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"
|
return 5, "JSON invalide"
|
||||||
ok_nom = "marie" in str(data.get("nom", "")).lower()
|
ok_nom = "marie" in str(data.get("nom", "")).lower()
|
||||||
ok_ville = "lyon" in str(data.get("ville", "")).lower()
|
ok_ville = "lyon" in str(data.get("ville", "")).lower()
|
||||||
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \
|
score = 10 + 5 * ok_nom + 5 * ok_ville
|
||||||
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle")
|
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
|
||||||
|
return score, detail
|
||||||
|
|
||||||
|
|
||||||
async def _task_format(model: str) -> tuple[int, str]:
|
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}
|
yield {"type": "task_start", "task": name}
|
||||||
try:
|
try:
|
||||||
score, detail = await fn(model)
|
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]}"
|
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
|
total += score
|
||||||
details.append({"task": name, "score": score, "detail": detail})
|
details.append({"task": name, "score": score, "detail": detail})
|
||||||
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
|
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class OllamaClient:
|
|||||||
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{self.host}/api/generate",
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|||||||
@@ -24,9 +24,16 @@ async def scores() -> dict:
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def run(req: BenchRequest) -> StreamingResponse:
|
async def run(req: BenchRequest) -> StreamingResponse:
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
async for ev in bench.run_bench(req.model):
|
try:
|
||||||
etype = ev.pop("type")
|
async for ev in bench.run_bench(req.model):
|
||||||
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
|
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(
|
return StreamingResponse(
|
||||||
event_stream(),
|
event_stream(),
|
||||||
|
|||||||
+43
-27
@@ -47,16 +47,24 @@ export interface LoadedModel {
|
|||||||
gpu_percent: number;
|
gpu_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Précharge un modèle en VRAM (best-effort, ne lève jamais). */
|
async function apiError(res: Response, fallback: string): Promise<Error> {
|
||||||
export async function warmModel(name: string, keepAlive = "30m"): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/models/warm", {
|
const payload = await res.json();
|
||||||
method: "POST",
|
return new Error(payload?.detail ?? payload?.error ?? fallback);
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name, keep_alive: keepAlive }),
|
|
||||||
});
|
|
||||||
} catch {
|
} 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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model }),
|
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 reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let final: BenchResult | null = null;
|
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) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
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");
|
const events = buffer.split("\n\n");
|
||||||
buffer = events.pop() ?? "";
|
buffer = events.pop() ?? "";
|
||||||
for (const block of events) {
|
for (const block of events) {
|
||||||
let event = "";
|
if (block.trim()) dispatch(block);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.trim()) dispatch(buffer);
|
||||||
|
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
|
||||||
return final;
|
return final;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { ChevronDown } from "./Icon";
|
|||||||
|
|
||||||
/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */
|
/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */
|
||||||
export function ModelSelector() {
|
export function ModelSelector() {
|
||||||
const { models, selectedModel, setSelectedModel, loadedModels } = useStore();
|
const {
|
||||||
|
models,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
loadedModels,
|
||||||
|
warmingModel,
|
||||||
|
warmError,
|
||||||
|
} = useStore();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -30,14 +37,20 @@ export function ModelSelector() {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-2.5 w-2.5 border-2 border-line ${
|
className={`h-2.5 w-2.5 border-2 border-line ${
|
||||||
selectedLoaded
|
warmingModel === selectedModel
|
||||||
|
? "bg-accent animate-pulse"
|
||||||
|
: selectedLoaded
|
||||||
? selectedLoaded.on_gpu
|
? selectedLoaded.on_gpu
|
||||||
? "bg-ok"
|
? "bg-ok"
|
||||||
: "bg-warn"
|
: "bg-warn"
|
||||||
: "bg-white"
|
: "bg-white"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
selectedLoaded
|
warmError
|
||||||
|
? warmError
|
||||||
|
: warmingModel === selectedModel
|
||||||
|
? "préchargement en cours…"
|
||||||
|
: selectedLoaded
|
||||||
? selectedLoaded.on_gpu
|
? selectedLoaded.on_gpu
|
||||||
? "chargé sur GPU"
|
? "chargé sur GPU"
|
||||||
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
|
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function SettingsView() {
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
setSelectedModel,
|
setSelectedModel,
|
||||||
refreshModels,
|
refreshModels,
|
||||||
|
loadedModels,
|
||||||
|
warmingModel,
|
||||||
|
warmError,
|
||||||
status,
|
status,
|
||||||
config,
|
config,
|
||||||
availableTools,
|
availableTools,
|
||||||
@@ -153,6 +156,8 @@ export function SettingsView() {
|
|||||||
)}
|
)}
|
||||||
{models.map((m) => {
|
{models.map((m) => {
|
||||||
const on = m.name === selectedModel;
|
const on = m.name === selectedModel;
|
||||||
|
const loaded = loadedModels.find((item) => item.name === m.name);
|
||||||
|
const warming = warmingModel === m.name;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.name}
|
key={m.name}
|
||||||
@@ -187,6 +192,28 @@ export function SettingsView() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -205,6 +232,11 @@ export function SettingsView() {
|
|||||||
{deleteError}
|
{deleteError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{warmError && (
|
||||||
|
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
|
||||||
|
Préchargement : {warmError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-4 h-[3px] bg-line" />
|
<div className="my-4 h-[3px] bg-line" />
|
||||||
@@ -461,6 +493,7 @@ function BenchCard() {
|
|||||||
const selectedModel = useStore((s) => s.selectedModel);
|
const selectedModel = useStore((s) => s.selectedModel);
|
||||||
const [scores, setScores] = useState<Record<string, BenchResult>>({});
|
const [scores, setScores] = useState<Record<string, BenchResult>>({});
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState<
|
const [progress, setProgress] = useState<
|
||||||
{ task: string; score: number | null; detail?: string }[]
|
{ task: string; score: number | null; detail?: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -472,6 +505,7 @@ function BenchCard() {
|
|||||||
const launch = async () => {
|
const launch = async () => {
|
||||||
if (!selectedModel || running) return;
|
if (!selectedModel || running) return;
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
setProgress([]);
|
setProgress([]);
|
||||||
try {
|
try {
|
||||||
const result = await runBench(selectedModel, (task, score, detail) => {
|
const result = await runBench(selectedModel, (task, score, detail) => {
|
||||||
@@ -481,6 +515,8 @@ function BenchCard() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
|
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "benchmark impossible");
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
@@ -524,6 +560,12 @@ function BenchCard() {
|
|||||||
</div>
|
</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 && (
|
{!running && current && (
|
||||||
<div className="border-2 border-line bg-base px-3 py-2">
|
<div className="border-2 border-line bg-base px-3 py-2">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface LokiState {
|
|||||||
status: OllamaStatus | null;
|
status: OllamaStatus | null;
|
||||||
systemStats: SystemStats | null;
|
systemStats: SystemStats | null;
|
||||||
loadedModels: LoadedModel[];
|
loadedModels: LoadedModel[];
|
||||||
|
warmingModel: string | null;
|
||||||
|
warmError: string | null;
|
||||||
models: OllamaModel[];
|
models: OllamaModel[];
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
loadingModels: boolean;
|
loadingModels: boolean;
|
||||||
@@ -81,6 +83,8 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
status: null,
|
status: null,
|
||||||
systemStats: null,
|
systemStats: null,
|
||||||
loadedModels: [],
|
loadedModels: [],
|
||||||
|
warmingModel: null,
|
||||||
|
warmError: null,
|
||||||
models: [],
|
models: [],
|
||||||
selectedModel: "",
|
selectedModel: "",
|
||||||
loadingModels: false,
|
loadingModels: false,
|
||||||
@@ -129,10 +133,11 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshConfig: async () => {
|
refreshConfig: async () => {
|
||||||
const { config, available_tools } = await getConfig(
|
const model = get().selectedModel;
|
||||||
get().selectedModel || undefined
|
const { config, available_tools } = await getConfig(model || undefined);
|
||||||
);
|
if (get().selectedModel === model) {
|
||||||
set({ config, availableTools: available_tools });
|
set({ config, availableTools: available_tools });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateConfig: async (patch) => {
|
updateConfig: async (patch) => {
|
||||||
@@ -146,11 +151,26 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSelectedModel: (name) => {
|
setSelectedModel: (name) => {
|
||||||
set({ selectedModel: name });
|
set({ selectedModel: name, warmingModel: name || null, warmError: null });
|
||||||
void get().refreshConfig();
|
void (async () => {
|
||||||
// Préchargement : le modèle est chargé en VRAM dès la sélection.
|
try {
|
||||||
const ka = get().config?.keep_alive ?? "30m";
|
await get().refreshConfig();
|
||||||
void warmModel(name, ka).then(() => get().refreshLoadedModels());
|
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 () => {
|
refreshFiles: async () => {
|
||||||
@@ -193,8 +213,10 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
: installed.has(def)
|
: installed.has(def)
|
||||||
? def
|
? def
|
||||||
: models[0]?.name ?? "";
|
: models[0]?.name ?? "";
|
||||||
set({ models, selectedModel });
|
set({ models });
|
||||||
await get().refreshConfig();
|
// 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 {
|
} finally {
|
||||||
set({ loadingModels: false });
|
set({ loadingModels: false });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user