Function calling para NPCs: del diálogo a la acción
Cómo conseguir que el LLM decida pelear, comerciar o huir en vez de solo describirlo. JSON schema, tool calls y el puente entre la respuesta del modelo y tu game state.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Conectas un NPC a un LLM, lo pruebas, le dices “atácame”. El modelo responde: “Desenvaino la espada y arremeto contra ti, lanzando un tajo horizontal a la altura del pecho.” Suena bien. El problema: tu jugador no pierde HP, el enemigo no se mueve, y el Animator sigue en idle. El LLM describió un ataque que nunca ocurrió.
Esto pasa porque la inferencia produce texto, no eventos en tu juego. La capa entre “el modelo dijo X” y “el enemigo ejecutó X” no existe por defecto. Intentos típicos que fracasan: regex sobre la respuesta para detectar verbos (“ataco”, “huyo”), marcadores artificiales tipo [ATTACK target=player], prompt engineering frágil que se rompe en cuanto el modelo improvisa. Todos funcionan el 80% del tiempo y dejan ese 20% que destroza la experiencia.
La solución estándar es function calling (también llamado tool use en la API de Anthropic): le das al modelo una lista de funciones disponibles con su firma, y en vez de devolverte prosa, te devuelve un JSON validado que indica qué función llamar y con qué argumentos. Tú la ejecutas, le devuelves el resultado, y el modelo narra.
En este tutorial vas a ver:
- Cómo se define una tool y por qué el
descriptiones el 70% del trabajo. - El pipeline completo: jugador → LLM → tool call → engine → resultado → LLM → respuesta natural.
- Por qué validar los argumentos es responsabilidad tuya, no del modelo, y cómo encajar esto sin romper tu Behavior Tree.
1. Demo
2. Concepto y arquitectura
Function calling es el mecanismo donde el LLM, en vez de devolver texto libre, devuelve un JSON validado contra un schema que tú definiste. Tu engine ejecuta esa función; el resultado puede volver al LLM para que genere la respuesta natural final. Es la diferencia entre un narrador y un actor con acceso al stage.
2.1 ¿Qué cambia respecto a una llamada normal a un LLM?
Una llamada de chat normal lleva dos cosas: el model y los messages (system + historial + mensaje actual). La respuesta es texto en content. Con function calling agregas un tercer parámetro: tools, la lista de funciones que el modelo puede invocar, cada una con su nombre, descripción y schema de argumentos.
La respuesta cambia: ya no es solo content. Puede traer un campo tool_calls (OpenAI) o bloques tool_use (Anthropic) con el nombre de la función elegida y los argumentos como JSON ya parseado y validado contra el schema. El modelo también puede no llamar a ninguna tool y responder con texto plano — eso lo decide él, no tú.
Sin tools, la respuesta es texto plano. Con tools, el modelo decide: o responde con content, o devuelve tool_calls.
2.2 Anatomía de una tool definition
Una tool es: un nombre, una descripción y un JSON Schema de los argumentos. Ejemplo para un enemigo:
{
"name": "attack_target",
"description": "Inicia un ataque contra un objetivo dentro del rango de combate. Úsalo cuando hayas decidido entrar en combate cuerpo a cuerpo o a distancia, considerando tu HP actual, tu arma equipada y la distancia al objetivo.",
"parameters": {
"type": "object",
"properties": {
"target_id": {
"type": "string",
"description": "ID del objetivo en la escena (p.ej. 'player_1')."
},
"weapon": {
"type": "string",
"enum": ["sword", "bow", "magic"],
"description": "Arma a usar. Debe estar en tu inventario."
}
},
"required": ["target_id", "weapon"]
}
}
Tres detalles que no son cosméticos:
namees un identificador de programa. Usasnake_case, sin espacios. Lo verás aparecer literal en la respuesta del modelo.descriptionlo lee el modelo para decidir cuándo llamarla. Es el campo más importante de todos. Más en 2.3.enumrestringe valores. Si tu NPC solo tiene tres armas, decláralo. El modelo no inventará una cuarta.
2.3 ¿Cómo decide el LLM cuándo usar una tool?
El modelo lee, en orden: el system prompt (quién es el NPC, estado actual), el historial, el mensaje del jugador, y la lista de tools con sus descripciones. Con esa información elige: o respondo con texto, o llamo a una tool, o ambas (algunos modelos permiten texto + tool_call simultáneos).
El description de cada tool es donde se gana o se pierde el comportamiento. Un description vago — “ataca al objetivo” — produce un modelo que llama a attack_target cuando no debería o no la llama cuando sí. Un description con condiciones explícitas — “Úsalo solo cuando tu HP > 30% y el objetivo está a menos de 10m” — produce decisiones razonables. Trata las descripciones como código, no como comentarios.
2.4 Pipeline completo paso a paso
El roundtrip típico tiene dos llamadas al LLM y una ejecución en el medio:
Llamada 1 decide la tool, tu engine valida y ejecuta sobre el game state, llamada 2 narra el resultado real.
Las dos llamadas no son negociables si quieres narración basada en lo que pasó. La primera decide; la segunda narra el resultado real. Si te saltas la segunda, el NPC actúa sin describir; si te saltas la primera, el NPC describe sin actuar — exactamente el problema del inicio.
2.5 ¿Qué tools dar a un NPC y cuáles NO?
La tentación es exponer todo. Mala idea. El modelo decide entre las tools que ve, así que cuantas más le des, más probable que confunda contextos. Reglas:
- Solo las acciones que tienen sentido para ese personaje en ese momento. Un mercader:
set_price,accept_deal,reject_deal,recommend_item,end_conversation. Un guardia:attack_target,chase,call_backup,dismiss,arrest. No mezcles los dos sets en el mismo NPC. - Filtra dinámicamente por estado. Si el NPC está sin armas, no le pases
attack_target. Si está en combate, no le pasestrade. Construir la lista de tools en cada llamada según el contexto cuesta poco y elimina decisiones absurdas. - Nada que el LLM no deba poder hacer. Si expones
set_player_hpogive_item("legendary_sword"), en cuanto el jugador encuentre el jailbreak adecuado, ese NPC le va a regalar el mundo. Mantén las tools en el lado seguro: el NPC decide qué intenta, el engine decide qué pasa.
2.6 La validación es responsabilidad tuya, no del modelo
El JSON Schema valida tipos y campos requeridos. No valida nada del game state. El modelo puede pedirte attack_target("npc_42", "sword") cuando npc_42 no existe, o cuando el NPC tiene un arco equipado, o cuando el target está a 200m. El schema dice “es un string válido”. Tu juego dice “ese string no apunta a nada”.
Tu wrapper de ejecución debe:
- Verificar que el handler existe.
- Verificar que los argumentos referencian entidades reales y en estado válido.
- Si algo falla, devolver un
tool_resultcon un campoerrorexplicando qué pasó.
Cuando devuelves un error como tool_result, el modelo lo lee y normalmente reintenta con otros argumentos o cambia de tool. Esto es feature, no bug: convierte al LLM en un planner reactivo.
2.7 ¿Cómo encaja function calling con un Behavior Tree?
El LLM no reemplaza al BT, lo modula. El BT sigue ejecutando las acciones primitivas — moverse, atacar, navegar — porque para eso es bueno: lógica determinista a 60 fps. El LLM elige qué rama de alto nivel disparar y narra el resultado. Patrón típico:
- BT con ramas:
Combat,Flee,Trade,Patrol,Talk. - El LLM expone tools que setean una variable en el blackboard:
set_intent("combat"),set_intent("flee"). - El BT lee el blackboard y elige la rama. Sigue ejecutando hasta que el intent cambie.
Así el LLM decide qué y cuándo, el BT decide cómo. Tienes el tutorial híbrido dedicado para esto.
2.8 Costos y latencia: lo que function calling te cuesta de verdad
Cada llamada con tools paga input tokens por el schema completo de cada tool. Una lista de 5 tools con descriptions decentes son ~600-1000 tokens extra por llamada. Si haces el roundtrip de dos llamadas, son ~1500 tokens solo de schemas, más el system prompt, más el historial.
La latencia se duplica también: dos llamadas en serie son 2-8 segundos en total. Para combate por turnos es aceptable; para diálogo casual el jugador siente el lag.
Mitigaciones reales:
- Prompt caching para que el schema no se recobre en cada llamada.
- Modelos más rápidos (Haiku, GPT-mini) para la primera llamada de decisión, y solo un modelo top para la narración cuando importa.
- Tools mínimas por contexto — la regla de 2.5.
- Saltarte la segunda llamada cuando el resultado de la tool no necesita narración (un
end_conversationno la necesita).
3. Pseudocódigo
function chatWithTools(client: LlmClient, messages: List<Message>, tools: List<ToolDef>) -> Response
response = client.chat(messages, tools)
if response.toolCalls.isEmpty
return response # texto normal, el modelo no quiso tool
for each toolCall in response.toolCalls
result = executeTool(toolCall.name, toolCall.args)
messages.append(assistantMessage(response.content, response.toolCalls))
messages.append(toolMessage(toolCall.id, result))
# segunda llamada: el LLM narra el resultado de las tools
final = client.chat(messages, tools)
return final
function executeTool(name: String, args: Dict) -> ToolResult
handler = TOOL_REGISTRY[name]
if handler == null
return ToolResult.error("unknown tool: " + name)
validation = handler.validate(args)
if not validation.ok
return ToolResult.error(validation.reason)
return handler.execute(args)
class ToolHandler
name: String
description: String
schema: JsonSchema
function validate(args: Dict) -> ValidationResult
function execute(args: Dict) -> ToolResult
Tres ideas: el registro central de tools desacopla la definición de la ejecución, la validación corre antes de tocar el game state, y el segundo chat() reutiliza la misma lista de tools porque la conversación puede encadenar más tool calls.
4. Implementación en Unity / C#
Patrón base: una interfaz INpcTool, un registro de tools por NPC, y el agente extiende el cliente del tutorial anterior. Snippet representativo (parser JSON omitido por brevedad — usa Newtonsoft.Json en producción).
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
public interface INpcTool {
string Name { get; }
string Description { get; }
JObject Schema { get; } // JSON Schema de los args
ToolResult Execute(JObject args); // valida + ejecuta
}
public readonly struct ToolResult {
public readonly bool Ok;
public readonly string Payload; // JSON serializado al LLM
public ToolResult(bool ok, string payload) { Ok = ok; Payload = payload; }
public static ToolResult Error(string reason) =>
new(false, $"{{\"error\":\"{reason}\"}}");
}
public class AttackTool : INpcTool {
readonly EnemyController owner;
public AttackTool(EnemyController o) { owner = o; }
public string Name => "attack_target";
public string Description =>
"Inicia un ataque cuerpo a cuerpo o a distancia contra un objetivo " +
"dentro de 10m. Úsalo solo si tu HP > 30% y tienes arma equipada.";
public JObject Schema => JObject.Parse(@"{
""type"": ""object"",
""properties"": {
""target_id"": { ""type"": ""string"" },
""weapon"": { ""type"": ""string"", ""enum"": [""sword"",""bow"",""magic""] }
},
""required"": [""target_id"",""weapon""]
}");
public ToolResult Execute(JObject args) {
var targetId = args.Value<string>("target_id");
var weapon = args.Value<string>("weapon");
var target = SceneRegistry.Find(targetId);
if (target == null) return ToolResult.Error($"target {targetId} not found");
if (!owner.HasWeapon(weapon)) return ToolResult.Error($"weapon {weapon} not equipped");
if (Vector3.Distance(owner.transform.position, target.position) > 10f)
return ToolResult.Error("target out of range");
var dmg = owner.AttackTarget(target, weapon);
return new ToolResult(true, $"{{\"hit\":true,\"damage\":{dmg}}}");
}
}
public class NpcLlmAgentWithTools : NpcLlmAgent {
readonly Dictionary<string, INpcTool> tools = new();
public void Register(INpcTool t) => tools[t.Name] = t;
public async Task<string> ChatWithTools(string userMessage) {
history.Add(("user", userMessage));
var first = await CallApi(BuildMessages(), tools.Values);
if (first.ToolCalls.Count == 0) return AppendAndReturn(first.Content);
foreach (var tc in first.ToolCalls) {
var result = tools.TryGetValue(tc.Name, out var h)
? h.Execute(JObject.Parse(tc.ArgsJson))
: ToolResult.Error("unknown tool: " + tc.Name);
history.Add(("tool", $"{tc.Id}:{result.Payload}"));
}
var second = await CallApi(BuildMessages(), tools.Values);
return AppendAndReturn(second.Content);
}
}
5. En otros engines
- Godot: la lógica es idéntica. Defines una clase
NpcToolconname,description,schema(Dictionary nativo de Godot), yexecute(args: Dictionary). El HTTP va porHTTPRequest. JSON nativo evita la dependencia de Newtonsoft. - Unreal:
FJsonObjectyFHttpModule. Más verboso pero misma estructura: unaUNpcToolabstracta conExecute(const TSharedPtr<FJsonObject>&). Plugins como OpenAI-Api-Unreal incluyen helpers de tool use. - JavaScript / Electron / web: los SDKs oficiales de OpenAI y Anthropic empaquetan el roundtrip. Pasas
toolscomo array de objetos JS, el SDK ya parseatool_callsy te entrega los args como objeto. Lo único que escribes es elexecute().
Lo que no cambia entre engines: el JSON Schema, el flujo de dos llamadas, la regla de validar args en el handler. Lo que cambia: cómo serializas/parseas JSON y cómo llamas a la API.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Le diste 5 tools a tu NPC enemigo, incluida `flee`. El NPC nunca elige `flee` aunque tenga 5% de HP. ¿Qué revisas primero?
El LLM llama `attack_target` con un `target_id` que no corresponde a ningún NPC en la escena. ¿Quién maneja eso?
¿Por qué normalmente haces dos llamadas al LLM por turno cuando usas function calling?
¿Por qué NO darle al LLM una tool `set_player_hp(value)`?