Structured Outputs con Claude API: JSON Affidabile al 100%

Structured Outputs con Claude API: JSON Affidabile al 100%

Perché gli Structured Outputs con Claude nel 2026

Chiunque abbia integrato un LLM in produzione conosce bene il problema: il modello risponde in linguaggio naturale, e tu devi estrarre dati strutturati da quel testo in modo affidabile. Regex fragili, parser JSON che esplodono su edge case, output troncati — un incubo da gestire in un sistema reale.

Nel 2026 Claude API mette a disposizione un meccanismo nativo per risolvere proprio questo problema: structured outputs. Tramite il parametro tool_use (o, nelle versioni più recenti, schemi JSON dichiarativi), puoi garantire che il modello restituisca sempre e solo dati conformi a uno schema preciso — zero parsing manuale, zero sorprese a runtime.

In questa guida vediamo tutto il percorso: dalla teoria alla produzione, con esempi reali in Python e TypeScript. Se hai già lavorato con tool use con Claude API, riconoscerai la struttura di base — ma gli structured outputs aprono un livello di controllo ancora superiore. Parleremo anche di validazione con Zod, gestione degli errori e pattern avanzati per pipeline dati.

Come Funzionano gli Structured Outputs: il Meccanismo

Claude non supporta (ancora) un parametro response_format: json_schema identico a OpenAI, ma ci sono due strategie equivalenti e molto potenti:

  • Tool use con schema JSON: definisci uno “strumento” fittizio con input_schema e forzi il modello a chiamarlo. L’output di ogni tool call è un oggetto JSON validato dall’SDK.
  • Prompt engineering + estrazione: chiedi esplicitamente JSON nel system prompt e poi validi tu con Pydantic o Zod. Meno robusto del primo ma utile per casi semplici.

Il pattern consigliato in produzione è il tool use forzato: imposti tool_choice: {"type": "tool", "name": "..."} e Claude è obbligato a restituire un JSON conforme allo schema che hai dichiarato. Nessuna altra scelta.

import anthropic
import json

client = anthropic.Anthropic()

# Schema che definisce l'output atteso
product_schema = {
    "name": "extract_product",
    "description": "Estrae le informazioni di prodotto dal testo",
    "input_schema": {
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "description": "Nome del prodotto"
            },
            "price": {
                "type": "number",
                "description": "Prezzo in euro"
            },
            "category": {
                "type": "string",
                "enum": ["elettronica", "abbigliamento", "casa", "sport"],
                "description": "Categoria del prodotto"
            },
            "in_stock": {
                "type": "boolean",
                "description": "Disponibilita in magazzino"
            },
            "tags": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Tag descrittivi"
            }
        },
        "required": ["name", "price", "category", "in_stock"]
    }
}

def extract_product(text: str) -> dict:
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=1024,
        tools=[product_schema],
        tool_choice={"type": "tool", "name": "extract_product"},
        messages=[{
            "role": "user",
            "content": f"Estrai le informazioni di prodotto da questo testo:\n\n{text}"
        }]
    )
    # Il primo content block e sempre il tool_use
    tool_use = response.content[0]
    return tool_use.input  # Gia un dict Python validato

# Test
text = (
    "Il Samsung Galaxy S25 Ultra e disponibile al prezzo di 1299 euro.\n"
    "E uno smartphone di fascia alta, attualmente in stock nel nostro magazzino.\n"
    "Tags: flagship, 5G, fotografia, gaming."
)

result = extract_product(text)
print(json.dumps(result, indent=2, ensure_ascii=False))

L’output sarà sempre un dizionario Python conforme allo schema — se il modello non riesce a estrarre un campo required, l’intera chiamata fallisce con un errore chiaro, non silenziosamente con dati mancanti.

Structured Outputs in TypeScript con Zod

Se lavori in TypeScript, la combinazione Claude API + Zod è devastante in senso positivo: ottieni type safety a compile time e validazione a runtime in un colpo solo. Se hai già usato Zod per validare API e form, questo pattern ti sembrerà familiare ma potenziato.

import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";

const client = new Anthropic();

// Schema Zod per la risposta
const InvoiceSchema = z.object({
  invoice_number: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  vendor: z.object({
    name: z.string(),
    vat_id: z.string().optional(),
  }),
  items: z.array(
    z.object({
      description: z.string(),
      quantity: z.number().positive(),
      unit_price: z.number().nonnegative(),
      total: z.number().nonnegative(),
    })
  ),
  total_amount: z.number().nonnegative(),
  currency: z.enum(["EUR", "USD", "GBP"]),
});

type Invoice = z.infer<typeof InvoiceSchema>;

async function extractInvoice(rawText: string): Promise<Invoice> {
  const response = await client.messages.create({
    model: "claude-opus-4-5",
    max_tokens: 2048,
    tools: [
      {
        name: "extract_invoice",
        description: "Estrae dati strutturati da una fattura",
        input_schema: {
          type: "object",
          properties: {
            invoice_number: { type: "string" },
            date: { type: "string", description: "Formato YYYY-MM-DD" },
            vendor: {
              type: "object",
              properties: {
                name: { type: "string" },
                vat_id: { type: "string" },
              },
              required: ["name"],
            },
            items: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  description: { type: "string" },
                  quantity: { type: "number" },
                  unit_price: { type: "number" },
                  total: { type: "number" },
                },
                required: ["description", "quantity", "unit_price", "total"],
              },
            },
            total_amount: { type: "number" },
            currency: { type: "string", enum: ["EUR", "USD", "GBP"] },
          },
          required: [
            "invoice_number", "date", "vendor", "items",
            "total_amount", "currency",
          ],
        },
      },
    ],
    tool_choice: { type: "tool", name: "extract_invoice" },
    messages: [
      {
        role: "user",
        content: `Estrai tutti i dati strutturati da questa fattura:\n\n${rawText}`,
      },
    ],
  });

  const toolUse = response.content[0];
  if (toolUse.type !== "tool_use") {
    throw new Error("Risposta inattesa da Claude");
  }

  // Validazione Zod - type safe a runtime
  const parsed = InvoiceSchema.safeParse(toolUse.input);
  if (!parsed.success) {
    throw new Error(`Dati non validi: ${parsed.error.message}`);
  }

  return parsed.data; // Invoice tipizzata correttamente
}

// Utilizzo
const invoiceText = [
  "FATTURA N. 2026-0042",
  "Data: 2026-06-15",
  "Fornitore: TechSupplies SRL - P.IVA IT12345678901",
  "Articoli:",
  "- MacBook Pro 14\" M4  x1  @ 2199.00 EUR = 2199.00",
  "- Thunderbolt Hub      x2  @ 89.50 EUR  = 179.00",
  "Totale: 2378.00 EUR",
].join("\n");

extractInvoice(invoiceText).then((invoice) => {
  console.log("Fattura estratta:", invoice);
  console.log("Totale:", invoice.total_amount, invoice.currency);
});

Nota il passaggio chiave: l’output di toolUse.input viene passato a InvoiceSchema.safeParse(). Se Claude restituisce qualcosa che non corrisponde allo schema Zod (anche se rispetta quello JSON), la validazione fallisce in modo esplicito e gestibile.

Pattern Avanzato: Schema Ricorsivo e Union Types

Gli structured outputs diventano davvero potenti quando devi gestire strutture dati gerarchiche o polimorfiche. Pensa a un sistema che analizza documenti di tipi diversi: un ordine, un contratto, una email di supporto — ognuno con schema diverso. Qui il pattern con oneOf o discriminated union fa la differenza.

Questo approccio si integra perfettamente con le pipeline RAG con Claude API: prima recuperi i documenti rilevanti, poi usi structured outputs per estrarne dati uniformi da passare al tuo database.

import anthropic
from pydantic import BaseModel
from typing import Literal, Union

client = anthropic.Anthropic()

# Schema con discriminated union tramite Pydantic
class SupportTicket(BaseModel):
    doc_type: Literal["support"]
    ticket_id: str
    priority: Literal["low", "medium", "high", "critical"]
    customer_email: str
    issue_summary: str
    suggested_action: str

class SalesOrder(BaseModel):
    doc_type: Literal["order"]
    order_id: str
    customer_name: str
    total: float
    currency: str
    items_count: int

class LegalContract(BaseModel):
    doc_type: Literal["contract"]
    parties: list[str]
    effective_date: str
    contract_type: str
    key_obligations: list[str]

# Tool schema che supporta tutti i tipi
classify_and_extract_schema = {
    "name": "classify_and_extract",
    "description": "Classifica il documento ed estrae i dati strutturati appropriati",
    "input_schema": {
        "type": "object",
        "properties": {
            "doc_type": {
                "type": "string",
                "enum": ["support", "order", "contract"],
                "description": "Tipo di documento rilevato"
            },
            "ticket_id": {"type": "string"},
            "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
            "customer_email": {"type": "string"},
            "issue_summary": {"type": "string"},
            "suggested_action": {"type": "string"},
            "order_id": {"type": "string"},
            "customer_name": {"type": "string"},
            "total": {"type": "number"},
            "currency": {"type": "string"},
            "items_count": {"type": "integer"},
            "parties": {"type": "array", "items": {"type": "string"}},
            "effective_date": {"type": "string"},
            "contract_type": {"type": "string"},
            "key_obligations": {"type": "array", "items": {"type": "string"}}
        },
        "required": ["doc_type"]
    }
}

def process_document(text: str) -> Union[SupportTicket, SalesOrder, LegalContract]:
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=1024,
        tools=[classify_and_extract_schema],
        tool_choice={"type": "tool", "name": "classify_and_extract"},
        system="Sei un sistema di classificazione documenti. Analizza il documento,\n"
               "determina il tipo e estrai SOLO i campi pertinenti al tipo rilevato.",
        messages=[{"role": "user", "content": text}]
    )
    raw = response.content[0].input
    doc_type = raw.get("doc_type")
    if doc_type == "support":
        return SupportTicket(**raw)
    elif doc_type == "order":
        return SalesOrder(**raw)
    elif doc_type == "contract":
        return LegalContract(**raw)
    else:
        raise ValueError(f"Tipo documento sconosciuto: {doc_type}")

# Test con un ticket di supporto
ticket_text = (
    "Da: mario.rossi@example.com\n"
    "Oggetto: Sistema di pagamento non funziona - URGENTE\n\n"
    "ID ticket: TKT-2026-8891\n"
    "Il checkout si blocca al pagamento con carta. Questo blocca le vendite!\n"
    "Ho bisogno di una risoluzione immediata."
)
result = process_document(ticket_text)
print(f"Tipo: {result.doc_type}")
print(f"Priorita: {result.priority}")
print(f"Azione: {result.suggested_action}")

Gestione Errori e Retry con Backoff Esponenziale

In produzione gli structured outputs possono fallire per vari motivi: token limit superato, input ambiguo, o semplicemente rate limit dell’API. Un sistema robusto implementa retry intelligente con backoff esponenziale e logging strutturato.

Il principio è lo stesso che vedi in qualsiasi gestione HTTP robusta: se la chiamata fallisce, aspetti un tempo crescente prima di riprovare, con un numero massimo di tentativi. Vale la pena notare come questo si integri perfettamente con sistemi come n8n + Claude per le pipeline automatizzate.

import anthropic
import time
import logging
from typing import Callable, TypeVar
from functools import wraps

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

client = anthropic.Anthropic()
T = TypeVar("T")

def with_retry(
    max_attempts: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    backoff_factor: float = 2.0
):
    """Decorator per retry con backoff esponenziale."""
    def decorator(func: Callable[..., T]) -> Callable[..., T]:
        @wraps(func)
        def wrapper(*args, **kwargs) -> T:
            last_error = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except anthropic.RateLimitError as e:
                    wait = min(base_delay * (backoff_factor ** attempt), max_delay)
                    logger.warning(
                        f"Rate limit (attempt {attempt+1}/{max_attempts}), retry in {wait:.1f}s"
                    )
                    time.sleep(wait)
                    last_error = e
                except anthropic.APIError as e:
                    logger.error(f"API error non recuperabile: {e}")
                    raise
                except Exception as e:
                    wait = min(base_delay * (backoff_factor ** attempt), max_delay)
                    logger.warning(f"Errore (attempt {attempt+1}/{max_attempts}): {e}, retry in {wait:.1f}s")
                    time.sleep(wait)
                    last_error = e
            raise last_error
        return wrapper
    return decorator

# Schema per l'analisi del sentiment
sentiment_schema = {
    "name": "analyze_sentiment",
    "description": "Analizza il sentiment e le entita del testo",
    "input_schema": {
        "type": "object",
        "properties": {
            "sentiment": {
                "type": "string",
                "enum": ["positivo", "neutro", "negativo", "misto"]
            },
            "confidence": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
            },
            "topics": {
                "type": "array",
                "items": {"type": "string"},
                "maxItems": 5
            },
            "key_phrases": {
                "type": "array",
                "items": {"type": "string"},
                "maxItems": 3
            }
        },
        "required": ["sentiment", "confidence", "topics"]
    }
}

@with_retry(max_attempts=3, base_delay=2.0)
def analyze_sentiment(text: str) -> dict:
    """Analisi sentiment con retry automatico."""
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=512,
        tools=[sentiment_schema],
        tool_choice={"type": "tool", "name": "analyze_sentiment"},
        messages=[{"role": "user", "content": f"Analizza questo testo:\n\n{text}"}]
    )
    result = response.content[0].input
    logger.info(f"Sentiment: {result['sentiment']} (confidence: {result['confidence']:.2f})")
    return result

# Batch processing
reviews = [
    "Prodotto eccellente, spedizione velocissima! Super consigliato.",
    "Qualita mediocre, non corrisponde alle foto. Deluso.",
    "Nella media, niente di speciale ma nemmeno problemi."
]
for review in reviews:
    result = analyze_sentiment(review)
    print(f"Sentiment: {result['sentiment']} | Topics: {result['topics']}")

Integrazione con Database: Pipeline End-to-End

Lo scenario più comune in produzione è questo: hai una fonte di testo non strutturato (email, recensioni, PDF) e vuoi popolare un database relazionale con dati puliti. Gli structured outputs di Claude sono il ponte perfetto tra chaos e ordine.

Se stai costruendo questo tipo di pipeline su stack TypeScript, vale la pena abbinare questo approccio con Drizzle ORM per le query type-safe — la tipizzazione end-to-end dall’estrazione al database è impressionante.

import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";

const client = new Anthropic();

const ReviewSchema = z.object({
  product_name: z.string(),
  rating: z.number().int().min(1).max(5),
  sentiment: z.enum(["positive", "neutral", "negative"]),
  pros: z.array(z.string()).max(5),
  cons: z.array(z.string()).max(5),
  verified_purchase: z.boolean(),
  language: z.enum(["it", "en", "fr", "de", "es"]),
  recommended: z.boolean(),
});

type Review = z.infer<typeof ReviewSchema>;

async function saveToDatabase(review: Review, originalText: string): Promise<void> {
  // db.insert(reviews).values({ ...review, raw_text: originalText })
  console.log("[DB] Inserimento:", {
    product: review.product_name,
    rating: review.rating,
    sentiment: review.sentiment,
  });
}

async function processReview(rawText: string): Promise<Review> {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 1024,
    tools: [
      {
        name: "normalize_review",
        description: "Normalizza una recensione in formato strutturato",
        input_schema: {
          type: "object",
          properties: {
            product_name: { type: "string" },
            rating: { type: "integer", minimum: 1, maximum: 5 },
            sentiment: { type: "string", enum: ["positive", "neutral", "negative"] },
            pros: { type: "array", items: { type: "string" } },
            cons: { type: "array", items: { type: "string" } },
            verified_purchase: { type: "boolean" },
            language: { type: "string", enum: ["it", "en", "fr", "de", "es"] },
            recommended: { type: "boolean" },
          },
          required: [
            "product_name", "rating", "sentiment", "pros",
            "cons", "verified_purchase", "language", "recommended"
          ],
        },
      },
    ],
    tool_choice: { type: "tool", name: "normalize_review" },
    system: "Sei un sistema di normalizzazione recensioni. Estrai tutte le informazioni rilevanti\n"
           + "e inferisci i campi mancanti dal contesto.",
    messages: [{ role: "user", content: rawText }],
  });

  const toolUse = response.content[0];
  if (toolUse.type !== "tool_use") throw new Error("Tool use non ricevuto");
  const parsed = ReviewSchema.parse(toolUse.input);
  return parsed;
}

async function processBatch(reviews: string[]): Promise<void> {
  const results = await Promise.allSettled(
    reviews.map(async (text) => {
      const review = await processReview(text);
      await saveToDatabase(review, text);
      return review;
    })
  );
  const succeeded = results.filter((r) => r.status === "fulfilled").length;
  const failed = results.filter((r) => r.status === "rejected").length;
  console.log(`Processate: ${succeeded} ok, ${failed} errori`);
}

processBatch([
  "Acquisto verificato. iPhone 15 Pro - Fantastico! Camera eccellente, batteria migliorata. 5 stelle.",
  "Sony WH-1000XM5: cancellazione rumore ottima ma scomodo dopo 2 ore. 3/5. Non raccomandato.",
]);

Ottimizzare i Costi: Scegliere il Modello Giusto

Un aspetto critico degli structured outputs in produzione è il costo per operazione. Non tutti i task richiedono Claude Opus — la scelta del modello può ridurre i costi del 90% senza perdita di qualità.

  • Claude Haiku 3.5 / 4.5: estrazione semplice (sentiment, classificazione, tag), batch ad alto volume, latenza bassa
  • Claude Sonnet 4.5: estrazione complessa (fatture, contratti), dati ambigui, strutture nidificate
  • Claude Opus 4.5: documenti legali critici, estrazione con ragionamento complesso, quando l’errore ha costo elevato

Un altro modo per ottimizzare i costi è il prompt caching: se usi lo stesso schema e system prompt per migliaia di documenti, puoi cachare il prompt e pagare solo per i token di input variabili. Se ti interessa approfondire, ho scritto una guida specifica sul prompt caching in Claude API che copre tutti i dettagli.

🔧 Regola pratica: usa Haiku per classificazione e tag, Sonnet per estrazione dati complessi, Opus solo quando l’errore ha un costo reale di business (es. parsing contratti legali, dati finanziari critici).

FAQ e Domande Frequenti

Claude API supporta response_format: json_object come OpenAI?

No, Claude non ha un parametro response_format nativo identico a OpenAI. Il modo corretto per ottenere JSON garantito con Claude — nel 2026 — è usare il tool use forzato con tool_choice: {type: "tool", name: "..."}. Questo approccio è in realtà più robusto del semplice JSON mode di OpenAI: puoi dichiarare uno schema dettagliato con tipi, enum, array nidificati e constraint, e il modello è vincolato a rispettarlo. Anthropic ha anche annunciato supporto esplicito agli structured outputs nelle versioni recenti dei modelli claude-3.5 e claude-4.x.

Cosa succede se il testo di input non contiene i dati richiesti dallo schema?

Dipende da come hai configurato lo schema. Se un campo è in required e il testo non lo contiene, Claude tipicamente inferisce o inventa un valore plausibile — il che può essere un problema. Per evitarlo: metti in required solo i campi realmente fondamentali, usa null come tipo opzionale per i campi che potrebbero mancare ({"type": ["string", "null"]}), e aggiungi nel system prompt “Se un campo non è presente nel testo, usa null”. In questo modo ottieni un comportamento prevedibile e puoi gestire i dati mancanti a livello applicativo.

Posso usare structured outputs per streaming?

Sì, ma con qualche caveat. Quando usi stream=True con tool use, ricevi i dati del tool via input_json_delta — cioè il JSON viene costruito incrementalmente. Non hai il JSON completo finché lo stream non è terminato, quindi per validare con Pydantic/Zod devi aspettare la fine. Se hai bisogno di aggiornamenti in tempo reale durante l’estrazione — ad esempio per mostrare un progress in UI — puoi fare parsing parziale del JSON in arrivo, ma è complesso. Per la maggior parte dei casi di estrazione dati, lo streaming non aggiunge valore: aspetta la risposta completa e valida in un colpo solo.

Quanti tool posso definire in una singola chiamata?

Claude supporta fino a 64 tool per chiamata, ma per gli structured outputs il pattern ottimale è usarne uno solo con tool_choice forzato. Avere più tool in gioco quando vuoi output strutturato può creare ambiguità: il modello potrebbe chiamare il tool sbagliato o nessuno. Riserva l’uso di più tool agli scenari di agenti con strumenti reali, dove il modello deve scegliere quale azione compiere. Per l’estrazione dati, uno schema, un tool, tool_choice forzato.

Conclusione

Gli structured outputs con Claude API risolvono uno dei problemi più concreti nell’integrazione LLM in produzione: avere output JSON affidabile senza dipendere da regex fragili o parsing manuale. Il pattern tool use forzato + Pydantic/Zod ti dà garanzie che semplici prompt engineering non possono offrire.

La combinazione che funziona meglio in produzione: schema JSON dettagliato con enum e required minimi, Pydantic/Zod per validazione a runtime, scelta del modello basata sulla complessità del task, e retry con backoff per gestire i casi limite. Con questo stack puoi costruire pipeline di estrazione dati robuste che processano migliaia di documenti al giorno con errori minimi e costi controllati.

Il passo successivo naturale è combinare structured outputs con retrieval: recuperi i documenti rilevanti, estrai i dati strutturati, e li usi per alimentare la tua applicazione. Un workflow completo che vale la pena esplorare.

Suggerimenti e Risorse

🔧 Tool consigliato: usa la libreria zod-to-json-schema (npm) o pydantic-to-jsonschema (pip) per generare automaticamente gli input_schema dai tuoi modelli esistenti — eviti di mantenere due definizioni parallele.

💡 Pro tip: per task di classificazione ad alto volume, usa claude-haiku-4-5 con schema minimale (3-5 campi). Il costo scende a meno di $0.001 per classificazione. Haiku è sorprendentemente accurato su task ben definiti.

🎯 Strategia: testa sempre il tuo schema con un set di documenti edge case prima di andare in produzione. Includi testi ambigui, testi in lingue diverse, testi vuoti e testi molto lunghi. Gli structured outputs sono robusti, ma lo schema deve essere progettato per il caso reale.

Condividi

Articoli Recenti

Categorie popolari