Назад в журнал
7 мин чтения

Локальные рабочие процессы разработчика: Использование WebGPU и WebContainers для дообучения LLM в браузере

Глубокое погружение в запуск и адаптацию LLM прямо в песочнице браузера. Мы объединяем StackBlitz WebContainers с WebGPU и LoRA для создания бесплатной приватной AI-песочницы.

WebGPUWebContainersLocal-First AIIn-Browser LLMsWASMWeb Engineering
Локальные рабочие процессы разработчика: Использование WebGPU и WebContainers для дообучения LLM в браузере

Запуск больших языковых моделей (LLM) во время разработки обычно требует использования облачных API или тяжелых локальных служб (демонов), таких как Ollama. Хотя облачные API обеспечивают высокую скорость, они сопряжены с ежемесячной абонентской платой, зависимостью от Интернета и потенциальными проблемами с конфиденциальностью данных.

Благодаря недавним достижениям веб-платформ теперь мы можем полностью избежать этих компромиссов. Объединив StackBlitz WebContainers — которые запускают полноценную операционную среду Node.js прямо во вкладке браузера — с WebGPU для локального аппаратного ускорения, мы можем создать полностью изолированную, приватную и бесплатную среду разработки ИИ (AI).

В этом руководстве мы рассмотрим архитектуру браузерной AI IDE. Мы реализуем Web Worker для управления инференсом (выводом) WebGPU, рассмотрим, как применять адаптеры Low-Rank Adaptation (LoRA) прямо в клиентском Javascript, и свяжем ИИ-движок с виртуальной файловой системой 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: 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

Чтобы сделать модель, находящуюся в браузере, полезной для конкретных кодовых баз, мы можем загрузить адаптер Low-Rank Adaptation (LoRA). LoRA изменяет поведение вывода, обновляя крошечную долю общих весов модели (часто менее 1%), что обеспечивает быструю загрузку и применение во время выполнения.

В современных средах WebLLM адаптеры упаковываются как отдельные файлы .bin и накладываются поверх весов базовой модели. Вот как мы настраиваем WebLLM для загрузки базовой модели (Qwen2.5-Coder-1.5B-Instruct-q4f16_1-MLC) и применения пользовательского адаптера кодовой базы:

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

При инициализации WebLLM загружает базовые веса и компилирует шейдеры WebGPU. Во время прямого прохода (forward pass) GPU применяет вычисления дельта-матрицы LoRA параллельно с основными блоками внимания (attention). Это позволяет разработчикам дообучать модель на своей машине, загружать 10-мегабайтный адаптер и мгновенно распространять пользовательские паттерны кодирования на браузеры команды.


Bridging WebContainers and WebGPU

Теперь, когда у нас есть фоновый воркер, выполняющий нашу модель, мы можем подключить его к нашему рабочему пространству WebContainer. Основной поток будет координировать чтение файла из виртуальной файловой системы WebContainer, отправку содержимого воркеру WebGPU и запись измененного ИИ вывода обратно.

Вот класс-координатор, управляющий этим потоком связи:

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

Запуск LLM на стороне клиента высокоэффективен после загрузки, но он работает в рамках строгих ограничений песочницы, выделяемых браузером.

1. Лимиты выделения VRAM

WebGPU выделяет блоки памяти непосредственно на видеокарте хост-компьютера. Однако браузеры накладывают жесткие ограничения на отдельные выделения памяти GPU (часто ограничиваясь от 2 ГБ до 4 ГБ в зависимости от операционной системы и класса оборудования).

  • Решение: Модели должны быть строго квантованы. Модель с 1,5 миллиардами параметров, такая как Qwen2.5-Coder-Instruct, требует около 1,3 ГБ VRAM при использовании схемы квантования q4f16_1 (4-битной), а дистиллированные модели рассуждений, такие как DeepSeek-R1-Distill-Qwen-1.5B, работают в пределах ~1,4 ГБ VRAM, что отлично укладывается в пределы безопасности браузера.

2. Сбои памяти вкладки (OOM)

В то время как память WebGPU живет на видеокарте, WebAssembly (WASM) и среда выполнения Javascript продолжают управлять уровнями оркестрации. Если модель пытается загрузить большие веса непосредственно в память процессора перед отправкой их на видеокарту, Chrome может вызвать сбой страницы из-за нехватки памяти (OOM).

  • Решение: Используйте потоковые буферы массивов (streaming array buffers). Потоковая передача загруженных весов непосредственно в IndexedDB с последующим поблочным анализом прямо в текстуры GPU позволяет избежать сохранения всего файла модели в куче (heap) Javascript.

3. Резервные варианты (Fallbacks)

Если браузер пользователя не поддерживает WebGPU (например, устаревшие мобильные браузеры или конфигурации с отключенными флагами), оркестратор должен плавно переключиться на выполнение на процессоре через WASM. Хотя скорость вывода снизится с ~30 токенов/сек до ~3 токенов/сек, этот резервный вариант гарантирует работоспособность песочницы.


Резюме

Используя WebGPU совместно со StackBlitz WebContainers, веб-приложения могут переходить от простых презентационных страниц к полноценным автономным средам разработки. Распространение пользовательских дообученных рабочих процессов теперь так же просто, как предоставление небольшого файла адаптера LoRA через CDN, что позволяет командам совместно работать в песочницах, запускаемых полностью на клиентских машинах с нулевыми затратами на инфраструктуру.

Share this article