Chatbot com LLM: streaming, compressão de contexto e memória
Crie um chatbot passo a passo: seleção de modelo, Stop no provider, orçamento de contexto medido, compressão em memória e busca web pelo gateway.
Nossas escolhas
preços ao vivo| Modelo | Veredito | Preço |
|---|---|---|
| Gemini 3.6 Flash Rápido, com um contexto real de 1M (a informação-alvo foi recuperada em 972K), e o raciocínio definido como mínimo reduziu o custo medido por chamada em 91-97% em tarefas de uma única etapa no formato de chat. | Melhor opção padrão | desde $1.5/M |
| DeepSeek V4 Flash A opção mais barata desta página com suporte a chat e o maior desconto para leitura de cache da tabela, então reler um histórico longo custa quase nada. Também é o sumarizador padrão do MVP. | Opção econômica | desde $0.138/M |
| Claude Sonnet 5 A maior consistência de persona e escrita entre os quatro. Observação sobre o custo: o mesmo texto é tokenizado em 41% mais tokens do que no Sonnet 4.6, então compare as contagens de tokens, não os preços anunciados. | Opção de qualidade | desde $2/M |
| GLM-5.2 Um turno de chamada de ferramenta com cache aquecido custou $0.0009, contra $0.0051 no claude-opus-4-8. A latência mediana por turno com cache aquecido foi de 6.6s, então reserve-o para fluxos em que o usuário espera aguardar. | Chamadas de ferramentas baratas | desde $1.4/M |
Conteúdo
- O que um chatbot MVP precisa fazer?
- Como tudo se encaixa?
- Quais modelos entram no seletor?
- O que o modelo vê em cada turno?
- Como funciona o prompt cache e quais modelos o respeitam?
- Como saber se o limite de contexto está próximo?
- O que acontece no limite?
- Como a memória persiste entre sessões?
- O que acontece quando o usuário clica em Stop?
- O que acontece quando um turno falha?
- Como a busca web funciona sem um loop de ferramentas no seu código?
- Como renderizar markdown sem quebrar durante o streaming?
- Quanto custa uma conversa?
- Onde ficam todas as configurações?
- O que ficou de fora e onde seria adicionado?
- Leituras relacionadas
Este guia mostra como montar um chatbot completo que roda localmente em cerca de dois minutos e cujo código dá para ler em uma tarde: um servidor FastAPI, uma página estática, sem banco de dados nem etapa de build. Explicamos o projeto de cada subsistema, a ordem das operações em cada turno e os modos de falha encontrados durante o desenvolvimento. O código completo está em github.com/synthorai-io/use-cases, no diretório chatbot/. Todas as requisições passam por um único endpoint compatível com OpenAI. Por isso, os modelos ficam em um menu, sem integrações separadas.
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

O que um chatbot MVP precisa fazer?
Oito coisas. Cada uma evita uma falha específica:
| Recurso | Falha que evita | Onde fica |
|---|---|---|
| Conversas persistentes (listar, buscar, renomear) | um chat que esquece tudo ao recarregar é uma demonstração, não uma ferramenta | storage.py |
| Seleção de modelo, com troca durante a conversa | usar em todas as perguntas um modelo precificado para as mais difíceis encarece as fáceis | config.py |
| Persona editável, com presets | o system prompt é o produto; alterá-lo não pode exigir deploy | presets/ |
| Streaming, com um Stop que chega ao provider | o silêncio antes do primeiro token parece travamento; um Stop falso continua gerando cobrança | server.py |
| Orçamento de contexto, com compressão no limite | todo modelo tem uma janela; atingir o limite silenciosamente faz o bot parecer menos inteligente | context.py |
| Memória de longo prazo | ter de se apresentar novamente em toda sessão é um dos problemas mais perceptíveis em chatbots | context.py + .data/memory.md |
| Busca e leitura de páginas web | o conhecimento do modelo parou na data de treinamento; perguntas em chats tendem a exigir dados atuais | tools.py |
| Visibilidade de custo por turno e sessão | a conta cresce com o histórico; não dá para otimizar o que não é visível | server.py |
Busca e leitura de páginas web merecem uma explicação mais longa porque costumam ser os primeiros itens cortados das listas de recursos de um MVP. O conhecimento de um modelo de chat termina na data de corte do treinamento, meses antes do presente. Já as perguntas recebidas em chats tratam muito do que é atual: preços, versões, lançamentos e se X já suporta Y. Quando um chatbot responde com base apenas nos dados de treinamento, ele não está só desatualizado. Ele está errado com confiança, sem que o usuário saiba quais respostas estão defasadas. A falha vai além da ausência de fatos. Durante o desenvolvimento, descobrimos que um modelo sem uma referência de data assume que a data de corte é o presente. Com isso, ele faz buscas erradas e inclui um ano desatualizado nas próprias consultas. Retrieval não é um recurso complementar em um chatbot. É o que separa uma interface para um retrato estático de um assistente.
Por que duas ferramentas em vez de uma? Porque busca e leitura respondem a perguntas diferentes. A busca devolve trechos com poucas centenas de caracteres que frequentemente discordam entre si. Ela responde “o que existe”. A leitura recupera uma página inteira e responde “o que esta página diz”, o que permite confirmar um número exato. Uma ferramenta descobre; a outra verifica. O modelo decide em cada turno se precisa usá-las. Um turno que não precisa delas não gera custo adicional, e os limites por uso restringem os demais.
Os outros recursos básicos também estão presentes: Regenerate, renderização de markdown que não quebra no meio do streaming e caminhos de recuperação específicos para cada falha. As seções abaixo seguem aproximadamente a ordem da tabela e o fluxo de uma requisição pelo código.
Como tudo se encaixa?
São três partes: uma página estática, seis arquivos Python pequenos e o gateway. A página envia uma mensagem; o servidor monta o contexto, comprime se necessário, transmite a completion de volta por server-sent events e registra o uso medido. As conversas ficam em arquivos JSON que podem ser lidos com cat.
static/index.html the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py routes; the per-turn pipeline: project → compress → stream → record
context.py token budgeting, compression call, memory file, message assembly
tools.py the /v1/messages transport used by tool-enabled turns
storage.py one JSON file per conversation under .data/ (settings included)
config.py env-driven settings: model lineup, budgets, prompts
presets/ system-prompt presets, one .txt each
A parte mais importante é entender o pipeline por turno em server.py. Toda mensagem enviada pelo usuário passa pelas mesmas cinco etapas, cada uma explicada em uma seção abaixo:
user message
│
▼
[1] project the next request's size last measured prompt_tokens
│ + estimate(new message, pessimistic)
│ + completion reserve
▼
[2] over budget? ──yes──▶ compress old turns ──▶ rolling summary
│ └───────▶ durable facts ──▶ memory.md
▼
[3] assemble and send [persona][memory][summary][date][history]
│ cache mark after the system blocks
▼
[4] stream SSE back to the page delta / reasoning / search / fetch /
│ compression notice / warning / error
▼
[5] record measured usage usage.prompt_tokens becomes step [1]'s
input on the next turn
O ciclo se retroalimenta: o valor medido na etapa 5 é usado pela etapa 1 no turno seguinte. Assim, o orçamento sempre se baseia no que a API realmente contou, não em uma estimativa local.
Há uma bifurcação arquitetural importante desde o início: turnos comuns usam /v1/chat/completions; turnos com busca ou leitura web habilitada usam /v1/messages. Não é uma preferência de estilo. A seção sobre ferramentas mostra o comportamento medido que exige essa divisão.
Quais modelos entram no seletor?
O .env inclui sete modelos, agrupados por categoria no seletor (rápidos e baratos, equilibrados, frontier). Também é possível adicionar em Settings qualquer id servido pelo gateway; os ids adicionados ficam persistidos em .data/models.json. O modelo padrão importa mais do que a lista. O MVP começa com Gemini 3.6 Flash e o nível de reasoning no mínimo. Respostas de chat são tarefas de uma só etapa. Nessas tarefas, reasoning_effort: "minimal" reduziu o custo medido por chamada em 91-97% em relação ao padrão, sem diferença perceptível para o leitor. Essa configuração fica por modelo, pois nem todos aceitam o parâmetro:
# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
"gemini-3.6-flash": {"reasoning_effort": "minimal"},
}
DeepSeek V4 Flash aparece duas vezes: como opção econômica no seletor e como summarizer padrão da compressão, já que resumir também é um trabalho de uma etapa com tolerância a pequenas diferenças de qualidade. Claude Sonnet 5 é a escolha quando a qualidade da escrita é o produto. Para estimar o orçamento, compare a contagem de tokens, não só o preço anunciado: um mesmo texto gera 41% mais tokens do que no Sonnet 4.6. GLM-5.2 ganha espaço em fluxos com muitas chamadas de ferramentas: um turno quente com tool call custou $0.0009, contra $0.0051 no Claude Opus 4.8. A ressalva é que a mediana de 6.6s por turno quente é perceptível em uma janela de chat.
A capacidade de cada modelo foi medida, não presumida. O código contorna as diferenças em vez de fingir que elas não existem. Nessa seleção, somente DeepSeek e GLM transmitem seu raciocínio como reasoning_content. Os modelos Claude não devolvem blocos de pensamento por este gateway, mesmo com o parâmetro de thinking ativado. Por isso, a interface só mostra um painel “Thinking” ao vivo quando ele pode receber conteúdo.
Trocar de modelo durante a conversa não exige mudança estrutural. O histórico usa mensagens neutras em relação ao provider, no formato {"role", "content"}. A mesma conversa pode começar com um modelo barato e passar para a opção de maior qualidade quando surgir uma pergunta difícil. O único custo real é invisível: a troca abandona o prompt cache do modelo anterior. No primeiro turno após a mudança, todo o contexto é relido pelo preço sem cache.
O que o modelo vê em cada turno?
Um prompt em camadas, montado em um único lugar e numa ordem deliberada: primeiro, o conteúdo mais estável.
| Camada | Origem | Quando muda |
|---|---|---|
| System prompt (persona) | presets/*.txt ou texto livre | nunca, salvo edição |
| Memória de longo prazo | .data/memory.md | raramente (quando fatos são adicionados) |
| Resumo contínuo | compressão | somente quando há compressão |
| Referência de data | relógio do servidor | diariamente |
| Instruções de ferramentas | Settings, apenas em turnos com ferramentas | raramente |
| Turnos recentes | conversa | em todo turno |
A regra de ordenação é colocar primeiro o conteúdo mais estável por causa do cache, explicado na próxima seção. A persona nunca muda, a memória muda raramente, o resumo muda apenas quando há compressão e o histórico muda em todo turno. Portanto, cada camada vem depois de todas as que mudam com menos frequência.
A referência de data tem função e posição específicas. Sem ela, o modelo assume que a data de corte do treinamento é o presente: cria consultas com um ano desatualizado e interpreta uma tabela de lançamentos como se a linha mais recente ainda fosse atual. A referência contém uma data, não um timestamp. Como ela fica no prefixo do prompt, qualquer granularidade inferior a um dia invalidaria o cache em todas as requisições. Ela também aparece depois da persona, da memória e do resumo, permitindo que essas camadas continuem em cache após a meia-noite.
Como funciona o prompt cache e quais modelos o respeitam?
O princípio é simples: providers armazenam em cache o prefixo idêntico, byte a byte, de uma requisição e o reutilizam com grande desconto, cobrando um pequeno adicional na primeira gravação. Chat é um dos melhores casos de uso para isso. Cada requisição é a anterior acrescida de duas mensagens, então todo o passado já forma um prefixo estável. O custo adicional de gravação se paga no turno seguinte. Nos modelos Anthropic, a gravação custa 1.25x com TTL de 5 minutos, enquanto a leitura fica em torno de 0.1x; medimos aqui a economia no lado da gravação. Marcadores cache_control reduziram o custo medido em 88-89% em três categorias de Claude. Em uma conversa com memória real e resumo, os turnos posteriores custam aproximadamente um décimo de um primeiro turno sem cache.
O problema é que “cache” não é um único mecanismo. Os providers se dividem em dois grupos, ambos presentes nesta seleção:
| Modelo | Tipo de cache | O que você faz | Como os hits são informados |
|---|---|---|---|
| Família Claude | explícito: breakpoints com cache_control | posiciona o marcador | cache_read_input_tokens |
| DeepSeek V4 Flash | implícito: prefixo automático | nada | prompt_tokens_details.cached_tokens |
| GLM-5.2 | implícito: prefixo automático | nada | prompt_tokens_details.cached_tokens |
| Gemini 3.6 Flash | implícito; o marcador é aceito e ignorado | nada funciona | não informado por este gateway |
O cache explícito, usado pela Anthropic, exige que você indique o breakpoint, mas informa exatamente o que aconteceu. Há campos separados para tokens gravados e lidos, o que permite auditar o desconto em cada turno. O cache implícito, comum no ecossistema compatível com OpenAI, dispensa marcadores e funciona quando o prefixo se repete. Em contrapartida, o provider decide o que armazenar, e o único indício posterior é um campo cached_tokens; a confiabilidade desse comportamento varia bastante entre providers. A estratégia do MVP atende aos dois grupos: ordena o prompt do conteúdo mais estável para o menos estável, como o cache implícito exige, e sempre envia o marcador explícito, ignorado sem efeitos pelo outro grupo. Com um único formato de requisição, cada modelo usa o cache que oferece.
A posição do marcador explícito veio de medições, não da documentação. Neste gateway, cache_control funciona em um bloco de sistema e é ignorado silenciosamente nos demais lugares. O padrão comum em conversas com vários turnos, de marcar a mensagem mais recente do usuário para permitir o cache de todo o histórico, retorna zero tokens em cache e cobra o preço integral. Por isso, o MVP marca o fim da seção de sistema. O prefixo armazenável passa a ser persona + memória + resumo. Há duas consequências. Primeiro, o cache só começa a funcionar quando o prefixo ultrapassa o mínimo do modelo, cerca de 1,024 tokens. Uma conversa nova não armazena nada; uma conversa com memória e resumo acumulados armazena tudo. Segundo, qualquer edição do prefixo tem custo: alterar o system prompt invalida o cache desde o primeiro byte; a compressão reescreve o resumo e gera uma leitura sem cache, abordada abaixo; trocar de modelo abandona todo o cache anterior.
O TTL é uma configuração com o mesmo padrão em todos os casos. O cache padrão de 5 minutos cobre uma conversa ativa; a opção de 1 hora cobre um usuário que se afastou, mas dobra o custo adicional de gravação. CACHE_TTL=1h faz a alteração em uma linha. A economia depende de os usuários realmente voltarem dentro desse período.
Como saber se o limite de contexto está próximo?
Meça em vez de estimar. A única contagem de tokens válida para o modelo em uso é o usage.prompt_tokens devolvido pela API na requisição anterior. Estimadores locais erram entre vendors, e a diferença pode ser grande: o mesmo texto em inglês varia 41% entre dois modelos do mesmo vendor (nossa medição) e ainda mais entre vendors. O MVP conta tokens localmente apenas para o que a API ainda não viu, ou seja, a mensagem que está sendo enviada. Essa estimativa é arredondada para cima de propósito:
# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
if message_count <= config.KEEP_RECENT_MESSAGES:
return False # nothing old enough to fold away
projected = (
last_prompt_tokens # measured, last response
+ estimate_tokens(pending_text) # estimated, pessimistic
+ config.MAX_COMPLETION_TOKENS # worst-case reply
)
return projected > (budget or config.CONTEXT_BUDGET_TOKENS)
Superestimar aciona a compressão um turno antes, ao custo de uma chamada barata para resumir. Subestimar estoura a janela e causa uma requisição com erro ou truncamento silencioso. Essa assimetria determina para onde arredondar.
Existe uma armadilha até mesmo em “medir”: os providers não concordam sobre a inclusão dos tokens em cache em prompt_tokens. Alguns informam o prompt inteiro; outros, somente a parte fora do cache. Esse erro não é cosmético, pois o número é a fonte de verdade do orçamento. Se a contagem ficar abaixo do valor real pelo tamanho do cache, a compressão nunca será acionada e a janela acabará excedida silenciosamente. O servidor normaliza o valor. Se prompt_tokens for menor que a quantidade informada de tokens em cache, ele não pode representar o prompt inteiro, então as partes são somadas. A estimativa local continua servindo como piso para o valor medido. O que cada provider informa em usage é um assunto próprio; Anatomia do uso de tokens em LLMs explica os detalhes.
O orçamento padrão é 102,400 tokens, valor que uma conversa normal não alcança. Trata-se de um orçamento operacional, não do limite do modelo. O número correto é o ponto em que um turno passa a custar mais do que vale. Para observar o mecanismo, abra Settings, reduza a janela de uma conversa para alguns milhares de tokens e cole algumas mensagens longas. A barra de contexto no cabeçalho usa o valor medido e mostra em segmentos o que ocupa a janela: persona, memória, resumo e histórico.

O que acontece no limite?
Comprima, não trunque. O truncamento faz o bot esquecer o início da conversa, algo que o usuário percebe como perda de inteligência. Em vez disso, tudo menos as mensagens mais recentes, por padrão as últimas 8, é condensado em um resumo contínuo pelo modelo barato de summarization. Esse resumo acompanha as requisições como bloco de sistema. A janela recente permanece intacta, preservando o estilo do bot no curto prazo; somente o passado distante sofre perda. Nada é removido silenciosamente: quando a compressão ocorre, aparece um aviso no histórico com o número de mensagens e o tamanho do resumo.
Uma compressão completa:
before (projected next request 103.1k > 102.4k budget)
[persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
old enough to fold last 8, kept
one summary call to deepseek-v4-flash:
in: prior summary + m1 ... m34
out: {"summary": "one dense paragraph: topics, decisions,
open questions, promises",
"facts": ["prefers Python", "timezone is UTC+8"]}
after
[persona][memory + new facts][summary][date][ m35 ....... m42 ]
unchanged grows rarely replaced verbatim
Três decisões no processo de compressão respondem pela maior parte da confiabilidade:
- O resumo incorpora o resumo anterior. Na segunda compressão, o primeiro resumo é condensado junto com as novas mensagens antigas. Assim, há sempre um único parágrafo contínuo, não uma cadeia crescente de resumos de resumos.
- Uma falha não impede o turno. Se a chamada de summarization falhar, o servidor descarta os turnos mais antigos sem resumir, informa exatamente o que foi perdido e responde à mensagem mesmo assim. Uma memória imperfeita é melhor do que um chatbot indisponível. O caminho de erro que nunca aparece nas demonstrações é o que aciona o plantão em produção.
- O prompt é configurável, com uma proteção. O pedido feito ao summarizer define o que a conversa lembrará. Por isso, o prompt de compressão pode ser editado por conversa. O servidor rejeita alterações que removam o placeholder
{transcript}, pois esse prompt não resumiria nada e a falha só surgiria muito depois, no primeiro estouro. A mesma lógica vale paraCOMPRESS_MAX_TOKENS: o limite de saída tem margem sobre o tamanho solicitado pelo prompt, porque um resumo truncado no meio de uma frase será herdado por todos os turnos seguintes.
Em relação ao cache, a compressão custa exatamente uma releitura sem cache. O bloco de resumo muda, então tudo depois do bloco de memória fica sem cache por uma requisição. Depois disso, o novo prefixo menor é armazenado novamente. Esta é a conta completa: uma chamada de summarization e uma leitura sem cache tornam todos os turnos seguintes mais baratos, com um contexto menor e quente.
Como a memória persiste entre sessões?
Visto de forma ampla, o bot tem três áreas de memória, separadas por escopo e duração. Todas as decisões desta seção decorrem dessa divisão:
| Área | Escopo | Duração | Tamanho enviado |
|---|---|---|---|
| Turnos recentes, sem alteração | esta conversa | até serem incorporados pela compressão | texto integral das últimas 8 mensagens |
| Resumo contínuo | esta conversa | termina com a conversa | um parágrafo, com menos de 200 palavras |
Fatos em memory.md | todas as conversas | até remoção manual | algumas linhas |
O resumo e os fatos podem parecer semelhantes, mas envelhecem de modos diferentes e por isso ficam separados. O resumo contínuo está ligado à conversa: decisões, perguntas em aberto e compromissos assumidos pelo assistente. Ele deve terminar junto com ela. Fatos duráveis sobre o usuário, como “prefere Python” e “o fuso horário é UTC+8”, ainda serão válidos na semana seguinte e seriam desperdiçados se desaparecessem.
O prompt de compressão pede os dois resultados em uma chamada e recebe JSON: uma string summary e um array facts. Os fatos são adicionados a .data/memory.md, carregado por todas as conversas como bloco de sistema. A extração acontece durante a compressão, não em uma etapa separada, por uma questão de custo. Nesse momento, o modelo já está relendo os turnos antigos, então a coleta aproveita tokens que já seriam pagos. Uma chamada, duas saídas.
A deduplicação do MVP compara linhas exatas, e a limitação logo aparece: uma compressão salva “User prefers Python over Node.js” e outra adiciona “The user prefers Python over Node.js.” Uma deduplicação semântica exigiria embeddings ou uma comparação por LLM entre cada candidato e o conteúdo armazenado. Isso é um recurso real, com custo real. Portanto, o MVP usa a opção simples e deixa essa limitação explícita. O arquivo de memória usa markdown justamente para ser lido e revisado por uma pessoa. Settings o expõe como campo de texto editável, que também funciona como painel de transparência: o que o bot sabe sobre você está em um arquivo acessível.
O que acontece quando o usuário clica em Stop?
O provider para de gerar. Essa é a funcionalidade, ausente em muitas interfaces de chat: um Stop que apenas oculta a saída enquanto a completion continua sendo executada cobra por tokens que ninguém lerá.
A cadeia tem três elos. Enquanto uma resposta está em streaming, o botão Send vira Stop, no mesmo lugar e sem confirmação. O clique aborta o fetch do navegador. O servidor verifica se houve desconexão entre os eventos, detecta o encerramento e sai do loop de streaming. Isso fecha a conexão upstream e interrompe o provider. Todo texto já recebido é persistido com a marca interrupted e aparece no histórico com uma borda tracejada. A mesma cadeia é acionada quando a aba fecha ou a conexão cai, pois para o servidor são o mesmo evento.
A persistência precisa ocorrer uma única vez. O código trata isso explicitamente: salva dentro do loop de streaming quando o término é normal, no exception handler quando o stream quebra e no bloco finally quando há uma desconexão abrupta. Nesse último caso, o framework cancela o generator e nada depois do loop é executado. A função é idempotente; o primeiro caminho acionado prevalece.
Stop funciona em conjunto com Regenerate: interrompa uma resposta indesejada e gere outra sem redigitar. Regenerate remove os turnos finais do assistente, inclusive um interrompido, para que a conversa volte a terminar na mensagem do usuário. Em seguida, responde novamente usando o modelo atual. Como a seleção no menu se aplica ao próximo turno, Stop seguido de Regenerate também permite repetir a mesma pergunta em um modelo mais forte.
O que acontece quando um turno falha?
Há seis possibilidades. Cada uma tem seu próprio caminho de recuperação, em vez de uma única mensagem de erro. classify() em server.py mapeia exceções upstream para tipos de falha; a interface associa esses tipos às ações:
| Falha | O que o usuário vê | Recuperação |
|---|---|---|
| Erro de rede | identificação explícita | botão Retry |
| Rate limit | tempo de espera de retry-after, quando disponível | botão Retry |
| API key inválida | nome exato da variável de ambiente a corrigir | editar .env |
| Filtro de conteúdo | “repetir o mesmo texto será recusado novamente” | Edit & resend |
| Interrupção durante o streaming | resposta parcial mantida e marcada como interrompida | Retry |
| Falha na compressão | aviso indicando o que foi descartado | nenhuma; o turno continua |
Duas invariantes resolvem a maior parte do problema. Primeiro, a mensagem do usuário nunca é perdida. Se a requisição falhar antes do primeiro evento, o servidor remove a mensagem da conversa e a interface devolve o rascunho ao campo de texto. Assim, uma nova tentativa não envia a mensagem duas vezes. Se o stream quebrar depois de algum texto chegar, a resposta parcial é mantida e marcada. Segundo, uma resposta recusada usa rewind, não retry. O servidor remove a mensagem do usuário do histórico e a devolve ao campo de texto para edição, pois repetir o mesmo conteúdo para um filtro resulta sempre na mesma falha.
Outra regra de armazenamento sustenta esses comportamentos: um turno vazio do assistente nunca é salvo. Reenviar upstream uma mensagem vazia do assistente quebra a alternância entre usuário e assistente exigida por alguns providers. O erro aparece depois, em outro turno, e aponta para a mensagem errada, o que dificulta muito o diagnóstico. O MVP remove conteúdo vazio do formato enviado e não armazena respostas sem texto, salvo quando há atividade de busca ou reasoning que vale a pena preservar. Nesse caso, a atividade é salva e o texto vazio é removido no envio seguinte.
Como a busca web funciona sem um loop de ferramentas no seu código?
O gateway executa as ferramentas no servidor, mudando quem fica responsável pelo trabalho. No function calling clássico, o loop pertence à aplicação: o modelo devolve um bloco tool_calls, seu código executa a ferramenta, adiciona uma mensagem tool_result e reenvia a conversa inteira, uma vez por chamada. Ferramentas server-side movem esse loop para o gateway. A requisição declara synthorai:web_search ou synthorai:web_fetch, e o gateway fica entre o LLM e os providers das ferramentas. Ele encaminha as chamadas do modelo para APIs externas de busca e leitura, depois incorpora os resultados ao contexto do modelo. Não há mágica: cada busca e leitura é uma chamada a uma API externa, razão pela qual a cobrança é por uso. Este código não tem round trip de tool_result, apenas eventos para renderizar:
browser server (tools.py) Synthorai gateway LLM / tool providers
│ POST /chat │ │
├──────────────▶│ declare tools + │
│ │ budget note │
│ ├────────────────────▶│── question + tools ──▶ [LLM]
│ │ │◀── tool_use: search ── [LLM]
│ │ │── query ─────────────▶ [search API]
│ │ search results │◀── results ─────────── [search API]
│ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
│ │ │◀── tool_use: fetch ─── [LLM]
│ │ │── URL ───────────────▶ [fetch API]
│ │ fetch result │◀── page text ───────── [fetch API]
│ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
│ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
│ SSE: done │ │
As responsabilidades ficam bem separadas. O modelo decide se deve buscar, se um trecho basta ou se precisa ler uma página e quando deve parar para escrever. O gateway executa: chama o provider de busca ou leitura e devolve o resultado ao modelo. Seu servidor apenas renderiza os eventos em streaming. As duas ferramentas vêm habilitadas por padrão, e um turno que não usa nenhuma delas não gera custo adicional. Assim, uma pergunta de aritmética continua gratuita, enquanto “qual é o valor atual…” aciona a busca. O loop no gateway também tem limites: se o loop server-side de busca parar no próprio limite de iterações (pause_turn), o transporte reenvia o turno para continuar, no máximo três vezes.
Quatro aprendizados do desenvolvimento são mais úteis do que o fluxo ideal:
- O endpoint determina o que fica visível. Os dois endpoints executam e cobram a busca, conforme medição em 2026-08-04 nos canais Anthropic e Gemini. Porém, somente
/v1/messagesexpõe a própria busca: a consulta e as URLs dos resultados chegam como blocos tipados, que o código pode renderizar e armazenar. Em/v1/chat/completions, a mesma busca ocorre de forma invisível: HTTP 200, resposta começando com “Based on the search results…” e nenhuma citação ou anotação, tanto no modo streaming quanto no modo comum. Pagar por uma busca sem poder auditá-la é uma degradação silenciosa, a pior forma de falha: nada dá erro, apenas a proveniência desaparece. Esse é o motivo completo paratools.pyexistir como um segundo transporte, em vez de ser apenas mais um campo na requisição comum. - O orçamento precisa ser explicado ao modelo em texto. Os limites, por padrão 3 buscas e 2 leituras por turno, são aplicados silenciosamente pelo gateway. Sem conhecê-los, o modelo planeja como se tivesse ferramentas ilimitadas e gasta a última rodada no meio do raciocínio. O turno termina em “Let me search for…” sem resposta. O MVP injeta uma frase informando o orçamento. A redação dessa frase afeta o custo de forma mensurável: nos testes, a ausência de instrução consumiu todo o orçamento e terminou no meio da frase; uma instrução rígida demais fez o modelo desistir e mandar o usuário ler a página; a redação adotada pulou a busca, leu diretamente as duas páginas oficiais e teve o menor custo. Ela pode ser editada em Settings; altere o texto e observe o custo por turno.
- Os limites são o único freio. As duas ferramentas são cobradas por uso, e o modelo decide quantas rodadas quer executar. Em testes, um único turno sem limites fez três buscas e duas leituras antes da cobrança do primeiro token de saída.
- Degrade o recurso, não derrube o turno. Duas falhas recebem o mesmo tratamento: remova a ferramenta, tente novamente uma vez e avise o usuário. A leitura web exige uma autorização na key. Sem ela, toda a requisição falha com
web_fetch_not_enabledem vez de continuar sem a ferramenta. O Gemini aceita a declaração da ferramenta, mas falha no momento da chamada (Function call is missing a thought_signature). Nos dois casos, perder o turno do usuário por causa de uma ferramenta opcional é a escolha errada.
Tudo o que o modelo fez para chegar à resposta, como raciocinar, buscar e ler, aparece ao vivo numa trilha de atividades acima da resposta. Ela fica recolhida em uma linha discreta (“Thought for 5s · Searched the web · Read 2 pages”) e pode ser expandida para mostrar uma linha do tempo com consultas, domínios dos resultados e texto de reasoning. A trilha é armazenada junto com a mensagem. Esse é o objetivo: uma trilha que desaparece após recarregar a página não permite auditar a resposta mais tarde. As citações aparecem abaixo da resposta como chips numerados com os domínios.

Como renderizar markdown sem quebrar durante o streaming?
Separando o texto acumulado em um prefixo estável, que pode ser renderizado com segurança, e uma parte final ainda incompleta. Renderizar uma estrutura aberta pela metade faz o layout saltar quando o token seguinte a fecha. Por isso, o renderer corta na última linha completa. Se houver uma abertura de bloco de código sem fechamento, tudo a partir dela permanece como texto simples até o fechamento chegar. O balão só é renderizado novamente quando o prefixo estável realmente cresce, evitando reconstruir o DOM a cada token. Blocos de código recebem um botão para copiar. O renderer tem cem linhas de vanilla JS, sem biblioteca. Isso define o que este MVP precisa, não é uma crítica a bibliotecas de markdown.
Quanto custa uma conversa?
O valor informado em usage pelo gateway, interpretado corretamente pelo MVP. Dois problemas de normalização se mostraram essenciais:
- Os vendors usam nomes de campos diferentes. Só para tokens em cache: modelos Anthropic informam
cache_read_input_tokens; DeepSeek e GLM usam apenasprompt_tokens_details.cached_tokens; Gemini não informa nenhum. A linha de estatísticas lê o campo disponível para que “cached” tenha um único significado. - Custo ausente não significa zero. O gateway informa
costem alguns turnos e omite em outros. Isso é reproduzível em turnos nos quais uma ferramenta é declarada, mas não utilizada. Turnos sem o campo de custo são contados e exibidos como “+N unreported”, em vez de entrarem na soma como zero. Um total que omite turnos silenciosamente parece ser o valor da conta, quando na verdade é apenas o mínimo conhecido.
O cabeçalho exibe os totais acumulados da sessão: entrada, saída, proporção em cache, buscas, leituras, custo e turnos. Cada resposta também mostra sua própria linha. A proporção em cache é a métrica principal: representa o funcionamento descrito acima, medido na sua própria conversa.
Onde ficam todas as configurações?
Em um único painel Settings. A decisão mais importante para reproduzir é o escopo: quase tudo pertence a uma conversa específica.

Somente duas configurações são globais, pois descrevem o usuário, não uma conversa: a lista de modelos, à qual se pode adicionar qualquer id servido pelo gateway, persistida em .data/models.json; e o arquivo de memória de longo prazo, exposto como campo de texto editável. Todo o restante pertence à conversa aberta: o system prompt, com os presets de presets/ em um menu, o orçamento de contexto, as opções de busca e leitura web, as instruções das ferramentas e o prompt de compressão. Assim, duas conversas podem rodar lado a lado com personas, orçamentos e ferramentas diferentes sobre os mesmos modelos. Isso permite reproduzir uma afirmação deste guia em vez de apenas confiar nela.
Cada campo tem um pequeno ícone de informação. Passe o cursor para ver ou clique para fixar. O texto explica o custo da alteração, pois a maioria desses controles tem um preço que não aparece na interface. Editar a persona invalida o cache desde o primeiro byte e custa um turno sem cache; reformular as instruções de ferramentas altera a cobrança por turno; reduzir o orçamento antecipa a compressão. O menu de modelos na barra superior é a única configuração salva imediatamente, pois trocar de modelo durante a conversa é uma ação principal, não uma mudança de configuração.
O que ficou de fora e onde seria adicionado?
Omissões deliberadas, cada uma com seu ponto de integração:
- Auth e múltiplos usuários — uma camada de sessão antes das rotas. As conversas já têm ids, então associá-las a um usuário exige um prefixo no nome do arquivo, não uma reformulação do projeto. Até lá, esta é uma ferramenta para localhost: toda requisição atendida gasta sua API key. Não a exponha à internet pública sem alterações.
- Rate limiting — no mesmo lugar e pelo mesmo motivo: protege uma implantação compartilhada, e esta ainda não é uma.
- RAG — os documentos recuperados entram depois do resumo e antes das mensagens recentes. Conteúdo volátil deve ficar no fim do prefixo para não invalidar os blocos em cache de persona e memória. É aqui que os modelos com contexto de 1M aproveitam suas janelas.
- Function calling no cliente — o loop de streaming ganha um ramo
tool_callse um executor. O artigo sobre GLM-5.2 explica as diferenças de contrato entre providers que aparecem nesse ponto. As ferramentas executadas pelo gateway, descritas acima, evitam esse loop de propósito. - Deduplicação semântica da memória — gere um embedding para cada fato candidato, compare com o conteúdo armazenado e mantenha os novos. O formato do arquivo de memória não muda.
- Syntax highlighting — o botão de copiar é o recurso que as pessoas usam; highlighting é uma escolha de biblioteca para um frontend de produção.
- Um banco de dados de verdade —
storage.pytem poucas funções. Migrá-las para SQLite leva uma tarde, e usar arquivos JSON é a escolha certa enquanto não houver usuários.
Leituras relacionadas
- Melhor LLM por caso de uso (2026): matriz de custo para chat, RAG e agentes — a fórmula de custo otimizada por este projeto, aplicada a diferentes tipos de carga.
- Gemini 3.6 Flash: o controle de thinking que altera o custo em 30x — as medições que justificam fixar o reasoning no mínimo.
- O tokenizer do Claude Sonnet 5 — por que a unidade correta de comparação entre modelos é a quantidade de tokens, não o preço.
- Anatomia do uso de tokens em LLMs — o que
usagerealmente informa em cada provider. - Tool calls no GLM 5.2 — custos de turnos quentes e particularidades de contrato para a extensão com function calling.