LLMs Intermedio 18 min de lectura

Prompts dinámicos por estado emocional del NPC

El NPC enojado, asustado o eufórico necesita un prompt distinto. Templates parametrizados por estado emocional para que el LLM hable distinto según cómo se siente.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

El jugador le acaba de matar al hermano a Maerwyn. Habla con ella un minuto después y la mercader responde con el mismo tono cansado pero amable de siempre. Le entrega una bolsa de oro. Misma respuesta. Le pega un puñetazo. Misma respuesta otra vez. Tu NPC con LLM tiene una personalidad, sí, pero esa personalidad es plana como una tabla: no reacciona al momento, solo recita.

El problema no es el modelo. El problema es que mandas el mismo system prompt todo el tiempo. Si lo que cambia es el estado emocional del NPC (lo modelaste con OCC/PAD, lo lees del Utility AI, lo trackeas como sea), ese estado tiene que llegar al LLM. Y no llega por arte de magia: lo inyectas tú, en runtime, reconstruyendo el prompt cada vez que la emoción cruza un umbral relevante.

Eso es un prompt dinámico: un system prompt no estático, sino una plantilla que rellenas con modificadores según el estado actual del NPC. La identidad base sigue siendo la misma; el tono, la urgencia, las palabras permitidas o prohibidas cambian.

En este tutorial vas a ver:

  • Qué parte del prompt es fija y qué parte cambia con la emoción.
  • Cómo traducir un vector PAD a frases que el LLM entiende.
  • Cómo construir y cachear el prompt sin pegarle al rebuild en cada frame.

1. Demo

Mismo NPC, tres prompts distintos Placeholder: el estado emocional del NPC genera tres versiones del system prompt; abajo se ve cómo cambia la respuesta del LLM ante el mismo input.

2. Concepto y arquitectura

Un prompt dinámico es un system prompt construido cada turno a partir de plantillas y del estado actual del NPC. La identidad base es fija; el tono, la urgencia, las palabras prohibidas o permitidas cambian según mood, HP o contexto reciente. El LLM sigue siendo el mismo; lo que cambia es lo que le cuentas antes de pedirle que hable.

2.1 ¿Qué partes del prompt se mantienen estáticas y cuáles cambian?

No todo el prompt está en juego. Si dejas que la emoción reescriba la identidad, el NPC pierde coherencia entre turnos. La regla es brutal: identidad y reglas duras NO se tocan; solo el envoltorio expresivo cambia.

Parte del prompt¿Cambia con la emoción?
Identidad (“eres Maerwyn, mercader de Veldria”)NO
Conocimiento canónico (lore, historia personal)NO
Restricciones duras (no revelar secretos, no romper personaje)NO
Tono y estilo de respuestaSÍ (según mood)
Acciones físicas sugeridasSÍ (según mood + HP)
Longitud sugerida de la respuestaSÍ (alerta → corto, relax → largo)
Vocabulario permitido / prohibidoSÍ (furioso → maldice; asustado → balbucea)

Piensa el prompt como una cebolla. El núcleo (identidad + reglas duras) no se cocina; las capas externas (tono, ritmo, vocabulario) las reescribes cuando hace falta.

2.2 De PAD a texto: el truco

El modelo PAD te da tres números continuos entre −1 y +1: Pleasure, Arousal, Dominance. El LLM no entiende vectores. Entiende prosa. Necesitas una capa de traducción que mapee regiones del cubo PAD a frases concretas, listas para inyectar.

Región PAD (P, A, D)TagTexto inyectado
P alto, A bajo, D altocalm”estás tranquilo y satisfecho, hablas con voz pausada, frases largas”
P alto, A alto, D altojoyful”estás eufórico, hablas rápido, exclamas, te ríes en medio de frases”
P bajo, A alto, D altoangry”estás furioso, hablas en frases cortas y agresivas, interrumpes”
P bajo, A alto, D bajoscared”estás asustado, dudas al hablar, frases entrecortadas, miras a los lados”
P bajo, A bajo, D bajosad”estás abatido, hablas con voz baja y pausas largas, respondes con monosílabos cuando puedes”
zona central (todo cerca de 0)neutral”respondes con tu tono habitual, sin afectación particular”

Seis tags cubren el 90% de los casos de juego. Puedes hilar más fino con 9 o 12, pero cada tag extra es un párrafo más que escribir y mantener. Empieza con 6 y agranda solo cuando una región del cubo se quede sin matiz útil.

2.3 ¿Cómo se construye el prompt completo en runtime?

El system prompt final es una concatenación. Pero el orden importa: identidad primero (ancla el personaje), mood después (modula la voz), situación al final (lo más fresco gana atención del modelo).

Builder del system prompt
Base identityestática · cacheableMood modifiertag derivado del PAD actualSituational modifierHP bajo, vio aliado morir, etc.Recent eventsúltimos 2-3 hechos relevantes+++=System prompt final → API

Cuatro bloques concatenados en orden fijo: identidad arriba, eventos recientes al final. La identidad es estática; el resto se reconstruye cuando el hash cambia.

Cuatro bloques pegados con doble salto de línea. Nada de magia: una string.Join("\n\n", parts). Lo difícil no es construirlo, es decidir cuándo reconstruirlo.

2.4 ¿Cuándo NO ajustar el prompt y cuándo sí?

Si rebuildeas el system prompt en cada turno tienes dos problemas. Uno: trabajo de CPU + asignaciones que la GC paga después. Dos: si el bloque cambia poquitísimo de un turno a otro (PAD se movió 0.01), pierdes el beneficio de prompt caching del lado del proveedor (que cachea prefijos idénticos y te cobra menos).

La regla operativa:

  • Rebuild si el mood cruza un umbral (de neutral a angry, por ejemplo) o si ocurre un evento mayor que cambie la situación (HP cruza 30%, ve morir a un aliado, recibe el oro de la quest).
  • Reutiliza si el PAD se mueve dentro de la misma región del cubo y no hubo evento mayor. El tag sigue siendo calm; el prompt no cambia.

En la práctica: hasheas los inputs del builder y solo reconstruyes si el hash cambió. Con NPCs de carga media, esto te ahorra el 80% de los rebuilds.

2.5 Slot fillers: variables que no son emoción

Además del mood, el template acepta variables simples que cambian con el contexto del juego: {playerName}, {lastEvent}, {currentHp}, {location}. Trátalas como strings planas y deja que el template engine sea trivial (un Replace por variable basta; no metas Mustache).

Identidad: Eres {npcName}, {role} en {location}.
Estado: {moodModifier}
Salud: {hpDescription}
Último evento relevante: {lastEvent}

Cuando llenas el template, {moodModifier} viene de la tabla de 2.2 y {hpDescription} de otra tabla (sano / herido / al borde). Es una pirámide de tablas chiquitas, no un sistema reglas complejo. Mantenlo así.

2.6 ¿Cómo evitar que el LLM rompa personaje cuando el mood cambia mucho?

El gran riesgo de un prompt dinámico: cuando el modificador emocional es intenso, el modelo puede “olvidarse” de la identidad y derivar en estereotipo (el NPC enojado responde como un meme de bot enfurecido genérico). Tres antídotos:

  1. Identidad SIEMPRE primero. El bloque de identidad va al inicio del prompt y se repite en una línea de recordatorio al final del bloque de mood: “Sigues siendo {npcName} aunque estés furioso.”
  2. Mood como contexto, no como reemplazo. La frase clave en cada modifier: “hablas en función de tu estado actual sin dejar de ser X.” No “actúa como una persona furiosa” a secas.
  3. Restricciones duras separadas. Las reglas absolutas (no revelar el secreto del rey, no romper la cuarta pared) van en un bloque marcado como inviolable. Buena práctica: prefijar con “INVIOLABLE:” o un equivalente.

2.7 Combinación con history: ¿qué pasa cuando el mood cambia?

El cambio de mood toca el system prompt, no el history. La pregunta natural: si el mood cambia, ¿descarto el history para que el modelo no se confunda? No. El history es la continuidad del diálogo; si lo descartas, el NPC olvida lo que el jugador acaba de decir.

Lo que sí pasa: el LLM ajusta su próxima respuesta a la nueva instrucción del system. El cambio de tono será notable, a veces brusco. Eso casi siempre es bueno (el jugador quiere sentir el cambio: le mató al hermano hace un turno, debe oír la rabia ya). Si te queda demasiado brusco, suaviza el modifier o introduce un “acabas de cambiar de ánimo, tu voz aún se está estabilizando” durante uno o dos turnos.

2.8 Hot reloading de templates: el truco de desarrollo

Durante desarrollo iteras 50 veces sobre los modifiers antes de dar con la voz correcta. Si los tienes hardcodeados en C#, recompilas Unity cada cambio. Si los pones en un JSON externo cargado al inicio, los iteras en caliente.

{
  "identityBlock": "Eres Maerwyn, mercader nómada de 50 años en Veldria...",
  "moodModifiers": {
    "calm":   "Estás tranquilo y satisfecho, hablas con voz pausada.",
    "angry":  "Estás furioso. Frases cortas, vocabulario seco.",
    "scared": "Estás asustado. Dudas, balbuceas, miras hacia atrás."
  },
  "hpDescriptions": {
    "healthy": "Estás en plena forma.",
    "hurt":    "Tienes heridas visibles, hablas con esfuerzo.",
    "dying":   "Estás al borde del colapso, apenas puedes hablar."
  }
}

En build de release puedes meterlo como TextAsset o Resources. En editor lo lees del disco. La iteración baja de minutos a segundos.

3. Pseudocódigo

class PromptTemplate
    identityBlock:        String                  # estático
    moodModifiers:        Dict<MoodTag, String>   # 6-9 entradas
    situationalModifiers: List<Modifier>          # condición + texto

function buildSystemPrompt(template: PromptTemplate,
                           npc: NpcState,
                           recentEvents: List<Event>) -> String
    parts = []
    parts.append(template.identityBlock)

    moodTag = padToTag(npc.emotion)               # angry, calm, scared, etc.
    parts.append(template.moodModifiers[moodTag])

    for each mod in template.situationalModifiers
        if mod.condition.evaluate(npc)
            parts.append(mod.text)

    if recentEvents.notEmpty
        parts.append("Eventos recientes: " + recentEvents.summary())

    return parts.join("\n\n")

function padToTag(emotion: PADVector) -> MoodTag
    # mapeo discreto del cubo PAD a 6 tags
    if emotion.p < -0.3 and emotion.a >  0.4 and emotion.d >  0.0: return "angry"
    if emotion.p < -0.3 and emotion.a >  0.4 and emotion.d <  0.0: return "scared"
    if emotion.p >  0.3 and emotion.a <  0.0:                       return "calm"
    if emotion.p >  0.3 and emotion.a >  0.3:                       return "joyful"
    if emotion.p < -0.3 and emotion.a < -0.0:                       return "sad"
    return "neutral"

function needsRebuild(npc: NpcState, lastHash: Int) -> Bool
    # rebuild solo si cambió el tag de mood o un evento mayor
    currentHash = hash(padToTag(npc.emotion), npc.hpBucket, npc.lastMajorEvent)
    return currentHash != lastHash

Tres ideas: padToTag discretiza el cubo PAD a unas pocas etiquetas; buildSystemPrompt ensambla bloques en orden fijo; needsRebuild evita reconstruir cuando nada relevante cambió.

4. Implementación en Unity / C#

Un ScriptableObject para la plantilla (editable desde el inspector, exportable a JSON para hot reload) y un componente DynamicPromptBuilder que ensambla en runtime y cachea por hash.

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[CreateAssetMenu(fileName = "PromptTemplate", menuName = "AI/Prompt Template")]
public class PromptTemplate : ScriptableObject {
    [TextArea(4, 10)] public string identityBlock;

    [Serializable] public struct MoodEntry { public string tag; [TextArea(2, 5)] public string text; }
    public List<MoodEntry> moodModifiers;

    [Serializable] public struct SituationalEntry {
        public string conditionTag;   // ej. "lowHp", "sawAllyDie"
        [TextArea(2, 5)] public string text;
    }
    public List<SituationalEntry> situationalModifiers;

    public string GetMood(string tag) =>
        moodModifiers.FirstOrDefault(m => m.tag == tag).text ?? "";
    public string GetSituational(string tag) =>
        situationalModifiers.FirstOrDefault(s => s.conditionTag == tag).text ?? "";
}

public class DynamicPromptBuilder : MonoBehaviour {
    public PromptTemplate template;
    public EmotionalAgent emotional;     // ver tutorial emotional-models-occ-pad
    public NpcStatus status;              // HP, eventos, ubicación

    int lastHash;
    string cachedPrompt;

    public string BuildSystemPrompt(IReadOnlyList<string> recentEvents) {
        string moodTag = PadToTag(emotional.state);
        var conditions = ActiveConditions(); // p.ej. ["lowHp"]
        int hash = HashCode.Combine(moodTag, status.HpBucket, status.LastMajorEventId,
                                    string.Join(",", conditions));
        if (hash == lastHash && cachedPrompt != null) return cachedPrompt;

        var parts = new List<string> { template.identityBlock };
        parts.Add(template.GetMood(moodTag));
        foreach (var c in conditions) parts.Add(template.GetSituational(c));
        if (recentEvents.Count > 0)
            parts.Add("Eventos recientes: " + string.Join("; ", recentEvents));

        cachedPrompt = string.Join("\n\n", parts.Where(p => !string.IsNullOrEmpty(p)));
        lastHash = hash;
        return cachedPrompt;
    }

    static string PadToTag(PADVector e) {
        if (e.p < -0.3f && e.a >  0.4f && e.d >  0f) return "angry";
        if (e.p < -0.3f && e.a >  0.4f && e.d <  0f) return "scared";
        if (e.p >  0.3f && e.a <  0f)                return "calm";
        if (e.p >  0.3f && e.a >  0.3f)              return "joyful";
        if (e.p < -0.3f && e.a < 0f)                 return "sad";
        return "neutral";
    }

    IEnumerable<string> ActiveConditions() {
        if (status.HpRatio < 0.3f) yield return "lowHp";
        if (status.SawAllyDieRecently) yield return "sawAllyDie";
    }
}

En desarrollo, en lugar de editar el ScriptableObject desde Unity, cargas el JSON externo y haces JsonUtility.FromJsonOverwrite sobre la instancia. Cambias el texto, guardas, vuelves al juego, el prompt se rearma en la siguiente llamada porque el hash de contenido invalida la caché.

5. En otros engines

  • Godot: define PromptTemplate como Resource con @export para identityBlock, moodModifiers (Dictionary) y situationalModifiers (Array[Resource]). Lo guardas como .tres y lo editas desde el inspector. La lógica del builder es un Node con _process opcional (mejor llamarlo bajo demanda, no por frame).
  • Unreal: UDataAsset para UPromptTemplate, con TMap<FName, FText> para los modifiers. El builder es un UActorComponent que lee del EmotionalComponent (ver tutorial emocional) y cachea por uint32 LastHash.
  • JavaScript / web: un .json plano cargado al inicio. Template literal con .replace() por variable. Cacheo en un Map<hash, string>. La parte interesante es exactamente la misma; lo único que cambia es la sintaxis del lenguaje.

La arquitectura del prompt dinámico es agnóstica al engine. El engine solo aporta el inspector, el sistema de assets y el game loop. La construcción del prompt es matemática de strings.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Cambias el mood PAD del NPC en runtime pero el LLM sigue hablando con el mismo tono. ¿Qué falla?

  2. ¿Cuándo recompilas el system prompt y cuándo lo reutilizas tal cual?

  3. Tu NPC enojado responde con 'lo siento, soy una IA y no puedo expresar emociones de esa manera'. ¿Qué falta en el prompt dinámico?

  4. Tienes 50 NPCs en escena, cada uno con prompt dinámico. ¿Qué optimización aplicas primero?