02IA5 min

Cada modelo novo quebra algo diferente

Seis migrações de modelo em um ano e meio. Nenhuma foi trocar uma string, embora todas começassem parecendo isso.

Por Salva Castells

Imagem de capa: Tim Green, CC BY 2.0.
Neste post

Num buscador semântico de anime tenho um reranker que ordena vinte e cinco candidatos com um LLM. Estava com temperature: 0 desde o primeiro dia, que é o óbvio quando você quer uma ordem reproduzível. Troquei o modelo por um de raciocínio e a chamada começou a devolver erro: esse modelo rejeita temperature. Não ignora, rejeita.

O conserto não foi apagar a linha. Foi substituí-la por reasoning_effort, extraí-lo para uma constante com low como padrão, e passá-lo também pelo outro lugar onde o mesmo modelo era chamado, um script de ingestão que sintetiza premissas de franquia. Três arquivos, uma variável de ambiente nova e um documento para atualizar. Por um parâmetro.

Isso aconteceu seis vezes em um ano e meio, e cada vez de um jeito diferente.

Os quatro tipos de quebra

Depois de colecioná-las, repetem-se sempre as mesmas quatro:

  • O parâmetro que desaparece. temperature em modelos de raciocínio. Não há equivalente exato: reasoning_effort não é a mesma coisa, é outra alavanca. Você não migra o valor, decide um novo.
  • A forma do payload. Passar de Chat Completions para a Responses API num parser de visão não é mudar o nome do método. messages vira input, {"type": "text"} vira input_text, e image_url deixa de ser um objeto {url, detail} para ser uma string com detail como irmão no mesmo nível.
  • A forma da resposta. Nessa mesma migração, response.choices[0].message.content virou response.output_text. E o contador de tokens deixou de ser um só: agora há input_tokens e output_tokens separados. Isso obrigou a mudar a assinatura da função de -> str para -> tuple[str, int, int] e a acumulá-los na mão ao recolher os futures em paralelo.
  • A qualidade muda sem avisar. Este é o pior porque não dá erro. O modelo responde, o JSON valida, e os resultados são piores.

As três primeiras você vê no primeiro deploy. A quarta você engole por semanas.

OpenRouter e LangChain movem o problema

A reação lógica é colocar uma camada por cima. Já fiz isso duas vezes.

Num SaaS de transcrição eu tinha um lib/claude.ts com um processWithClaude(text, prompt) chamando direto o SDK da Anthropic. Apaguei inteiro e substituí por um singleton AIService contra o OpenRouter, com modelName e temperature como parâmetros que viajam desde o FormData do endpoint. Ganho real: trocar de modelo virou mandar outra string na requisição.

Num app de chat com projetos e contextos, a camada foi o LangChain, com @langchain/anthropic e @langchain/google-genai para poder ter um dropdown agrupado por provedor. Ali aprendi a outra metade: a camada também quebra. Um commit dos primeiros dias se chama literalmente "update dependencies for @langchain/anthropic and zod to latest versions". Atualizar para latest em abstrações sobre APIs que se mexem é trocar um problema que você entende por um que não entende.

O que essas camadas fazem é real, mas limitado: normalizam a forma da chamada. Não normalizam as capacidades. Se o modelo A aceita temperature e o B não, a camada deixa você passar os dois parâmetros e o erro chega do mesmo jeito, só que agora de dentro de uma biblioteca, com o stack trace três níveis abaixo.

O custo de trocar no meio do produto

No dia em que troquei o modelo de um gerador de horários de google/gemini-3-flash-preview para deepseek/deepseek-v3.2, o diff tocou três arquivos: app/api/generate/route.ts, app/api/adjust-schedule/route.ts e app/api/verify-checks/route.ts. O mesmo literal, copiado três vezes dentro de três JSON.stringify diferentes. Isso não é falha do provedor, é minha, e é o custo real: quando o nome do modelo está incrustado no ponto de chamada, migrar é um find-and-replace e rezar para não esquecer nenhum.

Num serviço de análise de documentos tentei arrumar isso centralizando modelos e cálculo de custo num arquivo de constantes. Custou-me três refactors em três semanas: um no dia 18, outro no dia seguinte, e antes mais um. O nome de um dos commits tem o erro de digitação registrado, "reafactor constants", o que diz bastante sobre aquela tarde. A camada de constantes não pegou de primeira porque eu estava tentando enfiar na mesma estrutura duas coisas distintas: qual modelo usar e quanto custa.

Como isolo a chamada hoje

Sem cerimônia. Três regras.

O modelo nunca é um literal no ponto de chamada. Sempre uma constante lida do ambiente com default, do tipo RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Trocar de modelo em produção não deveria exigir um deploy.

Os parâmetros específicos do modelo viajam com o modelo. Se um modelo precisa de reasoning_effort e outro de temperature, isso é configuração do modelo, não da função. Um objeto por modelo, não um parâmetro solto que você arrasta por seis assinaturas.

Defesa na borda, sempre. Quando adicionei o timeout às chamadas da OpenAI, coloquei também isto no batch de embeddings:

if (embeddings.length !== input.length) {
  throw new Error(
    `Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
  );
}

Quatro linhas. Convertem um desalinhamento silencioso entre vetores e textos — que deixa o banco cheio de embeddings atribuídos ao anime errado — em um erro imediato. O AbortSignal.timeout(30_000) do mesmo commit faz o mesmo com as chamadas penduradas.

O que considero estável

Estável: que existe um endpoint HTTP, que aceita JSON, que devolve JSON. Aí termina.

Não estável: o nome do modelo, os parâmetros de amostragem, a forma da mensagem, a forma da resposta, onde vem o contador de tokens, o preço, e que o modelo de hoje ainda exista daqui a seis meses.

Não projeto para o modelo que uso. Projeto para o dia em que ele deixar de existir.