Blog

Contrato de saída estruturada: quando o esquema do modelo muda sozinho

O parser quebrou às três da manhã e ninguém tinha feito deploy. O prompt é o mesmo de seis meses atrás, o schema que você declara na chamada é o mesmo, e mesmo assim o campo que sempre vinha como número passou a vir como string, ou um enum ganhou um valor que você nunca escreveu, ou um objeto opcional que nunca aparecia começou a aparecer sempre. Isso não é bug de ninguém: é a consequência de tratar a saída do modelo como se fosse resposta de API versionada quando na verdade ela é uma distribuição de probabilidade que o provedor pode reformar sem te avisar. Este artigo trata a saída estruturada como um contrato de integração de verdade: por que schema declarado não é garantia de execução, como separar o contrato interno do formato que o modelo devolve com uma camada de adaptação, por que campo obrigatório novo é sempre incidente e opcional com default nunca é, como detectar deriva de esquema com métrica em vez de com exceção do parser, e qual teste de contrato roda contra o provedor real antes do usuário descobrir.

2026-08-21 / Arquitetura / 16 min

01

Schema declarado não é schema garantido

Todo provedor sério hoje aceita um schema na chamada, seja como JSON Schema em structured output, seja como definição de tool. A leitura errada disso é achar que o schema vira uma garantia de execução equivalente a um tipo de linguagem estática. Ele não vira. O que o schema faz é restringir fortemente a amostragem: os tokens que violariam a gramática ganham probabilidade zero ou quase zero. Isso resolve JSON sintaticamente quebrado e chave inventada, e é uma melhora enorme. Não resolve o que está na camada semântica acima da gramática: qual valor de enum foi escolhido, se um campo opcional foi preenchido, quantos itens entraram num array, se o número veio como 5 ou como "5" quando o schema aceita união.

Some daí a distinção que organiza o resto do artigo. Uma coisa é a validade sintática, e essa o provedor sustenta bem. Outra é a estabilidade semântica, que ninguém prometeu. Quando um provedor troca a versão do modelo por trás do mesmo alias, quando ajusta o decoder de structured output, quando muda como o schema é convertido internamente em gramática, a distribuição dos valores dentro do formato válido se desloca. Seu código continua fazendo parse com sucesso e passa a tomar decisões diferentes.

CamadaQuem garanteFalha típicaComo você descobre
Sintaxe JSONDecoder do provedorJSON truncado por limite de tokenExceção no parse, na hora
Forma do schemaProvedor, com boa coberturaChave a mais, tipo em união inesperadaValidação estrita, se você tiver uma
Semântica dos valoresNinguémEnum novo, opcional que virou frequente, array vazioMétrica de distribuição ou reclamação do cliente
Significado do campoNinguémMesmo nome, sentido diferente após troca de modeloAuditoria manual, quase sempre tarde

A linha que dói é a terceira. Ela não gera exceção, não aparece em log de erro, e por isso passa direto pelo alerta que você configurou. O sistema continua verde enquanto decide errado.

02

O contrato interno não pode ser o formato do modelo

O erro estrutural mais caro é deixar o objeto que o modelo devolve circular pelo sistema inteiro. Quando o retorno cru do provedor vira o argumento que atravessa serviço, entra na fila e é persistido, qualquer mudança na saída do modelo se propaga para dezenas de pontos ao mesmo tempo. O conserto exige tocar em tudo, e o rollback fica impossível porque já existe dado gravado no formato antigo e no novo.

A separação certa é a mesma de qualquer integração com terceiro: o formato do provedor é externo, o contrato do domínio é seu, e existe uma camada fina entre eles cuja única responsabilidade é traduzir. Essa camada é o único lugar do código que conhece o formato do modelo. Se o enum ganhar valor novo, o conserto acontece em um arquivo e não em quinze.

modelo                  adaptador                  dominio
  |                         |                          |
  |-- JSON valido --------->|                          |
  |   (forma do provedor)   |                          |
  |                         |-- valida estrito ------->| rejeita se
  |                         |   (chave extra = erro)   | forma mudou
  |                         |                          |
  |                         |-- normaliza ------------>| "5" -> 5
  |                         |   (coercao explicita)    | "URGENTE" -> urgent
  |                         |                          |
  |                         |-- mapeia desconhecido -->| enum novo ->
  |                         |   (nunca descarta)       | bucket "unknown"
  |                         |                          |
  |                         |-- emite metrica -------->| taxa por campo
  |                         |                          |
  |                         |==== Intake (tipo do dominio) ====>
  |                         |     so este atravessa o sistema

Repare no detalhe do valor desconhecido. A tentação é lançar exceção quando o enum traz algo fora da lista, porque parece o comportamento estrito e correto. Na prática isso transforma uma degradação parcial em indisponibilidade: um valor novo em dois por cento das requisições derruba dois por cento do tráfego. Mapear para um bucket explícito de desconhecido, contar e seguir para o caminho de revisão humana preserva o serviço e ainda te dá o sinal.

// adapters/intake.js
// Unica fronteira que conhece o formato do modelo. Valida estrito,
// normaliza com coercao explicita e nunca deixa valor novo virar excecao.

const PRIORITIES = new Set(['low', 'normal', 'high']);
const ALLOWED_KEYS = new Set(['intent', 'priority', 'entities', 'confidence']);

export function toIntake(raw, { metrics }) {
  // 1. Chave que o schema nao previa e sinal de deriva, nao de dado extra.
  const unknownKeys = Object.keys(raw).filter((key) => !ALLOWED_KEYS.has(key));
  if (unknownKeys.length > 0) {
    metrics.increment('intake.unknown_key', { keys: unknownKeys.join(',') });
  }

  // 2. Coercao explicita: aceita "5" e 5, mas registra quando o tipo muda,
  //    porque tipo instavel e o primeiro sintoma de troca de modelo.
  const confidence = coerceNumber(raw.confidence, { metrics, field: 'confidence' });

  // 3. Enum desconhecido vira bucket, nunca excecao. Um valor novo em 2%
  //    das requisicoes nao pode derrubar 2% do trafego.
  let priority = normalizePriority(raw.priority);
  if (!PRIORITIES.has(priority)) {
    metrics.increment('intake.unknown_enum', { field: 'priority', value: String(raw.priority) });
    priority = 'unknown';
  }

  return {
    intent: String(raw.intent ?? '').trim() || 'unclassified',
    priority,
    // Array ausente e array vazio sao coisas diferentes para o dominio:
    // ausente significa "o modelo nao respondeu isso", vazio significa
    // "o modelo respondeu que nao ha nada". Nao colapse os dois.
    entities: Array.isArray(raw.entities) ? raw.entities.map(String) : null,
    confidence: confidence ?? 0,
    needsReview: priority === 'unknown' || confidence === null,
  };
}

function coerceNumber(value, { metrics, field }) {
  if (typeof value === 'number' && Number.isFinite(value)) return value;
  if (typeof value === 'string' && value.trim() !== '') {
    const parsed = Number(value);
    if (Number.isFinite(parsed)) {
      metrics.increment('intake.type_coercion', { field, from: 'string' });
      return parsed;
    }
  }
  return null;
}

function normalizePriority(value) {
  return String(value ?? '').trim().toLowerCase();
}

Cada ponto de tolerância desse adaptador emite métrica. Essa é a diferença entre tolerar e ignorar: tolerar é aceitar o desvio e registrá-lo; ignorar é aceitar e ficar em silêncio. Um adaptador que absorve tudo sem contar nada esconde a deriva até o dia em que ela é grande demais.

03

A assimetria que decide o tamanho do incidente

Mudanças de esquema não são todas iguais, e vale internalizar a assimetria antes de escrever o primeiro schema. Adicionar um campo opcional com default é seguro: o consumidor antigo ignora, o novo aproveita. Tornar um campo obrigatório é quebra garantida: todo produtor que ainda não manda o campo passa a falhar. Remover um campo quebra quem lê. Trocar o tipo de um campo quebra quem faz parse. Adicionar valor a um enum quebra quem faz switch exaustivo sem cláusula default.

Mudança no esquemaEfeito no consumidorClassificaçãoMitigação
Campo opcional novo com defaultIgnorado por quem não conheceCompatívelNenhuma, é o caminho seguro
Campo obrigatório novoFalha em todo produtor antigoQuebraLançar como opcional, migrar, depois exigir
Campo removidoLeitor recebe undefined onde esperava valorQuebraManter por uma janela, marcar como obsoleto
Tipo alterado (número para string)Parse silenciosamente erradoQuebra silenciosaCampo novo com nome novo, nunca reusar o antigo
Valor novo em enumSwitch exaustivo cai no vazioQuebra parcialBucket de desconhecido mais alerta
Enum restringidoProdutor manda valor agora inválidoQuebraAceitar na leitura por uma janela

A regra operacional que sai daí é simples e vale para os dois lados: quando você muda o schema que envia ao modelo, adicione sempre como opcional primeiro e só exija depois que a métrica mostrar preenchimento consistente. E quando é o modelo que muda sozinho, a única defesa é o adaptador nunca fazer switch exaustivo sem caminho de escape.

Um caso merece destaque porque é o mais traiçoeiro: a troca de tipo. Quando um campo que era número passa a vir como string, boa parte das linguagens não reclama. JavaScript compara, soma como concatenação e segue. O resultado é uma decisão errada que nunca gera stack trace. É por isso que a coerção precisa ser explícita e instrumentada, e não implícita e silenciosa.

04

Detectar deriva com métrica, não com exceção

Se o único detector de mudança de esquema é a exceção do parser, você só vê a categoria de falha mais barulhenta e mais rara. A deriva que importa é a que mantém tudo válido e desloca a distribuição. O instrumento certo não é try/catch, é uma métrica por campo comparada contra uma linha de base.

Na prática, quatro séries por campo cobrem quase tudo. Taxa de presença, que é a fração de respostas em que o campo apareceu preenchido. Distribuição de tipo, que é a fração por tipo primitivo observado. Cardinalidade de enum, que é o conjunto distinto de valores visto na janela. E, para arrays, o percentil do tamanho. Deriva aparece nessas séries dias antes de aparecer em reclamação.

// monitor/schema-drift.js
// Compara a janela recente contra uma linha de base congelada.
// A ideia nao e detectar valor novo isolado, e detectar deslocamento
// sustentado, que e o que indica troca de modelo por baixo.

const PRESENCE_TOLERANCE = 0.10; // 10 pontos percentuais
const MIN_SAMPLES = 200;         // abaixo disso, ruido domina

export function compareToBaseline(window, baseline) {
  const alerts = [];

  for (const [field, current] of Object.entries(window.fields)) {
    const base = baseline.fields[field];
    if (!base || current.samples < MIN_SAMPLES) continue;

    // 1. Campo que passou a vir sempre (ou parou de vir) mudou de papel,
    //    mesmo continuando "opcional" no schema.
    const presenceDelta = current.presenceRate - base.presenceRate;
    if (Math.abs(presenceDelta) > PRESENCE_TOLERANCE) {
      alerts.push({
        field,
        kind: 'presence_shift',
        from: base.presenceRate,
        to: current.presenceRate,
        severity: presenceDelta > 0 ? 'warn' : 'page',
      });
    }

    // 2. Tipo novo em campo estavel e quase sempre troca de modelo.
    for (const type of Object.keys(current.typeShare)) {
      if (!(type in base.typeShare)) {
        alerts.push({ field, kind: 'new_type', type, severity: 'page' });
      }
    }

    // 3. Enum ganhou valor: nao e erro, mas exige decisao humana antes
    //    de o bucket "unknown" crescer sem ninguem olhar.
    const newValues = current.enumValues.filter((v) => !base.enumValues.includes(v));
    if (newValues.length > 0) {
      alerts.push({ field, kind: 'new_enum_value', values: newValues, severity: 'warn' });
    }
  }

  return alerts;
}

A escolha de severidade não é decorativa. Tipo novo em campo estável é página, porque quase sempre significa que o modelo por trás do alias mudou e o efeito é imediato em decisão de negócio. Valor novo de enum é aviso, porque o bucket de desconhecido já está segurando o tráfego e a decisão pode esperar o horário comercial. Queda de taxa de presença é página quando é queda, porque significa que o sistema parou de receber informação que já usava.

  • Congele a linha de base numa versão explícita e datada, não numa média móvel dos últimos sete dias, senão a deriva lenta vira a nova normalidade sem nunca disparar.
  • Exija um mínimo de amostras por janela antes de comparar, porque com volume baixo a variação natural gera alerta falso e o time aprende a ignorar.
  • Monitore por combinação de modelo e versão de prompt, não só por endpoint, senão um rollout de prompt em dez por cento do tráfego fica invisível dentro do agregado.
  • Alerte no bucket de desconhecido crescendo, e não só no valor novo aparecendo: um enum que ninguém revisou vira caminho de revisão humana entupido.

05

Teste de contrato contra o provedor real

Teste unitário com resposta fixa em fixture prova que seu adaptador funciona, o que é necessário e insuficiente: ele nunca vai te avisar que o provedor mudou, porque a fixture é sua e está congelada. O que fecha o buraco é uma suíte de contrato que roda contra o provedor de verdade, em agenda fixa, com um conjunto pequeno de entradas representativas, e falha quando a forma da saída sai do envelope acordado.

  1. Escolha de vinte a quarenta entradas que cubram os caminhos que importam, incluindo os casos de borda que produzem campos opcionais, arrays vazios e valores raros de enum.
  2. Rode contra o provedor real em agenda diária, fora do caminho de deploy, para que a falha seja um sinal sobre o mundo externo e não um bloqueio do seu pipeline.
  3. Valide a forma e o envelope estatístico, nunca o texto exato: o teste afirma que confidence é número entre zero e um em cem por cento das respostas, não que o intent de uma entrada específica seja uma string literal.
  4. Fixe a versão do modelo com o identificador completo, incluindo data, e trate o alias flutuante como ambiente separado que você monitora mas não usa em produção.
  5. Quando o teste falhar, o artefato é um diff entre a linha de base e a saída atual por campo, porque a pergunta operacional é o que mudou, não se algo mudou.
// tests/contract/intake.contract.test.js
// Roda contra o provedor real, em agenda, fora do deploy.
// Afirma o envelope, nunca o texto exato da resposta.

import { describe, it, expect } from 'vitest';
import { callModel } from '../../src/providers/client.js';
import { toIntake } from '../../src/adapters/intake.js';
import { CASES } from './cases.js';

const MODEL = 'claude-sonnet-4-5-20250929'; // versao fixa, nunca alias flutuante
const noopMetrics = { increment: () => {} };

describe('contrato de saida estruturada do intake', () => {
  it('mantem a forma acordada em todas as entradas representativas', async () => {
    const results = await Promise.all(
      CASES.map(async (testCase) => {
        const raw = await callModel({ model: MODEL, input: testCase.input });
        return { testCase, intake: toIntake(raw, { metrics: noopMetrics }) };
      }),
    );

    for (const { testCase, intake } of results) {
      // Forma: sempre verdadeiro, independente do conteudo.
      expect(typeof intake.intent).toBe('string');
      expect(intake.confidence).toBeGreaterThanOrEqual(0);
      expect(intake.confidence).toBeLessThanOrEqual(1);
      expect(intake.entities === null || Array.isArray(intake.entities)).toBe(true);

      // Envelope: o enum pode ganhar valor, mas nao pode virar
      // desconhecido no caso que existe exatamente para exercita-lo.
      if (testCase.expectsKnownPriority) {
        expect(intake.priority).not.toBe('unknown');
      }
    }

    // Estatistico: um caso isolado caindo em revisao e aceitavel,
    // um terco da suite caindo significa que a forma mudou.
    const reviewRate = results.filter((r) => r.intake.needsReview).length / results.length;
    expect(reviewRate).toBeLessThan(0.15);
  });
});

A última asserção é a que mais paga o custo da suíte. Ela não olha nenhum caso individual e sim a taxa agregada de casos que caíram em revisão. É exatamente a forma de deriva que passa por qualquer validação item a item: nada quebrou, tudo continua válido, e mesmo assim o sistema começou a não entender uma fatia maior do tráfego.

06

O que fazer no dia em que o esquema mudar

A resposta muda conforme o tipo de mudança, e vale ter isso decidido antes, no runbook, e não às três da manhã. Para valor novo de enum, o bucket de desconhecido já segurou o tráfego: a ação é revisar o valor, decidir se ele mapeia para uma categoria existente ou merece uma nova, e atualizar o adaptador. Nenhum rollback é necessário porque nada quebrou.

Para tipo alterado ou campo que sumiu, a ação imediata é fixar a versão anterior do modelo se você tinha o identificador completo, o que é o motivo prático de nunca chamar produção por alias flutuante. Com a versão fixada o sistema volta ao comportamento conhecido em minutos, e a adaptação ao formato novo passa a ser trabalho planejado em vez de emergência.

  • Um alias flutuante na configuração é confortável no dia do lançamento e caro no dia da mudança: sem versão explícita, não existe rollback, só adaptação sob pressão.
  • Adapte o novo formato num caminho paralelo primeiro, comparando as duas saídas em sombra sobre tráfego real antes de trocar, porque o adaptador novo também tem bug.
  • Registre a mudança de esquema como incidente mesmo quando não houve indisponibilidade, senão o histórico some e a mesma classe de falha volta no próximo trimestre.
  • Reveja a linha de base depois de estabilizar, com data e versão de modelo anotadas, senão o próximo comparativo mede contra um mundo que não existe mais.

O fio que amarra tudo é o mesmo de qualquer integração com terceiro cujo comportamento você não controla: assuma que a forma vai mudar, isole quem conhece a forma em um lugar só, meça o desvio antes que ele vire dano e mantenha um caminho de volta. A diferença aqui é que o terceiro não publica changelog de esquema e a mudança não vem com aviso, o que só aumenta o peso da métrica e do teste de contrato.

FAQ

Perguntas frequentes

Se eu uso structured output com JSON Schema, o provedor não garante o formato?

Garante a validade sintática e a forma declarada, e isso já elimina JSON quebrado e chave inventada. O que não é garantido é a camada semântica acima disso: qual valor de enum foi escolhido, se um campo opcional foi preenchido, quantos itens entraram num array, ou se um número veio como 5 ou "5" quando o schema aceita união de tipos. Quando o provedor troca a versão do modelo por trás de um alias ou ajusta o decoder, essa distribuição se desloca sem violar o schema. Seu código continua fazendo parse com sucesso e passa a decidir diferente, o que é pior que quebrar, porque não gera exceção nenhuma.

Por que não lançar exceção quando o modelo devolve um valor de enum desconhecido?

Porque isso converte uma degradação parcial em indisponibilidade. Se o valor novo aparece em dois por cento das requisições, a exceção derruba dois por cento do tráfego, e a alternativa custa quase nada: mapear para um bucket explícito de desconhecido, incrementar uma métrica e rotear aquele caso para revisão humana. O serviço continua de pé, o sinal chega ao time e a decisão sobre a categoria nova acontece em horário comercial. A regra é rejeitar de forma estrita o que muda a forma, como uma chave inesperada, e tolerar de forma contada o que só amplia um domínio de valores.

Testar contra o provedor real não deixa a suíte lenta, cara e instável?

Deixa, e por isso ela não pertence ao pipeline de deploy. É uma suíte separada, com vinte a quarenta entradas, rodando em agenda diária, cuja falha é um sinal sobre o mundo externo e não um bloqueio do seu merge. O custo é baixo porque o volume é pequeno, e a instabilidade some quando as asserções afirmam o envelope em vez do texto: confidence é número entre zero e um em cem por cento das respostas, e a taxa agregada de casos que caem em revisão fica abaixo de um limiar. Testes com fixture congelada continuam valendo para o adaptador, mas por definição nunca avisam que o provedor mudou.

Saída estruturada é integração com terceiro, e terceiro muda sem avisar

Um adaptador único que conhece o formato do modelo, coerção explícita e instrumentada, bucket de desconhecido em vez de exceção, métrica por campo contra linha de base congelada e uma suíte de contrato rodando contra o provedor real: é isso que transforma uma mudança de esquema em trabalho planejado em vez de incidente noturno. Posso desenhar essa fronteira no seu sistema, do adaptador ao alerta, integrada ao stack que você já roda.