Híbrido Behavior Tree + LLM: arquitectura para diálogos
El LLM elige qué decir; el BT decide cuándo se puede hablar y qué hacer físicamente. Cuándo usar lógica determinista y dónde dejar que el LLM improvise.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Conectas un LLM a tu NPC y todo se siente vivo durante diez minutos. Luego empiezas a sumar: un turno cuesta 0.01 USD, cada respuesta tarda 2-4 segundos, y en cuanto el jugador entra en combate el modelo decide que el enemigo “se detiene a reflexionar”. El LLM puro no escala. Pero un Behavior Tree puro tampoco resuelve diálogo natural: por cada línea escrita a mano, el jugador la oye tres veces y se acaba la magia.
La realidad de la industria es híbrida. Ningún proyecto AAA con LLM en producción usa LLM puro. Skyrim con Inworld, Suck Up!, las demos de NVIDIA ACE, los experimentos de Ubisoft: todos mezclan lógica determinista para el “esqueleto” del comportamiento con LLMs solo en los puntos donde el lenguaje natural justifica el costo.
La pregunta clave no es “¿LLM o BT?”. Es “¿qué decide el BT y qué decide el LLM?”. Una arquitectura híbrida BT+LLM usa el behavior tree como el cerebro de la situación (cuándo combatir, cuándo huir, cuándo hablar) y delega solo el contenido del diálogo al LLM. El BT no desaparece; el LLM se convierte en un leaf especializado en lenguaje.
En este tutorial vas a ver:
- Qué hace mejor cada uno, lado a lado, sin marketing.
- El patrón canónico: LLM como leaf del BT, con un gate de pre-condiciones.
- Cómo function calling cierra el círculo: el LLM decide intenciones, el BT las ejecuta.
1. Demo
2. Concepto y arquitectura
Una arquitectura híbrida BT+LLM usa el behavior tree como el cerebro de la situación (cuándo combatir, cuándo huir, cuándo hablar) y delega solo el contenido del diálogo al LLM. El BT no desaparece; el LLM se convierte en un leaf especializado en lenguaje, sujeto a las mismas reglas de cualquier otro nodo: tiene pre-condiciones, devuelve Success/Failure/Running, y lo puede gatear cualquier composite que esté por encima.
2.1 ¿Qué hace mejor cada uno?
Lado a lado, sin matices vendidos:
| Capacidad | Behavior Tree | LLM |
|---|---|---|
| Decisiones tácticas (combate, movimiento) | Excelente | Mal / inviable |
| Composición de diálogo natural | Imposible | Excelente |
| Determinismo / replay | Sí | No |
| Reactividad inmediata (mismo frame) | Sí | Latencia 1-5s |
| Costo por tick | ~0 | $$ por llamada |
| Authoring por diseñadores | Sí (editor visual) | Parcialmente (prompts) |
| Testing automatizado | Trivial | Cualitativo |
| Funciona offline | Sí | Solo con modelo local |
Lee esa tabla como un mapa de responsabilidades. Cada vez que dudas si una decisión va en el BT o en el LLM, busca la fila correspondiente. Si la fila dice “BT excelente”, el LLM no pinta nada. Si la fila dice “LLM excelente, BT imposible”, no intentes meterle un nodo determinista al diálogo.
2.2 El patrón canónico: LLM como leaf del BT
La estructura que se repite en todas las implementaciones serias es la misma: el LLM vive dentro de un Sequence, protegido por dos Conditions que actúan como gate, y los resultados se escriben al blackboard para que el resto del árbol los consuma.
El Selector raíz prioriza Combat y Flee (BT puro). Solo la rama de Dialogue invoca al LLM, y solo si el gate ShouldRespondWithLlm pasa.
El Selector raíz prioriza supervivencia: combate y huida ganan siempre al diálogo. Si el NPC está peleando, ni se evalúa la rama de diálogo. Esto te da la garantía gratis de que un enemigo nunca se pondrá a recitar prosa generada por LLM mientras el jugador le pega.
2.3 ¿Cuándo conviene invocar al LLM y cuándo no?
No todo diálogo merece una llamada al modelo. Tabla de decisión que te ahorra dinero:
| Situación | Quién responde | Por qué |
|---|---|---|
| Saludo inicial al NPC | LLM | Variedad evita repetición |
| Barks de combate (“¡toma!”) | Canned (BT puro) | Latencia inaceptable, vocab pequeño |
| Trading / negociación | LLM + function calling | Lenguaje natural + tools |
| Quest acceptance | LLM (con tools) | Respuesta contextual, validación con tools |
| Game over / cinemática | Canned | Determinismo de escritura |
| Daño recibido | Canned | Tiene que sonar en 100ms |
| Pregunta del jugador sobre lore | LLM | Open-ended, modelo brilla aquí |
| Saludo de despedida | Canned + 1 línea LLM ocasional | Híbrido dentro del híbrido |
Regla mental: si la respuesta tiene que salir en menos de 200ms, es canned; si la respuesta tiene que sonar única e improvisada, es LLM. Todo lo demás está en la zona gris donde mides y decides.
2.4 Function calling como puente al BT
El LLM nunca debe ejecutar acciones del mundo directamente. Cuando el modelo decide “atacar al jugador”, no llama a un endpoint mágico que mueve al NPC; llama a una función registrada que escribe una intención al blackboard. El BT lee esa intención en el siguiente tick y dispara la rama correspondiente.
El LLM emite un tool_call con intención; el handler lo escribe al blackboard; en el siguiente tick el BT lee el flag y dispara la rama de combate.
Esta separación de concerns no es estética. Es lo que te permite testear el BT sin tocar el LLM, lo que evita que un jailbreak prompt convierta al NPC en una bomba lógica, y lo que mantiene la simulación a 60fps mientras el LLM trabaja a 1Hz. Si te interesa el detalle de cómo se definen esas tools, ya lo cubrimos en Function calling para NPCs.
2.5 ¿Cómo evita el BT que el LLM “rompa” el juego?
El BT actúa como filtro de pre-condiciones antes de invocar al modelo. La Condition ShouldRespondWithLlm? puede comprobar cosas como:
- El NPC no está en combate.
- No hay una cinemática activa.
- El jugador no ha disparado más de N inputs en los últimos M segundos (rate limit).
- El NPC no está aturdido, dormido, muerto.
- Hay presupuesto restante para llamadas LLM en esta sesión (cap de costo).
Si cualquiera falla, el Sequence devuelve Failure, el LLM ni se llama, y el árbol pasa a la siguiente rama o cae a una respuesta canned de fallback. El LLM nunca decide cuándo hablar; solo decide qué decir cuando le toca. Esta inversión es la diferencia entre un sistema robusto y uno que te quema 80 USD por jugador en una mala tarde.
2.6 Caching de respuestas LLM en nodos BT
Si el contexto que mandas al LLM se repite, la respuesta también es probable que se repita. Para barks y reacciones, conviene cachear por hash de entrada.
hash = sha256(systemPrompt + relevantHistory + userInput + npcState)
if cache.contains(hash):
response = cache[hash] # gratis, 0ms
else:
response = llm.call(...)
cache[hash] = response
Funciona para saludos repetidos, reacciones al mismo evento (jugador entra a la tienda), preguntas frecuentes del lore. No funciona (y no debería) para conversaciones largas con contexto único. Métrica que vigilar: hit rate. Si el cache pega al 40%+, te ahorraste el 40% de la factura.
2.7 ¿Cómo testear un híbrido?
Tres niveles de tests, cada uno con herramientas distintas:
- BT en aislado. Mockeas el LlmDialogueNode con un stub que devuelve respuestas fijas. Tests deterministas, golden files, escenarios reproducibles. El 80% de los bugs de IA caen aquí — ramas que no priorizan bien, condiciones invertidas, blackboard leaks.
- Nodos LLM en aislado. Evaluación cualitativa con un set de prompts conocidos. Smoke tests: “dale este input al modelo y verifica que el output cumple criterios mínimos” (longitud razonable, no rompe el formato, no menciona temas prohibidos). Aceptas variabilidad pero pones cotas.
- Integración end-to-end. Simulas conversaciones con un LLM “jugador” automatizado que conversa con tu NPC. Mides cuántos turnos hasta romper la coherencia, cuántas tool calls se ejecutan, costo promedio. Es caro de correr (literalmente cuesta dinero), pero te dice si el sistema funciona de verdad.
El BT siempre tiene que ser determinista. Si los tests de BT empiezan a fallar de forma intermitente, es señal de que un nodo LLM se filtró donde no debía.
2.8 Migración gradual: cómo no romper lo que ya funciona
No reescribas tu IA. La migración a un híbrido se hace por capas, midiendo en cada paso:
- Estado 0: BT puro funcional con respuestas canned. Cero LLM.
- Estado 1: sustituyes un solo leaf de “decir bark” por un
LlmBarkNodecon cache agresivo. Mides quality (subjetiva, con playtesters) y costo. Si compensa, sigues. - Estado 2: añades el
LlmDialogueNodereal para conversaciones largas con el NPC mercader (uno solo). Implementas el gate. Mides latencia percibida. - Estado 3: function calling para que el LLM influya en intenciones del BT (intent al blackboard).
- Estado 4: extiendes a más NPCs. Refactor del gate a una librería compartida.
En cada estado, conserva un feature flag que te permita revertir el LLM a canned en caso de fallo del proveedor o de presupuesto. La arquitectura híbrida es tolerante a fallos por diseño: si el LLM se cae, el BT cae a respuestas canned y el juego sigue.
3. Pseudocódigo
class LlmDialogueNode extends BTNode
promptTemplate: PromptTemplate
cache: Dict<Hash, String>
client: LlmClient
function tick(blackboard: Blackboard) -> Status
if not blackboard.has("playerInput")
return Failure
input = blackboard.get("playerInput")
ctx = blackboard.get("npcContext")
# cache antes de cualquier gasto
hash = sha256(input + ctx.toHash())
if cache.contains(hash)
blackboard.set("npcResponse", cache[hash])
return Success
# gate de pre-condiciones (segundo cinturón de seguridad)
if not validatePreconditions(ctx)
return Failure
# llamada real al modelo (puede tardar)
prompt = buildPrompt(promptTemplate, ctx, input)
response = client.chat(prompt)
cache[hash] = response
blackboard.set("npcResponse", response)
return Success
function shouldUseLlm(ctx: NpcContext) -> Bool
if ctx.inCombat: return false
if ctx.isCutsceneActive: return false
if ctx.isStunned: return false
if ctx.recentInputCount > 5 in last 10 seconds:
return false # rate limit por jugador spamming
if ctx.sessionCostUsd > MAX_BUDGET_PER_SESSION:
return false # cap de presupuesto
return true
Tres cosas que mirar. La caché va antes del gate de pre-condiciones porque un cache hit es gratis y la respuesta cacheada ya fue validada en su día. El gate shouldUseLlm es una función pura: lee contexto, devuelve bool. Eso lo hace trivial de testear y de modificar sin tocar el nodo. Y el resultado del LLM se escribe al blackboard, no se devuelve por el árbol — así cualquier otro nodo lo puede consumir (TTS, UI, animator).
4. Implementación en Unity / C#
Snippet que integra un LlmDialogueNode en el BT mínimo del tutorial de Behavior Trees. Asume el cliente HTTP del tutorial de NPCs con LLM.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
public class Blackboard {
readonly Dictionary<string, object> store = new();
public void Set(string k, object v) => store[k] = v;
public T Get<T>(string k) => store.TryGetValue(k, out var v) ? (T)v : default;
public bool Has(string k) => store.ContainsKey(k);
}
public class LlmDialogueNode : Node {
readonly NpcLlmAgent llm;
readonly Blackboard bb;
readonly Func<bool> gate;
readonly Dictionary<string, string> cache = new();
Task<string> pendingCall;
string lastHash;
public LlmDialogueNode(NpcLlmAgent llm, Blackboard bb, Func<bool> gate) {
this.llm = llm; this.bb = bb; this.gate = gate;
}
public override Status Tick() {
if (!bb.Has("playerInput")) return Status.Failure;
var input = bb.Get<string>("playerInput");
var ctx = bb.Get<NpcContext>("npcContext");
var hash = $"{input}|{ctx.Hash()}";
// 1) Cache hit: gratis y al instante
if (cache.TryGetValue(hash, out var cached)) {
bb.Set("npcResponse", cached);
bb.Set("playerInput", null);
return Status.Success;
}
// 2) Gate de pre-condiciones (combat, cutscene, rate limit, budget)
if (!gate()) return Status.Failure;
// 3) Llamada asíncrona: el BT sigue tickeando mientras esperamos
if (pendingCall == null || lastHash != hash) {
lastHash = hash;
pendingCall = llm.Chat(input);
}
if (!pendingCall.IsCompleted) return Status.Running;
var response = pendingCall.Result;
cache[hash] = response;
bb.Set("npcResponse", response);
bb.Set("playerInput", null);
pendingCall = null;
return Status.Success;
}
}
// Montaje del árbol en el agente
void BuildTree() {
bb = new Blackboard();
root = new Selector(
new Sequence( // combat (BT puro)
new Condition(() => CanSeePlayer() && InRange()),
new BTAction(() => DoAttack())
),
new Sequence( // flee (BT puro)
new Condition(() => Hp() < 0.2f),
new BTAction(() => FleeToCover())
),
new Sequence( // dialogue (LLM como leaf)
new Condition(() => bb.Has("playerInput")),
new LlmDialogueNode(llmAgent, bb,
gate: () => !InCombat() && !InCutscene() && WithinBudget())
)
);
}
void Update() { root.Tick(); }
El nodo es Running mientras la llamada HTTP está en vuelo. El Selector raíz, al ver Running en la rama de diálogo, mantiene esa rama hasta que termine — exactamente la semántica que ya conoces de los BTs. Si en medio entra combate, el Selector reordena (Combat tiene prioridad), el LlmDialogueNode queda abortado y al volver al diálogo se reinicia desde cero con pendingCall = null.
5. En otros engines
- Godot: con LimboAI o Beehave tienes BTs nativos. El
LlmDialogueNodese escribe como unBTActioncustom en GDScript que llama a unHTTPRequestasync y devuelveRUNNINGmientras espera. El blackboard ya viene incluido en LimboAI. - Unreal: el
UBTTaskNodecustom es el equivalente. Llamas al módulo HTTP del engine desdeExecuteTask, devuelvesEBTNodeResult::InProgressy finalizas conFinishLatentTaskcuando llega la respuesta. ElUBlackboardComponentya está pensado para esto. - JavaScript:
behaviortree.jsomistreevous+fetch. La parte async encaja natural porque toda la API es promesa. Útil para prototipos rápidos o juegos web.
Lo que no cambia entre engines: el LLM va en una leaf, el gate va antes del LLM, el resultado va al blackboard. Lo que cambia: el cliente HTTP y cómo el engine maneja Running async.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
El jugador escribe 5 mensajes seguidos en 4 segundos. Cada uno dispara una llamada al LLM y la factura se dispara. ¿Qué le falta a tu árbol?
¿Por qué el LLM NO debe decidir directamente 'attack' y ejecutar el ataque?
Tu NPC enemigo sigue respondiendo al jugador con respuestas LLM elaboradas mientras le estás golpeando con la espada. ¿Qué falta en el árbol?
¿Cuándo NO conviene una arquitectura híbrida BT+LLM?