From d9169a1d2707f4df366311a35d08bb79f427595a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 12:07:08 +0000 Subject: [PATCH] =?UTF-8?q?Pr=C3=A9chargement=20des=20mod=C3=A8les=20:=20w?= =?UTF-8?q?arm=20+=20keep=5Falive=20+=20indicateur=20d'=C3=A9tat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Évite le rechargement lent quand Ollama a déchargé le modèle de la VRAM : - ollama_client.warm() : précharge un modèle (/api/generate sans prompt) ; chat() accepte keep_alive (durée de rétention en VRAM). - agent.run_agent transmet keep_alive ; route chat le passe depuis la config. - config : champ keep_alive (défaut 30m) par profil de modèle. - routes/models : POST /api/models/warm, GET /api/models/loaded (placement GPU/CPU via /api/ps). - main : préchargement du modèle par défaut au démarrage (arrière-plan, best-effort — n'empêche pas le démarrage si Ollama est absent). - Frontend : préchargement automatique à la sélection d'un modèle, poll des modèles chargés (8s), pastille verte (GPU) / orange (CPU) / blanche (à charger) dans le sélecteur, réglage 'Maintien en VRAM' dans Configuration. Tests : warm/loaded routes, keep_alive transmis, démarrage résilient sans Ollama, build front. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N --- README.md | 14 +++++++++ backend/app/agent.py | 4 ++- backend/app/agent_config.py | 4 +++ backend/app/main.py | 26 +++++++++++++++- backend/app/ollama_client.py | 17 +++++++++++ backend/app/routes/chat.py | 1 + backend/app/routes/models.py | 36 +++++++++++++++++++++++ frontend/src/App.tsx | 17 ++++++++--- frontend/src/api/client.ts | 29 ++++++++++++++++++ frontend/src/components/ModelSelector.tsx | 31 +++++++++++++++++-- frontend/src/panels/SettingsView.tsx | 21 +++++++++++++ frontend/src/store/useStore.ts | 13 ++++++++ 12 files changed, 205 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8c0d3fe..25a26ce 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,20 @@ npm run dev # http://localhost:5173 | `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | | `run_shell` | Exécuter une commande **(sensible)** | désactivé | +## Préchargement des modèles (réponses instantanées) + +Ollama décharge un modèle de la VRAM après quelques minutes d'inactivité : le +message suivant paie alors un rechargement complet (lent). Loki évite ça : + +- **Préchargement à la sélection** : choisir un modèle le charge immédiatement + en VRAM (`/api/models/warm`). +- **Maintien au chaud** : chaque requête envoie un `keep_alive` (défaut 30 min, + réglable dans Configuration → Intelligence : de « décharger aussitôt » à + « toujours »). +- **Préchargement au démarrage** du modèle par défaut (en arrière-plan). +- **Indicateur d'état** : la pastille du sélecteur de modèle est verte quand le + modèle est chargé sur GPU, orange sur CPU, blanche s'il reste à charger. + ## Intelligence augmentée - **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes diff --git a/backend/app/agent.py b/backend/app/agent.py index b3e1936..a602336 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -80,6 +80,7 @@ async def run_agent( enabled_tools: list[str] | None = None, confirm_shell: bool = True, think: bool = True, + keep_alive: str | None = None, ) -> AsyncIterator[dict]: # enabled_tools=None -> tous les outils ; liste vide -> aucun outil. if enabled_tools is None: @@ -127,6 +128,7 @@ async def run_agent( tools=active_tools, options=request_options, think=request_think, + keep_alive=keep_alive, stream=True, ): msg = chunk.get("message", {}) @@ -314,7 +316,7 @@ async def run_agent( final_chunk = "" async for chunk in ollama.chat( model, convo, options=request_options, - think=request_think, stream=True + think=request_think, keep_alive=keep_alive, stream=True ): tok = chunk.get("message", {}).get("content", "") if tok: diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index e81dc85..8b2ffd4 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -64,6 +64,7 @@ PROFILE_FIELDS = { "self_review", "rag_enabled", "embed_model", + "keep_alive", *GENERATION_FIELDS, } @@ -106,6 +107,9 @@ DEFAULT_CONFIG: dict = { # Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding. "rag_enabled": True, "embed_model": "auto", + # Durée de maintien du modèle en VRAM (préchargement). "0" = décharge + # aussitôt, "-1" = jamais, "30m" = 30 minutes. + "keep_alive": "30m", } diff --git a/backend/app/main.py b/backend/app/main.py index d4aa9a5..d02eec2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,17 +12,41 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from . import coder, db, rag +from . import agent_config, coder, db, rag from .config import settings from .routes import benchmark, chat, config, files, models, sessions, shell, system +async def _warm_default_model() -> None: + """Précharge le modèle par défaut en VRAM au démarrage (best-effort).""" + import asyncio + import logging + + from .ollama_client import OllamaError, ollama + + await asyncio.sleep(2) # laisse le service démarrer + try: + cfg = agent_config.get_config(settings.default_model) + await ollama.warm(settings.default_model, cfg.get("keep_alive", "30m")) + logging.getLogger(__name__).info( + "Modèle %s préchargé en VRAM", settings.default_model + ) + except (OllamaError, OSError, Exception) as exc: # best-effort + logging.getLogger(__name__).info( + "Préchargement au démarrage ignoré : %s", exc + ) + + @asynccontextmanager async def lifespan(_: FastAPI): + import asyncio + db.init_db() rag.init_table() # Workspace en dépôt git : requis pour les commits du moteur code (Aider). coder.ensure_git(settings.workspace_dir) + # Préchargement du modèle par défaut, sans bloquer le démarrage. + asyncio.create_task(_warm_default_model()) yield diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index afbea21..d9eb45f 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -117,6 +117,19 @@ class OllamaClient: raise OllamaError(str(chunk["error"])) yield chunk + async def warm(self, model: str, keep_alive: str = "30m") -> dict: + """Précharge un modèle en VRAM sans générer (/api/generate sans prompt). + + Le paramètre keep_alive fixe la durée de rétention en mémoire. + """ + 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}, + ) + resp.raise_for_status() + return resp.json() + async def chat( self, model: str, @@ -125,6 +138,7 @@ class OllamaClient: tools: list[dict] | None = None, options: dict | None = None, think: bool | None = None, + keep_alive: str | None = None, stream: bool = True, ) -> AsyncIterator[dict]: """Conversation avec le modèle, en streaming token par token.""" @@ -136,6 +150,9 @@ class OllamaClient: # think=False désactive le raisonnement des modèles « thinking ». if think is not None: payload["think"] = think + # keep_alive : durée de maintien du modèle en VRAM après la réponse. + if keep_alive is not None: + payload["keep_alive"] = keep_alive async with httpx.AsyncClient( timeout=_STREAM_TIMEOUT, follow_redirects=True diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 4102889..83e3ff2 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -245,6 +245,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: enabled_tools=agent_config.enabled_tool_names(cfg), confirm_shell=cfg.get("confirm_shell", True), think=cfg.get("think", True), + keep_alive=cfg.get("keep_alive", "30m"), ): await queue.put(event) except Exception as exc: diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py index 486e013..f31ecbe 100644 --- a/backend/app/routes/models.py +++ b/backend/app/routes/models.py @@ -58,6 +58,42 @@ async def list_models() -> dict: return {"models": models, "default": settings.default_model} +class WarmRequest(BaseModel): + name: str + keep_alive: str = "30m" + + +@router.post("/models/warm") +async def warm_model(req: WarmRequest) -> dict: + """Précharge un modèle en VRAM (préchargement).""" + if not req.name.strip(): + raise HTTPException(400, "nom de modèle vide") + try: + await ollama.warm(req.name.strip(), req.keep_alive) + except (httpx.HTTPError, OSError) as exc: + raise HTTPException(502, f"préchargement impossible : {exc}") from exc + return {"warmed": req.name.strip()} + + +@router.get("/models/loaded") +async def loaded_models() -> dict: + """Modèles actuellement chargés en mémoire + placement GPU/CPU (/api/ps).""" + try: + loaded = await ollama.ps() + except (httpx.HTTPError, OSError): + return {"loaded": []} + result = [] + for m in loaded: + size = m.get("size", 0) or 0 + vram = m.get("size_vram", 0) or 0 + result.append({ + "name": m.get("name") or m.get("model"), + "on_gpu": bool(size and vram >= size * 0.99), + "gpu_percent": int(vram / size * 100) if size else 0, + }) + return {"loaded": result} + + class PullRequest(BaseModel): name: str diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c4a233..4833477 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,22 +10,31 @@ import { useStore } from "./store/useStore"; export default function App() { const [view, setView] = useState("chat"); - const { refreshStatus, refreshSystemStats, refreshModels, refreshConfig } = - useStore(); + const { + refreshStatus, + refreshSystemStats, + refreshModels, + refreshConfig, + refreshLoadedModels, + } = useStore(); - // Au démarrage : statut Ollama + modèles + config. Poll du statut et des stats système. + // Au démarrage : statut Ollama + modèles + config. Poll du statut, des stats + // système et des modèles chargés en VRAM (indicateur de préchargement). useEffect(() => { refreshStatus(); refreshSystemStats(); refreshModels(); refreshConfig(); + refreshLoadedModels(); const statusId = setInterval(refreshStatus, 10000); const statsId = setInterval(refreshSystemStats, 2000); + const loadedId = setInterval(refreshLoadedModels, 8000); return () => { clearInterval(statusId); clearInterval(statsId); + clearInterval(loadedId); }; - }, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig]); + }, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig, refreshLoadedModels]); return (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 04bd897..79578bf 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -41,6 +41,34 @@ export async function getSystemStats(): Promise { return res.json(); } +export interface LoadedModel { + name: string; + on_gpu: boolean; + 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 { + try { + await fetch("/api/models/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, keep_alive: keepAlive }), + }); + } catch { + /* préchargement best-effort */ + } +} + +export async function getLoadedModels(): Promise { + try { + const res = await fetch("/api/models/loaded"); + return (await res.json()).loaded; + } catch { + return []; + } +} + export async function getModels(): Promise<{ models: OllamaModel[]; default: string; @@ -116,6 +144,7 @@ export interface AgentConfig { self_review: boolean; rag_enabled: boolean; embed_model: string; + keep_alive: string; } // ── Benchmark de modèles ───────────────────────────────────────────────── diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 7420545..2ce11a2 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -4,10 +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 } = useStore(); + const { models, selectedModel, setSelectedModel, loadedModels } = useStore(); const [open, setOpen] = useState(false); const ref = useRef(null); + const loadedInfo = (name: string) => + loadedModels.find((m) => m.name === name); + const selectedLoaded = loadedInfo(selectedModel); + useEffect(() => { const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); @@ -24,7 +28,22 @@ export function ModelSelector() { title={selectedModel || undefined} style={{ borderRadius: 7 }} > - + {selectedModel || "—"} @@ -55,6 +74,14 @@ export function ModelSelector() { {m.name} + {loadedInfo(m.name) && ( + + )} {m.size_go > 0 && ( {m.size_go} Go )} diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index ee6d12c..db55f5a 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -405,6 +405,27 @@ export function SettingsView() { />
))} +
+ + + Maintien en VRAM (préchargement) + + + Garde le modèle chargé pour des réponses instantanées + + + +
diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 89d7b4f..07d660c 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -7,6 +7,8 @@ import { getSession, getStatus, getSystemStats, + getLoadedModels, + warmModel, fileContent, listFiles, listSessions, @@ -19,6 +21,7 @@ import { type OllamaModel, type OllamaStatus, type Session, + type LoadedModel, type SystemStats, type ToolCall, } from "../api/client"; @@ -26,6 +29,7 @@ import { interface LokiState { status: OllamaStatus | null; systemStats: SystemStats | null; + loadedModels: LoadedModel[]; models: OllamaModel[]; selectedModel: string; loadingModels: boolean; @@ -59,6 +63,7 @@ interface LokiState { setSelectedModel: (name: string) => void; refreshStatus: () => Promise; refreshSystemStats: () => Promise; + refreshLoadedModels: () => Promise; refreshModels: () => Promise; refreshFiles: () => Promise; @@ -75,6 +80,7 @@ let activeStreamController: AbortController | null = null; export const useStore = create((set, get) => ({ status: null, systemStats: null, + loadedModels: [], models: [], selectedModel: "", loadingModels: false, @@ -142,6 +148,9 @@ export const useStore = create((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()); }, refreshFiles: async () => { @@ -169,6 +178,10 @@ export const useStore = create((set, get) => ({ } }, + refreshLoadedModels: async () => { + set({ loadedModels: await getLoadedModels() }); + }, + refreshModels: async () => { set({ loadingModels: true }); try {