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 ModelRouter que 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:

  1. O volume é alto o suficiente para a diferença de preço entre modelos aparecer na fatura.
  2. A distribuição é desigual: muitos pedidos fáceis e poucos difíceis.
  3. 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:

  1. stop_reason igual a max_tokens, ou seja, a resposta foi cortada no meio.
  2. 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.
  3. 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.