NPCs con LLM: arquitectura básica
Cómo conectar un NPC de Unity a un LLM (OpenAI, Anthropic) y conseguir que responda al jugador. System prompt, contexto, costos por interacción y latencia explicados sin humo.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Un NPC con diálogo escrito a mano envejece en la primera partida. El jugador prueba la línea “interesante”, la oye, y al volver a hablar con el mismo NPC ya sabe qué va a salir. Los LLMs (Large Language Models) abren otra puerta: el NPC compone su respuesta en runtime a partir de un prompt y el contexto de la conversación.
No es magia. Es una llamada HTTP a un servicio externo (OpenAI, Anthropic, Google) o local (Ollama, llama.cpp) que devuelve texto. Lo que tú escribes es el prompt, lo que el modelo entiende como “quién es este personaje y qué sabe”. Lo que pagas (en API comercial) son los tokens que entran y los que salen.
Este tutorial es el punto de entrada. Aquí solo conectas Unity a una API y consigues que un NPC responda. Memoria persistente, function calling, RAG y LLMs locales viven en tutoriales aparte.
En este tutorial vas a ver:
- Qué es un LLM y qué hace cuando lo llamas.
- La arquitectura mínima de un NPC con LLM (cliente HTTP, system prompt, history).
- Los costos reales por interacción y cómo no quemar tu wallet.
1. Demo
2. Concepto y arquitectura
Un LLM es un modelo entrenado para predecir el siguiente token (≈ una palabra o trozo de palabra) dado un texto previo. Cuando lo “llamas” no le mandas tu pregunta en limpio: le mandas un contexto formado por un system prompt (las reglas del personaje), el historial de la conversación y el mensaje actual del jugador. El modelo continúa la conversación generando la siguiente respuesta token por token.
2.1 ¿Qué pasa exactamente cuando llamas a un LLM?
El cliente arma un JSON con la lista de mensajes y la manda por HTTPS a la API. El servidor procesa el contexto, genera tokens uno a uno hasta llegar a un stop token o al max_tokens, y devuelve la respuesta. Cada llamada es independiente: el servidor no recuerda nada entre requests. Si quieres memoria, tú la mandas en cada llamada.
El cliente arma el JSON con system + history + user y lo manda por HTTPS. La API tokeniza, predice y devuelve content + usage. Cada llamada es stateless.
2.2 ¿Qué es un agente NPC con LLM en términos de código?
Un objeto que, mínimo, tiene:
systemPrompt— quién es el personaje. Inmutable durante la sesión.history— lista de mensajes (rol + contenido). Crece con cada turno.client— el envío HTTP a la API.- Límites:
maxHistoryMessages(cuántos turnos guardar),maxTokens(cuántos puede generar la respuesta),temperature(qué tan creativo es).
El bucle es simple: jugador escribe → tú agregas su mensaje al history → llamas a la API con system + history → recibes respuesta → la agregas al history → la muestras.
2.3 ¿Cómo se escribe un system prompt para un NPC?
El system prompt es el “alma” del personaje. Debe especificar:
- Identidad: nombre, rol, edad aproximada, origen.
- Tono: formal/informal, irónico, agresivo, melancólico.
- Conocimientos: qué sabe del mundo del juego, qué no sabe.
- Restricciones: qué nunca puede decir, qué nunca puede revelar.
- Formato: longitud preferida, si usa primera persona, si describe acciones entre asteriscos.
Ejemplo para un mercader en una taberna:
Eres Maerwyn, mercader nómada en una taberna del reino de Veldria.
Tienes 50 años, hablas con cansancio pero con humor seco.
Vendes pociones, mapas y rumores. Nunca rebajas precios sin un motivo en la conversación.
No sabes nada del mundo real ni de tecnología; si el jugador pregunta algo así, te confundes con gracia.
Respondes en 1-3 frases. Acciones físicas entre asteriscos (*se inclina*).
2.4 System prompt vs historial: ¿qué va dónde?
| Va en system prompt | Va en historial |
|---|---|
| Identidad del personaje | Lo que dijo el jugador |
| Reglas inmutables | Lo que dijo el NPC en turnos anteriores |
| Estado del mundo estático (lore) | Eventos recientes (“el jugador acaba de matar al guardia”) |
| Formato de respuesta esperado | Cambios de estado dinámicos |
El system prompt se manda en cada llamada porque el modelo no lo recuerda. Sí, eso significa que pagas esos tokens cada turno. Hay una técnica para reducirlo (prompt caching) que verás en el tutorial de costos.
2.5 ¿Cómo se limita el contexto sin romper la coherencia?
El history crece sin freno. Si no lo recortas, en 20 turnos tu llamada manda 10 000 tokens y la latencia se dispara. Estrategias:
- Sliding window: guardas solo los últimos N turnos (N entre 6 y 20 según juego).
- Summary rolling: cuando llegas al límite, pides al LLM un resumen de los turnos antiguos y reemplazas esos turnos por el resumen.
- Eventos pinned: marcas algunos turnos como “no descartar” (la primera vez que el jugador dio su nombre, una promesa importante).
Para un NPC tipo mercader o guardia, sliding window de 8-10 turnos basta. Para un compañero de aventura con memoria larga necesitas el tutorial de memoria persistente.
2.6 ¿Cuánto cuesta cada interacción en una API comercial?
Los precios se publican en USD por millón de tokens y cambian. Para fijar ideas con valores recientes (siempre verifica al implementar):
| Modelo (ejemplo) | Input USD/1M tok | Output USD/1M tok |
|---|---|---|
| Modelo económico (Haiku-tier, GPT mini) | ~0.25 – 1.00 | ~1.00 – 4.00 |
| Modelo medio (Sonnet-tier, GPT-4o) | ~3.00 – 5.00 | ~10.00 – 20.00 |
| Modelo top (Opus-tier, GPT-4 frontier) | ~15.00 | ~75.00 |
Un turno típico de NPC en un juego: ~800 tokens de input (system + history) y ~80 tokens de output. Con modelo económico, un turno cuesta del orden de 0.0003 USD (3 centavos por 100 turnos). Con modelo top, de 0.018 USD por turno.
Multiplica por la cantidad de turnos que un jugador hace en una sesión, por jugadores activos, y verás por qué la elección del modelo importa. Detalles en el tutorial de costos.
3. Pseudocódigo
class NpcLlmAgent
systemPrompt: String
history: List<Message>
maxHistoryMessages: Int = 10
maxTokens: Int = 200
temperature: Float = 0.8
function chat(agent: NpcLlmAgent, userMessage: String) -> String
agent.history.append(Message("user", userMessage))
trimHistory(agent)
messages = [Message("system", agent.systemPrompt)] + agent.history
response = callApi(messages, agent.maxTokens, agent.temperature)
agent.history.append(Message("assistant", response.content))
return response.content
function trimHistory(agent: NpcLlmAgent)
while agent.history.length > agent.maxHistoryMessages
agent.history.removeFirst()
function callApi(messages: List<Message>, maxTokens: Int, temp: Float) -> ApiResponse
body = { model: "...", messages: messages, max_tokens: maxTokens, temperature: temp }
return httpPost(API_URL, body, headers={Authorization: "Bearer " + API_KEY})
Tres ideas: el system prompt no se persiste en history (se inyecta cada llamada), el trim evita el crecimiento infinito, y la API es stateless desde el lado del servidor.
4. Implementación en Unity / C#
Cliente mínimo con HttpClient apuntando a la API de OpenAI. El mismo patrón sirve para Anthropic cambiando endpoint y formato del body.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
public class NpcLlmAgent : MonoBehaviour {
[TextArea(4, 10)] public string systemPrompt;
public int maxHistoryMessages = 10;
public int maxTokens = 200;
[Range(0f, 1.5f)] public float temperature = 0.8f;
readonly List<(string role, string content)> history = new();
static readonly HttpClient http = new();
public async Task<string> Chat(string userMessage) {
history.Add(("user", userMessage));
TrimHistory();
var messages = new List<object> {
new { role = "system", content = systemPrompt }
};
foreach (var (role, content) in history)
messages.Add(new { role, content });
var body = JsonUtility.ToJson(new {
model = "gpt-4o-mini",
messages = messages.ToArray(),
max_tokens = maxTokens,
temperature = temperature
});
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("OPENAI_API_KEY")}");
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await http.SendAsync(req);
var json = await resp.Content.ReadAsStringAsync();
var reply = ParseAssistantMessage(json); // helper que extrae choices[0].message.content
history.Add(("assistant", reply));
return reply;
}
void TrimHistory() {
while (history.Count > maxHistoryMessages) history.RemoveAt(0);
}
string ParseAssistantMessage(string json) {
// En producción usa Newtonsoft.Json o System.Text.Json. JsonUtility de Unity no maneja bien estructuras anidadas.
return ""; // implementación omitida por brevedad
}
}
5. En otros engines
- Godot:
HTTPRequestnode + el mismo JSON. Si usas C# en Godot, el código es prácticamente idéntico al de arriba. - Unreal:
FHttpModule+IHttpRequest. Más verboso pero misma idea. Plugins comoOpenAI-Api-Unrealempaquetan el cliente. - JavaScript / Electron / web:
fetchdirecto. Cuidado igual con la API key — usa proxy.
La parte que cambia entre engines es el cliente HTTP y la UI; el contenido del prompt y la arquitectura no cambian.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu NPC olvida quién es cada vez que el jugador empieza una nueva línea de diálogo. ¿Qué te falta?
Tras 30 turnos, las respuestas del NPC tardan 8 segundos y cuestan el triple. ¿Qué estás haciendo mal?
¿Por qué NO meter la API key dentro del build de Unity?
¿Qué va en el system prompt y qué va en el historial?