LLMs Intermedio 18 min de lectura

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 description es 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

Function calling: del texto a la acción Mensaje del jugador → el LLM elige una tool de la lista → el engine la ejecuta → el resultado vuelve al LLM → respuesta narrada.

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ú.

Llamada normal vs llamada con tools
Llamada normalLlamada con toolsPOST /chatmodel, messagesResponse[ content ]POST /chatmodel, messages,tools: [ name, desc, schema, … ][ content ]texto normal[ tool_calls ]decidió usar tooltexto planoacción estructuradasolo contentel modelo elige

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:

  • name es un identificador de programa. Usa snake_case, sin espacios. Lo verás aparecer literal en la respuesta del modelo.
  • description lo lee el modelo para decidir cuándo llamarla. Es el campo más importante de todos. Más en 2.3.
  • enum restringe 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:

Pipeline de dos roundtrips
Player”Atácame”Llamada 1 — LLMtools=[attack_target, retreat, trade]decide qué tool usartool_call: attack_target[ target_id, weapon ]Tu engine1. Validar (target existe? en rango? arma equipada?)2. Ejecutar: enemy.AttackTarget(player_1, sword)3. Construir tool_resultgame statetool_result[ hit: true, damage: 12 ]Llamada 2 — LLMmessages + tool_call + tool_resultnarra lo que pasó”desenvaina la espada y arremete con un tajo limpio”

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 pases trade. 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_hp o give_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:

  1. Verificar que el handler existe.
  2. Verificar que los argumentos referencian entidades reales y en estado válido.
  3. Si algo falla, devolver un tool_result con un campo error explicando 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_conversation no 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 NpcTool con name, description, schema (Dictionary nativo de Godot), y execute(args: Dictionary). El HTTP va por HTTPRequest. JSON nativo evita la dependencia de Newtonsoft.
  • Unreal: FJsonObject y FHttpModule. Más verboso pero misma estructura: una UNpcTool abstracta con Execute(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 tools como array de objetos JS, el SDK ya parsea tool_calls y te entrega los args como objeto. Lo único que escribes es el execute().

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.

  1. Le diste 5 tools a tu NPC enemigo, incluida `flee`. El NPC nunca elige `flee` aunque tenga 5% de HP. ¿Qué revisas primero?

  2. El LLM llama `attack_target` con un `target_id` que no corresponde a ningún NPC en la escena. ¿Quién maneja eso?

  3. ¿Por qué normalmente haces dos llamadas al LLM por turno cuando usas function calling?

  4. ¿Por qué NO darle al LLM una tool `set_player_hp(value)`?