Saltar al contenido
Angular•4 min de lectura

Structured outputs y tool calling: tu vLLM deja de devolver prosa

Tu servidor vLLM del capítulo 5 responde texto. Un agente necesita JSON ejecutable: nombres de funciones, argumentos tipados, campos que un cliente puede parsear sin un regex pegado con cinta. En vLLM eso se resuelve en dos capas: structured outputs para JSON garantizado y tool calling para funciones. Este capítulo configura las dos, con los flags que cambiaron este año y uno que ya no existe.

La diferencia con lo que viste en los 5 flags esenciales: allá prendiste --enable-auto-tool-choice y siguió caminando. Aquí entramos a qué hace cada pieza por dentro, porque cuando tu agente falla, la diferencia entre "el parser" y "el grammar" te ahorra horas.

JSON garantizado: structured outputs

vLLM fuerza la salida del modelo a través de un grammar compilado desde tu schema. Dos backends: xgrammar y guidance (llguidance). Si no eliges, el backend auto decide por request según el schema. Compilar el grammar cuesta: el primer request con un schema nuevo tarda varios segundos y después queda cacheado. Si tu servicio recibe 50 schemas distintos por minuto, ese cache es tu cuello de botella silencioso.

El código del lado del cliente, con un Pydantic que define el schema:

# structured_output.py
from pydantic import BaseModel
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")
model = client.models.list().data[0].id

class ExtractedIssue(BaseModel):
    file: str
    line: int
    severity: str
    summary: str

completion = client.chat.completions.create(
    model=model,
    messages=[{"role": "user", "content": "Extrae el issue del siguiente log: ..."}],
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "issue", "schema": ExtractedIssue.model_json_schema()},
    },
)
print(completion.choices[0].message.content)

Ojo con el campo viejo: si copiaste código de un tutorial con extra_body={"guided_json": schema}, ese parámetro fue eliminado en vLLM v0.12.0. El reemplazo es {"structured_outputs": {"json": schema}}. Fuente: docs de structured outputs, actualizadas el 24 de octubre de 2026. La mitad de los errores 400 que verás migrando ejemplos viejos vienen de ahí.

Si tu modelo razona (un Qwen3 con thinking, un DeepSeek-R1), el JSON sale del reasoning y el campo content llega vacío. Se arregla al arrancar el server con --reasoning-parser y, para Qwen3 Coder, --structured-outputs-config.enable_in_reasoning=True. Sin ese flag, structured outputs + reasoning conviven mal.

Tool calling: el parser es del modelo, no del server

Los flags de arranque siguen siendo dos:

vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8 \
  --enable-auto-tool-choice \
  --tool-call-parser qwen3_xml

Lo que cambió es el catálogo de parsers, porque cada familia de modelos emite las llamadas en su propio formato. Los que vas a cruzar en 2026:

ParserModelos
qwen3_xmlQwen3-Coder (30B, 480B)
hermesQwen2.5, Hermes 2 Pro+, QwQ
glm47 / glm45GLM-4.7, GLM-4.6, GLM-4.5
deepseek_v31DeepSeek-V3.1
llama3_jsonLlama 3.1/3.2 (con chat template extra)
openaigpt-oss-20b/120b

Si en tu guía anterior usaste qwen3_coder y hoy vLLM se lo rechaza, no es tu server: el parser para Qwen3-Coder ahora se llama qwen3_xml. El catálogo se mueve entre versiones; vllm serve --help | grep -A20 tool-call-parser manda sobre cualquier tutorial.

Por defecto, tool_choice="auto" no fuerza nada. El modelo puede emitir markup malformado y vLLM extrae las llamadas del texto crudo, con argumentos que ocasionalmente violan tu schema. Desde este año vLLM tiene strict mode: marcas la herramienta con strict: true en el request y vLLM fija el schema de los argumentos con un grammar. Si controlas el server y no confías en los clientes, existe --tool-strict-level parameter, que exige el schema de argumentos para todas las herramientas de todos los requests.

El loop completo, mínimo

Un agente no es más que un while que ejecuta tool_calls hasta que el modelo responde sin ellas:

# agent_loop.py
import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")

def read_file(path: str) -> str:
    return open(path).read()[:2000]

tools = [{
    "type": "function",
    "function": {
        "name": "read_file",
        "description": "Lee un archivo del repo",
        "parameters": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
            "additionalProperties": False,
        },
        "strict": True,
    },
}]

messages = [{"role": "user", "content": "Resume el README.md del repo"}]
for _ in range(5):
    res = client.chat.completions.create(
        model=client.models.list().data[0].id,
        messages=messages, tools=tools, tool_choice="auto",
    )
    msg = res.choices[0].message
    if not msg.tool_calls:
        print(msg.content)
        break
    messages.append(msg)
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        result = read_file(**args)
        messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

Nota dos decisiones. El schema sigue el estilo strict de OpenAI: additionalProperties: false y todos los campos en required — con eso el grammar de vLLM puede fijar los argumentos sin ambigüedad. Y el loop tiene tope de 5 iteraciones: sin tope, un modelo que insiste en llamar tools te cuesta dinero infinito. Los guardrails completos están en mi guía de agentes. Tu server ya habla el idioma; el resto del trabajo vive en tu cliente.

Checkpoint y el error típico

Checkpoint: tu server emite tool_calls estructurados (no prosa con JSON adentro) y tus requests con schema devuelven JSON válido siempre. La prueba: manda un request con schema y valida la salida con tu Pydantic — si nunca lanza, está listo.

El error típico: mezclar reasoning y structured outputs sin el flag --structured-outputs-config.enable_in_reasoning=True. Síntoma: content vacío o el JSON atrapado dentro del thinking. Segundo lugar: parser desactualizado del tutorial que copiaste — el catálogo cambia entre versiones.

Siguiente capítulo

Tu agente ya funciona. Ahora lo hacemos caber en la GPU que puedes pagar: cuantización FP8, INT4 y NVFP4 con números de calidad, no de marketing.

> Más posts