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."
)
),
]
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
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,
)
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}"}
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
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")
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 olimitque 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
"""
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..."""
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_v1diz. - 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)