Diálogos generados con LLM en pipeline (no runtime)
Generar miles de líneas de NPC en build time. Sin costo por turno, sin latencia, sin riesgo de output inapropiado. El compromiso: no es conversacional.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Quieres variedad de diálogo en tu juego, pero meter un LLM en runtime te trae tres dolores de cabeza: pagas tokens por cada turno, esperas entre uno y cinco segundos por respuesta, y nunca sabes cuándo el modelo va a soltar algo fuera de tono. Si lo que necesitas son líneas estáticas pero muchas —barks de NPC en un open world, gritos de combate, comentarios de público en un juego deportivo— hay una salida intermedia.
La idea es mover el LLM al pipeline. Durante el desarrollo, llamas al modelo desde un script de editor o un CI job para generar miles de variantes a partir de templates parametrizados. Las guardas en un ScriptableObject o un JSON. En runtime, tu juego solo hace lookup: cero llamadas, cero latencia, cero sorpresas.
En este tutorial vas a ver:
- Cuándo la pre-generación gana y cuándo pierde frente al LLM en runtime.
- Cómo diseñar templates con slots para cubrir variantes con poco esfuerzo.
- Cómo almacenar el catálogo y seleccionar líneas sin repetición.
1. Demo
2. Concepto y arquitectura
Los diálogos pre-generados con LLM consisten en usar un modelo durante desarrollo para crear muchas variantes de líneas a partir de templates parametrizados. En runtime no se llama al LLM; solo se lee del catálogo. Costo cero por turno, control total sobre el output.
El compromiso es claro: pierdes capacidad conversacional (el NPC no responde a lo que el jugador escriba), pero ganas todo lo demás —latencia despreciable, factura predecible, contenido revisable antes de ship.
A la izquierda, el pipeline offline que genera y cura el catálogo. A la derecha, el runtime que solo hace lookup.
2.1 ¿Cuándo build-time gana a runtime?
Gana cuando las líneas son estáticas en su rol: el NPC dice una frase reactiva a un evento del juego (recibió un golpe, vio al jugador, se quedó sin maná), no a un texto libre del jugador. Si el universo de combinaciones es acotado —pongamos, menos de mil situaciones distintas— lo cubres con pre-generación y sales ganando.
Pierde cuando el jugador escribe o habla en lenguaje natural y esperas que el NPC entienda y responda. Eso solo lo hace un LLM en vivo, con todo lo que cuesta.
2.2 Templates con slots
Un template es un prompt con huecos (slots) que rellenas combinatoriamente. Ejemplo:
Genera 5 frases cortas (máximo 12 palabras) que dice un {role}
cuando {event} con tono {emotion}. No uses signos de exclamación múltiples.
Devuelve una frase por línea, sin numeración.
Llenas los slots con todos los valores que te interesan:
role = [guardia, mercader, aldeano]
event = [ve al jugador, recibe un golpe, encuentra oro]
emotion = [sorprendido, irritado, cansado]
El cross-product te da 3 × 3 × 3 = 27 combinaciones. Pides 5 variantes por combinación y tienes 135 líneas. Con un modelo económico, el costo total ronda los 0.05 USD. Compáralo con pagar por cada turno en runtime.
2.3 ¿Cuántas variantes generar y para qué casos?
La regla práctica: cubre el cross-product mínimo viable y luego itera. Para un open world tipo Skyrim con barks de guardia:
- 4 estados emocionales × 6 eventos × 3 momentos del día = 72 combos.
- 5 variantes por combo = 360 líneas.
- A 80 tokens por línea, son 28 800 tokens de output. Calderilla.
Si después detectas que un combo está sobreexpuesto (los jugadores reportan repetición), regeneras solo ese subconjunto y mezclas con el pool anterior.
2.4 Curado humano: el paso crítico
El LLM genera; una persona revisa. No es opcional. Vas a encontrar:
- Líneas que rompen el tono del personaje.
- Duplicados semánticos disfrazados de variantes.
- Anacronismos (un guardia medieval diciendo “vamos a checar”).
- Cosas raras que el modelo metió por su cuenta.
La buena noticia: revisar 360 líneas se hace en una tarde. Revisar 30 000 turnos generados en vivo durante la vida del juego es imposible. Por eso la pre-generación es auditable y el runtime no.
2.5 ¿Cómo se almacenan?
Un ScriptableObject con una lista de entradas, cada una con su tag y sus variantes. En Unity es el patrón idiomático porque te da serialización gratis, inspector visible y referencias fuertes desde el editor.
Cada entrada agrupa un tag (rol.evento.emoción) y su lista de variantes. El bag random saca una sin repetir.
El lookup en runtime es un Dictionary<string, List<string>> indexado al cargar. O(1) por consulta.
2.6 Selección en runtime sin repetición
Random puro va a repetir. Si un jugador dispara el mismo evento diez veces seguidas, una distribución uniforme te dará la misma línea dos veces antes de que termines. La solución estándar es bag-based shuffling: barajas las variantes, sacas una por una, y solo rebarajas cuando se vacía la bolsa. Es lo que usan los sistemas de loot en juegos donde no quieres que el jugador sienta “mala suerte”.
Si quieres además que algunas líneas sean más raras, las metes en la bolsa con pesos —una línea con peso 3 aparece tres veces en la bolsa, una con peso 1 aparece una vez.
2.7 ¿Cómo iterar cuando el juego cambia?
Versionas los templates y los outputs en git, igual que código. Cuando cambies el tono del juego, las edades de los NPCs o agregues un evento nuevo, lanzas el script de generación en CI y revisas el diff. Esto te da:
- Reproducibilidad: la misma seed + el mismo prompt + el mismo modelo deberían darte el mismo pool (con caveats: los modelos comerciales no son 100% deterministas).
- Regresiones controladas: si un cambio rompe el tono, lo ves en el diff de líneas antes de que llegue al jugador.
- Auditoría: cuando un community manager te pregunte “¿de dónde salió esta línea?”, el git blame te lo dice.
2.8 Audio: combinar con TTS
El output del LLM va directo a un sistema de TTS (text-to-speech) para generar la voz. Lo grabas también en pipeline, lo guardas como .ogg indexado por el mismo tag, y en runtime tienes audio + texto sincronizados. Los detalles de voces sintéticas viven en su propio tutorial, pero la pieza clave es: mismo pipeline, mismo tag, mismo control de calidad.
3. Pseudocódigo
function generateDialoguePool(templates: List<PromptTemplate>, llm: LlmClient) -> Dict<Tag, List<String>>
pool = {}
for each tmpl in templates
# rellena los slots del template con todas las combinaciones
for each combo in tmpl.slotCombinations()
prompt = fillSlots(tmpl, combo)
tag = combo.asTag()
variants = []
# varias pasadas para acumular diversidad
for i in 0..tmpl.passes
response = llm.generate(prompt, temperature=0.9)
variants += parseLines(response)
pool[tag] = deduplicate(variants)
return pool
function pickRuntime(pool: Dict<Tag, List<String>>, tag: Tag, bag: BagState) -> String
candidates = pool[tag]
if candidates is empty: return null
if bag.isEmpty(tag): bag.reset(tag, shuffle(candidates))
return bag.drawNext(tag)
Tres ideas: el template explota su slot space sin que escribas cada prompt a mano, varias pasadas con temperature alto te dan diversidad, y el bag evita repetición percibida sin negar la aleatoriedad.
4. Implementación en Unity / C#
Dos piezas: un script de editor ([MenuItem]) que llama al LLM y guarda el pool, y un componente de runtime que sirve líneas con bag-based shuffle.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Dialogue/Pool")]
public class DialoguePool : ScriptableObject {
[System.Serializable]
public class Entry {
public string tag;
public List<string> variants;
}
public List<Entry> entries;
Dictionary<string, List<string>> index;
readonly Dictionary<string, Queue<string>> bags = new();
public void EnsureIndex() {
if (index != null) return;
index = new Dictionary<string, List<string>>();
foreach (var e in entries) index[e.tag] = e.variants;
}
public string Get(string tag) {
EnsureIndex();
if (!index.TryGetValue(tag, out var pool) || pool.Count == 0) return null;
if (!bags.TryGetValue(tag, out var bag) || bag.Count == 0) {
var shuffled = new List<string>(pool);
for (int i = shuffled.Count - 1; i > 0; i--) {
int j = Random.Range(0, i + 1);
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
}
bag = new Queue<string>(shuffled);
bags[tag] = bag;
}
return bag.Dequeue();
}
}
Y un editor script mínimo que dispara la generación (omito el cliente HTTP por brevedad; reutiliza el del tutorial de NPCs con LLM):
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public static class DialoguePoolGenerator {
[MenuItem("Tools/Dialogue/Generate Pool from Templates")]
public static void Generate() {
var templates = AssetDatabase.LoadAssetAtPath<TemplateSet>("Assets/Dialogue/templates.asset");
var pool = ScriptableObject.CreateInstance<DialoguePool>();
pool.entries = new System.Collections.Generic.List<DialoguePool.Entry>();
foreach (var combo in templates.AllCombinations()) {
string prompt = templates.FillSlots(combo);
string response = LlmClient.GenerateSync(prompt, temperature: 0.9f, maxTokens: 300);
pool.entries.Add(new DialoguePool.Entry {
tag = combo.AsTag(),
variants = LlmClient.ParseLines(response)
});
}
AssetDatabase.CreateAsset(pool, "Assets/Dialogue/pool.asset");
AssetDatabase.SaveAssets();
Debug.Log($"Generated {pool.entries.Count} entries.");
}
}
#endif
5. En otros engines
- Godot: usa un
toolscript (corre en el editor) que llame a la API y guarde unResourcecon la lista de entradas. El consumo en runtime es idéntico al de Unity: dictionary + bag. - Unreal: un Editor Utility Widget con un botón que dispara la generación, escribe a un
UDataAsset. En runtime, unUDialoguePoolComponentconGet(FName tag). - JavaScript / Web / Node: un script de Node.js que llama a la API y escribe
pool.json. El cliente carga el JSON confetch. Sin asset pipeline, todo es texto plano.
Lo que cambia entre engines es la serialización y el editor. La arquitectura —templates, generación batch, curación, bag-based pick— es la misma.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Quieres que el NPC entienda y responda a lo que el jugador escriba libremente. ¿Sirve pre-generación?
Tu pool tiene 300 líneas pero los jugadores reportan que el guardia 'siempre dice lo mismo'. ¿Qué falla?
¿Quién revisa los outputs del LLM antes de que lleguen al jugador en una pipeline pre-generada?
¿Por qué versionar los prompts y los outputs en git?