Flux de travail de développement local-first : Exploiter WebGPU et WebContainers pour le réglage fin des LLM dans le navigateur
Une plongée dans l'exécution et l'adaptation des LLM directement dans le bac à sable du navigateur. Nous combinons StackBlitz WebContainers avec WebGPU et LoRA pour créer un terrain de jeu IA privé et gratuit.

L'exécution de grands modèles de langage (LLM) pendant le développement nécessite généralement des API basées sur le cloud ou des démons locaux lourds comme Ollama. Bien que les API cloud offrent une vitesse élevée, elles entraînent des frais d'abonnement récurrents, des dépendances Internet et des problèmes potentiels de confidentialité des données.
Avec les récents progrès de la plateforme web, nous pouvons désormais contourner complètement ces compromis. En combinant StackBlitz WebContainers — qui exécutent un environnement d'exploitation Node.js complet directement dans un onglet du navigateur — avec WebGPU pour l'accélération matérielle locale, nous pouvons créer un espace de développement d'IA entièrement isolé, privé et gratuit.
Dans ce guide, nous examinerons l'architecture d'un IDE d'IA intégré au navigateur. Nous implémenterons un Web Worker pour gérer l'inférence WebGPU, explorerons comment appliquer des adaptateurs de Low-Rank Adaptation (LoRA) directement en JavaScript côté client, et connecterons le moteur d'IA au système de fichiers virtuel de WebContainer.
The Architecture of a Browser-Based Sandbox
To run both a Node.js development server and an LLM client-side, our architecture must isolate computation to prevent the browser's main thread from freezing.
+-------------------------------------------------------------------------+
| Browser Tab |
| |
| +---------------------------+ +-------------------------+ |
| | Main Thread | | Web Worker (WebGPU) | |
| | | | | |
| | +-----------------------+ | PostMessage| +--------------------+ | |
| | | WebContainer Instance | | <---------> | | WebLLM Engine | | |
| | | (Vite, Monaco Editor) | | | +----------+---------+ | |
| | +-----------+-----------+ | | | | |
| +-------------|-------------+ +-------------|-----------+ |
| v v |
| [Virtual Filesystem] [IndexedDB Cache] |
| | |
| v |
| [GPU VRAM] |
+-------------------------------------------------------------------------+- Thread Principal: Renders the Monaco Editor UI and boots the WebContainer instance. The WebContainer compiles code, runs dev servers, and serves hot-module reloaded (HMR) assets via a virtual network.
- WebGPU Worker: Houses the AI inference engine. Running the LLM inside a Web Worker prevents heavy WebGPU tensor calculations from blocking user interactions (like typing or scrolling).
- Cache IndexedDB: Stores the quantized model weights (e.g., gemma-4-e2b-it-q4f16_1-MLC, Qwen2.5-Coder-1.5B-Instruct, or a distilled reasoning model like DeepSeek-R1-Distill-Qwen-1.5B). On first visit, the weights download; on subsequent visits, they load instantly from disk.
Implementing the WebGPU Worker
We will use the @mlc-ai/web-llm library to run our model inside the Web Worker. This library leverages WebGPU compiler backends to execute compiled weights directly on local silicon.
First, let's write the Web Worker code (llm.worker.ts) to handle model loading, inference, and token streaming:
import { CreateWebGPUEngine, WebGPUEngine } from "@mlc-ai/web-llm";
let engine: WebGPUEngine | null = null;
// Listen for commands from the main thread
self.onmessage = async (event: MessageEvent) => {
const { type, payload } = event.data;
switch (type) {
case "INIT_ENGINE":
try {
// payload.model can be a prebuilt model ID string or a custom model configuration object
engine = await CreateWebGPUEngine(payload.model, {
initProgressCallback: (report) => {
self.postMessage({ type: "INIT_PROGRESS", payload: report.text });
}
});
self.postMessage({ type: "ENGINE_READY" });
} catch (err) {
self.postMessage({ type: "ERROR", payload: (err as Error).message });
}
break;
case "GENERATE":
if (!engine) {
self.postMessage({ type: "ERROR", payload: "Engine not initialized" });
return;
}
try {
const { prompt, systemPrompt } = payload;
const messages = [
{ role: "system", content: systemPrompt || "You are a helpful coding assistant." },
{ role: "user", content: prompt }
];
const completion = await engine.chat.completions.create({
messages: messages as any,
stream: true,
temperature: 0.2,
max_tokens: 1024
});
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
self.postMessage({ type: "TOKEN", payload: content });
}
}
self.postMessage({ type: "GENERATE_COMPLETE" });
} catch (err) {
self.postMessage({ type: "ERROR", payload: (err as Error).message });
}
break;
default:
console.warn(`Unknown message type: ${type}`);
}
};Applying In-Browser LoRA Adapters
Pour rendre le modèle résidant dans le navigateur utile pour des bases de code spécifiques, nous pouvons charger un adaptateur Low-Rank Adaptation (LoRA). LoRA modifie le comportement de sortie en mettant à jour une fraction minuscule des poids totaux du modèle (souvent moins de 1 %), ce qui le rend rapide à télécharger et à appliquer lors de l'exécution.
Dans les environnements WebLLM modernes, les adaptateurs sont empaquetés sous forme de fichiers .bin distincts et superposés aux poids du modèle de base. Voici comment configurer WebLLM pour charger un modèle de base (Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC) et appliquer un adaptateur personnalisé propre à la base de code :
const modelConfig = {
model_id: "Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC",
model_lib: "https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/main/web-packages/Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC-webgpu.wasm",
// Load custom adapter weights from an HTTP endpoint
lora_config: [
{
lora_name: "project-api-adapter",
lora_url: "https://cdn.myportfolio.com/adapters/project-api-lora.bin",
scale: 1.2
}
]
};Lorsque WebLLM s'initialise, il extrait les poids de base et compile les shaders WebGPU. Lors de la passe avant (forward pass), le GPU effectue les calculs de la matrice delta LoRA en parallèle avec les blocs d'attention principaux. Cela permet aux développeurs de régler précisément un modèle sur leur machine, de télécharger un adaptateur de 10 Mo et de distribuer instantanément des modèles de codage personnalisés aux navigateurs de l'équipe.
Bridging WebContainers and WebGPU
Maintenant que nous avons un worker en arrière-plan qui exécute notre modèle, nous pouvons le connecter à notre espace de travail WebContainer. Le thread principal coordonne la lecture d'un fichier à partir du système de fichiers virtuel du WebContainer, l'envoi du contenu au worker WebGPU et l'écriture de la sortie modifiée de l'IA en retour.
Voici la classe coordinatrice qui gère ce flux de communication :
import { WebContainer } from "@webcontainer/api";
export class SandboxAIManager {
private worker: Worker;
private webcontainer: WebContainer;
private onTokenCallback?: (token: string) => void;
constructor(webcontainer: WebContainer) {
this.webcontainer = webcontainer;
// Initialize Web Worker
this.worker = new Worker(
new URL("./llm.worker.ts", import.meta.url),
{ type: "module" }
);
this.worker.onmessage = this.handleWorkerMessage.bind(this);
}
public initModel(model: string | object) {
this.worker.postMessage({
type: "INIT_ENGINE",
payload: { model }
});
}
public async refactorFile(filePath: string, instruction: string, onToken: (t: string) => void) {
this.onTokenCallback = onToken;
// 1. Read file contents from WebContainer virtual filesystem
const fileBytes = await this.webcontainer.fs.readFile(filePath, "utf-8");
// 2. Format context prompt
const prompt = `
Refactor the following file according to this instruction: "${instruction}"
File Path: ${filePath}
Source Code:
\`\`\`typescript
${fileBytes}
\`\`\`
Return only the refactored code inside a single code block. Do not include markdown introductions.
`;
// 3. Trigger GPU inference in worker
this.worker.postMessage({
type: "GENERATE",
payload: { prompt }
});
}
private async handleWorkerMessage(event: MessageEvent) {
const { type, payload } = event.data;
if (type === "TOKEN" && this.onTokenCallback) {
this.onTokenCallback(payload);
} else if (type === "GENERATE_COMPLETE") {
console.log("Code generation finished.");
} else if (type === "INIT_PROGRESS") {
console.log(`Loading model progress: ${payload}`);
}
}
}Performance & VRAM Constraints in the Browser
L'exécution de LLM côté client est extrêmement efficace après téléchargement, mais elle s'effectue dans les limites strictes du bac à sable alloué par le navigateur.
1. Limites d'allocation de VRAM
WebGPU alloue des blocs de mémoire directement sur la carte graphique de la machine hôte. Cependant, les navigateurs imposent des limites strictes aux allocations GPU individuelles (souvent limitées à 2 Go ou 4 Go selon le système d'exploitation et la gamme de matériel).
- Atténuation : Les modèles doivent être strictement quantifiés. Un modèle à 1,5 milliard de paramètres comme Qwen2.5-Coder-Instruct nécessite environ 1,3 Go de VRAM avec un schéma
q4f16_1, gemma-4-e2b-it-q4f16_1-MLC de Google nécessite environ 1,6 Go (grâce à son architecture Effective 2B), et les modèles de raisonnement distillés comme DeepSeek-R1-Distill-Qwen-1.5B fonctionnent avec environ 1,4 Go de VRAM, ce qui rentre parfaitement dans les marges de sécurité du navigateur.
2. Plantage de la mémoire de l'onglet (OOM)
Alors que la mémoire WebGPU réside sur le GPU, WebAssembly (WASM) et le runtime JavaScript continuent de gérer les couches d'orchestration. Si un modèle tente de charger des poids importants directement dans la mémoire du processeur avant de les envoyer au GPU, Chrome peut déclencher un plantage pour dépassement de mémoire ("Page Out of Memory" ou OOM).
- Atténuation : Utilisez des tampons de tableau de streaming (streaming array buffers). Diffuser les poids téléchargés directement dans IndexedDB, puis les analyser bloc par bloc directement dans les textures du GPU, évite de stocker l'intégralité du fichier de modèle dans la mémoire heap de JavaScript.
3. Solutions de repli (Fallbacks)
Si le navigateur de l'utilisateur ne prend pas en charge WebGPU (comme les navigateurs mobiles obsolètes ou les configurations dont les drapeaux sont désactivés), l'orchestrateur doit basculer de manière transparente vers l'exécution sur processeur avec WASM. Bien que les vitesses d'inférence passent d'environ 30 jetons/s à environ 3 jetons/s, cette solution de repli garantit que le bac à sable reste opérationnel.
Résumé
En tirant parti de WebGPU avec les WebContainers de StackBlitz, les applications web peuvent passer de simples pages de présentation à des environnements de développement complets et autonomes. La distribution de flux de travail de développement personnalisés et optimisés est désormais aussi simple que la mise à disposition d'un petit fichier adaptateur LoRA via un CDN, permettant aux équipes de collaborer dans des bacs à sable exécutés entièrement sur des machines clientes avec des coûts d'infrastructure nuls.