Mantendo os turnos do agente com muitas imagens dentro da memória
Um turno do agente em produção ficou sem memória com 952 MB enquanto recortava produtos de seis digitalizações de catálogo e analisava os recortes. Não houve um único vazamento, e sim três. Cada chamada ao modelo reenviava as imagens do turno em tamanho original; a biblioteca que usamos para chamar o modelo mantinha cada requisição viva até o fim do turno; e o rastreamento do framework do agente guardava as entradas de cada chamada, imagens incluídas, durante todo o turno. Corrigimos as três: as imagens são reduzidas antes de serem enviadas, e as imagens de cada chamada são liberadas assim que ela termina.
Como o agente vê imagens
Quando você cria no Mythex, o agente trabalha dentro do sandbox na nuvem do seu projeto. Ele consegue analisar imagens (uma captura de tela que você anexou, um mockup, um arquivo no projeto ou uma imagem que ele mesmo acabou de produzir) enviando-as ao modelo junto com a conversa.
Um turno é tudo o que o agente faz em resposta a uma mensagem sua. Dentro de um turno, o agente chama o modelo muitas vezes: pensa, executa uma ferramenta, analisa o resultado, pensa de novo. Cada uma dessas chamadas envia a conversa até ali, e isso inclui as imagens do turno, codificadas como texto (base64, o que as deixa cerca de um terço maiores).
O turno que travou estava fazendo algo razoável. Ele pegou seis digitalizações de catálogo, de cerca de 2,4 MB cada, recortou produtos individuais delas e continuou lendo os recortes de volta para conferi-los. Foram muitas chamadas ao modelo, cada uma carregando várias imagens grandes.
Causa 1: cada chamada reenviava imagens em tamanho original
Cada chamada ao modelo montava o corpo da requisição com as imagens do turno em tamanho original: cerca de 20 MB de base64 por chamada. O corpo era construído como uma única string na memória do agente, então cada chamada alocava mais ~20 MB.
Enviar em tamanho original nem trazia benefício. O modelo reduz as imagens para cerca de 1.300 pixels no maior lado por conta própria, então os pixels extras só custavam memória e tempo de envio.
A correção: reduzir uma vez, enviar pequeno
- Imagens acima de 400 KB são convertidas para JPEG com no máximo 1.568 pixels no maior lado antes de serem enviadas. Imagens menores seguem como estão.
- A conversão usa o ImageMagick, que vem em todo sandbox do Mythex, e roda uma vez por arquivo. Um turno que mostra as mesmas digitalizações em cada chamada reaproveita a cópia reduzida. Esse cache é indexado pelo caminho, tamanho e data de modificação do arquivo, então um arquivo editado é convertido de novo, e ele tem seu próprio limite de tamanho para não se tornar o que enche a memória.
- O orçamento de imagens em uma única chamada ao modelo caiu de 16 MB para 6 MB.
As mesmas seis digitalizações agora custam 4,4 MB por chamada, em vez de 19,7 MB.
Causa 2: o cliente do modelo nunca liberava uma requisição
O tamanho de cada requisição era só parte do problema. A memória também crescia chamada após chamada. Em nosso ambiente de homologação, o mesmo tipo de turno mantinha 369 MB de memória viva mesmo depois de uma coleta de lixo completa, o ponto em que tudo o que é inacessível já foi liberado. A amostragem da heap mostrou que todo o crescimento estava na parte da biblioteca cliente do modelo que codifica os corpos das requisições.
A causa era um listener de abortar. Quando você aperta Parar, o sinal de abortar do turno dispara, e toda requisição que o escuta é cancelada. Nós passávamos esse sinal único do turno para cada requisição. A biblioteca cliente adiciona um listener de “abort” a qualquer sinal que recebe e nunca o remove.
Assim, cada requisição de um turno continuava acessível a partir do sinal do turno, durante todo o tempo que o turno durava: o listener, que segurava o controlador da requisição, que segurava a requisição, que segurava o corpo inteiro com as imagens.
A correção: um sinal por requisição
Agora cada requisição recebe o seu próprio sinal. Ele é vinculado ao sinal do turno apenas enquanto aquela requisição está rodando (no caso de uma resposta em streaming, até o último pedaço ser lido) e é desvinculado logo depois. Parar ainda alcança a requisição em andamento, mas o sinal do turno passa a ter no máximo um listener por vez.
Os testes cobrem as três partes:
- Depois de 12 chamadas, não restam listeners no sinal do turno. Antes da correção, havia 12.
- Em 15 requisições de 8 MB cada, a memória fica estável. Antes, crescia 128 MB.
- Apertar Parar ainda aborta uma requisição no meio do seu stream.
Causa 3: o rastreamento guardava as entradas de cada chamada
Um snapshot da heap mostrou um terceiro retentor: cerca de 6 MB mantidos por chamada ao modelo. Isso vinha da transmissão de eventos do framework do agente. Ele registra cada chamada ao modelo em um turno como filha do turno, incluindo as entradas, até o turno terminar. Cada imagem anexada a cada chamada permanecia na memória junto com ela.
A correção: apagar as imagens quando a chamada retorna
As imagens só são anexadas às mensagens montadas para uma chamada específica ao modelo. Elas nunca são guardadas no estado salvo da conversa. Então, assim que uma chamada retorna, é seguro esvaziar os dados de imagem dessas mensagens. O rastreamento mantém um registro em texto da chamada; os pixels são descartados.
Um detalhe fez diferença: as imagens precisam ser apagadas no próprio lugar. O rastreador pode alcançar os mesmos dados de imagem por mais de uma referência em uma mensagem, então trocar por uma lista nova e vazia deixaria o original, com os pixels, ainda acessível. O estado da conversa salvo nunca é modificado.
Um teste roda o grafo do agente sob a mesma transmissão de eventos, com uma imagem de 4 MB em cada chamada. Sem a correção, a memória cresceu 77 MB em 16 chamadas. Com ela, a memória fica estável.
O que tiramos disso
- Problemas de memória podem vir em camadas. Três causas separadas mantinham imagens por tempo demais, e as três precisaram ser resolvidas para o turno ficar dentro da memória.
- Tudo o que você entrega a uma biblioteca pode ser retido. Um sinal e um rastreamento sobreviveram às chamadas para as quais foram criados. Quando a memória cresce por chamada, observe quais objetos de longa duração a chamada toca.
- Não envie o que o outro lado descartaria. Pixels que o modelo descartaria de qualquer forma custavam memória em cada chamada.
- Teste a memória diretamente. Cada correção veio com um teste que mede o crescimento da heap ou a contagem de listeners, então uma regressão aparece como um número, e não como um travamento.
Esta é a segunda história de memória envolvendo o agente em pouco tempo; a primeira foi um rótulo de progresso reenviado 41.000 vezes. Se quiser saber mais sobre o que é o agente e como ele funciona, veja nosso guia sobre agentes de IA e a documentação do chat.