Manter os turnos do agente com muitas imagens dentro da memória
Um turno do agente em produção ficou sem memória aos 952 MB enquanto recortava produtos de seis digitalizações de catálogos e analisava os recortes. Não houve uma fuga de memória — houve três. Cada chamada ao modelo reenviava as imagens do turno em tamanho original; a biblioteca que usamos para chamar o modelo mantinha cada pedido vivo até o turno terminar; e o rastreio da 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 libertadas assim que a chamada termina.
Como o agente vê as imagens
Quando constrói na Mythex, o agente trabalha dentro do sandbox na cloud do seu projeto. Pode analisar imagens — uma captura de ecrã que anexa, uma maqueta, um ficheiro do projeto, ou uma imagem que 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, volta a pensar. Cada uma dessas chamadas envia a conversa até ao momento, o que inclui as imagens do turno, codificadas como texto (base64, que as torna cerca de um terço maiores).
O turno que falhou estava a fazer algo razoável. Recortou produtos individuais de seis digitalizações de catálogo, de cerca de 2,4 MB cada, e continuou a ler os recortes para os verificar. São muitas chamadas ao modelo, cada uma a transportar várias imagens grandes.
Causa 1: cada chamada reenviava imagens em tamanho original
Cada chamada ao modelo reconstruía o corpo do pedido com as imagens do turno em tamanho original — cerca de 20 MB de base64 por chamada. O corpo é construído como uma única string na memória do agente, por isso cada chamada alocava mais ~20 MB.
Enviar em tamanho original nem sequer trazia benefício. O modelo reduz as imagens para cerca de 1.300 pixels no lado maior por si próprio, por isso os pixels extra só custavam memória e tempo de envio.
A correção: reduzir uma vez, enviar pequeno
- As imagens acima de 400 KB são agora convertidas para JPEG com no máximo 1.568 pixels no lado maior antes de serem enviadas. As imagens menores seguem como estão.
- A conversão usa o ImageMagick, que vem em todos os sandboxes da Mythex, e corre uma vez por ficheiro. Um turno que mostra as mesmas digitalizações em cada chamada reutiliza a cópia reduzida. Essa cache é indexada pelo caminho, tamanho e data de modificação do ficheiro, por isso um ficheiro editado é convertido de novo, e tem um limite de tamanho próprio para não se tornar ele próprio o que enche a memória.
- O orçamento de imagens numa só chamada ao modelo desce de 16 MB para 6 MB.
As mesmas seis digitalizações custam agora 4,4 MB por chamada em vez de 19,7 MB.
Causa 2: o cliente do modelo nunca libertava um pedido
O tamanho de cada pedido era só parte do problema. A memória também crescia chamada após chamada. No nosso ambiente de staging, o mesmo tipo de turno mantinha 369 MB de memória ativa mesmo depois de uma recolha de lixo completa — o ponto em que tudo o que é inalcançável já foi libertado. A amostragem do heap atribuiu todo o crescimento à parte da biblioteca cliente do modelo que codifica os corpos dos pedidos.
A causa era um listener de abort. Quando carrega em Parar, o sinal de abort do turno dispara, e cada pedido que o escuta é cancelado. Passávamos esse sinal único do turno a todos os pedidos. A biblioteca cliente acrescenta um listener de «abort» a qualquer sinal que receba — e nunca o remove.
Assim, cada pedido de um turno ficava alcançável a partir do sinal do turno, durante toda a duração do turno: o listener, que segurava o controlador do pedido, que segurava o pedido, que segurava o corpo inteiro do pedido com as suas imagens.
A correção: um sinal por pedido
Cada pedido tem agora o seu próprio sinal. Fica ligado ao sinal do turno apenas enquanto esse pedido está a correr — numa resposta em streaming, até o último bloco ser lido — e é desligado logo a seguir. Parar continua a chegar a qualquer pedido em curso, mas o sinal do turno passa a ter, no máximo, um listener de cada vez.
Os testes cobrem as três partes:
- Depois de 12 chamadas, não fica nenhum listener no sinal do turno. Antes da correção, havia 12.
- Ao longo de 15 pedidos de 8 MB cada, a memória fica estável. Antes, crescia 128 MB.
- Carregar em Parar continua a abortar um pedido a meio do seu streaming.
Causa 3: o rastreio guardava as entradas de cada chamada
Um snapshot do heap mostrou um terceiro detentor: cerca de 6 MB retidos por cada chamada ao modelo. Esta vinha do streaming de eventos da framework do agente. Ele regista cada chamada ao modelo dentro de um turno como filha do turno, incluindo as entradas, até o turno terminar. Cada imagem anexada a cada chamada ficava em memória com ela.
A correção: esvaziar as imagens quando a chamada devolve resultado
As imagens só são anexadas às mensagens construídas para uma chamada específica ao modelo. Nunca são guardadas no estado da conversa. Por isso, quando uma chamada devolve resultado, é seguro esvaziar os dados das imagens nessas mensagens. O rastreio mantém um registo em texto da chamada; os pixels desaparecem.
Um detalhe foi importante: as imagens têm de ser esvaziadas no próprio sítio. O rastreador pode alcançar os mesmos dados de imagem através de mais do que uma referência numa mensagem, por isso trocar por uma lista nova e vazia deixaria o original — pixels incluídos — ainda alcançável. O estado da conversa guardada nunca é modificado.
Um teste corre o grafo do agente com o mesmo streaming de eventos, com uma imagem de 4 MB em cada chamada. Sem a correção, a memória crescia 77 MB ao longo de 16 chamadas. Com ela, fica estável.
O que tirámos disto
- Os problemas de memória podem vir em camadas. Três causas separadas retinham imagens tempo demais, e todas tinham de ser corrigidas para o turno ficar dentro da memória.
- Qualquer coisa que entregue a uma biblioteca pode ser retida. Um sinal e um rastreio sobreviveram às chamadas para as quais foram criados. Quando a memória cresce por chamada, veja que objetos de longa duração a chamada toca.
- Não envie o que o outro lado descarta. Pixels que o modelo deitaria fora de qualquer forma custavam-nos memória em cada chamada.
- Teste a memória diretamente. Cada correção veio com um teste que mede o crescimento do heap ou o número de listeners, para que uma regressão apareça como um número, e não como um crash.
Esta é a segunda história de memória do agente em pouco tempo; a primeira foi um rótulo de progresso reenviado 41.000 vezes. Se quer saber mais sobre o que é o agente e como funciona, veja o nosso guia sobre agentes de IA e a documentação do chat.