Amália Chat - o que é preciso para dar uma cara a um LLM público
O governo português fez algo raro este ano. Financiou e lançou um large language model open source treinado para português europeu, pesos e tudo, gratuito para qualquer pessoa correr. E ficou por aí. Havia um modelo no Hugging Face e um white paper, mas não havia forma de uma pessoa normal falar com ele. Precisavas de uma GPU, de uma stack de serving, e de tolerância para linhas de comando.
Esse fosso é a razão pela qual a Amália Chat existe. Queríamos que um falante de português abrisse um link, escrevesse uma pergunta na sua língua, e recebesse uma resposta. O tipo de coisa que as pessoas já sabem usar porque se parece com todas as outras aplicações de chat que já experimentaram.
Esta é a história de como lá chegámos, funcionalidade a funcionalidade, e das decisões que acabaram por importar mais do que esperávamos.
A começar pelo aborrecido de propósito
As primeiras horas foram deliberadamente sem glamour. Scaffolding de uma app Next.js no App Router com Tailwind, Postgres ligado, e Better Auth à frente. Depois streaming, através do Vercel AI SDK a falar com a Amália num endpoint compatível com OpenAI. Depois persistência de conversas, uma sidebar para alternar entre chats, e rendering de Markdown para que código e listas ficassem bem.
Nada disto vai fazer manchete. Mas é a fundação em que tudo o resto assenta, e tê-la bem sólida desde cedo significava que as funcionalidades mais interessantes depois não tinham de lutar contra a mesma.

Uma escolha nesta fase pagou dividendos repetidamente: autenticação sem password com sessões de convidado. Um visitante pela primeira vez não se regista. Começa só a escrever. Os guests têm algumas mensagens gratuitas, contadas do lado do servidor, e quando chegam ao limite a app transforma um HTTP 402 num ecrã de registo amigável. Se se registarem com um código de email, ligamos a sessão anónima à nova conta e transportamos a conversa já existente. Ninguém perde o chat por ter decidido registar-se a meio.
Esse percurso fez trabalho real. Nos primeiros doze dias, cerca de 3.300 pessoas chegaram ao bloqueio das mensagens gratuitas, e cerca de 980 delas criaram uma conta em vez de sair. Um muro que converte quase um terço das pessoas que chegam a ele é um muro que as pessoas, aparentemente, não se importam de encontrar.
A planear para o dia em que fica popular
Um modelo assim atrai um pico. Alguém partilha o link, um jornal publica, e de repente chegam alguns milhares de pessoas de uma vez. O nosso backend tem um número fixo de slots de geração, por isso a pergunta honesta era: o que acontece quando a procura os excede?
A resposta que construímos é uma cadeia de falhas graciosas em vez de um crash único.
No máximo corre um número definido de gerações ao mesmo tempo. Tudo o resto fica numa fila FIFO, e aqui está a parte que os utilizadores realmente sentem: a sua posição em directo nessa fila faz stream para o browser, por isso vêem "3.º na fila" a avançar para a frente em vez de ficarem a olhar para um spinner congelado. Quando mesmo a fila está cheia, o pedido volta como um estado limpo de "capacidade esgotada", e oferecemos uma lista de espera por email para que a pessoa possa deixar o endereço e voltar mais tarde.
Os pedidos em fila não ficam à espera indefinidamente; desistem depois de uma espera delimitada. As verificações de rate limit acontecem antes de reservarmos um slot, e só guardamos a mensagem do utilizador depois de ele ter sido admitido, por isso um pedido que seja recusado ou abandonado não nos custa nada. Há também um botão de débito: encurta o comprimento máximo de resposta em carga elevada para rodar os slots mais depressa, e depois volta a alargar quando as coisas acalmam.
Esperámos nunca precisar da maior parte disto. Construí-lo de qualquer forma significou que um lançamento movimentado curvaria, mas não quebraria.
E o lançamento chegou. O primeiro dia trouxe 2.318 visitantes. O segundo trouxe 4.550, quase sete mil pessoas nas primeiras 48 horas, todas a chegar contra um conjunto fixo de slots de geração. O tráfego aguentou, e depois estabilizou num ritmo constante de algumas centenas por dia. A semana que passámos em filas e load shedding pagou-se nas primeiras duas.
Duas máquinas, e a manter as suas caches quentinhas
No início corríamos tudo através de um endpoint. Depois colocámos uma segunda máquina online, um servidor llama.cpp num Mac ao lado do que já tínhamos no NVIDIA DGX Spark, e deparámo-nos com um problema subtil.
A nossa configuração de endpoint já aceitava uma lista separada por vírgulas de backends, mas fazia round-robin de cada geração individual. Isso significava que dois turnos consecutivos da mesma conversa podiam aterrar em máquinas diferentes, e cada uma reprocessaria o prompt completo a frio. Toda a prefix cache que a primeira máquina tinha construído ia por água abaixo.
A solução foi parar de distribuir gerações e começar a distribuir conversas. Fazemos hash do id de cada conversa e mapeamo-lo para um backend estável, para que cada turno desse chat vá à mesma máquina e a sua cache fique quente ao longo de toda a troca. Conversas diferentes ainda se distribuem pelas máquinas, por isso mantemos o load balancing. E se quiseres inclinar tráfego para uma máquina mais rápida, basta listar o URL dela duas vezes.
Algures aqui começámos também a registar qual o modelo que produziu cada mensagem de assistente, para que à medida que passámos do checkpoint fine-tuned para o preference-tuned pudéssemos dizer, mensagem a mensagem, de onde veio cada resposta. Coisa pequena. Muito útil quando depuras qualidade mais tarde.
A deixar as pessoas partilhar o que encontraram
Assim que as conversas eram persistentes, o pedido natural seguinte era mostrá-las a outra pessoa. Uma boa resposta da Amália é algo que queres enviar a um amigo ou colocar num grupo de chat.

Os links de partilha são mais complicados do que parecem, e a maior parte do trabalho estava nas fronteiras. Cada conversa pode ter um token de partilha impossível de adivinhar. Quando partilhas, carimbamos um timestamp de "partilhado em" usando o relógio da base de dados, e a página pública só renderiza mensagens até esse momento. Por isso se continuares a conversar depois de partilhar, as tuas mensagens posteriores ficam privadas; o link é um snapshot congelado, não uma janela em tempo real na tua conversa em curso.
Só os proprietários registados podem criar um link, os convidados não podem. Re-partilhar depois de teres revogado um link gera um token novo, para que o URL antigo fique morto. Conversas vazias recusam ser partilhadas. A página pública em si é um server component read-only marcado como noindex, para que os links partilhados não vazem para os resultados de pesquisa. Revoga e a conversa fica privada outra vez, instantaneamente.
Ser honesto sobre o que o modelo é
Um modelo treinado por um esforço de investigação, lançado com um white paper, vem com limites reais. Tem um knowledge cutoff. Pode estar errado. E a LayerX só construiu a interface; não treinámos o modelo e não somos a autoridade sobre as suas respostas.
Por isso adicionámos um link de Termos no footer do composer que abre um modal a explicar tudo isto em português europeu. Explica o knowledge cutoff de forma clara, que a Amália não tem acesso à internet em tempo real, que pode cometer erros, e onde a nossa responsabilidade como interface termina e o comportamento do modelo começa. Baseámos cada afirmação no white paper actual em vez de inventar uma data de cutoff, porque nenhuma está publicada e não íamos afirmar uma que não conseguíamos fundamentar.
É um modal pequeno. Também parecia o mínimo que devíamos às pessoas que confiam na ferramenta.
A instrumentar o backend
A esta altura a app estava a fazer o suficiente para que "ver os logs" precisasse de significar alguma coisa. Pusemos Pino em todo o backend: pretty-printed em desenvolvimento, NDJSON estruturado para stdout em produção, stack traces completos nos erros.
O pipeline de pedidos carrega agora um logger por pedido etiquetado com ids de pedido, utilizador e conversa, para que possas rastrear um único turno de chat de ponta a ponta. Registamos quando uma geração começa e qual o backend que atingiu, quando termina com contagens de tokens e latência, quando a fila admite ou rejeita carga, e quando a migração de convidado para conta corre. A regra que mantivemos: nunca fazer log do código OTP, nunca fazer log de emails raw, nunca fazer log da query de pesquisa raw. Observabilidade não deve tornar-se uma fuga de privacidade.
Há também um banner ASCII de arranque agora, impresso uma vez por boot. Puramente para as pessoas que o correm. Às vezes constróis uma pequena coisa agradável porque faz o trabalho parecer teu.
A limpar e a pesquisar na web
Duas das adições mais recentes completam a experiência.
Apagar um chat parece trivial até apagares o errado. Encaminhámos todos os apagamentos, seja do menu de três pontos do cabeçalho ou do ícone de lixo da sidebar, através de um diálogo de confirmação partilhado. Um modal de acção destrutiva reutilizável, um claro "tens a certeza", consistente em todo o lado.
E depois pesquisa na web, que é a funcionalidade de que estou mais entusiasmado. A Amália consegue agora pesquisar na web a meio de uma resposta através de uma ferramenta baseada no Tavily. Pergunta-lhe algo actual e ela pode ir ver, e depois responder com o que encontrou, em vez de adivinhar a partir de dados de treino que têm um cutoff.
Lançámos isto com cuidado. Está desligado por defeito e protegido por uma allowlist por utilizador, verificada no servidor onde não pode ser falsificada. Um botão de menu ao lado do composer abre um popover de opções com um toggle de pesquisa na web. Contas na allowlist têm-no com um badge "Beta"; utilizadores registados que ainda não foram convidados vêem-no protegido como "apenas por convite", e visitantes não autenticados são informados para se registarem primeiro. O estado do toggle vive suficientemente alto na app para sobreviver a troca entre conversas, e a flag viaja com cada mensagem, embora o servidor re-verifique a allowlist cada vez independentemente do que o cliente afirma.
Havia arestas para limar. Limitámos o fetch do Tavily com um timeout para que um upstream suspenso não possa fixar um slot de geração durante toda a duração da route. Mantemos o indicador de "a escrever" visível enquanto uma pesquisa corre, já que o turno do assistente não tem texto ainda durante a consulta. E assegurámos que o texto do assistente fica guardado em todos os passos de ferramenta, não apenas no final, para que nada dito antes de uma chamada de pesquisa se perca.
Os primeiros doze dias, em números
As analytics contam uma história mais clara do que qualquer lista de funcionalidades.
- Perto de 12.000 pessoas usaram a Amália, enviando cerca de 35.000 mensagens e recebendo quase 36.000 respostas.
- 73% eram de Portugal, que é exactamente para quem o modelo foi construído. O resto veio maioritariamente dos EUA, Países Baixos, Irlanda e Espanha.
- As pessoas ficaram. A visita média correu perto de seis minutos, e apenas 28% saíram sem interagir, elevado engagement para uma ferramenta que ninguém conhecia duas semanas antes.
- Registar mudou a forma como as pessoas a usaram. Os convidados enviaram cerca de 13 mensagens cada antes de se afastarem; as pessoas que criaram uma conta enviaram perto de 36. O muro fez o seu trabalho, e as contas por trás dele valeram mais do que o tráfego gratuito.
- A instrução de ser conciso por defeito que demos ao modelo aparece nos dados. Cerca de seis em dez respostas vieram com menos de 512 tokens, e cerca de 86% ficaram abaixo de 2.000. A Amália responde e para, que é o comportamento que pedimos.
- A pesquisa na web e os links de partilha são suficientemente recentes para que os seus números sejam pequenos, e isso está bem. A pesquisa na web em particular ainda é apenas por convite e mal está ligada. São as funcionalidades que vão importar mais à medida que os utilizadores regulares se instalam do que importaram na correria do lançamento.

O que construir isto nos ensinou
As funcionalidades que as pessoas notam são os links de partilha e a pesquisa na web. As funcionalidades que realmente mantêm a Amália de pé são a fila, o cache pinning, e a fundação aborrecida da primeira semana.
Dar uma cara a um modelo público acabou por ser menos sobre o modelo e mais sobre tudo à volta dele: esgotar graciosamente a capacidade, ser honesto sobre os limites, deixar as pessoas sair e voltar, impedir que um link partilhado diga mais do que o seu dono quis. O governo lançou a parte difícil. O nosso trabalho foi fazê-la sentir como algo que uma pessoa pode simplesmente usar.
Está live, está em português, e funciona. Era esse o ponto de chegada.