# Como rastrear um agente de IA no Laravel: trace de cada tool call e replay da conversa

> Log comum não explica por que o agente de IA errou. Veja como gravar traces e spans de cada chamada ao Claude e de cada tool call no PostgreSQL, com redação de LGPD e replay.

Por Lucas Roberto · IA · publicado em 2026-10-20 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-rastrear-um-agente-de-ia-no-laravel-trace-de-cada-tool-call-e-replay-da-conversa

Para depurar um agente de IA em produção, você precisa de um **trace** por execução e de um **span** por passo: cada chamada ao LLM e cada tool call, com entrada, saída, `stop_reason`, tokens e latência. No Laravel, isso cabe em duas tabelas no PostgreSQL, um wrapper de poucas linhas em volta do loop do agente e um comando Artisan que reexecuta qualquer passo com o prompt atual. Com isso, a pergunta "por que o agente fez isso?" passa a ter resposta em minutos, sem depender de adivinhação.

## Por que log comum não basta para depurar um agente de IA?

Um agente de IA é um loop: o modelo recebe a conversa, decide chamar uma ferramenta, o seu código executa, devolve o resultado e o modelo decide de novo. Quando o cliente reclama no WhatsApp que o agente confirmou um horário que não existia, o `Log::info()` espalhado pelo código mostra fragmentos soltos e fora de ordem, misturados com as requisições de todo mundo.

As perguntas que você precisa responder são outras:

1. Qual era exatamente o histórico de mensagens que o modelo viu naquele momento?
2. Qual ferramenta ele chamou, com quais argumentos, e o que ela devolveu?
3. O modelo parou porque terminou (`end_turn`) ou porque estourou o limite de saída (`max_tokens`)?
4. Qual versão do prompt estava ativa?
5. Quantas voltas o loop deu até responder?

Nenhuma dessas respostas sai de um log de texto. Elas saem de dados estruturados, gravados em ordem e ligados por um identificador comum.

## Qual a diferença entre trace e span numa conversa com LLM?

**Trace** é o registro de uma execução completa do agente: uma mensagem do usuário entra, uma resposta final sai. **Span** é cada passo dentro desse trace, com início, fim e resultado próprios. Num agente com tool calling, há dois tipos de span:

| Tipo de span | O que grava | Por que importa |
|---|---|---|
| `llm` | payload enviado à API, resposta, `stop_reason`, tokens, `request-id` do provedor | mostra o que o modelo viu e o que decidiu |
| `tool` | nome da ferramenta, argumentos que o modelo gerou, resultado ou erro | mostra se o erro foi do modelo ou do seu código |

Essa separação é o que permite apontar culpados com precisão. Muitas vezes o modelo chamou a ferramenta certa com argumentos certos, e quem devolveu lixo foi a sua consulta. Outras vezes a ferramenta funcionou e o modelo ignorou o resultado. Sem spans separados, as duas situações parecem iguais.

Se você ainda não montou o loop do agente, o post sobre [como criar um agente de IA com tool calling do Claude no Laravel](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-para-agendamentos-no-whatsapp) cobre essa parte; aqui o foco é instrumentar esse loop.

## Como modelar as tabelas de trace no PostgreSQL?

Duas tabelas resolvem. Uso `jsonb` para entrada e saída porque o formato muda com o tempo (ferramentas novas, campos novos) e porque dá para consultar dentro do JSON quando preciso.

```php
Schema::create('llm_traces', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignId('tenant_id')->index();
    $table->string('conversation_id')->index();
    $table->string('prompt_version');
    $table->string('status')->default('running'); // running | ok | error | max_iterations
    $table->unsignedSmallInteger('iterations')->default(0);
    $table->timestamps();
});

Schema::create('llm_spans', function (Blueprint $table) {
    $table->id();
    $table->foreignUuid('trace_id')->constrained('llm_traces')->cascadeOnDelete();
    $table->unsignedSmallInteger('sequence');
    $table->string('type'); // llm | tool
    $table->string('name');
    $table->jsonb('input');
    $table->jsonb('output')->nullable();
    $table->string('stop_reason')->nullable();
    $table->string('provider_request_id')->nullable();
    $table->unsignedInteger('input_tokens')->nullable();
    $table->unsignedInteger('output_tokens')->nullable();
    $table->unsignedInteger('latency_ms');
    $table->text('error')->nullable();
    $table->timestamp('created_at')->useCurrent();
    $table->index(['trace_id', 'sequence']);
});
```

Três decisões que valem explicar:

1. **`sequence` explícito** em vez de confiar no `created_at`. Dois spans podem cair no mesmo milissegundo, e a ordem é a informação mais importante de um trace.
2. **`prompt_version` no trace**. Quando você muda o prompt e a taxa de erro sobe, essa coluna separa o antes do depois. Se você já versiona prompts, o post sobre [versionar prompts no Laravel com rollout gradual](/blog/como-versionar-prompts-no-laravel-arquivos-no-repositorio-rollout-gradual-e-rollback) mostra de onde vem esse valor.
3. **`provider_request_id`**. A API da Anthropic devolve o header `request-id` em toda resposta. Se você precisar abrir um chamado com o provedor, é o que eles vão pedir.

No model `LlmSpan`, declare `const UPDATED_AT = null;` e os casts `'input' => 'array'` e `'output' => 'array'`.

## Como registrar cada chamada ao Claude sem espalhar código pelo agente?

A tentação é colocar `LlmSpan::create()` antes e depois de cada chamada. Isso vira bagunça rápido e alguém esquece o `finally`. Prefiro um `Tracer` pequeno que recebe uma closure, mede o tempo e grava o span mesmo quando dá exceção:

```php
final class Tracer
{
    private int $sequence = 0;

    public function __construct(public readonly LlmTrace $trace) {}

    public function span(string $type, string $name, array $input, Closure $fn): mixed
    {
        $start = hrtime(true);

        $span = new LlmSpan([
            'trace_id' => $this->trace->id,
            'sequence' => ++$this->sequence,
            'type' => $type,
            'name' => $name,
            'input' => Redactor::clean($input),
        ]);

        try {
            $result = $fn($span);
            $span->output = Redactor::clean(is_array($result) ? $result : ['value' => $result]);

            return $result;
        } catch (Throwable $e) {
            $span->error = $e::class . ': ' . $e->getMessage();
            throw $e;
        } finally {
            $span->latency_ms = intdiv(hrtime(true) - $start, 1_000_000);
            $span->save();
        }
    }
}
```

A closure recebe o próprio span para preencher os campos que só existem depois da resposta, como tokens e `stop_reason`. O contador de `sequence` fica em memória porque um trace roda inteiro dentro de um único processo (um job na fila, no meu caso).

## Como rastrear as tool calls dentro do loop do agente?

Com o `Tracer` pronto, o loop do agente fica instrumentado sem perder legibilidade. A chamada é a API de Messages da Anthropic via `Http::` do Laravel:

```php
private const MAX_ITERATIONS = 6;

public function run(Tracer $tracer, array $payload): ?string
{
    for ($i = 1; $i <= self::MAX_ITERATIONS; $i++) {
        $response = $tracer->span('llm', 'messages', $payload, function (LlmSpan $span) use ($payload) {
            $res = Http::withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])->timeout(60)->post('https://api.anthropic.com/v1/messages', $payload)->throw();

            $span->provider_request_id = $res->header('request-id');
            $span->stop_reason = $res->json('stop_reason');
            $span->input_tokens = $res->json('usage.input_tokens');
            $span->output_tokens = $res->json('usage.output_tokens');

            return $res->json();
        });

        $tracer->trace->update(['iterations' => $i]);
        $payload['messages'][] = ['role' => 'assistant', 'content' => $response['content']];

        if ($response['stop_reason'] !== 'tool_use') {
            $tracer->trace->update(['status' => 'ok']);

            return collect($response['content'])->firstWhere('type', 'text')['text'] ?? null;
        }

        $results = [];
        foreach ($response['content'] as $block) {
            if ($block['type'] !== 'tool_use') {
                continue;
            }

            $results[] = $this->runTool($tracer, $block);
        }

        $payload['messages'][] = ['role' => 'user', 'content' => $results];
    }

    $tracer->trace->update(['status' => 'max_iterations']);

    return null;
}
```

E a execução da ferramenta, que transforma exceção em `tool_result` com `is_error`, para o modelo poder se recuperar:

```php
private function runTool(Tracer $tracer, array $block): array
{
    try {
        $output = $tracer->span('tool', $block['name'], $block['input'],
            fn () => $this->tools->run($block['name'], $block['input']));

        return ['type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => json_encode($output)];
    } catch (ToolException $e) {
        return ['type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => $e->getMessage(), 'is_error' => true];
    }
}
```

O limite de iterações não é detalhe. Sem ele, um modelo que entra em ciclo (chama a mesma ferramenta, recebe o mesmo erro, tenta de novo) queima tokens até alguém perceber. Com o status `max_iterations` gravado, esse ciclo vira uma métrica que você acompanha, não uma surpresa na fatura.

Um trade-off honesto: gravar o payload completo em cada span `llm` duplica o histórico a cada volta. Para conversas curtas de atendimento no WhatsApp, isso é aceitável e o PostgreSQL comprime valores grandes de `jsonb` automaticamente via TOAST. Se suas conversas são longas, grave só as mensagens novas desde o span anterior e reconstrua o histórico no replay. É mais código, mas o armazenamento para de crescer de forma quadrática.

## Como propagar o trace_id para filas e logs no Laravel?

O agente raramente roda sozinho. Uma ferramenta pode despachar um job para enviar a confirmação no WhatsApp, e esse job pode falhar dez minutos depois. Para ligar o erro ao trace, uso o facade `Context`, disponível a partir do Laravel 11:

```php
use Illuminate\Support\Facades\Context;

$trace = LlmTrace::create([...]);
Context::add('llm_trace_id', $trace->id);
```

Tudo que for adicionado ao `Context` entra automaticamente nos logs como metadado e é levado junto para os jobs despachados a partir dali. No job, `Context::get('llm_trace_id')` devolve o mesmo valor. Assim, uma busca pelo UUID no seu agregador de logs traz o trace inteiro, inclusive os efeitos colaterais assíncronos.

## O que não gravar nos traces: dados sensíveis e LGPD

Trace de agente é, por definição, uma cópia de conversas com clientes. Em fintech, isso inclui CPF, número de cartão, valores e nomes. Gravar tudo em texto puro numa tabela de debug que meio time consulta é exatamente o tipo de coisa que dá problema com a LGPD.

O `Redactor` usado no `Tracer` aplica máscaras antes de salvar:

```php
final class Redactor
{
    private const PATTERNS = [
        '/\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/' => '[CPF]',
        '/\b(?:\d[ -]?){13,19}\b/' => '[CARTAO]',
    ];

    public static function clean(array $data): array
    {
        array_walk_recursive($data, function (&$value) {
            if (is_string($value)) {
                $value = preg_replace(array_keys(self::PATTERNS), array_values(self::PATTERNS), $value);
            }
        });

        return $data;
    }
}
```

O padrão de cartão vai pegar falso positivo, como telefones com DDI e DDD. Prefiro errar para o lado de mascarar demais: o telefone já está na tabela de conversas, não preciso dele no trace. O outro trade-off é que o replay passa a usar dados mascarados. Na prática isso quase nunca atrapalha, porque o que você quer reproduzir é a decisão do modelo, não o CPF do cliente.

O segundo cuidado é retenção. Trace serve para depurar, não para arquivo. Uso `MassPrunable` no model e deixo o `model:prune` agendado:

```php
class LlmTrace extends Model
{
    use MassPrunable;

    public function prunable(): Builder
    {
        return static::where('created_at', '<', now()->subDays(30));
    }
}

// routes/console.php
Schedule::command('model:prune')->daily();
```

Como a FK dos spans tem `cascadeOnDelete`, o PostgreSQL apaga os filhos junto. A janela de retenção é decisão de negócio e jurídico; o importante é que ela exista e esteja no código.

## Como reproduzir uma conversa que deu errado?

Aqui o trace se paga. Como cada span `llm` guarda o payload exato enviado, dá para reenviar o mesmo passo, opcionalmente com outra versão do prompt, e comparar a decisão:

```php
class ReplayLlmTrace extends Command
{
    protected $signature = 'llm:replay {trace} {--sequence=} {--prompt-version=}';

    public function handle(ClaudeClient $claude): int
    {
        $span = LlmSpan::where('trace_id', $this->argument('trace'))
            ->where('type', 'llm')
            ->when($this->option('sequence'), fn ($q, $seq) => $q->where('sequence', $seq))
            ->orderByDesc('sequence')
            ->firstOrFail();

        $payload = $span->input;

        if ($version = $this->option('prompt-version')) {
            $payload['system'] = Prompts::system($version);
        }

        $replay = $claude->messages($payload);

        $this->table(['', 'Original', 'Replay'], [
            ['stop_reason', $span->stop_reason, $replay['stop_reason']],
            ['tools', $this->toolNames($span->output), $this->toolNames($replay)],
        ]);

        return self::SUCCESS;
    }

    private function toolNames(array $response): string
    {
        return collect($response['content'] ?? [])->where('type', 'tool_use')->pluck('name')->implode(', ') ?: '-';
    }
}
```

O fluxo que uso quando chega uma reclamação:

1. Acho o trace pela `conversation_id` e leio os spans em ordem.
2. Identifico o primeiro span em que a decisão ficou errada.
3. Rodo `php artisan llm:replay {id} --sequence=N` algumas vezes para ver se o erro é consistente ou se foi variação do modelo.
4. Ajusto o prompt ou a descrição da ferramenta e rodo de novo com `--prompt-version`.
5. Exporto aquele payload e a resposta correta como fixture de teste.

O passo 5 é o que impede o bug de voltar. O trace vira um caso de regressão com `Http::fake`, do jeito que descrevo no post sobre [como testar código que usa LLM no Laravel com respostas gravadas e evals](/blog/como-testar-codigo-que-usa-llm-no-laravel-httpfake-respostas-gravadas-e-evals). Conversas reais de produção são os melhores casos de eval que você vai ter.

## Quais métricas acompanhar nos traces do agente?

Com os dados estruturados, as métricas saem de SQL simples. As que olho toda semana:

| Métrica | Sinal de problema |
|---|---|
| % de traces com `max_iterations` | modelo em ciclo ou ferramenta devolvendo erro que ele não entende |
| % de spans `llm` com `stop_reason = max_tokens` | limite de saída baixo demais, resposta cortada no meio |
| taxa de erro por ferramenta | bug no seu código, não no modelo |
| p95 de latência por tipo de span | ferramenta lenta segurando a resposta no WhatsApp |
| média de iterações por trace | prompt ou descrição de ferramenta ambígua |

A consulta de saúde das ferramentas, com `percentile_cont` do PostgreSQL:

```sql
select name,
       count(*) as chamadas,
       percentile_cont(0.95) within group (order by latency_ms) as p95_ms,
       avg((error is not null)::int) as taxa_erro
from llm_spans
where type = 'tool'
  and created_at > now() - interval '7 days'
group by name
order by taxa_erro desc;
```

Os tokens por span também mostram quanto custa cada volta do loop. Um agente que dá cinco voltas onde poderia dar duas está pagando o histórico inteiro cinco vezes. Para transformar a média de tokens por trace em reais por mês, jogo os números na [calculadora de custo de IA](/ferramentas/calculadora-custo-ia) e comparo com e sem prompt caching.

## Fazer em casa ou usar uma ferramenta de observabilidade de LLM?

Existem ferramentas prontas para isso, como o Langfuse (open source, com opção self-hosted), e o OpenTelemetry tem convenções semânticas para IA generativa se você já exporta spans para algum backend. A comparação que faço:

| Critério | Tabelas próprias no PostgreSQL | Ferramenta dedicada |
|---|---|---|
| Controle sobre dados sensíveis | total, fica no seu banco | depende de self-host ou do contrato |
| Interface para navegar no trace | você constrói (ou usa SQL) | pronta |
| Cruzar com dados do domínio | um `join` com pedidos e agendamentos | exige exportar ou duplicar dados |
| Replay e fixtures | código seu, sob medida | varia por ferramenta |
| Custo de manutenção | migrations e um comando | mais um serviço para operar |

Para um time pequeno, com dados financeiros e LGPD na mesa, começar com as tabelas próprias faz sentido: o `join` entre o trace e o agendamento ou pagamento que o agente mexeu responde muita pergunta que uma ferramenta externa não vê. Se o volume crescer ou mais gente precisar navegar nos traces, migrar é fácil, porque o `Tracer` já centraliza tudo num ponto só.

## Conclusão: por onde começar amanhã

Se o seu agente já está em produção sem trace, faça nesta ordem: crie as duas tabelas, envolva a chamada à API e a execução das ferramentas no `Tracer`, adicione o limite de iterações e o `Redactor` antes de qualquer outra coisa. Isso é uma tarde de trabalho. O comando de replay e as métricas podem vir na semana seguinte, quando a primeira reclamação de cliente aparecer e você quiser ver exatamente o que o modelo viu. A regra que guio: nenhuma mudança de prompt sobe sem que eu tenha rodado o replay de pelo menos alguns traces reais que motivaram a mudança, e cada bug corrigido vira uma fixture. É isso que transforma o agente de uma caixa-preta em código que você consegue manter.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
