Un LLM local útil en 30 minutos: programación, preguntas y respuestas en documentos y voz
Un LLM local útil en 30 minutos con Ollama, Qwen3 y Whisper.cpp: un asistente de programación, RAG para tus notas y un bucle de voz sin conexión, adaptado a tu memoria.
Fuentes: Run a Useful Local LLM in 30 Minutes (Coding, RAG, Voice)
Un modelo local puede responder a una pregunta real de programación con la red desconectada, en una laptop que ya está sobre el escritorio. La configuración tarda unos ocho minutos y la factura mensual es nula. Basta con un prompt como el que se muestra a continuación para comprobar que funciona.
$ ollama run qwen3:8b
>>> rewrite this function to use async/await
La calidad es lo sorprendente. No iguala a los mejores modelos en la nube en cuanto al razonamiento complejo y multietapa, pero maneja las tareas de ingeniería habituales de manera lo suficientemente fiable como para que la brecha entre un juguete de fin de semana y una herramienta útil se haya reducido mucho.
Tres actualizaciones en aproximadamente el último año y medio lo han hecho práctico. Los archivos del modelo se han reducido para caber en la RAM habitual: Qwen 3 incluye una versión con 8 mil millones de parámetros que ocupa unos 5 GB. Qwen3-Coder, lanzado en febrero de 2026, alcanza el 70,6 por ciento en SWE-bench; está verificado con un diseño de mezcla de expertos de 30 mil millones que activa solo 3 mil millones de parámetros por token. Además, la instalación pasó de ser un laberinto de entornos de Python a un único archivo binario, gracias a Ollama.
A continuación se presentan tres versiones separadas, cada una de aproximadamente media hora: un asistente de programación dentro del editor, un script en Python que responde preguntas sobre una carpeta de documentos y un bucle de voz que escucha y responde. Todas comparten una base de cinco minutos y luego toman rumbos diferentes, por lo que lo más sensato es elegir la que se ajuste mejor al trabajo diario y dejar las demás para más tarde.
La base común
Cada ruta necesita un motor de inferencia y un conjunto de pesos del modelo.
Ollama desempeña el papel de motor. Se ocupa de las diferencias de hardware, detectando si la máquina cuenta con una GPU NVIDIA, un chip Apple Silicon o solo una CPU, y prepara el grafo de ejecución correspondiente. Funciona en segundo plano y ofrece una API HTTP local que se comporta como los endpoints de los grandes proveedores en la nube.
La instalación depende de la plataforma. En macOS, Homebrew se encarga de ello:
brew install ollama
Linux cuenta con un script de instalación:
curl -fsSL https://ollama.com/install.sh | sh
Los usuarios de Windows pueden descargar el instalador MSI desde el sitio web de Ollama.
Una vez instalado el motor, falta el modelo. El modelo de referencia aquí es qwen3:8b, que necesita aproximadamente 5 GB de espacio en disco y funciona sin problemas con 16 GB de RAM. Sin una GPU dedicada, Ollama recurre a la inferencia en CPU, a una velocidad de unos 8 a 12 tokens por segundo, lo cual sigue siendo perfectamente legible.
Descárguelo:
ollama pull qwen3:8b
Luego confirme que todo funciona con una ejecución rápida:
ollama run qwen3:8b "Write a python script to reverse a string."
La respuesta se muestra en la terminal. En segundo plano, Ollama carga un archivo GGUF, un formato que mapea directamente los pesos del disco a la RAM, por lo que al observar el monitor del sistema se observa que la memoria aumenta aproximadamente en el tamaño del archivo.
Una máquina con 8 GB de RAM o menos comenzará a utilizar el disco como memoria intermedia, lo que ralentizará enormemente el proceso de inferencia. Para estos casos, qwen3:4b o gemma3:4b son opciones mejores. Con una GPU de 24 GB hay espacio suficiente para qwen3:14b como siguiente paso lógico.
Ruta A: un asistente de programación en el editor
Quienes trabajan constantemente en un editor de código obtendrán los mejores resultados aquí. El objetivo es conectar un modelo local a VS Code para realizar refactorizaciones, generar documentación y crear código básico.
El modelo general Qwen 3 es competente, pero el trabajo con código se beneficia de un entrenamiento especializado. A mayo de 2026, Qwen3-Coder es el mejor codificador local integral, obteniendo un 70.6 por ciento en SWE-bench Verified y un 44.3 por ciento en SWE-Bench Pro. Su tamaño total es de 30 mil millones de parámetros, pero solo 3 mil millones están activos por cada token, por lo que cabe en 24 GB de VRAM o funciona sin problemas en una Mac de la serie M con 32 GB de memoria unificada.
Una laptop de 16 GB sin GPU dedicada probablemente tendrá dificultades con el modelo de 30 mil millones de parámetros. La alternativa es qwen2.5-coder:7b, que necesita 4.7 GB y aún así alcanza aproximadamente un 92 por ciento en HumanEval.
# For 24GB+ VRAM or 32GB+ Mac Unified Memory
ollama pull qwen3-coder:30b
# For 16GB RAM laptops
ollama pull qwen2.5-coder:7b
El editor necesita una interfaz de usuario para comunicarse con el modelo. Cline, una extensión de VS Code con más de 5 millones de instalaciones, permite leer archivos, proponer diferencias y ejecutar comandos de terminal. Se puede encontrar buscando su nombre en el marketplace.
Hay una trampa que merece atención: Cline incluye una larga instrucción de sistema que explica cómo formatear las diferencias, leer el árbol de archivos y utilizar las herramientas, y dicha instrucción suele superar los 25,000 tokens antes de que se adjunte cualquier código. La ventana de contexto predeterminada de Ollama para los modelos Qwen 3 es de 40,000 tokens. Al abrir varios archivos, la cantidad total supera fácilmente ese límite, y en ese caso Ollama trunca la instrucción sin previo aviso. El modelo pierde entonces las instrucciones de formato, ignora las reglas y genera texto plano en lugar de diferencias válidas.
La solución es utilizar un Modelfile personalizado que fuerce el uso de una ventana más grande.
Un Modelfile ajustado para Cline
Guarde lo siguiente en el directorio de trabajo como un archivo llamado Modelfile.
# Modelfile — cline-tuned qwen3-coder
# Save as: ./Modelfile
FROM qwen3-coder:30b
# Cline's system prompt is ~25-30K tokens before your code is added.
# Ollama's default num_ctx for Qwen 3 is 40K, but Cline ships an
# expanded system prompt in v3+ that comfortably exceeds it on any
# real task. 65536 is the safe floor for serious use; 131072 if
# you have RAM/VRAM to spare.
PARAMETER num_ctx 65536
# Code edits want low-variance output. The default 0.7 is too loose.
PARAMETER temperature 0.2
# Stop after the model's natural turn. Cline parses this.
PARAMETER stop "<|im_end|>"
Construya el modelo a partir de él con la Ollama CLI y, a continuación, verifique que la configuración de contexto haya surtido efecto:
# Build the Cline-tuned model from the Modelfile in the current dir
ollama create qwen3-coder-cline -f ./Modelfile
# Verify the context window actually took
ollama show qwen3-coder-cline --modelfile | grep num_ctx
# Expected output: PARAMETER num_ctx 65536
# Sanity-check it answers
ollama run qwen3-coder-cline "write a python one-liner to read /etc/hostname"
En VS Code, abra la Paleta de Comandos con Cmd+Shift+P y elija la opción de configuración de Cline. Diríjala hacia el motor local:
API Provider: Ollama
Base URL: http://localhost:11434
Model: qwen3-coder-cline
Context Window: 65536
El valor de la ventana de contexto en la interfaz gráfica debe coincidir exactamente con num_ctx en el archivo del modelo. Si es menor, Cline asume que la ventana es más pequeña y limita silenciosamente los archivos que envía.
Para probarlo, abra un archivo de Python con una función síncrona, active el panel de chat de Cline y solicite una refactorización a async/await. Cline lee el archivo, lo envía al modelo local Qwen3-Coder, recibe la respuesta y muestra una diferencia limpia en el editor. Aceptarla se hace con un solo clic, y todo el proceso se realiza sin conexión a Internet.
Esta configuración permite realizar refactorizaciones de un solo archivo, probar funciones aisladas y generar código genérico. Las pruebas realizadas por la comunidad indican que alcanza entre el 70 y el 85 por ciento de la calidad de los servicios en la nube más avanzados en tareas de alcance limitado.
Se descompone ante cambios grandes en la arquitectura con múltiples archivos. Al trasladar un proyecto Flask de veinte archivos a FastAPI es cuando falla: el hilo se pierde, se inventan importaciones y la base de código termina dañada. Trabajos de esa magnitud siguen correspondiendo a un modelo en la nube de vanguardia. Sin embargo, para las decenas de ediciones pequeñas y tediosas que se realizan cada día, el bucle local es suficiente.
Opción B: recuperación a partir de tus propios documentos
Este caso se refiere a una carpeta con notas en markdown, transcripciones de reuniones o documentación de proyectos que se buscan a mano. Todo el proceso cabe en unas 60 líneas de Python estándar, utilizando únicamente el cliente Ollama y numpy.
Un modelo de embedding convierte el texto en vectores. Nomic Embed Text v1.5 tiene 137 millones de parámetros, genera vectores de 768 dimensiones, ocupa menos de 300MB, maneja un contexto de 8192 tokens y obtiene buenos resultados en las pruebas de recuperación MTEB.
Obtenga el modelo de embedding e instale los paquetes de Python:
llama pull nomic-embed-text
pip install ollama numpy
Luego guarde el script como rag.py.
"""Local RAG over a folder of notes — Ollama embeddings + numpy cosine.
No vector DB. For a few hundred docs, an in-memory list + numpy is
faster to set up and fast enough to run. Swap in sqlite-vec when
the corpus outgrows it (typically past ~5000 chunks).
Setup:
ollama pull qwen3:8b
ollama pull nomic-embed-text
pip install ollama numpy
Run:
python rag.py ./notes "what did the team decide about pricing?"
"""
from __future__ import annotations
import glob
import os
import sys
import numpy as np
import ollama
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "qwen3:8b"
CHUNK_WORDS = 400 # ~500 tokens; fits 4 chunks in an 8K context
TOP_K = 3
def load_chunks(folder: str) -> list[str]:
"""Read every .md / .txt under folder, split into ~CHUNK_WORDS chunks."""
chunks: list[str] = []
for path in glob.glob(os.path.join(folder, "**/*"), recursive=True):
if not path.endswith((".md", ".txt")):
continue
with open(path, encoding="utf-8") as f:
words = f.read().split()
for i in range(0, len(words), CHUNK_WORDS):
chunk = " ".join(words[i : i + CHUNK_WORDS])
if chunk.strip():
chunks.append(chunk)
return chunks
def embed(texts: list[str]) -> np.ndarray:
"""Embed a batch of texts via Ollama. Returns an (N, D) matrix."""
resp = ollama.embed(model=EMBED_MODEL, input=texts)
return np.array(resp["embeddings"], dtype=np.float32)
def top_k_indices(
query_vec: np.ndarray, doc_mat: np.ndarray, k: int
) -> list[int]:
"""Return indices of the k most cosine-similar rows in doc_mat.
The trick: if both vectors are unit-length (norm == 1), their
dot product equals their cosine similarity. So we normalize
once, then one matrix multiply gives a similarity score for
every chunk against the query — no Python loop needed.
"""
# Normalize query and every chunk to unit length.
# The 1e-8 prevents division by zero on a zero vector.
q = query_vec / (np.linalg.norm(query_vec) + 1e-8)
d = doc_mat / (np.linalg.norm(doc_mat, axis=1, keepdims=True) + 1e-8)
# One matmul across all chunks: scores[i] = cos(query, chunk_i).
scores = d @ q
# Sort descending, take the first k indices.
return np.argsort(scores)[::-1][:k].tolist()
def answer(query: str, chunks: list[str], doc_mat: np.ndarray) -> str:
"""Retrieve top-K chunks, stuff them into a prompt, generate."""
q_vec = embed([query])[0]
idx = top_k_indices(q_vec, doc_mat, k=TOP_K)
context = "\n\n---\n\n".join(chunks[i] for i in idx)
prompt = (
"Answer the question using ONLY the context below. "
"If the context does not contain the answer, say so plainly.\n\n"
f"CONTEXT:\n{context}\n\nQUESTION: {query}"
)
resp = ollama.chat(
model=CHAT_MODEL,
messages=[{"role": "user", "content": prompt}],
)
return resp["message"]["content"]
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit('usage: python rag.py <folder> "<question>"')
folder, query = sys.argv[1], sys.argv[2]
chunks = load_chunks(folder)
if not chunks:
sys.exit(f"no .md or .txt files found under {folder}")
print(f"indexing {len(chunks)} chunks...")
doc_mat = embed(chunks)
print(answer(query, chunks, doc_mat))
Diríjalo hacia un directorio de notas y haz una pregunta:
python rag.py ~/Documents/meeting_notes "what did the team decide about pricing?"
El script recorre la carpeta, lee los archivos y los divide en trozos de 400 palabras. La lista completa de estos trozos se envía al endpoint de embedding de Ollama en un solo lote. Para cada pregunta, el vector de consulta se compara con cada vector de documento utilizando la similitud de coseno.
Los tres trozos principales se incluyen en el prompt de generación y qwen3:8b escribe la respuesta. Una instrucción en ese prompt es estricta: responder únicamente con el contexto proporcionado, y indicar claramente si el contexto no contiene la respuesta. Los modelos locales tienden a generar información inventada que suena útil cuando la recuperación no devuelve nada, y esta regla los obliga a admitir su ignorancia.
Esto no es una recuperación en entorno de producción. El proceso de fragmentación consiste en un conteo brusco de palabras que a veces divide una oración por la mitad. Las representaciones vectoriales permanecen en la memoria y desaparecen al finalizar, por lo que el corpus debe ser reconvertido en representaciones vectoriales en cada ejecución. Una vez que la colección supera unos pocos miles de documentos y el tiempo de inicio se vuelve molesto, sustituir la matriz numpy por sqlite-vec permite almacenar los vectores en un archivo SQLite local. Para notas personales, el script breve es más que suficiente.
Ruta C: un bucle de voz
Para hablar con un modelo sin tener que escribir ni enviar audio a un servicio en la nube, esta ruta crea un bucle completo de voz a voz en hardware local.
El proceso consta de tres partes: conversión de voz a texto para transcribir lo que dice el micrófono, el LLM para redactar una respuesta y conversión de texto a voz para leerla en voz alta.
La transcripción utiliza Whisper.cpp, una versión optimizada al máximo de Whisper de OpenAI en C++, que funciona en CPU, Apple Metal y NVIDIA CUDA. Requiere la dependencia ffmpeg para la conversión de audio. En macOS:
brew install whisper-cpp ffmpeg
En Linux, instale ffmpeg a través del gestor de paquetes, clone el repositorio whisper.cpp y ejecute make.
Whisper.cpp requiere pesos en formato GGML. El archivo ggml-base.en.bin, de aproximadamente 150 MB, está disponible en el repositorio oficial de Hugging Face y es rápido y preciso para el inglés. Para otros idiomas o mayor precisión, ggml-large-v3.bin es la alternativa. Coloque el archivo en una carpeta conocida como ~/models/.
La salida de voz proviene de Kokoro ONNX, un motor de TTS ligero cuyos pesos suman unos 300 MB y que sintetiza casi en tiempo real en una CPU normal. Actualmente requiere Python 3.10 a 3.13 y no se puede instalar en 3.14.
pip install -U sounddevice ollama kokoro-onnx
El script del pipeline
Guarde lo siguiente como voice.py y cambie la ruta WHISPER_MODEL para que apunte al archivo GGML que acaba de descargar.
"""Local voice loop — mic → whisper.cpp → Ollama → Kokoro → speaker.
Everything runs offline. Nothing leaves the machine.
Setup:
brew install whisper-cpp ffmpeg # macOS
# (Linux: apt install ffmpeg; build whisper.cpp from source)
pip install -U sounddevice ollama kokoro-onnx
# Whisper GGML model — pick one:
# ggml-base.en.bin (~150MB, fast, English-only)
# ggml-large-v3.bin (~3GB, accurate, multilingual)
# Download from https://huggingface.co/ggerganov/whisper.cpp
# Kokoro model files auto-download to ~/.cache/kokoro-onnx/
# on first run, or grab them manually from
# https://github.com/thewh1teagle/kokoro-onnx/releases
Run:
python voice.py
"""
from __future__ import annotations
import subprocess
import tempfile
import wave
from pathlib import Path
import sounddevice as sd
import ollama
from kokoro_onnx import Kokoro
WHISPER_MODEL = Path.home() / "models" / "ggml-base.en.bin"
CHAT_MODEL = "qwen3:8b"
KOKORO_VOICE = "af_sarah" # see kokoro voices.json for all options
SAMPLE_RATE = 16000 # whisper.cpp requires 16kHz mono
RECORD_SECONDS = 5
# Kokoro auto-downloads the model files on first instantiation.
kokoro = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")
def record() -> Path:
"""Record from the default mic, write a 16kHz mono WAV, return its path."""
print(f"listening for {RECORD_SECONDS}s...")
audio = sd.rec(
int(RECORD_SECONDS * SAMPLE_RATE),
samplerate=SAMPLE_RATE,
channels=1, # mono
dtype="int16", # 16-bit PCM is what wave.open expects
)
sd.wait() # block until recording finishes
# Fixed path in the system tempdir — overwritten each invocation.
wav_path = Path(tempfile.gettempdir()) / "voice_in.wav"
with wave.open(str(wav_path), "wb") as w:
w.setnchannels(1) # mono
w.setsampwidth(2) # 2 bytes per sample == int16
w.setframerate(SAMPLE_RATE) # 16 kHz — whisper.cpp's required rate
w.writeframes(audio.tobytes())
return wav_path
def transcribe(wav_path: Path) -> str:
"""Run whisper.cpp on a WAV file, return the transcript text."""
result = subprocess.run(
[
"whisper-cli",
"-m", str(WHISPER_MODEL),
"-f", str(wav_path),
"-otxt", # write the transcript to <input>.txt
"-np", # suppress progress prints on stdout
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError(f"whisper-cli failed: {result.stderr}")
# -otxt writes the transcript next to the input file, with .txt
# tacked onto the existing name — so /tmp/voice_in.wav becomes
# /tmp/voice_in.wav.txt (same directory, same stem, extra suffix).
txt_path = wav_path.with_suffix(wav_path.suffix + ".txt")
return txt_path.read_text(encoding="utf-8").strip()
def respond(text: str) -> str:
"""Send the transcript to the local LLM, return the reply."""
resp = ollama.chat(
model=CHAT_MODEL,
messages=[
{
"role": "system",
"content": (
"You are a voice assistant. Keep replies to one or "
"two short sentences. No markdown, no lists."
),
},
{"role": "user", "content": text},
],
)
return resp["message"]["content"]
def speak(text: str) -> None:
"""Synthesize the reply with Kokoro and play it back."""
samples, sample_rate = kokoro.create(
text, voice=KOKORO_VOICE, speed=1.0, lang="en-us"
)
sd.play(samples, sample_rate)
sd.wait()
if __name__ == "__main__":
wav = record()
heard = transcribe(wav)
if not heard:
print("nothing heard. exiting.")
raise SystemExit(0)
print(f"you said: {heard}")
reply = respond(heard)
print(f"model: {reply}")
speak(reply)
Al ejecutarlo, el script espera cinco segundos, transcribe el audio, genera una respuesta y la pronuncia. La primera ejecución se detiene mientras Kokoro descarga sus archivos ONNX en ~/.cache/kokoro-onnx/.
Tres decisiones de diseño son más importantes de lo que parecen. Es obligatorio grabar en 16 kHz en modo mono: Whisper fue entrenado con audio a 16 kHz, y los micrófonos por defecto que ofrecen 44.1 kHz generan texto inventado o resultados vacíos. Ejecutar whisper-cli como un subproceso separado en lugar de una extensión de Python evita conflictos de bloqueo y extensiones en C con fugas de memoria, y su flag de salida de texto guarda la transcripción en un archivo para que nadie tenga que extraerla de la salida estándar. El prompt del sistema también prohíbe explícitamente el uso de Markdown, ya que Kokoro lee los asteriscos en voz alta y una respuesta formateada arruina la experiencia de uso.
El bucle solo maneja una sola conversación. No transmite fragmentos de audio en tiempo real ni guarda el historial de las conversaciones. Un verdadero agente conversacional incluiría detección de actividad vocal para saber cuándo termina el habla, en lugar de utilizar un período fijo de cinco segundos. Como punto de referencia, demuestra que el hardware local puede ejecutar toda la cadena de conversión de voz a texto, el modelo y la síntesis de voz en pocos segundos.
Lo que permite el hardware
Los modelos locales están limitados por el ancho de banda de la memoria, mucho más que por la capacidad de cálculo bruta. La rapidez con la que los pesos se transfieren de la RAM a los núcleos de procesamiento es más importante que la velocidad con la que el procesador realiza operaciones aritméticas, y ese factor determina qué modelo es adecuado para una máquina.
Una laptop de 16 GB sin GPU dedicada alcanza un ancho de banda de memoria del sistema de 50 a 70 GB/s. Puede ejecutar qwen3:8b o qwen2.5-coder:7b en la CPU a 8 a 12 tokens por segundo, lo cual es suficiente para tareas de recuperación de información y voz, pero lento para la codificación. Apple Silicon invierte esta situación gracias a su memoria unificada, con un ancho de banda de 200 a 400 GB/s; por eso, una versión Pro o Max de la serie M con 24 a 36 GB puede manejar cómodamente qwen3-coder:30b, ya que el diseño MoE activa solo 3 mil millones de parámetros por token.
Las tarjetas NVIDIA intercambian capacidad por ancho de banda. Con 12 a 16 GB de VRAM, qwen3:8b se ejecuta al instante y qwen3-coder:30b cuantizado también funciona bien. Con 24 GB o más, como en una RTX 3090 o 4090, el modelo de 30 mil millones de parámetros funciona a toda velocidad, lo que resulta similar a tener una suscripción de pago para trabajos con un solo archivo. Con menos de 8 GB de RAM, la codificación está prácticamente fuera de alcance, por lo que lo realista es utilizar qwen3:4b o phi-4-mini para tareas de voz y recuperación de información de pequeña escala.
Leer más
- Notas prácticas: Construcción de un agente de programación en nube privada: OpenCode + un LLM local — Guía paso a paso de las Notas prácticas: Construcción de un agente de programación en nube privada: OpenCode + un LLM local: contratos, verificaciones y espacios de código listos para usar para los equipos que implementan este patrón.
- Rastreo de un agente RAG de voz con LiveKit, Qdrant y MLflow — Crea un asistente de hipotecas de voz a voz utilizando LiveKit, LlamaIndex y Qdrant, con trazas de OpenTelemetry en MLflow que muestran cada recuperación y paso del LLM.