O Crash por Falta de Memória Escondido numa Etiqueta de Progresso
Numa noite de finais de setembro, o agente de IA num espaço de trabalho de um utilizador caiu com um erro de falta de memória a meio de um turno. Os suspeitos óbvios — demasiados subagentes a correr ao mesmo tempo, ou um contexto que tinha crescido demasiado — estavam ambos errados. A causa real era uma etiqueta de estado: enquanto o modelo transmitia um comando de shell grande, poucos caracteres de cada vez, reenviávamos o comando inteiro até ao momento como etiqueta de progresso a cada pedaço. Um comando de 124 KB transformou-se em cerca de 41.000 eventos e em aproximadamente 690 MB de strings em fila.
Como o agente transmite o seu trabalho
Quando constróis no Mythex, o agente corre dentro da sandbox na cloud privada do teu projeto. À medida que o modelo escreve, a saída é transmitida de volta para o teu browser: o texto aparece palavra a palavra e, quando o agente usa uma ferramenta — por exemplo, um comando de shell que escreve um ficheiro — vês uma etiqueta para ela que se atualiza ao vivo, para saberes o que está a fazer.
O modelo transmite em pequenos pedaços. Com o modelo que estávamos a usar, esses pedaços tinham muitas vezes apenas três caracteres.
As primeiras hipóteses estavam erradas
O crash parecia um problema de escala, por isso começámos por aí.
- Subagentes em paralelo? O turno tinha usado apenas dois, cada um com cerca de 1,5 MB de estado. Não era suficiente.
- Traços retidos? O nosso rastreio mantinha uma árvore de execução em memória, mas não estava nem perto do tamanho da heap no momento do crash.
Ambas as histórias eram plausíveis, e ambas eram baratas de acreditar. Nenhuma reproduziu o crash. A lição que continuamos a reaprender: uma hipótese não é uma causa até reproduzir a falha.
Reproduzir a sério
Construímos uma sandbox descartável com os mesmos limites de memória da produção e um modelo falso que transmitia um ficheiro grande em pedaços realistas de três caracteres. Também tornámos lento o leitor do outro lado do socket, como uma rede real.
Funcionou. A heap subiu e o processo morreu com o mesmo erro.
O mecanismo era simples depois de o podermos observar. Enquanto o modelo escrevia um comando grande — neste caso um heredoc que escrevia um ficheiro de 124 KB —, o nosso código de streaming atualizava a etiqueta da ferramenta a cada pedaço. A etiqueta não era os três caracteres novos; era o comando inteiro até ao momento. Assim, os eventos cresciam: 3 caracteres, depois 6, depois 9, … até 124 KB, cerca de 41.000 vezes. O socket não conseguia enviá-los tão depressa como eram produzidos, por isso acumulavam-se no seu buffer de escrita como strings — cerca de 690 MB delas.
A correção
- Limitar a etiqueta. Uma etiqueta de estado só precisa de dizer o que está a acontecer. Agora está limitada a 200 caracteres, e limitamos a frequência com que voltamos a analisar o comando parcial.
- Acabar com a concatenação quadrática. Juntar pedaços transmitidos com concatenação repetida de strings é O(n²) para saídas grandes. Passámos para uma abordagem baseada em blocos que acrescenta em tempo linear.
Depois procurámos qualquer outra coisa que pudesse crescer sem limite com uma enxurrada de saída, e limitámo-la:
- A saída de comandos em direto mostrada no chat é agrupada e mantém apenas os últimos 8.000 caracteres.
- Cada linha de evento individual está limitada a 4 MB.
- O buffer de repetição que um browser usa para recuperar após reconectar está limitado a 8 MB por projeto.
- Os checkpoints antigos são eliminados à medida que um turno avança, mantendo o estado mais recente e as âncoras que turnos anteriores precisam.
Testar enxurradas com honestidade
Duas coisas tornaram isto mais difícil de testar do que deveria ser:
- Os modelos reais não colaboram. Se pedires a um modelo para imprimir um ficheiro gigantesco, normalmente ele recusa, resume ou escreve-o de forma mais sensata. Para testar enxurradas precisas de um modelo com um guião que faça exatamente o que lhe dizes, a correr pelo código real do agente.
- Os guiões têm de se comportar como modelos. O nosso primeiro modelo com guião reutilizava o mesmo ID de resposta em todas as respostas, por isso a framework do agente tratava cada resposta como substituição da anterior e terminava os turnos antes do tempo. Parecia um bug de atualização da interface. Não era — era o harness de teste a mentir-nos. Tivemos também de impor no guião o limite real de contexto do modelo, ou os números de memória não eram realistas.
O que tirámos disto
- As interfaces de streaming multiplicam pequenos erros. Qualquer coisa que envies "por pedaço" é enviada dezenas de milhares de vezes. Envia deltas, ou limita a carga.
- Limita todo o buffer que atravessa uma fronteira lenta. Sockets, registos de repetição e checkpoints precisam todos de um teto.
- Reproduz antes de corrigir. As nossas duas primeiras explicações soavam bem e estavam erradas.
Se viste recentemente um ficheiro muito longo aparecer no chat do Mythex sem que o agente parasse, é em parte por isto.