Terug naar Journal
7 min leestijd

Local-First Developer Workflows: WebGPU en WebContainers benutten voor In-Browser LLM Fine-Tuning

Een diepe duik in het draaien en aanpassen van LLM's direct in de browser-sandbox. We combineren StackBlitz WebContainers met WebGPU en LoRA om een gratis, privé AI-speeltuin te bouwen.

WebGPUWebContainersLocal-First AIIn-Browser LLMsWASMWeb Engineering
Local-First Developer Workflows: WebGPU en WebContainers benutten voor In-Browser LLM Fine-Tuning

Het draaien van Large Language Models (LLM's) tijdens de ontwikkeling vereist meestal cloud-gebaseerde API's of zware lokale daemons zoals Ollama. Hoewel cloud-API's een hoge snelheid bieden, introduceren ze terugkerende abonnementskosten, internet-afhankelijkheden en mogelijke problemen met dataprivacy.

Met recente vorderingen in het webplatform kunnen we deze afwegingen nu volledig omzeilen. Door StackBlitz WebContainers — die een volledige Node.js besturingsomgeving rechtstreeks in een browsertabblad uitvoeren — te combineren met WebGPU voor lokale hardwareversnelling, kunnen we een volledig gesandboxte, privé en gratis AI-ontwikkelaarsspeeltuin bouwen.

In deze gids bekijken we de architectuur van een in-browser AI IDE. We zullen een Web Worker implementeren om WebGPU-inferentie te beheren, onderzoeken hoe we Low-Rank Adaptation (LoRA) adapters rechtstreeks in client-side Javascript kunnen toepassen en de AI-engine koppelen aan het virtuele bestandssysteem van 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]           |
+-------------------------------------------------------------------------+
  1. Main Thread: 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.
  2. 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).
  3. IndexedDB Cache: 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

Om het model in de browser nuttig te maken voor specifieke codebases, kunnen we een Low-Rank Adaptation (LoRA) adapter laden. LoRA wijzigt het uitvoergedrag door een fractie van de totale modelgewichten bij te werken (vaak minder dan 1%), waardoor het snel te downloaden en toe te passen is tijdens runtime.

In moderne WebLLM-omgevingen worden adapters verpakt als afzonderlijke .bin-bestanden en over de basismodelgewichten heen gelegd. Hier is hoe we WebLLM configureren om een basismodel (Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC) te laden en een aangepaste codebase-specifieke adapter toe te passen:

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
    }
  ]
};

Wanneer WebLLM initialiseert, haalt het de basisgewichten op en compileert het de WebGPU-shaders. Tijdens de forward pass past de GPU de LoRA delta-matrixberekeningen toe in parallel met de belangrijkste aandachtblokken (attention blocks). Hierdoor kunnen ontwikkelaars een model op hun machine finetunen, een adapter van 10 MB uploaden en direct aangepaste coderingspatronen distribueren naar browser van het team.


Bridging WebContainers and WebGPU

Nu we een achtergrondworker hebben die ons model uitvoert, kunnen we deze koppelen aan onze WebContainer-werkruimte. De hoofdthread coördineert het lezen van een bestand uit het virtuele bestandssysteem van de WebContainer, stuurt de inhoud naar de WebGPU-worker en schrijft de aangepaste uitvoer van de AI terug.

Hier is de coördinatorklasse die deze communicatiestroom beheert:

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

Het draaien van client-side LLM's is zeer efficiënt zodra ze zijn gedownload, maar het werkt binnen de strikte beperkingen van de door de browser toegewezen sandbox.

1. VRAM-toewijzingslimieten

WebGPU wijst geheugenblokken rechtstreeks toe op de grafische kaart van de hostmachine. Browsers leggen echter harde limieten op aan individuele GPU-toewijzingen (vaak beperkt tot 2 GB tot 4 GB, afhankelijk van het besturingssysteem en de hardwareklasse).

  • Mitigatie: Modellen moeten strikt gekwantiseerd zijn. Een model met 1,5 miljard parameters zoals Qwen2.5-Coder-Instruct vereist ongeveer 1,3 GB VRAM met een q4f16_1-schema, Google's gemma-4-e2b-it-q4f16_1-MLC vereist ongeveer 1,6 GB VRAM (dankzij de Effective 2B-architectuur), en gedistilleerde redeneermodellen zoals DeepSeek-R1-Distill-Qwen-1.5B draaien binnen ~1,4 GB VRAM, wat ruim binnen de veiligheidsmarges van de browser valt.

2. Tab Geheugencrash (OOM)

Hoewel het WebGPU-geheugen zich op de GPU bevindt, beheren WebAssembly (WASM) en de Javascript-runtime nog steeds de orkestratielagen. Als een model probeert om grote gewichten rechtstreeks in het CPU-geheugen te laden voordat ze naar de GPU worden gestuurd, kan Chrome een "Page Out of Memory" (OOM) crash veroorzaken.

  • Mitigatie: Gebruik streaming array buffers. Het rechtstreeks streamen van de gedownloade gewichten naar IndexedDB en het vervolgens blok-voor-blok parseren direct naar GPU-texturen voorkomt dat het volledige modelbestand in het Javascript-heapgeheugen wordt opgeslagen.

3. Fallbacks

Als de browser van de gebruiker geen WebGPU ondersteunt (zoals verouderde mobiele browsers of configuraties met uitgeschakelde vlaggen), moet de orkestrator soepel terugvallen op WASM CPU-uitvoering. Hoewel de inferentiesnelheden zullen dalen van ~30 tokens/sec naar ~3 tokens/sec, garandeert deze fallback dat de sandbox functioneel blijft.


Samenvatting

Door WebGPU te combineren met StackBlitz WebContainers kunnen webapplicaties overgaan van eenvoudige presentatiepagina's naar volledige, op zichzelf staande ontwikkelaarsomgevingen. Het distribueren van aangepaste, gefinetunde ontwikkelaarsworkflows is nu net zo eenvoudig als het serveren van een klein LoRA-adapterbestand via een CDN. Hierdoor kunnen teams samenwerken in sandboxes die volledig op clientmachines draaien met nul infrastructuurkosten.

Share this article