Limitar LLMs con salida estructurada: Ollama, Qwen3 & Python o Go

Un par de formas de obtener salida estructurada de Ollama

Índice

Modelos de Lenguaje Grande (LLMs) son poderosos, pero en producción raramente queremos párrafos libres. En su lugar, queremos datos predecibles: atributos, hechos u objetos estructurados que puedas alimentar en una aplicación. Eso es Salida Estructurada de LLM.

Hace algún tiempo Ollama introdujo soporte para salida estructurada (anuncio), lo que hace posible restringir las respuestas de un modelo para que coincidan con un esquema JSON. Esto desbloquea pipelines consistentes de extracción de datos para tareas como catalogar características de LLM, evaluar modelos o automatizar la integración del sistema.

ducks in a row

En este post, cubriremos:

  • ¿Qué es la salida estructurada y por qué importa?
  • Una forma sencilla de obtener salida estructurada de LLM
  • Cómo funciona la nueva característica de Ollama
  • Ejemplos de extracción de capacidades de LLM:

¿Qué es la Salida Estructurada?

Normalmente, los LLM generan texto libre:

“El modelo X admite razonamiento con cadena de pensamiento, tiene una ventana de contexto de 200K y habla inglés, chino y español.”

Eso es legible, pero difícil de analizar.

En cambio, con salida estructurada le pedimos un esquema estricto:

{
  "nombre": "Modelo X",
  "admite_pensamiento": true,
  "max_tokens_contexto": 200000,
  "idiomas": ["Inglés", "Chino", "Español"]
}

Este JSON es fácil de validar, almacenar en una base de datos o alimentar a una interfaz de usuario.


Forma sencilla de obtener Salida Estructurada de LLM

A veces los LLM entienden qué es el esquema y podemos pedirle que devuelva la salida en JSON usando un esquema específico. El modelo Qwen3 de Alibaba está optimizado para razonamiento y respuestas estructuradas. Puedes instruirlo explícitamente para que responda en JSON.

Ejemplo 1: Usando Qwen3 con ollama en Python, solicitando JSON con esquema

import json
import ollama

prompt = """
Eres un extractor de datos estructurados.
Devuelve solo JSON.
Texto: "Elon Musk tiene 53 años y vive en Austin."
Esquema: { "nombre": string, "edad": int, "ciudad": string }
"""

response = ollama.chat(model="qwen3", messages=[{"role": "user", "content": prompt}])
output = response['message']['content']

# Analizar JSON
try:
    data = json.loads(output)
    print(data)
except Exception as e:
    print("Error al analizar JSON:", e)

Salida:

{"nombre": "Elon Musk", "edad": 53, "ciudad": "Austin"}

Forzar la Validación del Esquema con Pydantic

Para evitar salidas malformadas, puedes validar contra un esquema Pydantic en Python.

from pydantic import BaseModel

class Persona(BaseModel):
    nombre: str
    edad: int
    ciudad: str

# Supongamos que 'output' es la cadena JSON de Qwen3
data = Persona.model_validate_json(output)
print(data.nombre, data.edad, data.ciudad)

Esto asegura que la salida se ajuste a la estructura esperada.


Salida Estructurada de Ollama

Ahora Ollama te permite pasar un esquema en el parámetro format. El modelo se ve restringido a responder solo en JSON que se ajuste al esquema (docs).

En Python, normalmente defines tu esquema con Pydantic y dejas que Ollama lo use como un esquema JSON.


Ejemplo 2: Extraer Metadatos de Capacidad de LLM

Supongamos que tienes un fragmento de texto que describe las capacidades de un LLM:

“Qwen3 tiene un fuerte soporte multilingüe (inglés, chino, francés, español, árabe). Permite pasos de razonamiento (cadena de pensamiento). La ventana de contexto es de 128K tokens.”

Quieres datos estructurados:

from pydantic import BaseModel
from typing import List
from ollama import chat

class LLMFeatures(BaseModel):
    nombre: str
    admite_pensamiento: bool
    max_tokens_contexto: int
    idiomas: List[str]

prompt = """
Analiza la siguiente descripción y devuelve las características del modelo en JSON solo.
Descripción del modelo:
'Qwen3 tiene un fuerte soporte multilingüe (inglés, chino, francés, español, árabe).
Permite pasos de razonamiento (cadena de pensamiento).
La ventana de contexto es de 128K tokens.'
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format=LLMFeatures.model_json_schema(),
    options={"temperature": 0},
)

print(resp.message.content)

Salida posible:

{
  "nombre": "Qwen3",
  "admite_pensamiento": true,
  "max_tokens_contexto": 128000,
  "idiomas": ["Inglés", "Chino", "Francés", "Español", "Árabe"]
}

Ejemplo 3: Comparar Varios Modelos

Proporciona descripciones de varios modelos y extrae en forma estructurada:

from typing import List

class ComparacionModelos(BaseModel):
    modelos: List[LLMFeatures]

prompt = """
Extrae las características de cada modelo en forma de JSON.

1. Llama 3.1 admite razonamiento. Ventana de contexto de 128K. Idiomas: solo inglés.
2. GPT-4 Turbo admite razonamiento. Ventana de contexto de 128K. Idiomas: inglés, japonés.
3. Qwen3 admite razonamiento. Ventana de contexto de 128K. Idiomas: inglés, chino, francés, español, árabe.
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format=ModelComparison.model_json_schema(),
    options={"temperature": 0},
)

print(resp.message.content)

Salida:

{
  "modelos": [
    {
      "nombre": "Llama 3.1",
      "admite_pensamiento": true,
      "max_tokens_contexto": 128000,
      "idiomas": ["Inglés"]
    },
    {
      "nombre": "GPT-4 Turbo",
      "admite_pensamiento": true,
      "max_tokens_contexto": 128000,
      "idiomas": ["Inglés", "Japonés"]
    },
    {
      "nombre": "Qwen3",
      "admite_pensamiento": true,
      "max_tokens_contexto": 128000,
      "idiomas": ["Inglés", "Chino", "Francés", "Español", "Árabe"]
    }
  ]
}

Esto hace trivial evaluar, visualizar o filtrar modelos según sus características.


Ejemplo 4: Detectar Brechas Automáticamente

Incluso puedes permitir valores null cuando un campo esté ausente:

from typing import Optional

class LLMFeaturesFlexibles(BaseModel):
    nombre: str
    admite_pensamiento: Optional[bool]
    max_tokens_contexto: Optional[int]
    idiomas: Optional[List[str]]

Esto asegura que tu esquema siga siendo válido incluso si falta cierta información.


Beneficios, Precauciones y Buenas Prácticas

Usar salida estructurada a través de Ollama (o cualquier sistema que lo admita) ofrece muchos beneficios — pero también tiene algunas precauciones.

Beneficios

  • Garantías más fuertes: El modelo se le pide que se ajuste a un esquema JSON en lugar de texto libre.
  • Análisis más fácil: Puedes usar directamente json.loads o validar con Pydantic / Zod, en lugar de expresiones regulares o heurísticas.
  • Evolución basada en esquema: Puedes versionar tu esquema, agregar campos (con valores predeterminados) y mantener la compatibilidad hacia atrás.
  • Interoperabilidad: Los sistemas downstream esperan datos estructurados.
  • Determinismo (mejor con temperatura baja): Cuando la temperatura es baja (por ejemplo, 0), el modelo es más propenso a adherirse estrictamente al esquema. Las documentaciones de Ollama recomiendan esto.

Precauciones y Peligros

  • Incongruencia del esquema: El modelo podría aún desviarse — por ejemplo, omitir una propiedad requerida, reordenar claves o incluir campos extraños. Necesitas validación.
  • Esquemas complejos: Esquemas JSON muy profundos o recursivos podrían confundir al modelo o causar errores.
  • Ambigüedad en el prompt: Si tu prompt es vago, el modelo podría adivinar campos o unidades incorrectamente.
  • Inconsistencia entre modelos: Algunos modelos pueden ser mejores o peores en cumplir con las restricciones estructuradas.
  • Límites de tokens: El esquema en sí mismo agrega costo de token al prompt o a la llamada de API.

Buenas Prácticas y Consejos (tomados del blog de Ollama + experiencia)

  • Usa Pydantic (Python) o Zod (JavaScript) para definir tus esquemas y generar automáticamente esquemas JSON. Esto evita errores manuales.
  • Siempre incluye instrucciones como “responde solo en JSON” o “no incluyas comentarios o texto extra” en tu prompt.
  • Usa temperatura = 0 (o muy baja) para minimizar la aleatoriedad y maximizar la adherencia al esquema. Ollama recomienda determinismo.
  • Valida y potencialmente recupera (por ejemplo, reintenta o limpia) cuando el análisis de JSON falle o la validación del esquema falle.
  • Comienza con un esquema más simple, luego extiéndelo gradualmente. No lo compliques al principio.
  • Incluye instrucciones de error útiles pero restringidas: por ejemplo, si el modelo no puede llenar un campo requerido, responda con null en lugar de omitirlo (si tu esquema lo permite).

Ejemplo 1 en Go: Extrayendo Características de LLM

Aquí hay un programa simple Go que le pide a Qwen3 una salida estructurada sobre las características de un LLM.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/ollama/ollama/api"
)

type LLMFeatures struct {
	Nombre             string   `json:"nombre"`
	AdmitePensamiento bool     `json:"admite_pensamiento"`
	MaxTokensContexto int      `json:"max_tokens_contexto"`
	Idiomas        []string `json:"idiomas"`
}

func main() {
	client, err := api.ClientFromEnvironment()
	if err != nil {
		log.Fatal(err)
	}

	prompt := `
  Analiza la siguiente descripción y devuelve las características del modelo en JSON solo.
  Descripción:
  "Qwen3 tiene un fuerte soporte multilingüe (inglés, chino, francés, español, árabe).
  Permite pasos de razonamiento (cadena de pensamiento).
  La ventana de contexto es de 128K tokens."
  `

	// Define el esquema JSON para la salida estructurada
	formatSchema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"nombre": map[string]string{
				"type": "string",
			},
			"admite_pensamiento": map[string]string{
				"type": "boolean",
			},
			"max_tokens_contexto": map[string]string{
				"type": "integer",
			},
			"idiomas": map[string]any{
				"type": "array",
				"items": map[string]string{
					"type": "string",
				},
			},
		},
		"required": []string{"nombre", "admite_pensamiento", "max_tokens_contexto", "idiomas"},
	}

	// Convierte el esquema a JSON
	formatJSON, err := json.Marshal(formatSchema)
	if err != nil {
		log.Fatal("Fallo al convertir el esquema de formato:", err)
	}

	req := &api.GenerateRequest{
		Model:   "qwen3:8b",
		Prompt:  prompt,
		Format:  formatJSON,
		Options: map[string]any{"temperature": 0},
	}

	var features LLMFeatures
	var rawResponse string
	err = client.Generate(context.Background(), req, func(response api.GenerateResponse) error {
		// Acumula contenido mientras se transmite
		rawResponse += response.Response

		// Solo analiza cuando la respuesta esté completa
		if response.Done {
			if err := json.Unmarshal([]byte(rawResponse), &features); err != nil {
				return fmt.Errorf("error de análisis JSON: %v", err)
			}
		}
		return nil
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Estructura analizada: %+v\n", features)
}

Para compilar y ejecutar este ejemplo de programa Go — supongamos que tenemos este archivo main.go en una carpeta ollama-struct, Necesitamos ejecutar dentro de esta carpeta:

# inicializar módulo
go mod init ollama-struct
# obtener todas las dependencias
go mod tidy
# compilar y ejecutar
go build -o ollama-struct main.go
./ollama-struct

Salida de ejemplo

Estructura analizada: {Nombre:Qwen3 AdmitePensamiento:true MaxTokensContexto:128000 Idiomas:[Inglés Chino Francés Español Árabe]}

Ejemplo 2 en Go: Comparando Varios Modelos

Puedes extender esto para extraer una lista de modelos para comparación.

  type ComparacionModelos struct {
		Modelos []LLMFeatures `json:"modelos"`
	}

	prompt = `
	Extrae las características de las siguientes descripciones de modelos y devuélvelas como JSON:

	1. PaLM 2: Este modelo tiene capacidades de razonamiento limitadas y se centra en el entendimiento básico del lenguaje. Soporta una ventana de contexto de 8,000 tokens. Principalmente soporta solo el idioma inglés.
	2. LLaMA 2: Este modelo tiene capacidades de razonamiento moderadas y puede manejar algunas tareas lógicas. Puede procesar hasta 4,000 tokens en su contexto. Soporta los idiomas inglés, español e italiano.
	3. Codex: Este modelo tiene fuertes capacidades de razonamiento específicamente para programación y análisis de código. Tiene una ventana de contexto de 16,000 tokens. Soporta los idiomas inglés, Python, JavaScript y Java.

	Devuelve un objeto JSON con una "lista de modelos" que contenga todos los modelos.
	`

	// Define el esquema JSON para la comparación de modelos
	comparisonSchema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"modelos": map[string]any{
				"type": "array",
				"items": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"nombre": map[string]string{
							"type": "string",
						},
						"admite_pensamiento": map[string]string{
							"type": "boolean",
						},
						"max_tokens_contexto": map[string]string{
							"type": "integer",
						},
						"idiomas": map[string]any{
							"type": "array",
							"items": map[string]string{
								"type": "string",
							},
						},
					},
					"required": []string{"nombre", "admite_pensamiento", "max_tokens_context意图", "idiomas"},
				},
			},
		},
		"required": []string{"modelos"},
	}

	// Convierte el esquema a JSON
	comparisonFormatJSON, err := json.Marshal(comparisonSchema)
	if err != nil {
		log.Fatal("Fallo al convertir el esquema de comparación:", err)
	}

	req = &api.GenerateRequest{
		Model:   "qwen3:8b",
		Prompt:  prompt,
		Format:  comparisonFormatJSON,
		Options: map[string]any{"temperature": 0},
	}

	var comp ComparacionModelos
	var comparisonResponse string
	err = client.Generate(context.Background(), req, func(response api.GenerateResponse) error {
		// Acumula contenido mientras se transmite
		comparisonResponse += response.Response

		// Solo analiza cuando la respuesta esté completa
		if response.Done {
			if err := json.Unmarshal([]byte(comparisonResponse), &comp); err != nil {
				return fmt.Errorf("error de análisis JSON: %v", err)
			}
		}
		return nil
	})
	if err != nil {
		log.Fatal(err)
	}

	for _, m := range comp.Modelos {
		fmt.Printf("%s: Contexto=%d, Idiomas=%v\n", m.Nombre, m.MaxTokensContexto, m.Idiomas)
	}

Salida de ejemplo

PaLM 2: Contexto=8000, Idiomas=[Inglés]
LLaMA 2: Contexto=4000, Idiomas=[Inglés Español Italiano]
Codex: Contexto=16000, Idiomas=[Inglés Python JavaScript Java]

Por cierto, qwen3:4b en estos ejemplos funciona bien, igual que qwen3:8b.

Buenas Prácticas para Desarrolladores en Go

  • Establece la temperatura a 0 para la máxima adherencia al esquema.
  • Valida con json.Unmarshal y recupera si falla el análisis.
  • Mantén los esquemas simples — estructuras JSON profundamente anidadas o recursivas pueden causar problemas.
  • Permite campos opcionales (usa omitempty en las etiquetas de structs de Go) si esperas datos faltantes.
  • Agrega reintentos si el modelo emite ocasionalmente JSON inválido.

Ejemplo completo - Dibujar un gráfico con especificaciones de LLM (paso a paso: desde JSON estructurado a tablas de comparación)

llm-chart

  1. Define un esquema para los datos que deseas

Usa Pydantic para poder (a) generar un esquema JSON para Ollama y (b) validar la respuesta del modelo.

from pydantic import BaseModel
from typing import List, Optional

class LLMFeatures(BaseModel):
    nombre: str
    admite_pensamiento: bool
    max_tokens_contexto: int
    idiomas: List[str]
  1. Pide a Ollama que devuelva solo JSON en ese formato

Pasa el esquema en format= y reduce la temperatura para determinismo.

from ollama import chat

prompt = """
Extrae las características de cada modelo. Devuelve solo JSON que coincida con el esquema.
1) Qwen3 admite cadena de pensamiento; 128K contexto; Inglés, Chino, Francés, Español, Árabe.
2) Llama 3.1 admite cadena de pensamiento; 128K contexto; Inglés.
3) GPT-4 Turbo admite cadena de pensamiento; 128K contexto; Inglés, Japonés.
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format={"type": "array", "items": LLMFeatures.model_json_schema()},
    options={"temperature": 0}
)

raw_json = resp.message.content  # lista JSON de LLMFeatures
  1. Valida y normaliza

Siempre valida antes de usar en producción.

from pydantic import TypeAdapter

adapter = TypeAdapter(list[LLMFeatures])
modelos = adapter.validate_json(raw_json)  # -> lista[LLMFeatures]
  1. Construye una tabla de comparación (pandas)

Convierte tus objetos validados en un DataFrame que puedas ordenar/filtrar y exportar.

import pandas as pd

df = pd.DataFrame([m.model_dump() for m in modelos])
df["idiomas_count"] = df["idiomas"].apply(len)
df["idiomas"] = df["idiomas"].apply(lambda xs: ", ".join(xs))

# Reordenar columnas para mejor legibilidad
df = df[["nombre", "admite_pensamiento", "max_tokens_contexto", "idiomas_count", "idiomas"]]

# Guardar como CSV para uso posterior
df.to_csv("comparacion_caracteristicas_llm.csv", index=False)
  1. (Opcional) Gráficos rápidos

Gráficos simples te ayudan a ver rápidamente las diferencias entre modelos.

import matplotlib.pyplot as plt

plt.figure()
plt.bar(df["nombre"], df["max_tokens_contexto"])
plt.title("Ventana de contexto máxima por modelo (tokens)")
plt.xlabel("Modelo")
plt.ylabel("Tokens máximos de contexto")
plt.xticks(rotation=20, ha="right")
plt.tight_layout()
plt.savefig("ventana_contexto_maxima.png")

TL;DR

Con el nuevo soporte para salida estructurada de Ollama, puedes tratar a los LLMs no solo como chatbots, sino como motores de extracción de datos.

Los ejemplos anteriores mostraron cómo extraer automáticamente metadatos estructurados sobre características de LLM como soporte de pensamiento, tamaño de ventana de contexto y idiomas admitidos — tareas que de otro modo requerirían análisis frágil.

Ya sea que estés construyendo un catálogo de modelos LLM, un tablero de evaluación o un asistente de investigación impulsado por IA, las salidas estructuradas hacen la integración suave, confiable y lista para producción.

Enlaces útiles