No primeiro amistoso da Agentic Football Cup contra outro time da Dati, o Um Prompt Um Sonho, os nossos agentes de IA perderam de 4 a 0. Naquele jogo, quem decidia cada jogada era o modelo. O problema não era só o tempo que ele levava para responder.
De 21 a 23 de outubro a Dati fez a Agentic Football Cup, com 14 times da empresa, em cima de um simulador de futebol da AWS. Cada time tinha cinco agentes de IA, um por jogador, rodando no Amazon Bedrock AgentCore, o serviço da AWS que hospeda agentes. A cada tick, uma rodada de 2 segundos, cada agente recebia o estado da partida e devolvia um comando, como correr, passar ou chutar.
Eu era a técnica do Claudinho FC e, durante o jogo, só falava com o time por um chat de texto livre, com mensagens que a gente chamava de gritos. Eu não estava sozinha no time, mas toda a parte técnica ficou comigo, com algumas sessões do Claude Code me ajudando. Como nos outros times havia gente com mais experiência que eu, o meu plano tinha duas partes, e a primeira era passar da fase de grupos.
A primeira versão: o modelo decidia tudo
Comecei pelo caminho que parecia natural, em que cada jogador tinha um prompt com a sua posição e as suas regras. A cada tick, o agente transformava o estado do jogo em texto, mandava para o Amazon Nova Lite e recebia de volta um comando em JSON. Contra os três times de treino que vinham prontos no simulador, isso até funcionava, mas no primeiro amistoso contra o Um Prompt Um Sonho levamos 4 a 0.
O problema mais visível era o tempo, porque mesmo no melhor caso os nossos agentes levavam uns 600 milissegundos, enquanto o Um Prompt Um Sonho respondia em uns 100. Os números vinham do portal da copa, o site onde cada time cadastrava os agentes, mandava jogar e via o relatório de cada partida. Quase todo esse tempo era a chamada ao modelo. Testei outros modelos da família Nova, mas nenhum era mais rápido que o Lite. Os 600 cabiam no tick de 2 segundos, e o servidor só parava de esperar um agente depois de 5. Ainda assim eu quis chegar perto dos 100 do time que tinha ganhado da gente, sem ter como saber se era isso que decidia o jogo.
Antes de chegar nesses 600, eu tropecei num problema que tem a ver com onde o agente roda. Cada jogador do Claudinho FC era um runtime no AgentCore, a unidade que recebe as chamadas e roda o código de um agente. Ali, o processo continua vivo de uma chamada para a outra. A biblioteca Strands, que montava a chamada ao modelo, guardava o histórico da conversa dentro desse processo. Como ele não morria entre os ticks, cada chamada acrescentava o estado novo ao histórico. No tick 60, o modelo já recebia 60 estados velhos. Zerar o histórico a cada tick foi um dos meus primeiros ajustes, depois que outro time mediu a resposta caindo de 1,5 para 0,7 segundo só com isso.
O outro problema era que o modelo não obedecia. Nos testes, o Nova Lite ignorou quatro regras do prompt. Chutou de longe demais, correu atrás da bola de um companheiro, mandou o zagueiro cruzar o meio-campo e pressionou fora da faixa do campo que cada jogador cobria. A resposta foi uma camada de sanidade, um código que confere cada comando do modelo antes de ele ir para o jogo. Se o modelo mandava o zagueiro passar do meio-campo, por exemplo, a sanidade trocava esse comando por outro calculado dentro da faixa dele.
Com a camada de sanidade, o código já decidia depois do modelo. Faltava fazer o agente responder sem esperar o modelo, e sem tirar o modelo do time.
A versão híbrida: o código joga, o modelo ouve
Antes de mexer na arquitetura, tentei deixar o modelo mais rápido. Tirei a biblioteca que montava a chamada e fixei o modelo numa região só, e a chamada caiu de 877 para 641 milissegundos no meu computador. Encurtar o prompt não mudou nada. No portal, o tempo continuava em uns 600 milissegundos, porque a chamada ao modelo dentro da AWS eu não conseguia cortar.
Então, na versão híbrida, o agente parou de esperar o modelo e quem decide a jogada passou a ser o código. Um arquivo de regras tem os cinco prompts traduzidos regra por regra, com os mesmos números. Se o prompt do meio-campo mandava chutar só de perto do gol, a regra em código usa a mesma distância. Como o código responde em menos de 2 milissegundos, o tempo no portal caiu para uns 100. Daí não dava para baixar, porque o resto era a rede e o próprio AgentCore. A desobediência também acabou, porque uma regra escrita em código é cumprida em todo tick.
O modelo continuou no time, com a única coisa que o código não sabe fazer, que é ler o que eu escrevo no chat. A cada tick, o agente chama o modelo em paralelo, com o estado do jogo e os gritos, e devolve na hora o comando do código, sem esperar. A resposta do modelo só é usada quando a chamada foi feita com um grito novo no chat. Nesse caso, ela assume o comando no tick seguinte, por dois ticks, uns 4 segundos. Depois o código volta, e a sanidade confere tudo, inclusive a resposta do modelo.
O chat ficou com o modelo porque é o único lugar onde o time recebe texto livre. Uma regra em código entende uma posição no campo, mas não entende uma frase que eu escrevo na hora. O custo é que o grito só muda o comando um tick depois, uns 2 segundos. Ele serve para uma situação que dura alguns ticks, como mudar a postura do time, mas para um lance chega tarde.
Esse jeito de chamar o modelo só funciona por causa de onde o agente roda. A chamada era feita num tick, e a resposta só era usada no tick seguinte, 2 segundos depois. Nesse meio-tempo, a chamada precisava continuar viva em algum lugar.
Cada tick chegava ao runtime como uma chamada separada, e numa função serverless comum, como o Lambda, nada garante que a chamada seguinte caia no mesmo processo. O processo também fica congelado entre uma chamada e outra, então a chamada ao modelo podia ficar parada ou se perder no caminho.
No AgentCore, quem resolve isso é a sessão, que ganha uma microVM própria, uma máquina virtual pequena e isolada. As chamadas com o mesmo id de sessão vão para a mesma microVM, e o processo Python continua vivo de um tick para o outro. Então a chamada ao modelo disparada num tick ainda está lá, rodando, quando o tick seguinte chega. É a mesma característica que tinha atrapalhado a primeira versão com o histórico do Strands, mas agora era ela que segurava a chamada ao modelo.
Eu não vi como o servidor da copa escolhia o id de sessão, mas o log mostra que ele reaproveitava o mesmo id. Num dos jogos, as sessões do Claude Code acharam no log a resposta do modelo a um grito sendo usada. Essa resposta só existe se a chamada disparada num tick foi encontrada no tick seguinte.
O código também lê errado
O código decide em cima do que lê no estado do jogo, e o pior caso em que ele leu errado foi o tamanho do campo. O código de exemplo da AWS supunha um campo de -55 a 55, mas nas posições da bola gravadas no log ela nunca passou de 6,5 para cada lado. Todo alvo que o time mandava estava fora do campo, e o motor do jogo empurrava o jogador até a grade, onde ele ficava se forçando. Eu vi o erro nos primeiros jogos e corrigi na primeira noite, com uma escala na entrada e na saída de cada agente. O 4 a 0 do primeiro amistoso também tinha sido com o campo errado, então aquele placar sozinho não mostra que o problema era o modelo.
Conferindo o log com as sessões do Claude Code, achei mais quatro erros de leitura, como o goleiro que corria para onde a bola estava 2 segundos antes. Cada correção virava um deploy no AgentCore, que levava alguns minutos para subir os cinco runtimes, e isso limitava o que dava para mudar entre dois jogos. A última correção subiu cerca de uma hora antes do primeiro jogo da fase de grupos.
Para o nosso time, quem decide a jogada é o código, e o modelo fica com o que o código não entende, que é o chat. Eu passei a decisão para o código por causa do tempo, mas não sei quanto o tempo pesou no placar. O que eu sei é que cada erro achado no log virava uma correção que valia em todo tick. Com o modelo, a correção era uma frase a mais no prompt, que o modelo podia ignorar.
A fase de grupos
A fase de grupos foi na quinta, dia 22, à tarde, e o time jogou com a versão híbrida e as cinco correções. Como o nosso grupo tinha três times, foram só dois jogos. Com uma derrota de 2 a 1 e uma vitória de 1 a 0, deu para passar.
Nas quartas de final, o adversário era o Um Prompt Um Sonho, o time de melhor campanha da copa. Ele tinha ganhado os três jogos da fase de grupos, liderava a tabela dos amistosos e já tinha ganhado da gente de 4 a 0 e de 5 a 1. Foi contra eles que a segunda parte do plano, que eu vinha preparando desde o primeiro dia, ia ser testada, e essa é a história do próximo texto.



