Para rotear chamadas entre modelos do Claude no Laravel, use uma camada própria que decide antes de cada chamada. Essa camada aplica primeiro regras baratas e determinísticas, como o tipo de tarefa e o tamanho da entrada. Só quando as regras não resolvem, ela pede uma triagem rápida ao Haiku. Depois que o modelo barato responde, uma validação decide se a resposta serve ou se o pedido sobe para o Sonnet.
No fim deste tutorial você vai ter:
- um
ModelRouterque escolhe entre um modelo rápido e um forte; - um serviço que tenta o modelo barato, valida a saída e escala quando precisa;
- uma tabela que registra cada decisão, para você medir se o roteamento está economizando de verdade.
Esse padrão nasceu de um problema comum em atendimento por WhatsApp e em suporte de fintech. A maior parte das mensagens é trivial ("que horas abre?", "qual a taxa do débito?"). Uma minoria exige raciocínio de verdade. Mandar tudo para o modelo mais caro desperdiça dinheiro. Mandar tudo para o mais barato gera resposta ruim justamente nos casos que mais importam.
Pré-requisitos:
- PHP 8.2+ e Laravel 11 ou 12
- PostgreSQL (o SQL de métricas usa
FILTER, mas a lógica funciona em qualquer banco) - Uma chave da API da Anthropic (
ANTHROPIC_API_KEY) - Nenhum SDK: a API é chamada com o
Http::do Laravel, então você controla payload, timeout e log
O que é roteamento de modelos e quando vale a pena?
Roteamento de modelos é decidir, a cada chamada, qual LLM vai atender aquele pedido específico. A alternativa é fixar um modelo para a aplicação inteira. Cascata (ou escalada) é a variação em que você tenta o modelo barato primeiro e só chama o caro quando a resposta do barato não passa numa verificação.
Vale a pena quando três coisas são verdade:
- O volume é alto o suficiente para a diferença de preço entre modelos aparecer na fatura.
- A distribuição é desigual: muitos pedidos fáceis e poucos difíceis.
- Você consegue verificar automaticamente se uma resposta é aceitável, pelo menos em parte dos casos.
Se o seu volume é baixo, ou se quase todo pedido é complexo, fique com um modelo só. O roteador adiciona latência e código, e precisa se pagar. Para estimar se compensa, simule os dois cenários na calculadora de custo de IA com o seu volume mensal.
1. Como configurar os modelos e as regras no .env?
Comece separando o nível do nome do modelo. O código fala em rapido e forte, e o .env diz qual modelo é cada um. Quando sair uma versão nova, você troca uma variável e não mexe em classe nenhuma.
ANTHROPIC_API_KEY=sk-ant-... LLM_MODEL_RAPIDO=claude-haiku-4-5 LLM_MODEL_FORTE=claude-sonnet-4-5 LLM_TIMEOUT=60
<?php // config/llm.php return [ 'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'version' => '2023-06-01', 'timeout' => (int) env('LLM_TIMEOUT', 60), ], 'models' => [ 'rapido' => env('LLM_MODEL_RAPIDO', 'claude-haiku-4-5'), 'forte' => env('LLM_MODEL_FORTE', 'claude-sonnet-4-5'), ], // Tarefa => nível fixo, ou 'auto' para passar pela triagem 'tarefas' => [ 'classificar_intencao' => 'rapido', 'resumo_conversa' => 'rapido', 'analise_contestacao' => 'forte', 'atendimento' => 'auto', ], 'router' => [ 'max_chars_rapido' => 4000, 'confianca_minima' => 0.7, ], ];
O mapa tarefas é a regra mais barata que existe, porque custa zero token. Muita equipe pula direto para "um LLM decide qual LLM usar", mas você já sabe de antemão que classificar intenção é tarefa de modelo pequeno. Você também já sabe que analisar uma contestação de chargeback (quando o portador do cartão contesta a compra junto ao banco) com histórico de transações não é.
2. Como chamar a API do Claude com o Http:: do Laravel?
Um cliente fino que devolve o JSON da API e mede a latência:
<?php namespace App\Llm; use Illuminate\Support\Facades\Http; final class ClaudeClient { public function messages(array $payload): array { $inicio = hrtime(true); $json = Http::withHeaders([ 'x-api-key' => config('llm.anthropic.key'), 'anthropic-version' => config('llm.anthropic.version'), ]) ->connectTimeout(10) ->timeout(config('llm.anthropic.timeout')) ->post('https://api.anthropic.com/v1/messages', $payload) ->throw() ->json(); $json['_latencia_ms'] = (int) ((hrtime(true) - $inicio) / 1_000_000); return $json; } public static function texto(array $resposta): string { return collect($resposta['content'] ?? []) ->where('type', 'text') ->pluck('text') ->implode(''); } }
Retry, backoff para 429/529 e execução em fila ficaram de fora de propósito. Esse assunto está detalhado em como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo. Aqui o foco é a decisão de qual modelo chamar.
3. Como fazer a triagem com Haiku usando tool calling?
Quando a tarefa é auto, o roteador pergunta ao modelo rápido se o pedido é simples ou complexo. Para garantir uma saída estruturada e parseável, uso tool calling com tool_choice forçado. Tool calling é o recurso em que o modelo devolve uma chamada de função com argumentos em JSON, validados contra um schema. Forçar a ferramenta faz o modelo sempre responder com o objeto, nunca com prosa.
<?php namespace App\Llm; final class Triagem { public function __construct(private ClaudeClient $claude) {} /** @return array{complexidade: string, confianca: float, motivo: string} */ public function classificar(string $texto): array { $resposta = $this->claude->messages([ 'model' => config('llm.models.rapido'), 'max_tokens' => 200, 'system' => 'Você classifica pedidos de clientes de um sistema de agendamento e pagamentos. ' . 'Simples: dúvida factual, saudação, consulta direta. ' . 'Complexa: várias etapas, cálculo, reclamação, comparação, ambiguidade.', 'tools' => [[ 'name' => 'classificar_pedido', 'description' => 'Registra a complexidade do pedido.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'complexidade' => ['type' => 'string', 'enum' => ['simples', 'complexa']], 'confianca' => ['type' => 'number', 'description' => 'Entre 0 e 1'], 'motivo' => ['type' => 'string'], ], 'required' => ['complexidade', 'confianca', 'motivo'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'classificar_pedido'], 'messages' => [['role' => 'user', 'content' => $texto]], ]); $input = collect($resposta['content'])->firstWhere('type', 'tool_use')['input'] ?? []; return [ 'complexidade' => in_array($input['complexidade'] ?? null, ['simples', 'complexa'], true) ? $input['complexidade'] : 'complexa', 'confianca' => (float) ($input['confianca'] ?? 0), 'motivo' => (string) ($input['motivo'] ?? 'sem motivo'), ]; } }
Repare no default: se a triagem vier quebrada, o pedido é tratado como complexa. Errar para o lado caro custa alguns centavos. Errar para o lado barato custa uma resposta ruim para um cliente que já está com problema.
Um aviso: a confianca que o modelo informa é um sinal fraco. LLMs costumam se declarar confiantes demais. Uso esse número só como corte grosso (abaixo de 0.7 vai para o forte) e não como probabilidade calibrada.
4. Como montar o ModelRouter com regras antes da IA?
O roteador aplica as regras em ordem, da mais barata para a mais cara, e devolve a decisão junto com o motivo. O motivo vai para o log, e é ele que você consulta quando alguém perguntar por que tal mensagem foi para o Sonnet.
<?php namespace App\Llm; final class ModelRouter { public function __construct(private Triagem $triagem) {} /** @return array{nivel: string, motivo: string} */ public function escolher(string $tarefa, string $texto): array { $regra = config("llm.tarefas.{$tarefa}", 'auto'); // 1. Tarefa com nível fixo if ($regra !== 'auto') { return ['nivel' => $regra, 'motivo' => "regra fixa da tarefa {$tarefa}"]; } // 2. Entrada longa demais vai direto para o forte if (mb_strlen($texto) > config('llm.router.max_chars_rapido')) { return ['nivel' => 'forte', 'motivo' => 'entrada longa']; } // 3. Triagem com o modelo rápido $t = $this->triagem->classificar($texto); if ($t['complexidade'] === 'simples' && $t['confianca'] >= config('llm.router.confianca_minima')) { return ['nivel' => 'rapido', 'motivo' => "triagem: {$t['motivo']}"]; } return ['nivel' => 'forte', 'motivo' => "triagem: {$t['motivo']} ({$t['confianca']})"]; } }
Você pode acrescentar regras entre o passo 2 e o 3 sem tocar no resto. Exemplos: cliente que já foi escalado nesta conversa continua no forte; mensagens com palavras como "estorno", "contestação" ou "cobrado duas vezes" vão direto para o forte. Regra de palavra-chave parece rudimentar, mas é previsível e não custa nada.
5. Como escalar para o Sonnet quando a resposta do Haiku não serve?
A triagem decide antes. A escalada corrige depois. O serviço abaixo chama o nível escolhido, valida a resposta e, se ela vier do rápido e falhar na validação, refaz a chamada no forte uma única vez.
O que conta como falha:
stop_reasonigual amax_tokens, ou seja, a resposta foi cortada no meio.- O próprio modelo pediu para escalar. O system prompt instrui o modelo a responder só
[ESCALAR]quando não conseguir atender com segurança. - Um validador específico da tarefa, opcional: JSON que não decodifica, campo obrigatório ausente, valor fora do esperado.
<?php namespace App\Llm; use App\Models\LlmRoutingLog; final class LlmService { private const INSTRUCAO_ESCALADA = "\n\nSe você não tiver certeza ou o pedido exigir análise que você não consegue fazer com segurança, responda apenas: [ESCALAR]"; public function __construct( private ClaudeClient $claude, private ModelRouter $router, ) {} public function responder(string $tarefa, string $system, string $texto, ?callable $validar = null): string { $decisao = $this->router->escolher($tarefa, $texto); $resposta = $this->chamar($decisao['nivel'], $system, $texto); $falha = $this->falha($resposta, $validar); $this->registrar($tarefa, $decisao['nivel'], $decisao['motivo'], false, $resposta); if ($falha !== null && $decisao['nivel'] === 'rapido') { $resposta = $this->chamar('forte', $system, $texto); $this->registrar($tarefa, 'forte', "escalada: {$falha}", true, $resposta); } return ClaudeClient::texto($resposta); } private function chamar(string $nivel, string $system, string $texto): array { return $this->claude->messages([ 'model' => config("llm.models.{$nivel}"), 'max_tokens' => $nivel === 'rapido' ? 800 : 2000, 'system' => $nivel === 'rapido' ? $system . self::INSTRUCAO_ESCALADA : $system, 'messages' => [['role' => 'user', 'content' => $texto]], ]); } private function falha(array $resposta, ?callable $validar): ?string { $texto = trim(ClaudeClient::texto($resposta)); return match (true) { ($resposta['stop_reason'] ?? null) === 'max_tokens' => 'resposta truncada', str_contains($texto, '[ESCALAR]') => 'modelo pediu escalada', $validar !== null && ! $validar($texto) => 'validador da tarefa', default => null, }; } private function registrar(string $tarefa, string $nivel, string $motivo, bool $escalado, array $r): void { LlmRoutingLog::create([ 'tarefa' => $tarefa, 'modelo' => $r['model'] ?? config("llm.models.{$nivel}"), 'nivel' => $nivel, 'motivo' => mb_substr($motivo, 0, 255), 'escalado' => $escalado, 'input_tokens' => $r['usage']['input_tokens'] ?? 0, 'output_tokens' => $r['usage']['output_tokens'] ?? 0, 'latencia_ms' => $r['_latencia_ms'] ?? 0, ]); } }
Dois detalhes importam aqui. Primeiro, a instrução [ESCALAR] só entra no prompt do modelo rápido. O forte é a última linha e precisa responder de qualquer jeito. Segundo, a escalada acontece no máximo uma vez, sem loop. Se o forte também falhar, o problema não é de roteamento e precisa aparecer no log, não ser escondido por mais tentativas.
Num agente com várias ferramentas, a mesma ideia se aplica ao loop inteiro, e não a uma única chamada. Mostro a estrutura desse loop em como criar um agente de IA com tool calling do Claude no Laravel.
6. Como registrar cada decisão de roteamento no PostgreSQL?
php artisan make:model LlmRoutingLog -m
<?php // database/migrations/xxxx_create_llm_routing_logs_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('llm_routing_logs', function (Blueprint $table) { $table->id(); $table->string('tarefa', 60)->index(); $table->string('modelo', 80); $table->string('nivel', 10); $table->string('motivo'); $table->boolean('escalado')->default(false); $table->unsignedInteger('input_tokens'); $table->unsignedInteger('output_tokens'); $table->unsignedInteger('latencia_ms'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('llm_routing_logs'); } };
// app/Models/LlmRoutingLog.php protected $guarded = []; protected $casts = ['escalado' => 'boolean'];
Registro o model que a API devolve, e não só o que eu pedi, porque é ele que aparece na fatura. Se você já tem medição de tokens por tenant, ligue esta tabela à mesma estrutura. O desenho completo está em como medir tokens e limitar o custo de IA por cliente no Laravel.
7. Como testar o roteador no terminal e com Http::fake?
Um comando Artisan para experimentar com mensagens reais:
<?php namespace App\Console\Commands; use App\Llm\LlmService; use Illuminate\Console\Command; class RotearLlm extends Command { protected $signature = 'llm:rotear {texto} {--tarefa=atendimento}'; protected $description = 'Testa o roteamento entre modelos'; public function handle(LlmService $llm): int { $resposta = $llm->responder( $this->option('tarefa'), 'Você é o assistente de atendimento de um estabelecimento. Responda em português, de forma curta.', $this->argument('texto'), ); $this->line($resposta); return self::SUCCESS; } }
php artisan llm:rotear "Vocês abrem sábado?" php artisan llm:rotear "Fui cobrado duas vezes na mesma compra e o estorno não apareceu"
Depois de cada execução, confira a tabela llm_routing_logs. No teste automatizado, use uma sequência de respostas falsas para provar que a escalada acontece:
use App\Llm\LlmService; use App\Models\LlmRoutingLog; use Illuminate\Support\Facades\Http; it('escala para o modelo forte quando o rápido pede', function () { config(['llm.tarefas.teste' => 'rapido']); Http::fake([ 'api.anthropic.com/*' => Http::sequence() ->push([ 'model' => 'claude-haiku-4-5', 'content' => [['type' => 'text', 'text' => '[ESCALAR]']], 'stop_reason' => 'end_turn', 'usage' => ['input_tokens' => 50, 'output_tokens' => 3], ]) ->push([ 'model' => 'claude-sonnet-4-5', 'content' => [['type' => 'text', 'text' => 'Resposta completa']], 'stop_reason' => 'end_turn', 'usage' => ['input_tokens' => 50, 'output_tokens' => 40], ]), ]); $texto = app(LlmService::class)->responder('teste', 'system', 'pergunta difícil'); expect($texto)->toBe('Resposta completa') ->and(LlmRoutingLog::where('escalado', true)->count())->toBe(1); });
Para avaliar a qualidade da triagem com um conjunto de mensagens reais, e não só o fluxo, vale montar evals como descrevo em como testar código que usa LLM no Laravel.
Como saber se o roteamento está economizando?
A métrica que importa é a taxa de escalada por tarefa. Se ela for alta, você paga a chamada rápida e a forte na maior parte dos pedidos, e o roteador sai mais caro que usar o forte direto.
SELECT tarefa, count(*) FILTER (WHERE nivel = 'rapido' AND NOT escalado) AS rapido_ok, count(*) FILTER (WHERE escalado) AS escaladas, round(100.0 * count(*) FILTER (WHERE escalado) / NULLIF(count(*) FILTER (WHERE nivel = 'rapido'), 0), 1) AS pct_escalada, percentile_cont(0.95) WITHIN GROUP (ORDER BY latencia_ms) AS p95_ms FROM llm_routing_logs WHERE created_at > now() - interval '7 days' GROUP BY tarefa;
Regra prática: quando uma tarefa escala com frequência, mude a regra dela para forte fixo. Quando o forte quase sempre responde coisas que o rápido resolveria, olhe as mensagens e ajuste o prompt da triagem.
Erros comuns
| Problema | Causa | Como resolver |
|---|---|---|
| Roteador aumentou o custo | Triagem rodando em todo pedido, inclusive nos óbvios | Coloque mais tarefas com nível fixo e use regras de tamanho e palavra-chave antes da triagem |
Haiku nunca pede [ESCALAR] |
Modelos tendem a tentar responder tudo | Combine com validador da tarefa e regras de palavra-chave; não dependa só da autoavaliação |
| Respostas truncadas tratadas como boas | Ninguém olhava o stop_reason |
Trate max_tokens como falha e escale |
| Prompt caching parou de render | O cache é por modelo, e alternar modelos fragmenta os acertos | Mantenha prefixos estáveis por nível e avalie o cache de cada modelo separadamente |
| Tom diferente entre respostas da mesma conversa | Modelos distintos seguem o mesmo prompt de jeitos diferentes | Uma vez escalada, mantenha a conversa no forte (regra de "conversa escalada") |
| Triagem devolve texto em vez de JSON | tool_choice não foi forçado |
Use 'tool_choice' => ['type' => 'tool', 'name' => '...'] |
Perguntas frequentes
Por que não usar o próprio Sonnet para fazer a triagem?
Porque a triagem roda antes de quase todo pedido auto. Ela precisa ser a chamada mais barata e mais rápida do sistema. Se o Sonnet decidisse, você pagaria o modelo caro em todo pedido.
Dá para rotear entre provedores diferentes, como Claude e OpenAI?
Dá, porque o roteador só devolve um nível. Mas o cliente HTTP, o formato de tool calling e a leitura de usage mudam por provedor. Comece com um provedor só e só generalize se tiver motivo concreto.
A escalada dobra a latência?
Nos pedidos escalados, sim: são duas chamadas em sequência. Por isso a taxa de escalada precisa ficar baixa. Em canais como WhatsApp, onde alguns segundos a mais são toleráveis, o trade-off costuma valer.
Onde fica o nome do modelo quando sair uma versão nova?
Só no .env. O código conhece rapido e forte. Troque LLM_MODEL_RAPIDO ou LLM_MODEL_FORTE, rode seus evals e compare a taxa de escalada antes e depois.
Conclusão
Comece pelo mapa de tarefas com níveis fixos: ele resolve boa parte do ganho sem gastar um token. Só depois ligue a triagem nas tarefas auto e coloque a escalada com stop_reason, [ESCALAR] e um validador por tarefa. Deixe o log rodando uma semana antes de mexer em qualquer limiar. A taxa de escalada por tarefa vai mostrar onde o modelo barato dá conta e onde é melhor assumir o custo do forte desde o início.