Préchargement des modèles : warm + keep_alive + indicateur d'état

É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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-07-07 12:07:08 +00:00
1 parent d9be1c4dda
commit d9169a1d27
12 files changed
+205 -8

No files matched your search

+14
View File
@@ -115,6 +115,20 @@ npm run dev # http://localhost:5173
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | | `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
| `run_shell` | Exécuter une commande **(sensible)** | 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 ## Intelligence augmentée
- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes - **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes
+3 -1
View File
@@ -80,6 +80,7 @@ async def run_agent(
enabled_tools: list[str] | None = None, enabled_tools: list[str] | None = None,
confirm_shell: bool = True, confirm_shell: bool = True,
think: bool = True, think: bool = True,
keep_alive: str | None = None,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil. # enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None: if enabled_tools is None:
@@ -127,6 +128,7 @@ async def run_agent(
tools=active_tools, tools=active_tools,
options=request_options, options=request_options,
think=request_think, think=request_think,
keep_alive=keep_alive,
stream=True, stream=True,
): ):
msg = chunk.get("message", {}) msg = chunk.get("message", {})
@@ -314,7 +316,7 @@ async def run_agent(
final_chunk = "" final_chunk = ""
async for chunk in ollama.chat( async for chunk in ollama.chat(
model, convo, options=request_options, 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", "") tok = chunk.get("message", {}).get("content", "")
if tok: if tok:
+4
View File
@@ -64,6 +64,7 @@ PROFILE_FIELDS = {
"self_review", "self_review",
"rag_enabled", "rag_enabled",
"embed_model", "embed_model",
"keep_alive",
*GENERATION_FIELDS, *GENERATION_FIELDS,
} }
@@ -106,6 +107,9 @@ DEFAULT_CONFIG: dict = {
# Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding. # Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding.
"rag_enabled": True, "rag_enabled": True,
"embed_model": "auto", "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",
} }
+25 -1
View File
@@ -12,17 +12,41 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import coder, db, rag from . import agent_config, coder, db, rag
from .config import settings from .config import settings
from .routes import benchmark, chat, config, files, models, sessions, shell, system 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 @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
import asyncio
db.init_db() db.init_db()
rag.init_table() rag.init_table()
# Workspace en dépôt git : requis pour les commits du moteur code (Aider). # Workspace en dépôt git : requis pour les commits du moteur code (Aider).
coder.ensure_git(settings.workspace_dir) 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 yield
+17
View File
@@ -117,6 +117,19 @@ class OllamaClient:
raise OllamaError(str(chunk["error"])) raise OllamaError(str(chunk["error"]))
yield chunk 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( async def chat(
self, self,
model: str, model: str,
@@ -125,6 +138,7 @@ class OllamaClient:
tools: list[dict] | None = None, tools: list[dict] | None = None,
options: dict | None = None, options: dict | None = None,
think: bool | None = None, think: bool | None = None,
keep_alive: str | None = None,
stream: bool = True, stream: bool = True,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""Conversation avec le modèle, en streaming token par token.""" """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 ». # think=False désactive le raisonnement des modèles « thinking ».
if think is not None: if think is not None:
payload["think"] = think 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( async with httpx.AsyncClient(
timeout=_STREAM_TIMEOUT, follow_redirects=True timeout=_STREAM_TIMEOUT, follow_redirects=True
+1
View File
@@ -245,6 +245,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
enabled_tools=agent_config.enabled_tool_names(cfg), enabled_tools=agent_config.enabled_tool_names(cfg),
confirm_shell=cfg.get("confirm_shell", True), confirm_shell=cfg.get("confirm_shell", True),
think=cfg.get("think", True), think=cfg.get("think", True),
keep_alive=cfg.get("keep_alive", "30m"),
): ):
await queue.put(event) await queue.put(event)
except Exception as exc: except Exception as exc:
+36
View File
@@ -58,6 +58,42 @@ async def list_models() -> dict:
return {"models": models, "default": settings.default_model} 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): class PullRequest(BaseModel):
name: str name: str
+13 -4
View File
@@ -10,22 +10,31 @@ import { useStore } from "./store/useStore";
export default function App() { export default function App() {
const [view, setView] = useState<View>("chat"); const [view, setView] = useState<View>("chat");
const { refreshStatus, refreshSystemStats, refreshModels, refreshConfig } = const {
useStore(); 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(() => { useEffect(() => {
refreshStatus(); refreshStatus();
refreshSystemStats(); refreshSystemStats();
refreshModels(); refreshModels();
refreshConfig(); refreshConfig();
refreshLoadedModels();
const statusId = setInterval(refreshStatus, 10000); const statusId = setInterval(refreshStatus, 10000);
const statsId = setInterval(refreshSystemStats, 2000); const statsId = setInterval(refreshSystemStats, 2000);
const loadedId = setInterval(refreshLoadedModels, 8000);
return () => { return () => {
clearInterval(statusId); clearInterval(statusId);
clearInterval(statsId); clearInterval(statsId);
clearInterval(loadedId);
}; };
}, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig]); }, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig, refreshLoadedModels]);
return ( return (
<div className="flex h-full flex-col bg-base text-ink"> <div className="flex h-full flex-col bg-base text-ink">
+29
View File
@@ -41,6 +41,34 @@ export async function getSystemStats(): Promise<SystemStats> {
return res.json(); 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<void> {
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<LoadedModel[]> {
try {
const res = await fetch("/api/models/loaded");
return (await res.json()).loaded;
} catch {
return [];
}
}
export async function getModels(): Promise<{ export async function getModels(): Promise<{
models: OllamaModel[]; models: OllamaModel[];
default: string; default: string;
@@ -116,6 +144,7 @@ export interface AgentConfig {
self_review: boolean; self_review: boolean;
rag_enabled: boolean; rag_enabled: boolean;
embed_model: string; embed_model: string;
keep_alive: string;
} }
// ── Benchmark de modèles ───────────────────────────────────────────────── // ── Benchmark de modèles ─────────────────────────────────────────────────
+29 -2
View File
@@ -4,10 +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 } = useStore(); const { models, selectedModel, setSelectedModel, loadedModels } = useStore();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const loadedInfo = (name: string) =>
loadedModels.find((m) => m.name === name);
const selectedLoaded = loadedInfo(selectedModel);
useEffect(() => { useEffect(() => {
const onClick = (e: MouseEvent) => { const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
@@ -24,7 +28,22 @@ export function ModelSelector() {
title={selectedModel || undefined} title={selectedModel || undefined}
style={{ borderRadius: 7 }} style={{ borderRadius: 7 }}
> >
<span className="h-2.5 w-2.5 border-2 border-line bg-white" /> <span
className={`h-2.5 w-2.5 border-2 border-line ${
selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
: "à charger"
}
/>
<span className="min-w-0 truncate text-[14px] leading-none">{selectedModel || "—"}</span> <span className="min-w-0 truncate text-[14px] leading-none">{selectedModel || "—"}</span>
<ChevronDown size={12} className="flex-none" /> <ChevronDown size={12} className="flex-none" />
</button> </button>
@@ -55,6 +74,14 @@ export function ModelSelector() {
<span className="min-w-0 flex-1 truncate text-xs text-ink-2" title={m.name}> <span className="min-w-0 flex-1 truncate text-xs text-ink-2" title={m.name}>
{m.name} {m.name}
</span> </span>
{loadedInfo(m.name) && (
<span
className={`h-1.5 w-1.5 border border-line ${
loadedInfo(m.name)!.on_gpu ? "bg-ok" : "bg-warn"
}`}
title={loadedInfo(m.name)!.on_gpu ? "chargé GPU" : "chargé CPU"}
/>
)}
{m.size_go > 0 && ( {m.size_go > 0 && (
<span className="text-[10px] text-muted-2">{m.size_go} Go</span> <span className="text-[10px] text-muted-2">{m.size_go} Go</span>
)} )}
+21
View File
@@ -405,6 +405,27 @@ export function SettingsView() {
/> />
</div> </div>
))} ))}
<div className="mt-2 flex items-center gap-3 border-t-2 border-line-soft pt-3">
<span className="flex-1">
<span className="block text-[14px] text-ink">
Maintien en VRAM (préchargement)
</span>
<span className="block text-[12px] text-muted-2">
Garde le modèle chargé pour des réponses instantanées
</span>
</span>
<select
value={draft.keep_alive}
onChange={(e) => set("keep_alive", e.target.value)}
className="h-8 border-[3px] border-line bg-card px-2 text-[13px] text-ink"
>
<option value="0">Décharger aussitôt</option>
<option value="5m">5 min</option>
<option value="30m">30 min</option>
<option value="2h">2 heures</option>
<option value="-1">Toujours</option>
</select>
</div>
</Card> </Card>
<BenchCard /> <BenchCard />
+13
View File
@@ -7,6 +7,8 @@ import {
getSession, getSession,
getStatus, getStatus,
getSystemStats, getSystemStats,
getLoadedModels,
warmModel,
fileContent, fileContent,
listFiles, listFiles,
listSessions, listSessions,
@@ -19,6 +21,7 @@ import {
type OllamaModel, type OllamaModel,
type OllamaStatus, type OllamaStatus,
type Session, type Session,
type LoadedModel,
type SystemStats, type SystemStats,
type ToolCall, type ToolCall,
} from "../api/client"; } from "../api/client";
@@ -26,6 +29,7 @@ import {
interface LokiState { interface LokiState {
status: OllamaStatus | null; status: OllamaStatus | null;
systemStats: SystemStats | null; systemStats: SystemStats | null;
loadedModels: LoadedModel[];
models: OllamaModel[]; models: OllamaModel[];
selectedModel: string; selectedModel: string;
loadingModels: boolean; loadingModels: boolean;
@@ -59,6 +63,7 @@ interface LokiState {
setSelectedModel: (name: string) => void; setSelectedModel: (name: string) => void;
refreshStatus: () => Promise<void>; refreshStatus: () => Promise<void>;
refreshSystemStats: () => Promise<void>; refreshSystemStats: () => Promise<void>;
refreshLoadedModels: () => Promise<void>;
refreshModels: () => Promise<void>; refreshModels: () => Promise<void>;
refreshFiles: () => Promise<void>; refreshFiles: () => Promise<void>;
@@ -75,6 +80,7 @@ let activeStreamController: AbortController | null = null;
export const useStore = create<LokiState>((set, get) => ({ export const useStore = create<LokiState>((set, get) => ({
status: null, status: null,
systemStats: null, systemStats: null,
loadedModels: [],
models: [], models: [],
selectedModel: "", selectedModel: "",
loadingModels: false, loadingModels: false,
@@ -142,6 +148,9 @@ export const useStore = create<LokiState>((set, get) => ({
setSelectedModel: (name) => { setSelectedModel: (name) => {
set({ selectedModel: name }); set({ selectedModel: name });
void get().refreshConfig(); 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 () => { refreshFiles: async () => {
@@ -169,6 +178,10 @@ export const useStore = create<LokiState>((set, get) => ({
} }
}, },
refreshLoadedModels: async () => {
set({ loadedModels: await getLoadedModels() });
},
refreshModels: async () => { refreshModels: async () => {
set({ loadingModels: true }); set({ loadingModels: true });
try { try {