Para validar a saída do Claude no Laravel, force a resposta por uma ferramenta com tool_choice, passe o input pelo Validator com as regras do seu domínio e, se falhar, devolva os erros num tool_result com is_error: true para o modelo corrigir. Limite o processo a duas ou três tentativas. Separe dois casos: erro de formato, que vale tentar de novo, e informação que falta no texto, que exige perguntar ao usuário.
Na prática, o JSON do LLM quase nunca quebra a sintaxe quando você usa tool calling. O que quebra é a regra de negócio: parcelamento em Pix, data de vencimento no passado, valor em reais quando o sistema espera centavos. O JSON Schema não pega isso. O Validator do Laravel pega, e o próprio modelo consegue corrigir se você mostrar o erro.
O que você vai ter funcionando no final
Um serviço StructuredExtractor que recebe um pedido em texto livre de um lojista, como "manda um link de 150 reais pro João Silva no cartão em 3x, vence sexta". Ele devolve:
completo: dados válidos e prontos para criar a cobrança.incompleto: dados válidos, mas faltou informação, e você sabe exatamente qual perguntar.falhou: o modelo não conseguiu produzir algo válido dentro do limite de tentativas.
O pacote inclui um comando Artisan para testar no terminal e testes com Http::fake() que simulam a correção.
Pré-requisitos:
- PHP 8.2+ e Laravel 11 ou 12.
- Uma chave da API da Anthropic (
ANTHROPIC_API_KEY). - Pest para os testes. Com PHPUnit funciona igual, muda só a sintaxe.
- Nenhum SDK. Vou chamar a Messages API direto com o
Http::do Laravel, porque assim você enxerga o JSON que vai e volta.
Por que validar a saída do LLM se eu já uso JSON Schema?
O input_schema da ferramenta garante a forma: tipos, campos obrigatórios, enums. Ele não expressa regras que dependem de mais de um campo ou do contexto. Três exemplos do domínio de cobrança:
| Regra | JSON Schema resolve? | Validator resolve? |
|---|---|---|
parcelas é inteiro |
Sim | Sim |
| Pix e boleto só aceitam 1 parcela | Não, na prática | Sim, com after() |
| Vencimento hoje ou depois | Não | Sim (after_or_equal:today) |
Campo nulo precisa constar em faltando |
Não | Sim |
| 150 reais virou 150 centavos | Não | Não, isso exige confirmação humana |
A última linha mostra o limite da técnica. A validação pega incoerência, mas não lê a mente do lojista. Para valores, a tela de confirmação continua obrigatória.
No post sobre extrair dados de comprovantes em PDF com saída estruturada eu tratei da extração em si. Aqui o foco é o que fazer quando a extração volta errada.
1. Configure a chave e o modelo
No .env:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 ANTHROPIC_TIMEOUT=30 APP_TIMEZONE=America/Sao_Paulo
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), 'timeout' => (int) env('ANTHROPIC_TIMEOUT', 30), ],
O APP_TIMEZONE importa mais do que parece. A regra after_or_equal:today usa o fuso da aplicação, e "vence amanhã" às 22h em São Paulo já é depois de amanhã em UTC.
2. Crie o cliente HTTP da Messages API
Aqui o retry cuida só de transporte: conexão caída, 429 e 5xx. Ele não tem nada a ver com o retry de validação que vem depois. Misturar os dois é a origem da maioria dos bugs que vejo nesse tipo de código.
<?php namespace App\Ai; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class ClaudeClient { public function messages(array $payload): array { return Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->acceptJson() ->timeout(config('services.anthropic.timeout')) ->retry(2, 1000, function ($e) { if ($e instanceof ConnectionException) { return true; } return $e instanceof RequestException && ($e->response->status() === 429 || $e->response->serverError()); }) ->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), ...$payload, ]) ->throw() ->json(); } }
Um 400 não entra no retry. Ele quase sempre indica payload malformado, e repetir a chamada só gasta tempo.
3. Defina a ferramenta e as regras no mesmo lugar
A ferramenta, chamada de tool na API, é a definição com nome, descrição e input_schema. Com tool_choice forçado, o modelo é obrigado a responder chamando essa ferramenta, então o resultado sempre chega como objeto estruturado em vez de texto solto.
O detalhe que mais faz diferença é o campo faltando. Ele dá ao modelo uma saída legítima para "não sei" e reduz bastante a tendência de inventar valor.
<?php namespace App\Ai\Schemas; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; class CobrancaSchema { public const TOOL = 'registrar_cobranca'; public const CAMPOS = ['valor_centavos', 'parcelas', 'metodo', 'cliente_nome', 'vencimento']; public static function tool(): array { return [ 'name' => self::TOOL, 'description' => 'Registra os dados de uma cobrança extraídos do pedido do lojista.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'valor_centavos' => [ 'type' => ['integer', 'null'], 'description' => 'Valor total em centavos. 150 reais = 15000. null se não informado.', ], 'parcelas' => ['type' => ['integer', 'null'], 'description' => 'De 1 a 12.'], 'metodo' => ['type' => ['string', 'null'], 'enum' => ['pix', 'boleto', 'cartao', null]], 'cliente_nome' => ['type' => ['string', 'null']], 'vencimento' => ['type' => ['string', 'null'], 'description' => 'Data no formato YYYY-MM-DD.'], 'descricao' => ['type' => ['string', 'null'], 'description' => 'Até 140 caracteres.'], 'faltando' => [ 'type' => 'array', 'items' => ['type' => 'string', 'enum' => self::CAMPOS], 'description' => 'Campos que o texto não informa. Todo campo null deve aparecer aqui.', ], ], 'required' => [...self::CAMPOS, 'descricao', 'faltando'], ], ]; } public static function rules(): array { return [ 'valor_centavos' => ['nullable', 'integer', 'min:100', 'max:10000000'], 'parcelas' => ['nullable', 'integer', 'between:1,12'], 'metodo' => ['nullable', Rule::in(['pix', 'boleto', 'cartao'])], 'cliente_nome' => ['nullable', 'string', 'max:120'], 'vencimento' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:today'], 'descricao' => ['nullable', 'string', 'max:140'], 'faltando' => ['present', 'array'], 'faltando.*' => [Rule::in(self::CAMPOS)], ]; } /** @return list<string> */ public static function validar(array $dados): array { $validator = Validator::make($dados, self::rules()); $validator->after(function ($v) use ($dados) { $metodo = $dados['metodo'] ?? null; $parcelas = $dados['parcelas'] ?? null; if (in_array($metodo, ['pix', 'boleto'], true) && is_int($parcelas) && $parcelas > 1) { $v->errors()->add('parcelas', 'Pix e boleto aceitam só 1 parcela. Parcelamento só no cartão.'); } $faltando = (array) ($dados['faltando'] ?? []); foreach (self::CAMPOS as $campo) { $nulo = ($dados[$campo] ?? null) === null; if ($nulo !== in_array($campo, $faltando, true)) { $v->errors()->add('faltando', "O campo {$campo} deve estar em faltando se, e somente se, for null."); } } }); return $validator->fails() ? $validator->errors()->all() : []; } }
Schema e regras ficam na mesma classe de propósito. Quando alguém adiciona um campo, os dois lados mudam juntos no mesmo diff.
4. Monte o loop de validação com feedback do erro
Esse é o coração do tutorial. Quando a validação falha, eu não mando a pergunta de novo do zero. Acrescento à conversa a resposta do assistente, com o bloco tool_use, e em seguida uma mensagem do usuário com um tool_result marcado como is_error: true, contendo os erros. O modelo vê o que mandou, entende o que estava errado e chama a ferramenta de novo.
Primeiro, o objeto de resultado:
<?php namespace App\Ai; final class ExtractionResult { private function __construct( public readonly string $status, public readonly array $dados, public readonly array $erros, public readonly int $tentativas, ) {} public static function ok(array $dados, int $tentativas): self { $status = empty($dados['faltando']) ? 'completo' : 'incompleto'; return new self($status, $dados, [], $tentativas); } public static function falhou(array $erros, int $tentativas): self { return new self('falhou', [], $erros, $tentativas); } }
Agora o extrator:
<?php namespace App\Ai; use App\Ai\Schemas\CobrancaSchema; use Illuminate\Support\Facades\Log; use RuntimeException; class StructuredExtractor { public function __construct( private ClaudeClient $claude, private int $maxTentativas = 3, ) {} public function extrair(string $texto): ExtractionResult { $messages = [['role' => 'user', 'content' => $texto]]; $erros = []; for ($tentativa = 1; $tentativa <= $this->maxTentativas; $tentativa++) { $response = $this->claude->messages([ 'max_tokens' => 1024, 'system' => $this->system(), 'tools' => [CobrancaSchema::tool()], 'tool_choice' => ['type' => 'tool', 'name' => CobrancaSchema::TOOL], 'messages' => $messages, ]); if (($response['stop_reason'] ?? null) === 'max_tokens') { throw new RuntimeException('Resposta truncada pelo max_tokens.'); } $toolUse = collect($response['content'] ?? [])->firstWhere('type', 'tool_use'); if (! $toolUse) { throw new RuntimeException('O modelo não chamou a ferramenta.'); } $dados = (array) $toolUse['input']; $erros = CobrancaSchema::validar($dados); if ($erros === []) { return ExtractionResult::ok($dados, $tentativa); } Log::warning('llm.validacao_falhou', [ 'tentativa' => $tentativa, 'erros' => $erros, 'usage' => $response['usage'] ?? null, ]); $messages[] = ['role' => 'assistant', 'content' => $response['content']]; $messages[] = ['role' => 'user', 'content' => [[ 'type' => 'tool_result', 'tool_use_id' => $toolUse['id'], 'is_error' => true, 'content' => "Os dados não passaram na validação. Corrija e chame a ferramenta de novo:\n- " . implode("\n- ", $erros), ]]]; } return ExtractionResult::falhou($erros, $this->maxTentativas); } private function system(): string { $hoje = now()->toDateString(); return <<<TXT Você extrai dados de pedidos de cobrança escritos por lojistas brasileiros. Hoje é {$hoje}. Converta datas relativas (amanhã, sexta) para YYYY-MM-DD. Nunca invente valores. Se o texto não informa um campo, use null e liste o campo em faltando. Valores sempre em centavos. TXT; } }
Três decisões aqui merecem explicação:
- A data de hoje vai no system prompt. Sem isso, o modelo não tem como saber o que é "sexta", e a regra
after_or_equal:todaypassa a reprovar por um motivo que ele não consegue corrigir. - O histórico cresce a cada tentativa. A terceira tentativa reenvia tudo: o pedido, as duas respostas e os dois erros. É por isso que o limite é baixo.
falhounão lança exceção. É um resultado esperado do negócio. Quem chama decide se vai mostrar um formulário manual ou abrir um ticket.
5. Separe erro de formato de informação que falta
Esse é o ponto que mais economiza dinheiro. Se o lojista escreveu "cobra o João" sem dizer valor, nenhum retry resolve. Insistir só empurra o modelo a inventar um número para passar na validação.
Por isso o campo faltando existe e a regra de consistência no after() obriga cada null a aparecer nele. O fluxo fica assim:
- O dado vem incoerente (Pix em 3x, data no passado,
nullsem estar emfaltando): retry com feedback. - O dado vem coerente, mas com campos em
faltando: statusincompleto, e você pergunta ao usuário só o que faltou. - Depois de N tentativas o dado continua incoerente: status
falhou, e o caso cai num fluxo manual.
No WhatsApp do Horalis ou no app da Granpag, o incompleto vira uma pergunta direta: "Qual o valor da cobrança?". Quando o usuário responde, você junta a resposta ao texto original e chama extrair() de novo.
6. Teste no terminal com um comando Artisan
Em routes/console.php:
<?php use App\Ai\StructuredExtractor; use Illuminate\Support\Facades\Artisan; Artisan::command('cobranca:extrair {texto}', function (StructuredExtractor $extractor) { $r = $extractor->extrair($this->argument('texto')); $this->info("Status: {$r->status} ({$r->tentativas} tentativa(s))"); $this->line(json_encode($r->dados ?: $r->erros, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); });
Rode:
php artisan cobranca:extrair "manda um pix de 150 reais pro João Silva em 3x, vence sexta"
Esse texto é bom para teste porque é contraditório: Pix em 3x. Você deve ver um llm.validacao_falhou no log. Depois disso, o modelo costuma fazer uma de duas coisas: troca para parcelas: 1 ou marca o caso como ambíguo. Se ele escolher trocar o método para cartão por conta própria, você descobriu uma instrução que faltava no system prompt, algo como "nunca troque o método escolhido pelo lojista". Testar textos contraditórios no terminal é o jeito mais rápido de achar essas lacunas.
7. Escreva os testes com Http::fake e sequência de respostas
O teste importante não é o caminho feliz. É provar que, quando a primeira resposta falha, a segunda requisição leva o tool_result com o erro. Http::sequence() devolve uma resposta diferente a cada chamada:
<?php use App\Ai\StructuredExtractor; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; function respostaTool(array $input): array { return [ 'id' => 'msg_'.Str::random(8), 'type' => 'message', 'role' => 'assistant', 'content' => [[ 'type' => 'tool_use', 'id' => 'toolu_'.Str::random(8), 'name' => 'registrar_cobranca', 'input' => $input, ]], 'stop_reason' => 'tool_use', 'usage' => ['input_tokens' => 600, 'output_tokens' => 90], ]; } it('devolve o erro ao modelo e aceita a correção', function () { $base = [ 'valor_centavos' => 15000, 'cliente_nome' => 'João Silva', 'vencimento' => now()->addDays(3)->toDateString(), 'descricao' => null, 'faltando' => [], ]; Http::fake([ 'api.anthropic.com/*' => Http::sequence() ->push(respostaTool([...$base, 'metodo' => 'pix', 'parcelas' => 3])) ->push(respostaTool([...$base, 'metodo' => 'pix', 'parcelas' => 1])), ]); $r = app(StructuredExtractor::class)->extrair('pix de 150 pro João em 3x'); expect($r->status)->toBe('completo') ->and($r->tentativas)->toBe(2) ->and($r->dados['parcelas'])->toBe(1); Http::assertSentCount(2); Http::assertSent(fn ($req) => count($req['messages']) === 3 && $req['messages'][2]['content'][0]['is_error'] === true); }); it('não tenta de novo quando só falta informação', function () { Http::fake([ 'api.anthropic.com/*' => Http::response(respostaTool([ 'valor_centavos' => null, 'parcelas' => 1, 'metodo' => 'pix', 'cliente_nome' => 'João', 'vencimento' => null, 'descricao' => null, 'faltando' => ['valor_centavos', 'vencimento'], ])), ]); $r = app(StructuredExtractor::class)->extrair('cobra o João no pix'); expect($r->status)->toBe('incompleto'); Http::assertSentCount(1); });
Esses testes rodam sem rede e em milissegundos. Eles cobrem o contrato do loop, não a qualidade do modelo. Para medir se o prompt acerta em textos reais, você precisa de evals, que eu detalhei em como testar código que usa LLM com Http::fake, respostas gravadas e evals.
Erros comuns
tool_resultsem otool_useantes. A API exige que todotool_resultvenha logo depois da mensagem do assistente que contém otool_usecom aqueleid. Se você mandar só a mensagem de erro, sem reenviar ocontentdo assistente, recebe um 400. Reenvie$response['content']inteiro como mensagemassistant.- Retry de validação dentro do retry de transporte. Se o job que chama o extrator também tem
$tries = 5, um timeout na terceira tentativa reinicia o loop do zero, e você paga por até 15 chamadas. Deixe o loop de validação idempotente e trate o job como explico em como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo. - Não calcular o custo das tentativas. Cada retry reenvia o histórico inteiro, então a terceira tentativa custa bem mais que a primeira. Monitore a taxa de
validacao_falhou. Se ela passar de alguns por cento, o problema está no prompt ou no schema, não no número de tentativas. A calculadora de custo de IA ajuda a estimar quanto um retry médio adiciona por mês. stop_reason: max_tokens. Se omax_tokensfor baixo, oinputda ferramenta chega cortado ou incompleto. Trate como erro de configuração, não como erro de validação, porque o modelo não consegue corrigir isso sozinho.- Fuso horário. Validação com
todayem UTC e system prompt com a data de São Paulo geram reprovações que o modelo nunca vai corrigir. Use a mesma fonte,now()comAPP_TIMEZONEconfigurado, nos dois lugares. tool_choiceforçado com extended thinking. A API não aceitatool_choicedo tipotoolouanycom o raciocínio estendido ligado e devolve 400. Se precisar de thinking, useautoe trate o caso em que o modelo responde em texto.- Acreditar que validação substitui confirmação. "150 reais" extraído como
150centavos passa em qualquer regra razoável de mínimo. Para dinheiro, mostre o valor formatado e peça confirmação antes de criar a cobrança.
Conclusão prática
Comece pelo schema com faltando e pela regra de consistência entre null e faltando. Só isso já elimina boa parte das alucinações de campo. Depois adicione as regras de negócio no after() do Validator, com mensagens escritas como se fossem para um colega, porque é o modelo que vai ler. Limite o loop a três tentativas, registre cada falha com os erros e o usage, e revise esse log toda semana. As mensagens que se repetem mostram exatamente qual linha do system prompt você precisa reescrever.
Perguntas frequentes
Por que não pedir JSON no texto e usar json_decode?
Porque você passa a lidar também com JSON inválido, texto antes do objeto e blocos de markdown. Com tool_choice forçado, a API entrega o input já como objeto, e sua validação cuida só do conteúdo.
Quantas tentativas devo permitir?
Duas ou três. Se o modelo não corrige com o erro explícito na segunda tentativa, raramente corrige na quinta, e o custo cresce a cada rodada porque o histórico é reenviado.
Isso funciona com a OpenAI?
Funciona. O conceito é o mesmo com function calling: você devolve o erro numa mensagem de papel tool referenciando o tool_call_id. O CobrancaSchema e o Validator continuam iguais, muda só o cliente HTTP.
Posso usar Form Request em vez de Validator::make?
Form Request é feito para requisições HTTP. Aqui os dados vêm do modelo, não do usuário. Use Validator::make com as regras numa classe própria, que você reaproveita tanto no extrator quanto nos testes.
Devo mandar a mensagem de erro em português ou inglês?
Tanto faz para o modelo. O que importa é a mensagem dizer o que está errado e qual é o valor aceito. "Pix aceita só 1 parcela" funciona muito melhor que "The parcelas field is invalid".