localaiforum
Iniciar sesión
Guía › Trabajo con modelos

Salida estructurada y llamada a funciones

Pídele JSON a un modelo y producirá felizmente un casi-JSON: una coma
final, una clave sin comillas, una frase amable antes de la primera
llave. El muestreo en forma libre siempre puede emitir un token inválido,
porque el sampler no sabe nada de tu esquema. La salida estructurada
cierra esa brecha, y la llamada a funciones es el mismo mecanismo con un
sombrero distinto.

Por qué se rompe el JSON en forma libre

Cada token generado es una muestra de una distribución. En cada paso
cierta masa de probabilidad — pequeña, pero nunca cero a temperatura mayor
que 0 — está sobre tokens que romperían tu formato: una llave de cierre
antes de su apertura, una coma tras el último elemento del array, un
true en minúscula deletreado True (un hábito de Python horneado en
los datos de entrenamiento). Multiplica «casi siempre válido» a lo largo
de 200 tokens de salida y obtienes rupturas intermitentes y no
reproducibles — el peor tipo de bug. Incluso la temperatura 0 solo vuelve
determinista la salida, no válida según el esquema.

La escalera de soluciones

Peldaño Mecanismo Garantía Dónde
Solo prompt («responde con JSON válido») Pedir amablemente Ninguna — el muestreo puede fallar igual cualquier motor
Modo JSON Prompt más restricción a sintaxis JSON JSON sintácticamente válido; la forma no está garantizada APIs compatibles con OpenAI, Ollama format: "json"
Restringido por esquema Esquema → gramática → logits enmascarados La salida siempre parsea y coincide con el esquema llama.cpp (json_schema / GBNF), vLLM guided_json, salidas estructuradas de Ollama, outlines / xgrammar

Cada peldaño cuesta flexibilidad y compra determinismo. Usa el peldaño
superior siempre que el consumidor sea código.

Decodificación restringida por gramática

Compila el esquema en una gramática. En cada paso de decodificación, cada
token que no podría extender un documento válido ve su logit fijado a
−infinito antes del muestreo. La salida inválida no es improbable — es
imposible. llama.cpp trae gramáticas GBNF; una gramática de número
decimal es una línea:

root ::= "-"? [0-9]+ ("." [0-9]+)?

La misma idea envuelve JSON Schema. Contra un servidor llama.cpp local
(endpoint compatible con OpenAI):

import json, requests

schema = {
    "type": "object",
    "properties": {
        "title":     {"type": "string"},
        "component": {"type": "string",
                      "enum": ["auth", "upload", "ui", "api"]},
        "severity":  {"type": "string",
                      "enum": ["low", "medium", "high"]}
    },
    "required": ["title", "component", "severity"],
    "additionalProperties": False
}

r = requests.post("http://localhost:8080/v1/chat/completions", json={
    "messages": [{"role": "user",
                  "content": "Extract the bug: " + report}],
    "response_format": {
        "type": "json_schema",
        "json_schema": {"name": "bug", "strict": True,
                        "schema": schema}
    },
    "temperature": 0
})
bug = json.loads(r.json()["choices"][0]["message"]["content"])

Ollama hace lo mismo con un JSON Schema en el campo format; vLLM con
guided_json. El mismo mecanismo, tres deletreos — los nombres de campo
exactos de tu motor viven en parámetros del motor.

Restringido no es correcto

La gramática garantiza forma, no verdad. El modelo puede emitir
{"severity": "low"} para un informe de crash, o rellenar un campo de
cadena con paja. El enmascaramiento también puede dañar los campos
abiertos: cuando el modelo quiere un salto de párrafo y la gramática
prohíbe el salto de línea, la calidad baja. Reglas prácticas:

  • Enumeraciones estrechas en vez de cadenas libres siempre que el espacio
    de valores sea conocido.
  • required para cada campo que consumas; additionalProperties: false
    para frenar invenciones.
  • Mantén cortos los campos de cadena; saca la prosa larga de la respuesta
    restringida.
  • Pon igualmente un ejemplo resuelto de la salida deseada en el prompt —
    dirige el contenido, lo cual la gramática no puede.

Llamada a funciones y herramientas

La llamada a herramientas es salida estructurada más una convención: el
modelo devuelve qué función llamar y con qué argumentos; tu código la
ejecuta y añade el resultado. La forma tools de OpenAI es el dialecto
de facto que imitan los motores locales:

{
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_weather",
      "description": "Current weather for a city",
      "parameters": {
        "type": "object",
        "properties": {
          "city": {"type": "string"},
          "unit": {"type": "string", "enum": ["C", "F"]}
        },
        "required": ["city"]
      }
    }
  }]
}

El modelo responde con tool_calls en el mensaje; tú devuelves la salida
como un mensaje role: "tool" y el bucle continúa. Los modelos están
entrenados para dialectos de herramientas concretos — las plantillas de
los linajes Hermes y Qwen son las mejor soportadas en motores locales
(llama.cpp con --jinja, Ollama, vLLM con elección automática de
herramientas). Un modelo sin entrenamiento de herramientas produce
llamadas plausibles con nombres de argumento alucinados sin importar
cuánto restrinjas, así que elige primero el modelo para el trabajo:
elegir un modelo.

Verifica parse y contenido

Dos comprobaciones tras cada llamada. Primera, el parseo — json.loads
tiene éxito (la gramática normalmente lo garantiza; el modo JSON no
siempre). Segunda, la semántica: claves requeridas presentes,
enumeraciones en rango, números plausibles, cadenas no vacías. Ante fallo,
reintenta una vez con el error de validación realimentado en el prompt —
los reintentos restringidos son baratos y suelen tener éxito al primer
bote.

Este bucle exacto — emitir una llamada, validar, ejecutar, observar — es
lo que los agentes corren todo el día: agentes.

Relacionados: agentes · prompting · muestreo · rag