Zurück zum Journal
7 Min. Lesezeit

Lokale Entwickler-Workflows: Nutzung von WebGPU und WebContainers für das LLM-Feintuning im Browser

Ein tiefer Einblick in das Ausführen und Anpassen von LLMs direkt in der Browser-Sandbox. Wir kombinieren StackBlitz WebContainers mit WebGPU und LoRA für eine kostenlose, private AI-Spielwiese.

WebGPUWebContainersLocal-First AIIn-Browser LLMsWASMWeb Engineering
Lokale Entwickler-Workflows: Nutzung von WebGPU und WebContainers für das LLM-Feintuning im Browser

Das Ausführen von Large Language Models (LLMs) während der Entwicklung erfordert normalerweise Cloud-basierte APIs oder schwere lokale Daemons wie Ollama. Während Cloud-APIs eine hohe Geschwindigkeit bieten, führen sie zu wiederkehrenden Abonnementgebühren, Internetabhängigkeiten und potenziellen Datenschutzproblemen.

Mit den jüngsten Fortschritten der Webplattform können wir diese Kompromisse nun vollständig umgehen. Durch die Kombination von StackBlitz WebContainers – die eine vollständige Node.js-Betriebsumgebung direkt in einem Browser-Tab ausführen – mit WebGPU für die lokale Hardwarebeschleunigung können wir eine vollständig isolierte, private und kostenlose KI-Entwicklerspielwiese aufbauen.

In dieser Anleitung werfen wir einen Blick auf die Architektur einer In-Browser-KI-IDE. Wir werden einen Web Worker implementieren, um das WebGPU-Inferenzieren zu verwalten, untersuchen, wie man Low-Rank Adaptation (LoRA) Adapter direkt in clientseitigem Javascript anwendet, und die KI-Engine mit dem virtuellen Dateisystem des WebContainers verbinden.


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. Haupt-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

Um das im Browser ausgeführte Modell für bestimmte Codebasen nützlich zu machen, können wir einen Low-Rank Adaptation (LoRA) Adapter laden. LoRA verändert das Ausgabeverhalten, indem es einen winzigen Bruchteil der gesamten Modellgewichte aktualisiert (oft weniger als 1%), was den Download und die Anwendung zur Laufzeit extrem schnell macht.

In modernen WebLLM-Umgebungen werden Adapter als separate .bin-Dateien verpackt und über die Gewichte des Basismodells gelegt. Hier ist die Konfiguration für WebLLM, um ein Basismodell (Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC) zu laden und einen benutzerdefinierten codebasenspezifischen Adapter anzuwenden:

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

Wenn sich WebLLM initialisiert, lädt es die Basisgewichte und kompiliert die WebGPU-Shader. Während des Forward-Passes führt die GPU die LoRA-Delta-Matrixberechnungen parallel zu den Haupt-Attention-Blöcken aus. Dies ermöglicht es Entwicklern, ein Modell auf ihrer Maschine feinzutunen, einen 10-MB-Adapter hochzuladen und benutzerdefinierte Codierungsmuster sofort an die Browser des Teams zu verteilen.


Bridging WebContainers and WebGPU

Jetzt, da wir einen Hintergrund-Worker haben, der unser Modell ausführt, können wir ihn mit unserem WebContainer-Arbeitsbereich verbinden. Der Haupt-Thread koordiniert das Lesen einer Datei aus dem virtuellen Dateisystem des WebContainers, sendet den Inhalt an den WebGPU-Worker und schreibt die modifizierte Ausgabe der KI zurück.

Hier ist die Koordinatorklasse, die diesen Kommunikationsfluss verwaltet:

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

Das Ausführen von clientseitigen LLMs ist nach dem Herunterladen hocheffizient, erfolgt jedoch innerhalb der strengen Grenzen der vom Browser zugewiesenen Sandbox.

1. VRAM-Zuweisungslimits

WebGPU weist Speicherblöcke direkt auf der Grafikkarte des Host-Rechners zu. Browser legen jedoch harte Grenzen für einzelne GPU-Zuweisungen fest (oft auf 2 GB bis 4 GB begrenzt, je nach Betriebssystem und Hardware-Tier).

  • Abmilderung: Modelle müssen strikt quantisiert werden. Ein Modell mit 1,5 Milliarden Parametern wie Qwen2.5-Coder-Instruct benötigt ca. 1,3 GB VRAM mit einem q4f16_1-Schema, Googles gemma-4-e2b-it-q4f16_1-MLC benötigt ca. 1,6 GB VRAM (dank seiner Effective 2B-Architektur), und destillierte Argumentationsmodelle wie DeepSeek-R1-Distill-Qwen-1.5B laufen innerhalb von ~1,4 GB VRAM und passt damit gut in die Sicherheitsmargen des Browsers.

2. Tab-Speicherabsturz (OOM)

Während der WebGPU-Speicher auf der GPU liegt, verwalten WebAssembly (WASM) und die Javascript-Runtime weiterhin die Orchestrierungsebenen. Wenn ein Modell versucht, große Gewichte direkt in den CPU-Speicher zu laden, bevor es sie an die GPU sendet, kann Chrome einen "Page Out of Memory" (OOM) Absturz auslösen.

  • Abmilderung: Verwenden Sie Streaming Array Buffers. Das direkte Streamen der heruntergeladenen Gewichte in IndexedDB und das anschließende blockweise Parsen direkt in GPU-Texturen vermeidet das Speichern der gesamten Modelldatei im Javascript-Heap-Speicher.

3. Fallbacks

Wenn der Browser des Benutzers kein WebGPU unterstützt (wie veraltete mobile Browser oder Konfigurationen mit deaktivierten Flags), sollte der Orchestrator elegant auf die WASM CPU-Ausführung ausweichen. Obwohl die Inferenzgeschwindigkeiten von ~30 Token/Sek. auf ~3 Token/Sek. sinken, garantiert dieser Fallback, dass die Sandbox funktionsfähig bleibt.


Zusammenfassung

Durch die Nutzung von WebGPU zusammen mit StackBlitz WebContainers können Webanwendungen von einfachen Präsentationsseiten zu vollständigen, in sich geschlossenen Entwicklerumgebungen übergehen. Das Verteilen benutzerdefinierter, feingetunter Entwickler-Workflows ist jetzt so einfach wie das Bereitstellen einer winzigen LoRA-Adapterdatei über ein CDN. Dadurch können Teams in Sandboxes zusammenarbeiten, die vollständig auf Client-Maschinen ohne Infrastrukturkosten ausgeführt werden.

Share this article