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:
- Qual era exatamente o histórico de mensagens que o modelo viu naquele momento?
- Qual ferramenta ele chamou, com quais argumentos, e o que ela devolveu?
- O modelo parou porque terminou (
end_turn) ou porque estourou o limite de saída (max_tokens)? - Qual versão do prompt estava ativa?
- 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 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.
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:
sequenceexplícito em vez de confiar nocreated_at. Dois spans podem cair no mesmo milissegundo, e a ordem é a informação mais importante de um trace.prompt_versionno 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 mostra de onde vem esse valor.provider_request_id. A API da Anthropic devolve o headerrequest-idem 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:
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:
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:
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:
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:
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:
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:
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:
- Acho o trace pela
conversation_ide leio os spans em ordem. - Identifico o primeiro span em que a decisão ficou errada.
- Rodo
php artisan llm:replay {id} --sequence=Nalgumas vezes para ver se o erro é consistente ou se foi variação do modelo. - Ajusto o prompt ou a descrição da ferramenta e rodo de novo com
--prompt-version. - 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. 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:
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 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.