NPCs con LLM local: Ollama + Llama 3 en Unity
Sin API key, sin costo por token, sin internet en runtime. Cómo correr un LLM dentro de la máquina del jugador con Ollama y conectarlo desde Unity en localhost.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Conectar un NPC a la API de OpenAI o Anthropic funciona, pero arrastra cuatro problemas que no se ven el primer día: pagas por cada token, dependes de la red del jugador, el contenido de la partida sale de tu juego rumbo a un tercero, y si tu API key se filtra alguien te factura la cena. Para muchos juegos esto es aceptable. Para mods de Skyrim con NPCs hablantes, juegos offline-first o prototipos sin facturación, no lo es.
La alternativa es correr el modelo dentro de la máquina del jugador. Ollama empaqueta esa idea: instalas un servicio que carga modelos en formato GGUF (Llama 3, Mistral, Phi-3) y los expone vía HTTP en localhost:11434. Desde Unity te conectas igual que a OpenAI; lo único que cambia es la URL y que el costo por token es cero.
Este tutorial asume que ya leíste NPCs con LLM: arquitectura básica. El system prompt, el history y el bucle de chat funcionan idénticos. Aquí cambias el backend.
En este tutorial vas a ver:
- Qué es Ollama y cómo se compara con una API comercial.
- Qué modelo elegir según la RAM del jugador.
- Cómo distribuir un juego que depende de un LLM local sin volver loco al usuario.
1. Demo
2. Concepto y arquitectura
Ollama es un servidor local que corre modelos LLM en formato GGUF (Llama 3, Mistral, Phi-3) y los expone vía HTTP REST en localhost. Desde Unity te conectas igual que a OpenAI; la diferencia es que el endpoint es tu propia máquina y el costo es cero por token.
2.1 ¿Qué es Ollama y qué hace diferente?
Por debajo, Ollama es un wrapper sobre llama.cpp — la implementación en C++ que popularizó correr LLMs en hardware de consumidor. Lo que aporta Ollama encima es la parte que llama.cpp no tiene: un daemon HTTP siempre escuchando, un registro de modelos descargables con ollama pull llama3, y una API REST estable.
Cuando instalas Ollama y ejecutas ollama serve (o lo dejas como servicio del sistema), queda escuchando en localhost:11434. No hay autenticación porque no sale de tu máquina. Cualquier proceso de tu PC puede hacer un POST y obtener una respuesta del modelo.
La API tiene dos endpoints clave para diálogo:
POST /api/chat— el equivalente achat/completionsde OpenAI: mandas lista de mensajes con roles, recibes respuesta del assistant.POST /api/generate— completion crudo, sin formato de chat. Útil si quieres control total del prompt template.
Hay también /api/tags (lista de modelos instalados, útil para health check) y /api/pull (descarga modelos programáticamente).
2.2 ¿Cómo se compara con una API comercial?
| Aspecto | API comercial | LLM local (Ollama) |
|---|---|---|
| Costo por turno | 0.0003 – 0.02 USD | 0 |
| Latencia | 1 – 5 s + red | depende del hardware del jugador |
| Privacidad | el prompt viaja a un tercero | todo se queda en la máquina |
| Calidad | frontier (GPT-4, Claude Opus) | depende del modelo elegido |
| Distribución | basta con una API key | el jugador instala Ollama o tú lo bundleas |
| Offline | imposible | posible por diseño |
| Multilingual nativo | sí | parcial según modelo |
No es “mejor uno que otro”, son trade-offs distintos. Si tu juego es un MMO online de todos modos, la red ya está; la API comercial es la opción cómoda. Si es un single-player que el jugador puede meter en un avión, Ollama gana.
2.3 ¿Qué modelo elegir según el hardware?
La métrica que importa es la RAM disponible (o VRAM si el jugador tiene GPU dedicada). El modelo entero, cuantizado, tiene que caber. Si no cabe, Ollama lo carga en disco y la latencia se vuelve inusable.
| Modelo | Tamaño Q4 | RAM mínima | Calidad para NPCs |
|---|---|---|---|
| Phi-3-mini 3.8B Q4 | ~2.4 GB | 4 – 6 GB | aceptable para diálogo simple, frases cortas |
| Llama 3 8B Q4 | ~5 GB | 8 GB | bueno, estándar de facto para indies |
| Mistral 7B Q4 | ~4.3 GB | 6 – 8 GB | rápido, calidad correcta, fuerte en código |
| Llama 3 70B Q4 | ~40 GB | 48 GB o GPU 24 GB+ | calidad GPT-4-ish, pero pesado y lento |
Para un NPC mercader o guardia, Phi-3-mini o Mistral 7B sobran. Para un compañero con diálogos largos y razonamiento ligero, Llama 3 8B es el punto dulce. Llama 3 70B solo si tu público objetivo es PC entusiasta — quedará fuera del 90% de los jugadores.
2.4 ¿Cómo lo distribuyo con mi juego?
Tres caminos, ordenados de menos a más fricción para el desarrollador (y al revés para el jugador):
-
El jugador instala Ollama aparte. Tu juego lo detecta y, si está corriendo, activa el modo IA. Si no, muestra instrucciones. Es la opción más limpia para indies: tu build no engorda, no peleas con licenciamiento, y los updates del modelo no son tu problema. Costo: la fricción de pedir a un jugador que instale algo aparte.
-
Bundle de llama.cpp dentro del build. Embebes el binario y cargas el modelo desde el directorio del juego. Más control, sin pasos extra para el jugador. Pero tu build pesa varios GB más, lidias con builds por plataforma, y revisas la licencia (Llama 3 tiene cláusulas comerciales específicas).
-
Híbrido: API comercial por defecto, Ollama como “modo privacidad / offline”. Tu juego abre con API comercial y un setting opcional “Usar IA local si está disponible”. Detectas si Ollama responde en
localhost:11434; si sí, switcheas. Si no, sigues con la API. Esta es la opción más amable para el jugador medio.
La mayoría de mods con NPCs LLM eligen la opción 1 porque ya asumen un usuario técnico. Juegos pulidos para público general suelen ir con la 3.
2.5 ¿Cómo se ve el endpoint de Ollama vs el de OpenAI?
A nivel de payload son casi idénticos. Aquí el flujo completo:
El cliente Unity hace POST a localhost:11434/api/chat. El daemon de Ollama tiene el modelo cargado en RAM y devuelve la respuesta. Nada sale del PC.
Carga del modelo (lento, una sola vez) → tokenización del prompt → generación token a token → JSON de salida con eval_count y eval_duration.
eval_duration viene en nanosegundos; dividido entre eval_count da los tokens por segundo reales en esa máquina. Útil para ajustar UX y mostrar al jugador si su hardware da la talla.
2.6 ¿Qué pierdes con un LLM local pequeño?
Honestidad antes que hype. Comparado con un GPT-4 o Claude Opus, un Llama 3 8B local te recorta en:
- Razonamiento complejo multi-paso. Si tu NPC tiene que combinar 4 piezas de lore para deducir algo, los modelos pequeños fallan más.
- Conocimiento de mundo amplio. Un modelo 8B sabe menos del mundo real que un frontier. Para NPCs de fantasía con lore propio, no importa: tú le das el lore en el system prompt.
- Multilingual fino. Llama 3 8B habla español bien pero comete giros raros. Mistral 7B es algo mejor en lenguas latinas. Los frontier comerciales siguen siendo más naturales.
- Instruction following estricto. “Responde SIEMPRE en menos de 30 palabras” se cumple peor en modelos chicos.
Lo que ganas: respuestas sin red, costo cero, privacidad total, y reproducibilidad (puedes fijar versión del modelo y la respuesta es estable entre updates).
2.7 ¿Qué latencia esperar en hardware típico?
Orden de magnitud para Llama 3 8B Q4 generando ~60 tokens (un turno corto de NPC). Son ejemplos, no benchmarks formales:
| Hardware | Tokens/seg | Latencia ~60 tokens |
|---|---|---|
| CPU 8-core moderno (Ryzen 5, i5) | 10 – 20 t/s | 3 – 6 s |
| Mac M2/M3 (memoria unificada) | 30 – 50 t/s | 1.2 – 2 s |
| GPU RTX 3060 (8 GB VRAM) | 50 – 80 t/s | ~1 s |
| GPU RTX 4090 | 100+ t/s | < 1 s |
La primera llamada incluye además la carga inicial del modelo en RAM, que puede tomar 5 – 30 segundos en SSDs lentos. Después se queda residente y la latencia baja al rango de la tabla. Diseña la UX asumiendo este “calentamiento”: pre-carga el modelo en el menú principal, no en mitad del diálogo.
3. Pseudocódigo
class OllamaClient
baseUrl: String = "http://localhost:11434"
model: String = "llama3"
timeout: Duration = 30s
function chat(client: OllamaClient, messages: List<Message>) -> String
body = {
model: client.model,
messages: messages,
stream: false
}
response = httpPost(client.baseUrl + "/api/chat", body, client.timeout)
return response.message.content
function isOllamaRunning(client: OllamaClient) -> Bool
# Health check rápido. Si /api/tags responde, el daemon está vivo.
try
response = httpGet(client.baseUrl + "/api/tags", timeout=500ms)
return response.statusCode == 200
catch
return false
function isModelInstalled(client: OllamaClient, modelName: String) -> Bool
response = httpGet(client.baseUrl + "/api/tags")
return response.models contains modelName
Tres ideas. Primero, no necesitas API key — el daemon es local y no autentica. Segundo, el health check es obligatorio: si Ollama no está corriendo, no quieres que tu juego se cuelgue 30 segundos en cada turno. Tercero, comprueba que el modelo está instalado antes de pedir inferencia, o caes en un 404 con mensaje críptico.
4. Implementación en Unity / C#
Cliente mínimo con HttpClient. Mismo NpcLlmAgent que en el tutorial básico, pero apuntando a Ollama. Lo importante es el health check antes de cualquier llamada y el parser JSON real (no JsonUtility, que se atraganta con estructuras anidadas).
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
public class OllamaClient : MonoBehaviour {
public string baseUrl = "http://localhost:11434";
public string model = "llama3";
public float requestTimeoutSeconds = 30f;
static readonly HttpClient http = new() { Timeout = TimeSpan.FromMinutes(2) };
public async Task<bool> IsAvailable(CancellationToken ct = default) {
try {
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromMilliseconds(500));
var resp = await http.GetAsync($"{baseUrl}/api/tags", cts.Token);
return resp.IsSuccessStatusCode;
} catch {
return false;
}
}
public async Task<string> Chat(List<(string role, string content)> messages, CancellationToken ct = default) {
var payload = new {
model,
messages = messages.ConvertAll(m => new { role = m.role, content = m.content }),
stream = false
};
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(requestTimeoutSeconds));
var resp = await http.PostAsync($"{baseUrl}/api/chat", content, cts.Token);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
var parsed = JObject.Parse(body);
return parsed["message"]?["content"]?.ToString() ?? "";
}
}
Notas rápidas. HttpClient se declara static readonly para no abrir un socket por turno (un error clásico que termina en SocketException). El timeout del request va por CancellationToken linked; el del propio HttpClient es solo backstop. Y Newtonsoft.Json es estándar en Unity moderno; viene con muchos packages o lo añades vía com.unity.nuget.newtonsoft-json.
5. En otros engines
- Godot:
HTTPRequestapuntando ahttp://localhost:11434/api/chat. El JSON es idéntico. Si usas C# en Godot, el cliente de arriba funciona casi tal cual. - Unreal:
FHttpModule+IHttpRequest. Misma idea, más boilerplate. Hay plugins comunitarios tipoUELlamaque envuelven llama.cpp directo si prefieres saltarte Ollama. - JavaScript / Electron / web:
fetch('http://localhost:11434/api/chat', { method: 'POST', body: JSON.stringify(...) }). Si tu juego corre en navegador puro (no Electron), choca con CORS — Ollama no manda los headers que el navegador exige. Solución: pequeño proxy local, o lanzar Ollama conOLLAMA_ORIGINS=*.
La parte que cambia entre engines es el cliente HTTP. El protocolo de Ollama es el mismo desde cualquier lado.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu juego funciona perfecto en tu PC. Un jugador reporta: 'instalé el juego pero los NPCs no responden, solo dicen "...".'. ¿Qué chequeas primero?
Tu juego viene con Llama 3 70B por defecto y un jugador con 16 GB de RAM lo prueba. ¿Qué pasa?
Tu juego cumple calidad tanto con Ollama local como con la API de OpenAI. ¿Cuándo elegirías la API comercial?
Ollama está instalado y el servicio aparece corriendo en el task manager. Aun así, tu cliente de Unity falla la conexión a localhost:11434. ¿Causa más probable?
Cuantización Q4 vs Q8 del mismo modelo. ¿Qué trade-off describe mejor la diferencia?