Skip to content
This page has been auto-translated and may contain errors.View in English

Chamando modelos a partir do código ​

docs.scrimba.com

No capítulo anterior, você montou uma lista de mensagens. Neste capítulo, você vai enviar essa lista para um modelo real a partir do Python e receber uma resposta de volta. Esse ciclo completo é o menor loop possível ao construir com esses modelos, e praticamente toda funcionalidade que você vai escrever se apoia nele.

Você digita um prompt, seu código o envia para algum lugar, e o texto volta. Parece que o modelo mora dentro do seu programa. Mas não mora, e entender onde ele de fato roda explica o custo, a velocidade e as peculiaridades que você vai encontrar pelo caminho.

Então imagine o que está acontecendo fisicamente. O modelo é um enorme conjunto de parâmetros hospedado nos servidores do provedor, em hardware que você nunca vai ver. Quando você o chama, seu código envia uma requisição web comum pela internet, carregando suas mensagens.

O provedor executa o loop de previsão na máquina dele e envia os tokens gerados de volta para você. Chamar um modelo é alugar alguns instantes do computador de outra pessoa. Essa forma de pensar explica bastante coisa: a latência é o modelo gerando tokens um a um do lado deles, o custo é a cobrança por esse processamento, e tudo isso, no fim das contas, é uma chamada de rede, com tudo que isso implica.

Os exemplos usam a biblioteca oficial da OpenAI. Outros provedores diferem em detalhes, mas o formato é quase o mesmo em todos os lugares: você envia uma lista de mensagens e recebe uma mensagem de volta. Essa uniformidade é o que te protege de ficar preso a um único provedor, já que o mesmo código pode apontar para um modelo aberto que você hospeda ou aluga em outro lugar, muitas vezes só trocando a URL base (o capítulo Modelos abertos e fechados trata dessa escolha).

Você chama uma função da biblioteca, alguns segundos passam, e o texto volta. A biblioteca faz parecer que tudo acontece localmente, mas nada do modelo está na sua máquina.

O ponto importante para entender é o que essa função está encapsulando. Seu código transforma a requisição em JSON, abre uma conexão HTTPS com o provedor e espera enquanto o hardware deles executa o loop de previsão e transmite os tokens de volta. O SDK (a biblioteca de software do provedor, como openai) é apenas uma camada fina de conveniência sobre uma única requisição POST.

Saber que o formato de transmissão por trás disso é JSON puro compensa na prática. Quando uma chamada se comporta de forma estranha, você pode inspecionar a requisição e a resposta exatas. Quando você quer usar um recurso que o SDK ainda não expõe, você pode enviar o campo manualmente.

O fato de tudo isso ser uma chamada de rede é o que determina o custo, a latência e o tratamento de erros para o restante deste capítulo. Os exemplos usam a OpenAI, mas o formato da requisição, uma lista de mensagens entrando, uma mensagem saindo, é praticamente idêntico entre provedores.

Uma chamada a um modelo parece uma chamada de função, mas se comporta como um procedimento remoto sobre uma infraestrutura instável. É desse descompasso que vêm os problemas em produção, então vale a pena entender o cenário real desde o início.

Por trás do SDK, toda chamada é uma única requisição HTTPS POST: um corpo JSON sobe, o provedor executa a inferência no hardware dele, e os tokens voltam, opcionalmente transmitidos pela conexão aberta. O SDK (a biblioteca cliente do provedor) te dá autenticação, retentativas, tipagem e parsing de streaming, e custa uma camada de abstração sobre mudanças que você não controla. Trate-o como um invólucro que você consegue enxergar por dentro, não como uma caixa-preta.

Duas consequências definem o restante do capítulo. Primeiro, isso é uma chamada de rede cobrada por token, então custo, latência, idempotência e tratamento de falhas são decisões de design, não detalhes que você acrescenta depois. Segundo, o SDK e os nomes dos campos que ele usa são a superfície mais instável da qual você depende, então a jogada duradoura é isolar as partes específicas do provedor por trás de uma fronteira própria. Os exemplos usam a OpenAI; o formato da requisição é quase universal, mas o invólucro em torno dele é exatamente o que você não quer espalhado pela sua base de código.

A requisição ​

Instale a biblioteca com pip install openai, crie um cliente e faça a chamada. O cliente lê sua chave de API a partir de uma variável de ambiente, então a chave nunca aparece no seu código.

python
from openai import OpenAI

client = OpenAI()  # lê OPENAI_API_KEY do ambiente

MODEL = "gpt-4o-mini"  # a única linha que você muda para trocar de modelo

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "You are a concise assistant. Answer in one sentence."},
        {"role": "user", "content": "Why is the sky blue?"},
    ],
)

Dois itens são obrigatórios: model, o nome do modelo que você quer executar, e messages, a lista de mensagens com papéis marcados do capítulo anterior. Todo o resto tem um valor padrão.

Note que o nome do modelo fica em uma única constante. Em uma área em que um modelo melhor e mais barato surge a cada poucos meses, isso é intencional. Mantenha o nome do modelo em um único lugar, para que trocar de modelo seja uma mudança de uma linha só. Essa é a regra da mudança constante aplicada na prática: mantenha os detalhes instáveis em um lugar fácil de encontrar, não espalhados pelo seu código.

JunoA requisição Uma chamada precisa de apenas duas coisas: model e messages. O nome do modelo deve ficar em uma única constante, porque um modelo mais barato ou melhor aparece a cada poucos meses e você não quer ter que caçá-lo depois. Aprendi isso do jeito difícil, depois que um modelo foi renomeado e eu descobri que ele estava colado em nove arquivos diferentes.

Dois campos são obrigatórios, model e messages, e todo o resto tem um valor padrão. Colocar o nome do modelo em uma constante não é só uma questão de estilo: é o único detalhe instável que você quer em um só lugar, porque um modelo mais barato ou melhor chega com frequência suficiente para que codificá-lo em vários arquivos transforme uma troca em uma caça ao tesouro.

Agora olhe um nível abaixo. Essa chamada create é serializada em um corpo JSON e enviada por POST. Explicitando, o formato de transmissão é a requisição literal que o provedor recebe:

python
import httpx, os

# a mesma requisição que o SDK envia, feita manualmente
resp = httpx.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
    json={
        "model": "gpt-4o-mini",
        "messages": [
            {"role": "system", "content": "You are a concise assistant."},
            {"role": "user", "content": "Why is the sky blue?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # o mesmo formato que o SDK converte em objetos

Na prática, você vai usar o SDK: ele cuida da autenticação, das retentativas e da tipagem para você. Mas ver que o corpo é um dicionário simples deixa o resto mais concreto. A URL e os nomes de campo aqui são da OpenAI, e o endpoint exato e as chaves variam de provedor para provedor, mas a lógica, JSON entra, JSON sai, é a mesma em todo lugar.

JunoA requisição Os campos obrigatórios são model e messages, nada mais. A chamada create do SDK é, por baixo dos panos, um único POST em JSON, então quando algo parece errado você pode descer até esse nível e inspecionar o corpo literal. Mantenha o nome do modelo em uma constante: é a parte que muda com frequência, e você quer ela em um só lugar.

Os campos obrigatórios são model e messages. A pergunta interessante não é o que eles são, mas onde eles vivem no seu código, porque model é o valor mais instável de toda a requisição, e o SDK é a dependência mais instável em torno dele.

A chamada create é serializada em um POST em JSON. Isso importa porque significa que a requisição é um dado que você controla, não uma mágica que o SDK executa. Você pode montar o corpo, registrá-lo em log, comparar diferenças entre execuções, ou enviá-lo manualmente quando o SDK ainda não acompanhou um recurso novo do provedor. Conhecer o formato de transmissão é o que permite depurar uma chamada que a abstração está escondendo de você.

O hábito consagrado em produção é rotear toda chamada por uma única função estreita, de sua autoria:

python
def complete(messages, *, model=DEFAULT_MODEL, **params):
    # um único ponto de passagem: troque de provedor, adicione logging, mude retentativas aqui
    return client.chat.completions.create(model=model, messages=messages, **params)

Esse ponto de passagem é onde uma troca de modelo se torna uma mudança de uma linha só, e onde as instabilidades específicas de cada provedor ficam contidas em vez de se espalharem para todos que chamam a função. O caminho do endpoint e os nomes de campo mostrados aqui são da OpenAI; outro provedor renomeia a rota e algumas chaves, então o valor dessa fronteira é que a renomeação afeta um único arquivo, não a base de código inteira. Fixe também a versão do modelo: um "latest" que muda silenciosamente por baixo dos seus pés é o mesmo risco de regressão coberto em Como os LLMs funcionam, agora dentro do corpo da sua requisição.

JunoA requisição Só model e messages são obrigatórios, e ambos merecem viver por trás de uma fronteira que você controla. Roteie toda chamada por uma única função: é ali que uma troca de modelo é uma linha só, é ali que entra o logging, e é ali que as instabilidades de cada provedor param antes de chegar até quem chama. Fixe também a versão do modelo, porque um "latest" que muda por baixo dos seus pés é uma regressão que você não implantou e não consegue ver.

Lendo a resposta ​

A resposta volta envolvida em alguma estrutura. O texto que você quer é um caminho dentro dela, mas vale a pena conhecer o resto da estrutura, porque ela conta o que aconteceu.

python
choice = response.choices[0]

print(choice.message.content)   # o texto da resposta
print(choice.finish_reason)     # "stop"  -> o modelo terminou por conta própria
print(response.usage)           # Usage(prompt_tokens=24, completion_tokens=18, total_tokens=42)

Três coisas ali são importantes. message.content é o texto, e ele fica dentro de choices[0] porque a API pode retornar mais de uma opção, embora quase sempre você peça só uma e leia a primeira.

finish_reason diz por que o modelo parou. "stop" significa que ele terminou o raciocínio, enquanto "length" significa que ele bateu no seu limite de tokens e foi cortado no meio da resposta. Verificar esse campo é como você detecta uma resposta truncada pelo código, em vez de perceber apenas olhando.

E usage informa os tokens que a chamada consumiu, divididos entre o prompt que você enviou e a resposta que o modelo escreveu. Esse objeto usage é a sua conta, detalhada item a item. Toda chamada te diz exatamente quanto custou em tokens, e esse é o número que você deve acompanhar enquanto constrói suas funcionalidades.

JunoLendo a resposta O texto da resposta está em response.choices[0].message.content, mas dois campos vizinhos também importam. finish_reason diz por que o modelo parou: "stop" significa que terminou, "length" significa que bateu no seu limite de tokens e foi cortado. E response.usage é a sua conta em tokens, com prompt e resposta separados, para você nunca precisar chutar quanto uma chamada custou.

Três campos carregam a informação relevante. choices[0].message.content é o texto. finish_reason diz por que o modelo parou: "stop" é um término limpo, "length" significa que ele bateu no seu limite de tokens e foi truncado, então verificar esse campo é como você detecta uma resposta cortada pelo código, em vez de entregar meia resposta ao usuário. E usage é a contagem de tokens, com prompt e resposta separados.

A jogada que vale a pena aqui é transformar usage em dinheiro de verdade. O objeto usage é o dado de cobrança de cada chamada, então você pode calcular o custo na hora:

python
# os preços dos provedores são por token; confira as taxas atuais, elas mudam
PROMPT_RATE = 0.15 / 1_000_000      # dólares por token de prompt
COMPLETION_RATE = 0.60 / 1_000_000  # dólares por token de resposta

u = response.usage
cost = u.prompt_tokens * PROMPT_RATE + u.completion_tokens * COMPLETION_RATE
print(f"{u.total_tokens} tokens, ${cost:.6f}")

O prompt e a resposta geralmente têm preços diferentes, e os tokens de resposta costumam custar mais, então essa divisão importa quando você for otimizar. Registre o custo por chamada em log e você vai ver quais funcionalidades são caras muito antes de a fatura mensal te avisar. Os nomes de campo aqui são da OpenAI; outros provedores expõem as mesmas contagens com chaves um pouco diferentes, então preste atenção na estrutura, não no nome exato do atributo.

JunoLendo a resposta Leia três campos: content para o texto, finish_reason para pegar uma truncagem "length" pelo código, e usage para os tokens. Transforme usage em dólares na hora, com prompt e resposta precificados separadamente, e registre isso em log por chamada. Você vai identificar a funcionalidade cara muito antes da fatura.

A resposta traz três campos sobre os quais você age: content, finish_reason e usage. O que quem constrói costuma ignorar, e se arrepende depois, é finish_reason. Um valor "length" significa que o modelo bateu no seu max_tokens e a saída está truncada, muitas vezes como um JSON malformado ou uma frase pela metade, que seu parser posterior vai engasgar ao tentar processar. Trate qualquer valor diferente de "stop" como uma chamada que falhou e trate esse caso, não assuma que content está completo.

O objeto usage é a sua telemetria de unit economics, e merece estar conectado à sua observabilidade, não apenas impresso e esquecido. Tokens de prompt e de resposta têm preços separados, com a resposta geralmente mais cara, então registre os dois por chamada, marcados pela funcionalidade:

python
u = response.usage
log.info("llm_call", feature="invoice_extract", model=MODEL,
         prompt_tokens=u.prompt_tokens, completion_tokens=u.completion_tokens,
         finish_reason=response.choices[0].finish_reason)

Com isso implementado, custo por funcionalidade e por usuário se torna uma consulta, não um palpite, e um prompt fora de controle que triplicou de tamanho silenciosamente aparece como uma métrica, em vez de uma cobrança surpresa. Fique atento à assimetria: tokens de entrada são baratos e processados em paralelo, tokens de saída são a parte cara e sequencial coberta em Como os LLMs funcionam, então uma funcionalidade que gera respostas longas custa e demora mais do que o tamanho do prompt sugere. Os nomes de atributo são da OpenAI e mudam de provedor para provedor, então registre as contagens por trás da sua própria fronteira, em vez de espalhar response.usage.prompt_tokens pela aplicação.

JunoLendo a resposta Trate qualquer finish_reason diferente de "stop" como uma chamada que falhou: uma truncagem "length" entrega ao seu parser um JSON quebrado, e você vai caçar esse bug rio abaixo. Conecte usage aos seus logs, marcado por funcionalidade, com prompt e resposta separados, para que o custo por funcionalidade seja uma consulta e um prompt que está inflando seja uma métrica, não uma surpresa na fatura. Tokens de saída são a parte cara e sequencial, então uma funcionalidade tagarela custa mais do que o tamanho do prompt sugere.

Os parâmetros que vale a pena conhecer ​

Além de model e messages, duas configurações opcionais aparecem o tempo todo.

temperature é o controle de aleatoriedade de Como os LLMs funcionam, agora de forma concreta. Valores baixos (próximos de 0) mantêm o modelo perto de suas escolhas mais prováveis: focado e repetível, o que você quer para extração e classificação. Valores mais altos (entre 0,7 e 1) permitem que ele busque tokens menos prováveis: mais variado e criativo, e mais propenso a se dispersar. Você está ajustando o quão ousadamente o modelo amostra, nada mais.

max_tokens limita até quantos tokens a resposta pode chegar. Isso te protege de uma resposta descontrolada e de uma conta descontrolada. Defina max_tokens muito baixo e o modelo será cortado, exatamente o motivo de finalização "length" da seção anterior, então deixe uma margem real.

python
response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    temperature=0.2,  # focado e consistente
    max_tokens=300,   # limita o tamanho da resposta
)
JunoOs parâmetros que vale a pena conhecertemperature ajusta o quão ousadamente o modelo amostra: baixo para respostas focadas e repetíveis, alto para respostas variadas e criativas. max_tokens limita o tamanho da resposta para proteger sua conta, mas se definido muito baixo dispara o corte por "length", então deixe margem. Para extração e classificação, use sempre uma temperature baixa.

Dois parâmetros carregam a maior parte do peso: temperature e max_tokens. A mecânica está em Como os LLMs funcionam; a jogada aqui é ajustar o valor à tarefa, em vez de confiar no valor padrão que vem pronto.

Pense na temperature como uma configuração por tarefa, não uma configuração global. Escolha-a a partir do que a tarefa exige:

python
TEMPERATURE_BY_TASK = {
    "extraction": 0.0,      # uma resposta certa, você quer ela sempre
    "classification": 0.0,  # escolher um rótulo, criatividade não é desejada
    "summary": 0.3,         # em geral fiel, com um pouco de liberdade de expressão
    "brainstorm": 0.9,      # variedade é o objetivo principal
}

Para qualquer coisa que você vai analisar depois, dado estruturado, uma categoria, um número extraído de um documento, vá para 0 e pare de pensar em aleatoriedade. Para rascunhos e geração de ideias, onde a variedade é o valor, vá para valores altos.

max_tokens é um teto, não uma meta: ele limita o gasto e respostas descontroladas, mas defini-lo abaixo do que a tarefa exige te dá a truncagem "length" da seção anterior, que é pior do que uma conta um pouco maior. Dimensione para a resposta legítima mais longa, com margem extra. Esses dois nomes de campo são estáveis entre provedores; alguns renomeiam max_tokens, então confirme a chave quando trocar de provedor.

JunoOs parâmetros que vale a pena conhecer Defina temperature por tarefa, não uma vez de forma global: 0 para tudo que você vai analisar depois pelo código, alto para rascunhos onde a variedade é o objetivo. max_tokens é um teto de segurança, então dimensione para a resposta real mais longa, com margem; se defini-lo muito apertado, você troca uma pequena economia na conta por uma resposta truncada. Pare de confiar no padrão que o SDK traz.

temperature e max_tokens parecem controles simples, mas se comportam como decisões de política, com custo e confiabilidade atrelados a elas. A profundidade aqui está em como eles interagem com tudo mais que você constrói.

Trate a temperature como parte do seu contrato de confiabilidade. Para qualquer saída que você analisa ou armazena, use 0, para que a variância provocada pela amostragem, coberta em Como os LLMs funcionam, seja a menor que o provedor permite. Ela não é perfeitamente determinística mesmo em 0, então não construa cache por correspondência exata ou testes de igualdade byte a byte sobre ela, mas 0 é o piso que você quer para trabalho estruturado. Reserve temperaturas mais altas para casos em que a variedade é o próprio produto, e trate essa variância como algo que suas avaliações medem entre várias execuções, não como algo que você julga em uma única passagem.

max_tokens é uma alavanca de latência e custo, não apenas uma rede de segurança. Tokens de saída são a parte cara e sequencial da geração, então limitar o tamanho reduz tanto o seu pior custo quanto a sua pior espera possível.

A armadilha é dimensioná-lo no olho: apertado demais trunca respostas válidas na falha "length", que entrega ao seu parser uma saída quebrada; solto demais deixa um loop degenerado inflar custo e latência antes que qualquer coisa o interrompa. Dimensione para a resposta legítima mais longa mais uma margem, por tarefa, e registre em log quando você atingir o limite, para que uma funcionalidade que continua truncando fique visível. Os dois nomes de parâmetro são da OpenAI, e alguns provedores os renomeiam, então configure-os por trás da função de fronteira, em vez de em cada ponto de chamada.

JunoOs parâmetros que vale a pena conhecertemperature é um contrato de confiabilidade: 0 para tudo que você analisa pelo código, valores altos só onde a variância é o produto, e nunca faça cache por correspondência exata sobre 0, porque não é determinístico byte a byte. max_tokens limita seu pior custo e sua pior latência, então dimensione para a resposta real mais longa mais margem, e registre em log quando bater no limite, porque uma funcionalidade que continua truncando é um bug disfarçado de parâmetro. Configure os dois por trás da sua fronteira, não em cada ponto de chamada.

Streaming ​

Por padrão, você espera a resposta inteira terminar de ser gerada, e então ela chega toda de uma vez. Como o modelo produz tokens um a um, uma resposta longa significa uma longa espera olhando para o nada.

Streaming envia cada token para você no instante em que ele é gerado, então o texto aparece imediatamente e vai se completando em tempo real, do jeito que os aplicativos de chat mostram uma resposta sendo "digitada" na tela.

python
stream = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    stream=True,
)

for chunk in stream:
    piece = chunk.choices[0].delta.content
    if piece:
        print(piece, end="", flush=True)

O tempo total de geração é o mesmo nos dois casos; o modelo não fica mais rápido. O streaming só muda quando você vê os tokens, exibindo-os conforme são produzidos, em vez de guardá-los até o final.

Você lida com isso iterando sobre os pedaços (chunks) e imprimindo cada delta de texto. O custo é um pouco mais de código em troca de uma espera muito melhor. O streaming muda quando você vê os tokens, não a velocidade com que eles chegam.

JunoStreaming O modelo gera tokens um a um, então por padrão uma resposta longa é uma longa espera em silêncio. O streaming envia cada token no instante em que ele surge, então a resposta vai se completando em tempo real, e é por isso que os aplicativos de chat parecem responsivos. O tempo total é o mesmo, você só vê os tokens mais cedo, iterando sobre os pedaços e imprimindo cada delta.

O streaming envia cada token no momento em que o modelo o produz, então o usuário vê o texto imediatamente, em vez de esperar a resposta completa. O tempo total de geração não muda; o que cai é o tempo até o primeiro token, que é praticamente o que significa "parecer rápido".

A versão bem feita trata duas coisas que o loop simplificado ignora: pedaços sem texto, e o acúmulo da resposta completa enquanto você a exibe.

python
stream = client.chat.completions.create(model=MODEL, messages=messages, stream=True)

full = []
for chunk in stream:
    delta = chunk.choices[0].delta
    piece = delta.content
    if piece is None:          # o pedaço inicial (só com o papel) e o final não trazem texto
        continue
    full.append(piece)
    print(piece, end="", flush=True)

reply = "".join(full)          # a mensagem completa, para histórico ou armazenamento

A verificação do delta vazio importa: o primeiro pedaço muitas vezes traz apenas o papel (role) e nenhum texto, e o pedaço final pode trazer um finish_reason com conteúdo vazio. Pule o None e você evita imprimir "None" na sua saída.

Acumular em full te dá a mensagem inteira para adicionar ao histórico, já que o streaming exibe o texto, mas não o salva para você. Um detalhe a saber: uma chamada em streaming não retorna um bloco usage por padrão, então, se você precisa das contagens de tokens, tem que solicitá-las explicitamente ou contá-las você mesmo. O streaming existe em todos os provedores, mas o formato do pedaço, delta, content, onde finish_reason aparece, é específico de cada provedor.

JunoStreaming O streaming reduz o tempo até o primeiro token, não o tempo total. Proteja-se contra o delta vazio: o primeiro pedaço costuma trazer só o papel e o final traz finish_reason sem texto, então pule o None ou você vai imprimir "None" na resposta. Acumule os pedaços você mesmo para o histórico, e lembre que uma chamada em streaming não traz usage a não ser que você peça.

O streaming troca uma resposta mais simples por um perfil de latência melhor: você reduz o tempo até o primeiro token, mas desiste do objeto de resposta único e limpo, e passa a montar o estado conforme os pedaços chegam. Vale a pena para qualquer coisa que um humano está observando, e raramente vale a pena para uma chamada de backend que você processa toda de uma vez.

Os detalhes que costumam causar problema em produção estão nas bordas dos pedaços. O primeiro pedaço normalmente traz só o papel, o pedaço final traz finish_reason com conteúdo vazio, e você precisa acumular o texto por conta própria, porque nada te entrega a mensagem já montada:

python
full, finish = [], None
for chunk in stream:
    choice = chunk.choices[0]
    if choice.delta.content:
        full.append(choice.delta.content)
    if choice.finish_reason:        # chega no último pedaço
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # a truncagem ainda acontece no meio do streaming
    handle_truncated(reply)

Três realidades de produção:

  • finish_reason ainda chega no final de um streaming, então a truncagem "length" não desaparece por você estar usando streaming, você só percebe mais tarde, e precisa verificá-la antes de confiar no texto montado.
  • Um streaming pode quebrar no meio do caminho: a conexão cai depois de dez tokens de quarenta, deixando você com uma resposta parcial, então chamadas em streaming precisam do próprio tratamento de falha parcial e são complicadas de repetir de forma limpa.
  • Respostas em streaming geralmente omitem usage, então você passa uma flag para incluí-lo ou tokeniza o texto montado por conta própria para acompanhar o custo.

O formato dos eventos (SSE) e o esquema dos pedaços são específicos de cada provedor, então mantenha o parsing de streaming por trás da mesma fronteira que o resto, e deixe quem chama ver seu resultado já montado, não os pedaços brutos.

JunoStreaming O streaming te dá tempo até o primeiro token e custa o objeto de resposta limpo, então use-o onde um humano está observando e evite-o para processamento de backend. O pedaço final ainda traz finish_reason, então uma truncagem "length" é real mesmo em pleno streaming, verifique isso antes de confiar no texto. E um streaming pode morrer depois de dez tokens de quarenta, então trate a resposta parcial e mantenha o parsing específico do provedor por trás da sua fronteira.

Toda chamada é independente ​

Este é o comportamento mais importante do capítulo, e ele decorre diretamente da lição sobre a janela de contexto. A API é sem estado: cada requisição se sustenta completamente por si só. O servidor do provedor não lembra da sua chamada anterior. Ele executa a previsão exatamente sobre as mensagens que você enviou, retorna o resultado, e não guarda nada sobre essa troca para a próxima vez.

Então a conversa não é armazenada em lugar nenhum pelo modelo ou pela API. Ela vive no seu código. Se você quer que o segundo turno saiba o que aconteceu no primeiro, você mesmo mantém a lista de mensagens acumulada e a envia toda de novo. Um vaivém de chat é uma ilusão que você cria reenviando um histórico cada vez maior a cada chamada.

É também por isso que uma conversa longa custa mais por mensagem, ao longo do tempo: cada chamada reenvia todos os turnos anteriores como tokens de entrada, então o prompt continua crescendo mesmo quando a nova pergunta do usuário é curta.

python
history = [
    {"role": "system", "content": "You are a friendly travel assistant. Keep answers short."},
]

def chat(user_text):
    history.append({"role": "user", "content": user_text})

    # o histórico INTEIRO é enviado a cada vez; o servidor não lembra de nada
    response = client.chat.completions.create(model=MODEL, messages=history)
    reply = response.choices[0].message.content

    history.append({"role": "assistant", "content": reply})  # guarda a resposta para o próximo turno
    return reply

chat("Tenho um fim de semana no Rio de Janeiro. O que devo ver?")
chat("Qual desses lugares é melhor para crianças?")  # só funciona porque o history carrega o primeiro turno

A segunda pergunta só faz sentido porque a primeira pergunta e sua resposta ainda estão em history e são enviadas junto. Você é a memória. Essa ideia central, que você monta e reenvia o contexto completo a cada vez, sustenta a conversa, e mais adiante vai sustentar também o uso de ferramentas, a busca de informações e os agentes. Todos eles são variações da mesma decisão: o que entra na lista de mensagens antes de cada chamada independente.

JunoToda chamada é independente A API é sem estado: cada requisição roda exatamente sobre as mensagens que você envia, e o servidor esquece a troca no instante em que responde. Uma conversa vive no seu código, não no modelo, então você mantém a lista de mensagens crescendo e reenvia tudo a cada turno. É por isso que chats longos custam mais ao longo do tempo, cada chamada reenvia todos os turnos anteriores como tokens de entrada.

A API é sem estado: o servidor roda exatamente sobre as mensagens que você envia e não guarda nada depois disso. A conversa vive no seu código, e você reenvia o histórico inteiro a cada turno para simular continuidade, e é por isso que um chat longo custa mais por mensagem, mesmo quando a nova pergunta é curta.

Esse crescimento é um problema que você precisa administrar, não algo que pode ignorar, porque o histórico eventualmente ultrapassa a janela de contexto. O primeiro passo é uma janela deslizante: manter a mensagem de sistema mais os turnos mais recentes.

python
def trim(history, keep_recent=10):
    system = history[:1]               # sempre mantém a mensagem de sistema
    recent = history[1:][-keep_recent:]  # só os turnos mais novos
    return system + recent

Isso é barato e limita o seu custo, mas descarta silenciosamente turnos mais antigos, então o modelo esquece fatos do início da conversa que o usuário ainda espera que ele saiba. Quando isso importa, o próximo passo é resumir os turnos descartados em uma mensagem curta, em vez de simplesmente excluí-los. Qual você escolhe depende da funcionalidade: um bot de suporte que precisa se referir ao problema original necessita do resumo, uma pergunta e resposta rápida funciona bem com a janela. A ausência de estado em si é universal entre provedores; só o formato da mensagem em torno dela varia.

JunoToda chamada é independente Sem estado significa que o servidor esquece depois de cada resposta, então você reenvia o histórico inteiro e paga por ele conforme cresce. Corte com uma janela deslizante dos turnos recentes mais a mensagem de sistema para limitar o custo, mas saiba que isso descarta silenciosamente fatos antigos que o usuário ainda espera que você lembre. Quando isso pesar, resuma os turnos descartados em vez de excluí-los.

A API é sem estado: você é responsável pela conversa e a reenvia em cada chamada. A versão ingênua, acumular para sempre e enviar tudo, falha de duas formas em escala: eventualmente ultrapassa a janela de contexto, e torna cada turno mais caro e mais lento que o anterior, já que a entrada cresce sem limite.

Então gerenciar o histórico é um problema de orçamento, com três estratégias padrão, cada uma com sua própria forma de falhar:

  • Uma janela deslizante mantém os N turnos mais recentes: barata e limitada, mas descarta silenciosamente fatos antigos que o usuário ainda referencia.
  • Resumo (summarisation) comprime turnos antigos em um resumo contínuo: guarda mais histórico por token, mas perde detalhes e pode fixar seus próprios erros em todos os turnos seguintes.
  • Busca (retrieval) armazena os turnos externamente e recupera apenas os relevantes: escala para históricos longos, mas depende do seu índice retornar as partes certas, e adiciona uma etapa de busca.

Escolha por caso de uso e instrumente, porque cada uma delas eventualmente vai exibir o contexto errado.

python
def build_messages(system, history, user_msg, budget_tokens=6000):
    msgs = [system]
    running = count_tokens(system) + count_tokens(user_msg)
    for turn in reversed(history):           # dos mais novos para os mais antigos
        t = count_tokens(turn)
        if running + t > budget_tokens:
            break                             # turnos mais antigos ficam fora do orçamento
        msgs.insert(1, turn)
        running += t
    msgs.append(user_msg)
    return msgs

Faça o orçamento por contagem de tokens, não por contagem de turnos, já que os turnos variam muito de tamanho, e uma janela por contagem de turnos explode o seu orçamento de contexto no instante em que alguém cola um documento inteiro. A estratégia é independente de provedor; o tokenizador usado para contar não é, então conte usando o tokenizador do próprio modelo. Esse é o mesmo trabalho de construção de contexto sobre o qual prompting e RAG se apoiam, agora aplicado à própria conversa.

JunoToda chamada é independente Sem estado significa que gerenciar o histórico é um problema de orçamento seu, e "acumular para sempre" explode a janela e infla o custo de cada turno. Escolha uma estratégia por caso de uso: uma janela deslizante é barata mas descarta fatos antigos, o resumo guarda mais mas fixa seus próprios erros, a busca escala mas depende do seu índice. Faça o orçamento por contagem de tokens, não por contagem de turnos, porque um documento colado explode uma janela por contagem de turnos instantaneamente.

Quando as coisas dão errado ​

Chamar um modelo é fazer uma chamada de rede para os servidores de outra pessoa, então ela falha de todas as formas que chamadas de rede falham. Projete pensando nisso desde o início.

  • Erros e limites de taxa. Os provedores limitam quantas requisições você pode enviar dentro de uma janela de tempo. Se você atingir esse limite, a chamada falha com um erro de limite de taxa. A resposta padrão é esperar e tentar de novo, aumentando a espera após cada falha (isso se chama backoff) para você não sobrecarregar um serviço já ocupado.
  • Tempos limite (timeouts). Uma chamada pode ficar travada. Defina um tempo limite para que uma requisição lenta não congele seu aplicativo.
  • As chaves ficam no servidor. Sua chave de API é uma senha que gasta o seu dinheiro. Nunca coloque a chave de API em código que roda no navegador. Qualquer pessoa pode abrir as ferramentas de desenvolvedor e lê-la. O navegador conversa com o seu backend, e o seu backend, que guarda a chave, conversa com o modelo.
  • Assuma que qualquer resposta pode estar errada. Mesmo uma chamada bem-sucedida pode retornar uma alucinação ou uma saída malformada, então os próximos capítulos tratam de como transformar o resultado em algo que o seu código possa confiar.
python
def ask_model(messages):
    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            timeout=20,  # desiste depois de 20 segundos
        )
        return response.choices[0].message.content
    except Exception as err:
        print("Model call failed:", err)
        return "Sorry, something went wrong. Please try again."
JunoQuando as coisas dão errado Chamar um modelo é fazer uma chamada de rede para os servidores de outra pessoa, então espere limites de taxa, tempos limite e falhas comuns, e envolva toda chamada em tratamento de erro com retentativa e backoff. Mantenha sua chave de API no servidor, nunca no navegador, porque ela gasta dinheiro de verdade e as ferramentas de desenvolvedor vão conseguir lê-la. E trate até uma resposta bem-sucedida como possivelmente errada, o que os próximos capítulos vão te ajudar a lidar.

Uma chamada a um modelo falha do mesmo jeito que chamadas de rede falham: limites de taxa, tempos limite, erros transitórios do servidor. A jogada certa é capturar as falhas específicas e tentar de novo apenas as que vale a pena tentar de novo, em vez de envolver tudo em um except genérico que escondegs bugs reais.

Capture exceções tipadas e aplique backoff, esperando mais tempo depois de cada falha, para você parar de sobrecarregar um serviço ocupado:

python
import time
from openai import RateLimitError, APITimeoutError, APIError

def ask_model(messages, retries=3):
    for attempt in range(retries):
        try:
            r = client.chat.completions.create(model=MODEL, messages=messages, timeout=20)
            return r.choices[0].message.content
        except (RateLimitError, APITimeoutError, APIError) as err:
            if attempt == retries - 1:
                raise
            wait = 2 ** attempt          # 1s, 2s, 4s: backoff exponencial
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

Os tipos de exceção dizem o que fazer: RateLimitError e APITimeoutError são transitórios e vale a pena tentar de novo, enquanto uma requisição malformada (mensagens quebradas, modelo desconhecido) é um bug seu e deve falhar de forma bem visível, não ser reenviada. Dobrar a espera a cada tentativa evita sobrecarregar ainda mais um serviço que já está com dificuldades. O SDK já tenta de novo algumas falhas por conta própria, então confira o comportamento padrão dele antes de adicionar sua própria camada.

Mantenha a chave no lado do servidor, e trate uma resposta bem-sucedida como possivelmente errada, o que os próximos capítulos vão abordar. Os nomes das classes de exceção aqui são da OpenAI; outros SDKs lançam as próprias, então mapeie-as na sua fronteira.

JunoQuando as coisas dão errado Capture exceções tipadas, não um except genérico: RateLimitError e APITimeoutError são transitórios e valem uma nova tentativa, uma requisição malformada é um bug seu e deve falhar de forma visível. Recue exponencialmente, dobrando a espera, para parar de sobrecarregar um serviço que já está com dificuldades. Verifique se o SDK já tenta de novo antes de adicionar sua própria camada, e mantenha a chave no lado do servidor.

O tratamento de falhas é o ponto em que isso deixa de ser uma simples chamada de função e se torna trabalho de sistemas distribuídos. A chamada falha de formas conhecidas: limites de taxa, tempos limite, erros 5xx transitórios, e a parte arriscada não é a retentativa em si, é tentar de novo sem pensar no que essa retentativa custa.

A armadilha é a idempotência (se executar uma operação duas vezes tem o mesmo efeito que executá-la uma vez). Uma retentativa ingênua depois de um timeout pode te cobrar em dobro: a primeira requisição pode ter sido concluída e gerado tokens do lado do provedor, a resposta nunca chegou até você, então a sua retentativa paga por uma segunda geração completa. Para uma chamada de leitura, isso é dinheiro desperdiçado; para qualquer coisa com efeito colateral, é uma ação duplicada. Quando o provedor suporta uma chave de idempotência, envie uma, para que uma retentativa seja reconhecida como a mesma requisição; quando não suporta, torne a operação ao redor segura para ser repetida.

python
import random, time
from openai import RateLimitError, APITimeoutError, APIError

RETRYABLE = (RateLimitError, APITimeoutError, APIError)

def complete(messages, *, retries=3, **params):
    for attempt in range(retries):
        try:
            return client.chat.completions.create(messages=messages, model=MODEL, **params)
        except RETRYABLE as err:
            if attempt == retries - 1:
                raise
            time.sleep((2 ** attempt) + random.random())  # backoff + jitter

Mais três realidades de produção:

  • Jitter, a fração aleatória adicionada a cada espera, impede que uma frota de clientes tente de novo em sincronia e volte a estourar o limite de taxa (o problema da "manada em disparada").
  • A reutilização de conexão importa sob carga: crie um único cliente e o compartilhe, para que as conexões fiquem agrupadas em vez de pagar um novo handshake TLS a cada chamada, e execute chamadas independentes em paralelo, em vez de em um loop sequencial lento.
  • Classifique antes de tentar de novo: um RateLimitError é transitório, um erro de requisição malformada é um bug seu, e tentar de novo desperdiça tempo e dinheiro em uma chamada que nunca vai ter sucesso.

As classes de exceção e o mecanismo de idempotência são específicos de cada provedor, então toda essa política pertence por trás da função de fronteira, onde você mapeia os erros de cada provedor para a sua própria decisão de retentativa, uma única vez. A chave permanece no lado do servidor, e uma resposta bem-sucedida ainda não é confiável até ser validada, o que saída estruturada e o capítulo sobre segurança tratam.

JunoQuando as coisas dão errado A retentativa é a parte perigosa: uma retentativa ingênua depois de um timeout pode te cobrar em dobro, porque a primeira chamada pode ter gerado tokens que você nunca recebeu, então use uma chave de idempotência ou torne a operação segura para repetir. Adicione jitter ao seu backoff para que uma frota de clientes não tente de novo em sincronia e volte a estourar o limite, reutilize um único cliente com pool de conexões, e execute chamadas independentes em paralelo. Classifique antes, tente de novo apenas os erros transitórios e falhe de forma visível nas suas próprias requisições malformadas, e mantenha toda a política por trás de uma única fronteira, para que as peculiaridades de cada provedor se traduzam na sua decisão de retentativa em um só lugar.