Torna al blog

2026-05-11

Output strutturato da LLM con Zod: streaming, validazione, recovery

Pattern TypeScript per validare output strutturato da modelli linguistici, gestire lo streaming e il recovery quando lo schema fallisce.

Quando un LLM deve restituire JSON che la tua applicazione consuma direttamente, il fallimento più comune non è un errore HTTP: è una stringa sintatticamente plausibile ma semanticamente inutilizzabile — JSON troncato, chiavi in inglese invece che nel contratto, numeri come stringhe, array vuoti dove servono oggetti. In streaming la situazione peggiora: arrivano chunk prima che il documento sia chiudibile con JSON.parse.

Stack di riferimento in questo pezzo: Node.js 20+, TypeScript 5.x, Zod 3.x (.safeParse), e un client che espone stream di token (pattern comune con Vercel AI SDK streamText / provider OpenAI o Anthropic documentati sul rispettivo “JSON mode” o structured output). Le API cambiano: verifica sempre la versione del pacchetto @ai-sdk/* o del client ufficiale che usi.

Il problema: token validi ma JSON inutilizzabile

Tre classi di errore ricorrenti:

  1. Parse error: stream interrotto, parentesi non bilanciate, virgole mancanti.
  2. Schema error: JSON valido ma non rispetta il contratto (tipo sbagliato, chiave mancante, enum fuori lista).
  3. Semantica tiepida: il JSON passa Zod ma i valori sono vuoti o generici (“description”: “N/A”) perché il modello ha “riempito” i campi.

Questo articolo copre 1 e 2 con codice. Per 3 serve validazione di business (es. lunghezza minima, vincoli incrociati tra campi) o un secondo passaggio di verifica.

Contratto interno con Zod

Definisci uno schema solo per la tua API interna, non per il prompt grezzo del modello. Il modello può produrre un superset (campi extra): usa .strip() in Zod o seleziona i campi con .pick().

import { z } from 'zod'

export const ExtractSchema = z.object({
  title: z.string().min(1).max(200),
  tags: z.array(z.string().min(1)).max(10),
  confidence: z.number().min(0).max(1),
})

export type Extract = z.infer<typeof ExtractSchema>

Dopo JSON.parse (solo su stringa completa) usa sempre safeParse, mai parse in hot path: ti restituisce l’errore strutturato senza eccezione.

const parsed = ExtractSchema.safeParse(unknownJson)
if (!parsed.success) {
  // recovery (sezione sotto)
}
return parsed.data

Streaming: buffer e dove fermarsi

Finché streami, non chiamare JSON.parse su ogni delta. Pattern minimale:

  1. Accumula testo in un buffer stringa.
  2. Opzionale: se il protocollo del provider include “reasoning” o prefissi, filtra solo il blocco JSON (alcuni modelli usano fence json … : togli il wrapper prima del parse).
  3. Solo quando chiudi lo stream (o quando un marker end-of-message arriva), prova JSON.parse sul buffer intero.

Se il provider offre structured output nativo (object mode) che garantisce JSON valido a fine stream, semplifichi il passo 2–3 ma non elimini Zod: il modello può ancora violare vincoli semantici.

Quando evitare lo streaming per output tabellare: se il client deve aggiornare l’UI riga per riga e il modello produce un unico grande JSON, gli aggiornamenti parziali confondono l’utente; spesso conviene streamare testo libero per l’UI e generare JSON in una seconda chiamata non streammata, oppure streammare NDJSON (una riga = un record) con schema per riga più semplice.

Recovery: parse fallito o Zod fallito

Strategia a livelli, con limite massimo di tentativi (es. 2 repair + 1 fallback):

  1. Repair leggero: se JSON.parse fallisce, prova a tagliare l’ultimo carattere non bilanciato solo se hai euristica sicura (rischioso). Spesso è più stabile inviare di nuovo al modello un prompt corto: “Questo JSON non è valido. Errore: …. Restituisci solo JSON corretto secondo lo schema.” allegando il buffer troncato.
  2. Zod fallito: passa a parsed.error.flatten() o issues nel messaggio di repair così il modello sa cosa correggere.
  3. Degrado: dopo N tentativi, restituisci errore business (422 verso il client) o un default sicuro (es. job in coda per revisione umana) — non loop infiniti: costano token e saturano rate limit.
const MAX_REPAIR = 2

async function extractWithRepair(
  raw: string,
  attempt: number,
): Promise<Extract> {
  let json: unknown
  try {
    json = JSON.parse(raw)
  } catch {
    if (attempt >= MAX_REPAIR) throw new SyntaxError('Invalid JSON after repairs')
    const fixed = await repairJsonWithLlm(raw, 'syntax')
    return extractWithRepair(fixed, attempt + 1)
  }
  const r = ExtractSchema.safeParse(json)
  if (r.success) return r.data
  if (attempt >= MAX_REPAIR) throw new Error('Schema validation failed')
  const fixed = await repairJsonWithLlm(raw, JSON.stringify(r.error.flatten()))
  return extractWithRepair(fixed, attempt + 1)
}

repairJsonWithLlm è una funzione tua: idealmente non stream, temperatura bassa, stesso provider con costo noto.

Telemetria: cosa loggare senza PII

Registra almeno:

  • model (nome versione es. gpt-4.1 / claude-3-5-sonnet-*) e finish_reason se disponibile.
  • Latency end-to-end e token in/out se il client li espone (OpenAI/Anthropic nel response object o header dove documentato).
  • Contatore repair e tipo fallimento (syntax vs zod).
  • Hash (es. SHA-256) del prompt di sistema + user senza salvare il testo utente in chiaro se non necessario — utile per correlare regressioni quando cambi prompt.

Opinione esplicita: loggare il JSON grezzo in produzione è utile in staging; in prod mascheralo o campionalo, per GDPR e perché i log diventano costosi.

Fonti verificate: Structured Outputs, JSON mode, AI SDK

  • OpenAI — Structured Outputs vs JSON mode: la guida ufficiale distingue JSON mode (JSON sintatticamente valido) da Structured Outputs con response_format / schema: nel secondo caso il modello è costretto a rispettare lo JSON Schema fornito dove supportato—riduce drasticamente errori di forma rispetto al prompt “solo JSON”. Documentazione: Structured model outputs e confronto con JSON mode. Zod resta utile come validazione unica cross-provider, per regole di business (min/max incrociati) e per ambienti in cui usi ancora modelli senza structured outputs.
  • Vercel AI SDK: per generare oggetti con schema Zod lo stato dell’arte documentato è Output.object({ schema: z.object({…}) }) con streamText / generateText e stream di oggetti parziali tipizzati (partialOutputStream). Riferimento: Generating structured data. Controlla la major installata (ai, @ai-sdk/openai, …): le API evolvono rapidamente.
  • Limiti reali: anche con garanzie di schema, gestisci stream interrotti, timeout, e rifiuti di sicurezza del modello; in AI SDK gli errori in streaming possono fluire come eventi di stream (onError) anziché solo eccezioni—leggi la pagina sopra per il comportamento della versione che usi.

Sintesi operativa

  1. Zod dopo parse, sempre safeParse.
  2. Stream = buffer unico, parse solo a fine stream (salvo protocolli NDJSON dedicati).
  3. Repair con budget finito e telemetria sui fallimenti.
  4. Considera non-stream per JSON monolitici critici.

Riferimenti da tenere a portata di mano: documentazione OpenAI su Structured Outputs / JSON mode e equivalente Anthropic, più Zod safeParse e flatten.

Vuoi applicare idee come queste al tuo prodotto?

Raccontaci contesto, vincoli e obiettivi: ti diciamo se ha senso lavorare insieme e come impostare il primo passo.