Saltar al contenido
M
Volver al blog
javascriptnextjsLLMWebGPUWebAssemblyinferencia en browserGemmaWebLLMedge inferenciaIA local

Metí un LLM chico adentro de una app Next.js y esto fue lo que aprendí

Reproducí el experimento del LLM tiny que explotó en Show HN: Gemma corriendo en el browser, sin API keys, desde mi stack habitual. Acá está todo lo que salió mal — y lo poco que salió bien.

7 de abril de 202610 min de lectura35 visualizaciones

Eran las 2am y Chrome me estaba mostrando 4.2GB de RAM usados en una sola pestaña. El modelo llevaba 47 segundos "pensando" una respuesta de tres palabras. Yo miraba la pantalla con esa mezcla de fascinación y horror que solo te da la tecnología cuando funciona y no funciona al mismo tiempo. Esto es lo que pasó cuando decidí meter un LLM chico adentro de una app Next.js.


LLM pequeño en el browser: qué promete, qué entrega

Cuando vi el thread de Show HN con 836 puntos sobre LLMs tiny corriendo directo en el browser, lo primero que pensé fue: esto tiene que entrar en mi stack. Después vi el de Gemma con 141 puntos. La idea es simple y poderosa: inferencia local, sin API keys, sin latencia de red, sin costos por token. Privacidad de verdad.

El concepto técnico es concreto: modelos cuantizados (GGUF, int4, int8) que bajan de 7B parámetros a territorios manejables — 1B, 500M, incluso menos — y corren en WebAssembly o WebGPU directamente en el browser. Sin servidor, sin Claude, sin OpenAI. Solo el cliente y el modelo.

Suena hermoso. Y en parte lo es. Pero hay un abismo entre el demo de Show HN y meterlo en producción en una app real.


El setup real: Next.js, WebLLM y el primer encontronazo con la realidad

Empecé con WebLLM de MLC AI — la librería más madura para esto. El approach es WebGPU cuando está disponible, con fallback a WebAssembly. El modelo que elegí: Gemma-2B-it-q4f32_1, que en teoría pesa ~1.5GB.

# Instalación — lo más fácil de todo el proceso
npm install @mlc-ai/web-llm

El primer problema apareció antes de escribir una sola línea de lógica de negocio.

// app/components/LocalLLM.tsx
'use client' // Crítico — todo esto vive en el cliente

import { CreateMLCEngine, MLCEngine } from '@mlc-ai/web-llm'
import { useState, useEffect, useRef } from 'react'

// El modelo que elegí tras varios intentos fallidos
const MODEL_ID = 'Gemma-2B-it-q4f32_1-MLC'

export function LocalLLM() {
  const engineRef = useRef<MLCEngine | null>(null)
  const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
  const [progress, setProgress] = useState(0)
  const [response, setResponse] = useState('')

  const initEngine = async () => {
    setStatus('loading')
    
    try {
      // Esto descarga ~1.5GB la primera vez — el usuario necesita saberlo
      engineRef.current = await CreateMLCEngine(MODEL_ID, {
        initProgressCallback: (report) => {
          // El progreso viene en texto, no en número — hay que parsearlo
          const match = report.text.match(/(\d+\.\d+)%/)
          if (match) setProgress(parseFloat(match[1]))
        }
      })
      
      setStatus('ready')
    } catch (error) {
      // Acá entra si el browser no soporta WebGPU
      // Safari en iOS: directo a error
      console.error('Engine init falló:', error)
      setStatus('error')
    }
  }

  const runInference = async (prompt: string) => {
    if (!engineRef.current) return
    
    const reply = await engineRef.current.chat.completions.create({
      messages: [{ role: 'user', content: prompt }],
      // Sin esto, espera a tener TODA la respuesta antes de mostrarte algo
      stream: true,
    })
    
    // Streaming en el browser — lo mejor del experimento
    for await (const chunk of reply) {
      const delta = chunk.choices[0]?.delta?.content || ''
      setResponse(prev => prev + delta)
    }
  }

  return (
    // UI básica para el experimento
    <div>
      {status === 'idle' && (
        <button onClick={initEngine}>Cargar modelo (~1.5GB)</button>
      )}
      {status === 'loading' && <p>Descargando: {progress.toFixed(1)}%</p>}
      {status === 'ready' && (
        <button onClick={() => runInference('Explicá qué es una red neuronal en 2 oraciones')}>Inferir</button>
      )}
      {response && <p>{response}</p>}
    </div>
  )
}

Esto funcionó. Primer token apareció. Me emocioné.

Después miré el task manager.


Dónde se rompe todo — los límites que nadie cuenta en los demos

El tutorial feliz termina cuando el primer token aparece en pantalla. El experimento real empieza ahí.

Problema 1: La descarga inicial es un UX nightmare

1.5GB en la primera visita. Sin cache service worker configurado, eso se baja cada vez que el browser limpia la cache. Con cache, el modelo vive en IndexedDB del browser — que en Safari tiene límites agresivos de almacenamiento.

WebLLM usa Cache API del browser automáticamente, pero la UX de "espere mientras descarga 1.5GB" no existe en ningún producto que hayas usado en tu vida. Tuve que construir una pantalla de progress desde cero.

Problema 2: Memoria — el número que asusta

Gemma 2B cuantizado a int4 promete ~1GB de RAM. En la práctica vi picos de 3-4GB en Chrome durante la carga inicial. Por qué: el proceso de inicialización carga el modelo completo antes de moverlo a la GPU. En dispositivos con menos de 8GB disponibles, es ruleta rusa.

En mobile: directo no. iOS Safari no tiene WebGPU estable. Android Chrome funciona en algunos Pixel, es impredecible en el resto.

Problema 3: La latencia real vs. la latencia de demo

En una M2 MacBook con WebGPU: 8-12 tokens/segundo. Decente. En un i7 de 2019 sin GPU dedicada (WebAssembly fallback): 0.8-1.2 tokens/segundo. Unusable. En el server de Railway (CPU): no tiene sentido — para eso usás una API.

El demo de Show HN corrió en el setup perfecto. El usuario promedio de tu app no tiene ese setup.

Problema 4: Next.js y el SSR que rompe todo

// Este import explota en el servidor — WebGPU no existe en Node
import { CreateMLCEngine } from '@mlc-ai/web-llm'

// La solución: dynamic import con ssr: false
import dynamic from 'next/dynamic'

const LocalLLM = dynamic(
  () => import('./components/LocalLLM'),
  { 
    ssr: false, // Sin esto, Railway tira error en build
    loading: () => <p>Cargando interfaz de inferencia...</p>
  }
)

Esto lo aprendí de una manera. Build exitoso, deploy en Railway, pantalla blanca. Tres horas después, ssr: false. Sobre cómo deployar en Railway y las optimizaciones de Next.js que importan, ya escribí antes — pero el ssr: false para WebGPU es un caso que no vi documentado en ningún lado.

Problema 5: El modelo es chico — y se nota

Gemma 2B es impresionante para su tamaño. Pero cuando lo comparás con GPT-4o o Claude, la diferencia en razonamiento es un cañón. Para tasks simples — clasificación, resumen corto, extracción de entidades — funciona bien. Para cualquier cosa que requiera razonamiento complejo, se nota el límite.

Esto no es crítica al modelo. Es calibrar expectativas: es un 2B corriendo cuantizado en un browser. La pregunta correcta no es "¿es tan bueno como GPT-4?" sino "¿alcanza para mi caso de uso específico?".


El momento en que decidí si valía la pena

Después de tres días de experimento, me senté a hacer el análisis frío. Tengo el hábito de pensar en el stack desde la perspectiva del proyecto, no desde el entusiasmo de la tecnología.

Casos donde SÍ lo usaría:

  • Herramienta interna donde controlás el hardware del usuario (siempre Chrome en desktop potente)
  • Feature de privacidad como diferencial de producto — procesar texto sensible sin mandarlo a un servidor
  • Offline-first apps donde la latencia de API es el killer
  • Prototipos y demos donde el WOW factor importa más que la performance consistente

Casos donde NO lo usaría:

  • App pública con base de usuarios heterogénea en dispositivos
  • Cualquier cosa donde la velocidad de respuesta sea crítica
  • Cuando el modelo chico no alcanza para la tarea (la mayoría de los casos de producción)

La conclusión honesta: es una tecnología en la que voy a seguir mirando, pero que hoy tiene un año o dos para madurar antes de que la meta en algo que use gente real sin que yo controle su hardware. Los patrones TypeScript que uso para abstraer estas decisiones me sirvieron para encapsular esto como un feature flag — el componente existe, está apagado por default, lo prendo solo en contextos donde sé que va a funcionar.

En juanchi.dev lo tengo como experimento en una ruta separada, no como feature principal. Ese es el lugar correcto para esto hoy.


FAQ — Lo que me preguntarían si contara esto en una charla

¿Qué diferencia hay entre correr un LLM en el browser vs. en el edge (Cloudflare Workers, Vercel Edge)?

Son dos cosas distintas. Browser inference = WebGPU/WASM, corre en la máquina del usuario, sin servidor. Edge inference = el modelo corre en el servidor edge, con acceso a GPU limitado (Cloudflare tiene acceso experimental a modelos vía Workers AI). El browser es más privado y no tiene costos de cómputo para vos, pero depende totalmente del hardware del usuario. Edge te da más control sobre la latencia y el modelo, pero tiene costos y los modelos disponibles son limitados.

¿Cuánto pesa el modelo más chico que funciona para algo útil?

En mi experimento, el mínimo viable para tareas de lenguaje natural razonables fue Gemma 2B cuantizado (~1.5GB descarga). Existen modelos más chicos — Phi-3 mini 3.8B es sorprendentemente bueno, y hay variantes de 500M params para clasificación — pero para generación de texto libre, bajás de 1B y la calidad cae cliff-edge. El tamaño del archivo no es el único número: importa la arquitectura y el fine-tuning del modelo.

¿Esto reemplaza usar la API de OpenAI o Anthropic?

No, y no creo que lo haga en el corto plazo para la mayoría de los casos. La diferencia de capacidad entre un 2B local y GPT-4o es enorme. Lo que sí puede reemplazar: tareas simples de NLP donde hoy pagás por millones de tokens para cosas que no necesitan razonamiento complejo — clasificación de sentimiento, extracción de keywords, resúmenes cortos. Para eso, un modelo local tiene sentido económico y de privacidad.

¿WebGPU ya está listo para producción?

Depende de tu definición de producción. En Chrome 113+ en desktop: sí, estable. Firefox: disponible pero más lento. Safari macOS: disponible desde Safari 18. iOS Safari: en progreso, inconsistente. Android Chrome: disponible en dispositivos modernos, impredecible en gama media-baja. Si tu app tiene usuarios en múltiples browsers y dispositivos, necesitás un fallback robusto a WebAssembly y necesitás comunicarle al usuario que la experiencia va a ser más lenta.

¿Se puede hacer streaming de la respuesta o hay que esperar al completion completo?

Sí, WebLLM soporta streaming nativo con la misma interfaz de OpenAI (stream: true). De hecho, el streaming es casi obligatorio — sin él, el usuario ve pantalla en blanco durante 30-60 segundos y después aparece todo el texto junto. Con streaming, el primer token aparece en 2-5 segundos y la respuesta va fluyendo. La diferencia en UX es abismal. Lo implementé con el mismo patrón de for await que uso con la API de Anthropic.

¿Vale la pena para un side project o es solo para grandes empresas con recursos?

Para un side project es perfecto — justamente porque no tenés que pagar por API calls. El costo real es el tiempo de setup y el entender los límites. Si hacés una herramienta de nicho donde podés asumir que tus usuarios tienen hardware decente (pensá: una extensión de Chrome para developers, una herramienta para diseñadores en desktop), el caso de uso encaja bien. Para una app consumer con usuarios heterogéneos, esperaría 12-18 meses más.


Conclusión: la tecnología está, la madurez no tanto

Lo que me llevo de tres días de experimento es esto: la inferencia en el browser funciona. No es marketing, no es smoke and mirrors. Vos podés meter Gemma en una pestaña de Chrome y hacer preguntas y obtener respuestas, sin mandar nada a ningún servidor. Eso es genuinamente notable.

Pero hay un salto grande entre "funciona" y "está listo para usuarios reales". Los 1.5GB de descarga inicial, la dependencia de WebGPU, la variabilidad brutal entre dispositivos — eso son problemas de producto, no solo de implementación técnica.

Mi lectura: es el momento perfecto para aprender esto, demasiado pronto para meterlo en producción mainstream. Lo tengo en radar activo, con código funcionando, esperando que el ecosistema madure. En 2026 vuelvo a esta pregunta y apuesto a que la respuesta va a ser diferente.

Si querés reproducir el experimento, el código está en mi repo y las notas de este post son el mapa honesto de dónde vas a gastar el tiempo.

Compartir:

Comentarios (0)

Deja un comentario

No hay comentarios aún. ¡Sé el primero en opinar!

Artículos Relacionados