DEV Community

Caio Carvalho
Caio Carvalho

Posted on

O eval achou a frase que o modelo inventou, e o conserto foi na tool

Perguntei ao meu agente quem venceu o GP de São Paulo de 2024. A resposta veio redonda:

Max Verstappen venceu o GP de São Paulo de 2024. Largou da 17ª posição no grid, completou as 69 voltas da prova e somou 26 pontos pela vitória com a volta mais rápida pela Red Bull.

Tudo certo. Verstappen fez mesmo a volta mais rápida em Interlagos. O eval reprovou a resposta mesmo assim, com nota 0,67, e tinha razão: a tool que o agente chamou devolveu points: 26, mas não disse nada sobre volta mais rápida. Essa parte o modelo tirou da própria memória.

Este é o segundo projeto de uma trilha de estudo do Google ADK. No primeiro artigo, o agente não tinha tools, e o assunto foi o eval reprovando o agente certo. Aqui entram as tools, e a tese é: a parte difícil de uma tool para LLM não é a função, é o contrato. Quem lê esse contrato é um modelo, e um contrato com lacuna convida o modelo a completá-la. No caso acima, o conserto não foi no prompt. Foi na tool.

O agente se chama Pit Wall. Ele usa o ADK for Python 2.10 com Gemini Flash e consulta a Jolpica, a sucessora pública da API Ergast. São três tools:

Tool Para quê
get_race_results Classificação oficial de um GP
get_driver_standings Campeonato de pilotos, atual ou após uma rodada
compare_lap_times Ritmo por stint entre 1 e 4 pilotos

Onde mora o contrato de uma tool no ADK Python

A documentação do ADK diz que a docstring é a interface da tool com o modelo. É verdade, mas não é tudo. Gerei as declarações das tools com o ADK 2.10 e li o código que monta essas declarações. Três coisas mudaram como escrevi as tools.

1. A seção Args: da docstring não vira descrição de parâmetro. O ADK põe a docstring inteira na descrição da tool, mas os parâmetros saem no schema só com nome e tipo. Para cada argumento levar a própria descrição, o caminho é o Annotated com Field:

Season = Annotated[
    int,
    Field(description="Championship year, for example 2024.", ge=FIRST_CHAMPIONSHIP),
]
CircuitId = Annotated[
    str | None,
    Field(
        description=(
            "Ergast circuit id, for example interlagos, monza, silverstone or "
            "red_bull_ring. Provide either round or circuit_id, never both."
        )
    ),
]
Enter fullscreen mode Exit fullscreen mode

Com isso, a divisão ficou clara: a docstring diz quando usar a tool, e o Annotated diz como preencher cada argumento.

2. O schema da resposta não chega ao modelo pela Gemini API. O retorno tipado em Pydantic funciona e chega ao modelo como {"result": {...}}. Mas o ADK apaga o response_json_schema da declaração quando o backend não é a Vertex AI, e deixa o motivo num comentário:

# Add response schema only for VERTEX_AI
# Pending cleanup: remove this check once the Gemini API accepts
# response_json_schema.
if variant != GoogleLLMVariant.VERTEX_AI:
    declaration.response_json_schema = None
Enter fullscreen mode Exit fullscreen mode

Consequência prática: o que o modelo precisa saber sobre a saída também tem que estar na docstring. Por exemplo, que os campos de ritmo somem quando um stint não tem voltas limpas, ou que fastest_driver_id não vem quando a comparação é de todos os stints.

3. Tool é método, não função solta. O FunctionTool aceita métodos vinculados (bound methods), porque o self some da assinatura. Então o cliente HTTP entra pelo construtor, e o agente registra os métodos:

pit_wall = PitWallTools(source)
return LlmAgent(
    name=AGENT_NAME,
    model=settings.agent_model,
    instruction=INSTRUCTION,
    tools=[
        pit_wall.get_race_results,
        pit_wall.get_driver_standings,
        pit_wall.compare_lap_times,
    ],
    before_model_callback=LlmCallBudget(settings.max_llm_calls),
    on_tool_error_callback=report_tool_error,
)
Enter fullscreen mode Exit fullscreen mode

PitWallTools depende de um Protocol (RaceDataSource), não do cliente da Jolpica. Toda a lógica das tools é testada contra uma fonte em memória, sem rede, sem modelo e sem ADK.

Uma exceção numa tool derruba a execução

Esta foi a descoberta que mais mudou a arquitetura. Imagine o modelo chamando compare_lap_times sem informar o circuito: a tool lança um erro de argumento inválido. Eu esperava que esse erro voltasse ao modelo para ele se corrigir. No ADK Python, isso só acontece se um on_tool_error_callback tratar a exceção. Sem callback, o _caller.py faz raise tool_error, e a execução inteira morre.

A regra da trilha proíbe except Exception dentro de tools, porque isso desliga retry, telemetria e human-in-the-loop. Então a tool lança, e um callback no agente decide o que o modelo pode consertar:

RECOVERABLE_ERRORS = (InvalidToolArgsError, F1Error, JolpicaError, httpx.HTTPError)


def report_tool_error(
    tool: BaseTool,
    args: dict[str, Any],
    tool_context: ToolContext,
    error: Exception,
) -> dict[str, str] | None:
    if not isinstance(error, RECOVERABLE_ERRORS):
        return None
    return {"error": f"{type(error).__name__}: {error}"}
Enter fullscreen mode Exit fullscreen mode

Um argumento faltando, uma corrida que não existe ou a Jolpica fora do ar viram uma resposta de função que o modelo explica ao usuário. Um KeyError, que seria um bug nosso, continua derrubando a execução, que é o que queremos. Dois testes sobem o runner real do ADK com um modelo roteirizado, que chama uma tool que falha, e provam os dois caminhos.

Um detalhe do asyncio entra aqui. Os pilotos de compare_lap_times são buscados com TaskGroup, que embrulha qualquer falha num ExceptionGroup. O callback não reconheceria o grupo, então a tool desembrulha e relança o erro original:

try:
    async with asyncio.TaskGroup() as group:
        tasks = [
            group.create_task(self._driver_stints(season, round_, driver_id))
            for driver_id in driver_ids
        ]
except ExceptionGroup as failures:
    raise failures.exceptions[0] from failures
Enter fullscreen mode Exit fullscreen mode

O schema não diz tudo

"Informe round ou circuit_id, nunca os dois" não se expressa no JSON Schema que o ADK gera. Essa regra fica na tool, com uma mensagem escrita para o modelo:

if (round_ is None) == (not circuit):
    raise InvalidToolArgsError("provide exactly one of round or circuit_id")
Enter fullscreen mode Exit fullscreen mode

Pelo callback acima, essa mensagem chega ao modelo, e ele tenta de novo com os argumentos certos. Um invalid input genérico não ensinaria nada.

Os dados mentem de jeitos específicos

Uma tool é tão confiável quanto os dados que ela resume. Três pegadinhas da Jolpica só apareceram olhando os payloads reais de Interlagos 2024:

  • A paginação conta registros, não voltas. O endpoint de voltas da corrida reporta total: 1133, uma linha por piloto por volta, com no máximo 100 por página. Sem paginar direito, a tool devolveria as primeiras voltas sem erro nenhum. As voltas são buscadas por piloto (cerca de 70 linhas), e o loop segue o limit que a API devolve, não o que foi pedido.
  • Bandeira vermelha conta como pit stop. O Norris tem duas paradas registradas: na volta 28 (24,7 s) e na volta 32 (23:35.363). A segunda é a bandeira vermelha. A divisão em stints continua certa, porque os carros trocaram pneus parados, e a docstring avisa o modelo.
  • Não há marcação de safety car. Uma volta atrás do safety car destrói uma média. Por isso quem decide o mais rápido é a mediana das voltas limpas.

A mediana trouxe uma surpresa. O stint final do Verstappen tem número par de voltas limpas, e a mediana cai exatamente no meio: 1:22.2705. O round() do Python usa arredondamento bancário (banker's rounding), que leva o meio exato ao par mais próximo, e mostrou 1:22.270. A versão anterior deste projeto mostrava .271. Nenhum dos dois está errado, mas um número citado num eval não pode mudar de acordo com a linguagem. A conversão agora usa aritmética inteira sobre microssegundos, com meio arredondado para cima.

Tool performance em Python é async

A página de performance de tools do ADK é direta: o framework roda em paralelo as tools pedidas na mesma resposta, mas uma tool síncrona bloqueia as outras. Todas as tools do Pit Wall são async, o cliente é um httpx.AsyncClient injetado, e um único token bucket do aiolimiter (4 requisições por segundo) é compartilhado por todas as chamadas. Não importa quantas tools o modelo dispare ao mesmo tempo: a rajada para a Jolpica fica dentro do limite. Um teste dispara três endpoints em paralelo e confere que o limiter foi acionado três vezes.

O limite de chamadas que existe, mas lança exceção

A regra da trilha pede um teto explícito de chamadas ao LLM por invocação. O ADK Python tem isso: RunConfig.max_llm_calls, ajustável pela variável ADK_MAX_LLM_CALLS. Lendo o código, porém, vi que ao estourar ele lança LlmCallsLimitExceededError, e o usuário receberia um erro cru.

A solução foram duas travas. Um before_model_callback conta as chamadas e responde no lugar do modelo quando o orçamento acaba. O limite do ADK fica um pouco acima, como trava de segurança. O contador mora no state com prefixo temp:, e o próprio código do ADK documenta por que esse é o escopo certo:

def _apply_temp_state(self, session: Session, event: Event) -> None:
    """Applies temp-scoped state delta to the in-memory session state.

    Temp state is ephemeral: it lives in the session's in-memory state for
    the duration of the current invocation but is NOT persisted to storage
    """
Enter fullscreen mode Exit fullscreen mode

O Settings recusa um orçamento que não seja menor que o limite do ADK, porque nesse caso o ADK estouraria antes de o orçamento responder. E, para não depender só da leitura do código, um teste sobe o runner com um modelo falso que pede tool para sempre. Com orçamento 3, a primeira invocação para em exatamente 3 chamadas, e a segunda, na mesma sessão, ganha mais 3: o contador zerou.

O eval que apontou a tool

O eval tem dois conjuntos de casos, e o motivo também está no código do ADK. Os critérios valem para o conjunto inteiro, não por caso. Com IN_ORDER, uma trajetória esperada vazia aprova qualquer trajetória (a função retorna True quando a lista esperada está vazia). Então não há como cobrar "não chame nenhuma tool" no mesmo conjunto dos casos com tool.

Conjunto Casos Critérios
pit_wall vencedor, classificação, ritmo no stint final trajetória IN_ORDER com ignore_args, final_response_match_v2, hallucinations_v1
pit_wall_guardrails temporada faltando, pergunta fora do escopo trajetória EXACT vazia, final_response_match_v2

O critério que importa aqui é o hallucinations_v1. Ele divide a resposta em frases e pergunta a um juiz se cada frase tem apoio na conversa. A evidência inclui as respostas das tools e as declarações das tools. A nota é a fração de frases com apoio.

Na primeira rodada, quatro casos passaram com nota máxima, e o race_winner levou 0,67 no hallucinations_v1: o juiz achou apoio para duas das três frases. A frase sem apoio era a do começo deste artigo:

Largou da 17ª posição no grid, completou as 69 voltas da prova e somou 26 pontos pela vitória com a volta mais rápida pela Red Bull.

Vale olhar as outras duas métricas do mesmo caso, na mesma rodada: trajetória 1,0 e final_response_match_v2 1,0. A tool certa foi chamada, e a resposta batia com a referência. Um eval só com esses dois critérios teria aprovado a frase, porque nenhum deles pergunta de onde veio cada afirmação.

A causa estava na saída da tool

O primeiro reflexo seria reforçar a instrução, mas ela já dizia: "todo número que você citar precisa vir de uma tool. Nunca responda de memória". Instrução não é garantia.

Olhando a saída da tool, a causa ficou clara. O get_race_results devolvia points: 26 para o vencedor, e uma vitória vale 25. O número era inexplicável com o que a tool entregava, e o modelo explicou com o que sabia. A informação existia: o payload da Jolpica traz um bloco FastestLap com o rank de cada piloto. O DTO é que descartava.

O conserto entrou no contrato da tool. O resultado passou a carregar a volta mais rápida de cada piloto, e a docstring passou a explicar a regra, com data de validade:

"""...fastest_lap.rank 1 marks the fastest lap of the race; from 2019 to
2024 that driver scored one extra point when finishing in the top 10.
fastest_lap is omitted when the archive has no timing for the driver..."""
Enter fullscreen mode Exit fullscreen mode

A data importa: o ponto extra pela volta mais rápida foi abolido a partir de 2025. Sem ela, o modelo poderia aplicar a regra a uma temporada em que ela não existe. E, como o juiz também lê as declarações das tools, a regra na docstring conta como evidência.

Nas duas rodadas seguintes, o race_winner deu 1,0, e os cinco casos passaram. A frase continua lá, agora com apoio:

Largando da 17ª posição no grid, completou as 69 voltas em 1º lugar, marcou a volta mais rápida da prova com 1:20.472 na volta 67 e somou 26 pontos pela Red Bull.

O modelo continua querendo explicar os 26 pontos. A diferença é que agora explica com o que a tool deu, até o tempo e o número da volta. O comportamento mudou sem uma linha nova no prompt.

O que eu levo para os próximos módulos

  • Uma saída que não explica os próprios números convida o modelo a completar. Antes de mexer no prompt por causa de uma alucinação, olhe o que a tool devolveu. Às vezes falta um campo, não uma regra.
  • O contrato está espalhado pelo framework. No ADK Python, ele mora em quatro lugares: a docstring, o Annotated, a docstring de novo (para a saída, que a Gemini API não recebe) e o callback de erro. Nenhum desses detalhes está na documentação; todos estão no código.
  • Um eval mede o que você mandou medir. Trajetória e termos certos não dizem de onde veio cada frase. O hallucinations_v1 diz.
  • Prove o comportamento através do framework. O orçamento de chamadas e o tratamento de erro têm testes que sobem o runner real com modelos roteirizados, sem rede e sem chave. É o que separa "li no código" de "funciona".

Código

O projeto completo, com o domínio, o cliente, as tools, os callbacks e os dois conjuntos de eval, está em github.com/carvalhocaio/adk-01-pit-wall. O make test roda sem rede e sem chave de API. O make eval roda contra o Gemini e a Jolpica de verdade.

Top comments (0)